panelui-native 0.87.0 → 0.88.1
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/lib/module/components/animated-badge/index.js +451 -0
- package/lib/module/components/animated-badge/index.js.map +1 -0
- package/lib/module/components/bottom-sheet/index.js +17 -6
- package/lib/module/components/bottom-sheet/index.js.map +1 -1
- package/lib/module/components/bubble-chart/index.js +557 -41
- package/lib/module/components/bubble-chart/index.js.map +1 -1
- package/lib/module/components/feedback/index.js +474 -0
- package/lib/module/components/feedback/index.js.map +1 -0
- package/lib/module/components/scroll-blur/index.js +363 -0
- package/lib/module/components/scroll-blur/index.js.map +1 -0
- package/lib/module/components/scroll-canvas/index.js +2 -0
- package/lib/module/components/scroll-canvas/index.js.map +1 -1
- package/lib/module/components/scroll-text/index.js +3 -0
- package/lib/module/components/scroll-text/index.js.map +1 -1
- package/lib/module/components/search-bar/index.js +390 -45
- package/lib/module/components/search-bar/index.js.map +1 -1
- package/lib/module/hooks/use-keyboard-avoidance.js +21 -1
- package/lib/module/hooks/use-keyboard-avoidance.js.map +1 -1
- package/lib/module/hooks/use-reveal-progress.js +21 -1
- package/lib/module/hooks/use-reveal-progress.js.map +1 -1
- package/lib/module/index.js +3 -0
- package/lib/module/index.js.map +1 -1
- package/lib/typescript/src/components/animated-badge/index.d.ts +224 -0
- package/lib/typescript/src/components/animated-badge/index.d.ts.map +1 -0
- package/lib/typescript/src/components/bottom-sheet/index.d.ts +1 -15
- package/lib/typescript/src/components/bottom-sheet/index.d.ts.map +1 -1
- package/lib/typescript/src/components/bubble-chart/index.d.ts +150 -7
- package/lib/typescript/src/components/bubble-chart/index.d.ts.map +1 -1
- package/lib/typescript/src/components/feedback/index.d.ts +156 -0
- package/lib/typescript/src/components/feedback/index.d.ts.map +1 -0
- package/lib/typescript/src/components/scroll-blur/index.d.ts +113 -0
- package/lib/typescript/src/components/scroll-blur/index.d.ts.map +1 -0
- package/lib/typescript/src/components/scroll-canvas/index.d.ts.map +1 -1
- package/lib/typescript/src/components/search-bar/index.d.ts +142 -7
- package/lib/typescript/src/components/search-bar/index.d.ts.map +1 -1
- package/lib/typescript/src/hooks/use-keyboard-avoidance.d.ts.map +1 -1
- package/lib/typescript/src/hooks/use-reveal-progress.d.ts +6 -27
- package/lib/typescript/src/hooks/use-reveal-progress.d.ts.map +1 -1
- package/lib/typescript/src/index.d.ts +5 -2
- package/lib/typescript/src/index.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/components/animated-badge/index.tsx +513 -0
- package/src/components/bottom-sheet/index.tsx +23 -8
- package/src/components/bubble-chart/index.tsx +664 -46
- package/src/components/feedback/index.tsx +557 -0
- package/src/components/scroll-blur/index.tsx +466 -0
- package/src/components/scroll-canvas/index.tsx +2 -1
- package/src/components/scroll-text/index.tsx +3 -3
- package/src/components/search-bar/index.tsx +471 -41
- package/src/hooks/use-keyboard-avoidance.ts +24 -1
- package/src/hooks/use-reveal-progress.ts +30 -1
- package/src/index.ts +27 -0
|
@@ -0,0 +1,513 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AnimatedBadge — a status pill whose icon and label roll over when the status
|
|
3
|
+
* changes.
|
|
4
|
+
*
|
|
5
|
+
* A badge that swaps its word between one frame and the next is a badge people
|
|
6
|
+
* miss. The status is the smallest thing on the screen and usually not the
|
|
7
|
+
* thing being looked at, so a change with no movement in it registers as
|
|
8
|
+
* having always said that. Rolling the old glyph out and the new one in is
|
|
9
|
+
* what makes the change itself visible.
|
|
10
|
+
*
|
|
11
|
+
* ```tsx
|
|
12
|
+
* <AnimatedBadge status="loading">Deploying</AnimatedBadge>
|
|
13
|
+
* <AnimatedBadge status="success">Live</AnimatedBadge>
|
|
14
|
+
* ```
|
|
15
|
+
*
|
|
16
|
+
* ## The roll, and the width
|
|
17
|
+
*
|
|
18
|
+
* The outgoing glyph rises out of the pill and fades; the incoming one comes
|
|
19
|
+
* up from below and settles on a spring, with a slight rotation and scale so
|
|
20
|
+
* it reads as turning over rather than sliding. Both are clipped to the pill,
|
|
21
|
+
* which is what keeps the movement inside the badge instead of over whatever
|
|
22
|
+
* it sits beside.
|
|
23
|
+
*
|
|
24
|
+
* The pill's width springs to the new word rather than jumping, because a
|
|
25
|
+
* badge in a row of them shoves its neighbours as it changes and a jump does
|
|
26
|
+
* that in one frame. Everything runs on the UI thread.
|
|
27
|
+
*
|
|
28
|
+
* ## Which change counts as a change
|
|
29
|
+
*
|
|
30
|
+
* The label is keyed on what it says, so `"Queued"` to `"Building"` rolls and
|
|
31
|
+
* a re-render with the same word does not. Where the label is an element
|
|
32
|
+
* rather than a string, or where two different states share a word, pass
|
|
33
|
+
* `contentKey` — the badge cannot tell those apart on its own, and without a
|
|
34
|
+
* key it either animates on every render or never.
|
|
35
|
+
*
|
|
36
|
+
* ## Reduced motion
|
|
37
|
+
*
|
|
38
|
+
* With the preference on, every part of this is skipped: the glyph and the
|
|
39
|
+
* label cut over, the pill resizes immediately, and the pulse does not run.
|
|
40
|
+
* The badge still says what it says, which is the part that mattered.
|
|
41
|
+
*/
|
|
42
|
+
import {
|
|
43
|
+
forwardRef,
|
|
44
|
+
useCallback,
|
|
45
|
+
useEffect,
|
|
46
|
+
useRef,
|
|
47
|
+
useState,
|
|
48
|
+
type ReactNode,
|
|
49
|
+
} from 'react';
|
|
50
|
+
import { View, type ViewProps } from 'react-native';
|
|
51
|
+
import Animated, {
|
|
52
|
+
Easing,
|
|
53
|
+
LinearTransition,
|
|
54
|
+
runOnJS,
|
|
55
|
+
useAnimatedStyle,
|
|
56
|
+
useReducedMotion,
|
|
57
|
+
useSharedValue,
|
|
58
|
+
withRepeat,
|
|
59
|
+
withSequence,
|
|
60
|
+
withSpring,
|
|
61
|
+
withTiming,
|
|
62
|
+
} from 'react-native-reanimated';
|
|
63
|
+
import { tv, type VariantProps } from 'tailwind-variants';
|
|
64
|
+
import { useCSSVariable } from 'uniwind';
|
|
65
|
+
import {
|
|
66
|
+
AlertTriangleIcon,
|
|
67
|
+
CheckCircleIcon,
|
|
68
|
+
CircleIcon,
|
|
69
|
+
InfoIcon,
|
|
70
|
+
XIcon,
|
|
71
|
+
} from '../../icons';
|
|
72
|
+
import { IconColorProvider } from '../../icons';
|
|
73
|
+
import { Text, textChildren } from '../../primitives/text';
|
|
74
|
+
|
|
75
|
+
/** How long the pulse takes to swell and settle again, in milliseconds. */
|
|
76
|
+
const PULSE_DURATION = 800;
|
|
77
|
+
|
|
78
|
+
/** The roll's exit: short, because it is over before anyone looks at it. */
|
|
79
|
+
const ROLL_OUT = 180;
|
|
80
|
+
|
|
81
|
+
/** The roll's entrance. Springy, so the glyph lands rather than arriving. */
|
|
82
|
+
const ROLL_IN = { damping: 18, stiffness: 210, mass: 0.85 } as const;
|
|
83
|
+
|
|
84
|
+
export type AnimatedBadgeStatus =
|
|
85
|
+
| 'neutral'
|
|
86
|
+
| 'info'
|
|
87
|
+
| 'success'
|
|
88
|
+
| 'warning'
|
|
89
|
+
| 'danger'
|
|
90
|
+
| 'loading';
|
|
91
|
+
|
|
92
|
+
export type AnimatedBadgeSize = 'sm' | 'md';
|
|
93
|
+
|
|
94
|
+
const animatedBadgeVariants = tv({
|
|
95
|
+
slots: {
|
|
96
|
+
/*
|
|
97
|
+
* Clipped, which is the whole mechanism: the glyphs travel a full line box
|
|
98
|
+
* in and out, and without this they would be drawn over whatever the badge
|
|
99
|
+
* is sitting next to.
|
|
100
|
+
*/
|
|
101
|
+
/*
|
|
102
|
+
* No border. A ring would have to be a colour per status, and there is no
|
|
103
|
+
* token for one — `border` on its own resolves to `currentColor`, which
|
|
104
|
+
* comes out black on every pill in every theme. The tinted fill is what
|
|
105
|
+
* separates the badge from the surface, which is how `Badge` does it too.
|
|
106
|
+
*/
|
|
107
|
+
root: 'flex-row items-center self-start overflow-hidden rounded-full',
|
|
108
|
+
label: 'font-medium',
|
|
109
|
+
/** The pulse's fill, behind the content and inside the same clip. */
|
|
110
|
+
pulse: 'absolute inset-0',
|
|
111
|
+
/** One clipped column per rolling element, so the two move independently. */
|
|
112
|
+
slot: 'items-center justify-center overflow-hidden',
|
|
113
|
+
},
|
|
114
|
+
variants: {
|
|
115
|
+
status: {
|
|
116
|
+
neutral: {
|
|
117
|
+
root: 'bg-muted',
|
|
118
|
+
label: 'text-muted-foreground',
|
|
119
|
+
pulse: 'bg-muted-foreground',
|
|
120
|
+
},
|
|
121
|
+
info: {
|
|
122
|
+
root: 'bg-info-subtle',
|
|
123
|
+
label: 'text-info-foreground',
|
|
124
|
+
pulse: 'bg-info',
|
|
125
|
+
},
|
|
126
|
+
success: {
|
|
127
|
+
root: 'bg-success-subtle',
|
|
128
|
+
label: 'text-success-foreground',
|
|
129
|
+
pulse: 'bg-success',
|
|
130
|
+
},
|
|
131
|
+
warning: {
|
|
132
|
+
root: 'bg-warning-subtle',
|
|
133
|
+
label: 'text-warning-foreground',
|
|
134
|
+
pulse: 'bg-warning',
|
|
135
|
+
},
|
|
136
|
+
danger: {
|
|
137
|
+
root: 'bg-destructive-subtle',
|
|
138
|
+
label: 'text-destructive-foreground',
|
|
139
|
+
pulse: 'bg-destructive',
|
|
140
|
+
},
|
|
141
|
+
loading: {
|
|
142
|
+
root: 'bg-info-subtle',
|
|
143
|
+
label: 'text-info-foreground',
|
|
144
|
+
pulse: 'bg-info',
|
|
145
|
+
},
|
|
146
|
+
},
|
|
147
|
+
size: {
|
|
148
|
+
sm: { root: 'h-6 gap-1.5 px-2', label: 'text-[11px]' },
|
|
149
|
+
md: { root: 'h-8 gap-2 px-3', label: 'text-xs' },
|
|
150
|
+
},
|
|
151
|
+
},
|
|
152
|
+
defaultVariants: {
|
|
153
|
+
status: 'neutral',
|
|
154
|
+
size: 'md',
|
|
155
|
+
},
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
/** The glyph each status carries, when none is passed. */
|
|
159
|
+
const STATUS_ICON: Record<AnimatedBadgeStatus, typeof InfoIcon> = {
|
|
160
|
+
neutral: CircleIcon,
|
|
161
|
+
info: InfoIcon,
|
|
162
|
+
success: CheckCircleIcon,
|
|
163
|
+
warning: AlertTriangleIcon,
|
|
164
|
+
danger: XIcon,
|
|
165
|
+
// Never drawn: `loading` uses the spinner instead, because a still glyph
|
|
166
|
+
// beside the word "loading" is the one status that has to move.
|
|
167
|
+
loading: CircleIcon,
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
/** Which token the glyph is tinted from, so it matches the label beside it. */
|
|
171
|
+
const STATUS_COLOR_VAR: Record<AnimatedBadgeStatus, string> = {
|
|
172
|
+
neutral: '--color-muted-foreground',
|
|
173
|
+
info: '--color-info-foreground',
|
|
174
|
+
success: '--color-success-foreground',
|
|
175
|
+
warning: '--color-warning-foreground',
|
|
176
|
+
danger: '--color-destructive-foreground',
|
|
177
|
+
loading: '--color-info-foreground',
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
/** Glyph sizes per badge size — the icon tracks the text, not the box. */
|
|
181
|
+
const ICON_SIZE: Record<AnimatedBadgeSize, number> = { sm: 12, md: 14 };
|
|
182
|
+
|
|
183
|
+
/** Milliseconds for one full turn of the loading ring. */
|
|
184
|
+
const SPIN_DURATION = 800;
|
|
185
|
+
|
|
186
|
+
export interface AnimatedBadgeProps
|
|
187
|
+
extends ViewProps,
|
|
188
|
+
VariantProps<typeof animatedBadgeVariants> {
|
|
189
|
+
status?: AnimatedBadgeStatus;
|
|
190
|
+
size?: AnimatedBadgeSize;
|
|
191
|
+
/** The word. Changing it rolls the old one out and the new one in. */
|
|
192
|
+
children?: ReactNode;
|
|
193
|
+
/** A glyph of your own, in place of the status's. */
|
|
194
|
+
icon?: ReactNode;
|
|
195
|
+
/** Whether a glyph is drawn at all. */
|
|
196
|
+
showIcon?: boolean;
|
|
197
|
+
/**
|
|
198
|
+
* A slow swell behind the content, for a status that is still happening.
|
|
199
|
+
* On by default while `status` is `loading`, and off otherwise — pass it
|
|
200
|
+
* explicitly for a state of your own that is also still running.
|
|
201
|
+
*/
|
|
202
|
+
pulse?: boolean;
|
|
203
|
+
/**
|
|
204
|
+
* What counts as a change, when the label cannot say. The label is keyed on
|
|
205
|
+
* its own text, so this is only needed where it is an element rather than a
|
|
206
|
+
* string, or where two states share a word.
|
|
207
|
+
*/
|
|
208
|
+
contentKey?: string | number;
|
|
209
|
+
className?: string;
|
|
210
|
+
labelClassName?: string;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
export const AnimatedBadge = forwardRef<View, AnimatedBadgeProps>(
|
|
214
|
+
(
|
|
215
|
+
{
|
|
216
|
+
status = 'neutral',
|
|
217
|
+
size = 'md',
|
|
218
|
+
children,
|
|
219
|
+
icon,
|
|
220
|
+
showIcon = true,
|
|
221
|
+
pulse,
|
|
222
|
+
contentKey,
|
|
223
|
+
className,
|
|
224
|
+
labelClassName,
|
|
225
|
+
...props
|
|
226
|
+
},
|
|
227
|
+
ref
|
|
228
|
+
) => {
|
|
229
|
+
const reducedMotion = useReducedMotion();
|
|
230
|
+
const slots = animatedBadgeVariants({ status, size });
|
|
231
|
+
|
|
232
|
+
const themeColor = useCSSVariable(STATUS_COLOR_VAR[status]);
|
|
233
|
+
const iconColor = typeof themeColor === 'string' ? themeColor : undefined;
|
|
234
|
+
const Icon = STATUS_ICON[status];
|
|
235
|
+
|
|
236
|
+
const pulsing = (pulse ?? status === 'loading') && !reducedMotion;
|
|
237
|
+
|
|
238
|
+
/*
|
|
239
|
+
* Keyed on what it says, so a re-render with the same word does not roll.
|
|
240
|
+
* Falling back to the status rather than to a constant means an element
|
|
241
|
+
* label at least changes when the state does, which is the common case for
|
|
242
|
+
* one — a caller with two states sharing a word passes `contentKey`.
|
|
243
|
+
*/
|
|
244
|
+
const labelKey =
|
|
245
|
+
contentKey ??
|
|
246
|
+
(typeof children === 'string' || typeof children === 'number'
|
|
247
|
+
? children
|
|
248
|
+
: status);
|
|
249
|
+
|
|
250
|
+
/*
|
|
251
|
+
* The width spring is for a badge changing, not for one arriving.
|
|
252
|
+
*
|
|
253
|
+
* Arming it on mount is not enough, and that is the whole subtlety: the
|
|
254
|
+
* pill's first *measured* layout lands a frame or two after the first
|
|
255
|
+
* render — text measures, the row settles — so a spring switched on at
|
|
256
|
+
* mount still catches that settling and animates it. From the outside the
|
|
257
|
+
* badge appears, drifts, and stops, on every screen that shows one.
|
|
258
|
+
*
|
|
259
|
+
* So it arms on the first real change instead. Nothing has to be guessed
|
|
260
|
+
* about when layout has finished, because until the status or the word
|
|
261
|
+
* actually moves there is nothing the spring is for.
|
|
262
|
+
*/
|
|
263
|
+
const [armed, setArmed] = useState(false);
|
|
264
|
+
const first = useRef<string | null>(null);
|
|
265
|
+
useEffect(() => {
|
|
266
|
+
const key = `${status}\u0000${labelKey}`;
|
|
267
|
+
if (first.current === null) {
|
|
268
|
+
first.current = key;
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
271
|
+
if (key !== first.current) setArmed(true);
|
|
272
|
+
}, [status, labelKey]);
|
|
273
|
+
|
|
274
|
+
const swell = useSharedValue(0);
|
|
275
|
+
useEffect(() => {
|
|
276
|
+
if (!pulsing) {
|
|
277
|
+
swell.value = withTiming(0, { duration: 200 });
|
|
278
|
+
return;
|
|
279
|
+
}
|
|
280
|
+
swell.value = withRepeat(
|
|
281
|
+
withSequence(
|
|
282
|
+
withTiming(1, { duration: PULSE_DURATION, easing: Easing.inOut(Easing.quad) }),
|
|
283
|
+
withTiming(0, { duration: PULSE_DURATION, easing: Easing.inOut(Easing.quad) })
|
|
284
|
+
),
|
|
285
|
+
-1,
|
|
286
|
+
false
|
|
287
|
+
);
|
|
288
|
+
}, [pulsing, swell]);
|
|
289
|
+
|
|
290
|
+
const pulseStyle = useAnimatedStyle(() => ({
|
|
291
|
+
opacity: 0.08 + swell.value * 0.1,
|
|
292
|
+
transform: [{ scale: 0.96 + swell.value * 0.08 }],
|
|
293
|
+
}));
|
|
294
|
+
|
|
295
|
+
return (
|
|
296
|
+
<Animated.View
|
|
297
|
+
ref={ref}
|
|
298
|
+
// The pill grows to the new word rather than jumping to it: a badge in
|
|
299
|
+
// a row of them shoves its neighbours as it changes, and a jump does
|
|
300
|
+
// all of that shoving in one frame.
|
|
301
|
+
layout={
|
|
302
|
+
reducedMotion || !armed
|
|
303
|
+
? undefined
|
|
304
|
+
: LinearTransition.springify().damping(22)
|
|
305
|
+
}
|
|
306
|
+
accessibilityRole="text"
|
|
307
|
+
accessibilityState={{ busy: status === 'loading' }}
|
|
308
|
+
className={slots.root({ className })}
|
|
309
|
+
{...props}
|
|
310
|
+
>
|
|
311
|
+
{pulsing ? (
|
|
312
|
+
<Animated.View
|
|
313
|
+
pointerEvents="none"
|
|
314
|
+
className={slots.pulse()}
|
|
315
|
+
style={pulseStyle}
|
|
316
|
+
/>
|
|
317
|
+
) : null}
|
|
318
|
+
|
|
319
|
+
{showIcon ? (
|
|
320
|
+
<View className={slots.slot()} style={{ height: ICON_SIZE[size] + 4 }}>
|
|
321
|
+
{/*
|
|
322
|
+
A glyph passed in is somebody else's, from any set, and it reads
|
|
323
|
+
the ambient colour rather than the badge's status. Provided here
|
|
324
|
+
so `icon` comes out the same colour as the word beside it instead
|
|
325
|
+
of the icon set's own grey.
|
|
326
|
+
*/}
|
|
327
|
+
<IconColorProvider color={iconColor}>
|
|
328
|
+
<Roll contentKey={status} reducedMotion={reducedMotion} turn>
|
|
329
|
+
{icon ??
|
|
330
|
+
(status === 'loading' ? (
|
|
331
|
+
<LoadingRing
|
|
332
|
+
size={ICON_SIZE[size]}
|
|
333
|
+
color={iconColor}
|
|
334
|
+
reducedMotion={reducedMotion}
|
|
335
|
+
/>
|
|
336
|
+
) : (
|
|
337
|
+
<Icon size={ICON_SIZE[size]} color={iconColor} />
|
|
338
|
+
))}
|
|
339
|
+
</Roll>
|
|
340
|
+
</IconColorProvider>
|
|
341
|
+
</View>
|
|
342
|
+
) : null}
|
|
343
|
+
|
|
344
|
+
{children != null ? (
|
|
345
|
+
<View className={slots.slot()}>
|
|
346
|
+
<Roll contentKey={labelKey} reducedMotion={reducedMotion}>
|
|
347
|
+
{textChildren(children, (text) => (
|
|
348
|
+
<Text className={slots.label({ className: labelClassName })}>{text}</Text>
|
|
349
|
+
))}
|
|
350
|
+
</Roll>
|
|
351
|
+
</View>
|
|
352
|
+
) : null}
|
|
353
|
+
</Animated.View>
|
|
354
|
+
);
|
|
355
|
+
}
|
|
356
|
+
);
|
|
357
|
+
|
|
358
|
+
AnimatedBadge.displayName = 'AnimatedBadge';
|
|
359
|
+
|
|
360
|
+
/**
|
|
361
|
+
* The turning ring `loading` draws in place of a glyph.
|
|
362
|
+
*
|
|
363
|
+
* Built here rather than borrowed from `Spinner` because this one has to be
|
|
364
|
+
* the status's colour and the badge's glyph size, and `Spinner` takes both
|
|
365
|
+
* from classes — a badge overriding them ends up fighting the merge, and an
|
|
366
|
+
* arbitrary border width that fails to compile leaves a ring with no border at
|
|
367
|
+
* all, which is an empty hole where the icon should be.
|
|
368
|
+
*
|
|
369
|
+
* The track and the arc are two views of the same colour rather than two
|
|
370
|
+
* colours: the track is the same stroke at low opacity, so there is nothing to
|
|
371
|
+
* resolve but the one value the label already uses.
|
|
372
|
+
*/
|
|
373
|
+
function LoadingRing({
|
|
374
|
+
size,
|
|
375
|
+
color,
|
|
376
|
+
reducedMotion,
|
|
377
|
+
}: {
|
|
378
|
+
size: number;
|
|
379
|
+
color: string | undefined;
|
|
380
|
+
reducedMotion: boolean;
|
|
381
|
+
}) {
|
|
382
|
+
const turn = useSharedValue(0);
|
|
383
|
+
|
|
384
|
+
useEffect(() => {
|
|
385
|
+
if (reducedMotion) return;
|
|
386
|
+
turn.value = withRepeat(
|
|
387
|
+
withTiming(1, { duration: SPIN_DURATION, easing: Easing.linear }),
|
|
388
|
+
-1,
|
|
389
|
+
false
|
|
390
|
+
);
|
|
391
|
+
}, [reducedMotion, turn]);
|
|
392
|
+
|
|
393
|
+
const style = useAnimatedStyle(() => ({
|
|
394
|
+
transform: [{ rotate: `${turn.value * 360}deg` }],
|
|
395
|
+
}));
|
|
396
|
+
|
|
397
|
+
const ring = {
|
|
398
|
+
position: 'absolute',
|
|
399
|
+
inset: 0,
|
|
400
|
+
borderRadius: size / 2,
|
|
401
|
+
borderWidth: Math.max(1, Math.round(size / 8)),
|
|
402
|
+
} as const;
|
|
403
|
+
|
|
404
|
+
return (
|
|
405
|
+
<View style={{ width: size, height: size }}>
|
|
406
|
+
<View style={[ring, { borderColor: color, opacity: 0.25 }]} />
|
|
407
|
+
<Animated.View
|
|
408
|
+
style={[ring, { borderColor: 'transparent', borderTopColor: color }, style]}
|
|
409
|
+
/>
|
|
410
|
+
</View>
|
|
411
|
+
);
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
/**
|
|
415
|
+
* One element's turn: the old one out through the top, the new one up from
|
|
416
|
+
* below.
|
|
417
|
+
*
|
|
418
|
+
* ## Why it is one view rather than two
|
|
419
|
+
*
|
|
420
|
+
* The obvious build is to key the element on its content and let the old one
|
|
421
|
+
* animate out while the new one animates in. It does not work here: for the
|
|
422
|
+
* length of the transition both are mounted in the same box, and the box
|
|
423
|
+
* becomes as large as the pair of them — a badge that swells and collapses
|
|
424
|
+
* around every change.
|
|
425
|
+
*
|
|
426
|
+
* So there is one view throughout, and the content is swapped at the far end
|
|
427
|
+
* of the roll: it travels out carrying the old word, the word is changed while
|
|
428
|
+
* it is off-screen, and it comes back with the new one. Nothing is ever in the
|
|
429
|
+
* badge twice.
|
|
430
|
+
*
|
|
431
|
+
* The two halves are deliberately not symmetrical. The entrance is what the
|
|
432
|
+
* reader is meant to follow, so it springs and takes its time; the exit is
|
|
433
|
+
* only getting out of the way, and an exit that lingers holds the badge empty.
|
|
434
|
+
*
|
|
435
|
+
* The travel is a percentage rather than a distance, so a glyph and a word of
|
|
436
|
+
* different heights each clear their own box by the same amount.
|
|
437
|
+
*/
|
|
438
|
+
function Roll({
|
|
439
|
+
contentKey,
|
|
440
|
+
children,
|
|
441
|
+
reducedMotion,
|
|
442
|
+
turn = false,
|
|
443
|
+
}: {
|
|
444
|
+
/** What counts as a change. A new value rolls; the same value does not. */
|
|
445
|
+
contentKey: string | number;
|
|
446
|
+
children: ReactNode;
|
|
447
|
+
reducedMotion: boolean;
|
|
448
|
+
/** Add a little rotation, for a glyph. A rotating word is a gimmick. */
|
|
449
|
+
turn?: boolean;
|
|
450
|
+
}) {
|
|
451
|
+
const [shownKey, setShownKey] = useState(contentKey);
|
|
452
|
+
const settled = Object.is(shownKey, contentKey);
|
|
453
|
+
|
|
454
|
+
/*
|
|
455
|
+
* What is on screen while the swap is in flight. Held in a ref rather than
|
|
456
|
+
* state because it is only read at the moment of the swap, and holding it in
|
|
457
|
+
* state would re-render the badge on every parent render to store an element
|
|
458
|
+
* nobody is looking at yet.
|
|
459
|
+
*/
|
|
460
|
+
const outgoing = useRef<ReactNode>(children);
|
|
461
|
+
const incomingKey = useRef(contentKey);
|
|
462
|
+
useEffect(() => {
|
|
463
|
+
incomingKey.current = contentKey;
|
|
464
|
+
if (settled) outgoing.current = children;
|
|
465
|
+
});
|
|
466
|
+
|
|
467
|
+
// -1 fully below, 0 at rest, +1 fully above.
|
|
468
|
+
const phase = useSharedValue(0);
|
|
469
|
+
|
|
470
|
+
const commit = useCallback(() => {
|
|
471
|
+
setShownKey(incomingKey.current);
|
|
472
|
+
phase.value = -1;
|
|
473
|
+
phase.value = withSpring(0, ROLL_IN);
|
|
474
|
+
}, [phase]);
|
|
475
|
+
|
|
476
|
+
useEffect(() => {
|
|
477
|
+
if (settled) {
|
|
478
|
+
/*
|
|
479
|
+
* Changed and changed back before the roll finished. The swap that would
|
|
480
|
+
* have brought the element home is never scheduled, so without this it
|
|
481
|
+
* stays parked outside the badge — an empty slot where the glyph should
|
|
482
|
+
* be, for as long as the status holds.
|
|
483
|
+
*/
|
|
484
|
+
if (phase.value !== 0) phase.value = withSpring(0, ROLL_IN);
|
|
485
|
+
return;
|
|
486
|
+
}
|
|
487
|
+
if (reducedMotion) {
|
|
488
|
+
setShownKey(incomingKey.current);
|
|
489
|
+
return;
|
|
490
|
+
}
|
|
491
|
+
phase.value = withTiming(1, { duration: ROLL_OUT }, (finished) => {
|
|
492
|
+
'worklet';
|
|
493
|
+
// Interrupted means another change arrived mid-roll; that change's own
|
|
494
|
+
// effect owns the swap, and committing here too would swap twice.
|
|
495
|
+
if (finished) runOnJS(commit)();
|
|
496
|
+
});
|
|
497
|
+
}, [contentKey, settled, reducedMotion, commit, phase]);
|
|
498
|
+
|
|
499
|
+
const style = useAnimatedStyle(() => {
|
|
500
|
+
const p = phase.value;
|
|
501
|
+
const distance = Math.min(Math.abs(p), 1);
|
|
502
|
+
return {
|
|
503
|
+
opacity: 1 - distance * 0.9,
|
|
504
|
+
transform: [
|
|
505
|
+
{ translateY: `${p * -90}%` },
|
|
506
|
+
{ scale: 1 - distance * 0.1 },
|
|
507
|
+
{ rotate: turn ? `${p * 12}deg` : '0deg' },
|
|
508
|
+
],
|
|
509
|
+
};
|
|
510
|
+
});
|
|
511
|
+
|
|
512
|
+
return <Animated.View style={style}>{settled ? children : outgoing.current}</Animated.View>;
|
|
513
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import {
|
|
2
2
|
cloneElement,
|
|
3
3
|
createContext,
|
|
4
|
+
forwardRef,
|
|
4
5
|
isValidElement,
|
|
5
6
|
useCallback,
|
|
6
7
|
useContext,
|
|
@@ -9,6 +10,7 @@ import {
|
|
|
9
10
|
useRef,
|
|
10
11
|
useState,
|
|
11
12
|
type ComponentProps,
|
|
13
|
+
type ComponentRef,
|
|
12
14
|
type ReactElement,
|
|
13
15
|
type ReactNode,
|
|
14
16
|
} from 'react';
|
|
@@ -29,6 +31,7 @@ import Animated, {
|
|
|
29
31
|
runOnJS,
|
|
30
32
|
useAnimatedScrollHandler,
|
|
31
33
|
useAnimatedStyle,
|
|
34
|
+
useComposedEventHandler,
|
|
32
35
|
useReducedMotion,
|
|
33
36
|
useSharedValue,
|
|
34
37
|
withSpring,
|
|
@@ -909,12 +912,10 @@ export interface BottomSheetBodyProps
|
|
|
909
912
|
* out — pull down on a list at its top and the sheet comes with you, pull
|
|
910
913
|
* down anywhere else and the list scrolls.
|
|
911
914
|
*/
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
...props
|
|
917
|
-
}: BottomSheetBodyProps) {
|
|
915
|
+
const BottomSheetBody = forwardRef<
|
|
916
|
+
ComponentRef<typeof Animated.ScrollView>,
|
|
917
|
+
BottomSheetBodyProps
|
|
918
|
+
>(function BottomSheetBody({ className, children, onScroll, ...props }, ref) {
|
|
918
919
|
const surface = useContext(SheetSurfaceContext);
|
|
919
920
|
|
|
920
921
|
useEffect(() => {
|
|
@@ -938,13 +939,27 @@ function BottomSheetBody({
|
|
|
938
939
|
if (scrollOffset) scrollOffset.value = event.contentOffset.y;
|
|
939
940
|
});
|
|
940
941
|
|
|
942
|
+
/*
|
|
943
|
+
* Composed, not replaced. A caller's `onScroll` arriving after this one used
|
|
944
|
+
* to take its place, which silently cost the sheet the position report its
|
|
945
|
+
* whole drag arrangement is built on — the list and the sheet went back to
|
|
946
|
+
* fighting over every downward swipe, and nothing said why.
|
|
947
|
+
*
|
|
948
|
+
* It has to be an animated handler for the same reason this one is.
|
|
949
|
+
*/
|
|
950
|
+
const composed = useComposedEventHandler([
|
|
951
|
+
handler,
|
|
952
|
+
(onScroll as typeof handler | undefined) ?? null,
|
|
953
|
+
]);
|
|
954
|
+
|
|
941
955
|
const body = (
|
|
942
956
|
<Animated.ScrollView
|
|
943
|
-
|
|
957
|
+
ref={ref}
|
|
944
958
|
scrollEventThrottle={16}
|
|
945
959
|
showsVerticalScrollIndicator={false}
|
|
946
960
|
className={cn('flex-1', className)}
|
|
947
961
|
{...props}
|
|
962
|
+
onScroll={composed}
|
|
948
963
|
>
|
|
949
964
|
{textChildren(children)}
|
|
950
965
|
</Animated.ScrollView>
|
|
@@ -957,7 +972,7 @@ function BottomSheetBody({
|
|
|
957
972
|
*/
|
|
958
973
|
if (!surface) return body;
|
|
959
974
|
return <GestureDetector gesture={surface.scrollGesture}>{body}</GestureDetector>;
|
|
960
|
-
}
|
|
975
|
+
});
|
|
961
976
|
BottomSheetBody.displayName = 'BottomSheet.Body';
|
|
962
977
|
|
|
963
978
|
export interface BottomSheetFooterProps extends ViewProps {
|