Skip to content

Commit

Permalink
Merge pull request #110 from inokawa/estimate-item-size
Browse files Browse the repository at this point in the history
Estimate initial item size from measured sizes
  • Loading branch information
inokawa authored Jun 25, 2023
2 parents 486514c + f85f695 commit 66bbffe
Show file tree
Hide file tree
Showing 9 changed files with 96 additions and 25 deletions.
32 changes: 32 additions & 0 deletions src/core/cache.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Cache> = {
_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<Cache> = {
_length: filledSizes.length,
_sizes: filledSizes,
_measuredOffsetIndex: 0,
_offsets: emptyOffsets,
_defaultItemSize: 20,
};

const res = setItemSize(cache, 0, 123);
expect(res).toBe(true);
});
});
});

describe(computeTotalSize.name, () => {
Expand Down
20 changes: 17 additions & 3 deletions src/core/cache.ts
Original file line number Diff line number Diff line change
@@ -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;

Expand All @@ -20,10 +20,12 @@ export const setItemSize = (
cache: Writeable<Cache>,
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 = (
Expand Down Expand Up @@ -119,13 +121,25 @@ export const hasUnmeasuredItemsInRange = (
return false;
};

export const estimateDefaultItemSize = (cache: Writeable<Cache>) => {
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)
Expand Down
30 changes: 24 additions & 6 deletions src/core/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
UNCACHED,
setItemSize,
hasUnmeasuredItemsInRange,
estimateDefaultItemSize,
} from "./cache";
import type { Writeable } from "./types";
import { abs, exists, max } from "./utils";
Expand Down Expand Up @@ -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];
Expand Down Expand Up @@ -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<Cache>, index, size);
if (setItemSize(cache as Writeable<Cache>, index, size)) {
isNewItemMeasured = true;
}
});

if (
shouldAutoEstimateItemSize &&
isNewItemMeasured &&
// TODO support reverse scroll also
!scrollOffset
) {
// Estimate initial item size from measured sizes
estimateDefaultItemSize(cache as Writeable<Cache>);
}

if (
_scrollDirection === SCROLL_MANUAL ||
_scrollDirection === SCROLL_UP
Expand Down Expand 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);
},
};
};
6 changes: 6 additions & 0 deletions src/core/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,12 @@ export const exists = <T>(v: T): v is Exclude<T, null | undefined> => v != null;
export const range = <T>(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 = <T extends (...args: any[]) => void>(
fn: T,
ms: number
Expand Down
16 changes: 9 additions & 7 deletions src/react/VList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand Down Expand Up @@ -267,8 +269,8 @@ export const VList = forwardRef<VListHandle, VListProps>(
(
{
children,
itemSize: itemSizeProp = 40,
overscan = 4,
initialItemSize,
initialItemCount,
horizontal: horizontalProp,
mode,
Expand Down Expand Up @@ -303,7 +305,7 @@ export const VList = forwardRef<VListHandle, VListProps>(
const _isRtl = mode === "rtl";
const _store = createVirtualStore(
count,
itemSizeProp,
initialItemSize,
initialItemCount,
mode === "reverse",
(isScrolling) => {
Expand Down
12 changes: 6 additions & 6 deletions src/react/__snapshots__/VList.spec.tsx.snap
Original file line number Diff line number Diff line change
Expand Up @@ -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;"
>
<div
style="position: relative; visibility: hidden; width: 4360px; height: 100%; pointer-events: auto;"
style="position: relative; visibility: hidden; width: 10000px; height: 100%; pointer-events: auto;"
>
<div
style="margin: 0px; padding: 0px; position: absolute; height: 100%; top: 0px; left: 0px; visibility: visible; display: flex;"
Expand Down Expand Up @@ -129,7 +129,7 @@ exports[`horizontal should render 1000 children 1`] = `
style="overflow: auto hidden; contain: strict; width: 100%; height: 100%; padding: 0px; margin: 0px;"
>
<div
style="position: relative; visibility: hidden; width: 40360px; height: 100%; pointer-events: auto;"
style="position: relative; visibility: hidden; width: 100000px; height: 100%; pointer-events: auto;"
>
<div
style="margin: 0px; padding: 0px; position: absolute; height: 100%; top: 0px; left: 0px; visibility: visible; display: flex;"
Expand Down Expand Up @@ -184,7 +184,7 @@ exports[`horizontal should render 10000 children 1`] = `
style="overflow: auto hidden; contain: strict; width: 100%; height: 100%; padding: 0px; margin: 0px;"
>
<div
style="position: relative; visibility: hidden; width: 400360px; height: 100%; pointer-events: auto;"
style="position: relative; visibility: hidden; width: 1000000px; height: 100%; pointer-events: auto;"
>
<div
style="margin: 0px; padding: 0px; position: absolute; height: 100%; top: 0px; left: 0px; visibility: visible; display: flex;"
Expand Down Expand Up @@ -518,7 +518,7 @@ exports[`vertical should render 100 children 1`] = `
style="overflow: hidden auto; contain: strict; width: 100%; height: 100%; padding: 0px; margin: 0px;"
>
<div
style="position: relative; visibility: hidden; width: 100%; height: 4060px; pointer-events: auto;"
style="position: relative; visibility: hidden; width: 100%; height: 5000px; pointer-events: auto;"
>
<div
style="margin: 0px; padding: 0px; position: absolute; width: 100%; left: 0px; top: 0px; visibility: visible;"
Expand Down Expand Up @@ -573,7 +573,7 @@ exports[`vertical should render 1000 children 1`] = `
style="overflow: hidden auto; contain: strict; width: 100%; height: 100%; padding: 0px; margin: 0px;"
>
<div
style="position: relative; visibility: hidden; width: 100%; height: 40060px; pointer-events: auto;"
style="position: relative; visibility: hidden; width: 100%; height: 50000px; pointer-events: auto;"
>
<div
style="margin: 0px; padding: 0px; position: absolute; width: 100%; left: 0px; top: 0px; visibility: visible;"
Expand Down Expand Up @@ -628,7 +628,7 @@ exports[`vertical should render 10000 children 1`] = `
style="overflow: hidden auto; contain: strict; width: 100%; height: 100%; padding: 0px; margin: 0px;"
>
<div
style="position: relative; visibility: hidden; width: 100%; height: 400060px; pointer-events: auto;"
style="position: relative; visibility: hidden; width: 100%; height: 500000px; pointer-events: auto;"
>
<div
style="margin: 0px; padding: 0px; position: absolute; width: 100%; left: 0px; top: 0px; visibility: visible;"
Expand Down
1 change: 0 additions & 1 deletion stories/advanced/InfiniteScrolling.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,6 @@ export const InfiniteScrolling: StoryObj = {
</div>
<VList
style={{ flex: 1 }}
itemSize={200}
onRangeChange={async ({ start, end, count }) => {
startTransition(() => {
setRange([start, end]);
Expand Down
2 changes: 1 addition & 1 deletion stories/advanced/Loop.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ export const Loop: StoryObj = {
<VList
ref={ref}
style={{ flex: 1 }}
itemSize={200}
initialItemSize={200}
onScroll={(offset) => {
if (!ref.current) return;

Expand Down
2 changes: 1 addition & 1 deletion stories/basics/VList.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,7 @@ export const Reverse: StoryObj = {
export const Sticky: StoryObj = {
render: () => {
return (
<VList style={{ height: "100vh" }} itemSize={570}>
<VList style={{ height: "100vh" }}>
{Array.from({ length: 100 }).map((_, i) => {
return (
<div
Expand Down

0 comments on commit 66bbffe

Please sign in to comment.