/** * ScrollHeader — a screen header that hands over to a compact bar as the page * scrolls. * * A screen with a title has two states and every app draws both: the title * large, at rest, with room around it, and the title small, pinned, once you * are reading. Hand-rolling the change between them is where screens stop * matching each other — one snaps, one crossfades at a different point, one * forgets the bar is over a safe area — so the two states and the transit * between them are one component here. * * ```tsx * * * Library * * * * * * Library * 128 components * * {rows} * * ``` * * ## The band collapses; nothing inside it is animated up * * The header is one absolutely positioned band over the scroller, and the only * thing driven by the scroll is its height: `bar + max(0, large - offset)`. * The bar is anchored to its top and the large block to its bottom, so the * band shrinking is what carries the large block up and behind the bar, and * `overflow-hidden` is what cuts it off there. * * Driving the height rather than a `translateY` is what makes the rest fall * out for free. Over-scrolling makes `large - offset` larger than `large`, so * the band grows and a cover filling it stretches by being laid out bigger — * no scale transform, so a photograph stretches without going soft. And the * band's height is the inset the content needs, so there is one number rather * than two that have to agree. * * ## The two titles cross-fade; neither one morphs * * The large title and the bar title are separate elements that fade past each * other. A single title scaled and translated between the two positions tracks * beautifully until the text is long enough to truncate, at which point it is * animating between two different strings. * * Both are therefore in the tree at once, which is a problem for a screen * reader — one of them is invisible and would still be read. So the crossing * point is also published to React as `collapsed`, and whichever title is not * being shown is hidden from accessibility. That is one re-render per crossing * and no more: everything that runs per frame stays in shared values. * * ## The bar's surface is a fill, or a frost * * `surface` says what the bar is drawn on once it has taken over: a token * fill, nothing at all over a cover, or `blur` — a real material, so the rows * passing under the bar stay legible as shape and colour while losing the * detail that would compete with the title on top. * * The frost needs a native view, and there are two ways it cannot be drawn: * `expo-blur` is optional and may not be installed, and Reduce Transparency is * a preference that outranks the design. Both fall back to the plain * background token rather than to nothing, because a bar you cannot read is a * worse answer than a bar that is not frosted. * * ## What it needs * * A height to fill, and exactly one scrollable child. The child is cloned with * the scroll handler and the content inset composed onto it, the same way * `ScrollFade` wraps one, so a `ScrollView`, a `FlatList` or a `SectionList` * all work unchanged. */ import { Children, createContext, forwardRef, isValidElement, useCallback, useContext, useMemo, useState, type ComponentType, type ReactNode, type Ref, } from 'react'; import { Image, Platform, StyleSheet, View, type ImageSourcePropType, type LayoutChangeEvent, type NativeScrollEvent, type NativeSyntheticEvent, type StyleProp, type Text as RNText, type ViewProps, type ViewStyle, } from 'react-native'; import { LinearGradient } from 'expo-linear-gradient'; import Animated, { interpolate, runOnJS, scrollTo, useAnimatedReaction, useAnimatedRef, useAnimatedScrollHandler, useAnimatedStyle, useComposedEventHandler, useDerivedValue, useReducedMotion, useSharedValue, withTiming, type AnimatedScrollViewProps, type SharedValue, } from 'react-native-reanimated'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { tv, type VariantProps } from 'tailwind-variants'; import { useCSSVariable } from 'uniwind'; import { hasBlur, useReduceTransparency } from '../../primitives/scrim'; import { Text, textChildren, type TextProps } from '../../primitives/text'; import { useThemeMode } from '../../theme/use-theme'; import { cn } from '../../utils/cn'; import { BAR_TITLE_ARRIVE, HANDOVER_DURATION, LARGE_EXIT, SURFACE_ARRIVE, bandHeight, collapseProgress, contentInset, hasSpan, isCrossing, snapTarget, } from './scroll-header-math'; /** * Height of the pinned bar, in points, before the device's top inset is added. * The platform navigation bars are 44 and 56; 48 is the target-size floor the * rest of the library holds compact controls to, and it sits between them. */ const BAR_HEIGHT = 48; /** * The scrollables Reanimated already animates. Its animated components are * ordinary function components carrying the *inner* component's name, so there * is nothing on one to test — but these two are module-level constants, and * identity is exact. */ /** Which way the frost tints. `default` follows the app's theme. */ export type ScrollHeaderMaterial = 'light' | 'dark' | 'default'; /** * `expo-blur`'s BlurView, or null when it is not installed. Resolved once at * module load — the require is cheap and caching it avoids a try/catch on * every render. */ const BlurView: ComponentType<{ intensity?: number; tint?: ScrollHeaderMaterial; style?: unknown; }> | null = (() => { try { // eslint-disable-next-line @typescript-eslint/no-require-imports const mod = require('expo-blur'); return (mod?.BlurView as ComponentType<{ intensity?: number }>) ?? null; } catch { return null; } })(); /** * Depth of the frost. Heavier than a scrim's, because a scrim covers a whole * screen and this is a thin band read against content that is moving under it. */ const DEFAULT_BLUR_INTENSITY = 40; const ANIMATED_SCROLLABLES = new Set([Animated.ScrollView, Animated.FlatList]); const scrollHeaderVariants = tv({ slots: { root: 'flex-1', band: 'absolute inset-x-0 top-0 overflow-hidden', cover: 'absolute inset-0', bar: 'absolute inset-x-0 top-0 flex-row items-center gap-3 px-4', barSurface: 'absolute inset-0', large: 'absolute inset-x-0 bottom-0 gap-1 px-4 pb-3', actions: 'ml-auto flex-row items-center gap-1', }, variants: { /** What the bar is drawn on once it has taken over. */ surface: { plain: { barSurface: 'bg-background' }, muted: { barSurface: 'bg-card' }, none: { barSurface: 'bg-transparent' }, // No fill of its own: an opaque colour behind the material is part of // what the material samples, so a frost over one is a flat bar that has // paid for a native view. blur: { barSurface: 'bg-transparent' }, }, divider: { true: { barSurface: 'border-b border-border' }, false: {}, }, }, defaultVariants: { surface: 'plain', divider: true, }, }); type ScrollHeaderVariantProps = VariantProps; /** What the bar is drawn on once the large block has gone. */ export type ScrollHeaderSurface = NonNullable; /** Which half of the header a part is standing in. */ export type ScrollHeaderSlot = 'bar' | 'large'; interface ScrollHeaderContextValue { /** 0 while the large block is whole, 1 once the bar has taken over. */ progress: SharedValue; /** Distance scrolled, in points. Negative while the finger pulls down. */ offset: SharedValue; /** Measured height of the large block. */ largeHeight: SharedValue; /** The crossing point, published to React for accessibility. */ collapsed: boolean; /** Whether there is a large block at all. A bar-only header has no crossing. */ hasLarge: boolean; measureLarge: (event: LayoutChangeEvent) => void; } const ScrollHeaderContext = createContext(undefined); function useScrollHeader(component: string): ScrollHeaderContextValue { const context = useContext(ScrollHeaderContext); if (!context) throw new Error(`${component} must be used within a `); return context; } /** * Which half a part is in. `Title` reads it to know which size to be and which * way to fade, so the same element can be written in both places. */ const ScrollHeaderSlotContext = createContext('large'); interface ScrollableProps { onScroll?: AnimatedScrollViewProps['onScroll']; contentContainerStyle?: StyleProp; scrollIndicatorInsets?: { top?: number; bottom?: number; left?: number; right?: number }; scrollEventThrottle?: number; contentInsetAdjustmentBehavior?: AnimatedScrollViewProps['contentInsetAdjustmentBehavior']; ref?: Ref; } export interface ScrollHeaderProps extends ViewProps { className?: string; /** * Height of the pinned bar in points, before the device's top inset. The * inset is added on top of this rather than taken out of it, so the bar's * contents keep this much room on every device. */ barHeight?: number; /** * How much of the large block has to leave before the bar has fully taken * over, as a fraction of its height. Below 1 the crossing happens early, * which suits a tall block whose last few points are not worth waiting for. */ threshold?: number; /** * Settle a part-scrolled band open or closed when the finger lifts, rather * than leaving the header half-collapsed. */ snap?: boolean; /** * Let the band grow past its resting height when the scroller is pulled * down. A cover fills the band, so this is what stretches it. Off under * Reduce Motion. */ stretch?: boolean; /** Add the device's top inset above the bar. Off inside a screen that already has one. */ inset?: boolean; /** * Called as the bar takes over, and again when the large block comes back. * Fires on the crossing, not on every frame. */ onCollapsedChange?: (collapsed: boolean) => void; /** * A shared value to mirror the collapse into, 0 to 1, for animating * something outside the header against the same transition. */ progress?: SharedValue; /** The parts, and exactly one scrollable. */ children?: ReactNode; } /** * The band, and the scroller it sits over. Everything that is not a part is * treated as the scrollable child. */ const ScrollHeaderRoot = forwardRef( ( { className, barHeight = BAR_HEIGHT, threshold = 1, snap = true, stretch = true, inset = true, onCollapsedChange, progress: externalProgress, children, ...props }, ref ) => { const insets = useSafeAreaInsets(); const reducedMotion = useReducedMotion(); const insetTop = inset ? insets.top : 0; const barBand = insetTop + barHeight; const offset = useSharedValue(0); const largeHeight = useSharedValue(0); const [largeSize, setLargeSize] = useState(0); const [collapsed, setCollapsed] = useState(false); const scrollRef = useAnimatedRef(); // Measured into both a shared value, for the band's height, and React // state, for the content inset — the inset is a resting number that only // changes when the block is re-laid-out, so paying a render for it costs // nothing per frame and keeps the scroller's props stable. const measureLarge = useCallback((event: LayoutChangeEvent) => { const { height } = event.nativeEvent.layout; largeHeight.value = height; setLargeSize((previous) => (Math.abs(previous - height) < 0.5 ? previous : height)); }, [largeHeight]); const progress = useDerivedValue(() => { const next = collapseProgress(offset.value, largeHeight.value, threshold); // A header with a block hands over across that block's height, and the // finger sets the pace. One without has no distance to interpolate over, // so the step is timed rather than instant — the alternative is a bar // whose surface appears between one frame and the next. const value = hasSpan(largeHeight.value) ? next : withTiming(next, { duration: HANDOVER_DURATION }); if (externalProgress) externalProgress.value = value; return value; }, [threshold, externalProgress]); // The one thing the transition tells React about. `collapsed` gates the // accessibility of the two titles and is what `onCollapsedChange` reports; // the reaction fires on the crossing, not on the frames either side of it. const cross = useCallback( (next: boolean) => { setCollapsed(next); onCollapsedChange?.(next); }, [onCollapsedChange] ); useAnimatedReaction( () => progress.value >= 1, (isCollapsed, was) => { if (!isCrossing(isCollapsed, was)) return; runOnJS(cross)(isCollapsed); }, [cross] ); const settle = useCallback( (velocity: number) => { 'worklet'; if (!snap) return; const target = snapTarget(offset.value, largeHeight.value, velocity); if (target === null) return; scrollTo(scrollRef, 0, target, true); }, [snap, largeHeight, offset, scrollRef] ); const { parts, scrollable } = useMemo(() => splitChildren(children), [children]); const childType = isValidElement(scrollable) ? (scrollable.type as ComponentType) : null; // Keyed on the element type rather than the element: rebuilding the // wrapper would remount the list and lose its scroll position. A child // that is animated already is used as it stands — wrapping one twice is // unsupported, and there is no marker on them to test for, so the two // that exist are recognised by identity. const AnimatedScrollable = useMemo(() => { if (!childType) return null; if (ANIMATED_SCROLLABLES.has(childType)) return childType; return Animated.createAnimatedComponent(childType); }, [childType]); const childProps = (isValidElement(scrollable) ? scrollable.props : {}) as ScrollableProps; const childOnScroll = childProps.onScroll; /* * A consumer's own `onScroll` is kept, whichever of the two kinds it is, * because silently dropping one looks like a bug in the scrolling rather * than in the call site. * * `useEvent` returns an object wearing a function's type, so the two are * told apart by what they actually are: an object is a Reanimated handler * and composes onto ours, staying on the UI thread. A plain function * cannot — `useComposedEventHandler` keeps only worklet handlers and drops * anything else without a word — so it is called across the bridge * instead, once per scroll event the child delivers. How often that is, is * `scrollEventThrottle`'s answer rather than this component's: every frame * at the default of 16, and as rare as the caller asks for above it. */ const workletOnScroll = typeof childOnScroll === 'function' ? null : childOnScroll ?? null; const plainOnScroll = typeof childOnScroll === 'function' ? childOnScroll : null; // Rebuilt only when the callback itself changes, so the worklet below is // not rebuilt on every render of the child. const forwardScroll = useCallback( (native: NativeScrollEvent) => { // The `nativeEvent` crosses whole; the synthetic wrapper around it does // not, because the rest of one is a live object with methods on it. plainOnScroll?.({ nativeEvent: native } as NativeSyntheticEvent); }, [plainOnScroll] ); const forwards = plainOnScroll !== null; const scrollHandler = useAnimatedScrollHandler( { onScroll: (event) => { offset.value = event.contentOffset.y; if (forwards) { // Every field React Native puts on a scroll event, so a callback // reading `velocity` for a direction, or `targetContentOffset` for // where a fling is going, finds what it would have without the // header. Listed rather than spread: the event carries Reanimated's // own `eventName` too, and that is not part of the contract. runOnJS(forwardScroll)({ contentInset: event.contentInset, contentOffset: event.contentOffset, contentSize: event.contentSize, layoutMeasurement: event.layoutMeasurement, velocity: event.velocity, zoomScale: event.zoomScale, targetContentOffset: event.targetContentOffset, } as NativeScrollEvent); } }, onEndDrag: (event) => { settle(event.velocity?.y ?? 0); }, onMomentumEnd: () => { settle(0); }, }, [settle, forwards, forwardScroll] ); const onScroll = useComposedEventHandler([ scrollHandler, (workletOnScroll as typeof scrollHandler | null) ?? null, ]); /* * The band's resting height, plus whatever top padding the child asked * for. A style array overrides rather than adds, so composing ours after * theirs would drop their padding and composing theirs after ours would * drop the inset — and they cannot write the sum themselves, because the * band's height is measured. Reading it and adding is the only * arrangement where both survive. */ const childContentStyle = StyleSheet.flatten(childProps.contentContainerStyle) ?? {}; const headerHeight = contentInset( barBand + largeSize, childContentStyle.paddingTop ?? childContentStyle.paddingVertical ?? childContentStyle.padding ); const bandStyle = useAnimatedStyle( () => ({ height: bandHeight(barBand, largeHeight.value, offset.value, stretch && !reducedMotion), }), [barBand, stretch, reducedMotion] ); const metrics = useMemo( () => ({ barBand, insetTop }), [barBand, insetTop] ); const hasLarge = largeSize >= 1; const context = useMemo( () => ({ progress, offset, largeHeight, collapsed, hasLarge, measureLarge }), [progress, offset, largeHeight, collapsed, hasLarge, measureLarge] ); const { root, band } = scrollHeaderVariants(); return ( {AnimatedScrollable && isValidElement(scrollable) ? ( ) : ( scrollable )} {/* Draw order, not writing order: the cover is behind the block, and the bar is over both so it covers the block on its way past rather than being crossed by it. */} {parts.cover} {parts.large} {parts.bar} ); } ); ScrollHeaderRoot.displayName = 'ScrollHeader'; interface BandMetrics { /** Height of the pinned band, inset included. */ barBand: number; /** How much of it is safe area. */ insetTop: number; } const BandMetricsContext = createContext({ barBand: BAR_HEIGHT, insetTop: 0 }); export interface ScrollHeaderBarProps extends ViewProps { className?: string; /** * What the bar is drawn on once it has taken over. `none` leaves it clear, * for a bar over a cover that should stay visible. `blur` frosts it, so the * content passing under the bar stays legible as shape and colour. * * `blur` needs `expo-blur`, which is optional, and it is replaced by an * opaque bar under Reduce Transparency. Both fall back to `plain` — a bar * whose title cannot be read is a worse answer than one that is not frosted. */ surface?: ScrollHeaderSurface; /** A hairline under the bar, drawn with its surface. */ divider?: boolean; /** * Depth of the frost, on `expo-blur`'s 0–100 scale. Defaults to 40 — heavier * than a scrim's, because a scrim covers a whole screen and this is a thin * band read against content moving under it. Ignored unless `surface` is * `blur`. */ intensity?: number; /** * Which way the frost tints. Defaults to the app's theme rather than the * device's, so an app running light inside a dark OS frosts light. Ignored * unless `surface` is `blur`. */ material?: ScrollHeaderMaterial; children?: ReactNode; } /** * The pinned bar. Its contents never move; its surface fades in as the large * block leaves, which is what makes the change read as one crossfade rather * than two things happening at once. */ const ScrollHeaderBar = forwardRef( ( { className, surface = 'plain', divider = true, intensity = DEFAULT_BLUR_INTENSITY, material = 'default', children, ...props }, ref ) => { const { progress } = useScrollHeader('ScrollHeader.Bar'); const { barBand, insetTop } = useContext(BandMetricsContext); const { mode } = useThemeMode(); const reduceTransparency = useReduceTransparency(); // Three gates, and a preference that has not been answered yet counts as // switched on: the case worth being careful about is the one where it is. const blurring = surface === 'blur' && hasBlur && BlurView !== null && reduceTransparency === false; const fill = surface === 'blur' && !blurring ? 'plain' : surface; const { bar, barSurface } = scrollHeaderVariants({ surface: fill, divider }); // Closed by the time the block has gone, so the block is never seen through // it — and never on top of whatever else the bar is carrying. const surfaceStyle = useAnimatedStyle(() => ({ opacity: interpolate(progress.value, SURFACE_ARRIVE, [0, 1], 'clamp'), })); return ( {/* The material is a child of the surface rather than a sibling of it, so there is one opacity driving both and the frost cannot arrive on a different curve from the fill it replaces. It sits inside the divider's border box, so the hairline stays on top of it. */} {blurring && BlurView ? ( ) : null} {textChildren(children)} ); } ); ScrollHeaderBar.displayName = 'ScrollHeader.Bar'; export interface ScrollHeaderLargeProps extends ViewProps { className?: string; children?: ReactNode; } /** * The expanded block. Its measured height is the distance the header * collapses over, so whatever is put in it — a title, a search field, a row of * chips — sets the scroll distance rather than a prop having to agree with it. */ const ScrollHeaderLarge = forwardRef( ({ className, onLayout, children, ...props }, ref) => { const { progress, measureLarge, collapsed } = useScrollHeader('ScrollHeader.Large'); const { large } = scrollHeaderVariants(); const style = useAnimatedStyle(() => ({ opacity: interpolate(progress.value, LARGE_EXIT, [1, 0], 'clamp'), })); // The measurement is this block's whole job, so it is taken first and a // consumer's own `onLayout` runs after it rather than instead of it. const measure = useCallback( (event: LayoutChangeEvent) => { measureLarge(event); onLayout?.(event); }, [measureLarge, onLayout] ); return ( {textChildren(children)} ); } ); ScrollHeaderLarge.displayName = 'ScrollHeader.Large'; /** * The screen's title. Written in both halves and styled from whichever it is * in: large and at rest in the block, compact and fading in on the bar. */ const ScrollHeaderTitle = forwardRef(({ className, ...props }, ref) => { const { progress, largeHeight, collapsed, hasLarge } = useScrollHeader('ScrollHeader.Title'); const slot = useContext(ScrollHeaderSlotContext); // A bar with no block above it is not handing over from anything: it is the // only title the screen has, and it is wanted from the first frame. Fading it // in with the collapse would leave that screen untitled until somebody // scrolled it. const style = useAnimatedStyle(() => ({ opacity: hasSpan(largeHeight.value) ? interpolate(progress.value, BAR_TITLE_ARRIVE, [0, 1], 'clamp') : 1, })); if (slot === 'large') { return ( ); } return ( ); }); ScrollHeaderTitle.displayName = 'ScrollHeader.Title'; /** The quiet line under the title — a count, a byline, a date. */ const ScrollHeaderDescription = forwardRef(({ className, ...props }, ref) => ( )); ScrollHeaderDescription.displayName = 'ScrollHeader.Description'; export interface ScrollHeaderActionsProps extends ViewProps { className?: string; children?: ReactNode; } /** * The controls at the trailing end of the bar. They stay put and stay * reachable — only the bar's surface and title are part of the transition. */ const ScrollHeaderActions = forwardRef( ({ className, children, ...props }, ref) => { const { actions } = scrollHeaderVariants(); return ( {textChildren(children)} ); } ); ScrollHeaderActions.displayName = 'ScrollHeader.Actions'; export interface ScrollHeaderCoverProps extends ViewProps { className?: string; /** A picture behind the header. Laid out to fill the band, so it stretches with it. */ source?: ImageSourcePropType; /** * The gradient drawn when there is no picture, or under one that has not * loaded. Defaults to two of the theme's series tokens, so an app that puts * its charts on brand puts this on brand with them. */ colors?: [string, string, ...string[]]; /** A wash over the cover, so a title stays legible on a bright picture. */ scrim?: boolean; children?: ReactNode; } /** * A picture or a gradient filling the band. It has no height of its own — the * band's height is its height, which is why it stretches on an over-scroll by * being laid out larger rather than by being scaled up and going soft. */ const ScrollHeaderCover = forwardRef( ( { className, source, colors, scrim = true, children, ...props }, ref ) => { const { cover } = scrollHeaderVariants(); const seriesOne = useCSSVariable('--color-chart-1'); const seriesTwo = useCSSVariable('--color-chart-2'); const ramp: [string, string, ...string[]] = colors ?? [ typeof seriesOne === 'string' ? seriesOne : '#6366f1', typeof seriesTwo === 'string' ? seriesTwo : '#8b5cf6', ]; return ( {source ? ( ) : null} {scrim ? : null} {children} ); } ); ScrollHeaderCover.displayName = 'ScrollHeader.Cover'; /** * Sort the children into the three band slots and the one scrollable. Sorting * by component rather than by order is what lets the example above read * top-down — bar, block, list — while the band still draws the cover first. */ function splitChildren(children: ReactNode): { parts: { cover: ReactNode; large: ReactNode; bar: ReactNode }; scrollable: ReactNode; } { let cover: ReactNode = null; let large: ReactNode = null; let bar: ReactNode = null; let scrollable: ReactNode = null; Children.forEach(children, (child) => { if (!isValidElement(child)) return; if (child.type === ScrollHeaderCover) cover = child; else if (child.type === ScrollHeaderLarge) large = child; else if (child.type === ScrollHeaderBar) bar = child; else if (!scrollable) scrollable = child; }); return { parts: { cover, large, bar }, scrollable }; } /** Point several refs at one node, skipping the ones that were not given. */ function composeRefs(...refs: (Ref | undefined)[]) { return (node: unknown) => { for (const ref of refs) { if (typeof ref === 'function') ref(node); else if (ref && typeof ref === 'object') { (ref as { current: unknown }).current = node; } } }; } const styles = StyleSheet.create({ // Android draws by elevation before z-order, so a band with none of its own // ends up under the scroller's own background. lift: { elevation: 4 }, }); export const ScrollHeader = Object.assign(ScrollHeaderRoot, { Bar: ScrollHeaderBar, Large: ScrollHeaderLarge, Title: ScrollHeaderTitle, Description: ScrollHeaderDescription, Actions: ScrollHeaderActions, Cover: ScrollHeaderCover, });