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,6 +24,13 @@ const Filter = ({ height, offset, prefixCls }, { slots }) => { | |||
| 
 | ||||
|   return ( | ||||
|     <div style={outerStyle}> | ||||
|       <ResizeObserver | ||||
|         onResize={({ offsetHeight }) => { | ||||
|           if (offsetHeight && onInnerResize) { | ||||
|             onInnerResize(); | ||||
|           } | ||||
|         }} | ||||
|       > | ||||
|         <div | ||||
|           style={innerStyle} | ||||
|           class={classNames({ | ||||
|  | @ -31,8 +39,19 @@ const Filter = ({ height, offset, prefixCls }, { slots }) => { | |||
|         > | ||||
|           {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. | ||||
|  */ | ||||
| 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 ( | ||||
|       <Item key={key} setRef={ele => setNodeRef(item, ele)}> | ||||
|         {node} | ||||
|       </Item> | ||||
|     ); | ||||
|   }); | ||||
| } | ||||
| 
 | ||||
| export default { | ||||
|   name: 'List', | ||||
|   mixins: [BaseMixin], | ||||
|   props: { | ||||
| const ListProps = { | ||||
|   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, | ||||
|   component: PropTypes.any, | ||||
|   /** 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); | ||||
|   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; | ||||
|     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), | ||||
|       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]; | ||||
|     }; | ||||
|   }, | ||||
|   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; | ||||
|       } | ||||
|     const sharedConfig = { | ||||
|       getKey, | ||||
|     }; | ||||
| 
 | ||||
|       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; | ||||
|     // ================================ Scroll ================================ | ||||
|     function syncScrollTop(newTop) { | ||||
|       let value; | ||||
|       if (typeof newTop === 'function') { | ||||
|         value = newTop(state.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), | ||||
|           }); | ||||
|         value = newTop; | ||||
|       } | ||||
| 
 | ||||
|         // 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; | ||||
|       const alignedTop = keepInRange(value); | ||||
| 
 | ||||
|       componentRef.current.scrollTop = alignedTop; | ||||
|       return alignedTop; | ||||
|     } | ||||
| 
 | ||||
|         // 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, | ||||
|         }); | ||||
|     // ================================ Legacy ================================ | ||||
|     // Put ref here since the range is generate by follow | ||||
|     const rangeRef = ref({ start: 0, end: state.mergedData.length }); | ||||
| 
 | ||||
|         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, | ||||
|           }, | ||||
|         }); | ||||
|       } | ||||
|     // const diffItemRef = ref(); | ||||
|     // const [diffItem] = useDiffItem(mergedData, getKey); | ||||
|     // diffItemRef.current = diffItem; | ||||
| 
 | ||||
|       this.cachedProps = getOptionProps(this); | ||||
|     }); | ||||
|   }, | ||||
|   methods: { | ||||
|     getDerivedStateFromProps(nextProps) { | ||||
|       if (!nextProps.disabled) { | ||||
|     // ================================ 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; | ||||
|         // Check item top in the range | ||||
|         if (currentItemBottom >= state.scrollTop && startIndex === undefined) { | ||||
|           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; | ||||
|         // 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; | ||||
|         } | ||||
| 
 | ||||
|       const item = data[index]; | ||||
|       if (!item) { | ||||
|         itemTop = currentItemBottom; | ||||
|       } | ||||
| 
 | ||||
|       // Fallback to normal if not match. This code should never reach | ||||
|       /* istanbul ignore next */ | ||||
|         console.error('Not find index item. Please report this since it is a bug.'); | ||||
|       if (startIndex === undefined) { | ||||
|         startIndex = 0; | ||||
|         startOffset = 0; | ||||
|       } | ||||
|       if (endIndex === undefined) { | ||||
|         endIndex = state.mergedData.length - 1; | ||||
|       } | ||||
| 
 | ||||
|       return this.getItemKey(item, mergedProps); | ||||
|     }, | ||||
|     getItemKey(item, props) { | ||||
|       const { itemKey } = props || getOptionProps(this); | ||||
|       // 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 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}`]); | ||||
|     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; | ||||
|         }); | ||||
|       }, | ||||
|     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, | ||||
|     // 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); | ||||
| 
 | ||||
|           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; | ||||
|         // Firefox only | ||||
|         function onMozMousePixelScroll(e) { | ||||
|           if (inVirtual.value) { | ||||
|             e.preventDefault(); | ||||
|           } | ||||
|         } | ||||
| 
 | ||||
|         // If keeping 10 times not match similarity, | ||||
|         // check more scrollTop is meaningless. | ||||
|         // Here boundary is set to 10. | ||||
|         if (missSimilarity > 10) { | ||||
|           break; | ||||
|         } | ||||
|       } | ||||
| 
 | ||||
|       // 2. Re-scroll if has best scroll match | ||||
|       if (bestScrollTop !== null) { | ||||
|         this.lockScroll = true; | ||||
|         this.$refs.list.current.scrollTop = bestScrollTop; | ||||
| 
 | ||||
|         this.setState({ | ||||
|           status: 'MEASURE_START', | ||||
|           scrollTop: bestScrollTop, | ||||
|           itemIndex: bestItemIndex, | ||||
|           itemOffsetPtg: bestItemOffsetPtg, | ||||
|           startIndex: bestStartIndex, | ||||
|           endIndex: bestEndIndex, | ||||
|         }); | ||||
| 
 | ||||
|         requestAnimationFrame(() => { | ||||
|           requestAnimationFrame(() => { | ||||
|             this.lockScroll = false; | ||||
|         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 { isVirtual, itemCount } = this.$data; | ||||
|     const { style, class: className } = this.$attrs; | ||||
|     const { | ||||
|       prefixCls, | ||||
|       prefixCls = 'rc-virtual-list', | ||||
|       height, | ||||
|       itemHeight, | ||||
|       // eslint-disable-next-line no-unused-vars | ||||
|       fullHeight = true, | ||||
|       component: Component = 'div', | ||||
|       data, | ||||
|       children, | ||||
|       itemKey, | ||||
|       onSkipRender, | ||||
|       disabled, | ||||
|       virtual, | ||||
|       ...restProps | ||||
|     } = getOptionProps(this); | ||||
|     const style = getStyle(this); | ||||
| 
 | ||||
|     if (!isVirtual) { | ||||
|       /** | ||||
|        * Virtual list switch is works on component updated. | ||||
|        * We should double check here if need cut the content. | ||||
|        */ | ||||
|       const shouldVirtual = requireVirtual(height, itemHeight, data.length, virtual); | ||||
| 
 | ||||
|       return ( | ||||
|         <Component | ||||
|           style={ | ||||
|             height | ||||
|               ? { ...style, [fullHeight ? 'height' : 'maxHeight']: height, ...ScrollStyle } | ||||
|               : style | ||||
|           } | ||||
|           {...restProps} | ||||
|           onScroll={this.onRawScroll} | ||||
|           ref="list" | ||||
|         > | ||||
|           <Filler prefixCls={prefixCls} height={height}> | ||||
|             {this.renderChildren( | ||||
|               shouldVirtual ? data.slice(0, Math.ceil(height / itemHeight)) : data, | ||||
|               0, | ||||
|       component: Component = 'div', | ||||
|       onScroll, | ||||
|       children, | ||||
|             )} | ||||
|           </Filler> | ||||
|         </Component> | ||||
|       ...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, | ||||
|     ); | ||||
|     } | ||||
| 
 | ||||
|     // Use virtual list | ||||
|     const mergedStyle = { | ||||
|       ...style, | ||||
|       height, | ||||
|       ...ScrollStyle, | ||||
|     }; | ||||
| 
 | ||||
|     const { status, startIndex, endIndex, startItemTop } = this.$data; | ||||
|     const contentHeight = itemCount * itemHeight * ITEM_SCALE_RATE; | ||||
| 
 | ||||
|     return ( | ||||
|       <Component style={mergedStyle} {...restProps} onScroll={this.onScroll} ref="list"> | ||||
|       <div | ||||
|         style={{ | ||||
|           ...style, | ||||
|           position: 'relative', | ||||
|         }} | ||||
|         class={mergedClassName} | ||||
|         {...restProps} | ||||
|       > | ||||
|         <Component | ||||
|           class={`${prefixCls}-holder`} | ||||
|           style={componentStyle} | ||||
|           ref={componentRef} | ||||
|           onScroll={onFallbackScroll} | ||||
|         > | ||||
|           <Filler | ||||
|             prefixCls={prefixCls} | ||||
|           height={contentHeight} | ||||
|           offset={status === 'MEASURE_DONE' ? startItemTop : 0} | ||||
|             height={scrollHeight} | ||||
|             offset={offset} | ||||
|             onInnerResize={collectHeight} | ||||
|           > | ||||
|           {this.renderChildren(data.slice(startIndex, endIndex + 1), startIndex, children)} | ||||
|             {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
	
	 tanjinzhou
						tanjinzhou