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.
- package/LICENSE +21 -0
- package/README.md +44 -0
- package/lib/module/core/constants.js +21 -0
- package/lib/module/core/constants.js.map +1 -0
- package/lib/module/core/intlHelpers.js +310 -0
- package/lib/module/core/intlHelpers.js.map +1 -0
- package/lib/module/core/layout.js +71 -0
- package/lib/module/core/layout.js.map +1 -0
- package/lib/module/core/mask.js +50 -0
- package/lib/module/core/mask.js.map +1 -0
- package/lib/module/core/numerals/detection.js +105 -0
- package/lib/module/core/numerals/detection.js.map +1 -0
- package/lib/module/core/numerals/digits.js +128 -0
- package/lib/module/core/numerals/digits.js.map +1 -0
- package/lib/module/core/numerals/index.js +5 -0
- package/lib/module/core/numerals/index.js.map +1 -0
- package/lib/module/core/numerals/tables.js +114 -0
- package/lib/module/core/numerals/tables.js.map +1 -0
- package/lib/module/core/superscript.js +31 -0
- package/lib/module/core/superscript.js.map +1 -0
- package/lib/module/core/timeLayout.js +98 -0
- package/lib/module/core/timeLayout.js.map +1 -0
- package/lib/module/core/timeTypes.js +4 -0
- package/lib/module/core/timeTypes.js.map +1 -0
- package/lib/module/core/timing.js +45 -0
- package/lib/module/core/timing.js.map +1 -0
- package/lib/module/core/types.js +58 -0
- package/lib/module/core/types.js.map +1 -0
- package/lib/module/core/useAccessibilityAnnouncement.js +27 -0
- package/lib/module/core/useAccessibilityAnnouncement.js.map +1 -0
- package/lib/module/core/useAnimatedX.js +25 -0
- package/lib/module/core/useAnimatedX.js.map +1 -0
- package/lib/module/core/useAnimationLifecycle.js +37 -0
- package/lib/module/core/useAnimationLifecycle.js.map +1 -0
- package/lib/module/core/useCanAnimate.js +22 -0
- package/lib/module/core/useCanAnimate.js.map +1 -0
- package/lib/module/core/useContinuousSpin.js +89 -0
- package/lib/module/core/useContinuousSpin.js.map +1 -0
- package/lib/module/core/useDebouncedWidths.js +74 -0
- package/lib/module/core/useDebouncedWidths.js.map +1 -0
- package/lib/module/core/useDigitAnimation.js +138 -0
- package/lib/module/core/useDigitAnimation.js.map +1 -0
- package/lib/module/core/useFlowPipeline.js +85 -0
- package/lib/module/core/useFlowPipeline.js.map +1 -0
- package/lib/module/core/useFormattedValue.js +28 -0
- package/lib/module/core/useFormattedValue.js.map +1 -0
- package/lib/module/core/useLayoutDiff.js +59 -0
- package/lib/module/core/useLayoutDiff.js.map +1 -0
- package/lib/module/core/useNumberFormatting.js +158 -0
- package/lib/module/core/useNumberFormatting.js.map +1 -0
- package/lib/module/core/useSlotOpacity.js +53 -0
- package/lib/module/core/useSlotOpacity.js.map +1 -0
- package/lib/module/core/useTimeFormatting.js +74 -0
- package/lib/module/core/useTimeFormatting.js.map +1 -0
- package/lib/module/core/useTimingResolution.js +21 -0
- package/lib/module/core/useTimingResolution.js.map +1 -0
- package/lib/module/core/useWorkletFormatting.js +49 -0
- package/lib/module/core/useWorkletFormatting.js.map +1 -0
- package/lib/module/core/utils.js +132 -0
- package/lib/module/core/utils.js.map +1 -0
- package/lib/module/core/warnings.js +10 -0
- package/lib/module/core/warnings.js.map +1 -0
- package/lib/module/index.js +7 -0
- package/lib/module/index.js.map +1 -0
- package/lib/module/native/DigitSlot.js +163 -0
- package/lib/module/native/DigitSlot.js.map +1 -0
- package/lib/module/native/NumberFlow.js +244 -0
- package/lib/module/native/NumberFlow.js.map +1 -0
- package/lib/module/native/SymbolSlot.js +52 -0
- package/lib/module/native/SymbolSlot.js.map +1 -0
- package/lib/module/native/TimeFlow.js +270 -0
- package/lib/module/native/TimeFlow.js.map +1 -0
- package/lib/module/native/index.js +5 -0
- package/lib/module/native/index.js.map +1 -0
- package/lib/module/native/renderSlots.js +108 -0
- package/lib/module/native/renderSlots.js.map +1 -0
- package/lib/module/native/types.js +4 -0
- package/lib/module/native/types.js.map +1 -0
- package/lib/module/native/useMeasuredGlyphMetrics.js +156 -0
- package/lib/module/native/useMeasuredGlyphMetrics.js.map +1 -0
- package/lib/module/package.json +1 -0
- package/lib/module/skia/DigitSlot.js +171 -0
- package/lib/module/skia/DigitSlot.js.map +1 -0
- package/lib/module/skia/SkiaNumberFlow.js +430 -0
- package/lib/module/skia/SkiaNumberFlow.js.map +1 -0
- package/lib/module/skia/SkiaTimeFlow.js +226 -0
- package/lib/module/skia/SkiaTimeFlow.js.map +1 -0
- package/lib/module/skia/SymbolSlot.js +92 -0
- package/lib/module/skia/SymbolSlot.js.map +1 -0
- package/lib/module/skia/index.js +6 -0
- package/lib/module/skia/index.js.map +1 -0
- package/lib/module/skia/renderSlots.js +131 -0
- package/lib/module/skia/renderSlots.js.map +1 -0
- package/lib/module/skia/useGlyphMetrics.js +72 -0
- package/lib/module/skia/useGlyphMetrics.js.map +1 -0
- package/lib/module/skia/useScrubbing.js +165 -0
- package/lib/module/skia/useScrubbing.js.map +1 -0
- package/lib/module/skia/useSkiaFont.js +23 -0
- package/lib/module/skia/useSkiaFont.js.map +1 -0
- package/lib/typescript/core/constants.d.ts +15 -0
- package/lib/typescript/core/constants.d.ts.map +1 -0
- package/lib/typescript/core/intlHelpers.d.ts +10 -0
- package/lib/typescript/core/intlHelpers.d.ts.map +1 -0
- package/lib/typescript/core/layout.d.ts +22 -0
- package/lib/typescript/core/layout.d.ts.map +1 -0
- package/lib/typescript/core/mask.d.ts +18 -0
- package/lib/typescript/core/mask.d.ts.map +1 -0
- package/lib/typescript/core/numerals/detection.d.ts +17 -0
- package/lib/typescript/core/numerals/detection.d.ts.map +1 -0
- package/lib/typescript/core/numerals/digits.d.ts +43 -0
- package/lib/typescript/core/numerals/digits.d.ts.map +1 -0
- package/lib/typescript/core/numerals/index.d.ts +3 -0
- package/lib/typescript/core/numerals/index.d.ts.map +1 -0
- package/lib/typescript/core/numerals/tables.d.ts +32 -0
- package/lib/typescript/core/numerals/tables.d.ts.map +1 -0
- package/lib/typescript/core/superscript.d.ts +16 -0
- package/lib/typescript/core/superscript.d.ts.map +1 -0
- package/lib/typescript/core/timeLayout.d.ts +19 -0
- package/lib/typescript/core/timeLayout.d.ts.map +1 -0
- package/lib/typescript/core/timeTypes.d.ts +80 -0
- package/lib/typescript/core/timeTypes.d.ts.map +1 -0
- package/lib/typescript/core/timing.d.ts +6 -0
- package/lib/typescript/core/timing.d.ts.map +1 -0
- package/lib/typescript/core/types.d.ts +165 -0
- package/lib/typescript/core/types.d.ts.map +1 -0
- package/lib/typescript/core/useAccessibilityAnnouncement.d.ts +10 -0
- package/lib/typescript/core/useAccessibilityAnnouncement.d.ts.map +1 -0
- package/lib/typescript/core/useAnimatedX.d.ts +9 -0
- package/lib/typescript/core/useAnimatedX.d.ts.map +1 -0
- package/lib/typescript/core/useAnimationLifecycle.d.ts +14 -0
- package/lib/typescript/core/useAnimationLifecycle.d.ts.map +1 -0
- package/lib/typescript/core/useCanAnimate.d.ts +14 -0
- package/lib/typescript/core/useCanAnimate.d.ts.map +1 -0
- package/lib/typescript/core/useContinuousSpin.d.ts +23 -0
- package/lib/typescript/core/useContinuousSpin.d.ts.map +1 -0
- package/lib/typescript/core/useDebouncedWidths.d.ts +17 -0
- package/lib/typescript/core/useDebouncedWidths.d.ts.map +1 -0
- package/lib/typescript/core/useDigitAnimation.d.ts +38 -0
- package/lib/typescript/core/useDigitAnimation.d.ts.map +1 -0
- package/lib/typescript/core/useFlowPipeline.d.ts +46 -0
- package/lib/typescript/core/useFlowPipeline.d.ts.map +1 -0
- package/lib/typescript/core/useFormattedValue.d.ts +14 -0
- package/lib/typescript/core/useFormattedValue.d.ts.map +1 -0
- package/lib/typescript/core/useLayoutDiff.d.ts +18 -0
- package/lib/typescript/core/useLayoutDiff.d.ts.map +1 -0
- package/lib/typescript/core/useNumberFormatting.d.ts +18 -0
- package/lib/typescript/core/useNumberFormatting.d.ts.map +1 -0
- package/lib/typescript/core/useSlotOpacity.d.ts +18 -0
- package/lib/typescript/core/useSlotOpacity.d.ts.map +1 -0
- package/lib/typescript/core/useTimeFormatting.d.ts +22 -0
- package/lib/typescript/core/useTimeFormatting.d.ts.map +1 -0
- package/lib/typescript/core/useTimingResolution.d.ts +13 -0
- package/lib/typescript/core/useTimingResolution.d.ts.map +1 -0
- package/lib/typescript/core/useWorkletFormatting.d.ts +14 -0
- package/lib/typescript/core/useWorkletFormatting.d.ts.map +1 -0
- package/lib/typescript/core/utils.d.ts +44 -0
- package/lib/typescript/core/utils.d.ts.map +1 -0
- package/lib/typescript/core/warnings.d.ts +2 -0
- package/lib/typescript/core/warnings.d.ts.map +1 -0
- package/lib/typescript/index.d.ts +8 -0
- package/lib/typescript/index.d.ts.map +1 -0
- package/lib/typescript/native/DigitSlot.d.ts +27 -0
- package/lib/typescript/native/DigitSlot.d.ts.map +1 -0
- package/lib/typescript/native/NumberFlow.d.ts +3 -0
- package/lib/typescript/native/NumberFlow.d.ts.map +1 -0
- package/lib/typescript/native/SymbolSlot.d.ts +19 -0
- package/lib/typescript/native/SymbolSlot.d.ts.map +1 -0
- package/lib/typescript/native/TimeFlow.d.ts +3 -0
- package/lib/typescript/native/TimeFlow.d.ts.map +1 -0
- package/lib/typescript/native/index.d.ts +3 -0
- package/lib/typescript/native/index.d.ts.map +1 -0
- package/lib/typescript/native/renderSlots.d.ts +31 -0
- package/lib/typescript/native/renderSlots.d.ts.map +1 -0
- package/lib/typescript/native/types.d.ts +36 -0
- package/lib/typescript/native/types.d.ts.map +1 -0
- package/lib/typescript/native/useMeasuredGlyphMetrics.d.ts +8 -0
- package/lib/typescript/native/useMeasuredGlyphMetrics.d.ts.map +1 -0
- package/lib/typescript/package.json +1 -0
- package/lib/typescript/skia/DigitSlot.d.ts +35 -0
- package/lib/typescript/skia/DigitSlot.d.ts.map +1 -0
- package/lib/typescript/skia/SkiaNumberFlow.d.ts +3 -0
- package/lib/typescript/skia/SkiaNumberFlow.d.ts.map +1 -0
- package/lib/typescript/skia/SkiaTimeFlow.d.ts +3 -0
- package/lib/typescript/skia/SkiaTimeFlow.d.ts.map +1 -0
- package/lib/typescript/skia/SymbolSlot.d.ts +26 -0
- package/lib/typescript/skia/SymbolSlot.d.ts.map +1 -0
- package/lib/typescript/skia/index.d.ts +6 -0
- package/lib/typescript/skia/index.d.ts.map +1 -0
- package/lib/typescript/skia/renderSlots.d.ts +40 -0
- package/lib/typescript/skia/renderSlots.d.ts.map +1 -0
- package/lib/typescript/skia/useGlyphMetrics.d.ts +16 -0
- package/lib/typescript/skia/useGlyphMetrics.d.ts.map +1 -0
- package/lib/typescript/skia/useScrubbing.d.ts +59 -0
- package/lib/typescript/skia/useScrubbing.d.ts.map +1 -0
- package/lib/typescript/skia/useSkiaFont.d.ts +13 -0
- package/lib/typescript/skia/useSkiaFont.d.ts.map +1 -0
- package/package.json +104 -0
- package/src/core/constants.ts +20 -0
- package/src/core/intlHelpers.ts +351 -0
- package/src/core/layout.ts +108 -0
- package/src/core/mask.ts +72 -0
- package/src/core/numerals/detection.ts +112 -0
- package/src/core/numerals/digits.ts +102 -0
- package/src/core/numerals/index.ts +9 -0
- package/src/core/numerals/tables.ts +112 -0
- package/src/core/superscript.ts +27 -0
- package/src/core/timeLayout.ts +119 -0
- package/src/core/timeTypes.ts +88 -0
- package/src/core/timing.ts +60 -0
- package/src/core/types.ts +189 -0
- package/src/core/useAccessibilityAnnouncement.ts +27 -0
- package/src/core/useAnimatedX.ts +30 -0
- package/src/core/useAnimationLifecycle.ts +54 -0
- package/src/core/useCanAnimate.ts +21 -0
- package/src/core/useContinuousSpin.ts +112 -0
- package/src/core/useDebouncedWidths.ts +93 -0
- package/src/core/useDigitAnimation.ts +192 -0
- package/src/core/useFlowPipeline.ts +126 -0
- package/src/core/useFormattedValue.ts +32 -0
- package/src/core/useLayoutDiff.ts +71 -0
- package/src/core/useNumberFormatting.ts +164 -0
- package/src/core/useSlotOpacity.ts +66 -0
- package/src/core/useTimeFormatting.ts +95 -0
- package/src/core/useTimingResolution.ts +47 -0
- package/src/core/useWorkletFormatting.ts +59 -0
- package/src/core/utils.ts +149 -0
- package/src/core/warnings.ts +8 -0
- package/src/index.ts +15 -0
- package/src/native/DigitSlot.tsx +203 -0
- package/src/native/NumberFlow.tsx +287 -0
- package/src/native/SymbolSlot.tsx +68 -0
- package/src/native/TimeFlow.tsx +287 -0
- package/src/native/index.ts +2 -0
- package/src/native/renderSlots.tsx +150 -0
- package/src/native/types.ts +40 -0
- package/src/native/useMeasuredGlyphMetrics.tsx +205 -0
- package/src/skia/DigitSlot.tsx +221 -0
- package/src/skia/SkiaNumberFlow.tsx +506 -0
- package/src/skia/SkiaTimeFlow.tsx +257 -0
- package/src/skia/SymbolSlot.tsx +120 -0
- package/src/skia/index.ts +5 -0
- package/src/skia/renderSlots.tsx +180 -0
- package/src/skia/useGlyphMetrics.ts +79 -0
- package/src/skia/useScrubbing.ts +223 -0
- package/src/skia/useSkiaFont.ts +25 -0
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import { useCallback, useLayoutEffect, useRef, useState } from "react";
|
|
2
|
+
import {
|
|
3
|
+
makeMutable,
|
|
4
|
+
runOnJS,
|
|
5
|
+
type SharedValue,
|
|
6
|
+
useAnimatedReaction,
|
|
7
|
+
withTiming,
|
|
8
|
+
} from "react-native-reanimated";
|
|
9
|
+
import { DIGIT_COUNT } from "./constants";
|
|
10
|
+
import type { TimingConfig, Trend } from "./types";
|
|
11
|
+
import { useSlotOpacity } from "./useSlotOpacity";
|
|
12
|
+
import { computeRollDelta } from "./utils";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* We use makeMutable (via useState) instead of useSharedValue because
|
|
16
|
+
* useSharedValue's cleanup calls cancelAnimation, which kills in-flight
|
|
17
|
+
* animations when the component re-renders in StrictMode.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
interface UseDigitAnimationParams {
|
|
21
|
+
digitValue: number;
|
|
22
|
+
entering: boolean;
|
|
23
|
+
exiting: boolean;
|
|
24
|
+
trend: Trend;
|
|
25
|
+
spinTiming: TimingConfig;
|
|
26
|
+
opacityTiming: TimingConfig;
|
|
27
|
+
exitKey?: string;
|
|
28
|
+
onExitComplete?: (key: string) => void;
|
|
29
|
+
workletDigitValue?: SharedValue<number>;
|
|
30
|
+
/** Number of values in this digit's wheel. Defaults to 10 (0-9). */
|
|
31
|
+
digitCount?: number;
|
|
32
|
+
/** Incrementing counter from useContinuousSpin; triggers a full-wheel rotation when changed. */
|
|
33
|
+
continuousSpinGeneration?: number;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
interface UseDigitAnimationResult {
|
|
37
|
+
initialDigit: number;
|
|
38
|
+
animDelta: SharedValue<number>;
|
|
39
|
+
currentDigitSV: SharedValue<number>;
|
|
40
|
+
slotOpacity: SharedValue<number>;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Manages the digit rolling state machine: delta tracking, exit animations,
|
|
45
|
+
* prop-driven digit changes, and worklet-driven digit updates.
|
|
46
|
+
*
|
|
47
|
+
* Each renderer creates its own format-specific Y transforms and position
|
|
48
|
+
* reaction that reads animDelta + currentDigitSV from this hook.
|
|
49
|
+
*/
|
|
50
|
+
export function useDigitAnimation({
|
|
51
|
+
digitValue,
|
|
52
|
+
entering,
|
|
53
|
+
exiting,
|
|
54
|
+
trend,
|
|
55
|
+
spinTiming,
|
|
56
|
+
opacityTiming,
|
|
57
|
+
exitKey,
|
|
58
|
+
onExitComplete,
|
|
59
|
+
workletDigitValue,
|
|
60
|
+
digitCount,
|
|
61
|
+
continuousSpinGeneration,
|
|
62
|
+
}: UseDigitAnimationParams): UseDigitAnimationResult {
|
|
63
|
+
const resolvedDigitCount = digitCount ?? DIGIT_COUNT;
|
|
64
|
+
const initialDigit = entering ? 0 : digitValue;
|
|
65
|
+
const prevDigitRef = useRef(initialDigit);
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* animDelta starts at the computed roll distance and animates toward 0.
|
|
69
|
+
* The per-digit position reaction computes c = currentDigit - animDelta
|
|
70
|
+
* and uses mod-10 arithmetic to position each digit individually.
|
|
71
|
+
*/
|
|
72
|
+
const [animDelta] = useState(() => makeMutable(0));
|
|
73
|
+
const [currentDigitSV] = useState(() => makeMutable(initialDigit));
|
|
74
|
+
|
|
75
|
+
const handleExitingStart = useCallback(() => {
|
|
76
|
+
if (prevDigitRef.current !== 0) {
|
|
77
|
+
const delta = computeRollDelta(prevDigitRef.current, 0, trend, resolvedDigitCount);
|
|
78
|
+
prevDigitRef.current = 0;
|
|
79
|
+
currentDigitSV.value = 0;
|
|
80
|
+
animDelta.value = delta;
|
|
81
|
+
animDelta.value = withTiming(0, {
|
|
82
|
+
duration: spinTiming.duration,
|
|
83
|
+
easing: spinTiming.easing,
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
}, [trend, spinTiming, animDelta, currentDigitSV, resolvedDigitCount]);
|
|
87
|
+
|
|
88
|
+
const slotOpacity = useSlotOpacity({
|
|
89
|
+
entering,
|
|
90
|
+
exiting,
|
|
91
|
+
opacityTiming,
|
|
92
|
+
exitKey,
|
|
93
|
+
onExitComplete,
|
|
94
|
+
onExitingStart: handleExitingStart,
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
useLayoutEffect(() => {
|
|
98
|
+
const workletActive = workletDigitValue !== undefined && workletDigitValue.value >= 0;
|
|
99
|
+
if (!exiting && !workletActive && prevDigitRef.current !== digitValue) {
|
|
100
|
+
const delta = computeRollDelta(prevDigitRef.current, digitValue, trend, resolvedDigitCount);
|
|
101
|
+
prevDigitRef.current = digitValue;
|
|
102
|
+
currentDigitSV.value = digitValue;
|
|
103
|
+
animDelta.value = delta;
|
|
104
|
+
animDelta.value = withTiming(0, {
|
|
105
|
+
duration: spinTiming.duration,
|
|
106
|
+
easing: spinTiming.easing,
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
}, [
|
|
110
|
+
digitValue,
|
|
111
|
+
exiting,
|
|
112
|
+
workletDigitValue,
|
|
113
|
+
trend,
|
|
114
|
+
spinTiming,
|
|
115
|
+
animDelta,
|
|
116
|
+
currentDigitSV,
|
|
117
|
+
resolvedDigitCount,
|
|
118
|
+
]);
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Continuous spin: when the generation counter increments, this digit's
|
|
122
|
+
* value didn't change but a higher-significance digit did. Perform a
|
|
123
|
+
* full-wheel rotation so the digit appears to "carry" through.
|
|
124
|
+
*
|
|
125
|
+
* Example: 100 → 200 with trend=1 — the ones digit (0→0) spins through
|
|
126
|
+
* all 10 values upward while the hundreds digit rolls 1→2 normally.
|
|
127
|
+
*/
|
|
128
|
+
const prevSpinGenRef = useRef(continuousSpinGeneration ?? 0);
|
|
129
|
+
|
|
130
|
+
useLayoutEffect(() => {
|
|
131
|
+
const currentGen = continuousSpinGeneration ?? 0;
|
|
132
|
+
|
|
133
|
+
// Generation unchanged — no continuous spin needed
|
|
134
|
+
if (currentGen === prevSpinGenRef.current) return;
|
|
135
|
+
prevSpinGenRef.current = currentGen;
|
|
136
|
+
|
|
137
|
+
// Don't spin digits that are entering or exiting — they animate via opacity
|
|
138
|
+
if (exiting || entering) return;
|
|
139
|
+
|
|
140
|
+
// Full wheel rotation: e.g. 10 * 1 = spin up 10, or 6 * -1 = spin down 6 (for s10)
|
|
141
|
+
const delta = resolvedDigitCount * trend;
|
|
142
|
+
if (delta === 0) return;
|
|
143
|
+
|
|
144
|
+
// Accumulate onto any in-flight animation, then ease back to 0
|
|
145
|
+
animDelta.value = animDelta.value + delta;
|
|
146
|
+
animDelta.value = withTiming(0, {
|
|
147
|
+
duration: spinTiming.duration,
|
|
148
|
+
easing: spinTiming.easing,
|
|
149
|
+
});
|
|
150
|
+
}, [
|
|
151
|
+
continuousSpinGeneration,
|
|
152
|
+
exiting,
|
|
153
|
+
entering,
|
|
154
|
+
trend,
|
|
155
|
+
spinTiming,
|
|
156
|
+
resolvedDigitCount,
|
|
157
|
+
animDelta,
|
|
158
|
+
]);
|
|
159
|
+
|
|
160
|
+
const syncFromWorklet = useCallback((digit: number) => {
|
|
161
|
+
prevDigitRef.current = digit;
|
|
162
|
+
}, []);
|
|
163
|
+
|
|
164
|
+
useAnimatedReaction(
|
|
165
|
+
() => workletDigitValue?.value ?? -1,
|
|
166
|
+
(current, previous) => {
|
|
167
|
+
if (current < 0 || current === previous) return;
|
|
168
|
+
|
|
169
|
+
const prev = currentDigitSV.value;
|
|
170
|
+
if (prev === current) return;
|
|
171
|
+
|
|
172
|
+
const delta = computeRollDelta(prev, current, trend, resolvedDigitCount);
|
|
173
|
+
currentDigitSV.value = current;
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Accumulate remaining animation delta (composite: 'accumulate').
|
|
177
|
+
* Safe to read animDelta.value here — we're on the UI thread.
|
|
178
|
+
*/
|
|
179
|
+
animDelta.value = animDelta.value + delta;
|
|
180
|
+
|
|
181
|
+
animDelta.value = withTiming(0, {
|
|
182
|
+
duration: spinTiming.duration,
|
|
183
|
+
easing: spinTiming.easing,
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
runOnJS(syncFromWorklet)(current);
|
|
187
|
+
},
|
|
188
|
+
[workletDigitValue, spinTiming, trend],
|
|
189
|
+
);
|
|
190
|
+
|
|
191
|
+
return { initialDigit, animDelta, currentDigitSV, slotOpacity };
|
|
192
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { useMemo, useRef } from "react";
|
|
2
|
+
import type { CharLayout } from "./layout";
|
|
3
|
+
import { computeAdaptiveMaskHeights, type MaskHeights } from "./mask";
|
|
4
|
+
import type { GlyphMetrics, KeyedPart, TimingConfig, Trend, TrendProp } from "./types";
|
|
5
|
+
import { useAnimationLifecycle } from "./useAnimationLifecycle";
|
|
6
|
+
import { useContinuousSpin } from "./useContinuousSpin";
|
|
7
|
+
import { useLayoutDiff } from "./useLayoutDiff";
|
|
8
|
+
import { useTimingResolution } from "./useTimingResolution";
|
|
9
|
+
import { resolveTrend } from "./utils";
|
|
10
|
+
|
|
11
|
+
interface FlowPipelineInput {
|
|
12
|
+
keyedParts: KeyedPart[];
|
|
13
|
+
|
|
14
|
+
// The scalar used for trend detection:
|
|
15
|
+
// NumberFlow: the numeric `value`
|
|
16
|
+
// TimeFlow: `totalSeconds` (h*3600 + m*60 + s)
|
|
17
|
+
trendValue: number | undefined;
|
|
18
|
+
|
|
19
|
+
// Pre-computed layout — each component handles its own layout computation
|
|
20
|
+
// because Skia has special sharedValue layout paths
|
|
21
|
+
layout: CharLayout[];
|
|
22
|
+
|
|
23
|
+
// Metrics for adaptive mask computation
|
|
24
|
+
metrics: GlyphMetrics | null;
|
|
25
|
+
|
|
26
|
+
// AnimationBehaviorProps
|
|
27
|
+
animated?: boolean;
|
|
28
|
+
respectMotionPreference?: boolean;
|
|
29
|
+
spinTiming?: TimingConfig;
|
|
30
|
+
opacityTiming?: TimingConfig;
|
|
31
|
+
transformTiming?: TimingConfig;
|
|
32
|
+
trend?: TrendProp;
|
|
33
|
+
continuous?: boolean;
|
|
34
|
+
mask?: boolean;
|
|
35
|
+
onAnimationsStart?: () => void;
|
|
36
|
+
onAnimationsFinish?: () => void;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
interface FlowPipelineOutput {
|
|
40
|
+
resolvedSpinTiming: TimingConfig;
|
|
41
|
+
resolvedOpacityTiming: TimingConfig;
|
|
42
|
+
resolvedTransformTiming: TimingConfig;
|
|
43
|
+
resolvedTrend: Trend;
|
|
44
|
+
spinGenerations: Map<string, number> | undefined;
|
|
45
|
+
|
|
46
|
+
prevMap: Map<string, CharLayout>;
|
|
47
|
+
isInitialRender: boolean;
|
|
48
|
+
exitingEntries: Map<string, CharLayout>;
|
|
49
|
+
onExitComplete: (key: string) => void;
|
|
50
|
+
|
|
51
|
+
accessibilityLabel: string | undefined;
|
|
52
|
+
adaptiveMask: MaskHeights;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const ZERO_MASK: MaskHeights = { top: 0, bottom: 0, expansionTop: 0, expansionBottom: 0 };
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Shared data pipeline for all Flow components (NumberFlow, TimeFlow,
|
|
59
|
+
* SkiaNumberFlow, SkiaTimeFlow).
|
|
60
|
+
*
|
|
61
|
+
* Handles: timing resolution, trend tracking, continuous spin,
|
|
62
|
+
* animation lifecycle, layout diffing, accessibility label,
|
|
63
|
+
* and adaptive mask computation.
|
|
64
|
+
*
|
|
65
|
+
* Layout computation is left to callers because Skia components
|
|
66
|
+
* have additional sharedValue-driven layout paths.
|
|
67
|
+
*/
|
|
68
|
+
export function useFlowPipeline(input: FlowPipelineInput): FlowPipelineOutput {
|
|
69
|
+
// 1. Timing resolution
|
|
70
|
+
const { resolvedSpinTiming, resolvedOpacityTiming, resolvedTransformTiming } =
|
|
71
|
+
useTimingResolution(
|
|
72
|
+
input.animated,
|
|
73
|
+
input.respectMotionPreference,
|
|
74
|
+
input.spinTiming,
|
|
75
|
+
input.opacityTiming,
|
|
76
|
+
input.transformTiming,
|
|
77
|
+
);
|
|
78
|
+
|
|
79
|
+
// 2. Trend tracking
|
|
80
|
+
const prevValueRef = useRef<number | undefined>(input.trendValue);
|
|
81
|
+
const resolvedTrend = resolveTrend(input.trend, prevValueRef.current, input.trendValue);
|
|
82
|
+
prevValueRef.current = input.trendValue;
|
|
83
|
+
|
|
84
|
+
// 3. Continuous spin
|
|
85
|
+
const spinGenerations = useContinuousSpin(input.keyedParts, input.continuous, resolvedTrend);
|
|
86
|
+
|
|
87
|
+
// 4. Animation lifecycle
|
|
88
|
+
useAnimationLifecycle(
|
|
89
|
+
input.layout,
|
|
90
|
+
{ spin: resolvedSpinTiming, opacity: resolvedOpacityTiming, transform: resolvedTransformTiming },
|
|
91
|
+
input.onAnimationsStart,
|
|
92
|
+
input.onAnimationsFinish,
|
|
93
|
+
);
|
|
94
|
+
|
|
95
|
+
// 5. Layout diff
|
|
96
|
+
const { prevMap, isInitialRender, exitingEntries, onExitComplete } = useLayoutDiff(input.layout);
|
|
97
|
+
|
|
98
|
+
// 6. Accessibility label
|
|
99
|
+
const accessibilityLabel = useMemo(() => {
|
|
100
|
+
if (input.keyedParts.length === 0) return undefined;
|
|
101
|
+
|
|
102
|
+
return input.keyedParts.map((p) => p.char).join("");
|
|
103
|
+
}, [input.keyedParts]);
|
|
104
|
+
|
|
105
|
+
// 7. Adaptive mask
|
|
106
|
+
const resolvedMask = input.mask ?? true;
|
|
107
|
+
const adaptiveMask = useMemo(() => {
|
|
108
|
+
if (!input.metrics || !resolvedMask) return ZERO_MASK;
|
|
109
|
+
|
|
110
|
+
return computeAdaptiveMaskHeights(input.layout, exitingEntries, input.metrics);
|
|
111
|
+
}, [input.metrics, resolvedMask, input.layout, exitingEntries]);
|
|
112
|
+
|
|
113
|
+
return {
|
|
114
|
+
resolvedSpinTiming,
|
|
115
|
+
resolvedOpacityTiming,
|
|
116
|
+
resolvedTransformTiming,
|
|
117
|
+
resolvedTrend,
|
|
118
|
+
spinGenerations,
|
|
119
|
+
prevMap,
|
|
120
|
+
isInitialRender,
|
|
121
|
+
exitingEntries,
|
|
122
|
+
onExitComplete,
|
|
123
|
+
accessibilityLabel,
|
|
124
|
+
adaptiveMask,
|
|
125
|
+
};
|
|
126
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { useMemo } from "react";
|
|
2
|
+
import { getOrCreateFormatter } from "./intlHelpers";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Formats a numeric value to a display string using Intl.NumberFormat, with optional prefix/suffix.
|
|
6
|
+
* Useful for providing an accessibility label on a parent Canvas for Skia components:
|
|
7
|
+
*
|
|
8
|
+
* ```tsx
|
|
9
|
+
* const label = useFormattedValue(value, { style: "currency", currency: "USD" });
|
|
10
|
+
*
|
|
11
|
+
* <Canvas accessible accessibilityLabel={label}>
|
|
12
|
+
* <SkiaNumberFlow value={value} font={font} format={{ style: "currency", currency: "USD" }} />
|
|
13
|
+
* </Canvas>
|
|
14
|
+
* ```
|
|
15
|
+
*/
|
|
16
|
+
export function useFormattedValue(
|
|
17
|
+
value: number | undefined,
|
|
18
|
+
format?: Intl.NumberFormatOptions,
|
|
19
|
+
locales?: Intl.LocalesArgument,
|
|
20
|
+
prefix?: string,
|
|
21
|
+
suffix?: string,
|
|
22
|
+
): string | undefined {
|
|
23
|
+
// Serialize format/locales to a stable string — avoids re-runs when callers pass inline objects
|
|
24
|
+
const formatKey = useMemo(() => JSON.stringify([locales, format]), [locales, format]);
|
|
25
|
+
|
|
26
|
+
return useMemo(() => {
|
|
27
|
+
if (value === undefined) return undefined;
|
|
28
|
+
const formatter = getOrCreateFormatter(locales, format);
|
|
29
|
+
return `${prefix ?? ""}${formatter.format(value)}${suffix ?? ""}`;
|
|
30
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
31
|
+
}, [value, prefix, suffix, formatKey]);
|
|
32
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { useCallback, useReducer, useRef } from "react";
|
|
2
|
+
import type { CharLayout } from "./layout";
|
|
3
|
+
|
|
4
|
+
interface LayoutDiffResult {
|
|
5
|
+
prevMap: Map<string, CharLayout>;
|
|
6
|
+
isInitialRender: boolean;
|
|
7
|
+
exitingEntries: Map<string, CharLayout>;
|
|
8
|
+
onExitComplete: (key: string) => void;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Manages enter/exit tracking for layout transitions. Diffs the current
|
|
13
|
+
* layout against the previous one and tracks exiting entries until their
|
|
14
|
+
* animations complete.
|
|
15
|
+
*
|
|
16
|
+
* The diff logic runs during render (not in useEffect) using a ref-based
|
|
17
|
+
* idempotent pattern for StrictMode safety.
|
|
18
|
+
*/
|
|
19
|
+
export function useLayoutDiff(layout: CharLayout[]): LayoutDiffResult {
|
|
20
|
+
const prevLayoutRef = useRef<CharLayout[]>([]);
|
|
21
|
+
const exitingRef = useRef<Map<string, CharLayout>>(new Map());
|
|
22
|
+
const [, forceUpdate] = useReducer((n: number) => n + 1, 0);
|
|
23
|
+
const isFirstLayoutRef = useRef(true);
|
|
24
|
+
|
|
25
|
+
const lastDiffIdRef = useRef("");
|
|
26
|
+
const diffResultRef = useRef<{
|
|
27
|
+
prevMap: Map<string, CharLayout>;
|
|
28
|
+
isInitialRender: boolean;
|
|
29
|
+
}>({ prevMap: new Map(), isInitialRender: true });
|
|
30
|
+
|
|
31
|
+
const onExitComplete = useCallback((key: string) => {
|
|
32
|
+
exitingRef.current.delete(key);
|
|
33
|
+
forceUpdate();
|
|
34
|
+
}, []);
|
|
35
|
+
|
|
36
|
+
// Stable layout identity — fast string concat instead of map+join
|
|
37
|
+
let layoutId = "";
|
|
38
|
+
for (const s of layout) {
|
|
39
|
+
layoutId += `${s.key}:${s.digitValue}|`;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
if (layoutId !== lastDiffIdRef.current) {
|
|
43
|
+
lastDiffIdRef.current = layoutId;
|
|
44
|
+
|
|
45
|
+
const currentKeys = new Set(layout.map((s) => s.key));
|
|
46
|
+
const prevMap = new Map(prevLayoutRef.current.map((s) => [s.key, s]));
|
|
47
|
+
|
|
48
|
+
for (const prev of prevLayoutRef.current) {
|
|
49
|
+
if (!currentKeys.has(prev.key) && !exitingRef.current.has(prev.key)) {
|
|
50
|
+
exitingRef.current.set(prev.key, prev);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
for (const key of currentKeys) {
|
|
55
|
+
exitingRef.current.delete(key);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const isInitialRender = isFirstLayoutRef.current;
|
|
59
|
+
if (layout.length > 0) isFirstLayoutRef.current = false;
|
|
60
|
+
|
|
61
|
+
diffResultRef.current = { prevMap, isInitialRender };
|
|
62
|
+
prevLayoutRef.current = layout;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return {
|
|
66
|
+
prevMap: diffResultRef.current.prevMap,
|
|
67
|
+
isInitialRender: diffResultRef.current.isInitialRender,
|
|
68
|
+
exitingEntries: exitingRef.current,
|
|
69
|
+
onExitComplete,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import { useMemo } from "react";
|
|
2
|
+
import { getOrCreateFormatter, safeFormatToParts } from "./intlHelpers";
|
|
3
|
+
import { detectOutputZeroCodePoint, localeDigitValue } from "./numerals";
|
|
4
|
+
import type { KeyedPart } from "./types";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Transforms `Intl.NumberFormat.formatToParts()` output into a flat array of
|
|
8
|
+
* stably-keyed single-character parts. Integer digits are keyed right-to-left
|
|
9
|
+
* (ones = `integer:0`, tens = `integer:1`, etc.) so that the ones place always
|
|
10
|
+
* maps to the same React key regardless of digit count. Fraction digits are
|
|
11
|
+
* keyed left-to-right from the decimal point (`fraction:0` = tenths, etc.).
|
|
12
|
+
*
|
|
13
|
+
* This keying ensures correct animation behavior when digits are added/removed
|
|
14
|
+
* (e.g., 9→10: ones place spins 9→0, tens place enters as new digit).
|
|
15
|
+
*/
|
|
16
|
+
export function formatToKeyedParts(
|
|
17
|
+
value: number,
|
|
18
|
+
formatter: Intl.NumberFormat,
|
|
19
|
+
locales: Intl.LocalesArgument | undefined,
|
|
20
|
+
prefix = "",
|
|
21
|
+
suffix = "",
|
|
22
|
+
): KeyedPart[] {
|
|
23
|
+
const rawParts = safeFormatToParts(formatter, value, locales);
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Detect the actual zero codepoint from the formatted output rather than
|
|
27
|
+
* trusting resolvedOptions().numberingSystem. Hermes may report "arab" but
|
|
28
|
+
* output Latin digits, or vice versa. Output-based detection is always correct.
|
|
29
|
+
*/
|
|
30
|
+
const rawString = rawParts.map((p) => p.value).join("");
|
|
31
|
+
const zeroCP = detectOutputZeroCodePoint(rawString);
|
|
32
|
+
|
|
33
|
+
// Step 1: Flatten all parts into single characters with their source type
|
|
34
|
+
interface FlatChar {
|
|
35
|
+
sourceType: string;
|
|
36
|
+
char: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const flatChars: FlatChar[] = [];
|
|
40
|
+
|
|
41
|
+
for (const char of prefix) {
|
|
42
|
+
flatChars.push({ sourceType: "prefix", char });
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
for (const part of rawParts) {
|
|
46
|
+
// Merge signs into unified types.
|
|
47
|
+
// The fallback parser emits extended types (exponentMinusSign, etc.)
|
|
48
|
+
// that aren't in Intl.NumberFormatPart["type"], so we widen to string.
|
|
49
|
+
const partType = part.type as string;
|
|
50
|
+
const type =
|
|
51
|
+
partType === "minusSign" || partType === "plusSign"
|
|
52
|
+
? "sign"
|
|
53
|
+
: partType === "exponentMinusSign" || partType === "exponentPlusSign"
|
|
54
|
+
? "exponentSign"
|
|
55
|
+
: partType;
|
|
56
|
+
|
|
57
|
+
// Replace exponentSeparator "E" with ×10 display
|
|
58
|
+
if (type === "exponentSeparator") {
|
|
59
|
+
flatChars.push({ sourceType: "exponentSeparator", char: "\u00D7" });
|
|
60
|
+
flatChars.push({ sourceType: "exponentSeparator", char: "1" });
|
|
61
|
+
flatChars.push({ sourceType: "exponentSeparator", char: "0" });
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Flatten digit sequences into individual characters
|
|
66
|
+
if (type === "integer" || type === "fraction" || type === "exponentInteger") {
|
|
67
|
+
for (const char of part.value) {
|
|
68
|
+
flatChars.push({ sourceType: type, char });
|
|
69
|
+
}
|
|
70
|
+
} else {
|
|
71
|
+
flatChars.push({ sourceType: type, char: part.value });
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
for (const char of suffix) {
|
|
76
|
+
flatChars.push({ sourceType: "suffix", char });
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Step 2: Key characters — integers+groups RTL, everything else LTR
|
|
80
|
+
const result: KeyedPart[] = new Array(flatChars.length);
|
|
81
|
+
const counts: Record<string, number> = {};
|
|
82
|
+
|
|
83
|
+
const nextKey = (type: string) => `${type}:${(counts[type] = (counts[type] ?? -1) + 1)}`;
|
|
84
|
+
|
|
85
|
+
// Find all integer + group indices (they form one contiguous block to key RTL)
|
|
86
|
+
const integerGroupIndices: number[] = [];
|
|
87
|
+
for (let i = 0; i < flatChars.length; i++) {
|
|
88
|
+
if (flatChars[i].sourceType === "integer" || flatChars[i].sourceType === "group") {
|
|
89
|
+
integerGroupIndices.push(i);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Key integer digits and group separators RTL — rightmost integer digit
|
|
95
|
+
* gets "integer:0" (ones place), next gets "integer:1" (tens), etc.
|
|
96
|
+
*/
|
|
97
|
+
for (let i = integerGroupIndices.length - 1; i >= 0; i--) {
|
|
98
|
+
const idx = integerGroupIndices[i];
|
|
99
|
+
const fc = flatChars[idx];
|
|
100
|
+
const isDigit = fc.sourceType === "integer";
|
|
101
|
+
result[idx] = {
|
|
102
|
+
key: nextKey(fc.sourceType),
|
|
103
|
+
type: isDigit ? "digit" : "symbol",
|
|
104
|
+
char: fc.char,
|
|
105
|
+
digitValue: isDigit ? localeDigitValue(fc.char.charCodeAt(0), zeroCP) : -1,
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Find all exponent integer indices (key RTL, same logic as mantissa integers)
|
|
110
|
+
const exponentIntegerIndices: number[] = [];
|
|
111
|
+
for (let i = 0; i < flatChars.length; i++) {
|
|
112
|
+
if (flatChars[i].sourceType === "exponentInteger") {
|
|
113
|
+
exponentIntegerIndices.push(i);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
for (let i = exponentIntegerIndices.length - 1; i >= 0; i--) {
|
|
118
|
+
const idx = exponentIntegerIndices[i];
|
|
119
|
+
const fc = flatChars[idx];
|
|
120
|
+
result[idx] = {
|
|
121
|
+
key: nextKey("exponentInteger"),
|
|
122
|
+
type: "digit",
|
|
123
|
+
char: fc.char,
|
|
124
|
+
digitValue: localeDigitValue(fc.char.charCodeAt(0), zeroCP),
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Key everything else LTR (fraction, decimal, prefix, suffix, exponentSeparator, exponentSign, etc.)
|
|
129
|
+
for (let i = 0; i < flatChars.length; i++) {
|
|
130
|
+
if (result[i]) continue; // already keyed (integer, group, or exponentInteger)
|
|
131
|
+
const fc = flatChars[i];
|
|
132
|
+
const isDigit = fc.sourceType === "fraction";
|
|
133
|
+
result[i] = {
|
|
134
|
+
key: nextKey(fc.sourceType),
|
|
135
|
+
type: isDigit ? "digit" : "symbol",
|
|
136
|
+
char: fc.char,
|
|
137
|
+
digitValue: isDigit ? localeDigitValue(fc.char.charCodeAt(0), zeroCP) : -1,
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
return result;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Hook that formats a numeric value into stably-keyed character parts.
|
|
146
|
+
* Uses Intl.NumberFormat.formatToParts() with RTL integer keying.
|
|
147
|
+
*/
|
|
148
|
+
export function useNumberFormatting(
|
|
149
|
+
value: number | undefined,
|
|
150
|
+
format: Intl.NumberFormatOptions | undefined,
|
|
151
|
+
locales: Intl.LocalesArgument | undefined,
|
|
152
|
+
prefix: string,
|
|
153
|
+
suffix: string,
|
|
154
|
+
): KeyedPart[] {
|
|
155
|
+
// Serialize format/locales to a stable string — avoids re-runs when callers pass inline objects
|
|
156
|
+
const formatKey = useMemo(() => JSON.stringify([locales, format]), [locales, format]);
|
|
157
|
+
|
|
158
|
+
return useMemo(() => {
|
|
159
|
+
if (value === undefined) return [];
|
|
160
|
+
const formatter = getOrCreateFormatter(locales, format);
|
|
161
|
+
return formatToKeyedParts(value, formatter, locales, prefix, suffix);
|
|
162
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
163
|
+
}, [value, prefix, suffix, formatKey]);
|
|
164
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { useLayoutEffect, useRef, useState } from "react";
|
|
2
|
+
import { makeMutable, runOnJS, type SharedValue, withTiming } from "react-native-reanimated";
|
|
3
|
+
import type { TimingConfig } from "./types";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* We use makeMutable (via useState) instead of useSharedValue because
|
|
7
|
+
* useSharedValue's cleanup calls cancelAnimation, which kills in-flight
|
|
8
|
+
* animations when the component re-renders in StrictMode.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
interface UseSlotOpacityParams {
|
|
12
|
+
entering: boolean;
|
|
13
|
+
exiting: boolean;
|
|
14
|
+
opacityTiming: TimingConfig;
|
|
15
|
+
exitKey?: string;
|
|
16
|
+
onExitComplete?: (key: string) => void;
|
|
17
|
+
onExitingStart?: () => void;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function useSlotOpacity({
|
|
21
|
+
entering,
|
|
22
|
+
exiting,
|
|
23
|
+
opacityTiming,
|
|
24
|
+
exitKey,
|
|
25
|
+
onExitComplete,
|
|
26
|
+
onExitingStart,
|
|
27
|
+
}: UseSlotOpacityParams): SharedValue<number> {
|
|
28
|
+
const [slotOpacity] = useState(() => makeMutable(entering ? 0 : 1));
|
|
29
|
+
const prevStateRef = useRef<"entering" | "exiting" | "active" | null>(null);
|
|
30
|
+
const currentState = entering ? "entering" : exiting ? "exiting" : "active";
|
|
31
|
+
|
|
32
|
+
useLayoutEffect(() => {
|
|
33
|
+
if (currentState === prevStateRef.current) return;
|
|
34
|
+
const wasInitial = prevStateRef.current === null;
|
|
35
|
+
prevStateRef.current = currentState;
|
|
36
|
+
|
|
37
|
+
if (currentState === "entering") {
|
|
38
|
+
slotOpacity.value = withTiming(1, {
|
|
39
|
+
duration: opacityTiming.duration,
|
|
40
|
+
easing: opacityTiming.easing,
|
|
41
|
+
});
|
|
42
|
+
} else if (currentState === "exiting") {
|
|
43
|
+
slotOpacity.value = withTiming(
|
|
44
|
+
0,
|
|
45
|
+
{
|
|
46
|
+
duration: opacityTiming.duration,
|
|
47
|
+
easing: opacityTiming.easing,
|
|
48
|
+
},
|
|
49
|
+
(finished) => {
|
|
50
|
+
"worklet";
|
|
51
|
+
if (finished && onExitComplete && exitKey) {
|
|
52
|
+
runOnJS(onExitComplete)(exitKey);
|
|
53
|
+
}
|
|
54
|
+
},
|
|
55
|
+
);
|
|
56
|
+
onExitingStart?.();
|
|
57
|
+
} else if (!wasInitial) {
|
|
58
|
+
slotOpacity.value = withTiming(1, {
|
|
59
|
+
duration: opacityTiming.duration,
|
|
60
|
+
easing: opacityTiming.easing,
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
}, [currentState, opacityTiming, exitKey, onExitComplete, onExitingStart, slotOpacity]);
|
|
64
|
+
|
|
65
|
+
return slotOpacity;
|
|
66
|
+
}
|