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