react-native-laminar 1.2.0 → 1.4.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (147) hide show
  1. package/README.md +75 -2
  2. package/lib/commonjs/hooks/use-display-units.js +1 -0
  3. package/lib/commonjs/hooks/use-display-units.js.map +1 -1
  4. package/lib/commonjs/hooks/use-inline-auto-width.js +43 -10
  5. package/lib/commonjs/hooks/use-inline-auto-width.js.map +1 -1
  6. package/lib/commonjs/hooks/use-morph-motion.js +2 -1
  7. package/lib/commonjs/hooks/use-morph-motion.js.map +1 -1
  8. package/lib/commonjs/hooks/use-morph-text-style.js +4 -0
  9. package/lib/commonjs/hooks/use-morph-text-style.js.map +1 -1
  10. package/lib/commonjs/hooks/use-numeric-lanes.js +1 -0
  11. package/lib/commonjs/hooks/use-numeric-lanes.js.map +1 -1
  12. package/lib/commonjs/hooks/use-text-glyphs.js +28 -7
  13. package/lib/commonjs/hooks/use-text-glyphs.js.map +1 -1
  14. package/lib/commonjs/index.js +54 -11
  15. package/lib/commonjs/index.js.map +1 -1
  16. package/lib/commonjs/model/display-units.js +7 -1
  17. package/lib/commonjs/model/display-units.js.map +1 -1
  18. package/lib/commonjs/model/numeric-lanes.js +4 -0
  19. package/lib/commonjs/model/numeric-lanes.js.map +1 -1
  20. package/lib/commonjs/model/text-keys.js +4 -0
  21. package/lib/commonjs/model/text-keys.js.map +1 -1
  22. package/lib/commonjs/motion/entry-exit-builders.js +78 -1
  23. package/lib/commonjs/motion/entry-exit-builders.js.map +1 -1
  24. package/lib/commonjs/motion/preset-map.js +7 -0
  25. package/lib/commonjs/motion/preset-map.js.map +1 -1
  26. package/lib/commonjs/view/glyph-run.js +21 -2
  27. package/lib/commonjs/view/glyph-run.js.map +1 -1
  28. package/lib/commonjs/view/morph-viewport.js +4 -0
  29. package/lib/commonjs/view/morph-viewport.js.map +1 -1
  30. package/lib/commonjs/view/number-lane.js +5 -1
  31. package/lib/commonjs/view/number-lane.js.map +1 -1
  32. package/lib/commonjs/view/number-run.js +4 -0
  33. package/lib/commonjs/view/number-run.js.map +1 -1
  34. package/lib/commonjs/view/slots-run.js +258 -0
  35. package/lib/commonjs/view/slots-run.js.map +1 -0
  36. package/lib/commonjs/view/text-run.js +35 -3
  37. package/lib/commonjs/view/text-run.js.map +1 -1
  38. package/lib/module/hooks/use-display-units.js +2 -0
  39. package/lib/module/hooks/use-display-units.js.map +1 -1
  40. package/lib/module/hooks/use-inline-auto-width.js +44 -11
  41. package/lib/module/hooks/use-inline-auto-width.js.map +1 -1
  42. package/lib/module/hooks/use-morph-motion.js +2 -1
  43. package/lib/module/hooks/use-morph-motion.js.map +1 -1
  44. package/lib/module/hooks/use-morph-text-style.js +4 -0
  45. package/lib/module/hooks/use-morph-text-style.js.map +1 -1
  46. package/lib/module/hooks/use-numeric-lanes.js +1 -0
  47. package/lib/module/hooks/use-numeric-lanes.js.map +1 -1
  48. package/lib/module/hooks/use-text-glyphs.js +28 -7
  49. package/lib/module/hooks/use-text-glyphs.js.map +1 -1
  50. package/lib/module/index.js +56 -12
  51. package/lib/module/index.js.map +1 -1
  52. package/lib/module/model/display-units.js +7 -1
  53. package/lib/module/model/display-units.js.map +1 -1
  54. package/lib/module/model/numeric-lanes.js +5 -0
  55. package/lib/module/model/numeric-lanes.js.map +1 -1
  56. package/lib/module/model/text-keys.js +4 -0
  57. package/lib/module/model/text-keys.js.map +1 -1
  58. package/lib/module/motion/entry-exit-builders.js +75 -0
  59. package/lib/module/motion/entry-exit-builders.js.map +1 -1
  60. package/lib/module/motion/preset-map.js +7 -0
  61. package/lib/module/motion/preset-map.js.map +1 -1
  62. package/lib/module/view/glyph-run.js +21 -2
  63. package/lib/module/view/glyph-run.js.map +1 -1
  64. package/lib/module/view/morph-viewport.js +4 -0
  65. package/lib/module/view/morph-viewport.js.map +1 -1
  66. package/lib/module/view/number-lane.js +5 -1
  67. package/lib/module/view/number-lane.js.map +1 -1
  68. package/lib/module/view/number-run.js +4 -0
  69. package/lib/module/view/number-run.js.map +1 -1
  70. package/lib/module/view/slots-run.js +253 -0
  71. package/lib/module/view/slots-run.js.map +1 -0
  72. package/lib/module/view/text-run.js +36 -4
  73. package/lib/module/view/text-run.js.map +1 -1
  74. package/lib/typescript/commonjs/hooks/use-display-units.d.ts.map +1 -1
  75. package/lib/typescript/commonjs/hooks/use-inline-auto-width.d.ts +3 -1
  76. package/lib/typescript/commonjs/hooks/use-inline-auto-width.d.ts.map +1 -1
  77. package/lib/typescript/commonjs/hooks/use-morph-motion.d.ts.map +1 -1
  78. package/lib/typescript/commonjs/hooks/use-morph-text-style.d.ts.map +1 -1
  79. package/lib/typescript/commonjs/hooks/use-numeric-lanes.d.ts.map +1 -1
  80. package/lib/typescript/commonjs/hooks/use-text-glyphs.d.ts +2 -1
  81. package/lib/typescript/commonjs/hooks/use-text-glyphs.d.ts.map +1 -1
  82. package/lib/typescript/commonjs/index.d.ts +1 -3
  83. package/lib/typescript/commonjs/index.d.ts.map +1 -1
  84. package/lib/typescript/commonjs/model/display-units.d.ts.map +1 -1
  85. package/lib/typescript/commonjs/model/numeric-lanes.d.ts.map +1 -1
  86. package/lib/typescript/commonjs/model/text-keys.d.ts.map +1 -1
  87. package/lib/typescript/commonjs/motion/entry-exit-builders.d.ts +13 -0
  88. package/lib/typescript/commonjs/motion/entry-exit-builders.d.ts.map +1 -1
  89. package/lib/typescript/commonjs/motion/preset-map.d.ts.map +1 -1
  90. package/lib/typescript/commonjs/types.d.ts +12 -2
  91. package/lib/typescript/commonjs/types.d.ts.map +1 -1
  92. package/lib/typescript/commonjs/view/glyph-run.d.ts +5 -2
  93. package/lib/typescript/commonjs/view/glyph-run.d.ts.map +1 -1
  94. package/lib/typescript/commonjs/view/morph-viewport.d.ts.map +1 -1
  95. package/lib/typescript/commonjs/view/number-lane.d.ts.map +1 -1
  96. package/lib/typescript/commonjs/view/number-run.d.ts.map +1 -1
  97. package/lib/typescript/commonjs/view/slots-run.d.ts +15 -0
  98. package/lib/typescript/commonjs/view/slots-run.d.ts.map +1 -0
  99. package/lib/typescript/commonjs/view/text-run.d.ts +5 -1
  100. package/lib/typescript/commonjs/view/text-run.d.ts.map +1 -1
  101. package/lib/typescript/module/hooks/use-display-units.d.ts.map +1 -1
  102. package/lib/typescript/module/hooks/use-inline-auto-width.d.ts +3 -1
  103. package/lib/typescript/module/hooks/use-inline-auto-width.d.ts.map +1 -1
  104. package/lib/typescript/module/hooks/use-morph-motion.d.ts.map +1 -1
  105. package/lib/typescript/module/hooks/use-morph-text-style.d.ts.map +1 -1
  106. package/lib/typescript/module/hooks/use-numeric-lanes.d.ts.map +1 -1
  107. package/lib/typescript/module/hooks/use-text-glyphs.d.ts +2 -1
  108. package/lib/typescript/module/hooks/use-text-glyphs.d.ts.map +1 -1
  109. package/lib/typescript/module/index.d.ts +1 -3
  110. package/lib/typescript/module/index.d.ts.map +1 -1
  111. package/lib/typescript/module/model/display-units.d.ts.map +1 -1
  112. package/lib/typescript/module/model/numeric-lanes.d.ts.map +1 -1
  113. package/lib/typescript/module/model/text-keys.d.ts.map +1 -1
  114. package/lib/typescript/module/motion/entry-exit-builders.d.ts +13 -0
  115. package/lib/typescript/module/motion/entry-exit-builders.d.ts.map +1 -1
  116. package/lib/typescript/module/motion/preset-map.d.ts.map +1 -1
  117. package/lib/typescript/module/types.d.ts +12 -2
  118. package/lib/typescript/module/types.d.ts.map +1 -1
  119. package/lib/typescript/module/view/glyph-run.d.ts +5 -2
  120. package/lib/typescript/module/view/glyph-run.d.ts.map +1 -1
  121. package/lib/typescript/module/view/morph-viewport.d.ts.map +1 -1
  122. package/lib/typescript/module/view/number-lane.d.ts.map +1 -1
  123. package/lib/typescript/module/view/number-run.d.ts.map +1 -1
  124. package/lib/typescript/module/view/slots-run.d.ts +15 -0
  125. package/lib/typescript/module/view/slots-run.d.ts.map +1 -0
  126. package/lib/typescript/module/view/text-run.d.ts +5 -1
  127. package/lib/typescript/module/view/text-run.d.ts.map +1 -1
  128. package/package.json +11 -2
  129. package/src/hooks/use-display-units.ts +1 -0
  130. package/src/hooks/use-inline-auto-width.ts +55 -12
  131. package/src/hooks/use-morph-motion.ts +2 -1
  132. package/src/hooks/use-morph-text-style.ts +3 -0
  133. package/src/hooks/use-numeric-lanes.ts +1 -0
  134. package/src/hooks/use-text-glyphs.ts +48 -10
  135. package/src/index.tsx +98 -17
  136. package/src/model/display-units.ts +4 -1
  137. package/src/model/numeric-lanes.ts +3 -0
  138. package/src/model/text-keys.ts +3 -0
  139. package/src/motion/entry-exit-builders.ts +73 -0
  140. package/src/motion/preset-map.ts +4 -0
  141. package/src/types.ts +14 -2
  142. package/src/view/glyph-run.tsx +38 -13
  143. package/src/view/morph-viewport.tsx +3 -0
  144. package/src/view/number-lane.tsx +7 -2
  145. package/src/view/number-run.tsx +3 -0
  146. package/src/view/slots-run.tsx +385 -0
  147. package/src/view/text-run.tsx +77 -6
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,11 +7,13 @@ 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",
@@ -19,6 +21,9 @@ export const Laminar = React.memo(function Laminar({
19
21
  color,
20
22
  align = "left",
21
23
  className,
24
+ leading,
25
+ leadingKey,
26
+ leadingGap = 0,
22
27
  style,
23
28
  containerStyle,
24
29
  fontStyle,
@@ -29,6 +34,12 @@ export const Laminar = React.memo(function Laminar({
29
34
  clipToBounds = false,
30
35
  }: Readonly<MorphingTextProps>) {
31
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;
32
43
  const { motionRecipe, staggerMs } = useMorphMotion({
33
44
  variant,
34
45
  animationPreset,
@@ -42,16 +53,47 @@ export const Laminar = React.memo(function Laminar({
42
53
  style,
43
54
  });
44
55
 
45
- const { captureLayout, animatedWidthStyle } = useInlineAutoWidth({
46
- enabled: autoSize,
47
- driveToWidth: motionRecipe.driveNumber,
48
- });
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
49
89
  const measuredValue = React.useMemo(() => {
50
90
  if (!autoSize) {
51
91
  return "";
52
92
  }
53
93
 
54
- return splitDisplayUnits(resolvedValue).map(normalizeDisplayUnit).join("");
94
+ return splitDisplayUnits(resolvedValue)
95
+ .map(normalizeDisplayUnit)
96
+ .join("");
55
97
  }, [autoSize, resolvedValue]);
56
98
 
57
99
  return (
@@ -62,16 +104,42 @@ export const Laminar = React.memo(function Laminar({
62
104
  containerStyle={containerStyle}
63
105
  animatedWidthStyle={animatedWidthStyle}
64
106
  measurement={
65
- <Text
66
- numberOfLines={1}
67
- onLayout={captureLayout}
68
- style={textStyle}
69
- >
70
- {measuredValue}
71
- </Text>
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
72
130
  }
73
131
  >
74
- {variant === "number" ? (
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" ? (
75
143
  <NumberRun
76
144
  value={resolvedValue}
77
145
  motionRecipe={motionRecipe}
@@ -86,6 +154,9 @@ export const Laminar = React.memo(function Laminar({
86
154
  value={resolvedValue}
87
155
  motionRecipe={motionRecipe}
88
156
  align={align}
157
+ leading={resolvedLeading}
158
+ leadingKey={resolvedLeadingKey}
159
+ leadingGap={leadingGap}
89
160
  textStyle={textStyle}
90
161
  className={className}
91
162
  />
@@ -94,11 +165,21 @@ export const Laminar = React.memo(function Laminar({
94
165
  );
95
166
  });
96
167
 
97
- export const MorphingText = Laminar;
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
+ }
98
179
 
99
- export default Laminar;
100
180
  export type {
101
181
  LaminarAlign,
182
+ LaminarLeadingMap,
102
183
  LaminarProps,
103
184
  MorphAnimationPresetName,
104
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
- // swap plain spaces so measured text keeps its width
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,
@@ -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
+ };
@@ -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,8 +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";
15
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>>;
16
19
 
17
20
  type CubicBezierTuple = readonly [number, number, number, number];
18
21
 
@@ -29,12 +32,15 @@ export type MorphAnimationPreset =
29
32
 
30
33
  export type GlyphToken = {
31
34
  readonly id: string;
32
- readonly value: string;
35
+ readonly kind: "text" | "element";
36
+ readonly value?: string;
37
+ readonly element?: ReactNode;
33
38
  };
34
39
 
35
40
  export type NumericFlowDirection = -1 | 0 | 1;
36
41
 
37
42
  export type MotionRecipe = {
43
+ // every variant consumes the same timing and layout contract
38
44
  readonly durationMs: number;
39
45
  readonly easing: NonNullable<WithTimingConfig["easing"]>;
40
46
  readonly layoutTransition: ComplexAnimationBuilder;
@@ -50,6 +56,12 @@ export type MorphingTextProps = {
50
56
  readonly color?: string;
51
57
  readonly align?: LaminarAlign;
52
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;
53
65
  readonly style?: StyleProp<TextStyle>;
54
66
  readonly containerStyle?: StyleProp<ViewStyle>;
55
67
  readonly fontStyle?: StyleProp<TextStyle>;
@@ -19,38 +19,63 @@ const rowAlignStyles = {
19
19
  right: { alignSelf: "flex-end" },
20
20
  } as const;
21
21
 
22
+ // render text and inline elements through one animated token row
22
23
  export const GlyphRun = React.memo(
23
24
  ({
24
25
  glyphs,
25
26
  layoutTransition,
26
27
  enterTransition,
28
+ elementEnterTransition,
27
29
  exitTransition,
30
+ elementExitTransition,
31
+ leadingGap,
28
32
  align,
29
33
  textStyle,
30
34
  className,
31
35
  }: Readonly<{
32
36
  glyphs: readonly GlyphToken[];
33
- layoutTransition: ComplexAnimationBuilder;
37
+ layoutTransition?: ComplexAnimationBuilder;
34
38
  enterTransition?: EntryExitAnimationFunction;
39
+ elementEnterTransition?: EntryExitAnimationFunction;
35
40
  exitTransition?: EntryExitAnimationFunction;
41
+ elementExitTransition?: EntryExitAnimationFunction;
42
+ leadingGap?: number;
36
43
  align: LaminarAlign;
37
44
  textStyle?: StyleProp<TextStyle>;
38
45
  className?: string;
39
46
  }>) => (
47
+ // each token keeps its own view so reanimated can animate only changed content
40
48
  <View style={[rowStyle, rowAlignStyles[align]]}>
41
49
  {/* glyph ids decide what swaps, layout handles the row reflow */}
42
- {glyphs.map((glyph) => (
43
- <Animated.Text
44
- key={glyph.id}
45
- layout={layoutTransition}
46
- entering={enterTransition}
47
- exiting={exitTransition}
48
- style={textStyle}
49
- className={className}
50
- >
51
- {glyph.value}
52
- </Animated.Text>
53
- ))}
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
+ )}
54
79
  </View>
55
80
  )
56
81
  );
@@ -56,6 +56,7 @@ type MorphViewportProps = {
56
56
  readonly children: React.ReactNode;
57
57
  };
58
58
 
59
+ // separate hidden measurement from visible content while sharing the shell alignment
59
60
  export const MorphViewport = React.memo(
60
61
  ({
61
62
  autoSize,
@@ -66,6 +67,7 @@ export const MorphViewport = React.memo(
66
67
  measurement,
67
68
  children,
68
69
  }: MorphViewportProps) => {
70
+ // compute alignment once so the animated shell and its child share one layout contract
69
71
  const resolvedViewportStyle = useMemo(
70
72
  () => [
71
73
  viewportStyle,
@@ -75,6 +77,7 @@ export const MorphViewport = React.memo(
75
77
  [align, clipToBounds]
76
78
  );
77
79
 
80
+ // keep measurement outside the visible viewport so it cannot affect accessibility or layout
78
81
  return (
79
82
  <View style={[shellStyle, shellAlignStyles[align], containerStyle]}>
80
83
  {autoSize ? (
@@ -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={hasAnimated ? motionRecipe.enterTransition : undefined}
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
  >
@@ -26,6 +26,7 @@ type NumberRunProps = {
26
26
  readonly className?: string;
27
27
  };
28
28
 
29
+ // arrange numeric lanes from left to right while the hook preserves their identities
29
30
  export const NumberRun = React.memo(
30
31
  ({
31
32
  value,
@@ -36,6 +37,7 @@ export const NumberRun = React.memo(
36
37
  staggerMs,
37
38
  className,
38
39
  }: Readonly<NumberRunProps>) => {
40
+ // the hook owns lane identity while this component owns row placement and timing
39
41
  const { units, laneKeys, direction, leadLength } = useNumericLanes(value);
40
42
  const lastValueRef = useRef(value);
41
43
  const hasAnimatedRef = useRef(false);
@@ -46,6 +48,7 @@ export const NumberRun = React.memo(
46
48
  lastValueRef.current = value;
47
49
  }
48
50
 
51
+ // scale travel from the font while keeping a minimum visible movement
49
52
  const travelDistance = useMemo(
50
53
  () => Math.max(8, Math.round((fontSize ?? 16) * 0.4)),
51
54
  [fontSize]