293 lines
8.0 KiB
JavaScript
293 lines
8.0 KiB
JavaScript
import React, { forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react'
|
|
import { BackHandler, Pressable, StyleSheet, useWindowDimensions, View } from 'react-native'
|
|
import { Gesture, GestureDetector } from 'react-native-gesture-handler'
|
|
import Animated, {
|
|
configureReanimatedLogger,
|
|
Extrapolation,
|
|
FadeIn,
|
|
FadeInDown,
|
|
FadeInLeft,
|
|
FadeInRight,
|
|
FadeInUp,
|
|
FadeOut,
|
|
FadeOutDown,
|
|
FadeOutLeft,
|
|
FadeOutRight,
|
|
FadeOutUp,
|
|
interpolate,
|
|
ReanimatedLogLevel,
|
|
runOnJS,
|
|
useAnimatedStyle,
|
|
useSharedValue,
|
|
withSpring,
|
|
ZoomIn,
|
|
ZoomOut,
|
|
} from 'react-native-reanimated'
|
|
|
|
import Overlay from './Overlay'
|
|
|
|
configureReanimatedLogger({
|
|
level: ReanimatedLogLevel.warn,
|
|
strict: false, // Reanimated runs in strict mode by default
|
|
})
|
|
|
|
export const defaultDirection = 'down'
|
|
export const defaultConfig = {
|
|
animationInTiming: 150,
|
|
animationOutTiming: 150,
|
|
hideBackdrop: false,
|
|
backdropColor: 'rgba(0, 0, 0, 0.5)',
|
|
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
|
|
entering={FadeIn.duration(config.animationInTiming)}
|
|
exiting={FadeOut.duration(config.animationOutTiming)}
|
|
pointerEvents={isBackdropVisible ? 'auto' : 'none'}
|
|
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
|
|
exiting={config.exiting ?? defaultAnimationOutMap[config.swipeDirection ?? defaultDirection].duration(config.animationOutTiming)}
|
|
pointerEvents="box-none"
|
|
style={[styles.overlay, config.style]}
|
|
// entering={FadeInUp}
|
|
entering={config.entering ?? defaultAnimationInMap[config.swipeDirection ?? defaultDirection].duration(config.animationInTiming)}
|
|
>
|
|
<GestureDetector gesture={pan}>
|
|
<View collapsable={false}>{modalContent}</View>
|
|
</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%',
|
|
},
|
|
})
|