Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
97 changes: 96 additions & 1 deletion __tests__/components/PositionView.native.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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 (
<PositionViewSticky
animatedScrollY={animatedScrollY as any}
horizontal={false}
id={7}
onLayout={() => {}}
refView={{ current: null }}
style={{}}
>
<MountProbe onMount={onMount} />
</PositionViewSticky>
);
}

describe("PositionView.native", () => {
it("pushes a tall sticky header out when the next sticky header arrives", () => {
const interpolate = mock((config: any) => config);
Expand Down Expand Up @@ -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(
<StateProvider>
<StickyRemountHarness animatedScrollY={{ interpolate }} onMount={onMount} />
</StateProvider>,
);

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();
});
});
18 changes: 18 additions & 0 deletions __tests__/utils/getItemSize.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
5 changes: 4 additions & 1 deletion src/components/PositionView.native.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<Animated.View ref={refView} style={viewStyle} {...rest}>
<Animated.View key={`sticky-pos:${position}`} ref={refView} style={viewStyle} {...rest}>
{renderStickyHeaderBackdrop}
{children}
</Animated.View>
Expand Down
6 changes: 3 additions & 3 deletions src/utils/getItemSize.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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);
}
}

Expand All @@ -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);
}
}

Expand Down
6 changes: 6 additions & 0 deletions src/utils/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down