diff --git a/src/core/cache.spec.ts b/src/core/cache.spec.ts index b895ccc74..905b72b17 100644 --- a/src/core/cache.spec.ts +++ b/src/core/cache.spec.ts @@ -137,6 +137,38 @@ describe(setItemSize.name, () => { expect(cache._measuredOffsetIndex).toBe(4); }); }); + + describe("should return measurement status", () => { + it("should return false if already measured", () => { + const filledSizes = range(10, () => 20); + const emptyOffsets = range(10, (i) => (i === 0 ? 0 : -1)); + const cache: Writeable = { + _length: filledSizes.length, + _sizes: filledSizes, + _measuredOffsetIndex: 0, + _offsets: emptyOffsets, + _defaultItemSize: 20, + }; + + const res = setItemSize(cache, 0, 123); + expect(res).toBe(false); + }); + + it("should return true if not measured", () => { + const filledSizes = range(10, () => -1); + const emptyOffsets = range(10, (i) => (i === 0 ? 0 : -1)); + const cache: Writeable = { + _length: filledSizes.length, + _sizes: filledSizes, + _measuredOffsetIndex: 0, + _offsets: emptyOffsets, + _defaultItemSize: 20, + }; + + const res = setItemSize(cache, 0, 123); + expect(res).toBe(true); + }); + }); }); describe(computeTotalSize.name, () => { diff --git a/src/core/cache.ts b/src/core/cache.ts index efcd76df1..e5aef6127 100644 --- a/src/core/cache.ts +++ b/src/core/cache.ts @@ -1,5 +1,5 @@ import type { DeepReadonly, Writeable } from "./types"; -import { exists, max, min, range } from "./utils"; +import { exists, max, median, min, range } from "./utils"; export const UNCACHED = -1; @@ -20,10 +20,12 @@ export const setItemSize = ( cache: Writeable, index: number, size: number -) => { +): boolean => { + const isInitialMeasurement = cache._sizes[index] === UNCACHED; cache._sizes[index] = size; // mark as dirty cache._measuredOffsetIndex = min(index, cache._measuredOffsetIndex); + return isInitialMeasurement; }; const computeOffset = ( @@ -119,13 +121,25 @@ export const hasUnmeasuredItemsInRange = ( return false; }; +export const estimateDefaultItemSize = (cache: Writeable) => { + const measuredSizes = cache._sizes.filter((s) => s !== UNCACHED); + // This function will be called after measurement so measured size array must be longer than 0 + const startItemSize = measuredSizes[0]!; + + cache._defaultItemSize = measuredSizes.every((s) => s === startItemSize) + ? // Maybe a fixed size array + startItemSize + : // Maybe a variable size array + median(measuredSizes); +}; + export const resetCache = ( length: number, itemSize: number, cache?: Cache ): Cache => { return { - _defaultItemSize: itemSize, + _defaultItemSize: cache ? cache._defaultItemSize : itemSize, _length: length, _measuredOffsetIndex: cache ? min(cache._measuredOffsetIndex, length - 1) diff --git a/src/core/store.ts b/src/core/store.ts index 55ced9d5f..159021d40 100644 --- a/src/core/store.ts +++ b/src/core/store.ts @@ -9,6 +9,7 @@ import { UNCACHED, setItemSize, hasUnmeasuredItemsInRange, + estimateDefaultItemSize, } from "./cache"; import type { Writeable } from "./types"; import { abs, exists, max } from "./utils"; @@ -67,18 +68,20 @@ export type VirtualStore = { }; export const createVirtualStore = ( - itemCount: number, - itemSize: number, + elementsCount: number, + itemSize: number | undefined, initialItemCount: number = 0, isReverse: boolean, onScrollStateChange: (scrolling: boolean) => void, onScrollOffsetChange: (offset: number) => void ): VirtualStore => { - let viewportSize = itemSize * max(initialItemCount - 1, 0); + const shouldAutoEstimateItemSize = !itemSize; + const initialItemSize = itemSize || 40; + let viewportSize = initialItemSize * max(initialItemCount - 1, 0); let scrollOffset = 0; let jumpCount = 0; let jump: ScrollJump | undefined; - let cache = resetCache(itemCount, itemSize); + let cache = resetCache(elementsCount, initialItemSize); let _scrollDirection: ScrollDirection = SCROLL_STOP; let _resized = false; let _prevRange: ItemsRange = [0, initialItemCount]; @@ -209,11 +212,26 @@ export const createVirtualStore = ( break; } + let isNewItemMeasured = false; + // Calculate jump const updatedJump: ItemJump[] = []; updated.forEach(([index, size]) => { updatedJump.push([size - getItemSize(cache, index), index]); - setItemSize(cache as Writeable, index, size); + if (setItemSize(cache as Writeable, index, size)) { + isNewItemMeasured = true; + } }); + + if ( + shouldAutoEstimateItemSize && + isNewItemMeasured && + // TODO support reverse scroll also + !scrollOffset + ) { + // Estimate initial item size from measured sizes + estimateDefaultItemSize(cache as Writeable); + } + if ( _scrollDirection === SCROLL_MANUAL || _scrollDirection === SCROLL_UP @@ -290,7 +308,7 @@ export const createVirtualStore = ( _updateCacheLength(length) { // It's ok to be updated in render because states should be calculated consistently regardless cache length if (cache._length === length) return; - cache = resetCache(length, itemSize, cache); + cache = resetCache(length, initialItemSize, cache); }, }; }; diff --git a/src/core/utils.ts b/src/core/utils.ts index 6b272e352..710a634c6 100644 --- a/src/core/utils.ts +++ b/src/core/utils.ts @@ -8,6 +8,12 @@ export const exists = (v: T): v is Exclude => v != null; export const range = (length: number, cb: (i: number) => T): T[] => Array.from({ length }, (_, i) => cb(i)); +export const median = (arr: number[]): number => { + const s = [...arr].sort((a, b) => a - b); + const mid = (arr.length / 2) | 0; + return s.length % 2 === 0 ? (s[mid - 1]! + s[mid]!) / 2 : s[mid]!; +}; + export const debounce = void>( fn: T, ms: number diff --git a/src/react/VList.tsx b/src/react/VList.tsx index 5f68c7fa3..83b6753ad 100644 --- a/src/react/VList.tsx +++ b/src/react/VList.tsx @@ -197,16 +197,18 @@ export interface VListProps extends WindowComponentAttributes { * Elements rendered by this component. */ children: ReactNode; - /** - * Item size hint for unmeasured items. It's recommended to specify this prop if item sizes are fixed and known, or much larger than the defaultValue. It will help to reduce scroll jump when items are measured. - * @defaultValue 40 - */ - itemSize?: number; /** * Number of items to render above/below the visible bounds of the list. You can increase to avoid showing blank items in fast scrolling. * @defaultValue 4 */ overscan?: number; + /** + * Item size hint for unmeasured items. It will help to reduce scroll jump when items are measured if used properly. + * + * - If not set, initial item sizes will be automatically estimated from measured sizes. This is recommended for most cases. + * - If set, you can opt out estimation and use the value as initial item size. + */ + initialItemSize?: number; /** * If set, the specified amount of items will be mounted in the initial rendering regardless of the container size. This prop is mostly for SSR. */ @@ -267,8 +269,8 @@ export const VList = forwardRef( ( { children, - itemSize: itemSizeProp = 40, overscan = 4, + initialItemSize, initialItemCount, horizontal: horizontalProp, mode, @@ -303,7 +305,7 @@ export const VList = forwardRef( const _isRtl = mode === "rtl"; const _store = createVirtualStore( count, - itemSizeProp, + initialItemSize, initialItemCount, mode === "reverse", (isScrolling) => { diff --git a/src/react/__snapshots__/VList.spec.tsx.snap b/src/react/__snapshots__/VList.spec.tsx.snap index 2f48f86ec..acc70c1af 100644 --- a/src/react/__snapshots__/VList.spec.tsx.snap +++ b/src/react/__snapshots__/VList.spec.tsx.snap @@ -74,7 +74,7 @@ exports[`horizontal should render 100 children 1`] = ` style="overflow: auto hidden; contain: strict; width: 100%; height: 100%; padding: 0px; margin: 0px;" >
{ startTransition(() => { setRange([start, end]); diff --git a/stories/advanced/Loop.stories.tsx b/stories/advanced/Loop.stories.tsx index 240d77353..83d7f12b4 100644 --- a/stories/advanced/Loop.stories.tsx +++ b/stories/advanced/Loop.stories.tsx @@ -45,7 +45,7 @@ export const Loop: StoryObj = { { if (!ref.current) return; diff --git a/stories/basics/VList.stories.tsx b/stories/basics/VList.stories.tsx index 39a7b6d55..7012b93b6 100644 --- a/stories/basics/VList.stories.tsx +++ b/stories/basics/VList.stories.tsx @@ -122,7 +122,7 @@ export const Reverse: StoryObj = { export const Sticky: StoryObj = { render: () => { return ( - + {Array.from({ length: 100 }).map((_, i) => { return (