import Filler from './Filler'; import Item from './Item'; import ScrollBar from './ScrollBar'; import useHeights from './hooks/useHeights'; import useScrollTo from './hooks/useScrollTo'; 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, watchEffect } from 'vue'; import classNames from '../_util/classNames'; import createRef from '../_util/createRef'; const EMPTY_DATA = []; const ScrollStyle = { overflowY: 'auto', overflowAnchor: 'none', }; 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' } : {}, }); const key = getKey(item); return ( setNodeRef(item, ele)}> {node} ); }); } const ListProps = { prefixCls: PropTypes.string, data: PropTypes.array, height: PropTypes.number, itemHeight: PropTypes.number, /** If not match virtual scroll condition, Set List still use height of container. */ fullHeight: PropTypes.bool.def(true), 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); if (componentRef.current) { componentRef.current.scrollTop = alignedTop; } state.scrollTop = alignedTop; } // ================================ Height ================================ const [setInstance, collectHeight, heights] = useHeights(getKey, null, null); // ========================== Visible Calculation ========================= const calRes = computed(() => { if (!inVirtual.value) { return { scrollHeight: undefined, start: 0, end: state.mergedData.length - 1, offset: undefined, }; } let itemTop = 0; let startIndex; let startOffset; let endIndex; const dataLen = state.mergedData.length; for (let i = 0; i < dataLen; i += 1) { const item = state.mergedData[i]; const key = getKey(item); const cacheHeight = heights[key]; const currentItemBottom = itemTop + (cacheHeight === undefined ? props.itemHeight : cacheHeight); if (currentItemBottom >= state.scrollTop && startIndex === undefined) { startIndex = i; startOffset = itemTop; } // 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; } // 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; } // Give cache to improve scroll experience endIndex = Math.min(endIndex + 1, state.mergedData.length); return { scrollHeight: itemTop, start: startIndex, end: endIndex, offset: startOffset, }; }); // =============================== In Range =============================== const maxScrollHeight = computed(() => calRes.value.scrollHeight - props.height); function keepInRange(newScrollTop) { let newTop = Math.max(newScrollTop, 0); if (!Number.isNaN(maxScrollHeight.value)) { newTop = Math.min(newTop, maxScrollHeight.value); } return newTop; } const isScrollAtTop = computed(() => state.scrollTop <= 0); const isScrollAtBottom = computed(() => state.scrollTop >= maxScrollHeight.value); 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(() => { if (componentRef.current) { // Firefox only function onMozMousePixelScroll(e) { if (inVirtual.value) { e.preventDefault(); } } componentRef.current.removeEventListener('wheel', onRawWheel); componentRef.current.removeEventListener('DOMMouseScroll', onFireFoxScroll); componentRef.current.removeEventListener('MozMousePixelScroll', onMozMousePixelScroll); componentRef.current.addEventListener('wheel', onRawWheel); componentRef.current.addEventListener('DOMMouseScroll', onFireFoxScroll); componentRef.current.addEventListener('MozMousePixelScroll', onMozMousePixelScroll); } }); }); // ================================= Ref ================================== const scrollTo = useScrollTo( componentRef, state, heights, props, 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, 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 (
{listChildren} {inVirtual && ( { this.state.scrollMoving = true; }} onStopMove={() => { this.state.scrollMoving = false; }} /> )}
); }, }; export default List;