198 lines
5.0 KiB
TypeScript
198 lines
5.0 KiB
TypeScript
import type { ReactNode } from 'react'
|
||
import type { DimensionValue, ViewStyle } from 'react-native'
|
||
import React, { useEffect, useMemo, useState } from 'react'
|
||
import { Dimensions, StyleSheet, Text, View } from 'react-native'
|
||
import { Gesture, GestureDetector } from 'react-native-gesture-handler'
|
||
import Animated, {
|
||
interpolate,
|
||
runOnJS,
|
||
useAnimatedStyle,
|
||
useSharedValue,
|
||
withTiming,
|
||
} from 'react-native-reanimated'
|
||
import LucideIcon from './LucideIcon'
|
||
|
||
const SCREEN = Dimensions.get('screen')
|
||
|
||
interface DrawerMenuProps {
|
||
visible: boolean
|
||
duration?: number
|
||
menuPosition?: 'left' | 'right' | 'top' | 'bottom'
|
||
height?: DimensionValue
|
||
hideModal: () => void
|
||
children?: ReactNode
|
||
header?: ReactNode
|
||
isCircle?: boolean
|
||
CircleNum?: number
|
||
isShadowHideModal?: boolean
|
||
title?: string
|
||
showHeader?: boolean
|
||
}
|
||
|
||
export default function Drawer({
|
||
visible,
|
||
height = 300,
|
||
hideModal,
|
||
duration = 300,
|
||
isCircle,
|
||
menuPosition = 'bottom',
|
||
children,
|
||
CircleNum = 12,
|
||
isShadowHideModal = true,
|
||
header,
|
||
title,
|
||
showHeader = true,
|
||
}: DrawerMenuProps) {
|
||
const progress = useSharedValue(0)
|
||
const drag = useSharedValue(0)
|
||
const [innerVisible, setInnerVisible] = useState(false)
|
||
|
||
/* ================= 显隐 ================= */
|
||
useEffect(() => {
|
||
if (visible) {
|
||
setInnerVisible(true)
|
||
progress.value = withTiming(1, { duration })
|
||
}
|
||
else {
|
||
progress.value = withTiming(0, { duration }, () => {
|
||
runOnJS(setInnerVisible)(false)
|
||
})
|
||
}
|
||
}, [visible])
|
||
|
||
/* ================= Drawer 尺寸 ================= */
|
||
const drawerBox = useMemo<ViewStyle>(() => {
|
||
switch (menuPosition) {
|
||
case 'left':
|
||
return { width: height, left: 0, top: 0, bottom: 0 }
|
||
case 'right':
|
||
return { width: height, right: 0, top: 0, bottom: 0 }
|
||
case 'top':
|
||
return { height, left: 0, right: 0, top: 0 }
|
||
case 'bottom':
|
||
default:
|
||
return { height, left: 0, right: 0, bottom: 0 }
|
||
}
|
||
}, [menuPosition, height])
|
||
|
||
/* ================= 动画样式(关键) ================= */
|
||
const drawerStyle = useAnimatedStyle(() => {
|
||
switch (menuPosition) {
|
||
case 'bottom': {
|
||
const y = interpolate(progress.value, [0, 1], [SCREEN.height, 0], 'clamp')
|
||
return { transform: [{ translateY: y + drag.value }] }
|
||
}
|
||
case 'top': {
|
||
const y = interpolate(progress.value, [0, 1], [-SCREEN.height, 0], 'clamp')
|
||
return { transform: [{ translateY: y }] }
|
||
}
|
||
case 'left': {
|
||
const x = interpolate(progress.value, [0, 1], [-SCREEN.width, 0], 'clamp')
|
||
return { transform: [{ translateX: x }] }
|
||
}
|
||
case 'right': {
|
||
const x = interpolate(progress.value, [0, 1], [SCREEN.width, 0], 'clamp')
|
||
return { transform: [{ translateX: x }] }
|
||
}
|
||
default:
|
||
return {}
|
||
}
|
||
})
|
||
|
||
const maskStyle = useAnimatedStyle(() => ({
|
||
opacity: interpolate(progress.value, [0, 1], [0, 0.45], 'clamp'),
|
||
}))
|
||
|
||
/* ================= 手势(只给 bottom) ================= */
|
||
const pan = Gesture.Pan()
|
||
.onUpdate((e) => {
|
||
if (menuPosition === 'bottom' && e.translationY > 0) {
|
||
drag.value = e.translationY
|
||
}
|
||
})
|
||
.onEnd(() => {
|
||
if (menuPosition === 'bottom' && drag.value > 120) {
|
||
progress.value = withTiming(0, { duration }, () => {
|
||
drag.value = 0
|
||
runOnJS(hideModal)()
|
||
})
|
||
}
|
||
else {
|
||
drag.value = withTiming(0)
|
||
}
|
||
})
|
||
|
||
if (!innerVisible)
|
||
return null
|
||
|
||
return (
|
||
<View style={StyleSheet.absoluteFill} pointerEvents="box-none" className="z-[99]">
|
||
{/* 遮罩 */}
|
||
<Animated.View
|
||
style={[styles.mask, maskStyle]}
|
||
onTouchEnd={() => isShadowHideModal && hideModal()}
|
||
/>
|
||
|
||
{/* Drawer */}
|
||
<View style={[styles.drawerBox, drawerBox]}>
|
||
<GestureDetector gesture={pan}>
|
||
<Animated.View
|
||
style={[
|
||
styles.drawer,
|
||
drawerStyle,
|
||
{
|
||
borderTopLeftRadius: isCircle ? CircleNum : 0,
|
||
borderTopRightRadius: isCircle ? CircleNum : 0,
|
||
},
|
||
]}
|
||
>
|
||
{showHeader && (
|
||
header ?? (
|
||
<View style={styles.headTitle}>
|
||
<Text style={styles.title}>{title}</Text>
|
||
<LucideIcon
|
||
icon="X"
|
||
size={22}
|
||
style={styles.close}
|
||
onPress={hideModal}
|
||
/>
|
||
</View>
|
||
)
|
||
)}
|
||
|
||
{children}
|
||
</Animated.View>
|
||
</GestureDetector>
|
||
</View>
|
||
</View>
|
||
)
|
||
}
|
||
|
||
const styles = StyleSheet.create({
|
||
mask: {
|
||
...StyleSheet.absoluteFillObject,
|
||
backgroundColor: '#000',
|
||
},
|
||
drawerBox: {
|
||
position: 'absolute',
|
||
},
|
||
drawer: {
|
||
flex: 1,
|
||
backgroundColor: '#fff',
|
||
},
|
||
headTitle: {
|
||
padding: 12,
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
},
|
||
title: {
|
||
fontSize: 18,
|
||
fontWeight: '600',
|
||
},
|
||
close: {
|
||
position: 'absolute',
|
||
right: 16,
|
||
top: 12,
|
||
},
|
||
})
|