react-native-laminar 1.4.4 → 1.5.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/README.md +36 -6
- package/lib/commonjs/hooks/use-inline-auto-width.js +24 -1
- package/lib/commonjs/hooks/use-inline-auto-width.js.map +1 -1
- package/lib/commonjs/hooks/use-leading-animation.js +49 -0
- package/lib/commonjs/hooks/use-leading-animation.js.map +1 -0
- package/lib/commonjs/hooks/use-morph-motion.js +3 -1
- package/lib/commonjs/hooks/use-morph-motion.js.map +1 -1
- package/lib/commonjs/index.js +35 -6
- package/lib/commonjs/index.js.map +1 -1
- package/lib/commonjs/motion/preset-map.js +5 -0
- package/lib/commonjs/motion/preset-map.js.map +1 -1
- package/lib/commonjs/view/leading-element.js +37 -0
- package/lib/commonjs/view/leading-element.js.map +1 -0
- package/lib/commonjs/view/morph-viewport.js +26 -0
- package/lib/commonjs/view/morph-viewport.js.map +1 -1
- package/lib/commonjs/view/number-run.js +70 -22
- package/lib/commonjs/view/number-run.js.map +1 -1
- package/lib/commonjs/view/slot-shadow.js +77 -0
- package/lib/commonjs/view/slot-shadow.js.map +1 -0
- package/lib/commonjs/view/slots-run.js +111 -42
- package/lib/commonjs/view/slots-run.js.map +1 -1
- package/lib/commonjs/view/text-run.js +13 -24
- package/lib/commonjs/view/text-run.js.map +1 -1
- package/lib/commonjs/view/ticker-run.js +127 -0
- package/lib/commonjs/view/ticker-run.js.map +1 -0
- package/lib/module/hooks/use-inline-auto-width.js +24 -1
- package/lib/module/hooks/use-inline-auto-width.js.map +1 -1
- package/lib/module/hooks/use-leading-animation.js +44 -0
- package/lib/module/hooks/use-leading-animation.js.map +1 -0
- package/lib/module/hooks/use-morph-motion.js +3 -1
- package/lib/module/hooks/use-morph-motion.js.map +1 -1
- package/lib/module/index.js +35 -6
- package/lib/module/index.js.map +1 -1
- package/lib/module/motion/preset-map.js +5 -0
- package/lib/module/motion/preset-map.js.map +1 -1
- package/lib/module/view/leading-element.js +32 -0
- package/lib/module/view/leading-element.js.map +1 -0
- package/lib/module/view/morph-viewport.js +28 -2
- package/lib/module/view/morph-viewport.js.map +1 -1
- package/lib/module/view/number-run.js +70 -23
- package/lib/module/view/number-run.js.map +1 -1
- package/lib/module/view/slot-shadow.js +72 -0
- package/lib/module/view/slot-shadow.js.map +1 -0
- package/lib/module/view/slots-run.js +111 -42
- package/lib/module/view/slots-run.js.map +1 -1
- package/lib/module/view/text-run.js +14 -25
- package/lib/module/view/text-run.js.map +1 -1
- package/lib/module/view/ticker-run.js +121 -0
- package/lib/module/view/ticker-run.js.map +1 -0
- package/lib/typescript/commonjs/hooks/use-inline-auto-width.d.ts.map +1 -1
- package/lib/typescript/commonjs/hooks/use-leading-animation.d.ts +16 -0
- package/lib/typescript/commonjs/hooks/use-leading-animation.d.ts.map +1 -0
- package/lib/typescript/commonjs/hooks/use-morph-motion.d.ts +3 -2
- package/lib/typescript/commonjs/hooks/use-morph-motion.d.ts.map +1 -1
- package/lib/typescript/commonjs/index.d.ts +1 -1
- package/lib/typescript/commonjs/index.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/leading-element.d.ts +14 -0
- package/lib/typescript/commonjs/view/leading-element.d.ts.map +1 -0
- package/lib/typescript/commonjs/view/morph-viewport.d.ts +4 -1
- package/lib/typescript/commonjs/view/morph-viewport.d.ts.map +1 -1
- package/lib/typescript/commonjs/view/number-run.d.ts +6 -2
- package/lib/typescript/commonjs/view/number-run.d.ts.map +1 -1
- package/lib/typescript/commonjs/view/slot-shadow.d.ts +11 -0
- package/lib/typescript/commonjs/view/slot-shadow.d.ts.map +1 -0
- package/lib/typescript/commonjs/view/slots-run.d.ts +8 -3
- package/lib/typescript/commonjs/view/slots-run.d.ts.map +1 -1
- package/lib/typescript/commonjs/view/text-run.d.ts.map +1 -1
- package/lib/typescript/commonjs/view/ticker-run.d.ts +21 -0
- package/lib/typescript/commonjs/view/ticker-run.d.ts.map +1 -0
- package/lib/typescript/module/hooks/use-inline-auto-width.d.ts.map +1 -1
- package/lib/typescript/module/hooks/use-leading-animation.d.ts +16 -0
- package/lib/typescript/module/hooks/use-leading-animation.d.ts.map +1 -0
- package/lib/typescript/module/hooks/use-morph-motion.d.ts +3 -2
- package/lib/typescript/module/hooks/use-morph-motion.d.ts.map +1 -1
- package/lib/typescript/module/index.d.ts +1 -1
- package/lib/typescript/module/index.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/leading-element.d.ts +14 -0
- package/lib/typescript/module/view/leading-element.d.ts.map +1 -0
- package/lib/typescript/module/view/morph-viewport.d.ts +4 -1
- package/lib/typescript/module/view/morph-viewport.d.ts.map +1 -1
- package/lib/typescript/module/view/number-run.d.ts +6 -2
- package/lib/typescript/module/view/number-run.d.ts.map +1 -1
- package/lib/typescript/module/view/slot-shadow.d.ts +11 -0
- package/lib/typescript/module/view/slot-shadow.d.ts.map +1 -0
- package/lib/typescript/module/view/slots-run.d.ts +8 -3
- package/lib/typescript/module/view/slots-run.d.ts.map +1 -1
- package/lib/typescript/module/view/text-run.d.ts.map +1 -1
- package/lib/typescript/module/view/ticker-run.d.ts +21 -0
- package/lib/typescript/module/view/ticker-run.d.ts.map +1 -0
- package/package.json +3 -1
- package/src/hooks/use-inline-auto-width.ts +33 -1
- package/src/hooks/use-leading-animation.ts +74 -0
- package/src/hooks/use-morph-motion.ts +10 -1
- package/src/index.tsx +44 -6
- package/src/motion/preset-map.ts +5 -0
- package/src/types.ts +17 -2
- package/src/view/leading-element.tsx +45 -0
- package/src/view/morph-viewport.tsx +39 -1
- package/src/view/number-run.tsx +94 -26
- package/src/view/slot-shadow.tsx +99 -0
- package/src/view/slots-run.tsx +163 -58
- package/src/view/text-run.tsx +14 -51
- package/src/view/ticker-run.tsx +167 -0
package/src/view/number-run.tsx
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
|
-
import React, { useMemo, useRef } from "react";
|
|
1
|
+
import React, { useMemo, useRef, type ReactNode } from "react";
|
|
2
2
|
import { type StyleProp, type TextStyle, View } from "react-native";
|
|
3
|
+
import Animated from "react-native-reanimated";
|
|
4
|
+
import { useLeadingAnimation } from "../hooks/use-leading-animation";
|
|
3
5
|
import { useNumericLanes } from "../hooks/use-numeric-lanes";
|
|
4
6
|
import type { LaminarAlign, MotionRecipe } from "../types";
|
|
7
|
+
import { LeadingElement } from "./leading-element";
|
|
5
8
|
import { NumberLane } from "./number-lane";
|
|
6
9
|
|
|
7
10
|
const rowStyle = {
|
|
@@ -24,6 +27,10 @@ type NumberRunProps = {
|
|
|
24
27
|
readonly textStyle?: StyleProp<TextStyle>;
|
|
25
28
|
readonly staggerMs: number;
|
|
26
29
|
readonly className?: string;
|
|
30
|
+
readonly leading?: ReactNode;
|
|
31
|
+
readonly leadingKey?: string | number;
|
|
32
|
+
readonly leadingGap?: number;
|
|
33
|
+
readonly ready?: boolean;
|
|
27
34
|
};
|
|
28
35
|
|
|
29
36
|
// arrange numeric lanes from left to right while the hook preserves their identities
|
|
@@ -36,16 +43,59 @@ export const NumberRun = React.memo(
|
|
|
36
43
|
textStyle,
|
|
37
44
|
staggerMs,
|
|
38
45
|
className,
|
|
46
|
+
leading,
|
|
47
|
+
leadingKey,
|
|
48
|
+
leadingGap = 0,
|
|
49
|
+
ready = true,
|
|
39
50
|
}: Readonly<NumberRunProps>) => {
|
|
51
|
+
const hasDisplayedRef = useRef(false);
|
|
52
|
+
const displayedValueRef = useRef(value);
|
|
53
|
+
const displayedLeadingRef = useRef(leading);
|
|
54
|
+
const displayedLeadingKeyRef = useRef(leadingKey);
|
|
55
|
+
const displayedLeadingGapRef = useRef(leadingGap);
|
|
56
|
+
const shouldDisplayTarget = ready || !hasDisplayedRef.current;
|
|
57
|
+
const visibleValue = shouldDisplayTarget
|
|
58
|
+
? value
|
|
59
|
+
: displayedValueRef.current;
|
|
60
|
+
const visibleLeading = shouldDisplayTarget
|
|
61
|
+
? leading
|
|
62
|
+
: displayedLeadingRef.current;
|
|
63
|
+
const visibleLeadingKey = shouldDisplayTarget
|
|
64
|
+
? leadingKey
|
|
65
|
+
: displayedLeadingKeyRef.current;
|
|
66
|
+
const visibleLeadingGap = shouldDisplayTarget
|
|
67
|
+
? leadingGap
|
|
68
|
+
: displayedLeadingGapRef.current;
|
|
69
|
+
|
|
70
|
+
if (shouldDisplayTarget) {
|
|
71
|
+
hasDisplayedRef.current = true;
|
|
72
|
+
displayedValueRef.current = value;
|
|
73
|
+
displayedLeadingRef.current = leading;
|
|
74
|
+
displayedLeadingKeyRef.current = leadingKey;
|
|
75
|
+
displayedLeadingGapRef.current = leadingGap;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const {
|
|
79
|
+
elementEnterTransition,
|
|
80
|
+
elementExitTransition,
|
|
81
|
+
isLeadingChange,
|
|
82
|
+
isLeadingSwap,
|
|
83
|
+
} = useLeadingAnimation({
|
|
84
|
+
leading: visibleLeading,
|
|
85
|
+
leadingKey: visibleLeadingKey,
|
|
86
|
+
leadingGap: visibleLeadingGap,
|
|
87
|
+
motionRecipe,
|
|
88
|
+
});
|
|
40
89
|
// the hook owns lane identity while this component owns row placement and timing
|
|
41
|
-
const { units, laneKeys, direction, leadLength } =
|
|
42
|
-
|
|
90
|
+
const { units, laneKeys, direction, leadLength } =
|
|
91
|
+
useNumericLanes(visibleValue);
|
|
92
|
+
const lastValueRef = useRef(visibleValue);
|
|
43
93
|
const hasAnimatedRef = useRef(false);
|
|
44
94
|
|
|
45
95
|
// skip enter animations on first paint so the number feels settled
|
|
46
|
-
if (
|
|
96
|
+
if (visibleValue !== lastValueRef.current || isLeadingChange) {
|
|
47
97
|
hasAnimatedRef.current = true;
|
|
48
|
-
lastValueRef.current =
|
|
98
|
+
lastValueRef.current = visibleValue;
|
|
49
99
|
}
|
|
50
100
|
|
|
51
101
|
// scale travel from the font while keeping a minimum visible movement
|
|
@@ -57,28 +107,46 @@ export const NumberRun = React.memo(
|
|
|
57
107
|
|
|
58
108
|
return (
|
|
59
109
|
<View style={[rowStyle, rowAlignStyles[align]]}>
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
110
|
+
<LeadingElement
|
|
111
|
+
leading={visibleLeading}
|
|
112
|
+
leadingKey={visibleLeadingKey}
|
|
113
|
+
leadingGap={visibleLeadingGap}
|
|
114
|
+
enterTransition={
|
|
115
|
+
hasAnimated ? motionRecipe.enterTransition : undefined
|
|
116
|
+
}
|
|
117
|
+
elementEnterTransition={
|
|
118
|
+
isLeadingSwap ? elementEnterTransition : undefined
|
|
119
|
+
}
|
|
120
|
+
exitTransition={motionRecipe.exitTransition}
|
|
121
|
+
elementExitTransition={elementExitTransition}
|
|
122
|
+
/>
|
|
123
|
+
<Animated.View
|
|
124
|
+
layout={isLeadingChange ? motionRecipe.layoutTransition : undefined}
|
|
125
|
+
style={rowStyle}
|
|
126
|
+
>
|
|
127
|
+
{units.map((unit, index) => {
|
|
128
|
+
const inLead = index < leadLength;
|
|
129
|
+
const laneKey = inLead
|
|
130
|
+
? `lead:${index}`
|
|
131
|
+
: `lane:${units.length - 1 - index}`;
|
|
65
132
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
133
|
+
return (
|
|
134
|
+
<NumberLane
|
|
135
|
+
key={laneKey}
|
|
136
|
+
unit={unit}
|
|
137
|
+
tokenKey={laneKeys[index]}
|
|
138
|
+
isLead={inLead}
|
|
139
|
+
hasAnimated={hasAnimated}
|
|
140
|
+
delayMs={index * staggerMs}
|
|
141
|
+
direction={direction}
|
|
142
|
+
travelDistance={travelDistance}
|
|
143
|
+
motionRecipe={motionRecipe}
|
|
144
|
+
textStyle={textStyle}
|
|
145
|
+
className={className}
|
|
146
|
+
/>
|
|
147
|
+
);
|
|
148
|
+
})}
|
|
149
|
+
</Animated.View>
|
|
82
150
|
</View>
|
|
83
151
|
);
|
|
84
152
|
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import MaskedView from "@react-native-masked-view/masked-view";
|
|
3
|
+
import { LinearGradient } from "expo-linear-gradient";
|
|
4
|
+
import { StyleSheet } from "react-native";
|
|
5
|
+
|
|
6
|
+
const DEFAULT_SHADOW_COLOR = "#ffffff";
|
|
7
|
+
// The reference digit viewport fades across roughly one third of its visible row.
|
|
8
|
+
const DEFAULT_SHADOW_SIZE_RATIO = 0.28;
|
|
9
|
+
|
|
10
|
+
const transparentColor = (color: string) => {
|
|
11
|
+
const value = color.trim();
|
|
12
|
+
|
|
13
|
+
if (value.startsWith("#")) {
|
|
14
|
+
const hex = value.slice(1);
|
|
15
|
+
const expanded =
|
|
16
|
+
hex.length === 3 || hex.length === 4
|
|
17
|
+
? hex
|
|
18
|
+
.slice(0, 3)
|
|
19
|
+
.split("")
|
|
20
|
+
.map((channel) => channel + channel)
|
|
21
|
+
.join("")
|
|
22
|
+
: hex.slice(0, 6);
|
|
23
|
+
|
|
24
|
+
if (expanded.length === 6 && /^[0-9a-f]+$/i.test(expanded)) {
|
|
25
|
+
return `rgba(${Number.parseInt(expanded.slice(0, 2), 16)}, ${Number.parseInt(
|
|
26
|
+
expanded.slice(2, 4),
|
|
27
|
+
16
|
|
28
|
+
)}, ${Number.parseInt(expanded.slice(4, 6), 16)}, 0)`;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const channels = value.match(/\d+(?:\.\d+)?%?/g);
|
|
33
|
+
if ((value.startsWith("rgb(") || value.startsWith("rgba(")) && channels) {
|
|
34
|
+
const rgb = channels.slice(0, 3).map((channel) => {
|
|
35
|
+
if (channel.endsWith("%")) {
|
|
36
|
+
return Math.round((Number.parseFloat(channel) / 100) * 255);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return Number.parseFloat(channel);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
if (rgb.length === 3 && rgb.every(Number.isFinite)) {
|
|
43
|
+
return `rgba(${rgb.join(", ")}, 0)`;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Keep the fallback light so an unknown color never introduces a dark edge.
|
|
48
|
+
return "rgba(255, 255, 255, 0)";
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
type SlotShadowProps = {
|
|
52
|
+
readonly slotHeight: number;
|
|
53
|
+
readonly viewportHeight: number;
|
|
54
|
+
readonly color?: string;
|
|
55
|
+
readonly size?: number;
|
|
56
|
+
readonly children: React.ReactElement;
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
// Keep the mask mounted for the lifetime of the viewport. The gradient controls
|
|
60
|
+
// content alpha at the clipping edges instead of painting over the glyphs.
|
|
61
|
+
export const SlotShadow = React.memo(function SlotShadow({
|
|
62
|
+
slotHeight,
|
|
63
|
+
viewportHeight,
|
|
64
|
+
color = DEFAULT_SHADOW_COLOR,
|
|
65
|
+
size,
|
|
66
|
+
children,
|
|
67
|
+
}: SlotShadowProps) {
|
|
68
|
+
const edgeSize = Math.min(
|
|
69
|
+
Math.max(4, size ?? slotHeight * DEFAULT_SHADOW_SIZE_RATIO),
|
|
70
|
+
viewportHeight / 2
|
|
71
|
+
);
|
|
72
|
+
const fadeColor = transparentColor(color);
|
|
73
|
+
const fadeRatio = edgeSize / viewportHeight;
|
|
74
|
+
|
|
75
|
+
return (
|
|
76
|
+
<MaskedView
|
|
77
|
+
pointerEvents="none"
|
|
78
|
+
style={styles.container}
|
|
79
|
+
maskElement={
|
|
80
|
+
<LinearGradient
|
|
81
|
+
colors={[fadeColor, color, color, fadeColor]}
|
|
82
|
+
locations={[0, fadeRatio, 1 - fadeRatio, 1]}
|
|
83
|
+
start={{ x: 0, y: 0 }}
|
|
84
|
+
end={{ x: 0, y: 1 }}
|
|
85
|
+
style={styles.mask}
|
|
86
|
+
/>
|
|
87
|
+
}
|
|
88
|
+
>
|
|
89
|
+
{children}
|
|
90
|
+
</MaskedView>
|
|
91
|
+
);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
SlotShadow.displayName = "SlotShadow";
|
|
95
|
+
|
|
96
|
+
const styles = StyleSheet.create({
|
|
97
|
+
container: StyleSheet.absoluteFillObject,
|
|
98
|
+
mask: StyleSheet.absoluteFillObject,
|
|
99
|
+
});
|
package/src/view/slots-run.tsx
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import React, { useEffect, useMemo, useRef } from "react";
|
|
1
|
+
import React, { useEffect, useMemo, useRef, type ReactNode } from "react";
|
|
2
2
|
import {
|
|
3
3
|
StyleSheet,
|
|
4
4
|
Text,
|
|
@@ -11,9 +11,17 @@ import Animated, {
|
|
|
11
11
|
useAnimatedStyle,
|
|
12
12
|
useSharedValue,
|
|
13
13
|
} from "react-native-reanimated";
|
|
14
|
+
import { useLeadingAnimation } from "../hooks/use-leading-animation";
|
|
14
15
|
import { useNumericLanes } from "../hooks/use-numeric-lanes";
|
|
15
16
|
import { isAsciiDigit } from "../model/display-units";
|
|
16
|
-
import type {
|
|
17
|
+
import type {
|
|
18
|
+
LaminarAlign,
|
|
19
|
+
LaminarShadow,
|
|
20
|
+
MotionRecipe,
|
|
21
|
+
NumericFlowDirection,
|
|
22
|
+
} from "../types";
|
|
23
|
+
import { LeadingElement } from "./leading-element";
|
|
24
|
+
import { SlotShadow } from "./slot-shadow";
|
|
17
25
|
|
|
18
26
|
const rowStyle = {
|
|
19
27
|
flexDirection: "row",
|
|
@@ -64,7 +72,7 @@ const equalStyleValue = (left: unknown, right: unknown) =>
|
|
|
64
72
|
left.length === right.length &&
|
|
65
73
|
left.every((value, index) => value === right[index]));
|
|
66
74
|
|
|
67
|
-
// prevent every parent update from rebuilding the
|
|
75
|
+
// prevent every parent update from rebuilding the bounded reel
|
|
68
76
|
const areSlotReelPropsEqual = (
|
|
69
77
|
previous: SlotReelProps,
|
|
70
78
|
next: SlotReelProps
|
|
@@ -151,6 +159,7 @@ type SlotColumnProps = {
|
|
|
151
159
|
readonly slotHeight: number;
|
|
152
160
|
readonly textStyle?: StyleProp<TextStyle>;
|
|
153
161
|
readonly className?: string;
|
|
162
|
+
readonly shadow?: LaminarShadow;
|
|
154
163
|
};
|
|
155
164
|
|
|
156
165
|
function SlotColumn({
|
|
@@ -162,6 +171,7 @@ function SlotColumn({
|
|
|
162
171
|
slotHeight,
|
|
163
172
|
textStyle,
|
|
164
173
|
className,
|
|
174
|
+
shadow = false,
|
|
165
175
|
}: SlotColumnProps) {
|
|
166
176
|
// cumulative positions let the reel move across digit wraparound without jumps
|
|
167
177
|
const spinInDistance = Math.max(digit, 1);
|
|
@@ -173,13 +183,19 @@ function SlotColumn({
|
|
|
173
183
|
const cumulativeRef = useRef(digit);
|
|
174
184
|
const previousDigitRef = useRef(digit);
|
|
175
185
|
const initialRef = useRef(true);
|
|
186
|
+
const drivenTargetRef = useRef<number | null>(null);
|
|
176
187
|
const exitStateRef = useRef({
|
|
177
188
|
digit,
|
|
178
189
|
direction,
|
|
179
190
|
motionRecipe,
|
|
180
191
|
target: digit,
|
|
181
192
|
});
|
|
182
|
-
|
|
193
|
+
const shadowOptions =
|
|
194
|
+
shadow !== null && typeof shadow === "object"
|
|
195
|
+
? shadow
|
|
196
|
+
: shadow
|
|
197
|
+
? {}
|
|
198
|
+
: undefined;
|
|
183
199
|
if (digit !== previousDigitRef.current) {
|
|
184
200
|
const previousDigit = previousDigitRef.current;
|
|
185
201
|
let digitDelta: number;
|
|
@@ -228,6 +244,7 @@ function SlotColumn({
|
|
|
228
244
|
initialRef.current = false;
|
|
229
245
|
|
|
230
246
|
if (!animateIn) {
|
|
247
|
+
drivenTargetRef.current = cumulativeRef.current;
|
|
231
248
|
return;
|
|
232
249
|
}
|
|
233
250
|
}
|
|
@@ -251,23 +268,46 @@ function SlotColumn({
|
|
|
251
268
|
target,
|
|
252
269
|
};
|
|
253
270
|
|
|
271
|
+
// A direction change can rerender every column even when this column's
|
|
272
|
+
// digit is unchanged. Keep the idle reel untouched in that case.
|
|
273
|
+
if (drivenTargetRef.current === target) {
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
drivenTargetRef.current = target;
|
|
278
|
+
|
|
254
279
|
current.value = motionRecipe.driveNumber(target, delayMs);
|
|
255
280
|
}, [animateIn, current, delayMs, digit, direction, motionRecipe]);
|
|
256
281
|
|
|
282
|
+
const reel = (
|
|
283
|
+
<SlotReel
|
|
284
|
+
current={current}
|
|
285
|
+
slotHeight={slotHeight}
|
|
286
|
+
textStyle={textStyle}
|
|
287
|
+
className={className}
|
|
288
|
+
/>
|
|
289
|
+
);
|
|
290
|
+
|
|
257
291
|
return (
|
|
258
|
-
<View style={slotFrameStyle}>
|
|
292
|
+
<View style={[slotFrameStyle, { height: slotHeight }]}>
|
|
259
293
|
<Text
|
|
260
294
|
style={[textStyle, slotProbeStyle, { lineHeight: slotHeight }]}
|
|
261
295
|
className={className}
|
|
262
296
|
>
|
|
263
297
|
0
|
|
264
298
|
</Text>
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
299
|
+
{shadowOptions ? (
|
|
300
|
+
<SlotShadow
|
|
301
|
+
slotHeight={slotHeight}
|
|
302
|
+
viewportHeight={slotHeight}
|
|
303
|
+
color={shadowOptions.color}
|
|
304
|
+
size={shadowOptions.size}
|
|
305
|
+
>
|
|
306
|
+
{reel}
|
|
307
|
+
</SlotShadow>
|
|
308
|
+
) : (
|
|
309
|
+
reel
|
|
310
|
+
)}
|
|
271
311
|
</View>
|
|
272
312
|
);
|
|
273
313
|
}
|
|
@@ -280,6 +320,11 @@ type SlotsRunProps = {
|
|
|
280
320
|
readonly textStyle?: StyleProp<TextStyle>;
|
|
281
321
|
readonly staggerMs: number;
|
|
282
322
|
readonly className?: string;
|
|
323
|
+
readonly leading?: ReactNode;
|
|
324
|
+
readonly leadingKey?: string | number;
|
|
325
|
+
readonly leadingGap?: number;
|
|
326
|
+
readonly ready?: boolean;
|
|
327
|
+
readonly shadow?: LaminarShadow;
|
|
283
328
|
};
|
|
284
329
|
|
|
285
330
|
// render text prefixes beside reusable digit reels
|
|
@@ -292,10 +337,53 @@ export const SlotsRun = React.memo(
|
|
|
292
337
|
textStyle,
|
|
293
338
|
staggerMs,
|
|
294
339
|
className,
|
|
340
|
+
leading,
|
|
341
|
+
leadingKey,
|
|
342
|
+
leadingGap = 0,
|
|
343
|
+
ready = true,
|
|
344
|
+
shadow = false,
|
|
295
345
|
}: Readonly<SlotsRunProps>) => {
|
|
346
|
+
const hasDisplayedRef = useRef(false);
|
|
347
|
+
const displayedValueRef = useRef(value);
|
|
348
|
+
const displayedLeadingRef = useRef(leading);
|
|
349
|
+
const displayedLeadingKeyRef = useRef(leadingKey);
|
|
350
|
+
const displayedLeadingGapRef = useRef(leadingGap);
|
|
351
|
+
const shouldDisplayTarget = ready || !hasDisplayedRef.current;
|
|
352
|
+
const visibleValue = shouldDisplayTarget
|
|
353
|
+
? value
|
|
354
|
+
: displayedValueRef.current;
|
|
355
|
+
const visibleLeading = shouldDisplayTarget
|
|
356
|
+
? leading
|
|
357
|
+
: displayedLeadingRef.current;
|
|
358
|
+
const visibleLeadingKey = shouldDisplayTarget
|
|
359
|
+
? leadingKey
|
|
360
|
+
: displayedLeadingKeyRef.current;
|
|
361
|
+
const visibleLeadingGap = shouldDisplayTarget
|
|
362
|
+
? leadingGap
|
|
363
|
+
: displayedLeadingGapRef.current;
|
|
364
|
+
|
|
365
|
+
if (shouldDisplayTarget) {
|
|
366
|
+
hasDisplayedRef.current = true;
|
|
367
|
+
displayedValueRef.current = value;
|
|
368
|
+
displayedLeadingRef.current = leading;
|
|
369
|
+
displayedLeadingKeyRef.current = leadingKey;
|
|
370
|
+
displayedLeadingGapRef.current = leadingGap;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
const {
|
|
374
|
+
elementEnterTransition,
|
|
375
|
+
elementExitTransition,
|
|
376
|
+
isLeadingChange,
|
|
377
|
+
isLeadingSwap,
|
|
378
|
+
} = useLeadingAnimation({
|
|
379
|
+
leading: visibleLeading,
|
|
380
|
+
leadingKey: visibleLeadingKey,
|
|
381
|
+
leadingGap: visibleLeadingGap,
|
|
382
|
+
motionRecipe,
|
|
383
|
+
});
|
|
296
384
|
// numeric lane identity decides which columns stay mounted as values change
|
|
297
|
-
const { units, direction, leadLength } = useNumericLanes(
|
|
298
|
-
const lastValueRef = useRef(
|
|
385
|
+
const { units, direction, leadLength } = useNumericLanes(visibleValue);
|
|
386
|
+
const lastValueRef = useRef(visibleValue);
|
|
299
387
|
const hasAnimatedRef = useRef(false);
|
|
300
388
|
// derive one stable row height so every reel position shares the same baseline
|
|
301
389
|
const slotHeight = useMemo(() => {
|
|
@@ -315,9 +403,9 @@ export const SlotsRun = React.memo(
|
|
|
315
403
|
);
|
|
316
404
|
}, [fontSize, textStyle]);
|
|
317
405
|
|
|
318
|
-
if (
|
|
406
|
+
if (visibleValue !== lastValueRef.current || isLeadingChange) {
|
|
319
407
|
hasAnimatedRef.current = true;
|
|
320
|
-
lastValueRef.current =
|
|
408
|
+
lastValueRef.current = visibleValue;
|
|
321
409
|
}
|
|
322
410
|
|
|
323
411
|
const digitCount = units.filter(isAsciiDigit).length;
|
|
@@ -326,57 +414,74 @@ export const SlotsRun = React.memo(
|
|
|
326
414
|
|
|
327
415
|
return (
|
|
328
416
|
<View style={[rowStyle, rowAlignStyles[align]]}>
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
417
|
+
<LeadingElement
|
|
418
|
+
leading={visibleLeading}
|
|
419
|
+
leadingKey={visibleLeadingKey}
|
|
420
|
+
leadingGap={visibleLeadingGap}
|
|
421
|
+
enterTransition={
|
|
422
|
+
hasAnimated ? motionRecipe.enterTransition : undefined
|
|
423
|
+
}
|
|
424
|
+
elementEnterTransition={
|
|
425
|
+
isLeadingSwap ? elementEnterTransition : undefined
|
|
426
|
+
}
|
|
427
|
+
exitTransition={motionRecipe.exitTransition}
|
|
428
|
+
elementExitTransition={elementExitTransition}
|
|
429
|
+
/>
|
|
430
|
+
<Animated.View
|
|
431
|
+
layout={isLeadingChange ? motionRecipe.layoutTransition : undefined}
|
|
432
|
+
style={rowStyle}
|
|
433
|
+
>
|
|
434
|
+
{units.map((unit, index) => {
|
|
435
|
+
const inLead = index < leadLength;
|
|
436
|
+
const laneKey = inLead
|
|
437
|
+
? `lead:${index}`
|
|
438
|
+
: `slot:${units.length - 1 - index}`;
|
|
439
|
+
|
|
440
|
+
if (inLead || !isAsciiDigit(unit)) {
|
|
441
|
+
return (
|
|
442
|
+
<Animated.Text
|
|
443
|
+
key={laneKey}
|
|
444
|
+
layout={
|
|
445
|
+
hasAnimated ? motionRecipe.layoutTransition : undefined
|
|
446
|
+
}
|
|
447
|
+
exiting={!inLead ? motionRecipe.exitTransition : undefined}
|
|
448
|
+
style={textStyle}
|
|
449
|
+
className={className}
|
|
450
|
+
>
|
|
451
|
+
{unit}
|
|
452
|
+
</Animated.Text>
|
|
453
|
+
);
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
const delayMs = (digitCount - 1 - digitIndex) * staggerMs;
|
|
457
|
+
digitIndex += 1;
|
|
334
458
|
|
|
335
|
-
if (inLead || !isAsciiDigit(unit)) {
|
|
336
459
|
return (
|
|
337
|
-
<Animated.
|
|
460
|
+
<Animated.View
|
|
338
461
|
key={laneKey}
|
|
339
462
|
layout={
|
|
340
463
|
hasAnimated ? motionRecipe.layoutTransition : undefined
|
|
341
464
|
}
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
465
|
+
entering={
|
|
466
|
+
hasAnimated ? motionRecipe.enterTransition : undefined
|
|
467
|
+
}
|
|
468
|
+
exiting={motionRecipe.exitTransition}
|
|
345
469
|
>
|
|
346
|
-
|
|
347
|
-
|
|
470
|
+
<SlotColumn
|
|
471
|
+
digit={Number(unit)}
|
|
472
|
+
direction={direction}
|
|
473
|
+
delayMs={delayMs}
|
|
474
|
+
animateIn={hasAnimated}
|
|
475
|
+
motionRecipe={motionRecipe}
|
|
476
|
+
slotHeight={slotHeight}
|
|
477
|
+
textStyle={textStyle}
|
|
478
|
+
className={className}
|
|
479
|
+
shadow={shadow}
|
|
480
|
+
/>
|
|
481
|
+
</Animated.View>
|
|
348
482
|
);
|
|
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
|
-
})}
|
|
483
|
+
})}
|
|
484
|
+
</Animated.View>
|
|
380
485
|
</View>
|
|
381
486
|
);
|
|
382
487
|
}
|
package/src/view/text-run.tsx
CHANGED
|
@@ -1,12 +1,8 @@
|
|
|
1
|
-
import React, { useId,
|
|
1
|
+
import React, { useId, useRef } from "react";
|
|
2
2
|
import type { ReactNode } from "react";
|
|
3
3
|
import type { StyleProp, TextStyle } from "react-native";
|
|
4
|
-
import { useSharedValue } from "react-native-reanimated";
|
|
5
4
|
import { useTextGlyphs } from "../hooks/use-text-glyphs";
|
|
6
|
-
import {
|
|
7
|
-
createLeadingEnterTransition,
|
|
8
|
-
createLeadingExitTransition,
|
|
9
|
-
} from "../motion/entry-exit-builders";
|
|
5
|
+
import { useLeadingAnimation } from "../hooks/use-leading-animation";
|
|
10
6
|
import type { LaminarAlign, MotionRecipe } from "../types";
|
|
11
7
|
import { GlyphRun } from "./glyph-run";
|
|
12
8
|
|
|
@@ -67,62 +63,29 @@ export const TextRun = React.memo(
|
|
|
67
63
|
}
|
|
68
64
|
|
|
69
65
|
const lastValueRef = useRef(visibleValue);
|
|
70
|
-
const lastLeadingPresenceRef = useRef(Boolean(visibleLeading));
|
|
71
|
-
const lastLeadingKeyRef = useRef(visibleLeadingKey);
|
|
72
66
|
const hasAnimatedRef = useRef(false);
|
|
73
|
-
const
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
leadingSwapProgress.value = isLeadingSwap ? 1 : 0;
|
|
85
|
-
}, [isLeadingSwap, leadingSwapProgress]);
|
|
67
|
+
const {
|
|
68
|
+
elementEnterTransition,
|
|
69
|
+
elementExitTransition,
|
|
70
|
+
isLeadingChange,
|
|
71
|
+
isLeadingSwap,
|
|
72
|
+
} = useLeadingAnimation({
|
|
73
|
+
leading: visibleLeading,
|
|
74
|
+
leadingKey: visibleLeadingKey,
|
|
75
|
+
leadingGap: visibleLeadingGap,
|
|
76
|
+
motionRecipe,
|
|
77
|
+
});
|
|
86
78
|
const glyphs = useTextGlyphs(
|
|
87
79
|
visibleValue,
|
|
88
80
|
scopeId,
|
|
89
81
|
visibleLeading,
|
|
90
82
|
visibleLeadingKey
|
|
91
83
|
);
|
|
92
|
-
const elementEnterTransition = useMemo(
|
|
93
|
-
() =>
|
|
94
|
-
createLeadingEnterTransition({
|
|
95
|
-
durationMs: motionRecipe.durationMs,
|
|
96
|
-
easing: motionRecipe.easing,
|
|
97
|
-
}),
|
|
98
|
-
[motionRecipe.durationMs, motionRecipe.easing]
|
|
99
|
-
);
|
|
100
|
-
const elementExitTransition = useMemo(
|
|
101
|
-
() =>
|
|
102
|
-
createLeadingExitTransition({
|
|
103
|
-
durationMs: motionRecipe.durationMs,
|
|
104
|
-
easing: motionRecipe.easing,
|
|
105
|
-
leadingGap: visibleLeadingGap,
|
|
106
|
-
scale: leadingSwapProgress,
|
|
107
|
-
}),
|
|
108
|
-
[
|
|
109
|
-
visibleLeadingGap,
|
|
110
|
-
leadingSwapProgress,
|
|
111
|
-
motionRecipe.durationMs,
|
|
112
|
-
motionRecipe.easing,
|
|
113
|
-
]
|
|
114
|
-
);
|
|
115
84
|
|
|
116
85
|
// mark the first value as settled and later changes as eligible for motion
|
|
117
|
-
if (
|
|
118
|
-
visibleValue !== lastValueRef.current ||
|
|
119
|
-
Boolean(visibleLeading) !== lastLeadingPresenceRef.current ||
|
|
120
|
-
visibleLeadingKey !== lastLeadingKeyRef.current
|
|
121
|
-
) {
|
|
86
|
+
if (visibleValue !== lastValueRef.current || isLeadingChange) {
|
|
122
87
|
hasAnimatedRef.current = true;
|
|
123
88
|
lastValueRef.current = visibleValue;
|
|
124
|
-
lastLeadingPresenceRef.current = Boolean(visibleLeading);
|
|
125
|
-
lastLeadingKeyRef.current = visibleLeadingKey;
|
|
126
89
|
}
|
|
127
90
|
|
|
128
91
|
const hasAnimated = hasAnimatedRef.current;
|