diff --git a/packages/components/picker-item/picker-item.less b/packages/components/picker-item/picker-item.less index f8938cb27..96cdb7620 100644 --- a/packages/components/picker-item/picker-item.less +++ b/packages/components/picker-item/picker-item.less @@ -22,6 +22,8 @@ &__wrapper { padding: 144rpx 0; + // 虚拟滚动性能优化:使用 will-change 提示浏览器 + will-change: transform; } &__item { @@ -30,6 +32,8 @@ align-items: center; color: @picker-item-color; font-size: @picker-item-font-size; + // 虚拟滚动性能优化:隔离渲染上下文 + contain: layout style paint; &-icon { font-size: 36rpx; diff --git a/packages/components/picker-item/picker-item.ts b/packages/components/picker-item/picker-item.ts index e60a3d143..4d3212572 100644 --- a/packages/components/picker-item/picker-item.ts +++ b/packages/components/picker-item/picker-item.ts @@ -13,6 +13,16 @@ const INERTIA_TIME = 300; // 且距离大于`MOMENTUM_DISTANCE`时,执行惯性滚动 const INERTIA_DISTANCE = 15; +// 虚拟滚动配置 +const VIRTUAL_SCROLL_CONFIG = { + ENABLE_THRESHOLD: 100, // 超过100个选项启用虚拟滚动 + VISIBLE_COUNT: 5, // 可见区域显示5个选项 + BUFFER_COUNT: 8, // 上下各缓冲8个选项(增加缓冲区,防止快速滑动时空白) + THROTTLE_TIME: 16, // 节流时间(60fps,提高更新频率) + FAST_SCROLL_BUFFER: 12, // 快速滑动时的额外缓冲区 + FAST_SCROLL_THRESHOLD: 50, // 判定为快速滑动的速度阈值(px/frame) +}; + const range = function (num: number, min: number, max: number) { return Math.min(Math.max(num, min), max); }; @@ -70,6 +80,12 @@ export default class PickerItem extends SuperComponent { columnIndex: 0, pickerKeys: { value: 'value', label: 'label', icon: 'icon' }, formatOptions: props.options.value, + // 虚拟滚动相关 + enableVirtualScroll: false, // 是否启用虚拟滚动 + visibleOptions: [], // 可见的选项列表 + virtualStartIndex: 0, // 虚拟滚动起始索引 + virtualOffsetY: 0, // 虚拟滚动偏移量 + totalHeight: 0, // 总高度(用于占位) }; lifetimes = { @@ -77,6 +93,22 @@ export default class PickerItem extends SuperComponent { this.StartY = 0; this.StartOffset = 0; this.startTime = 0; + this._moveTimer = null; + this._animationTimer = null; // 动画期间更新虚拟滚动的定时器 + this._lastOffset = 0; // 上一次的偏移量(用于计算滑动速度) + this._lastMoveTime = 0; // 上一次移动的时间 + this._scrollDirection = 0; // 滑动方向:1向下,-1向上,0静止 + }, + detached() { + // 清理定时器,防止内存泄漏 + if (this._moveTimer) { + clearTimeout(this._moveTimer); + this._moveTimer = null; + } + if (this._animationTimer) { + clearInterval(this._animationTimer); + this._animationTimer = null; + } }, }; @@ -104,16 +136,60 @@ export default class PickerItem extends SuperComponent { onTouchMove(event) { const { StartY, StartOffset } = this; - const { pickItemHeight } = this.data; + const { pickItemHeight, enableVirtualScroll } = this.data; + const currentTime = Date.now(); + // 偏移增量 const deltaY = event.touches[0].clientY - StartY; const newOffset = range(StartOffset + deltaY, -(this.getCount() * pickItemHeight), 0); - this.setData({ - offset: newOffset, - }); + + // 计算滑动速度和方向 + const offsetDelta = newOffset - this._lastOffset; + const timeDelta = currentTime - this._lastMoveTime || 16; + const scrollSpeed = Math.abs(offsetDelta / timeDelta) * 16; // 转换为 px/frame + + // 计算滑动方向(避免嵌套三元表达式) + if (offsetDelta > 0) { + this._scrollDirection = 1; // 向下滑动 + } else if (offsetDelta < 0) { + this._scrollDirection = -1; // 向上滑动 + } else { + this._scrollDirection = 0; // 静止 + } + + // 判断是否为快速滑动 + const isFastScroll = scrollSpeed > VIRTUAL_SCROLL_CONFIG.FAST_SCROLL_THRESHOLD; + + // 优化节流策略:快速滑动时立即更新,慢速滑动时节流 + if (!this._moveTimer || isFastScroll) { + if (this._moveTimer) { + clearTimeout(this._moveTimer); + this._moveTimer = null; + } + + this.setData({ offset: newOffset }); + + // 虚拟滚动:更新可见范围(快速滑动时使用更大的缓冲区) + if (enableVirtualScroll) { + this.updateVisibleOptions(newOffset, isFastScroll); + } + + this._moveTimer = setTimeout(() => { + this._moveTimer = null; + }, VIRTUAL_SCROLL_CONFIG.THROTTLE_TIME); + } + + // 记录当前状态 + this._lastOffset = newOffset; + this._lastMoveTime = currentTime; }, onTouchEnd(event) { + if (this._moveTimer) { + clearTimeout(this._moveTimer); + this._moveTimer = null; + } + const { offset, pickItemHeight } = this.data; const { startTime } = this; if (offset === this.StartOffset) { @@ -131,11 +207,64 @@ export default class PickerItem extends SuperComponent { // 调整偏移量 const newOffset = range(offset + distance, -this.getCount() * pickItemHeight, 0); const index = range(Math.round(-newOffset / pickItemHeight), 0, this.getCount() - 1); - this.setData({ - offset: -index * pickItemHeight, - duration: ANIMATION_DURATION, - curIndex: index, - }); + + // 判断是否为快速惯性滚动 + const isFastInertia = Math.abs(distance) > pickItemHeight * 3; + + // 立即更新虚拟滚动视图(修复惯性滚动后空白问题,快速滚动时使用更大缓冲区) + if (this.data.enableVirtualScroll) { + this.updateVisibleOptions(-index * pickItemHeight, isFastInertia); + } + + // 清除之前的动画更新定时器 + if (this._animationTimer) { + clearInterval(this._animationTimer); + this._animationTimer = null; + } + + // 在动画执行期间定期更新虚拟滚动视图(确保动画过程流畅) + if (this.data.enableVirtualScroll && Math.abs(distance) > 0) { + const startOffset = offset; + const endOffset = -index * pickItemHeight; + const startTime = Date.now(); + + this._animationTimer = setInterval(() => { + const elapsed = Date.now() - startTime; + const progress = Math.min(elapsed / ANIMATION_DURATION, 1); + + // 使用缓动函数计算当前偏移量 + const easeOutCubic = 1 - (1 - progress) ** 3; + const currentOffset = startOffset + (endOffset - startOffset) * easeOutCubic; + + // 快速惯性滚动时使用更大的缓冲区 + this.updateVisibleOptions(currentOffset, isFastInertia); + + if (progress >= 1) { + clearInterval(this._animationTimer); + this._animationTimer = null; + } + }, 16); // 约60fps + } + + this.setData( + { + offset: -index * pickItemHeight, + duration: ANIMATION_DURATION, + curIndex: index, + }, + () => { + // 动画结束后清除定时器并最终更新虚拟滚动视图 + if (this._animationTimer) { + clearInterval(this._animationTimer); + this._animationTimer = null; + } + if (this.data.enableVirtualScroll) { + // 动画结束后使用正常缓冲区(不再是快速滚动状态) + this.updateVisibleOptions(-index * pickItemHeight, false); + } + }, + ); + if (index === this._selectedIndex) return; this.updateSelected(index, true); }, @@ -167,24 +296,118 @@ export default class PickerItem extends SuperComponent { const { options, value, pickerKeys, pickItemHeight, format, columnIndex } = this.data; const formatOptions = this.formatOption(options, columnIndex, format); + const optionsCount = formatOptions.length; - const index = formatOptions.findIndex((item: PickerItemOption) => item[pickerKeys?.value] === value); + // 判断是否启用虚拟滚动 + const enableVirtualScroll = optionsCount >= VIRTUAL_SCROLL_CONFIG.ENABLE_THRESHOLD; + + // 大数据量优化:使用 Map 快速查找索引 + let index: number = -1; + if (optionsCount > 500) { + // 构建临时 Map(只在查找时构建,不缓存) + const valueMap = new Map(formatOptions.map((item, idx) => [item[pickerKeys?.value], idx])); + index = valueMap.get(value) ?? -1; + } else { + index = formatOptions.findIndex((item: PickerItemOption) => item[pickerKeys?.value] === value); + } const selectedIndex = index > 0 ? index : 0; - this.setData( - { - formatOptions, - offset: -selectedIndex * pickItemHeight, - curIndex: selectedIndex, - }, - () => { - this.updateSelected(selectedIndex, false); - }, - ); + const updateData: any = { + formatOptions, + offset: -selectedIndex * pickItemHeight, + curIndex: selectedIndex, + enableVirtualScroll, + totalHeight: optionsCount * pickItemHeight, + }; + + // 如果启用虚拟滚动,计算可见选项 + if (enableVirtualScroll) { + const visibleRange = this.computeVirtualRange(-selectedIndex * pickItemHeight, optionsCount, pickItemHeight); + updateData.visibleOptions = formatOptions.slice(visibleRange.startIndex, visibleRange.endIndex); + updateData.virtualStartIndex = visibleRange.startIndex; + updateData.virtualOffsetY = visibleRange.startIndex * pickItemHeight; + } else { + // 不启用虚拟滚动时,visibleOptions 等于 formatOptions + updateData.visibleOptions = formatOptions; + updateData.virtualStartIndex = 0; + updateData.virtualOffsetY = 0; + } + + this.setData(updateData, () => { + this.updateSelected(selectedIndex, false); + }); + }, + + /** + * 计算虚拟滚动的可见范围 + * @param offset 当前滚动偏移量 + * @param totalCount 总选项数量 + * @param itemHeight 单个选项高度 + * @param isFastScroll 是否为快速滑动 + */ + computeVirtualRange(offset: number, totalCount: number, itemHeight: number, isFastScroll = false) { + const scrollTop = Math.abs(offset); + const { VISIBLE_COUNT, BUFFER_COUNT, FAST_SCROLL_BUFFER } = VIRTUAL_SCROLL_CONFIG; + + // 根据滑动速度动态调整缓冲区大小 + const dynamicBuffer = isFastScroll ? FAST_SCROLL_BUFFER : BUFFER_COUNT; + + // 根据滑动方向调整缓冲区分配 + // 向上滑动(_scrollDirection = -1):增加顶部缓冲区 + // 向下滑动(_scrollDirection = 1):增加底部缓冲区 + const topBuffer = this._scrollDirection === -1 ? dynamicBuffer + 2 : dynamicBuffer; + const bottomBuffer = this._scrollDirection === 1 ? dynamicBuffer + 2 : dynamicBuffer; + + // 计算当前可见区域的中心索引 + const centerIndex = Math.floor(scrollTop / itemHeight); + + // 计算起始索引(减去顶部缓冲区) + const startIndex = Math.max(0, centerIndex - topBuffer); + // 计算结束索引(加上可见数量和底部缓冲区) + const endIndex = Math.min(totalCount, centerIndex + VISIBLE_COUNT + bottomBuffer); + + return { startIndex, endIndex }; + }, + + /** + * 更新虚拟滚动的可见选项 + * @param offset 当前滚动偏移量(可选,不传则使用 data.offset) + * @param isFastScroll 是否为快速滑动 + */ + updateVisibleOptions(offset?: number, isFastScroll = false) { + const { formatOptions, pickItemHeight, enableVirtualScroll } = this.data; + + if (!enableVirtualScroll) return; + + const currentOffset = offset !== undefined ? offset : this.data.offset; + const visibleRange = this.computeVirtualRange(currentOffset, formatOptions.length, pickItemHeight, isFastScroll); + + // 只有当可见范围发生变化时才更新 + if ( + visibleRange.startIndex !== this.data.virtualStartIndex || + visibleRange.endIndex !== this.data.virtualStartIndex + this.data.visibleOptions.length + ) { + this.setData({ + visibleOptions: formatOptions.slice(visibleRange.startIndex, visibleRange.endIndex), + virtualStartIndex: visibleRange.startIndex, + virtualOffsetY: visibleRange.startIndex * pickItemHeight, + }); + } }, getCount() { return this.data?.options?.length; }, + + getCurrentSelected() { + const { offset, pickItemHeight, formatOptions, pickerKeys } = this.data; + const currentIndex = Math.max(0, Math.min(Math.round(-offset / pickItemHeight), this.getCount() - 1)); + + return { + index: currentIndex, + value: formatOptions[currentIndex]?.[pickerKeys?.value], + label: formatOptions[currentIndex]?.[pickerKeys?.label], + }; + }, }; } diff --git a/packages/components/picker-item/picker-item.wxml b/packages/components/picker-item/picker-item.wxml index 6d65994bc..b76b3a781 100644 --- a/packages/components/picker-item/picker-item.wxml +++ b/packages/components/picker-item/picker-item.wxml @@ -12,22 +12,49 @@ class="{{classPrefix}}__wrapper" style="transition: transform {{ duration }}ms cubic-bezier(0.215, 0.61, 0.355, 1); transform: translate3d(0, {{ offset }}px, 0)" > - - - {{option[pickerKeys.label]}} - + + + + + + + {{option[pickerKeys.label]}} + + + + + + + + + {{option[pickerKeys.label]}} + + + diff --git a/packages/components/picker/README.en-US.md b/packages/components/picker/README.en-US.md index a89efff89..c0cb38592 100644 --- a/packages/components/picker/README.en-US.md +++ b/packages/components/picker/README.en-US.md @@ -55,8 +55,6 @@ Name | Default Value | Description --td-picker-confirm-color | @brand-color | - --td-picker-indicator-bg-color | @bg-color-secondarycontainer | - --td-picker-indicator-border-radius | 12rpx | - ---td-picker-mask-color-bottom | hsla(0, 0%, 100%, 0.4) | - ---td-picker-mask-color-top | hsla(0, 0%, 100%, 0.92) | - --td-picker-title-color | @text-color-primary | - --td-picker-title-font-size | 36rpx | - --td-picker-title-font-weight | 600 | - diff --git a/packages/components/picker/README.md b/packages/components/picker/README.md index 61ac25614..45e620f01 100644 --- a/packages/components/picker/README.md +++ b/packages/components/picker/README.md @@ -100,8 +100,6 @@ options | Array | [] | 数据源。TS 类型:`PickerItemOption[]` `interface P --td-picker-confirm-color | @brand-color | - --td-picker-indicator-bg-color | @bg-color-secondarycontainer | - --td-picker-indicator-border-radius | 12rpx | - ---td-picker-mask-color-bottom | hsla(0, 0%, 100%, 0.4) | - ---td-picker-mask-color-top | hsla(0, 0%, 100%, 0.92) | - --td-picker-title-color | @text-color-primary | - --td-picker-title-font-size | 36rpx | - --td-picker-title-font-weight | 600 | - diff --git a/packages/components/picker/picker.less b/packages/components/picker/picker.less index 49286d728..910bd46ef 100644 --- a/packages/components/picker/picker.less +++ b/packages/components/picker/picker.less @@ -16,8 +16,6 @@ @picker-bg-color: var(--td-picker-bg-color, @bg-color-container); @picker-mask-color: var(--td-picker-transparent-color); -@picker-mask-color-top: var(--td-picker-mask-color-top, hsla(0, 0%, 100%, 0.92)); -@picker-mask-color-bottom: var(--td-picker-mask-color-bottom, hsla(0, 0%, 100%, 0.4)); @picker-indicator-bg-color: var(--td-picker-indicator-bg-color, @bg-color-secondarycontainer); @picker-indicator-border-radius: var(--td-picker-indicator-border-radius, 12rpx); diff --git a/packages/components/picker/picker.ts b/packages/components/picker/picker.ts index ce80eaea1..d441fb456 100644 --- a/packages/components/picker/picker.ts +++ b/packages/components/picker/picker.ts @@ -29,8 +29,11 @@ export default class Picker extends SuperComponent { }; observers = { - 'value, visible'() { - this.updateChildren(); + 'value, visible'(value, visible) { + // 只在打开弹窗或value变化时更新,关闭时不更新避免滚动 + if (visible) { + this.updateChildren(); + } }, }; @@ -82,8 +85,21 @@ export default class Picker extends SuperComponent { }, onConfirm() { - const [value, label] = this.getSelectedValue(); - const columns = this.getColumnIndexes(); + // 获取当前实际显示的选中值 + const value = []; + const label = []; + const columns = []; + + this.$children.forEach((child, columnIndex) => { + const current = child.getCurrentSelected(); + + value.push(current.value); + label.push(current.label); + columns.push({ + column: columnIndex, + index: current.index, + }); + }); this.close('confirm-btn'); this.triggerEvent('confirm', { value, label, columns });