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 @@