expo-duooomi-app/components/ui/drawer.tsx

445 lines
14 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, 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<DrawerRef, DrawerProps>(
(
{
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 (
<Modal
transparent
visible={visible}
animationType="none"
onRequestClose={close}
statusBarTranslucent={Platform.OS === 'android'}
>
<View style={styles.container}>
{/* 遮罩层 */}
{showBackdrop && (
<AnimatedPressable
style={[
styles.backdrop,
backdropAnimatedStyle,
{ backgroundColor: backdropColor },
backdropStyle,
]}
onPress={closeOnBackdropPress ? close : undefined}
/>
)}
{/* 抽屉内容 */}
<GestureDetector gesture={pan}>
<Animated.View
style={[
styles.drawer,
drawerAnimatedStyle,
getPositionStyles(position),
safeAreaStyle,
style,
]}
>
{showCloseButton && (
<Pressable
style={styles.closeButton}
onPress={close}
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
>
<CloseIcon />
</Pressable>
)}
{children}
</Animated.View>
</GestureDetector>
</View>
</Modal>
)
},
)
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