diff --git a/packages/taro-components-advanced/package.json b/packages/taro-components-advanced/package.json index 5ffe0ed028eb..30c28da4d234 100644 --- a/packages/taro-components-advanced/package.json +++ b/packages/taro-components-advanced/package.json @@ -26,6 +26,7 @@ "@tarojs/shared": "workspace:*", "@tarojs/runtime": "workspace:*", "@tarojs/taro": "workspace:*", + "classnames": "^2.2.5", "memoize-one": "^6.0.0", "postcss": "^8.4.18" }, diff --git a/packages/taro-components-advanced/rollup.config.js b/packages/taro-components-advanced/rollup.config.js index e2dca4b5e43e..0eb1ec88cb07 100644 --- a/packages/taro-components-advanced/rollup.config.js +++ b/packages/taro-components-advanced/rollup.config.js @@ -10,7 +10,10 @@ export default { 'src/components/index.ts', 'src/components/virtual-list/index.ts', 'src/components/virtual-list/react/index.ts', - 'src/components/virtual-list/vue/index.ts' + 'src/components/virtual-list/vue/index.ts', + 'src/components/virtual-waterfall/index.ts', + 'src/components/virtual-waterfall/react/index.ts', + 'src/components/virtual-waterfall/vue/index.ts', ], output: { dir: 'dist', diff --git a/packages/taro-components-advanced/src/components/index.ts b/packages/taro-components-advanced/src/components/index.ts index a302d1bc7ccf..e98c5fdb0fa1 100644 --- a/packages/taro-components-advanced/src/components/index.ts +++ b/packages/taro-components-advanced/src/components/index.ts @@ -1 +1,2 @@ export * from './virtual-list' +export * from './virtual-waterfall' diff --git a/packages/taro-components-advanced/src/components/virtual-list/index.ts b/packages/taro-components-advanced/src/components/virtual-list/index.ts index 27787d569d33..4aec1d5d27a1 100644 --- a/packages/taro-components-advanced/src/components/virtual-list/index.ts +++ b/packages/taro-components-advanced/src/components/virtual-list/index.ts @@ -1,7 +1,7 @@ import type { BaseEventOrig, BaseEventOrigFunction, ScrollViewProps, StandardProps } from '@tarojs/components' import type { Component, ComponentType, CSSProperties, ReactNode } from 'react' -interface VirtualListProps extends Omit { +interface VirtualListProps extends Omit { /** 列表的高度。 */ height: string | number /** 列表的宽度。 */ @@ -13,7 +13,7 @@ interface VirtualListProps extends Omit { /** 单项的样式,样式必须传入组件的 style 中 */ style?: CSSProperties /** 组件渲染的数据 */ - data: any + data: T[] /** 组件渲染数据的索引 */ index: number /** 组件是否正在滚动,当 useIsScrolling 值为 true 时返回布尔值,否则返回 undefined */ @@ -22,14 +22,14 @@ interface VirtualListProps extends Omit { /** 列表的长度 */ itemCount: number /** 渲染数据 */ - itemData: any[] + itemData: T[] /** 列表单项的大小,垂直滚动时为高度,水平滚动时为宽度。 * * > Note: * > - unlimitedSize 模式下如果传入函数,只会调用一次用于设置初始值 * > - 非 unlimitedSize 模式下如果传入函数,为避免性能问题,每个节点只会调用一次用于设置初始值 */ - itemSize: number | ((index?: number, itemData?: any[]) => number) + itemSize: number | ((index?: number, itemData?: T[]) => number) /** 解开高度列表单项大小限制,默认值使用: itemSize (请注意,初始高度与实际高度差异过大会导致隐患)。 * * > Note: 通过 itemSize 设置的初始高度与子节点实际高度差异过大会导致隐患 @@ -40,35 +40,43 @@ interface VirtualListProps extends Omit { * @default "absolute" */ position?: 'absolute' | 'relative' + /** 滚动方向。vertical 为垂直滚动,horizontal 为平行滚动。 + * @default "vertical" + */ + layout?: 'vertical' | 'horizontal' /** 初始滚动偏移值,水平滚动影响 scrollLeft,垂直滚动影响 scrollTop。 */ initialScrollOffset?: number - /** 列表内部容器组件类型。 - * @default View - */ - innerElementType?: ComponentType + /** 在可视区域之外渲染的列表单项数量,值设置得越高,快速滚动时出现白屏的概率就越小,相应地,每次滚动的性能会变得越差。 */ + overscanCount?: number + /** 上下滚动预占位节点 */ + placeholderCount?: number + /** 是否注入 isScrolling 属性到 item 组件。这个参数一般用于实现滚动骨架屏(或其它 placeholder) 时比较有用。 */ + useIsScrolling?: boolean /** 通过 ScrollViewContext 优化组件滚动性能 * @default false * @note 部分平台不支持,使用时请注意甄别 */ enhanced?: boolean + /** 列表外部容器组件类型。 + * @default ScrollView + */ + outerElementType?: ComponentType | string + /** 列表内部容器组件类型。 + * @default View + */ + innerElementType?: ComponentType | string + /** 列表子节点容器组件类型。 + * @default View + */ + itemElementType?: ComponentType | string /** 顶部区域 */ renderTop?: ReactNode /** 底部区域 */ renderBottom?: ReactNode - /** 滚动方向。vertical 为垂直滚动,horizontal 为平行滚动。 - * @default "vertical" - */ - layout?: 'vertical' | 'horizontal' /** 列表滚动时调用函数 */ onScroll?: (event: VirtualListProps.IVirtualListEvent) => void /** 调用平台原生的滚动监听函数。 */ onScrollNative?: BaseEventOrigFunction - /** 在可视区域之外渲染的列表单项数量,值设置得越高,快速滚动时出现白屏的概率就越小,相应地,每次滚动的性能会变得越差。 */ - overscanCount?: number - /** 上下滚动预占位节点 */ - placeholderCount?: number - /** 是否注入 isScrolling 属性到 item 组件。这个参数一般用于实现滚动骨架屏(或其它 placeholder) 时比较有用。 */ - useIsScrolling?: boolean style?: CSSProperties } @@ -78,6 +86,9 @@ declare namespace VirtualListProps { scrollTop: number scrollHeight: number scrollWidth: number + clientHeight: number + clientWidth: number + diffOffset: number } interface IVirtualListEvent extends BaseEventOrig { @@ -87,7 +98,7 @@ declare namespace VirtualListProps { scrollOffset: number /** 当滚动是由 scrollTo() 或 scrollToItem() 调用时返回 true,否则返回 false */ scrollUpdateWasRequested: boolean - /** 当前只有 React 支持 */ + /** 滚动信息 */ detail: T } } diff --git a/packages/taro-components-advanced/src/components/virtual-list/list-set.ts b/packages/taro-components-advanced/src/components/virtual-list/list-set.ts index e87d364c1a15..088213b952e6 100644 --- a/packages/taro-components-advanced/src/components/virtual-list/list-set.ts +++ b/packages/taro-components-advanced/src/components/virtual-list/list-set.ts @@ -1,5 +1,6 @@ import { isFunction } from '@tarojs/shared' +import { getOffsetForIndexAndAlignment } from '../../utils' import { isHorizontalFunc } from './utils' import type { IProps } from './preset' @@ -147,52 +148,14 @@ export default class ListSet { } getOffsetForIndexAndAlignment (index: number, align: string, scrollOffset: number) { - const wrapperSize = this.wrapperSize - const itemSize = this.getSize(index) - const lastItemOffset = Math.max(0, this.getOffsetSize(this.props.itemCount) - wrapperSize) - const maxOffset = Math.min(lastItemOffset, this.getOffsetSize(index)) - const minOffset = Math.max(0, this.getOffsetSize(index) - wrapperSize + itemSize) - - if (align === 'smart') { - if (scrollOffset >= minOffset - wrapperSize && scrollOffset <= maxOffset + wrapperSize) { - align = 'auto' - } else { - align = 'center' - } - } - - switch (align) { - case 'start': - return maxOffset - - case 'end': - return minOffset - - case 'center': - { - // "Centered" offset is usually the average of the min and max. - // But near the edges of the list, this doesn't hold true. - const middleOffset = Math.round(minOffset + (maxOffset - minOffset) / 2) - - if (middleOffset < Math.ceil(wrapperSize / 2)) { - return 0 // near the beginning - } else if (middleOffset > lastItemOffset + Math.floor(wrapperSize / 2)) { - return lastItemOffset // near the end - } else { - return middleOffset - } - } - - case 'auto': - default: - if (scrollOffset >= minOffset && scrollOffset <= maxOffset) { - return scrollOffset - } else if (scrollOffset < minOffset) { - return minOffset - } else { - return maxOffset - } - } + return getOffsetForIndexAndAlignment({ + align, + containerSize: this.wrapperSize, + currentOffset: scrollOffset, + scrollSize: this.getOffsetSize(this.props.itemCount), + slideSize: this.getSize(index), + targetOffset: this.getOffsetSize(index), + }) } compareSize (i = 0, size = 0) { diff --git a/packages/taro-components-advanced/src/components/virtual-list/preset.ts b/packages/taro-components-advanced/src/components/virtual-list/preset.ts index 97a6a253e1ea..ecf87a5ca1de 100644 --- a/packages/taro-components-advanced/src/components/virtual-list/preset.ts +++ b/packages/taro-components-advanced/src/components/virtual-list/preset.ts @@ -1,8 +1,8 @@ import memoizeOne from 'memoize-one' -import { convertNumber2PX, isCosDistributing } from '../../utils' +import { convertNumber2PX, defaultItemKey, isCosDistributing } from '../../utils' import ListSet from './list-set' -import { defaultItemKey, isHorizontalFunc, isRtlFunc } from './utils' +import { isHorizontalFunc, isRtlFunc } from './utils' import type { VirtualListProps } from './' @@ -15,8 +15,6 @@ export interface IProps extends Partial { itemTagName?: string innerTagName?: string outerTagName?: string - itemElementType?: React.ComponentType | string - outerElementType?: React.ComponentType | string innerRef?: React.Ref | string outerRef?: React.Ref | string onItemsRendered?: TFunc @@ -72,15 +70,15 @@ export default class Preset { return this.props.placeholderCount >= 0 ? this.props.placeholderCount : this.props.overscanCount } - get outerTagName () { + get outerElement () { return this.props.outerElementType || this.props.outerTagName || 'div' } - get innerTagName () { + get innerElement () { return this.props.innerElementType || this.props.innerTagName || 'div' } - get itemTagName () { + get itemElement () { return this.props.itemElementType || this.props.itemTagName || 'div' } diff --git a/packages/taro-components-advanced/src/components/virtual-list/react/index.ts b/packages/taro-components-advanced/src/components/virtual-list/react/index.ts index 82c8b78f9187..f819683d88cd 100644 --- a/packages/taro-components-advanced/src/components/virtual-list/react/index.ts +++ b/packages/taro-components-advanced/src/components/virtual-list/react/index.ts @@ -58,12 +58,12 @@ const VirtualList = React.forwardRef(function VirtualList (props: VirtualListPro return React.createElement(List, { ref, ...rest, + outerElementType: OuterScrollView, itemElementType, innerElementType, - outerElementType: OuterScrollView, direction, initialScrollOffset, - overscanCount + overscanCount, }) }) diff --git a/packages/taro-components-advanced/src/components/virtual-list/react/list.ts b/packages/taro-components-advanced/src/components/virtual-list/react/list.ts index 66ff6e4338ad..3c72ebfbbc08 100644 --- a/packages/taro-components-advanced/src/components/virtual-list/react/list.ts +++ b/packages/taro-components-advanced/src/components/virtual-list/react/list.ts @@ -1,14 +1,11 @@ import memoizeOne from 'memoize-one' import React from 'react' -import { convertNumber2PX } from '../../../utils/convert' -import { omit } from '../../../utils/lodash' -import { cancelTimeout, requestTimeout } from '../../../utils/timer' +import { cancelTimeout, convertNumber2PX, defaultItemKey, getRectSize, getScrollViewContextNode, omit, requestTimeout } from '../../../utils' import { IS_SCROLLING_DEBOUNCE_INTERVAL } from '../constants' import { getRTLOffsetType } from '../dom-helpers' import ListSet from '../list-set' import Preset from '../preset' -import { defaultItemKey, getRectSize, getScrollViewContextNode } from '../utils' import { validateListProps } from './validate' import type { IProps } from '../preset' @@ -45,7 +42,7 @@ export default class List extends React.PureComponent { this.preset = new Preset( props, - this.refresh + this.refresh, ) this.itemList = this.preset.itemList @@ -59,7 +56,7 @@ export default class List extends React.PureComponent { ? this.props.initialScrollOffset : 0, scrollUpdateWasRequested: false, - refreshCount: 0 + refreshCount: 0, } } @@ -77,11 +74,11 @@ export default class List extends React.PureComponent { _resetIsScrollingTimeoutId = null - _callOnItemsRendered = memoizeOne((overscanStartIndex, overscanStopIndex, visibleStartIndex, visibleStopIndex) => this.props.onItemsRendered({ + _callOnItemsRendered = memoizeOne((overscanStartIndex, overscanStopIndex, startIndex, stopIndex) => this.props.onItemsRendered({ overscanStartIndex, overscanStopIndex, - visibleStartIndex, - visibleStopIndex + startIndex, + stopIndex })) _callOnScroll = memoizeOne((scrollDirection, scrollOffset, scrollUpdateWasRequested, detail) => this.props.onScroll({ @@ -95,9 +92,9 @@ export default class List extends React.PureComponent { if (typeof this.props.onItemsRendered === 'function') { if (this.props.itemCount > 0) { if (prevProps && prevProps.itemCount !== this.props.itemCount) { - const [overscanStartIndex, overscanStopIndex, visibleStartIndex, visibleStopIndex] = this._getRangeToRender() + const [overscanStartIndex, overscanStopIndex, startIndex, stopIndex] = this._getRangeToRender() - this._callOnItemsRendered(overscanStartIndex, overscanStopIndex, visibleStartIndex, visibleStopIndex) + this._callOnItemsRendered(overscanStartIndex, overscanStopIndex, startIndex, stopIndex) } } } @@ -423,14 +420,14 @@ export default class List extends React.PureComponent { if (itemCount > 0) { const prevPlaceholder = startIndex < placeholderCount ? startIndex : placeholderCount items.push(new Array(prevPlaceholder).fill(-1).map((_, index) => React.createElement( - this.preset.itemTagName, { + this.preset.itemElement, { key: itemKey(index + startIndex - prevPlaceholder, itemData), style: { display: 'none' } } ))) for (let index = startIndex; index <= stopIndex; index++) { const style = this.preset.getItemStyle(index) - items.push(React.createElement(this.preset.itemTagName, { + items.push(React.createElement(this.preset.itemElement, { key: itemKey(index, itemData), style }, React.createElement(item, { @@ -444,7 +441,7 @@ export default class List extends React.PureComponent { restCount = restCount > 0 ? restCount : 0 const postPlaceholder = restCount < placeholderCount ? restCount : placeholderCount items.push(new Array(postPlaceholder).fill(-1).map((_, index) => React.createElement( - this.preset.itemTagName, { + this.preset.itemElement, { key: itemKey(1 + index + stopIndex, itemData), style: { display: 'none' } } @@ -454,7 +451,7 @@ export default class List extends React.PureComponent { // Read this value AFTER items have been created, // So their actual sizes (if variable) are taken into consideration. const estimatedTotalSize = convertNumber2PX(this.itemList.getOffsetSize()) - const outerElementProps: any = { + const outerProps: any = { ...rest, id, className, @@ -476,17 +473,17 @@ export default class List extends React.PureComponent { if (!enhanced) { if (isHorizontal) { - outerElementProps.scrollLeft = scrollUpdateWasRequested ? scrollOffset : this.preset.field.scrollLeft + outerProps.scrollLeft = scrollUpdateWasRequested ? scrollOffset : this.preset.field.scrollLeft } else { - outerElementProps.scrollTop = scrollUpdateWasRequested ? scrollOffset : this.preset.field.scrollTop + outerProps.scrollTop = scrollUpdateWasRequested ? scrollOffset : this.preset.field.scrollTop } } if (this.preset.isRelative) { const pre = convertNumber2PX(this.itemList.getOffsetSize(startIndex)) - return React.createElement(this.preset.outerTagName, outerElementProps, + return React.createElement(this.preset.outerElement, outerProps, renderTop, - React.createElement(this.preset.itemTagName, { + React.createElement(this.preset.itemElement, { key: `${id}-pre`, id: `${id}-pre`, style: { @@ -494,7 +491,7 @@ export default class List extends React.PureComponent { width: !isHorizontal ? '100%' : pre } }), - React.createElement(this.preset.innerTagName, { + React.createElement(this.preset.innerElement, { ref: innerRef, key: `${id}-inner`, id: `${id}-inner`, @@ -506,9 +503,9 @@ export default class List extends React.PureComponent { renderBottom ) } else { - return React.createElement(this.preset.outerTagName, outerElementProps, + return React.createElement(this.preset.outerElement, outerProps, renderTop, - React.createElement(this.preset.innerTagName, { + React.createElement(this.preset.innerElement, { ref: innerRef, key: `${id}-inner`, id: `${id}-inner`, diff --git a/packages/taro-components-advanced/src/components/virtual-list/utils.ts b/packages/taro-components-advanced/src/components/virtual-list/utils.ts index 7a820451f166..ec9c1798b8af 100644 --- a/packages/taro-components-advanced/src/components/virtual-list/utils.ts +++ b/packages/taro-components-advanced/src/components/virtual-list/utils.ts @@ -1,9 +1,3 @@ -import { createSelectorQuery } from '@tarojs/taro' - -// In DEV mode, this Set helps us only log a warning once per component instance. -// This avoids spamming the console every time a render happens. -export const defaultItemKey = (index: number, _itemData?: unknown) => index - interface IHorizontal { direction?: string layout?: string @@ -17,25 +11,3 @@ interface IRrl { export function isRtlFunc ({ direction }: IRrl) { return direction === 'rtl' } - -export function getRectSize (id: string, success?: TFunc, fail?: TFunc, retryMs = 500) { - const query = createSelectorQuery() - try { - query.select(id).boundingClientRect((res) => { - if (res) { - success?.(res) - } else { - fail?.() - } - }).exec() - } catch (err) { - setTimeout(() => { - getRectSize(id, success, fail, retryMs) - }, retryMs) - } -} - -export async function getScrollViewContextNode (id: string) { - const query = createSelectorQuery() - return new Promise((resolve) => query.select(id).node(({ node }) => resolve(node)).exec()) -} diff --git a/packages/taro-components-advanced/src/components/virtual-list/vue/list.ts b/packages/taro-components-advanced/src/components/virtual-list/vue/list.ts index bf57641852f7..6c426f7f6b57 100644 --- a/packages/taro-components-advanced/src/components/virtual-list/vue/list.ts +++ b/packages/taro-components-advanced/src/components/virtual-list/vue/list.ts @@ -1,14 +1,11 @@ import { isWebPlatform } from '@tarojs/shared' import memoizeOne from 'memoize-one' -import { convertNumber2PX } from '../../../utils/convert' -import { omit } from '../../../utils/lodash' -import { cancelTimeout, requestTimeout } from '../../../utils/timer' +import { cancelTimeout, convertNumber2PX, defaultItemKey, getRectSize, getScrollViewContextNode, omit, requestTimeout } from '../../../utils' +import render from '../../../utils/vue-render' import { IS_SCROLLING_DEBOUNCE_INTERVAL } from '../constants' import { getRTLOffsetType } from '../dom-helpers' import Preset from '../preset' -import { defaultItemKey, getRectSize, getScrollViewContextNode } from '../utils' -import render from './render' const isWeb = isWebPlatform() @@ -22,6 +19,9 @@ export default { type: [String, Number], required: true }, + item: { + required: true + }, itemCount: { type: Number, required: true @@ -30,6 +30,7 @@ export default { type: Array, required: true }, + itemKey: String, itemSize: { type: [Number, Function], required: true @@ -42,14 +43,6 @@ export default { type: String, default: 'absolute' }, - initialScrollOffset: { - type: Number, - default: 0 - }, - innerElementType: { - type: String, - default: isWeb ? 'taro-view-core' : 'view' - }, direction: { type: String, default: 'ltr' @@ -58,6 +51,10 @@ export default { type: String, default: 'vertical' }, + initialScrollOffset: { + type: Number, + default: 0 + }, overscanCount: { type: Number, default: 1 @@ -69,32 +66,33 @@ export default { type: Boolean, default: false }, - item: { - required: true + enhanced: { + type: Boolean, + default: false }, - itemKey: String, - itemTagName: { + shouldResetStyleCacheOnItemSizeChange: { + type: Boolean, + default: true + }, + outerElementType: { type: String, - default: isWeb ? 'taro-view-core' : 'view' + default: isWeb ? 'taro-scroll-view-core' : 'scroll-view' }, - innerTagName: { + innerElementType: { type: String, default: isWeb ? 'taro-view-core' : 'view' }, - outerTagName: { + itemElementType: { type: String, - default: isWeb ? 'taro-scroll-view-core' : 'scroll-view' + default: isWeb ? 'taro-view-core' : 'view' }, - itemElementType: String, - outerElementType: String, - innerRef: String, + outerTagName: String, + innerTagName: String, + itemTagName: String, outerRef: String, - onItemsRendered: Function, + innerRef: String, onScrollNative: Function, - shouldResetStyleCacheOnItemSizeChange: { - type: Boolean, - default: true - }, + onItemsRendered: Function, }, data () { const preset = new Preset(this.$props, this.refresh) @@ -164,14 +162,14 @@ export default { function ( overscanStartIndex, overscanStopIndex, - visibleStartIndex, - visibleStopIndex + startIndex, + stopIndex ) { return this.$props.onItemsRendered({ overscanStartIndex, overscanStopIndex, - visibleStartIndex, - visibleStopIndex + startIndex, + stopIndex }) } ), @@ -199,14 +197,14 @@ export default { const [ overscanStartIndex, overscanStopIndex, - visibleStartIndex, - visibleStopIndex + startIndex, + stopIndex ] = this._getRangeToRender() this._callOnItemsRendered( overscanStartIndex, overscanStopIndex, - visibleStartIndex, - visibleStopIndex + startIndex, + stopIndex ) } } @@ -473,7 +471,7 @@ export default { if (itemCount > 0) { const prevPlaceholder = startIndex < placeholderCount ? startIndex : placeholderCount items.push(new Array(prevPlaceholder).fill(-1).map((_, index) => render( - this.preset.itemTagName, { + this.preset.itemElement, { key: itemKey(index + startIndex - prevPlaceholder, itemData), style: { display: 'none' } } @@ -481,7 +479,7 @@ export default { for (let index = startIndex; index <= stopIndex; index++) { const style = this.preset.getItemStyle(index) items.push( - render(this.preset.itemTagName, { + render(this.preset.itemElement, { key: itemKey(index, itemData), style }, [ @@ -501,7 +499,7 @@ export default { restCount = restCount > 0 ? restCount : 0 const postPlaceholder = restCount < placeholderCount ? restCount : placeholderCount items.push(new Array(postPlaceholder).fill(-1).map((_, index) => render( - this.preset.itemTagName, { + this.preset.itemElement, { key: itemKey(1 + index + stopIndex, itemData), style: { display: 'none' } } @@ -544,9 +542,9 @@ export default { if (this.preset.isRelative) { const pre = convertNumber2PX(this.itemList.getOffsetSize(startIndex)) - return render(this.preset.outerTagName, outerElementProps, [ + return render(this.preset.outerElement, outerElementProps, [ process.env.FRAMEWORK === 'vue3' ? this.$slots.top?.() : this.$slots.top, - render(this.preset.itemTagName, { + render(this.preset.itemElement, { key: `${id}-pre`, id: `${id}-pre`, style: { @@ -554,7 +552,7 @@ export default { width: !isHorizontal ? '100%' : pre } }), - render(this.preset.innerTagName, { + render(this.preset.innerElement, { ref: innerRef, key: `${id}-inner`, id: `${id}-inner`, @@ -566,9 +564,9 @@ export default { process.env.FRAMEWORK === 'vue3' ? this.$slots.bottom?.() : this.$slots.bottom, ]) } else { - return render(this.preset.outerTagName, outerElementProps, [ + return render(this.preset.outerElement, outerElementProps, [ process.env.FRAMEWORK === 'vue3' ? this.$slots.top?.() : this.$slots.top, - render(this.preset.innerTagName, { + render(this.preset.innerElement, { ref: innerRef, key: `${id}-inner`, id: `${id}-inner`, diff --git a/packages/taro-components-advanced/src/components/virtual-waterfall/constants.ts b/packages/taro-components-advanced/src/components/virtual-waterfall/constants.ts new file mode 100644 index 000000000000..739698e71675 --- /dev/null +++ b/packages/taro-components-advanced/src/components/virtual-waterfall/constants.ts @@ -0,0 +1 @@ +export const IS_SCROLLING_DEBOUNCE_INTERVAL = 200 diff --git a/packages/taro-components-advanced/src/components/virtual-waterfall/index.ts b/packages/taro-components-advanced/src/components/virtual-waterfall/index.ts new file mode 100644 index 000000000000..238cb353ca4e --- /dev/null +++ b/packages/taro-components-advanced/src/components/virtual-waterfall/index.ts @@ -0,0 +1,122 @@ +import type { BaseEventOrig, BaseEventOrigFunction, ScrollViewProps, StandardProps } from '@tarojs/components' +import type { Component, ComponentType, CSSProperties } from 'react' + +interface VirtualWaterfallProps extends Omit { + /** 高度 */ + height: string | number + /** 宽度 */ + width: string | number + /** 瀑布流占用列数量,默认值依照 width / columnWidth ||= 2 计算 */ + column?: number + /** 瀑布流单列宽度,默认值依照 width / column 计算 */ + columnWidth?: number + /** 子组件 */ + item: ComponentType<{ + /** 组件 ID */ + id: string + /** 单项的样式,样式必须传入组件的 style 中 */ + style?: CSSProperties + /** 组件渲染的数据 */ + data: T[] + /** 组件渲染数据的索引 */ + index: number + /** 组件是否正在滚动,当 useIsScrolling 值为 true 时返回布尔值,否则返回 undefined */ + isScrolling?: boolean + }> + /** 渲染数据 */ + itemData: T[] + /** 单项的大小 */ + itemSize: number | ((index?: number, itemData?: T[]) => number) + /** 布局方式 + * @default "absolute" + */ + position?: 'absolute' | 'relative' + /** 初始滚动偏移值 */ + initialScrollOffset?: number + /** 在可视区域之外预渲染的距离,值设置得越高,快速滚动时出现白屏的概率就越小,相应地,每次滚动的性能会变得越差。 + * @default 50 + */ + overscanDistance?: number + /** 上下滚动预占位节点 + * @default 0 + */ + placeholderCount?: number + /** 是否注入 isScrolling 属性到 item 组件。这个参数一般用于实现滚动骨架屏(或其它 placeholder) 时比较有用。 */ + useIsScrolling?: boolean + /** 通过 ScrollViewContext 优化组件滚动性能 + * @default false + * @note 部分平台不支持,使用时请注意甄别 + */ + enhanced?: boolean + /** 列表外部容器组件类型。 + * @default ScrollView + */ + outerElementType?: ComponentType | string + /** 列表内部容器组件类型。 + * @default View + */ + innerElementType?: ComponentType | string + /** 列表子节点容器组件类型。 + * @default View + */ + itemElementType?: ComponentType | string + /** 滚动时调用函数 */ + onScroll?: (event: VirtualWaterfallProps.IVirtualWaterfallEvent) => void + /** 调用平台原生的滚动监听函数。 */ + onScrollNative?: BaseEventOrigFunction + style?: CSSProperties +} + +declare namespace VirtualWaterfallProps { + interface IVirtualWaterfallEventDetail extends ScrollViewProps.onScrollDetail { + scrollLeft: number + scrollTop: number + scrollHeight: number + scrollWidth: number + clientHeight: number + clientWidth: number + diffOffset: number + } + + interface IVirtualWaterfallEvent extends BaseEventOrig { + /** 滚动方向,可能值为 forward 往前, backward 往后。 */ + scrollDirection: 'forward' | 'backward' + /** 滚动距离 */ + scrollOffset: number + /** 当滚动是由 scrollTo() 或 scrollToItem() 调用时返回 true,否则返回 false */ + scrollUpdateWasRequested: boolean + /** 滚动信息 */ + detail: T + } +} + +/** 虚拟瀑布流 + * @classification viewContainer + * @supported weapp, swan, alipay, tt, qq, jd, h5 + */ +declare class VirtualWaterfallComponent extends Component { + /** + * 滚动到指定的地点。 + */ + public scrollTo(scrollOffset: number): void + + /** 滚动到指定的条目。 + * @param index 指定条目的索引。 + * @param align 滚动到指定条目时,指定条目的位置。默认值为 auto。 + * + * - start:指定条目在可视区域的顶部。 + * - end:指定条目在可视区域的底部。 + * - center:指定条目在可视区域的中间。 + * - auto:尽可能滚动距离最小保证条目在可视区域中,如果已经在可视区域,就不滚动。 + * - smart:条目如果已经在可视区域,就不滚动;如果有部分在可视区域,尽可能滚动距离最小保证条目在可视区域中;如果条目完全不在可视区域,那就滚动到条目在可视区域居中显示。 + */ + public scrollToItem(index: number, align: 'start' | 'end' | 'center' | 'auto' | 'smart'): void +} + +declare type VirtualWaterfall = VirtualWaterfallComponent +const VirtualWaterfall: typeof VirtualWaterfallComponent = (process.env.FRAMEWORK === 'vue' || process.env.FRAMEWORK === 'vue3') + ? require('./vue').default + : require('./react').default + +export { VirtualWaterfall, VirtualWaterfallProps } +export default VirtualWaterfall diff --git a/packages/taro-components-advanced/src/components/virtual-waterfall/list-map.ts b/packages/taro-components-advanced/src/components/virtual-waterfall/list-map.ts new file mode 100644 index 000000000000..54771c7b58e7 --- /dev/null +++ b/packages/taro-components-advanced/src/components/virtual-waterfall/list-map.ts @@ -0,0 +1,224 @@ +import { isFunction, isNumber } from '@tarojs/shared' + +import { getOffsetForIndexAndAlignment } from '../../utils' + +import type { IProps } from './preset' + +type TProps = Pick + +export default class ListMap { + #columns: number + #columnMap: [number, number, number][][] = [] // [itemIndex, startPosition, itemSize] + #items: [number, number][] = [] // [columnIndex, rowIndex] + mode?: 'normal' | 'function' + clientSize = 0 + minItemSize = 0 + maxItemSize = 0 + + constructor (columns = 2, protected props: TProps, protected refresh?: TFunc) { + if (isFunction(this.props.itemSize)) { + this.mode = 'function' + } else if (isNumber(this.props.itemSize)) { + this.mode = 'normal' + this.minItemSize = this.props.itemSize + this.maxItemSize = this.props.itemSize + } + + this.updateColumns(columns, props) + } + + updateColumns (columns = 2, props: TProps) { + this.#columns = columns + if (!this.isNormalMode) { + this.#columnMap = new Array(this.#columns).fill(0).map(() => []) + this.#items = [] + this.update(props) + } + } + + update (props: TProps) { + this.props = props + + if (!this.isNormalMode) { + this.updateItem(this.props.itemData.length - 1) + } + } + + updateItem (itemIndex: number) { + const itemSizeFunc = this.props.itemSize as Exclude + const itemSize = itemSizeFunc(itemIndex, this.props.itemData) + let column = 0 + let row = 0 + let startPosition = 0 + if (itemIndex > 0) { + const [lastColumn, lastRow] = this.getItemPosition(itemIndex - 1) + if (lastRow === this.#columns - 1) { + column = lastColumn + 1 + row = 0 + } else { + column = lastColumn + row = lastRow + 1 + } + this.#items[itemIndex] = [column, row] + + const [, lastStart, lastSize] = this.getItemInfo(itemIndex - 1) + startPosition = lastStart + lastSize + } else { + this.#items[itemIndex] = [column, row] + } + + const list = this.getColumnList(column) + list[row] = [itemIndex, startPosition, itemSize] + return this.#items[itemIndex] + } + + get isNormalMode () { + return this.mode === 'normal' + } + + get overscan () { + return this.props.overscanDistance || 50 + } + + get maxColumnSize () { + if (this.isNormalMode) return this.getColumnSize() + + const list = new Array(this.#columns).fill(0).map((_, i) => this.getColumnSize(i)) + return Math.max(...list) + } + + // 不支持 normal 模式 + getColumnList (columnIndex: number) { + this.#columnMap[columnIndex] ||= [] + return this.#columnMap[columnIndex] + } + + getColumnSize (columnIndex = 0) { + if (this.isNormalMode) { + const columnLength = Math.ceil(this.props.itemData.length / this.#columns) + return this.minItemSize * columnLength + } + + const list = this.getColumnList(columnIndex) + const [, , start = 0, size = 0] = list[list.length - 1] || [] + return start + size + } + + getItemPosition (itemIndex: number) { + if (this.isNormalMode) { + const column = itemIndex % this.#columns + const row = Math.floor(itemIndex / this.#columns) + return [column, row] + } + + const position = this.#items[itemIndex] + + if (position) { + return position + } else { + return this.updateItem(itemIndex) + } + } + + getItemInfo (itemIndex: number) { + const [column, row] = this.getItemPosition(itemIndex) + if (this.isNormalMode) { + const itemSize = this.minItemSize + const startPosition = row * itemSize + return [itemIndex, startPosition, itemSize] + } + + const list = this.getColumnList(column) + return list[row] + } + + getItemDetail (itemIndex: number) { + return this.props.itemData[itemIndex] + } + + getOffsetSize (itemIndex: number) { + const [, start] = this.getItemInfo(itemIndex) + return start + } + + getStartItems (offset: number) { + const list = new Array(this.#columns).fill(0) + offset = Math.max(0, offset - this.overscan) + if (this.isNormalMode) { + const size = this.minItemSize || 1 + const count = Math.max(0, Math.floor(offset / size)) + return list.map(() => count) + } else { + return list.map((_, i) => { + const column = this.getColumnList(i) + let idx = offset / (this.minItemSize || 1) + while (column[idx][2] > offset) { + idx-- + } + return idx + }) + } + } + + getStartIndex (column: number, offset: number) { + if (this.isNormalMode) { + const size = this.minItemSize || 1 + return Math.max(0, Math.floor(offset / size)) + } + const columns = this.getColumnList(column) + let x = offset / (this.minItemSize || 1) + let y = offset / (this.maxItemSize || 1) + while (columns[x][2] > offset || columns[y][2] < offset) { + x-- + y++ + } + return columns[x][2] <= offset ? x : y + } + + getStopIndex (column: number, offset: number, start = 0) { + if (this.isNormalMode) { + const size = this.minItemSize || 1 + const count = Math.ceil(offset / size) + return Math.min(count, this.props.itemData.length) + } + const columns = this.getColumnList(column) + let x = offset / (this.minItemSize || 1) + let y = Math.max(start, offset / (this.maxItemSize || 1)) + while (columns[y][2] < offset || columns[x][2] > offset) { + y++ + x-- + } + return columns[y][2] >= offset ? y : x + } + + getRangeToRender (direction: 'forward' | 'backward', column: number, offset: number, block = false) { + const length = this.props.itemData.length + if (length === 0) return [0, 0] + + const clientSize = this.clientSize + const scrollSize = this.maxColumnSize + const backwardDistance = !block || direction === 'backward' ? Math.max(0, this.overscan) : 0 + const forwardDistance = !block || direction === 'forward' ? Math.max(0, this.overscan) : 0 + + const overscanBackward = this.getStartIndex(column, Math.max(0, offset - backwardDistance)) + const overscanForward = this.getStopIndex(column, Math.max(0, Math.min(scrollSize, clientSize + offset + forwardDistance)), overscanBackward) + return [ + overscanBackward, + overscanForward, + ] + } + + getOffsetForIndexAndAlignment (index: number, align: string, scrollOffset: number) { + return getOffsetForIndexAndAlignment({ + align, + containerSize: this.clientSize, + currentOffset: scrollOffset, + scrollSize: this.maxColumnSize, + slideSize: this.getColumnSize(index), + targetOffset: this.getOffsetSize(index), + }) + } + + // Note: 不支持动态更新。不需要对比节点大小 + // compareSize +} diff --git a/packages/taro-components-advanced/src/components/virtual-waterfall/preset.ts b/packages/taro-components-advanced/src/components/virtual-waterfall/preset.ts new file mode 100644 index 000000000000..2400df8cef86 --- /dev/null +++ b/packages/taro-components-advanced/src/components/virtual-waterfall/preset.ts @@ -0,0 +1,179 @@ +import memoizeOne from 'memoize-one' + +import { convertNumber2PX, defaultItemKey, getRectSizeSync, isCosDistributing } from '../../utils' +import ListMap from './list-map' + +import type { VirtualWaterfallProps } from './' + +let INSTANCE_ID = 0 + +export interface IProps extends Partial { + children?: VirtualWaterfallProps['item'] + itemKey?: typeof defaultItemKey + itemTagName?: string + innerTagName?: string + outerTagName?: string + outerRef?: React.Ref | string + onItemsRendered?: TFunc + shouldResetStyleCacheOnItemSizeChange?: boolean +} + +export default class Preset { + itemMap: ListMap + + constructor (protected props: IProps, protected refresh?: TFunc) { + this.init(this.props) + this.itemMap = new ListMap(this.columns, props, refresh) + } + + #wrapperField = { + scrollLeft: 0, + scrollTop: 0, + scrollHeight: 0, + scrollWidth: 0, + clientHeight: 0, + clientWidth: 0, + diffOffset: 0 + } + + wrapperHeight = 0 + wrapperWidth = 0 + columns = 2 + columnWidth = 0 + + diffList: number[] = [0, 0, 0] + + init (props: IProps) { + this.props = props + } + + update (props: IProps) { + this.props = props + this.itemMap.update(props) + } + + async updateWrapper (id: string) { + const { width = 0, height = 0, column, columnWidth } = this.props + const validWidth = typeof width === 'number' && width > 0 + const validHeight = typeof height === 'number' && height > 0 + if (validWidth) { + this.wrapperWidth = width + } + if (validHeight) { + this.wrapperHeight = height + this.itemMap.clientSize = height + } + + if (!validHeight || !validWidth) { + const res = await getRectSizeSync(`#${id}`, 100) + this.wrapperWidth ||= res.width + this.wrapperHeight ||= res.height + this.refresh?.() + } + + if (typeof column === 'number' && column > 0) { + this.columns = column + this.columnWidth = this.wrapperWidth / column + } else if (typeof columnWidth === 'number' && columnWidth > 0) { + this.columns = Math.floor(this.wrapperWidth / columnWidth) + this.columnWidth = columnWidth + } else { + this.columns = 2 + this.columnWidth = this.wrapperWidth / this.columns + } + this.itemMap.updateColumns(this.columns, this.props) + } + + get id () { + return `virtual-waterfall-${INSTANCE_ID++}` + } + + get isRelative () { + return this.props.position === 'relative' + } + + get placeholderCount () { + return this.props.placeholderCount || 0 + } + + get outerElement () { + return this.props.outerElementType || this.props.outerTagName || 'div' + } + + get innerElement () { + return this.props.innerElementType || this.props.innerTagName || 'div' + } + + get itemElement () { + return this.props.itemElementType || this.props.itemTagName || 'div' + } + + get field () { + return this.#wrapperField + } + + set field (o: Record) { + Object.assign(this.#wrapperField, o) + } + + isShaking (diff?: number) { + const list = this.diffList.slice(-3) + this.diffList.push(diff) + return list.findIndex(e => Math.abs(e) === Math.abs(diff)) !== -1 || isCosDistributing(this.diffList.slice(-4)) + } + + getItemStyleCache = memoizeOne(( + _itemSize?: IProps['itemSize'] | false, + ) => { + // TODO: Cache of item styles, keyed by item index. + return {} + }) + + getItemStyle (index: number) { + const { + itemSize, + shouldResetStyleCacheOnItemSizeChange + } = this.props + + const itemStyleCache = this.getItemStyleCache( + shouldResetStyleCacheOnItemSizeChange ? itemSize : false, + ) + + let style + + const [, nodeOffset, nodeSize] = this.itemMap.getItemInfo(index) + const offset = convertNumber2PX(nodeOffset) + const size = convertNumber2PX(nodeSize) + if (itemStyleCache.hasOwnProperty(index)) { + // Note: style is frozen. + style = { ...itemStyleCache[index] } + style.height = size + if (!this.isRelative) { + style.top = offset + } + } else { + if (this.isRelative) { + itemStyleCache[index] = style = { + height: size, + width: '100%' + } + } else { + itemStyleCache[index] = style = { + position: 'absolute', + left: 0, + top: offset, + height: size, + width: '100%' + } + } + } + + for (const k in style) { + if (style.hasOwnProperty(k)) { + style[k] = convertNumber2PX(style[k]) + } + } + + return style + } +} diff --git a/packages/taro-components-advanced/src/components/virtual-waterfall/react/index.ts b/packages/taro-components-advanced/src/components/virtual-waterfall/react/index.ts new file mode 100644 index 000000000000..00b3df99b638 --- /dev/null +++ b/packages/taro-components-advanced/src/components/virtual-waterfall/react/index.ts @@ -0,0 +1,69 @@ +import { type BaseEventOrig, ScrollView, View } from '@tarojs/components' +import React from 'react' + +import { convertPX2Int } from '../../../utils/convert' +import Waterfall from './waterfall' + +import type { VirtualWaterfallProps } from '../' +import type { IProps } from '../preset' + +const OuterScrollView = React.forwardRef( + function OuterScrollView (props, ref) { + const { id, className, style, onScroll, onScrollNative, ...rest } = props as IProps + const handleScroll = (event: BaseEventOrig) => { + onScroll({ + ...event as any, + currentTarget: { + ...event.detail, + clientWidth: convertPX2Int(style.width), + clientHeight: convertPX2Int(style.height) + } as any + }) + + if (typeof onScrollNative === 'function') { + onScrollNative(event) + } + } + + return React.createElement(ScrollView, { + ref, + id, + className, + style, + scrollY: true, + onScroll: handleScroll, + ...rest + }) + } +) + +const VirtualWaterfall = React.forwardRef(function VirtualWaterfall (props: VirtualWaterfallProps, ref) { + const { + outerElementType = OuterScrollView, + innerElementType = View, + itemElementType = View, + initialScrollOffset = 0, + overscanDistance = 50, + ...rest + } = props as IProps + + if ('children' in rest) { + console.warn('Taro(VirtualWaterfall): children props have been deprecated. ' + 'Please use the item props instead.') + rest.item = rest.children as IProps['item'] + } + if (rest.item instanceof Array) { + console.warn('Taro(VirtualWaterfall): item should not be an array') + rest.item = rest.item[0] + } + return React.createElement(Waterfall, { + ref, + ...rest, + outerElementType, + itemElementType, + innerElementType, + initialScrollOffset, + overscanDistance, + }) +}) + +export default VirtualWaterfall diff --git a/packages/taro-components-advanced/src/components/virtual-waterfall/react/waterfall.ts b/packages/taro-components-advanced/src/components/virtual-waterfall/react/waterfall.ts new file mode 100644 index 000000000000..bf710329c93e --- /dev/null +++ b/packages/taro-components-advanced/src/components/virtual-waterfall/react/waterfall.ts @@ -0,0 +1,364 @@ +import classNames from 'classnames' +import memoizeOne from 'memoize-one' +import React from 'react' + +import { cancelTimeout, convertNumber2PX, defaultItemKey, getScrollViewContextNode, requestTimeout } from '../../../utils' +import { IS_SCROLLING_DEBOUNCE_INTERVAL } from '../constants' +import ListMap from '../list-map' +import Preset, { type IProps } from '../preset' + +interface IState { + id: string + instance: Waterfall + isScrolling: boolean + scrollDirection: 'forward' | 'backward' + scrollOffset: number + scrollUpdateWasRequested: boolean + refreshCount: number +} + +export default class Waterfall extends React.PureComponent { + static defaultProps: IProps = { + itemData: undefined, + overscanDistance: 50, + useIsScrolling: false, + shouldResetStyleCacheOnItemSizeChange: true + } + + itemMap: ListMap + preset: Preset + + constructor (props: IProps) { + super(props) + + this.preset = new Preset( + props, + this.refresh, + ) + const id = this.props.id || this.preset.id + this.preset.updateWrapper(id) + this.itemMap = this.preset.itemMap + + this.state = { + id, + instance: this, + isScrolling: false, + scrollDirection: 'forward', + scrollOffset: + typeof this.props.initialScrollOffset === 'number' + ? this.props.initialScrollOffset + : 0, + scrollUpdateWasRequested: false, + refreshCount: 0, + } + } + + refresh = () => { + if (process.env.FRAMEWORK === 'preact') { + this.forceUpdate() + } else { + this.setState(({ refreshCount }) => ({ + refreshCount: ++refreshCount + })) + } + } + + #outerRef = undefined + + #resetIsScrollingTimeoutId = null + + #callOnItemsRendered = memoizeOne((columnIndex, overscanStartIndex, overscanStopIndex) => this.props.onItemsRendered({ + columnIndex, + overscanStartIndex, + overscanStopIndex, + })) + + #callOnScroll = memoizeOne((scrollDirection, scrollOffset, scrollUpdateWasRequested, detail) => this.props.onScroll({ + scrollDirection, + scrollOffset, + scrollUpdateWasRequested, + detail + } as any)) + + #callPropsCallbacks (prevProps: any = {}, prevState: any = {}) { + if (typeof this.props.onItemsRendered === 'function') { + if (this.props.itemData.length > 0) { + if (prevProps && prevProps.itemData.length !== this.props.itemData.length) { + for (let columnIndex = 0; columnIndex < this.preset.columns; columnIndex++) { + const [overscanStartIndex, overscanStopIndex] = this.#getRangeToRender(columnIndex) + this.#callOnItemsRendered(columnIndex, overscanStartIndex, overscanStopIndex) + } + } + } + } + + if (typeof this.props.onScroll === 'function') { + if (!prevState || + prevState.scrollDirection !== this.state.scrollDirection || + prevState.scrollOffset !== this.state.scrollOffset || + prevState.scrollUpdateWasRequested !== this.state.scrollUpdateWasRequested + ) { + this.#callOnScroll( + this.state.scrollDirection, + this.state.scrollOffset, + this.state.scrollUpdateWasRequested, + this.preset.field + ) + } + } + + // setTimeout(() => { + // const [startIndex, stopIndex] = this.#getRangeToRender() + // for (let index = startIndex; index <= stopIndex; index++) { + // this.#getSizeUploadSync(index) + // } + // }, 0) + } + + // Lazily create and cache item styles while scrolling, + // So that pure component sCU will prevent re-renders. + // We maintain this cache, and pass a style prop rather than index, + // So that List can clear cached styles and force item re-render if necessary. + #getRangeToRender (columnIndex = 0) { + return this.itemMap.getRangeToRender( + this.state.scrollDirection, + columnIndex, + this.state.scrollOffset, + this.state.isScrolling, + ) + } + + #outerRefSetter = ref => { + const { + outerRef + } = this.props + this.#outerRef = ref + + if (typeof outerRef === 'function') { + outerRef(ref) + } else if (outerRef != null && typeof outerRef === 'object' && outerRef.hasOwnProperty('current')) { + // @ts-ignore + outerRef.current = ref + } + } + + #resetIsScrollingDebounced = () => { + if (this.#resetIsScrollingTimeoutId !== null) { + cancelTimeout(this.#resetIsScrollingTimeoutId) + } + + this.#resetIsScrollingTimeoutId = requestTimeout(this.#resetIsScrolling, IS_SCROLLING_DEBOUNCE_INTERVAL) + } + + #resetIsScrolling = () => { + this.#resetIsScrollingTimeoutId = null + this.setState({ + isScrolling: false + }, () => { + // Clear style cache after state update has been committed. + // This way we don't break pure sCU for items that don't use isScrolling param. + this.preset.getItemStyleCache(-1) + }) + } + + #onScroll = event => { + const { + clientHeight = this.preset.wrapperHeight, + scrollHeight = this.itemMap.maxColumnSize, + scrollWidth, + scrollTop, + scrollLeft + } = event.currentTarget + this.setState((prevState: IState) => { + const diffOffset = this.preset.field.scrollTop - scrollTop + if (prevState.scrollOffset === scrollTop || this.preset.isShaking(diffOffset)) { + // Scroll position may have been updated by cDM/cDU, + // In which case we don't need to trigger another render, + // And we don't want to update state.isScrolling. + return null + } + // FIXME preact 中使用时,该组件会出现触底滚动事件重复触发导致的抖动问题,后续修复 + // Prevent Safari's elastic scrolling from causing visual shaking when scrolling past bounds. + const scrollOffset = Math.max(0, Math.min(scrollTop, scrollHeight - clientHeight)) + this.preset.field = { + scrollHeight: this.itemMap.maxColumnSize, + scrollWidth: scrollWidth, + scrollTop: scrollOffset, + scrollLeft: scrollLeft, + clientHeight: clientHeight, + clientWidth: scrollWidth, + diffOffset: this.preset.field.scrollTop - scrollOffset, + } + return { + isScrolling: true, + scrollDirection: prevState.scrollOffset < scrollOffset ? 'forward' : 'backward', + scrollOffset, + scrollUpdateWasRequested: false + } + }, this.#resetIsScrollingDebounced) + } + + public scrollTo (scrollOffset = 0) { + const { enhanced } = this.props + scrollOffset = Math.max(0, scrollOffset) + if (this.state.scrollOffset === scrollOffset) return + + if (enhanced) { + const option: any = { + animated: true, + duration: 500 + } + option.top = scrollOffset + return getScrollViewContextNode(`#${this.state.id}`).then((node: any) => node.scrollTo(option)) + } + + this.setState((prevState: IState) => { + if (prevState.scrollOffset === scrollOffset) { + return null + } + + return { + scrollDirection: prevState.scrollOffset < scrollOffset ? 'forward' : 'backward', + scrollOffset: scrollOffset, + scrollUpdateWasRequested: true + } + }, this.#resetIsScrollingDebounced) + } + + public scrollToItem (index: number, align = 'auto') { + const { itemData } = this.props + const { scrollOffset } = this.state + index = Math.max(0, Math.min(index, itemData.length - 1)) + this.scrollTo(this.itemMap.getOffsetForIndexAndAlignment(index, align, scrollOffset)) + } + + componentDidMount () { + const { initialScrollOffset } = this.props + + if (typeof initialScrollOffset === 'number' && this.#outerRef != null) { + this.#outerRef.scrollTop = initialScrollOffset + } + + this.#callPropsCallbacks() + } + + componentDidUpdate (prevProps: IProps, prevState: IState) { + const { scrollOffset, scrollUpdateWasRequested } = this.state + + // Note: 不支持动态更新 + // this.preset.update(this.props) + + if (scrollUpdateWasRequested && this.#outerRef != null) { + this.#outerRef.scrollTop = scrollOffset + } + + this.#callPropsCallbacks(prevProps, prevState) + } + + componentWillUnmount () { + if (this.#resetIsScrollingTimeoutId !== null) { + cancelTimeout(this.#resetIsScrollingTimeoutId) + } + } + + getRenderItemNode (index: number, type: 'node' | 'placeholder' = 'node') { + const { item, itemData, itemKey = defaultItemKey, useIsScrolling } = this.props + const { id, isScrolling } = this.state + + if (type === 'placeholder') { + return React.createElement(this.preset.itemElement, { + key: itemKey(index, itemData), + style: { display: 'none' } + }) + } + + const style = this.preset.getItemStyle(index) + return React.createElement(this.preset.itemElement, { + key: itemKey(index, itemData), + style + }, React.createElement(item, { + id: `${id}-${index}`, + data: itemData, + index, + isScrolling: useIsScrolling ? isScrolling : undefined + })) + } + + getRenderColumnNode (index: number, estimatedHeight: string) { + const { id, isScrolling } = this.state + const columnProps: any = { + key: `${id}-column-${index}`, + id: `${id}-column-${index}`, + style: { + height: estimatedHeight, + pointerEvents: isScrolling ? 'none' : 'auto', + position: 'relative', + width: convertNumber2PX(this.preset.columnWidth) + } + } + + const [startIndex, stopIndex] = this.#getRangeToRender(index) + const length = this.itemMap.getColumnSize(index) + const items = [] + const placeholderCount = this.preset.placeholderCount + let restCount = length - stopIndex + restCount = restCount > 0 ? restCount : 0 + const prevPlaceholder = startIndex < placeholderCount ? startIndex : placeholderCount + items.push(new Array(prevPlaceholder).fill(0).map((_, i) => this.getRenderItemNode(i, 'placeholder'))) + for (let i = startIndex; i <= stopIndex; i++) { + items.push(this.getRenderItemNode(i)) + } + const postPlaceholder = restCount < placeholderCount ? restCount : placeholderCount + items.push(new Array(postPlaceholder).fill(0).map((_, i) => this.getRenderItemNode(i, 'placeholder'))) + return React.createElement(this.preset.innerElement, columnProps, items) + } + + render () { + const { + className, + style, + height, + width, + enhanced = false, + ...rest + } = this.props + const { + id, + scrollOffset, + scrollUpdateWasRequested + } = this.state + + const estimatedHeight = convertNumber2PX(this.itemMap.maxColumnSize) + const outerProps: any = { + ...rest, + id, + className: classNames(className, 'virtual-waterfall'), + onScroll: this.#onScroll, + ref: this.#outerRefSetter, + enhanced, + style: { + display: 'flex', + justifyContent: 'space-evenly', + position: 'relative', + height: convertNumber2PX(height), + width: convertNumber2PX(width), + overflow: 'auto', + WebkitOverflowScrolling: 'touch', + willChange: 'transform', + ...style + }, + } + + if (!enhanced) { + outerProps.scrollTop = scrollUpdateWasRequested ? scrollOffset : this.preset.field.scrollTop + } + + const columns = this.preset.columns + const columnNodes: React.ReactNode[] = [] + for (let i = 0; i < columns; i++) { + columnNodes.push(this.getRenderColumnNode(i, estimatedHeight)) + } + + return React.createElement(this.preset.outerElement, outerProps, columnNodes) + } +} diff --git a/packages/taro-components-advanced/src/components/virtual-waterfall/vue/index.ts b/packages/taro-components-advanced/src/components/virtual-waterfall/vue/index.ts new file mode 100644 index 000000000000..9801eeb9a673 --- /dev/null +++ b/packages/taro-components-advanced/src/components/virtual-waterfall/vue/index.ts @@ -0,0 +1,25 @@ +import Waterfall from './waterfall' + +import type { ElementAttrs, TransformReact2VueType, VueComponentType } from '@tarojs/components/types/index.vue3' +import type { App } from 'vue' +import type { VirtualWaterfallProps } from '../' + +export type VueVirtualWaterfallProps = VirtualWaterfallProps + +declare global { + namespace JSX { + interface IntrinsicElements { + 'virtual-waterfall': ElementAttrs> + } + } +} + +export const VirtualWaterfall = Waterfall as unknown as VueComponentType + +function install (Vue: App) { + Vue.component('virtual-waterfall', Waterfall) +} + +export default { + install +} diff --git a/packages/taro-components-advanced/src/components/virtual-waterfall/vue/waterfall.ts b/packages/taro-components-advanced/src/components/virtual-waterfall/vue/waterfall.ts new file mode 100644 index 000000000000..2b189721c3f0 --- /dev/null +++ b/packages/taro-components-advanced/src/components/virtual-waterfall/vue/waterfall.ts @@ -0,0 +1,80 @@ +import { isWebPlatform } from '@tarojs/shared' +import { defineComponent } from 'vue' + +const isWeb = isWebPlatform() + +export default defineComponent({ + props: { + height: { + type: [String, Number], + required: true + }, + width: { + type: [String, Number], + required: true + }, + column: Number, + columnWidth: Number, + item: { + required: true + }, + itemData: { + type: Array, + required: true + }, + itemKey: String, + itemSize: { + type: [Number, Function], + required: true + }, + position: { + type: String, + default: 'absolute' + }, + initialScrollOffset: { + type: Number, + default: 0 + }, + overscanDistance: { + type: Number, + default: 50 + }, + placeholderCount: { + type: Number, + default: 0 + }, + useIsScrolling: { + type: Boolean, + default: false + }, + enhanced: { + type: Boolean, + default: false + }, + shouldResetStyleCacheOnItemSizeChange: { + type: Boolean, + default: true + }, + outerElementType: { + type: String, + default: isWeb ? 'taro-scroll-view-core' : 'scroll-view' + }, + innerElementType: { + type: String, + default: isWeb ? 'taro-view-core' : 'view' + }, + itemElementType: { + type: String, + default: isWeb ? 'taro-view-core' : 'view' + }, + outerTagName: String, + innerTagName: String, + itemTagName: String, + outerRef: String, + onScrollNative: Function, + onItemsRendered: Function, + }, + render () { + console.warn('virtual-waterfall is not supported in vue.') + } +}) diff --git a/packages/taro-components-advanced/src/utils/convert.ts b/packages/taro-components-advanced/src/utils/convert.ts index 4239d4fb2b2e..77e11c7a7265 100644 --- a/packages/taro-components-advanced/src/utils/convert.ts +++ b/packages/taro-components-advanced/src/utils/convert.ts @@ -11,7 +11,7 @@ export function convertPX2Int (distance: string | number) { return distance } -export function convertNumber2PX (styleValue: unknown) { +export function convertNumber2PX (styleValue: unknown): string { if (!styleValue && styleValue !== 0) return '' - return typeof styleValue === 'number' ? styleValue + 'px' : styleValue + return typeof styleValue === 'number' ? styleValue + 'px' : styleValue as string } diff --git a/packages/taro-components-advanced/src/utils/dom.ts b/packages/taro-components-advanced/src/utils/dom.ts new file mode 100644 index 000000000000..1b79c8a16cae --- /dev/null +++ b/packages/taro-components-advanced/src/utils/dom.ts @@ -0,0 +1,40 @@ +import { createSelectorQuery } from '@tarojs/taro' + +export function getRectSize (id: string, success?: TFunc, fail?: TFunc, retryMs = 500) { + const query = createSelectorQuery() + try { + query.select(id).boundingClientRect((res) => { + if (res) { + success?.(res) + } else { + fail?.() + } + }).exec() + } catch (err) { + setTimeout(() => { + getRectSize(id, success, fail, retryMs) + }, retryMs) + } +} + +export function getRectSizeSync (id: string, retryMs = 500) { + return new Promise<{ width?: number, height?: number }>((resolve) => { + function retry () { + setTimeout(async () => { + try { + const res = await getRectSizeSync(id, retryMs) + resolve(res) + } catch (err) { + retry() + } + }, retryMs) + } + getRectSize(id, resolve, retry, retryMs) + }) +} + +export async function getScrollViewContextNode (id: string) { + const query = createSelectorQuery() + return new Promise((resolve) => query.select(id).node(({ node }) => resolve(node)).exec()) +} + diff --git a/packages/taro-components-advanced/src/utils/helper.ts b/packages/taro-components-advanced/src/utils/helper.ts new file mode 100644 index 000000000000..5f7f2ee570d4 --- /dev/null +++ b/packages/taro-components-advanced/src/utils/helper.ts @@ -0,0 +1,57 @@ +// In DEV mode, this Set helps us only log a warning once per component instance. +// This avoids spamming the console every time a render happens. +export const defaultItemKey = (index: number, _itemData?: unknown) => index + +export function getOffsetForIndexAndAlignment ({ + align = 'auto', + containerSize = 0, + currentOffset = 0, + scrollSize = 0, + slideSize = 0, + targetOffset = 0, +}) { + const lastItemOffset = Math.max(0, scrollSize - containerSize) + const maxOffset = Math.min(lastItemOffset, targetOffset) + const minOffset = Math.max(0, targetOffset - containerSize + slideSize) + + if (align === 'smart') { + if (currentOffset >= minOffset - containerSize && currentOffset <= maxOffset + containerSize) { + align = 'auto' + } else { + align = 'center' + } + } + + switch (align) { + case 'start': + return maxOffset + + case 'end': + return minOffset + + case 'center': + { + // "Centered" offset is usually the average of the min and max. + // But near the edges of the list, this doesn't hold true. + const middleOffset = Math.round(minOffset + (maxOffset - minOffset) / 2) + + if (middleOffset < Math.ceil(containerSize / 2)) { + return 0 // near the beginning + } else if (middleOffset > lastItemOffset + Math.floor(containerSize / 2)) { + return lastItemOffset // near the end + } else { + return middleOffset + } + } + + case 'auto': + default: + if (currentOffset >= minOffset && currentOffset <= maxOffset) { + return currentOffset + } else if (currentOffset < minOffset) { + return minOffset + } else { + return maxOffset + } + } +} diff --git a/packages/taro-components-advanced/src/utils/index.ts b/packages/taro-components-advanced/src/utils/index.ts index 68d744113303..60c3cacd6694 100644 --- a/packages/taro-components-advanced/src/utils/index.ts +++ b/packages/taro-components-advanced/src/utils/index.ts @@ -1,4 +1,6 @@ export * from './convert' +export * from './dom' +export * from './helper' export * from './lodash' export * from './math' export * from './timer' diff --git a/packages/taro-components-advanced/src/components/virtual-list/vue/render.ts b/packages/taro-components-advanced/src/utils/vue-render.ts similarity index 100% rename from packages/taro-components-advanced/src/components/virtual-list/vue/render.ts rename to packages/taro-components-advanced/src/utils/vue-render.ts diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 31bc6d3393a1..754783d96269 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -707,6 +707,7 @@ importers: '@tarojs/taro': workspace:* '@types/node': ^14.14.31 babel-preset-taro: workspace:* + classnames: ^2.2.5 memoize-one: ^6.0.0 postcss: ^8.4.18 react: ^18.2.0 @@ -722,6 +723,7 @@ importers: '@tarojs/runtime': link:../taro-runtime '@tarojs/shared': link:../shared '@tarojs/taro': link:../taro + classnames: registry.npmjs.org/classnames/2.3.2 memoize-one: registry.npmjs.org/memoize-one/6.0.0 postcss: registry.npmjs.org/postcss/8.4.23 devDependencies: