Files
digital-pilates/components/ui/ActionSheet.tsx
2025-08-16 14:15:11 +08:00

317 lines
7.5 KiB
TypeScript

import { Ionicons } from '@expo/vector-icons';
import * as Haptics from 'expo-haptics';
import React, { useEffect, useRef } from 'react';
import {
Animated,
Dimensions,
Modal,
StyleSheet,
Text,
TouchableOpacity,
View,
} from 'react-native';
const { height: screenHeight } = Dimensions.get('window');
export interface ActionSheetOption {
id: string;
title: string;
subtitle?: string;
icon?: keyof typeof Ionicons.glyphMap;
iconColor?: string;
destructive?: boolean;
onPress: () => void;
}
interface ActionSheetProps {
visible: boolean;
onClose: () => void;
title?: string;
subtitle?: string;
options: ActionSheetOption[];
cancelText?: string;
}
export function ActionSheet({
visible,
onClose,
title,
subtitle,
options,
cancelText = '取消'
}: ActionSheetProps) {
const slideAnim = useRef(new Animated.Value(screenHeight)).current;
const opacityAnim = useRef(new Animated.Value(0)).current;
useEffect(() => {
if (visible) {
// 显示动画
Animated.parallel([
Animated.timing(slideAnim, {
toValue: 0,
duration: 300,
useNativeDriver: true,
}),
Animated.timing(opacityAnim, {
toValue: 1,
duration: 200,
useNativeDriver: true,
}),
]).start();
} else {
// 隐藏动画
Animated.parallel([
Animated.timing(slideAnim, {
toValue: screenHeight,
duration: 250,
useNativeDriver: true,
}),
Animated.timing(opacityAnim, {
toValue: 0,
duration: 150,
useNativeDriver: true,
}),
]).start();
}
}, [visible]);
const handleOptionPress = (option: ActionSheetOption) => {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
onClose();
// 延迟执行选项回调,让关闭动画先完成
setTimeout(() => {
option.onPress();
}, 100);
};
const handleCancel = () => {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
onClose();
};
if (!visible) return null;
return (
<Modal
visible={visible}
transparent
animationType="none"
onRequestClose={onClose}
statusBarTranslucent
>
<View style={styles.container}>
{/* 背景遮罩 */}
<Animated.View
style={[
styles.backdrop,
{
opacity: opacityAnim,
},
]}
>
<TouchableOpacity
style={StyleSheet.absoluteFillObject}
activeOpacity={1}
onPress={handleCancel}
/>
</Animated.View>
{/* 弹窗内容 */}
<Animated.View
style={[
styles.sheet,
{
transform: [{ translateY: slideAnim }],
},
]}
>
{/* 拖拽指示器 */}
<View style={styles.dragIndicator} />
{/* 标题区域 */}
{(title || subtitle) && (
<View style={styles.header}>
{title && <Text style={styles.title}>{title}</Text>}
{subtitle && <Text style={styles.subtitle}>{subtitle}</Text>}
</View>
)}
{/* 选项列表 */}
<View style={styles.optionsContainer}>
{options.map((option, index) => (
<TouchableOpacity
key={option.id}
style={[
styles.option,
index === 0 && styles.firstOption,
index === options.length - 1 && styles.lastOption,
]}
onPress={() => handleOptionPress(option)}
activeOpacity={0.7}
>
<View style={styles.optionContent}>
{option.icon && (
<View style={styles.iconContainer}>
<Ionicons
name={option.icon}
size={20}
color={option.iconColor || (option.destructive ? '#EF4444' : '#374151')}
/>
</View>
)}
<View style={styles.textContainer}>
<Text
style={[
styles.optionTitle,
option.destructive && styles.destructiveText,
]}
>
{option.title}
</Text>
{option.subtitle && (
<Text style={styles.optionSubtitle}>{option.subtitle}</Text>
)}
</View>
</View>
<Ionicons name="chevron-forward" size={16} color="#9CA3AF" />
</TouchableOpacity>
))}
</View>
{/* 取消按钮 */}
<TouchableOpacity
style={styles.cancelButton}
onPress={handleCancel}
activeOpacity={0.7}
>
<Text style={styles.cancelText}>{cancelText}</Text>
</TouchableOpacity>
{/* 底部安全区域 */}
<View style={styles.safeBottom} />
</Animated.View>
</View>
</Modal>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
justifyContent: 'flex-end',
},
backdrop: {
...StyleSheet.absoluteFillObject,
backgroundColor: 'rgba(0, 0, 0, 0.4)',
},
sheet: {
backgroundColor: '#FFFFFF',
borderTopLeftRadius: 20,
borderTopRightRadius: 20,
paddingTop: 8,
maxHeight: screenHeight * 0.8,
},
dragIndicator: {
width: 36,
height: 4,
backgroundColor: '#E5E7EB',
borderRadius: 2,
alignSelf: 'center',
marginBottom: 16,
},
header: {
paddingHorizontal: 20,
paddingBottom: 16,
borderBottomWidth: 1,
borderBottomColor: '#F3F4F6',
},
title: {
fontSize: 18,
fontWeight: '700',
color: '#111827',
textAlign: 'center',
marginBottom: 4,
},
subtitle: {
fontSize: 14,
color: '#6B7280',
textAlign: 'center',
lineHeight: 20,
},
optionsContainer: {
paddingHorizontal: 20,
paddingTop: 8,
},
option: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingVertical: 16,
paddingHorizontal: 16,
backgroundColor: '#F9FAFB',
borderWidth: 1,
borderColor: '#E5E7EB',
marginBottom: 1,
},
firstOption: {
borderTopLeftRadius: 12,
borderTopRightRadius: 12,
},
lastOption: {
borderBottomLeftRadius: 12,
borderBottomRightRadius: 12,
marginBottom: 0,
},
optionContent: {
flexDirection: 'row',
alignItems: 'center',
flex: 1,
},
iconContainer: {
width: 32,
height: 32,
borderRadius: 8,
backgroundColor: '#FFFFFF',
alignItems: 'center',
justifyContent: 'center',
marginRight: 12,
shadowColor: '#000',
shadowOpacity: 0.05,
shadowRadius: 2,
shadowOffset: { width: 0, height: 1 },
elevation: 1,
},
textContainer: {
flex: 1,
},
optionTitle: {
fontSize: 16,
fontWeight: '600',
color: '#111827',
marginBottom: 2,
},
optionSubtitle: {
fontSize: 13,
color: '#6B7280',
lineHeight: 18,
},
destructiveText: {
color: '#EF4444',
},
cancelButton: {
marginHorizontal: 20,
marginTop: 16,
paddingVertical: 16,
backgroundColor: '#F3F4F6',
borderRadius: 12,
alignItems: 'center',
},
cancelText: {
fontSize: 16,
fontWeight: '600',
color: '#374151',
},
safeBottom: {
height: 34, // iPhone底部安全区域高度
},
});