/** * ScrollProgress — publishes a scroll container's position so children can * animate against it. * * Scroll-driven effects all need the same two numbers: where the scroller is, * and how tall its viewport is. Every component that wanted them measuring for * itself would mean one scroll listener per effect and one measurement pass per * effect per frame, for two values that are the same for all of them. * * It wraps the scroll view you already have rather than replacing it — the * child is cloned with an animated scroll handler composed onto it, the same * way `ScrollFade` does — so a `FlatList`, a `SectionList` or your own * scrollable all work, and nothing has to be rewritten to adopt it. * * ```tsx * * * * * * ``` * * Both values are shared values, so the whole chain from scroll event to * animated style stays on the UI thread and nothing re-renders as you scroll. */ import { Children, createContext, isValidElement, useContext, useMemo, useRef, type ComponentType, type ReactElement, type ReactNode, } from 'react'; import { View, type LayoutChangeEvent, type ViewProps } from 'react-native'; import Animated, { useAnimatedScrollHandler, useComposedEventHandler, useSharedValue, type AnimatedScrollViewProps, type SharedValue, } from 'react-native-reanimated'; export interface ScrollProgressValue { /** Distance scrolled, in pixels. */ offset: SharedValue; /** Height of the visible area. */ viewport: SharedValue; /** Total height of the content. */ content: SharedValue; /** * Window-space top edge of the scroller. What turns an element's measured * `pageY` into a position inside *this* viewport rather than the screen's — * they differ by whatever sits above the scroller, and assuming they do not * is why scroll effects drift under a header. */ top: SharedValue; } const ScrollProgressContext = createContext(null); /** * The nearest enclosing scroll container's position, or `null` outside one. * * It returns null rather than throwing because every consumer of this also * takes an explicit `progress` prop — a component that can be driven by hand * should not insist on a provider it does not need. */ export function useScrollProgress(): ScrollProgressValue | null { return useContext(ScrollProgressContext); } interface ScrollableProps { onScroll?: AnimatedScrollViewProps['onScroll']; onLayout?: (event: LayoutChangeEvent) => void; onContentSizeChange?: (width: number, height: number) => void; scrollEventThrottle?: number; } export interface ScrollProgressProps extends ViewProps { className?: string; /** Exactly one scrollable — a ScrollView, FlatList, or anything like them. */ children?: ReactNode; } export function ScrollProgress({ className, children, ...props }: ScrollProgressProps) { const offset = useSharedValue(0); const viewport = useSharedValue(0); const content = useSharedValue(0); const top = useSharedValue(0); const hostRef = useRef(null); const child = Children.only(children) as ReactElement; // Reanimated can only drive a scroll handler on an animated component, and // what was passed in is not necessarily `Animated.ScrollView`. Keyed on the // element *type*, not the element: rebuilding the wrapper would remount the // list and lose its scroll position. const childType = isValidElement(child) ? (child.type as ComponentType) : null; const AnimatedScrollable = useMemo( () => (childType ? Animated.createAnimatedComponent(childType) : null), [childType] ); const scrollHandler = useAnimatedScrollHandler({ onScroll: (event) => { offset.value = event.contentOffset.y; viewport.value = event.layoutMeasurement.height; content.value = event.contentSize.height; }, }); // A consumer's own `onScroll` is composed rather than dropped — but since the // child is now an animated component, it has to be an animated handler too. const onScroll = useComposedEventHandler([ scrollHandler, (child.props.onScroll as typeof scrollHandler | undefined) ?? null, ]); // Taken up front as well as on scroll, so anything already on screen animates // from the first frame instead of waiting for the first scroll event. const onLayout = (event: LayoutChangeEvent) => { viewport.value = event.nativeEvent.layout.height; // A frame later than onLayout on both platforms, because measureInWindow // is only meaningful once the view is attached and positioned. requestAnimationFrame(() => { hostRef.current?.measureInWindow((_x, y) => { if (y >= 0) top.value = y; }); }); child.props.onLayout?.(event); }; const onContentSizeChange = (width: number, height: number) => { content.value = height; child.props.onContentSizeChange?.(width, height); }; const value = useMemo( () => ({ offset, viewport, content, top }), [offset, viewport, content, top] ); return ( {AnimatedScrollable && isValidElement(child) ? ( ) : ( child )} ); } ScrollProgress.displayName = 'ScrollProgress';