From 0d34f45895e401193ec8453b5b6c3b8226273a24 Mon Sep 17 00:00:00 2001 From: Amour1688 Date: Sat, 26 Sep 2020 22:52:40 +0800 Subject: [PATCH] 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; +}