Initial commit: expo-popcore-app
This commit is contained in:
443
components/ui/drawer.tsx
Normal file
443
components/ui/drawer.tsx
Normal file
@@ -0,0 +1,443 @@
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user