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 ( {modalContent && ( <> {modalContent} )} ) }) 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%', }, })