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
62 changes: 62 additions & 0 deletions __tests__/platform/useStickyScrollHandler.native.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -72,4 +72,66 @@ describe("useStickyScrollHandler.native", () => {

animatedEventSpy.mockRestore();
});

it("seeds animatedScrollY from the last native scroll when the handler attaches after mount", () => {
const onScroll = () => {};
const onResult = () => {};
const ctx = createMockContext();
const setValueSpy = spyOn(ctx.animatedScrollY, "setValue");
const animatedEventSpy = spyOn(Animated, "event");

let renderer: ReturnType<typeof TestRenderer.create>;
act(() => {
renderer = TestRenderer.create(
<HookProbe ctx={ctx} onResult={onResult} onScroll={onScroll} stickyHeaderIndices={undefined} />,
);
});

// Nothing has attached Animated.event yet, so the mount-time inset adjustment iOS reports
// for contentInsetAdjustmentBehavior="automatic" only reaches the plain JS scroll handler.
expect(animatedEventSpy).toHaveBeenCalledTimes(0);
expect(setValueSpy).toHaveBeenCalledTimes(0);
ctx.state.lastNativeScroll = -96;

act(() => {
renderer!.update(<HookProbe ctx={ctx} onResult={onResult} onScroll={onScroll} stickyHeaderIndices={[0]} />);
});

expect(animatedEventSpy).toHaveBeenCalledTimes(1);
expect(setValueSpy).toHaveBeenCalledTimes(1);
expect(setValueSpy).toHaveBeenCalledWith(-96);

animatedEventSpy.mockRestore();
setValueSpy.mockRestore();
});

it("does not re-seed animatedScrollY while the handler stays attached", () => {
const onScroll = () => {};
const onResult = () => {};
const ctx = createMockContext();
ctx.state.lastNativeScroll = -96;
const setValueSpy = spyOn(ctx.animatedScrollY, "setValue");

let renderer: ReturnType<typeof TestRenderer.create>;
act(() => {
renderer = TestRenderer.create(
<HookProbe ctx={ctx} onResult={onResult} onScroll={onScroll} stickyHeaderIndices={[0]} />,
);
});

expect(setValueSpy).toHaveBeenCalledTimes(1);

// Animated.event owns the value once attached, so a later indices change must not overwrite
// it with the JS-tracked offset, which can lag behind during a scroll.
ctx.state.lastNativeScroll = -40;
act(() => {
renderer!.update(
<HookProbe ctx={ctx} onResult={onResult} onScroll={onScroll} stickyHeaderIndices={[0, 5]} />,
);
});

expect(setValueSpy).toHaveBeenCalledTimes(1);

setValueSpy.mockRestore();
});
});
31 changes: 27 additions & 4 deletions src/platform/useStickyScrollHandler.native.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { useMemo } from "react";
import { useMemo, useRef } from "react";
import { Animated, type NativeScrollEvent, type NativeSyntheticEvent } from "react-native";

import type { StateContext } from "@/state/state";
import { isHorizontalRTL } from "@/utils/rtl";

export function useStickyScrollHandler(
stickyHeaderIndices: number[] | undefined,
Expand All @@ -10,16 +11,38 @@ export function useStickyScrollHandler(
onScroll: (event: NativeSyntheticEvent<NativeScrollEvent>) => void,
) {
const shouldUseRnAnimatedEngine = !ctx.state.props.stickyPositionComponentInternal;
const isAnimatedEngineActive = !!stickyHeaderIndices?.length && shouldUseRnAnimatedEngine;
const wasAnimatedEngineActive = useRef(false);

// Create dual scroll handlers - one for native animations, one for JS logic
return useMemo<typeof onScroll>(() => {
if (stickyHeaderIndices?.length && shouldUseRnAnimatedEngine) {
const { animatedScrollY } = ctx;
const wasActive = wasAnimatedEngineActive.current;
wasAnimatedEngineActive.current = isAnimatedEngineActive;

if (isAnimatedEngineActive) {
const animatedScrollY = ctx.animatedScrollY as unknown as Animated.Value;
if (!wasActive) {
// Animated.event is the only writer of animatedScrollY, so any scroll that happened
// before it attached was never recorded - most notably the rest offset iOS reports at
// mount for contentInsetAdjustmentBehavior="automatic". Seed the value from the
// JS-tracked native offset so sticky headers paint in the right place immediately
// instead of staying stuck at 0 until the next scroll event. lastNativeScroll matches
// the raw contentOffset that Animated.event writes, except on RTL horizontal lists
// where it has been converted to a logical offset.
const { lastNativeScroll } = ctx.state;
if (
typeof lastNativeScroll === "number" &&
Number.isFinite(lastNativeScroll) &&
!isHorizontalRTL(ctx.state)
) {
animatedScrollY.setValue(lastNativeScroll);
}
}
return Animated.event(
[
{
nativeEvent: {
contentOffset: { [horizontal ? "x" : "y"]: animatedScrollY as unknown as Animated.Value },
contentOffset: { [horizontal ? "x" : "y"]: animatedScrollY },
},
},
],
Expand Down