panelui-native 0.40.0 → 0.41.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 +2 -1
- package/lib/module/components/card/index.js +172 -3
- package/lib/module/components/card/index.js.map +1 -1
- package/lib/module/components/drawer/index.js +13 -4
- package/lib/module/components/drawer/index.js.map +1 -1
- package/lib/module/components/map/index.js +72 -12
- package/lib/module/components/map/index.js.map +1 -1
- package/lib/module/components/pagination/index.js +19 -9
- package/lib/module/components/pagination/index.js.map +1 -1
- package/lib/module/components/panelside/index.js +68 -18
- package/lib/module/components/panelside/index.js.map +1 -1
- package/lib/module/components/text-animation/index.js +857 -0
- package/lib/module/components/text-animation/index.js.map +1 -0
- package/lib/module/icons/index.js +25 -7
- package/lib/module/icons/index.js.map +1 -1
- package/lib/module/index.js +1 -0
- package/lib/module/index.js.map +1 -1
- package/lib/typescript/src/components/card/index.d.ts +20 -0
- package/lib/typescript/src/components/card/index.d.ts.map +1 -1
- package/lib/typescript/src/components/drawer/index.d.ts +13 -1
- package/lib/typescript/src/components/drawer/index.d.ts.map +1 -1
- package/lib/typescript/src/components/map/index.d.ts +37 -2
- package/lib/typescript/src/components/map/index.d.ts.map +1 -1
- package/lib/typescript/src/components/pagination/index.d.ts.map +1 -1
- package/lib/typescript/src/components/panelside/index.d.ts +50 -4
- package/lib/typescript/src/components/panelside/index.d.ts.map +1 -1
- package/lib/typescript/src/components/text-animation/index.d.ts +268 -0
- package/lib/typescript/src/components/text-animation/index.d.ts.map +1 -0
- package/lib/typescript/src/icons/index.d.ts +6 -0
- package/lib/typescript/src/icons/index.d.ts.map +1 -1
- package/lib/typescript/src/index.d.ts +4 -3
- package/lib/typescript/src/index.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/components/card/index.tsx +227 -2
- package/src/components/drawer/index.tsx +31 -9
- package/src/components/map/index.tsx +158 -14
- package/src/components/pagination/index.tsx +18 -6
- package/src/components/panelside/index.tsx +134 -19
- package/src/components/text-animation/index.tsx +1093 -0
- package/src/icons/index.tsx +23 -4
- package/src/index.ts +13 -1
|
@@ -0,0 +1,857 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* TextAnimation — the five ways a piece of text arrives.
|
|
5
|
+
*
|
|
6
|
+
* ```tsx
|
|
7
|
+
* <TextAnimation.Typing text="Everything ships with its accessibility done" />
|
|
8
|
+
* <TextAnimation.Rotating text={['fast', 'native', 'yours']} />
|
|
9
|
+
* <TextAnimation.Counting value={2048} />
|
|
10
|
+
* <TextAnimation.Sliding value={price} decimals={2} />
|
|
11
|
+
* <TextAnimation.Scrolling value={120} step={10} />
|
|
12
|
+
* ```
|
|
13
|
+
*
|
|
14
|
+
* ## Why one component with five parts
|
|
15
|
+
*
|
|
16
|
+
* They are one idea — a value that changes and wants to be seen changing —
|
|
17
|
+
* and they share every prop that says *how*: `duration`, `delay`, `loop`,
|
|
18
|
+
* `enabled`. Put on the `TextAnimation` root, those become the defaults for
|
|
19
|
+
* everything inside it, so a hero with three of these in it is configured once
|
|
20
|
+
* rather than three times.
|
|
21
|
+
*
|
|
22
|
+
* ## What React Native forces
|
|
23
|
+
*
|
|
24
|
+
* Nested `Text` is the only thing that gets real line-breaking, and it cannot
|
|
25
|
+
* be transformed — a `translateY` on it is ignored. So the two parts that
|
|
26
|
+
* animate a whole string, `Typing` and `Counting`, stay real text and keep
|
|
27
|
+
* wrapping; the three that slide glyphs past each other lay out as rows of
|
|
28
|
+
* views, which buys the transform and costs line-breaking. None of the three
|
|
29
|
+
* is ever a paragraph, so the trade only ever falls the right way.
|
|
30
|
+
*
|
|
31
|
+
* ## Why the digits are drawn rather than measured
|
|
32
|
+
*
|
|
33
|
+
* A number animated as React state is a re-render per frame. `Counting` runs
|
|
34
|
+
* the value on the UI thread and only crosses back when the *rounded* number
|
|
35
|
+
* changes, which for a whole number is a couple of dozen times rather than
|
|
36
|
+
* sixty a second. The sliding and scrolling parts do not cross back at all:
|
|
37
|
+
* every digit is already rendered, and the animation is a transform on a
|
|
38
|
+
* column of them.
|
|
39
|
+
*/
|
|
40
|
+
import { createContext, useContext, useEffect, useMemo, useRef, useState } from 'react';
|
|
41
|
+
import { Text as RNText, View } from 'react-native';
|
|
42
|
+
import Animated, { Easing, cancelAnimation, runOnJS, useAnimatedReaction, useAnimatedStyle, useReducedMotion, useSharedValue, withRepeat, withSequence, withSpring, withTiming } from 'react-native-reanimated';
|
|
43
|
+
import { LinearGradient } from 'expo-linear-gradient';
|
|
44
|
+
import { tv } from 'tailwind-variants';
|
|
45
|
+
import { useCSSVariable } from 'uniwind';
|
|
46
|
+
import { cn } from "../../utils/cn.js";
|
|
47
|
+
import { Text } from "../../primitives/text.js";
|
|
48
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
49
|
+
const AnimatedText = Animated.createAnimatedComponent(RNText);
|
|
50
|
+
|
|
51
|
+
/** How long a caret spends lit, and then dark. */
|
|
52
|
+
const CARET_PERIOD = 520;
|
|
53
|
+
/** The spring every number rides. Slow enough to read the digits going past. */
|
|
54
|
+
const NUMBER_SPRING = {
|
|
55
|
+
damping: 26,
|
|
56
|
+
stiffness: 90,
|
|
57
|
+
mass: 1
|
|
58
|
+
};
|
|
59
|
+
/** How far a rotating phrase travels, in points. */
|
|
60
|
+
const ROTATE_DISTANCE = 22;
|
|
61
|
+
const textAnimationVariants = tv({
|
|
62
|
+
slots: {
|
|
63
|
+
/*
|
|
64
|
+
* `tabular-nums` on everything numeric, without exception. Proportional
|
|
65
|
+
* digits are different widths, so a rolling number changes width as it
|
|
66
|
+
* rolls and shoves whatever is beside it back and forth — which reads as
|
|
67
|
+
* the layout being broken rather than as the number being alive.
|
|
68
|
+
*/
|
|
69
|
+
digits: 'flex-row items-center tabular-nums',
|
|
70
|
+
column: 'items-center',
|
|
71
|
+
caret: 'text-foreground',
|
|
72
|
+
highlight: 'rounded-lg bg-secondary'
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
/* -------------------------------------------------------------------------- */
|
|
77
|
+
/* Root */
|
|
78
|
+
/* -------------------------------------------------------------------------- */
|
|
79
|
+
|
|
80
|
+
const TextAnimationContext = /*#__PURE__*/createContext({});
|
|
81
|
+
/**
|
|
82
|
+
* Shared configuration, and a row to lay the parts out in.
|
|
83
|
+
*
|
|
84
|
+
* Optional in every case — each part works standalone with its own props, and
|
|
85
|
+
* this is only here so a line with three of them in it says `duration` once.
|
|
86
|
+
*/
|
|
87
|
+
function TextAnimationRoot({
|
|
88
|
+
className,
|
|
89
|
+
duration,
|
|
90
|
+
delay,
|
|
91
|
+
loop,
|
|
92
|
+
enabled,
|
|
93
|
+
children,
|
|
94
|
+
...props
|
|
95
|
+
}) {
|
|
96
|
+
const value = useMemo(() => ({
|
|
97
|
+
duration,
|
|
98
|
+
delay,
|
|
99
|
+
loop,
|
|
100
|
+
enabled
|
|
101
|
+
}), [delay, duration, enabled, loop]);
|
|
102
|
+
return /*#__PURE__*/_jsx(TextAnimationContext.Provider, {
|
|
103
|
+
value: value,
|
|
104
|
+
children: /*#__PURE__*/_jsx(View, {
|
|
105
|
+
className: cn('flex-row items-center', className),
|
|
106
|
+
...props,
|
|
107
|
+
children: children
|
|
108
|
+
})
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
TextAnimationRoot.displayName = 'TextAnimation';
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* The part's own prop, then the root's, then the part's own default.
|
|
115
|
+
*
|
|
116
|
+
* Written out because `??` alone would take the root's value over an explicit
|
|
117
|
+
* `false` on the part, and "the root says loop, this one does not" is the
|
|
118
|
+
* whole reason the root's values are defaults rather than settings.
|
|
119
|
+
*/
|
|
120
|
+
function useShared(key, own, fallback) {
|
|
121
|
+
const inherited = useContext(TextAnimationContext);
|
|
122
|
+
return own ?? inherited[key] ?? fallback;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* The same colour at a given alpha.
|
|
127
|
+
*
|
|
128
|
+
* A gradient stop of `transparent` is black at zero alpha on Android, so a
|
|
129
|
+
* fade to it arrives through a grey smear on the way — it has to be the same
|
|
130
|
+
* colour, faded.
|
|
131
|
+
*/
|
|
132
|
+
function withAlpha(color, alpha) {
|
|
133
|
+
if (color.startsWith('#')) {
|
|
134
|
+
const hex = color.slice(1);
|
|
135
|
+
const full = hex.length === 3 ? hex.split('').map(c => c + c).join('') : hex.slice(0, 6);
|
|
136
|
+
const r = parseInt(full.slice(0, 2), 16);
|
|
137
|
+
const g = parseInt(full.slice(2, 4), 16);
|
|
138
|
+
const b = parseInt(full.slice(4, 6), 16);
|
|
139
|
+
if (Number.isNaN(r + g + b)) return color;
|
|
140
|
+
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
|
|
141
|
+
}
|
|
142
|
+
const channels = color.match(/rgba?\(([^)]+)\)/)?.[1];
|
|
143
|
+
if (channels) {
|
|
144
|
+
const [r, g, b] = channels.split(',').map(part => part.trim());
|
|
145
|
+
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
|
|
146
|
+
}
|
|
147
|
+
return color;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** True when nothing should move: asked for, or asked for by the system. */
|
|
151
|
+
function useStill(enabled) {
|
|
152
|
+
return useReducedMotion() || !enabled;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/* -------------------------------------------------------------------------- */
|
|
156
|
+
/* Typing */
|
|
157
|
+
/* -------------------------------------------------------------------------- */
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* A string arriving one character at a time.
|
|
161
|
+
*
|
|
162
|
+
* The text is React state rather than anything on the UI thread, and it has
|
|
163
|
+
* to be: a character is a different string, and a string is a re-render
|
|
164
|
+
* whichever thread decided on it. That is fine at a keystroke every 50-odd
|
|
165
|
+
* milliseconds, which is three orders of magnitude slower than a frame.
|
|
166
|
+
*
|
|
167
|
+
* It reserves no space. A line that grows as it types pushes whatever is under
|
|
168
|
+
* it down the screen on every keystroke, so give the container a height, or
|
|
169
|
+
* type into a block that has one already.
|
|
170
|
+
*/
|
|
171
|
+
function TextAnimationTyping({
|
|
172
|
+
className,
|
|
173
|
+
text,
|
|
174
|
+
duration,
|
|
175
|
+
delay,
|
|
176
|
+
hold = 1400,
|
|
177
|
+
loop,
|
|
178
|
+
caret = false,
|
|
179
|
+
caretClassName,
|
|
180
|
+
onDone,
|
|
181
|
+
enabled,
|
|
182
|
+
...props
|
|
183
|
+
}) {
|
|
184
|
+
const step = useShared('duration', duration, 55);
|
|
185
|
+
const wait = useShared('delay', delay, 0);
|
|
186
|
+
const repeat = useShared('loop', loop, false);
|
|
187
|
+
const still = useStill(useShared('enabled', enabled, true));
|
|
188
|
+
const strings = useMemo(() => typeof text === 'string' ? [text] : text, [text]);
|
|
189
|
+
const [shown, setShown] = useState(() => still ? strings[0] ?? '' : '');
|
|
190
|
+
const [typing, setTyping] = useState(false);
|
|
191
|
+
|
|
192
|
+
// Held in a ref so the effect below does not restart the whole run every
|
|
193
|
+
// time the caller passes a new inline arrow function.
|
|
194
|
+
const done = useRef(onDone);
|
|
195
|
+
done.current = onDone;
|
|
196
|
+
useEffect(() => {
|
|
197
|
+
if (still) {
|
|
198
|
+
setShown(strings[0] ?? '');
|
|
199
|
+
setTyping(false);
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
const timers = [];
|
|
203
|
+
let cancelled = false;
|
|
204
|
+
const run = index => {
|
|
205
|
+
const value = strings[index] ?? '';
|
|
206
|
+
let at = 0;
|
|
207
|
+
setTyping(true);
|
|
208
|
+
const type = () => {
|
|
209
|
+
if (cancelled) return;
|
|
210
|
+
setShown(value.slice(0, at));
|
|
211
|
+
if (at < value.length) {
|
|
212
|
+
at += 1;
|
|
213
|
+
timers.push(setTimeout(type, step));
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
setTyping(false);
|
|
217
|
+
const last = index === strings.length - 1;
|
|
218
|
+
if (last && !repeat) {
|
|
219
|
+
done.current?.();
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
timers.push(setTimeout(() => erase(value, index), hold));
|
|
223
|
+
};
|
|
224
|
+
type();
|
|
225
|
+
};
|
|
226
|
+
const erase = (value, index) => {
|
|
227
|
+
if (cancelled) return;
|
|
228
|
+
let at = value.length;
|
|
229
|
+
setTyping(true);
|
|
230
|
+
const back = () => {
|
|
231
|
+
if (cancelled) return;
|
|
232
|
+
setShown(value.slice(0, at));
|
|
233
|
+
if (at > 0) {
|
|
234
|
+
at -= 1;
|
|
235
|
+
timers.push(setTimeout(back, step));
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
setTyping(false);
|
|
239
|
+
run(index === strings.length - 1 ? 0 : index + 1);
|
|
240
|
+
};
|
|
241
|
+
back();
|
|
242
|
+
};
|
|
243
|
+
timers.push(setTimeout(() => run(0), wait));
|
|
244
|
+
return () => {
|
|
245
|
+
cancelled = true;
|
|
246
|
+
timers.forEach(clearTimeout);
|
|
247
|
+
};
|
|
248
|
+
}, [hold, repeat, step, still, strings, wait]);
|
|
249
|
+
return (
|
|
250
|
+
/*#__PURE__*/
|
|
251
|
+
/*
|
|
252
|
+
* The caret is nested *inside* the text rather than laid out beside it. As
|
|
253
|
+
* a sibling in a row it sits against the right of the whole block, so the
|
|
254
|
+
* moment a line wraps it stops being after the last character and starts
|
|
255
|
+
* floating at the end of the paragraph — which is exactly where a caret
|
|
256
|
+
* should never be. Nested, it is one more glyph in the flow and lands
|
|
257
|
+
* wherever typing has got to, on whichever line that is.
|
|
258
|
+
*/
|
|
259
|
+
_jsxs(Text, {
|
|
260
|
+
className: className,
|
|
261
|
+
...props,
|
|
262
|
+
children: [shown, caret ? /*#__PURE__*/_jsx(TypingCaret, {
|
|
263
|
+
blinking: !typing,
|
|
264
|
+
still: still,
|
|
265
|
+
className: caretClassName
|
|
266
|
+
}) : null]
|
|
267
|
+
})
|
|
268
|
+
);
|
|
269
|
+
}
|
|
270
|
+
TextAnimationTyping.displayName = 'TextAnimation.Typing';
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* The caret. Solid while characters are arriving, blinking while they are not
|
|
274
|
+
* — which is what a real one does, and what says "waiting" rather than
|
|
275
|
+
* "finished".
|
|
276
|
+
*
|
|
277
|
+
* A glyph rather than a view, because only a glyph takes part in line-breaking
|
|
278
|
+
* — and because a character is sized by the font it is in, so it is the right
|
|
279
|
+
* height at every text size without being told any of them. Nested text can be
|
|
280
|
+
* faded but not transformed, which the opacity here is well inside.
|
|
281
|
+
*/
|
|
282
|
+
function TypingCaret({
|
|
283
|
+
blinking,
|
|
284
|
+
still,
|
|
285
|
+
className
|
|
286
|
+
}) {
|
|
287
|
+
const {
|
|
288
|
+
caret
|
|
289
|
+
} = textAnimationVariants();
|
|
290
|
+
const opacity = useSharedValue(1);
|
|
291
|
+
useEffect(() => {
|
|
292
|
+
if (still || !blinking) {
|
|
293
|
+
cancelAnimation(opacity);
|
|
294
|
+
opacity.value = 1;
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
297
|
+
opacity.value = withRepeat(withSequence(withTiming(1, {
|
|
298
|
+
duration: CARET_PERIOD
|
|
299
|
+
}), withTiming(0, {
|
|
300
|
+
duration: CARET_PERIOD
|
|
301
|
+
})), -1, false);
|
|
302
|
+
return () => cancelAnimation(opacity);
|
|
303
|
+
}, [blinking, opacity, still]);
|
|
304
|
+
const style = useAnimatedStyle(() => ({
|
|
305
|
+
opacity: opacity.value
|
|
306
|
+
}));
|
|
307
|
+
return /*#__PURE__*/_jsx(AnimatedText
|
|
308
|
+
// Decorative: the text it follows is what a screen reader should read.
|
|
309
|
+
, {
|
|
310
|
+
accessibilityElementsHidden: true,
|
|
311
|
+
importantForAccessibility: "no-hide-descendants",
|
|
312
|
+
style: style,
|
|
313
|
+
className: caret({
|
|
314
|
+
className
|
|
315
|
+
}),
|
|
316
|
+
children: '\u258F'
|
|
317
|
+
});
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/* -------------------------------------------------------------------------- */
|
|
321
|
+
/* Rotating */
|
|
322
|
+
/* -------------------------------------------------------------------------- */
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* One phrase replaced by the next, the outgoing one leaving upward and the
|
|
326
|
+
* incoming one arriving from below.
|
|
327
|
+
*
|
|
328
|
+
* The box is sized by the longest phrase and not by the current one. A box
|
|
329
|
+
* that resizes as the words change makes the line around it jump, which is
|
|
330
|
+
* more distracting than the effect is interesting — and a box sized by the
|
|
331
|
+
* *first* phrase clips every longer one that follows, which is worse than
|
|
332
|
+
* either.
|
|
333
|
+
*
|
|
334
|
+
* So the width comes from a sizer: every phrase, laid out invisibly in a view
|
|
335
|
+
* with no height. Yoga still takes a column's width from its children when
|
|
336
|
+
* their height is fixed at zero, so the box ends up as wide as the longest
|
|
337
|
+
* phrase and as tall as one line of them.
|
|
338
|
+
*/
|
|
339
|
+
function TextAnimationRotating({
|
|
340
|
+
className,
|
|
341
|
+
text,
|
|
342
|
+
duration,
|
|
343
|
+
delay,
|
|
344
|
+
enabled,
|
|
345
|
+
...props
|
|
346
|
+
}) {
|
|
347
|
+
const period = useShared('duration', duration, 2200);
|
|
348
|
+
const wait = useShared('delay', delay, 0);
|
|
349
|
+
const still = useStill(useShared('enabled', enabled, true));
|
|
350
|
+
const phrases = useMemo(() => typeof text === 'string' ? [text] : text, [text]);
|
|
351
|
+
const [index, setIndex] = useState(0);
|
|
352
|
+
useEffect(() => {
|
|
353
|
+
if (still || phrases.length < 2) return;
|
|
354
|
+
let interval;
|
|
355
|
+
const start = setTimeout(() => {
|
|
356
|
+
setIndex(current => (current + 1) % phrases.length);
|
|
357
|
+
interval = setInterval(() => setIndex(current => (current + 1) % phrases.length), period);
|
|
358
|
+
}, wait + period);
|
|
359
|
+
return () => {
|
|
360
|
+
clearTimeout(start);
|
|
361
|
+
if (interval) clearInterval(interval);
|
|
362
|
+
};
|
|
363
|
+
}, [period, phrases.length, still, wait]);
|
|
364
|
+
return /*#__PURE__*/_jsxs(View, {
|
|
365
|
+
className: cn('overflow-hidden', className),
|
|
366
|
+
children: [/*#__PURE__*/_jsx(View, {
|
|
367
|
+
style: {
|
|
368
|
+
height: 0
|
|
369
|
+
},
|
|
370
|
+
pointerEvents: "none",
|
|
371
|
+
accessibilityElementsHidden: true,
|
|
372
|
+
children: phrases.map(phrase => /*#__PURE__*/_jsx(Text, {
|
|
373
|
+
...props,
|
|
374
|
+
className: "opacity-0",
|
|
375
|
+
children: phrase
|
|
376
|
+
}, phrase))
|
|
377
|
+
}), phrases.map((phrase, at) => /*#__PURE__*/_jsx(RotatingPhrase, {
|
|
378
|
+
phrase: phrase
|
|
379
|
+
// The first is laid out in flow and gives the box its height; the
|
|
380
|
+
// rest are stacked over it, so one line is the height whichever
|
|
381
|
+
// phrase is showing.
|
|
382
|
+
,
|
|
383
|
+
measured: at === 0,
|
|
384
|
+
active: at === index,
|
|
385
|
+
still: still,
|
|
386
|
+
textProps: props
|
|
387
|
+
}, phrase))]
|
|
388
|
+
});
|
|
389
|
+
}
|
|
390
|
+
TextAnimationRotating.displayName = 'TextAnimation.Rotating';
|
|
391
|
+
function RotatingPhrase({
|
|
392
|
+
phrase,
|
|
393
|
+
measured,
|
|
394
|
+
active,
|
|
395
|
+
still,
|
|
396
|
+
textProps
|
|
397
|
+
}) {
|
|
398
|
+
const travel = useSharedValue(active ? 0 : ROTATE_DISTANCE);
|
|
399
|
+
const opacity = useSharedValue(active ? 1 : 0);
|
|
400
|
+
useEffect(() => {
|
|
401
|
+
if (still) {
|
|
402
|
+
travel.value = active ? 0 : ROTATE_DISTANCE;
|
|
403
|
+
opacity.value = active ? 1 : 0;
|
|
404
|
+
return;
|
|
405
|
+
}
|
|
406
|
+
if (active) {
|
|
407
|
+
// From below, not from wherever it was left: a phrase coming back after
|
|
408
|
+
// a full cycle should arrive the same way it did the first time.
|
|
409
|
+
travel.value = ROTATE_DISTANCE;
|
|
410
|
+
travel.value = withTiming(0, {
|
|
411
|
+
duration: 300,
|
|
412
|
+
easing: Easing.out(Easing.cubic)
|
|
413
|
+
});
|
|
414
|
+
opacity.value = withTiming(1, {
|
|
415
|
+
duration: 240
|
|
416
|
+
});
|
|
417
|
+
} else {
|
|
418
|
+
travel.value = withTiming(-ROTATE_DISTANCE, {
|
|
419
|
+
duration: 300,
|
|
420
|
+
easing: Easing.in(Easing.cubic)
|
|
421
|
+
});
|
|
422
|
+
opacity.value = withTiming(0, {
|
|
423
|
+
duration: 200
|
|
424
|
+
});
|
|
425
|
+
}
|
|
426
|
+
}, [active, opacity, still, travel]);
|
|
427
|
+
const style = useAnimatedStyle(() => ({
|
|
428
|
+
opacity: opacity.value,
|
|
429
|
+
transform: [{
|
|
430
|
+
translateY: travel.value
|
|
431
|
+
}]
|
|
432
|
+
}));
|
|
433
|
+
return /*#__PURE__*/_jsx(Animated.View, {
|
|
434
|
+
style: [style, measured ? undefined : {
|
|
435
|
+
position: 'absolute',
|
|
436
|
+
left: 0,
|
|
437
|
+
right: 0
|
|
438
|
+
}]
|
|
439
|
+
// Only the phrase on screen is announced. All of them are laid out, and
|
|
440
|
+
// a screen reader reading the whole list is the cost of that.
|
|
441
|
+
,
|
|
442
|
+
accessibilityElementsHidden: !active,
|
|
443
|
+
importantForAccessibility: active ? 'auto' : 'no-hide-descendants',
|
|
444
|
+
children: /*#__PURE__*/_jsx(Text, {
|
|
445
|
+
...textProps,
|
|
446
|
+
children: phrase
|
|
447
|
+
})
|
|
448
|
+
});
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
/* -------------------------------------------------------------------------- */
|
|
452
|
+
/* Counting */
|
|
453
|
+
/* -------------------------------------------------------------------------- */
|
|
454
|
+
|
|
455
|
+
/** Formats a number the way both counting parts agree to. */
|
|
456
|
+
function useFormatter(decimals, formatOptions) {
|
|
457
|
+
return useMemo(() => {
|
|
458
|
+
if (formatOptions) {
|
|
459
|
+
try {
|
|
460
|
+
const format = new Intl.NumberFormat(undefined, formatOptions);
|
|
461
|
+
return value => format.format(value);
|
|
462
|
+
} catch {
|
|
463
|
+
// Some engines ship a partial Intl; fall through to the plain form.
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
return value => value.toFixed(decimals);
|
|
467
|
+
}, [decimals, formatOptions]);
|
|
468
|
+
}
|
|
469
|
+
/**
|
|
470
|
+
* A number counting up to itself.
|
|
471
|
+
*
|
|
472
|
+
* The text is rewritten from a Reanimated reaction rather than from state:
|
|
473
|
+
* sixty re-renders a second for a number that is only ever a string is the
|
|
474
|
+
* kind of thing that makes an otherwise still screen drop frames. Only the
|
|
475
|
+
* rounded value crosses back to JavaScript, so the work per frame is one
|
|
476
|
+
* comparison and, at most, one `setState` on a value that actually changed.
|
|
477
|
+
*/
|
|
478
|
+
function TextAnimationCounting({
|
|
479
|
+
className,
|
|
480
|
+
value,
|
|
481
|
+
from = 0,
|
|
482
|
+
duration,
|
|
483
|
+
delay,
|
|
484
|
+
decimals = 0,
|
|
485
|
+
formatOptions,
|
|
486
|
+
enabled,
|
|
487
|
+
...props
|
|
488
|
+
}) {
|
|
489
|
+
const span = useShared('duration', duration, 1200);
|
|
490
|
+
const wait = useShared('delay', delay, 0);
|
|
491
|
+
const still = useStill(useShared('enabled', enabled, true));
|
|
492
|
+
const format = useFormatter(decimals, formatOptions);
|
|
493
|
+
const progress = useSharedValue(still ? value : from);
|
|
494
|
+
const [shown, setShown] = useState(() => still ? value : from);
|
|
495
|
+
useEffect(() => {
|
|
496
|
+
if (still) {
|
|
497
|
+
cancelAnimation(progress);
|
|
498
|
+
progress.value = value;
|
|
499
|
+
setShown(value);
|
|
500
|
+
return;
|
|
501
|
+
}
|
|
502
|
+
progress.value = from;
|
|
503
|
+
const timer = setTimeout(() => {
|
|
504
|
+
progress.value = withTiming(value, {
|
|
505
|
+
duration: span,
|
|
506
|
+
// Fast at first and settling at the end, which is what makes the last
|
|
507
|
+
// few digits readable instead of a blur that stops.
|
|
508
|
+
easing: Easing.out(Easing.cubic)
|
|
509
|
+
});
|
|
510
|
+
}, wait);
|
|
511
|
+
return () => {
|
|
512
|
+
clearTimeout(timer);
|
|
513
|
+
cancelAnimation(progress);
|
|
514
|
+
};
|
|
515
|
+
}, [from, progress, span, still, value, wait]);
|
|
516
|
+
const factor = 10 ** decimals;
|
|
517
|
+
useAnimatedReaction(() => Math.round(progress.value * factor) / factor, (current, previous) => {
|
|
518
|
+
if (current !== previous) runOnJS(setShown)(current);
|
|
519
|
+
}, [factor]);
|
|
520
|
+
return /*#__PURE__*/_jsx(Text
|
|
521
|
+
// The final value, always: a screen reader should be told the number,
|
|
522
|
+
// not read a slot machine.
|
|
523
|
+
, {
|
|
524
|
+
accessibilityLabel: format(value),
|
|
525
|
+
className: cn('tabular-nums', className),
|
|
526
|
+
...props,
|
|
527
|
+
children: format(shown)
|
|
528
|
+
});
|
|
529
|
+
}
|
|
530
|
+
TextAnimationCounting.displayName = 'TextAnimation.Counting';
|
|
531
|
+
|
|
532
|
+
/* -------------------------------------------------------------------------- */
|
|
533
|
+
/* Sliding */
|
|
534
|
+
/* -------------------------------------------------------------------------- */
|
|
535
|
+
|
|
536
|
+
/**
|
|
537
|
+
* An odometer: every digit is a column of ten, and each column rolls to the
|
|
538
|
+
* one it should be showing.
|
|
539
|
+
*
|
|
540
|
+
* Nothing here is measured at runtime. A column is ten stacked digits and the
|
|
541
|
+
* whole thing is moved by a fraction of its own height, so the roll needs no
|
|
542
|
+
* `onLayout`, no first frame at the wrong offset, and no re-render per digit
|
|
543
|
+
* per frame. The height comes from one hidden `0` in flow, which is what makes
|
|
544
|
+
* the column as tall as the font is rather than as tall as a number happens to
|
|
545
|
+
* be.
|
|
546
|
+
*/
|
|
547
|
+
function TextAnimationSliding({
|
|
548
|
+
className,
|
|
549
|
+
value,
|
|
550
|
+
decimals = 0,
|
|
551
|
+
padStart = 1,
|
|
552
|
+
thousandSeparator,
|
|
553
|
+
decimalSeparator = '.',
|
|
554
|
+
textClassName,
|
|
555
|
+
size,
|
|
556
|
+
weight,
|
|
557
|
+
delay,
|
|
558
|
+
enabled,
|
|
559
|
+
...props
|
|
560
|
+
}) {
|
|
561
|
+
const wait = useShared('delay', delay, 0);
|
|
562
|
+
const still = useStill(useShared('enabled', enabled, true));
|
|
563
|
+
const {
|
|
564
|
+
digits
|
|
565
|
+
} = textAnimationVariants();
|
|
566
|
+
const {
|
|
567
|
+
whole,
|
|
568
|
+
fraction,
|
|
569
|
+
negative
|
|
570
|
+
} = useMemo(() => {
|
|
571
|
+
const absolute = Math.abs(value);
|
|
572
|
+
const fixed = absolute.toFixed(decimals);
|
|
573
|
+
const [left = '0', right = ''] = fixed.split('.');
|
|
574
|
+
return {
|
|
575
|
+
whole: left.padStart(padStart, '0'),
|
|
576
|
+
fraction: right,
|
|
577
|
+
negative: value < 0
|
|
578
|
+
};
|
|
579
|
+
}, [decimals, padStart, value]);
|
|
580
|
+
const columns = [];
|
|
581
|
+
whole.split('').forEach((digit, index) => {
|
|
582
|
+
const fromEnd = whole.length - index - 1;
|
|
583
|
+
columns.push(/*#__PURE__*/_jsx(SlidingColumn, {
|
|
584
|
+
digit: Number(digit),
|
|
585
|
+
delay: wait,
|
|
586
|
+
still: still,
|
|
587
|
+
className: textClassName,
|
|
588
|
+
size: size,
|
|
589
|
+
weight: weight
|
|
590
|
+
}, `w${index}`));
|
|
591
|
+
if (thousandSeparator && fromEnd > 0 && fromEnd % 3 === 0) {
|
|
592
|
+
columns.push(/*#__PURE__*/_jsx(Text, {
|
|
593
|
+
size: size,
|
|
594
|
+
weight: weight,
|
|
595
|
+
className: textClassName,
|
|
596
|
+
children: thousandSeparator
|
|
597
|
+
}, `s${index}`));
|
|
598
|
+
}
|
|
599
|
+
});
|
|
600
|
+
if (fraction) {
|
|
601
|
+
columns.push(/*#__PURE__*/_jsx(Text, {
|
|
602
|
+
size: size,
|
|
603
|
+
weight: weight,
|
|
604
|
+
className: textClassName,
|
|
605
|
+
children: decimalSeparator
|
|
606
|
+
}, "point"));
|
|
607
|
+
fraction.split('').forEach((digit, index) => {
|
|
608
|
+
columns.push(/*#__PURE__*/_jsx(SlidingColumn, {
|
|
609
|
+
digit: Number(digit),
|
|
610
|
+
delay: wait,
|
|
611
|
+
still: still,
|
|
612
|
+
className: textClassName,
|
|
613
|
+
size: size,
|
|
614
|
+
weight: weight
|
|
615
|
+
}, `f${index}`));
|
|
616
|
+
});
|
|
617
|
+
}
|
|
618
|
+
return /*#__PURE__*/_jsxs(View
|
|
619
|
+
// The number as one thing, so a screen reader says "1,024" rather than
|
|
620
|
+
// reading four columns of ten digits each.
|
|
621
|
+
, {
|
|
622
|
+
accessibilityRole: "text",
|
|
623
|
+
accessibilityLabel: `${negative ? '-' : ''}${whole}${fraction ? decimalSeparator + fraction : ''}`,
|
|
624
|
+
className: digits({
|
|
625
|
+
className
|
|
626
|
+
}),
|
|
627
|
+
...props,
|
|
628
|
+
children: [negative ? /*#__PURE__*/_jsx(Text, {
|
|
629
|
+
size: size,
|
|
630
|
+
weight: weight,
|
|
631
|
+
className: textClassName,
|
|
632
|
+
children: "\u2212"
|
|
633
|
+
}) : null, columns]
|
|
634
|
+
});
|
|
635
|
+
}
|
|
636
|
+
TextAnimationSliding.displayName = 'TextAnimation.Sliding';
|
|
637
|
+
const TEN = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
|
|
638
|
+
function SlidingColumn({
|
|
639
|
+
digit,
|
|
640
|
+
delay,
|
|
641
|
+
still,
|
|
642
|
+
className,
|
|
643
|
+
size,
|
|
644
|
+
weight
|
|
645
|
+
}) {
|
|
646
|
+
const {
|
|
647
|
+
column
|
|
648
|
+
} = textAnimationVariants();
|
|
649
|
+
const offset = useSharedValue(still ? digit : 0);
|
|
650
|
+
useEffect(() => {
|
|
651
|
+
if (still) {
|
|
652
|
+
cancelAnimation(offset);
|
|
653
|
+
offset.value = digit;
|
|
654
|
+
return;
|
|
655
|
+
}
|
|
656
|
+
const timer = setTimeout(() => {
|
|
657
|
+
offset.value = withSpring(digit, NUMBER_SPRING);
|
|
658
|
+
}, delay);
|
|
659
|
+
return () => {
|
|
660
|
+
clearTimeout(timer);
|
|
661
|
+
cancelAnimation(offset);
|
|
662
|
+
};
|
|
663
|
+
}, [delay, digit, offset, still]);
|
|
664
|
+
|
|
665
|
+
/*
|
|
666
|
+
* A percentage of the column's own height, so nothing has to be measured.
|
|
667
|
+
* The column is ten digits tall; moving it by `-n/10` of that puts the nth
|
|
668
|
+
* one in the window, whatever the font size turns out to be.
|
|
669
|
+
*/
|
|
670
|
+
const style = useAnimatedStyle(() => ({
|
|
671
|
+
transform: [{
|
|
672
|
+
translateY: `${-offset.value / TEN.length * 100}%`
|
|
673
|
+
}]
|
|
674
|
+
}));
|
|
675
|
+
return /*#__PURE__*/_jsxs(View, {
|
|
676
|
+
className: "overflow-hidden",
|
|
677
|
+
children: [/*#__PURE__*/_jsx(Text, {
|
|
678
|
+
size: size,
|
|
679
|
+
weight: weight,
|
|
680
|
+
className: cn('opacity-0', className),
|
|
681
|
+
children: "0"
|
|
682
|
+
}), /*#__PURE__*/_jsx(Animated.View, {
|
|
683
|
+
style: [style, {
|
|
684
|
+
position: 'absolute',
|
|
685
|
+
top: 0
|
|
686
|
+
}],
|
|
687
|
+
className: column(),
|
|
688
|
+
children: TEN.map(n => /*#__PURE__*/_jsx(Text, {
|
|
689
|
+
size: size,
|
|
690
|
+
weight: weight,
|
|
691
|
+
className: cn('tabular-nums', className),
|
|
692
|
+
children: n
|
|
693
|
+
}, n))
|
|
694
|
+
})]
|
|
695
|
+
});
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
/* -------------------------------------------------------------------------- */
|
|
699
|
+
/* Scrolling */
|
|
700
|
+
/* -------------------------------------------------------------------------- */
|
|
701
|
+
|
|
702
|
+
/**
|
|
703
|
+
* A column of values scrolling past a window, coming to rest on one.
|
|
704
|
+
*
|
|
705
|
+
* The difference from `Sliding` is what the reader is being told. An odometer
|
|
706
|
+
* says *this number changed*; a column that scrolls past its neighbours says
|
|
707
|
+
* *this number was chosen from a scale*, and the values either side of it are
|
|
708
|
+
* the scale. Reach for it for a target, a threshold, a picked quantity — and
|
|
709
|
+
* for a plain change of value, reach for the other one.
|
|
710
|
+
*/
|
|
711
|
+
function TextAnimationScrolling({
|
|
712
|
+
className,
|
|
713
|
+
value,
|
|
714
|
+
step = 1,
|
|
715
|
+
around = 2,
|
|
716
|
+
duration,
|
|
717
|
+
delay,
|
|
718
|
+
formatOptions,
|
|
719
|
+
textClassName,
|
|
720
|
+
size,
|
|
721
|
+
weight,
|
|
722
|
+
highlight = false,
|
|
723
|
+
highlightClassName,
|
|
724
|
+
fadeColor = '--color-background',
|
|
725
|
+
enabled,
|
|
726
|
+
...props
|
|
727
|
+
}) {
|
|
728
|
+
const span = useShared('duration', duration, 1400);
|
|
729
|
+
const wait = useShared('delay', delay, 0);
|
|
730
|
+
const still = useStill(useShared('enabled', enabled, true));
|
|
731
|
+
const {
|
|
732
|
+
column,
|
|
733
|
+
highlight: band
|
|
734
|
+
} = textAnimationVariants();
|
|
735
|
+
const token = useCSSVariable(typeof fadeColor === 'string' && fadeColor.startsWith('--') ? fadeColor : '--color-background');
|
|
736
|
+
const resolvedToken = typeof token === 'string' ? token : undefined;
|
|
737
|
+
const fade = fadeColor === false ? undefined : (fadeColor.startsWith('--') ? resolvedToken : fadeColor) ?? resolvedToken;
|
|
738
|
+
const format = useFormatter(0, formatOptions);
|
|
739
|
+
|
|
740
|
+
// The window shows `around` either side of the resting value, so the run
|
|
741
|
+
// starts that many steps below zero and ends that many above the target.
|
|
742
|
+
const values = useMemo(() => {
|
|
743
|
+
const out = [];
|
|
744
|
+
for (let i = -around; value + around * step >= i * step; i += 1) {
|
|
745
|
+
out.push(i * step);
|
|
746
|
+
if (out.length > 400) break;
|
|
747
|
+
}
|
|
748
|
+
return out;
|
|
749
|
+
}, [around, step, value]);
|
|
750
|
+
const rows = around * 2 + 1;
|
|
751
|
+
/*
|
|
752
|
+
* The window shows `rows` values and the chosen one belongs in the middle of
|
|
753
|
+
* them, so the column comes to rest `around` short of the target's own index.
|
|
754
|
+
* Landing on the index itself would put the answer against the top edge with
|
|
755
|
+
* the whole scale below it, which reads as having overshot.
|
|
756
|
+
*/
|
|
757
|
+
const found = values.indexOf(value);
|
|
758
|
+
const target = Math.max(0, (found >= 0 ? found : values.length - 1) - around);
|
|
759
|
+
const offset = useSharedValue(still ? target : 0);
|
|
760
|
+
useEffect(() => {
|
|
761
|
+
if (still) {
|
|
762
|
+
cancelAnimation(offset);
|
|
763
|
+
offset.value = target;
|
|
764
|
+
return;
|
|
765
|
+
}
|
|
766
|
+
offset.value = 0;
|
|
767
|
+
const timer = setTimeout(() => {
|
|
768
|
+
offset.value = withTiming(target, {
|
|
769
|
+
duration: span,
|
|
770
|
+
easing: Easing.out(Easing.cubic)
|
|
771
|
+
});
|
|
772
|
+
}, wait);
|
|
773
|
+
return () => {
|
|
774
|
+
clearTimeout(timer);
|
|
775
|
+
cancelAnimation(offset);
|
|
776
|
+
};
|
|
777
|
+
}, [offset, span, still, target, wait]);
|
|
778
|
+
const style = useAnimatedStyle(() => ({
|
|
779
|
+
transform: [{
|
|
780
|
+
translateY: `${-offset.value / values.length * 100}%`
|
|
781
|
+
}]
|
|
782
|
+
}));
|
|
783
|
+
return /*#__PURE__*/_jsxs(View, {
|
|
784
|
+
accessibilityRole: "text",
|
|
785
|
+
accessibilityLabel: format(value),
|
|
786
|
+
className: cn('overflow-hidden', className),
|
|
787
|
+
...props,
|
|
788
|
+
children: [/*#__PURE__*/_jsx(View, {
|
|
789
|
+
className: "opacity-0",
|
|
790
|
+
children: Array.from({
|
|
791
|
+
length: rows
|
|
792
|
+
}, (_, i) => /*#__PURE__*/_jsx(Text, {
|
|
793
|
+
size: size,
|
|
794
|
+
weight: weight,
|
|
795
|
+
className: textClassName,
|
|
796
|
+
children: "0"
|
|
797
|
+
}, i))
|
|
798
|
+
}), highlight ? /*#__PURE__*/_jsx(View, {
|
|
799
|
+
pointerEvents: "none",
|
|
800
|
+
className: band({
|
|
801
|
+
className: highlightClassName
|
|
802
|
+
}),
|
|
803
|
+
style: {
|
|
804
|
+
position: 'absolute',
|
|
805
|
+
left: 0,
|
|
806
|
+
right: 0,
|
|
807
|
+
top: `${around / rows * 100}%`,
|
|
808
|
+
height: `${1 / rows * 100}%`
|
|
809
|
+
}
|
|
810
|
+
}) : null, /*#__PURE__*/_jsx(Animated.View, {
|
|
811
|
+
style: [style, {
|
|
812
|
+
position: 'absolute',
|
|
813
|
+
top: 0,
|
|
814
|
+
left: 0,
|
|
815
|
+
right: 0
|
|
816
|
+
}],
|
|
817
|
+
className: column(),
|
|
818
|
+
children: values.map(entry => /*#__PURE__*/_jsx(Text, {
|
|
819
|
+
size: size,
|
|
820
|
+
weight: weight,
|
|
821
|
+
className: cn('tabular-nums', textClassName),
|
|
822
|
+
children: format(entry)
|
|
823
|
+
}, entry))
|
|
824
|
+
}), fade ? /*#__PURE__*/_jsxs(_Fragment, {
|
|
825
|
+
children: [/*#__PURE__*/_jsx(LinearGradient, {
|
|
826
|
+
pointerEvents: "none",
|
|
827
|
+
colors: [fade, withAlpha(fade, 0)],
|
|
828
|
+
style: {
|
|
829
|
+
position: 'absolute',
|
|
830
|
+
top: 0,
|
|
831
|
+
left: 0,
|
|
832
|
+
right: 0,
|
|
833
|
+
height: `${around / rows * 100}%`
|
|
834
|
+
}
|
|
835
|
+
}), /*#__PURE__*/_jsx(LinearGradient, {
|
|
836
|
+
pointerEvents: "none",
|
|
837
|
+
colors: [withAlpha(fade, 0), fade],
|
|
838
|
+
style: {
|
|
839
|
+
position: 'absolute',
|
|
840
|
+
bottom: 0,
|
|
841
|
+
left: 0,
|
|
842
|
+
right: 0,
|
|
843
|
+
height: `${around / rows * 100}%`
|
|
844
|
+
}
|
|
845
|
+
})]
|
|
846
|
+
}) : null]
|
|
847
|
+
});
|
|
848
|
+
}
|
|
849
|
+
TextAnimationScrolling.displayName = 'TextAnimation.Scrolling';
|
|
850
|
+
export const TextAnimation = Object.assign(TextAnimationRoot, {
|
|
851
|
+
Typing: TextAnimationTyping,
|
|
852
|
+
Rotating: TextAnimationRotating,
|
|
853
|
+
Counting: TextAnimationCounting,
|
|
854
|
+
Sliding: TextAnimationSliding,
|
|
855
|
+
Scrolling: TextAnimationScrolling
|
|
856
|
+
});
|
|
857
|
+
//# sourceMappingURL=index.js.map
|