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,287 @@
1
+ import MaskedView from "@rednegniw/masked-view";
2
+ import { useCallback, useEffect, useMemo, useState } from "react";
3
+ import { type LayoutChangeEvent, Text, View } from "react-native";
4
+ import { computeKeyedLayout } from "../core/layout";
5
+ import { detectNumberingSystem, getDigitStrings } from "../core/numerals";
6
+ import { getFormatCharacters } from "../core/intlHelpers";
7
+ import { useFlowPipeline } from "../core/useFlowPipeline";
8
+ import { useNumberFormatting } from "../core/useNumberFormatting";
9
+ import { getDigitCount } from "../core/utils";
10
+ import { warnOnce } from "../core/warnings";
11
+ import { renderSlots } from "./renderSlots";
12
+ import type { NumberFlowProps } from "./types";
13
+ import { useMeasuredGlyphMetrics } from "./useMeasuredGlyphMetrics";
14
+
15
+ export const NumberFlow = ({
16
+ value,
17
+ format,
18
+ locales,
19
+ style: nfStyle,
20
+ textAlign = "left",
21
+ prefix = "",
22
+ suffix = "",
23
+ spinTiming,
24
+ opacityTiming,
25
+ transformTiming,
26
+ trend,
27
+ animated,
28
+ respectMotionPreference,
29
+ continuous,
30
+ digits,
31
+ onAnimationsStart,
32
+ onAnimationsFinish,
33
+ containerStyle,
34
+ mask,
35
+ }: NumberFlowProps) => {
36
+ const formatChars = useMemo(
37
+ () => getFormatCharacters(locales, format, prefix, suffix),
38
+ [locales, format, prefix, suffix]
39
+ );
40
+ const numberingSystem = useMemo(
41
+ () => detectNumberingSystem(locales, format),
42
+ [locales, format]
43
+ );
44
+ const digitStrings = useMemo(
45
+ () => getDigitStrings(numberingSystem),
46
+ [numberingSystem]
47
+ );
48
+ const { metrics, MeasureElement } = useMeasuredGlyphMetrics(
49
+ nfStyle,
50
+ formatChars,
51
+ digitStrings
52
+ );
53
+
54
+ if (__DEV__) {
55
+ if (!nfStyle.fontSize) {
56
+ warnOnce(
57
+ "nf-fontSize",
58
+ "style.fontSize is required for NumberFlow to measure glyphs."
59
+ );
60
+ }
61
+ if (digits) {
62
+ for (const [posStr, constraint] of Object.entries(digits)) {
63
+ if (constraint.max < 1 || constraint.max > 9) {
64
+ warnOnce(
65
+ `nf-digit-max-${posStr}`,
66
+ `digits[${posStr}].max must be between 1 and 9, got ${constraint.max}.`
67
+ );
68
+ }
69
+ }
70
+ }
71
+ }
72
+
73
+ const keyedParts = useNumberFormatting(
74
+ value,
75
+ format,
76
+ locales,
77
+ prefix,
78
+ suffix
79
+ );
80
+
81
+ const [containerWidth, setContainerWidth] = useState(0);
82
+ const handleContainerLayout = useCallback((e: LayoutChangeEvent) => {
83
+ setContainerWidth(e.nativeEvent.layout.width);
84
+ }, []);
85
+
86
+ const layout = useMemo(() => {
87
+ if (!metrics) return [];
88
+
89
+ // Skip layout when container hasn't measured yet and alignment needs width.
90
+ // Without this guard, center/right alignment computes with width=0,
91
+ // then re-computes after onLayout — causing a visible slide-in animation.
92
+ if (containerWidth === 0 && textAlign !== "left") return [];
93
+
94
+ if (keyedParts.length === 0) return [];
95
+ return computeKeyedLayout(
96
+ keyedParts,
97
+ metrics,
98
+ containerWidth,
99
+ textAlign,
100
+ digitStrings
101
+ );
102
+ }, [metrics, keyedParts, containerWidth, textAlign, digitStrings]);
103
+
104
+ const {
105
+ resolvedSpinTiming,
106
+ resolvedOpacityTiming,
107
+ resolvedTransformTiming,
108
+ resolvedTrend,
109
+ spinGenerations,
110
+ prevMap,
111
+ isInitialRender,
112
+ exitingEntries,
113
+ onExitComplete,
114
+ accessibilityLabel,
115
+ adaptiveMask,
116
+ } = useFlowPipeline({
117
+ keyedParts,
118
+ trendValue: value,
119
+ layout,
120
+ metrics,
121
+ animated,
122
+ respectMotionPreference,
123
+ spinTiming,
124
+ opacityTiming,
125
+ transformTiming,
126
+ trend,
127
+ continuous,
128
+ mask,
129
+ onAnimationsStart,
130
+ onAnimationsFinish,
131
+ });
132
+
133
+ const textStyle = useMemo(
134
+ () => ({
135
+ ...nfStyle,
136
+ color: nfStyle.color ?? "#000000",
137
+ }),
138
+ [nfStyle]
139
+ );
140
+
141
+ const resolvedMask = mask ?? true;
142
+ const maskTop = adaptiveMask.top;
143
+ const maskBottom = adaptiveMask.bottom;
144
+ const { expansionTop, expansionBottom } = adaptiveMask;
145
+
146
+ // Step count scales with mask height — each step must be >=1px (sub-pixel Views collapse to 0).
147
+ const topSteps = Math.max(2, Math.round(maskTop));
148
+ const bottomSteps = Math.max(2, Math.round(maskBottom));
149
+
150
+ const gradientMaskElement = useMemo(() => {
151
+ if (!resolvedMask || !metrics) return null;
152
+ return (
153
+ <View style={{ flex: 1, flexDirection: "column" }}>
154
+ {/* Top fade: transparent -> opaque */}
155
+ {Array.from({ length: topSteps }, (_, i) => (
156
+ <View
157
+ key={`t${i}`}
158
+ style={{
159
+ height: maskTop / topSteps,
160
+ backgroundColor: `rgba(0,0,0,${i / (topSteps - 1)})`,
161
+ }}
162
+ />
163
+ ))}
164
+
165
+ {/* Middle: fully opaque */}
166
+ <View style={{ flex: 1, backgroundColor: "black" }} />
167
+
168
+ {/* Bottom fade: opaque -> transparent */}
169
+ {Array.from({ length: bottomSteps }, (_, i) => (
170
+ <View
171
+ key={`b${i}`}
172
+ style={{
173
+ height: maskBottom / bottomSteps,
174
+ backgroundColor: `rgba(0,0,0,${1 - i / (bottomSteps - 1)})`,
175
+ }}
176
+ />
177
+ ))}
178
+ </View>
179
+ );
180
+ }, [resolvedMask, metrics, maskTop, maskBottom, topSteps, bottomSteps]);
181
+
182
+
183
+ const [slotsReady, setSlotsReady] = useState(false);
184
+ const metricsReady = !!metrics;
185
+
186
+ useEffect(() => {
187
+ if (!metricsReady) return;
188
+ const id = requestAnimationFrame(() => setSlotsReady(true));
189
+ return () => cancelAnimationFrame(id);
190
+ }, [metricsReady]);
191
+
192
+ if (!metrics || (layout.length === 0 && exitingEntries.size === 0)) {
193
+ return (
194
+ <View
195
+ accessible
196
+ accessibilityRole="text"
197
+ accessibilityLabel={accessibilityLabel}
198
+ onLayout={handleContainerLayout}
199
+ style={containerStyle}
200
+ >
201
+ <Text style={[textStyle, { textAlign }]}>{accessibilityLabel}</Text>
202
+ {MeasureElement}
203
+ </View>
204
+ );
205
+ }
206
+
207
+ // Placeholder branch: show plain Text while slot tree loads
208
+ if (!slotsReady) {
209
+ return (
210
+ <View
211
+ accessible
212
+ accessibilityRole="text"
213
+ accessibilityLabel={accessibilityLabel}
214
+ onLayout={handleContainerLayout}
215
+ style={[
216
+ containerStyle,
217
+ {
218
+ height: metrics.lineHeight + expansionTop + expansionBottom,
219
+ marginTop: -expansionTop,
220
+ marginBottom: -expansionBottom,
221
+ position: "relative",
222
+ overflow: "hidden",
223
+ },
224
+ ]}
225
+ >
226
+ <Text style={[textStyle, { textAlign }]}>{accessibilityLabel}</Text>
227
+ {MeasureElement}
228
+ </View>
229
+ );
230
+ }
231
+
232
+ const slots = renderSlots({
233
+ layout,
234
+ exitingEntries,
235
+ prevMap,
236
+ isInitialRender,
237
+ onExitComplete,
238
+ metrics,
239
+ textStyle,
240
+ resolvedTrend,
241
+ spinTiming: resolvedSpinTiming,
242
+ opacityTiming: resolvedOpacityTiming,
243
+ transformTiming: resolvedTransformTiming,
244
+ spinGenerations,
245
+ digitCountResolver: (key) => getDigitCount(digits, key),
246
+ maskTop,
247
+ maskBottom,
248
+ digitStrings,
249
+ });
250
+
251
+ // Optionally wrap in MaskedView for gradient edge fade.
252
+ const maskedContent =
253
+ resolvedMask && gradientMaskElement ? (
254
+ <MaskedView maskElement={gradientMaskElement} style={{ flex: 1 }}>
255
+ <View style={{ flex: 1, position: "relative", top: expansionTop }}>
256
+ {MeasureElement}
257
+ {slots}
258
+ </View>
259
+ </MaskedView>
260
+ ) : (
261
+ <View style={{ flex: 1, position: "relative", top: expansionTop }}>
262
+ {MeasureElement}
263
+ {slots}
264
+ </View>
265
+ );
266
+
267
+ return (
268
+ <View
269
+ accessible
270
+ accessibilityRole="text"
271
+ accessibilityLabel={accessibilityLabel}
272
+ onLayout={handleContainerLayout}
273
+ style={[
274
+ containerStyle,
275
+ {
276
+ height: metrics.lineHeight + expansionTop + expansionBottom,
277
+ marginTop: -expansionTop,
278
+ marginBottom: -expansionBottom,
279
+ position: "relative",
280
+ overflow: "hidden",
281
+ },
282
+ ]}
283
+ >
284
+ {maskedContent}
285
+ </View>
286
+ );
287
+ };
@@ -0,0 +1,68 @@
1
+ import React, { useMemo } from "react";
2
+ import { Text, type TextStyle } from "react-native";
3
+ import Animated, { useAnimatedStyle } from "react-native-reanimated";
4
+ import { SUPERSCRIPT_SCALE } from "../core/constants";
5
+ import { getSuperscriptTextStyle } from "../core/superscript";
6
+ import type { TimingConfig } from "../core/types";
7
+ import { useAnimatedX } from "../core/useAnimatedX";
8
+ import { useSlotOpacity } from "../core/useSlotOpacity";
9
+
10
+ interface SymbolSlotProps {
11
+ char: string;
12
+ targetX: number;
13
+ lineHeight: number;
14
+ textStyle: TextStyle;
15
+ opacityTiming: TimingConfig;
16
+ transformTiming: TimingConfig;
17
+ entering: boolean;
18
+ exiting: boolean;
19
+ exitKey?: string;
20
+ onExitComplete?: (key: string) => void;
21
+ superscript?: boolean;
22
+ }
23
+
24
+ export const SymbolSlot = React.memo(
25
+ ({
26
+ char,
27
+ targetX,
28
+ lineHeight,
29
+ textStyle,
30
+ opacityTiming,
31
+ transformTiming,
32
+ entering,
33
+ exiting,
34
+ exitKey,
35
+ onExitComplete,
36
+ superscript,
37
+ }: SymbolSlotProps) => {
38
+ const effectiveHeight = superscript ? lineHeight * SUPERSCRIPT_SCALE : lineHeight;
39
+
40
+ const effectiveTextStyle = useMemo(
41
+ () => (superscript ? getSuperscriptTextStyle(textStyle, effectiveHeight) : textStyle),
42
+ [textStyle, superscript, effectiveHeight],
43
+ );
44
+
45
+ const slotOpacity = useSlotOpacity({
46
+ entering,
47
+ exiting,
48
+ opacityTiming,
49
+ exitKey,
50
+ onExitComplete,
51
+ });
52
+
53
+ const animatedX = useAnimatedX(targetX, exiting, transformTiming);
54
+
55
+ const animatedStyle = useAnimatedStyle(() => ({
56
+ transform: [{ translateX: animatedX.value }],
57
+ opacity: slotOpacity.value,
58
+ }));
59
+
60
+ return (
61
+ <Animated.View style={[{ position: "absolute", height: effectiveHeight }, animatedStyle]}>
62
+ <Text style={effectiveTextStyle}>{char}</Text>
63
+ </Animated.View>
64
+ );
65
+ },
66
+ );
67
+
68
+ SymbolSlot.displayName = "SymbolSlot";
@@ -0,0 +1,287 @@
1
+ import MaskedView from "@rednegniw/masked-view";
2
+ import { useCallback, useEffect, useMemo, useState } from "react";
3
+ import { type LayoutChangeEvent, Text, View } from "react-native";
4
+ import { computeKeyedLayout } from "../core/layout";
5
+ import type { TimeFlowProps } from "../core/timeTypes";
6
+ import { useFlowPipeline } from "../core/useFlowPipeline";
7
+ import { useTimeFormatting } from "../core/useTimeFormatting";
8
+ import { TIME_DIGIT_COUNTS } from "../core/utils";
9
+ import { warnOnce } from "../core/warnings";
10
+ import { renderSlots } from "./renderSlots";
11
+ import { useMeasuredGlyphMetrics } from "./useMeasuredGlyphMetrics";
12
+
13
+ export const TimeFlow = ({
14
+ hours,
15
+ minutes,
16
+ seconds,
17
+ timestamp,
18
+ timezoneOffset,
19
+ is24Hour = true,
20
+ padHours = true,
21
+ style: nfStyle,
22
+ textAlign = "left",
23
+ spinTiming,
24
+ opacityTiming,
25
+ transformTiming,
26
+ trend,
27
+ animated,
28
+ respectMotionPreference,
29
+ continuous,
30
+ onAnimationsStart,
31
+ onAnimationsFinish,
32
+ containerStyle,
33
+ mask,
34
+ }: TimeFlowProps) => {
35
+ const { metrics, MeasureElement } = useMeasuredGlyphMetrics(nfStyle);
36
+
37
+ if (__DEV__) {
38
+ if (!nfStyle.fontSize) {
39
+ warnOnce("tf-fontSize", "style.fontSize is required for TimeFlow to measure glyphs.");
40
+ }
41
+ }
42
+
43
+ const resolved = useMemo(() => {
44
+ if (timestamp !== undefined) {
45
+ const d = new Date(timestamp + (timezoneOffset ?? 0));
46
+ return {
47
+ hours: d.getUTCHours(),
48
+ minutes: d.getUTCMinutes(),
49
+ seconds: d.getUTCSeconds(),
50
+ };
51
+ }
52
+ return { hours, minutes, seconds };
53
+ }, [timestamp, timezoneOffset, hours, minutes, seconds]);
54
+
55
+ const resolvedHours = resolved.hours;
56
+ const resolvedMinutes = resolved.minutes;
57
+ const resolvedSeconds = resolved.seconds;
58
+
59
+ if (__DEV__) {
60
+ if (resolvedHours !== undefined && (resolvedHours < 0 || resolvedHours > 23)) {
61
+ warnOnce("tf-hours", "hours must be 0-23.");
62
+ }
63
+ if (resolvedMinutes !== undefined && (resolvedMinutes < 0 || resolvedMinutes > 59)) {
64
+ warnOnce("tf-minutes", "minutes must be 0-59.");
65
+ }
66
+ if (resolvedSeconds !== undefined && (resolvedSeconds < 0 || resolvedSeconds > 59)) {
67
+ warnOnce("tf-seconds", "seconds must be 0-59.");
68
+ }
69
+ }
70
+
71
+ const totalSeconds =
72
+ (resolvedHours ?? 0) * 3600 + (resolvedMinutes ?? 0) * 60 + (resolvedSeconds ?? 0);
73
+
74
+ const keyedParts = useTimeFormatting(
75
+ resolvedHours,
76
+ resolvedMinutes,
77
+ resolvedSeconds,
78
+ is24Hour,
79
+ padHours,
80
+ );
81
+
82
+ const [containerWidth, setContainerWidth] = useState(0);
83
+ const handleContainerLayout = useCallback((e: LayoutChangeEvent) => {
84
+ setContainerWidth(e.nativeEvent.layout.width);
85
+ }, []);
86
+
87
+ const layout = useMemo(() => {
88
+ if (!metrics) return [];
89
+
90
+ // Skip layout when container hasn't measured yet and alignment needs width.
91
+ // Without this guard, center/right alignment computes with width=0,
92
+ // then re-computes after onLayout — causing a visible slide-in animation.
93
+ if (containerWidth === 0 && textAlign !== "left") return [];
94
+
95
+ if (keyedParts.length === 0) return [];
96
+ return computeKeyedLayout(keyedParts, metrics, containerWidth, textAlign);
97
+ }, [metrics, keyedParts, containerWidth, textAlign]);
98
+
99
+ const pipeline = useFlowPipeline({
100
+ keyedParts,
101
+ trendValue: totalSeconds,
102
+ layout,
103
+ metrics,
104
+ animated,
105
+ respectMotionPreference,
106
+ spinTiming,
107
+ opacityTiming,
108
+ transformTiming,
109
+ trend,
110
+ continuous,
111
+ mask,
112
+ onAnimationsStart,
113
+ onAnimationsFinish,
114
+ });
115
+
116
+ const {
117
+ resolvedSpinTiming,
118
+ resolvedOpacityTiming,
119
+ resolvedTransformTiming,
120
+ resolvedTrend,
121
+ spinGenerations,
122
+ prevMap,
123
+ isInitialRender,
124
+ exitingEntries,
125
+ onExitComplete,
126
+ accessibilityLabel,
127
+ adaptiveMask,
128
+ } = pipeline;
129
+
130
+ const textStyle = useMemo(
131
+ () => ({
132
+ ...nfStyle,
133
+ color: nfStyle.color ?? "#000000",
134
+ }),
135
+ [nfStyle],
136
+ );
137
+
138
+ const resolvedMask = mask ?? true;
139
+ const maskTop = adaptiveMask.top;
140
+ const maskBottom = adaptiveMask.bottom;
141
+ const { expansionTop, expansionBottom } = adaptiveMask;
142
+
143
+ // Step count scales with mask height — each step must be >=1px (sub-pixel Views collapse to 0).
144
+ const topSteps = Math.max(2, Math.round(maskTop));
145
+ const bottomSteps = Math.max(2, Math.round(maskBottom));
146
+
147
+ const gradientMaskElement = useMemo(() => {
148
+ if (!resolvedMask || !metrics) return null;
149
+ return (
150
+ <View style={{ flex: 1, flexDirection: "column" }}>
151
+ {/* Top fade: transparent -> opaque */}
152
+ {Array.from({ length: topSteps }, (_, i) => (
153
+ <View
154
+ key={`t${i}`}
155
+ style={{
156
+ height: maskTop / topSteps,
157
+ backgroundColor: `rgba(0,0,0,${i / (topSteps - 1)})`,
158
+ }}
159
+ />
160
+ ))}
161
+ {/* Middle: fully opaque */}
162
+ <View style={{ flex: 1, backgroundColor: "black" }} />
163
+ {/* Bottom fade: opaque -> transparent */}
164
+ {Array.from({ length: bottomSteps }, (_, i) => (
165
+ <View
166
+ key={`b${i}`}
167
+ style={{
168
+ height: maskBottom / bottomSteps,
169
+ backgroundColor: `rgba(0,0,0,${1 - i / (bottomSteps - 1)})`,
170
+ }}
171
+ />
172
+ ))}
173
+ </View>
174
+ );
175
+ }, [resolvedMask, metrics, maskTop, maskBottom, topSteps, bottomSteps]);
176
+
177
+ // Progressive mount: render a plain <Text> on the first frame, then swap to
178
+ // the full animated slot tree on the next frame. At mount time there is never
179
+ // a value change to animate, so the placeholder is visually identical while
180
+ // avoiding the ~80ms cost of instantiating dozens of useAnimatedStyle hooks.
181
+ const [slotsReady, setSlotsReady] = useState(false);
182
+ const metricsReady = !!metrics;
183
+
184
+ useEffect(() => {
185
+ if (!metricsReady) return;
186
+ const id = requestAnimationFrame(() => setSlotsReady(true));
187
+ return () => cancelAnimationFrame(id);
188
+ }, [metricsReady]);
189
+
190
+ if (!metrics || (layout.length === 0 && exitingEntries.size === 0)) {
191
+ return (
192
+ <View
193
+ accessible
194
+ accessibilityRole="text"
195
+ accessibilityLabel={accessibilityLabel}
196
+ onLayout={handleContainerLayout}
197
+ style={containerStyle}
198
+ >
199
+ <Text style={[textStyle, { textAlign }]}>{accessibilityLabel}</Text>
200
+ {MeasureElement}
201
+ </View>
202
+ );
203
+ }
204
+
205
+ {/* Placeholder branch: show plain Text while slot tree loads */}
206
+ if (!slotsReady) {
207
+ return (
208
+ <View
209
+ accessible
210
+ accessibilityRole="text"
211
+ accessibilityLabel={accessibilityLabel}
212
+ onLayout={handleContainerLayout}
213
+ style={[
214
+ containerStyle,
215
+ {
216
+ height: metrics.lineHeight + expansionTop + expansionBottom,
217
+ marginTop: -expansionTop,
218
+ marginBottom: -expansionBottom,
219
+ position: "relative",
220
+ overflow: "hidden",
221
+ },
222
+ ]}
223
+ >
224
+ <Text style={[textStyle, { textAlign }]}>{accessibilityLabel}</Text>
225
+ {MeasureElement}
226
+ </View>
227
+ );
228
+ }
229
+
230
+ const slots = renderSlots({
231
+ layout,
232
+ exitingEntries,
233
+ prevMap,
234
+ isInitialRender,
235
+ onExitComplete,
236
+ metrics,
237
+ textStyle,
238
+ resolvedTrend,
239
+ spinTiming: resolvedSpinTiming,
240
+ opacityTiming: resolvedOpacityTiming,
241
+ transformTiming: resolvedTransformTiming,
242
+ spinGenerations,
243
+ digitCountResolver: (key) => TIME_DIGIT_COUNTS[key],
244
+ maskTop,
245
+ maskBottom,
246
+ });
247
+
248
+ // Optionally wrap in MaskedView for gradient edge fade.
249
+ // Content must be inside a single wrapper View so MaskedView's native
250
+ // didUpdateReactSubviews always sees one stable child — avoids Fabric
251
+ // "Attempt to recycle a mounted view" crash from dynamic slot churn.
252
+ const maskedContent =
253
+ resolvedMask && gradientMaskElement ? (
254
+ <MaskedView maskElement={gradientMaskElement} style={{ flex: 1 }}>
255
+ <View style={{ flex: 1, position: "relative", top: expansionTop }}>
256
+ {MeasureElement}
257
+ {slots}
258
+ </View>
259
+ </MaskedView>
260
+ ) : (
261
+ <View style={{ flex: 1, position: "relative", top: expansionTop }}>
262
+ {MeasureElement}
263
+ {slots}
264
+ </View>
265
+ );
266
+
267
+ return (
268
+ <View
269
+ accessible
270
+ accessibilityRole="text"
271
+ accessibilityLabel={accessibilityLabel}
272
+ onLayout={handleContainerLayout}
273
+ style={[
274
+ containerStyle,
275
+ {
276
+ height: metrics.lineHeight + expansionTop + expansionBottom,
277
+ marginTop: -expansionTop,
278
+ marginBottom: -expansionBottom,
279
+ position: "relative",
280
+ overflow: "hidden",
281
+ },
282
+ ]}
283
+ >
284
+ {maskedContent}
285
+ </View>
286
+ );
287
+ };
@@ -0,0 +1,2 @@
1
+ export { NumberFlow } from "./NumberFlow";
2
+ export { TimeFlow } from "./TimeFlow";