import React, { useEffect, useImperativeHandle, forwardRef, ReactNode, useCallback, useMemo } from 'react' import { View, StyleSheet, Pressable, useWindowDimensions, BackHandler, Modal, Platform, } from 'react-native' import { useSafeAreaInsets } from 'react-native-safe-area-context' import { CloseIcon } from '@/components/icon' import Animated, { useSharedValue, useAnimatedStyle, withSpring, withTiming, runOnJS, interpolate, Extrapolation, } from 'react-native-reanimated' import { Gesture, GestureDetector } from 'react-native-gesture-handler' const AnimatedPressable = Animated.createAnimatedComponent(Pressable) export type DrawerPosition = 'bottom' | 'top' | 'left' | 'right' export interface DrawerProps { /** * 是否显示抽屉 */ visible: boolean /** * 关闭回调 */ onClose: () => void /** * 抽屉内容 */ children: ReactNode /** * 抽屉位置,默认为 'bottom' */ position?: DrawerPosition /** * 抽屉高度(position 为 'top' 或 'bottom' 时使用) */ height?: number | string /** * 抽屉宽度(position 为 'left' 或 'right' 时使用) */ width?: number | string /** * 是否显示遮罩层,默认为 true */ showBackdrop?: boolean /** * 遮罩层颜色,默认为 'rgba(0, 0, 0, 0.5)' */ backdropColor?: string /** * 点击遮罩层是否关闭,默认为 true */ closeOnBackdropPress?: boolean /** * 是否支持手势拖拽关闭,默认为 true */ enableGesture?: boolean /** * 拖拽关闭的阈值(速度),默认为 500 */ swipeVelocityThreshold?: number /** * 自定义样式 */ style?: any /** * 遮罩层样式 */ backdropStyle?: any /** * 是否显示关闭按钮,默认为 true */ showCloseButton?: boolean } export interface DrawerRef { /** * 关闭抽屉 */ close: () => void } const Drawer = forwardRef( ( { visible, onClose, children, position = 'bottom', height, width, showBackdrop = true, backdropColor = 'rgba(0, 0, 0, 0.5)', closeOnBackdropPress = true, enableGesture = true, swipeVelocityThreshold = 500, style, backdropStyle, showCloseButton = true, }, ref, ) => { const { width: screenWidth, height: screenHeight } = useWindowDimensions() const insets = useSafeAreaInsets() const translateX = useSharedValue(0) const translateY = useSharedValue(0) const backdropOpacity = useSharedValue(0) // 计算抽屉尺寸 const drawerHeight = height === undefined ? screenHeight * 0.6 : typeof height === 'string' ? (parseFloat(height) / 100) * screenHeight : height const drawerWidth = width === undefined ? screenWidth * 0.8 : typeof width === 'string' ? (parseFloat(width) / 100) * screenWidth : width // 根据位置计算最大偏移量 const getMaxOffset = () => { switch (position) { case 'bottom': return drawerHeight case 'top': return -drawerHeight case 'left': return -drawerWidth case 'right': return drawerWidth default: return drawerHeight } } const maxOffset = getMaxOffset() const isHorizontal = position === 'left' || position === 'right' // 关闭抽屉 const close = useCallback(() => { translateX.value = 0 translateY.value = 0 backdropOpacity.value = withTiming(0, { duration: 200 }) onClose() }, [onClose]) useImperativeHandle(ref, () => ({ close, }), [close]) // 处理返回键(仅 Android) useEffect(() => { if (!visible || Platform.OS !== 'android') return const backHandler = BackHandler.addEventListener('hardwareBackPress', () => { close() return true }) return () => backHandler.remove() }, [visible, close]) // 显示/隐藏动画 useEffect(() => { if (visible) { backdropOpacity.value = withTiming(1, { duration: 200 }) translateX.value = 0 translateY.value = 0 } else { backdropOpacity.value = withTiming(0, { duration: 200 }) translateX.value = 0 translateY.value = 0 } }, [visible]) // 手势处理 const pan = Gesture.Pan() .enabled(enableGesture && visible) .onUpdate((event) => { if (isHorizontal) { const newTranslateX = event.translationX // 根据位置限制拖拽方向 if (position === 'left' && newTranslateX > 0) { translateX.value = newTranslateX } else if (position === 'right' && newTranslateX < 0) { translateX.value = newTranslateX } else if (position === 'left' || position === 'right') { translateX.value = newTranslateX } } else { const newTranslateY = event.translationY // 根据位置限制拖拽方向 if (position === 'bottom' && newTranslateY > 0) { translateY.value = newTranslateY } else if (position === 'top' && newTranslateY < 0) { translateY.value = newTranslateY } else if (position === 'bottom' || position === 'top') { translateY.value = newTranslateY } } // 更新遮罩层透明度 const currentOffset = isHorizontal ? translateX.value : translateY.value const opacity = interpolate( Math.abs(currentOffset), [0, Math.abs(maxOffset)], [1, 0], Extrapolation.CLAMP, ) backdropOpacity.value = opacity }) .onEnd((event) => { const velocity = isHorizontal ? event.velocityX : event.velocityY const translation = isHorizontal ? translateX.value : translateY.value // 判断是否应该关闭 const shouldClose = Math.abs(velocity) > swipeVelocityThreshold || Math.abs(translation) > Math.abs(maxOffset) * 0.5 if (shouldClose) { // 关闭动画 if (isHorizontal) { translateX.value = withSpring( maxOffset, { velocity: event.velocityX, damping: 20, stiffness: 90, }, () => { runOnJS(close)() }, ) } else { translateY.value = withSpring( maxOffset, { velocity: event.velocityY, damping: 20, stiffness: 90, }, () => { runOnJS(close)() }, ) } } else { // 回弹动画 if (isHorizontal) { translateX.value = withSpring(0, { velocity: event.velocityX, damping: 20, stiffness: 90, }) } else { translateY.value = withSpring(0, { velocity: event.velocityY, damping: 20, stiffness: 90, }) } backdropOpacity.value = withTiming(1, { duration: 200 }) } }) // 计算安全区域样式 const safeAreaStyle = useMemo(() => { const safeStyle: any = {} if (Platform.OS === 'ios') { switch (position) { case 'bottom': safeStyle.paddingBottom = insets.bottom break case 'top': safeStyle.paddingTop = insets.top break case 'left': case 'right': safeStyle.paddingTop = insets.top safeStyle.paddingBottom = insets.bottom break } } return safeStyle }, [position, insets]) // 抽屉内容动画样式 const drawerAnimatedStyle = useAnimatedStyle(() => { const baseStyle: any = { overflow: 'hidden', } switch (position) { case 'bottom': baseStyle.transform = [{ translateY: translateY.value }] baseStyle.bottom = 0 baseStyle.left = 0 baseStyle.right = 0 baseStyle.height = drawerHeight baseStyle.borderTopLeftRadius = 20 baseStyle.borderTopRightRadius = 20 break case 'top': baseStyle.transform = [{ translateY: translateY.value }] baseStyle.top = 0 baseStyle.left = 0 baseStyle.right = 0 baseStyle.height = drawerHeight baseStyle.borderBottomLeftRadius = 20 baseStyle.borderBottomRightRadius = 20 break case 'left': baseStyle.transform = [{ translateX: translateX.value }] baseStyle.left = 0 baseStyle.top = 0 baseStyle.bottom = 0 baseStyle.width = drawerWidth baseStyle.borderTopRightRadius = 20 baseStyle.borderBottomRightRadius = 20 break case 'right': baseStyle.transform = [{ translateX: translateX.value }] baseStyle.right = 0 baseStyle.top = 0 baseStyle.bottom = 0 baseStyle.width = drawerWidth baseStyle.borderTopLeftRadius = 20 baseStyle.borderBottomLeftRadius = 20 break } return baseStyle }, [position, drawerHeight, drawerWidth]) // 遮罩层动画样式 const backdropAnimatedStyle = useAnimatedStyle(() => { return { opacity: backdropOpacity.value, } }) if (!visible) { return null } return ( {/* 遮罩层 */} {showBackdrop && ( )} {/* 抽屉内容 */} {showCloseButton && ( )} {children} ) }, ) Drawer.displayName = 'Drawer' // 根据位置获取样式(圆角已在动画样式中设置) const getPositionStyles = (position: DrawerPosition) => { return {} } const styles = StyleSheet.create({ container: { flex: 1, }, backdrop: { ...StyleSheet.absoluteFillObject, }, drawer: { position: 'absolute', backgroundColor: '#1C1E20', overflow: 'hidden', }, closeButton: { position: 'absolute', top: Platform.select({ ios: 16, android: 16, default: 16 }), right: Platform.select({ ios: 16, android: 16, default: 16 }), zIndex: 10, width: 24, height: 24, alignItems: 'center', justifyContent: 'center', }, }) export default Drawer