From 0d34f45895e401193ec8453b5b6c3b8226273a24 Mon Sep 17 00:00:00 2001 From: Amour1688 Date: Sat, 26 Sep 2020 22:52:40 +0800 Subject: [PATCH 1/7] feat: virtual list --- antdv-demo | 2 +- components/vc-virtual-list/Filler.jsx | 38 ++ components/vc-virtual-list/List.jsx | 576 ++++++++++++++++++ components/vc-virtual-list/index.js | 3 + .../vc-virtual-list/utils/algorithmUtil.js | 81 +++ components/vc-virtual-list/utils/itemUtil.js | 147 +++++ 6 files changed, 846 insertions(+), 1 deletion(-) create mode 100644 components/vc-virtual-list/Filler.jsx create mode 100644 components/vc-virtual-list/List.jsx create mode 100644 components/vc-virtual-list/index.js create mode 100644 components/vc-virtual-list/utils/algorithmUtil.js create mode 100644 components/vc-virtual-list/utils/itemUtil.js diff --git a/antdv-demo b/antdv-demo index 05da262e3..83ab203d1 160000 --- a/antdv-demo +++ b/antdv-demo @@ -1 +1 @@ -Subproject commit 05da262e31f9c6cc524154df13f5e2b05c20c1c8 +Subproject commit 83ab203d1ab9861132f6efd1e74015507c0e45f6 diff --git a/components/vc-virtual-list/Filler.jsx b/components/vc-virtual-list/Filler.jsx new file mode 100644 index 000000000..0704c70b6 --- /dev/null +++ b/components/vc-virtual-list/Filler.jsx @@ -0,0 +1,38 @@ +import classNames from '../_util/classNames'; + +const Filter = ({ height, offset, prefixCls }, { slots }) => { + let outerStyle = {}; + + let innerStyle = { + display: 'flex', + flexDirection: 'column', + }; + + if (offset !== undefined) { + outerStyle = { height, position: 'relative', overflow: 'hidden' }; + + innerStyle = { + ...innerStyle, + transform: `translateY(${offset}px)`, + position: 'absolute', + left: 0, + right: 0, + top: 0, + }; + } + + return ( +
+
+ {slots.default?.()} +
+
+ ); +}; + +export default Filter; diff --git a/components/vc-virtual-list/List.jsx b/components/vc-virtual-list/List.jsx new file mode 100644 index 000000000..1d141c657 --- /dev/null +++ b/components/vc-virtual-list/List.jsx @@ -0,0 +1,576 @@ +import { getOptionProps, getStyle } from '../_util/props-util'; +import PropTypes from '../_util/vue-types'; +import { cloneElement } from '../_util/vnode'; +import BaseMixin from '../_util/BaseMixin'; +import Filler from './Filler'; +import { + getNodeHeight, + requireVirtual, + getElementScrollPercentage, + getRangeIndex, + alignScrollTop, + getItemAbsoluteTop, + getItemRelativeTop, + getScrollPercentage, + getCompareItemRelativeTop, + GHOST_ITEM_KEY, +} from './utils/itemUtil'; +import { getIndexByStartLoc, findListDiffIndex } from './utils/algorithmUtil'; + +const ITEM_SCALE_RATE = 1; + +const ScrollStyle = { + overflowY: 'auto', + overflowAnchor: 'none', +}; + +/** + * + * Virtual list display logic: + * 1. scroll / initialize trigger measure + * 2. Get location item of current `scrollTop` + * 3. [Render] Render visible items + * 4. Get all the visible items height + * 5. [Render] Update top item `margin-top` to fit the position + * + * Algorithm: + * We split scroll bar into equal slice. An item with whatever height occupy the same range slice. + * When `scrollTop` change, + * it will calculate the item percentage position and move item to the position. + * Then calculate other item position base on the located item. + * + * Concept: + * + * # located item + * The base position item which other items position calculate base on. + */ + +export default { + name: 'List', + mixins: [BaseMixin], + props: { + prefixCls: PropTypes.string, + children: PropTypes.func, + data: PropTypes.array, + height: PropTypes.number, + itemHeight: PropTypes.number, + /** If not match virtual scroll condition, Set List still use height of container. */ + fullHeight: PropTypes.bool, + itemKey: PropTypes.any, + component: PropTypes.string, + /** Disable scroll check. Usually used on animation control */ + disabled: PropTypes.bool, + /** Set `false` will always use real scroll instead of virtual one */ + virtual: PropTypes.bool, + /** When `disabled`, trigger if changed item not render. */ + onSkipRender: PropTypes.func, + }, + data() { + const props = getOptionProps(this); + const { height, itemHeight, data, virtual } = props; + this.cachedProps = props; + this.itemElements = {}; + this.itemElementHeights = {}; + return { + status: 'NONE', + scrollTop: null, + itemIndex: 0, + itemOffsetPtg: 0, + startIndex: 0, + endIndex: 0, + startItemTop: 0, + isVirtual: requireVirtual(height, itemHeight, data.length, virtual), + itemCount: data.length, + ...this.getDerivedStateFromProps(props), + }; + }, + watch: { + disabled(val) { + if (!val) { + const props = getOptionProps(this); + this.itemCount = props.data.length; + } + }, + }, + /** + * Phase 1: Initial should sync with default scroll top + */ + mounted() { + if (this.$refs.list) { + this.$refs.list.scrollTop = 0; + this.onScroll(null); + } + }, + /** + * Phase 4: Record used item height + * Phase 5: Trigger re-render to use correct position + */ + updated() { + this.$nextTick(() => { + const { status } = this.$data; + const { data, height, itemHeight, disabled, onSkipRender, virtual } = getOptionProps(this); + const prevData = this.cachedProps.data || []; + + let changedItemIndex = null; + if (prevData.length !== data.length) { + const diff = findListDiffIndex(prevData, data, this.getItemKey); + changedItemIndex = diff ? diff.index : null; + } + + if (disabled) { + // Should trigger `onSkipRender` to tell that diff component is not render in the list + if (data.length > prevData.length) { + const { startIndex, endIndex } = this.$data; + if ( + onSkipRender && + (changedItemIndex === null || + changedItemIndex < startIndex || + endIndex < changedItemIndex) + ) { + onSkipRender(); + } + } + return; + } + + const isVirtual = requireVirtual(height, itemHeight, data.length, virtual); + let nextStatus = status; + if (this.$data.isVirtual !== isVirtual) { + nextStatus = isVirtual ? 'SWITCH_TO_VIRTUAL' : 'SWITCH_TO_RAW'; + this.setState({ + isVirtual, + status: nextStatus, + }); + + /** + * We will wait a tick to let list turn to virtual list. + * And then use virtual list sync logic to adjust the scroll. + */ + if (nextStatus === 'SWITCH_TO_VIRTUAL') { + return; + } + } + + if (status === 'MEASURE_START') { + const { startIndex, itemIndex, itemOffsetPtg } = this.$data; + const { scrollTop } = this.$refs.list; + + // Record here since measure item height will get warning in `render` + this.collectItemHeights(); + + // Calculate top visible item top offset + const locatedItemTop = getItemAbsoluteTop({ + itemIndex, + itemOffsetPtg, + itemElementHeights: this.itemElementHeights, + scrollTop, + scrollPtg: getElementScrollPercentage(this.$refs.list), + clientHeight: this.$refs.list.clientHeight, + getItemKey: this.getIndexKey, + }); + + let startItemTop = locatedItemTop; + for (let index = itemIndex - 1; index >= startIndex; index -= 1) { + startItemTop -= this.itemElementHeights[this.getIndexKey(index)] || 0; + } + + this.setState({ + status: 'MEASURE_DONE', + startItemTop, + }); + } + + if (status === 'SWITCH_TO_RAW') { + /** + * After virtual list back to raw list, + * we update the `scrollTop` to real top instead of percentage top. + */ + const { + cacheScroll: { itemIndex, relativeTop }, + } = this.$data; + let rawTop = relativeTop; + for (let index = 0; index < itemIndex; index += 1) { + rawTop -= this.itemElementHeights[this.getIndexKey(index)] || 0; + } + + this.lockScroll = true; + this.$refs.list.current.scrollTop = -rawTop; + + this.setState({ + status: 'MEASURE_DONE', + itemIndex: 0, + }); + + requestAnimationFrame(() => { + requestAnimationFrame(() => { + this.lockScroll = false; + }); + }); + } else if (prevData.length !== data.length && changedItemIndex !== null && height) { + /** + * Re-calculate the item position since `data` length changed. + * [IMPORTANT] We use relative position calculate here. + */ + let { itemIndex: originItemIndex } = this.$data; + const { + itemOffsetPtg: originItemOffsetPtg, + startIndex: originStartIndex, + endIndex: originEndIndex, + scrollTop: originScrollTop, + } = this.$data; + + // 1. Refresh item heights + this.collectItemHeights(); + + // 1. Get origin located item top + let originLocatedItemRelativeTop; + + if (this.$data.status === 'SWITCH_TO_VIRTUAL') { + originItemIndex = 0; + originLocatedItemRelativeTop = -this.$data.scrollTop; + } else { + originLocatedItemRelativeTop = getItemRelativeTop({ + itemIndex: originItemIndex, + itemOffsetPtg: originItemOffsetPtg, + itemElementHeights: this.itemElementHeights, + scrollPtg: getScrollPercentage({ + scrollTop: originScrollTop, + scrollHeight: prevData.length * itemHeight, + clientHeight: this.$refs.list.current.clientHeight, + }), + clientHeight: this.$refs.list.current.clientHeight, + getItemKey: index => this.getIndexKey(index, this.cachedProps), + }); + } + + // 2. Find the compare item + let originCompareItemIndex = changedItemIndex - 1; + // Use next one since there are not more item before removed + if (originCompareItemIndex < 0) { + originCompareItemIndex = 0; + } + + // 3. Find the compare item top + const originCompareItemTop = getCompareItemRelativeTop({ + locatedItemRelativeTop: originLocatedItemRelativeTop, + locatedItemIndex: originItemIndex, + compareItemIndex: originCompareItemIndex, + startIndex: originStartIndex, + endIndex: originEndIndex, + getItemKey: index => this.getIndexKey(index, this.cachedProps), + itemElementHeights: this.itemElementHeights, + }); + + if (nextStatus === 'SWITCH_TO_RAW') { + /** + * We will record current measure relative item top and apply in raw list after list turned + */ + this.setState({ + cacheScroll: { + itemIndex: originCompareItemIndex, + relativeTop: originCompareItemTop, + }, + }); + } else { + this.internalScrollTo({ + itemIndex: originCompareItemIndex, + relativeTop: originCompareItemTop, + }); + } + } else if (nextStatus === 'SWITCH_TO_RAW') { + // This is only trigger when height changes that all items can show in raw + // Let's reset back to top + this.setState({ + cacheScroll: { + itemIndex: 0, + relativeTop: 0, + }, + }); + } + + this.cachedProps = getOptionProps(this); + }); + }, + methods: { + getDerivedStateFromProps(nextProps) { + if (!nextProps.disabled) { + return { + itemCount: nextProps.data.length, + }; + } + + return null; + }, + /** + * Phase 2: Trigger render since we should re-calculate current position. + */ + onScroll(e) { + const { data, height, itemHeight, disabled } = this.$props; + + const { scrollTop: originScrollTop, clientHeight, scrollHeight } = this.$refs.list; + const scrollTop = alignScrollTop(originScrollTop, scrollHeight - clientHeight); + + // Skip if `scrollTop` not change to avoid shake + if (scrollTop === this.$data.scrollTop || this.lockScroll || disabled) { + return; + } + + const scrollPtg = getElementScrollPercentage(this.$refs.list); + const visibleCount = Math.ceil(height / itemHeight); + + const { itemIndex, itemOffsetPtg, startIndex, endIndex } = getRangeIndex( + scrollPtg, + data.length, + visibleCount, + ); + + this.setState({ + status: 'MEASURE_START', + scrollTop, + itemIndex, + itemOffsetPtg, + startIndex, + endIndex, + }); + + this.triggerOnScroll(e); + }, + onRawScroll(e) { + const { scrollTop } = this.$refs.list; + this.setState({ scrollTop }); + this.triggerOnScroll(e); + }, + triggerOnScroll(e) { + if (e) { + this.$emit('scroll', e); + } + }, + /** + * Phase 4: Render item and get all the visible items height + */ + renderChildren(list, startIndex, renderFunc) { + const { status } = this.$data; + // We should measure rendered item height + return list.map((item, index) => { + const eleIndex = startIndex + index; + const node = renderFunc(item, eleIndex, { + style: status === 'MEASURE_START' ? { visibility: 'hidden' } : {}, + }); + const eleKey = this.getIndexKey(eleIndex); + + // Pass `key` and `ref` for internal measure + return cloneElement(node, { + key: eleKey, + ref: eleKey, + }); + }); + }, + getIndexKey(index, props) { + const mergedProps = props || getOptionProps(this); + const { data = [] } = mergedProps; + + // Return ghost key as latest index item + if (index === data.length) { + return GHOST_ITEM_KEY; + } + + const item = data[index]; + if (!item) { + /* istanbul ignore next */ + console.error('Not find index item. Please report this since it is a bug.'); + } + + return this.getItemKey(item, mergedProps); + }, + getItemKey(item, props) { + const { itemKey } = props || getOptionProps(this); + + return typeof itemKey === 'function' ? itemKey(item) : item[itemKey]; + }, + /** + * Collect current rendered dom element item heights + */ + collectItemHeights(range) { + const { startIndex, endIndex } = range || this.$data; + const { data } = getOptionProps(this); + + // Record here since measure item height will get warning in `render` + for (let index = startIndex; index <= endIndex; index += 1) { + const item = data[index]; + + // Only collect exist item height + if (item) { + const eleKey = this.getItemKey(item); + this.itemElementHeights[eleKey] = getNodeHeight(this.refs[`itemElement-${eleKey}`]); + } + } + }, + internalScrollTo(relativeScroll) { + const { itemIndex: compareItemIndex, relativeTop: compareItemRelativeTop } = relativeScroll; + const { scrollTop: originScrollTop } = this.$data; + const { data, itemHeight, height } = getOptionProps(this); + + // 1. Find the best match compare item top + let bestSimilarity = Number.MAX_VALUE; + let bestScrollTop = null; + let bestItemIndex = null; + let bestItemOffsetPtg = null; + let bestStartIndex = null; + let bestEndIndex = null; + + let missSimilarity = 0; + + const scrollHeight = data.length * itemHeight; + const { clientHeight } = this.$refs.list; + const maxScrollTop = scrollHeight - clientHeight; + + for (let i = 0; i < maxScrollTop; i += 1) { + const scrollTop = getIndexByStartLoc(0, maxScrollTop, originScrollTop, i); + + const scrollPtg = getScrollPercentage({ scrollTop, scrollHeight, clientHeight }); + const visibleCount = Math.ceil(height / itemHeight); + + const { itemIndex, itemOffsetPtg, startIndex, endIndex } = getRangeIndex( + scrollPtg, + data.length, + visibleCount, + ); + + // No need to check if compare item out of the index to save performance + if (startIndex <= compareItemIndex && compareItemIndex <= endIndex) { + // 1.1 Get measure located item relative top + const locatedItemRelativeTop = getItemRelativeTop({ + itemIndex, + itemOffsetPtg, + itemElementHeights: this.itemElementHeights, + scrollPtg, + clientHeight, + getItemKey: this.getIndexKey, + }); + + const compareItemTop = getCompareItemRelativeTop({ + locatedItemRelativeTop, + locatedItemIndex: itemIndex, + compareItemIndex, // Same as origin index + startIndex, + endIndex, + getItemKey: this.getIndexKey, + itemElementHeights: this.itemElementHeights, + }); + + // 1.2 Find best match compare item top + const similarity = Math.abs(compareItemTop - compareItemRelativeTop); + if (similarity < bestSimilarity) { + bestSimilarity = similarity; + bestScrollTop = scrollTop; + bestItemIndex = itemIndex; + bestItemOffsetPtg = itemOffsetPtg; + bestStartIndex = startIndex; + bestEndIndex = endIndex; + + missSimilarity = 0; + } else { + missSimilarity += 1; + } + } + + // If keeping 10 times not match similarity, + // check more scrollTop is meaningless. + // Here boundary is set to 10. + if (missSimilarity > 10) { + break; + } + } + + // 2. Re-scroll if has best scroll match + if (bestScrollTop !== null) { + this.lockScroll = true; + this.$refs.list.current.scrollTop = bestScrollTop; + + this.setState({ + status: 'MEASURE_START', + scrollTop: bestScrollTop, + itemIndex: bestItemIndex, + itemOffsetPtg: bestItemOffsetPtg, + startIndex: bestStartIndex, + endIndex: bestEndIndex, + }); + + requestAnimationFrame(() => { + requestAnimationFrame(() => { + this.lockScroll = false; + }); + }); + } + }, + }, + render() { + const { isVirtual, itemCount } = this.$data; + const { + prefixCls, + height, + itemHeight, + fullHeight = true, + component: Component = 'div', + data, + children, + itemKey, + onSkipRender, + disabled, + virtual, + ...restProps + } = getOptionProps(this); + const style = getStyle(this); + + if (!isVirtual) { + /** + * Virtual list switch is works on component updated. + * We should double check here if need cut the content. + */ + const shouldVirtual = requireVirtual(height, itemHeight, data.length, virtual); + + return ( + + + {this.renderChildren( + shouldVirtual ? data.slice(0, Math.ceil(height / itemHeight)) : data, + 0, + children, + )} + + + ); + } + + // Use virtual list + const mergedStyle = { + ...style, + height, + ...ScrollStyle, + }; + + const { status, startIndex, endIndex, startItemTop } = this.$data; + const contentHeight = itemCount * itemHeight * ITEM_SCALE_RATE; + + return ( + + + {this.renderChildren(data.slice(startIndex, endIndex + 1), startIndex, children)} + + + ); + }, +}; diff --git a/components/vc-virtual-list/index.js b/components/vc-virtual-list/index.js new file mode 100644 index 000000000..1e2ddb439 --- /dev/null +++ b/components/vc-virtual-list/index.js @@ -0,0 +1,3 @@ +import List from './List'; + +export default List; diff --git a/components/vc-virtual-list/utils/algorithmUtil.js b/components/vc-virtual-list/utils/algorithmUtil.js new file mode 100644 index 000000000..faf7b3163 --- /dev/null +++ b/components/vc-virtual-list/utils/algorithmUtil.js @@ -0,0 +1,81 @@ +/** + * Get index with specific start index one by one. e.g. + * min: 3, max: 9, start: 6 + * + * Return index is: + * [0]: 6 + * [1]: 7 + * [2]: 5 + * [3]: 8 + * [4]: 4 + * [5]: 9 + * [6]: 3 + */ +export function getIndexByStartLoc(min, max, start, index) { + const beforeCount = start - min; + const afterCount = max - start; + const balanceCount = Math.min(beforeCount, afterCount) * 2; + + // Balance + if (index <= balanceCount) { + const stepIndex = Math.floor(index / 2); + if (index % 2) { + return start + stepIndex + 1; + } + return start - stepIndex; + } + + // One is out of range + if (beforeCount > afterCount) { + return start - (index - afterCount); + } + return start + (index - beforeCount); +} + +/** + * We assume that 2 list has only 1 item diff and others keeping the order. + * So we can use dichotomy algorithm to find changed one. + */ +export function findListDiffIndex(originList, targetList, getKey) { + const originLen = originList.length; + const targetLen = targetList.length; + + let shortList; + let longList; + + if (originLen === 0 && targetLen === 0) { + return null; + } + + if (originLen < targetLen) { + shortList = originList; + longList = targetList; + } else { + shortList = targetList; + longList = originList; + } + + const notExistKey = { __EMPTY_ITEM__: true }; + function getItemKey(item) { + if (item !== undefined) { + return getKey(item); + } + return notExistKey; + } + + // Loop to find diff one + let diffIndex = null; + let multiple = Math.abs(originLen - targetLen) !== 1; + for (let i = 0; i < longList.length; i += 1) { + const shortKey = getItemKey(shortList[i]); + const longKey = getItemKey(longList[i]); + + if (shortKey !== longKey) { + diffIndex = i; + multiple = multiple || shortKey !== getItemKey(longList[i + 1]); + break; + } + } + + return diffIndex === null ? null : { index: diffIndex, multiple }; +} diff --git a/components/vc-virtual-list/utils/itemUtil.js b/components/vc-virtual-list/utils/itemUtil.js new file mode 100644 index 000000000..e432018ee --- /dev/null +++ b/components/vc-virtual-list/utils/itemUtil.js @@ -0,0 +1,147 @@ +/** + * Our algorithm have additional one ghost item + * whose index as `data.length` to simplify the calculation + */ +export const GHOST_ITEM_KEY = '__vc_ghost_item__'; + +/** + * Safari has the elasticity effect which provides negative `scrollTop` value. + * We should ignore it since will make scroll animation shake. + */ +export function alignScrollTop(scrollTop, scrollRange) { + if (scrollTop < 0) { + return 0; + } + if (scrollTop >= scrollRange) { + return scrollRange; + } + + return scrollTop; +} + +/** + * Get node `offsetHeight`. We prefer node is a dom element directly. + * But if not provided, downgrade to `findDOMNode` to get the real dom element. + */ +export function getNodeHeight(node) { + return node ? node.offsetHeight : 0; +} + +/** + * Calculate the located item absolute top with whole scroll height + */ +export function getItemAbsoluteTop({ scrollTop, ...rest }) { + return scrollTop + getItemRelativeTop(rest); +} + +/** + * Calculate the located item related top with current window height + */ +export function getItemRelativeTop({ + itemIndex, + itemOffsetPtg, + itemElementHeights, + scrollPtg, + clientHeight, + getItemKey, +}) { + const locatedItemHeight = itemElementHeights[getItemKey(itemIndex)] || 0; + const locatedItemTop = scrollPtg * clientHeight; + const locatedItemOffset = itemOffsetPtg * locatedItemHeight; + return Math.floor(locatedItemTop - locatedItemOffset); +} + +export function getCompareItemRelativeTop({ + locatedItemRelativeTop, + locatedItemIndex, + compareItemIndex, + startIndex, + endIndex, + getItemKey, + itemElementHeights, +}) { + let originCompareItemTop = locatedItemRelativeTop; + const compareItemKey = getItemKey(compareItemIndex); + + if (compareItemIndex <= locatedItemIndex) { + for (let index = locatedItemIndex; index >= startIndex; index -= 1) { + const key = getItemKey(index); + if (key === compareItemKey) { + break; + } + + const prevItemKey = getItemKey(index - 1); + originCompareItemTop -= itemElementHeights[prevItemKey] || 0; + } + } else { + for (let index = locatedItemIndex; index <= endIndex; index += 1) { + const key = getItemKey(index); + if (key === compareItemKey) { + break; + } + + originCompareItemTop += itemElementHeights[key] || 0; + } + } + + return originCompareItemTop; +} + +export function getScrollPercentage({ scrollTop, scrollHeight, clientHeight }) { + if (scrollHeight <= clientHeight) { + return 0; + } + + const scrollRange = scrollHeight - clientHeight; + const alignedScrollTop = alignScrollTop(scrollTop, scrollRange); + const scrollTopPtg = alignedScrollTop / scrollRange; + return scrollTopPtg; +} + +export function getElementScrollPercentage(element) { + if (!element) { + return 0; + } + + return getScrollPercentage(element); +} + +/** + * Get location item and its align percentage with the scroll percentage. + * We should measure current scroll position to decide which item is the location item. + * And then fill the top count and bottom count with the base of location item. + * + * `total` should be the real count instead of `total - 1` in calculation. + */ +function getLocationItem(scrollPtg, total) { + const itemIndex = Math.floor(scrollPtg * total); + const itemTopPtg = itemIndex / total; + const itemBottomPtg = (itemIndex + 1) / total; + const itemOffsetPtg = (scrollPtg - itemTopPtg) / (itemBottomPtg - itemTopPtg); + + return { + index: itemIndex, + offsetPtg: itemOffsetPtg, + }; +} + +/** + * Get display items start, end, located item index. This is pure math calculation + */ +export function getRangeIndex(scrollPtg, itemCount, visibleCount) { + const { index, offsetPtg } = getLocationItem(scrollPtg, itemCount); + + const beforeCount = Math.ceil(scrollPtg * visibleCount); + const afterCount = Math.ceil((1 - scrollPtg) * visibleCount); + + return { + itemIndex: index, + itemOffsetPtg: offsetPtg, + startIndex: Math.max(0, index - beforeCount), + endIndex: Math.min(itemCount - 1, index + afterCount), + }; +} + +export function requireVirtual(height, itemHeight, count, virtual) { + return virtual !== false && typeof height === 'number' && count * itemHeight > height; +} From ab80874fa59736505c0cce9894c9e8518fdb94db Mon Sep 17 00:00:00 2001 From: tanjinzhou <415800467@qq.com> Date: Sun, 27 Sep 2020 16:05:53 +0800 Subject: [PATCH 2/7] feat: update virtuallist --- antdv-demo | 2 +- components/vc-virtual-list/utils/CacheMap.js | 19 +++++++++++++++++++ components/vc-virtual-list/utils/isFirefox.js | 3 +++ 3 files changed, 23 insertions(+), 1 deletion(-) create mode 100644 components/vc-virtual-list/utils/CacheMap.js create mode 100644 components/vc-virtual-list/utils/isFirefox.js diff --git a/antdv-demo b/antdv-demo index 83ab203d1..955716e4e 160000 --- a/antdv-demo +++ b/antdv-demo @@ -1 +1 @@ -Subproject commit 83ab203d1ab9861132f6efd1e74015507c0e45f6 +Subproject commit 955716e4e9533bc628c651d6ba6c8d1eb9b21a9d diff --git a/components/vc-virtual-list/utils/CacheMap.js b/components/vc-virtual-list/utils/CacheMap.js new file mode 100644 index 000000000..ffca328f7 --- /dev/null +++ b/components/vc-virtual-list/utils/CacheMap.js @@ -0,0 +1,19 @@ +// Firefox has low performance of map. +class CacheMap { + maps; + + constructor() { + this.maps = {}; + this.maps.prototype = null; + } + + set(key, value) { + this.maps[key] = value; + } + + get(key) { + return this.maps[key]; + } +} + +export default CacheMap; diff --git a/components/vc-virtual-list/utils/isFirefox.js b/components/vc-virtual-list/utils/isFirefox.js new file mode 100644 index 000000000..871180a78 --- /dev/null +++ b/components/vc-virtual-list/utils/isFirefox.js @@ -0,0 +1,3 @@ +const isFF = typeof navigator === 'object' && /Firefox/i.test(navigator.userAgent); + +export default isFF; From 3844466ff2ef6fc6b90a26c7c25dbb81a7fe1888 Mon Sep 17 00:00:00 2001 From: tanjinzhou <415800467@qq.com> Date: Mon, 28 Sep 2020 19:14:00 +0800 Subject: [PATCH 3/7] feat: add virtual-list --- components/_util/createRef.js | 8 + components/_util/vue-types/index.js | 6 +- components/vc-virtual-list/Filler.jsx | 37 +- components/vc-virtual-list/Item.jsx | 17 + components/vc-virtual-list/List.jsx | 883 +++++++----------- components/vc-virtual-list/ScrollBar.jsx | 231 +++++ .../vc-virtual-list/examples/animate.less | 31 + .../vc-virtual-list/examples/animate.tsx | 214 +++++ components/vc-virtual-list/examples/basic.jsx | 216 +++++ .../vc-virtual-list/examples/basic.less | 8 + .../vc-virtual-list/examples/height.tsx | 60 ++ .../vc-virtual-list/examples/no-virtual.tsx | 86 ++ .../vc-virtual-list/examples/switch.tsx | 106 +++ .../vc-virtual-list/hooks/useChildren.jsx | 23 + .../vc-virtual-list/hooks/useDiffItem.js | 18 + .../vc-virtual-list/hooks/useFrameWheel.js | 50 + .../vc-virtual-list/hooks/useHeights.jsx | 56 ++ .../hooks/useMobileTouchMove.js | 74 ++ .../vc-virtual-list/hooks/useOriginScroll.js | 42 + .../vc-virtual-list/hooks/useScrollTo.jsx | 102 ++ examples/App.vue | 2 +- 21 files changed, 1713 insertions(+), 557 deletions(-) create mode 100644 components/_util/createRef.js create mode 100644 components/vc-virtual-list/Item.jsx create mode 100644 components/vc-virtual-list/ScrollBar.jsx create mode 100644 components/vc-virtual-list/examples/animate.less create mode 100644 components/vc-virtual-list/examples/animate.tsx create mode 100644 components/vc-virtual-list/examples/basic.jsx create mode 100644 components/vc-virtual-list/examples/basic.less create mode 100644 components/vc-virtual-list/examples/height.tsx create mode 100644 components/vc-virtual-list/examples/no-virtual.tsx create mode 100644 components/vc-virtual-list/examples/switch.tsx create mode 100644 components/vc-virtual-list/hooks/useChildren.jsx create mode 100644 components/vc-virtual-list/hooks/useDiffItem.js create mode 100644 components/vc-virtual-list/hooks/useFrameWheel.js create mode 100644 components/vc-virtual-list/hooks/useHeights.jsx create mode 100644 components/vc-virtual-list/hooks/useMobileTouchMove.js create mode 100644 components/vc-virtual-list/hooks/useOriginScroll.js create mode 100644 components/vc-virtual-list/hooks/useScrollTo.jsx diff --git a/components/_util/createRef.js b/components/_util/createRef.js new file mode 100644 index 000000000..9e98baf1a --- /dev/null +++ b/components/_util/createRef.js @@ -0,0 +1,8 @@ +function createRef() { + const func = function setRef(node) { + func.current = node; + }; + return func; +} + +export default createRef; diff --git a/components/_util/vue-types/index.js b/components/_util/vue-types/index.js index ec46f5087..e15acd2fd 100644 --- a/components/_util/vue-types/index.js +++ b/components/_util/vue-types/index.js @@ -1,7 +1,7 @@ import isPlainObject from 'lodash-es/isPlainObject'; import { toType, getType, isFunction, validateType, isInteger, isArray, warn } from './utils'; -const VuePropTypes = { +const PropTypes = { get any() { return toType('any', { type: null, @@ -244,7 +244,7 @@ const typeDefaults = () => ({ let currentDefaults = typeDefaults(); -Object.defineProperty(VuePropTypes, 'sensibleDefaults', { +Object.defineProperty(PropTypes, 'sensibleDefaults', { enumerable: false, set(value) { if (value === false) { @@ -260,4 +260,4 @@ Object.defineProperty(VuePropTypes, 'sensibleDefaults', { }, }); -export default VuePropTypes; +export default PropTypes; diff --git a/components/vc-virtual-list/Filler.jsx b/components/vc-virtual-list/Filler.jsx index 0704c70b6..ad1ba70d3 100644 --- a/components/vc-virtual-list/Filler.jsx +++ b/components/vc-virtual-list/Filler.jsx @@ -1,6 +1,7 @@ import classNames from '../_util/classNames'; +import ResizeObserver from '../vc-resize-observer'; -const Filter = ({ height, offset, prefixCls }, { slots }) => { +const Filter = ({ height, offset, prefixCls, onInnerResize }, { slots }) => { let outerStyle = {}; let innerStyle = { @@ -9,7 +10,7 @@ const Filter = ({ height, offset, prefixCls }, { slots }) => { }; if (offset !== undefined) { - outerStyle = { height, position: 'relative', overflow: 'hidden' }; + outerStyle = { height: `${height}px`, position: 'relative', overflow: 'hidden' }; innerStyle = { ...innerStyle, @@ -23,16 +24,34 @@ const Filter = ({ height, offset, prefixCls }, { slots }) => { return (
-
{ + if (offsetHeight && onInnerResize) { + onInnerResize(); + } + }} > - {slots.default?.()} -
+
+ {slots.default?.()} +
+
); }; +Filter.displayName = 'Filter'; +Filter.inheritAttrs = false; +Filter.props = { + prefixCls: String, + /** Virtual filler height. Should be `count * itemMinHeight` */ + height: Number, + /** Set offset of visible items. Should be the top of start item position */ + offset: Number, + onInnerResize: Function, +}; export default Filter; diff --git a/components/vc-virtual-list/Item.jsx b/components/vc-virtual-list/Item.jsx new file mode 100644 index 000000000..c36c23b0a --- /dev/null +++ b/components/vc-virtual-list/Item.jsx @@ -0,0 +1,17 @@ +import { cloneVNode } from 'vue'; + +function Item({ setRef }, { slots }) { + const children = slots?.default(); + return children && children.length + ? cloneVNode(children[0], { + ref: setRef, + }) + : children; +} +Item.props = { + setRef: { + type: Function, + default: () => {}, + }, +}; +export default Item; diff --git a/components/vc-virtual-list/List.jsx b/components/vc-virtual-list/List.jsx index 1d141c657..6ce4faf81 100644 --- a/components/vc-virtual-list/List.jsx +++ b/components/vc-virtual-list/List.jsx @@ -1,576 +1,371 @@ -import { getOptionProps, getStyle } from '../_util/props-util'; -import PropTypes from '../_util/vue-types'; -import { cloneElement } from '../_util/vnode'; -import BaseMixin from '../_util/BaseMixin'; import Filler from './Filler'; -import { - getNodeHeight, - requireVirtual, - getElementScrollPercentage, - getRangeIndex, - alignScrollTop, - getItemAbsoluteTop, - getItemRelativeTop, - getScrollPercentage, - getCompareItemRelativeTop, - GHOST_ITEM_KEY, -} from './utils/itemUtil'; -import { getIndexByStartLoc, findListDiffIndex } from './utils/algorithmUtil'; +import Item from './Item'; +import ScrollBar from './ScrollBar'; +import useHeights from './hooks/useHeights'; +import useScrollTo from './hooks/useScrollTo'; +// import useDiffItem from './hooks/useDiffItem'; +import useFrameWheel from './hooks/useFrameWheel'; +import useMobileTouchMove from './hooks/useMobileTouchMove'; +import useOriginScroll from './hooks/useOriginScroll'; +import PropTypes from '../_util/vue-types'; +import { computed, nextTick, reactive, ref, watchEffect } from 'vue'; +import classNames from '../_util/classNames'; +import createRef from '../_util/createRef'; -const ITEM_SCALE_RATE = 1; +const EMPTY_DATA = []; const ScrollStyle = { overflowY: 'auto', overflowAnchor: 'none', }; -/** - * - * Virtual list display logic: - * 1. scroll / initialize trigger measure - * 2. Get location item of current `scrollTop` - * 3. [Render] Render visible items - * 4. Get all the visible items height - * 5. [Render] Update top item `margin-top` to fit the position - * - * Algorithm: - * We split scroll bar into equal slice. An item with whatever height occupy the same range slice. - * When `scrollTop` change, - * it will calculate the item percentage position and move item to the position. - * Then calculate other item position base on the located item. - * - * Concept: - * - * # located item - * The base position item which other items position calculate base on. - */ - -export default { - name: 'List', - mixins: [BaseMixin], - props: { - prefixCls: PropTypes.string, - children: PropTypes.func, - data: PropTypes.array, - height: PropTypes.number, - itemHeight: PropTypes.number, - /** If not match virtual scroll condition, Set List still use height of container. */ - fullHeight: PropTypes.bool, - itemKey: PropTypes.any, - component: PropTypes.string, - /** Disable scroll check. Usually used on animation control */ - disabled: PropTypes.bool, - /** Set `false` will always use real scroll instead of virtual one */ - virtual: PropTypes.bool, - /** When `disabled`, trigger if changed item not render. */ - onSkipRender: PropTypes.func, - }, - data() { - const props = getOptionProps(this); - const { height, itemHeight, data, virtual } = props; - this.cachedProps = props; - this.itemElements = {}; - this.itemElementHeights = {}; - return { - status: 'NONE', - scrollTop: null, - itemIndex: 0, - itemOffsetPtg: 0, - startIndex: 0, - endIndex: 0, - startItemTop: 0, - isVirtual: requireVirtual(height, itemHeight, data.length, virtual), - itemCount: data.length, - ...this.getDerivedStateFromProps(props), - }; - }, - watch: { - disabled(val) { - if (!val) { - const props = getOptionProps(this); - this.itemCount = props.data.length; - } - }, - }, - /** - * Phase 1: Initial should sync with default scroll top - */ - mounted() { - if (this.$refs.list) { - this.$refs.list.scrollTop = 0; - this.onScroll(null); - } - }, - /** - * Phase 4: Record used item height - * Phase 5: Trigger re-render to use correct position - */ - updated() { - this.$nextTick(() => { - const { status } = this.$data; - const { data, height, itemHeight, disabled, onSkipRender, virtual } = getOptionProps(this); - const prevData = this.cachedProps.data || []; - - let changedItemIndex = null; - if (prevData.length !== data.length) { - const diff = findListDiffIndex(prevData, data, this.getItemKey); - changedItemIndex = diff ? diff.index : null; - } - - if (disabled) { - // Should trigger `onSkipRender` to tell that diff component is not render in the list - if (data.length > prevData.length) { - const { startIndex, endIndex } = this.$data; - if ( - onSkipRender && - (changedItemIndex === null || - changedItemIndex < startIndex || - endIndex < changedItemIndex) - ) { - onSkipRender(); - } - } - return; - } - - const isVirtual = requireVirtual(height, itemHeight, data.length, virtual); - let nextStatus = status; - if (this.$data.isVirtual !== isVirtual) { - nextStatus = isVirtual ? 'SWITCH_TO_VIRTUAL' : 'SWITCH_TO_RAW'; - this.setState({ - isVirtual, - status: nextStatus, - }); - - /** - * We will wait a tick to let list turn to virtual list. - * And then use virtual list sync logic to adjust the scroll. - */ - if (nextStatus === 'SWITCH_TO_VIRTUAL') { - return; - } - } - - if (status === 'MEASURE_START') { - const { startIndex, itemIndex, itemOffsetPtg } = this.$data; - const { scrollTop } = this.$refs.list; - - // Record here since measure item height will get warning in `render` - this.collectItemHeights(); - - // Calculate top visible item top offset - const locatedItemTop = getItemAbsoluteTop({ - itemIndex, - itemOffsetPtg, - itemElementHeights: this.itemElementHeights, - scrollTop, - scrollPtg: getElementScrollPercentage(this.$refs.list), - clientHeight: this.$refs.list.clientHeight, - getItemKey: this.getIndexKey, - }); - - let startItemTop = locatedItemTop; - for (let index = itemIndex - 1; index >= startIndex; index -= 1) { - startItemTop -= this.itemElementHeights[this.getIndexKey(index)] || 0; - } - - this.setState({ - status: 'MEASURE_DONE', - startItemTop, - }); - } - - if (status === 'SWITCH_TO_RAW') { - /** - * After virtual list back to raw list, - * we update the `scrollTop` to real top instead of percentage top. - */ - const { - cacheScroll: { itemIndex, relativeTop }, - } = this.$data; - let rawTop = relativeTop; - for (let index = 0; index < itemIndex; index += 1) { - rawTop -= this.itemElementHeights[this.getIndexKey(index)] || 0; - } - - this.lockScroll = true; - this.$refs.list.current.scrollTop = -rawTop; - - this.setState({ - status: 'MEASURE_DONE', - itemIndex: 0, - }); - - requestAnimationFrame(() => { - requestAnimationFrame(() => { - this.lockScroll = false; - }); - }); - } else if (prevData.length !== data.length && changedItemIndex !== null && height) { - /** - * Re-calculate the item position since `data` length changed. - * [IMPORTANT] We use relative position calculate here. - */ - let { itemIndex: originItemIndex } = this.$data; - const { - itemOffsetPtg: originItemOffsetPtg, - startIndex: originStartIndex, - endIndex: originEndIndex, - scrollTop: originScrollTop, - } = this.$data; - - // 1. Refresh item heights - this.collectItemHeights(); - - // 1. Get origin located item top - let originLocatedItemRelativeTop; - - if (this.$data.status === 'SWITCH_TO_VIRTUAL') { - originItemIndex = 0; - originLocatedItemRelativeTop = -this.$data.scrollTop; - } else { - originLocatedItemRelativeTop = getItemRelativeTop({ - itemIndex: originItemIndex, - itemOffsetPtg: originItemOffsetPtg, - itemElementHeights: this.itemElementHeights, - scrollPtg: getScrollPercentage({ - scrollTop: originScrollTop, - scrollHeight: prevData.length * itemHeight, - clientHeight: this.$refs.list.current.clientHeight, - }), - clientHeight: this.$refs.list.current.clientHeight, - getItemKey: index => this.getIndexKey(index, this.cachedProps), - }); - } - - // 2. Find the compare item - let originCompareItemIndex = changedItemIndex - 1; - // Use next one since there are not more item before removed - if (originCompareItemIndex < 0) { - originCompareItemIndex = 0; - } - - // 3. Find the compare item top - const originCompareItemTop = getCompareItemRelativeTop({ - locatedItemRelativeTop: originLocatedItemRelativeTop, - locatedItemIndex: originItemIndex, - compareItemIndex: originCompareItemIndex, - startIndex: originStartIndex, - endIndex: originEndIndex, - getItemKey: index => this.getIndexKey(index, this.cachedProps), - itemElementHeights: this.itemElementHeights, - }); - - if (nextStatus === 'SWITCH_TO_RAW') { - /** - * We will record current measure relative item top and apply in raw list after list turned - */ - this.setState({ - cacheScroll: { - itemIndex: originCompareItemIndex, - relativeTop: originCompareItemTop, - }, - }); - } else { - this.internalScrollTo({ - itemIndex: originCompareItemIndex, - relativeTop: originCompareItemTop, - }); - } - } else if (nextStatus === 'SWITCH_TO_RAW') { - // This is only trigger when height changes that all items can show in raw - // Let's reset back to top - this.setState({ - cacheScroll: { - itemIndex: 0, - relativeTop: 0, - }, - }); - } - - this.cachedProps = getOptionProps(this); +function renderChildren(list, startIndex, endIndex, setNodeRef, renderFunc, { getKey }) { + return list.slice(startIndex, endIndex + 1).map((item, index) => { + const eleIndex = startIndex + index; + const node = renderFunc(item, eleIndex, { + // style: status === 'MEASURE_START' ? { visibility: 'hidden' } : {}, }); - }, - methods: { - getDerivedStateFromProps(nextProps) { - if (!nextProps.disabled) { + const key = getKey(item); + return ( + setNodeRef(item, ele)}> + {node} + + ); + }); +} + +const ListProps = { + prefixCls: PropTypes.string, + data: PropTypes.array, + height: PropTypes.number, + itemHeight: PropTypes.number, + /** If not match virtual scroll condition, Set List still use height of container. */ + fullHeight: PropTypes.bool, + itemKey: PropTypes.any, + component: PropTypes.any, + /** Set `false` will always use real scroll instead of virtual one */ + virtual: PropTypes.bool, + children: PropTypes.func, + onScroll: PropTypes.func, +}; + +const List = { + props: ListProps, + inheritAttrs: false, + name: 'List', + setup(props) { + // ================================= MISC ================================= + + const inVirtual = computed(() => { + const { height, itemHeight, data, virtual } = props; + return virtual !== false && height && itemHeight && data && itemHeight * data.length > height; + }); + + const state = reactive({ + scrollTop: 0, + scrollMoving: false, + mergedData: computed(() => props.data || EMPTY_DATA), + }); + + const componentRef = createRef(); + + // =============================== Item Key =============================== + const getKey = item => { + if (typeof props.itemKey === 'function') { + return props.itemKey(item); + } + return item[props.itemKey]; + }; + + const sharedConfig = { + getKey, + }; + + // ================================ Scroll ================================ + function syncScrollTop(newTop) { + let value; + if (typeof newTop === 'function') { + value = newTop(state.scrollTop); + } else { + value = newTop; + } + + const alignedTop = keepInRange(value); + + componentRef.current.scrollTop = alignedTop; + return alignedTop; + } + + // ================================ Legacy ================================ + // Put ref here since the range is generate by follow + const rangeRef = ref({ start: 0, end: state.mergedData.length }); + + // const diffItemRef = ref(); + // const [diffItem] = useDiffItem(mergedData, getKey); + // diffItemRef.current = diffItem; + + // ================================ Height ================================ + const [setInstance, collectHeight, heights, updatedMark] = useHeights(getKey, null, null); + + // ========================== Visible Calculation ========================= + const calRes = computed(() => { + if (!inVirtual.value) { return { - itemCount: nextProps.data.length, + scrollHeight: undefined, + start: 0, + end: state.mergedData.length - 1, + offset: undefined, }; } - return null; - }, - /** - * Phase 2: Trigger render since we should re-calculate current position. - */ - onScroll(e) { - const { data, height, itemHeight, disabled } = this.$props; + let itemTop = 0; + let startIndex; + let startOffset; + let endIndex; + // eslint-disable-next-line no-console + console.log('updatedMark', updatedMark); + const dataLen = state.mergedData.length; + for (let i = 0; i < dataLen; i += 1) { + const item = state.mergedData[i]; + const key = getKey(item); - const { scrollTop: originScrollTop, clientHeight, scrollHeight } = this.$refs.list; - const scrollTop = alignScrollTop(originScrollTop, scrollHeight - clientHeight); + const cacheHeight = heights.get(key); + const currentItemBottom = + itemTop + (cacheHeight === undefined ? props.itemHeight : cacheHeight); - // Skip if `scrollTop` not change to avoid shake - if (scrollTop === this.$data.scrollTop || this.lockScroll || disabled) { - return; - } - - const scrollPtg = getElementScrollPercentage(this.$refs.list); - const visibleCount = Math.ceil(height / itemHeight); - - const { itemIndex, itemOffsetPtg, startIndex, endIndex } = getRangeIndex( - scrollPtg, - data.length, - visibleCount, - ); - - this.setState({ - status: 'MEASURE_START', - scrollTop, - itemIndex, - itemOffsetPtg, - startIndex, - endIndex, - }); - - this.triggerOnScroll(e); - }, - onRawScroll(e) { - const { scrollTop } = this.$refs.list; - this.setState({ scrollTop }); - this.triggerOnScroll(e); - }, - triggerOnScroll(e) { - if (e) { - this.$emit('scroll', e); - } - }, - /** - * Phase 4: Render item and get all the visible items height - */ - renderChildren(list, startIndex, renderFunc) { - const { status } = this.$data; - // We should measure rendered item height - return list.map((item, index) => { - const eleIndex = startIndex + index; - const node = renderFunc(item, eleIndex, { - style: status === 'MEASURE_START' ? { visibility: 'hidden' } : {}, - }); - const eleKey = this.getIndexKey(eleIndex); - - // Pass `key` and `ref` for internal measure - return cloneElement(node, { - key: eleKey, - ref: eleKey, - }); - }); - }, - getIndexKey(index, props) { - const mergedProps = props || getOptionProps(this); - const { data = [] } = mergedProps; - - // Return ghost key as latest index item - if (index === data.length) { - return GHOST_ITEM_KEY; - } - - const item = data[index]; - if (!item) { - /* istanbul ignore next */ - console.error('Not find index item. Please report this since it is a bug.'); - } - - return this.getItemKey(item, mergedProps); - }, - getItemKey(item, props) { - const { itemKey } = props || getOptionProps(this); - - return typeof itemKey === 'function' ? itemKey(item) : item[itemKey]; - }, - /** - * Collect current rendered dom element item heights - */ - collectItemHeights(range) { - const { startIndex, endIndex } = range || this.$data; - const { data } = getOptionProps(this); - - // Record here since measure item height will get warning in `render` - for (let index = startIndex; index <= endIndex; index += 1) { - const item = data[index]; - - // Only collect exist item height - if (item) { - const eleKey = this.getItemKey(item); - this.itemElementHeights[eleKey] = getNodeHeight(this.refs[`itemElement-${eleKey}`]); - } - } - }, - internalScrollTo(relativeScroll) { - const { itemIndex: compareItemIndex, relativeTop: compareItemRelativeTop } = relativeScroll; - const { scrollTop: originScrollTop } = this.$data; - const { data, itemHeight, height } = getOptionProps(this); - - // 1. Find the best match compare item top - let bestSimilarity = Number.MAX_VALUE; - let bestScrollTop = null; - let bestItemIndex = null; - let bestItemOffsetPtg = null; - let bestStartIndex = null; - let bestEndIndex = null; - - let missSimilarity = 0; - - const scrollHeight = data.length * itemHeight; - const { clientHeight } = this.$refs.list; - const maxScrollTop = scrollHeight - clientHeight; - - for (let i = 0; i < maxScrollTop; i += 1) { - const scrollTop = getIndexByStartLoc(0, maxScrollTop, originScrollTop, i); - - const scrollPtg = getScrollPercentage({ scrollTop, scrollHeight, clientHeight }); - const visibleCount = Math.ceil(height / itemHeight); - - const { itemIndex, itemOffsetPtg, startIndex, endIndex } = getRangeIndex( - scrollPtg, - data.length, - visibleCount, - ); - - // No need to check if compare item out of the index to save performance - if (startIndex <= compareItemIndex && compareItemIndex <= endIndex) { - // 1.1 Get measure located item relative top - const locatedItemRelativeTop = getItemRelativeTop({ - itemIndex, - itemOffsetPtg, - itemElementHeights: this.itemElementHeights, - scrollPtg, - clientHeight, - getItemKey: this.getIndexKey, - }); - - const compareItemTop = getCompareItemRelativeTop({ - locatedItemRelativeTop, - locatedItemIndex: itemIndex, - compareItemIndex, // Same as origin index - startIndex, - endIndex, - getItemKey: this.getIndexKey, - itemElementHeights: this.itemElementHeights, - }); - - // 1.2 Find best match compare item top - const similarity = Math.abs(compareItemTop - compareItemRelativeTop); - if (similarity < bestSimilarity) { - bestSimilarity = similarity; - bestScrollTop = scrollTop; - bestItemIndex = itemIndex; - bestItemOffsetPtg = itemOffsetPtg; - bestStartIndex = startIndex; - bestEndIndex = endIndex; - - missSimilarity = 0; - } else { - missSimilarity += 1; - } + // Check item top in the range + if (currentItemBottom >= state.scrollTop && startIndex === undefined) { + startIndex = i; + startOffset = itemTop; } - // If keeping 10 times not match similarity, - // check more scrollTop is meaningless. - // Here boundary is set to 10. - if (missSimilarity > 10) { - break; + // Check item bottom in the range. We will render additional one item for motion usage + if (currentItemBottom > state.scrollTop + props.height && endIndex === undefined) { + endIndex = i; } + + itemTop = currentItemBottom; } - // 2. Re-scroll if has best scroll match - if (bestScrollTop !== null) { - this.lockScroll = true; - this.$refs.list.current.scrollTop = bestScrollTop; - - this.setState({ - status: 'MEASURE_START', - scrollTop: bestScrollTop, - itemIndex: bestItemIndex, - itemOffsetPtg: bestItemOffsetPtg, - startIndex: bestStartIndex, - endIndex: bestEndIndex, - }); - - requestAnimationFrame(() => { - requestAnimationFrame(() => { - this.lockScroll = false; - }); - }); + // Fallback to normal if not match. This code should never reach + /* istanbul ignore next */ + if (startIndex === undefined) { + startIndex = 0; + startOffset = 0; + } + if (endIndex === undefined) { + endIndex = state.mergedData.length - 1; } - }, - }, - render() { - const { isVirtual, itemCount } = this.$data; - const { - prefixCls, - height, - itemHeight, - fullHeight = true, - component: Component = 'div', - data, - children, - itemKey, - onSkipRender, - disabled, - virtual, - ...restProps - } = getOptionProps(this); - const style = getStyle(this); - if (!isVirtual) { - /** - * Virtual list switch is works on component updated. - * We should double check here if need cut the content. - */ - const shouldVirtual = requireVirtual(height, itemHeight, data.length, virtual); + // Give cache to improve scroll experience + endIndex = Math.min(endIndex + 1, state.mergedData.length); + rangeRef.value.start = startIndex; + rangeRef.value.end = endIndex; + return { + scrollHeight: itemTop, + start: startIndex, + end: endIndex, + offset: startOffset, + }; + }); + // =============================== In Range =============================== + const maxScrollHeight = computed(() => calRes.scrollHeight - props.height); - return ( - - - {this.renderChildren( - shouldVirtual ? data.slice(0, Math.ceil(height / itemHeight)) : data, - 0, - children, - )} - - - ); + function keepInRange(newScrollTop) { + let newTop = Math.max(newScrollTop, 0); + if (!Number.isNaN(maxScrollHeight.value)) { + newTop = Math.min(newTop, maxScrollHeight.value); + } + return newTop; } - // Use virtual list - const mergedStyle = { - ...style, - height, - ...ScrollStyle, - }; + const isScrollAtTop = computed(() => state.scrollTop <= 0); + const isScrollAtBottom = computed(() => state.scrollTop >= maxScrollHeight.value); - const { status, startIndex, endIndex, startItemTop } = this.$data; - const contentHeight = itemCount * itemHeight * ITEM_SCALE_RATE; + const originScroll = useOriginScroll(isScrollAtTop, isScrollAtBottom); + + // ================================ Scroll ================================ + function onScrollBar(newScrollTop) { + const newTop = newScrollTop; + syncScrollTop(newTop); + } + + // This code may only trigger in test case. + // But we still need a sync if some special escape + function onFallbackScroll(e) { + const { scrollTop: newScrollTop } = e.currentTarget; + if (newScrollTop !== state.scrollTop) { + syncScrollTop(newScrollTop); + } + + // Trigger origin onScroll + props.onScroll?.(e); + } + + // Since this added in global,should use ref to keep update + const [onRawWheel, onFireFoxScroll] = useFrameWheel( + inVirtual, + isScrollAtTop, + isScrollAtBottom, + offsetY => { + syncScrollTop(top => { + const newTop = top + offsetY; + return newTop; + }); + }, + ); + + // Mobile touch move + useMobileTouchMove(inVirtual, componentRef, (deltaY, smoothOffset) => { + if (originScroll(deltaY, smoothOffset)) { + return false; + } + + onRawWheel({ preventDefault() {}, deltaY }); + return true; + }); + watchEffect(() => { + nextTick(() => { + componentRef.current.removeEventListener('wheel', onRawWheel); + componentRef.current.removeEventListener('DOMMouseScroll', onFireFoxScroll); + componentRef.current.removeEventListener('MozMousePixelScroll', onMozMousePixelScroll); + + // Firefox only + function onMozMousePixelScroll(e) { + if (inVirtual.value) { + e.preventDefault(); + } + } + + componentRef.current.addEventListener('wheel', onRawWheel); + componentRef.current.addEventListener('DOMMouseScroll', onFireFoxScroll); + componentRef.current.addEventListener('MozMousePixelScroll', onMozMousePixelScroll); + }); + }); + + // ================================= Ref ================================== + const scrollTo = useScrollTo( + componentRef, + state.mergedData, + heights, + props.itemHeight, + getKey, + collectHeight, + syncScrollTop, + ); + + const componentStyle = computed(() => { + let cs = null; + if (props.height) { + cs = { [props.fullHeight ? 'height' : 'maxHeight']: props.height + 'px', ...ScrollStyle }; + + if (inVirtual.value) { + cs.overflowY = 'hidden'; + + if (state.scrollMoving) { + cs.pointerEvents = 'none'; + } + } + } + return cs; + }); + + return { + state, + componentStyle, + scrollTo, + onFallbackScroll, + onScrollBar, + componentRef, + inVirtual, + calRes, + collectHeight, + setInstance, + sharedConfig, + }; + }, + render() { + const { style, class: className } = this.$attrs; + const { + prefixCls = 'rc-virtual-list', + height, + itemHeight, + // eslint-disable-next-line no-unused-vars + fullHeight = true, + data, + itemKey, + virtual, + component: Component = 'div', + onScroll, + children, + ...restProps + } = this.$props; + const mergedClassName = classNames(prefixCls, className); + const { scrollTop, mergedData } = this.state; + const { scrollHeight, offset, start, end } = this.calRes; + const { + componentStyle, + onFallbackScroll, + onScrollBar, + componentRef, + inVirtual, + collectHeight, + sharedConfig, + setInstance, + } = this; + const listChildren = renderChildren( + mergedData, + start, + end, + setInstance, + children, + sharedConfig, + ); return ( - - + - {this.renderChildren(data.slice(startIndex, endIndex + 1), startIndex, children)} - - + + {listChildren} + + + + {inVirtual && ( + { + this.state.scrollMoving = true; + }} + onStopMove={() => { + this.state.scrollMoving = false; + }} + /> + )} + ); }, }; + +export default List; diff --git a/components/vc-virtual-list/ScrollBar.jsx b/components/vc-virtual-list/ScrollBar.jsx new file mode 100644 index 000000000..6eced3c6c --- /dev/null +++ b/components/vc-virtual-list/ScrollBar.jsx @@ -0,0 +1,231 @@ +import classNames from '../_util/classNames'; +import createRef from '../_util/createRef'; +import raf from '../_util/raf'; +import PropTypes from '../_util/vue-types'; + +const MIN_SIZE = 20; + +// export interface ScrollBarProps { +// prefixCls: string; +// scrollTop: number; +// scrollHeight: number; +// height: number; +// count: number; +// onScroll: (scrollTop: number) => void; +// onStartMove: () => void; +// onStopMove: () => void; +// } + +// interface ScrollBarState { +// dragging: boolean; +// pageY: number; +// startTop: number; +// visible: boolean; +// } + +function getPageY(e) { + return 'touches' in e ? e.touches[0].pageY : e.pageY; +} + +export default { + name: 'ScrollBar', + inheritAttrs: false, + props: { + prefixCls: PropTypes.string, + scrollTop: PropTypes.number, + scrollHeight: PropTypes.number, + height: PropTypes.number, + count: PropTypes.number, + onScroll: PropTypes.func, + onStartMove: PropTypes.func, + onStopMove: PropTypes.func, + }, + setup() { + return { + moveRaf: null, + scrollbarRef: createRef(), + thumbRef: createRef(), + visibleTimeout: null, + state: { + dragging: false, + pageY: null, + startTop: null, + visible: false, + }, + }; + }, + watch: { + scrollTop: { + handler() { + this.delayHidden(); + }, + flush: 'post', + }, + }, + + mounted() { + this.scrollbarRef.current.addEventListener('touchstart', this.onScrollbarTouchStart); + this.thumbRef.current.addEventListener('touchstart', this.onMouseDown); + }, + + unmounted() { + this.removeEvents(); + clearTimeout(this.visibleTimeout); + }, + methods: { + delayHidden() { + clearTimeout(this.visibleTimeout); + this.state.visible = true; + this.visibleTimeout = setTimeout(() => { + this.state.visible = false; + }, 2000); + }, + + onScrollbarTouchStart(e) { + e.preventDefault(); + }, + + onContainerMouseDown(e) { + e.stopPropagation(); + e.preventDefault(); + }, + + // ======================= Clean ======================= + patchEvents() { + window.addEventListener('mousemove', this.onMouseMove); + window.addEventListener('mouseup', this.onMouseUp); + + this.thumbRef.current.addEventListener('touchmove', this.onMouseMove); + this.thumbRef.current.addEventListener('touchend', this.onMouseUp); + }, + + removeEvents() { + window.removeEventListener('mousemove', this.onMouseMove); + window.removeEventListener('mouseup', this.onMouseUp); + + this.scrollbarRef.current.removeEventListener('touchstart', this.onScrollbarTouchStart); + this.thumbRef.current.removeEventListener('touchstart', this.onMouseDown); + this.thumbRef.current.removeEventListener('touchmove', this.onMouseMove); + this.thumbRef.current.removeEventListener('touchend', this.onMouseUp); + + raf.cancel(this.moveRaf); + }, + + // ======================= Thumb ======================= + onMouseDown(e) { + const { onStartMove } = this.$props; + + Object.assign(this.state, { + dragging: true, + pageY: getPageY(e), + startTop: this.getTop(), + }); + + onStartMove(); + this.patchEvents(); + e.stopPropagation(); + e.preventDefault(); + }, + + onMouseMove(e) { + const { dragging, pageY, startTop } = this.state; + const { onScroll } = this.$props; + + raf.cancel(this.moveRaf); + + if (dragging) { + const offsetY = getPageY(e) - pageY; + const newTop = startTop + offsetY; + + const enableScrollRange = this.getEnableScrollRange(); + const enableHeightRange = this.getEnableHeightRange(); + + const ptg = newTop / enableHeightRange; + const newScrollTop = Math.ceil(ptg * enableScrollRange); + this.moveRaf = raf(() => { + onScroll(newScrollTop); + }); + } + }, + + onMouseUp() { + const { onStopMove } = this.$props; + this.state.dragging = false; + + onStopMove(); + this.removeEvents(); + }, + + // ===================== Calculate ===================== + getSpinHeight() { + const { height, count } = this.$props; + let baseHeight = (height / count) * 10; + baseHeight = Math.max(baseHeight, MIN_SIZE); + baseHeight = Math.min(baseHeight, height / 2); + return Math.floor(baseHeight); + }, + + getEnableScrollRange() { + const { scrollHeight, height } = this.$props; + return scrollHeight - height; + }, + + getEnableHeightRange() { + const { height } = this.$props; + const spinHeight = this.getSpinHeight(); + return height - spinHeight; + }, + + getTop() { + const { scrollTop } = this.$props; + const enableScrollRange = this.getEnableScrollRange(); + const enableHeightRange = this.getEnableHeightRange(); + const ptg = scrollTop / enableScrollRange; + return ptg * enableHeightRange; + }, + }, + + render() { + // eslint-disable-next-line no-unused-vars + const { visible, dragging } = this.state; + const { prefixCls } = this.$props; + const spinHeight = this.getSpinHeight() + 'px'; + const top = this.getTop() + 'px'; + + return ( +
+
+
+ ); + }, +}; diff --git a/components/vc-virtual-list/examples/animate.less b/components/vc-virtual-list/examples/animate.less new file mode 100644 index 000000000..753fff92d --- /dev/null +++ b/components/vc-virtual-list/examples/animate.less @@ -0,0 +1,31 @@ +.motion { + transition: all 0.3s; +} + +.item { + display: inline-block; + box-sizing: border-box; + margin: 0; + padding: 0 16px; + overflow: hidden; + line-height: 31px; + position: relative; + + &:hover { + background: rgba(255, 0, 0, 0.1); + } + + &::after { + content: ''; + border-bottom: 1px solid gray; + position: absolute; + bottom: 0; + left: 0; + right: 0; + } + + button { + vertical-align: text-top; + margin-right: 8px; + } +} diff --git a/components/vc-virtual-list/examples/animate.tsx b/components/vc-virtual-list/examples/animate.tsx new file mode 100644 index 000000000..94688ee22 --- /dev/null +++ b/components/vc-virtual-list/examples/animate.tsx @@ -0,0 +1,214 @@ +/* eslint-disable arrow-body-style */ + +import * as React from 'react'; +// @ts-ignore +import CSSMotion from 'rc-animate/lib/CSSMotion'; +import classNames from 'classnames'; +import List, { ListRef } from '../src/List'; +import './animate.less'; + +let uuid = 0; +function genItem() { + const item = { + id: `key_${uuid}`, + uuid, + }; + uuid += 1; + return item; +} + +const originData: Item[] = []; +for (let i = 0; i < 1000; i += 1) { + originData.push(genItem()); +} + +interface Item { + id: string; + uuid: number; +} + +interface MyItemProps extends Item { + visible: boolean; + motionAppear: boolean; + onClose: (id: string) => void; + onLeave: (id: string) => void; + onAppear: (...args: any[]) => void; + onInsertBefore: (id: string) => void; + onInsertAfter: (id: string) => void; +} + +const getCurrentHeight = (node: HTMLElement) => ({ height: node.offsetHeight }); +const getMaxHeight = (node: HTMLElement) => { + return { height: node.scrollHeight }; +}; +const getCollapsedHeight = () => ({ height: 0, opacity: 0 }); + +const MyItem: React.ForwardRefRenderFunction = ( + { + id, + uuid: itemUuid, + visible, + onClose, + onLeave, + onAppear, + onInsertBefore, + onInsertAfter, + motionAppear, + }, + ref, +) => { + const motionRef = React.useRef(false); + React.useEffect(() => { + return () => { + if (motionRef.current) { + onAppear(); + } + }; + }, []); + + return ( + { + motionRef.current = true; + return getMaxHeight(node); + }} + onAppearEnd={onAppear} + onLeaveStart={getCurrentHeight} + onLeaveActive={getCollapsedHeight} + onLeaveEnd={() => { + onLeave(id); + }} + > + {({ className, style }, passedMotionRef) => { + return ( +
+
+ + + + {id} +
+
+ ); + }} +
+ ); +}; + +const ForwardMyItem = React.forwardRef(MyItem); + +const Demo = () => { + const [data, setData] = React.useState(originData); + const [closeMap, setCloseMap] = React.useState<{ [id: number]: boolean }>({}); + const [animating, setAnimating] = React.useState(false); + const [insertIndex, setInsertIndex] = React.useState(); + + const listRef = React.useRef(); + + const onClose = (id: string) => { + setCloseMap({ + ...closeMap, + [id]: true, + }); + }; + + const onLeave = (id: string) => { + const newData = data.filter(item => item.id !== id); + setData(newData); + }; + + const onAppear = (...args: any[]) => { + console.log('Appear:', args); + setAnimating(false); + }; + + function lockForAnimation() { + setAnimating(true); + } + + const onInsertBefore = (id: string) => { + const index = data.findIndex(item => item.id === id); + const newData = [...data.slice(0, index), genItem(), ...data.slice(index)]; + setInsertIndex(index); + setData(newData); + lockForAnimation(); + }; + const onInsertAfter = (id: string) => { + const index = data.findIndex(item => item.id === id) + 1; + const newData = [...data.slice(0, index), genItem(), ...data.slice(index)]; + setInsertIndex(index); + setData(newData); + lockForAnimation(); + }; + + return ( + +
+

Animate

+

Current: {data.length} records

+ + + data={data} + data-id="list" + height={200} + itemHeight={20} + itemKey="id" + // disabled={animating} + ref={listRef} + style={{ + border: '1px solid red', + boxSizing: 'border-box', + }} + // onSkipRender={onAppear} + // onItemRemove={onAppear} + > + {(item, index) => ( + + )} + +
+
+ ); +}; + +export default Demo; diff --git a/components/vc-virtual-list/examples/basic.jsx b/components/vc-virtual-list/examples/basic.jsx new file mode 100644 index 000000000..e260197cd --- /dev/null +++ b/components/vc-virtual-list/examples/basic.jsx @@ -0,0 +1,216 @@ +/* eslint-disable no-console */ +import { reactive, ref } from 'vue'; + +import List from '../List'; +import './basic.less'; + +const MyItem = (_, { attrs: { id } }) => ( + { + console.log('Click:', id); + }} + > + {id} + +); + +const TestItem = { + render() { + return
{this.$attrs.id}
; + }, +}; + +const data = []; +for (let i = 0; i < 1000; i += 1) { + data.push({ + id: String(i), + }); +} + +const TYPES = [ + { name: 'ref real dom element', type: 'dom' }, + { name: 'ref vue node', type: 'vue' }, +]; + +const onScroll = e => { + console.log('scroll:', e.currentTarget.scrollTop); +}; + +const state = reactive({ + destroy: false, + visible: true, + type: 'dom', +}); + +const listRef = ref(null); +const Demo = () => { + const { destroy, visible, type } = state; + return ( +
+

Basic

+ {TYPES.map(({ name, type: nType }) => ( + + ))} + + + + + + + + + + + + + + + + + {!destroy && ( + + type === 'dom' ? : + } + > + )} +
+ ); +}; + +export default Demo; + +/* eslint-enable */ diff --git a/components/vc-virtual-list/examples/basic.less b/components/vc-virtual-list/examples/basic.less new file mode 100644 index 000000000..1939f7a6f --- /dev/null +++ b/components/vc-virtual-list/examples/basic.less @@ -0,0 +1,8 @@ +.fixed-item { + border: 1px solid gray; + padding: 0 16px; + height: 32px; + line-height: 30px; + box-sizing: border-box; + display: inline-block; +} diff --git a/components/vc-virtual-list/examples/height.tsx b/components/vc-virtual-list/examples/height.tsx new file mode 100644 index 000000000..301572840 --- /dev/null +++ b/components/vc-virtual-list/examples/height.tsx @@ -0,0 +1,60 @@ +import * as React from 'react'; +import List from '../src/List'; + +interface Item { + id: number; + height: number; +} + +const MyItem: React.FC = ({ id, height }, ref) => { + return ( + + {id} + + ); +}; + +const ForwardMyItem = React.forwardRef(MyItem); + +const data: Item[] = []; +for (let i = 0; i < 100; i += 1) { + data.push({ + id: i, + height: 30 + (i % 2 ? 70 : 0), + }); +} + +const Demo = () => { + return ( + +
+

Dynamic Height

+ + + {item => } + +
+
+ ); +}; + +export default Demo; diff --git a/components/vc-virtual-list/examples/no-virtual.tsx b/components/vc-virtual-list/examples/no-virtual.tsx new file mode 100644 index 000000000..1af58ff79 --- /dev/null +++ b/components/vc-virtual-list/examples/no-virtual.tsx @@ -0,0 +1,86 @@ +import * as React from 'react'; +import List from '../src/List'; + +interface Item { + id: number; + height: number; +} + +const MyItem: React.FC = ({ id, height }, ref) => { + return ( + + {id} + + ); +}; + +const ForwardMyItem = React.forwardRef(MyItem); + +const data: Item[] = []; +for (let i = 0; i < 100; i += 1) { + data.push({ + id: i, + height: 30 + (i % 2 ? 20 : 0), + }); +} + +const Demo = () => { + return ( + +
+

Less Count

+ + {item => } + + +

Less Item Height

+ + {item => } + + +

Without Height

+ + {item => } + +
+
+ ); +}; + +export default Demo; diff --git a/components/vc-virtual-list/examples/switch.tsx b/components/vc-virtual-list/examples/switch.tsx new file mode 100644 index 000000000..010cd911d --- /dev/null +++ b/components/vc-virtual-list/examples/switch.tsx @@ -0,0 +1,106 @@ +/* eslint-disable jsx-a11y/label-has-associated-control, jsx-a11y/label-has-for */ +import * as React from 'react'; +import List from '../src/List'; + +interface Item { + id: number; +} + +const MyItem: React.FC = ({ id }, ref) => ( + + {id} + +); + +const ForwardMyItem = React.forwardRef(MyItem); + +function getData(count: number) { + const data: Item[] = []; + for (let i = 0; i < count; i += 1) { + data.push({ + id: i, + }); + } + return data; +} + +const Demo = () => { + const [height, setHeight] = React.useState(100); + const [data, setData] = React.useState(getData(20)); + + return ( + +
+

Switch

+ { + setData(getData(Number(e.target.value))); + }} + > + Data + + + + + + + { + setHeight(Number(e.target.value)); + }} + > + | Height + + + + + + + {(item, _, props) => } + +
+
+ ); +}; + +export default Demo; diff --git a/components/vc-virtual-list/hooks/useChildren.jsx b/components/vc-virtual-list/hooks/useChildren.jsx new file mode 100644 index 000000000..9aabe5798 --- /dev/null +++ b/components/vc-virtual-list/hooks/useChildren.jsx @@ -0,0 +1,23 @@ +import { Item } from '../Item'; + +export default function useChildren( + list, + startIndex, + endIndex, + setNodeRef, + renderFunc, + { getKey }, +) { + return list.slice(startIndex, endIndex + 1).map((item, index) => { + const eleIndex = startIndex + index; + const node = renderFunc(item, eleIndex, { + // style: status === 'MEASURE_START' ? { visibility: 'hidden' } : {}, + }); + const key = getKey(item); + return ( + setNodeRef(item, ele)}> + {node} + + ); + }); +} diff --git a/components/vc-virtual-list/hooks/useDiffItem.js b/components/vc-virtual-list/hooks/useDiffItem.js new file mode 100644 index 000000000..af26e5c06 --- /dev/null +++ b/components/vc-virtual-list/hooks/useDiffItem.js @@ -0,0 +1,18 @@ +import { ref, toRaw, watch } from 'vue'; +import cloneDeep from 'lodash-es/cloneDeep'; +import { findListDiffIndex } from '../utils/algorithmUtil'; + +export default function useDiffItem(data, getKey, onDiff) { + const diffItem = ref(null); + let prevData = cloneDeep(toRaw(data)); + watch(data, val => { + const diff = findListDiffIndex(prevData || [], val || [], getKey); + if (diff?.index !== undefined) { + onDiff?.(diff.index); + diffItem = val[diff.index]; + } + prevData = cloneDeep(toRaw(val)); + }); + + return [diffItem]; +} diff --git a/components/vc-virtual-list/hooks/useFrameWheel.js b/components/vc-virtual-list/hooks/useFrameWheel.js new file mode 100644 index 000000000..dbdf8802d --- /dev/null +++ b/components/vc-virtual-list/hooks/useFrameWheel.js @@ -0,0 +1,50 @@ +import raf from '../../_util/raf'; +import isFF from '../utils/isFirefox'; +import useOriginScroll from './useOriginScroll'; + +export default function useFrameWheel(inVirtual, isScrollAtTop, isScrollAtBottom, onWheelDelta) { + let offsetRef = 0; + let nextFrame = null; + + // Firefox patch + let wheelValue = null; + let isMouseScroll = false; + + // Scroll status sync + const originScroll = useOriginScroll(isScrollAtTop, isScrollAtBottom); + + function onWheel(event) { + if (!inVirtual.value) return; + + raf.cancel(nextFrame); + + const { deltaY } = event; + offsetRef += deltaY; + wheelValue = deltaY; + + // Do nothing when scroll at the edge, Skip check when is in scroll + if (originScroll(deltaY)) return; + + // Proxy of scroll events + if (!isFF) { + event.preventDefault(); + } + + nextFrame = raf(() => { + // Patch a multiple for Firefox to fix wheel number too small + // ref: https://github.com/ant-design/ant-design/issues/26372#issuecomment-679460266 + const patchMultiple = isMouseScroll ? 10 : 1; + onWheelDelta(offsetRef * patchMultiple); + offsetRef = 0; + }); + } + + // A patch for firefox + function onFireFoxScroll(event) { + if (!inVirtual.value) return; + + isMouseScroll = event.detail === wheelValue; + } + + return [onWheel, onFireFoxScroll]; +} diff --git a/components/vc-virtual-list/hooks/useHeights.jsx b/components/vc-virtual-list/hooks/useHeights.jsx new file mode 100644 index 000000000..2fde69c0d --- /dev/null +++ b/components/vc-virtual-list/hooks/useHeights.jsx @@ -0,0 +1,56 @@ +import { ref } from 'vue'; +import { findDOMNode } from '../../_util/props-util'; +import CacheMap from '../utils/CacheMap'; + +export default function useHeights(getKey, onItemAdd, onItemRemove) { + const instance = new Map(); + const heights = new CacheMap(); + let updatedMark = ref(0); + let heightUpdateId = 0; + function collectHeight() { + heightUpdateId += 1; + const currentId = heightUpdateId; + + Promise.resolve().then(() => { + // Only collect when it's latest call + if (currentId !== heightUpdateId) return; + let changed = false; + instance.forEach((element, key) => { + if (element && element.offsetParent) { + const htmlElement = findDOMNode(element); + const { offsetHeight } = htmlElement; + if (heights.get(key) !== offsetHeight) { + changed = true; + heights.set(key, htmlElement.offsetHeight); + } + } + }); + if (changed) { + updatedMark.value++; + } + }); + } + + function setInstance(item, ins) { + const key = getKey(item); + const origin = instance.get(key); + + if (ins) { + instance.set(key, ins); + collectHeight(); + } else { + instance.delete(key); + } + + // Instance changed + if (!origin !== !ins) { + if (ins) { + onItemAdd?.(item); + } else { + onItemRemove?.(item); + } + } + } + + return [setInstance, collectHeight, heights, updatedMark]; +} diff --git a/components/vc-virtual-list/hooks/useMobileTouchMove.js b/components/vc-virtual-list/hooks/useMobileTouchMove.js new file mode 100644 index 000000000..0a1bdcf8b --- /dev/null +++ b/components/vc-virtual-list/hooks/useMobileTouchMove.js @@ -0,0 +1,74 @@ +import { watch } from 'vue'; + +const SMOOTH_PTG = 14 / 15; + +export default function useMobileTouchMove(inVirtual, listRef, callback) { + let touched = false; + let touchY = 0; + + let element = null; + + // Smooth scroll + let interval = null; + + let cleanUpEvents; + + const onTouchMove = e => { + if (touched) { + const currentY = Math.ceil(e.touches[0].pageY); + let offsetY = touchY - currentY; + touchY = currentY; + + if (callback(offsetY)) { + e.preventDefault(); + } + + // Smooth interval + clearInterval(interval); + interval = setInterval(() => { + offsetY *= SMOOTH_PTG; + + if (!callback(offsetY, true) || Math.abs(offsetY) <= 0.1) { + clearInterval(interval); + } + }, 16); + } + }; + + const onTouchEnd = () => { + touched = false; + + cleanUpEvents(); + }; + + const onTouchStart = e => { + cleanUpEvents(); + + if (e.touches.length === 1 && !touched) { + touched = true; + touchY = Math.ceil(e.touches[0].pageY); + + element = e.target; + element.addEventListener('touchmove', onTouchMove); + element.addEventListener('touchend', onTouchEnd); + } + }; + + cleanUpEvents = () => { + if (element) { + element.removeEventListener('touchmove', onTouchMove); + element.removeEventListener('touchend', onTouchEnd); + } + }; + watch(inVirtual, val => { + if (val.value) { + listRef.current.addEventListener('touchstart', onTouchStart); + } + + return () => { + listRef.current.removeEventListener('touchstart', onTouchStart); + cleanUpEvents(); + clearInterval(interval); + }; + }); +} diff --git a/components/vc-virtual-list/hooks/useOriginScroll.js b/components/vc-virtual-list/hooks/useOriginScroll.js new file mode 100644 index 000000000..5518023d3 --- /dev/null +++ b/components/vc-virtual-list/hooks/useOriginScroll.js @@ -0,0 +1,42 @@ +import { reactive } from 'vue'; + +export default (isScrollAtTop, isScrollAtBottom) => { + // Do lock for a wheel when scrolling + let lock = false; + let lockTimeout = null; + function lockScroll() { + clearTimeout(lockTimeout); + + lock = true; + + lockTimeout = setTimeout(() => { + lock = false; + }, 50); + } + + // Pass to ref since global add is in closure + const scrollPingRef = reactive({ + top: isScrollAtTop.value, + bottom: isScrollAtBottom.value, + }); + // scrollPingRef.value.top = isScrollAtTop; + // scrollPingRef.value.bottom = isScrollAtBottom; + + return (deltaY, smoothOffset = false) => { + const originScroll = + // Pass origin wheel when on the top + (deltaY < 0 && scrollPingRef.top) || + // Pass origin wheel when on the bottom + (deltaY > 0 && scrollPingRef.bottom); + + if (smoothOffset && originScroll) { + // No need lock anymore when it's smooth offset from touchMove interval + clearTimeout(lockTimeout); + lock = false; + } else if (!originScroll || lock) { + lockScroll(); + } + + return !lock && originScroll; + }; +}; diff --git a/components/vc-virtual-list/hooks/useScrollTo.jsx b/components/vc-virtual-list/hooks/useScrollTo.jsx new file mode 100644 index 000000000..d0de1d6d3 --- /dev/null +++ b/components/vc-virtual-list/hooks/useScrollTo.jsx @@ -0,0 +1,102 @@ +/* eslint-disable no-param-reassign */ + +import raf from '../../_util/raf'; + +export default function useScrollTo( + containerRef, + data, + heights, + itemHeight, + getKey, + collectHeight, + syncScrollTop, +) { + let scroll = null; + + return arg => { + raf.cancel(scroll); + + if (typeof arg === 'number') { + syncScrollTop(arg); + } else if (arg && typeof arg === 'object') { + let index; + const { align } = arg; + + if ('index' in arg) { + ({ index } = arg); + } else { + index = data.findIndex(item => getKey(item) === arg.key); + } + + const { offset = 0 } = arg; + + // We will retry 3 times in case dynamic height shaking + const syncScroll = (times, targetAlign) => { + if (times < 0 || !containerRef.current) return; + + const height = containerRef.current.clientHeight; + let needCollectHeight = false; + let newTargetAlign = targetAlign; + + // Go to next frame if height not exist + if (height) { + const mergedAlign = targetAlign || align; + + // Get top & bottom + let stackTop = 0; + let itemTop = 0; + let itemBottom = 0; + + for (let i = 0; i <= index; i += 1) { + const key = getKey(data[i]); + itemTop = stackTop; + const cacheHeight = heights.get(key); + itemBottom = itemTop + (cacheHeight === undefined ? itemHeight : cacheHeight); + + stackTop = itemBottom; + + if (i === index && cacheHeight === undefined) { + needCollectHeight = true; + } + } + + // Scroll to + let targetTop = null; + + switch (mergedAlign) { + case 'top': + targetTop = itemTop - offset; + break; + case 'bottom': + targetTop = itemBottom - height + offset; + break; + + default: { + const { scrollTop } = containerRef.current; + const scrollBottom = scrollTop + height; + if (itemTop < scrollTop) { + newTargetAlign = 'top'; + } else if (itemBottom > scrollBottom) { + newTargetAlign = 'bottom'; + } + } + } + + if (targetTop !== null && targetTop !== containerRef.current.scrollTop) { + syncScrollTop(targetTop); + } + } + + // We will retry since element may not sync height as it described + scroll = raf(() => { + if (needCollectHeight) { + collectHeight(); + } + syncScroll(times - 1, newTargetAlign); + }); + }; + + syncScroll(3); + } + }; +} diff --git a/examples/App.vue b/examples/App.vue index 0f02c1ac0..cd089ac7a 100644 --- a/examples/App.vue +++ b/examples/App.vue @@ -4,7 +4,7 @@