From 00e6ef65c72975adf78e8c96540e293a36786152 Mon Sep 17 00:00:00 2001 From: hubing1 Date: Thu, 14 Jul 2022 10:20:22 +0800 Subject: [PATCH 1/2] feat: Add virtual list component --- components/dropdown/menu.ts | 4 +- components/dropdown/menu.vdt | 29 +- components/select/demos/virtualList.md | 47 ++++ components/select/index.md | 1 + components/select/menu.vdt | 4 +- components/select/select.ts | 2 + components/virtualList/index.ts | 350 +++++++++++++++++++++++++ 7 files changed, 423 insertions(+), 14 deletions(-) create mode 100644 components/select/demos/virtualList.md create mode 100644 components/virtualList/index.ts diff --git a/components/dropdown/menu.ts b/components/dropdown/menu.ts index ffefc4d91..24d522a2e 100644 --- a/components/dropdown/menu.ts +++ b/components/dropdown/menu.ts @@ -7,7 +7,9 @@ import {useMenuKeyboard} from './useKeyboard'; import {useMouseOutsidable} from '../../hooks/useMouseOutsidable'; import {FeedbackCallback} from './usePosition'; -export interface DropdownMenuProps { } +export interface DropdownMenuProps { + virtualList?: boolean | string +} export interface DropdownMenuEvents { } export interface DropdownMenuBlocks { } diff --git a/components/dropdown/menu.vdt b/components/dropdown/menu.vdt index d32147911..809df5cb0 100644 --- a/components/dropdown/menu.vdt +++ b/components/dropdown/menu.vdt @@ -1,8 +1,9 @@ import {Transition} from 'intact'; import {makeMenuStyles} from './styles'; +import {Virtual} from '../virtualList'; const {value, trigger, container} = this.dropdown.get(); -const {children, className} = this.get(); +const {children, className, virtualList} = this.get(); const classNameObj = { 'k-dropdown-menu': true, @@ -18,14 +19,20 @@ const transition = $props.transition || {css: false, ...this.transition}; appear={true} {...transition} > -
- - {children} - -
- + + + \ No newline at end of file diff --git a/components/select/demos/virtualList.md b/components/select/demos/virtualList.md new file mode 100644 index 000000000..adc497cf8 --- /dev/null +++ b/components/select/demos/virtualList.md @@ -0,0 +1,47 @@ +--- +title: 虚拟列表 +order: 12 +--- + +给`Select`添加`virtualList`属性可以在`Option`过多时保证页面的性能,`virtualList`的值为true时开启,为false时关闭,也可以设置为`"auto"`,在`Option`数量超过200时则自动开启虚拟列表功能。 + +```vdt +import {Select, Option} from 'kpc'; + +
+ + You selected: {this.get('day')} +
+``` + +```ts +interface Props { + day?: string | null +} + +const list = []; +for(let i = 0;i < 2000; i++) { + list.push({ + title: `item - ${i}`, + id: i + }) +} + +export default class extends Component { + static template = template; + static defaults() { + return { + day: 100, + list: list + }; + } +} +``` + + +```styl +.k-select-option + width: 280px; +``` diff --git a/components/select/index.md b/components/select/index.md index 4d66202d2..a82d07f44 100644 --- a/components/select/index.md +++ b/components/select/index.md @@ -31,6 +31,7 @@ sidebar: doc | labelMap | 建立值`value`到展示标签`label`的映射,可以在`value`不在`Option`集合中时,依然能够正确展示相应的`label` | `Map` | `new Map()` | | card | 是否展示`card`模式 | `boolean` | `false` | | autoDisableArrow | 是否在没有更多可选项时,给箭头一个`disabled`状态来提示用户 | `boolean` | `false` | +| virtualList | 是否开启虚拟列表功能 | `boolean` | `"auto"` | `false` | ```ts export type Container = string | ((parentDom: Element, anchor: Node | null) => Element) diff --git a/components/select/menu.vdt b/components/select/menu.vdt index 7967f16e1..63b253dd2 100644 --- a/components/select/menu.vdt +++ b/components/select/menu.vdt @@ -11,7 +11,7 @@ import {context} from './useSearchable'; import {Tabs, Tab} from '../tabs'; let {children, className} = this.get(); -const {card, searchable, multiple} = this.select.get(); +const {card, searchable, multiple, virtualList} = this.select.get(); const classNameObj = { 'k-select-menu': true, @@ -86,6 +86,6 @@ if (searchable) { ); } - + {children} diff --git a/components/select/select.ts b/components/select/select.ts index 9b68d761f..12dcf6f9e 100644 --- a/components/select/select.ts +++ b/components/select/select.ts @@ -21,6 +21,7 @@ export interface SelectProps exte labelMap?: Map card?: boolean autoDisableArrow?: boolean + virtualList?: boolean | string } export interface SelectEvents extends BaseSelectEvents { } @@ -37,6 +38,7 @@ const typeDefs: Required> = { labelMap: Map, card: Boolean, autoDisableArrow: Boolean, + virtualList: Boolean, }; const defaults = (): Partial => ({ diff --git a/components/virtualList/index.ts b/components/virtualList/index.ts new file mode 100644 index 000000000..ed982c8ef --- /dev/null +++ b/components/virtualList/index.ts @@ -0,0 +1,350 @@ +import { + Component, + TypeDefs, + VNode, + nextTick, + Children, + createVNode as h, + createElementVNode, + className as getClassName, + normalizeChildren +} from 'intact'; + +export interface VirtualProps { + keeps?: number + buffer?: number + estimateSize?: number + total: number + isFixedType?: boolean + nativeProps?: any + start: number + end: number + enableVirtualList?: boolean | string +} + +export type ListRange = { + start: number + end: number + paddingTop: number + paddingBottom: number +} + +const CALC_TYPE = { + INIT: Symbol('init'), + FIXED: Symbol('fixed'), + DYNAMIC: Symbol('dynamic') +}; + +const typeDefs: Required> = { + keeps: Number, + buffer: Number, + estimateSize: Number, + total: Number, + isFixedType: Boolean, + nativeProps: Object, + start: Number, + end: Number, + enableVirtualList: [Boolean, String] +}; + +const defaults = (): Partial => ({ + keeps: 30, + buffer: 10 +}); + +export class Virtual extends Component { + static typeDefs = typeDefs; + static defaults = defaults; + static template = function(this: Virtual) { + const vt = this; + const children: Children = this.get('children'); + const nativeProps = this.$vNode.props?.nativeProps; + const enableVirtualList = this.get('enableVirtualList'); + + if(nativeProps.class) { + nativeProps.class = getClassName(nativeProps.class); + } + + const auto = enableVirtualList === 'auto' && this.normalizedVnodes.length < 200; + if(!enableVirtualList || auto) { + return h('div', nativeProps, children); + } + + const outerAttrs = { + ...nativeProps, + 'ev-scroll': function(this: HTMLDivElement) { + vt.handleScroll(this.scrollTop); + } + } + const {start, end} = this.get(); + const {paddingTop, paddingBottom} = this.getRange(); + const innerAttrs = {style: `padding-top: ${paddingTop}px; padding-bottom: ${paddingBottom}px`}; + const croppedChildren = this.normalizedVnodes.slice(start, end + 1); + const vnode = this.vnode = h('div', outerAttrs, h('div', innerAttrs, croppedChildren)); + + return vnode; + }; + + private vnode: VNode | null = null; + + private normalizedVnodes: VNode[] = []; + + private hasComputedAll: boolean | undefined = undefined; + + private fixedSizeValue: number = 0; + + private firstRangeTotalSize: number = 0; + + private firstRangeAverageSize: number = 0; + + private offset: number = 0; + + private renderedIndex: number = 0; + + private sizes: Map = new Map(); + + private calcType: Symbol = CALC_TYPE.INIT; + + private range: ListRange = { + start: 0, + end: 0, + paddingTop: 0, + paddingBottom: 0 + }; + + init() { + if(!this.get('enableVirtualList')) return; + + const keeps = this.get('keeps')!; + const children = this.get('children') as VNode; + + this.set('start', 0); + this.set('end', keeps - 1); + + if(Array.isArray(children) && children.length == 2) { + this.set('total', children[1].length); + } + + this.watch('children', (newValue) => { + const fakeVNode = {} as VNode; + normalizeChildren(fakeVNode, newValue); + this.normalizedVnodes = fakeVNode.children as VNode[]; + }) + } + + mounted() { + if(!this.get('enableVirtualList')) return; + + this.setItemSize(); + } + + setItemSize(): void { + const dom = this.vnode?.dom; + const items = (dom?.firstChild as HTMLDivElement).children; + const start = this.get('start'); + + for(let i = 0;i < items.length; i++) { + const item = items[i]; + const size = (item as HTMLDivElement).offsetHeight; + const index = start + i; + + if(this.sizes.get(index) == undefined) { + this.saveSize(index, size); + } + } + } + + isFixedType(): boolean { + const {isFixedType} = this.get(); + return isFixedType !== undefined + ? isFixedType + : this.calcType === CALC_TYPE.FIXED; + } + + getEstimateSize(): number { + return this.isFixedType() + ? this.fixedSizeValue + : (this.get('estimateSize') ?? this.firstRangeAverageSize); + } + + getRange(): ListRange { + const range: ListRange = Object.create(null); + range.start = this.range.start; + range.end = this.range.end; + range.paddingTop = this.range.paddingTop; + range.paddingBottom = this.range.paddingBottom; + + return range; + } + + getLastIndex(): number { + return this.get('total') - 1; + } + + getEndByStart(start: number): number { + const theoreticalEnd = start + this.get('keeps')! - 1; + return Math.min(theoreticalEnd, this.getLastIndex()); + } + + saveSize(itemIndex: number, size: number): void { + const {keeps, total} = this.get(); + this.sizes.set(itemIndex, size); + + if(this.calcType === CALC_TYPE.INIT) { + this.fixedSizeValue = size; + this.calcType = CALC_TYPE.FIXED; + } else if(this.calcType === CALC_TYPE.FIXED && this.fixedSizeValue !== size) { + this.calcType = CALC_TYPE.DYNAMIC; + } + + // Computed average size of item when render first group of list + if(this.sizes.size < Math.min(keeps!, total)) { + this.firstRangeTotalSize = [...this.sizes.values()].reduce((p, c) => p + c, 0); + this.firstRangeAverageSize = Math.round(this.firstRangeTotalSize / this.sizes.size); + } + + // Set initial size of item + if(++this.renderedIndex == keeps) { + this.initItems(); + } + } + + initItems(): void { + this.checkRange(0, this.getEndByStart(0)); + } + + handleScroll(offset: number): void { + const isDown = offset > this.offset; + this.offset = offset; + + if(isDown) { + this.handleBehind(); + } else { + this.handleFront(); + } + } + + handleBehind(): void { + const overs = this.getPassedItems(); + + // Range should not change if scroll overs within buffer + if(overs < this.range.start + this.get('buffer')!) { + return; + } + + this.checkRange(overs, this.getEndByStart(overs)); + } + + handleFront(): void { + const overs = this.getPassedItems(); + + if(overs > this.range.start) { + return; + } + + const start = Math.max(overs - this.get('buffer')!, 0); + this.checkRange(start, this.getEndByStart(start)); + } + + getPassedItems(): number { + if(this.isFixedType()) { + return Math.floor(this.offset / this.fixedSizeValue); + } + + // If item's size is dynamic, get passed items according + // to current scroll offset and size of item in sizes + let low = 0; + let middle = 0; + let high = this.get('total'); + let middleOffset = 0; + + while(low <= high) { + middle = low + Math.floor((high - low) / 2); + middleOffset = this.getIndexOffset(middle); + + if(middleOffset === this.offset) { + return middle; + } else if(middleOffset < this.offset) { + low = middle + 1; + } else if(middleOffset > this.offset) { + high = middle - 1; + } + } + + return low > 0 ? --low : 0; + } + + getIndexOffset(givenIndex: number): number { + if(!givenIndex) return 0; + + let offset = 0; + let indexSize = 0; + for(let index = 0; index < givenIndex; index++) { + indexSize = this.sizes.get(index)!; + offset = offset + (typeof indexSize === 'number' ? indexSize : this.getEstimateSize()); + } + + return offset; + } + + checkRange(start: number, end: number): void { + const keeps = this.get('keeps')! + const total = this.get('total'); + + // Render all if total <= keeps + if(total <= keeps) { + start = 0; + end = this.getLastIndex(); + } else if(end - start < keeps - 1) { + start = end - keeps + 1; + } + + this.updateRange(start, end); + } + + setRange(): void { + const {start, end} = this.getRange(); + this.set('start', start); + this.set('end', end); + + nextTick(this.setItemSize.bind(this)); + } + + updateRange(start: number, end: number): void { + this.range.start = start; + this.range.end = end; + this.range.paddingTop = this.getPadFront(); + this.range.paddingBottom = this.getPadBehind(); + + this.setRange(); + } + + getPadFront(): number { + if(this.isFixedType()) { + return this.fixedSizeValue * this.range.start; + } else { + return this.getIndexOffset(this.range.start); + } + } + + getPadBehind(): number { + const end = this.range.end; + const lastIndex = this.getLastIndex(); + + if(this.isFixedType()) { + return (lastIndex - end) * this.fixedSizeValue; + } + + if(end === lastIndex && this.hasComputedAll === undefined) { + this.hasComputedAll = true; + } + + if(this.hasComputedAll) { + // If all item has been computed, return an actual size + return this.getIndexOffset(lastIndex) - this.getIndexOffset(end); + } else { + // Return a estimated value before all items has computed + return (lastIndex - end) * this.getEstimateSize(); + } + } +} \ No newline at end of file From 5301c7941710def1b93a93a4b93071c41a18b07f Mon Sep 17 00:00:00 2001 From: hubing1 Date: Mon, 1 Aug 2022 13:47:23 +0800 Subject: [PATCH 2/2] refactor(virtualList) --- components/virtualList/index.ts | 256 ++-------------------------- components/virtualList/useRange.ts | 68 ++++++++ components/virtualList/useScroll.ts | 49 ++++++ components/virtualList/useSize.ts | 169 ++++++++++++++++++ 4 files changed, 301 insertions(+), 241 deletions(-) create mode 100644 components/virtualList/useRange.ts create mode 100644 components/virtualList/useScroll.ts create mode 100644 components/virtualList/useSize.ts diff --git a/components/virtualList/index.ts b/components/virtualList/index.ts index ed982c8ef..52cc28222 100644 --- a/components/virtualList/index.ts +++ b/components/virtualList/index.ts @@ -2,13 +2,14 @@ import { Component, TypeDefs, VNode, - nextTick, Children, createVNode as h, - createElementVNode, className as getClassName, normalizeChildren } from 'intact'; +import {useSize} from './useSize'; +import {useRange} from './useRange'; +import {useScroll} from './useScroll'; export interface VirtualProps { keeps?: number @@ -22,19 +23,13 @@ export interface VirtualProps { enableVirtualList?: boolean | string } -export type ListRange = { +export type RangeInfo = { start: number end: number paddingTop: number paddingBottom: number } -const CALC_TYPE = { - INIT: Symbol('init'), - FIXED: Symbol('fixed'), - DYNAMIC: Symbol('dynamic') -}; - const typeDefs: Required> = { keeps: Number, buffer: Number, @@ -56,7 +51,6 @@ export class Virtual extends Component { static typeDefs = typeDefs; static defaults = defaults; static template = function(this: Virtual) { - const vt = this; const children: Children = this.get('children'); const nativeProps = this.$vNode.props?.nativeProps; const enableVirtualList = this.get('enableVirtualList'); @@ -72,12 +66,10 @@ export class Virtual extends Component { const outerAttrs = { ...nativeProps, - 'ev-scroll': function(this: HTMLDivElement) { - vt.handleScroll(this.scrollTop); - } + 'ev-scroll': (e: WheelEvent) => this.scroll.handleScroll((e.target as HTMLElement).scrollTop) } const {start, end} = this.get(); - const {paddingTop, paddingBottom} = this.getRange(); + const {paddingTop, paddingBottom} = this.range.getRange(); const innerAttrs = {style: `padding-top: ${paddingTop}px; padding-bottom: ${paddingBottom}px`}; const croppedChildren = this.normalizedVnodes.slice(start, end + 1); const vnode = this.vnode = h('div', outerAttrs, h('div', innerAttrs, croppedChildren)); @@ -85,33 +77,22 @@ export class Virtual extends Component { return vnode; }; - private vnode: VNode | null = null; + vnode: VNode | null = null; + offset: number = 0; private normalizedVnodes: VNode[] = []; - private hasComputedAll: boolean | undefined = undefined; - - private fixedSizeValue: number = 0; - - private firstRangeTotalSize: number = 0; - - private firstRangeAverageSize: number = 0; - - private offset: number = 0; - - private renderedIndex: number = 0; - - private sizes: Map = new Map(); - - private calcType: Symbol = CALC_TYPE.INIT; - - private range: ListRange = { + rangeInfo: RangeInfo = { start: 0, end: 0, paddingTop: 0, paddingBottom: 0 }; + private size = useSize(); + private range = useRange(this.size); + private scroll = useScroll(this.size, this.range); + init() { if(!this.get('enableVirtualList')) return; @@ -134,217 +115,10 @@ export class Virtual extends Component { mounted() { if(!this.get('enableVirtualList')) return; - - this.setItemSize(); - } - - setItemSize(): void { - const dom = this.vnode?.dom; - const items = (dom?.firstChild as HTMLDivElement).children; - const start = this.get('start'); - - for(let i = 0;i < items.length; i++) { - const item = items[i]; - const size = (item as HTMLDivElement).offsetHeight; - const index = start + i; - - if(this.sizes.get(index) == undefined) { - this.saveSize(index, size); - } - } - } - - isFixedType(): boolean { - const {isFixedType} = this.get(); - return isFixedType !== undefined - ? isFixedType - : this.calcType === CALC_TYPE.FIXED; - } - - getEstimateSize(): number { - return this.isFixedType() - ? this.fixedSizeValue - : (this.get('estimateSize') ?? this.firstRangeAverageSize); - } - - getRange(): ListRange { - const range: ListRange = Object.create(null); - range.start = this.range.start; - range.end = this.range.end; - range.paddingTop = this.range.paddingTop; - range.paddingBottom = this.range.paddingBottom; - - return range; - } - - getLastIndex(): number { - return this.get('total') - 1; - } - - getEndByStart(start: number): number { - const theoreticalEnd = start + this.get('keeps')! - 1; - return Math.min(theoreticalEnd, this.getLastIndex()); - } - - saveSize(itemIndex: number, size: number): void { - const {keeps, total} = this.get(); - this.sizes.set(itemIndex, size); - - if(this.calcType === CALC_TYPE.INIT) { - this.fixedSizeValue = size; - this.calcType = CALC_TYPE.FIXED; - } else if(this.calcType === CALC_TYPE.FIXED && this.fixedSizeValue !== size) { - this.calcType = CALC_TYPE.DYNAMIC; - } - - // Computed average size of item when render first group of list - if(this.sizes.size < Math.min(keeps!, total)) { - this.firstRangeTotalSize = [...this.sizes.values()].reduce((p, c) => p + c, 0); - this.firstRangeAverageSize = Math.round(this.firstRangeTotalSize / this.sizes.size); - } - - // Set initial size of item - if(++this.renderedIndex == keeps) { - this.initItems(); - } + this.size.setItemSize(); } initItems(): void { - this.checkRange(0, this.getEndByStart(0)); - } - - handleScroll(offset: number): void { - const isDown = offset > this.offset; - this.offset = offset; - - if(isDown) { - this.handleBehind(); - } else { - this.handleFront(); - } - } - - handleBehind(): void { - const overs = this.getPassedItems(); - - // Range should not change if scroll overs within buffer - if(overs < this.range.start + this.get('buffer')!) { - return; - } - - this.checkRange(overs, this.getEndByStart(overs)); - } - - handleFront(): void { - const overs = this.getPassedItems(); - - if(overs > this.range.start) { - return; - } - - const start = Math.max(overs - this.get('buffer')!, 0); - this.checkRange(start, this.getEndByStart(start)); - } - - getPassedItems(): number { - if(this.isFixedType()) { - return Math.floor(this.offset / this.fixedSizeValue); - } - - // If item's size is dynamic, get passed items according - // to current scroll offset and size of item in sizes - let low = 0; - let middle = 0; - let high = this.get('total'); - let middleOffset = 0; - - while(low <= high) { - middle = low + Math.floor((high - low) / 2); - middleOffset = this.getIndexOffset(middle); - - if(middleOffset === this.offset) { - return middle; - } else if(middleOffset < this.offset) { - low = middle + 1; - } else if(middleOffset > this.offset) { - high = middle - 1; - } - } - - return low > 0 ? --low : 0; - } - - getIndexOffset(givenIndex: number): number { - if(!givenIndex) return 0; - - let offset = 0; - let indexSize = 0; - for(let index = 0; index < givenIndex; index++) { - indexSize = this.sizes.get(index)!; - offset = offset + (typeof indexSize === 'number' ? indexSize : this.getEstimateSize()); - } - - return offset; - } - - checkRange(start: number, end: number): void { - const keeps = this.get('keeps')! - const total = this.get('total'); - - // Render all if total <= keeps - if(total <= keeps) { - start = 0; - end = this.getLastIndex(); - } else if(end - start < keeps - 1) { - start = end - keeps + 1; - } - - this.updateRange(start, end); - } - - setRange(): void { - const {start, end} = this.getRange(); - this.set('start', start); - this.set('end', end); - - nextTick(this.setItemSize.bind(this)); - } - - updateRange(start: number, end: number): void { - this.range.start = start; - this.range.end = end; - this.range.paddingTop = this.getPadFront(); - this.range.paddingBottom = this.getPadBehind(); - - this.setRange(); - } - - getPadFront(): number { - if(this.isFixedType()) { - return this.fixedSizeValue * this.range.start; - } else { - return this.getIndexOffset(this.range.start); - } - } - - getPadBehind(): number { - const end = this.range.end; - const lastIndex = this.getLastIndex(); - - if(this.isFixedType()) { - return (lastIndex - end) * this.fixedSizeValue; - } - - if(end === lastIndex && this.hasComputedAll === undefined) { - this.hasComputedAll = true; - } - - if(this.hasComputedAll) { - // If all item has been computed, return an actual size - return this.getIndexOffset(lastIndex) - this.getIndexOffset(end); - } else { - // Return a estimated value before all items has computed - return (lastIndex - end) * this.getEstimateSize(); - } + this.range.checkRange(0, this.size.getEndByStart(0)); } } \ No newline at end of file diff --git a/components/virtualList/useRange.ts b/components/virtualList/useRange.ts new file mode 100644 index 000000000..57f5baaa1 --- /dev/null +++ b/components/virtualList/useRange.ts @@ -0,0 +1,68 @@ +import {useInstance, nextTick} from 'intact'; +import {Virtual} from './index'; +import {Size} from './useSize'; + +type RangeInfo = { + start: number + end: number + paddingTop: number + paddingBottom: number +} + +export type Range = { + checkRange: (start: number, end: number) => void + getRange: () => RangeInfo +} + +export function useRange(size: Size): Range { + const instance = useInstance() as Virtual; + + function getRange(): RangeInfo { + const range: RangeInfo = Object.create(null); + const tmpRange = instance.rangeInfo; + range.start = tmpRange.start; + range.end = tmpRange.end; + range.paddingTop = tmpRange.paddingTop; + range.paddingBottom = tmpRange.paddingBottom; + + return range; + } + + function checkRange(start: number, end: number): void { + const keeps = instance.get('keeps')!; + const total = instance.get('total'); + + // Render all if total <= keeps + if(total <= keeps) { + start = 0; + end = size.getLastIndex(); + } else if(end - start < keeps - 1) { + start = end - keeps + 1; + } + + updateRange(start, end); + } + + function setRange(): void { + const {start, end} = getRange(); + instance.set('start', start); + instance.set('end', end); + + nextTick(size.setItemSize.bind(instance)); + } + + function updateRange(start: number, end: number): void { + const tmp = instance.rangeInfo; + tmp.start = start; + tmp.end = end; + tmp.paddingTop = size.getPadFront(); + tmp.paddingBottom = size.getPadBehind(); + + setRange(); + } + + return { + checkRange, + getRange + } +} \ No newline at end of file diff --git a/components/virtualList/useScroll.ts b/components/virtualList/useScroll.ts new file mode 100644 index 000000000..abda2a20e --- /dev/null +++ b/components/virtualList/useScroll.ts @@ -0,0 +1,49 @@ +import {useInstance} from 'intact'; +import {Virtual} from './index'; +import {Size} from './useSize'; +import {Range} from './useRange'; + +type ScrollObj = { + handleScroll: (offset: number) => void +} + +export function useScroll(size: Size, range: Range): ScrollObj { + const instance = useInstance() as Virtual; + + function handleScroll(offset: number): void { + const isDown = offset > instance.offset; + instance.offset = offset; + + if(isDown) { + handleBehind(); + } else { + handleFront(); + } + } + + function handleBehind(): void { + const overs = size.getPassedItems(); + + // Range should not change if scroll overs within buffer + if(overs < instance.rangeInfo.start + instance.get('buffer')!) { + return; + } + + range.checkRange(overs, size.getEndByStart(overs)); + } + + function handleFront(): void { + const overs = size.getPassedItems(); + + if(overs > instance.rangeInfo.start) { + return; + } + + const start = Math.max(overs - instance.get('buffer')!, 0); + range.checkRange(start, size.getEndByStart(start)); + } + + return { + handleScroll + } +} \ No newline at end of file diff --git a/components/virtualList/useSize.ts b/components/virtualList/useSize.ts new file mode 100644 index 000000000..358d4fdc1 --- /dev/null +++ b/components/virtualList/useSize.ts @@ -0,0 +1,169 @@ +import {useInstance} from 'intact'; +import {Virtual} from './index'; + +export type Size = { + getLastIndex: () => number + setItemSize: () => void + getPadFront: () => number + getPadBehind: () => number + getPassedItems: () => number + getEndByStart: (start: number) => number +} + +const CALC_TYPE = { + INIT: Symbol('init'), + FIXED: Symbol('fixed'), + DYNAMIC: Symbol('dynamic') +}; + +export function useSize(): Size { + const instance = useInstance() as Virtual; + const sizes: Map = new Map(); + + let calcType = CALC_TYPE.INIT; + let firstRangeTotalSize = 0; + let firstRangeAverageSize = 0; + let renderedIndex = 0; + let fixedSizeValue = 0; + let hasComputedAll: boolean | undefined = undefined; + + function isFixedType(): boolean { + const {isFixedType} = instance.get(); + return isFixedType !== undefined + ? isFixedType + : calcType === CALC_TYPE.FIXED; + } + + function getEstimateSize(): number { + return isFixedType() + ? fixedSizeValue + : (instance.get('estimateSize') ?? firstRangeAverageSize); + } + + function getLastIndex(): number { + return instance.get('total') - 1; + } + + function getEndByStart(start: number): number { + const theoreticalEnd = start + instance.get('keeps')! - 1; + return Math.min(theoreticalEnd, getLastIndex()); + } + + function saveSize(itemIndex: number, size: number): void { + const {keeps, total} = instance.get(); + sizes.set(itemIndex, size); + + if(calcType === CALC_TYPE.INIT) { + fixedSizeValue = size; + calcType = CALC_TYPE.FIXED; + } else if(calcType === CALC_TYPE.FIXED && fixedSizeValue !== size) { + calcType = CALC_TYPE.DYNAMIC; + } + + // Computed average size of item when render first group of list + if(sizes.size < Math.min(keeps!, total)) { + firstRangeTotalSize = [...sizes.values()].reduce((p, c) => p + c, 0); + firstRangeAverageSize = Math.round(firstRangeTotalSize / sizes.size); + } + + // Set initial size of item + if(++renderedIndex == keeps) { + instance.initItems(); + } + } + + function setItemSize(): void { + const dom = instance.vnode?.dom; + const items = (dom?.firstChild as HTMLDivElement).children; + const start = instance.get('start'); + + for(let i = 0;i < items.length; i++) { + const item = items[i]; + const size = (item as HTMLDivElement).offsetHeight; + const index = start + i; + + if(sizes.get(index) == undefined) { + saveSize(index, size); + } + } + } + + function getIndexOffset(givenIndex: number): number { + if(!givenIndex) return 0; + + let offset = 0; + let indexSize = 0; + for(let index = 0; index < givenIndex; index++) { + indexSize = sizes.get(index)!; + offset = offset + (typeof indexSize === 'number' ? indexSize : getEstimateSize()); + } + + return offset; + } + + function getPadFront(): number { + if(isFixedType()) { + return fixedSizeValue * instance.rangeInfo.start; + } else { + return getIndexOffset(instance.rangeInfo.start); + } + } + + function getPadBehind(): number { + const end = instance.rangeInfo.end; + const lastIndex = getLastIndex(); + + if(isFixedType()) { + return (lastIndex - end) * fixedSizeValue; + } + + if(end === lastIndex && hasComputedAll === undefined) { + hasComputedAll = true; + } + + if(hasComputedAll) { + // If all item has been computed, return an actual size + return getIndexOffset(lastIndex) - getIndexOffset(end); + } else { + // Return a estimated value before all items has computed + return (lastIndex - end) * getEstimateSize(); + } + } + + function getPassedItems(): number { + if(isFixedType()) { + return Math.floor(instance.offset / fixedSizeValue); + } + + // If item's size is dynamic, get passed items according + // to current scroll offset and size of item in sizes + let low = 0; + let middle = 0; + let high = instance.get('total'); + let middleOffset = 0; + + while(low <= high) { + middle = low + Math.floor((high - low) / 2); + middleOffset = getIndexOffset(middle); + + if(middleOffset === instance.offset) { + return middle; + } else if(middleOffset < instance.offset) { + low = middle + 1; + } else if(middleOffset > instance.offset) { + high = middle - 1; + } + } + + return low > 0 ? --low : 0; + } + + return { + getLastIndex, + setItemSize, + getPadFront, + getPadBehind, + getPassedItems, + getEndByStart + } +} \ No newline at end of file