import { useEffect, useState } from 'react'; import { StyleSheet, View, type LayoutChangeEvent, type StyleProp, type TextStyle, type ViewStyle } from 'react-native'; import type { TextLayoutEvent } from 'react-native/Libraries/Types/CoreEventTypes'; import Animated, { Easing, cancelAnimation, useAnimatedStyle, useSharedValue, withDelay, withRepeat, withSequence, withTiming, } from 'react-native-reanimated'; import { Text } from './Text'; type TextVariant = 'title' | 'heading' | 'body' | 'label' | 'caption' | 'mono'; const DEFAULT_DELAY_MS = 900; const DEFAULT_HOLD_MS = 900; const DEFAULT_SPEED_PX_PER_SECOND = 28; const MIN_DURATION_MS = 1600; const MEASURE_WIDTH = 10000; interface MarqueeTextProps { children: string; variant?: TextVariant; color?: string; style?: StyleProp; containerStyle?: StyleProp; delayMs?: number; holdMs?: number; speedPxPerSecond?: number; } export function MarqueeText({ children, variant = 'body', color, style, containerStyle, delayMs = DEFAULT_DELAY_MS, holdMs = DEFAULT_HOLD_MS, speedPxPerSecond = DEFAULT_SPEED_PX_PER_SECOND, }: MarqueeTextProps) { const [containerWidth, setContainerWidth] = useState(0); const [textWidth, setTextWidth] = useState(0); const offset = useSharedValue(0); const overflowDistance = Math.max(0, Math.ceil(textWidth - containerWidth)); useEffect(() => { cancelAnimation(offset); offset.value = 0; if (overflowDistance <= 1) return; const duration = Math.max( MIN_DURATION_MS, Math.round((overflowDistance / speedPxPerSecond) * 1000) ); offset.value = withDelay( delayMs, withRepeat( withSequence( withTiming(-overflowDistance, { duration, easing: Easing.linear }), withDelay(holdMs, withTiming(-overflowDistance, { duration: 0 })), withTiming(0, { duration, easing: Easing.linear }), withDelay(holdMs, withTiming(0, { duration: 0 })) ), -1, false ) ); }, [delayMs, holdMs, offset, overflowDistance, speedPxPerSecond]); const animatedStyle = useAnimatedStyle(() => ({ transform: [{ translateX: offset.value }], })); const handleContainerLayout = (event: LayoutChangeEvent) => { setContainerWidth(event.nativeEvent.layout.width); }; const handleTextLayout = (event: TextLayoutEvent) => { const measuredWidth = Math.ceil(event.nativeEvent.lines[0]?.width ?? 0); setTextWidth((current) => (Math.abs(current - measuredWidth) > 1 ? measuredWidth : current)); }; return ( 0 ? { width: textWidth } : null, animatedStyle]} > {children} {children} ); } const styles = StyleSheet.create({ container: { overflow: 'hidden', }, content: { alignSelf: 'flex-start', }, measure: { position: 'absolute', width: MEASURE_WIDTH, opacity: 0, }, }); export default MarqueeText;