import React, { useEffect, useMemo, useState, useRef } from 'react'; import { Modal, Pressable, StyleSheet, Text, View, PanResponder, Animated as RNAnimated, Dimensions, Image, ImageSourcePropType } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import Animated, { Easing, runOnJS, useAnimatedStyle, useSharedValue, withTiming, } from 'react-native-reanimated'; const { height: SCREEN_HEIGHT } = Dimensions.get('window'); const FIXED_TOP_GAP = 100; // 统一距离顶部的高度 type Props = { visible: boolean; title?: string; onClose: () => void; children: React.ReactNode; leftIcon?: ImageSourcePropType; // 新增:支持自定义左侧图标 height?: number; // 新增:支持自定义高度 }; /** * 通用底部上拉弹窗(Sheet) * - 内容区背景色固定:#FAF3EC * - 高度固定:默认距离顶部固定间距,也支持传入指定高度 */ export default function SheetModal({ visible, title, onClose, children, leftIcon, height: customHeight }: Props) { const insets = useSafeAreaInsets(); const [mounted, setMounted] = useState(false); const progress = useSharedValue(0); // 0: 关闭, 1: 打开 const dragY = useSharedValue(0); // 拖拽位移 const sheetHeight = customHeight || (SCREEN_HEIGHT - FIXED_TOP_GAP); useEffect(() => { if (visible) { setMounted(true); dragY.value = 0; progress.value = withTiming(1, { duration: 260, easing: Easing.out(Easing.cubic) }); return; } if (!mounted) return; progress.value = withTiming( 0, { duration: 220, easing: Easing.in(Easing.cubic) }, (finished) => { if (finished) runOnJS(setMounted)(false); } ); }, [visible, mounted, progress, dragY]); // 使用 PanResponder 处理下滑手势 const panResponder = useRef( PanResponder.create({ onStartShouldSetPanResponder: () => false, onMoveShouldSetPanResponder: () => false, // 暂时屏蔽下拉关闭手势,解决滑动冲突 onPanResponderMove: (_, gestureState) => { if (gestureState.dy > 0) { dragY.value = gestureState.dy; } }, onPanResponderRelease: (_, gestureState) => { if (gestureState.dy > 80 || gestureState.vy > 0.5) { runOnJS(onClose)(); } else { dragY.value = withTiming(0, { duration: 300, easing: Easing.out(Easing.back(1)) }); } }, onPanResponderTerminate: () => { dragY.value = withTiming(0, { duration: 200 }); }, }) ).current; const overlayStyle = useAnimatedStyle(() => { return { opacity: 0.4 * progress.value }; }); const sheetStyle = useAnimatedStyle(() => { const baseTranslateY = (1 - progress.value) * sheetHeight; // 基础位移 return { transform: [{ translateY: baseTranslateY + dragY.value }] }; }); const containerPaddingBottom = useMemo(() => Math.max(insets.bottom, 80), [insets.bottom]); // 增加底部间距至 80,约占 350 高度的 22%,确保内容不被截断并留出足够呼吸感 // 注意:Modal 的 visible 必须为 true 才会渲染,因此用 mounted 保持退场动画 return ( {title ?? ''} {leftIcon ? ( ) : ( × )} {children} ); } const styles = StyleSheet.create({ root: { flex: 1, justifyContent: 'flex-end', }, overlay: { ...StyleSheet.absoluteFillObject, backgroundColor: '#000', }, sheet: { backgroundColor: '#FAF3EC', borderTopLeftRadius: 24, borderTopRightRadius: 24, paddingTop: 8, paddingHorizontal: 16, }, handleContainer: { alignItems: 'center', paddingVertical: 8, }, handle: { width: 40, height: 5, borderRadius: 2.5, backgroundColor: 'rgba(94,42,40,0.15)', }, header: { height: 44, justifyContent: 'center', alignItems: 'center', }, title: { color: '#5E2A28', fontSize: 16, fontWeight: '700', }, close: { position: 'absolute', left: 4, top: 0, height: 44, width: 44, alignItems: 'center', justifyContent: 'center', }, closeText: { color: '#5E2A28', fontSize: 28, lineHeight: 28, fontWeight: '400', }, backIcon: { width: 20, height: 20, resizeMode: 'contain', }, body: { paddingTop: 10, flex: 1, }, });