444 lines
14 KiB
TypeScript
444 lines
14 KiB
TypeScript
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: position === 'bottom' ? 'visible' : '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',
|
||
},
|
||
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
|
||
|