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
|
@@ -18,13 +18,14 @@ type MorphMotion = {
|
|
|
18
18
|
readonly staggerMs: number;
|
|
19
19
|
};
|
|
20
20
|
|
|
21
|
+
// resolve one motion recipe and one shared stagger unit for a render tree
|
|
21
22
|
export const useMorphMotion = ({
|
|
22
23
|
variant,
|
|
23
24
|
animationPreset,
|
|
24
25
|
animationDuration,
|
|
25
26
|
stagger,
|
|
26
27
|
}: Params): MorphMotion => {
|
|
27
|
-
//
|
|
28
|
+
// numeric variants need a quicker default because their lanes update often
|
|
28
29
|
const resolvedPreset =
|
|
29
30
|
animationPreset ?? (variant === "text" ? "default" : "snappy");
|
|
30
31
|
|
|
@@ -12,12 +12,14 @@ type MorphTextStyle = {
|
|
|
12
12
|
readonly textStyle: StyleProp<TextStyle>;
|
|
13
13
|
};
|
|
14
14
|
|
|
15
|
+
// compose the public style layers into the style passed to every glyph
|
|
15
16
|
export const useMorphTextStyle = ({
|
|
16
17
|
fontSize,
|
|
17
18
|
color,
|
|
18
19
|
fontStyle,
|
|
19
20
|
style,
|
|
20
21
|
}: Params): MorphTextStyle => {
|
|
22
|
+
// keep the base layer small so caller styles can override it predictably
|
|
21
23
|
const baseTextStyle = useMemo(() => {
|
|
22
24
|
const nextStyle: TextStyle = {
|
|
23
25
|
includeFontPadding: false,
|
|
@@ -34,6 +36,7 @@ export const useMorphTextStyle = ({
|
|
|
34
36
|
return nextStyle;
|
|
35
37
|
}, [color, fontSize]);
|
|
36
38
|
|
|
39
|
+
// preserve the merge order used by the public style contract
|
|
37
40
|
const textStyle = useMemo(
|
|
38
41
|
() => [baseTextStyle, fontStyle, style],
|
|
39
42
|
[baseTextStyle, fontStyle, style]
|
|
@@ -20,6 +20,7 @@ type NumericLaneSnapshot = {
|
|
|
20
20
|
readonly leadLength: number;
|
|
21
21
|
};
|
|
22
22
|
|
|
23
|
+
// retain numeric lane identity across value changes without storing it in state
|
|
23
24
|
export const useNumericLanes = (value: string): NumericLaneSnapshot => {
|
|
24
25
|
const units = useMemo(() => splitDisplayUnits(value), [value]);
|
|
25
26
|
const ledgerRef = useRef<NumericLedger>({
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { useMemo, useRef } from "react";
|
|
1
|
+
import { useMemo, useRef, type ReactNode } from "react";
|
|
2
2
|
import {
|
|
3
3
|
normalizeDisplayUnit,
|
|
4
4
|
splitDisplayUnits,
|
|
@@ -8,28 +8,62 @@ import type { GlyphToken } from "../types";
|
|
|
8
8
|
|
|
9
9
|
type TextGlyphLedger = {
|
|
10
10
|
previousValue: string;
|
|
11
|
-
|
|
11
|
+
previousSignatures: readonly string[];
|
|
12
12
|
glyphKeys: readonly string[];
|
|
13
13
|
nextSeed: number;
|
|
14
14
|
};
|
|
15
15
|
|
|
16
|
+
type TextGlyphUnit =
|
|
17
|
+
| { readonly kind: "text"; readonly signature: string; readonly value: string }
|
|
18
|
+
| { readonly kind: "element"; readonly signature: string; readonly element: ReactNode };
|
|
19
|
+
|
|
20
|
+
// build the inline token stream that lets text and leading content share one ledger
|
|
16
21
|
export const useTextGlyphs = (
|
|
17
22
|
value: string,
|
|
18
|
-
namespace: string
|
|
23
|
+
namespace: string,
|
|
24
|
+
leading?: ReactNode,
|
|
25
|
+
leadingKey?: string | number
|
|
19
26
|
): readonly GlyphToken[] => {
|
|
20
|
-
const units = useMemo
|
|
27
|
+
const units = useMemo<readonly TextGlyphUnit[]>(
|
|
28
|
+
() => [
|
|
29
|
+
// the map key becomes the identity when leading content is state keyed
|
|
30
|
+
...(leading
|
|
31
|
+
? [
|
|
32
|
+
{
|
|
33
|
+
kind: "element",
|
|
34
|
+
signature: `leading:${String(leadingKey ?? "default")}`,
|
|
35
|
+
element: leading,
|
|
36
|
+
} as const,
|
|
37
|
+
]
|
|
38
|
+
: []),
|
|
39
|
+
...splitDisplayUnits(value).map((unit) => ({
|
|
40
|
+
kind: "text" as const,
|
|
41
|
+
signature: `text:${unit}`,
|
|
42
|
+
value: unit,
|
|
43
|
+
})),
|
|
44
|
+
],
|
|
45
|
+
[leading, leadingKey, value]
|
|
46
|
+
);
|
|
47
|
+
const signatures = units.map((unit) => unit.signature);
|
|
21
48
|
const ledgerRef = useRef<TextGlyphLedger>({
|
|
22
49
|
previousValue: value,
|
|
23
|
-
|
|
50
|
+
previousSignatures: signatures,
|
|
24
51
|
glyphKeys: units.map((_, index) => `${namespace}:c${index}`),
|
|
25
52
|
nextSeed: units.length,
|
|
26
53
|
});
|
|
27
54
|
|
|
28
|
-
|
|
55
|
+
// reconcile only when a token signature changes so stable glyphs keep their views
|
|
56
|
+
if (
|
|
57
|
+
value !== ledgerRef.current.previousValue ||
|
|
58
|
+
signatures.length !== ledgerRef.current.previousSignatures.length ||
|
|
59
|
+
signatures.some(
|
|
60
|
+
(signature, index) => signature !== ledgerRef.current.previousSignatures[index]
|
|
61
|
+
)
|
|
62
|
+
) {
|
|
29
63
|
// keep ids stable so unchanged glyphs stay mounted between updates
|
|
30
64
|
const nextLedger = reconcileTextGlyphKeys(
|
|
31
|
-
ledgerRef.current.
|
|
32
|
-
|
|
65
|
+
ledgerRef.current.previousSignatures,
|
|
66
|
+
signatures,
|
|
33
67
|
ledgerRef.current.glyphKeys,
|
|
34
68
|
ledgerRef.current.nextSeed,
|
|
35
69
|
namespace
|
|
@@ -37,7 +71,7 @@ export const useTextGlyphs = (
|
|
|
37
71
|
|
|
38
72
|
ledgerRef.current = {
|
|
39
73
|
previousValue: value,
|
|
40
|
-
|
|
74
|
+
previousSignatures: signatures,
|
|
41
75
|
glyphKeys: nextLedger.glyphKeys,
|
|
42
76
|
nextSeed: nextLedger.nextSeed,
|
|
43
77
|
};
|
|
@@ -45,11 +79,15 @@ export const useTextGlyphs = (
|
|
|
45
79
|
|
|
46
80
|
const glyphKeys = ledgerRef.current.glyphKeys;
|
|
47
81
|
|
|
82
|
+
// normalize spaces at the edge where tokens become renderable glyphs
|
|
48
83
|
return useMemo(
|
|
49
84
|
() =>
|
|
50
85
|
units.map((unit, index) => ({
|
|
51
86
|
id: glyphKeys[index],
|
|
52
|
-
|
|
87
|
+
kind: unit.kind,
|
|
88
|
+
...(unit.kind === "text"
|
|
89
|
+
? { value: normalizeDisplayUnit(unit.value) }
|
|
90
|
+
: { element: unit.element }),
|
|
53
91
|
})),
|
|
54
92
|
[glyphKeys, units]
|
|
55
93
|
);
|
package/src/index.tsx
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import React from "react";
|
|
2
|
-
import { Text } from "react-native";
|
|
2
|
+
import { StyleSheet, Text, View } from "react-native";
|
|
3
3
|
import { useInlineAutoWidth } from "./hooks/use-inline-auto-width";
|
|
4
4
|
import { useMorphMotion } from "./hooks/use-morph-motion";
|
|
5
5
|
import { useMorphTextStyle } from "./hooks/use-morph-text-style";
|
|
@@ -7,17 +7,23 @@ import {
|
|
|
7
7
|
normalizeDisplayUnit,
|
|
8
8
|
splitDisplayUnits,
|
|
9
9
|
} from "./model/display-units";
|
|
10
|
-
import type { MorphingTextProps } from "./types";
|
|
10
|
+
import type { LaminarLeadingMap, MorphingTextProps } from "./types";
|
|
11
11
|
import { MorphViewport } from "./view/morph-viewport";
|
|
12
12
|
import { NumberRun } from "./view/number-run";
|
|
13
|
+
import { SlotsRun } from "./view/slots-run";
|
|
13
14
|
import { TextRun } from "./view/text-run";
|
|
14
15
|
|
|
16
|
+
// choose the content variant and connect it to the shared viewport and measurement flow
|
|
15
17
|
export const Laminar = React.memo(function Laminar({
|
|
16
18
|
text,
|
|
17
19
|
variant = "text",
|
|
18
20
|
fontSize,
|
|
19
21
|
color,
|
|
22
|
+
align = "left",
|
|
20
23
|
className,
|
|
24
|
+
leading,
|
|
25
|
+
leadingKey,
|
|
26
|
+
leadingGap = 0,
|
|
21
27
|
style,
|
|
22
28
|
containerStyle,
|
|
23
29
|
fontStyle,
|
|
@@ -28,6 +34,12 @@ export const Laminar = React.memo(function Laminar({
|
|
|
28
34
|
clipToBounds = false,
|
|
29
35
|
}: Readonly<MorphingTextProps>) {
|
|
30
36
|
const resolvedValue = String(text ?? "");
|
|
37
|
+
// resolve state keyed leading content before measurement and token reconciliation
|
|
38
|
+
const leadingMap = isLeadingMap(leading) ? leading : undefined;
|
|
39
|
+
const resolvedLeading: React.ReactNode = leadingMap
|
|
40
|
+
? leadingMap[resolvedValue]
|
|
41
|
+
: (leading as React.ReactNode);
|
|
42
|
+
const resolvedLeadingKey = leadingMap ? resolvedValue : leadingKey;
|
|
31
43
|
const { motionRecipe, staggerMs } = useMorphMotion({
|
|
32
44
|
variant,
|
|
33
45
|
animationPreset,
|
|
@@ -41,38 +53,97 @@ export const Laminar = React.memo(function Laminar({
|
|
|
41
53
|
style,
|
|
42
54
|
});
|
|
43
55
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
56
|
+
// invalidate the probe when text style, leading content, or leading spacing changes
|
|
57
|
+
const measurementKey = React.useMemo(() => {
|
|
58
|
+
const flattenedStyle = StyleSheet.flatten(textStyle);
|
|
59
|
+
|
|
60
|
+
return JSON.stringify([
|
|
61
|
+
resolvedValue,
|
|
62
|
+
flattenedStyle?.fontFamily,
|
|
63
|
+
flattenedStyle?.fontSize,
|
|
64
|
+
flattenedStyle?.fontStyle,
|
|
65
|
+
flattenedStyle?.fontWeight,
|
|
66
|
+
flattenedStyle?.fontVariant,
|
|
67
|
+
flattenedStyle?.letterSpacing,
|
|
68
|
+
flattenedStyle?.lineHeight,
|
|
69
|
+
flattenedStyle?.textTransform,
|
|
70
|
+
variant === "text" && Boolean(resolvedLeading),
|
|
71
|
+
variant === "text" ? resolvedLeadingKey : undefined,
|
|
72
|
+
variant === "text" ? leadingGap : 0,
|
|
73
|
+
]);
|
|
74
|
+
}, [
|
|
75
|
+
leadingGap,
|
|
76
|
+
resolvedLeading,
|
|
77
|
+
resolvedLeadingKey,
|
|
78
|
+
resolvedValue,
|
|
79
|
+
textStyle,
|
|
80
|
+
variant,
|
|
81
|
+
]);
|
|
82
|
+
const { captureLayout, animatedWidthStyle, shouldMeasure } =
|
|
83
|
+
useInlineAutoWidth({
|
|
84
|
+
enabled: autoSize,
|
|
85
|
+
driveToWidth: motionRecipe.driveNumber,
|
|
86
|
+
measurementKey,
|
|
87
|
+
});
|
|
88
|
+
// measurement uses the same normalized units as the visible text row
|
|
48
89
|
const measuredValue = React.useMemo(() => {
|
|
49
90
|
if (!autoSize) {
|
|
50
91
|
return "";
|
|
51
92
|
}
|
|
52
93
|
|
|
53
|
-
return splitDisplayUnits(resolvedValue)
|
|
94
|
+
return splitDisplayUnits(resolvedValue)
|
|
95
|
+
.map(normalizeDisplayUnit)
|
|
96
|
+
.join("");
|
|
54
97
|
}, [autoSize, resolvedValue]);
|
|
55
98
|
|
|
56
99
|
return (
|
|
57
100
|
<MorphViewport
|
|
58
101
|
autoSize={autoSize}
|
|
59
102
|
clipToBounds={clipToBounds}
|
|
103
|
+
align={align}
|
|
60
104
|
containerStyle={containerStyle}
|
|
61
105
|
animatedWidthStyle={animatedWidthStyle}
|
|
62
106
|
measurement={
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
107
|
+
shouldMeasure ? (
|
|
108
|
+
<View
|
|
109
|
+
onLayout={captureLayout}
|
|
110
|
+
style={{ flexDirection: "row", alignSelf: "flex-start" }}
|
|
111
|
+
>
|
|
112
|
+
{variant === "text" && resolvedLeading ? (
|
|
113
|
+
<View
|
|
114
|
+
style={{
|
|
115
|
+
alignItems: "center",
|
|
116
|
+
justifyContent: "center",
|
|
117
|
+
marginRight: leadingGap,
|
|
118
|
+
}}
|
|
119
|
+
>
|
|
120
|
+
{resolvedLeading}
|
|
121
|
+
</View>
|
|
122
|
+
) : null}
|
|
123
|
+
{splitDisplayUnits(measuredValue).map((unit, index) => (
|
|
124
|
+
<Text key={`${unit}-${index}`} style={textStyle}>
|
|
125
|
+
{normalizeDisplayUnit(unit)}
|
|
126
|
+
</Text>
|
|
127
|
+
))}
|
|
128
|
+
</View>
|
|
129
|
+
) : undefined
|
|
70
130
|
}
|
|
71
131
|
>
|
|
72
|
-
{variant === "
|
|
132
|
+
{variant === "slots" ? (
|
|
133
|
+
<SlotsRun
|
|
134
|
+
value={resolvedValue}
|
|
135
|
+
motionRecipe={motionRecipe}
|
|
136
|
+
align={align}
|
|
137
|
+
fontSize={fontSize}
|
|
138
|
+
textStyle={textStyle}
|
|
139
|
+
staggerMs={staggerMs}
|
|
140
|
+
className={className}
|
|
141
|
+
/>
|
|
142
|
+
) : variant === "number" ? (
|
|
73
143
|
<NumberRun
|
|
74
144
|
value={resolvedValue}
|
|
75
145
|
motionRecipe={motionRecipe}
|
|
146
|
+
align={align}
|
|
76
147
|
fontSize={fontSize}
|
|
77
148
|
textStyle={textStyle}
|
|
78
149
|
staggerMs={staggerMs}
|
|
@@ -82,6 +153,10 @@ export const Laminar = React.memo(function Laminar({
|
|
|
82
153
|
<TextRun
|
|
83
154
|
value={resolvedValue}
|
|
84
155
|
motionRecipe={motionRecipe}
|
|
156
|
+
align={align}
|
|
157
|
+
leading={resolvedLeading}
|
|
158
|
+
leadingKey={resolvedLeadingKey}
|
|
159
|
+
leadingGap={leadingGap}
|
|
85
160
|
textStyle={textStyle}
|
|
86
161
|
className={className}
|
|
87
162
|
/>
|
|
@@ -90,10 +165,21 @@ export const Laminar = React.memo(function Laminar({
|
|
|
90
165
|
);
|
|
91
166
|
});
|
|
92
167
|
|
|
93
|
-
|
|
168
|
+
function isLeadingMap(
|
|
169
|
+
value: MorphingTextProps["leading"]
|
|
170
|
+
): value is LaminarLeadingMap {
|
|
171
|
+
// react elements are objects too, so exclude them before treating a value as a map
|
|
172
|
+
return (
|
|
173
|
+
typeof value === "object" &&
|
|
174
|
+
value !== null &&
|
|
175
|
+
!React.isValidElement(value) &&
|
|
176
|
+
!Array.isArray(value)
|
|
177
|
+
);
|
|
178
|
+
}
|
|
94
179
|
|
|
95
|
-
export default Laminar;
|
|
96
180
|
export type {
|
|
181
|
+
LaminarAlign,
|
|
182
|
+
LaminarLeadingMap,
|
|
97
183
|
LaminarProps,
|
|
98
184
|
MorphAnimationPresetName,
|
|
99
185
|
MorphContentVariant,
|
|
@@ -4,6 +4,7 @@ const graphemeSegmenter =
|
|
|
4
4
|
? new Intl.Segmenter(undefined, { granularity: "grapheme" })
|
|
5
5
|
: null;
|
|
6
6
|
|
|
7
|
+
// segment graphemes when the runtime supports them so emoji and combining marks stay whole
|
|
7
8
|
export const splitDisplayUnits = (input: string): string[] => {
|
|
8
9
|
if (graphemeSegmenter) {
|
|
9
10
|
return Array.from(
|
|
@@ -15,12 +16,14 @@ export const splitDisplayUnits = (input: string): string[] => {
|
|
|
15
16
|
return Array.from(input);
|
|
16
17
|
};
|
|
17
18
|
|
|
18
|
-
//
|
|
19
|
+
// replace plain spaces because native text measurement can collapse them
|
|
19
20
|
export const normalizeDisplayUnit = (unit: string) =>
|
|
20
21
|
unit === " " ? NBSP : unit;
|
|
21
22
|
|
|
23
|
+
// numeric lanes only treat ascii digits as place-value columns
|
|
22
24
|
export const isAsciiDigit = (unit: string) => unit >= "0" && unit <= "9";
|
|
23
25
|
|
|
26
|
+
// leading symbols and punctuation stay attached to the left side of a number
|
|
24
27
|
export const findNumericLeadLength = (units: readonly string[]) => {
|
|
25
28
|
const firstDigitIndex = units.findIndex(isAsciiDigit);
|
|
26
29
|
|
|
@@ -4,9 +4,11 @@ import {
|
|
|
4
4
|
splitDisplayUnits,
|
|
5
5
|
} from "./display-units";
|
|
6
6
|
|
|
7
|
+
// strip formatting before comparing values so direction follows the displayed number
|
|
7
8
|
const readNumericMagnitude = (input: string) =>
|
|
8
9
|
parseFloat(input.replace(/[^0-9.-]/g, "")) || 0;
|
|
9
10
|
|
|
11
|
+
// the sign controls which side of each digit lane enters and exits
|
|
10
12
|
const deriveFlowDirection = (
|
|
11
13
|
previousValue: string,
|
|
12
14
|
nextValue: string
|
|
@@ -19,6 +21,7 @@ type ReconciledLaneState = {
|
|
|
19
21
|
readonly direction: NumericFlowDirection;
|
|
20
22
|
};
|
|
21
23
|
|
|
24
|
+
// preserve right-aligned place values while allowing prefixes to grow or shrink
|
|
22
25
|
export const reconcileNumericLanes = (
|
|
23
26
|
previousValue: string,
|
|
24
27
|
nextValue: string,
|
package/src/model/text-keys.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
// find the longest ordered set of shared units so edits keep nearby glyph identity
|
|
1
2
|
const computeLcsPairs = (
|
|
2
3
|
previousUnits: readonly string[],
|
|
3
4
|
nextUnits: readonly string[]
|
|
@@ -9,6 +10,7 @@ const computeLcsPairs = (
|
|
|
9
10
|
return [];
|
|
10
11
|
}
|
|
11
12
|
|
|
13
|
+
// dynamic programming makes the middle edit deterministic when prefix and suffix match
|
|
12
14
|
const dp = Array.from({ length: previousLength + 1 }, () =>
|
|
13
15
|
new Array<number>(nextLength + 1).fill(0)
|
|
14
16
|
);
|
|
@@ -54,6 +56,7 @@ type ReconciledTextGlyphState = {
|
|
|
54
56
|
readonly nextSeed: number;
|
|
55
57
|
};
|
|
56
58
|
|
|
59
|
+
// reuse prefix, suffix, and lcs matches before allocating keys for new glyphs
|
|
57
60
|
export const reconcileTextGlyphKeys = (
|
|
58
61
|
previousUnits: readonly string[],
|
|
59
62
|
nextUnits: readonly string[],
|
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import {
|
|
2
2
|
type EntryExitAnimationFunction,
|
|
3
|
+
type ExitAnimationsValues,
|
|
3
4
|
type WithTimingConfig,
|
|
4
5
|
withDelay,
|
|
5
6
|
withTiming,
|
|
6
7
|
} from "react-native-reanimated";
|
|
8
|
+
import type { SharedValue } from "react-native-reanimated";
|
|
7
9
|
|
|
8
10
|
type TransitionParams = {
|
|
9
11
|
readonly delayMs?: number;
|
|
@@ -17,6 +19,40 @@ type TransitionParams = {
|
|
|
17
19
|
readonly toScale: number;
|
|
18
20
|
};
|
|
19
21
|
|
|
22
|
+
type LeadingExitTransitionParams = {
|
|
23
|
+
readonly durationMs: number;
|
|
24
|
+
readonly easing: NonNullable<WithTimingConfig["easing"]>;
|
|
25
|
+
readonly leadingGap: number;
|
|
26
|
+
readonly scale?: boolean | SharedValue<number>;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
type LeadingEnterTransitionParams = {
|
|
30
|
+
readonly durationMs: number;
|
|
31
|
+
readonly easing: NonNullable<WithTimingConfig["easing"]>;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
// leading replacements use scale while first appearance uses the regular fade path
|
|
35
|
+
export const createLeadingEnterTransition = ({
|
|
36
|
+
durationMs,
|
|
37
|
+
easing,
|
|
38
|
+
}: LeadingEnterTransitionParams): EntryExitAnimationFunction => {
|
|
39
|
+
return () => {
|
|
40
|
+
"worklet";
|
|
41
|
+
|
|
42
|
+
return {
|
|
43
|
+
initialValues: {
|
|
44
|
+
opacity: 0,
|
|
45
|
+
transform: [{ scale: 0.5 }],
|
|
46
|
+
},
|
|
47
|
+
animations: {
|
|
48
|
+
opacity: withTiming(1, { duration: durationMs, easing }),
|
|
49
|
+
transform: [{ scale: withTiming(1, { duration: durationMs, easing }) }],
|
|
50
|
+
},
|
|
51
|
+
};
|
|
52
|
+
};
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
// one builder covers glyph opacity, travel, scale, and optional stagger timing
|
|
20
56
|
export const createShiftTransition = ({
|
|
21
57
|
delayMs = 0,
|
|
22
58
|
durationMs,
|
|
@@ -72,3 +108,40 @@ export const createShiftTransition = ({
|
|
|
72
108
|
};
|
|
73
109
|
};
|
|
74
110
|
};
|
|
111
|
+
|
|
112
|
+
// the shared swap flag lets an exiting worklet read the current transition type
|
|
113
|
+
export const createLeadingExitTransition = ({
|
|
114
|
+
durationMs,
|
|
115
|
+
easing,
|
|
116
|
+
leadingGap,
|
|
117
|
+
scale = false,
|
|
118
|
+
}: LeadingExitTransitionParams): EntryExitAnimationFunction => {
|
|
119
|
+
return (values: ExitAnimationsValues) => {
|
|
120
|
+
"worklet";
|
|
121
|
+
|
|
122
|
+
const shouldScale =
|
|
123
|
+
typeof scale === "object" ? scale.value > 0.5 : scale;
|
|
124
|
+
|
|
125
|
+
return {
|
|
126
|
+
initialValues: {
|
|
127
|
+
opacity: 1,
|
|
128
|
+
originX: values.currentOriginX,
|
|
129
|
+
...(shouldScale ? { transform: [{ scale: 1 }] } : {}),
|
|
130
|
+
},
|
|
131
|
+
animations: {
|
|
132
|
+
opacity: withTiming(0, { duration: durationMs, easing }),
|
|
133
|
+
originX: withTiming(
|
|
134
|
+
values.currentOriginX - values.currentWidth - leadingGap,
|
|
135
|
+
{ duration: durationMs, easing }
|
|
136
|
+
),
|
|
137
|
+
...(shouldScale
|
|
138
|
+
? {
|
|
139
|
+
transform: [
|
|
140
|
+
{ scale: withTiming(0.5, { duration: durationMs, easing }) },
|
|
141
|
+
],
|
|
142
|
+
}
|
|
143
|
+
: {}),
|
|
144
|
+
},
|
|
145
|
+
};
|
|
146
|
+
};
|
|
147
|
+
};
|
package/src/motion/preset-map.ts
CHANGED
|
@@ -13,6 +13,7 @@ import type {
|
|
|
13
13
|
MorphAnimationPresetName,
|
|
14
14
|
} from "../types";
|
|
15
15
|
|
|
16
|
+
// keep all presets on one curve family so layout and glyph motion agree
|
|
16
17
|
const DEFAULT_CURVE = [0.19, 1, 0.22, 1] as const;
|
|
17
18
|
|
|
18
19
|
export const MOTION_PRESETS: Record<
|
|
@@ -40,11 +41,13 @@ export const MOTION_PRESETS: Record<
|
|
|
40
41
|
},
|
|
41
42
|
} as const;
|
|
42
43
|
|
|
44
|
+
// the public preset table uses seconds while reanimated timing uses milliseconds
|
|
43
45
|
const toMilliseconds = (seconds: number) => Math.round(seconds * 1000);
|
|
44
46
|
|
|
45
47
|
const toDampingRatio = (bounce: number) =>
|
|
46
48
|
Math.max(0.55, Math.min(1, 1 - bounce));
|
|
47
49
|
|
|
50
|
+
// spring and timing presets share the same opacity contract
|
|
48
51
|
const createOpacityTransition = (
|
|
49
52
|
fromOpacity: number,
|
|
50
53
|
toOpacity: number,
|
|
@@ -68,6 +71,7 @@ const createOpacityTransition = (
|
|
|
68
71
|
};
|
|
69
72
|
};
|
|
70
73
|
|
|
74
|
+
// convert a named preset into the builders consumed by every rendering variant
|
|
71
75
|
export const resolveMotionRecipe = (
|
|
72
76
|
presetName: MorphAnimationPresetName = "default",
|
|
73
77
|
durationOverride?: number
|
package/src/types.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
1
2
|
import type { StyleProp, TextStyle, ViewStyle } from "react-native";
|
|
2
3
|
import type {
|
|
3
4
|
ComplexAnimationBuilder,
|
|
@@ -11,7 +12,10 @@ export type MorphAnimationPresetName =
|
|
|
11
12
|
| "snappy"
|
|
12
13
|
| "bouncy";
|
|
13
14
|
|
|
14
|
-
export type MorphContentVariant = "text" | "number";
|
|
15
|
+
export type MorphContentVariant = "text" | "number" | "slots";
|
|
16
|
+
export type LaminarAlign = "left" | "center" | "right";
|
|
17
|
+
// text keys let leading content follow the same state as the displayed value
|
|
18
|
+
export type LaminarLeadingMap = Readonly<Record<string, ReactNode>>;
|
|
15
19
|
|
|
16
20
|
type CubicBezierTuple = readonly [number, number, number, number];
|
|
17
21
|
|
|
@@ -28,12 +32,15 @@ export type MorphAnimationPreset =
|
|
|
28
32
|
|
|
29
33
|
export type GlyphToken = {
|
|
30
34
|
readonly id: string;
|
|
31
|
-
readonly
|
|
35
|
+
readonly kind: "text" | "element";
|
|
36
|
+
readonly value?: string;
|
|
37
|
+
readonly element?: ReactNode;
|
|
32
38
|
};
|
|
33
39
|
|
|
34
40
|
export type NumericFlowDirection = -1 | 0 | 1;
|
|
35
41
|
|
|
36
42
|
export type MotionRecipe = {
|
|
43
|
+
// every variant consumes the same timing and layout contract
|
|
37
44
|
readonly durationMs: number;
|
|
38
45
|
readonly easing: NonNullable<WithTimingConfig["easing"]>;
|
|
39
46
|
readonly layoutTransition: ComplexAnimationBuilder;
|
|
@@ -47,7 +54,14 @@ export type MorphingTextProps = {
|
|
|
47
54
|
readonly variant?: MorphContentVariant;
|
|
48
55
|
readonly fontSize?: number;
|
|
49
56
|
readonly color?: string;
|
|
57
|
+
readonly align?: LaminarAlign;
|
|
50
58
|
readonly className?: string;
|
|
59
|
+
/** optional inline element reconciled and animated as the leading token */
|
|
60
|
+
readonly leading?: ReactNode | LaminarLeadingMap;
|
|
61
|
+
/** identity for swapping one leading element for another */
|
|
62
|
+
readonly leadingKey?: string | number;
|
|
63
|
+
/** spacing between the leading element and the first text glyph */
|
|
64
|
+
readonly leadingGap?: number;
|
|
51
65
|
readonly style?: StyleProp<TextStyle>;
|
|
52
66
|
readonly containerStyle?: StyleProp<ViewStyle>;
|
|
53
67
|
readonly fontStyle?: StyleProp<TextStyle>;
|
package/src/view/glyph-run.tsx
CHANGED
|
@@ -5,7 +5,7 @@ import type {
|
|
|
5
5
|
ComplexAnimationBuilder,
|
|
6
6
|
EntryExitAnimationFunction,
|
|
7
7
|
} from "react-native-reanimated";
|
|
8
|
-
import type { GlyphToken } from "../types";
|
|
8
|
+
import type { GlyphToken, LaminarAlign } from "../types";
|
|
9
9
|
|
|
10
10
|
const rowStyle = {
|
|
11
11
|
flexDirection: "row",
|
|
@@ -13,36 +13,69 @@ const rowStyle = {
|
|
|
13
13
|
alignSelf: "flex-start",
|
|
14
14
|
} as const;
|
|
15
15
|
|
|
16
|
+
const rowAlignStyles = {
|
|
17
|
+
left: { alignSelf: "flex-start" },
|
|
18
|
+
center: { alignSelf: "center" },
|
|
19
|
+
right: { alignSelf: "flex-end" },
|
|
20
|
+
} as const;
|
|
21
|
+
|
|
22
|
+
// render text and inline elements through one animated token row
|
|
16
23
|
export const GlyphRun = React.memo(
|
|
17
24
|
({
|
|
18
25
|
glyphs,
|
|
19
26
|
layoutTransition,
|
|
20
27
|
enterTransition,
|
|
28
|
+
elementEnterTransition,
|
|
21
29
|
exitTransition,
|
|
30
|
+
elementExitTransition,
|
|
31
|
+
leadingGap,
|
|
32
|
+
align,
|
|
22
33
|
textStyle,
|
|
23
34
|
className,
|
|
24
35
|
}: Readonly<{
|
|
25
36
|
glyphs: readonly GlyphToken[];
|
|
26
|
-
layoutTransition
|
|
37
|
+
layoutTransition?: ComplexAnimationBuilder;
|
|
27
38
|
enterTransition?: EntryExitAnimationFunction;
|
|
39
|
+
elementEnterTransition?: EntryExitAnimationFunction;
|
|
28
40
|
exitTransition?: EntryExitAnimationFunction;
|
|
41
|
+
elementExitTransition?: EntryExitAnimationFunction;
|
|
42
|
+
leadingGap?: number;
|
|
43
|
+
align: LaminarAlign;
|
|
29
44
|
textStyle?: StyleProp<TextStyle>;
|
|
30
45
|
className?: string;
|
|
31
46
|
}>) => (
|
|
32
|
-
|
|
47
|
+
// each token keeps its own view so reanimated can animate only changed content
|
|
48
|
+
<View style={[rowStyle, rowAlignStyles[align]]}>
|
|
33
49
|
{/* glyph ids decide what swaps, layout handles the row reflow */}
|
|
34
|
-
{glyphs.map((glyph) =>
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
50
|
+
{glyphs.map((glyph, glyphIndex) =>
|
|
51
|
+
glyph.kind === "element" ? (
|
|
52
|
+
<Animated.View
|
|
53
|
+
key={glyph.id}
|
|
54
|
+
layout={layoutTransition}
|
|
55
|
+
entering={elementEnterTransition ?? enterTransition}
|
|
56
|
+
exiting={elementExitTransition ?? exitTransition}
|
|
57
|
+
style={[
|
|
58
|
+
{ alignItems: "center", justifyContent: "center" },
|
|
59
|
+
glyphIndex === 0 && (leadingGap ?? 0) > 0
|
|
60
|
+
? { marginRight: leadingGap }
|
|
61
|
+
: undefined,
|
|
62
|
+
]}
|
|
63
|
+
>
|
|
64
|
+
{glyph.element}
|
|
65
|
+
</Animated.View>
|
|
66
|
+
) : (
|
|
67
|
+
<Animated.Text
|
|
68
|
+
key={glyph.id}
|
|
69
|
+
layout={layoutTransition}
|
|
70
|
+
entering={enterTransition}
|
|
71
|
+
exiting={exitTransition}
|
|
72
|
+
style={textStyle}
|
|
73
|
+
className={className}
|
|
74
|
+
>
|
|
75
|
+
{glyph.value}
|
|
76
|
+
</Animated.Text>
|
|
77
|
+
)
|
|
78
|
+
)}
|
|
46
79
|
</View>
|
|
47
80
|
)
|
|
48
81
|
);
|