panelui-native 0.94.0 → 0.96.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,903 @@
1
+ /**
2
+ * ScrollHeader — a screen header that hands over to a compact bar as the page
3
+ * scrolls.
4
+ *
5
+ * A screen with a title has two states and every app draws both: the title
6
+ * large, at rest, with room around it, and the title small, pinned, once you
7
+ * are reading. Hand-rolling the change between them is where screens stop
8
+ * matching each other — one snaps, one crossfades at a different point, one
9
+ * forgets the bar is over a safe area — so the two states and the transit
10
+ * between them are one component here.
11
+ *
12
+ * ```tsx
13
+ * <ScrollHeader className="flex-1">
14
+ * <ScrollHeader.Bar>
15
+ * <ScrollHeader.Title>Library</ScrollHeader.Title>
16
+ * <ScrollHeader.Actions>
17
+ * <Button variant="ghost" size="icon"><SearchIcon size={18} /></Button>
18
+ * </ScrollHeader.Actions>
19
+ * </ScrollHeader.Bar>
20
+ * <ScrollHeader.Large>
21
+ * <ScrollHeader.Title>Library</ScrollHeader.Title>
22
+ * <ScrollHeader.Description>128 components</ScrollHeader.Description>
23
+ * </ScrollHeader.Large>
24
+ * <ScrollView>{rows}</ScrollView>
25
+ * </ScrollHeader>
26
+ * ```
27
+ *
28
+ * ## The band collapses; nothing inside it is animated up
29
+ *
30
+ * The header is one absolutely positioned band over the scroller, and the only
31
+ * thing driven by the scroll is its height: `bar + max(0, large - offset)`.
32
+ * The bar is anchored to its top and the large block to its bottom, so the
33
+ * band shrinking is what carries the large block up and behind the bar, and
34
+ * `overflow-hidden` is what cuts it off there.
35
+ *
36
+ * Driving the height rather than a `translateY` is what makes the rest fall
37
+ * out for free. Over-scrolling makes `large - offset` larger than `large`, so
38
+ * the band grows and a cover filling it stretches by being laid out bigger —
39
+ * no scale transform, so a photograph stretches without going soft. And the
40
+ * band's height is the inset the content needs, so there is one number rather
41
+ * than two that have to agree.
42
+ *
43
+ * ## The two titles cross-fade; neither one morphs
44
+ *
45
+ * The large title and the bar title are separate elements that fade past each
46
+ * other. A single title scaled and translated between the two positions tracks
47
+ * beautifully until the text is long enough to truncate, at which point it is
48
+ * animating between two different strings.
49
+ *
50
+ * Both are therefore in the tree at once, which is a problem for a screen
51
+ * reader — one of them is invisible and would still be read. So the crossing
52
+ * point is also published to React as `collapsed`, and whichever title is not
53
+ * being shown is hidden from accessibility. That is one re-render per crossing
54
+ * and no more: everything that runs per frame stays in shared values.
55
+ *
56
+ * ## The bar's surface is a fill, or a frost
57
+ *
58
+ * `surface` says what the bar is drawn on once it has taken over: a token
59
+ * fill, nothing at all over a cover, or `blur` — a real material, so the rows
60
+ * passing under the bar stay legible as shape and colour while losing the
61
+ * detail that would compete with the title on top.
62
+ *
63
+ * The frost needs a native view, and there are two ways it cannot be drawn:
64
+ * `expo-blur` is optional and may not be installed, and Reduce Transparency is
65
+ * a preference that outranks the design. Both fall back to the plain
66
+ * background token rather than to nothing, because a bar you cannot read is a
67
+ * worse answer than a bar that is not frosted.
68
+ *
69
+ * ## What it needs
70
+ *
71
+ * A height to fill, and exactly one scrollable child. The child is cloned with
72
+ * the scroll handler and the content inset composed onto it, the same way
73
+ * `ScrollFade` wraps one, so a `ScrollView`, a `FlatList` or a `SectionList`
74
+ * all work unchanged.
75
+ */
76
+ import {
77
+ Children,
78
+ createContext,
79
+ forwardRef,
80
+ isValidElement,
81
+ useCallback,
82
+ useContext,
83
+ useMemo,
84
+ useState,
85
+ type ComponentType,
86
+ type ReactNode,
87
+ type Ref,
88
+ } from 'react';
89
+ import {
90
+ Image,
91
+ Platform,
92
+ StyleSheet,
93
+ View,
94
+ type ImageSourcePropType,
95
+ type LayoutChangeEvent,
96
+ type NativeScrollEvent,
97
+ type NativeSyntheticEvent,
98
+ type StyleProp,
99
+ type Text as RNText,
100
+ type ViewProps,
101
+ type ViewStyle,
102
+ } from 'react-native';
103
+ import { LinearGradient } from 'expo-linear-gradient';
104
+ import Animated, {
105
+ interpolate,
106
+ runOnJS,
107
+ scrollTo,
108
+ useAnimatedReaction,
109
+ useAnimatedRef,
110
+ useAnimatedScrollHandler,
111
+ useAnimatedStyle,
112
+ useComposedEventHandler,
113
+ useDerivedValue,
114
+ useReducedMotion,
115
+ useSharedValue,
116
+ withTiming,
117
+ type AnimatedScrollViewProps,
118
+ type SharedValue,
119
+ } from 'react-native-reanimated';
120
+ import { useSafeAreaInsets } from 'react-native-safe-area-context';
121
+ import { tv, type VariantProps } from 'tailwind-variants';
122
+ import { useCSSVariable } from 'uniwind';
123
+ import { hasBlur, useReduceTransparency } from '../../primitives/scrim';
124
+ import { Text, textChildren, type TextProps } from '../../primitives/text';
125
+ import { useThemeMode } from '../../theme/use-theme';
126
+ import { cn } from '../../utils/cn';
127
+ import {
128
+ BAR_TITLE_ARRIVE,
129
+ HANDOVER_DURATION,
130
+ LARGE_EXIT,
131
+ SURFACE_ARRIVE,
132
+ bandHeight,
133
+ collapseProgress,
134
+ contentInset,
135
+ hasSpan,
136
+ isCrossing,
137
+ snapTarget,
138
+ } from './scroll-header-math';
139
+
140
+ /**
141
+ * Height of the pinned bar, in points, before the device's top inset is added.
142
+ * The platform navigation bars are 44 and 56; 48 is the target-size floor the
143
+ * rest of the library holds compact controls to, and it sits between them.
144
+ */
145
+ const BAR_HEIGHT = 48;
146
+
147
+ /**
148
+ * The scrollables Reanimated already animates. Its animated components are
149
+ * ordinary function components carrying the *inner* component's name, so there
150
+ * is nothing on one to test — but these two are module-level constants, and
151
+ * identity is exact.
152
+ */
153
+ /** Which way the frost tints. `default` follows the app's theme. */
154
+ export type ScrollHeaderMaterial = 'light' | 'dark' | 'default';
155
+
156
+ /**
157
+ * `expo-blur`'s BlurView, or null when it is not installed. Resolved once at
158
+ * module load — the require is cheap and caching it avoids a try/catch on
159
+ * every render.
160
+ */
161
+ const BlurView: ComponentType<{
162
+ intensity?: number;
163
+ tint?: ScrollHeaderMaterial;
164
+ style?: unknown;
165
+ }> | null = (() => {
166
+ try {
167
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
168
+ const mod = require('expo-blur');
169
+ return (mod?.BlurView as ComponentType<{ intensity?: number }>) ?? null;
170
+ } catch {
171
+ return null;
172
+ }
173
+ })();
174
+
175
+ /**
176
+ * Depth of the frost. Heavier than a scrim's, because a scrim covers a whole
177
+ * screen and this is a thin band read against content that is moving under it.
178
+ */
179
+ const DEFAULT_BLUR_INTENSITY = 40;
180
+
181
+ const ANIMATED_SCROLLABLES = new Set<unknown>([Animated.ScrollView, Animated.FlatList]);
182
+
183
+ const scrollHeaderVariants = tv({
184
+ slots: {
185
+ root: 'flex-1',
186
+ band: 'absolute inset-x-0 top-0 overflow-hidden',
187
+ cover: 'absolute inset-0',
188
+ bar: 'absolute inset-x-0 top-0 flex-row items-center gap-3 px-4',
189
+ barSurface: 'absolute inset-0',
190
+ large: 'absolute inset-x-0 bottom-0 gap-1 px-4 pb-3',
191
+ actions: 'ml-auto flex-row items-center gap-1',
192
+ },
193
+ variants: {
194
+ /** What the bar is drawn on once it has taken over. */
195
+ surface: {
196
+ plain: { barSurface: 'bg-background' },
197
+ muted: { barSurface: 'bg-card' },
198
+ none: { barSurface: 'bg-transparent' },
199
+ // No fill of its own: an opaque colour behind the material is part of
200
+ // what the material samples, so a frost over one is a flat bar that has
201
+ // paid for a native view.
202
+ blur: { barSurface: 'bg-transparent' },
203
+ },
204
+ divider: {
205
+ true: { barSurface: 'border-b border-border' },
206
+ false: {},
207
+ },
208
+ },
209
+ defaultVariants: {
210
+ surface: 'plain',
211
+ divider: true,
212
+ },
213
+ });
214
+
215
+ type ScrollHeaderVariantProps = VariantProps<typeof scrollHeaderVariants>;
216
+
217
+ /** What the bar is drawn on once the large block has gone. */
218
+ export type ScrollHeaderSurface = NonNullable<ScrollHeaderVariantProps['surface']>;
219
+
220
+ /** Which half of the header a part is standing in. */
221
+ export type ScrollHeaderSlot = 'bar' | 'large';
222
+
223
+ interface ScrollHeaderContextValue {
224
+ /** 0 while the large block is whole, 1 once the bar has taken over. */
225
+ progress: SharedValue<number>;
226
+ /** Distance scrolled, in points. Negative while the finger pulls down. */
227
+ offset: SharedValue<number>;
228
+ /** Measured height of the large block. */
229
+ largeHeight: SharedValue<number>;
230
+ /** The crossing point, published to React for accessibility. */
231
+ collapsed: boolean;
232
+ /** Whether there is a large block at all. A bar-only header has no crossing. */
233
+ hasLarge: boolean;
234
+ measureLarge: (event: LayoutChangeEvent) => void;
235
+ }
236
+
237
+ const ScrollHeaderContext = createContext<ScrollHeaderContextValue | undefined>(undefined);
238
+
239
+ function useScrollHeader(component: string): ScrollHeaderContextValue {
240
+ const context = useContext(ScrollHeaderContext);
241
+ if (!context) throw new Error(`${component} must be used within a <ScrollHeader>`);
242
+ return context;
243
+ }
244
+
245
+ /**
246
+ * Which half a part is in. `Title` reads it to know which size to be and which
247
+ * way to fade, so the same element can be written in both places.
248
+ */
249
+ const ScrollHeaderSlotContext = createContext<ScrollHeaderSlot>('large');
250
+
251
+ interface ScrollableProps {
252
+ onScroll?: AnimatedScrollViewProps['onScroll'];
253
+ contentContainerStyle?: StyleProp<ViewStyle>;
254
+ scrollIndicatorInsets?: { top?: number; bottom?: number; left?: number; right?: number };
255
+ scrollEventThrottle?: number;
256
+ contentInsetAdjustmentBehavior?: AnimatedScrollViewProps['contentInsetAdjustmentBehavior'];
257
+ ref?: Ref<unknown>;
258
+ }
259
+
260
+ export interface ScrollHeaderProps extends ViewProps {
261
+ className?: string;
262
+ /**
263
+ * Height of the pinned bar in points, before the device's top inset. The
264
+ * inset is added on top of this rather than taken out of it, so the bar's
265
+ * contents keep this much room on every device.
266
+ */
267
+ barHeight?: number;
268
+ /**
269
+ * How much of the large block has to leave before the bar has fully taken
270
+ * over, as a fraction of its height. Below 1 the crossing happens early,
271
+ * which suits a tall block whose last few points are not worth waiting for.
272
+ */
273
+ threshold?: number;
274
+ /**
275
+ * Settle a part-scrolled band open or closed when the finger lifts, rather
276
+ * than leaving the header half-collapsed.
277
+ */
278
+ snap?: boolean;
279
+ /**
280
+ * Let the band grow past its resting height when the scroller is pulled
281
+ * down. A cover fills the band, so this is what stretches it. Off under
282
+ * Reduce Motion.
283
+ */
284
+ stretch?: boolean;
285
+ /** Add the device's top inset above the bar. Off inside a screen that already has one. */
286
+ inset?: boolean;
287
+ /**
288
+ * Called as the bar takes over, and again when the large block comes back.
289
+ * Fires on the crossing, not on every frame.
290
+ */
291
+ onCollapsedChange?: (collapsed: boolean) => void;
292
+ /**
293
+ * A shared value to mirror the collapse into, 0 to 1, for animating
294
+ * something outside the header against the same transition.
295
+ */
296
+ progress?: SharedValue<number>;
297
+ /** The parts, and exactly one scrollable. */
298
+ children?: ReactNode;
299
+ }
300
+
301
+ /**
302
+ * The band, and the scroller it sits over. Everything that is not a part is
303
+ * treated as the scrollable child.
304
+ */
305
+ const ScrollHeaderRoot = forwardRef<View, ScrollHeaderProps>(
306
+ (
307
+ {
308
+ className,
309
+ barHeight = BAR_HEIGHT,
310
+ threshold = 1,
311
+ snap = true,
312
+ stretch = true,
313
+ inset = true,
314
+ onCollapsedChange,
315
+ progress: externalProgress,
316
+ children,
317
+ ...props
318
+ },
319
+ ref
320
+ ) => {
321
+ const insets = useSafeAreaInsets();
322
+ const reducedMotion = useReducedMotion();
323
+ const insetTop = inset ? insets.top : 0;
324
+ const barBand = insetTop + barHeight;
325
+
326
+ const offset = useSharedValue(0);
327
+ const largeHeight = useSharedValue(0);
328
+ const [largeSize, setLargeSize] = useState(0);
329
+ const [collapsed, setCollapsed] = useState(false);
330
+
331
+ const scrollRef = useAnimatedRef<Animated.ScrollView>();
332
+
333
+ // Measured into both a shared value, for the band's height, and React
334
+ // state, for the content inset — the inset is a resting number that only
335
+ // changes when the block is re-laid-out, so paying a render for it costs
336
+ // nothing per frame and keeps the scroller's props stable.
337
+ const measureLarge = useCallback((event: LayoutChangeEvent) => {
338
+ const { height } = event.nativeEvent.layout;
339
+ largeHeight.value = height;
340
+ setLargeSize((previous) => (Math.abs(previous - height) < 0.5 ? previous : height));
341
+ }, [largeHeight]);
342
+
343
+ const progress = useDerivedValue(() => {
344
+ const next = collapseProgress(offset.value, largeHeight.value, threshold);
345
+ // A header with a block hands over across that block's height, and the
346
+ // finger sets the pace. One without has no distance to interpolate over,
347
+ // so the step is timed rather than instant — the alternative is a bar
348
+ // whose surface appears between one frame and the next.
349
+ const value = hasSpan(largeHeight.value)
350
+ ? next
351
+ : withTiming(next, { duration: HANDOVER_DURATION });
352
+ if (externalProgress) externalProgress.value = value;
353
+ return value;
354
+ }, [threshold, externalProgress]);
355
+
356
+ // The one thing the transition tells React about. `collapsed` gates the
357
+ // accessibility of the two titles and is what `onCollapsedChange` reports;
358
+ // the reaction fires on the crossing, not on the frames either side of it.
359
+ const cross = useCallback(
360
+ (next: boolean) => {
361
+ setCollapsed(next);
362
+ onCollapsedChange?.(next);
363
+ },
364
+ [onCollapsedChange]
365
+ );
366
+
367
+ useAnimatedReaction(
368
+ () => progress.value >= 1,
369
+ (isCollapsed, was) => {
370
+ if (!isCrossing(isCollapsed, was)) return;
371
+ runOnJS(cross)(isCollapsed);
372
+ },
373
+ [cross]
374
+ );
375
+
376
+ const settle = useCallback(
377
+ (velocity: number) => {
378
+ 'worklet';
379
+ if (!snap) return;
380
+ const target = snapTarget(offset.value, largeHeight.value, velocity);
381
+ if (target === null) return;
382
+ scrollTo(scrollRef, 0, target, true);
383
+ },
384
+ [snap, largeHeight, offset, scrollRef]
385
+ );
386
+
387
+ const { parts, scrollable } = useMemo(() => splitChildren(children), [children]);
388
+
389
+ const childType = isValidElement(scrollable)
390
+ ? (scrollable.type as ComponentType<ScrollableProps>)
391
+ : null;
392
+ // Keyed on the element type rather than the element: rebuilding the
393
+ // wrapper would remount the list and lose its scroll position. A child
394
+ // that is animated already is used as it stands — wrapping one twice is
395
+ // unsupported, and there is no marker on them to test for, so the two
396
+ // that exist are recognised by identity.
397
+ const AnimatedScrollable = useMemo(() => {
398
+ if (!childType) return null;
399
+ if (ANIMATED_SCROLLABLES.has(childType)) return childType;
400
+ return Animated.createAnimatedComponent(childType);
401
+ }, [childType]);
402
+
403
+ const childProps = (isValidElement(scrollable) ? scrollable.props : {}) as ScrollableProps;
404
+ const childOnScroll = childProps.onScroll;
405
+
406
+ /*
407
+ * A consumer's own `onScroll` is kept, whichever of the two kinds it is,
408
+ * because silently dropping one looks like a bug in the scrolling rather
409
+ * than in the call site.
410
+ *
411
+ * `useEvent` returns an object wearing a function's type, so the two are
412
+ * told apart by what they actually are: an object is a Reanimated handler
413
+ * and composes onto ours, staying on the UI thread. A plain function
414
+ * cannot — `useComposedEventHandler` keeps only worklet handlers and drops
415
+ * anything else without a word — so it is called across the bridge
416
+ * instead, once per scroll event the child delivers. How often that is, is
417
+ * `scrollEventThrottle`'s answer rather than this component's: every frame
418
+ * at the default of 16, and as rare as the caller asks for above it.
419
+ */
420
+ const workletOnScroll = typeof childOnScroll === 'function' ? null : childOnScroll ?? null;
421
+ const plainOnScroll = typeof childOnScroll === 'function' ? childOnScroll : null;
422
+
423
+ // Rebuilt only when the callback itself changes, so the worklet below is
424
+ // not rebuilt on every render of the child.
425
+ const forwardScroll = useCallback(
426
+ (native: NativeScrollEvent) => {
427
+ // The `nativeEvent` crosses whole; the synthetic wrapper around it does
428
+ // not, because the rest of one is a live object with methods on it.
429
+ plainOnScroll?.({ nativeEvent: native } as NativeSyntheticEvent<NativeScrollEvent>);
430
+ },
431
+ [plainOnScroll]
432
+ );
433
+ const forwards = plainOnScroll !== null;
434
+
435
+ const scrollHandler = useAnimatedScrollHandler(
436
+ {
437
+ onScroll: (event) => {
438
+ offset.value = event.contentOffset.y;
439
+ if (forwards) {
440
+ // Every field React Native puts on a scroll event, so a callback
441
+ // reading `velocity` for a direction, or `targetContentOffset` for
442
+ // where a fling is going, finds what it would have without the
443
+ // header. Listed rather than spread: the event carries Reanimated's
444
+ // own `eventName` too, and that is not part of the contract.
445
+ runOnJS(forwardScroll)({
446
+ contentInset: event.contentInset,
447
+ contentOffset: event.contentOffset,
448
+ contentSize: event.contentSize,
449
+ layoutMeasurement: event.layoutMeasurement,
450
+ velocity: event.velocity,
451
+ zoomScale: event.zoomScale,
452
+ targetContentOffset: event.targetContentOffset,
453
+ } as NativeScrollEvent);
454
+ }
455
+ },
456
+ onEndDrag: (event) => {
457
+ settle(event.velocity?.y ?? 0);
458
+ },
459
+ onMomentumEnd: () => {
460
+ settle(0);
461
+ },
462
+ },
463
+ [settle, forwards, forwardScroll]
464
+ );
465
+
466
+ const onScroll = useComposedEventHandler([
467
+ scrollHandler,
468
+ (workletOnScroll as typeof scrollHandler | null) ?? null,
469
+ ]);
470
+
471
+ /*
472
+ * The band's resting height, plus whatever top padding the child asked
473
+ * for. A style array overrides rather than adds, so composing ours after
474
+ * theirs would drop their padding and composing theirs after ours would
475
+ * drop the inset — and they cannot write the sum themselves, because the
476
+ * band's height is measured. Reading it and adding is the only
477
+ * arrangement where both survive.
478
+ */
479
+ const childContentStyle = StyleSheet.flatten(childProps.contentContainerStyle) ?? {};
480
+ const headerHeight = contentInset(
481
+ barBand + largeSize,
482
+ childContentStyle.paddingTop ??
483
+ childContentStyle.paddingVertical ??
484
+ childContentStyle.padding
485
+ );
486
+
487
+ const bandStyle = useAnimatedStyle(
488
+ () => ({
489
+ height: bandHeight(barBand, largeHeight.value, offset.value, stretch && !reducedMotion),
490
+ }),
491
+ [barBand, stretch, reducedMotion]
492
+ );
493
+
494
+ const metrics = useMemo<BandMetrics>(
495
+ () => ({ barBand, insetTop }),
496
+ [barBand, insetTop]
497
+ );
498
+
499
+ const hasLarge = largeSize >= 1;
500
+
501
+ const context = useMemo<ScrollHeaderContextValue>(
502
+ () => ({ progress, offset, largeHeight, collapsed, hasLarge, measureLarge }),
503
+ [progress, offset, largeHeight, collapsed, hasLarge, measureLarge]
504
+ );
505
+
506
+ const { root, band } = scrollHeaderVariants();
507
+
508
+ return (
509
+ <ScrollHeaderContext.Provider value={context}>
510
+ <View {...props} ref={ref} className={root({ className })}>
511
+ {AnimatedScrollable && isValidElement(scrollable) ? (
512
+ <AnimatedScrollable
513
+ {...childProps}
514
+ ref={composeRefs(scrollRef, childProps.ref)}
515
+ onScroll={onScroll}
516
+ scrollEventThrottle={childProps.scrollEventThrottle ?? 16}
517
+ // The band is the inset. iOS would otherwise add one of its own
518
+ // on top of it, for a header it cannot see.
519
+ contentInsetAdjustmentBehavior={
520
+ childProps.contentInsetAdjustmentBehavior ?? 'never'
521
+ }
522
+ contentContainerStyle={[
523
+ childProps.contentContainerStyle,
524
+ { paddingTop: headerHeight },
525
+ ]}
526
+ scrollIndicatorInsets={{ top: barBand, ...childProps.scrollIndicatorInsets }}
527
+ />
528
+ ) : (
529
+ scrollable
530
+ )}
531
+
532
+ <Animated.View
533
+ pointerEvents="box-none"
534
+ style={[bandStyle, Platform.OS === 'android' ? styles.lift : null]}
535
+ className={band()}
536
+ >
537
+ <BandMetricsContext.Provider value={metrics}>
538
+ {/* Draw order, not writing order: the cover is behind the block,
539
+ and the bar is over both so it covers the block on its way
540
+ past rather than being crossed by it. */}
541
+ {parts.cover}
542
+ {parts.large}
543
+ {parts.bar}
544
+ </BandMetricsContext.Provider>
545
+ </Animated.View>
546
+ </View>
547
+ </ScrollHeaderContext.Provider>
548
+ );
549
+ }
550
+ );
551
+ ScrollHeaderRoot.displayName = 'ScrollHeader';
552
+
553
+ interface BandMetrics {
554
+ /** Height of the pinned band, inset included. */
555
+ barBand: number;
556
+ /** How much of it is safe area. */
557
+ insetTop: number;
558
+ }
559
+
560
+ const BandMetricsContext = createContext<BandMetrics>({ barBand: BAR_HEIGHT, insetTop: 0 });
561
+
562
+ export interface ScrollHeaderBarProps extends ViewProps {
563
+ className?: string;
564
+ /**
565
+ * What the bar is drawn on once it has taken over. `none` leaves it clear,
566
+ * for a bar over a cover that should stay visible. `blur` frosts it, so the
567
+ * content passing under the bar stays legible as shape and colour.
568
+ *
569
+ * `blur` needs `expo-blur`, which is optional, and it is replaced by an
570
+ * opaque bar under Reduce Transparency. Both fall back to `plain` — a bar
571
+ * whose title cannot be read is a worse answer than one that is not frosted.
572
+ */
573
+ surface?: ScrollHeaderSurface;
574
+ /** A hairline under the bar, drawn with its surface. */
575
+ divider?: boolean;
576
+ /**
577
+ * Depth of the frost, on `expo-blur`'s 0–100 scale. Defaults to 40 — heavier
578
+ * than a scrim's, because a scrim covers a whole screen and this is a thin
579
+ * band read against content moving under it. Ignored unless `surface` is
580
+ * `blur`.
581
+ */
582
+ intensity?: number;
583
+ /**
584
+ * Which way the frost tints. Defaults to the app's theme rather than the
585
+ * device's, so an app running light inside a dark OS frosts light. Ignored
586
+ * unless `surface` is `blur`.
587
+ */
588
+ material?: ScrollHeaderMaterial;
589
+ children?: ReactNode;
590
+ }
591
+
592
+ /**
593
+ * The pinned bar. Its contents never move; its surface fades in as the large
594
+ * block leaves, which is what makes the change read as one crossfade rather
595
+ * than two things happening at once.
596
+ */
597
+ const ScrollHeaderBar = forwardRef<View, ScrollHeaderBarProps>(
598
+ (
599
+ {
600
+ className,
601
+ surface = 'plain',
602
+ divider = true,
603
+ intensity = DEFAULT_BLUR_INTENSITY,
604
+ material = 'default',
605
+ children,
606
+ ...props
607
+ },
608
+ ref
609
+ ) => {
610
+ const { progress } = useScrollHeader('ScrollHeader.Bar');
611
+ const { barBand, insetTop } = useContext(BandMetricsContext);
612
+ const { mode } = useThemeMode();
613
+ const reduceTransparency = useReduceTransparency();
614
+
615
+ // Three gates, and a preference that has not been answered yet counts as
616
+ // switched on: the case worth being careful about is the one where it is.
617
+ const blurring =
618
+ surface === 'blur' && hasBlur && BlurView !== null && reduceTransparency === false;
619
+ const fill = surface === 'blur' && !blurring ? 'plain' : surface;
620
+ const { bar, barSurface } = scrollHeaderVariants({ surface: fill, divider });
621
+
622
+ // Closed by the time the block has gone, so the block is never seen through
623
+ // it — and never on top of whatever else the bar is carrying.
624
+ const surfaceStyle = useAnimatedStyle(() => ({
625
+ opacity: interpolate(progress.value, SURFACE_ARRIVE, [0, 1], 'clamp'),
626
+ }));
627
+
628
+ return (
629
+ <View
630
+ {...props}
631
+ ref={ref}
632
+ // The bar owns only its own band. `box-none` above it lets a pull
633
+ // land on the scroller rather than on the header covering it.
634
+ style={{ height: barBand, paddingTop: insetTop }}
635
+ className={bar({ className })}
636
+ >
637
+ {/*
638
+ The material is a child of the surface rather than a sibling of it,
639
+ so there is one opacity driving both and the frost cannot arrive on a
640
+ different curve from the fill it replaces. It sits inside the
641
+ divider's border box, so the hairline stays on top of it.
642
+ */}
643
+ <Animated.View
644
+ pointerEvents="none"
645
+ style={surfaceStyle}
646
+ className={barSurface()}
647
+ >
648
+ {blurring && BlurView ? (
649
+ <BlurView
650
+ intensity={intensity}
651
+ tint={material === 'default' ? mode : material}
652
+ style={StyleSheet.absoluteFill}
653
+ />
654
+ ) : null}
655
+ </Animated.View>
656
+ <ScrollHeaderSlotContext.Provider value="bar">
657
+ {textChildren(children)}
658
+ </ScrollHeaderSlotContext.Provider>
659
+ </View>
660
+ );
661
+ }
662
+ );
663
+ ScrollHeaderBar.displayName = 'ScrollHeader.Bar';
664
+
665
+ export interface ScrollHeaderLargeProps extends ViewProps {
666
+ className?: string;
667
+ children?: ReactNode;
668
+ }
669
+
670
+ /**
671
+ * The expanded block. Its measured height is the distance the header
672
+ * collapses over, so whatever is put in it — a title, a search field, a row of
673
+ * chips — sets the scroll distance rather than a prop having to agree with it.
674
+ */
675
+ const ScrollHeaderLarge = forwardRef<View, ScrollHeaderLargeProps>(
676
+ ({ className, onLayout, children, ...props }, ref) => {
677
+ const { progress, measureLarge, collapsed } = useScrollHeader('ScrollHeader.Large');
678
+ const { large } = scrollHeaderVariants();
679
+
680
+ const style = useAnimatedStyle(() => ({
681
+ opacity: interpolate(progress.value, LARGE_EXIT, [1, 0], 'clamp'),
682
+ }));
683
+
684
+ // The measurement is this block's whole job, so it is taken first and a
685
+ // consumer's own `onLayout` runs after it rather than instead of it.
686
+ const measure = useCallback(
687
+ (event: LayoutChangeEvent) => {
688
+ measureLarge(event);
689
+ onLayout?.(event);
690
+ },
691
+ [measureLarge, onLayout]
692
+ );
693
+
694
+ return (
695
+ <Animated.View
696
+ {...props}
697
+ ref={ref}
698
+ onLayout={measure}
699
+ // Behind the bar and faded out by the time it gets there, so it must
700
+ // not keep taking touches that belong to the content underneath.
701
+ pointerEvents={collapsed ? 'none' : 'box-none'}
702
+ accessibilityElementsHidden={collapsed}
703
+ importantForAccessibility={collapsed ? 'no-hide-descendants' : 'auto'}
704
+ style={style}
705
+ className={large({ className })}
706
+ >
707
+ <ScrollHeaderSlotContext.Provider value="large">
708
+ {textChildren(children)}
709
+ </ScrollHeaderSlotContext.Provider>
710
+ </Animated.View>
711
+ );
712
+ }
713
+ );
714
+ ScrollHeaderLarge.displayName = 'ScrollHeader.Large';
715
+
716
+ /**
717
+ * The screen's title. Written in both halves and styled from whichever it is
718
+ * in: large and at rest in the block, compact and fading in on the bar.
719
+ */
720
+ const ScrollHeaderTitle = forwardRef<RNText, TextProps>(({ className, ...props }, ref) => {
721
+ const { progress, largeHeight, collapsed, hasLarge } = useScrollHeader('ScrollHeader.Title');
722
+ const slot = useContext(ScrollHeaderSlotContext);
723
+
724
+ // A bar with no block above it is not handing over from anything: it is the
725
+ // only title the screen has, and it is wanted from the first frame. Fading it
726
+ // in with the collapse would leave that screen untitled until somebody
727
+ // scrolled it.
728
+ const style = useAnimatedStyle(() => ({
729
+ opacity: hasSpan(largeHeight.value)
730
+ ? interpolate(progress.value, BAR_TITLE_ARRIVE, [0, 1], 'clamp')
731
+ : 1,
732
+ }));
733
+
734
+ if (slot === 'large') {
735
+ return (
736
+ <Text
737
+ {...props}
738
+ ref={ref}
739
+ size="3xl"
740
+ weight="bold"
741
+ accessibilityRole="header"
742
+ className={cn('text-foreground', className)}
743
+ />
744
+ );
745
+ }
746
+
747
+ return (
748
+ <Animated.View
749
+ style={style}
750
+ // The large title is the one being read until the bar has taken over.
751
+ // Both are in the tree the whole time, and only one of them should be —
752
+ // unless there is no large title, in which case this one always is.
753
+ accessibilityElementsHidden={hasLarge ? !collapsed : false}
754
+ importantForAccessibility={!hasLarge || collapsed ? 'auto' : 'no-hide-descendants'}
755
+ className="flex-1"
756
+ >
757
+ <Text
758
+ {...props}
759
+ ref={ref}
760
+ size="base"
761
+ weight="semibold"
762
+ numberOfLines={props.numberOfLines ?? 1}
763
+ accessibilityRole="header"
764
+ className={cn('text-foreground', className)}
765
+ />
766
+ </Animated.View>
767
+ );
768
+ });
769
+ ScrollHeaderTitle.displayName = 'ScrollHeader.Title';
770
+
771
+ /** The quiet line under the title — a count, a byline, a date. */
772
+ const ScrollHeaderDescription = forwardRef<RNText, TextProps>(({ className, ...props }, ref) => (
773
+ <Text {...props} ref={ref} size="sm" muted className={className} />
774
+ ));
775
+ ScrollHeaderDescription.displayName = 'ScrollHeader.Description';
776
+
777
+ export interface ScrollHeaderActionsProps extends ViewProps {
778
+ className?: string;
779
+ children?: ReactNode;
780
+ }
781
+
782
+ /**
783
+ * The controls at the trailing end of the bar. They stay put and stay
784
+ * reachable — only the bar's surface and title are part of the transition.
785
+ */
786
+ const ScrollHeaderActions = forwardRef<View, ScrollHeaderActionsProps>(
787
+ ({ className, children, ...props }, ref) => {
788
+ const { actions } = scrollHeaderVariants();
789
+ return (
790
+ <View {...props} ref={ref} className={actions({ className })}>
791
+ {textChildren(children)}
792
+ </View>
793
+ );
794
+ }
795
+ );
796
+ ScrollHeaderActions.displayName = 'ScrollHeader.Actions';
797
+
798
+ export interface ScrollHeaderCoverProps extends ViewProps {
799
+ className?: string;
800
+ /** A picture behind the header. Laid out to fill the band, so it stretches with it. */
801
+ source?: ImageSourcePropType;
802
+ /**
803
+ * The gradient drawn when there is no picture, or under one that has not
804
+ * loaded. Defaults to two of the theme's series tokens, so an app that puts
805
+ * its charts on brand puts this on brand with them.
806
+ */
807
+ colors?: [string, string, ...string[]];
808
+ /** A wash over the cover, so a title stays legible on a bright picture. */
809
+ scrim?: boolean;
810
+ children?: ReactNode;
811
+ }
812
+
813
+ /**
814
+ * A picture or a gradient filling the band. It has no height of its own — the
815
+ * band's height is its height, which is why it stretches on an over-scroll by
816
+ * being laid out larger rather than by being scaled up and going soft.
817
+ */
818
+ const ScrollHeaderCover = forwardRef<View, ScrollHeaderCoverProps>(
819
+ (
820
+ {
821
+ className,
822
+ source,
823
+ colors,
824
+ scrim = true,
825
+ children,
826
+ ...props
827
+ },
828
+ ref
829
+ ) => {
830
+ const { cover } = scrollHeaderVariants();
831
+ const seriesOne = useCSSVariable('--color-chart-1');
832
+ const seriesTwo = useCSSVariable('--color-chart-2');
833
+
834
+ const ramp: [string, string, ...string[]] = colors ?? [
835
+ typeof seriesOne === 'string' ? seriesOne : '#6366f1',
836
+ typeof seriesTwo === 'string' ? seriesTwo : '#8b5cf6',
837
+ ];
838
+
839
+ return (
840
+ <View {...props} ref={ref} pointerEvents="none" className={cover({ className })}>
841
+ <LinearGradient colors={ramp} start={{ x: 0, y: 0 }} end={{ x: 1, y: 1 }} style={StyleSheet.absoluteFill} />
842
+ {source ? (
843
+ <Image source={source} resizeMode="cover" style={StyleSheet.absoluteFill} />
844
+ ) : null}
845
+ {scrim ? <View className="absolute inset-0 bg-black/25" /> : null}
846
+ {children}
847
+ </View>
848
+ );
849
+ }
850
+ );
851
+ ScrollHeaderCover.displayName = 'ScrollHeader.Cover';
852
+
853
+ /**
854
+ * Sort the children into the three band slots and the one scrollable. Sorting
855
+ * by component rather than by order is what lets the example above read
856
+ * top-down — bar, block, list — while the band still draws the cover first.
857
+ */
858
+ function splitChildren(children: ReactNode): {
859
+ parts: { cover: ReactNode; large: ReactNode; bar: ReactNode };
860
+ scrollable: ReactNode;
861
+ } {
862
+ let cover: ReactNode = null;
863
+ let large: ReactNode = null;
864
+ let bar: ReactNode = null;
865
+ let scrollable: ReactNode = null;
866
+
867
+ Children.forEach(children, (child) => {
868
+ if (!isValidElement(child)) return;
869
+ if (child.type === ScrollHeaderCover) cover = child;
870
+ else if (child.type === ScrollHeaderLarge) large = child;
871
+ else if (child.type === ScrollHeaderBar) bar = child;
872
+ else if (!scrollable) scrollable = child;
873
+ });
874
+
875
+ return { parts: { cover, large, bar }, scrollable };
876
+ }
877
+
878
+ /** Point several refs at one node, skipping the ones that were not given. */
879
+ function composeRefs(...refs: (Ref<unknown> | undefined)[]) {
880
+ return (node: unknown) => {
881
+ for (const ref of refs) {
882
+ if (typeof ref === 'function') ref(node);
883
+ else if (ref && typeof ref === 'object') {
884
+ (ref as { current: unknown }).current = node;
885
+ }
886
+ }
887
+ };
888
+ }
889
+
890
+ const styles = StyleSheet.create({
891
+ // Android draws by elevation before z-order, so a band with none of its own
892
+ // ends up under the scroller's own background.
893
+ lift: { elevation: 4 },
894
+ });
895
+
896
+ export const ScrollHeader = Object.assign(ScrollHeaderRoot, {
897
+ Bar: ScrollHeaderBar,
898
+ Large: ScrollHeaderLarge,
899
+ Title: ScrollHeaderTitle,
900
+ Description: ScrollHeaderDescription,
901
+ Actions: ScrollHeaderActions,
902
+ Cover: ScrollHeaderCover,
903
+ });