react-native-slot-text 2.2.2 → 3.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AnimatedNumbers.d.ts +6 -1
- package/AnimatedNumbers.js +33 -78
- package/ContinuousSlot.d.ts +3 -0
- package/ContinuousSlot.js +113 -0
- package/ContinuousSlots.d.ts +3 -0
- package/ContinuousSlots.js +72 -0
- package/README.md +12 -8
- package/Slot.js +7 -7
- package/Slots.d.ts +3 -0
- package/Slots.js +86 -0
- package/helpers.d.ts +2 -2
- package/helpers.js +5 -5
- package/package.json +7 -2
- package/styles.d.ts +6 -0
- package/styles.js +8 -2
- package/types.d.ts +9 -1
package/AnimatedNumbers.d.ts
CHANGED
@@ -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=
|
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
|
*/
|
package/AnimatedNumbers.js
CHANGED
@@ -1,11 +1,7 @@
|
|
1
|
-
import { jsx as _jsx,
|
2
|
-
import
|
3
|
-
import
|
4
|
-
import
|
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=
|
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
|
-
|
26
|
-
|
27
|
-
|
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,113 @@
|
|
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 } from 'react-native-reanimated';
|
7
|
+
import { LinearGradient } from "expo-linear-gradient";
|
8
|
+
import styles from './styles';
|
9
|
+
const GAP = 12;
|
10
|
+
const PADDING_FRACTION = .25;
|
11
|
+
const AnimatedMaskView = Animated.createAnimatedComponent(MaskedView);
|
12
|
+
const ContinuousSlot = (props) => {
|
13
|
+
const config = {
|
14
|
+
useNativeDriver: true,
|
15
|
+
duration: props.animationDuration,
|
16
|
+
easing: Easing.bezier(0, .4, .4, .9)
|
17
|
+
};
|
18
|
+
const id = useRef(`rn-slottext-slot-${Math.random().toString(36).slice(0, 9)}`);
|
19
|
+
const width = useSharedValue(0);
|
20
|
+
const height = useSharedValue(0);
|
21
|
+
const y = useAnimatedValue(0);
|
22
|
+
const slotOpacity = useAnimatedValue(0);
|
23
|
+
const periodY = useAnimatedValue(0);
|
24
|
+
const commaY = useAnimatedValue(0);
|
25
|
+
const periodOpacity = useAnimatedValue(0);
|
26
|
+
const commaOpacity = useAnimatedValue(0);
|
27
|
+
const maskTop = useAnimatedValue(0);
|
28
|
+
const measuredHeight = useRef(0);
|
29
|
+
const [isMeasured, setIsMeasured] = useState(false);
|
30
|
+
useEffect(() => {
|
31
|
+
if (!isMeasured)
|
32
|
+
return;
|
33
|
+
const newWidth = props.charSizes[typeof props.slot[0] === 'number'
|
34
|
+
? props.slot[0]
|
35
|
+
: props.slot[0] === ',' ? 10 : 11];
|
36
|
+
width.value = newWidth > width.value
|
37
|
+
? newWidth // Growing
|
38
|
+
: withTiming(newWidth, { duration: props.animationDuration / 4 });
|
39
|
+
if (typeof props.slot[0] === 'number') {
|
40
|
+
Animated.timing(slotOpacity, { toValue: 1, ...config }).start();
|
41
|
+
Animated.timing(y, {
|
42
|
+
toValue: -1 * (((GAP + measuredHeight.current) * (9 - props.slot[0])) -
|
43
|
+
(PADDING_FRACTION * measuredHeight.current)),
|
44
|
+
...config
|
45
|
+
}).start();
|
46
|
+
Animated.timing(commaY, { toValue: 0, ...config }).start();
|
47
|
+
Animated.timing(periodY, { toValue: 0, ...config }).start();
|
48
|
+
Animated.timing(commaOpacity, { toValue: 0, ...config }).start();
|
49
|
+
Animated.timing(periodOpacity, { toValue: 0, ...config }).start();
|
50
|
+
}
|
51
|
+
else if (props.slot[0] === '.') {
|
52
|
+
Animated.timing(periodY, { toValue: (PADDING_FRACTION * measuredHeight.current), ...config }).start();
|
53
|
+
Animated.timing(periodOpacity, { toValue: 1, ...config }).start();
|
54
|
+
Animated.timing(slotOpacity, { toValue: 0, ...config }).start();
|
55
|
+
Animated.timing(commaY, { toValue: 0, ...config }).start();
|
56
|
+
}
|
57
|
+
else if (props.slot[0] === ',') {
|
58
|
+
Animated.timing(commaY, { toValue: (PADDING_FRACTION * measuredHeight.current), ...config }).start();
|
59
|
+
Animated.timing(commaOpacity, { toValue: 1, ...config }).start();
|
60
|
+
Animated.timing(periodY, { toValue: 1, ...config }).start();
|
61
|
+
Animated.timing(slotOpacity, { toValue: 0, ...config }).start();
|
62
|
+
}
|
63
|
+
}, [props.slot]);
|
64
|
+
// LINEAR GRADIENT
|
65
|
+
const { colors, locations } = easeGradient({
|
66
|
+
colorStops: {
|
67
|
+
0: { color: 'transparent' },
|
68
|
+
0.1: { color: 'black' },
|
69
|
+
.9: { color: 'black' },
|
70
|
+
1: { color: 'transparent' }
|
71
|
+
},
|
72
|
+
});
|
73
|
+
return (_jsx(ReAnimated.View, { layout: LinearTransition.duration(props.animationDuration / 4), entering: props.charSizes[0] ? FadeInUp.duration(props.animationDuration / 2) : undefined, exiting: FadeOutDown.duration(props.animationDuration / 2), children: isMeasured
|
74
|
+
?
|
75
|
+
_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: {
|
76
|
+
position: 'absolute',
|
77
|
+
opacity: slotOpacity,
|
78
|
+
gap: GAP,
|
79
|
+
transform: [{ translateY: y }]
|
80
|
+
}, children: Array.from({ length: 10 }, (_, i) => (9 - i))
|
81
|
+
.concat([',', '.'])
|
82
|
+
.map(i => (_jsx(Text, { style: props.fontStyle, children: i }, `${id}-${i}`))) }), _jsx(Animated.View, { style: {
|
83
|
+
opacity: commaOpacity,
|
84
|
+
transform: [{ translateY: commaY }],
|
85
|
+
position: 'absolute'
|
86
|
+
}, children: _jsx(Text, { style: props.fontStyle, children: "," }) }), _jsx(Animated.View, { style: {
|
87
|
+
opacity: periodOpacity,
|
88
|
+
transform: [{ translateY: periodY }],
|
89
|
+
position: 'absolute'
|
90
|
+
}, children: _jsx(Text, { style: props.fontStyle, children: "." }) })] }) })
|
91
|
+
:
|
92
|
+
_jsx(Text, { style: props.fontStyle, onLayout: ({ nativeEvent: ne }) => {
|
93
|
+
width.value = ne.layout.width;
|
94
|
+
height.value = ne.layout.height;
|
95
|
+
maskTop.setValue(-1 * (PADDING_FRACTION * ne.layout.height));
|
96
|
+
measuredHeight.current = ne.layout.height;
|
97
|
+
if (typeof props.slot[0] === 'number') {
|
98
|
+
y.setValue(-1 * (((GAP + ne.layout.height) * (9 - props.slot[0])) -
|
99
|
+
(PADDING_FRACTION * ne.layout.height)));
|
100
|
+
slotOpacity.setValue(1);
|
101
|
+
}
|
102
|
+
else if (props.slot[0] === ',') {
|
103
|
+
commaY.setValue(PADDING_FRACTION * ne.layout.height);
|
104
|
+
commaOpacity.setValue(1);
|
105
|
+
}
|
106
|
+
else if (props.slot[0] === '.') {
|
107
|
+
periodY.setValue(PADDING_FRACTION * ne.layout.height);
|
108
|
+
periodOpacity.setValue(1);
|
109
|
+
}
|
110
|
+
setIsMeasured(true);
|
111
|
+
}, children: props.slot[0] }) }));
|
112
|
+
};
|
113
|
+
export default ContinuousSlot;
|
@@ -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, 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/
|
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
|
32
|
-
|
33
|
-
| `value`
|
34
|
-
| `fontStyle`
|
35
|
-
| `animationDuration`
|
36
|
-
| `prefix`
|
37
|
-
| `includeComma`
|
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,7 +4,7 @@ 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
|
7
|
+
const easing = Easing.bezier(0, 0.4, 0.4, .8);
|
8
8
|
const Slot = (props) => {
|
9
9
|
const margin = useSharedValue(0);
|
10
10
|
const y = useSharedValue(0);
|
@@ -82,26 +82,26 @@ const Slot = (props) => {
|
|
82
82
|
const currentStyle = useAnimatedStyle(() => ({
|
83
83
|
transform: [
|
84
84
|
{ translateY: y.value },
|
85
|
-
{ scale: interpolate(y.value, [-1 * props.height, 0, props.height], [
|
85
|
+
{ scale: interpolate(y.value, [-1 * props.height, 0, props.height], [.25, 1, .25]) }
|
86
86
|
],
|
87
87
|
marginRight: margin.value,
|
88
|
-
opacity: interpolate(y.value, [-1 *
|
88
|
+
opacity: interpolate(y.value, [-1 * props.height / 2, 0, 1 * props.height / 2], [0, 1, 0]),
|
89
89
|
}));
|
90
90
|
const incomingStyle = useAnimatedStyle(() => {
|
91
91
|
return ({
|
92
92
|
transform: [
|
93
93
|
{ translateY: incomingY.value },
|
94
|
-
{ scale: interpolate(incomingY.value, [-1 * props.height, 0, props.height], [
|
94
|
+
{ scale: interpolate(incomingY.value, [-1 * props.height, 0, props.height], [.25, 1, .25]) },
|
95
95
|
],
|
96
|
-
opacity: interpolate(incomingY.value, [-1 *
|
96
|
+
opacity: interpolate(incomingY.value, [-1 * props.height / 2, 0, 1 * props.height / 2], [0, 1, 0]),
|
97
97
|
position: 'absolute'
|
98
98
|
});
|
99
99
|
});
|
100
100
|
const incomingTextStyle = useAnimatedStyle(() => ({
|
101
|
-
transform: [{ rotateX: `${interpolate(incomingY.value, [-1 * props.height / 2, 0, props.height / 2], [
|
101
|
+
transform: [{ rotateX: `${interpolate(incomingY.value, [-1 * props.height / 2, 0, props.height / 2], [45, 0, -45])}deg` }]
|
102
102
|
}));
|
103
103
|
const currentTextStyle = useAnimatedStyle(() => ({
|
104
|
-
transform: [{ rotateX: `${interpolate(y.value, [-1 * props.height / 2, 0, props.height / 2], [
|
104
|
+
transform: [{ rotateX: `${interpolate(y.value, [-1 * props.height / 2, 0, props.height / 2], [45, 0, -45])}deg` }]
|
105
105
|
}));
|
106
106
|
return (_jsxs(View, { style: styles.slotContainer, children: [_jsx(Animated.View, { style: currentStyle, children: Number.isFinite(props.slot[0]) &&
|
107
107
|
_jsx(Animated.Text, { style: [props.fontStyle, currentTextStyle], children: props.slot[0] }) }), _jsx(Animated.View, { style: incomingStyle, children: Number.isFinite(props.slot[1]) &&
|
package/Slots.d.ts
ADDED
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, 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/helpers.d.ts
CHANGED
@@ -1,3 +1,3 @@
|
|
1
1
|
import type { SlotValue, CommaPosition } from "./types";
|
2
|
-
export declare const getNewSlots: (newValue:
|
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.
|
2
|
+
const iterLength = Math.max(newValue.length, currentSlots.length);
|
3
3
|
const shiftCurrent = parseFromLeft
|
4
4
|
? 0
|
5
|
-
: newValue.
|
5
|
+
: newValue.length > currentSlots.length ? currentSlots.length - newValue.length : 0;
|
6
6
|
const shiftIncoming = parseFromLeft
|
7
7
|
? 0
|
8
|
-
: currentSlots.length > newValue.
|
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": "
|
3
|
+
"version": "3.0.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
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
@@ -8,11 +8,16 @@ export interface SlotProps {
|
|
8
8
|
animationDuration: number;
|
9
9
|
fontStyle?: StyleProp<TextStyle>;
|
10
10
|
commaWidth: number;
|
11
|
+
periodWidth: number;
|
11
12
|
onCompleted?: () => void;
|
12
13
|
commaPositions?: CommaPosition[];
|
13
14
|
charSizes: number[];
|
14
15
|
height: number;
|
15
16
|
}
|
17
|
+
type Shared = 'fontStyle' | 'index' | 'charSizes' | 'animationDuration';
|
18
|
+
export interface ContinuousSlotProps extends Pick<SlotProps, Shared> {
|
19
|
+
slot: [number | string, string];
|
20
|
+
}
|
16
21
|
export type CommaPosition = 1 | -1 | 0 | null;
|
17
22
|
export interface CommaProps {
|
18
23
|
isEntering: boolean;
|
@@ -26,7 +31,10 @@ export interface CommaProps {
|
|
26
31
|
export interface AnimatedNumbersProps {
|
27
32
|
value: number;
|
28
33
|
fontStyle?: StyleProp<TextStyle>;
|
29
|
-
animationDuration?: number;
|
30
34
|
prefix?: string;
|
31
35
|
includeComma?: boolean;
|
36
|
+
precision?: number;
|
37
|
+
animationDuration?: number;
|
38
|
+
animateIntermediateValues?: true;
|
32
39
|
}
|
40
|
+
export {};
|