/** * AnimatedNumber (native). Same count-up/down contract as web — rAF works * fine in React Native, so the interpolation logic is identical. Renders * an RN Text with fontVariant tabular-nums so digits don't wobble. * * * `$${n.toFixed(2)}`} /> * * Reduced motion (AccessibilityInfo) snaps to the value instantly. */ import { useEffect, useRef, useState } from "react"; import { Text as RNText, type TextStyle } from "react-native"; import { motion } from "@plyxui/core"; import { useReducedMotion } from "@plyxui/hooks"; export interface AnimatedNumberProps { value: number; /** Count duration in ms. Default motion.celebrate (500). */ duration?: number; /** Render the interpolated value. Default rounds to an integer string. */ format?: (n: number) => string; style?: TextStyle; } function defaultFormat(n: number): string { return Math.round(n).toString(); } // easeOutCubic — fast start, gentle landing on the final digit. function easeOut(t: number): number { return 1 - Math.pow(1 - t, 3); } export function AnimatedNumber({ value, duration = motion.celebrate.duration, format = defaultFormat, style, }: AnimatedNumberProps) { const [display, setDisplay] = useState(value); const displayRef = useRef(value); displayRef.current = display; const reduced = useReducedMotion(); useEffect(() => { if (displayRef.current === value) return; if (reduced || duration <= 0) { setDisplay(value); return; } const start = displayRef.current; const delta = value - start; const t0 = Date.now(); let raf = 0; const tick = () => { const t = Math.min((Date.now() - t0) / duration, 1); setDisplay(start + delta * easeOut(t)); if (t < 1) raf = requestAnimationFrame(tick); }; raf = requestAnimationFrame(tick); return () => cancelAnimationFrame(raf); }, [value, duration, reduced]); return {format(display)}; }