feat: add virtual-list

pull/2930/head^2
tanjinzhou 2020-09-28 19:14:00 +08:00
parent ab80874fa5
commit 3844466ff2
21 changed files with 1713 additions and 557 deletions

View File

@ -0,0 +1,8 @@
function createRef() {
const func = function setRef(node) {
func.current = node;
};
return func;
}
export default createRef;

View File

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

View File

@ -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 (
<div style={outerStyle}>
<div
style={innerStyle}
class={classNames({
[`${prefixCls}-holder-inner`]: prefixCls,
})}
<ResizeObserver
onResize={({ offsetHeight }) => {
if (offsetHeight && onInnerResize) {
onInnerResize();
}
}}
>
{slots.default?.()}
</div>
<div
style={innerStyle}
class={classNames({
[`${prefixCls}-holder-inner`]: prefixCls,
})}
>
{slots.default?.()}
</div>
</ResizeObserver>
</div>
);
};
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;

View File

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

View File

@ -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 (
<Item key={key} setRef={ele => setNodeRef(item, ele)}>
{node}
</Item>
);
});
}
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 (
<Component
style={
height
? { ...style, [fullHeight ? 'height' : 'maxHeight']: height, ...ScrollStyle }
: style
}
{...restProps}
onScroll={this.onRawScroll}
ref="list"
>
<Filler prefixCls={prefixCls} height={height}>
{this.renderChildren(
shouldVirtual ? data.slice(0, Math.ceil(height / itemHeight)) : data,
0,
children,
)}
</Filler>
</Component>
);
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 (
<Component style={mergedStyle} {...restProps} onScroll={this.onScroll} ref="list">
<Filler
prefixCls={prefixCls}
height={contentHeight}
offset={status === 'MEASURE_DONE' ? startItemTop : 0}
<div
style={{
...style,
position: 'relative',
}}
class={mergedClassName}
{...restProps}
>
<Component
class={`${prefixCls}-holder`}
style={componentStyle}
ref={componentRef}
onScroll={onFallbackScroll}
>
{this.renderChildren(data.slice(startIndex, endIndex + 1), startIndex, children)}
</Filler>
</Component>
<Filler
prefixCls={prefixCls}
height={scrollHeight}
offset={offset}
onInnerResize={collectHeight}
>
{listChildren}
</Filler>
</Component>
{inVirtual && (
<ScrollBar
prefixCls={prefixCls}
scrollTop={scrollTop}
height={height}
scrollHeight={scrollHeight}
count={mergedData.length}
onScroll={onScrollBar}
onStartMove={() => {
this.state.scrollMoving = true;
}}
onStopMove={() => {
this.state.scrollMoving = false;
}}
/>
)}
</div>
);
},
};
export default List;

View File

@ -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 (
<div
ref={this.scrollbarRef}
class={`${prefixCls}-scrollbar`}
style={{
width: '8px',
top: 0,
bottom: 0,
right: 0,
position: 'absolute',
// display: visible ? null : 'none',
}}
onMousedown={this.onContainerMouseDown}
onMousemove={this.delayHidden}
>
<div
ref={this.thumbRef}
class={classNames(`${prefixCls}-scrollbar-thumb`, {
[`${prefixCls}-scrollbar-thumb-moving`]: dragging,
})}
style={{
width: '100%',
height: spinHeight,
top,
left: 0,
position: 'absolute',
background: 'rgba(0, 0, 0, 0.5)',
borderRadius: '99px',
cursor: 'pointer',
userSelect: 'none',
}}
onMousedown={this.onMouseDown}
/>
</div>
);
},
};

View File

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

View File

@ -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<any, MyItemProps> = (
{
id,
uuid: itemUuid,
visible,
onClose,
onLeave,
onAppear,
onInsertBefore,
onInsertAfter,
motionAppear,
},
ref,
) => {
const motionRef = React.useRef(false);
React.useEffect(() => {
return () => {
if (motionRef.current) {
onAppear();
}
};
}, []);
return (
<CSSMotion
visible={visible}
ref={ref}
motionName="motion"
motionAppear={motionAppear}
onAppearStart={getCollapsedHeight}
onAppearActive={node => {
motionRef.current = true;
return getMaxHeight(node);
}}
onAppearEnd={onAppear}
onLeaveStart={getCurrentHeight}
onLeaveActive={getCollapsedHeight}
onLeaveEnd={() => {
onLeave(id);
}}
>
{({ className, style }, passedMotionRef) => {
return (
<div
ref={passedMotionRef}
className={classNames('item', className)}
style={style}
data-id={id}
>
<div style={{ height: itemUuid % 2 ? 100 : undefined }}>
<button
type="button"
onClick={() => {
onClose(id);
}}
>
Close
</button>
<button
type="button"
onClick={() => {
onInsertBefore(id);
}}
>
Insert Before
</button>
<button
type="button"
onClick={() => {
onInsertAfter(id);
}}
>
Insert After
</button>
{id}
</div>
</div>
);
}}
</CSSMotion>
);
};
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<number>();
const listRef = React.useRef<ListRef>();
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 (
<React.StrictMode>
<div>
<h2>Animate</h2>
<p>Current: {data.length} records</p>
<List<Item>
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) => (
<ForwardMyItem
{...item}
motionAppear={animating && insertIndex === index}
visible={!closeMap[item.id]}
onClose={onClose}
onLeave={onLeave}
onAppear={onAppear}
onInsertBefore={onInsertBefore}
onInsertAfter={onInsertAfter}
/>
)}
</List>
</div>
</React.StrictMode>
);
};
export default Demo;

View File

@ -0,0 +1,216 @@
/* eslint-disable no-console */
import { reactive, ref } from 'vue';
import List from '../List';
import './basic.less';
const MyItem = (_, { attrs: { id } }) => (
<span
// style={{
// // height: 30 + (id % 2 ? 0 : 10),
// }}
class="fixed-item"
onClick={() => {
console.log('Click:', id);
}}
>
{id}
</span>
);
const TestItem = {
render() {
return <div style={{ lineHeight: '30px' }}>{this.$attrs.id}</div>;
},
};
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 (
<div style={{ height: '200vh' }}>
<h2>Basic</h2>
{TYPES.map(({ name, type: nType }) => (
<label key={nType}>
<input
name="type"
type="radio"
checked={type === nType}
onChange={() => {
state.type = nType;
}}
/>
{name}
</label>
))}
<button
type="button"
onClick={() => {
listRef.value.scrollTo(100);
}}
>
Scroll To 100px
</button>
<button
type="button"
onClick={() => {
listRef.value.scrollTo({
index: 50,
align: 'top',
});
}}
>
Scroll To 50 (top)
</button>
<button
type="button"
onClick={() => {
listRef.value.scrollTo({
index: 50,
align: 'bottom',
});
}}
>
Scroll To 50 (bottom)
</button>
<button
type="button"
onClick={() => {
listRef.value.scrollTo({
index: 50,
align: 'auto',
});
}}
>
Scroll To 50 (auto)
</button>
<button
type="button"
onClick={() => {
listRef.value.scrollTo({
index: 50,
align: 'top',
offset: 15,
});
}}
>
Scroll To 50 (top) + 15 offset
</button>
<button
type="button"
onClick={() => {
listRef.value.scrollTo({
index: 50,
align: 'bottom',
offset: 15,
});
}}
>
Scroll To 50 (bottom) + 15 offset
</button>
<button
type="button"
onClick={() => {
listRef.value.scrollTo({
key: '50',
align: 'auto',
});
}}
>
Scroll To key 50 (auto)
</button>
<button
type="button"
onClick={() => {
state.visible = !state.visible;
}}
>
visible
</button>
<button
type="button"
onClick={() => {
listRef.value.scrollTo({
index: data.length - 2,
align: 'top',
});
}}
>
Scroll To Last (top)
</button>
<button
type="button"
onClick={() => {
listRef.value.scrollTo({
index: 0,
align: 'bottom',
});
}}
>
Scroll To First (bottom)
</button>
<button
type="button"
onClick={() => {
listRef.value.scrollTo({
index: 50,
align: 'top',
});
state.destroy = true;
}}
>
Scroll To remove
</button>
{!destroy && (
<List
id="list"
ref={listRef}
data={data}
height={200}
itemHeight={20}
itemKey="id"
style={{
border: '1px solid red',
boxSizing: 'border-box',
display: visible ? null : 'none',
}}
onScroll={onScroll}
children={(item, _, props) =>
type === 'dom' ? <MyItem {...item} {...props} /> : <TestItem {...item} {...props} />
}
></List>
)}
</div>
);
};
export default Demo;
/* eslint-enable */

View File

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

View File

@ -0,0 +1,60 @@
import * as React from 'react';
import List from '../src/List';
interface Item {
id: number;
height: number;
}
const MyItem: React.FC<Item> = ({ id, height }, ref) => {
return (
<span
ref={ref}
style={{
border: '1px solid gray',
padding: '0 16px',
height,
lineHeight: '30px',
boxSizing: 'border-box',
display: 'inline-block',
}}
>
{id}
</span>
);
};
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 (
<React.StrictMode>
<div>
<h2>Dynamic Height</h2>
<List
data={data}
height={500}
itemHeight={30}
itemKey="id"
style={{
border: '1px solid red',
boxSizing: 'border-box',
}}
>
{item => <ForwardMyItem {...item} />}
</List>
</div>
</React.StrictMode>
);
};
export default Demo;

View File

@ -0,0 +1,86 @@
import * as React from 'react';
import List from '../src/List';
interface Item {
id: number;
height: number;
}
const MyItem: React.FC<Item> = ({ id, height }, ref) => {
return (
<span
ref={ref}
style={{
border: '1px solid gray',
padding: '0 16px',
height,
lineHeight: '30px',
boxSizing: 'border-box',
display: 'inline-block',
}}
>
{id}
</span>
);
};
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 (
<React.StrictMode>
<div>
<h2>Less Count</h2>
<List
data={data.slice(0, 1)}
itemHeight={30}
height={100}
itemKey="id"
style={{
border: '1px solid red',
boxSizing: 'border-box',
}}
>
{item => <ForwardMyItem {...item} />}
</List>
<h2>Less Item Height</h2>
<List
data={data.slice(0, 10)}
itemHeight={1}
height={100}
itemKey="id"
style={{
border: '1px solid red',
boxSizing: 'border-box',
}}
>
{item => <ForwardMyItem {...item} />}
</List>
<h2>Without Height</h2>
<List
data={data}
itemHeight={30}
itemKey="id"
style={{
border: '1px solid red',
boxSizing: 'border-box',
}}
>
{item => <ForwardMyItem {...item} />}
</List>
</div>
</React.StrictMode>
);
};
export default Demo;

View File

@ -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<Item> = ({ id }, ref) => (
<span
ref={ref}
style={{
border: '1px solid gray',
padding: '0 16px',
height: 30,
lineHeight: '30px',
boxSizing: 'border-box',
display: 'inline-block',
}}
>
{id}
</span>
);
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 (
<React.StrictMode>
<div style={{ height: '150vh' }}>
<h2>Switch</h2>
<span
onChange={(e: any) => {
setData(getData(Number(e.target.value)));
}}
>
Data
<label>
<input type="radio" name="switch" value={0} />0
</label>
<label>
<input type="radio" name="switch" value={2} />2
</label>
<label>
<input type="radio" name="switch" value={100} />
100
</label>
<label>
<input type="radio" name="switch" value={200} />
200
</label>
<label>
<input type="radio" name="switch" value={1000} />
1000
</label>
</span>
<span
onChange={(e: any) => {
setHeight(Number(e.target.value));
}}
>
| Height
<label>
<input type="radio" name="switch" value={0} />0
</label>
<label>
<input type="radio" name="switch" value={100} />
100
</label>
<label>
<input type="radio" name="switch" value={200} />
200
</label>
</span>
<List
data={data}
height={height}
itemHeight={30}
itemKey="id"
style={{
border: '1px solid red',
boxSizing: 'border-box',
}}
>
{(item, _, props) => <ForwardMyItem {...item} {...props} />}
</List>
</div>
</React.StrictMode>
);
};
export default Demo;

View File

@ -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 (
<Item key={key} setRef={ele => setNodeRef(item, ele)}>
{node}
</Item>
);
});
}

View File

@ -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];
}

View File

@ -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];
}

View File

@ -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];
}

View File

@ -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);
};
});
}

View File

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

View File

@ -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);
}
};
}

View File

@ -4,7 +4,7 @@
</div>
</template>
<script>
import demo from '../antdv-demo/docs/input/demo/basic.md';
import demo from '../components/vc-virtual-list/examples/basic.jsx';
export default {
components: {
demo,