import { useCallback, useState } from 'react'; import { type LayoutChangeEvent, type StyleProp, View, type ViewStyle } from 'react-native'; import { usePageVisible } from '../../../hooks/use-page-visible'; import { useReducedMotion } from '../../../hooks/use-reduced-motion'; import { cn } from '../../../lib/cn'; import { EASE_IN_OUT } from '../../../lib/ease'; import { MotiView } from '../../../moti/components/view'; import { AnimatePresence } from '../../../moti/presence/animate-presence'; import { Text, type TextWeight } from '../Text/text'; // Spring enter mirrors the reference (soft, slightly heavy feel). const ENTER_TRANSITION = { type: 'spring', damping: 32, stiffness: 160, mass: 0.75 } as const; // Timing exit: a clean, quick fade-slide out. const EXIT_TRANSITION = { type: 'timing', duration: 250, easing: EASE_IN_OUT } as const; // Fallback roll distance before the slot has been measured (px). const ROLL_FALLBACK = 32; export type TextRollingDirection = /** New label enters from below; old exits to the top. Default. */ | 'forward' /** New label enters from above; old exits to the bottom. */ | 'backward'; export type TextRollingProps = { /** Current text. Changing it rolls the whole label to the new value. */ text: string; /** * Rolling direction. `'forward'` (default) — new text enters from below. * `'backward'` — new text enters from above. */ direction?: TextRollingDirection; /** Text styling (size/colour). */ className?: string; /** Font weight (resolves a per-weight font token). */ weight?: TextWeight; style?: StyleProp; accessibilityLabel?: string; testID?: string; /** Reports the label's natural height (e.g. to size an animated slot). */ onLayout?: (event: LayoutChangeEvent) => void; }; /** * Rolls the whole text label as a single block when `text` changes — the old * value slides out and the new value slides in from the opposite side. * * A hidden sizer `Text` establishes the clip height; the visible label sits * absolutely on top of it. `AnimatePresence initial={false}` skips the enter * animation on the first mount so the text appears instantly. * * Use this when you want the full string to move together (e.g. a status * label cycling through states). For a character-by-character cascade see * `TextCascade`; for digit-by-digit rolling numbers see `TextNumberTicker`. */ export function TextRolling({ text, direction = 'forward', className, weight, accessibilityLabel, ...props }: TextRollingProps) { const reduce = useReducedMotion(); const pageVisible = usePageVisible(); const [rollHeight, setRollHeight] = useState(0); const onLayout = useCallback( (e: LayoutChangeEvent) => { const height = e.nativeEvent.layout.height; if (height && height !== rollHeight) setRollHeight(height); }, [rollHeight], ); // Reduced motion or hidden page: render the label statically. Background tabs // would queue then replay all queued swaps on return — settled label avoids that. if (reduce || !pageVisible) return ( {text} ); const roll = rollHeight || ROLL_FALLBACK; // 'forward': enters from below (+roll), exits to top (-roll). // 'backward': enters from above (-roll), exits to bottom (+roll). const enterY = direction === 'backward' ? -roll : roll; const exitY = direction === 'backward' ? roll : -roll; return ( {/* Hidden sizer keeps the slot at the correct line height. Always renders the current text so the clip region tracks text size changes. */} {text} {/* initial={false}: skip enter animation on first mount — text appears instantly. Subsequent key changes animate normally. */} {text} ); }