react-native-laminar 1.4.4 → 1.5.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.
Files changed (109) hide show
  1. package/README.md +36 -6
  2. package/lib/commonjs/hooks/use-inline-auto-width.js +24 -1
  3. package/lib/commonjs/hooks/use-inline-auto-width.js.map +1 -1
  4. package/lib/commonjs/hooks/use-leading-animation.js +49 -0
  5. package/lib/commonjs/hooks/use-leading-animation.js.map +1 -0
  6. package/lib/commonjs/hooks/use-morph-motion.js +3 -1
  7. package/lib/commonjs/hooks/use-morph-motion.js.map +1 -1
  8. package/lib/commonjs/index.js +35 -6
  9. package/lib/commonjs/index.js.map +1 -1
  10. package/lib/commonjs/motion/preset-map.js +5 -0
  11. package/lib/commonjs/motion/preset-map.js.map +1 -1
  12. package/lib/commonjs/view/leading-element.js +37 -0
  13. package/lib/commonjs/view/leading-element.js.map +1 -0
  14. package/lib/commonjs/view/morph-viewport.js +26 -0
  15. package/lib/commonjs/view/morph-viewport.js.map +1 -1
  16. package/lib/commonjs/view/number-run.js +70 -22
  17. package/lib/commonjs/view/number-run.js.map +1 -1
  18. package/lib/commonjs/view/slot-shadow.js +77 -0
  19. package/lib/commonjs/view/slot-shadow.js.map +1 -0
  20. package/lib/commonjs/view/slots-run.js +111 -42
  21. package/lib/commonjs/view/slots-run.js.map +1 -1
  22. package/lib/commonjs/view/text-run.js +13 -24
  23. package/lib/commonjs/view/text-run.js.map +1 -1
  24. package/lib/commonjs/view/ticker-run.js +127 -0
  25. package/lib/commonjs/view/ticker-run.js.map +1 -0
  26. package/lib/module/hooks/use-inline-auto-width.js +24 -1
  27. package/lib/module/hooks/use-inline-auto-width.js.map +1 -1
  28. package/lib/module/hooks/use-leading-animation.js +44 -0
  29. package/lib/module/hooks/use-leading-animation.js.map +1 -0
  30. package/lib/module/hooks/use-morph-motion.js +3 -1
  31. package/lib/module/hooks/use-morph-motion.js.map +1 -1
  32. package/lib/module/index.js +35 -6
  33. package/lib/module/index.js.map +1 -1
  34. package/lib/module/motion/preset-map.js +5 -0
  35. package/lib/module/motion/preset-map.js.map +1 -1
  36. package/lib/module/view/leading-element.js +32 -0
  37. package/lib/module/view/leading-element.js.map +1 -0
  38. package/lib/module/view/morph-viewport.js +28 -2
  39. package/lib/module/view/morph-viewport.js.map +1 -1
  40. package/lib/module/view/number-run.js +70 -23
  41. package/lib/module/view/number-run.js.map +1 -1
  42. package/lib/module/view/slot-shadow.js +72 -0
  43. package/lib/module/view/slot-shadow.js.map +1 -0
  44. package/lib/module/view/slots-run.js +111 -42
  45. package/lib/module/view/slots-run.js.map +1 -1
  46. package/lib/module/view/text-run.js +14 -25
  47. package/lib/module/view/text-run.js.map +1 -1
  48. package/lib/module/view/ticker-run.js +121 -0
  49. package/lib/module/view/ticker-run.js.map +1 -0
  50. package/lib/typescript/commonjs/hooks/use-inline-auto-width.d.ts.map +1 -1
  51. package/lib/typescript/commonjs/hooks/use-leading-animation.d.ts +16 -0
  52. package/lib/typescript/commonjs/hooks/use-leading-animation.d.ts.map +1 -0
  53. package/lib/typescript/commonjs/hooks/use-morph-motion.d.ts +3 -2
  54. package/lib/typescript/commonjs/hooks/use-morph-motion.d.ts.map +1 -1
  55. package/lib/typescript/commonjs/index.d.ts +1 -1
  56. package/lib/typescript/commonjs/index.d.ts.map +1 -1
  57. package/lib/typescript/commonjs/motion/preset-map.d.ts.map +1 -1
  58. package/lib/typescript/commonjs/types.d.ts +14 -2
  59. package/lib/typescript/commonjs/types.d.ts.map +1 -1
  60. package/lib/typescript/commonjs/view/leading-element.d.ts +14 -0
  61. package/lib/typescript/commonjs/view/leading-element.d.ts.map +1 -0
  62. package/lib/typescript/commonjs/view/morph-viewport.d.ts +4 -1
  63. package/lib/typescript/commonjs/view/morph-viewport.d.ts.map +1 -1
  64. package/lib/typescript/commonjs/view/number-run.d.ts +6 -2
  65. package/lib/typescript/commonjs/view/number-run.d.ts.map +1 -1
  66. package/lib/typescript/commonjs/view/slot-shadow.d.ts +11 -0
  67. package/lib/typescript/commonjs/view/slot-shadow.d.ts.map +1 -0
  68. package/lib/typescript/commonjs/view/slots-run.d.ts +8 -3
  69. package/lib/typescript/commonjs/view/slots-run.d.ts.map +1 -1
  70. package/lib/typescript/commonjs/view/text-run.d.ts.map +1 -1
  71. package/lib/typescript/commonjs/view/ticker-run.d.ts +21 -0
  72. package/lib/typescript/commonjs/view/ticker-run.d.ts.map +1 -0
  73. package/lib/typescript/module/hooks/use-inline-auto-width.d.ts.map +1 -1
  74. package/lib/typescript/module/hooks/use-leading-animation.d.ts +16 -0
  75. package/lib/typescript/module/hooks/use-leading-animation.d.ts.map +1 -0
  76. package/lib/typescript/module/hooks/use-morph-motion.d.ts +3 -2
  77. package/lib/typescript/module/hooks/use-morph-motion.d.ts.map +1 -1
  78. package/lib/typescript/module/index.d.ts +1 -1
  79. package/lib/typescript/module/index.d.ts.map +1 -1
  80. package/lib/typescript/module/motion/preset-map.d.ts.map +1 -1
  81. package/lib/typescript/module/types.d.ts +14 -2
  82. package/lib/typescript/module/types.d.ts.map +1 -1
  83. package/lib/typescript/module/view/leading-element.d.ts +14 -0
  84. package/lib/typescript/module/view/leading-element.d.ts.map +1 -0
  85. package/lib/typescript/module/view/morph-viewport.d.ts +4 -1
  86. package/lib/typescript/module/view/morph-viewport.d.ts.map +1 -1
  87. package/lib/typescript/module/view/number-run.d.ts +6 -2
  88. package/lib/typescript/module/view/number-run.d.ts.map +1 -1
  89. package/lib/typescript/module/view/slot-shadow.d.ts +11 -0
  90. package/lib/typescript/module/view/slot-shadow.d.ts.map +1 -0
  91. package/lib/typescript/module/view/slots-run.d.ts +8 -3
  92. package/lib/typescript/module/view/slots-run.d.ts.map +1 -1
  93. package/lib/typescript/module/view/text-run.d.ts.map +1 -1
  94. package/lib/typescript/module/view/ticker-run.d.ts +21 -0
  95. package/lib/typescript/module/view/ticker-run.d.ts.map +1 -0
  96. package/package.json +3 -1
  97. package/src/hooks/use-inline-auto-width.ts +33 -1
  98. package/src/hooks/use-leading-animation.ts +74 -0
  99. package/src/hooks/use-morph-motion.ts +10 -1
  100. package/src/index.tsx +44 -6
  101. package/src/motion/preset-map.ts +5 -0
  102. package/src/types.ts +17 -2
  103. package/src/view/leading-element.tsx +45 -0
  104. package/src/view/morph-viewport.tsx +39 -1
  105. package/src/view/number-run.tsx +94 -26
  106. package/src/view/slot-shadow.tsx +99 -0
  107. package/src/view/slots-run.tsx +163 -58
  108. package/src/view/text-run.tsx +14 -51
  109. package/src/view/ticker-run.tsx +167 -0
@@ -1,7 +1,10 @@
1
- import React, { useMemo, useRef } from "react";
1
+ import React, { useMemo, useRef, type ReactNode } from "react";
2
2
  import { type StyleProp, type TextStyle, View } from "react-native";
3
+ import Animated from "react-native-reanimated";
4
+ import { useLeadingAnimation } from "../hooks/use-leading-animation";
3
5
  import { useNumericLanes } from "../hooks/use-numeric-lanes";
4
6
  import type { LaminarAlign, MotionRecipe } from "../types";
7
+ import { LeadingElement } from "./leading-element";
5
8
  import { NumberLane } from "./number-lane";
6
9
 
7
10
  const rowStyle = {
@@ -24,6 +27,10 @@ type NumberRunProps = {
24
27
  readonly textStyle?: StyleProp<TextStyle>;
25
28
  readonly staggerMs: number;
26
29
  readonly className?: string;
30
+ readonly leading?: ReactNode;
31
+ readonly leadingKey?: string | number;
32
+ readonly leadingGap?: number;
33
+ readonly ready?: boolean;
27
34
  };
28
35
 
29
36
  // arrange numeric lanes from left to right while the hook preserves their identities
@@ -36,16 +43,59 @@ export const NumberRun = React.memo(
36
43
  textStyle,
37
44
  staggerMs,
38
45
  className,
46
+ leading,
47
+ leadingKey,
48
+ leadingGap = 0,
49
+ ready = true,
39
50
  }: Readonly<NumberRunProps>) => {
51
+ const hasDisplayedRef = useRef(false);
52
+ const displayedValueRef = useRef(value);
53
+ const displayedLeadingRef = useRef(leading);
54
+ const displayedLeadingKeyRef = useRef(leadingKey);
55
+ const displayedLeadingGapRef = useRef(leadingGap);
56
+ const shouldDisplayTarget = ready || !hasDisplayedRef.current;
57
+ const visibleValue = shouldDisplayTarget
58
+ ? value
59
+ : displayedValueRef.current;
60
+ const visibleLeading = shouldDisplayTarget
61
+ ? leading
62
+ : displayedLeadingRef.current;
63
+ const visibleLeadingKey = shouldDisplayTarget
64
+ ? leadingKey
65
+ : displayedLeadingKeyRef.current;
66
+ const visibleLeadingGap = shouldDisplayTarget
67
+ ? leadingGap
68
+ : displayedLeadingGapRef.current;
69
+
70
+ if (shouldDisplayTarget) {
71
+ hasDisplayedRef.current = true;
72
+ displayedValueRef.current = value;
73
+ displayedLeadingRef.current = leading;
74
+ displayedLeadingKeyRef.current = leadingKey;
75
+ displayedLeadingGapRef.current = leadingGap;
76
+ }
77
+
78
+ const {
79
+ elementEnterTransition,
80
+ elementExitTransition,
81
+ isLeadingChange,
82
+ isLeadingSwap,
83
+ } = useLeadingAnimation({
84
+ leading: visibleLeading,
85
+ leadingKey: visibleLeadingKey,
86
+ leadingGap: visibleLeadingGap,
87
+ motionRecipe,
88
+ });
40
89
  // the hook owns lane identity while this component owns row placement and timing
41
- const { units, laneKeys, direction, leadLength } = useNumericLanes(value);
42
- const lastValueRef = useRef(value);
90
+ const { units, laneKeys, direction, leadLength } =
91
+ useNumericLanes(visibleValue);
92
+ const lastValueRef = useRef(visibleValue);
43
93
  const hasAnimatedRef = useRef(false);
44
94
 
45
95
  // skip enter animations on first paint so the number feels settled
46
- if (value !== lastValueRef.current) {
96
+ if (visibleValue !== lastValueRef.current || isLeadingChange) {
47
97
  hasAnimatedRef.current = true;
48
- lastValueRef.current = value;
98
+ lastValueRef.current = visibleValue;
49
99
  }
50
100
 
51
101
  // scale travel from the font while keeping a minimum visible movement
@@ -57,28 +107,46 @@ export const NumberRun = React.memo(
57
107
 
58
108
  return (
59
109
  <View style={[rowStyle, rowAlignStyles[align]]}>
60
- {units.map((unit, index) => {
61
- const inLead = index < leadLength;
62
- const laneKey = inLead
63
- ? `lead:${index}`
64
- : `lane:${units.length - 1 - index}`;
110
+ <LeadingElement
111
+ leading={visibleLeading}
112
+ leadingKey={visibleLeadingKey}
113
+ leadingGap={visibleLeadingGap}
114
+ enterTransition={
115
+ hasAnimated ? motionRecipe.enterTransition : undefined
116
+ }
117
+ elementEnterTransition={
118
+ isLeadingSwap ? elementEnterTransition : undefined
119
+ }
120
+ exitTransition={motionRecipe.exitTransition}
121
+ elementExitTransition={elementExitTransition}
122
+ />
123
+ <Animated.View
124
+ layout={isLeadingChange ? motionRecipe.layoutTransition : undefined}
125
+ style={rowStyle}
126
+ >
127
+ {units.map((unit, index) => {
128
+ const inLead = index < leadLength;
129
+ const laneKey = inLead
130
+ ? `lead:${index}`
131
+ : `lane:${units.length - 1 - index}`;
65
132
 
66
- return (
67
- <NumberLane
68
- key={laneKey}
69
- unit={unit}
70
- tokenKey={laneKeys[index]}
71
- isLead={inLead}
72
- hasAnimated={hasAnimated}
73
- delayMs={index * staggerMs}
74
- direction={direction}
75
- travelDistance={travelDistance}
76
- motionRecipe={motionRecipe}
77
- textStyle={textStyle}
78
- className={className}
79
- />
80
- );
81
- })}
133
+ return (
134
+ <NumberLane
135
+ key={laneKey}
136
+ unit={unit}
137
+ tokenKey={laneKeys[index]}
138
+ isLead={inLead}
139
+ hasAnimated={hasAnimated}
140
+ delayMs={index * staggerMs}
141
+ direction={direction}
142
+ travelDistance={travelDistance}
143
+ motionRecipe={motionRecipe}
144
+ textStyle={textStyle}
145
+ className={className}
146
+ />
147
+ );
148
+ })}
149
+ </Animated.View>
82
150
  </View>
83
151
  );
84
152
  }
@@ -0,0 +1,99 @@
1
+ import React from "react";
2
+ import MaskedView from "@react-native-masked-view/masked-view";
3
+ import { LinearGradient } from "expo-linear-gradient";
4
+ import { StyleSheet } from "react-native";
5
+
6
+ const DEFAULT_SHADOW_COLOR = "#ffffff";
7
+ // The reference digit viewport fades across roughly one third of its visible row.
8
+ const DEFAULT_SHADOW_SIZE_RATIO = 0.28;
9
+
10
+ const transparentColor = (color: string) => {
11
+ const value = color.trim();
12
+
13
+ if (value.startsWith("#")) {
14
+ const hex = value.slice(1);
15
+ const expanded =
16
+ hex.length === 3 || hex.length === 4
17
+ ? hex
18
+ .slice(0, 3)
19
+ .split("")
20
+ .map((channel) => channel + channel)
21
+ .join("")
22
+ : hex.slice(0, 6);
23
+
24
+ if (expanded.length === 6 && /^[0-9a-f]+$/i.test(expanded)) {
25
+ return `rgba(${Number.parseInt(expanded.slice(0, 2), 16)}, ${Number.parseInt(
26
+ expanded.slice(2, 4),
27
+ 16
28
+ )}, ${Number.parseInt(expanded.slice(4, 6), 16)}, 0)`;
29
+ }
30
+ }
31
+
32
+ const channels = value.match(/\d+(?:\.\d+)?%?/g);
33
+ if ((value.startsWith("rgb(") || value.startsWith("rgba(")) && channels) {
34
+ const rgb = channels.slice(0, 3).map((channel) => {
35
+ if (channel.endsWith("%")) {
36
+ return Math.round((Number.parseFloat(channel) / 100) * 255);
37
+ }
38
+
39
+ return Number.parseFloat(channel);
40
+ });
41
+
42
+ if (rgb.length === 3 && rgb.every(Number.isFinite)) {
43
+ return `rgba(${rgb.join(", ")}, 0)`;
44
+ }
45
+ }
46
+
47
+ // Keep the fallback light so an unknown color never introduces a dark edge.
48
+ return "rgba(255, 255, 255, 0)";
49
+ };
50
+
51
+ type SlotShadowProps = {
52
+ readonly slotHeight: number;
53
+ readonly viewportHeight: number;
54
+ readonly color?: string;
55
+ readonly size?: number;
56
+ readonly children: React.ReactElement;
57
+ };
58
+
59
+ // Keep the mask mounted for the lifetime of the viewport. The gradient controls
60
+ // content alpha at the clipping edges instead of painting over the glyphs.
61
+ export const SlotShadow = React.memo(function SlotShadow({
62
+ slotHeight,
63
+ viewportHeight,
64
+ color = DEFAULT_SHADOW_COLOR,
65
+ size,
66
+ children,
67
+ }: SlotShadowProps) {
68
+ const edgeSize = Math.min(
69
+ Math.max(4, size ?? slotHeight * DEFAULT_SHADOW_SIZE_RATIO),
70
+ viewportHeight / 2
71
+ );
72
+ const fadeColor = transparentColor(color);
73
+ const fadeRatio = edgeSize / viewportHeight;
74
+
75
+ return (
76
+ <MaskedView
77
+ pointerEvents="none"
78
+ style={styles.container}
79
+ maskElement={
80
+ <LinearGradient
81
+ colors={[fadeColor, color, color, fadeColor]}
82
+ locations={[0, fadeRatio, 1 - fadeRatio, 1]}
83
+ start={{ x: 0, y: 0 }}
84
+ end={{ x: 0, y: 1 }}
85
+ style={styles.mask}
86
+ />
87
+ }
88
+ >
89
+ {children}
90
+ </MaskedView>
91
+ );
92
+ });
93
+
94
+ SlotShadow.displayName = "SlotShadow";
95
+
96
+ const styles = StyleSheet.create({
97
+ container: StyleSheet.absoluteFillObject,
98
+ mask: StyleSheet.absoluteFillObject,
99
+ });
@@ -1,4 +1,4 @@
1
- import React, { useEffect, useMemo, useRef } from "react";
1
+ import React, { useEffect, useMemo, useRef, type ReactNode } from "react";
2
2
  import {
3
3
  StyleSheet,
4
4
  Text,
@@ -11,9 +11,17 @@ import Animated, {
11
11
  useAnimatedStyle,
12
12
  useSharedValue,
13
13
  } from "react-native-reanimated";
14
+ import { useLeadingAnimation } from "../hooks/use-leading-animation";
14
15
  import { useNumericLanes } from "../hooks/use-numeric-lanes";
15
16
  import { isAsciiDigit } from "../model/display-units";
16
- import type { LaminarAlign, MotionRecipe, NumericFlowDirection } from "../types";
17
+ import type {
18
+ LaminarAlign,
19
+ LaminarShadow,
20
+ MotionRecipe,
21
+ NumericFlowDirection,
22
+ } from "../types";
23
+ import { LeadingElement } from "./leading-element";
24
+ import { SlotShadow } from "./slot-shadow";
17
25
 
18
26
  const rowStyle = {
19
27
  flexDirection: "row",
@@ -64,7 +72,7 @@ const equalStyleValue = (left: unknown, right: unknown) =>
64
72
  left.length === right.length &&
65
73
  left.every((value, index) => value === right[index]));
66
74
 
67
- // prevent every parent update from rebuilding the thirty-text reel
75
+ // prevent every parent update from rebuilding the bounded reel
68
76
  const areSlotReelPropsEqual = (
69
77
  previous: SlotReelProps,
70
78
  next: SlotReelProps
@@ -151,6 +159,7 @@ type SlotColumnProps = {
151
159
  readonly slotHeight: number;
152
160
  readonly textStyle?: StyleProp<TextStyle>;
153
161
  readonly className?: string;
162
+ readonly shadow?: LaminarShadow;
154
163
  };
155
164
 
156
165
  function SlotColumn({
@@ -162,6 +171,7 @@ function SlotColumn({
162
171
  slotHeight,
163
172
  textStyle,
164
173
  className,
174
+ shadow = false,
165
175
  }: SlotColumnProps) {
166
176
  // cumulative positions let the reel move across digit wraparound without jumps
167
177
  const spinInDistance = Math.max(digit, 1);
@@ -173,13 +183,19 @@ function SlotColumn({
173
183
  const cumulativeRef = useRef(digit);
174
184
  const previousDigitRef = useRef(digit);
175
185
  const initialRef = useRef(true);
186
+ const drivenTargetRef = useRef<number | null>(null);
176
187
  const exitStateRef = useRef({
177
188
  digit,
178
189
  direction,
179
190
  motionRecipe,
180
191
  target: digit,
181
192
  });
182
-
193
+ const shadowOptions =
194
+ shadow !== null && typeof shadow === "object"
195
+ ? shadow
196
+ : shadow
197
+ ? {}
198
+ : undefined;
183
199
  if (digit !== previousDigitRef.current) {
184
200
  const previousDigit = previousDigitRef.current;
185
201
  let digitDelta: number;
@@ -228,6 +244,7 @@ function SlotColumn({
228
244
  initialRef.current = false;
229
245
 
230
246
  if (!animateIn) {
247
+ drivenTargetRef.current = cumulativeRef.current;
231
248
  return;
232
249
  }
233
250
  }
@@ -251,23 +268,46 @@ function SlotColumn({
251
268
  target,
252
269
  };
253
270
 
271
+ // A direction change can rerender every column even when this column's
272
+ // digit is unchanged. Keep the idle reel untouched in that case.
273
+ if (drivenTargetRef.current === target) {
274
+ return;
275
+ }
276
+
277
+ drivenTargetRef.current = target;
278
+
254
279
  current.value = motionRecipe.driveNumber(target, delayMs);
255
280
  }, [animateIn, current, delayMs, digit, direction, motionRecipe]);
256
281
 
282
+ const reel = (
283
+ <SlotReel
284
+ current={current}
285
+ slotHeight={slotHeight}
286
+ textStyle={textStyle}
287
+ className={className}
288
+ />
289
+ );
290
+
257
291
  return (
258
- <View style={slotFrameStyle}>
292
+ <View style={[slotFrameStyle, { height: slotHeight }]}>
259
293
  <Text
260
294
  style={[textStyle, slotProbeStyle, { lineHeight: slotHeight }]}
261
295
  className={className}
262
296
  >
263
297
  0
264
298
  </Text>
265
- <SlotReel
266
- current={current}
267
- slotHeight={slotHeight}
268
- textStyle={textStyle}
269
- className={className}
270
- />
299
+ {shadowOptions ? (
300
+ <SlotShadow
301
+ slotHeight={slotHeight}
302
+ viewportHeight={slotHeight}
303
+ color={shadowOptions.color}
304
+ size={shadowOptions.size}
305
+ >
306
+ {reel}
307
+ </SlotShadow>
308
+ ) : (
309
+ reel
310
+ )}
271
311
  </View>
272
312
  );
273
313
  }
@@ -280,6 +320,11 @@ type SlotsRunProps = {
280
320
  readonly textStyle?: StyleProp<TextStyle>;
281
321
  readonly staggerMs: number;
282
322
  readonly className?: string;
323
+ readonly leading?: ReactNode;
324
+ readonly leadingKey?: string | number;
325
+ readonly leadingGap?: number;
326
+ readonly ready?: boolean;
327
+ readonly shadow?: LaminarShadow;
283
328
  };
284
329
 
285
330
  // render text prefixes beside reusable digit reels
@@ -292,10 +337,53 @@ export const SlotsRun = React.memo(
292
337
  textStyle,
293
338
  staggerMs,
294
339
  className,
340
+ leading,
341
+ leadingKey,
342
+ leadingGap = 0,
343
+ ready = true,
344
+ shadow = false,
295
345
  }: Readonly<SlotsRunProps>) => {
346
+ const hasDisplayedRef = useRef(false);
347
+ const displayedValueRef = useRef(value);
348
+ const displayedLeadingRef = useRef(leading);
349
+ const displayedLeadingKeyRef = useRef(leadingKey);
350
+ const displayedLeadingGapRef = useRef(leadingGap);
351
+ const shouldDisplayTarget = ready || !hasDisplayedRef.current;
352
+ const visibleValue = shouldDisplayTarget
353
+ ? value
354
+ : displayedValueRef.current;
355
+ const visibleLeading = shouldDisplayTarget
356
+ ? leading
357
+ : displayedLeadingRef.current;
358
+ const visibleLeadingKey = shouldDisplayTarget
359
+ ? leadingKey
360
+ : displayedLeadingKeyRef.current;
361
+ const visibleLeadingGap = shouldDisplayTarget
362
+ ? leadingGap
363
+ : displayedLeadingGapRef.current;
364
+
365
+ if (shouldDisplayTarget) {
366
+ hasDisplayedRef.current = true;
367
+ displayedValueRef.current = value;
368
+ displayedLeadingRef.current = leading;
369
+ displayedLeadingKeyRef.current = leadingKey;
370
+ displayedLeadingGapRef.current = leadingGap;
371
+ }
372
+
373
+ const {
374
+ elementEnterTransition,
375
+ elementExitTransition,
376
+ isLeadingChange,
377
+ isLeadingSwap,
378
+ } = useLeadingAnimation({
379
+ leading: visibleLeading,
380
+ leadingKey: visibleLeadingKey,
381
+ leadingGap: visibleLeadingGap,
382
+ motionRecipe,
383
+ });
296
384
  // numeric lane identity decides which columns stay mounted as values change
297
- const { units, direction, leadLength } = useNumericLanes(value);
298
- const lastValueRef = useRef(value);
385
+ const { units, direction, leadLength } = useNumericLanes(visibleValue);
386
+ const lastValueRef = useRef(visibleValue);
299
387
  const hasAnimatedRef = useRef(false);
300
388
  // derive one stable row height so every reel position shares the same baseline
301
389
  const slotHeight = useMemo(() => {
@@ -315,9 +403,9 @@ export const SlotsRun = React.memo(
315
403
  );
316
404
  }, [fontSize, textStyle]);
317
405
 
318
- if (value !== lastValueRef.current) {
406
+ if (visibleValue !== lastValueRef.current || isLeadingChange) {
319
407
  hasAnimatedRef.current = true;
320
- lastValueRef.current = value;
408
+ lastValueRef.current = visibleValue;
321
409
  }
322
410
 
323
411
  const digitCount = units.filter(isAsciiDigit).length;
@@ -326,57 +414,74 @@ export const SlotsRun = React.memo(
326
414
 
327
415
  return (
328
416
  <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}`;
417
+ <LeadingElement
418
+ leading={visibleLeading}
419
+ leadingKey={visibleLeadingKey}
420
+ leadingGap={visibleLeadingGap}
421
+ enterTransition={
422
+ hasAnimated ? motionRecipe.enterTransition : undefined
423
+ }
424
+ elementEnterTransition={
425
+ isLeadingSwap ? elementEnterTransition : undefined
426
+ }
427
+ exitTransition={motionRecipe.exitTransition}
428
+ elementExitTransition={elementExitTransition}
429
+ />
430
+ <Animated.View
431
+ layout={isLeadingChange ? motionRecipe.layoutTransition : undefined}
432
+ style={rowStyle}
433
+ >
434
+ {units.map((unit, index) => {
435
+ const inLead = index < leadLength;
436
+ const laneKey = inLead
437
+ ? `lead:${index}`
438
+ : `slot:${units.length - 1 - index}`;
439
+
440
+ if (inLead || !isAsciiDigit(unit)) {
441
+ return (
442
+ <Animated.Text
443
+ key={laneKey}
444
+ layout={
445
+ hasAnimated ? motionRecipe.layoutTransition : undefined
446
+ }
447
+ exiting={!inLead ? motionRecipe.exitTransition : undefined}
448
+ style={textStyle}
449
+ className={className}
450
+ >
451
+ {unit}
452
+ </Animated.Text>
453
+ );
454
+ }
455
+
456
+ const delayMs = (digitCount - 1 - digitIndex) * staggerMs;
457
+ digitIndex += 1;
334
458
 
335
- if (inLead || !isAsciiDigit(unit)) {
336
459
  return (
337
- <Animated.Text
460
+ <Animated.View
338
461
  key={laneKey}
339
462
  layout={
340
463
  hasAnimated ? motionRecipe.layoutTransition : undefined
341
464
  }
342
- exiting={!inLead ? motionRecipe.exitTransition : undefined}
343
- style={textStyle}
344
- className={className}
465
+ entering={
466
+ hasAnimated ? motionRecipe.enterTransition : undefined
467
+ }
468
+ exiting={motionRecipe.exitTransition}
345
469
  >
346
- {unit}
347
- </Animated.Text>
470
+ <SlotColumn
471
+ digit={Number(unit)}
472
+ direction={direction}
473
+ delayMs={delayMs}
474
+ animateIn={hasAnimated}
475
+ motionRecipe={motionRecipe}
476
+ slotHeight={slotHeight}
477
+ textStyle={textStyle}
478
+ className={className}
479
+ shadow={shadow}
480
+ />
481
+ </Animated.View>
348
482
  );
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
- })}
483
+ })}
484
+ </Animated.View>
380
485
  </View>
381
486
  );
382
487
  }
@@ -1,12 +1,8 @@
1
- import React, { useId, useLayoutEffect, useMemo, useRef } from "react";
1
+ import React, { useId, useRef } from "react";
2
2
  import type { ReactNode } from "react";
3
3
  import type { StyleProp, TextStyle } from "react-native";
4
- import { useSharedValue } from "react-native-reanimated";
5
4
  import { useTextGlyphs } from "../hooks/use-text-glyphs";
6
- import {
7
- createLeadingEnterTransition,
8
- createLeadingExitTransition,
9
- } from "../motion/entry-exit-builders";
5
+ import { useLeadingAnimation } from "../hooks/use-leading-animation";
10
6
  import type { LaminarAlign, MotionRecipe } from "../types";
11
7
  import { GlyphRun } from "./glyph-run";
12
8
 
@@ -67,62 +63,29 @@ export const TextRun = React.memo(
67
63
  }
68
64
 
69
65
  const lastValueRef = useRef(visibleValue);
70
- const lastLeadingPresenceRef = useRef(Boolean(visibleLeading));
71
- const lastLeadingKeyRef = useRef(visibleLeadingKey);
72
66
  const hasAnimatedRef = useRef(false);
73
- const isLeadingSwap =
74
- Boolean(visibleLeading) &&
75
- lastLeadingPresenceRef.current &&
76
- visibleLeadingKey !== lastLeadingKeyRef.current;
77
- const isLeadingChange =
78
- Boolean(visibleLeading) !== lastLeadingPresenceRef.current ||
79
- (Boolean(visibleLeading) &&
80
- visibleLeadingKey !== lastLeadingKeyRef.current);
81
- // update the worklet flag after commit so render never writes a shared value
82
- const leadingSwapProgress = useSharedValue(0);
83
- useLayoutEffect(() => {
84
- leadingSwapProgress.value = isLeadingSwap ? 1 : 0;
85
- }, [isLeadingSwap, leadingSwapProgress]);
67
+ const {
68
+ elementEnterTransition,
69
+ elementExitTransition,
70
+ isLeadingChange,
71
+ isLeadingSwap,
72
+ } = useLeadingAnimation({
73
+ leading: visibleLeading,
74
+ leadingKey: visibleLeadingKey,
75
+ leadingGap: visibleLeadingGap,
76
+ motionRecipe,
77
+ });
86
78
  const glyphs = useTextGlyphs(
87
79
  visibleValue,
88
80
  scopeId,
89
81
  visibleLeading,
90
82
  visibleLeadingKey
91
83
  );
92
- const elementEnterTransition = useMemo(
93
- () =>
94
- createLeadingEnterTransition({
95
- durationMs: motionRecipe.durationMs,
96
- easing: motionRecipe.easing,
97
- }),
98
- [motionRecipe.durationMs, motionRecipe.easing]
99
- );
100
- const elementExitTransition = useMemo(
101
- () =>
102
- createLeadingExitTransition({
103
- durationMs: motionRecipe.durationMs,
104
- easing: motionRecipe.easing,
105
- leadingGap: visibleLeadingGap,
106
- scale: leadingSwapProgress,
107
- }),
108
- [
109
- visibleLeadingGap,
110
- leadingSwapProgress,
111
- motionRecipe.durationMs,
112
- motionRecipe.easing,
113
- ]
114
- );
115
84
 
116
85
  // mark the first value as settled and later changes as eligible for motion
117
- if (
118
- visibleValue !== lastValueRef.current ||
119
- Boolean(visibleLeading) !== lastLeadingPresenceRef.current ||
120
- visibleLeadingKey !== lastLeadingKeyRef.current
121
- ) {
86
+ if (visibleValue !== lastValueRef.current || isLeadingChange) {
122
87
  hasAnimatedRef.current = true;
123
88
  lastValueRef.current = visibleValue;
124
- lastLeadingPresenceRef.current = Boolean(visibleLeading);
125
- lastLeadingKeyRef.current = visibleLeadingKey;
126
89
  }
127
90
 
128
91
  const hasAnimated = hasAnimatedRef.current;