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