react-native-slot-text 2.2.3 → 3.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -8,9 +8,14 @@ import type { AnimatedNumbersProps } from './types';
8
8
  *
9
9
  * @param {number|string} props.value - The value to animate to. Can be string of numbers.
10
10
  * @param {Object} props.fontStyle - The style of the text, passed as a TextStyle object.
11
- * @param {number} [props.animationDuration=200] - The duration of the animation in milliseconds. Defaults to 200ms.
11
+ * @param {number} [props.animationDuration=400] - The duration of the animation in milliseconds. Defaults to 400ms.
12
+ * Only supported when animateIntermediateValues is false
12
13
  * @param {string} [props.prefix=""] - A prefix to the number, such as a currency symbol.
13
14
  * @param {boolean} [props.includeComma=false] - Whether to include commas as thousand separators.
15
+ * @param {true} [props.animateIntermediateValues=false] - Whether to animate all intermediate numbers between new value
16
+ * and current value of a slot. If the value is changing rapidly, this option is best. Otherwise the animations
17
+ * may glitch or act unexpectedly.
18
+ * @param {number} [props.precision] - Number of decimal places. e.g. value prop of 42069 with precision of 2 would become 420.69
14
19
  *
15
20
  * @returns {JSX.Element} The animated number component with slots for digits and commas.
16
21
  */
@@ -1,11 +1,7 @@
1
- import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
- import { useState, useRef, Fragment, useLayoutEffect, useCallback, useEffect } from "react";
3
- import { View, Text } from "react-native";
4
- import { useSharedValue } from 'react-native-reanimated';
5
- import styles from './styles';
6
- import Slot from './Slot';
7
- import { getNewSlots, getNewCommaPositions } from './helpers';
8
- const DEFAULT_DURATION = 200;
1
+ import { jsx as _jsx, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import React from 'react';
3
+ import ContinuousSlots from './ContinuousSlots';
4
+ import Slots from './Slots';
9
5
  /**
10
6
  * AnimatedNumbers Component
11
7
  *
@@ -15,81 +11,40 @@ const DEFAULT_DURATION = 200;
15
11
  *
16
12
  * @param {number|string} props.value - The value to animate to. Can be string of numbers.
17
13
  * @param {Object} props.fontStyle - The style of the text, passed as a TextStyle object.
18
- * @param {number} [props.animationDuration=200] - The duration of the animation in milliseconds. Defaults to 200ms.
14
+ * @param {number} [props.animationDuration=400] - The duration of the animation in milliseconds. Defaults to 400ms.
15
+ * Only supported when animateIntermediateValues is false
19
16
  * @param {string} [props.prefix=""] - A prefix to the number, such as a currency symbol.
20
17
  * @param {boolean} [props.includeComma=false] - Whether to include commas as thousand separators.
18
+ * @param {true} [props.animateIntermediateValues=false] - Whether to animate all intermediate numbers between new value
19
+ * and current value of a slot. If the value is changing rapidly, this option is best. Otherwise the animations
20
+ * may glitch or act unexpectedly.
21
+ * @param {number} [props.precision] - Number of decimal places. e.g. value prop of 42069 with precision of 2 would become 420.69
21
22
  *
22
23
  * @returns {JSX.Element} The animated number component with slots for digits and commas.
23
24
  */
24
25
  const AnimatedNumbers = (props) => {
25
- // [number] : static number
26
- /// [number, null]: removing slot
27
- // [null, number]: adding slot
28
- // [number, number]: animating slot
29
- const idRef = useRef(`slots-${Math.random().toString(36).substring(7)}`);
30
- // Indexes of slots that have finished animating
31
- const queuedNumber = useSharedValue(null);
32
- const [slots, setSlots] = useState([]);
33
- const [commaPositions, setCommaPositions] = useState([]);
34
- const [commaWidth, setCommaWidth] = useState(0);
35
- const [sizesMeasured, setSizesMeasured] = useState(false);
36
- const [keys, setKeys] = useState(Array.from({ length: props.value.toString().length }).map((_, i) => Math.random().toString(36).substring(2, 9)));
37
- const [slotHeight, setSlotHeight] = useState(0);
38
- const [charSizes, setCharSizes] = useState(Array.from({ length: 10 }).map((_, i) => 0));
39
- useLayoutEffect(() => {
40
- if (slots.some(s => s.length > 1)) {
41
- queuedNumber.value = props.value;
42
- }
43
- else {
44
- const parseFromLeft = props.value.toString()[0] === slots[0]?.[0]?.toString();
45
- if (props.includeComma) {
46
- const newCommaPositions = getNewCommaPositions(props.value.toString(), commaPositions, parseFromLeft);
47
- setCommaPositions(newCommaPositions);
48
- }
49
- const newSlots = getNewSlots(props.value, slots, parseFromLeft);
50
- setKeys(prev => prev.length > newSlots.length
51
- ? newSlots[0]?.[1] === null
52
- ? prev.slice(-1 * prev.length - newSlots.length)
53
- : prev.slice(0, newSlots.length)
54
- : prev.length < newSlots.length
55
- ? newSlots[0]?.[0] === null
56
- ? Array.from({ length: newSlots.length - prev.length }).map((_, i) => Math.random().toString(36).substring(2, 9)).concat(prev)
57
- : prev.concat(Array.from({ length: newSlots.length - prev.length }).map((_, i) => Math.random().toString(36).substring(2, 9)))
58
- : prev);
59
- setSlots(newSlots);
60
- }
61
- }, [props.value]);
62
- const onCompleted = useCallback(() => {
63
- const cleanedSlots = slots
64
- .filter(s => (Number.isFinite(s[0]) && s[1] !== null) || Number.isFinite(s[1]))
65
- .map(s => Number.isFinite(s[1]) ? [s[1]] : [s[0]]);
66
- const numberOfSlotsRemoved = Math.max(slots.length - cleanedSlots.length, 0);
67
- const cleanedCommas = commaPositions
68
- .map(c => c === -1 ? null : c === 1 ? 0 : c)
69
- .slice(slots[0]?.[1] === null ? numberOfSlotsRemoved : 0) // Trim from left
70
- .slice(0, slots[slots.length - 1]?.[1] === null ? -1 * numberOfSlotsRemoved : undefined); // Trim from right
71
- setTimeout(() => {
72
- setSlots(cleanedSlots);
73
- setCommaPositions(cleanedCommas);
74
- }, 0);
75
- }, [slots, commaPositions]);
76
- return (_jsxs(_Fragment, { children: [_jsxs(View, { style: styles.slotsContainer, children: [props.prefix && (_jsx(Text, { style: props.fontStyle, children: props.prefix })), slots.map((slot, i) => {
77
- const callback = i === slots.findIndex(s => s.length > 1) ? onCompleted : undefined;
78
- return (_jsx(Slot, { slot: slot, index: i, height: slotHeight, charSizes: charSizes, commaWidth: commaWidth, commaPositions: props.includeComma && commaPositions.length ? commaPositions : undefined, onCompleted: callback, animationDuration: (props.animationDuration || DEFAULT_DURATION), fontStyle: props.fontStyle }, `${idRef.current}-${keys[i]}`));
79
- }), _jsx(Text, { style: [styles.spacer, props.fontStyle], children: "1" })] }), !sizesMeasured && Array.from({ length: 10 }).concat(',').map((_, i) => (_jsx(Fragment, { children: _jsx(Text, { style: [styles.hiddenSlot, props.fontStyle], onLayout: (e) => {
80
- setSlotHeight(e.nativeEvent.layout.height);
81
- if (i === 10) {
82
- setCommaWidth(e.nativeEvent.layout.width);
83
- setSizesMeasured(true);
84
- }
85
- else {
86
- const charSize = e.nativeEvent.layout.width;
87
- setCharSizes(prev => {
88
- const newSizes = [...prev];
89
- newSizes[i] = charSize;
90
- return newSizes;
91
- });
92
- }
93
- }, children: i === 10 ? ',' : i }, `slot-${i}`) }, `measure-slot-${i}`)))] }));
26
+ return (_jsx(_Fragment, { children: props.animateIntermediateValues
27
+ ? _jsx(ContinuousSlots, { ...props })
28
+ : _jsx(Slots, { ...props }) }));
94
29
  };
30
+ /*
31
+ Basic logic:
32
+
33
+ When a new value comes in:
34
+
35
+ 1. If there is an animation currently in progress then queue the new value with any prefix prepended
36
+
37
+ 2. For an animation cycle, split the new number and set the new number state
38
+
39
+ 3. Loop through the new number and old number and compare digits, determing which way the new numbers
40
+ and old numers need to animate. There are two versions of the number, one that's visible, and one outside
41
+ the visible clipping container. If a slot has the same number between the old and new nummer, it wont be animated.
42
+
43
+ 4. Set the new positions wich will trigger the animations of the slots
44
+
45
+ 5. At the end, clear the new number and set the old number to the new number
46
+
47
+ 6. Pop any queued values and set the formated value, which will retriger the animation cycle
48
+
49
+ */
95
50
  export default AnimatedNumbers;
@@ -0,0 +1,3 @@
1
+ import type { ContinuousSlotProps } from "./types";
2
+ declare const ContinuousSlot: (props: ContinuousSlotProps) => import("react/jsx-runtime").JSX.Element;
3
+ export default ContinuousSlot;
@@ -0,0 +1,117 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useEffect, useRef, useState } from 'react';
3
+ import { Text, StyleSheet, Animated, useAnimatedValue, Easing } from 'react-native';
4
+ import MaskedView from "@react-native-masked-view/masked-view";
5
+ import { easeGradient } from "react-native-easing-gradient";
6
+ import ReAnimated, { LinearTransition, FadeOutDown, FadeInUp, useSharedValue, withTiming, Easing as ReEasing } from 'react-native-reanimated';
7
+ import { LinearGradient } from "expo-linear-gradient";
8
+ import styles from './styles';
9
+ import { bezier_points } from './constants';
10
+ const GAP = 12;
11
+ const PADDING_FRACTION = .25;
12
+ const AnimatedMaskView = Animated.createAnimatedComponent(MaskedView);
13
+ const ContinuousSlot = (props) => {
14
+ const easing = bezier_points[props.easing || 'linear'];
15
+ const config = {
16
+ useNativeDriver: true,
17
+ duration: props.animationDuration,
18
+ easing: Easing.bezier(...easing)
19
+ };
20
+ const id = useRef(`rn-slottext-slot-${Math.random().toString(36).slice(0, 9)}`);
21
+ const width = useSharedValue(0);
22
+ const height = useSharedValue(0);
23
+ const y = useAnimatedValue(0);
24
+ const slotOpacity = useAnimatedValue(0);
25
+ const periodY = useAnimatedValue(0);
26
+ const commaY = useAnimatedValue(0);
27
+ const periodOpacity = useAnimatedValue(0);
28
+ const commaOpacity = useAnimatedValue(0);
29
+ const maskTop = useAnimatedValue(0);
30
+ const measuredHeight = useRef(0);
31
+ const [isMeasured, setIsMeasured] = useState(false);
32
+ useEffect(() => {
33
+ if (!isMeasured)
34
+ return;
35
+ const newWidth = props.charSizes[typeof props.slot[0] === 'number'
36
+ ? props.slot[0]
37
+ : props.slot[0] === ',' ? 10 : 11];
38
+ width.value = newWidth > width.value
39
+ ? newWidth // Growing
40
+ : withTiming(newWidth, { duration: props.animationDuration });
41
+ if (typeof props.slot[0] === 'number') {
42
+ Animated.timing(slotOpacity, { toValue: 1, ...config }).start();
43
+ Animated.timing(y, {
44
+ toValue: -1 * (((GAP + measuredHeight.current) * (9 - props.slot[0])) -
45
+ (PADDING_FRACTION * measuredHeight.current)),
46
+ ...config
47
+ }).start();
48
+ Animated.timing(commaY, { toValue: 0, ...config }).start();
49
+ Animated.timing(periodY, { toValue: 0, ...config }).start();
50
+ Animated.timing(commaOpacity, { toValue: 0, ...config }).start();
51
+ Animated.timing(periodOpacity, { toValue: 0, ...config }).start();
52
+ }
53
+ else if (props.slot[0] === '.') {
54
+ Animated.timing(periodY, { toValue: (PADDING_FRACTION * measuredHeight.current), ...config }).start();
55
+ Animated.timing(periodOpacity, { toValue: 1, ...config }).start();
56
+ Animated.timing(slotOpacity, { toValue: 0, ...config }).start();
57
+ Animated.timing(commaY, { toValue: 0, ...config }).start();
58
+ }
59
+ else if (props.slot[0] === ',') {
60
+ Animated.timing(commaY, { toValue: (PADDING_FRACTION * measuredHeight.current), ...config }).start();
61
+ Animated.timing(commaOpacity, { toValue: 1, ...config }).start();
62
+ Animated.timing(periodY, { toValue: 1, ...config }).start();
63
+ Animated.timing(slotOpacity, { toValue: 0, ...config }).start();
64
+ }
65
+ }, [props.slot]);
66
+ // LINEAR GRADIENT
67
+ const { colors, locations } = easeGradient({
68
+ colorStops: {
69
+ 0: { color: 'transparent' },
70
+ 0.1: { color: 'black' },
71
+ .9: { color: 'black' },
72
+ 1: { color: 'transparent' }
73
+ },
74
+ });
75
+ return (_jsx(ReAnimated.View, { layout: LinearTransition.duration(props.animationDuration).easing(ReEasing.bezier(...easing).factory()), entering: props.charSizes[0]
76
+ ? FadeInUp.duration(props.animationDuration / 1.5).easing(ReEasing.bezier(...easing).factory())
77
+ : undefined, exiting: FadeOutDown.duration(props.animationDuration / 1.5).easing(ReEasing.bezier(...easing).factory()), children: isMeasured
78
+ ?
79
+ _jsx(ReAnimated.View, { style: { height, width }, children: _jsxs(AnimatedMaskView, { maskElement: _jsx(LinearGradient, { locations: locations, colors: colors, style: StyleSheet.absoluteFill }), style: [styles.mask, { top: maskTop, bottom: maskTop }], children: [_jsx(Animated.View, { style: {
80
+ position: 'absolute',
81
+ opacity: slotOpacity,
82
+ gap: GAP,
83
+ transform: [{ translateY: y }]
84
+ }, children: Array.from({ length: 10 }, (_, i) => (9 - i))
85
+ .concat([',', '.'])
86
+ .map(i => (_jsx(Text, { style: props.fontStyle, children: i }, `${id}-${i}`))) }), _jsx(Animated.View, { style: {
87
+ opacity: commaOpacity,
88
+ transform: [{ translateY: commaY }],
89
+ position: 'absolute'
90
+ }, children: _jsx(Text, { style: props.fontStyle, children: "," }) }), _jsx(Animated.View, { style: {
91
+ opacity: periodOpacity,
92
+ transform: [{ translateY: periodY }],
93
+ position: 'absolute'
94
+ }, children: _jsx(Text, { style: props.fontStyle, children: "." }) })] }) })
95
+ :
96
+ _jsx(Text, { style: props.fontStyle, onLayout: ({ nativeEvent: ne }) => {
97
+ width.value = ne.layout.width;
98
+ height.value = ne.layout.height;
99
+ maskTop.setValue(-1 * (PADDING_FRACTION * ne.layout.height));
100
+ measuredHeight.current = ne.layout.height;
101
+ if (typeof props.slot[0] === 'number') {
102
+ y.setValue(-1 * (((GAP + ne.layout.height) * (9 - props.slot[0])) -
103
+ (PADDING_FRACTION * ne.layout.height)));
104
+ slotOpacity.setValue(1);
105
+ }
106
+ else if (props.slot[0] === ',') {
107
+ commaY.setValue(PADDING_FRACTION * ne.layout.height);
108
+ commaOpacity.setValue(1);
109
+ }
110
+ else if (props.slot[0] === '.') {
111
+ periodY.setValue(PADDING_FRACTION * ne.layout.height);
112
+ periodOpacity.setValue(1);
113
+ }
114
+ setIsMeasured(true);
115
+ }, children: props.slot[0] }) }));
116
+ };
117
+ export default ContinuousSlot;
@@ -0,0 +1,3 @@
1
+ import type { AnimatedNumbersProps } from './types';
2
+ declare const ContinuousSlots: (props: AnimatedNumbersProps) => import("react/jsx-runtime").JSX.Element;
3
+ export default ContinuousSlots;
@@ -0,0 +1,72 @@
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import React, { useRef, useState, Fragment, useEffect } from 'react';
3
+ import { Text, View } from 'react-native';
4
+ import Animated, { LinearTransition } from 'react-native-reanimated';
5
+ import ContinuousSlot from './ContinuousSlot';
6
+ import styles from './styles';
7
+ const DEFAULT_DURATION = 700;
8
+ const ContinuousSlots = (props) => {
9
+ const formatter = new Intl.NumberFormat('en-US', {
10
+ style: 'decimal',
11
+ minimumFractionDigits: props.precision || 0,
12
+ maximumFractionDigits: props.precision || 0
13
+ });
14
+ const idRef = useRef(`rn-continuous-slot-${Math.random().toString(36).substring(7)}`);
15
+ // [value, key]
16
+ const [slots, setSlots] = useState(formatter.format(props.value).split('').map((v, i) => {
17
+ const slotValue = Number.isFinite(parseInt(v)) ? parseInt(v) : v;
18
+ return [
19
+ slotValue,
20
+ Math.random().toString(36).slice(0, 9)
21
+ ];
22
+ }));
23
+ const [charSizes, setCharSizes] = useState(Array.from({ length: 10 }).map((_, i) => 0));
24
+ const [sizesMeasured, setSizesMeasured] = useState(false);
25
+ useEffect(() => {
26
+ // Left parsing
27
+ // _ _ _ _ _ _ new val
28
+ // _ _ _ old val
29
+ // Right parsing
30
+ // _ _ _ new val
31
+ // _ _ _ _ _ _ old val
32
+ const parseFromLeft = slots[0][0] === parseInt(props.value.toString()[0]);
33
+ const formatted = props.includeComma
34
+ ? formatter.format(props.value).split('')
35
+ : `${props.value}`.split('');
36
+ const parser = Array.from({ length: formatted.length }, (_, i) => parseFromLeft ? i : formatted.length - i - 1);
37
+ const newSlots = parser.map((index) => {
38
+ const slotValue = Number.isFinite(parseInt(formatted[index]))
39
+ ? parseInt(formatted[index])
40
+ : formatted[index];
41
+ const parseIndex = parseFromLeft
42
+ ? index
43
+ : index + (slots.length - formatted.length);
44
+ const keepKeySame = [
45
+ // Both numbers
46
+ Number.isFinite(parseInt(formatted[index])) && Number.isFinite(slots[parseIndex]?.[0]),
47
+ // Both comma or period
48
+ typeof slots[parseIndex]?.[0] === 'string' && !Number.isFinite(parseInt(formatted[index]))
49
+ ];
50
+ const slot = keepKeySame.some(Boolean)
51
+ ? [slotValue, slots[parseIndex][1]]
52
+ : [slotValue, Math.random().toString(36).slice(0, 9)];
53
+ return slot;
54
+ });
55
+ setSlots(parseFromLeft ? newSlots : newSlots.reverse());
56
+ }, [props.value]);
57
+ return (_jsxs(_Fragment, { children: [_jsxs(View, { style: styles.slotsContainer, children: [props.prefix && (_jsx(Animated.Text, { style: props.fontStyle, layout: LinearTransition, children: props.prefix })), slots.map((slot, i) => (_jsx(ContinuousSlot, { slot: slot, index: i, charSizes: charSizes, fontStyle: props.fontStyle, easing: props.easing, animationDuration: DEFAULT_DURATION }, `${idRef.current}-${slot[1]}`))), _jsx(Text, { style: [styles.spacer, props.fontStyle], children: "1" })] }), !sizesMeasured &&
58
+ Array.from({ length: 10 }, (_, i) => i)
59
+ .concat([',', '.'])
60
+ .map((char, i) => (_jsx(Fragment, { children: _jsx(Text, { style: [styles.hiddenSlot, props.fontStyle], onLayout: (e) => {
61
+ const charSize = e.nativeEvent.layout.width;
62
+ setCharSizes(prev => {
63
+ const newSizes = [...prev];
64
+ newSizes[i] = charSize;
65
+ return newSizes;
66
+ });
67
+ if (char === '.') {
68
+ setSizesMeasured(true);
69
+ }
70
+ }, children: char }) }, `rn-slots-measure-slot-${i}`)))] }));
71
+ };
72
+ export default ContinuousSlots;
package/README.md CHANGED
@@ -10,10 +10,13 @@ This project was created using `bun init` in bun v1.1.21. [Bun](https://bun.sh)
10
10
 
11
11
 
12
12
 
13
- https://github.com/user-attachments/assets/192d168a-6497-4035-9c8e-b39b88dabf56
13
+ https://github.com/user-attachments/assets/49671041-9481-4c04-b61d-6afdca74757d
14
14
 
15
15
 
16
16
 
17
+ https://github.com/user-attachments/assets/9cea5bdb-032e-4e4a-8d32-f2fa58684d9d
18
+
19
+
17
20
  ## Usage
18
21
 
19
22
  ```
@@ -28,10 +31,11 @@ https://github.com/user-attachments/assets/192d168a-6497-4035-9c8e-b39b88dabf56
28
31
 
29
32
  ### Props
30
33
 
31
- | Prop | Type | Default | Description |
32
- |---------------------|-----------------------|----------|--------------------------------------------------------------------------------------------------|
33
- | `value` | '${number}' | `N/A` | The value to animate to. Can be a number or a string of numbers. |
34
- | `fontStyle` | `Object` | `N/A` | The style of the text, passed as a `TextStyle` object. |
35
- | `animationDuration` | `number` | `200` | The duration of the animation in milliseconds. Defaults to 200ms. |
36
- | `prefix` | `string` | `""` | A prefix to the number, such as a currency symbol. |
37
- | `includeComma` | `boolean` | `false` | Whether to include commas as thousand separators. |
34
+ | **Prop** | **Type** | **Default** | **Description** |
35
+ |-----------------------------|-------------------------|-------------|---------------------------------------------------------------------------------------------------------------------------------------------------|
36
+ | `value` | `number` \| `string` | **Required** | The value to animate to. Can be a number or a string of numeric characters. |
37
+ | `fontStyle` | `Object` (TextStyle) | **Required** | The style of the text, passed as a TextStyle object. |
38
+ | `animationDuration` | `number` | `400` | The duration of the animation in milliseconds. Only supported when `animateIntermediateValues` is `false`. |
39
+ | `prefix` | `string` | `""` | A prefix to the number, such as a currency symbol (e.g., `$` or `€`). |
40
+ | `includeComma` | `boolean` | `false` | Whether to include commas as thousand separators. |
41
+ | `animateIntermediateValues`| `boolean` | `false` | Whether to animate all intermediate numbers between the new value and the current value of a digit. Useful for rapidly changing values to prevent glitches in animation. |
package/Slot.js CHANGED
@@ -4,8 +4,9 @@ import { View } from 'react-native';
4
4
  import { Text } from 'react-native';
5
5
  import Animated, { useSharedValue, withTiming, withSequence, useAnimatedStyle, interpolate, runOnJS, Easing } from 'react-native-reanimated';
6
6
  import styles from './styles';
7
- const easing = Easing.bezier(0, 0.4, 0.4, .8);
7
+ import { bezier_points } from './constants';
8
8
  const Slot = (props) => {
9
+ const easing = Easing.bezier(...bezier_points[props.easing || 'linear']);
9
10
  const margin = useSharedValue(0);
10
11
  const y = useSharedValue(0);
11
12
  const incomingY = useSharedValue(0);
@@ -28,12 +29,12 @@ const Slot = (props) => {
28
29
  y.value = withTiming(-1 * props.height, { duration: props.animationDuration, easing }, () => {
29
30
  props.onCompleted && runOnJS(props.onCompleted)();
30
31
  });
31
- margin.value = withTiming(-1 * props.charSizes[currentValue], { duration: props.animationDuration / 2 });
32
+ margin.value = withTiming(-1 * props.charSizes[currentValue], { duration: props.animationDuration / 2, easing });
32
33
  }
33
34
  // Adding slot
34
35
  else if (currentValue === null) {
35
- margin.value = withTiming(props.charSizes[incomingValue], { duration: props.animationDuration / 2 });
36
- incomingY.value = withSequence(withTiming(-1 * props.height, { duration: 0 }), withTiming(0, { duration: props.animationDuration, easing }, () => {
36
+ margin.value = withTiming(props.charSizes[incomingValue], { duration: props.animationDuration / 2, easing });
37
+ incomingY.value = withSequence(withTiming(-1 * props.height, { duration: 0, easing }), withTiming(0, { duration: props.animationDuration }, () => {
37
38
  props.onCompleted && runOnJS(props.onCompleted)();
38
39
  }));
39
40
  }
@@ -42,13 +43,13 @@ const Slot = (props) => {
42
43
  margin.value = withTiming((props.charSizes[incomingValue] - props.charSizes[currentValue]), { duration: props.animationDuration / 2 });
43
44
  if (incomingValue > currentValue) {
44
45
  y.value = withTiming(props.height, { duration: props.animationDuration, easing });
45
- incomingY.value = withSequence(withTiming(-1 * props.height, { duration: 0, easing }), withTiming(0, { duration: props.animationDuration }, () => {
46
+ incomingY.value = withSequence(withTiming(-1 * props.height, { duration: 0 }), withTiming(0, { duration: props.animationDuration }, () => {
46
47
  props.onCompleted && runOnJS(props.onCompleted)();
47
48
  }));
48
49
  }
49
50
  else if (incomingValue < currentValue) {
50
51
  y.value = withTiming(-1 * props.height, { duration: props.animationDuration, easing });
51
- incomingY.value = withSequence(withTiming(1 * props.height, { duration: 0, easing }), withTiming(0, { duration: props.animationDuration }, () => {
52
+ incomingY.value = withSequence(withTiming(1 * props.height, { duration: 0 }), withTiming(0, { duration: props.animationDuration, easing }, () => {
52
53
  props.onCompleted && runOnJS(props.onCompleted)();
53
54
  }));
54
55
  }
@@ -56,13 +57,13 @@ const Slot = (props) => {
56
57
  }, [props.slot]);
57
58
  useLayoutEffect(() => {
58
59
  if (props.commaPositions?.[props.index] === 1) {
59
- commaScale.value = withTiming(1, { duration: props.animationDuration });
60
- commaWidth.value = withSequence(withTiming(0, { duration: 0 }), withTiming(props.commaWidth, { duration: props.animationDuration }));
60
+ commaScale.value = withTiming(1, { duration: props.animationDuration, easing });
61
+ commaWidth.value = withSequence(withTiming(0, { duration: 0 }), withTiming(props.commaWidth, { duration: props.animationDuration, easing }));
61
62
  }
62
63
  else if (props.commaPositions?.[props.index] === -1) {
63
- commaScale.value = withSequence(withTiming(1, { duration: 0 }), withTiming(0, { duration: props.animationDuration }));
64
+ commaScale.value = withSequence(withTiming(1, { duration: 0 }), withTiming(0, { duration: props.animationDuration, easing }));
64
65
  const timeout = setTimeout(() => {
65
- commaWidth.value = withTiming(0, { duration: props.animationDuration });
66
+ commaWidth.value = withTiming(0, { duration: props.animationDuration, easing });
66
67
  }, 0);
67
68
  return () => clearTimeout(timeout);
68
69
  }
package/Slots.d.ts ADDED
@@ -0,0 +1,3 @@
1
+ import type { AnimatedNumbersProps } from './types';
2
+ declare const Slots: (props: AnimatedNumbersProps) => import("react/jsx-runtime").JSX.Element;
3
+ export default Slots;
package/Slots.js ADDED
@@ -0,0 +1,86 @@
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import React, { useState, useLayoutEffect, useCallback, Fragment, useRef } from 'react';
3
+ import { View, Text } from 'react-native';
4
+ import styles from './styles';
5
+ import { getNewSlots, getNewCommaPositions } from './helpers';
6
+ import Slot from './Slot';
7
+ const DEFAULT_DURATION = 400;
8
+ const Slots = (props) => {
9
+ const idRef = useRef(`slots-${Math.random().toString(36).substring(7)}`);
10
+ // [number] : static number
11
+ /// [number, null]: removing slot
12
+ // [null, number]: adding slot
13
+ // [number, number]: animating slot
14
+ const [slots, setSlots] = useState([]);
15
+ const [commaPositions, setCommaPositions] = useState([]);
16
+ const [commaWidth, setCommaWidth] = useState(0);
17
+ const [periodWidth, setPeriodWidth] = useState(0);
18
+ const [sizesMeasured, setSizesMeasured] = useState(false);
19
+ const [keys, setKeys] = useState(Array.from({ length: props.value.toString().replace('.', '').length }).map((_, i) => Math.random().toString(36).substring(2, 9)));
20
+ const [slotHeight, setSlotHeight] = useState(0);
21
+ const [charSizes, setCharSizes] = useState(Array.from({ length: 10 }).map((_, i) => 0));
22
+ useLayoutEffect(() => {
23
+ const stringValue = props.value.toString().replace('.', '');
24
+ if (props.animateIntermediateValues)
25
+ return;
26
+ const parseFromLeft = stringValue[0] === slots[0]?.[0]?.toString();
27
+ if (props.includeComma) {
28
+ const newCommaPositions = getNewCommaPositions(stringValue, commaPositions, parseFromLeft, props.precision);
29
+ setCommaPositions(newCommaPositions);
30
+ }
31
+ const newSlots = getNewSlots(stringValue, slots, parseFromLeft);
32
+ setKeys(prev => prev.length > newSlots.length
33
+ ? newSlots[0]?.[1] === null
34
+ ? prev.slice(-1 * prev.length - newSlots.length)
35
+ : prev.slice(0, newSlots.length)
36
+ : prev.length < newSlots.length
37
+ ? newSlots[0]?.[0] === null
38
+ ? Array.from({ length: newSlots.length - prev.length }).map((_, i) => Math.random().toString(36).substring(2, 9)).concat(prev)
39
+ : prev.concat(Array.from({ length: newSlots.length - prev.length }).map((_, i) => Math.random().toString(36).substring(2, 9)))
40
+ : prev);
41
+ setSlots(newSlots);
42
+ }, [props.value]);
43
+ const onCompleted = useCallback(() => {
44
+ const cleanedSlots = slots
45
+ .filter(s => (Number.isFinite(s[0]) && s[1] !== null) || Number.isFinite(s[1]))
46
+ .map(s => Number.isFinite(s[1]) ? [s[1]] : [s[0]]);
47
+ const numberOfSlotsRemoved = Math.max(slots.length - cleanedSlots.length, 0);
48
+ const cleanedCommas = commaPositions
49
+ .map(c => c === -1 ? null : c === 1 ? 0 : c)
50
+ .slice(slots[0]?.[1] === null ? numberOfSlotsRemoved : 0) // Trim from left
51
+ .slice(0, slots[slots.length - 1]?.[1] === null ? -1 * numberOfSlotsRemoved : undefined); // Trim from right
52
+ setTimeout(() => {
53
+ setSlots(cleanedSlots);
54
+ setCommaPositions(cleanedCommas);
55
+ }, 200);
56
+ }, [slots, commaPositions]);
57
+ return (_jsxs(_Fragment, { children: [_jsxs(View, { style: styles.slotsContainer, children: [props.prefix && (_jsx(Text, { style: props.fontStyle, children: props.prefix })), _jsx(Text, { style: [styles.spacer, props.fontStyle], children: "1" }), slots.map((slot, i) => {
58
+ const callback = i === slots.findIndex(s => s.length > 1) ? onCompleted : undefined;
59
+ const animationDuration = "animationDuration" in props
60
+ ? props.animationDuration || DEFAULT_DURATION
61
+ : DEFAULT_DURATION;
62
+ return (_jsxs(Fragment, { children: [_jsx(Slot, { slot: slot, easing: props.easing, index: i, height: slotHeight, charSizes: charSizes, commaWidth: commaWidth, periodWidth: periodWidth, commaPositions: props.includeComma && commaPositions.length ? commaPositions : undefined, onCompleted: callback, animationDuration: animationDuration, fontStyle: props.fontStyle }), (props.precision || 0) > 0 && props.precision === (slots.length - 1 - i) &&
63
+ _jsx(Text, { style: props.fontStyle, children: "." })] }, `${idRef.current}-${keys[i]}`));
64
+ })] }), !sizesMeasured &&
65
+ Array.from({ length: 10 }, (_, i) => i)
66
+ .concat([',', '.'])
67
+ .map((char, i) => (_jsx(Fragment, { children: _jsx(Text, { style: [styles.hiddenSlot, props.fontStyle], onLayout: (e) => {
68
+ if (i === 10) {
69
+ setSlotHeight(e.nativeEvent.layout.height);
70
+ setCommaWidth(e.nativeEvent.layout.width);
71
+ }
72
+ else if (i === 11) {
73
+ setPeriodWidth(e.nativeEvent.layout.width);
74
+ setSizesMeasured(true);
75
+ }
76
+ else {
77
+ const charSize = e.nativeEvent.layout.width;
78
+ setCharSizes(prev => {
79
+ const newSizes = [...prev];
80
+ newSizes[i] = charSize;
81
+ return newSizes;
82
+ });
83
+ }
84
+ }, children: char }, `slot-${i}`) }, `measure-slot-${i}`)))] }));
85
+ };
86
+ export default Slots;
package/constants.d.ts ADDED
@@ -0,0 +1,3 @@
1
+ export declare const bezier_points: {
2
+ [key: string]: [number, number, number, number];
3
+ };
package/constants.js ADDED
@@ -0,0 +1,5 @@
1
+ export const bezier_points = {
2
+ 'in-out': [.23, 0, .23, .99],
3
+ 'linear': [0, 0, 1, 1],
4
+ 'out': [0, .4, .4, .9]
5
+ };
package/helpers.d.ts CHANGED
@@ -1,3 +1,3 @@
1
1
  import type { SlotValue, CommaPosition } from "./types";
2
- export declare const getNewSlots: (newValue: number, currentSlots: SlotValue[], parseFromLeft: boolean) => SlotValue[];
3
- export declare const getNewCommaPositions: (newValue: string, currentCommaPositions: CommaPosition[], parseFromLeft: boolean) => CommaPosition[];
2
+ export declare const getNewSlots: (newValue: string, currentSlots: SlotValue[], parseFromLeft: boolean) => SlotValue[];
3
+ export declare const getNewCommaPositions: (newValue: string, currentCommaPositions: CommaPosition[], parseFromLeft: boolean, precision?: number) => CommaPosition[];
package/helpers.js CHANGED
@@ -1,11 +1,11 @@
1
1
  export const getNewSlots = (newValue, currentSlots, parseFromLeft) => {
2
- const iterLength = Math.max(newValue.toString().length, currentSlots.length);
2
+ const iterLength = Math.max(newValue.length, currentSlots.length);
3
3
  const shiftCurrent = parseFromLeft
4
4
  ? 0
5
- : newValue.toString().length > currentSlots.length ? currentSlots.length - newValue.toString().length : 0;
5
+ : newValue.length > currentSlots.length ? currentSlots.length - newValue.length : 0;
6
6
  const shiftIncoming = parseFromLeft
7
7
  ? 0
8
- : currentSlots.length > newValue.toString().length ? newValue.toString().length - currentSlots.length : 0;
8
+ : currentSlots.length > newValue.length ? newValue.length - currentSlots.length : 0;
9
9
  const newSlots = Array.from({ length: iterLength }, (_, i) => [i]);
10
10
  Array.from({ length: iterLength }, (_, i) => parseFromLeft ? i : iterLength - i - 1).forEach(i => {
11
11
  const newValueParsed = parseInt(`${newValue}`[i + shiftIncoming]);
@@ -35,7 +35,7 @@ export const getNewSlots = (newValue, currentSlots, parseFromLeft) => {
35
35
  };
36
36
  // Takes in a new value and spits out an array of comma positions
37
37
  // for all of the commas being added, removed, or staying the sameM
38
- export const getNewCommaPositions = (newValue, currentCommaPositions, parseFromLeft) => {
38
+ export const getNewCommaPositions = (newValue, currentCommaPositions, parseFromLeft, precision) => {
39
39
  const parseLength = Math.max(newValue.length, currentCommaPositions.length);
40
40
  // On first render, no size change
41
41
  const sizeChange = currentCommaPositions.length === 0
@@ -74,5 +74,5 @@ export const getNewCommaPositions = (newValue, currentCommaPositions, parseFromL
74
74
  newCommaPositions[index] = -1;
75
75
  }
76
76
  }
77
- return newCommaPositions;
77
+ return newCommaPositions.concat(Array.from({ length: precision || 0 }, () => null)).filter((_, i) => i >= (precision || 0));
78
78
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "react-native-slot-text",
3
- "version": "2.2.3",
3
+ "version": "3.1.0",
4
4
  "author": "sc-mitton <scm.mitton@gmail.com> (https://github.com/sc-mitton)",
5
5
  "main": "index.js",
6
6
  "module": "index.ts",
@@ -37,5 +37,10 @@
37
37
  "release": "tsx src/pre-release.ts && cd lib && npm publish --access public --no-git-checks"
38
38
  },
39
39
  "type": "module",
40
- "types": "src/types.d.ts"
40
+ "types": "src/types.d.ts",
41
+ "dependencies": {
42
+ "@react-native-masked-view/masked-view": "^0.3.2",
43
+ "expo-linear-gradient": "^14.0.1",
44
+ "react-native-easing-gradient": "^1.1.1"
45
+ }
41
46
  }
package/styles.d.ts CHANGED
@@ -14,5 +14,11 @@ declare const styles: {
14
14
  position: "absolute";
15
15
  opacity: number;
16
16
  };
17
+ mask: {
18
+ position: "absolute";
19
+ left: number;
20
+ right: number;
21
+ width: "100%";
22
+ };
17
23
  };
18
24
  export default styles;
package/styles.js CHANGED
@@ -5,7 +5,7 @@ const styles = StyleSheet.create({
5
5
  },
6
6
  slotsContainer: {
7
7
  flexDirection: 'row',
8
- justifyContent: 'center',
8
+ justifyContent: 'center'
9
9
  },
10
10
  slotContainer: {
11
11
  flexDirection: 'row',
@@ -13,7 +13,13 @@ const styles = StyleSheet.create({
13
13
  },
14
14
  hiddenSlot: {
15
15
  position: 'absolute',
16
- opacity: 0,
16
+ opacity: 0
17
+ },
18
+ mask: {
19
+ position: 'absolute',
20
+ left: 0,
21
+ right: 0,
22
+ width: '100%'
17
23
  }
18
24
  });
19
25
  export default styles;
package/types.d.ts CHANGED
@@ -2,17 +2,25 @@ import type { StyleProp, TextStyle } from 'react-native';
2
2
  import type { SharedValue } from 'react-native-reanimated';
3
3
  export type Position = -1 | 0 | 1;
4
4
  export type SlotValue = [number] | [number | null, number | null];
5
+ type EasingT = 'linear' | 'in-out' | 'out';
5
6
  export interface SlotProps {
6
7
  slot: SlotValue;
7
8
  index: number;
9
+ easing?: EasingT;
8
10
  animationDuration: number;
9
11
  fontStyle?: StyleProp<TextStyle>;
10
12
  commaWidth: number;
13
+ periodWidth: number;
11
14
  onCompleted?: () => void;
12
15
  commaPositions?: CommaPosition[];
13
16
  charSizes: number[];
14
17
  height: number;
15
18
  }
19
+ type Shared = 'fontStyle' | 'index' | 'charSizes' | 'animationDuration';
20
+ export interface ContinuousSlotProps extends Pick<SlotProps, Shared> {
21
+ slot: [number | string, string];
22
+ easing?: EasingT;
23
+ }
16
24
  export type CommaPosition = 1 | -1 | 0 | null;
17
25
  export interface CommaProps {
18
26
  isEntering: boolean;
@@ -26,7 +34,11 @@ export interface CommaProps {
26
34
  export interface AnimatedNumbersProps {
27
35
  value: number;
28
36
  fontStyle?: StyleProp<TextStyle>;
29
- animationDuration?: number;
30
37
  prefix?: string;
31
38
  includeComma?: boolean;
39
+ precision?: number;
40
+ animationDuration?: number;
41
+ animateIntermediateValues?: true;
42
+ easing?: EasingT;
32
43
  }
44
+ export {};