import { memo, useCallback, useMemo, useState, useEffect, type ComponentProps, } from 'react'; import { View, Text, StyleSheet, type TextStyle, type TextLayoutLine, type NativeSyntheticEvent, type TextLayoutEventData, } from 'react-native'; import Reanimated, { LinearTransition, ReduceMotion, useAnimatedStyle, withDelay, withSpring, } from 'react-native-reanimated'; interface AnimationConfig { /** Enable or disable animations (default: true) */ enabled?: boolean; /** Enable or disable animation on initial mount (default: true) */ animateOnMount?: boolean; /** Delay between each digit animation in ms (default: 20) */ digitDelay?: number; /** Spring animation mass (default: 0.8) */ mass?: number; /** Spring animation stiffness (default: 75) */ stiffness?: number; /** Spring animation damping (default: 15) */ damping?: number; /** Enable/disable reduce motion (default: System) */ reduceMotion?: ReduceMotion; } interface NumberFlowProps { /** The value to be displayed */ value: string; /** Style for the entire counter */ style?: TextStyle; /** Style specifically for non-numeric characters (like commas, currency symbols) */ separatorStyle?: TextStyle; /** Animation configuration */ animationConfig?: AnimationConfig; /** Enable/disable text auto-fitting based on ascender (default: false) */ autoFitText?: boolean; } const DIGITS = [...Array(10).keys()]; const Character = memo( ({ children, style, ...rest }: ComponentProps) => { const charStyle = typeof style === 'object' ? style : {}; return ( {children} ); } ); const CharacterList = memo( ({ number, index, style, animationConfig, initialRender, }: { number: number; index: number; style?: TextStyle; animationConfig: Required< Omit >; initialRender: boolean; }) => { const lineHeight = Math.round(Number(style?.lineHeight || 0)); const { mass, stiffness, damping, reduceMotion } = animationConfig; const targetPosition = -lineHeight * number; const animateStyle = useAnimatedStyle(() => { 'worklet'; if (initialRender) { return { transform: [{ translateY: Math.round(targetPosition) }], }; } return { transform: [ { translateY: withDelay( index, withSpring(targetPosition, { mass, stiffness, damping, reduceMotion, restSpeedThreshold: 0.01, restDisplacementThreshold: 0.01, }), reduceMotion ), }, ], }; }, [ number, index, lineHeight, mass, stiffness, damping, reduceMotion, initialRender, targetPosition, ]); return ( {DIGITS.map((digit, digitIndex) => ( {digit} ))} ); } ); export const NumberFlow = memo( ({ value, style, separatorStyle, animationConfig = {}, autoFitText = false, }: NumberFlowProps) => { const [textLayout, setTextLayout] = useState(); const [isMounted, setIsMounted] = useState(false); const { enabled = true, animateOnMount = true, digitDelay = 20, mass = 0.8, stiffness = 75, damping = 15, reduceMotion = ReduceMotion.System, } = animationConfig; useEffect(() => { const timer = setTimeout(() => { setIsMounted(true); }, 0); return () => clearTimeout(timer); }, []); const shouldAnimate = enabled && (isMounted ? true : animateOnMount); const isInitialRender = !animateOnMount && !isMounted; const fontSize = useMemo(() => { if (autoFitText && textLayout?.ascender) { return Math.round(textLayout.ascender); } return Math.round(Number(style?.fontSize || 16)); }, [autoFitText, textLayout?.ascender, style?.fontSize]); const lineHeight = useMemo(() => { return Math.round(fontSize * 1.2); }, [fontSize]); const charStyle = useMemo(() => { return { ...style, fontSize, lineHeight, height: lineHeight, }; }, [fontSize, lineHeight, style]); const separatorCharStyle = useMemo(() => { return { ...charStyle, ...separatorStyle, }; }, [charStyle, separatorStyle]); const accessibleValue = useMemo(() => { return `Current value is ${value}`; }, [value]); const splitValue = useMemo(() => { return value.split(''); }, [value]); const handleTextLayout = useCallback( (e: NativeSyntheticEvent) => { if (autoFitText) { setTextLayout(e.nativeEvent.lines[0]); } }, [autoFitText] ); return ( <> {autoFitText && ( {value} )} {splitValue.map((char, index) => { if (char === ' ') { return ( {' '} ); } if (!isNaN(Number(char))) { return shouldAnimate ? ( ) : ( {char} ); } return ( {char} ); })} ); } ); const styles = StyleSheet.create({ counterContainer: { flexDirection: 'row', flexWrap: 'wrap', }, hiddenCounter: { position: 'absolute', opacity: 0, color: 'red', }, characterList: { overflow: 'hidden', }, });