/**
* SectionProgress — a floating pill saying how far through a screen you are,
* and which part of it you are in.
*
* A ring filled to the scroll position, and beside it the title of the section
* being read. Pressed, it opens into the list of sections and jumps to any of
* them.
*
* ```tsx
* const sections = useScrollSections({ ids: SECTIONS.map((s) => s.id) });
*
*
* Introduction
* Setup
*
* ```
*
* ## Two readings, one control
*
* The ring is continuous and the label is not, and that is the point: a
* percentage says how much is left, a section name says what is being read.
* Either on its own leaves the other question open — a bar at 60% of an
* unfamiliar page means nothing in particular, and a heading with no sense of
* depth is a position without a scale.
*
* ## It arrives, and then it stays
*
* Nothing is drawn on the first screen. Past `revealAt` the pill fades in and
* remains for the rest of the scroll — it does not hide again on the way back
* up. A label that comes and goes with the scroll direction is one the reader
* has to catch rather than read.
*
* ## One surface, not a card above a button
*
* Open, the list and the pill are a single bordered box: the pill's row is the
* end of the card rather than a control sitting under a panel of its own. Two
* boxes would draw two outlines a few points apart, and the pill would read as
* something the list had landed on top of rather than as the thing it grew
* out of.
*
* The card is the only thing carrying a border, a background and a shadow.
* Everything inside it is a row.
*
* ## The section, and the colour it brings
*
* An `Item` may carry a `color`, and the active one's colour is taken by the
* ring, the label and a wash across the pill, crossfading as the reader moves
* between sections. It turns the pill into a second, peripheral signal — the
* part of the page you are in, readable without the words.
*/
import {
Children,
createContext,
isValidElement,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
type ReactNode,
} from 'react';
import { Pressable, ScrollView, StyleSheet, View, type ViewProps } from 'react-native';
import Animated, {
FadeIn,
interpolate,
interpolateColor,
runOnJS,
useAnimatedProps,
useAnimatedReaction,
useAnimatedStyle,
useReducedMotion,
useSharedValue,
withTiming,
type SharedValue,
} from 'react-native-reanimated';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import Svg, { Circle, G } from 'react-native-svg';
import { useCSSVariable } from 'uniwind';
import { useBackHandler } from '../../hooks/use-back-handler';
import { useScrollProgress } from '../../primitives/scroll-progress';
import { Text, textChildren } from '../../primitives/text';
import { cn } from '../../utils/cn';
import { selectionTick } from '../../utils/haptics';
const AnimatedCircle = Animated.createAnimatedComponent(Circle);
/** Diameter of the ring, and the weight of its stroke. */
const RING_SIZE = 22;
const RING_STROKE = 2.5;
/**
* How long the ring takes to reach a new scroll position.
*
* The position arrives from a scroll handler on the JavaScript thread, so it
* comes in steps rather than continuously. Easing towards each step turns that
* back into a glide — and because the easing itself runs on the UI thread, a
* busy JavaScript thread costs the ring some lag rather than the whole motion.
*/
const PROGRESS_EASE = 160;
/** How long the pill takes to arrive, and to take on a new section's colour. */
const REVEAL_DURATION = 200;
const TINT_DURATION = 240;
/** How far the pill rises as it appears. */
const REVEAL_RISE = 10;
/**
* How long a jump from the panel is given to arrive before section changes
* start being felt again. Only reached when the scroll had nowhere to go.
*/
const JUMP_TIMEOUT = 900;
/** List width: a floor so one short section still reads, and a cap so long
titles wrap instead of pushing the card across the screen. */
const LIST_MIN_WIDTH = 180;
const LIST_MAX_WIDTH = '86%' as const;
/**
* How tall the list may grow before it scrolls: about six rows.
*
* A cap, and a `flexGrow: 0` beside it, because a `ScrollView` carries
* `flexGrow: 1` in its own base style — inside a card whose height comes from
* its contents, that is a list which fills every point the screen will give it
* and a card stretched from edge to edge behind six rows.
*/
const LIST_MAX_HEIGHT = 260;
/**
* The collapsed pill's height, from what it is built out of: the ring and the
* padding either side of it.
*
* Halved, it is the closed corner radius — and it is a number rather than a
* `rounded-full` because a radius of 9999 on a bordered shape draws a border
* that thickens through the curve at each end and thins along the straight
* top and bottom. A radius that is exactly half the height curves once.
*/
const PILL_HEIGHT = RING_SIZE + 12;
/** The card's radius once it has opened into a list. */
const CARD_RADIUS = 20;
/** How long the corner takes to round off into a card, and the list to arrive. */
const EXPAND_DURATION = 220;
const LIST_FADE = 160;
/** The border the card draws, and the radius the wash inside it takes. */
const CARD_BORDER = 1;
export type SectionProgressPlacement =
| 'top-left'
| 'top-center'
| 'top-right'
| 'bottom-left'
| 'bottom-center'
| 'bottom-right';
/** The colour an `Item` can bring with it. */
export type SectionProgressColor =
| 'primary'
| 'success'
| 'warning'
| 'danger'
| 'info'
| 'foreground';
/** Which side of the pill's row the content sits on, per placement. */
const ALIGNMENT: Record = {
'top-left': 'items-start',
'top-center': 'items-center',
'top-right': 'items-end',
'bottom-left': 'items-start',
'bottom-center': 'items-center',
'bottom-right': 'items-end',
};
/**
* The tokens, read in one call, and where each colour name sits in the result.
*
* One array call rather than one hook per name: an `Item`'s colour is a prop
* on a child element, and a hook cannot be run per child without the number of
* hooks changing with the number of sections.
*/
const COLOR_TOKENS = [
'--color-foreground',
'--color-primary',
'--color-success',
'--color-warning',
'--color-destructive',
'--color-info',
'--color-muted',
];
const COLOR_INDEX: Record = {
foreground: 0,
primary: 1,
success: 2,
warning: 3,
danger: 4,
info: 5,
};
/** Where the track colour sits in the same result. */
const TRACK_INDEX = 6;
/** Drawn if a token cannot be resolved — a theme that has not loaded yet. */
const COLOR_FALLBACK = '#f5f5f5';
const TRACK_FALLBACK = '#3f3f46';
/**
* Where the scroller is.
*
* Three values rather than one fraction, because the fraction is not the only
* thing that is wanted: the reveal threshold is a distance in points, and a
* distance cannot be recovered from a percentage.
*
* Both `useScrollSections().scroll` and the `ScrollProgress` primitive's
* context satisfy this shape.
*/
export interface SectionProgressScroll {
/** Distance scrolled, in points. */
offset: SharedValue;
/** Height of the visible area. */
viewport: SharedValue;
/** Total height of the content. */
content: SharedValue;
}
interface SectionProgressContextValue {
value: string | undefined;
onValueChange: (value: string) => void;
close: () => void;
/** The active section's colour, already resolved to something drawable. */
tint: string;
}
const SectionProgressContext = createContext(null);
function useSectionProgress(component: string): SectionProgressContextValue {
const context = useContext(SectionProgressContext);
if (!context) {
throw new Error(`${component} must be used within a `);
}
return context;
}
export interface SectionProgressProps extends Omit {
className?: string;
/**
* The scroll position the ring is filled from. `useScrollSections` returns
* one as `scroll`; without it the component falls back to the nearest
* `ScrollProgress`, and with neither the ring stays empty.
*/
scroll?: SectionProgressScroll;
/**
* Fill the ring from a value of your own, between 0 and 1. Nothing is
* derived when this is passed.
*/
progress?: SharedValue | number;
/** Active section id. Controlled — usually driven by a scroll handler. */
value?: string;
/** Starting section when uncontrolled. */
defaultValue?: string;
/** Fires when a section is chosen from the panel. Scroll there. */
onValueChange?: (value: string) => void;
/** Controlled expansion of the panel. */
open?: boolean;
/** Whether the panel starts open when uncontrolled. */
defaultOpen?: boolean;
/** Fires when the panel opens or closes, however it was done. */
onOpenChange?: (open: boolean) => void;
/** Which corner or edge the pill floats in. */
placement?: SectionProgressPlacement;
/** Gap between the pill and the edge of the safe area. */
offset?: number;
/**
* How far the reader must scroll, in points, before the pill appears. `0`
* shows it from the first frame. It never hides again.
*/
revealAt?: number;
/**
* Tick under the finger on every change of section, however it was made.
* Nothing between a tap in the panel and its arrival counts as a change.
* Needs the optional `expo-haptics` package; without it this does nothing.
*/
haptics?: boolean;
/**
* What the pill is called to a screen reader. The section being read and
* the percentage are announced after it, so this names the control rather
* than describing the state.
*/
label?: string;
/** One `SectionProgress.Item` per section, in the order they appear. */
children: ReactNode;
}
function SectionProgressRoot({
className,
scroll,
progress,
value: valueProp,
defaultValue,
onValueChange,
open: openProp,
defaultOpen = false,
onOpenChange,
placement = 'bottom-center',
offset = 16,
revealAt = 64,
haptics = false,
label = 'Sections',
children,
...props
}: SectionProgressProps) {
const insets = useSafeAreaInsets();
const reduceMotion = useReducedMotion();
const [internalValue, setInternalValue] = useState(defaultValue);
const [internalOpen, setInternalOpen] = useState(defaultOpen);
const isControlled = valueProp !== undefined;
const value = isControlled ? valueProp : internalValue;
const isOpenControlled = openProp !== undefined;
const open = isOpenControlled ? openProp : internalOpen;
const setOpen = useCallback(
(next: boolean) => {
if (!isOpenControlled) setInternalOpen(next);
onOpenChange?.(next);
},
[isOpenControlled, onOpenChange]
);
const close = useCallback(() => setOpen(false), [setOpen]);
// The panel owns the back button while it is up — back should put the list
// away, not leave the screen behind it.
useBackHandler(open, close);
/*
* The sections, read straight off the children.
*
* The pill has to draw the active section's own label and colour while the
* panel that holds the rows is closed and unmounted. Reading the elements is
* synchronous, so the first frame is already right — a registration effect
* would leave the pill blank until after mount, which is the frame the
* reveal animation is playing on.
*/
const items = useMemo(() => {
const found: { value: string; label: ReactNode; color?: SectionProgressColor }[] = [];
Children.forEach(children, (child) => {
if (!isValidElement(child) || child.type !== SectionProgressItem) return;
const itemProps = child.props as SectionProgressItemProps;
if (typeof itemProps.value !== 'string') return;
found.push({
value: itemProps.value,
label: itemProps.children,
color: itemProps.color,
});
});
return found;
}, [children]);
const active = items.find((item) => item.value === value) ?? items[0];
// Narrowed on the way out because `useCSSVariable` resolves to a number for
// any token that happens to be one.
const tokens = useCSSVariable(COLOR_TOKENS);
const resolved = tokens[COLOR_INDEX[active?.color ?? 'foreground']];
const tint = typeof resolved === 'string' ? resolved : COLOR_FALLBACK;
const trackToken = tokens[TRACK_INDEX];
const track = typeof trackToken === 'string' ? trackToken : TRACK_FALLBACK;
/* ------------------------------------------------------------------ *
* The ring
* ------------------------------------------------------------------ */
// Called unconditionally, used only as a fallback: a hook cannot sit behind
// a `??`, and the context returns null outside a provider anyway.
const inherited = useScrollProgress();
const source = scroll ?? inherited;
const filled = useSharedValue(0);
useAnimatedReaction(
() => {
if (typeof progress === 'number') return progress;
if (progress) return progress.value;
if (!source) return 0;
const span = source.content.value - source.viewport.value;
return span > 0 ? source.offset.value / span : 0;
},
(next) => {
const clamped = next < 0 ? 0 : next > 1 ? 1 : next;
filled.value = withTiming(clamped, { duration: PROGRESS_EASE });
},
[progress, source]
);
const radius = (RING_SIZE - RING_STROKE) / 2;
const circumference = 2 * Math.PI * radius;
const centre = RING_SIZE / 2;
/*
* A circle's stroke starts at three o'clock, so it is turned back a quarter
* to start at twelve. An arc that begins at three reads as a gauge already
* part-way along.
*/
const rotate = `rotate(-90 ${centre} ${centre})`;
/* ------------------------------------------------------------------ *
* The reveal
* ------------------------------------------------------------------ */
/*
* A React state, not only a shared value: the pill has to stop taking
* touches while it is invisible, and `pointerEvents` is not a style. The
* reaction fires when the threshold is crossed rather than every frame, so
* this costs one hop to the JavaScript thread per scroll, not per event.
*/
const [revealed, setRevealed] = useState(revealAt <= 0);
useAnimatedReaction(
() => (source ? source.offset.value >= revealAt : true),
(next, previous) => {
if (next !== previous) runOnJS(setRevealed)(next);
},
[revealAt, source]
);
const shown = useSharedValue(revealAt <= 0 ? 1 : 0);
useEffect(() => {
const to = revealed ? 1 : 0;
shown.value = reduceMotion ? to : withTiming(to, { duration: REVEAL_DURATION });
}, [revealed, reduceMotion, shown]);
const revealStyle = useAnimatedStyle(() => ({
opacity: shown.value,
transform: [
{
translateY: interpolate(
shown.value,
[0, 1],
[placement.startsWith('top') ? -REVEAL_RISE : REVEAL_RISE, 0]
),
},
],
}));
/* ------------------------------------------------------------------ *
* The colour the section brings
* ------------------------------------------------------------------ */
/*
* Two colours and a scalar between them, rather than one colour swapped: a
* section change is a change of state the reader did not ask for, and one
* that lands instantly reads as a flicker rather than as a transition.
*/
const [fade, setFade] = useState({ from: tint, to: tint });
const crossfade = useSharedValue(1);
useEffect(() => {
setFade((current) => (current.to === tint ? current : { from: current.to, to: tint }));
}, [tint]);
useEffect(() => {
if (fade.from === fade.to) return;
crossfade.value = 0;
crossfade.value = reduceMotion ? 1 : withTiming(1, { duration: TINT_DURATION });
}, [fade, crossfade, reduceMotion]);
const ringProps = useAnimatedProps(() => ({
strokeDasharray: [circumference * filled.value, circumference],
stroke: interpolateColor(crossfade.value, [0, 1], [fade.from, fade.to]),
}));
const tintStyle = useAnimatedStyle(() => ({
color: interpolateColor(crossfade.value, [0, 1], [fade.from, fade.to]),
}));
/*
* The corner, from a pill to a card.
*
* A number rather than `rounded-full`, because a radius far larger than the
* shape draws a border that thickens through each curved end and thins along
* the straight edges between them. Exactly half the height curves once, and
* the hairline stays one weight the whole way round.
*/
const expanded = useSharedValue(open ? 1 : 0);
useEffect(() => {
const to = open ? 1 : 0;
expanded.value = reduceMotion ? to : withTiming(to, { duration: EXPAND_DURATION });
}, [open, reduceMotion, expanded]);
const cardStyle = useAnimatedStyle(() => ({
borderRadius: interpolate(expanded.value, [0, 1], [PILL_HEIGHT / 2, CARD_RADIUS]),
}));
const washStyle = useAnimatedStyle(() => ({
backgroundColor: interpolateColor(crossfade.value, [0, 1], [fade.from, fade.to]),
// One point tighter than the card's, since it sits inside the border.
borderRadius:
interpolate(expanded.value, [0, 1], [PILL_HEIGHT / 2, CARD_RADIUS]) - CARD_BORDER,
}));
/* ------------------------------------------------------------------ *
* Choosing a section, and feeling the ones scrolled past
* ------------------------------------------------------------------ */
/*
* The section a tap asked for, while the screen is still travelling to it.
* A jump passes every section in between, and each of those arrives here as
* a change of section — one tap, three ticks, none of them a place the
* reader went.
*/
const jumpTo = useRef(null);
const jumpTimer = useRef | null>(null);
const endJump = useCallback(() => {
jumpTo.current = null;
if (jumpTimer.current) {
clearTimeout(jumpTimer.current);
jumpTimer.current = null;
}
}, []);
useEffect(() => () => endJump(), [endJump]);
const handleValueChange = useCallback(
(next: string) => {
jumpTo.current = next;
if (jumpTimer.current) clearTimeout(jumpTimer.current);
// A backstop, not the usual way out: a jump to a section the scroller
// cannot reach never arrives, and without this nothing would tick again.
jumpTimer.current = setTimeout(endJump, JUMP_TIMEOUT);
if (!isControlled) setInternalValue(next);
onValueChange?.(next);
setOpen(false);
},
[isControlled, onValueChange, setOpen, endJump]
);
/*
* Fired from the resolved value rather than from the handler, so a section
* arrived at by scrolling is felt as well as one that was tapped. The ref
* skips the first run — mounting is not a change of section.
*/
const ticked = useRef(false);
useEffect(() => {
if (!ticked.current) {
ticked.current = true;
return;
}
if (jumpTo.current !== null) {
if (value !== jumpTo.current) return;
endJump();
}
if (haptics) selectionTick();
}, [value, haptics, endJump]);
/* ------------------------------------------------------------------ *
* What a screen reader is told
* ------------------------------------------------------------------ */
/*
* Rounded to five, and pushed across only when it changes: the ring is a
* continuous value and an announcement is not, so the number is coarse on
* purpose rather than accurate to a percent nobody can act on.
*/
const [percent, setPercent] = useState(0);
useAnimatedReaction(
() => Math.round(filled.value * 20) * 5,
(next, previous) => {
if (next !== previous) runOnJS(setPercent)(next);
}
);
const activeLabel = typeof active?.label === 'string' ? active.label : undefined;
const context = useMemo(
() => ({ value: active?.value, onValueChange: handleValueChange, close, tint }),
[active?.value, handleValueChange, close, tint]
);
/*
* Where the card sits, as padding on a full-screen overlay rather than as a
* position on the card itself.
*
* The overlay is what the dismiss layer needs: a press anywhere outside the
* card has to put the list away, and "anywhere" is the whole screen. It
* takes no touches of its own, so everything under it still scrolls.
*/
const atTop = placement.startsWith('top');
return (
{open ? (
) : null}
{/*
* One box around the list and the pill.
*
* It carries the border, the background and the shadow; everything
* inside it is a row. Drawn as two boxes the pill would read as a
* control the list had landed on rather than as the thing the list
* grew out of — and two outlines a few points apart is the seam that
* makes a floating control look assembled.
*/}
{/* The section's colour, washed across the whole card. Inset by the
border rather than over it, and rounded one point tighter, so the
outline stays a single even hairline through the curves. */}
{open && !atTop ? (
{children}
) : null}
setOpen(!open)}
className="flex-row items-center gap-2.5 py-1.5 pe-4 ps-2"
>
{active?.label}
{open && atTop ? (
{children}
) : null}
);
}
/**
* The sections.
*
* No rule between the list and the pill's row. The rows are already separated
* from the row below by their own gap and by the fill on the active one, and a
* hairline across a card this small draws a second edge a few points inside
* the one the card already has.
*
* It fades in and is gone on close, with no exit animation. An exiting
* animation keeps the view mounted as a snapshot while the card re-lays-out
* around it — the card shrinks to the pill under a list that is still on
* screen, so the control shifts and settles back over the following frames.
* Closing is one change, in one frame.
*/
function SectionProgressList({
children,
reduceMotion,
}: {
children: ReactNode;
reduceMotion: boolean;
}) {
return (
{textChildren(children)}
);
}
export interface SectionProgressItemProps {
className?: string;
/** Section this row jumps to. Matches the root's `value`. */
value: string;
/**
* The colour this section brings to the pill. Left out, the section takes
* the foreground colour like every other.
*/
color?: SectionProgressColor;
/** The section's title. It is what the collapsed pill shows. */
children: ReactNode;
}
/**
* One section: a row in the panel, and the label the pill shows while that
* section is the one being read.
*/
function SectionProgressItem({ className, value, children }: SectionProgressItemProps) {
const { value: active, onValueChange, tint } = useSectionProgress('SectionProgress.Item');
const selected = active === value;
return (
onValueChange(value)}
className={cn('flex-row items-center gap-3 rounded-xl px-3 py-2 active:bg-accent', selected && 'bg-accent', className)}
>
{/* The dot is the position marker the pill's ring cannot be at this
size — filled and in the section's own colour when it is the one
being read, a hairline dot otherwise. */}
{children}
);
}
SectionProgressRoot.displayName = 'SectionProgress';
SectionProgressItem.displayName = 'SectionProgress.Item';
export const SectionProgress = Object.assign(SectionProgressRoot, {
Item: SectionProgressItem,
});