react-native-laminar 1.1.0 → 1.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +47 -3
- package/lib/commonjs/hooks/use-display-units.js +1 -0
- package/lib/commonjs/hooks/use-display-units.js.map +1 -1
- package/lib/commonjs/hooks/use-inline-auto-width.js +43 -10
- package/lib/commonjs/hooks/use-inline-auto-width.js.map +1 -1
- package/lib/commonjs/hooks/use-morph-motion.js +2 -1
- package/lib/commonjs/hooks/use-morph-motion.js.map +1 -1
- package/lib/commonjs/hooks/use-morph-text-style.js +4 -0
- package/lib/commonjs/hooks/use-morph-text-style.js.map +1 -1
- package/lib/commonjs/hooks/use-numeric-lanes.js +1 -0
- package/lib/commonjs/hooks/use-numeric-lanes.js.map +1 -1
- package/lib/commonjs/hooks/use-text-glyphs.js +28 -7
- package/lib/commonjs/hooks/use-text-glyphs.js.map +1 -1
- package/lib/commonjs/index.js +58 -11
- package/lib/commonjs/index.js.map +1 -1
- package/lib/commonjs/model/display-units.js +7 -1
- package/lib/commonjs/model/display-units.js.map +1 -1
- package/lib/commonjs/model/numeric-lanes.js +4 -0
- package/lib/commonjs/model/numeric-lanes.js.map +1 -1
- package/lib/commonjs/model/text-keys.js +4 -0
- package/lib/commonjs/model/text-keys.js.map +1 -1
- package/lib/commonjs/motion/entry-exit-builders.js +78 -1
- package/lib/commonjs/motion/entry-exit-builders.js.map +1 -1
- package/lib/commonjs/motion/preset-map.js +7 -0
- package/lib/commonjs/motion/preset-map.js.map +1 -1
- package/lib/commonjs/view/glyph-run.js +34 -3
- package/lib/commonjs/view/glyph-run.js.map +1 -1
- package/lib/commonjs/view/morph-viewport.js +33 -3
- package/lib/commonjs/view/morph-viewport.js.map +1 -1
- package/lib/commonjs/view/number-lane.js +5 -1
- package/lib/commonjs/view/number-lane.js.map +1 -1
- package/lib/commonjs/view/number-run.js +17 -1
- package/lib/commonjs/view/number-run.js.map +1 -1
- package/lib/commonjs/view/slots-run.js +258 -0
- package/lib/commonjs/view/slots-run.js.map +1 -0
- package/lib/commonjs/view/text-run.js +37 -3
- package/lib/commonjs/view/text-run.js.map +1 -1
- package/lib/module/hooks/use-display-units.js +2 -0
- package/lib/module/hooks/use-display-units.js.map +1 -1
- package/lib/module/hooks/use-inline-auto-width.js +44 -11
- package/lib/module/hooks/use-inline-auto-width.js.map +1 -1
- package/lib/module/hooks/use-morph-motion.js +2 -1
- package/lib/module/hooks/use-morph-motion.js.map +1 -1
- package/lib/module/hooks/use-morph-text-style.js +4 -0
- package/lib/module/hooks/use-morph-text-style.js.map +1 -1
- package/lib/module/hooks/use-numeric-lanes.js +1 -0
- package/lib/module/hooks/use-numeric-lanes.js.map +1 -1
- package/lib/module/hooks/use-text-glyphs.js +28 -7
- package/lib/module/hooks/use-text-glyphs.js.map +1 -1
- package/lib/module/index.js +60 -12
- package/lib/module/index.js.map +1 -1
- package/lib/module/model/display-units.js +7 -1
- package/lib/module/model/display-units.js.map +1 -1
- package/lib/module/model/numeric-lanes.js +5 -0
- package/lib/module/model/numeric-lanes.js.map +1 -1
- package/lib/module/model/text-keys.js +4 -0
- package/lib/module/model/text-keys.js.map +1 -1
- package/lib/module/motion/entry-exit-builders.js +75 -0
- package/lib/module/motion/entry-exit-builders.js.map +1 -1
- package/lib/module/motion/preset-map.js +7 -0
- package/lib/module/motion/preset-map.js.map +1 -1
- package/lib/module/view/glyph-run.js +34 -3
- package/lib/module/view/glyph-run.js.map +1 -1
- package/lib/module/view/morph-viewport.js +33 -3
- package/lib/module/view/morph-viewport.js.map +1 -1
- package/lib/module/view/number-lane.js +5 -1
- package/lib/module/view/number-lane.js.map +1 -1
- package/lib/module/view/number-run.js +17 -1
- package/lib/module/view/number-run.js.map +1 -1
- package/lib/module/view/slots-run.js +253 -0
- package/lib/module/view/slots-run.js.map +1 -0
- package/lib/module/view/text-run.js +38 -4
- package/lib/module/view/text-run.js.map +1 -1
- package/lib/typescript/commonjs/hooks/use-display-units.d.ts.map +1 -1
- package/lib/typescript/commonjs/hooks/use-inline-auto-width.d.ts +3 -1
- package/lib/typescript/commonjs/hooks/use-inline-auto-width.d.ts.map +1 -1
- package/lib/typescript/commonjs/hooks/use-morph-motion.d.ts.map +1 -1
- package/lib/typescript/commonjs/hooks/use-morph-text-style.d.ts.map +1 -1
- package/lib/typescript/commonjs/hooks/use-numeric-lanes.d.ts.map +1 -1
- package/lib/typescript/commonjs/hooks/use-text-glyphs.d.ts +2 -1
- package/lib/typescript/commonjs/hooks/use-text-glyphs.d.ts.map +1 -1
- package/lib/typescript/commonjs/index.d.ts +1 -3
- package/lib/typescript/commonjs/index.d.ts.map +1 -1
- package/lib/typescript/commonjs/model/display-units.d.ts.map +1 -1
- package/lib/typescript/commonjs/model/numeric-lanes.d.ts.map +1 -1
- package/lib/typescript/commonjs/model/text-keys.d.ts.map +1 -1
- package/lib/typescript/commonjs/motion/entry-exit-builders.d.ts +13 -0
- package/lib/typescript/commonjs/motion/entry-exit-builders.d.ts.map +1 -1
- package/lib/typescript/commonjs/motion/preset-map.d.ts.map +1 -1
- package/lib/typescript/commonjs/types.d.ts +14 -2
- package/lib/typescript/commonjs/types.d.ts.map +1 -1
- package/lib/typescript/commonjs/view/glyph-run.d.ts +7 -3
- package/lib/typescript/commonjs/view/glyph-run.d.ts.map +1 -1
- package/lib/typescript/commonjs/view/morph-viewport.d.ts +3 -1
- package/lib/typescript/commonjs/view/morph-viewport.d.ts.map +1 -1
- package/lib/typescript/commonjs/view/number-lane.d.ts.map +1 -1
- package/lib/typescript/commonjs/view/number-run.d.ts +3 -2
- package/lib/typescript/commonjs/view/number-run.d.ts.map +1 -1
- package/lib/typescript/commonjs/view/slots-run.d.ts +15 -0
- package/lib/typescript/commonjs/view/slots-run.d.ts.map +1 -0
- package/lib/typescript/commonjs/view/text-run.d.ts +7 -2
- package/lib/typescript/commonjs/view/text-run.d.ts.map +1 -1
- package/lib/typescript/module/hooks/use-display-units.d.ts.map +1 -1
- package/lib/typescript/module/hooks/use-inline-auto-width.d.ts +3 -1
- package/lib/typescript/module/hooks/use-inline-auto-width.d.ts.map +1 -1
- package/lib/typescript/module/hooks/use-morph-motion.d.ts.map +1 -1
- package/lib/typescript/module/hooks/use-morph-text-style.d.ts.map +1 -1
- package/lib/typescript/module/hooks/use-numeric-lanes.d.ts.map +1 -1
- package/lib/typescript/module/hooks/use-text-glyphs.d.ts +2 -1
- package/lib/typescript/module/hooks/use-text-glyphs.d.ts.map +1 -1
- package/lib/typescript/module/index.d.ts +1 -3
- package/lib/typescript/module/index.d.ts.map +1 -1
- package/lib/typescript/module/model/display-units.d.ts.map +1 -1
- package/lib/typescript/module/model/numeric-lanes.d.ts.map +1 -1
- package/lib/typescript/module/model/text-keys.d.ts.map +1 -1
- package/lib/typescript/module/motion/entry-exit-builders.d.ts +13 -0
- package/lib/typescript/module/motion/entry-exit-builders.d.ts.map +1 -1
- package/lib/typescript/module/motion/preset-map.d.ts.map +1 -1
- package/lib/typescript/module/types.d.ts +14 -2
- package/lib/typescript/module/types.d.ts.map +1 -1
- package/lib/typescript/module/view/glyph-run.d.ts +7 -3
- package/lib/typescript/module/view/glyph-run.d.ts.map +1 -1
- package/lib/typescript/module/view/morph-viewport.d.ts +3 -1
- package/lib/typescript/module/view/morph-viewport.d.ts.map +1 -1
- package/lib/typescript/module/view/number-lane.d.ts.map +1 -1
- package/lib/typescript/module/view/number-run.d.ts +3 -2
- package/lib/typescript/module/view/number-run.d.ts.map +1 -1
- package/lib/typescript/module/view/slots-run.d.ts +15 -0
- package/lib/typescript/module/view/slots-run.d.ts.map +1 -0
- package/lib/typescript/module/view/text-run.d.ts +7 -2
- package/lib/typescript/module/view/text-run.d.ts.map +1 -1
- package/package.json +11 -2
- package/src/hooks/use-display-units.ts +1 -0
- package/src/hooks/use-inline-auto-width.ts +55 -12
- package/src/hooks/use-morph-motion.ts +2 -1
- package/src/hooks/use-morph-text-style.ts +3 -0
- package/src/hooks/use-numeric-lanes.ts +1 -0
- package/src/hooks/use-text-glyphs.ts +48 -10
- package/src/index.tsx +103 -17
- package/src/model/display-units.ts +4 -1
- package/src/model/numeric-lanes.ts +3 -0
- package/src/model/text-keys.ts +3 -0
- package/src/motion/entry-exit-builders.ts +73 -0
- package/src/motion/preset-map.ts +4 -0
- package/src/types.ts +16 -2
- package/src/view/glyph-run.tsx +48 -15
- package/src/view/morph-viewport.tsx +28 -3
- package/src/view/number-lane.tsx +7 -2
- package/src/view/number-run.tsx +13 -2
- package/src/view/slots-run.tsx +385 -0
- package/src/view/text-run.tsx +80 -7
|
@@ -2,6 +2,7 @@ import React, { useMemo } from "react";
|
|
|
2
2
|
import type { StyleProp, ViewStyle } from "react-native";
|
|
3
3
|
import { View } from "react-native";
|
|
4
4
|
import Animated from "react-native-reanimated";
|
|
5
|
+
import type { LaminarAlign } from "../types";
|
|
5
6
|
|
|
6
7
|
const shellStyle = {
|
|
7
8
|
position: "relative",
|
|
@@ -29,34 +30,56 @@ const unclippedViewportStyle: ViewStyle = {
|
|
|
29
30
|
overflow: "visible",
|
|
30
31
|
};
|
|
31
32
|
|
|
33
|
+
const fullWidthViewportStyle: ViewStyle = {
|
|
34
|
+
width: "100%",
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
const shellAlignStyles: Record<LaminarAlign, ViewStyle> = {
|
|
38
|
+
left: { alignSelf: "flex-start" },
|
|
39
|
+
center: { alignSelf: "center" },
|
|
40
|
+
right: { alignSelf: "flex-end" },
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
const viewportAlignStyles: Record<LaminarAlign, ViewStyle> = {
|
|
44
|
+
left: { alignItems: "flex-start" },
|
|
45
|
+
center: { alignItems: "center" },
|
|
46
|
+
right: { alignItems: "flex-end" },
|
|
47
|
+
};
|
|
48
|
+
|
|
32
49
|
type MorphViewportProps = {
|
|
33
50
|
readonly autoSize: boolean;
|
|
34
51
|
readonly clipToBounds: boolean;
|
|
52
|
+
readonly align: LaminarAlign;
|
|
35
53
|
readonly containerStyle?: StyleProp<ViewStyle>;
|
|
36
54
|
readonly animatedWidthStyle?: React.ComponentProps<typeof Animated.View>["style"];
|
|
37
55
|
readonly measurement?: React.ReactNode;
|
|
38
56
|
readonly children: React.ReactNode;
|
|
39
57
|
};
|
|
40
58
|
|
|
59
|
+
// separate hidden measurement from visible content while sharing the shell alignment
|
|
41
60
|
export const MorphViewport = React.memo(
|
|
42
61
|
({
|
|
43
62
|
autoSize,
|
|
44
63
|
clipToBounds,
|
|
64
|
+
align,
|
|
45
65
|
containerStyle,
|
|
46
66
|
animatedWidthStyle,
|
|
47
67
|
measurement,
|
|
48
68
|
children,
|
|
49
69
|
}: MorphViewportProps) => {
|
|
70
|
+
// compute alignment once so the animated shell and its child share one layout contract
|
|
50
71
|
const resolvedViewportStyle = useMemo(
|
|
51
72
|
() => [
|
|
52
73
|
viewportStyle,
|
|
74
|
+
viewportAlignStyles[align],
|
|
53
75
|
clipToBounds ? clippedViewportStyle : unclippedViewportStyle,
|
|
54
76
|
],
|
|
55
|
-
[clipToBounds]
|
|
77
|
+
[align, clipToBounds]
|
|
56
78
|
);
|
|
57
79
|
|
|
80
|
+
// keep measurement outside the visible viewport so it cannot affect accessibility or layout
|
|
58
81
|
return (
|
|
59
|
-
<View style={[shellStyle, containerStyle]}>
|
|
82
|
+
<View style={[shellStyle, shellAlignStyles[align], containerStyle]}>
|
|
60
83
|
{autoSize ? (
|
|
61
84
|
<>
|
|
62
85
|
<View
|
|
@@ -73,7 +96,9 @@ export const MorphViewport = React.memo(
|
|
|
73
96
|
</Animated.View>
|
|
74
97
|
</>
|
|
75
98
|
) : (
|
|
76
|
-
<View style={resolvedViewportStyle}>
|
|
99
|
+
<View style={[resolvedViewportStyle, fullWidthViewportStyle]}>
|
|
100
|
+
{children}
|
|
101
|
+
</View>
|
|
77
102
|
)}
|
|
78
103
|
</View>
|
|
79
104
|
);
|
package/src/view/number-lane.tsx
CHANGED
|
@@ -33,6 +33,7 @@ type NumberLaneProps = {
|
|
|
33
33
|
readonly className?: string;
|
|
34
34
|
};
|
|
35
35
|
|
|
36
|
+
// render one place-value lane with a hidden layout probe and an animated token
|
|
36
37
|
export const NumberLane = React.memo(
|
|
37
38
|
({
|
|
38
39
|
unit,
|
|
@@ -46,6 +47,7 @@ export const NumberLane = React.memo(
|
|
|
46
47
|
textStyle,
|
|
47
48
|
className,
|
|
48
49
|
}: NumberLaneProps) => {
|
|
50
|
+
// punctuation stays in place while digits travel by the shared numeric direction
|
|
49
51
|
const usesDigitTravel = isAsciiDigit(unit);
|
|
50
52
|
const verticalOffset =
|
|
51
53
|
direction > 0
|
|
@@ -98,14 +100,17 @@ export const NumberLane = React.memo(
|
|
|
98
100
|
]
|
|
99
101
|
);
|
|
100
102
|
|
|
103
|
+
// prefix units do not need an absolute lane or a duplicate probe
|
|
101
104
|
if (isLead) {
|
|
102
105
|
return <Text style={textStyle} className={className}>{unit}</Text>;
|
|
103
106
|
}
|
|
104
107
|
|
|
105
108
|
return (
|
|
106
109
|
<Animated.View
|
|
107
|
-
layout={motionRecipe.layoutTransition}
|
|
108
|
-
entering={
|
|
110
|
+
layout={hasAnimated ? motionRecipe.layoutTransition : undefined}
|
|
111
|
+
entering={
|
|
112
|
+
hasAnimated ? motionRecipe.enterTransition : undefined
|
|
113
|
+
}
|
|
109
114
|
exiting={motionRecipe.exitTransition}
|
|
110
115
|
style={laneStyle}
|
|
111
116
|
>
|
package/src/view/number-run.tsx
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import React, { useMemo, useRef } from "react";
|
|
2
2
|
import { type StyleProp, type TextStyle, View } from "react-native";
|
|
3
3
|
import { useNumericLanes } from "../hooks/use-numeric-lanes";
|
|
4
|
-
import type { MotionRecipe } from "../types";
|
|
4
|
+
import type { LaminarAlign, MotionRecipe } from "../types";
|
|
5
5
|
import { NumberLane } from "./number-lane";
|
|
6
6
|
|
|
7
7
|
const rowStyle = {
|
|
@@ -10,24 +10,34 @@ const rowStyle = {
|
|
|
10
10
|
alignSelf: "flex-start",
|
|
11
11
|
} as const;
|
|
12
12
|
|
|
13
|
+
const rowAlignStyles = {
|
|
14
|
+
left: { alignSelf: "flex-start" },
|
|
15
|
+
center: { alignSelf: "center" },
|
|
16
|
+
right: { alignSelf: "flex-end" },
|
|
17
|
+
} as const;
|
|
18
|
+
|
|
13
19
|
type NumberRunProps = {
|
|
14
20
|
readonly value: string;
|
|
15
21
|
readonly motionRecipe: MotionRecipe;
|
|
22
|
+
readonly align: LaminarAlign;
|
|
16
23
|
readonly fontSize?: number;
|
|
17
24
|
readonly textStyle?: StyleProp<TextStyle>;
|
|
18
25
|
readonly staggerMs: number;
|
|
19
26
|
readonly className?: string;
|
|
20
27
|
};
|
|
21
28
|
|
|
29
|
+
// arrange numeric lanes from left to right while the hook preserves their identities
|
|
22
30
|
export const NumberRun = React.memo(
|
|
23
31
|
({
|
|
24
32
|
value,
|
|
25
33
|
motionRecipe,
|
|
34
|
+
align,
|
|
26
35
|
fontSize,
|
|
27
36
|
textStyle,
|
|
28
37
|
staggerMs,
|
|
29
38
|
className,
|
|
30
39
|
}: Readonly<NumberRunProps>) => {
|
|
40
|
+
// the hook owns lane identity while this component owns row placement and timing
|
|
31
41
|
const { units, laneKeys, direction, leadLength } = useNumericLanes(value);
|
|
32
42
|
const lastValueRef = useRef(value);
|
|
33
43
|
const hasAnimatedRef = useRef(false);
|
|
@@ -38,6 +48,7 @@ export const NumberRun = React.memo(
|
|
|
38
48
|
lastValueRef.current = value;
|
|
39
49
|
}
|
|
40
50
|
|
|
51
|
+
// scale travel from the font while keeping a minimum visible movement
|
|
41
52
|
const travelDistance = useMemo(
|
|
42
53
|
() => Math.max(8, Math.round((fontSize ?? 16) * 0.4)),
|
|
43
54
|
[fontSize]
|
|
@@ -45,7 +56,7 @@ export const NumberRun = React.memo(
|
|
|
45
56
|
const hasAnimated = hasAnimatedRef.current;
|
|
46
57
|
|
|
47
58
|
return (
|
|
48
|
-
<View style={rowStyle}>
|
|
59
|
+
<View style={[rowStyle, rowAlignStyles[align]]}>
|
|
49
60
|
{units.map((unit, index) => {
|
|
50
61
|
const inLead = index < leadLength;
|
|
51
62
|
const laneKey = inLead
|
|
@@ -0,0 +1,385 @@
|
|
|
1
|
+
import React, { useEffect, useMemo, useRef } from "react";
|
|
2
|
+
import {
|
|
3
|
+
StyleSheet,
|
|
4
|
+
Text,
|
|
5
|
+
type StyleProp,
|
|
6
|
+
type TextStyle,
|
|
7
|
+
View,
|
|
8
|
+
} from "react-native";
|
|
9
|
+
import Animated, {
|
|
10
|
+
type SharedValue,
|
|
11
|
+
useAnimatedStyle,
|
|
12
|
+
useSharedValue,
|
|
13
|
+
} from "react-native-reanimated";
|
|
14
|
+
import { useNumericLanes } from "../hooks/use-numeric-lanes";
|
|
15
|
+
import { isAsciiDigit } from "../model/display-units";
|
|
16
|
+
import type { LaminarAlign, MotionRecipe, NumericFlowDirection } from "../types";
|
|
17
|
+
|
|
18
|
+
const rowStyle = {
|
|
19
|
+
flexDirection: "row",
|
|
20
|
+
alignItems: "center",
|
|
21
|
+
alignSelf: "flex-start",
|
|
22
|
+
} as const;
|
|
23
|
+
|
|
24
|
+
const rowAlignStyles = {
|
|
25
|
+
left: { alignSelf: "flex-start" },
|
|
26
|
+
center: { alignSelf: "center" },
|
|
27
|
+
right: { alignSelf: "flex-end" },
|
|
28
|
+
} as const;
|
|
29
|
+
|
|
30
|
+
const slotFrameStyle = {
|
|
31
|
+
position: "relative",
|
|
32
|
+
alignSelf: "flex-start",
|
|
33
|
+
overflow: "hidden",
|
|
34
|
+
} as const;
|
|
35
|
+
|
|
36
|
+
const slotProbeStyle = {
|
|
37
|
+
opacity: 0,
|
|
38
|
+
} as const;
|
|
39
|
+
|
|
40
|
+
const slotDigitStyle = {
|
|
41
|
+
textAlign: "center",
|
|
42
|
+
} as const;
|
|
43
|
+
|
|
44
|
+
const REEL_MIN = -12;
|
|
45
|
+
const REEL_MAX = 22;
|
|
46
|
+
const REEL_POSITIONS = Array.from({ length: 30 }, (_, index) => index - 10);
|
|
47
|
+
|
|
48
|
+
// wrap reel positions so a bounded strip can represent an endless digit wheel
|
|
49
|
+
const mod = (value: number, divisor: number) =>
|
|
50
|
+
((value % divisor) + divisor) % divisor;
|
|
51
|
+
|
|
52
|
+
type SlotReelProps = {
|
|
53
|
+
readonly current: SharedValue<number>;
|
|
54
|
+
readonly slotHeight: number;
|
|
55
|
+
readonly textStyle?: StyleProp<TextStyle>;
|
|
56
|
+
readonly className?: string;
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
// compare flattened style values without treating a new array as a visual change
|
|
60
|
+
const equalStyleValue = (left: unknown, right: unknown) =>
|
|
61
|
+
left === right ||
|
|
62
|
+
(Array.isArray(left) &&
|
|
63
|
+
Array.isArray(right) &&
|
|
64
|
+
left.length === right.length &&
|
|
65
|
+
left.every((value, index) => value === right[index]));
|
|
66
|
+
|
|
67
|
+
// prevent every parent update from rebuilding the thirty-text reel
|
|
68
|
+
const areSlotReelPropsEqual = (
|
|
69
|
+
previous: SlotReelProps,
|
|
70
|
+
next: SlotReelProps
|
|
71
|
+
) => {
|
|
72
|
+
if (
|
|
73
|
+
previous.current !== next.current ||
|
|
74
|
+
previous.slotHeight !== next.slotHeight ||
|
|
75
|
+
previous.className !== next.className
|
|
76
|
+
) {
|
|
77
|
+
return false;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const previousStyle = StyleSheet.flatten(previous.textStyle) ?? {};
|
|
81
|
+
const nextStyle = StyleSheet.flatten(next.textStyle) ?? {};
|
|
82
|
+
const previousKeys = Object.keys(previousStyle) as (keyof TextStyle)[];
|
|
83
|
+
const nextKeys = Object.keys(nextStyle);
|
|
84
|
+
|
|
85
|
+
return (
|
|
86
|
+
previousKeys.length === nextKeys.length &&
|
|
87
|
+
previousKeys.every((key) =>
|
|
88
|
+
equalStyleValue(previousStyle[key], nextStyle[key])
|
|
89
|
+
)
|
|
90
|
+
);
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
const SlotReel = React.memo(function SlotReel({
|
|
94
|
+
current,
|
|
95
|
+
slotHeight,
|
|
96
|
+
textStyle,
|
|
97
|
+
className,
|
|
98
|
+
}: SlotReelProps) {
|
|
99
|
+
// translate one bounded reel instead of mounting a new animated digit per update
|
|
100
|
+
const animatedStyle = useAnimatedStyle(
|
|
101
|
+
() => ({
|
|
102
|
+
transform: [{ translateY: (-10 - current.value) * slotHeight }],
|
|
103
|
+
}),
|
|
104
|
+
[slotHeight]
|
|
105
|
+
);
|
|
106
|
+
|
|
107
|
+
return (
|
|
108
|
+
<Animated.View
|
|
109
|
+
pointerEvents="none"
|
|
110
|
+
style={[
|
|
111
|
+
{
|
|
112
|
+
position: "absolute",
|
|
113
|
+
top: 0,
|
|
114
|
+
left: 0,
|
|
115
|
+
right: 0,
|
|
116
|
+
},
|
|
117
|
+
animatedStyle,
|
|
118
|
+
]}
|
|
119
|
+
>
|
|
120
|
+
{REEL_POSITIONS.map((position, index) => (
|
|
121
|
+
<Text
|
|
122
|
+
key={position}
|
|
123
|
+
numberOfLines={1}
|
|
124
|
+
style={[
|
|
125
|
+
textStyle,
|
|
126
|
+
slotDigitStyle,
|
|
127
|
+
{
|
|
128
|
+
position: "absolute",
|
|
129
|
+
top: index * slotHeight,
|
|
130
|
+
left: 0,
|
|
131
|
+
right: 0,
|
|
132
|
+
height: slotHeight,
|
|
133
|
+
lineHeight: slotHeight,
|
|
134
|
+
},
|
|
135
|
+
]}
|
|
136
|
+
className={className}
|
|
137
|
+
>
|
|
138
|
+
{mod(position, 10)}
|
|
139
|
+
</Text>
|
|
140
|
+
))}
|
|
141
|
+
</Animated.View>
|
|
142
|
+
);
|
|
143
|
+
}, areSlotReelPropsEqual);
|
|
144
|
+
|
|
145
|
+
type SlotColumnProps = {
|
|
146
|
+
readonly digit: number;
|
|
147
|
+
readonly direction: NumericFlowDirection;
|
|
148
|
+
readonly delayMs: number;
|
|
149
|
+
readonly animateIn: boolean;
|
|
150
|
+
readonly motionRecipe: MotionRecipe;
|
|
151
|
+
readonly slotHeight: number;
|
|
152
|
+
readonly textStyle?: StyleProp<TextStyle>;
|
|
153
|
+
readonly className?: string;
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
function SlotColumn({
|
|
157
|
+
digit,
|
|
158
|
+
direction,
|
|
159
|
+
delayMs,
|
|
160
|
+
animateIn,
|
|
161
|
+
motionRecipe,
|
|
162
|
+
slotHeight,
|
|
163
|
+
textStyle,
|
|
164
|
+
className,
|
|
165
|
+
}: SlotColumnProps) {
|
|
166
|
+
// cumulative positions let the reel move across digit wraparound without jumps
|
|
167
|
+
const spinInDistance = Math.max(digit, 1);
|
|
168
|
+
const initialDirection = direction || 1;
|
|
169
|
+
const initialValue = animateIn
|
|
170
|
+
? digit - spinInDistance * initialDirection
|
|
171
|
+
: digit;
|
|
172
|
+
const current = useSharedValue(initialValue);
|
|
173
|
+
const cumulativeRef = useRef(digit);
|
|
174
|
+
const previousDigitRef = useRef(digit);
|
|
175
|
+
const initialRef = useRef(true);
|
|
176
|
+
const exitStateRef = useRef({
|
|
177
|
+
digit,
|
|
178
|
+
direction,
|
|
179
|
+
motionRecipe,
|
|
180
|
+
target: digit,
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
if (digit !== previousDigitRef.current) {
|
|
184
|
+
const previousDigit = previousDigitRef.current;
|
|
185
|
+
let digitDelta: number;
|
|
186
|
+
|
|
187
|
+
if (direction > 0) {
|
|
188
|
+
digitDelta =
|
|
189
|
+
digit >= previousDigit
|
|
190
|
+
? digit - previousDigit
|
|
191
|
+
: 10 - previousDigit + digit;
|
|
192
|
+
} else if (direction < 0) {
|
|
193
|
+
digitDelta =
|
|
194
|
+
previousDigit >= digit
|
|
195
|
+
? -(previousDigit - digit)
|
|
196
|
+
: -(10 - digit + previousDigit);
|
|
197
|
+
} else {
|
|
198
|
+
digitDelta = digit - previousDigit;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
cumulativeRef.current += digitDelta;
|
|
202
|
+
previousDigitRef.current = digit;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
exitStateRef.current = {
|
|
206
|
+
digit,
|
|
207
|
+
direction,
|
|
208
|
+
motionRecipe,
|
|
209
|
+
target: cumulativeRef.current,
|
|
210
|
+
};
|
|
211
|
+
|
|
212
|
+
useEffect(() => {
|
|
213
|
+
// continue the current reel from its last committed target when the column exits
|
|
214
|
+
return () => {
|
|
215
|
+
const exitState = exitStateRef.current;
|
|
216
|
+
const spinOutDistance = Math.max(exitState.digit, 1);
|
|
217
|
+
const exitDirection = exitState.direction || 1;
|
|
218
|
+
|
|
219
|
+
current.value = exitState.motionRecipe.driveNumber(
|
|
220
|
+
exitState.target + spinOutDistance * exitDirection
|
|
221
|
+
);
|
|
222
|
+
};
|
|
223
|
+
}, [current]);
|
|
224
|
+
|
|
225
|
+
useEffect(() => {
|
|
226
|
+
// keep the reel bounded by recentring after enough cumulative turns
|
|
227
|
+
if (initialRef.current) {
|
|
228
|
+
initialRef.current = false;
|
|
229
|
+
|
|
230
|
+
if (!animateIn) {
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
let target = cumulativeRef.current;
|
|
236
|
+
|
|
237
|
+
if (target < REEL_MIN + 4 || target > REEL_MAX - 4) {
|
|
238
|
+
const shift = Math.trunc(target / 10) * 10;
|
|
239
|
+
|
|
240
|
+
if (shift !== 0) {
|
|
241
|
+
target -= shift;
|
|
242
|
+
cumulativeRef.current = target;
|
|
243
|
+
current.value -= shift;
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
exitStateRef.current = {
|
|
248
|
+
digit,
|
|
249
|
+
direction,
|
|
250
|
+
motionRecipe,
|
|
251
|
+
target,
|
|
252
|
+
};
|
|
253
|
+
|
|
254
|
+
current.value = motionRecipe.driveNumber(target, delayMs);
|
|
255
|
+
}, [animateIn, current, delayMs, digit, direction, motionRecipe]);
|
|
256
|
+
|
|
257
|
+
return (
|
|
258
|
+
<View style={slotFrameStyle}>
|
|
259
|
+
<Text
|
|
260
|
+
style={[textStyle, slotProbeStyle, { lineHeight: slotHeight }]}
|
|
261
|
+
className={className}
|
|
262
|
+
>
|
|
263
|
+
0
|
|
264
|
+
</Text>
|
|
265
|
+
<SlotReel
|
|
266
|
+
current={current}
|
|
267
|
+
slotHeight={slotHeight}
|
|
268
|
+
textStyle={textStyle}
|
|
269
|
+
className={className}
|
|
270
|
+
/>
|
|
271
|
+
</View>
|
|
272
|
+
);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
type SlotsRunProps = {
|
|
276
|
+
readonly value: string;
|
|
277
|
+
readonly motionRecipe: MotionRecipe;
|
|
278
|
+
readonly align: LaminarAlign;
|
|
279
|
+
readonly fontSize?: number;
|
|
280
|
+
readonly textStyle?: StyleProp<TextStyle>;
|
|
281
|
+
readonly staggerMs: number;
|
|
282
|
+
readonly className?: string;
|
|
283
|
+
};
|
|
284
|
+
|
|
285
|
+
// render text prefixes beside reusable digit reels
|
|
286
|
+
export const SlotsRun = React.memo(
|
|
287
|
+
({
|
|
288
|
+
value,
|
|
289
|
+
motionRecipe,
|
|
290
|
+
align,
|
|
291
|
+
fontSize,
|
|
292
|
+
textStyle,
|
|
293
|
+
staggerMs,
|
|
294
|
+
className,
|
|
295
|
+
}: Readonly<SlotsRunProps>) => {
|
|
296
|
+
// numeric lane identity decides which columns stay mounted as values change
|
|
297
|
+
const { units, direction, leadLength } = useNumericLanes(value);
|
|
298
|
+
const lastValueRef = useRef(value);
|
|
299
|
+
const hasAnimatedRef = useRef(false);
|
|
300
|
+
// derive one stable row height so every reel position shares the same baseline
|
|
301
|
+
const slotHeight = useMemo(() => {
|
|
302
|
+
const flattenedStyle = StyleSheet.flatten(textStyle);
|
|
303
|
+
const resolvedLineHeight =
|
|
304
|
+
typeof flattenedStyle?.lineHeight === "number"
|
|
305
|
+
? flattenedStyle.lineHeight
|
|
306
|
+
: undefined;
|
|
307
|
+
const resolvedFontSize =
|
|
308
|
+
typeof flattenedStyle?.fontSize === "number"
|
|
309
|
+
? flattenedStyle.fontSize
|
|
310
|
+
: fontSize;
|
|
311
|
+
|
|
312
|
+
return Math.max(
|
|
313
|
+
12,
|
|
314
|
+
Math.ceil(resolvedLineHeight ?? (resolvedFontSize ?? 16) * 1.18)
|
|
315
|
+
);
|
|
316
|
+
}, [fontSize, textStyle]);
|
|
317
|
+
|
|
318
|
+
if (value !== lastValueRef.current) {
|
|
319
|
+
hasAnimatedRef.current = true;
|
|
320
|
+
lastValueRef.current = value;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
const digitCount = units.filter(isAsciiDigit).length;
|
|
324
|
+
let digitIndex = 0;
|
|
325
|
+
const hasAnimated = hasAnimatedRef.current;
|
|
326
|
+
|
|
327
|
+
return (
|
|
328
|
+
<View style={[rowStyle, rowAlignStyles[align]]}>
|
|
329
|
+
{units.map((unit, index) => {
|
|
330
|
+
const inLead = index < leadLength;
|
|
331
|
+
const laneKey = inLead
|
|
332
|
+
? `lead:${index}`
|
|
333
|
+
: `slot:${units.length - 1 - index}`;
|
|
334
|
+
|
|
335
|
+
if (inLead || !isAsciiDigit(unit)) {
|
|
336
|
+
return (
|
|
337
|
+
<Animated.Text
|
|
338
|
+
key={laneKey}
|
|
339
|
+
layout={
|
|
340
|
+
hasAnimated ? motionRecipe.layoutTransition : undefined
|
|
341
|
+
}
|
|
342
|
+
exiting={!inLead ? motionRecipe.exitTransition : undefined}
|
|
343
|
+
style={textStyle}
|
|
344
|
+
className={className}
|
|
345
|
+
>
|
|
346
|
+
{unit}
|
|
347
|
+
</Animated.Text>
|
|
348
|
+
);
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
const delayMs = (digitCount - 1 - digitIndex) * staggerMs;
|
|
352
|
+
digitIndex += 1;
|
|
353
|
+
|
|
354
|
+
return (
|
|
355
|
+
<Animated.View
|
|
356
|
+
key={laneKey}
|
|
357
|
+
layout={
|
|
358
|
+
hasAnimated ? motionRecipe.layoutTransition : undefined
|
|
359
|
+
}
|
|
360
|
+
entering={
|
|
361
|
+
hasAnimated ? motionRecipe.enterTransition : undefined
|
|
362
|
+
}
|
|
363
|
+
exiting={
|
|
364
|
+
motionRecipe.exitTransition
|
|
365
|
+
}
|
|
366
|
+
>
|
|
367
|
+
<SlotColumn
|
|
368
|
+
digit={Number(unit)}
|
|
369
|
+
direction={direction}
|
|
370
|
+
delayMs={delayMs}
|
|
371
|
+
animateIn={hasAnimated}
|
|
372
|
+
motionRecipe={motionRecipe}
|
|
373
|
+
slotHeight={slotHeight}
|
|
374
|
+
textStyle={textStyle}
|
|
375
|
+
className={className}
|
|
376
|
+
/>
|
|
377
|
+
</Animated.View>
|
|
378
|
+
);
|
|
379
|
+
})}
|
|
380
|
+
</View>
|
|
381
|
+
);
|
|
382
|
+
}
|
|
383
|
+
);
|
|
384
|
+
|
|
385
|
+
SlotsRun.displayName = "SlotsRun";
|
package/src/view/text-run.tsx
CHANGED
|
@@ -1,37 +1,110 @@
|
|
|
1
|
-
import React, { useId, useRef } from "react";
|
|
1
|
+
import React, { useId, useLayoutEffect, useMemo, useRef } from "react";
|
|
2
|
+
import type { ReactNode } from "react";
|
|
2
3
|
import type { StyleProp, TextStyle } from "react-native";
|
|
4
|
+
import { useSharedValue } from "react-native-reanimated";
|
|
3
5
|
import { useTextGlyphs } from "../hooks/use-text-glyphs";
|
|
4
|
-
import
|
|
6
|
+
import {
|
|
7
|
+
createLeadingEnterTransition,
|
|
8
|
+
createLeadingExitTransition,
|
|
9
|
+
} from "../motion/entry-exit-builders";
|
|
10
|
+
import type { LaminarAlign, MotionRecipe } from "../types";
|
|
5
11
|
import { GlyphRun } from "./glyph-run";
|
|
6
12
|
|
|
7
13
|
type TextRunProps = {
|
|
8
14
|
readonly value: string;
|
|
9
15
|
readonly motionRecipe: MotionRecipe;
|
|
16
|
+
readonly align: LaminarAlign;
|
|
17
|
+
readonly leading?: ReactNode;
|
|
18
|
+
readonly leadingKey?: string | number;
|
|
19
|
+
readonly leadingGap?: number;
|
|
10
20
|
readonly textStyle?: StyleProp<TextStyle>;
|
|
11
21
|
readonly className?: string;
|
|
12
22
|
};
|
|
13
23
|
|
|
24
|
+
// connect text identity, leading transitions, and glyph layout into one run
|
|
14
25
|
export const TextRun = React.memo(
|
|
15
|
-
({
|
|
26
|
+
({
|
|
27
|
+
value,
|
|
28
|
+
motionRecipe,
|
|
29
|
+
align,
|
|
30
|
+
leading,
|
|
31
|
+
leadingKey,
|
|
32
|
+
leadingGap = 0,
|
|
33
|
+
textStyle,
|
|
34
|
+
className,
|
|
35
|
+
}: TextRunProps) => {
|
|
16
36
|
// namespace ids per instance so repeated strings do not collide
|
|
17
37
|
const scopeId = useId();
|
|
18
|
-
const glyphs = useTextGlyphs(value, scopeId);
|
|
19
38
|
const lastValueRef = useRef(value);
|
|
39
|
+
const lastLeadingPresenceRef = useRef(Boolean(leading));
|
|
40
|
+
const lastLeadingKeyRef = useRef(leadingKey);
|
|
20
41
|
const hasAnimatedRef = useRef(false);
|
|
42
|
+
const isLeadingSwap =
|
|
43
|
+
Boolean(leading) &&
|
|
44
|
+
lastLeadingPresenceRef.current &&
|
|
45
|
+
leadingKey !== lastLeadingKeyRef.current;
|
|
46
|
+
// update the worklet flag after commit so render never writes a shared value
|
|
47
|
+
const leadingSwapProgress = useSharedValue(0);
|
|
48
|
+
useLayoutEffect(() => {
|
|
49
|
+
leadingSwapProgress.value = isLeadingSwap ? 1 : 0;
|
|
50
|
+
}, [isLeadingSwap, leadingSwapProgress]);
|
|
51
|
+
const glyphs = useTextGlyphs(value, scopeId, leading, leadingKey);
|
|
52
|
+
const elementEnterTransition = useMemo(
|
|
53
|
+
() =>
|
|
54
|
+
createLeadingEnterTransition({
|
|
55
|
+
durationMs: motionRecipe.durationMs,
|
|
56
|
+
easing: motionRecipe.easing,
|
|
57
|
+
}),
|
|
58
|
+
[motionRecipe.durationMs, motionRecipe.easing]
|
|
59
|
+
);
|
|
60
|
+
const elementExitTransition = useMemo(
|
|
61
|
+
() =>
|
|
62
|
+
createLeadingExitTransition({
|
|
63
|
+
durationMs: motionRecipe.durationMs,
|
|
64
|
+
easing: motionRecipe.easing,
|
|
65
|
+
leadingGap,
|
|
66
|
+
scale: leadingSwapProgress,
|
|
67
|
+
}),
|
|
68
|
+
[
|
|
69
|
+
leadingGap,
|
|
70
|
+
leadingSwapProgress,
|
|
71
|
+
motionRecipe.durationMs,
|
|
72
|
+
motionRecipe.easing,
|
|
73
|
+
]
|
|
74
|
+
);
|
|
21
75
|
|
|
22
|
-
|
|
76
|
+
// mark the first value as settled and later changes as eligible for motion
|
|
77
|
+
if (
|
|
78
|
+
value !== lastValueRef.current ||
|
|
79
|
+
Boolean(leading) !== lastLeadingPresenceRef.current ||
|
|
80
|
+
leadingKey !== lastLeadingKeyRef.current
|
|
81
|
+
) {
|
|
23
82
|
hasAnimatedRef.current = true;
|
|
24
83
|
lastValueRef.current = value;
|
|
84
|
+
lastLeadingPresenceRef.current = Boolean(leading);
|
|
85
|
+
lastLeadingKeyRef.current = leadingKey;
|
|
25
86
|
}
|
|
26
87
|
|
|
88
|
+
const hasAnimated = hasAnimatedRef.current;
|
|
89
|
+
|
|
27
90
|
return (
|
|
28
91
|
<GlyphRun
|
|
29
92
|
glyphs={glyphs}
|
|
30
|
-
layoutTransition={
|
|
93
|
+
layoutTransition={
|
|
94
|
+
hasAnimated ? motionRecipe.layoutTransition : undefined
|
|
95
|
+
}
|
|
31
96
|
enterTransition={
|
|
32
|
-
hasAnimatedRef.current
|
|
97
|
+
hasAnimatedRef.current
|
|
98
|
+
? motionRecipe.enterTransition
|
|
99
|
+
: undefined
|
|
100
|
+
}
|
|
101
|
+
elementEnterTransition={
|
|
102
|
+
isLeadingSwap ? elementEnterTransition : undefined
|
|
33
103
|
}
|
|
34
104
|
exitTransition={motionRecipe.exitTransition}
|
|
105
|
+
elementExitTransition={elementExitTransition}
|
|
106
|
+
leadingGap={leadingGap}
|
|
107
|
+
align={align}
|
|
35
108
|
textStyle={textStyle}
|
|
36
109
|
className={className}
|
|
37
110
|
/>
|