react-native-laminar 1.2.0 → 1.4.2
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 +75 -2
- package/lib/commonjs/hooks/use-display-units.js +1 -0
- package/lib/commonjs/hooks/use-display-units.js.map +1 -1
- package/lib/commonjs/hooks/use-inline-auto-width.js +43 -10
- package/lib/commonjs/hooks/use-inline-auto-width.js.map +1 -1
- package/lib/commonjs/hooks/use-morph-motion.js +2 -1
- package/lib/commonjs/hooks/use-morph-motion.js.map +1 -1
- package/lib/commonjs/hooks/use-morph-text-style.js +4 -0
- package/lib/commonjs/hooks/use-morph-text-style.js.map +1 -1
- package/lib/commonjs/hooks/use-numeric-lanes.js +1 -0
- package/lib/commonjs/hooks/use-numeric-lanes.js.map +1 -1
- package/lib/commonjs/hooks/use-text-glyphs.js +28 -7
- package/lib/commonjs/hooks/use-text-glyphs.js.map +1 -1
- package/lib/commonjs/index.js +54 -11
- package/lib/commonjs/index.js.map +1 -1
- package/lib/commonjs/model/display-units.js +7 -1
- package/lib/commonjs/model/display-units.js.map +1 -1
- package/lib/commonjs/model/numeric-lanes.js +4 -0
- package/lib/commonjs/model/numeric-lanes.js.map +1 -1
- package/lib/commonjs/model/text-keys.js +4 -0
- package/lib/commonjs/model/text-keys.js.map +1 -1
- package/lib/commonjs/motion/entry-exit-builders.js +78 -1
- package/lib/commonjs/motion/entry-exit-builders.js.map +1 -1
- package/lib/commonjs/motion/preset-map.js +7 -0
- package/lib/commonjs/motion/preset-map.js.map +1 -1
- package/lib/commonjs/view/glyph-run.js +21 -2
- package/lib/commonjs/view/glyph-run.js.map +1 -1
- package/lib/commonjs/view/morph-viewport.js +4 -0
- package/lib/commonjs/view/morph-viewport.js.map +1 -1
- package/lib/commonjs/view/number-lane.js +5 -1
- package/lib/commonjs/view/number-lane.js.map +1 -1
- package/lib/commonjs/view/number-run.js +4 -0
- package/lib/commonjs/view/number-run.js.map +1 -1
- package/lib/commonjs/view/slots-run.js +258 -0
- package/lib/commonjs/view/slots-run.js.map +1 -0
- package/lib/commonjs/view/text-run.js +35 -3
- package/lib/commonjs/view/text-run.js.map +1 -1
- package/lib/module/hooks/use-display-units.js +2 -0
- package/lib/module/hooks/use-display-units.js.map +1 -1
- package/lib/module/hooks/use-inline-auto-width.js +44 -11
- package/lib/module/hooks/use-inline-auto-width.js.map +1 -1
- package/lib/module/hooks/use-morph-motion.js +2 -1
- package/lib/module/hooks/use-morph-motion.js.map +1 -1
- package/lib/module/hooks/use-morph-text-style.js +4 -0
- package/lib/module/hooks/use-morph-text-style.js.map +1 -1
- package/lib/module/hooks/use-numeric-lanes.js +1 -0
- package/lib/module/hooks/use-numeric-lanes.js.map +1 -1
- package/lib/module/hooks/use-text-glyphs.js +28 -7
- package/lib/module/hooks/use-text-glyphs.js.map +1 -1
- package/lib/module/index.js +56 -12
- package/lib/module/index.js.map +1 -1
- package/lib/module/model/display-units.js +7 -1
- package/lib/module/model/display-units.js.map +1 -1
- package/lib/module/model/numeric-lanes.js +5 -0
- package/lib/module/model/numeric-lanes.js.map +1 -1
- package/lib/module/model/text-keys.js +4 -0
- package/lib/module/model/text-keys.js.map +1 -1
- package/lib/module/motion/entry-exit-builders.js +75 -0
- package/lib/module/motion/entry-exit-builders.js.map +1 -1
- package/lib/module/motion/preset-map.js +7 -0
- package/lib/module/motion/preset-map.js.map +1 -1
- package/lib/module/view/glyph-run.js +21 -2
- package/lib/module/view/glyph-run.js.map +1 -1
- package/lib/module/view/morph-viewport.js +4 -0
- package/lib/module/view/morph-viewport.js.map +1 -1
- package/lib/module/view/number-lane.js +5 -1
- package/lib/module/view/number-lane.js.map +1 -1
- package/lib/module/view/number-run.js +4 -0
- package/lib/module/view/number-run.js.map +1 -1
- package/lib/module/view/slots-run.js +253 -0
- package/lib/module/view/slots-run.js.map +1 -0
- package/lib/module/view/text-run.js +36 -4
- package/lib/module/view/text-run.js.map +1 -1
- package/lib/typescript/commonjs/hooks/use-display-units.d.ts.map +1 -1
- package/lib/typescript/commonjs/hooks/use-inline-auto-width.d.ts +3 -1
- package/lib/typescript/commonjs/hooks/use-inline-auto-width.d.ts.map +1 -1
- package/lib/typescript/commonjs/hooks/use-morph-motion.d.ts.map +1 -1
- package/lib/typescript/commonjs/hooks/use-morph-text-style.d.ts.map +1 -1
- package/lib/typescript/commonjs/hooks/use-numeric-lanes.d.ts.map +1 -1
- package/lib/typescript/commonjs/hooks/use-text-glyphs.d.ts +2 -1
- package/lib/typescript/commonjs/hooks/use-text-glyphs.d.ts.map +1 -1
- package/lib/typescript/commonjs/index.d.ts +1 -3
- package/lib/typescript/commonjs/index.d.ts.map +1 -1
- package/lib/typescript/commonjs/model/display-units.d.ts.map +1 -1
- package/lib/typescript/commonjs/model/numeric-lanes.d.ts.map +1 -1
- package/lib/typescript/commonjs/model/text-keys.d.ts.map +1 -1
- package/lib/typescript/commonjs/motion/entry-exit-builders.d.ts +13 -0
- package/lib/typescript/commonjs/motion/entry-exit-builders.d.ts.map +1 -1
- package/lib/typescript/commonjs/motion/preset-map.d.ts.map +1 -1
- package/lib/typescript/commonjs/types.d.ts +12 -2
- package/lib/typescript/commonjs/types.d.ts.map +1 -1
- package/lib/typescript/commonjs/view/glyph-run.d.ts +5 -2
- package/lib/typescript/commonjs/view/glyph-run.d.ts.map +1 -1
- package/lib/typescript/commonjs/view/morph-viewport.d.ts.map +1 -1
- package/lib/typescript/commonjs/view/number-lane.d.ts.map +1 -1
- package/lib/typescript/commonjs/view/number-run.d.ts.map +1 -1
- package/lib/typescript/commonjs/view/slots-run.d.ts +15 -0
- package/lib/typescript/commonjs/view/slots-run.d.ts.map +1 -0
- package/lib/typescript/commonjs/view/text-run.d.ts +5 -1
- package/lib/typescript/commonjs/view/text-run.d.ts.map +1 -1
- package/lib/typescript/module/hooks/use-display-units.d.ts.map +1 -1
- package/lib/typescript/module/hooks/use-inline-auto-width.d.ts +3 -1
- package/lib/typescript/module/hooks/use-inline-auto-width.d.ts.map +1 -1
- package/lib/typescript/module/hooks/use-morph-motion.d.ts.map +1 -1
- package/lib/typescript/module/hooks/use-morph-text-style.d.ts.map +1 -1
- package/lib/typescript/module/hooks/use-numeric-lanes.d.ts.map +1 -1
- package/lib/typescript/module/hooks/use-text-glyphs.d.ts +2 -1
- package/lib/typescript/module/hooks/use-text-glyphs.d.ts.map +1 -1
- package/lib/typescript/module/index.d.ts +1 -3
- package/lib/typescript/module/index.d.ts.map +1 -1
- package/lib/typescript/module/model/display-units.d.ts.map +1 -1
- package/lib/typescript/module/model/numeric-lanes.d.ts.map +1 -1
- package/lib/typescript/module/model/text-keys.d.ts.map +1 -1
- package/lib/typescript/module/motion/entry-exit-builders.d.ts +13 -0
- package/lib/typescript/module/motion/entry-exit-builders.d.ts.map +1 -1
- package/lib/typescript/module/motion/preset-map.d.ts.map +1 -1
- package/lib/typescript/module/types.d.ts +12 -2
- package/lib/typescript/module/types.d.ts.map +1 -1
- package/lib/typescript/module/view/glyph-run.d.ts +5 -2
- package/lib/typescript/module/view/glyph-run.d.ts.map +1 -1
- package/lib/typescript/module/view/morph-viewport.d.ts.map +1 -1
- package/lib/typescript/module/view/number-lane.d.ts.map +1 -1
- package/lib/typescript/module/view/number-run.d.ts.map +1 -1
- package/lib/typescript/module/view/slots-run.d.ts +15 -0
- package/lib/typescript/module/view/slots-run.d.ts.map +1 -0
- package/lib/typescript/module/view/text-run.d.ts +5 -1
- package/lib/typescript/module/view/text-run.d.ts.map +1 -1
- package/package.json +11 -2
- package/src/hooks/use-display-units.ts +1 -0
- package/src/hooks/use-inline-auto-width.ts +55 -12
- package/src/hooks/use-morph-motion.ts +2 -1
- package/src/hooks/use-morph-text-style.ts +3 -0
- package/src/hooks/use-numeric-lanes.ts +1 -0
- package/src/hooks/use-text-glyphs.ts +48 -10
- package/src/index.tsx +98 -17
- package/src/model/display-units.ts +4 -1
- package/src/model/numeric-lanes.ts +3 -0
- package/src/model/text-keys.ts +3 -0
- package/src/motion/entry-exit-builders.ts +73 -0
- package/src/motion/preset-map.ts +4 -0
- package/src/types.ts +14 -2
- package/src/view/glyph-run.tsx +38 -13
- package/src/view/morph-viewport.tsx +3 -0
- package/src/view/number-lane.tsx +7 -2
- package/src/view/number-run.tsx +3 -0
- package/src/view/slots-run.tsx +385 -0
- package/src/view/text-run.tsx +77 -6
|
@@ -0,0 +1,385 @@
|
|
|
1
|
+
import React, { useEffect, useMemo, useRef } from "react";
|
|
2
|
+
import {
|
|
3
|
+
StyleSheet,
|
|
4
|
+
Text,
|
|
5
|
+
type StyleProp,
|
|
6
|
+
type TextStyle,
|
|
7
|
+
View,
|
|
8
|
+
} from "react-native";
|
|
9
|
+
import Animated, {
|
|
10
|
+
type SharedValue,
|
|
11
|
+
useAnimatedStyle,
|
|
12
|
+
useSharedValue,
|
|
13
|
+
} from "react-native-reanimated";
|
|
14
|
+
import { useNumericLanes } from "../hooks/use-numeric-lanes";
|
|
15
|
+
import { isAsciiDigit } from "../model/display-units";
|
|
16
|
+
import type { LaminarAlign, MotionRecipe, NumericFlowDirection } from "../types";
|
|
17
|
+
|
|
18
|
+
const rowStyle = {
|
|
19
|
+
flexDirection: "row",
|
|
20
|
+
alignItems: "center",
|
|
21
|
+
alignSelf: "flex-start",
|
|
22
|
+
} as const;
|
|
23
|
+
|
|
24
|
+
const rowAlignStyles = {
|
|
25
|
+
left: { alignSelf: "flex-start" },
|
|
26
|
+
center: { alignSelf: "center" },
|
|
27
|
+
right: { alignSelf: "flex-end" },
|
|
28
|
+
} as const;
|
|
29
|
+
|
|
30
|
+
const slotFrameStyle = {
|
|
31
|
+
position: "relative",
|
|
32
|
+
alignSelf: "flex-start",
|
|
33
|
+
overflow: "hidden",
|
|
34
|
+
} as const;
|
|
35
|
+
|
|
36
|
+
const slotProbeStyle = {
|
|
37
|
+
opacity: 0,
|
|
38
|
+
} as const;
|
|
39
|
+
|
|
40
|
+
const slotDigitStyle = {
|
|
41
|
+
textAlign: "center",
|
|
42
|
+
} as const;
|
|
43
|
+
|
|
44
|
+
const REEL_MIN = -12;
|
|
45
|
+
const REEL_MAX = 22;
|
|
46
|
+
const REEL_POSITIONS = Array.from({ length: 30 }, (_, index) => index - 10);
|
|
47
|
+
|
|
48
|
+
// wrap reel positions so a bounded strip can represent an endless digit wheel
|
|
49
|
+
const mod = (value: number, divisor: number) =>
|
|
50
|
+
((value % divisor) + divisor) % divisor;
|
|
51
|
+
|
|
52
|
+
type SlotReelProps = {
|
|
53
|
+
readonly current: SharedValue<number>;
|
|
54
|
+
readonly slotHeight: number;
|
|
55
|
+
readonly textStyle?: StyleProp<TextStyle>;
|
|
56
|
+
readonly className?: string;
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
// compare flattened style values without treating a new array as a visual change
|
|
60
|
+
const equalStyleValue = (left: unknown, right: unknown) =>
|
|
61
|
+
left === right ||
|
|
62
|
+
(Array.isArray(left) &&
|
|
63
|
+
Array.isArray(right) &&
|
|
64
|
+
left.length === right.length &&
|
|
65
|
+
left.every((value, index) => value === right[index]));
|
|
66
|
+
|
|
67
|
+
// prevent every parent update from rebuilding the thirty-text reel
|
|
68
|
+
const areSlotReelPropsEqual = (
|
|
69
|
+
previous: SlotReelProps,
|
|
70
|
+
next: SlotReelProps
|
|
71
|
+
) => {
|
|
72
|
+
if (
|
|
73
|
+
previous.current !== next.current ||
|
|
74
|
+
previous.slotHeight !== next.slotHeight ||
|
|
75
|
+
previous.className !== next.className
|
|
76
|
+
) {
|
|
77
|
+
return false;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const previousStyle = StyleSheet.flatten(previous.textStyle) ?? {};
|
|
81
|
+
const nextStyle = StyleSheet.flatten(next.textStyle) ?? {};
|
|
82
|
+
const previousKeys = Object.keys(previousStyle) as (keyof TextStyle)[];
|
|
83
|
+
const nextKeys = Object.keys(nextStyle);
|
|
84
|
+
|
|
85
|
+
return (
|
|
86
|
+
previousKeys.length === nextKeys.length &&
|
|
87
|
+
previousKeys.every((key) =>
|
|
88
|
+
equalStyleValue(previousStyle[key], nextStyle[key])
|
|
89
|
+
)
|
|
90
|
+
);
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
const SlotReel = React.memo(function SlotReel({
|
|
94
|
+
current,
|
|
95
|
+
slotHeight,
|
|
96
|
+
textStyle,
|
|
97
|
+
className,
|
|
98
|
+
}: SlotReelProps) {
|
|
99
|
+
// translate one bounded reel instead of mounting a new animated digit per update
|
|
100
|
+
const animatedStyle = useAnimatedStyle(
|
|
101
|
+
() => ({
|
|
102
|
+
transform: [{ translateY: (-10 - current.value) * slotHeight }],
|
|
103
|
+
}),
|
|
104
|
+
[slotHeight]
|
|
105
|
+
);
|
|
106
|
+
|
|
107
|
+
return (
|
|
108
|
+
<Animated.View
|
|
109
|
+
pointerEvents="none"
|
|
110
|
+
style={[
|
|
111
|
+
{
|
|
112
|
+
position: "absolute",
|
|
113
|
+
top: 0,
|
|
114
|
+
left: 0,
|
|
115
|
+
right: 0,
|
|
116
|
+
},
|
|
117
|
+
animatedStyle,
|
|
118
|
+
]}
|
|
119
|
+
>
|
|
120
|
+
{REEL_POSITIONS.map((position, index) => (
|
|
121
|
+
<Text
|
|
122
|
+
key={position}
|
|
123
|
+
numberOfLines={1}
|
|
124
|
+
style={[
|
|
125
|
+
textStyle,
|
|
126
|
+
slotDigitStyle,
|
|
127
|
+
{
|
|
128
|
+
position: "absolute",
|
|
129
|
+
top: index * slotHeight,
|
|
130
|
+
left: 0,
|
|
131
|
+
right: 0,
|
|
132
|
+
height: slotHeight,
|
|
133
|
+
lineHeight: slotHeight,
|
|
134
|
+
},
|
|
135
|
+
]}
|
|
136
|
+
className={className}
|
|
137
|
+
>
|
|
138
|
+
{mod(position, 10)}
|
|
139
|
+
</Text>
|
|
140
|
+
))}
|
|
141
|
+
</Animated.View>
|
|
142
|
+
);
|
|
143
|
+
}, areSlotReelPropsEqual);
|
|
144
|
+
|
|
145
|
+
type SlotColumnProps = {
|
|
146
|
+
readonly digit: number;
|
|
147
|
+
readonly direction: NumericFlowDirection;
|
|
148
|
+
readonly delayMs: number;
|
|
149
|
+
readonly animateIn: boolean;
|
|
150
|
+
readonly motionRecipe: MotionRecipe;
|
|
151
|
+
readonly slotHeight: number;
|
|
152
|
+
readonly textStyle?: StyleProp<TextStyle>;
|
|
153
|
+
readonly className?: string;
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
function SlotColumn({
|
|
157
|
+
digit,
|
|
158
|
+
direction,
|
|
159
|
+
delayMs,
|
|
160
|
+
animateIn,
|
|
161
|
+
motionRecipe,
|
|
162
|
+
slotHeight,
|
|
163
|
+
textStyle,
|
|
164
|
+
className,
|
|
165
|
+
}: SlotColumnProps) {
|
|
166
|
+
// cumulative positions let the reel move across digit wraparound without jumps
|
|
167
|
+
const spinInDistance = Math.max(digit, 1);
|
|
168
|
+
const initialDirection = direction || 1;
|
|
169
|
+
const initialValue = animateIn
|
|
170
|
+
? digit - spinInDistance * initialDirection
|
|
171
|
+
: digit;
|
|
172
|
+
const current = useSharedValue(initialValue);
|
|
173
|
+
const cumulativeRef = useRef(digit);
|
|
174
|
+
const previousDigitRef = useRef(digit);
|
|
175
|
+
const initialRef = useRef(true);
|
|
176
|
+
const exitStateRef = useRef({
|
|
177
|
+
digit,
|
|
178
|
+
direction,
|
|
179
|
+
motionRecipe,
|
|
180
|
+
target: digit,
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
if (digit !== previousDigitRef.current) {
|
|
184
|
+
const previousDigit = previousDigitRef.current;
|
|
185
|
+
let digitDelta: number;
|
|
186
|
+
|
|
187
|
+
if (direction > 0) {
|
|
188
|
+
digitDelta =
|
|
189
|
+
digit >= previousDigit
|
|
190
|
+
? digit - previousDigit
|
|
191
|
+
: 10 - previousDigit + digit;
|
|
192
|
+
} else if (direction < 0) {
|
|
193
|
+
digitDelta =
|
|
194
|
+
previousDigit >= digit
|
|
195
|
+
? -(previousDigit - digit)
|
|
196
|
+
: -(10 - digit + previousDigit);
|
|
197
|
+
} else {
|
|
198
|
+
digitDelta = digit - previousDigit;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
cumulativeRef.current += digitDelta;
|
|
202
|
+
previousDigitRef.current = digit;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
exitStateRef.current = {
|
|
206
|
+
digit,
|
|
207
|
+
direction,
|
|
208
|
+
motionRecipe,
|
|
209
|
+
target: cumulativeRef.current,
|
|
210
|
+
};
|
|
211
|
+
|
|
212
|
+
useEffect(() => {
|
|
213
|
+
// continue the current reel from its last committed target when the column exits
|
|
214
|
+
return () => {
|
|
215
|
+
const exitState = exitStateRef.current;
|
|
216
|
+
const spinOutDistance = Math.max(exitState.digit, 1);
|
|
217
|
+
const exitDirection = exitState.direction || 1;
|
|
218
|
+
|
|
219
|
+
current.value = exitState.motionRecipe.driveNumber(
|
|
220
|
+
exitState.target + spinOutDistance * exitDirection
|
|
221
|
+
);
|
|
222
|
+
};
|
|
223
|
+
}, [current]);
|
|
224
|
+
|
|
225
|
+
useEffect(() => {
|
|
226
|
+
// keep the reel bounded by recentring after enough cumulative turns
|
|
227
|
+
if (initialRef.current) {
|
|
228
|
+
initialRef.current = false;
|
|
229
|
+
|
|
230
|
+
if (!animateIn) {
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
let target = cumulativeRef.current;
|
|
236
|
+
|
|
237
|
+
if (target < REEL_MIN + 4 || target > REEL_MAX - 4) {
|
|
238
|
+
const shift = Math.trunc(target / 10) * 10;
|
|
239
|
+
|
|
240
|
+
if (shift !== 0) {
|
|
241
|
+
target -= shift;
|
|
242
|
+
cumulativeRef.current = target;
|
|
243
|
+
current.value -= shift;
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
exitStateRef.current = {
|
|
248
|
+
digit,
|
|
249
|
+
direction,
|
|
250
|
+
motionRecipe,
|
|
251
|
+
target,
|
|
252
|
+
};
|
|
253
|
+
|
|
254
|
+
current.value = motionRecipe.driveNumber(target, delayMs);
|
|
255
|
+
}, [animateIn, current, delayMs, digit, direction, motionRecipe]);
|
|
256
|
+
|
|
257
|
+
return (
|
|
258
|
+
<View style={slotFrameStyle}>
|
|
259
|
+
<Text
|
|
260
|
+
style={[textStyle, slotProbeStyle, { lineHeight: slotHeight }]}
|
|
261
|
+
className={className}
|
|
262
|
+
>
|
|
263
|
+
0
|
|
264
|
+
</Text>
|
|
265
|
+
<SlotReel
|
|
266
|
+
current={current}
|
|
267
|
+
slotHeight={slotHeight}
|
|
268
|
+
textStyle={textStyle}
|
|
269
|
+
className={className}
|
|
270
|
+
/>
|
|
271
|
+
</View>
|
|
272
|
+
);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
type SlotsRunProps = {
|
|
276
|
+
readonly value: string;
|
|
277
|
+
readonly motionRecipe: MotionRecipe;
|
|
278
|
+
readonly align: LaminarAlign;
|
|
279
|
+
readonly fontSize?: number;
|
|
280
|
+
readonly textStyle?: StyleProp<TextStyle>;
|
|
281
|
+
readonly staggerMs: number;
|
|
282
|
+
readonly className?: string;
|
|
283
|
+
};
|
|
284
|
+
|
|
285
|
+
// render text prefixes beside reusable digit reels
|
|
286
|
+
export const SlotsRun = React.memo(
|
|
287
|
+
({
|
|
288
|
+
value,
|
|
289
|
+
motionRecipe,
|
|
290
|
+
align,
|
|
291
|
+
fontSize,
|
|
292
|
+
textStyle,
|
|
293
|
+
staggerMs,
|
|
294
|
+
className,
|
|
295
|
+
}: Readonly<SlotsRunProps>) => {
|
|
296
|
+
// numeric lane identity decides which columns stay mounted as values change
|
|
297
|
+
const { units, direction, leadLength } = useNumericLanes(value);
|
|
298
|
+
const lastValueRef = useRef(value);
|
|
299
|
+
const hasAnimatedRef = useRef(false);
|
|
300
|
+
// derive one stable row height so every reel position shares the same baseline
|
|
301
|
+
const slotHeight = useMemo(() => {
|
|
302
|
+
const flattenedStyle = StyleSheet.flatten(textStyle);
|
|
303
|
+
const resolvedLineHeight =
|
|
304
|
+
typeof flattenedStyle?.lineHeight === "number"
|
|
305
|
+
? flattenedStyle.lineHeight
|
|
306
|
+
: undefined;
|
|
307
|
+
const resolvedFontSize =
|
|
308
|
+
typeof flattenedStyle?.fontSize === "number"
|
|
309
|
+
? flattenedStyle.fontSize
|
|
310
|
+
: fontSize;
|
|
311
|
+
|
|
312
|
+
return Math.max(
|
|
313
|
+
12,
|
|
314
|
+
Math.ceil(resolvedLineHeight ?? (resolvedFontSize ?? 16) * 1.18)
|
|
315
|
+
);
|
|
316
|
+
}, [fontSize, textStyle]);
|
|
317
|
+
|
|
318
|
+
if (value !== lastValueRef.current) {
|
|
319
|
+
hasAnimatedRef.current = true;
|
|
320
|
+
lastValueRef.current = value;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
const digitCount = units.filter(isAsciiDigit).length;
|
|
324
|
+
let digitIndex = 0;
|
|
325
|
+
const hasAnimated = hasAnimatedRef.current;
|
|
326
|
+
|
|
327
|
+
return (
|
|
328
|
+
<View style={[rowStyle, rowAlignStyles[align]]}>
|
|
329
|
+
{units.map((unit, index) => {
|
|
330
|
+
const inLead = index < leadLength;
|
|
331
|
+
const laneKey = inLead
|
|
332
|
+
? `lead:${index}`
|
|
333
|
+
: `slot:${units.length - 1 - index}`;
|
|
334
|
+
|
|
335
|
+
if (inLead || !isAsciiDigit(unit)) {
|
|
336
|
+
return (
|
|
337
|
+
<Animated.Text
|
|
338
|
+
key={laneKey}
|
|
339
|
+
layout={
|
|
340
|
+
hasAnimated ? motionRecipe.layoutTransition : undefined
|
|
341
|
+
}
|
|
342
|
+
exiting={!inLead ? motionRecipe.exitTransition : undefined}
|
|
343
|
+
style={textStyle}
|
|
344
|
+
className={className}
|
|
345
|
+
>
|
|
346
|
+
{unit}
|
|
347
|
+
</Animated.Text>
|
|
348
|
+
);
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
const delayMs = (digitCount - 1 - digitIndex) * staggerMs;
|
|
352
|
+
digitIndex += 1;
|
|
353
|
+
|
|
354
|
+
return (
|
|
355
|
+
<Animated.View
|
|
356
|
+
key={laneKey}
|
|
357
|
+
layout={
|
|
358
|
+
hasAnimated ? motionRecipe.layoutTransition : undefined
|
|
359
|
+
}
|
|
360
|
+
entering={
|
|
361
|
+
hasAnimated ? motionRecipe.enterTransition : undefined
|
|
362
|
+
}
|
|
363
|
+
exiting={
|
|
364
|
+
motionRecipe.exitTransition
|
|
365
|
+
}
|
|
366
|
+
>
|
|
367
|
+
<SlotColumn
|
|
368
|
+
digit={Number(unit)}
|
|
369
|
+
direction={direction}
|
|
370
|
+
delayMs={delayMs}
|
|
371
|
+
animateIn={hasAnimated}
|
|
372
|
+
motionRecipe={motionRecipe}
|
|
373
|
+
slotHeight={slotHeight}
|
|
374
|
+
textStyle={textStyle}
|
|
375
|
+
className={className}
|
|
376
|
+
/>
|
|
377
|
+
</Animated.View>
|
|
378
|
+
);
|
|
379
|
+
})}
|
|
380
|
+
</View>
|
|
381
|
+
);
|
|
382
|
+
}
|
|
383
|
+
);
|
|
384
|
+
|
|
385
|
+
SlotsRun.displayName = "SlotsRun";
|
package/src/view/text-run.tsx
CHANGED
|
@@ -1,6 +1,12 @@
|
|
|
1
|
-
import React, { useId, useRef } from "react";
|
|
1
|
+
import React, { useId, useLayoutEffect, useMemo, useRef } from "react";
|
|
2
|
+
import type { ReactNode } from "react";
|
|
2
3
|
import type { StyleProp, TextStyle } from "react-native";
|
|
4
|
+
import { useSharedValue } from "react-native-reanimated";
|
|
3
5
|
import { useTextGlyphs } from "../hooks/use-text-glyphs";
|
|
6
|
+
import {
|
|
7
|
+
createLeadingEnterTransition,
|
|
8
|
+
createLeadingExitTransition,
|
|
9
|
+
} from "../motion/entry-exit-builders";
|
|
4
10
|
import type { LaminarAlign, MotionRecipe } from "../types";
|
|
5
11
|
import { GlyphRun } from "./glyph-run";
|
|
6
12
|
|
|
@@ -8,31 +14,96 @@ type TextRunProps = {
|
|
|
8
14
|
readonly value: string;
|
|
9
15
|
readonly motionRecipe: MotionRecipe;
|
|
10
16
|
readonly align: LaminarAlign;
|
|
17
|
+
readonly leading?: ReactNode;
|
|
18
|
+
readonly leadingKey?: string | number;
|
|
19
|
+
readonly leadingGap?: number;
|
|
11
20
|
readonly textStyle?: StyleProp<TextStyle>;
|
|
12
21
|
readonly className?: string;
|
|
13
22
|
};
|
|
14
23
|
|
|
24
|
+
// connect text identity, leading transitions, and glyph layout into one run
|
|
15
25
|
export const TextRun = React.memo(
|
|
16
|
-
({
|
|
26
|
+
({
|
|
27
|
+
value,
|
|
28
|
+
motionRecipe,
|
|
29
|
+
align,
|
|
30
|
+
leading,
|
|
31
|
+
leadingKey,
|
|
32
|
+
leadingGap = 0,
|
|
33
|
+
textStyle,
|
|
34
|
+
className,
|
|
35
|
+
}: TextRunProps) => {
|
|
17
36
|
// namespace ids per instance so repeated strings do not collide
|
|
18
37
|
const scopeId = useId();
|
|
19
|
-
const glyphs = useTextGlyphs(value, scopeId);
|
|
20
38
|
const lastValueRef = useRef(value);
|
|
39
|
+
const lastLeadingPresenceRef = useRef(Boolean(leading));
|
|
40
|
+
const lastLeadingKeyRef = useRef(leadingKey);
|
|
21
41
|
const hasAnimatedRef = useRef(false);
|
|
42
|
+
const isLeadingSwap =
|
|
43
|
+
Boolean(leading) &&
|
|
44
|
+
lastLeadingPresenceRef.current &&
|
|
45
|
+
leadingKey !== lastLeadingKeyRef.current;
|
|
46
|
+
// update the worklet flag after commit so render never writes a shared value
|
|
47
|
+
const leadingSwapProgress = useSharedValue(0);
|
|
48
|
+
useLayoutEffect(() => {
|
|
49
|
+
leadingSwapProgress.value = isLeadingSwap ? 1 : 0;
|
|
50
|
+
}, [isLeadingSwap, leadingSwapProgress]);
|
|
51
|
+
const glyphs = useTextGlyphs(value, scopeId, leading, leadingKey);
|
|
52
|
+
const elementEnterTransition = useMemo(
|
|
53
|
+
() =>
|
|
54
|
+
createLeadingEnterTransition({
|
|
55
|
+
durationMs: motionRecipe.durationMs,
|
|
56
|
+
easing: motionRecipe.easing,
|
|
57
|
+
}),
|
|
58
|
+
[motionRecipe.durationMs, motionRecipe.easing]
|
|
59
|
+
);
|
|
60
|
+
const elementExitTransition = useMemo(
|
|
61
|
+
() =>
|
|
62
|
+
createLeadingExitTransition({
|
|
63
|
+
durationMs: motionRecipe.durationMs,
|
|
64
|
+
easing: motionRecipe.easing,
|
|
65
|
+
leadingGap,
|
|
66
|
+
scale: leadingSwapProgress,
|
|
67
|
+
}),
|
|
68
|
+
[
|
|
69
|
+
leadingGap,
|
|
70
|
+
leadingSwapProgress,
|
|
71
|
+
motionRecipe.durationMs,
|
|
72
|
+
motionRecipe.easing,
|
|
73
|
+
]
|
|
74
|
+
);
|
|
22
75
|
|
|
23
|
-
|
|
76
|
+
// mark the first value as settled and later changes as eligible for motion
|
|
77
|
+
if (
|
|
78
|
+
value !== lastValueRef.current ||
|
|
79
|
+
Boolean(leading) !== lastLeadingPresenceRef.current ||
|
|
80
|
+
leadingKey !== lastLeadingKeyRef.current
|
|
81
|
+
) {
|
|
24
82
|
hasAnimatedRef.current = true;
|
|
25
83
|
lastValueRef.current = value;
|
|
84
|
+
lastLeadingPresenceRef.current = Boolean(leading);
|
|
85
|
+
lastLeadingKeyRef.current = leadingKey;
|
|
26
86
|
}
|
|
27
87
|
|
|
88
|
+
const hasAnimated = hasAnimatedRef.current;
|
|
89
|
+
|
|
28
90
|
return (
|
|
29
91
|
<GlyphRun
|
|
30
92
|
glyphs={glyphs}
|
|
31
|
-
layoutTransition={
|
|
93
|
+
layoutTransition={
|
|
94
|
+
hasAnimated ? motionRecipe.layoutTransition : undefined
|
|
95
|
+
}
|
|
32
96
|
enterTransition={
|
|
33
|
-
hasAnimatedRef.current
|
|
97
|
+
hasAnimatedRef.current
|
|
98
|
+
? motionRecipe.enterTransition
|
|
99
|
+
: undefined
|
|
100
|
+
}
|
|
101
|
+
elementEnterTransition={
|
|
102
|
+
isLeadingSwap ? elementEnterTransition : undefined
|
|
34
103
|
}
|
|
35
104
|
exitTransition={motionRecipe.exitTransition}
|
|
105
|
+
elementExitTransition={elementExitTransition}
|
|
106
|
+
leadingGap={leadingGap}
|
|
36
107
|
align={align}
|
|
37
108
|
textStyle={textStyle}
|
|
38
109
|
className={className}
|