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
JavaScript
"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