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
	
	 tanjinzhou
						tanjinzhou