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 isPlainObject from 'lodash-es/isPlainObject';
|
||||||
import { toType, getType, isFunction, validateType, isInteger, isArray, warn } from './utils';
|
import { toType, getType, isFunction, validateType, isInteger, isArray, warn } from './utils';
|
||||||
|
|
||||||
const VuePropTypes = {
|
const PropTypes = {
|
||||||
get any() {
|
get any() {
|
||||||
return toType('any', {
|
return toType('any', {
|
||||||
type: null,
|
type: null,
|
||||||
|
@ -244,7 +244,7 @@ const typeDefaults = () => ({
|
||||||
|
|
||||||
let currentDefaults = typeDefaults();
|
let currentDefaults = typeDefaults();
|
||||||
|
|
||||||
Object.defineProperty(VuePropTypes, 'sensibleDefaults', {
|
Object.defineProperty(PropTypes, 'sensibleDefaults', {
|
||||||
enumerable: false,
|
enumerable: false,
|
||||||
set(value) {
|
set(value) {
|
||||||
if (value === false) {
|
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 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 outerStyle = {};
|
||||||
|
|
||||||
let innerStyle = {
|
let innerStyle = {
|
||||||
|
@ -9,7 +10,7 @@ const Filter = ({ height, offset, prefixCls }, { slots }) => {
|
||||||
};
|
};
|
||||||
|
|
||||||
if (offset !== undefined) {
|
if (offset !== undefined) {
|
||||||
outerStyle = { height, position: 'relative', overflow: 'hidden' };
|
outerStyle = { height: `${height}px`, position: 'relative', overflow: 'hidden' };
|
||||||
|
|
||||||
innerStyle = {
|
innerStyle = {
|
||||||
...innerStyle,
|
...innerStyle,
|
||||||
|
@ -23,16 +24,34 @@ const Filter = ({ height, offset, prefixCls }, { slots }) => {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={outerStyle}>
|
<div style={outerStyle}>
|
||||||
<div
|
<ResizeObserver
|
||||||
style={innerStyle}
|
onResize={({ offsetHeight }) => {
|
||||||
class={classNames({
|
if (offsetHeight && onInnerResize) {
|
||||||
[`${prefixCls}-holder-inner`]: prefixCls,
|
onInnerResize();
|
||||||
})}
|
}
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{slots.default?.()}
|
<div
|
||||||
</div>
|
style={innerStyle}
|
||||||
|
class={classNames({
|
||||||
|
[`${prefixCls}-holder-inner`]: prefixCls,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{slots.default?.()}
|
||||||
|
</div>
|
||||||
|
</ResizeObserver>
|
||||||
</div>
|
</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;
|
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 Filler from './Filler';
|
||||||
import {
|
import Item from './Item';
|
||||||
getNodeHeight,
|
import ScrollBar from './ScrollBar';
|
||||||
requireVirtual,
|
import useHeights from './hooks/useHeights';
|
||||||
getElementScrollPercentage,
|
import useScrollTo from './hooks/useScrollTo';
|
||||||
getRangeIndex,
|
// import useDiffItem from './hooks/useDiffItem';
|
||||||
alignScrollTop,
|
import useFrameWheel from './hooks/useFrameWheel';
|
||||||
getItemAbsoluteTop,
|
import useMobileTouchMove from './hooks/useMobileTouchMove';
|
||||||
getItemRelativeTop,
|
import useOriginScroll from './hooks/useOriginScroll';
|
||||||
getScrollPercentage,
|
import PropTypes from '../_util/vue-types';
|
||||||
getCompareItemRelativeTop,
|
import { computed, nextTick, reactive, ref, watchEffect } from 'vue';
|
||||||
GHOST_ITEM_KEY,
|
import classNames from '../_util/classNames';
|
||||||
} from './utils/itemUtil';
|
import createRef from '../_util/createRef';
|
||||||
import { getIndexByStartLoc, findListDiffIndex } from './utils/algorithmUtil';
|
|
||||||
|
|
||||||
const ITEM_SCALE_RATE = 1;
|
const EMPTY_DATA = [];
|
||||||
|
|
||||||
const ScrollStyle = {
|
const ScrollStyle = {
|
||||||
overflowY: 'auto',
|
overflowY: 'auto',
|
||||||
overflowAnchor: 'none',
|
overflowAnchor: 'none',
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
function renderChildren(list, startIndex, endIndex, setNodeRef, renderFunc, { getKey }) {
|
||||||
*
|
return list.slice(startIndex, endIndex + 1).map((item, index) => {
|
||||||
* Virtual list display logic:
|
const eleIndex = startIndex + index;
|
||||||
* 1. scroll / initialize trigger measure
|
const node = renderFunc(item, eleIndex, {
|
||||||
* 2. Get location item of current `scrollTop`
|
// style: status === 'MEASURE_START' ? { visibility: 'hidden' } : {},
|
||||||
* 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);
|
|
||||||
});
|
});
|
||||||
},
|
const key = getKey(item);
|
||||||
methods: {
|
return (
|
||||||
getDerivedStateFromProps(nextProps) {
|
<Item key={key} setRef={ele => setNodeRef(item, ele)}>
|
||||||
if (!nextProps.disabled) {
|
{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 {
|
return {
|
||||||
itemCount: nextProps.data.length,
|
scrollHeight: undefined,
|
||||||
|
start: 0,
|
||||||
|
end: state.mergedData.length - 1,
|
||||||
|
offset: undefined,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
let itemTop = 0;
|
||||||
},
|
let startIndex;
|
||||||
/**
|
let startOffset;
|
||||||
* Phase 2: Trigger render since we should re-calculate current position.
|
let endIndex;
|
||||||
*/
|
// eslint-disable-next-line no-console
|
||||||
onScroll(e) {
|
console.log('updatedMark', updatedMark);
|
||||||
const { data, height, itemHeight, disabled } = this.$props;
|
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 cacheHeight = heights.get(key);
|
||||||
const scrollTop = alignScrollTop(originScrollTop, scrollHeight - clientHeight);
|
const currentItemBottom =
|
||||||
|
itemTop + (cacheHeight === undefined ? props.itemHeight : cacheHeight);
|
||||||
|
|
||||||
// Skip if `scrollTop` not change to avoid shake
|
// Check item top in the range
|
||||||
if (scrollTop === this.$data.scrollTop || this.lockScroll || disabled) {
|
if (currentItemBottom >= state.scrollTop && startIndex === undefined) {
|
||||||
return;
|
startIndex = i;
|
||||||
}
|
startOffset = itemTop;
|
||||||
|
|
||||||
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 item bottom in the range. We will render additional one item for motion usage
|
||||||
// check more scrollTop is meaningless.
|
if (currentItemBottom > state.scrollTop + props.height && endIndex === undefined) {
|
||||||
// Here boundary is set to 10.
|
endIndex = i;
|
||||||
if (missSimilarity > 10) {
|
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
itemTop = currentItemBottom;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Re-scroll if has best scroll match
|
// Fallback to normal if not match. This code should never reach
|
||||||
if (bestScrollTop !== null) {
|
/* istanbul ignore next */
|
||||||
this.lockScroll = true;
|
if (startIndex === undefined) {
|
||||||
this.$refs.list.current.scrollTop = bestScrollTop;
|
startIndex = 0;
|
||||||
|
startOffset = 0;
|
||||||
this.setState({
|
}
|
||||||
status: 'MEASURE_START',
|
if (endIndex === undefined) {
|
||||||
scrollTop: bestScrollTop,
|
endIndex = state.mergedData.length - 1;
|
||||||
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) {
|
// Give cache to improve scroll experience
|
||||||
/**
|
endIndex = Math.min(endIndex + 1, state.mergedData.length);
|
||||||
* Virtual list switch is works on component updated.
|
rangeRef.value.start = startIndex;
|
||||||
* We should double check here if need cut the content.
|
rangeRef.value.end = endIndex;
|
||||||
*/
|
return {
|
||||||
const shouldVirtual = requireVirtual(height, itemHeight, data.length, virtual);
|
scrollHeight: itemTop,
|
||||||
|
start: startIndex,
|
||||||
|
end: endIndex,
|
||||||
|
offset: startOffset,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
// =============================== In Range ===============================
|
||||||
|
const maxScrollHeight = computed(() => calRes.scrollHeight - props.height);
|
||||||
|
|
||||||
return (
|
function keepInRange(newScrollTop) {
|
||||||
<Component
|
let newTop = Math.max(newScrollTop, 0);
|
||||||
style={
|
if (!Number.isNaN(maxScrollHeight.value)) {
|
||||||
height
|
newTop = Math.min(newTop, maxScrollHeight.value);
|
||||||
? { ...style, [fullHeight ? 'height' : 'maxHeight']: height, ...ScrollStyle }
|
}
|
||||||
: style
|
return newTop;
|
||||||
}
|
|
||||||
{...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 isScrollAtTop = computed(() => state.scrollTop <= 0);
|
||||||
const mergedStyle = {
|
const isScrollAtBottom = computed(() => state.scrollTop >= maxScrollHeight.value);
|
||||||
...style,
|
|
||||||
height,
|
|
||||||
...ScrollStyle,
|
|
||||||
};
|
|
||||||
|
|
||||||
const { status, startIndex, endIndex, startItemTop } = this.$data;
|
const originScroll = useOriginScroll(isScrollAtTop, isScrollAtBottom);
|
||||||
const contentHeight = itemCount * itemHeight * ITEM_SCALE_RATE;
|
|
||||||
|
// ================================ 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 (
|
return (
|
||||||
<Component style={mergedStyle} {...restProps} onScroll={this.onScroll} ref="list">
|
<div
|
||||||
<Filler
|
style={{
|
||||||
prefixCls={prefixCls}
|
...style,
|
||||||
height={contentHeight}
|
position: 'relative',
|
||||||
offset={status === 'MEASURE_DONE' ? startItemTop : 0}
|
}}
|
||||||
|
class={mergedClassName}
|
||||||
|
{...restProps}
|
||||||
|
>
|
||||||
|
<Component
|
||||||
|
class={`${prefixCls}-holder`}
|
||||||
|
style={componentStyle}
|
||||||
|
ref={componentRef}
|
||||||
|
onScroll={onFallbackScroll}
|
||||||
>
|
>
|
||||||
{this.renderChildren(data.slice(startIndex, endIndex + 1), startIndex, children)}
|
<Filler
|
||||||
</Filler>
|
prefixCls={prefixCls}
|
||||||
</Component>
|
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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<script>
|
<script>
|
||||||
import demo from '../antdv-demo/docs/input/demo/basic.md';
|
import demo from '../components/vc-virtual-list/examples/basic.jsx';
|
||||||
export default {
|
export default {
|
||||||
components: {
|
components: {
|
||||||
demo,
|
demo,
|
||||||
|
|
Loading…
Reference in New Issue