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.
- package/README.md +1 -0
- package/lib/module/components/scroll-header/index.js +709 -0
- package/lib/module/components/scroll-header/index.js.map +1 -0
- package/lib/module/components/scroll-header/scroll-header-math.js +144 -0
- package/lib/module/components/scroll-header/scroll-header-math.js.map +1 -0
- package/lib/module/index.js +1 -0
- package/lib/module/index.js.map +1 -1
- package/lib/typescript/src/components/scroll-header/index.d.ts +285 -0
- package/lib/typescript/src/components/scroll-header/index.d.ts.map +1 -0
- package/lib/typescript/src/components/scroll-header/scroll-header-math.d.ts +99 -0
- package/lib/typescript/src/components/scroll-header/scroll-header-math.d.ts.map +1 -0
- package/lib/typescript/src/index.d.ts +1 -0
- package/lib/typescript/src/index.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/components/scroll-header/index.tsx +903 -0
- package/src/components/scroll-header/scroll-header-math.ts +147 -0
- package/src/index.ts +10 -0
|
@@ -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
|
+
});
|