Files
mindfulness/client/components/ui/SheetModal.tsx

207 lines
5.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 { useTranslation } from 'react-i18next';
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 { t } = useTranslation();
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 (
<Modal transparent visible={mounted} animationType="none" onRequestClose={onClose}>
<View style={styles.root}>
<Pressable
style={StyleSheet.absoluteFill}
onPress={onClose}
>
<Animated.View style={[styles.overlay, overlayStyle]} />
</Pressable>
<Animated.View
{...panResponder.panHandlers}
style={[
styles.sheet,
sheetStyle,
{
height: sheetHeight,
paddingBottom: containerPaddingBottom,
},
]}
>
<View style={styles.handleContainer}>
<View style={styles.handle} />
</View>
<View style={styles.header}>
<Text style={styles.title} numberOfLines={1}>
{title ?? ''}
</Text>
<Pressable
accessibilityRole="button"
accessibilityLabel={leftIcon ? t('common.back') : t('common.close')}
onPress={onClose}
hitSlop={10}
style={styles.close}
>
{leftIcon ? (
<Image source={leftIcon} style={styles.backIcon} />
) : (
<Text style={styles.closeText}>×</Text>
)}
</Pressable>
</View>
<View style={styles.body}>{children}</View>
</Animated.View>
</View>
</Modal>
);
}
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,
},
});