diff --git a/__tests__/components/PositionView.native.test.tsx b/__tests__/components/PositionView.native.test.tsx index 479e5253..94658eba 100644 --- a/__tests__/components/PositionView.native.test.tsx +++ b/__tests__/components/PositionView.native.test.tsx @@ -5,7 +5,7 @@ import * as React from "react"; import { describe, expect, it, mock } from "bun:test"; import { PositionView, PositionViewSticky } from "../../src/components/PositionView.native"; import { updateItemSizes } from "../../src/core/updateItemSizes"; -import { type StateContext, StateProvider, useStateContext } from "../../src/state/state"; +import { type StateContext, StateProvider, set$, useStateContext } from "../../src/state/state"; import { createMockState } from "../__mocks__/createMockState"; import { setLayoutValue } from "../helpers/layoutArrays"; import { act, render } from "../helpers/testingLibrary"; @@ -127,6 +127,58 @@ function ReplacementMeasurementHarness() { ); } +function MountProbe({ onMount }: { onMount: () => void }) { + React.useEffect(() => { + onMount(); + }, [onMount]); + + return null; +} + +function StickyRemountHarness({ + animatedScrollY, + onMount, +}: { + animatedScrollY: { interpolate: (config: any) => any }; + onMount: () => void; +}) { + const ctx = useStateContext(); + const didSetupRef = React.useRef(false); + currentCtx = ctx; + + if (!didSetupRef.current) { + ctx.state = createMockState({ + positions: [], + props: { + stickyHeaderIndicesArr: [1], + }, + }) as any; + ctx.state.positions[1] = 100; + ctx.state.sizes.set("header-1", 120); + ctx.values.set("alignItemsAtEndPadding", 0); + ctx.values.set("containerItemIndex7", 1); + ctx.values.set("containerItemKey7", "header-1"); + ctx.values.set("containerPosition7", 100); + ctx.values.set("headerSize", 0); + ctx.values.set("stylePaddingTop", 0); + ctx.values.set("totalSize", 420); + didSetupRef.current = true; + } + + return ( + {}} + refView={{ current: null }} + style={{}} + > + + + ); +} + describe("PositionView.native", () => { it("pushes a tall sticky header out when the next sticky header arrives", () => { const interpolate = mock((config: any) => config); @@ -226,4 +278,47 @@ describe("PositionView.native", () => { globalThis.requestAnimationFrame = originalRaf; } }); + it("rebuilds the sticky transform node when the container position changes", () => { + const interpolate = mock((config: any) => config); + const onMount = mock(() => {}); + currentCtx = undefined; + + const { toJSON, unmount } = render( + + + , + ); + + expect(onMount).toHaveBeenCalledTimes(1); + expect(flattenStyle((toJSON() as any)?.props?.style)?.transform).toEqual([ + { + translateY: { + extrapolateLeft: "clamp", + extrapolateRight: "extend", + inputRange: [100, 5100], + outputRange: [100, 5100], + }, + }, + ]); + + act(() => { + set$(currentCtx!, "containerPosition7", 260); + }); + + expect(flattenStyle((toJSON() as any)?.props?.style)?.transform).toEqual([ + { + translateY: { + extrapolateLeft: "clamp", + extrapolateRight: "extend", + inputRange: [260, 5260], + outputRange: [260, 5260], + }, + }, + ]); + // The interpolation node is recreated, so the view has to remount for Animated to + // attach the new node instead of keeping the one bound at the old position. + expect(onMount).toHaveBeenCalledTimes(2); + + unmount(); + }); }); diff --git a/__tests__/utils/getItemSize.test.ts b/__tests__/utils/getItemSize.test.ts index 9405fab9..9487e4d2 100644 --- a/__tests__/utils/getItemSize.test.ts +++ b/__tests__/utils/getItemSize.test.ts @@ -165,4 +165,22 @@ describe("getItemSize", () => { expect(result).toBe(0); }); + it("rounds an averaged size to the nearest eighth instead of flooring it", () => { + mockState.averageSizes[""] = { avg: 80.1, num: 1 }; + + const result = callGetItemSize("item_0", 0, { id: 0 }, true); + + expect(result).toBe(80.125); + }); + + it("does not accumulate a downward bias when summing averaged sizes", () => { + mockState.averageSizes[""] = { avg: 80.1, num: 1 }; + + let total = 0; + for (let index = 0; index < 1000; index++) { + total += callGetItemSize(`item_${index}`, index, { id: index }, true); + } + + expect(Math.abs(total - 80.1 * 1000)).toBeLessThan(30); + }); }); diff --git a/src/components/PositionView.native.tsx b/src/components/PositionView.native.tsx index 9f3cdde7..809bc065 100644 --- a/src/components/PositionView.native.tsx +++ b/src/components/PositionView.native.tsx @@ -162,8 +162,11 @@ const PositionViewSticky = typedMemo(function PositionViewSticky({ ); }, [stickyHeaderConfig?.backdropComponent]); + // The interpolation above is rebuilt whenever position changes, but Animated keeps the node it + // attached on mount, so the header would keep following the range of its previous position. + // Keying on position remounts the view and lets Animated attach the new node. return ( - + {renderStickyHeaderBackdrop} {children} diff --git a/src/utils/getItemSize.ts b/src/utils/getItemSize.ts index accdc454..c9183084 100644 --- a/src/utils/getItemSize.ts +++ b/src/utils/getItemSize.ts @@ -1,6 +1,6 @@ import { setSize } from "@/core/setSize"; import type { StateContext } from "@/state/state"; -import { roundSize } from "@/utils/helpers"; +import { roundEstimatedSize } from "@/utils/helpers"; import { getId } from "./getId"; export interface ResolvedItemSize { @@ -92,7 +92,7 @@ export function getItemSize( // Use item type specific average if available const averageSizeForType = averageSizes[itemType]?.avg; if (averageSizeForType !== undefined) { - size = roundSize(averageSizeForType); + size = roundEstimatedSize(averageSizeForType); } } @@ -105,7 +105,7 @@ export function getItemSize( if (size === undefined && useAverageSize && scrollingTo) { const averageSizeForType = scrollingTo.averageSizeSnapshot?.[itemType]; if (averageSizeForType !== undefined) { - size = roundSize(averageSizeForType); + size = roundEstimatedSize(averageSizeForType); } } diff --git a/src/utils/helpers.ts b/src/utils/helpers.ts index e51b1212..4835da5f 100644 --- a/src/utils/helpers.ts +++ b/src/utils/helpers.ts @@ -25,6 +25,12 @@ export function roundSize(size: number) { return Math.floor(size * 8) / 8; // Round to nearest quater pixel to avoid accumulating rounding errors } +// Estimates are summed across many items, so flooring would bias every estimate low and the +// error would accumulate over the list. Round to the nearest eighth instead to keep it unbiased. +export function roundEstimatedSize(size: number) { + return Math.round(size * 8) / 8; +} + export function isNullOrUndefined(value: unknown) { return value === null || value === undefined; }