diff --git a/__tests__/platform/useStickyScrollHandler.native.test.tsx b/__tests__/platform/useStickyScrollHandler.native.test.tsx index 2bd924f7a..c5d3fc5c7 100644 --- a/__tests__/platform/useStickyScrollHandler.native.test.tsx +++ b/__tests__/platform/useStickyScrollHandler.native.test.tsx @@ -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; + act(() => { + renderer = TestRenderer.create( + , + ); + }); + + // 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(); + }); + + 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; + act(() => { + renderer = TestRenderer.create( + , + ); + }); + + 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( + , + ); + }); + + expect(setValueSpy).toHaveBeenCalledTimes(1); + + setValueSpy.mockRestore(); + }); }); diff --git a/src/platform/useStickyScrollHandler.native.ts b/src/platform/useStickyScrollHandler.native.ts index 88c19365b..62b07b784 100644 --- a/src/platform/useStickyScrollHandler.native.ts +++ b/src/platform/useStickyScrollHandler.native.ts @@ -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, @@ -10,16 +11,38 @@ export function useStickyScrollHandler( onScroll: (event: NativeSyntheticEvent) => 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(() => { - 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 }, }, }, ],