Files
mindfulness/client/components/ui/SheetModal.tsx
2026-01-29 19:09:36 +08:00

139 lines
3.4 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 } from 'react';
import { Modal, Pressable, StyleSheet, Text, View } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import Animated, {
Easing,
runOnJS,
useAnimatedStyle,
useSharedValue,
withTiming,
} from 'react-native-reanimated';
type Props = {
visible: boolean;
title?: string;
onClose: () => void;
children: React.ReactNode;
};
/**
* 通用底部上拉弹窗Sheet
* - 内容区背景色固定:#FAF3EC
* - 关闭方式:点 X本期不要求点遮罩关闭
*/
export default function SheetModal({ visible, title, onClose, children }: Props) {
const insets = useSafeAreaInsets();
const [mounted, setMounted] = useState(false);
const progress = useSharedValue(0); // 0: 关闭, 1: 打开
useEffect(() => {
if (visible) {
setMounted(true);
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]);
const overlayStyle = useAnimatedStyle(() => {
return { opacity: 0.4 * progress.value };
});
const sheetStyle = useAnimatedStyle(() => {
const translateY = (1 - progress.value) * 380;
return { transform: [{ translateY }] };
});
const containerPaddingBottom = useMemo(() => Math.max(insets.bottom, 12), [insets.bottom]);
// 注意Modal 的 visible 必须为 true 才会渲染,因此用 mounted 保持退场动画
return (
<Modal transparent visible={mounted} animationType="none" onRequestClose={onClose}>
<View style={styles.root}>
<Animated.View style={[styles.overlay, overlayStyle]} />
<Animated.View
style={[
styles.sheet,
sheetStyle,
{
paddingBottom: containerPaddingBottom,
},
]}
>
<View style={styles.header}>
<Text style={styles.title} numberOfLines={1}>
{title ?? ''}
</Text>
<Pressable
accessibilityRole="button"
accessibilityLabel="关闭"
onPress={onClose}
hitSlop={10}
style={styles.close}
>
<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: 14,
paddingHorizontal: 16,
},
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',
},
body: {
paddingTop: 10,
},
});