UNPKG

wx-mini-weview

Version:

weixin UI

993 lines (843 loc) 31.2 kB
const { WvInheritable } = require('../../behaviors/WvInheritable'); const { WvComponentEvent } = require('../../behaviors/WvComponentEvent'); Component({ options: { styleIsolation: 'apply-shared', multipleSlots: true }, relations: { '../vtabbar/vtabbar': { type: 'child', linked(target) { this.linkChildNode('vtabbar', target); this._initTabbar(); }, linkChanged() { }, unlinked(target) { this.unlinkChildNode('vtabbar', target); this._resetTabbar(); } }, '../tabpanel-content/tabpanel-content': { type: 'child', linked(target) { this.linkChildNode('tabpanelContent', target); this._initContent(); // 当内容项被链接时,执行可见性检测 if (this.data.contentsMode === 'continuous') { this._detectVisibleContents(this.data.containerScrollTop); // 监听appear事件,在continuous模式下同步tabbar激活状态 target.on('appear', (detail) => { // 当内容出现在视口中时,同步激活对应的标签 if (this.data.contentsMode === 'continuous') { const tabbar = this._getTabbar(); if (tabbar && detail.index !== undefined) { // 静默更新tabbar激活状态,不触发额外的内容切换 tabbar.setActiveTabSilent(detail.index); // 更新当前索引(但不触发内容切换动作,因为内容已经可见) if (this.data.currentIndex !== detail.index) { this.setData({ currentIndex: detail.index }); } } } }, 'vtabpanel'); } }, linkChanged() { }, unlinked(target) { // 取消监听 target.off && target.off('appear', 'vtabpanel'); this.unlinkChildNode('tabpanelContent', target); this._initContent(); } } }, behaviors: [ WvInheritable({ properties: {}, childrenProperties: {} }), WvComponentEvent ], properties: { active: { type: null, value: 0, observer(newVal, oldVal) { if (newVal !== oldVal) { this.setActive(newVal); } } }, background: { type: String, value: '' }, gap: { type: String, value: '0' }, // 整个 vTabPanel 的高度 height: { type: String, value: '300rpx' }, // vTabBar 的位置,可以是 'left' 或 'right' tabbarPosition: { type: String, value: 'left' }, loop: { type: Boolean, value: false }, autoplay: { type: Boolean, value: false }, duration: { type: Number, value: 200 }, interval: { type: Number, value: 3000 }, // ================ 内容区域相关属性(content前缀)================ contentBackground: { type: String, value: 'rgba(var(--wv-theme-light-rgb), 1)' }, contentBorderRadius: { type: String, value: '0' }, contentIndicatorDots: { type: Boolean, value: true }, contentIndicatorColor: { type: String, value: 'rgba(0, 0, 0, .3)' }, contentIndicatorActiveColor: { type: String, value: '#000' }, contentIndicatorActiveStyle: { type: String, value: 'longer', // scale || longer }, // contentWidth为空的时候,表示随flex布局自然而然成为wv-vtabpanel - vtabbar部分的宽度也就是flex: 1的结果,如果指定了,则拥有自己的宽度 contentWidth: { type: String, value: '' }, // 内容展示模式:continuous(连续)或 independent(独立) contentsMode: { type: String, value: 'independent' // 'continuous' | 'independent' }, customClass: { type: String, value: '' }, // 是否可滑动切换内容 contentSwipeable: { type: Boolean, value: true }, }, data: { currentIndex: 0, contentItemCount: 0, isAnimating: false, contentHeight: 0, autoplayTimer: null, // 添加防抖计时器 _debounceInitContentTimer: null, _debounceUpdateContentTimer: null, _debounceSetActiveTimer: null, // 添加滚动控制变量 containerScrollTop: 0, // 添加滚动节流控制变量 _scrollThrottleTimer: null, _lastScrollTime: 0, // 添加标志,避免滚动时重复触发 _isScrollingProgrammatically: false, _detectVisibleContentsDebounceTimer: null, _lastVisibilityScrollTop: null, _lastVisibilityDetectionTime: null, _lastFullyVisibleTimestamps: null, // 添加触摸事件相关变量 touchStartX: 0, touchStartY: 0, touchDeltaY: 0, isTouching: false }, lifetimes: { attached() { }, ready() { this._getContentHeight() this._startAutoplay(); }, detached() { // 清除定时器 if (this.data.autoplayTimer) { clearInterval(this.data.autoplayTimer); } } }, methods: { /** * 初始化内容组件 - 主要初始化入口 * @private */ _initContent() { if (this.data._debounceInitContentTimer) { clearTimeout(this.data._debounceInitContentTimer); } this.data._debounceInitContentTimer = setTimeout(() => { const contentNodes = this.getChildrenNodes('tabpanelContent'); if (!contentNodes || !contentNodes.length) return; // Synchronize currentIndex with active property if needed const activeValue = this.data.active; if (typeof activeValue === 'number' && activeValue !== this.data.currentIndex) { if (activeValue >= 0 && activeValue < contentNodes.length) { this.setData({ currentIndex: activeValue }); } } else if (typeof activeValue === 'string') { // Handle string active values (named tabs) const foundIndex = contentNodes.findIndex(content => content.data.name === activeValue || content.data.wvInheritableApply?.name === activeValue); if (foundIndex !== -1 && foundIndex !== this.data.currentIndex) { this.setData({ currentIndex: foundIndex }); } } this.setData({ contentItemCount: contentNodes.length }, () => { this._updateContent(); }); }, 100); }, /** * 初始化标签栏组件 * @private */ _initTabbar() { const tabbar = this._getTabbar(); if (!tabbar) return; // 同步 active 状态到标签栏 tabbar.setData({ active: this.data.active }); tabbar.on('change', (detail) => { if (detail.targetIndex !== undefined && detail.targetIndex !== this.data.currentIndex) { this.setActive(detail.targetIndex, 'vtabbar'); } }, 'vtabpanel'); }, /** * 清理标签栏事件监听 * @private */ _resetTabbar() { const tabbar = this._getTabbar(); if (tabbar) { // 取消监听 tabbar.off('change', 'vtabpanel'); } }, /** * 获取标签栏组件实例 * @private * @return {Object|null} 标签栏组件实例 */ _getTabbar() { const tabbarNodes = this.getChildrenNodes('vtabbar'); return tabbarNodes && tabbarNodes.length > 0 ? tabbarNodes[0] : null; }, /** * 获取内容区域的高度 * @private */ _getContentHeight() { const query = this.createSelectorQuery(); query.select('.wv-vtabpanel-content-wrapper').boundingClientRect(); query.exec(res => { if (res && res[0]) { this.setData({ contentHeight: res[0].height }); } }); }, /** * 更新内容显示 * @private */ _updateContent() { if (this.data._debounceUpdateContentTimer) { clearTimeout(this.data._debounceUpdateContentTimer); } this.data._debounceUpdateContentTimer = setTimeout(() => { const { currentIndex, contentsMode } = this.data; const contentNodes = this.getChildrenNodes('tabpanelContent'); if (!contentNodes || !contentNodes.length) return; contentNodes.forEach((content, index) => { const isActive = index === currentIndex; // 设置内容的激活状态 content.setActive(isActive, index); if (contentsMode === 'independent') { const style = isActive ? '' : 'display: none;'; content.updateStyle(style); } else if (contentsMode === 'continuous') { content.updateStyle('position: relative; display: block; transform: none; height: auto; '); } }); if (contentsMode === 'continuous') { this._scrollToActiveContent(); } }, 10); }, /** * 处理容器滚动事件,更新滚动位置 * @param {Object} e 滚动事件对象 */ onContainerScroll(e) { // 使用节流控制处理频率,避免频繁更新影响性能 const now = Date.now(); const throttleDelay = 100; // 节流延迟时间,单位毫秒 if (this.data._scrollThrottleTimer) { clearTimeout(this.data._scrollThrottleTimer); } // 如果距离上次更新超过节流时间,立即更新 if (now - this.data._lastScrollTime > throttleDelay) { this.data._lastScrollTime = now; this._updateScrollPosition(e.detail.scrollTop); this._detectVisibleContents(e.detail.scrollTop); } else { // 否则延迟更新,确保最后一次滚动也被记录 this.data._scrollThrottleTimer = setTimeout(() => { this._updateScrollPosition(e.detail.scrollTop); this._detectVisibleContents(e.detail.scrollTop); this.data._lastScrollTime = Date.now(); }, throttleDelay); } }, /** * 更新滚动位置 * @param {Number} scrollTop 滚动位置 * @private */ _updateScrollPosition(scrollTop, toView = false) { if(toView){ // 使用this.setData({})会引起渲染 this.setData({ containerScrollTop: scrollTop }); }else{ // 直接更新data属性,避免触发不必要的渲染 this.data.containerScrollTop = scrollTop; } }, /** * 检测当前可见的内容项 * @param {Number} scrollTop 当前滚动位置 * @private */ _detectVisibleContents(scrollTop) { const now = Date.now(); const throttleDelay = 100; // 使用200毫秒的节流延迟,略高于滚动事件的100毫秒 if (this.data._detectVisibleContentsDebounceTimer) { clearTimeout(this.data._detectVisibleContentsDebounceTimer); } // 记录最后调用的参数,以便延迟执行时使用最新值 this.data._lastVisibilityScrollTop = scrollTop; // 如果距离上次更新超过节流时间,立即更新 if (!this.data._lastVisibilityDetectionTime || now - this.data._lastVisibilityDetectionTime > throttleDelay) { this.data._lastVisibilityDetectionTime = now; this._performVisibilityDetection(scrollTop); } else { // 否则延迟更新,确保最后一次滚动也被处理 this.data._detectVisibleContentsDebounceTimer = setTimeout(() => { this.data._lastVisibilityDetectionTime = Date.now(); this._performVisibilityDetection(this.data._lastVisibilityScrollTop); }, throttleDelay); } }, /** * 执行可见性检测的实际逻辑 * @param {Number} scrollTop 当前滚动位置 * @private */ _performVisibilityDetection(scrollTop) { if (this.data.contentsMode !== 'continuous') return; const contentNodes = this.getChildrenNodes('tabpanelContent'); if (!contentNodes || !contentNodes.length) return; // 确定滚动方向 const isScrollingUp = this.data._lastVisibilityScrollTop !== undefined && scrollTop < this.data._lastVisibilityScrollTop; this.data._lastVisibilityScrollTop = scrollTop; const query = this.createSelectorQuery(); query.select('.wv-vtabpanel-container.continuous-mode').boundingClientRect(); query.select('.wv-vtabpanel-container.continuous-mode').scrollOffset(); query.exec(res => { if (!res || !res[0] || !res[1]) return; const containerRect = res[0]; const containerVisibleTop = containerRect.top; const containerVisibleBottom = containerRect.bottom; const containerHeight = containerRect.bottom - containerRect.top; // 获取正确的滚动高度和当前滚动位置 const scrollHeight = res[1].scrollHeight; const currentScrollTop = res[1].scrollTop; // 增加对底部检测的阈值,使其更容易触发 const bottomThreshold = 30; // 增加到30px const isNearBottom = scrollHeight - currentScrollTop - containerHeight < bottomThreshold; // 如果接近底部,直接激活最后一个内容并提前返回 if (isNearBottom && contentNodes.length > 0) { const lastContent = contentNodes[contentNodes.length - 1]; this._updateAllContentsVisibility(lastContent); return; } // 存储每个内容的可见率信息 const visibilityInfo = []; // 创建所有内容节点的查询 const contentQueries = contentNodes.map(content => { const contentQuery = content.createSelectorQuery(); contentQuery.select('.wv-tabpanel-content').boundingClientRect(); return { content, query: contentQuery }; }); // 初始化最大可见率追踪 let maxVisibleRatio = 0; let mostVisibleContent = null; // 记录完全可见的内容列表 const fullyVisibleContents = []; // 如果当前没有最后完全可见内容的记录,初始化它 if (!this.data._lastFullyVisibleTimestamps) { this.data._lastFullyVisibleTimestamps = new Map(); } // 并行执行所有查询,收集信息 let pendingQueries = contentQueries.length; contentQueries.forEach(({ content, query }) => { query.exec(contentRes => { if (!contentRes || !contentRes[0]) { pendingQueries--; return; } const contentRect = contentRes[0]; const contentHeight = contentRect.height; // 检测元素是否在视口内 const isVisible = ( contentRect.bottom > containerVisibleTop && contentRect.top < containerVisibleBottom ); let visibleRatio = 0; if (isVisible) { // 计算元素可见部分 const visibleHeight = Math.min(contentRect.bottom, containerVisibleBottom) - Math.max(contentRect.top, containerVisibleTop); visibleRatio = visibleHeight / contentHeight; // 特殊处理超高元素 if (contentHeight > containerHeight * 1.5) { // 对于非常高的元素,如果可见高度超过容器的70%,视为"足够可见" if (visibleHeight > containerHeight * 0.7) { visibleRatio = 1.0; } } } // 保存可见率信息 visibilityInfo.push({ content, visibleRatio, index: content.data.index }); // 更新最大可见率追踪 if (visibleRatio > maxVisibleRatio) { maxVisibleRatio = visibleRatio; mostVisibleContent = content; } // 记录完全可见(或接近完全可见)的内容 if (visibleRatio >= 0.95) { fullyVisibleContents.push(content); // 更新最后完全可见时间戳 const now = Date.now(); this.data._lastFullyVisibleTimestamps.set(content, now); } pendingQueries--; // 所有查询都已完成,现在处理结果 if (pendingQueries === 0) { this._processVisibilityResults(visibilityInfo, fullyVisibleContents, mostVisibleContent, maxVisibleRatio); } }); }); }); }, /** * 处理可见性检测结果,确定哪个content应该被标记为可见 * @private */ _processVisibilityResults(visibilityInfo, fullyVisibleContents, mostVisibleContent, maxVisibleRatio) { // 1. 如果有完全可见的内容,选择最近成为完全可见的一个 if (fullyVisibleContents.length > 0) { let latestFullyVisible = fullyVisibleContents[0]; let latestTimestamp = this.data._lastFullyVisibleTimestamps.get(latestFullyVisible) || 0; fullyVisibleContents.forEach(content => { const timestamp = this.data._lastFullyVisibleTimestamps.get(content) || 0; if (timestamp > latestTimestamp) { latestTimestamp = timestamp; latestFullyVisible = content; } }); // 将最近完全可见的内容标记为可见 this._updateAllContentsVisibility(latestFullyVisible); return; } // 2. 如果没有完全可见的内容,但有部分可见的内容 if (mostVisibleContent && maxVisibleRatio >= 0.3) { this._updateAllContentsVisibility(mostVisibleContent); return; } // 3. 边缘情况:如果所有内容的可见比例都未变化或都很低 // 查看是否接近容器边缘,决定是第一个还是最后一个内容 if (visibilityInfo.length > 0) { const contentNodes = this.getChildrenNodes('tabpanelContent'); // 如果接近顶部,选择第一个内容 if (this.data.containerScrollTop < 10) { const firstContent = contentNodes[0]; this._updateAllContentsVisibility(firstContent); } // 底部检测移到了函数开始处的query.exec回调中,因为这里需要更准确的滚动高度信息 } }, /** * 更新所有内容的可见性状态,只将指定的内容标记为可见 * @param {Object} visibleContent 应标记为可见的内容组件 * @private */ _updateAllContentsVisibility(visibleContent) { const contentNodes = this.getChildrenNodes('tabpanelContent'); if (!contentNodes || !contentNodes.length) return; contentNodes.forEach(content => { const shouldBeVisible = content === visibleContent; // 避免不必要的状态更新 if (content._appeared !== shouldBeVisible) { content.updateVisibility(shouldBeVisible); } }); }, /** * 仅同步标签栏状态,不触发内容滚动 * @private */ _syncTabbarOnly() { const tabbar = this._getTabbar(); if (tabbar) { tabbar.setActiveTabSilent(this.data.currentIndex); } }, /** * 滚动到当前激活的内容 * @private */ _scrollToActiveContent() { if (this.data.contentsMode !== 'continuous') return; const contentNodes = this.getChildrenNodes('tabpanelContent'); if (!contentNodes?.length) return; // First try to find by active flag, then by currentIndex const activeContent = contentNodes.find(node => node.data.active) || (this.data.currentIndex < contentNodes.length ? contentNodes[this.data.currentIndex] : null); if (!activeContent) return; const query = this.createSelectorQuery(); // 同时获取容器布局信息和滚动状态 query.select('.wv-vtabpanel-container').fields({ rect: true, scrollOffset: true, size: true }); query.exec(res => { const containerInfo = res?.[0]; if (!containerInfo) return; // 获取容器关键参数 const { top: containerTop, // 容器顶部视口位置 height: containerHeight, // 容器可视高度 scrollHeight, // 容器总高度 scrollTop: currentScroll // 当前滚动位置 } = containerInfo; // 查询目标元素布局 const contentQuery = activeContent.createSelectorQuery(); contentQuery.select('.wv-tabpanel-content.active').boundingClientRect(); contentQuery.exec(contentRes => { const targetRect = contentRes?.[0]; if (!targetRect) return; // 计算元素相对容器的理论定位 const targetRelativeTop = targetRect.top - containerTop; // 计算目标滚动位置(核心算法) let targetScroll = currentScroll + targetRelativeTop; // 边界处理:确保不超过可滚动范围 const maxScroll = scrollHeight - containerHeight; targetScroll = Math.max(0, Math.min(targetScroll, maxScroll)); // 智能修正:当目标元素高度超过容器时 if (targetRect.height > containerHeight) { // 如果当前在元素上半部分,对齐顶部;否则对齐底部 const visiblePart = currentScroll - (targetScroll - targetRect.height); if (visiblePart > containerHeight / 2) { targetScroll = Math.min(targetScroll + containerHeight, maxScroll); } } // 应用滚动修正 this.data._isScrollingProgrammatically = true; this._updateScrollPosition(targetScroll, true); }); }); }, /** * 切换到指定索引的内容 * @param {Number|String} index 目标索引或名称 * @param {String} source 事件来源 */ setActive(index, source = 'code') { // 添加防抖 if (this.data._debounceSetActiveTimer) { clearTimeout(this.data._debounceSetActiveTimer); } this.data._debounceSetActiveTimer = setTimeout(() => { const contentNodes = this.getChildrenNodes('tabpanelContent'); if (!contentNodes || !contentNodes.length) return; let targetIndex = index; // 如果提供的是名称,查找对应的索引 if (typeof index === 'string') { const foundIndex = contentNodes.findIndex(content => content.data.name === index); if (foundIndex !== -1) { targetIndex = foundIndex; } else { return; } } // 检查索引有效性 if (targetIndex < 0 || targetIndex >= contentNodes.length) { if (!this.data.loop) return; // 循环模式下处理边界情况 targetIndex = targetIndex < 0 ? contentNodes.length - 1 : 0; } // 触发切换前事件 const beforeChangeEvent = this.triggerEvent('beforeChange', { current: this.data.currentIndex, target: targetIndex }); if (beforeChangeEvent && beforeChangeEvent.defaultPrevented) { return; } // 更新状态 this.setData({ isAnimating: true, currentIndex: targetIndex }, () => { // 更新内容显示 this._updateContent(); if (source !== 'vtabbar') { this._syncTabbar(); } // 触发切换后事件 this.triggerEvent('change', { current: targetIndex, source: source }); setTimeout(() => { this.setData({ isAnimating: false }); }, this.data.duration); }); }, 10); }, /** * 同步标签栏状态 * @private */ _syncTabbar() { const tabbar = this._getTabbar(); if (tabbar) { tabbar.setActiveTab(this.data.currentIndex); } }, /** * 切换到下一个内容 * @public * @param {String} source 事件来源 */ next(source = 'code') { const { currentIndex, contentItemCount } = this.data; let nextIndex = currentIndex + 1; if (nextIndex >= contentItemCount && !this.data.loop) { nextIndex = currentIndex } this.setActive(nextIndex, source); }, /** * 切换到上一个内容 * @public * @param {String} source 事件来源 */ prev(source = 'code') { const { currentIndex } = this.data; let prevIndex = currentIndex - 1; if (prevIndex < 0 && !this.data.loop) { prevIndex = currentIndex } this.setActive(prevIndex, source); }, /** * 启动自动播放 * @private */ _startAutoplay() { if (this.data.autoplay && this.data.contentsMode === 'independent') { if (this.data.autoplayTimer) { clearInterval(this.data.autoplayTimer); } const timer = setInterval(() => { this.next(); }, this.data.interval); this.setData({ autoplayTimer: timer }); } }, /** * 停止自动播放 * @private */ _stopAutoplay() { if (this.data.autoplayTimer) { clearInterval(this.data.autoplayTimer); this.setData({ autoplayTimer: null }); } }, /** * 触摸开始事件处理 * @param {Object} e 事件对象 */ onTouchStart(e) { if (!this.data.contentSwipeable || this.data.contentsMode === 'continuous') return; const touch = e.touches[0]; this.setData({ touchStartX: touch.clientX, touchStartY: touch.clientY, touchDeltaY: 0, isTouching: true }); // 暂停自动播放 if (this.data.autoplay) { this._stopAutoplay(); } }, /** * 触摸移动事件处理 * @param {Object} e 事件对象 */ onTouchMove(e) { if (!this.data.contentSwipeable || !this.data.isTouching || this.data.contentsMode === 'continuous') return; const touch = e.touches[0]; const deltaX = touch.clientX - this.data.touchStartX; const deltaY = touch.clientY - this.data.touchStartY; // 如果是横向滑动,不进行纵向滑动处理 if (Math.abs(deltaX) > Math.abs(deltaY)) { return; } // 边界检查:如果不是循环模式,在首尾项限制滑动 // if (!this.data.loop) { // if ((this.data.currentIndex === 0 && deltaY > 0) || // (this.data.currentIndex === this.data.contentItemCount - 1 && deltaY < 0)) { // return; // } // } this.setData({ touchDeltaY: deltaY }); // 计算滑动百分比 let maxPercentage = 100 const absPercentage = Math.min(Math.abs(deltaY) / this.data.contentHeight * 100, maxPercentage); // 应用拖动效果到内容 this._applyDragEffect(deltaY); // 计算指示器移动方向 const direction = deltaY < 0 ? 'down' : 'up'; // 调用vtabbar的shiftIndicator方法 const tabbar = this._getTabbar(); if (tabbar && typeof tabbar.shiftIndicator === 'function') { tabbar.shiftIndicator(direction, absPercentage); } }, /** * 触摸结束事件处理 * @param {Object} e 事件对象 */ onTouchEnd(e) { if (!this.data.contentSwipeable || !this.data.isTouching || this.data.contentsMode === 'continuous') return; const { touchDeltaY, contentHeight } = this.data; let threshold = contentHeight * 0.3; // 滑动阈值,超过30%高度则切换 threshold = Math.min(threshold, 200); // 最大阈值为200px this.setData({ isTouching: false }); // 触发微信原生事件 this.triggerEvent('change', { current: this.data.currentIndex, source: 'swipe' }); // 根据滑动距离决定是否切换 if (Math.abs(touchDeltaY) > threshold) { if (touchDeltaY > 0) { // 向上滑动,切换到上一个,并标记来源为swipe this.prev('swipe'); } else { // 向下滑动,切换到下一个,并标记来源为swipe this.next('swipe'); } } else { // 滑动距离不够,恢复原位置 this._updateContent(); // 恢复指示器位置 const tabbar = this._getTabbar(); if (tabbar && typeof tabbar.updateIndicator === 'function') { tabbar.updateIndicator(); } } // 恢复自动播放 if (this.data.autoplay) { this._startAutoplay(); } }, /** * 应用拖动效果 * @private * @param {Number} deltaY 垂直位移 */ _applyDragEffect(deltaY) { // 在独立模式下应用拖动效果 if (this.data.contentsMode === 'independent') { const contentNodes = this.getChildrenNodes('tabpanelContent'); const { currentIndex } = this.data; if (!contentNodes || !contentNodes.length) return; // 获取内容容器高度,用于计算基础位置 const query = this.createSelectorQuery(); query.select('.wv-vtabpanel-content-wrapper').boundingClientRect(); query.exec(res => { if (!res || !res[0]) return; const containerHeight = res[0].height; // 应用变换到所有内容元素 contentNodes.forEach((content, index) => { // 计算基础位置 - 相对于当前激活内容的位置 const basePosition = (index - currentIndex) * containerHeight; // 加上拖动位移 const position = basePosition + deltaY; // 应用无动画的变换 - 连续定位所有内容面板 const style = `transform: translateY(${position}px); transition-duration: 0ms; display: ${Math.abs(index - currentIndex) <= 1 ? 'block' : 'none'};`; content.updateStyle(style); }); }); } }, } });