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;
}