UNPKG

react-native-reanimated-modal

Version:

A lightweight and performant modal component. Designed for smooth animations, flexibility, and minimal footprint.

585 lines (551 loc) 18.4 kB
"use strict"; import { useCallback, useEffect, useMemo, useState } from 'react'; import { BackHandler, Modal as RNModal, Pressable, View, useWindowDimensions, Platform } from 'react-native'; import { Gesture, GestureDetector, GestureHandlerRootView } from 'react-native-gesture-handler'; import Animated, { Easing, interpolate, runOnJS, useAnimatedReaction, useAnimatedStyle, useSharedValue, withTiming, withSpring } from 'react-native-reanimated'; import { styles } from "./styles.js"; import { normalizeAnimationConfig, normalizeSwipeConfig, normalizeBackdropConfig, getSwipeDirections, getSlideInDirection, DEFAULT_MODAL_ANIMATION_DURATION, DEFAULT_MODAL_SCALE_FACTOR, DEFAULT_MODAL_SWIPE_THRESHOLD, DEFAULT_MODAL_BOUNCE_SPRING_CONFIG, DEFAULT_MODAL_BOUNCE_OPACITY_THRESHOLD } from "./config.js"; /** * Modal component with smooth, customizable animations and gesture support. * Built on top of React Native's Modal, Reanimated, and Gesture Handler. * * @param {ModalProps} props - Props for the modal component. * @returns {JSX.Element} */ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime"; export const Modal = ({ visible = false, closable = true, children, style, contentContainerStyle, // animation, // backdrop, onBackdropPress, // swipe, // coverScreen = false, // onShow, onHide, // hardwareAccelerated, navigationBarTranslucent, onOrientationChange, statusBarTranslucent, supportedOrientations, // testIDs backdropTestID = 'modal-backdrop', contentTestID = 'modal-content', containerTestID = 'modal-container' }) => { const { width: SCREEN_WIDTH, height: SCREEN_HEIGHT } = useWindowDimensions(); // Normalize configs with defaults const normalizedAnimationConfig = useMemo(() => normalizeAnimationConfig(animation), [animation]); const normalizedSwipeConfig = useMemo(() => normalizeSwipeConfig(swipe), [swipe]); const normalizedBackdropConfig = useMemo(() => normalizeBackdropConfig(backdrop), [backdrop]); // Extract values from configs const { duration: animationDuration = DEFAULT_MODAL_ANIMATION_DURATION } = normalizedAnimationConfig; const { enabled: swipeEnabled = true, threshold: swipeThreshold = DEFAULT_MODAL_SWIPE_THRESHOLD, bounceSpringConfig = DEFAULT_MODAL_BOUNCE_SPRING_CONFIG, bounceOpacityThreshold = normalizedSwipeConfig.bounceOpacityThreshold || DEFAULT_MODAL_BOUNCE_OPACITY_THRESHOLD } = normalizedSwipeConfig; const { enabled: hasBackdrop, isCustom: isCustomBackdrop, config: backdropConfig, customRenderer: customBackdropRenderer } = normalizedBackdropConfig; /** * Shared values for animation progress and gesture state. */ const progress = useSharedValue(0); const offsetX = useSharedValue(0); const offsetY = useSharedValue(0); const activeSwipeDirection = useSharedValue(null); const animationMode = useSharedValue(null); const shouldRender = useSharedValue(visible); /** * React state buffers for shared values (for React hooks dependencies). */ const [shouldRenderValue, setShouldRenderValue] = useState(visible); const [animationModeValue, setAnimationModeValue] = useState(null); /** * Animated reactions to sync shared values with React state buffers. */ useAnimatedReaction(() => shouldRender.value, current => { runOnJS(setShouldRenderValue)(current); }, []); useAnimatedReaction(() => animationMode.value, current => { runOnJS(setAnimationModeValue)(current); }, []); /** * Memo: determines if modal needs to open */ const isNeedOpen = useMemo(() => { return visible && !shouldRenderValue && !animationModeValue; }, [visible, shouldRenderValue, animationModeValue]); /** * Memo: determines if modal needs to close */ const isNeedClose = useMemo(() => { return !visible && shouldRenderValue && !animationModeValue; }, [visible, shouldRenderValue, animationModeValue]); /** * Allowed swipe directions for dismissing the modal. */ const swipeDirections = useMemo(() => getSwipeDirections(normalizedSwipeConfig, normalizedAnimationConfig), [normalizedSwipeConfig, normalizedAnimationConfig]); /** * Slide-in direction for the modal. */ const slideInDirection = useMemo(() => getSlideInDirection(normalizedAnimationConfig), [normalizedAnimationConfig]); /** * Resets all animation and gesture state to initial values. */ const resetAnimationState = useCallback(() => { 'worklet'; progress.value = 0; offsetX.value = 0; offsetY.value = 0; activeSwipeDirection.value = null; animationMode.value = null; }, [progress, offsetX, offsetY, activeSwipeDirection, animationMode]); /** * Handles modal opening */ const handleOpen = useCallback(() => { shouldRender.value = true; animationMode.value = 'opening'; progress.value = withTiming(1, { duration: animationDuration, easing: Easing.out(Easing.ease) }, () => { animationMode.value = null; if (onShow) runOnJS(onShow)(); }); }, [animationDuration, progress, onShow, shouldRender, animationMode]); const handleCloseCompletion = useCallback(() => { 'worklet'; shouldRender.value = false; resetAnimationState(); if (onHide) runOnJS(onHide)(); }, [onHide, resetAnimationState, shouldRender]); /** * Handles modal closing */ const handleClose = useCallback(() => { if (animationModeValue) return; animationMode.value = 'closing'; progress.value = withTiming(0, { duration: animationDuration, easing: Easing.in(Easing.ease) }, handleCloseCompletion); }, [animationModeValue, animationMode, progress, animationDuration, handleCloseCompletion]); /** * Checks if a swipe direction is allowed for dismiss. */ const isDirectionAllowed = useCallback(direction => { 'worklet'; return swipeDirections.includes(direction); }, [swipeDirections]); /** * Calculates swipe progress for gesture-based dismiss. */ const calculateSwipeProgress = useCallback((dx, dy) => { 'worklet'; if (!activeSwipeDirection.value) return 0; let dist = 0; switch (activeSwipeDirection.value) { case 'left': dist = Math.abs(dx); break; case 'right': dist = dx; break; case 'up': dist = Math.abs(dy); break; case 'down': dist = dy; break; } return Math.min(1, Math.max(0, dist / swipeThreshold)); }, [activeSwipeDirection, swipeThreshold]); /** * Pan gesture handler for swipe-to-dismiss. */ const panGesture = Gesture.Pan().enabled(swipeEnabled && closable && !!onHide).onBegin(() => { if (animationMode.value) return; activeSwipeDirection.value = null; }).onUpdate(event => { // Only set direction once per gesture if (activeSwipeDirection.value == null) { const { translationX, translationY } = event; const absX = Math.abs(translationX); const absY = Math.abs(translationY); // Require minimum movement to detect direction (prevent false detection at 0,0) if (absX < 1 && absY < 1) return; // Not enough movement to determine direction if (absX > absY) { const dir = translationX > 0 ? 'right' : 'left'; if (isDirectionAllowed(dir)) { activeSwipeDirection.value = dir; animationMode.value = 'sliding'; } } else { const dir = translationY > 0 ? 'down' : 'up'; if (isDirectionAllowed(dir)) { activeSwipeDirection.value = dir; animationMode.value = 'sliding'; } } } // Move only along the chosen direction switch (activeSwipeDirection.value) { case 'left': offsetX.value = Math.min(0, event.translationX); offsetY.value = 0; break; case 'right': offsetX.value = Math.max(0, event.translationX); offsetY.value = 0; break; case 'up': offsetY.value = Math.min(0, event.translationY); offsetX.value = 0; break; case 'down': offsetY.value = Math.max(0, event.translationY); offsetX.value = 0; break; } }).onEnd(() => { if (animationMode.value !== 'sliding' || !activeSwipeDirection.value) { animationMode.value = null; return; } const swipeProg = calculateSwipeProgress(offsetX.value, offsetY.value); if (swipeProg >= 1) { // Close via swipe animationMode.value = 'closing'; const finalX = activeSwipeDirection.value === 'left' ? -SCREEN_WIDTH : activeSwipeDirection.value === 'right' ? SCREEN_WIDTH : 0; const finalY = activeSwipeDirection.value === 'up' ? -SCREEN_HEIGHT : activeSwipeDirection.value === 'down' ? SCREEN_HEIGHT : 0; let counter = 0; const onComplete = () => { counter += 1; if (counter === 2) handleCloseCompletion(); }; offsetX.value = withTiming(finalX, { duration: animationDuration, easing: Easing.out(Easing.ease) }, onComplete); offsetY.value = withTiming(finalY, { duration: animationDuration, easing: Easing.out(Easing.ease) }, onComplete); } else { // Bounce back animationMode.value = 'bouncing'; switch (activeSwipeDirection.value) { case 'left': case 'right': offsetX.value = withSpring(0, bounceSpringConfig, () => { animationMode.value = null; activeSwipeDirection.value = null; }); break; case 'up': case 'down': offsetY.value = withSpring(0, bounceSpringConfig, () => { animationMode.value = null; activeSwipeDirection.value = null; }); break; } } }); /** * Animated style for the backdrop (opacity, fade, bounce correction). */ const backdropAnimatedStyle = useAnimatedStyle(() => { // Default backdrop animation logic const computedOpacity = !isCustomBackdrop ? backdropConfig.opacity || 1 : 1; let swipeFade = 0; if (activeSwipeDirection.value) { let fullSwipeDistance = 1; let offset = 0; switch (activeSwipeDirection.value) { case 'left': case 'right': fullSwipeDistance = SCREEN_WIDTH; offset = Math.abs(offsetX.value); break; case 'up': case 'down': fullSwipeDistance = SCREEN_HEIGHT; offset = Math.abs(offsetY.value); break; } swipeFade = Math.min(1, Math.max(0, offset / fullSwipeDistance)); } let baseOpacity = computedOpacity * (1 - swipeFade); if (animationMode.value === 'bouncing' && computedOpacity - baseOpacity <= bounceOpacityThreshold) { baseOpacity = computedOpacity; } const defaultStyle = { opacity: interpolate(progress.value, [0, 1], [0, baseOpacity]) }; // Merge with custom backdrop worklet if provided if (normalizedAnimationConfig.backdropAnimatedStyle) { const customStyle = normalizedAnimationConfig.backdropAnimatedStyle({ animationState: animationMode.value, swipeDirection: activeSwipeDirection.value, progress: progress.value, offsetX: offsetX.value, offsetY: offsetY.value, screenWidth: SCREEN_WIDTH, screenHeight: SCREEN_HEIGHT }); return { ...defaultStyle, ...customStyle }; } return defaultStyle; }); /** * Animated style for the modal content (slide/fade/scale/gesture transforms). */ const contentAnimatedStyle = useAnimatedStyle(() => { let defaultStyle = {}; // Handle swipe gestures (applies to all animation types) if (activeSwipeDirection.value) { const baseOpacity = normalizedAnimationConfig.type === 'fade' ? progress.value : 1; defaultStyle = { opacity: baseOpacity, transform: [{ translateX: offsetX.value }, { translateY: offsetY.value }] }; } else { // Default preset animations switch (normalizedAnimationConfig.type) { case 'fade': { defaultStyle = { opacity: progress.value, transform: [{ translateX: 0 }, { translateY: 0 }] }; break; } case 'scale': { const scaleConfig = normalizedAnimationConfig; const scaleFactor = scaleConfig.scaleFactor || DEFAULT_MODAL_SCALE_FACTOR; const scale = interpolate(progress.value, [0, 1], [scaleFactor, 1]); defaultStyle = { opacity: progress.value, transform: [{ translateX: 0 }, { translateY: 0 }, { scale }] }; break; } case 'slide': { const slideIn = direction => { switch (direction) { case 'up': return { x: 0, y: -SCREEN_HEIGHT }; case 'down': return { x: 0, y: SCREEN_HEIGHT }; case 'left': return { x: -SCREEN_WIDTH, y: 0 }; case 'right': return { x: SCREEN_WIDTH, y: 0 }; } }; const entryPos = slideIn(slideInDirection); defaultStyle = { opacity: 1, transform: [{ translateX: interpolate(progress.value, [0, 1], [entryPos.x, 0]) }, { translateY: interpolate(progress.value, [0, 1], [entryPos.y, 0]) }] }; break; } case 'custom': default: { // For custom type without worklet, return minimal style defaultStyle = { opacity: progress.value, transform: [{ translateX: 0 }, { translateY: 0 }] }; break; } } } // Merge with custom content worklet if provided if (normalizedAnimationConfig.contentAnimatedStyle) { const customStyle = normalizedAnimationConfig.contentAnimatedStyle({ animationState: animationMode.value, swipeDirection: activeSwipeDirection.value, progress: progress.value, offsetX: offsetX.value, offsetY: offsetY.value, screenWidth: SCREEN_WIDTH, screenHeight: SCREEN_HEIGHT }); // Merge transform arrays if both exist if (!!defaultStyle.transform && !!customStyle.transform) { return { ...defaultStyle, ...customStyle, transform: [...defaultStyle.transform, ...customStyle.transform] }; } return { ...defaultStyle, ...customStyle }; } return defaultStyle; }); /** * Effect: handles modal opening */ useEffect(() => { if (!isNeedOpen) return; handleOpen(); }, [isNeedOpen, handleOpen]); /** * Effect: handles modal closing */ useEffect(() => { if (!isNeedClose) return; handleClose(); }, [isNeedClose, handleClose]); /** * Effect: handles hardware back button for Android. */ useEffect(() => { if (!closable || !shouldRenderValue || !onHide) return; const backHandler = BackHandler.addEventListener('hardwareBackPress', () => { if (shouldRenderValue && !animationModeValue) { handleClose(); return true; } return false; }); return () => backHandler.remove(); }, [shouldRenderValue, handleClose, closable, animationModeValue, onHide]); /** * Renders the modal content, optionally wrapped with gesture detector. * @returns {ReactNode} */ const renderContent = () => { const content = /*#__PURE__*/_jsx(Animated.View, { testID: contentTestID, style: [contentContainerStyle, contentAnimatedStyle], children: children }); if (swipeEnabled && swipeDirections.length > 0) { return /*#__PURE__*/_jsx(GestureDetector, { gesture: panGesture, children: content }); } return content; }; /** * Renders the backdrop component if enabled or custom. * @returns {ReactNode|null} */ const renderBackdropInternal = () => { if (!hasBackdrop) return null; return /*#__PURE__*/_jsx(Pressable, { testID: backdropTestID, style: styles.absolute, onPress: closable && onBackdropPress !== false ? () => { if (onBackdropPress) onBackdropPress();else handleClose(); } : undefined, children: /*#__PURE__*/_jsx(Animated.View, { style: [styles.absolute, !isCustomBackdrop && { backgroundColor: backdropConfig.color }, backdropAnimatedStyle], children: isCustomBackdrop && !!customBackdropRenderer ? customBackdropRenderer : null }) }); }; if (coverScreen && shouldRenderValue) { return /*#__PURE__*/_jsxs(View, { testID: containerTestID, style: [styles.absolute, styles.root, style], pointerEvents: "box-none", children: [renderBackdropInternal(), renderContent()] }); } return /*#__PURE__*/_jsx(RNModal, { hardwareAccelerated: hardwareAccelerated, navigationBarTranslucent: navigationBarTranslucent, statusBarTranslucent: statusBarTranslucent, onOrientationChange: onOrientationChange, supportedOrientations: supportedOrientations // presentationStyle="overFullScreen" , transparent: true, visible: shouldRenderValue, onRequestClose: handleClose, children: Platform.OS === 'android' ? /*#__PURE__*/_jsxs(GestureHandlerRootView, { testID: containerTestID, style: [styles.root, style], children: [renderBackdropInternal(), renderContent()] }) : /*#__PURE__*/_jsxs(View, { testID: containerTestID, style: [styles.root, style], children: [renderBackdropInternal(), renderContent()] }) }); }; //# sourceMappingURL=component.js.map