/** * 可滑动的消息气泡容器 * 实现滑动回复手势功能 - 严格单向滑动,无抖动 */ import React, { useRef, useCallback } from 'react'; import { View, StyleSheet, Animated, Platform } from 'react-native'; import { MaterialCommunityIcons } from '@expo/vector-icons'; import { PanGestureHandler, State } from 'react-native-gesture-handler'; import * as Haptics from 'expo-haptics'; import { useAppColors } from '../../../../theme'; const SWIPE_THRESHOLD = 30; const MAX_SWIPE_DISTANCE = 50; interface SwipeableMessageBubbleProps { children: React.ReactNode; isMe: boolean; onReply: () => void; enabled?: boolean; } export const SwipeableMessageBubble: React.FC = ({ children, isMe, onReply, enabled = true, }) => { const colors = useAppColors(); const translateX = useRef(new Animated.Value(0)).current; const hasTriggeredRef = useRef(false); const onGestureEvent = Animated.event( [{ nativeEvent: { translationX: translateX } }], { useNativeDriver: true } ); const onHandlerStateChange = useCallback((event: any) => { const { nativeEvent } = event; if (nativeEvent.state === State.END) { const translationX = nativeEvent.translationX; const shouldTrigger = isMe ? translationX < -SWIPE_THRESHOLD : translationX > SWIPE_THRESHOLD; if (shouldTrigger && !hasTriggeredRef.current) { hasTriggeredRef.current = true; if (Platform.OS !== 'web') { Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); } onReply(); setTimeout(() => { hasTriggeredRef.current = false; }, 300); } translateX.setValue(0); } }, [isMe, onReply]); const iconOpacity = translateX.interpolate({ inputRange: isMe ? [-MAX_SWIPE_DISTANCE, -SWIPE_THRESHOLD, 0, 10] : [-10, 0, SWIPE_THRESHOLD, MAX_SWIPE_DISTANCE], outputRange: isMe ? [1, 0.8, 0, 0] : [0, 0, 0.8, 1], extrapolate: 'clamp', }); const clampedTranslateX = translateX.interpolate({ inputRange: isMe ? [-MAX_SWIPE_DISTANCE - 10, -MAX_SWIPE_DISTANCE, 0, 10] : [-10, 0, MAX_SWIPE_DISTANCE, MAX_SWIPE_DISTANCE + 10], outputRange: isMe ? [-MAX_SWIPE_DISTANCE, -MAX_SWIPE_DISTANCE, 0, 0] : [0, 0, MAX_SWIPE_DISTANCE, MAX_SWIPE_DISTANCE], extrapolate: 'clamp', }); const webTouchStartRef = useRef<{ x: number; time: number } | null>(null); const webTranslateXRef = useRef(new Animated.Value(0)).current; const webIconOpacity = webTranslateXRef.interpolate({ inputRange: isMe ? [-MAX_SWIPE_DISTANCE, -SWIPE_THRESHOLD, 0, 10] : [-10, 0, SWIPE_THRESHOLD, MAX_SWIPE_DISTANCE], outputRange: isMe ? [1, 0.8, 0, 0] : [0, 0, 0.8, 1], extrapolate: 'clamp', }); const webClampedTranslateX = webTranslateXRef.interpolate({ inputRange: isMe ? [-MAX_SWIPE_DISTANCE - 10, -MAX_SWIPE_DISTANCE, 0, 10] : [-10, 0, MAX_SWIPE_DISTANCE, MAX_SWIPE_DISTANCE + 10], outputRange: isMe ? [-MAX_SWIPE_DISTANCE, -MAX_SWIPE_DISTANCE, 0, 0] : [0, 0, MAX_SWIPE_DISTANCE, MAX_SWIPE_DISTANCE], extrapolate: 'clamp', }); const webLastTouchRef = useRef<{ x: number } | null>(null); const handleWebTouchStart = useCallback((e: any) => { const touch = e.nativeEvent?.touches?.[0] || e.nativeEvent; if (touch && touch.pageX !== undefined) { webTouchStartRef.current = { x: touch.pageX, time: Date.now() }; webLastTouchRef.current = { x: touch.pageX }; } }, []); const handleWebTouchMove = useCallback((e: any) => { const start = webTouchStartRef.current; if (!start) return; const touch = e.nativeEvent?.touches?.[0] || e.nativeEvent; if (!touch || touch.pageX === undefined) return; const dx = touch.pageX - start.x; const allowed = isMe ? dx < 5 : dx > -5; if (allowed) { webTranslateXRef.setValue(dx); webLastTouchRef.current = { x: touch.pageX }; } }, [isMe]); const handleWebTouchEnd = useCallback((e: any) => { const start = webTouchStartRef.current; if (!start) return; webTouchStartRef.current = null; const touch = e.nativeEvent?.changedTouches?.[0] || e.nativeEvent; if (!touch || touch.pageX === undefined) { Animated.spring(webTranslateXRef, { toValue: 0, useNativeDriver: true }).start(); return; } const dx = touch.pageX - start.x; const shouldTrigger = isMe ? dx < -SWIPE_THRESHOLD : dx > SWIPE_THRESHOLD; if (shouldTrigger && !hasTriggeredRef.current) { hasTriggeredRef.current = true; onReply(); setTimeout(() => { hasTriggeredRef.current = false; }, 300); } Animated.spring(webTranslateXRef, { toValue: 0, useNativeDriver: true }).start(); }, [isMe, onReply]); if (!enabled) { return <>{children}; } if (Platform.OS === 'web') { return ( {children} ); } return ( {children} ); }; const styles = StyleSheet.create({ container: { position: 'relative', }, replyIconContainer: { position: 'absolute', top: '50%', marginTop: -15, width: 30, height: 30, borderRadius: 15, backgroundColor: 'rgba(255, 107, 53, 0.1)', alignItems: 'center', justifyContent: 'center', }, replyIconLeft: { left: 8, }, replyIconRight: { right: 8, }, }); export default SwipeableMessageBubble;