react-native-reanimated-modal
Version:
A lightweight and performant modal component. Designed for smooth animations, flexibility, and minimal footprint.
508 lines (479 loc) • 16.3 kB
JavaScript
"use strict";
import { useCallback, useEffect, useMemo, useState } from 'react';
import { BackHandler, Modal as RNModal, Pressable, View, useWindowDimensions } from 'react-native';
import { Gesture, GestureDetector } 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, getSwipeDirections, getSlideInDirection, DEFAULT_MODAL_ANIMATION_DURATION, DEFAULT_MODAL_SCALE_FACTOR, DEFAULT_MODAL_BACKDROP_OPACITY, DEFAULT_MODAL_BACKDROP_COLOR, DEFAULT_MODAL_SWIPE_THRESHOLD, DEFAULT_MODAL_BOUNCE_SPRING_CONFIG, DEFAULT_MODAL_BOUNCE_OPACITY_THRESHOLD } from "./config.js";
/**
* Animation state for the modal.
* @enum {string}
*/
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
var AnimationMode = /*#__PURE__*/function (AnimationMode) {
AnimationMode["None"] = "None";
AnimationMode["Open"] = "Open";
AnimationMode["Slide"] = "Slide";
AnimationMode["Bounce"] = "Bounce";
AnimationMode["Close"] = "Close";
return AnimationMode;
}(AnimationMode || {});
/**
* 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}
*/
export const Modal = ({
visible = false,
closable = true,
children,
style,
contentContainerStyle,
//
animationConfig,
//
hasBackdrop = true,
backdropColor = DEFAULT_MODAL_BACKDROP_COLOR,
backdropOpacity = DEFAULT_MODAL_BACKDROP_OPACITY,
onBackdropPress,
renderBackdrop,
//
swipeConfig,
//
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(animationConfig), [animationConfig]);
const normalizedSwipeConfig = useMemo(() => normalizeSwipeConfig(swipeConfig), [swipeConfig]);
// Extract values from configs
const animationDuration = normalizedAnimationConfig.duration || DEFAULT_MODAL_ANIMATION_DURATION;
const swipeEnabled = normalizedSwipeConfig.enabled ?? true;
const swipeThreshold = normalizedSwipeConfig.threshold || DEFAULT_MODAL_SWIPE_THRESHOLD;
const bounceSpringConfig = normalizedSwipeConfig.bounceSpringConfig || DEFAULT_MODAL_BOUNCE_SPRING_CONFIG;
const bounceOpacityThreshold = normalizedSwipeConfig.bounceOpacityThreshold || DEFAULT_MODAL_BOUNCE_OPACITY_THRESHOLD;
/**
* 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(AnimationMode.None);
const shouldRender = useSharedValue(visible);
/**
* React state buffers for shared values (for React hooks dependencies).
*/
const [shouldRenderValue, setShouldRenderValue] = useState(visible);
const [animationModeValue, setAnimationModeValue] = useState(AnimationMode.None);
/**
* 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 === AnimationMode.None;
}, [visible, shouldRenderValue, animationModeValue]);
/**
* Memo: determines if modal needs to close
*/
const isNeedClose = useMemo(() => {
return !visible && shouldRenderValue && animationModeValue === AnimationMode.None;
}, [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 = AnimationMode.None;
}, [progress, offsetX, offsetY, activeSwipeDirection, animationMode]);
/**
* Handles modal opening
*/
const handleOpen = useCallback(() => {
shouldRender.value = true;
animationMode.value = AnimationMode.Open;
progress.value = withTiming(1, {
duration: animationDuration,
easing: Easing.out(Easing.ease)
}, () => {
animationMode.value = AnimationMode.None;
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 !== AnimationMode.None) return;
animationMode.value = AnimationMode.Close;
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 !== AnimationMode.None) return;
activeSwipeDirection.value = null;
}).onUpdate(event => {
// Only set direction once per gesture
if (activeSwipeDirection.value == null) {
if (Math.abs(event.translationX) > Math.abs(event.translationY)) {
const dir = event.translationX > 0 ? 'right' : 'left';
if (isDirectionAllowed(dir)) {
activeSwipeDirection.value = dir;
animationMode.value = AnimationMode.Slide;
}
} else {
const dir = event.translationY > 0 ? 'down' : 'up';
if (isDirectionAllowed(dir)) {
activeSwipeDirection.value = dir;
animationMode.value = AnimationMode.Slide;
}
}
}
// 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 !== AnimationMode.Slide || !activeSwipeDirection.value) {
animationMode.value = AnimationMode.None;
return;
}
const swipeProg = calculateSwipeProgress(offsetX.value, offsetY.value);
if (swipeProg >= 1) {
// Close via swipe
animationMode.value = AnimationMode.Close;
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 = AnimationMode.Bounce;
switch (activeSwipeDirection.value) {
case 'left':
case 'right':
offsetX.value = withSpring(0, bounceSpringConfig, () => {
animationMode.value = AnimationMode.None;
activeSwipeDirection.value = null;
});
break;
case 'up':
case 'down':
offsetY.value = withSpring(0, bounceSpringConfig, () => {
animationMode.value = AnimationMode.None;
activeSwipeDirection.value = null;
});
break;
}
}
});
/**
* Animated style for the backdrop (opacity, fade, bounce correction).
*/
const backdropAnimatedStyle = useAnimatedStyle(() => {
const computedOpacity = !renderBackdrop ? backdropOpacity : 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 === AnimationMode.Bounce && computedOpacity - baseOpacity <= bounceOpacityThreshold) {
baseOpacity = computedOpacity;
}
return {
opacity: interpolate(progress.value, [0, 1], [0, baseOpacity])
};
});
/**
* Animated style for the modal content (slide/fade/scale/gesture transforms).
*/
const contentAnimatedStyle = useAnimatedStyle(() => {
if (activeSwipeDirection.value) {
const baseOpacity = normalizedAnimationConfig.animation === 'fade' ? progress.value : 1;
return {
opacity: baseOpacity,
transform: [{
translateX: offsetX.value
}, {
translateY: offsetY.value
}]
};
}
switch (normalizedAnimationConfig.animation) {
case 'fade':
{
return {
opacity: progress.value,
transform: [{
translateX: 0
}, {
translateY: 0
}]
};
}
case 'scale':
{
const scaleConfig = normalizedAnimationConfig;
const scaleFactor = scaleConfig.scaleFactor || DEFAULT_MODAL_SCALE_FACTOR;
const scale = interpolate(progress.value, [0, 1], [scaleFactor, 1]);
return {
opacity: progress.value,
transform: [{
translateX: 0
}, {
translateY: 0
}, {
scale
}]
};
}
case 'slide':
default:
{
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);
return {
opacity: 1,
transform: [{
translateX: interpolate(progress.value, [0, 1], [entryPos.x, 0])
}, {
translateY: interpolate(progress.value, [0, 1], [entryPos.y, 0])
}]
};
}
}
});
/**
* 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 === AnimationMode.None) {
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 || onHide) ? onBackdropPress || handleClose : undefined,
children: /*#__PURE__*/_jsx(Animated.View, {
style: [styles.absolute, !renderBackdrop && {
backgroundColor: backdropColor
}, backdropAnimatedStyle],
children: renderBackdrop ? renderBackdrop() : 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: /*#__PURE__*/_jsxs(View, {
testID: containerTestID,
style: [styles.root, style],
pointerEvents: "box-none",
children: [renderBackdropInternal(), renderContent()]
})
});
};
//# sourceMappingURL=component.js.map