number-flow-react-native 0.1.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 (245) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +44 -0
  3. package/lib/module/core/constants.js +21 -0
  4. package/lib/module/core/constants.js.map +1 -0
  5. package/lib/module/core/intlHelpers.js +310 -0
  6. package/lib/module/core/intlHelpers.js.map +1 -0
  7. package/lib/module/core/layout.js +71 -0
  8. package/lib/module/core/layout.js.map +1 -0
  9. package/lib/module/core/mask.js +50 -0
  10. package/lib/module/core/mask.js.map +1 -0
  11. package/lib/module/core/numerals/detection.js +105 -0
  12. package/lib/module/core/numerals/detection.js.map +1 -0
  13. package/lib/module/core/numerals/digits.js +128 -0
  14. package/lib/module/core/numerals/digits.js.map +1 -0
  15. package/lib/module/core/numerals/index.js +5 -0
  16. package/lib/module/core/numerals/index.js.map +1 -0
  17. package/lib/module/core/numerals/tables.js +114 -0
  18. package/lib/module/core/numerals/tables.js.map +1 -0
  19. package/lib/module/core/superscript.js +31 -0
  20. package/lib/module/core/superscript.js.map +1 -0
  21. package/lib/module/core/timeLayout.js +98 -0
  22. package/lib/module/core/timeLayout.js.map +1 -0
  23. package/lib/module/core/timeTypes.js +4 -0
  24. package/lib/module/core/timeTypes.js.map +1 -0
  25. package/lib/module/core/timing.js +45 -0
  26. package/lib/module/core/timing.js.map +1 -0
  27. package/lib/module/core/types.js +58 -0
  28. package/lib/module/core/types.js.map +1 -0
  29. package/lib/module/core/useAccessibilityAnnouncement.js +27 -0
  30. package/lib/module/core/useAccessibilityAnnouncement.js.map +1 -0
  31. package/lib/module/core/useAnimatedX.js +25 -0
  32. package/lib/module/core/useAnimatedX.js.map +1 -0
  33. package/lib/module/core/useAnimationLifecycle.js +37 -0
  34. package/lib/module/core/useAnimationLifecycle.js.map +1 -0
  35. package/lib/module/core/useCanAnimate.js +22 -0
  36. package/lib/module/core/useCanAnimate.js.map +1 -0
  37. package/lib/module/core/useContinuousSpin.js +89 -0
  38. package/lib/module/core/useContinuousSpin.js.map +1 -0
  39. package/lib/module/core/useDebouncedWidths.js +74 -0
  40. package/lib/module/core/useDebouncedWidths.js.map +1 -0
  41. package/lib/module/core/useDigitAnimation.js +138 -0
  42. package/lib/module/core/useDigitAnimation.js.map +1 -0
  43. package/lib/module/core/useFlowPipeline.js +85 -0
  44. package/lib/module/core/useFlowPipeline.js.map +1 -0
  45. package/lib/module/core/useFormattedValue.js +28 -0
  46. package/lib/module/core/useFormattedValue.js.map +1 -0
  47. package/lib/module/core/useLayoutDiff.js +59 -0
  48. package/lib/module/core/useLayoutDiff.js.map +1 -0
  49. package/lib/module/core/useNumberFormatting.js +158 -0
  50. package/lib/module/core/useNumberFormatting.js.map +1 -0
  51. package/lib/module/core/useSlotOpacity.js +53 -0
  52. package/lib/module/core/useSlotOpacity.js.map +1 -0
  53. package/lib/module/core/useTimeFormatting.js +74 -0
  54. package/lib/module/core/useTimeFormatting.js.map +1 -0
  55. package/lib/module/core/useTimingResolution.js +21 -0
  56. package/lib/module/core/useTimingResolution.js.map +1 -0
  57. package/lib/module/core/useWorkletFormatting.js +49 -0
  58. package/lib/module/core/useWorkletFormatting.js.map +1 -0
  59. package/lib/module/core/utils.js +132 -0
  60. package/lib/module/core/utils.js.map +1 -0
  61. package/lib/module/core/warnings.js +10 -0
  62. package/lib/module/core/warnings.js.map +1 -0
  63. package/lib/module/index.js +7 -0
  64. package/lib/module/index.js.map +1 -0
  65. package/lib/module/native/DigitSlot.js +163 -0
  66. package/lib/module/native/DigitSlot.js.map +1 -0
  67. package/lib/module/native/NumberFlow.js +244 -0
  68. package/lib/module/native/NumberFlow.js.map +1 -0
  69. package/lib/module/native/SymbolSlot.js +52 -0
  70. package/lib/module/native/SymbolSlot.js.map +1 -0
  71. package/lib/module/native/TimeFlow.js +270 -0
  72. package/lib/module/native/TimeFlow.js.map +1 -0
  73. package/lib/module/native/index.js +5 -0
  74. package/lib/module/native/index.js.map +1 -0
  75. package/lib/module/native/renderSlots.js +108 -0
  76. package/lib/module/native/renderSlots.js.map +1 -0
  77. package/lib/module/native/types.js +4 -0
  78. package/lib/module/native/types.js.map +1 -0
  79. package/lib/module/native/useMeasuredGlyphMetrics.js +156 -0
  80. package/lib/module/native/useMeasuredGlyphMetrics.js.map +1 -0
  81. package/lib/module/package.json +1 -0
  82. package/lib/module/skia/DigitSlot.js +171 -0
  83. package/lib/module/skia/DigitSlot.js.map +1 -0
  84. package/lib/module/skia/SkiaNumberFlow.js +430 -0
  85. package/lib/module/skia/SkiaNumberFlow.js.map +1 -0
  86. package/lib/module/skia/SkiaTimeFlow.js +226 -0
  87. package/lib/module/skia/SkiaTimeFlow.js.map +1 -0
  88. package/lib/module/skia/SymbolSlot.js +92 -0
  89. package/lib/module/skia/SymbolSlot.js.map +1 -0
  90. package/lib/module/skia/index.js +6 -0
  91. package/lib/module/skia/index.js.map +1 -0
  92. package/lib/module/skia/renderSlots.js +131 -0
  93. package/lib/module/skia/renderSlots.js.map +1 -0
  94. package/lib/module/skia/useGlyphMetrics.js +72 -0
  95. package/lib/module/skia/useGlyphMetrics.js.map +1 -0
  96. package/lib/module/skia/useScrubbing.js +165 -0
  97. package/lib/module/skia/useScrubbing.js.map +1 -0
  98. package/lib/module/skia/useSkiaFont.js +23 -0
  99. package/lib/module/skia/useSkiaFont.js.map +1 -0
  100. package/lib/typescript/core/constants.d.ts +15 -0
  101. package/lib/typescript/core/constants.d.ts.map +1 -0
  102. package/lib/typescript/core/intlHelpers.d.ts +10 -0
  103. package/lib/typescript/core/intlHelpers.d.ts.map +1 -0
  104. package/lib/typescript/core/layout.d.ts +22 -0
  105. package/lib/typescript/core/layout.d.ts.map +1 -0
  106. package/lib/typescript/core/mask.d.ts +18 -0
  107. package/lib/typescript/core/mask.d.ts.map +1 -0
  108. package/lib/typescript/core/numerals/detection.d.ts +17 -0
  109. package/lib/typescript/core/numerals/detection.d.ts.map +1 -0
  110. package/lib/typescript/core/numerals/digits.d.ts +43 -0
  111. package/lib/typescript/core/numerals/digits.d.ts.map +1 -0
  112. package/lib/typescript/core/numerals/index.d.ts +3 -0
  113. package/lib/typescript/core/numerals/index.d.ts.map +1 -0
  114. package/lib/typescript/core/numerals/tables.d.ts +32 -0
  115. package/lib/typescript/core/numerals/tables.d.ts.map +1 -0
  116. package/lib/typescript/core/superscript.d.ts +16 -0
  117. package/lib/typescript/core/superscript.d.ts.map +1 -0
  118. package/lib/typescript/core/timeLayout.d.ts +19 -0
  119. package/lib/typescript/core/timeLayout.d.ts.map +1 -0
  120. package/lib/typescript/core/timeTypes.d.ts +80 -0
  121. package/lib/typescript/core/timeTypes.d.ts.map +1 -0
  122. package/lib/typescript/core/timing.d.ts +6 -0
  123. package/lib/typescript/core/timing.d.ts.map +1 -0
  124. package/lib/typescript/core/types.d.ts +165 -0
  125. package/lib/typescript/core/types.d.ts.map +1 -0
  126. package/lib/typescript/core/useAccessibilityAnnouncement.d.ts +10 -0
  127. package/lib/typescript/core/useAccessibilityAnnouncement.d.ts.map +1 -0
  128. package/lib/typescript/core/useAnimatedX.d.ts +9 -0
  129. package/lib/typescript/core/useAnimatedX.d.ts.map +1 -0
  130. package/lib/typescript/core/useAnimationLifecycle.d.ts +14 -0
  131. package/lib/typescript/core/useAnimationLifecycle.d.ts.map +1 -0
  132. package/lib/typescript/core/useCanAnimate.d.ts +14 -0
  133. package/lib/typescript/core/useCanAnimate.d.ts.map +1 -0
  134. package/lib/typescript/core/useContinuousSpin.d.ts +23 -0
  135. package/lib/typescript/core/useContinuousSpin.d.ts.map +1 -0
  136. package/lib/typescript/core/useDebouncedWidths.d.ts +17 -0
  137. package/lib/typescript/core/useDebouncedWidths.d.ts.map +1 -0
  138. package/lib/typescript/core/useDigitAnimation.d.ts +38 -0
  139. package/lib/typescript/core/useDigitAnimation.d.ts.map +1 -0
  140. package/lib/typescript/core/useFlowPipeline.d.ts +46 -0
  141. package/lib/typescript/core/useFlowPipeline.d.ts.map +1 -0
  142. package/lib/typescript/core/useFormattedValue.d.ts +14 -0
  143. package/lib/typescript/core/useFormattedValue.d.ts.map +1 -0
  144. package/lib/typescript/core/useLayoutDiff.d.ts +18 -0
  145. package/lib/typescript/core/useLayoutDiff.d.ts.map +1 -0
  146. package/lib/typescript/core/useNumberFormatting.d.ts +18 -0
  147. package/lib/typescript/core/useNumberFormatting.d.ts.map +1 -0
  148. package/lib/typescript/core/useSlotOpacity.d.ts +18 -0
  149. package/lib/typescript/core/useSlotOpacity.d.ts.map +1 -0
  150. package/lib/typescript/core/useTimeFormatting.d.ts +22 -0
  151. package/lib/typescript/core/useTimeFormatting.d.ts.map +1 -0
  152. package/lib/typescript/core/useTimingResolution.d.ts +13 -0
  153. package/lib/typescript/core/useTimingResolution.d.ts.map +1 -0
  154. package/lib/typescript/core/useWorkletFormatting.d.ts +14 -0
  155. package/lib/typescript/core/useWorkletFormatting.d.ts.map +1 -0
  156. package/lib/typescript/core/utils.d.ts +44 -0
  157. package/lib/typescript/core/utils.d.ts.map +1 -0
  158. package/lib/typescript/core/warnings.d.ts +2 -0
  159. package/lib/typescript/core/warnings.d.ts.map +1 -0
  160. package/lib/typescript/index.d.ts +8 -0
  161. package/lib/typescript/index.d.ts.map +1 -0
  162. package/lib/typescript/native/DigitSlot.d.ts +27 -0
  163. package/lib/typescript/native/DigitSlot.d.ts.map +1 -0
  164. package/lib/typescript/native/NumberFlow.d.ts +3 -0
  165. package/lib/typescript/native/NumberFlow.d.ts.map +1 -0
  166. package/lib/typescript/native/SymbolSlot.d.ts +19 -0
  167. package/lib/typescript/native/SymbolSlot.d.ts.map +1 -0
  168. package/lib/typescript/native/TimeFlow.d.ts +3 -0
  169. package/lib/typescript/native/TimeFlow.d.ts.map +1 -0
  170. package/lib/typescript/native/index.d.ts +3 -0
  171. package/lib/typescript/native/index.d.ts.map +1 -0
  172. package/lib/typescript/native/renderSlots.d.ts +31 -0
  173. package/lib/typescript/native/renderSlots.d.ts.map +1 -0
  174. package/lib/typescript/native/types.d.ts +36 -0
  175. package/lib/typescript/native/types.d.ts.map +1 -0
  176. package/lib/typescript/native/useMeasuredGlyphMetrics.d.ts +8 -0
  177. package/lib/typescript/native/useMeasuredGlyphMetrics.d.ts.map +1 -0
  178. package/lib/typescript/package.json +1 -0
  179. package/lib/typescript/skia/DigitSlot.d.ts +35 -0
  180. package/lib/typescript/skia/DigitSlot.d.ts.map +1 -0
  181. package/lib/typescript/skia/SkiaNumberFlow.d.ts +3 -0
  182. package/lib/typescript/skia/SkiaNumberFlow.d.ts.map +1 -0
  183. package/lib/typescript/skia/SkiaTimeFlow.d.ts +3 -0
  184. package/lib/typescript/skia/SkiaTimeFlow.d.ts.map +1 -0
  185. package/lib/typescript/skia/SymbolSlot.d.ts +26 -0
  186. package/lib/typescript/skia/SymbolSlot.d.ts.map +1 -0
  187. package/lib/typescript/skia/index.d.ts +6 -0
  188. package/lib/typescript/skia/index.d.ts.map +1 -0
  189. package/lib/typescript/skia/renderSlots.d.ts +40 -0
  190. package/lib/typescript/skia/renderSlots.d.ts.map +1 -0
  191. package/lib/typescript/skia/useGlyphMetrics.d.ts +16 -0
  192. package/lib/typescript/skia/useGlyphMetrics.d.ts.map +1 -0
  193. package/lib/typescript/skia/useScrubbing.d.ts +59 -0
  194. package/lib/typescript/skia/useScrubbing.d.ts.map +1 -0
  195. package/lib/typescript/skia/useSkiaFont.d.ts +13 -0
  196. package/lib/typescript/skia/useSkiaFont.d.ts.map +1 -0
  197. package/package.json +104 -0
  198. package/src/core/constants.ts +20 -0
  199. package/src/core/intlHelpers.ts +351 -0
  200. package/src/core/layout.ts +108 -0
  201. package/src/core/mask.ts +72 -0
  202. package/src/core/numerals/detection.ts +112 -0
  203. package/src/core/numerals/digits.ts +102 -0
  204. package/src/core/numerals/index.ts +9 -0
  205. package/src/core/numerals/tables.ts +112 -0
  206. package/src/core/superscript.ts +27 -0
  207. package/src/core/timeLayout.ts +119 -0
  208. package/src/core/timeTypes.ts +88 -0
  209. package/src/core/timing.ts +60 -0
  210. package/src/core/types.ts +189 -0
  211. package/src/core/useAccessibilityAnnouncement.ts +27 -0
  212. package/src/core/useAnimatedX.ts +30 -0
  213. package/src/core/useAnimationLifecycle.ts +54 -0
  214. package/src/core/useCanAnimate.ts +21 -0
  215. package/src/core/useContinuousSpin.ts +112 -0
  216. package/src/core/useDebouncedWidths.ts +93 -0
  217. package/src/core/useDigitAnimation.ts +192 -0
  218. package/src/core/useFlowPipeline.ts +126 -0
  219. package/src/core/useFormattedValue.ts +32 -0
  220. package/src/core/useLayoutDiff.ts +71 -0
  221. package/src/core/useNumberFormatting.ts +164 -0
  222. package/src/core/useSlotOpacity.ts +66 -0
  223. package/src/core/useTimeFormatting.ts +95 -0
  224. package/src/core/useTimingResolution.ts +47 -0
  225. package/src/core/useWorkletFormatting.ts +59 -0
  226. package/src/core/utils.ts +149 -0
  227. package/src/core/warnings.ts +8 -0
  228. package/src/index.ts +15 -0
  229. package/src/native/DigitSlot.tsx +203 -0
  230. package/src/native/NumberFlow.tsx +287 -0
  231. package/src/native/SymbolSlot.tsx +68 -0
  232. package/src/native/TimeFlow.tsx +287 -0
  233. package/src/native/index.ts +2 -0
  234. package/src/native/renderSlots.tsx +150 -0
  235. package/src/native/types.ts +40 -0
  236. package/src/native/useMeasuredGlyphMetrics.tsx +205 -0
  237. package/src/skia/DigitSlot.tsx +221 -0
  238. package/src/skia/SkiaNumberFlow.tsx +506 -0
  239. package/src/skia/SkiaTimeFlow.tsx +257 -0
  240. package/src/skia/SymbolSlot.tsx +120 -0
  241. package/src/skia/index.ts +5 -0
  242. package/src/skia/renderSlots.tsx +180 -0
  243. package/src/skia/useGlyphMetrics.ts +79 -0
  244. package/src/skia/useScrubbing.ts +223 -0
  245. package/src/skia/useSkiaFont.ts +25 -0
@@ -0,0 +1,150 @@
1
+ import type { TextStyle } from "react-native";
2
+ import type { CharLayout } from "../core/layout";
3
+ import type { GlyphMetrics, TimingConfig, Trend } from "../core/types";
4
+ import { DigitSlot } from "./DigitSlot";
5
+ import { SymbolSlot } from "./SymbolSlot";
6
+
7
+ interface RenderSlotsParams {
8
+ layout: CharLayout[];
9
+ exitingEntries: Map<string, CharLayout>;
10
+ prevMap: Map<string, CharLayout>;
11
+ isInitialRender: boolean;
12
+ onExitComplete: (key: string) => void;
13
+ metrics: GlyphMetrics;
14
+ textStyle: TextStyle;
15
+ resolvedTrend: Trend;
16
+ spinTiming: TimingConfig;
17
+ opacityTiming: TimingConfig;
18
+ transformTiming: TimingConfig;
19
+ spinGenerations: Map<string, number> | undefined;
20
+ digitCountResolver: (key: string) => number;
21
+ maskTop: number;
22
+ maskBottom: number;
23
+ digitStrings?: string[];
24
+ }
25
+
26
+ /**
27
+ * Renders the active and exiting digit/symbol slot tree.
28
+ *
29
+ * Shared between NumberFlow and TimeFlow — the only behavioral differences
30
+ * are abstracted via `digitCountResolver` (which digit-count lookup to use)
31
+ * and the optional `digitStrings` (locale-specific digit characters).
32
+ */
33
+ export function renderSlots({
34
+ layout,
35
+ exitingEntries,
36
+ prevMap,
37
+ isInitialRender,
38
+ onExitComplete,
39
+ metrics,
40
+ textStyle,
41
+ resolvedTrend,
42
+ spinTiming,
43
+ opacityTiming,
44
+ transformTiming,
45
+ spinGenerations,
46
+ digitCountResolver,
47
+ maskTop,
48
+ maskBottom,
49
+ digitStrings,
50
+ }: RenderSlotsParams) {
51
+ return (
52
+ <>
53
+ {/* Active entries */}
54
+ {layout.map((entry) => {
55
+ const isEntering = !isInitialRender && !prevMap.has(entry.key);
56
+
57
+ if (entry.isDigit) {
58
+ const digitCount = digitCountResolver(entry.key);
59
+ const spinGeneration = spinGenerations?.get(entry.key);
60
+
61
+ return (
62
+ <DigitSlot
63
+ charWidth={entry.width}
64
+ continuousSpinGeneration={spinGeneration}
65
+ digitCount={digitCount}
66
+ digitStrings={digitStrings}
67
+ digitValue={entry.digitValue}
68
+ entering={isEntering}
69
+ exiting={false}
70
+ key={entry.key}
71
+ maskTop={maskTop}
72
+ maskBottom={maskBottom}
73
+ metrics={metrics}
74
+ opacityTiming={opacityTiming}
75
+ spinTiming={spinTiming}
76
+ superscript={entry.superscript}
77
+ targetX={entry.x}
78
+ textStyle={textStyle}
79
+ transformTiming={transformTiming}
80
+ trend={resolvedTrend}
81
+ />
82
+ );
83
+ }
84
+
85
+ return (
86
+ <SymbolSlot
87
+ char={entry.char}
88
+ entering={isEntering}
89
+ exiting={false}
90
+ key={entry.key}
91
+ lineHeight={metrics.lineHeight}
92
+ opacityTiming={opacityTiming}
93
+ superscript={entry.superscript}
94
+ targetX={entry.x}
95
+ textStyle={textStyle}
96
+ transformTiming={transformTiming}
97
+ />
98
+ );
99
+ })}
100
+
101
+ {/* Exiting entries */}
102
+ {Array.from(exitingEntries.entries()).map(([key, entry]) => {
103
+ if (entry.isDigit) {
104
+ const digitCount = digitCountResolver(key);
105
+
106
+ return (
107
+ <DigitSlot
108
+ charWidth={entry.width}
109
+ digitCount={digitCount}
110
+ digitStrings={digitStrings}
111
+ digitValue={entry.digitValue}
112
+ entering={false}
113
+ exitKey={key}
114
+ exiting
115
+ key={key}
116
+ maskTop={maskTop}
117
+ maskBottom={maskBottom}
118
+ metrics={metrics}
119
+ onExitComplete={onExitComplete}
120
+ opacityTiming={opacityTiming}
121
+ spinTiming={spinTiming}
122
+ superscript={entry.superscript}
123
+ targetX={entry.x}
124
+ textStyle={textStyle}
125
+ transformTiming={transformTiming}
126
+ trend={resolvedTrend}
127
+ />
128
+ );
129
+ }
130
+
131
+ return (
132
+ <SymbolSlot
133
+ char={entry.char}
134
+ entering={false}
135
+ exitKey={key}
136
+ exiting
137
+ key={key}
138
+ lineHeight={metrics.lineHeight}
139
+ onExitComplete={onExitComplete}
140
+ opacityTiming={opacityTiming}
141
+ superscript={entry.superscript}
142
+ targetX={entry.x}
143
+ textStyle={textStyle}
144
+ transformTiming={transformTiming}
145
+ />
146
+ );
147
+ })}
148
+ </>
149
+ );
150
+ }
@@ -0,0 +1,40 @@
1
+ import type { TextStyle, ViewStyle } from "react-native";
2
+ import type { AnimationBehaviorProps, DigitsProp, TextAlign } from "../core/types";
3
+
4
+ /** Text style for NumberFlow/TimeFlow. Requires fontSize; fontFamily defaults to the system font when omitted. */
5
+ export type NumberFlowStyle = TextStyle & {
6
+ fontSize: number;
7
+ };
8
+
9
+ interface NumberFlowValueProps {
10
+ /** Numeric value to display */
11
+ value: number;
12
+ /** Intl.NumberFormatOptions for value formatting */
13
+ format?: Intl.NumberFormatOptions;
14
+ /** Locale(s) for Intl.NumberFormat */
15
+ locales?: Intl.LocalesArgument;
16
+ }
17
+
18
+ interface NumberFlowBaseProps extends AnimationBehaviorProps {
19
+ /** Text styling. fontSize is required; fontFamily defaults to the platform system font. */
20
+ style: NumberFlowStyle;
21
+ /** Text alignment. Defaults to "left". */
22
+ textAlign?: TextAlign;
23
+ /** Static string prepended before the number */
24
+ prefix?: string;
25
+ /** Static string appended after the number */
26
+ suffix?: string;
27
+
28
+ /**
29
+ * Per-position digit constraints. Maps integer position (0=ones, 1=tens, ...)
30
+ * to { max: N } where N is the highest digit value (inclusive).
31
+ * Example: { 1: { max: 5 } } for a wheel where tens go 0-5.
32
+ */
33
+ digits?: DigitsProp;
34
+
35
+ /** Style applied to the outer container View */
36
+ containerStyle?: ViewStyle;
37
+ }
38
+
39
+ /** Props for NumberFlow. Accessibility is built-in: accessibilityRole="text" and accessibilityLabel are set automatically. */
40
+ export type NumberFlowProps = NumberFlowBaseProps & NumberFlowValueProps;
@@ -0,0 +1,205 @@
1
+ import React, { useCallback, useMemo, useReducer, useRef } from "react";
2
+ import { Text } from "react-native";
3
+ import { MEASURABLE_CHARS } from "../core/constants";
4
+ import type { GlyphMetrics } from "../core/types";
5
+ import type { NumberFlowStyle } from "./types";
6
+
7
+ // Combines MEASURABLE_CHARS with additional chars, deduplicating
8
+ function buildCharSet(additionalChars?: string): string {
9
+ if (!additionalChars) return MEASURABLE_CHARS;
10
+ const unique = Array.from(additionalChars).filter((c) => !MEASURABLE_CHARS.includes(c));
11
+ return unique.length > 0 ? MEASURABLE_CHARS + unique.join("") : MEASURABLE_CHARS;
12
+ }
13
+
14
+ type TextLayoutEvent = Parameters<
15
+ NonNullable<React.ComponentProps<typeof Text>["onTextLayout"]>
16
+ >[0];
17
+
18
+ /**
19
+ * Key: "fontFamily:fontSize", Value: measured GlyphMetrics.
20
+ * Once measured for a given font config, subsequent mounts return instantly.
21
+ */
22
+ const metricsCache = new Map<string, GlyphMetrics>();
23
+
24
+ function cacheKey(
25
+ style: { fontFamily?: string; fontSize: number },
26
+ additionalChars?: string,
27
+ ): string {
28
+ const base = `${style.fontFamily}:${style.fontSize}`;
29
+ return additionalChars ? `${base}:${additionalChars}` : base;
30
+ }
31
+
32
+ /**
33
+ * Renders a single off-screen <Text> with all measurable characters separated
34
+ * by newlines. onTextLayout fires once with lines[] — one entry per line, each
35
+ * containing the advance width of that single character.
36
+ */
37
+
38
+ // ASCII characters known to have no descender (bottom = 0)
39
+ const NO_DESCENDER = new Set("0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ.:%+-~°!^*×/$€£¥₩ \u00A0");
40
+
41
+ /**
42
+ * Estimates per-character vertical bounds using font-level metrics.
43
+ * Top: -capHeight for all chars (precise for digits, safe overestimate for smaller glyphs).
44
+ * Bottom: 0 for known no-descender chars (+ locale digits), full descent otherwise.
45
+ */
46
+ function estimateCharBounds(
47
+ charSet: string,
48
+ descent: number,
49
+ capHeight: number,
50
+ localeDigitStrings?: string[],
51
+ ): Record<string, { top: number; bottom: number }> {
52
+ const noDescender = new Set(NO_DESCENDER);
53
+ if (localeDigitStrings) {
54
+ for (const d of localeDigitStrings) noDescender.add(d);
55
+ }
56
+
57
+ const bounds: Record<string, { top: number; bottom: number }> = {};
58
+ const top = -capHeight;
59
+
60
+ for (let i = 0; i < charSet.length; i++) {
61
+ const bottom = noDescender.has(charSet[i]) ? 0 : descent;
62
+ bounds[charSet[i]] = { top, bottom };
63
+ }
64
+
65
+ return bounds;
66
+ }
67
+
68
+ interface MeasureComponentProps {
69
+ nfStyle: NumberFlowStyle;
70
+ charSet: string;
71
+ localeDigitStrings?: string[];
72
+ onComplete: (metrics: GlyphMetrics) => void;
73
+ }
74
+
75
+ const MeasureComponent = React.memo(
76
+ ({ nfStyle, charSet, localeDigitStrings, onComplete }: MeasureComponentProps) => {
77
+ const completedRef = useRef(false);
78
+
79
+ /**
80
+ * Replace regular space with NBSP for measurement safety — some Android
81
+ * text layout engines collapse trailing whitespace on a line.
82
+ */
83
+ const measureString = useMemo(
84
+ () =>
85
+ Array.from(charSet)
86
+ .map((c) => (c === " " ? "\u00A0" : c))
87
+ .join("\n"),
88
+ [charSet],
89
+ );
90
+
91
+ const handleTextLayout = useCallback(
92
+ (e: TextLayoutEvent) => {
93
+ if (completedRef.current) return;
94
+ const lines = e.nativeEvent.lines;
95
+ if (lines.length < charSet.length) return;
96
+
97
+ completedRef.current = true;
98
+
99
+ const charWidths: Record<string, number> = {};
100
+ for (let i = 0; i < charSet.length; i++) {
101
+ charWidths[charSet[i]] = lines[i].width;
102
+ }
103
+
104
+ let maxDigitWidth = 0;
105
+ const digitChars = localeDigitStrings ?? Array.from({ length: 10 }, (_, d) => String(d));
106
+ for (const dc of digitChars) {
107
+ const w = charWidths[dc] ?? 0;
108
+ if (w > maxDigitWidth) maxDigitWidth = w;
109
+ }
110
+
111
+ let tallestLine = lines[0];
112
+ for (let i = 1; i < charSet.length; i++) {
113
+ if (lines[i].height > tallestLine.height) tallestLine = lines[i];
114
+ }
115
+
116
+ // Negate ascender to match Skia convention (negative = above baseline).
117
+ const ascent = -tallestLine.ascender;
118
+ const descent = tallestLine.descender;
119
+ // Fallback 0.72 × |ascent| matches typical system font cap-height ratio.
120
+ const capHeight =
121
+ (tallestLine as unknown as Record<string, number>).capHeight || -ascent * 0.72;
122
+
123
+ const charBounds = estimateCharBounds(charSet, descent, capHeight, localeDigitStrings);
124
+
125
+ onComplete({
126
+ charWidths,
127
+ maxDigitWidth,
128
+ lineHeight: Math.ceil(tallestLine.height),
129
+ ascent,
130
+ descent,
131
+ charBounds,
132
+ });
133
+ },
134
+ [onComplete, charSet, localeDigitStrings],
135
+ );
136
+
137
+ return (
138
+ <Text
139
+ onTextLayout={handleTextLayout}
140
+ pointerEvents="none"
141
+ style={{
142
+ fontFamily: nfStyle.fontFamily,
143
+ fontSize: nfStyle.fontSize,
144
+ position: "absolute",
145
+ opacity: 0,
146
+ }}
147
+ >
148
+ {measureString}
149
+ </Text>
150
+ );
151
+ },
152
+ );
153
+
154
+ MeasureComponent.displayName = "NumberFlowMeasure";
155
+
156
+ export function useMeasuredGlyphMetrics(
157
+ style: NumberFlowStyle,
158
+ additionalChars?: string,
159
+ localeDigitStrings?: string[],
160
+ ): {
161
+ metrics: GlyphMetrics | null;
162
+ MeasureElement: React.ReactElement | null;
163
+ } {
164
+ const charSet = useMemo(() => buildCharSet(additionalChars), [additionalChars]);
165
+ const key = cacheKey(style, additionalChars);
166
+ const cached = metricsCache.get(key);
167
+ const [, forceUpdate] = useReducer((n: number) => n + 1, 0);
168
+
169
+ /**
170
+ * Track the best available metrics for this font config — prevents
171
+ * returning null (and unmounting all slots) when additionalChars
172
+ * changes and triggers an async re-measurement.
173
+ */
174
+ const baseKey = cacheKey(style);
175
+ const prevMetricsRef = useRef<GlyphMetrics | null>(null);
176
+ if (cached) prevMetricsRef.current = cached;
177
+
178
+ const handleComplete = useCallback(
179
+ (metrics: GlyphMetrics) => {
180
+ metricsCache.set(key, metrics);
181
+ forceUpdate();
182
+ },
183
+ [key],
184
+ );
185
+
186
+ if (cached) {
187
+ return { metrics: cached, MeasureElement: null };
188
+ }
189
+
190
+ /**
191
+ * If we have stale metrics from a previous measurement of this font,
192
+ * return them while re-measuring — avoids unmounting the slot tree.
193
+ */
194
+ const staleMetrics = prevMetricsRef.current ?? metricsCache.get(baseKey) ?? null;
195
+
196
+ return {
197
+ metrics: staleMetrics,
198
+ MeasureElement: React.createElement(MeasureComponent, {
199
+ nfStyle: style,
200
+ charSet,
201
+ localeDigitStrings,
202
+ onComplete: handleComplete,
203
+ }),
204
+ };
205
+ }
@@ -0,0 +1,221 @@
1
+ import { Group, Paint, rect, Text as SkiaText } from "@shopify/react-native-skia";
2
+ import React, { useMemo, useState } from "react";
3
+ import {
4
+ makeMutable,
5
+ type SharedValue,
6
+ useAnimatedReaction,
7
+ useDerivedValue,
8
+ } from "react-native-reanimated";
9
+ import { DIGIT_COUNT, SUPERSCRIPT_SCALE } from "../core/constants";
10
+ import { getSuperscriptTransform } from "../core/superscript";
11
+ import type { GlyphMetrics, SkiaNumberFlowProps, TimingConfig, Trend } from "../core/types";
12
+ import { useAnimatedX } from "../core/useAnimatedX";
13
+ import { useDigitAnimation } from "../core/useDigitAnimation";
14
+ import { signedDigitOffset } from "../core/utils";
15
+
16
+ interface DigitSlotProps {
17
+ metrics: GlyphMetrics;
18
+ digitValue: number;
19
+ targetX: number;
20
+ charWidth: number;
21
+ baseY: number;
22
+ color: string;
23
+ font: NonNullable<SkiaNumberFlowProps["font"]>;
24
+ spinTiming: TimingConfig;
25
+ opacityTiming: TimingConfig;
26
+ transformTiming: TimingConfig;
27
+ trend: Trend;
28
+ entering: boolean;
29
+ exiting: boolean;
30
+ exitKey?: string;
31
+ onExitComplete?: (key: string) => void;
32
+ workletDigitValue?: SharedValue<number>;
33
+ workletLayout?: SharedValue<{ x: number; width: number }[]>;
34
+ slotIndex?: number;
35
+ digitCount?: number;
36
+ continuousSpinGeneration?: number;
37
+ maskTop?: number;
38
+ maskBottom?: number;
39
+ superscript?: boolean;
40
+ digitStrings?: string[];
41
+ }
42
+
43
+ export const DigitSlot = React.memo(
44
+ ({
45
+ metrics,
46
+ digitValue,
47
+ targetX,
48
+ charWidth,
49
+ baseY,
50
+ color,
51
+ font,
52
+ spinTiming,
53
+ opacityTiming,
54
+ transformTiming,
55
+ trend,
56
+ entering,
57
+ exiting,
58
+ exitKey,
59
+ onExitComplete,
60
+ workletDigitValue,
61
+ workletLayout,
62
+ slotIndex,
63
+ digitCount,
64
+ continuousSpinGeneration,
65
+ maskTop = 0,
66
+ maskBottom = 0,
67
+ superscript,
68
+ digitStrings,
69
+ }: DigitSlotProps) => {
70
+ const resolvedDigitCount = digitCount ?? DIGIT_COUNT;
71
+ const resolvedDigitStrings =
72
+ digitStrings ?? Array.from({ length: resolvedDigitCount }, (_, i) => String(i));
73
+
74
+ const { initialDigit, animDelta, currentDigitSV, slotOpacity } = useDigitAnimation({
75
+ digitValue,
76
+ entering,
77
+ exiting,
78
+ trend,
79
+ spinTiming,
80
+ opacityTiming,
81
+ exitKey,
82
+ onExitComplete,
83
+ workletDigitValue,
84
+ digitCount: resolvedDigitCount,
85
+ continuousSpinGeneration,
86
+ });
87
+
88
+ /**
89
+ * Per-digit Y transforms: each digit independently positions itself
90
+ * based on its signed modular distance from the virtual scroll position.
91
+ * Initialized with correct positions so the first frame is accurate.
92
+ */
93
+ const [digitYTransforms] = useState(() => {
94
+ const lh = metrics.lineHeight;
95
+ return Array.from({ length: resolvedDigitCount }, (_, n) => {
96
+ const offset = signedDigitOffset(n, initialDigit, resolvedDigitCount);
97
+ const clamped = Math.max(-1.5, Math.min(1.5, offset));
98
+ return makeMutable([{ translateY: clamped * lh }]);
99
+ });
100
+ });
101
+
102
+ /**
103
+ * Mirrors NumberFlow's CSS mod(): each digit n computes its signed
104
+ * offset from virtual position c, clamped to [-1.5, 1.5].
105
+ * Only the current digit (offset ≈ 0) and its neighbors (offset ≈ ±1)
106
+ * are visible through the clip window. All others park just outside.
107
+ * Runs every frame via Reanimated's mapper system (animDelta changes →
108
+ * marks mapper dirty → microtask recalculates).
109
+ */
110
+ useAnimatedReaction(
111
+ () => currentDigitSV.value - animDelta.value,
112
+ (c) => {
113
+ const lh = metrics.lineHeight;
114
+ for (let n = 0; n < resolvedDigitCount; n++) {
115
+ const offset = signedDigitOffset(n, c, resolvedDigitCount);
116
+ const clamped = Math.max(-1.5, Math.min(1.5, offset));
117
+ digitYTransforms[n].value = [{ translateY: clamped * lh }];
118
+ }
119
+ },
120
+ [metrics.lineHeight, resolvedDigitCount],
121
+ );
122
+
123
+ const animatedX = useAnimatedX(targetX, exiting, transformTiming);
124
+
125
+ /**
126
+ * Group transform absorbs clipX (centering offset within slot width).
127
+ * This makes clipRect and digitXOffsets static (font-metric only).
128
+ * For superscript slots the visual clip is scaled, so cx accounts for that.
129
+ */
130
+ const visualClipWidth = superscript
131
+ ? metrics.maxDigitWidth * SUPERSCRIPT_SCALE
132
+ : metrics.maxDigitWidth;
133
+
134
+ const groupTransform = useDerivedValue(() => {
135
+ const wl = workletLayout?.value;
136
+ if (wl && slotIndex !== undefined && slotIndex < wl.length) {
137
+ const slotWidth = wl[slotIndex].width;
138
+ const cx = slotWidth / 2 - visualClipWidth / 2;
139
+ return [{ translateX: wl[slotIndex].x + cx }];
140
+ }
141
+ const cx = charWidth / 2 - visualClipWidth / 2;
142
+ return [{ translateX: animatedX.value + cx }];
143
+ });
144
+
145
+ // Digit centering within the maxDigitWidth clip (font-metric only, static)
146
+ const digitXOffsets = useMemo(() => {
147
+ const offsets: number[] = [];
148
+ for (let d = 0; d < resolvedDigitCount; d++) {
149
+ const w = metrics.charWidths[resolvedDigitStrings[d]] ?? metrics.maxDigitWidth;
150
+ offsets.push((metrics.maxDigitWidth - w) / 2);
151
+ }
152
+ return offsets;
153
+ }, [metrics, resolvedDigitCount, resolvedDigitStrings]);
154
+
155
+ // Superscript digits use a tight clip (no mask buffer) — the container-level
156
+ // gradient doesn't cover the superscript position, so buffer would leak neighbors.
157
+ const effectiveMaskTop = superscript ? 0 : maskTop;
158
+ const effectiveMaskBottom = superscript ? 0 : maskBottom;
159
+
160
+ const clipRect = useMemo(
161
+ () =>
162
+ rect(
163
+ 0,
164
+ baseY + metrics.ascent - effectiveMaskTop,
165
+ metrics.maxDigitWidth,
166
+ metrics.lineHeight + effectiveMaskTop + effectiveMaskBottom,
167
+ ),
168
+ [baseY, metrics, effectiveMaskTop, effectiveMaskBottom],
169
+ );
170
+
171
+ const opacityPaint = useMemo(() => <Paint opacity={slotOpacity} />, [slotOpacity]);
172
+
173
+ /**
174
+ * Each digit gets its own Group transform driven by the position
175
+ * reaction. Only 10 elements needed (vs 30 with the copy approach).
176
+ * useMemo creates stable JSX — shared values drive the animation.
177
+ */
178
+ const digitElements = useMemo(
179
+ () =>
180
+ Array.from({ length: resolvedDigitCount }, (_, n) => (
181
+ <Group key={n} transform={digitYTransforms[n]}>
182
+ <SkiaText
183
+ color={color}
184
+ font={font}
185
+ text={resolvedDigitStrings[n]}
186
+ x={digitXOffsets[n]}
187
+ y={baseY}
188
+ />
189
+ </Group>
190
+ )),
191
+ [
192
+ resolvedDigitCount,
193
+ resolvedDigitStrings,
194
+ baseY,
195
+ color,
196
+ font,
197
+ digitXOffsets,
198
+ digitYTransforms,
199
+ ],
200
+ );
201
+
202
+ const superscriptTransform = useMemo(
203
+ () => (superscript ? getSuperscriptTransform(baseY, metrics.ascent) : undefined),
204
+ [superscript, baseY, metrics],
205
+ );
206
+
207
+ const clipContent = <Group clip={clipRect}>{digitElements}</Group>;
208
+
209
+ return (
210
+ <Group layer={opacityPaint} transform={groupTransform}>
211
+ {superscriptTransform ? (
212
+ <Group transform={superscriptTransform}>{clipContent}</Group>
213
+ ) : (
214
+ clipContent
215
+ )}
216
+ </Group>
217
+ );
218
+ },
219
+ );
220
+
221
+ DigitSlot.displayName = "DigitSlot";