feat: virtual list
parent
b36043c241
commit
0d34f45895
|
@ -1 +1 @@
|
||||||
Subproject commit 05da262e31f9c6cc524154df13f5e2b05c20c1c8
|
Subproject commit 83ab203d1ab9861132f6efd1e74015507c0e45f6
|
|
@ -0,0 +1,38 @@
|
||||||
|
import classNames from '../_util/classNames';
|
||||||
|
|
||||||
|
const Filter = ({ height, offset, prefixCls }, { slots }) => {
|
||||||
|
let outerStyle = {};
|
||||||
|
|
||||||
|
let innerStyle = {
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
};
|
||||||
|
|
||||||
|
if (offset !== undefined) {
|
||||||
|
outerStyle = { height, position: 'relative', overflow: 'hidden' };
|
||||||
|
|
||||||
|
innerStyle = {
|
||||||
|
...innerStyle,
|
||||||
|
transform: `translateY(${offset}px)`,
|
||||||
|
position: 'absolute',
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
top: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={outerStyle}>
|
||||||
|
<div
|
||||||
|
style={innerStyle}
|
||||||
|
class={classNames({
|
||||||
|
[`${prefixCls}-holder-inner`]: prefixCls,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{slots.default?.()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Filter;
|
|
@ -0,0 +1,576 @@
|
||||||
|
import { getOptionProps, getStyle } from '../_util/props-util';
|
||||||
|
import PropTypes from '../_util/vue-types';
|
||||||
|
import { cloneElement } from '../_util/vnode';
|
||||||
|
import BaseMixin from '../_util/BaseMixin';
|
||||||
|
import Filler from './Filler';
|
||||||
|
import {
|
||||||
|
getNodeHeight,
|
||||||
|
requireVirtual,
|
||||||
|
getElementScrollPercentage,
|
||||||
|
getRangeIndex,
|
||||||
|
alignScrollTop,
|
||||||
|
getItemAbsoluteTop,
|
||||||
|
getItemRelativeTop,
|
||||||
|
getScrollPercentage,
|
||||||
|
getCompareItemRelativeTop,
|
||||||
|
GHOST_ITEM_KEY,
|
||||||
|
} from './utils/itemUtil';
|
||||||
|
import { getIndexByStartLoc, findListDiffIndex } from './utils/algorithmUtil';
|
||||||
|
|
||||||
|
const ITEM_SCALE_RATE = 1;
|
||||||
|
|
||||||
|
const ScrollStyle = {
|
||||||
|
overflowY: 'auto',
|
||||||
|
overflowAnchor: 'none',
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* Virtual list display logic:
|
||||||
|
* 1. scroll / initialize trigger measure
|
||||||
|
* 2. Get location item of current `scrollTop`
|
||||||
|
* 3. [Render] Render visible items
|
||||||
|
* 4. Get all the visible items height
|
||||||
|
* 5. [Render] Update top item `margin-top` to fit the position
|
||||||
|
*
|
||||||
|
* Algorithm:
|
||||||
|
* We split scroll bar into equal slice. An item with whatever height occupy the same range slice.
|
||||||
|
* When `scrollTop` change,
|
||||||
|
* it will calculate the item percentage position and move item to the position.
|
||||||
|
* Then calculate other item position base on the located item.
|
||||||
|
*
|
||||||
|
* Concept:
|
||||||
|
*
|
||||||
|
* # located item
|
||||||
|
* The base position item which other items position calculate base on.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: 'List',
|
||||||
|
mixins: [BaseMixin],
|
||||||
|
props: {
|
||||||
|
prefixCls: PropTypes.string,
|
||||||
|
children: PropTypes.func,
|
||||||
|
data: PropTypes.array,
|
||||||
|
height: PropTypes.number,
|
||||||
|
itemHeight: PropTypes.number,
|
||||||
|
/** If not match virtual scroll condition, Set List still use height of container. */
|
||||||
|
fullHeight: PropTypes.bool,
|
||||||
|
itemKey: PropTypes.any,
|
||||||
|
component: PropTypes.string,
|
||||||
|
/** Disable scroll check. Usually used on animation control */
|
||||||
|
disabled: PropTypes.bool,
|
||||||
|
/** Set `false` will always use real scroll instead of virtual one */
|
||||||
|
virtual: PropTypes.bool,
|
||||||
|
/** When `disabled`, trigger if changed item not render. */
|
||||||
|
onSkipRender: PropTypes.func,
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
const props = getOptionProps(this);
|
||||||
|
const { height, itemHeight, data, virtual } = props;
|
||||||
|
this.cachedProps = props;
|
||||||
|
this.itemElements = {};
|
||||||
|
this.itemElementHeights = {};
|
||||||
|
return {
|
||||||
|
status: 'NONE',
|
||||||
|
scrollTop: null,
|
||||||
|
itemIndex: 0,
|
||||||
|
itemOffsetPtg: 0,
|
||||||
|
startIndex: 0,
|
||||||
|
endIndex: 0,
|
||||||
|
startItemTop: 0,
|
||||||
|
isVirtual: requireVirtual(height, itemHeight, data.length, virtual),
|
||||||
|
itemCount: data.length,
|
||||||
|
...this.getDerivedStateFromProps(props),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
watch: {
|
||||||
|
disabled(val) {
|
||||||
|
if (!val) {
|
||||||
|
const props = getOptionProps(this);
|
||||||
|
this.itemCount = props.data.length;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* Phase 1: Initial should sync with default scroll top
|
||||||
|
*/
|
||||||
|
mounted() {
|
||||||
|
if (this.$refs.list) {
|
||||||
|
this.$refs.list.scrollTop = 0;
|
||||||
|
this.onScroll(null);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* Phase 4: Record used item height
|
||||||
|
* Phase 5: Trigger re-render to use correct position
|
||||||
|
*/
|
||||||
|
updated() {
|
||||||
|
this.$nextTick(() => {
|
||||||
|
const { status } = this.$data;
|
||||||
|
const { data, height, itemHeight, disabled, onSkipRender, virtual } = getOptionProps(this);
|
||||||
|
const prevData = this.cachedProps.data || [];
|
||||||
|
|
||||||
|
let changedItemIndex = null;
|
||||||
|
if (prevData.length !== data.length) {
|
||||||
|
const diff = findListDiffIndex(prevData, data, this.getItemKey);
|
||||||
|
changedItemIndex = diff ? diff.index : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (disabled) {
|
||||||
|
// Should trigger `onSkipRender` to tell that diff component is not render in the list
|
||||||
|
if (data.length > prevData.length) {
|
||||||
|
const { startIndex, endIndex } = this.$data;
|
||||||
|
if (
|
||||||
|
onSkipRender &&
|
||||||
|
(changedItemIndex === null ||
|
||||||
|
changedItemIndex < startIndex ||
|
||||||
|
endIndex < changedItemIndex)
|
||||||
|
) {
|
||||||
|
onSkipRender();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isVirtual = requireVirtual(height, itemHeight, data.length, virtual);
|
||||||
|
let nextStatus = status;
|
||||||
|
if (this.$data.isVirtual !== isVirtual) {
|
||||||
|
nextStatus = isVirtual ? 'SWITCH_TO_VIRTUAL' : 'SWITCH_TO_RAW';
|
||||||
|
this.setState({
|
||||||
|
isVirtual,
|
||||||
|
status: nextStatus,
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* We will wait a tick to let list turn to virtual list.
|
||||||
|
* And then use virtual list sync logic to adjust the scroll.
|
||||||
|
*/
|
||||||
|
if (nextStatus === 'SWITCH_TO_VIRTUAL') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status === 'MEASURE_START') {
|
||||||
|
const { startIndex, itemIndex, itemOffsetPtg } = this.$data;
|
||||||
|
const { scrollTop } = this.$refs.list;
|
||||||
|
|
||||||
|
// Record here since measure item height will get warning in `render`
|
||||||
|
this.collectItemHeights();
|
||||||
|
|
||||||
|
// Calculate top visible item top offset
|
||||||
|
const locatedItemTop = getItemAbsoluteTop({
|
||||||
|
itemIndex,
|
||||||
|
itemOffsetPtg,
|
||||||
|
itemElementHeights: this.itemElementHeights,
|
||||||
|
scrollTop,
|
||||||
|
scrollPtg: getElementScrollPercentage(this.$refs.list),
|
||||||
|
clientHeight: this.$refs.list.clientHeight,
|
||||||
|
getItemKey: this.getIndexKey,
|
||||||
|
});
|
||||||
|
|
||||||
|
let startItemTop = locatedItemTop;
|
||||||
|
for (let index = itemIndex - 1; index >= startIndex; index -= 1) {
|
||||||
|
startItemTop -= this.itemElementHeights[this.getIndexKey(index)] || 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.setState({
|
||||||
|
status: 'MEASURE_DONE',
|
||||||
|
startItemTop,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status === 'SWITCH_TO_RAW') {
|
||||||
|
/**
|
||||||
|
* After virtual list back to raw list,
|
||||||
|
* we update the `scrollTop` to real top instead of percentage top.
|
||||||
|
*/
|
||||||
|
const {
|
||||||
|
cacheScroll: { itemIndex, relativeTop },
|
||||||
|
} = this.$data;
|
||||||
|
let rawTop = relativeTop;
|
||||||
|
for (let index = 0; index < itemIndex; index += 1) {
|
||||||
|
rawTop -= this.itemElementHeights[this.getIndexKey(index)] || 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.lockScroll = true;
|
||||||
|
this.$refs.list.current.scrollTop = -rawTop;
|
||||||
|
|
||||||
|
this.setState({
|
||||||
|
status: 'MEASURE_DONE',
|
||||||
|
itemIndex: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
this.lockScroll = false;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} else if (prevData.length !== data.length && changedItemIndex !== null && height) {
|
||||||
|
/**
|
||||||
|
* Re-calculate the item position since `data` length changed.
|
||||||
|
* [IMPORTANT] We use relative position calculate here.
|
||||||
|
*/
|
||||||
|
let { itemIndex: originItemIndex } = this.$data;
|
||||||
|
const {
|
||||||
|
itemOffsetPtg: originItemOffsetPtg,
|
||||||
|
startIndex: originStartIndex,
|
||||||
|
endIndex: originEndIndex,
|
||||||
|
scrollTop: originScrollTop,
|
||||||
|
} = this.$data;
|
||||||
|
|
||||||
|
// 1. Refresh item heights
|
||||||
|
this.collectItemHeights();
|
||||||
|
|
||||||
|
// 1. Get origin located item top
|
||||||
|
let originLocatedItemRelativeTop;
|
||||||
|
|
||||||
|
if (this.$data.status === 'SWITCH_TO_VIRTUAL') {
|
||||||
|
originItemIndex = 0;
|
||||||
|
originLocatedItemRelativeTop = -this.$data.scrollTop;
|
||||||
|
} else {
|
||||||
|
originLocatedItemRelativeTop = getItemRelativeTop({
|
||||||
|
itemIndex: originItemIndex,
|
||||||
|
itemOffsetPtg: originItemOffsetPtg,
|
||||||
|
itemElementHeights: this.itemElementHeights,
|
||||||
|
scrollPtg: getScrollPercentage({
|
||||||
|
scrollTop: originScrollTop,
|
||||||
|
scrollHeight: prevData.length * itemHeight,
|
||||||
|
clientHeight: this.$refs.list.current.clientHeight,
|
||||||
|
}),
|
||||||
|
clientHeight: this.$refs.list.current.clientHeight,
|
||||||
|
getItemKey: index => this.getIndexKey(index, this.cachedProps),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Find the compare item
|
||||||
|
let originCompareItemIndex = changedItemIndex - 1;
|
||||||
|
// Use next one since there are not more item before removed
|
||||||
|
if (originCompareItemIndex < 0) {
|
||||||
|
originCompareItemIndex = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Find the compare item top
|
||||||
|
const originCompareItemTop = getCompareItemRelativeTop({
|
||||||
|
locatedItemRelativeTop: originLocatedItemRelativeTop,
|
||||||
|
locatedItemIndex: originItemIndex,
|
||||||
|
compareItemIndex: originCompareItemIndex,
|
||||||
|
startIndex: originStartIndex,
|
||||||
|
endIndex: originEndIndex,
|
||||||
|
getItemKey: index => this.getIndexKey(index, this.cachedProps),
|
||||||
|
itemElementHeights: this.itemElementHeights,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (nextStatus === 'SWITCH_TO_RAW') {
|
||||||
|
/**
|
||||||
|
* We will record current measure relative item top and apply in raw list after list turned
|
||||||
|
*/
|
||||||
|
this.setState({
|
||||||
|
cacheScroll: {
|
||||||
|
itemIndex: originCompareItemIndex,
|
||||||
|
relativeTop: originCompareItemTop,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
this.internalScrollTo({
|
||||||
|
itemIndex: originCompareItemIndex,
|
||||||
|
relativeTop: originCompareItemTop,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else if (nextStatus === 'SWITCH_TO_RAW') {
|
||||||
|
// This is only trigger when height changes that all items can show in raw
|
||||||
|
// Let's reset back to top
|
||||||
|
this.setState({
|
||||||
|
cacheScroll: {
|
||||||
|
itemIndex: 0,
|
||||||
|
relativeTop: 0,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
this.cachedProps = getOptionProps(this);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
getDerivedStateFromProps(nextProps) {
|
||||||
|
if (!nextProps.disabled) {
|
||||||
|
return {
|
||||||
|
itemCount: nextProps.data.length,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* Phase 2: Trigger render since we should re-calculate current position.
|
||||||
|
*/
|
||||||
|
onScroll(e) {
|
||||||
|
const { data, height, itemHeight, disabled } = this.$props;
|
||||||
|
|
||||||
|
const { scrollTop: originScrollTop, clientHeight, scrollHeight } = this.$refs.list;
|
||||||
|
const scrollTop = alignScrollTop(originScrollTop, scrollHeight - clientHeight);
|
||||||
|
|
||||||
|
// Skip if `scrollTop` not change to avoid shake
|
||||||
|
if (scrollTop === this.$data.scrollTop || this.lockScroll || disabled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const scrollPtg = getElementScrollPercentage(this.$refs.list);
|
||||||
|
const visibleCount = Math.ceil(height / itemHeight);
|
||||||
|
|
||||||
|
const { itemIndex, itemOffsetPtg, startIndex, endIndex } = getRangeIndex(
|
||||||
|
scrollPtg,
|
||||||
|
data.length,
|
||||||
|
visibleCount,
|
||||||
|
);
|
||||||
|
|
||||||
|
this.setState({
|
||||||
|
status: 'MEASURE_START',
|
||||||
|
scrollTop,
|
||||||
|
itemIndex,
|
||||||
|
itemOffsetPtg,
|
||||||
|
startIndex,
|
||||||
|
endIndex,
|
||||||
|
});
|
||||||
|
|
||||||
|
this.triggerOnScroll(e);
|
||||||
|
},
|
||||||
|
onRawScroll(e) {
|
||||||
|
const { scrollTop } = this.$refs.list;
|
||||||
|
this.setState({ scrollTop });
|
||||||
|
this.triggerOnScroll(e);
|
||||||
|
},
|
||||||
|
triggerOnScroll(e) {
|
||||||
|
if (e) {
|
||||||
|
this.$emit('scroll', e);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* Phase 4: Render item and get all the visible items height
|
||||||
|
*/
|
||||||
|
renderChildren(list, startIndex, renderFunc) {
|
||||||
|
const { status } = this.$data;
|
||||||
|
// We should measure rendered item height
|
||||||
|
return list.map((item, index) => {
|
||||||
|
const eleIndex = startIndex + index;
|
||||||
|
const node = renderFunc(item, eleIndex, {
|
||||||
|
style: status === 'MEASURE_START' ? { visibility: 'hidden' } : {},
|
||||||
|
});
|
||||||
|
const eleKey = this.getIndexKey(eleIndex);
|
||||||
|
|
||||||
|
// Pass `key` and `ref` for internal measure
|
||||||
|
return cloneElement(node, {
|
||||||
|
key: eleKey,
|
||||||
|
ref: eleKey,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
},
|
||||||
|
getIndexKey(index, props) {
|
||||||
|
const mergedProps = props || getOptionProps(this);
|
||||||
|
const { data = [] } = mergedProps;
|
||||||
|
|
||||||
|
// Return ghost key as latest index item
|
||||||
|
if (index === data.length) {
|
||||||
|
return GHOST_ITEM_KEY;
|
||||||
|
}
|
||||||
|
|
||||||
|
const item = data[index];
|
||||||
|
if (!item) {
|
||||||
|
/* istanbul ignore next */
|
||||||
|
console.error('Not find index item. Please report this since it is a bug.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.getItemKey(item, mergedProps);
|
||||||
|
},
|
||||||
|
getItemKey(item, props) {
|
||||||
|
const { itemKey } = props || getOptionProps(this);
|
||||||
|
|
||||||
|
return typeof itemKey === 'function' ? itemKey(item) : item[itemKey];
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* Collect current rendered dom element item heights
|
||||||
|
*/
|
||||||
|
collectItemHeights(range) {
|
||||||
|
const { startIndex, endIndex } = range || this.$data;
|
||||||
|
const { data } = getOptionProps(this);
|
||||||
|
|
||||||
|
// Record here since measure item height will get warning in `render`
|
||||||
|
for (let index = startIndex; index <= endIndex; index += 1) {
|
||||||
|
const item = data[index];
|
||||||
|
|
||||||
|
// Only collect exist item height
|
||||||
|
if (item) {
|
||||||
|
const eleKey = this.getItemKey(item);
|
||||||
|
this.itemElementHeights[eleKey] = getNodeHeight(this.refs[`itemElement-${eleKey}`]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
internalScrollTo(relativeScroll) {
|
||||||
|
const { itemIndex: compareItemIndex, relativeTop: compareItemRelativeTop } = relativeScroll;
|
||||||
|
const { scrollTop: originScrollTop } = this.$data;
|
||||||
|
const { data, itemHeight, height } = getOptionProps(this);
|
||||||
|
|
||||||
|
// 1. Find the best match compare item top
|
||||||
|
let bestSimilarity = Number.MAX_VALUE;
|
||||||
|
let bestScrollTop = null;
|
||||||
|
let bestItemIndex = null;
|
||||||
|
let bestItemOffsetPtg = null;
|
||||||
|
let bestStartIndex = null;
|
||||||
|
let bestEndIndex = null;
|
||||||
|
|
||||||
|
let missSimilarity = 0;
|
||||||
|
|
||||||
|
const scrollHeight = data.length * itemHeight;
|
||||||
|
const { clientHeight } = this.$refs.list;
|
||||||
|
const maxScrollTop = scrollHeight - clientHeight;
|
||||||
|
|
||||||
|
for (let i = 0; i < maxScrollTop; i += 1) {
|
||||||
|
const scrollTop = getIndexByStartLoc(0, maxScrollTop, originScrollTop, i);
|
||||||
|
|
||||||
|
const scrollPtg = getScrollPercentage({ scrollTop, scrollHeight, clientHeight });
|
||||||
|
const visibleCount = Math.ceil(height / itemHeight);
|
||||||
|
|
||||||
|
const { itemIndex, itemOffsetPtg, startIndex, endIndex } = getRangeIndex(
|
||||||
|
scrollPtg,
|
||||||
|
data.length,
|
||||||
|
visibleCount,
|
||||||
|
);
|
||||||
|
|
||||||
|
// No need to check if compare item out of the index to save performance
|
||||||
|
if (startIndex <= compareItemIndex && compareItemIndex <= endIndex) {
|
||||||
|
// 1.1 Get measure located item relative top
|
||||||
|
const locatedItemRelativeTop = getItemRelativeTop({
|
||||||
|
itemIndex,
|
||||||
|
itemOffsetPtg,
|
||||||
|
itemElementHeights: this.itemElementHeights,
|
||||||
|
scrollPtg,
|
||||||
|
clientHeight,
|
||||||
|
getItemKey: this.getIndexKey,
|
||||||
|
});
|
||||||
|
|
||||||
|
const compareItemTop = getCompareItemRelativeTop({
|
||||||
|
locatedItemRelativeTop,
|
||||||
|
locatedItemIndex: itemIndex,
|
||||||
|
compareItemIndex, // Same as origin index
|
||||||
|
startIndex,
|
||||||
|
endIndex,
|
||||||
|
getItemKey: this.getIndexKey,
|
||||||
|
itemElementHeights: this.itemElementHeights,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 1.2 Find best match compare item top
|
||||||
|
const similarity = Math.abs(compareItemTop - compareItemRelativeTop);
|
||||||
|
if (similarity < bestSimilarity) {
|
||||||
|
bestSimilarity = similarity;
|
||||||
|
bestScrollTop = scrollTop;
|
||||||
|
bestItemIndex = itemIndex;
|
||||||
|
bestItemOffsetPtg = itemOffsetPtg;
|
||||||
|
bestStartIndex = startIndex;
|
||||||
|
bestEndIndex = endIndex;
|
||||||
|
|
||||||
|
missSimilarity = 0;
|
||||||
|
} else {
|
||||||
|
missSimilarity += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If keeping 10 times not match similarity,
|
||||||
|
// check more scrollTop is meaningless.
|
||||||
|
// Here boundary is set to 10.
|
||||||
|
if (missSimilarity > 10) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Re-scroll if has best scroll match
|
||||||
|
if (bestScrollTop !== null) {
|
||||||
|
this.lockScroll = true;
|
||||||
|
this.$refs.list.current.scrollTop = bestScrollTop;
|
||||||
|
|
||||||
|
this.setState({
|
||||||
|
status: 'MEASURE_START',
|
||||||
|
scrollTop: bestScrollTop,
|
||||||
|
itemIndex: bestItemIndex,
|
||||||
|
itemOffsetPtg: bestItemOffsetPtg,
|
||||||
|
startIndex: bestStartIndex,
|
||||||
|
endIndex: bestEndIndex,
|
||||||
|
});
|
||||||
|
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
this.lockScroll = false;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
render() {
|
||||||
|
const { isVirtual, itemCount } = this.$data;
|
||||||
|
const {
|
||||||
|
prefixCls,
|
||||||
|
height,
|
||||||
|
itemHeight,
|
||||||
|
fullHeight = true,
|
||||||
|
component: Component = 'div',
|
||||||
|
data,
|
||||||
|
children,
|
||||||
|
itemKey,
|
||||||
|
onSkipRender,
|
||||||
|
disabled,
|
||||||
|
virtual,
|
||||||
|
...restProps
|
||||||
|
} = getOptionProps(this);
|
||||||
|
const style = getStyle(this);
|
||||||
|
|
||||||
|
if (!isVirtual) {
|
||||||
|
/**
|
||||||
|
* Virtual list switch is works on component updated.
|
||||||
|
* We should double check here if need cut the content.
|
||||||
|
*/
|
||||||
|
const shouldVirtual = requireVirtual(height, itemHeight, data.length, virtual);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use virtual list
|
||||||
|
const mergedStyle = {
|
||||||
|
...style,
|
||||||
|
height,
|
||||||
|
...ScrollStyle,
|
||||||
|
};
|
||||||
|
|
||||||
|
const { status, startIndex, endIndex, startItemTop } = this.$data;
|
||||||
|
const contentHeight = itemCount * itemHeight * ITEM_SCALE_RATE;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Component style={mergedStyle} {...restProps} onScroll={this.onScroll} ref="list">
|
||||||
|
<Filler
|
||||||
|
prefixCls={prefixCls}
|
||||||
|
height={contentHeight}
|
||||||
|
offset={status === 'MEASURE_DONE' ? startItemTop : 0}
|
||||||
|
>
|
||||||
|
{this.renderChildren(data.slice(startIndex, endIndex + 1), startIndex, children)}
|
||||||
|
</Filler>
|
||||||
|
</Component>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
|
@ -0,0 +1,3 @@
|
||||||
|
import List from './List';
|
||||||
|
|
||||||
|
export default List;
|
|
@ -0,0 +1,81 @@
|
||||||
|
/**
|
||||||
|
* Get index with specific start index one by one. e.g.
|
||||||
|
* min: 3, max: 9, start: 6
|
||||||
|
*
|
||||||
|
* Return index is:
|
||||||
|
* [0]: 6
|
||||||
|
* [1]: 7
|
||||||
|
* [2]: 5
|
||||||
|
* [3]: 8
|
||||||
|
* [4]: 4
|
||||||
|
* [5]: 9
|
||||||
|
* [6]: 3
|
||||||
|
*/
|
||||||
|
export function getIndexByStartLoc(min, max, start, index) {
|
||||||
|
const beforeCount = start - min;
|
||||||
|
const afterCount = max - start;
|
||||||
|
const balanceCount = Math.min(beforeCount, afterCount) * 2;
|
||||||
|
|
||||||
|
// Balance
|
||||||
|
if (index <= balanceCount) {
|
||||||
|
const stepIndex = Math.floor(index / 2);
|
||||||
|
if (index % 2) {
|
||||||
|
return start + stepIndex + 1;
|
||||||
|
}
|
||||||
|
return start - stepIndex;
|
||||||
|
}
|
||||||
|
|
||||||
|
// One is out of range
|
||||||
|
if (beforeCount > afterCount) {
|
||||||
|
return start - (index - afterCount);
|
||||||
|
}
|
||||||
|
return start + (index - beforeCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* We assume that 2 list has only 1 item diff and others keeping the order.
|
||||||
|
* So we can use dichotomy algorithm to find changed one.
|
||||||
|
*/
|
||||||
|
export function findListDiffIndex(originList, targetList, getKey) {
|
||||||
|
const originLen = originList.length;
|
||||||
|
const targetLen = targetList.length;
|
||||||
|
|
||||||
|
let shortList;
|
||||||
|
let longList;
|
||||||
|
|
||||||
|
if (originLen === 0 && targetLen === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (originLen < targetLen) {
|
||||||
|
shortList = originList;
|
||||||
|
longList = targetList;
|
||||||
|
} else {
|
||||||
|
shortList = targetList;
|
||||||
|
longList = originList;
|
||||||
|
}
|
||||||
|
|
||||||
|
const notExistKey = { __EMPTY_ITEM__: true };
|
||||||
|
function getItemKey(item) {
|
||||||
|
if (item !== undefined) {
|
||||||
|
return getKey(item);
|
||||||
|
}
|
||||||
|
return notExistKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Loop to find diff one
|
||||||
|
let diffIndex = null;
|
||||||
|
let multiple = Math.abs(originLen - targetLen) !== 1;
|
||||||
|
for (let i = 0; i < longList.length; i += 1) {
|
||||||
|
const shortKey = getItemKey(shortList[i]);
|
||||||
|
const longKey = getItemKey(longList[i]);
|
||||||
|
|
||||||
|
if (shortKey !== longKey) {
|
||||||
|
diffIndex = i;
|
||||||
|
multiple = multiple || shortKey !== getItemKey(longList[i + 1]);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return diffIndex === null ? null : { index: diffIndex, multiple };
|
||||||
|
}
|
|
@ -0,0 +1,147 @@
|
||||||
|
/**
|
||||||
|
* Our algorithm have additional one ghost item
|
||||||
|
* whose index as `data.length` to simplify the calculation
|
||||||
|
*/
|
||||||
|
export const GHOST_ITEM_KEY = '__vc_ghost_item__';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Safari has the elasticity effect which provides negative `scrollTop` value.
|
||||||
|
* We should ignore it since will make scroll animation shake.
|
||||||
|
*/
|
||||||
|
export function alignScrollTop(scrollTop, scrollRange) {
|
||||||
|
if (scrollTop < 0) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
if (scrollTop >= scrollRange) {
|
||||||
|
return scrollRange;
|
||||||
|
}
|
||||||
|
|
||||||
|
return scrollTop;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get node `offsetHeight`. We prefer node is a dom element directly.
|
||||||
|
* But if not provided, downgrade to `findDOMNode` to get the real dom element.
|
||||||
|
*/
|
||||||
|
export function getNodeHeight(node) {
|
||||||
|
return node ? node.offsetHeight : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate the located item absolute top with whole scroll height
|
||||||
|
*/
|
||||||
|
export function getItemAbsoluteTop({ scrollTop, ...rest }) {
|
||||||
|
return scrollTop + getItemRelativeTop(rest);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate the located item related top with current window height
|
||||||
|
*/
|
||||||
|
export function getItemRelativeTop({
|
||||||
|
itemIndex,
|
||||||
|
itemOffsetPtg,
|
||||||
|
itemElementHeights,
|
||||||
|
scrollPtg,
|
||||||
|
clientHeight,
|
||||||
|
getItemKey,
|
||||||
|
}) {
|
||||||
|
const locatedItemHeight = itemElementHeights[getItemKey(itemIndex)] || 0;
|
||||||
|
const locatedItemTop = scrollPtg * clientHeight;
|
||||||
|
const locatedItemOffset = itemOffsetPtg * locatedItemHeight;
|
||||||
|
return Math.floor(locatedItemTop - locatedItemOffset);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getCompareItemRelativeTop({
|
||||||
|
locatedItemRelativeTop,
|
||||||
|
locatedItemIndex,
|
||||||
|
compareItemIndex,
|
||||||
|
startIndex,
|
||||||
|
endIndex,
|
||||||
|
getItemKey,
|
||||||
|
itemElementHeights,
|
||||||
|
}) {
|
||||||
|
let originCompareItemTop = locatedItemRelativeTop;
|
||||||
|
const compareItemKey = getItemKey(compareItemIndex);
|
||||||
|
|
||||||
|
if (compareItemIndex <= locatedItemIndex) {
|
||||||
|
for (let index = locatedItemIndex; index >= startIndex; index -= 1) {
|
||||||
|
const key = getItemKey(index);
|
||||||
|
if (key === compareItemKey) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
const prevItemKey = getItemKey(index - 1);
|
||||||
|
originCompareItemTop -= itemElementHeights[prevItemKey] || 0;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
for (let index = locatedItemIndex; index <= endIndex; index += 1) {
|
||||||
|
const key = getItemKey(index);
|
||||||
|
if (key === compareItemKey) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
originCompareItemTop += itemElementHeights[key] || 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return originCompareItemTop;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getScrollPercentage({ scrollTop, scrollHeight, clientHeight }) {
|
||||||
|
if (scrollHeight <= clientHeight) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const scrollRange = scrollHeight - clientHeight;
|
||||||
|
const alignedScrollTop = alignScrollTop(scrollTop, scrollRange);
|
||||||
|
const scrollTopPtg = alignedScrollTop / scrollRange;
|
||||||
|
return scrollTopPtg;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getElementScrollPercentage(element) {
|
||||||
|
if (!element) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return getScrollPercentage(element);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get location item and its align percentage with the scroll percentage.
|
||||||
|
* We should measure current scroll position to decide which item is the location item.
|
||||||
|
* And then fill the top count and bottom count with the base of location item.
|
||||||
|
*
|
||||||
|
* `total` should be the real count instead of `total - 1` in calculation.
|
||||||
|
*/
|
||||||
|
function getLocationItem(scrollPtg, total) {
|
||||||
|
const itemIndex = Math.floor(scrollPtg * total);
|
||||||
|
const itemTopPtg = itemIndex / total;
|
||||||
|
const itemBottomPtg = (itemIndex + 1) / total;
|
||||||
|
const itemOffsetPtg = (scrollPtg - itemTopPtg) / (itemBottomPtg - itemTopPtg);
|
||||||
|
|
||||||
|
return {
|
||||||
|
index: itemIndex,
|
||||||
|
offsetPtg: itemOffsetPtg,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get display items start, end, located item index. This is pure math calculation
|
||||||
|
*/
|
||||||
|
export function getRangeIndex(scrollPtg, itemCount, visibleCount) {
|
||||||
|
const { index, offsetPtg } = getLocationItem(scrollPtg, itemCount);
|
||||||
|
|
||||||
|
const beforeCount = Math.ceil(scrollPtg * visibleCount);
|
||||||
|
const afterCount = Math.ceil((1 - scrollPtg) * visibleCount);
|
||||||
|
|
||||||
|
return {
|
||||||
|
itemIndex: index,
|
||||||
|
itemOffsetPtg: offsetPtg,
|
||||||
|
startIndex: Math.max(0, index - beforeCount),
|
||||||
|
endIndex: Math.min(itemCount - 1, index + afterCount),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function requireVirtual(height, itemHeight, count, virtual) {
|
||||||
|
return virtual !== false && typeof height === 'number' && count * itemHeight > height;
|
||||||
|
}
|
Loading…
Reference in New Issue