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,257 @@
1
+ import { Group, LinearGradient, Paint, Rect as SkiaRect, vec } from "@shopify/react-native-skia";
2
+ import { useMemo } from "react";
3
+ import { MASK_WIDTH_RATIO } from "../core/constants";
4
+ import { computeKeyedLayout } from "../core/layout";
5
+ import { computeTimeStringLayout } from "../core/timeLayout";
6
+ import type { SkiaTimeFlowProps } from "../core/timeTypes";
7
+ import { useAccessibilityAnnouncement } from "../core/useAccessibilityAnnouncement";
8
+ import { useFlowPipeline } from "../core/useFlowPipeline";
9
+ import { useTimeFormatting } from "../core/useTimeFormatting";
10
+ import { useWorkletFormatting } from "../core/useWorkletFormatting";
11
+ import { TIME_DIGIT_COUNTS } from "../core/utils";
12
+ import { warnOnce } from "../core/warnings";
13
+ import { renderSlots } from "./renderSlots";
14
+ import { useGlyphMetrics } from "./useGlyphMetrics";
15
+
16
+ export const SkiaTimeFlow = ({
17
+ hours,
18
+ minutes,
19
+ seconds,
20
+ timestamp,
21
+ timezoneOffset,
22
+ sharedValue,
23
+ is24Hour = true,
24
+ padHours = true,
25
+ font,
26
+ color = "#000000",
27
+ x = 0,
28
+ y = 0,
29
+ width = 0,
30
+ textAlign = "left",
31
+ opacity,
32
+ spinTiming,
33
+ opacityTiming,
34
+ transformTiming,
35
+ trend,
36
+ animated,
37
+ respectMotionPreference,
38
+ continuous,
39
+ mask,
40
+ onAnimationsStart,
41
+ onAnimationsFinish,
42
+ }: SkiaTimeFlowProps) => {
43
+ const metrics = useGlyphMetrics(font);
44
+
45
+ if (__DEV__) {
46
+ if (!font) {
47
+ warnOnce(
48
+ "skia-tf-font",
49
+ "font is null — pass a loaded SkFont from useFont(). Component renders empty until font loads.",
50
+ );
51
+ }
52
+ }
53
+
54
+ const resolved = useMemo(() => {
55
+ if (timestamp !== undefined) {
56
+ const d = new Date(timestamp + (timezoneOffset ?? 0));
57
+ return {
58
+ hours: d.getUTCHours(),
59
+ minutes: d.getUTCMinutes(),
60
+ seconds: d.getUTCSeconds(),
61
+ };
62
+ }
63
+ return { hours, minutes, seconds };
64
+ }, [timestamp, timezoneOffset, hours, minutes, seconds]);
65
+
66
+ const resolvedHours = resolved.hours;
67
+ const resolvedMinutes = resolved.minutes;
68
+ const resolvedSeconds = resolved.seconds;
69
+
70
+ const hasHours = resolvedHours !== undefined;
71
+ const hasSeconds = resolvedSeconds !== undefined;
72
+
73
+ if (__DEV__) {
74
+ if (resolvedHours !== undefined && (resolvedHours < 0 || resolvedHours > 23)) {
75
+ warnOnce("skia-tf-hours", "hours must be 0-23.");
76
+ }
77
+ if (resolvedMinutes !== undefined && (resolvedMinutes < 0 || resolvedMinutes > 59)) {
78
+ warnOnce("skia-tf-minutes", "minutes must be 0-59.");
79
+ }
80
+ if (resolvedSeconds !== undefined && (resolvedSeconds < 0 || resolvedSeconds > 59)) {
81
+ warnOnce("skia-tf-seconds", "seconds must be 0-59.");
82
+ }
83
+ }
84
+
85
+ const totalSeconds =
86
+ (resolvedHours ?? 0) * 3600 + (resolvedMinutes ?? 0) * 60 + (resolvedSeconds ?? 0);
87
+
88
+ const keyedParts = useTimeFormatting(
89
+ resolvedHours,
90
+ // Fallback for sharedValue mode where minutes is undefined (unused — layout comes from string path)
91
+ resolvedMinutes ?? 0,
92
+ resolvedSeconds,
93
+ is24Hour,
94
+ padHours,
95
+ );
96
+
97
+ const workletDigitValues = useWorkletFormatting(sharedValue, "", "");
98
+
99
+ const layout = useMemo(() => {
100
+ if (!metrics) return [];
101
+
102
+ if (sharedValue && resolvedHours === undefined && resolvedMinutes === undefined) {
103
+ return computeTimeStringLayout(
104
+ sharedValue.value,
105
+ metrics,
106
+ width,
107
+ textAlign,
108
+ hasHours,
109
+ hasSeconds,
110
+ );
111
+ }
112
+
113
+ if (keyedParts.length === 0) return [];
114
+ return computeKeyedLayout(keyedParts, metrics, width, textAlign);
115
+ }, [
116
+ metrics,
117
+ keyedParts,
118
+ width,
119
+ textAlign,
120
+ sharedValue,
121
+ resolvedHours,
122
+ resolvedMinutes,
123
+ hasHours,
124
+ hasSeconds,
125
+ ]);
126
+
127
+ const pipeline = useFlowPipeline({
128
+ keyedParts,
129
+ trendValue: totalSeconds,
130
+ layout,
131
+ metrics,
132
+ animated,
133
+ respectMotionPreference,
134
+ spinTiming,
135
+ opacityTiming,
136
+ transformTiming,
137
+ trend,
138
+ continuous,
139
+ mask,
140
+ onAnimationsStart,
141
+ onAnimationsFinish,
142
+ });
143
+
144
+ const {
145
+ resolvedSpinTiming,
146
+ resolvedOpacityTiming,
147
+ resolvedTransformTiming,
148
+ resolvedTrend,
149
+ spinGenerations,
150
+ prevMap,
151
+ isInitialRender,
152
+ exitingEntries,
153
+ onExitComplete,
154
+ accessibilityLabel,
155
+ adaptiveMask,
156
+ } = pipeline;
157
+
158
+ useAccessibilityAnnouncement(accessibilityLabel);
159
+
160
+ if (!font || !metrics) {
161
+ return <Group />;
162
+ }
163
+
164
+ if (layout.length === 0 && exitingEntries.size === 0) {
165
+ return <Group />;
166
+ }
167
+
168
+ const baseY = y;
169
+ const resolvedMask = mask ?? true;
170
+
171
+ const maskTopHeight = resolvedMask ? adaptiveMask.top : 0;
172
+ const maskBottomHeight = resolvedMask ? adaptiveMask.bottom : 0;
173
+ const maskWidth = resolvedMask ? MASK_WIDTH_RATIO * metrics.lineHeight : 0;
174
+
175
+ // Content bounds from layout (in local coordinate space before translateX)
176
+ const contentLeft = layout.reduce((min, entry) => Math.min(min, entry.x), Infinity);
177
+ const contentRight = layout.reduce((max, entry) => Math.max(max, entry.x + entry.width), 0);
178
+ const contentWidth = layout.length > 0 ? contentRight - contentLeft : 0;
179
+
180
+ const content = (
181
+ <Group transform={[{ translateX: x }]}>
182
+ {renderSlots({
183
+ layout,
184
+ exitingEntries,
185
+ prevMap,
186
+ isInitialRender,
187
+ onExitComplete,
188
+ metrics,
189
+ font,
190
+ color,
191
+ baseY,
192
+ resolvedTrend,
193
+ spinTiming: resolvedSpinTiming,
194
+ opacityTiming: resolvedOpacityTiming,
195
+ transformTiming: resolvedTransformTiming,
196
+ spinGenerations,
197
+ digitCountResolver: (key) => TIME_DIGIT_COUNTS[key],
198
+ maskTop: maskTopHeight,
199
+ maskBottom: maskBottomHeight,
200
+ workletDigitValues,
201
+ })}
202
+ </Group>
203
+ );
204
+
205
+ /**
206
+ * Container-level 2D gradient mask matching web NumberFlow's vignette.
207
+ * Horizontal: fade extends outside text edges (for enter/exit animations).
208
+ * Vertical: fade is within the text line height (digits roll through it).
209
+ * Two DstIn layers compose: final_alpha = content * h_alpha * v_alpha.
210
+ */
211
+ const maskLeft = x + contentLeft - maskWidth;
212
+ const maskRight = x + contentRight + maskWidth;
213
+ const maskY = baseY + metrics.ascent;
214
+ const maskTotalWidth = contentWidth + 2 * maskWidth;
215
+ const maskTotalHeight = metrics.lineHeight;
216
+ const hRatio = maskTotalWidth > 0 ? maskWidth / maskTotalWidth : 0;
217
+ const vRatioTop = maskTotalHeight > 0 ? maskTopHeight / maskTotalHeight : 0;
218
+ const vRatioBottom = maskTotalHeight > 0 ? maskBottomHeight / maskTotalHeight : 0;
219
+
220
+ const maskedContent = resolvedMask ? (
221
+ <Group layer={<Paint />}>
222
+ {content}
223
+
224
+ {/* Horizontal fade */}
225
+ <Group layer={<Paint blendMode="dstIn" />}>
226
+ <SkiaRect height={maskTotalHeight} width={maskTotalWidth} x={maskLeft} y={maskY}>
227
+ <LinearGradient
228
+ colors={["transparent", "black", "black", "transparent"]}
229
+ end={vec(maskRight, 0)}
230
+ positions={[0, hRatio, 1 - hRatio, 1]}
231
+ start={vec(maskLeft, 0)}
232
+ />
233
+ </SkiaRect>
234
+ </Group>
235
+
236
+ {/* Vertical fade */}
237
+ <Group layer={<Paint blendMode="dstIn" />}>
238
+ <SkiaRect height={maskTotalHeight} width={maskTotalWidth} x={maskLeft} y={maskY}>
239
+ <LinearGradient
240
+ colors={["transparent", "black", "black", "transparent"]}
241
+ end={vec(0, maskY + maskTotalHeight)}
242
+ positions={[0, vRatioTop, 1 - vRatioBottom, 1]}
243
+ start={vec(0, maskY)}
244
+ />
245
+ </SkiaRect>
246
+ </Group>
247
+ </Group>
248
+ ) : (
249
+ content
250
+ );
251
+
252
+ if (opacity) {
253
+ return <Group layer={<Paint opacity={opacity} />}>{maskedContent}</Group>;
254
+ }
255
+
256
+ return maskedContent;
257
+ };
@@ -0,0 +1,120 @@
1
+ import { Group, Paint, Text as SkiaText } from "@shopify/react-native-skia";
2
+ import React, { useMemo, useState } from "react";
3
+ import {
4
+ Easing,
5
+ makeMutable,
6
+ type SharedValue,
7
+ useAnimatedReaction,
8
+ useDerivedValue,
9
+ withTiming,
10
+ } from "react-native-reanimated";
11
+ import { getSuperscriptTransform } from "../core/superscript";
12
+ import type { SkiaNumberFlowProps, TimingConfig } from "../core/types";
13
+ import { useAnimatedX } from "../core/useAnimatedX";
14
+ import { useSlotOpacity } from "../core/useSlotOpacity";
15
+
16
+ // Timing for smooth prefix/suffix animation during scrubbing
17
+ const WORKLET_X_ANIMATION_MS = 150;
18
+
19
+ interface SymbolSlotProps {
20
+ char: string;
21
+ targetX: number;
22
+ baseY: number;
23
+ ascent: number;
24
+ color: string;
25
+ font: NonNullable<SkiaNumberFlowProps["font"]>;
26
+ opacityTiming: TimingConfig;
27
+ transformTiming: TimingConfig;
28
+ entering: boolean;
29
+ exiting: boolean;
30
+ exitKey?: string;
31
+ onExitComplete?: (key: string) => void;
32
+ workletLayout?: SharedValue<{ x: number; width: number }[]>;
33
+ slotIndex?: number;
34
+ superscript?: boolean;
35
+ }
36
+
37
+ export const SymbolSlot = React.memo(
38
+ ({
39
+ char,
40
+ targetX,
41
+ baseY,
42
+ ascent,
43
+ color,
44
+ font,
45
+ opacityTiming,
46
+ transformTiming,
47
+ entering,
48
+ exiting,
49
+ exitKey,
50
+ onExitComplete,
51
+ workletLayout,
52
+ slotIndex,
53
+ superscript,
54
+ }: SymbolSlotProps) => {
55
+ const slotOpacity = useSlotOpacity({
56
+ entering,
57
+ exiting,
58
+ opacityTiming,
59
+ exitKey,
60
+ onExitComplete,
61
+ });
62
+
63
+ const animatedX = useAnimatedX(targetX, exiting, transformTiming);
64
+
65
+ /**
66
+ * Animated X position (worklet-driven).
67
+ * Unlike digits (which need instant response for scrubbing),
68
+ * symbols (prefix/suffix) should animate smoothly when the layout
69
+ * shifts due to digit count changes. This prevents jarring jumps.
70
+ */
71
+ const [workletAnimatedX] = useState(() => makeMutable(targetX));
72
+
73
+ useAnimatedReaction(
74
+ () => {
75
+ const wl = workletLayout?.value;
76
+ if (wl && slotIndex !== undefined && slotIndex < wl.length) {
77
+ return wl[slotIndex].x;
78
+ }
79
+ return null;
80
+ },
81
+ (newX, prevX) => {
82
+ if (newX !== null && newX !== prevX) {
83
+ workletAnimatedX.value = withTiming(newX, {
84
+ duration: WORKLET_X_ANIMATION_MS,
85
+ easing: Easing.out(Easing.ease),
86
+ });
87
+ }
88
+ },
89
+ );
90
+
91
+ const groupTransform = useDerivedValue(() => {
92
+ const wl = workletLayout?.value;
93
+ if (wl && slotIndex !== undefined && slotIndex < wl.length) {
94
+ return [{ translateX: workletAnimatedX.value }];
95
+ }
96
+ return [{ translateX: animatedX.value }];
97
+ });
98
+
99
+ const superscriptTransform = useMemo(
100
+ () => (superscript ? getSuperscriptTransform(baseY, ascent) : undefined),
101
+ [superscript, baseY, ascent],
102
+ );
103
+
104
+ const opacityPaint = useMemo(() => <Paint opacity={slotOpacity} />, [slotOpacity]);
105
+
106
+ const textElement = <SkiaText color={color} font={font} text={char} x={0} y={baseY} />;
107
+
108
+ return (
109
+ <Group layer={opacityPaint} transform={groupTransform}>
110
+ {superscriptTransform ? (
111
+ <Group transform={superscriptTransform}>{textElement}</Group>
112
+ ) : (
113
+ textElement
114
+ )}
115
+ </Group>
116
+ );
117
+ },
118
+ );
119
+
120
+ SymbolSlot.displayName = "SymbolSlot";
@@ -0,0 +1,5 @@
1
+ export { SkiaNumberFlow } from "./SkiaNumberFlow";
2
+ export { SkiaTimeFlow } from "./SkiaTimeFlow";
3
+ export { useSkiaFont } from "./useSkiaFont";
4
+ export type { SkiaNumberFlowProps } from "../core/types";
5
+ export type { SkiaTimeFlowProps } from "../core/timeTypes";
@@ -0,0 +1,180 @@
1
+ import type { SkFont } from "@shopify/react-native-skia";
2
+ import type { SharedValue } from "react-native-reanimated";
3
+ import type { CharLayout } from "../core/layout";
4
+ import type { GlyphMetrics, TimingConfig, Trend } from "../core/types";
5
+ import { DigitSlot } from "./DigitSlot";
6
+ import { SymbolSlot } from "./SymbolSlot";
7
+
8
+ interface RenderSlotsParams {
9
+ layout: CharLayout[];
10
+ exitingEntries: Map<string, CharLayout>;
11
+ prevMap: Map<string, CharLayout>;
12
+ isInitialRender: boolean;
13
+ onExitComplete: (key: string) => void;
14
+ metrics: GlyphMetrics;
15
+ font: SkFont;
16
+ color: string;
17
+ baseY: number;
18
+ resolvedTrend: Trend;
19
+ spinTiming: TimingConfig;
20
+ opacityTiming: TimingConfig;
21
+ transformTiming: TimingConfig;
22
+ spinGenerations: Map<string, number> | undefined;
23
+ digitCountResolver: (key: string) => number;
24
+ maskTop: number;
25
+ maskBottom: number;
26
+ digitStrings?: string[];
27
+ workletDigitValues?: SharedValue<number>[] | null;
28
+ workletLayout?: SharedValue<{ x: number; width: number }[]>;
29
+ }
30
+
31
+ /**
32
+ * Renders the active and exiting digit/symbol slot tree for Skia.
33
+ *
34
+ * Shared between SkiaNumberFlow and SkiaTimeFlow. Behavioral differences:
35
+ * - `digitCountResolver`: NumberFlow uses getDigitCount, TimeFlow uses TIME_DIGIT_COUNTS
36
+ * - `digitStrings`: NumberFlow passes locale-specific digits, TimeFlow omits (Latin fallback)
37
+ * - `workletLayout`: NumberFlow passes for scrubbing positioning, TimeFlow omits
38
+ */
39
+ export function renderSlots({
40
+ layout,
41
+ exitingEntries,
42
+ prevMap,
43
+ isInitialRender,
44
+ onExitComplete,
45
+ metrics,
46
+ font,
47
+ color,
48
+ baseY,
49
+ resolvedTrend,
50
+ spinTiming,
51
+ opacityTiming,
52
+ transformTiming,
53
+ spinGenerations,
54
+ digitCountResolver,
55
+ maskTop,
56
+ maskBottom,
57
+ digitStrings,
58
+ workletDigitValues,
59
+ workletLayout,
60
+ }: RenderSlotsParams) {
61
+ let digitIndex = 0;
62
+ let slotIndex = 0;
63
+
64
+ return (
65
+ <>
66
+ {/* Active entries */}
67
+ {layout.map((entry) => {
68
+ const isEntering = !isInitialRender && !prevMap.has(entry.key);
69
+ const currentSlotIndex = slotIndex++;
70
+
71
+ if (entry.isDigit) {
72
+ const wdv = workletDigitValues?.[digitIndex];
73
+ const digitCount = digitCountResolver(entry.key);
74
+ const spinGeneration = spinGenerations?.get(entry.key);
75
+
76
+ digitIndex++;
77
+
78
+ return (
79
+ <DigitSlot
80
+ baseY={baseY}
81
+ charWidth={entry.width}
82
+ color={color}
83
+ continuousSpinGeneration={spinGeneration}
84
+ digitCount={digitCount}
85
+ digitStrings={digitStrings}
86
+ digitValue={entry.digitValue}
87
+ entering={isEntering}
88
+ exiting={false}
89
+ font={font}
90
+ key={entry.key}
91
+ maskTop={maskTop}
92
+ maskBottom={maskBottom}
93
+ metrics={metrics}
94
+ opacityTiming={opacityTiming}
95
+ slotIndex={workletLayout ? currentSlotIndex : undefined}
96
+ spinTiming={spinTiming}
97
+ superscript={entry.superscript}
98
+ targetX={entry.x}
99
+ transformTiming={transformTiming}
100
+ trend={resolvedTrend}
101
+ workletDigitValue={wdv}
102
+ workletLayout={workletLayout}
103
+ />
104
+ );
105
+ }
106
+
107
+ return (
108
+ <SymbolSlot
109
+ ascent={metrics.ascent}
110
+ baseY={baseY}
111
+ char={entry.char}
112
+ color={color}
113
+ entering={isEntering}
114
+ exiting={false}
115
+ font={font}
116
+ key={entry.key}
117
+ opacityTiming={opacityTiming}
118
+ slotIndex={workletLayout ? currentSlotIndex : undefined}
119
+ superscript={entry.superscript}
120
+ targetX={entry.x}
121
+ transformTiming={transformTiming}
122
+ workletLayout={workletLayout}
123
+ />
124
+ );
125
+ })}
126
+
127
+ {/* Exiting entries */}
128
+ {Array.from(exitingEntries.entries()).map(([key, entry]) => {
129
+ if (entry.isDigit) {
130
+ const digitCount = digitCountResolver(key);
131
+
132
+ return (
133
+ <DigitSlot
134
+ baseY={baseY}
135
+ charWidth={entry.width}
136
+ color={color}
137
+ digitCount={digitCount}
138
+ digitStrings={digitStrings}
139
+ digitValue={entry.digitValue}
140
+ entering={false}
141
+ exitKey={key}
142
+ exiting
143
+ font={font}
144
+ key={key}
145
+ maskTop={maskTop}
146
+ maskBottom={maskBottom}
147
+ metrics={metrics}
148
+ onExitComplete={onExitComplete}
149
+ opacityTiming={opacityTiming}
150
+ spinTiming={spinTiming}
151
+ superscript={entry.superscript}
152
+ targetX={entry.x}
153
+ transformTiming={transformTiming}
154
+ trend={resolvedTrend}
155
+ />
156
+ );
157
+ }
158
+
159
+ return (
160
+ <SymbolSlot
161
+ ascent={metrics.ascent}
162
+ baseY={baseY}
163
+ char={entry.char}
164
+ color={color}
165
+ entering={false}
166
+ exitKey={key}
167
+ exiting
168
+ font={font}
169
+ key={key}
170
+ onExitComplete={onExitComplete}
171
+ opacityTiming={opacityTiming}
172
+ superscript={entry.superscript}
173
+ targetX={entry.x}
174
+ transformTiming={transformTiming}
175
+ />
176
+ );
177
+ })}
178
+ </>
179
+ );
180
+ }
@@ -0,0 +1,79 @@
1
+ import type { SkFont } from "@shopify/react-native-skia";
2
+ import { useMemo } from "react";
3
+ import { MEASURABLE_CHARS } from "../core/constants";
4
+ import type { GlyphMetrics } from "../core/types";
5
+
6
+ /**
7
+ * Pre-computes glyph width lookup table and line metrics from an SkFont.
8
+ * Runs once on font load. All measurements use advance widths (not bounding boxes)
9
+ * for correct text layout spacing.
10
+ *
11
+ * Digit slots use `maxDigitWidth` (the widest of 0-9) for the clip window
12
+ * so all 10 digit glyphs fit during rolling. Individual digit positions
13
+ * within the clip use proportional charWidths for natural spacing.
14
+ *
15
+ * @param font - The SkFont to measure
16
+ * @param additionalChars - Optional additional characters to measure (e.g., from prefix/suffix with diacritics)
17
+ */
18
+ export function useGlyphMetrics(
19
+ font: SkFont | null,
20
+ additionalChars?: string,
21
+ localeDigitStrings?: string[],
22
+ ): GlyphMetrics | null {
23
+ return useMemo(() => {
24
+ if (!font) return null;
25
+
26
+ /**
27
+ * Combine base chars with any additional chars from prefix/suffix so
28
+ * diacritics and special characters get proper width measurements.
29
+ */
30
+ let charsToMeasure = MEASURABLE_CHARS;
31
+ if (additionalChars) {
32
+ const uniqueAdditional = additionalChars
33
+ .split("")
34
+ .filter((c) => !MEASURABLE_CHARS.includes(c))
35
+ .join("");
36
+ if (uniqueAdditional) {
37
+ charsToMeasure = MEASURABLE_CHARS + uniqueAdditional;
38
+ }
39
+ }
40
+
41
+ const glyphIDs = font.getGlyphIDs(charsToMeasure);
42
+ const widths = font.getGlyphWidths(glyphIDs);
43
+
44
+ const charWidths: Record<string, number> = {};
45
+ for (let i = 0; i < charsToMeasure.length; i++) {
46
+ charWidths[charsToMeasure[i]] = widths[i];
47
+ }
48
+
49
+ let maxDigitWidth = 0;
50
+ const digitChars = localeDigitStrings ?? Array.from({ length: 10 }, (_, d) => String(d));
51
+ for (const dc of digitChars) {
52
+ const w = charWidths[dc] ?? 0;
53
+ if (w > maxDigitWidth) maxDigitWidth = w;
54
+ }
55
+
56
+ const metrics = font.getMetrics();
57
+ // ascent is negative (above baseline), descent is positive (below baseline)
58
+ const lineHeight = Math.ceil(metrics.descent - metrics.ascent);
59
+
60
+ // Per-character tight vertical bounds from measureText SkRect.
61
+ // rect.y = top of glyph (negative = above baseline),
62
+ // rect.y + rect.height = bottom of glyph (positive = below baseline).
63
+ const charBounds: Record<string, { top: number; bottom: number }> = {};
64
+ for (let i = 0; i < charsToMeasure.length; i++) {
65
+ const char = charsToMeasure[i];
66
+ const rect = font.measureText(char);
67
+ charBounds[char] = { top: rect.y, bottom: rect.y + rect.height };
68
+ }
69
+
70
+ return {
71
+ charWidths,
72
+ maxDigitWidth,
73
+ lineHeight,
74
+ ascent: metrics.ascent,
75
+ descent: metrics.descent,
76
+ charBounds,
77
+ };
78
+ }, [font, additionalChars, localeDigitStrings]);
79
+ }