react-native-laminar 1.1.0 → 1.4.1

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 (151) hide show
  1. package/README.md +47 -3
  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 +58 -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 +34 -3
  27. package/lib/commonjs/view/glyph-run.js.map +1 -1
  28. package/lib/commonjs/view/morph-viewport.js +33 -3
  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 +17 -1
  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 +37 -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 +60 -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 +34 -3
  63. package/lib/module/view/glyph-run.js.map +1 -1
  64. package/lib/module/view/morph-viewport.js +33 -3
  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 +17 -1
  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 +38 -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 +14 -2
  91. package/lib/typescript/commonjs/types.d.ts.map +1 -1
  92. package/lib/typescript/commonjs/view/glyph-run.d.ts +7 -3
  93. package/lib/typescript/commonjs/view/glyph-run.d.ts.map +1 -1
  94. package/lib/typescript/commonjs/view/morph-viewport.d.ts +3 -1
  95. package/lib/typescript/commonjs/view/morph-viewport.d.ts.map +1 -1
  96. package/lib/typescript/commonjs/view/number-lane.d.ts.map +1 -1
  97. package/lib/typescript/commonjs/view/number-run.d.ts +3 -2
  98. package/lib/typescript/commonjs/view/number-run.d.ts.map +1 -1
  99. package/lib/typescript/commonjs/view/slots-run.d.ts +15 -0
  100. package/lib/typescript/commonjs/view/slots-run.d.ts.map +1 -0
  101. package/lib/typescript/commonjs/view/text-run.d.ts +7 -2
  102. package/lib/typescript/commonjs/view/text-run.d.ts.map +1 -1
  103. package/lib/typescript/module/hooks/use-display-units.d.ts.map +1 -1
  104. package/lib/typescript/module/hooks/use-inline-auto-width.d.ts +3 -1
  105. package/lib/typescript/module/hooks/use-inline-auto-width.d.ts.map +1 -1
  106. package/lib/typescript/module/hooks/use-morph-motion.d.ts.map +1 -1
  107. package/lib/typescript/module/hooks/use-morph-text-style.d.ts.map +1 -1
  108. package/lib/typescript/module/hooks/use-numeric-lanes.d.ts.map +1 -1
  109. package/lib/typescript/module/hooks/use-text-glyphs.d.ts +2 -1
  110. package/lib/typescript/module/hooks/use-text-glyphs.d.ts.map +1 -1
  111. package/lib/typescript/module/index.d.ts +1 -3
  112. package/lib/typescript/module/index.d.ts.map +1 -1
  113. package/lib/typescript/module/model/display-units.d.ts.map +1 -1
  114. package/lib/typescript/module/model/numeric-lanes.d.ts.map +1 -1
  115. package/lib/typescript/module/model/text-keys.d.ts.map +1 -1
  116. package/lib/typescript/module/motion/entry-exit-builders.d.ts +13 -0
  117. package/lib/typescript/module/motion/entry-exit-builders.d.ts.map +1 -1
  118. package/lib/typescript/module/motion/preset-map.d.ts.map +1 -1
  119. package/lib/typescript/module/types.d.ts +14 -2
  120. package/lib/typescript/module/types.d.ts.map +1 -1
  121. package/lib/typescript/module/view/glyph-run.d.ts +7 -3
  122. package/lib/typescript/module/view/glyph-run.d.ts.map +1 -1
  123. package/lib/typescript/module/view/morph-viewport.d.ts +3 -1
  124. package/lib/typescript/module/view/morph-viewport.d.ts.map +1 -1
  125. package/lib/typescript/module/view/number-lane.d.ts.map +1 -1
  126. package/lib/typescript/module/view/number-run.d.ts +3 -2
  127. package/lib/typescript/module/view/number-run.d.ts.map +1 -1
  128. package/lib/typescript/module/view/slots-run.d.ts +15 -0
  129. package/lib/typescript/module/view/slots-run.d.ts.map +1 -0
  130. package/lib/typescript/module/view/text-run.d.ts +7 -2
  131. package/lib/typescript/module/view/text-run.d.ts.map +1 -1
  132. package/package.json +11 -2
  133. package/src/hooks/use-display-units.ts +1 -0
  134. package/src/hooks/use-inline-auto-width.ts +55 -12
  135. package/src/hooks/use-morph-motion.ts +2 -1
  136. package/src/hooks/use-morph-text-style.ts +3 -0
  137. package/src/hooks/use-numeric-lanes.ts +1 -0
  138. package/src/hooks/use-text-glyphs.ts +48 -10
  139. package/src/index.tsx +103 -17
  140. package/src/model/display-units.ts +4 -1
  141. package/src/model/numeric-lanes.ts +3 -0
  142. package/src/model/text-keys.ts +3 -0
  143. package/src/motion/entry-exit-builders.ts +73 -0
  144. package/src/motion/preset-map.ts +4 -0
  145. package/src/types.ts +16 -2
  146. package/src/view/glyph-run.tsx +48 -15
  147. package/src/view/morph-viewport.tsx +28 -3
  148. package/src/view/number-lane.tsx +7 -2
  149. package/src/view/number-run.tsx +13 -2
  150. package/src/view/slots-run.tsx +385 -0
  151. package/src/view/text-run.tsx +80 -7
@@ -2,6 +2,7 @@ import React, { useMemo } from "react";
2
2
  import type { StyleProp, ViewStyle } from "react-native";
3
3
  import { View } from "react-native";
4
4
  import Animated from "react-native-reanimated";
5
+ import type { LaminarAlign } from "../types";
5
6
 
6
7
  const shellStyle = {
7
8
  position: "relative",
@@ -29,34 +30,56 @@ const unclippedViewportStyle: ViewStyle = {
29
30
  overflow: "visible",
30
31
  };
31
32
 
33
+ const fullWidthViewportStyle: ViewStyle = {
34
+ width: "100%",
35
+ };
36
+
37
+ const shellAlignStyles: Record<LaminarAlign, ViewStyle> = {
38
+ left: { alignSelf: "flex-start" },
39
+ center: { alignSelf: "center" },
40
+ right: { alignSelf: "flex-end" },
41
+ };
42
+
43
+ const viewportAlignStyles: Record<LaminarAlign, ViewStyle> = {
44
+ left: { alignItems: "flex-start" },
45
+ center: { alignItems: "center" },
46
+ right: { alignItems: "flex-end" },
47
+ };
48
+
32
49
  type MorphViewportProps = {
33
50
  readonly autoSize: boolean;
34
51
  readonly clipToBounds: boolean;
52
+ readonly align: LaminarAlign;
35
53
  readonly containerStyle?: StyleProp<ViewStyle>;
36
54
  readonly animatedWidthStyle?: React.ComponentProps<typeof Animated.View>["style"];
37
55
  readonly measurement?: React.ReactNode;
38
56
  readonly children: React.ReactNode;
39
57
  };
40
58
 
59
+ // separate hidden measurement from visible content while sharing the shell alignment
41
60
  export const MorphViewport = React.memo(
42
61
  ({
43
62
  autoSize,
44
63
  clipToBounds,
64
+ align,
45
65
  containerStyle,
46
66
  animatedWidthStyle,
47
67
  measurement,
48
68
  children,
49
69
  }: MorphViewportProps) => {
70
+ // compute alignment once so the animated shell and its child share one layout contract
50
71
  const resolvedViewportStyle = useMemo(
51
72
  () => [
52
73
  viewportStyle,
74
+ viewportAlignStyles[align],
53
75
  clipToBounds ? clippedViewportStyle : unclippedViewportStyle,
54
76
  ],
55
- [clipToBounds]
77
+ [align, clipToBounds]
56
78
  );
57
79
 
80
+ // keep measurement outside the visible viewport so it cannot affect accessibility or layout
58
81
  return (
59
- <View style={[shellStyle, containerStyle]}>
82
+ <View style={[shellStyle, shellAlignStyles[align], containerStyle]}>
60
83
  {autoSize ? (
61
84
  <>
62
85
  <View
@@ -73,7 +96,9 @@ export const MorphViewport = React.memo(
73
96
  </Animated.View>
74
97
  </>
75
98
  ) : (
76
- <View style={resolvedViewportStyle}>{children}</View>
99
+ <View style={[resolvedViewportStyle, fullWidthViewportStyle]}>
100
+ {children}
101
+ </View>
77
102
  )}
78
103
  </View>
79
104
  );
@@ -33,6 +33,7 @@ type NumberLaneProps = {
33
33
  readonly className?: string;
34
34
  };
35
35
 
36
+ // render one place-value lane with a hidden layout probe and an animated token
36
37
  export const NumberLane = React.memo(
37
38
  ({
38
39
  unit,
@@ -46,6 +47,7 @@ export const NumberLane = React.memo(
46
47
  textStyle,
47
48
  className,
48
49
  }: NumberLaneProps) => {
50
+ // punctuation stays in place while digits travel by the shared numeric direction
49
51
  const usesDigitTravel = isAsciiDigit(unit);
50
52
  const verticalOffset =
51
53
  direction > 0
@@ -98,14 +100,17 @@ export const NumberLane = React.memo(
98
100
  ]
99
101
  );
100
102
 
103
+ // prefix units do not need an absolute lane or a duplicate probe
101
104
  if (isLead) {
102
105
  return <Text style={textStyle} className={className}>{unit}</Text>;
103
106
  }
104
107
 
105
108
  return (
106
109
  <Animated.View
107
- layout={motionRecipe.layoutTransition}
108
- entering={hasAnimated ? motionRecipe.enterTransition : undefined}
110
+ layout={hasAnimated ? motionRecipe.layoutTransition : undefined}
111
+ entering={
112
+ hasAnimated ? motionRecipe.enterTransition : undefined
113
+ }
109
114
  exiting={motionRecipe.exitTransition}
110
115
  style={laneStyle}
111
116
  >
@@ -1,7 +1,7 @@
1
1
  import React, { useMemo, useRef } from "react";
2
2
  import { type StyleProp, type TextStyle, View } from "react-native";
3
3
  import { useNumericLanes } from "../hooks/use-numeric-lanes";
4
- import type { MotionRecipe } from "../types";
4
+ import type { LaminarAlign, MotionRecipe } from "../types";
5
5
  import { NumberLane } from "./number-lane";
6
6
 
7
7
  const rowStyle = {
@@ -10,24 +10,34 @@ const rowStyle = {
10
10
  alignSelf: "flex-start",
11
11
  } as const;
12
12
 
13
+ const rowAlignStyles = {
14
+ left: { alignSelf: "flex-start" },
15
+ center: { alignSelf: "center" },
16
+ right: { alignSelf: "flex-end" },
17
+ } as const;
18
+
13
19
  type NumberRunProps = {
14
20
  readonly value: string;
15
21
  readonly motionRecipe: MotionRecipe;
22
+ readonly align: LaminarAlign;
16
23
  readonly fontSize?: number;
17
24
  readonly textStyle?: StyleProp<TextStyle>;
18
25
  readonly staggerMs: number;
19
26
  readonly className?: string;
20
27
  };
21
28
 
29
+ // arrange numeric lanes from left to right while the hook preserves their identities
22
30
  export const NumberRun = React.memo(
23
31
  ({
24
32
  value,
25
33
  motionRecipe,
34
+ align,
26
35
  fontSize,
27
36
  textStyle,
28
37
  staggerMs,
29
38
  className,
30
39
  }: Readonly<NumberRunProps>) => {
40
+ // the hook owns lane identity while this component owns row placement and timing
31
41
  const { units, laneKeys, direction, leadLength } = useNumericLanes(value);
32
42
  const lastValueRef = useRef(value);
33
43
  const hasAnimatedRef = useRef(false);
@@ -38,6 +48,7 @@ export const NumberRun = React.memo(
38
48
  lastValueRef.current = value;
39
49
  }
40
50
 
51
+ // scale travel from the font while keeping a minimum visible movement
41
52
  const travelDistance = useMemo(
42
53
  () => Math.max(8, Math.round((fontSize ?? 16) * 0.4)),
43
54
  [fontSize]
@@ -45,7 +56,7 @@ export const NumberRun = React.memo(
45
56
  const hasAnimated = hasAnimatedRef.current;
46
57
 
47
58
  return (
48
- <View style={rowStyle}>
59
+ <View style={[rowStyle, rowAlignStyles[align]]}>
49
60
  {units.map((unit, index) => {
50
61
  const inLead = index < leadLength;
51
62
  const laneKey = inLead
@@ -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,37 +1,110 @@
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";
4
- import type { MotionRecipe } from "../types";
6
+ import {
7
+ createLeadingEnterTransition,
8
+ createLeadingExitTransition,
9
+ } from "../motion/entry-exit-builders";
10
+ import type { LaminarAlign, MotionRecipe } from "../types";
5
11
  import { GlyphRun } from "./glyph-run";
6
12
 
7
13
  type TextRunProps = {
8
14
  readonly value: string;
9
15
  readonly motionRecipe: MotionRecipe;
16
+ readonly align: LaminarAlign;
17
+ readonly leading?: ReactNode;
18
+ readonly leadingKey?: string | number;
19
+ readonly leadingGap?: number;
10
20
  readonly textStyle?: StyleProp<TextStyle>;
11
21
  readonly className?: string;
12
22
  };
13
23
 
24
+ // connect text identity, leading transitions, and glyph layout into one run
14
25
  export const TextRun = React.memo(
15
- ({ value, motionRecipe, textStyle, className }: TextRunProps) => {
26
+ ({
27
+ value,
28
+ motionRecipe,
29
+ align,
30
+ leading,
31
+ leadingKey,
32
+ leadingGap = 0,
33
+ textStyle,
34
+ className,
35
+ }: TextRunProps) => {
16
36
  // namespace ids per instance so repeated strings do not collide
17
37
  const scopeId = useId();
18
- const glyphs = useTextGlyphs(value, scopeId);
19
38
  const lastValueRef = useRef(value);
39
+ const lastLeadingPresenceRef = useRef(Boolean(leading));
40
+ const lastLeadingKeyRef = useRef(leadingKey);
20
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
+ );
21
75
 
22
- 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
+ ) {
23
82
  hasAnimatedRef.current = true;
24
83
  lastValueRef.current = value;
84
+ lastLeadingPresenceRef.current = Boolean(leading);
85
+ lastLeadingKeyRef.current = leadingKey;
25
86
  }
26
87
 
88
+ const hasAnimated = hasAnimatedRef.current;
89
+
27
90
  return (
28
91
  <GlyphRun
29
92
  glyphs={glyphs}
30
- layoutTransition={motionRecipe.layoutTransition}
93
+ layoutTransition={
94
+ hasAnimated ? motionRecipe.layoutTransition : undefined
95
+ }
31
96
  enterTransition={
32
- hasAnimatedRef.current ? motionRecipe.enterTransition : undefined
97
+ hasAnimatedRef.current
98
+ ? motionRecipe.enterTransition
99
+ : undefined
100
+ }
101
+ elementEnterTransition={
102
+ isLeadingSwap ? elementEnterTransition : undefined
33
103
  }
34
104
  exitTransition={motionRecipe.exitTransition}
105
+ elementExitTransition={elementExitTransition}
106
+ leadingGap={leadingGap}
107
+ align={align}
35
108
  textStyle={textStyle}
36
109
  className={className}
37
110
  />