expo-popcore-app/components/ui/ModalPortal.jsx

301 lines
8.0 KiB
JavaScript

import React, {
useCallback,
useEffect,
useImperativeHandle,
useMemo,
useRef,
useState,
forwardRef,
} from 'react'
import Animated, {
Extrapolation,
FadeIn,
FadeInDown,
FadeInLeft,
FadeInRight,
FadeInUp,
FadeOut,
FadeOutDown,
FadeOutLeft,
FadeOutRight,
FadeOutUp,
ZoomIn,
ZoomOut,
interpolate,
runOnJS,
useAnimatedStyle,
useSharedValue,
withSpring,
} from 'react-native-reanimated'
import { Gesture, GestureDetector } from 'react-native-gesture-handler'
import { BackHandler, Pressable, StyleSheet, View, useWindowDimensions } from 'react-native'
import Overlay from './Overlay'
export const defaultDirection = 'down'
export const defaultConfig = {
animationInTiming: 150,
animationOutTiming: 150,
hideBackdrop: false,
backdropColor: 'rgba(0, 0, 0, 0.1)',
dampingFactor: 0.2,
swipeDirection: defaultDirection,
swipeVelocityThreshold: 500,
onBackButtonPress: undefined,
onBackdropPress: undefined,
style: {},
// 点击穿透
// pointerEvents: 'auto',
}
const AnimatedPressable = Animated.createAnimatedComponent(Pressable)
const defaultAnimationInMap = {
fade: FadeIn,
zoom: ZoomIn,
up: FadeInUp,
down: FadeInDown,
left: FadeInLeft,
right: FadeInRight,
}
const defaultAnimationOutMap = {
fade: FadeOut,
zoom: ZoomOut,
up: FadeOutUp,
down: FadeOutDown,
left: FadeOutLeft,
right: FadeOutRight,
}
const ModalPortal = forwardRef((props, ref) => {
const [config, setConfig] = useState(defaultConfig)
const [modalContent, setModalContent] = useState()
const translationX = useSharedValue(0)
const translationY = useSharedValue(0)
const prevTranslationX = useSharedValue(0)
const prevTranslationY = useSharedValue(0)
const onHideRef = useRef(() => {})
const { width, height } = useWindowDimensions()
const hide = useCallback(async (props) => {
setModalContent(undefined)
await new Promise((resolve) => {
setTimeout(resolve, config.animationOutTiming)
})
translationX.value = 0
translationY.value = 0
onHideRef.current(props)
}, [])
useImperativeHandle(ref, () => ({
hide,
show: async (newComponent, newConfig = {}) => {
if (modalContent) await hide()
const mergedConfig = { ...defaultConfig, ...newConfig }
setConfig(mergedConfig)
if (React.isValidElement(newComponent) && newComponent) {
setModalContent(newComponent)
}
return new Promise((resolve) => {
onHideRef.current = resolve
})
},
}))
const onBackdropPress = useMemo(() => {
return config.onBackdropPress ?? (() => hide())
}, [config.onBackdropPress, hide])
const isHorizontal = config.swipeDirection === 'left' || config.swipeDirection === 'right'
const rangeMap = useMemo(() => {
return {
up: -height,
down: height,
left: -width,
right: width,
}
}, [height, width])
const pan = Gesture.Pan()
.enabled(!!config.swipeDirection)
.minDistance(1)
.onStart(() => {
prevTranslationX.value = translationX.value
prevTranslationY.value = translationY.value
})
.onUpdate((event) => {
const translationValue = isHorizontal ? event.translationX : event.translationY
const prevTranslationValue = isHorizontal ? prevTranslationX.value : prevTranslationY.value
const shouldDampMap = {
up: translationValue > 0,
down: translationValue < 0,
left: translationValue > 0,
right: translationValue < 0,
}
const shouldDamp = shouldDampMap[config.swipeDirection ?? defaultDirection]
const dampedTranslation = shouldDamp
? prevTranslationValue + translationValue * config.dampingFactor
: prevTranslationValue + translationValue
if (isHorizontal) {
translationX.value = dampedTranslation
} else {
translationY.value = dampedTranslation
}
})
.onEnd((event) => {
const velocityThreshold = config.swipeVelocityThreshold
const shouldHideMap = {
up: event.velocityY < -velocityThreshold,
down: event.velocityY > velocityThreshold,
right: event.velocityX > velocityThreshold,
left: event.velocityX < -velocityThreshold,
}
const shouldHide = shouldHideMap[config.swipeDirection ?? defaultDirection]
if (!shouldHide) {
translationX.value = withSpring(0, {
velocity: event.velocityX,
damping: 75,
})
translationY.value = withSpring(0, {
velocity: event.velocityY,
damping: 75,
})
return
}
const mainTranslation = isHorizontal ? translationX : translationY
mainTranslation.value = withSpring(
rangeMap[config.swipeDirection ?? defaultDirection],
{ velocity: event.velocityX, overshootClamping: true },
(success) => success && runOnJS(hide)(),
)
return
})
const animatedStyles = useAnimatedStyle(
() => ({
transform: [{ translateX: translationX.value }, { translateY: translationY.value }],
}),
[translationX.value, translationY.value],
)
const animatedBackdropStyles = useAnimatedStyle(() => {
const translationValue = isHorizontal ? translationX.value : translationY.value
return {
opacity: interpolate(
translationValue,
[rangeMap[config.defaultDirection ?? defaultDirection], 0],
[0, 1],
Extrapolation.CLAMP,
),
}
}, [config.swipeDirection, translationX.value, translationY.value, rangeMap])
useEffect(() => {
if (!modalContent) return
const backHandler = BackHandler.addEventListener('hardwareBackPress', () => {
if (config.onBackButtonPress) {
config.onBackButtonPress()
} else {
hide()
}
return true
})
return () => backHandler.remove()
}, [config.onBackButtonPress, hide, modalContent])
const isBackdropVisible = modalContent && !config.hideBackdrop
return (
<Overlay>
<View pointerEvents={isBackdropVisible ? 'auto' : 'box-none'} style={StyleSheet.absoluteFill}>
{modalContent && (
<>
<Animated.View
pointerEvents={isBackdropVisible ? 'auto' : 'none'}
entering={FadeIn.duration(config.animationInTiming)}
exiting={FadeOut.duration(config.animationOutTiming)}
style={styles.backdropContainer}
>
<AnimatedPressable
testID="magic-modal-backdrop"
style={[
styles.backdrop,
animatedBackdropStyles,
{
backgroundColor: isBackdropVisible ? config.backdropColor : 'transparent',
},
]}
onPress={onBackdropPress}
/>
</Animated.View>
<Animated.View pointerEvents="box-none" style={[styles.overlay, animatedStyles]}>
<Animated.View
pointerEvents="box-none"
style={[styles.overlay, config.style]}
// entering={FadeInUp}
entering={
config.entering ??
defaultAnimationInMap[config.swipeDirection ?? defaultDirection].duration(
config.animationInTiming,
)
}
exiting={
config.exiting ??
defaultAnimationOutMap[config.swipeDirection ?? defaultDirection].duration(
config.animationOutTiming,
)
}
>
<GestureDetector gesture={pan}>{modalContent}</GestureDetector>
</Animated.View>
</Animated.View>
</>
)}
</View>
</Overlay>
)
})
ModalPortal.displayName = 'ModalPortal'
export default ModalPortal
const styles = StyleSheet.create({
overlay: {
flex: 1,
justifyContent: 'center',
},
backdrop: {
flex: 1,
backgroundColor: 'rgba(0, 0, 0, 0.5)',
},
backdropContainer: {
position: 'absolute',
width: '100%',
height: '100%',
},
})