panelui-native 0.94.0 → 0.95.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 +650 -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 +237 -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 +814 -0
- package/src/components/scroll-header/scroll-header-math.ts +147 -0
- package/src/index.ts +9 -0
package/README.md
CHANGED
|
@@ -283,6 +283,7 @@ memory pressure the OS can end the process with nothing in the terminal to expla
|
|
|
283
283
|
| `ScatterChart` | Two quantities against each other, to show how they relate |
|
|
284
284
|
| `ScrollCanvas` | Image frame whose contents move as you scroll |
|
|
285
285
|
| `ScrollFade` | Fades the edges of a scroll container |
|
|
286
|
+
| `ScrollHeader` | A screen title that hands over to a compact bar as the page scrolls |
|
|
286
287
|
| `ScrollText` | Text that resolves word by word as you scroll |
|
|
287
288
|
| `SearchBar` | Search field with a clear button, a Cancel button and a panel of results |
|
|
288
289
|
| `SectionRail` | Floating section navigator for a long screen |
|
|
@@ -0,0 +1,650 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* ScrollHeader — a screen header that hands over to a compact bar as the page
|
|
5
|
+
* scrolls.
|
|
6
|
+
*
|
|
7
|
+
* A screen with a title has two states and every app draws both: the title
|
|
8
|
+
* large, at rest, with room around it, and the title small, pinned, once you
|
|
9
|
+
* are reading. Hand-rolling the change between them is where screens stop
|
|
10
|
+
* matching each other — one snaps, one crossfades at a different point, one
|
|
11
|
+
* forgets the bar is over a safe area — so the two states and the transit
|
|
12
|
+
* between them are one component here.
|
|
13
|
+
*
|
|
14
|
+
* ```tsx
|
|
15
|
+
* <ScrollHeader className="flex-1">
|
|
16
|
+
* <ScrollHeader.Bar>
|
|
17
|
+
* <ScrollHeader.Title>Library</ScrollHeader.Title>
|
|
18
|
+
* <ScrollHeader.Actions>
|
|
19
|
+
* <Button variant="ghost" size="icon"><SearchIcon size={18} /></Button>
|
|
20
|
+
* </ScrollHeader.Actions>
|
|
21
|
+
* </ScrollHeader.Bar>
|
|
22
|
+
* <ScrollHeader.Large>
|
|
23
|
+
* <ScrollHeader.Title>Library</ScrollHeader.Title>
|
|
24
|
+
* <ScrollHeader.Description>128 components</ScrollHeader.Description>
|
|
25
|
+
* </ScrollHeader.Large>
|
|
26
|
+
* <ScrollView>{rows}</ScrollView>
|
|
27
|
+
* </ScrollHeader>
|
|
28
|
+
* ```
|
|
29
|
+
*
|
|
30
|
+
* ## The band collapses; nothing inside it is animated up
|
|
31
|
+
*
|
|
32
|
+
* The header is one absolutely positioned band over the scroller, and the only
|
|
33
|
+
* thing driven by the scroll is its height: `bar + max(0, large - offset)`.
|
|
34
|
+
* The bar is anchored to its top and the large block to its bottom, so the
|
|
35
|
+
* band shrinking is what carries the large block up and behind the bar, and
|
|
36
|
+
* `overflow-hidden` is what cuts it off there.
|
|
37
|
+
*
|
|
38
|
+
* Driving the height rather than a `translateY` is what makes the rest fall
|
|
39
|
+
* out for free. Over-scrolling makes `large - offset` larger than `large`, so
|
|
40
|
+
* the band grows and a cover filling it stretches by being laid out bigger —
|
|
41
|
+
* no scale transform, so a photograph stretches without going soft. And the
|
|
42
|
+
* band's height is the inset the content needs, so there is one number rather
|
|
43
|
+
* than two that have to agree.
|
|
44
|
+
*
|
|
45
|
+
* ## The two titles cross-fade; neither one morphs
|
|
46
|
+
*
|
|
47
|
+
* The large title and the bar title are separate elements that fade past each
|
|
48
|
+
* other. A single title scaled and translated between the two positions tracks
|
|
49
|
+
* beautifully until the text is long enough to truncate, at which point it is
|
|
50
|
+
* animating between two different strings.
|
|
51
|
+
*
|
|
52
|
+
* Both are therefore in the tree at once, which is a problem for a screen
|
|
53
|
+
* reader — one of them is invisible and would still be read. So the crossing
|
|
54
|
+
* point is also published to React as `collapsed`, and whichever title is not
|
|
55
|
+
* being shown is hidden from accessibility. That is one re-render per crossing
|
|
56
|
+
* and no more: everything that runs per frame stays in shared values.
|
|
57
|
+
*
|
|
58
|
+
* ## What it needs
|
|
59
|
+
*
|
|
60
|
+
* A height to fill, and exactly one scrollable child. The child is cloned with
|
|
61
|
+
* the scroll handler and the content inset composed onto it, the same way
|
|
62
|
+
* `ScrollFade` wraps one, so a `ScrollView`, a `FlatList` or a `SectionList`
|
|
63
|
+
* all work unchanged.
|
|
64
|
+
*/
|
|
65
|
+
import { Children, createContext, forwardRef, isValidElement, useCallback, useContext, useMemo, useState } from 'react';
|
|
66
|
+
import { Image, Platform, StyleSheet, View } from 'react-native';
|
|
67
|
+
import { LinearGradient } from 'expo-linear-gradient';
|
|
68
|
+
import Animated, { interpolate, runOnJS, scrollTo, useAnimatedReaction, useAnimatedRef, useAnimatedScrollHandler, useAnimatedStyle, useComposedEventHandler, useDerivedValue, useReducedMotion, useSharedValue, withTiming } from 'react-native-reanimated';
|
|
69
|
+
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
|
70
|
+
import { tv } from 'tailwind-variants';
|
|
71
|
+
import { useCSSVariable } from 'uniwind';
|
|
72
|
+
import { Text, textChildren } from "../../primitives/text.js";
|
|
73
|
+
import { cn } from "../../utils/cn.js";
|
|
74
|
+
import { BAR_TITLE_ARRIVE, HANDOVER_DURATION, LARGE_EXIT, SURFACE_ARRIVE, bandHeight, collapseProgress, contentInset, hasSpan, isCrossing, snapTarget } from "./scroll-header-math.js";
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Height of the pinned bar, in points, before the device's top inset is added.
|
|
78
|
+
* The platform navigation bars are 44 and 56; 48 is the target-size floor the
|
|
79
|
+
* rest of the library holds compact controls to, and it sits between them.
|
|
80
|
+
*/
|
|
81
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
82
|
+
const BAR_HEIGHT = 48;
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* The scrollables Reanimated already animates. Its animated components are
|
|
86
|
+
* ordinary function components carrying the *inner* component's name, so there
|
|
87
|
+
* is nothing on one to test — but these two are module-level constants, and
|
|
88
|
+
* identity is exact.
|
|
89
|
+
*/
|
|
90
|
+
const ANIMATED_SCROLLABLES = new Set([Animated.ScrollView, Animated.FlatList]);
|
|
91
|
+
const scrollHeaderVariants = tv({
|
|
92
|
+
slots: {
|
|
93
|
+
root: 'flex-1',
|
|
94
|
+
band: 'absolute inset-x-0 top-0 overflow-hidden',
|
|
95
|
+
cover: 'absolute inset-0',
|
|
96
|
+
bar: 'absolute inset-x-0 top-0 flex-row items-center gap-3 px-4',
|
|
97
|
+
barSurface: 'absolute inset-0',
|
|
98
|
+
large: 'absolute inset-x-0 bottom-0 gap-1 px-4 pb-3',
|
|
99
|
+
actions: 'ml-auto flex-row items-center gap-1'
|
|
100
|
+
},
|
|
101
|
+
variants: {
|
|
102
|
+
/** What the bar is drawn on once it has taken over. */
|
|
103
|
+
surface: {
|
|
104
|
+
plain: {
|
|
105
|
+
barSurface: 'bg-background'
|
|
106
|
+
},
|
|
107
|
+
muted: {
|
|
108
|
+
barSurface: 'bg-card'
|
|
109
|
+
},
|
|
110
|
+
none: {
|
|
111
|
+
barSurface: 'bg-transparent'
|
|
112
|
+
}
|
|
113
|
+
},
|
|
114
|
+
divider: {
|
|
115
|
+
true: {
|
|
116
|
+
barSurface: 'border-b border-border'
|
|
117
|
+
},
|
|
118
|
+
false: {}
|
|
119
|
+
}
|
|
120
|
+
},
|
|
121
|
+
defaultVariants: {
|
|
122
|
+
surface: 'plain',
|
|
123
|
+
divider: true
|
|
124
|
+
}
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
/** What the bar is drawn on once the large block has gone. */
|
|
128
|
+
|
|
129
|
+
/** Which half of the header a part is standing in. */
|
|
130
|
+
|
|
131
|
+
const ScrollHeaderContext = /*#__PURE__*/createContext(undefined);
|
|
132
|
+
function useScrollHeader(component) {
|
|
133
|
+
const context = useContext(ScrollHeaderContext);
|
|
134
|
+
if (!context) throw new Error(`${component} must be used within a <ScrollHeader>`);
|
|
135
|
+
return context;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Which half a part is in. `Title` reads it to know which size to be and which
|
|
140
|
+
* way to fade, so the same element can be written in both places.
|
|
141
|
+
*/
|
|
142
|
+
const ScrollHeaderSlotContext = /*#__PURE__*/createContext('large');
|
|
143
|
+
/**
|
|
144
|
+
* The band, and the scroller it sits over. Everything that is not a part is
|
|
145
|
+
* treated as the scrollable child.
|
|
146
|
+
*/
|
|
147
|
+
const ScrollHeaderRoot = /*#__PURE__*/forwardRef(({
|
|
148
|
+
className,
|
|
149
|
+
barHeight = BAR_HEIGHT,
|
|
150
|
+
threshold = 1,
|
|
151
|
+
snap = true,
|
|
152
|
+
stretch = true,
|
|
153
|
+
inset = true,
|
|
154
|
+
onCollapsedChange,
|
|
155
|
+
progress: externalProgress,
|
|
156
|
+
children,
|
|
157
|
+
...props
|
|
158
|
+
}, ref) => {
|
|
159
|
+
const insets = useSafeAreaInsets();
|
|
160
|
+
const reducedMotion = useReducedMotion();
|
|
161
|
+
const insetTop = inset ? insets.top : 0;
|
|
162
|
+
const barBand = insetTop + barHeight;
|
|
163
|
+
const offset = useSharedValue(0);
|
|
164
|
+
const largeHeight = useSharedValue(0);
|
|
165
|
+
const [largeSize, setLargeSize] = useState(0);
|
|
166
|
+
const [collapsed, setCollapsed] = useState(false);
|
|
167
|
+
const scrollRef = useAnimatedRef();
|
|
168
|
+
|
|
169
|
+
// Measured into both a shared value, for the band's height, and React
|
|
170
|
+
// state, for the content inset — the inset is a resting number that only
|
|
171
|
+
// changes when the block is re-laid-out, so paying a render for it costs
|
|
172
|
+
// nothing per frame and keeps the scroller's props stable.
|
|
173
|
+
const measureLarge = useCallback(event => {
|
|
174
|
+
const {
|
|
175
|
+
height
|
|
176
|
+
} = event.nativeEvent.layout;
|
|
177
|
+
largeHeight.value = height;
|
|
178
|
+
setLargeSize(previous => Math.abs(previous - height) < 0.5 ? previous : height);
|
|
179
|
+
}, [largeHeight]);
|
|
180
|
+
const progress = useDerivedValue(() => {
|
|
181
|
+
const next = collapseProgress(offset.value, largeHeight.value, threshold);
|
|
182
|
+
// A header with a block hands over across that block's height, and the
|
|
183
|
+
// finger sets the pace. One without has no distance to interpolate over,
|
|
184
|
+
// so the step is timed rather than instant — the alternative is a bar
|
|
185
|
+
// whose surface appears between one frame and the next.
|
|
186
|
+
const value = hasSpan(largeHeight.value) ? next : withTiming(next, {
|
|
187
|
+
duration: HANDOVER_DURATION
|
|
188
|
+
});
|
|
189
|
+
if (externalProgress) externalProgress.value = value;
|
|
190
|
+
return value;
|
|
191
|
+
}, [threshold, externalProgress]);
|
|
192
|
+
|
|
193
|
+
// The one thing the transition tells React about. `collapsed` gates the
|
|
194
|
+
// accessibility of the two titles and is what `onCollapsedChange` reports;
|
|
195
|
+
// the reaction fires on the crossing, not on the frames either side of it.
|
|
196
|
+
const cross = useCallback(next => {
|
|
197
|
+
setCollapsed(next);
|
|
198
|
+
onCollapsedChange?.(next);
|
|
199
|
+
}, [onCollapsedChange]);
|
|
200
|
+
useAnimatedReaction(() => progress.value >= 1, (isCollapsed, was) => {
|
|
201
|
+
if (!isCrossing(isCollapsed, was)) return;
|
|
202
|
+
runOnJS(cross)(isCollapsed);
|
|
203
|
+
}, [cross]);
|
|
204
|
+
const settle = useCallback(velocity => {
|
|
205
|
+
'worklet';
|
|
206
|
+
|
|
207
|
+
if (!snap) return;
|
|
208
|
+
const target = snapTarget(offset.value, largeHeight.value, velocity);
|
|
209
|
+
if (target === null) return;
|
|
210
|
+
scrollTo(scrollRef, 0, target, true);
|
|
211
|
+
}, [snap, largeHeight, offset, scrollRef]);
|
|
212
|
+
const {
|
|
213
|
+
parts,
|
|
214
|
+
scrollable
|
|
215
|
+
} = useMemo(() => splitChildren(children), [children]);
|
|
216
|
+
const childType = /*#__PURE__*/isValidElement(scrollable) ? scrollable.type : null;
|
|
217
|
+
// Keyed on the element type rather than the element: rebuilding the
|
|
218
|
+
// wrapper would remount the list and lose its scroll position. A child
|
|
219
|
+
// that is animated already is used as it stands — wrapping one twice is
|
|
220
|
+
// unsupported, and there is no marker on them to test for, so the two
|
|
221
|
+
// that exist are recognised by identity.
|
|
222
|
+
const AnimatedScrollable = useMemo(() => {
|
|
223
|
+
if (!childType) return null;
|
|
224
|
+
if (ANIMATED_SCROLLABLES.has(childType)) return childType;
|
|
225
|
+
return Animated.createAnimatedComponent(childType);
|
|
226
|
+
}, [childType]);
|
|
227
|
+
const childProps = /*#__PURE__*/isValidElement(scrollable) ? scrollable.props : {};
|
|
228
|
+
const childOnScroll = childProps.onScroll;
|
|
229
|
+
|
|
230
|
+
/*
|
|
231
|
+
* A consumer's own `onScroll` is kept, whichever of the two kinds it is,
|
|
232
|
+
* because silently dropping one looks like a bug in the scrolling rather
|
|
233
|
+
* than in the call site.
|
|
234
|
+
*
|
|
235
|
+
* `useEvent` returns an object wearing a function's type, so the two are
|
|
236
|
+
* told apart by what they actually are: an object is a Reanimated handler
|
|
237
|
+
* and composes onto ours, staying on the UI thread. A plain function
|
|
238
|
+
* cannot — `useComposedEventHandler` keeps only worklet handlers and drops
|
|
239
|
+
* anything else without a word — so it is called across the bridge
|
|
240
|
+
* instead, once per scroll event the child delivers. How often that is, is
|
|
241
|
+
* `scrollEventThrottle`'s answer rather than this component's: every frame
|
|
242
|
+
* at the default of 16, and as rare as the caller asks for above it.
|
|
243
|
+
*/
|
|
244
|
+
const workletOnScroll = typeof childOnScroll === 'function' ? null : childOnScroll ?? null;
|
|
245
|
+
const plainOnScroll = typeof childOnScroll === 'function' ? childOnScroll : null;
|
|
246
|
+
|
|
247
|
+
// Rebuilt only when the callback itself changes, so the worklet below is
|
|
248
|
+
// not rebuilt on every render of the child.
|
|
249
|
+
const forwardScroll = useCallback(native => {
|
|
250
|
+
// The `nativeEvent` crosses whole; the synthetic wrapper around it does
|
|
251
|
+
// not, because the rest of one is a live object with methods on it.
|
|
252
|
+
plainOnScroll?.({
|
|
253
|
+
nativeEvent: native
|
|
254
|
+
});
|
|
255
|
+
}, [plainOnScroll]);
|
|
256
|
+
const forwards = plainOnScroll !== null;
|
|
257
|
+
const scrollHandler = useAnimatedScrollHandler({
|
|
258
|
+
onScroll: event => {
|
|
259
|
+
offset.value = event.contentOffset.y;
|
|
260
|
+
if (forwards) {
|
|
261
|
+
// Every field React Native puts on a scroll event, so a callback
|
|
262
|
+
// reading `velocity` for a direction, or `targetContentOffset` for
|
|
263
|
+
// where a fling is going, finds what it would have without the
|
|
264
|
+
// header. Listed rather than spread: the event carries Reanimated's
|
|
265
|
+
// own `eventName` too, and that is not part of the contract.
|
|
266
|
+
runOnJS(forwardScroll)({
|
|
267
|
+
contentInset: event.contentInset,
|
|
268
|
+
contentOffset: event.contentOffset,
|
|
269
|
+
contentSize: event.contentSize,
|
|
270
|
+
layoutMeasurement: event.layoutMeasurement,
|
|
271
|
+
velocity: event.velocity,
|
|
272
|
+
zoomScale: event.zoomScale,
|
|
273
|
+
targetContentOffset: event.targetContentOffset
|
|
274
|
+
});
|
|
275
|
+
}
|
|
276
|
+
},
|
|
277
|
+
onEndDrag: event => {
|
|
278
|
+
settle(event.velocity?.y ?? 0);
|
|
279
|
+
},
|
|
280
|
+
onMomentumEnd: () => {
|
|
281
|
+
settle(0);
|
|
282
|
+
}
|
|
283
|
+
}, [settle, forwards, forwardScroll]);
|
|
284
|
+
const onScroll = useComposedEventHandler([scrollHandler, workletOnScroll ?? null]);
|
|
285
|
+
|
|
286
|
+
/*
|
|
287
|
+
* The band's resting height, plus whatever top padding the child asked
|
|
288
|
+
* for. A style array overrides rather than adds, so composing ours after
|
|
289
|
+
* theirs would drop their padding and composing theirs after ours would
|
|
290
|
+
* drop the inset — and they cannot write the sum themselves, because the
|
|
291
|
+
* band's height is measured. Reading it and adding is the only
|
|
292
|
+
* arrangement where both survive.
|
|
293
|
+
*/
|
|
294
|
+
const childContentStyle = StyleSheet.flatten(childProps.contentContainerStyle) ?? {};
|
|
295
|
+
const headerHeight = contentInset(barBand + largeSize, childContentStyle.paddingTop ?? childContentStyle.paddingVertical ?? childContentStyle.padding);
|
|
296
|
+
const bandStyle = useAnimatedStyle(() => ({
|
|
297
|
+
height: bandHeight(barBand, largeHeight.value, offset.value, stretch && !reducedMotion)
|
|
298
|
+
}), [barBand, stretch, reducedMotion]);
|
|
299
|
+
const metrics = useMemo(() => ({
|
|
300
|
+
barBand,
|
|
301
|
+
insetTop
|
|
302
|
+
}), [barBand, insetTop]);
|
|
303
|
+
const hasLarge = largeSize >= 1;
|
|
304
|
+
const context = useMemo(() => ({
|
|
305
|
+
progress,
|
|
306
|
+
offset,
|
|
307
|
+
largeHeight,
|
|
308
|
+
collapsed,
|
|
309
|
+
hasLarge,
|
|
310
|
+
measureLarge
|
|
311
|
+
}), [progress, offset, largeHeight, collapsed, hasLarge, measureLarge]);
|
|
312
|
+
const {
|
|
313
|
+
root,
|
|
314
|
+
band
|
|
315
|
+
} = scrollHeaderVariants();
|
|
316
|
+
return /*#__PURE__*/_jsx(ScrollHeaderContext.Provider, {
|
|
317
|
+
value: context,
|
|
318
|
+
children: /*#__PURE__*/_jsxs(View, {
|
|
319
|
+
...props,
|
|
320
|
+
ref: ref,
|
|
321
|
+
className: root({
|
|
322
|
+
className
|
|
323
|
+
}),
|
|
324
|
+
children: [AnimatedScrollable && /*#__PURE__*/isValidElement(scrollable) ? /*#__PURE__*/_jsx(AnimatedScrollable, {
|
|
325
|
+
...childProps,
|
|
326
|
+
ref: composeRefs(scrollRef, childProps.ref),
|
|
327
|
+
onScroll: onScroll,
|
|
328
|
+
scrollEventThrottle: childProps.scrollEventThrottle ?? 16
|
|
329
|
+
// The band is the inset. iOS would otherwise add one of its own
|
|
330
|
+
// on top of it, for a header it cannot see.
|
|
331
|
+
,
|
|
332
|
+
contentInsetAdjustmentBehavior: childProps.contentInsetAdjustmentBehavior ?? 'never',
|
|
333
|
+
contentContainerStyle: [childProps.contentContainerStyle, {
|
|
334
|
+
paddingTop: headerHeight
|
|
335
|
+
}],
|
|
336
|
+
scrollIndicatorInsets: {
|
|
337
|
+
top: barBand,
|
|
338
|
+
...childProps.scrollIndicatorInsets
|
|
339
|
+
}
|
|
340
|
+
}) : scrollable, /*#__PURE__*/_jsx(Animated.View, {
|
|
341
|
+
pointerEvents: "box-none",
|
|
342
|
+
style: [bandStyle, Platform.OS === 'android' ? styles.lift : null],
|
|
343
|
+
className: band(),
|
|
344
|
+
children: /*#__PURE__*/_jsxs(BandMetricsContext.Provider, {
|
|
345
|
+
value: metrics,
|
|
346
|
+
children: [parts.cover, parts.large, parts.bar]
|
|
347
|
+
})
|
|
348
|
+
})]
|
|
349
|
+
})
|
|
350
|
+
});
|
|
351
|
+
});
|
|
352
|
+
ScrollHeaderRoot.displayName = 'ScrollHeader';
|
|
353
|
+
const BandMetricsContext = /*#__PURE__*/createContext({
|
|
354
|
+
barBand: BAR_HEIGHT,
|
|
355
|
+
insetTop: 0
|
|
356
|
+
});
|
|
357
|
+
/**
|
|
358
|
+
* The pinned bar. Its contents never move; its surface fades in as the large
|
|
359
|
+
* block leaves, which is what makes the change read as one crossfade rather
|
|
360
|
+
* than two things happening at once.
|
|
361
|
+
*/
|
|
362
|
+
const ScrollHeaderBar = /*#__PURE__*/forwardRef(({
|
|
363
|
+
className,
|
|
364
|
+
surface = 'plain',
|
|
365
|
+
divider = true,
|
|
366
|
+
children,
|
|
367
|
+
...props
|
|
368
|
+
}, ref) => {
|
|
369
|
+
const {
|
|
370
|
+
progress
|
|
371
|
+
} = useScrollHeader('ScrollHeader.Bar');
|
|
372
|
+
const {
|
|
373
|
+
barBand,
|
|
374
|
+
insetTop
|
|
375
|
+
} = useContext(BandMetricsContext);
|
|
376
|
+
const {
|
|
377
|
+
bar,
|
|
378
|
+
barSurface
|
|
379
|
+
} = scrollHeaderVariants({
|
|
380
|
+
surface,
|
|
381
|
+
divider
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
// Closed by the time the block has gone, so the block is never seen through
|
|
385
|
+
// it — and never on top of whatever else the bar is carrying.
|
|
386
|
+
const surfaceStyle = useAnimatedStyle(() => ({
|
|
387
|
+
opacity: interpolate(progress.value, SURFACE_ARRIVE, [0, 1], 'clamp')
|
|
388
|
+
}));
|
|
389
|
+
return /*#__PURE__*/_jsxs(View, {
|
|
390
|
+
...props,
|
|
391
|
+
ref: ref
|
|
392
|
+
// The bar owns only its own band. `box-none` above it lets a pull
|
|
393
|
+
// land on the scroller rather than on the header covering it.
|
|
394
|
+
,
|
|
395
|
+
style: {
|
|
396
|
+
height: barBand,
|
|
397
|
+
paddingTop: insetTop
|
|
398
|
+
},
|
|
399
|
+
className: bar({
|
|
400
|
+
className
|
|
401
|
+
}),
|
|
402
|
+
children: [/*#__PURE__*/_jsx(Animated.View, {
|
|
403
|
+
pointerEvents: "none",
|
|
404
|
+
style: surfaceStyle,
|
|
405
|
+
className: barSurface()
|
|
406
|
+
}), /*#__PURE__*/_jsx(ScrollHeaderSlotContext.Provider, {
|
|
407
|
+
value: "bar",
|
|
408
|
+
children: textChildren(children)
|
|
409
|
+
})]
|
|
410
|
+
});
|
|
411
|
+
});
|
|
412
|
+
ScrollHeaderBar.displayName = 'ScrollHeader.Bar';
|
|
413
|
+
/**
|
|
414
|
+
* The expanded block. Its measured height is the distance the header
|
|
415
|
+
* collapses over, so whatever is put in it — a title, a search field, a row of
|
|
416
|
+
* chips — sets the scroll distance rather than a prop having to agree with it.
|
|
417
|
+
*/
|
|
418
|
+
const ScrollHeaderLarge = /*#__PURE__*/forwardRef(({
|
|
419
|
+
className,
|
|
420
|
+
onLayout,
|
|
421
|
+
children,
|
|
422
|
+
...props
|
|
423
|
+
}, ref) => {
|
|
424
|
+
const {
|
|
425
|
+
progress,
|
|
426
|
+
measureLarge,
|
|
427
|
+
collapsed
|
|
428
|
+
} = useScrollHeader('ScrollHeader.Large');
|
|
429
|
+
const {
|
|
430
|
+
large
|
|
431
|
+
} = scrollHeaderVariants();
|
|
432
|
+
const style = useAnimatedStyle(() => ({
|
|
433
|
+
opacity: interpolate(progress.value, LARGE_EXIT, [1, 0], 'clamp')
|
|
434
|
+
}));
|
|
435
|
+
|
|
436
|
+
// The measurement is this block's whole job, so it is taken first and a
|
|
437
|
+
// consumer's own `onLayout` runs after it rather than instead of it.
|
|
438
|
+
const measure = useCallback(event => {
|
|
439
|
+
measureLarge(event);
|
|
440
|
+
onLayout?.(event);
|
|
441
|
+
}, [measureLarge, onLayout]);
|
|
442
|
+
return /*#__PURE__*/_jsx(Animated.View, {
|
|
443
|
+
...props,
|
|
444
|
+
ref: ref,
|
|
445
|
+
onLayout: measure
|
|
446
|
+
// Behind the bar and faded out by the time it gets there, so it must
|
|
447
|
+
// not keep taking touches that belong to the content underneath.
|
|
448
|
+
,
|
|
449
|
+
pointerEvents: collapsed ? 'none' : 'box-none',
|
|
450
|
+
accessibilityElementsHidden: collapsed,
|
|
451
|
+
importantForAccessibility: collapsed ? 'no-hide-descendants' : 'auto',
|
|
452
|
+
style: style,
|
|
453
|
+
className: large({
|
|
454
|
+
className
|
|
455
|
+
}),
|
|
456
|
+
children: /*#__PURE__*/_jsx(ScrollHeaderSlotContext.Provider, {
|
|
457
|
+
value: "large",
|
|
458
|
+
children: textChildren(children)
|
|
459
|
+
})
|
|
460
|
+
});
|
|
461
|
+
});
|
|
462
|
+
ScrollHeaderLarge.displayName = 'ScrollHeader.Large';
|
|
463
|
+
|
|
464
|
+
/**
|
|
465
|
+
* The screen's title. Written in both halves and styled from whichever it is
|
|
466
|
+
* in: large and at rest in the block, compact and fading in on the bar.
|
|
467
|
+
*/
|
|
468
|
+
const ScrollHeaderTitle = /*#__PURE__*/forwardRef(({
|
|
469
|
+
className,
|
|
470
|
+
...props
|
|
471
|
+
}, ref) => {
|
|
472
|
+
const {
|
|
473
|
+
progress,
|
|
474
|
+
largeHeight,
|
|
475
|
+
collapsed,
|
|
476
|
+
hasLarge
|
|
477
|
+
} = useScrollHeader('ScrollHeader.Title');
|
|
478
|
+
const slot = useContext(ScrollHeaderSlotContext);
|
|
479
|
+
|
|
480
|
+
// A bar with no block above it is not handing over from anything: it is the
|
|
481
|
+
// only title the screen has, and it is wanted from the first frame. Fading it
|
|
482
|
+
// in with the collapse would leave that screen untitled until somebody
|
|
483
|
+
// scrolled it.
|
|
484
|
+
const style = useAnimatedStyle(() => ({
|
|
485
|
+
opacity: hasSpan(largeHeight.value) ? interpolate(progress.value, BAR_TITLE_ARRIVE, [0, 1], 'clamp') : 1
|
|
486
|
+
}));
|
|
487
|
+
if (slot === 'large') {
|
|
488
|
+
return /*#__PURE__*/_jsx(Text, {
|
|
489
|
+
...props,
|
|
490
|
+
ref: ref,
|
|
491
|
+
size: "3xl",
|
|
492
|
+
weight: "bold",
|
|
493
|
+
accessibilityRole: "header",
|
|
494
|
+
className: cn('text-foreground', className)
|
|
495
|
+
});
|
|
496
|
+
}
|
|
497
|
+
return /*#__PURE__*/_jsx(Animated.View, {
|
|
498
|
+
style: style
|
|
499
|
+
// The large title is the one being read until the bar has taken over.
|
|
500
|
+
// Both are in the tree the whole time, and only one of them should be —
|
|
501
|
+
// unless there is no large title, in which case this one always is.
|
|
502
|
+
,
|
|
503
|
+
accessibilityElementsHidden: hasLarge ? !collapsed : false,
|
|
504
|
+
importantForAccessibility: !hasLarge || collapsed ? 'auto' : 'no-hide-descendants',
|
|
505
|
+
className: "flex-1",
|
|
506
|
+
children: /*#__PURE__*/_jsx(Text, {
|
|
507
|
+
...props,
|
|
508
|
+
ref: ref,
|
|
509
|
+
size: "base",
|
|
510
|
+
weight: "semibold",
|
|
511
|
+
numberOfLines: props.numberOfLines ?? 1,
|
|
512
|
+
accessibilityRole: "header",
|
|
513
|
+
className: cn('text-foreground', className)
|
|
514
|
+
})
|
|
515
|
+
});
|
|
516
|
+
});
|
|
517
|
+
ScrollHeaderTitle.displayName = 'ScrollHeader.Title';
|
|
518
|
+
|
|
519
|
+
/** The quiet line under the title — a count, a byline, a date. */
|
|
520
|
+
const ScrollHeaderDescription = /*#__PURE__*/forwardRef(({
|
|
521
|
+
className,
|
|
522
|
+
...props
|
|
523
|
+
}, ref) => /*#__PURE__*/_jsx(Text, {
|
|
524
|
+
...props,
|
|
525
|
+
ref: ref,
|
|
526
|
+
size: "sm",
|
|
527
|
+
muted: true,
|
|
528
|
+
className: className
|
|
529
|
+
}));
|
|
530
|
+
ScrollHeaderDescription.displayName = 'ScrollHeader.Description';
|
|
531
|
+
/**
|
|
532
|
+
* The controls at the trailing end of the bar. They stay put and stay
|
|
533
|
+
* reachable — only the bar's surface and title are part of the transition.
|
|
534
|
+
*/
|
|
535
|
+
const ScrollHeaderActions = /*#__PURE__*/forwardRef(({
|
|
536
|
+
className,
|
|
537
|
+
children,
|
|
538
|
+
...props
|
|
539
|
+
}, ref) => {
|
|
540
|
+
const {
|
|
541
|
+
actions
|
|
542
|
+
} = scrollHeaderVariants();
|
|
543
|
+
return /*#__PURE__*/_jsx(View, {
|
|
544
|
+
...props,
|
|
545
|
+
ref: ref,
|
|
546
|
+
className: actions({
|
|
547
|
+
className
|
|
548
|
+
}),
|
|
549
|
+
children: textChildren(children)
|
|
550
|
+
});
|
|
551
|
+
});
|
|
552
|
+
ScrollHeaderActions.displayName = 'ScrollHeader.Actions';
|
|
553
|
+
/**
|
|
554
|
+
* A picture or a gradient filling the band. It has no height of its own — the
|
|
555
|
+
* band's height is its height, which is why it stretches on an over-scroll by
|
|
556
|
+
* being laid out larger rather than by being scaled up and going soft.
|
|
557
|
+
*/
|
|
558
|
+
const ScrollHeaderCover = /*#__PURE__*/forwardRef(({
|
|
559
|
+
className,
|
|
560
|
+
source,
|
|
561
|
+
colors,
|
|
562
|
+
scrim = true,
|
|
563
|
+
children,
|
|
564
|
+
...props
|
|
565
|
+
}, ref) => {
|
|
566
|
+
const {
|
|
567
|
+
cover
|
|
568
|
+
} = scrollHeaderVariants();
|
|
569
|
+
const seriesOne = useCSSVariable('--color-chart-1');
|
|
570
|
+
const seriesTwo = useCSSVariable('--color-chart-2');
|
|
571
|
+
const ramp = colors ?? [typeof seriesOne === 'string' ? seriesOne : '#6366f1', typeof seriesTwo === 'string' ? seriesTwo : '#8b5cf6'];
|
|
572
|
+
return /*#__PURE__*/_jsxs(View, {
|
|
573
|
+
...props,
|
|
574
|
+
ref: ref,
|
|
575
|
+
pointerEvents: "none",
|
|
576
|
+
className: cover({
|
|
577
|
+
className
|
|
578
|
+
}),
|
|
579
|
+
children: [/*#__PURE__*/_jsx(LinearGradient, {
|
|
580
|
+
colors: ramp,
|
|
581
|
+
start: {
|
|
582
|
+
x: 0,
|
|
583
|
+
y: 0
|
|
584
|
+
},
|
|
585
|
+
end: {
|
|
586
|
+
x: 1,
|
|
587
|
+
y: 1
|
|
588
|
+
},
|
|
589
|
+
style: StyleSheet.absoluteFill
|
|
590
|
+
}), source ? /*#__PURE__*/_jsx(Image, {
|
|
591
|
+
source: source,
|
|
592
|
+
resizeMode: "cover",
|
|
593
|
+
style: StyleSheet.absoluteFill
|
|
594
|
+
}) : null, scrim ? /*#__PURE__*/_jsx(View, {
|
|
595
|
+
className: "absolute inset-0 bg-black/25"
|
|
596
|
+
}) : null, children]
|
|
597
|
+
});
|
|
598
|
+
});
|
|
599
|
+
ScrollHeaderCover.displayName = 'ScrollHeader.Cover';
|
|
600
|
+
|
|
601
|
+
/**
|
|
602
|
+
* Sort the children into the three band slots and the one scrollable. Sorting
|
|
603
|
+
* by component rather than by order is what lets the example above read
|
|
604
|
+
* top-down — bar, block, list — while the band still draws the cover first.
|
|
605
|
+
*/
|
|
606
|
+
function splitChildren(children) {
|
|
607
|
+
let cover = null;
|
|
608
|
+
let large = null;
|
|
609
|
+
let bar = null;
|
|
610
|
+
let scrollable = null;
|
|
611
|
+
Children.forEach(children, child => {
|
|
612
|
+
if (! /*#__PURE__*/isValidElement(child)) return;
|
|
613
|
+
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;
|
|
614
|
+
});
|
|
615
|
+
return {
|
|
616
|
+
parts: {
|
|
617
|
+
cover,
|
|
618
|
+
large,
|
|
619
|
+
bar
|
|
620
|
+
},
|
|
621
|
+
scrollable
|
|
622
|
+
};
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
/** Point several refs at one node, skipping the ones that were not given. */
|
|
626
|
+
function composeRefs(...refs) {
|
|
627
|
+
return node => {
|
|
628
|
+
for (const ref of refs) {
|
|
629
|
+
if (typeof ref === 'function') ref(node);else if (ref && typeof ref === 'object') {
|
|
630
|
+
ref.current = node;
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
};
|
|
634
|
+
}
|
|
635
|
+
const styles = StyleSheet.create({
|
|
636
|
+
// Android draws by elevation before z-order, so a band with none of its own
|
|
637
|
+
// ends up under the scroller's own background.
|
|
638
|
+
lift: {
|
|
639
|
+
elevation: 4
|
|
640
|
+
}
|
|
641
|
+
});
|
|
642
|
+
export const ScrollHeader = Object.assign(ScrollHeaderRoot, {
|
|
643
|
+
Bar: ScrollHeaderBar,
|
|
644
|
+
Large: ScrollHeaderLarge,
|
|
645
|
+
Title: ScrollHeaderTitle,
|
|
646
|
+
Description: ScrollHeaderDescription,
|
|
647
|
+
Actions: ScrollHeaderActions,
|
|
648
|
+
Cover: ScrollHeaderCover
|
|
649
|
+
});
|
|
650
|
+
//# sourceMappingURL=index.js.map
|