2026-03-20 19:28:42 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 可滑动的消息气泡容器
|
|
|
|
|
|
* 实现滑动回复手势功能 - 严格单向滑动,无抖动
|
|
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
|
|
import React, { useRef, useCallback } from 'react';
|
|
|
|
|
|
import { View, StyleSheet, Animated } from 'react-native';
|
|
|
|
|
|
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
|
|
|
|
|
import { PanGestureHandler, State } from 'react-native-gesture-handler';
|
|
|
|
|
|
import * as Haptics from 'expo-haptics';
|
2026-03-25 05:16:54 +08:00
|
|
|
|
import { useAppColors } from '../../../../theme';
|
2026-03-20 19:28:42 +08:00
|
|
|
|
|
|
|
|
|
|
// 滑动阈值
|
|
|
|
|
|
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<SwipeableMessageBubbleProps> = ({
|
|
|
|
|
|
children,
|
|
|
|
|
|
isMe,
|
|
|
|
|
|
onReply,
|
|
|
|
|
|
enabled = true,
|
|
|
|
|
|
}) => {
|
2026-03-25 05:16:54 +08:00
|
|
|
|
const colors = useAppColors();
|
2026-03-20 19:28:42 +08:00
|
|
|
|
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;
|
|
|
|
|
|
|
|
|
|
|
|
// 触觉反馈
|
|
|
|
|
|
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',
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// 如果禁用手势,直接返回子元素
|
|
|
|
|
|
if (!enabled) {
|
|
|
|
|
|
return <>{children}</>;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<View style={styles.container}>
|
|
|
|
|
|
{/* 回复图标 - 在消息后面 */}
|
|
|
|
|
|
<Animated.View
|
|
|
|
|
|
style={[
|
|
|
|
|
|
styles.replyIconContainer,
|
|
|
|
|
|
isMe ? styles.replyIconLeft : styles.replyIconRight,
|
|
|
|
|
|
{
|
|
|
|
|
|
opacity: iconOpacity,
|
|
|
|
|
|
},
|
|
|
|
|
|
]}
|
|
|
|
|
|
>
|
|
|
|
|
|
<MaterialCommunityIcons
|
|
|
|
|
|
name="reply"
|
|
|
|
|
|
size={20}
|
|
|
|
|
|
color={colors.primary.main}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</Animated.View>
|
|
|
|
|
|
|
|
|
|
|
|
{/* 可滑动的消息内容 */}
|
|
|
|
|
|
<PanGestureHandler
|
|
|
|
|
|
onGestureEvent={onGestureEvent}
|
|
|
|
|
|
onHandlerStateChange={onHandlerStateChange}
|
|
|
|
|
|
activeOffsetX={isMe ? [-10, 9999] : [-9999, 10]}
|
|
|
|
|
|
failOffsetY={[-20, 20]}
|
|
|
|
|
|
>
|
|
|
|
|
|
<Animated.View
|
|
|
|
|
|
style={{
|
|
|
|
|
|
transform: [{ translateX: clampedTranslateX }],
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
|
|
|
|
|
{children}
|
|
|
|
|
|
</Animated.View>
|
|
|
|
|
|
</PanGestureHandler>
|
|
|
|
|
|
</View>
|
|
|
|
|
|
);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
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;
|