/**
* useScrollSections — tracks which section of a scroll view you are reading.
*
* The active section is the last one whose top has passed a reading line a
* little way down the viewport, so the heading you have just scrolled past
* still counts as the one you are in.
*
* That rule alone has a hole at the end, and it is the hole every hand-rolled
* scrollspy has: the final section's top may never reach the reading line,
* because the content runs out first. Its bar then only lights up if you
* over-scroll past the bottom. So being at the bottom of the scroll view is
* treated as being in the last section outright — there is no more scrolling
* left with which to get there.
*
* ```tsx
* const sections = useScrollSections({ ids: SECTIONS.map((s) => s.id) });
*
*
* {SECTIONS.map((section) => (
* …
* ))}
*
*
* …
* ```
*
* It also publishes the scroll position itself, as `scroll`, so a component
* that wants a percentage as well as a section name does not need a second
* scroll listener to get one. The three values are everything a fraction is
* made of, and they come out of the event this hook is already handling.
*/
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import type {
LayoutChangeEvent,
NativeScrollEvent,
NativeSyntheticEvent,
ScrollView,
} from 'react-native';
import { useSharedValue, type SharedValue } from 'react-native-reanimated';
export interface UseScrollSectionsOptions {
/** Section ids, in the order they appear down the page. */
ids: string[];
/**
* How far down the viewport the reading line sits, in pixels. Larger values
* switch to the next section later.
*/
offset?: number;
/** How close to the bottom counts as "at the bottom", in pixels. */
endThreshold?: number;
/** Extra gap left above a section when scrolling to it. */
scrollPadding?: number;
}
/**
* Where the scroller is, as shared values.
*
* Written from the scroll handler on the JavaScript thread, which is where the
* section tracking already runs — so they arrive at `scrollEventThrottle`
* rather than every frame, and anything reading them should ease towards the
* value rather than jumping to it.
*/
export interface ScrollSectionsPosition {
/** Distance scrolled, in points. */
offset: SharedValue;
/** Height of the visible area. */
viewport: SharedValue;
/** Total height of the content. */
content: SharedValue;
}
export interface UseScrollSectionsResult {
/** Attach to the ScrollView, so `scrollTo` has something to drive. */
ref: React.RefObject;
/** The section being read. */
active: string | undefined;
/**
* The scroll position, for anything that needs how far through the page the
* reader is rather than which part of it they are in.
*/
scroll: ScrollSectionsPosition;
/** Spread onto the ScrollView. */
scrollProps: {
onScroll: (event: NativeSyntheticEvent) => void;
onMomentumScrollEnd: () => void;
onLayout: (event: LayoutChangeEvent) => void;
onContentSizeChange: (width: number, height: number) => void;
scrollEventThrottle: number;
};
/** `onLayout` for a section's wrapper: `onLayout={measure(id)}`. */
measure: (id: string) => (event: LayoutChangeEvent) => void;
/** Scroll a section to the top. Pass straight to a rail's `onValueChange`. */
scrollTo: (id: string) => void;
}
/**
* How long a programmatic scroll is given to arrive before the scroll handler
* starts believing positions again. Only reached when the scroll had nowhere to
* go and no momentum end ever fires.
*/
const JUMP_TIMEOUT = 900;
export function useScrollSections({
ids,
offset = 120,
endThreshold = 24,
scrollPadding = 0,
}: UseScrollSectionsOptions): UseScrollSectionsResult {
const ref = useRef(null);
const offsets = useRef>({});
const settle = useRef | null>(null);
const [active, setActive] = useState(ids[0]);
const scrollOffset = useSharedValue(0);
const viewport = useSharedValue(0);
const content = useSharedValue(0);
// Read inside the scroll handler, which must not be re-created on every
// render — a new handler each frame would defeat the throttle.
const idsRef = useRef(ids);
idsRef.current = ids;
const measure = useCallback(
(id: string) => (event: LayoutChangeEvent) => {
offsets.current[id] = event.nativeEvent.layout.y;
},
[]
);
/*
* The section a `scrollTo` is travelling to, while it is still travelling.
*
* An animated scroll passes every section between here and there, and the
* scroll handler cannot tell those apart from sections the reader arrived at
* themselves — so it reported each one as active in turn. Downstream that is
* a real change of section every 16ms: a rail lighting up rows nobody chose,
* a haptic for each, and a jump that ends somewhere the reader watched it
* pass through. None of it happened; the reader asked for one section.
*/
const jumpingTo = useRef(null);
const onScroll = useCallback(
(event: NativeSyntheticEvent) => {
const { contentOffset, layoutMeasurement, contentSize } = event.nativeEvent;
/*
* The position is published before the jump guard, not after it.
*
* A jump is still travel: the page really is moving under the reader, and
* a progress indicator that froze for the length of the animation would
* be reporting something that is not true. What the guard protects is the
* *section*, which the reader chose and which should not flicker through
* every heading the scroll passes on the way there.
*/
scrollOffset.value = contentOffset.y;
viewport.value = layoutMeasurement.height;
content.value = contentSize.height;
// Mid-jump, the position is the animation's and not the reader's.
if (jumpingTo.current !== null) return;
const list = idsRef.current;
if (!list.length) return;
const atEnd =
contentOffset.y + layoutMeasurement.height >= contentSize.height - endThreshold;
if (atEnd) {
setActive(list[list.length - 1]);
return;
}
const line = contentOffset.y + offset;
let current = list[0];
for (const id of list) {
const top = offsets.current[id];
if (top !== undefined && top <= line) current = id;
}
setActive(current);
},
[offset, endThreshold, scrollOffset, viewport, content]
);
/*
* Seeded at layout as well as on scroll, so a page that has not been touched
* yet still reports a viewport and a content height. Without these the
* fraction is zero over zero until the first scroll event, and anything
* drawing it starts by being wrong rather than by being empty.
*/
const onLayout = useCallback(
(event: LayoutChangeEvent) => {
viewport.value = event.nativeEvent.layout.height;
},
[viewport]
);
const onContentSizeChange = useCallback(
(_width: number, height: number) => {
content.value = height;
},
[content]
);
const scrollTo = useCallback(
(id: string) => {
const top = offsets.current[id];
if (top === undefined) return;
jumpingTo.current = id;
if (settle.current) clearTimeout(settle.current);
/*
* A backstop, not the normal way out. `onMomentumScrollEnd` does not fire
* for a scroll that had nowhere to go — a jump to the section already on
* screen — and without this the handler would stay muted for good.
*/
settle.current = setTimeout(() => {
jumpingTo.current = null;
}, JUMP_TIMEOUT);
setActive(id);
ref.current?.scrollTo({ y: Math.max(top - scrollPadding, 0), animated: true });
},
[scrollPadding]
);
const onMomentumScrollEnd = useCallback(() => {
if (settle.current) {
clearTimeout(settle.current);
settle.current = null;
}
jumpingTo.current = null;
}, []);
useEffect(
() => () => {
if (settle.current) clearTimeout(settle.current);
},
[]
);
const scrollProps = useMemo(
() => ({
onScroll,
onMomentumScrollEnd,
onLayout,
onContentSizeChange,
scrollEventThrottle: 16,
}),
[onScroll, onMomentumScrollEnd, onLayout, onContentSizeChange]
);
const scroll = useMemo(
() => ({ offset: scrollOffset, viewport, content }),
[scrollOffset, viewport, content]
);
return { ref, active, scroll, scrollProps, measure, scrollTo };
}