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.
Files changed (147) hide show
  1. package/README.md +75 -2
  2. package/lib/commonjs/hooks/use-display-units.js +1 -0
  3. package/lib/commonjs/hooks/use-display-units.js.map +1 -1
  4. package/lib/commonjs/hooks/use-inline-auto-width.js +43 -10
  5. package/lib/commonjs/hooks/use-inline-auto-width.js.map +1 -1
  6. package/lib/commonjs/hooks/use-morph-motion.js +2 -1
  7. package/lib/commonjs/hooks/use-morph-motion.js.map +1 -1
  8. package/lib/commonjs/hooks/use-morph-text-style.js +4 -0
  9. package/lib/commonjs/hooks/use-morph-text-style.js.map +1 -1
  10. package/lib/commonjs/hooks/use-numeric-lanes.js +1 -0
  11. package/lib/commonjs/hooks/use-numeric-lanes.js.map +1 -1
  12. package/lib/commonjs/hooks/use-text-glyphs.js +28 -7
  13. package/lib/commonjs/hooks/use-text-glyphs.js.map +1 -1
  14. package/lib/commonjs/index.js +54 -11
  15. package/lib/commonjs/index.js.map +1 -1
  16. package/lib/commonjs/model/display-units.js +7 -1
  17. package/lib/commonjs/model/display-units.js.map +1 -1
  18. package/lib/commonjs/model/numeric-lanes.js +4 -0
  19. package/lib/commonjs/model/numeric-lanes.js.map +1 -1
  20. package/lib/commonjs/model/text-keys.js +4 -0
  21. package/lib/commonjs/model/text-keys.js.map +1 -1
  22. package/lib/commonjs/motion/entry-exit-builders.js +78 -1
  23. package/lib/commonjs/motion/entry-exit-builders.js.map +1 -1
  24. package/lib/commonjs/motion/preset-map.js +7 -0
  25. package/lib/commonjs/motion/preset-map.js.map +1 -1
  26. package/lib/commonjs/view/glyph-run.js +21 -2
  27. package/lib/commonjs/view/glyph-run.js.map +1 -1
  28. package/lib/commonjs/view/morph-viewport.js +4 -0
  29. package/lib/commonjs/view/morph-viewport.js.map +1 -1
  30. package/lib/commonjs/view/number-lane.js +5 -1
  31. package/lib/commonjs/view/number-lane.js.map +1 -1
  32. package/lib/commonjs/view/number-run.js +4 -0
  33. package/lib/commonjs/view/number-run.js.map +1 -1
  34. package/lib/commonjs/view/slots-run.js +258 -0
  35. package/lib/commonjs/view/slots-run.js.map +1 -0
  36. package/lib/commonjs/view/text-run.js +35 -3
  37. package/lib/commonjs/view/text-run.js.map +1 -1
  38. package/lib/module/hooks/use-display-units.js +2 -0
  39. package/lib/module/hooks/use-display-units.js.map +1 -1
  40. package/lib/module/hooks/use-inline-auto-width.js +44 -11
  41. package/lib/module/hooks/use-inline-auto-width.js.map +1 -1
  42. package/lib/module/hooks/use-morph-motion.js +2 -1
  43. package/lib/module/hooks/use-morph-motion.js.map +1 -1
  44. package/lib/module/hooks/use-morph-text-style.js +4 -0
  45. package/lib/module/hooks/use-morph-text-style.js.map +1 -1
  46. package/lib/module/hooks/use-numeric-lanes.js +1 -0
  47. package/lib/module/hooks/use-numeric-lanes.js.map +1 -1
  48. package/lib/module/hooks/use-text-glyphs.js +28 -7
  49. package/lib/module/hooks/use-text-glyphs.js.map +1 -1
  50. package/lib/module/index.js +56 -12
  51. package/lib/module/index.js.map +1 -1
  52. package/lib/module/model/display-units.js +7 -1
  53. package/lib/module/model/display-units.js.map +1 -1
  54. package/lib/module/model/numeric-lanes.js +5 -0
  55. package/lib/module/model/numeric-lanes.js.map +1 -1
  56. package/lib/module/model/text-keys.js +4 -0
  57. package/lib/module/model/text-keys.js.map +1 -1
  58. package/lib/module/motion/entry-exit-builders.js +75 -0
  59. package/lib/module/motion/entry-exit-builders.js.map +1 -1
  60. package/lib/module/motion/preset-map.js +7 -0
  61. package/lib/module/motion/preset-map.js.map +1 -1
  62. package/lib/module/view/glyph-run.js +21 -2
  63. package/lib/module/view/glyph-run.js.map +1 -1
  64. package/lib/module/view/morph-viewport.js +4 -0
  65. package/lib/module/view/morph-viewport.js.map +1 -1
  66. package/lib/module/view/number-lane.js +5 -1
  67. package/lib/module/view/number-lane.js.map +1 -1
  68. package/lib/module/view/number-run.js +4 -0
  69. package/lib/module/view/number-run.js.map +1 -1
  70. package/lib/module/view/slots-run.js +253 -0
  71. package/lib/module/view/slots-run.js.map +1 -0
  72. package/lib/module/view/text-run.js +36 -4
  73. package/lib/module/view/text-run.js.map +1 -1
  74. package/lib/typescript/commonjs/hooks/use-display-units.d.ts.map +1 -1
  75. package/lib/typescript/commonjs/hooks/use-inline-auto-width.d.ts +3 -1
  76. package/lib/typescript/commonjs/hooks/use-inline-auto-width.d.ts.map +1 -1
  77. package/lib/typescript/commonjs/hooks/use-morph-motion.d.ts.map +1 -1
  78. package/lib/typescript/commonjs/hooks/use-morph-text-style.d.ts.map +1 -1
  79. package/lib/typescript/commonjs/hooks/use-numeric-lanes.d.ts.map +1 -1
  80. package/lib/typescript/commonjs/hooks/use-text-glyphs.d.ts +2 -1
  81. package/lib/typescript/commonjs/hooks/use-text-glyphs.d.ts.map +1 -1
  82. package/lib/typescript/commonjs/index.d.ts +1 -3
  83. package/lib/typescript/commonjs/index.d.ts.map +1 -1
  84. package/lib/typescript/commonjs/model/display-units.d.ts.map +1 -1
  85. package/lib/typescript/commonjs/model/numeric-lanes.d.ts.map +1 -1
  86. package/lib/typescript/commonjs/model/text-keys.d.ts.map +1 -1
  87. package/lib/typescript/commonjs/motion/entry-exit-builders.d.ts +13 -0
  88. package/lib/typescript/commonjs/motion/entry-exit-builders.d.ts.map +1 -1
  89. package/lib/typescript/commonjs/motion/preset-map.d.ts.map +1 -1
  90. package/lib/typescript/commonjs/types.d.ts +12 -2
  91. package/lib/typescript/commonjs/types.d.ts.map +1 -1
  92. package/lib/typescript/commonjs/view/glyph-run.d.ts +5 -2
  93. package/lib/typescript/commonjs/view/glyph-run.d.ts.map +1 -1
  94. package/lib/typescript/commonjs/view/morph-viewport.d.ts.map +1 -1
  95. package/lib/typescript/commonjs/view/number-lane.d.ts.map +1 -1
  96. package/lib/typescript/commonjs/view/number-run.d.ts.map +1 -1
  97. package/lib/typescript/commonjs/view/slots-run.d.ts +15 -0
  98. package/lib/typescript/commonjs/view/slots-run.d.ts.map +1 -0
  99. package/lib/typescript/commonjs/view/text-run.d.ts +5 -1
  100. package/lib/typescript/commonjs/view/text-run.d.ts.map +1 -1
  101. package/lib/typescript/module/hooks/use-display-units.d.ts.map +1 -1
  102. package/lib/typescript/module/hooks/use-inline-auto-width.d.ts +3 -1
  103. package/lib/typescript/module/hooks/use-inline-auto-width.d.ts.map +1 -1
  104. package/lib/typescript/module/hooks/use-morph-motion.d.ts.map +1 -1
  105. package/lib/typescript/module/hooks/use-morph-text-style.d.ts.map +1 -1
  106. package/lib/typescript/module/hooks/use-numeric-lanes.d.ts.map +1 -1
  107. package/lib/typescript/module/hooks/use-text-glyphs.d.ts +2 -1
  108. package/lib/typescript/module/hooks/use-text-glyphs.d.ts.map +1 -1
  109. package/lib/typescript/module/index.d.ts +1 -3
  110. package/lib/typescript/module/index.d.ts.map +1 -1
  111. package/lib/typescript/module/model/display-units.d.ts.map +1 -1
  112. package/lib/typescript/module/model/numeric-lanes.d.ts.map +1 -1
  113. package/lib/typescript/module/model/text-keys.d.ts.map +1 -1
  114. package/lib/typescript/module/motion/entry-exit-builders.d.ts +13 -0
  115. package/lib/typescript/module/motion/entry-exit-builders.d.ts.map +1 -1
  116. package/lib/typescript/module/motion/preset-map.d.ts.map +1 -1
  117. package/lib/typescript/module/types.d.ts +12 -2
  118. package/lib/typescript/module/types.d.ts.map +1 -1
  119. package/lib/typescript/module/view/glyph-run.d.ts +5 -2
  120. package/lib/typescript/module/view/glyph-run.d.ts.map +1 -1
  121. package/lib/typescript/module/view/morph-viewport.d.ts.map +1 -1
  122. package/lib/typescript/module/view/number-lane.d.ts.map +1 -1
  123. package/lib/typescript/module/view/number-run.d.ts.map +1 -1
  124. package/lib/typescript/module/view/slots-run.d.ts +15 -0
  125. package/lib/typescript/module/view/slots-run.d.ts.map +1 -0
  126. package/lib/typescript/module/view/text-run.d.ts +5 -1
  127. package/lib/typescript/module/view/text-run.d.ts.map +1 -1
  128. package/package.json +11 -2
  129. package/src/hooks/use-display-units.ts +1 -0
  130. package/src/hooks/use-inline-auto-width.ts +55 -12
  131. package/src/hooks/use-morph-motion.ts +2 -1
  132. package/src/hooks/use-morph-text-style.ts +3 -0
  133. package/src/hooks/use-numeric-lanes.ts +1 -0
  134. package/src/hooks/use-text-glyphs.ts +48 -10
  135. package/src/index.tsx +98 -17
  136. package/src/model/display-units.ts +4 -1
  137. package/src/model/numeric-lanes.ts +3 -0
  138. package/src/model/text-keys.ts +3 -0
  139. package/src/motion/entry-exit-builders.ts +73 -0
  140. package/src/motion/preset-map.ts +4 -0
  141. package/src/types.ts +14 -2
  142. package/src/view/glyph-run.tsx +38 -13
  143. package/src/view/morph-viewport.tsx +3 -0
  144. package/src/view/number-lane.tsx +7 -2
  145. package/src/view/number-run.tsx +3 -0
  146. package/src/view/slots-run.tsx +385 -0
  147. 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";
@@ -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
- ({ value, motionRecipe, align, textStyle, className }: TextRunProps) => {
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
- if (value !== lastValueRef.current) {
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={motionRecipe.layoutTransition}
93
+ layoutTransition={
94
+ hasAnimated ? motionRecipe.layoutTransition : undefined
95
+ }
32
96
  enterTransition={
33
- hasAnimatedRef.current ? motionRecipe.enterTransition : undefined
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}