feat: add virtual-list
parent
ab80874fa5
commit
3844466ff2
|
@ -0,0 +1,8 @@
|
|||
function createRef() {
|
||||
const func = function setRef(node) {
|
||||
func.current = node;
|
||||
};
|
||||
return func;
|
||||
}
|
||||
|
||||
export default createRef;
|
|
@ -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;
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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;
|
|
@ -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;
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
},
|
||||
};
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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;
|
|
@ -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 */
|
|
@ -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;
|
||||
}
|
|
@ -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;
|
|
@ -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;
|
|
@ -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;
|
|
@ -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>
|
||||
);
|
||||
});
|
||||
}
|
|
@ -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];
|
||||
}
|
|
@ -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];
|
||||
}
|
|
@ -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];
|
||||
}
|
|
@ -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);
|
||||
};
|
||||
});
|
||||
}
|
|
@ -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;
|
||||
};
|
||||
};
|
|
@ -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);
|
||||
}
|
||||
};
|
||||
}
|
|
@ -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,
|
||||
|
|
Loading…
Reference in New Issue