144 lines
3.6 KiB
TypeScript
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,
|
|
},
|
|
});
|