import * as React from "react"; import { Animated, DeviceEventEmitter, Dimensions, InteractionManager, KeyboardAvoidingView, Modal, PanResponder, PanResponderGestureState, PanResponderInstance, Platform, StyleProp, TouchableWithoutFeedback, View, ViewStyle, ViewProps, } from "react-native"; import * as animatable from "react-native-animatable"; import { Animation, CustomAnimation } from "react-native-animatable"; import { // initializeAnimations, buildAnimations, reversePercentage, } from "./utils"; import styles from "./modal.style"; import { Direction, Orientation, OrNull, AnimationEvent, PresentationStyle, OnOrientationChange, } from "./types"; // Override default react-native-animatable animations // initializeAnimations(); export type OnSwipeCompleteParams = { swipingDirection: Direction; }; type State = { showContent: boolean; isVisible: boolean; deviceWidth: number; deviceHeight: number; isSwipeable: boolean; pan: OrNull; }; export interface ModalProps extends ViewProps { children: React.ReactNode; onSwipeStart?: () => void; onSwipeMove?: (percentageShown: number) => void; onSwipeComplete?: (params: OnSwipeCompleteParams) => void; onSwipeCancel?: () => void; style?: StyleProp; swipeDirection?: Direction | Array; onDismiss?: () => void; onShow?: () => void; hardwareAccelerated?: boolean; onOrientationChange?: OnOrientationChange; presentationStyle?: PresentationStyle; // Default ModalProps Provided animationIn: Animation | CustomAnimation; animationInTiming: number; animationOut: Animation | CustomAnimation; animationOutTiming: number; avoidKeyboard: boolean; coverScreen: boolean; hasBackdrop: boolean; backdropColor: string; backdropOpacity: number; backdropTransitionInTiming: number; backdropTransitionOutTiming: number; customBackdrop: React.ReactNode; useNativeDriver: boolean; useNativeDriverForBackdrop?: boolean; deviceHeight: number; deviceWidth: number; hideModalContentWhileAnimating: boolean; propagateSwipe: boolean; isVisible: boolean; onModalShow: () => void; onModalWillShow: () => void; onModalHide: () => void; onModalWillHide: () => void; onBackButtonPress: () => void; onBackdropPress: () => void; swipeThreshold: number; scrollTo: OrNull<(e: any) => void>; scrollOffset: number; scrollOffsetMax: number; scrollHorizontal: boolean; statusBarTranslucent?: boolean; supportedOrientations?: Orientation[]; } const extractAnimationFromProps = (props: ModalProps) => ({ animationIn: props.animationIn, animationOut: props.animationOut, }); export class ReactNativeModal extends React.Component { public static defaultProps = { animationIn: "slideInUp", animationInTiming: 300, animationOut: "slideOutDown", animationOutTiming: 300, avoidKeyboard: false, coverScreen: true, hasBackdrop: true, backdropColor: "black", backdropOpacity: 0.7, backdropTransitionInTiming: 300, backdropTransitionOutTiming: 300, customBackdrop: null, useNativeDriver: false, deviceHeight: null, deviceWidth: null, hideModalContentWhileAnimating: false, propagateSwipe: false, isVisible: false, onModalShow: () => null, onModalWillShow: () => null, onModalHide: () => null, onModalWillHide: () => null, onBackdropPress: () => null, onBackButtonPress: () => null, swipeThreshold: 100, scrollTo: null, scrollOffset: 0, scrollOffsetMax: 0, scrollHorizontal: false, supportedOrientations: ["portrait", "landscape"], }; // We use an internal state for keeping track of the modal visibility: this allows us to keep // the modal visible during the exit animation, even if the user has already change the // isVisible prop to false. // We store in the state the device width and height so that we can update the modal on // device rotation. state: State = { showContent: true, isVisible: false, deviceWidth: Dimensions.get("screen").width, deviceHeight: Dimensions.get("screen").height, isSwipeable: !!this.props.swipeDirection, pan: null, }; isTransitioning = false; inSwipeClosingState = false; currentSwipingDirection: OrNull = null; animationIn: string; animationOut: string; backdropRef: any; contentRef: any; panResponder: OrNull = null; constructor(props: ModalProps) { super(props); const { animationIn, animationOut } = buildAnimations( extractAnimationFromProps(props), ); this.animationIn = animationIn; this.animationOut = animationOut; if (this.state.isSwipeable) { this.state = { ...this.state, pan: new Animated.ValueXY(), }; this.buildPanResponder(); } if (props.isVisible) { this.state = { ...this.state, isVisible: true, showContent: true, }; } } static getDerivedStateFromProps(nextProps: ModalProps, state: State) { if (!state.isVisible && nextProps.isVisible) { return { isVisible: true, showContent: true }; } return null; } componentDidMount() { // Show deprecation message if ((this.props as any).onSwipe) { console.warn( "`` is deprecated and will be removed starting from 13.0.0. Use `` instead.", ); } DeviceEventEmitter.addListener( "didUpdateDimensions", this.handleDimensionsUpdate, ); if (this.state.isVisible) { this.open(); } } componentWillUnmount() { DeviceEventEmitter.removeListener( "didUpdateDimensions", this.handleDimensionsUpdate, ); } componentDidUpdate(prevProps: ModalProps) { // If the animations have been changed then rebuild them to make sure we're // using the most up-to-date ones if ( this.props.animationIn !== prevProps.animationIn || this.props.animationOut !== prevProps.animationOut ) { const { animationIn, animationOut } = buildAnimations( extractAnimationFromProps(this.props), ); this.animationIn = animationIn; this.animationOut = animationOut; } // If backdrop opacity has been changed then make sure to update it if ( this.props.backdropOpacity !== prevProps.backdropOpacity && this.backdropRef ) { this.backdropRef.transitionTo( { opacity: this.props.backdropOpacity }, this.props.backdropTransitionInTiming, ); } // On modal open request, we slide the view up and fade in the backdrop if (this.props.isVisible && !prevProps.isVisible) { this.open(); } else if (!this.props.isVisible && prevProps.isVisible) { // On modal close request, we slide the view down and fade out the backdrop this.close(); } } getDeviceHeight = () => this.props.deviceHeight || this.state.deviceHeight; getDeviceWidth = () => this.props.deviceWidth || this.state.deviceWidth; buildPanResponder = () => { let animEvt: OrNull = null; this.panResponder = PanResponder.create({ onMoveShouldSetPanResponder: (evt, gestureState) => { // Use propagateSwipe to allow inner content to scroll. See PR: // https://github.com/react-native-community/react-native-modal/pull/246 if (!this.props.propagateSwipe) { // The number "4" is just a good tradeoff to make the panResponder // work correctly even when the modal has touchable buttons. // For reference: // https://github.com/react-native-community/react-native-modal/pull/197 const shouldSetPanResponder = Math.abs(gestureState.dx) >= 4 || Math.abs(gestureState.dy) >= 4; if (shouldSetPanResponder && this.props.onSwipeStart) { this.props.onSwipeStart(); } this.currentSwipingDirection = this.getSwipingDirection(gestureState); animEvt = this.createAnimationEventForSwipe(); return shouldSetPanResponder; } return false; }, onStartShouldSetPanResponder: (e: any) => { const hasScrollableView = e._dispatchInstances && e._dispatchInstances.some((instance: any) => /scrollview|flatlist/i.test(instance.type), ); if ( hasScrollableView && this.props.propagateSwipe && this.props.scrollTo && this.props.scrollOffset > 0 ) { return false; // user needs to be able to scroll content back up } if (this.props.onSwipeStart) { this.props.onSwipeStart(); } // Cleared so that onPanResponderMove can wait to have some delta // to work with this.currentSwipingDirection = null; return true; }, onPanResponderMove: (evt, gestureState) => { // Using onStartShouldSetPanResponder we don't have any delta so we don't know // The direction to which the user is swiping until some move have been done if (!this.currentSwipingDirection) { if (gestureState.dx === 0 && gestureState.dy === 0) { return; } this.currentSwipingDirection = this.getSwipingDirection(gestureState); animEvt = this.createAnimationEventForSwipe(); } if (this.isSwipeDirectionAllowed(gestureState)) { // Dim the background while swiping the modal const newOpacityFactor = 1 - this.calcDistancePercentage(gestureState); this.backdropRef && this.backdropRef.transitionTo({ opacity: this.props.backdropOpacity * newOpacityFactor, }); if (typeof animEvt === "function") { animEvt(evt, gestureState); } if (this.props.onSwipeMove) { this.props.onSwipeMove(newOpacityFactor); } } else { if (this.props.scrollTo) { if (this.props.scrollHorizontal) { let offsetX = -gestureState.dx; if (offsetX > this.props.scrollOffsetMax) { offsetX -= (offsetX - this.props.scrollOffsetMax) / 2; } this.props.scrollTo({ x: offsetX, animated: false }); } else { let offsetY = -gestureState.dy; if (offsetY > this.props.scrollOffsetMax) { offsetY -= (offsetY - this.props.scrollOffsetMax) / 2; } this.props.scrollTo({ y: offsetY, animated: false }); } } } }, onPanResponderRelease: (evt, gestureState) => { // Call the onSwipe prop if the threshold has been exceeded on the right direction const accDistance = this.getAccDistancePerDirection(gestureState); if ( accDistance > this.props.swipeThreshold && this.isSwipeDirectionAllowed(gestureState) ) { if (this.props.onSwipeComplete) { this.inSwipeClosingState = true; this.props.onSwipeComplete({ swipingDirection: this.getSwipingDirection(gestureState), }); return; } // Deprecated. Remove later. if ((this.props as any).onSwipe) { this.inSwipeClosingState = true; (this.props as any).onSwipe(); return; } } //Reset backdrop opacity and modal position if (this.props.onSwipeCancel) { this.props.onSwipeCancel(); } if (this.backdropRef) { this.backdropRef.transitionTo({ opacity: this.props.backdropOpacity, }); } // Animated.spring(this.state.pan!, { Animated.spring(this.state.pan, { toValue: { x: 0, y: 0 }, bounciness: 0, useNativeDriver: false, }).start(); if (this.props.scrollTo) { // if (this.props.scrollOffset > this.props.scrollOffsetMax!) { if (this.props.scrollOffset > this.props.scrollOffsetMax) { this.props.scrollTo({ y: this.props.scrollOffsetMax, animated: true, }); } } }, }); }; getAccDistancePerDirection = (gestureState: PanResponderGestureState) => { switch (this.currentSwipingDirection) { case "up": return -gestureState.dy; case "down": return gestureState.dy; case "right": return gestureState.dx; case "left": return -gestureState.dx; default: return 0; } }; getSwipingDirection = (gestureState: PanResponderGestureState) => { if (Math.abs(gestureState.dx) > Math.abs(gestureState.dy)) { return gestureState.dx > 0 ? "right" : "left"; } return gestureState.dy > 0 ? "down" : "up"; }; calcDistancePercentage = (gestureState: PanResponderGestureState) => { switch (this.currentSwipingDirection) { case "down": return ( (gestureState.moveY - gestureState.y0) / ((this.props.deviceHeight || this.state.deviceHeight) - gestureState.y0) ); case "up": return reversePercentage(gestureState.moveY / gestureState.y0); case "left": return reversePercentage(gestureState.moveX / gestureState.x0); case "right": return ( (gestureState.moveX - gestureState.x0) / ((this.props.deviceWidth || this.state.deviceWidth) - gestureState.x0) ); default: return 0; } }; createAnimationEventForSwipe = () => { if ( this.currentSwipingDirection === "right" || this.currentSwipingDirection === "left" ) { return Animated.event([null, { dx: this.state.pan?.x }], { useNativeDriver: false, }); } else { return Animated.event([null, { dy: this.state.pan?.y }], { useNativeDriver: false, }); } }; isDirectionIncluded = (direction: Direction) => { return Array.isArray(this.props.swipeDirection) ? this.props.swipeDirection.includes(direction) : this.props.swipeDirection === direction; }; isSwipeDirectionAllowed = ({ dy, dx }: PanResponderGestureState) => { const draggedDown = dy > 0; const draggedUp = dy < 0; const draggedLeft = dx < 0; const draggedRight = dx > 0; if ( this.currentSwipingDirection === "up" && this.isDirectionIncluded("up") && draggedUp ) { return true; } else if ( this.currentSwipingDirection === "down" && this.isDirectionIncluded("down") && draggedDown ) { return true; } else if ( this.currentSwipingDirection === "right" && this.isDirectionIncluded("right") && draggedRight ) { return true; } else if ( this.currentSwipingDirection === "left" && this.isDirectionIncluded("left") && draggedLeft ) { return true; } return false; }; handleDimensionsUpdate = () => { if (!this.props.deviceHeight && !this.props.deviceWidth) { // Here we update the device dimensions in the state if the layout changed // (triggering a render) const deviceWidth = Dimensions.get("screen").width; const deviceHeight = Dimensions.get("screen").height; if ( deviceWidth !== this.state.deviceWidth || deviceHeight !== this.state.deviceHeight ) { this.setState({ deviceWidth, deviceHeight }); } } }; open = () => { if (this.isTransitioning) { return; } this.isTransitioning = true; if (this.backdropRef) { this.backdropRef.transitionTo( { opacity: this.props.backdropOpacity }, this.props.backdropTransitionInTiming, ); } // This is for resetting the pan position,otherwise the modal gets stuck // at the last released position when you try to open it. // TODO: Could certainly be improved - no idea for the moment. if (this.state.isSwipeable) { this.state.pan?.setValue({ x: 0, y: 0 }); } if (this.contentRef) { this.props.onModalWillShow && this.props.onModalWillShow(); const interactionHandle = InteractionManager.createInteractionHandle(); this.contentRef .animate(this.animationIn, this.props.animationInTiming) .then(() => { this.isTransitioning = false; InteractionManager.clearInteractionHandle(interactionHandle); if (!this.props.isVisible) { this.close(); } else { this.props.onModalShow(); } }); } }; close = () => { if (this.isTransitioning) { return; } this.isTransitioning = true; if (this.backdropRef) { this.backdropRef.transitionTo( { opacity: 0 }, this.props.backdropTransitionOutTiming, ); } let animationOut = this.animationOut; if (this.inSwipeClosingState) { this.inSwipeClosingState = false; if (this.currentSwipingDirection === "up") { animationOut = "slideOutUp"; } else if (this.currentSwipingDirection === "down") { animationOut = "slideOutDown"; } else if (this.currentSwipingDirection === "right") { animationOut = "slideOutRight"; } else if (this.currentSwipingDirection === "left") { animationOut = "slideOutLeft"; } } if (this.contentRef) { this.props.onModalWillHide && this.props.onModalWillHide(); const interactionHandle = InteractionManager.createInteractionHandle(); this.contentRef .animate(animationOut, this.props.animationOutTiming) .then(() => { this.isTransitioning = false; InteractionManager.clearInteractionHandle(interactionHandle); if (this.props.isVisible) { this.open(); } else { this.setState( { showContent: false, }, () => { this.setState( { isVisible: false, }, () => { this.props.onModalHide(); }, ); }, ); } }); } }; makeBackdrop = () => { if (!this.props.hasBackdrop) { return null; } if ( this.props.customBackdrop && !React.isValidElement(this.props.customBackdrop) ) { console.warn( "Invalid customBackdrop element passed to Modal. You must provide a valid React element.", ); } const { customBackdrop, backdropColor, useNativeDriver, useNativeDriverForBackdrop, onBackdropPress, } = this.props; const hasCustomBackdrop = !!this.props.customBackdrop; const backdropComputedStyle = [ { width: this.getDeviceWidth(), height: this.getDeviceHeight(), backgroundColor: this.state.showContent && !hasCustomBackdrop ? backdropColor : "transparent", }, ]; const backdropWrapper = ( (this.backdropRef = ref)} useNativeDriver={ useNativeDriverForBackdrop !== undefined ? useNativeDriverForBackdrop : useNativeDriver } style={[styles.backdrop, backdropComputedStyle]}> {hasCustomBackdrop && customBackdrop} ); if (hasCustomBackdrop) { // The user will handle backdrop presses himself return backdropWrapper; } // If there's no custom backdrop, handle presses with // TouchableWithoutFeedback return ( {backdropWrapper} ); }; render() { /* eslint-disable @typescript-eslint/no-unused-vars */ const { animationIn, animationInTiming, animationOut, animationOutTiming, avoidKeyboard, coverScreen, hasBackdrop, backdropColor, backdropOpacity, backdropTransitionInTiming, backdropTransitionOutTiming, customBackdrop, children, isVisible, onModalShow, onBackButtonPress, useNativeDriver, propagateSwipe, style, ...otherProps } = this.props; const { testID, ...containerProps } = otherProps; const computedStyle = [ { margin: this.getDeviceWidth() * 0.05, transform: [{ translateY: 0 }] }, styles.content, style, ]; let panHandlers = {}; let panPosition = {}; if (this.state.isSwipeable) { panHandlers = { ...this.panResponder?.panHandlers }; if (useNativeDriver) { panPosition = { transform: this.state.pan?.getTranslateTransform(), }; } else { panPosition = this.state.pan?.getLayout(); } } // The user might decide not to show the modal while it is animating // to enhance performance. const _children = this.props.hideModalContentWhileAnimating && this.props.useNativeDriver && !this.state.showContent ? ( ) : ( children ); const containerView = ( (this.contentRef = ref)} style={[panPosition, computedStyle]} pointerEvents="box-none" useNativeDriver={useNativeDriver} {...containerProps}> {_children} ); // If coverScreen is set to false by the user // we render the modal inside the parent view directly if (!coverScreen && this.state.isVisible) { return ( {this.makeBackdrop()} {containerView} ); } return ( {this.makeBackdrop()} {avoidKeyboard ? ( {containerView} ) : ( containerView )} ); } } export default ReactNativeModal;