import type { ReactNode } from 'react'; import React, { useEffect, useRef, useState } from 'react'; import { Animated, BackHandler, Dimensions, Easing, Platform, StyleSheet, } from 'react-native'; import { useTheme } from '../../theme'; import Portal from '../Portal'; const DEFAULT_BACKDROP_OPACITY = 0.4; const DEFAULT_ANIMATION_CONFIG = { easing: Easing.inOut(Easing.cubic), useNativeDriver: Platform.OS !== 'web', duration: 400, }; export interface ModalProps { /** * Content of the modal. */ children: ReactNode; /** * Visibility of the modal */ visible?: boolean; /** * Callback when the modal is shown. */ onShow?: () => void; /** * Callback when the user taps the hardware back button. */ onRequestClose?: () => void; /** * TestID of the modal. */ testID?: string; /** * Animation type of the modal content. */ animationType?: 'none' | 'slide' | 'fade'; /** * Whether to show the modal backdrop */ transparent?: boolean; /** * Callback when the modal is dismissed. iOS only. */ onDismiss?: () => void; } const Modal = ({ children, visible = true, onShow, onRequestClose, testID, animationType = 'none', transparent = false, onDismiss, }: ModalProps) => { const theme = useTheme(); const animatedBackdropValue = useRef(new Animated.Value(0)).current; const animatedModalValue = useRef(new Animated.Value(0)).current; const [mounted, setMounted] = useState(visible); const previousVisible = useRef(false); // Animate in/out based on visible prop useEffect(() => { if (visible && !previousVisible.current) { // Show animation setMounted(true); if (animationType !== 'none') { if (!transparent) { Animated.timing(animatedBackdropValue, { toValue: 1, ...DEFAULT_ANIMATION_CONFIG, }).start(); } Animated.timing(animatedModalValue, { toValue: 1, ...DEFAULT_ANIMATION_CONFIG, }).start(() => { onShow?.(); }); } else { onShow?.(); } } else if (!visible && previousVisible.current) { // Hide animation if (animationType !== 'none') { if (!transparent) { Animated.timing(animatedBackdropValue, { toValue: 0, ...DEFAULT_ANIMATION_CONFIG, }).start(); } Animated.timing(animatedModalValue, { toValue: 0, ...DEFAULT_ANIMATION_CONFIG, }).start(() => { setMounted(false); if (Platform.OS === 'ios') { onDismiss?.(); } }); } else { setMounted(false); if (Platform.OS === 'ios') { onDismiss?.(); } } } previousVisible.current = visible; }, [visible, animationType, transparent, onShow, onDismiss]); // Back button handler useEffect(() => { if (!visible) return; const backHandler = BackHandler.addEventListener( 'hardwareBackPress', () => { onRequestClose?.(); return true; } ); return () => backHandler.remove(); }, [visible, onRequestClose]); const backdropOpacityAnimation = animatedBackdropValue.interpolate({ inputRange: [0, 1], outputRange: [0, DEFAULT_BACKDROP_OPACITY], }); const modalAnimation = animatedModalValue.interpolate({ inputRange: [0, 1], outputRange: animationType === 'slide' ? [Dimensions.get('window').height, 0] : [0, 1], }); if (!visible && !mounted) { return null; } return ( {children} ); }; export default Modal;