diff --git a/README.md b/README.md index 5a59e38..06ae4d3 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,7 @@ const virtualizer = createVirtualizer({ getItemKey: (index) => rows[index]!.id, initialViewportSize: 600, overscan: 5, + overscanPixels: 600, }); virtualizer.setViewport(4_800, 600); @@ -30,6 +31,11 @@ Each virtual item contains its `index`, stable `key`, `start`, `size`, and `end`. Render the returned items inside a spacer with `totalSize` on the main scrolling axis. +`overscan` adds an item-count buffer, while `overscanPixels` adds a scroll-axis +pixel buffer on both sides of the viewport. A pixel buffer of roughly one +viewport helps framework renderers stay ahead of high-velocity native +scrolling, especially when item heights vary. + ## Dynamic measurement Supply measured sizes after rendering: diff --git a/src/index.ts b/src/index.ts index ff3266c..a946863 100644 --- a/src/index.ts +++ b/src/index.ts @@ -26,6 +26,12 @@ export interface VirtualizerOptions { readonly initialOffset?: number; readonly initialViewportSize?: number; readonly overscan?: number; + /** + * Additional render buffer in scroll-axis pixels on both sides of the + * viewport. Useful for preventing native scrolling from outrunning a + * framework renderer. + */ + readonly overscanPixels?: number; readonly paddingEnd?: number; readonly paddingStart?: number; } @@ -56,6 +62,7 @@ interface ResolvedOptions { readonly estimateSize: (index: number) => number; readonly getItemKey: (index: number) => TKey; readonly overscan: number; + readonly overscanPixels: number; readonly paddingEnd: number; readonly paddingStart: number; } @@ -97,6 +104,10 @@ function resolveOptions( getItemKey: options.getItemKey ?? (defaultKey as unknown as (index: number) => TKey), overscan: validateOverscan(options.overscan ?? 1), + overscanPixels: validateNonNegativeFinite( + options.overscanPixels ?? 0, + "overscanPixels", + ), paddingEnd: validateNonNegativeFinite( options.paddingEnd ?? 0, "paddingEnd", @@ -497,11 +508,34 @@ export class Virtualizer { visibleStart + 1, this.#sizes.countStartingBefore(relativeEnd), ); - const renderStart = Math.max(0, visibleStart - this.#options.overscan); - const renderEndExclusive = Math.min( + let renderStart = Math.max(0, visibleStart - this.#options.overscan); + let renderEndExclusive = Math.min( this.#options.count, visibleEndExclusive + this.#options.overscan, ); + if (this.#options.overscanPixels > 0) { + const pixelRenderStart = Math.max( + 0, + relativeStart - this.#options.overscanPixels, + ); + const pixelRenderEnd = Math.min( + this.#sizes.total, + relativeEnd + this.#options.overscanPixels, + ); + const pixelRenderStartIndex = Math.min( + this.#options.count - 1, + this.#sizes.countEndingAtOrBefore(pixelRenderStart), + ); + const pixelRenderEndExclusive = Math.max( + pixelRenderStartIndex + 1, + this.#sizes.countStartingBefore(pixelRenderEnd), + ); + renderStart = Math.min(renderStart, pixelRenderStartIndex); + renderEndExclusive = Math.max( + renderEndExclusive, + pixelRenderEndExclusive, + ); + } const items: VirtualItem[] = []; for (let index = renderStart; index < renderEndExclusive; index += 1) { diff --git a/test/index.test.ts b/test/index.test.ts index 7164035..021934f 100644 --- a/test/index.test.ts +++ b/test/index.test.ts @@ -39,6 +39,22 @@ describe("Virtualizer", () => { }); }); + it("combines item and pixel overscan", () => { + const virtualizer = createDefault({ + initialOffset: 200, + overscan: 1, + overscanPixels: 60, + }); + + expect(virtualizer.getSnapshot().items.map((item) => item.index)).toEqual([ + 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, + ]); + expect(virtualizer.getSnapshot().visibleRange).toEqual({ + endIndex: 14, + startIndex: 10, + }); + }); + it("accounts for item and outer padding", () => { const virtualizer = createDefault({ count: 3, @@ -292,6 +308,13 @@ describe("Virtualizer", () => { overscan: -1, }), ).toThrow("overscan must be a non-negative safe integer"); + expect(() => + createVirtualizer({ + count: 1, + estimateSize: () => 20, + overscanPixels: Number.NaN, + }), + ).toThrow("overscanPixels must be a non-negative finite number"); expect(() => createVirtualizer({ count: 1,