Initial commit: expo-popcore-app

This commit is contained in:
imeepos
2025-12-25 16:25:55 +08:00
commit 02d0c807cd
160 changed files with 39560 additions and 0 deletions

443
components/ui/drawer.tsx Normal file
View 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