import React, { createContext, ReactNode, useCallback, useContext, useEffect, useMemo, useRef, useState, } from "react"; import { BackHandler, StyleProp, StyleSheet, TouchableOpacity, View, ViewStyle, } from "react-native"; import { KeyboardAvoidingView } from "react-native-keyboard-controller"; import Animated, { Easing, runOnJS, useAnimatedStyle, useSharedValue, withTiming, WithTimingConfig, } from "react-native-reanimated"; import { useSafeAreaInsets } from "react-native-safe-area-context"; export interface ModalOptions { closeOnBackdropPress?: boolean; closeOnBackButton?: boolean; animationConfig?: WithTimingConfig; backdropStyle?: StyleProp; containerStyle?: StyleProp; position?: "center" | "bottom" | "top"; animateFrom?: "center" | "bottom" | "top"; avoidKeyboard?: boolean; closeIconComponent?: ReactNode; } interface HideModalOptions { onDismiss?: () => void; } interface ModalContextType { showModal: (content: ReactNode, options?: ModalOptions) => void; hideModal: (options?: HideModalOptions) => void; } interface ModalProviderProps { children: ReactNode; defaultOptions?: ModalOptions; portalHostName?: string; CustomModalComponent?: React.FC<{ isVisible: boolean; children: ReactNode; options: ModalOptions; onClose: () => void; onDismiss: () => void; }>; } const ModalContext = createContext(undefined); export const DEFAULT_MODAL_HOST = "UniversalModalHost"; const DEFAULT_ANIMATION_CONFIG: WithTimingConfig = { duration: 300, easing: Easing.bezier(0.25, 0.1, 0.25, 1), }; const DefaultModalUI: React.FC<{ isVisible: boolean; children: ReactNode; options: ModalOptions; onClose: () => void; onDismiss: () => void; }> = ({ isVisible, children, options, onClose, onDismiss }) => { const insets = useSafeAreaInsets(); const [isRendered, setIsRendered] = useState(isVisible); const hasRenderedVisibleModalRef = useRef(false); const opacity = useSharedValue(0); const { closeOnBackdropPress = true, closeOnBackButton = true, animationConfig = DEFAULT_ANIMATION_CONFIG, position = "center", animateFrom = "center", avoidKeyboard = true, backdropStyle, containerStyle, closeIconComponent, } = options; useEffect(() => { if (isVisible) { hasRenderedVisibleModalRef.current = true; setIsRendered(true); } opacity.value = withTiming( isVisible ? 1 : 0, animationConfig, (finished) => { if (finished && !isVisible) { runOnJS(setIsRendered)(false); } }, ); }, [isVisible, animationConfig, opacity]); useEffect(() => { if (hasRenderedVisibleModalRef.current && !isVisible && !isRendered) { hasRenderedVisibleModalRef.current = false; onDismiss(); } }, [isVisible, isRendered, onDismiss]); useEffect(() => { const backHandler = BackHandler.addEventListener( "hardwareBackPress", () => { if (isVisible && closeOnBackButton) { onClose(); return true; } return false; }, ); return () => backHandler.remove(); }, [isVisible, closeOnBackButton, onClose]); const backdropAnimatedStyle = useAnimatedStyle(() => ({ opacity: opacity.value, })); const closeIconAnimatedStyle = useAnimatedStyle(() => ({ opacity: opacity.value, })); const contentAnimatedStyle = useAnimatedStyle(() => { const transform = animateFrom === "bottom" ? [{ translateY: (1 - opacity.value) * 100 }] : animateFrom === "top" ? [{ translateY: (1 - opacity.value) * -100 }] : [{ scale: 0.9 + opacity.value * 0.1 }]; return { opacity: opacity.value, transform, }; }); if (!isRendered) return null; const justifyContent = position === "bottom" ? "flex-end" : position === "top" ? "flex-start" : "center"; const paddingBottom = position === "bottom" ? insets.bottom : 0; const paddingTop = position === "top" ? insets.top : 0; const ContentWrapper = avoidKeyboard ? KeyboardAvoidingView : View; return ( {closeIconComponent && ( {closeIconComponent} )} {children} ); }; export const UniversalModalProvider: React.FC = ({ children, defaultOptions, CustomModalComponent, }) => { const [state, setState] = useState<{ isVisible: boolean; content: ReactNode | null; options: ModalOptions; }>({ isVisible: false, content: null, options: {}, }); const dismissCallbackRef = useRef<(() => void) | undefined>(undefined); const showModal = useCallback( (content: ReactNode, options?: ModalOptions) => { dismissCallbackRef.current = undefined; setState({ isVisible: true, content, options: { ...defaultOptions, ...options }, }); }, [defaultOptions], ); const hideModal = useCallback((options?: HideModalOptions) => { dismissCallbackRef.current = options?.onDismiss; setState((prev) => ({ ...prev, isVisible: false })); }, []); const handleDismiss = useCallback(() => { setState((prev) => { if (prev.isVisible || prev.content === null) return prev; return { ...prev, content: null }; }); const callback = dismissCallbackRef.current; dismissCallbackRef.current = undefined; callback?.(); }, []); const ModalUI = CustomModalComponent || DefaultModalUI; const value = useMemo( () => ({ showModal, hideModal }), [showModal, hideModal], ); return ( {children} {state.content} ); }; export const useModal = () => { const context = useContext(ModalContext); if (!context) { throw new Error("useModal must be used within a UniversalModalProvider"); } return context; }; const styles = StyleSheet.create({ overlayWrapper: {}, backdrop: { backgroundColor: "rgba(0, 0, 0, 0.6)", }, keyboardView: { justifyContent: "center", alignItems: "center", }, baseContainer: { width: "90%", }, closeIconContainer: { position: "absolute", right: 16, zIndex: 2000, }, });