Files
Toolbox/apps/app_zen_box/components/MarqueeText.tsx
T

144 lines
3.6 KiB
TypeScript

import React, { useEffect, useMemo, useRef, useState } from 'react';
import {
Animated,
Easing,
LayoutChangeEvent,
StyleProp,
StyleSheet,
Text,
TextProps,
TextStyle,
View,
ViewStyle,
} from 'react-native';
interface MarqueeTextProps extends Omit<TextProps, 'style'> {
text: string;
enabled?: boolean;
style?: StyleProp<TextStyle>;
containerStyle?: StyleProp<ViewStyle>;
gap?: number;
speed?: number;
initialDelayMs?: number;
}
export const MarqueeText: React.FC<MarqueeTextProps> = ({
text,
enabled = false,
style,
containerStyle,
gap = 40,
speed = 28,
initialDelayMs = 1200,
...textProps
}) => {
const translateX = useRef(new Animated.Value(0)).current;
const animationRef = useRef<Animated.CompositeAnimation | null>(null);
const [containerWidth, setContainerWidth] = useState(0);
const [textWidth, setTextWidth] = useState(0);
const normalizedText = useMemo(() => String(text || ''), [text]);
const shouldAnimate = enabled && containerWidth > 0 && textWidth > containerWidth + 4;
const distance = Math.max(0, textWidth + gap);
const durationMs = Math.max(6000, Math.round((distance / Math.max(speed, 1)) * 1000));
useEffect(() => {
animationRef.current?.stop();
translateX.stopAnimation();
translateX.setValue(0);
if (!shouldAnimate || !distance) {
return () => {
animationRef.current?.stop();
};
}
const sequence = Animated.loop(
Animated.sequence([
Animated.delay(initialDelayMs),
Animated.timing(translateX, {
toValue: -distance,
duration: durationMs,
easing: Easing.linear,
useNativeDriver: true,
}),
Animated.delay(700),
Animated.timing(translateX, {
toValue: 0,
duration: 0,
useNativeDriver: true,
}),
])
);
animationRef.current = sequence;
sequence.start();
return () => {
sequence.stop();
animationRef.current = null;
translateX.stopAnimation();
translateX.setValue(0);
};
}, [distance, durationMs, initialDelayMs, shouldAnimate, translateX]);
const handleContainerLayout = (event: LayoutChangeEvent) => {
const nextWidth = Math.round(event.nativeEvent.layout.width || 0);
if (nextWidth !== containerWidth) {
setContainerWidth(nextWidth);
}
};
const handleTextLayout = (event: LayoutChangeEvent) => {
const nextWidth = Math.round(event.nativeEvent.layout.width || 0);
if (nextWidth !== textWidth) {
setTextWidth(nextWidth);
}
};
return (
<View style={[styles.container, containerStyle]} onLayout={handleContainerLayout}>
<Text
{...textProps}
style={[style, styles.measureText]}
onLayout={handleTextLayout}
numberOfLines={1}
>
{normalizedText}
</Text>
{shouldAnimate ? (
<Animated.View style={[styles.track, { transform: [{ translateX }] }]}>
<Text {...textProps} style={style} numberOfLines={1}>
{normalizedText}
</Text>
<View style={{ width: gap }} />
<Text {...textProps} style={style} numberOfLines={1}>
{normalizedText}
</Text>
</Animated.View>
) : (
<Text {...textProps} style={style} numberOfLines={1} ellipsizeMode="tail">
{normalizedText}
</Text>
)}
</View>
);
};
const styles = StyleSheet.create({
container: {
width: '100%',
overflow: 'hidden',
},
track: {
flexDirection: 'row',
alignItems: 'center',
},
measureText: {
position: 'absolute',
opacity: 0,
left: 0,
top: -9999,
},
});