UNPKG

react-native-modal-v2

Version:

Fix backdrop opacity bug To React Native modal

584 lines (583 loc) 27.3 kB
import * as React from 'react'; import { Animated, DeviceEventEmitter, Dimensions, InteractionManager, KeyboardAvoidingView, Modal, PanResponder, BackHandler, Platform, TouchableWithoutFeedback, View, } from 'react-native'; import * as PropTypes from 'prop-types'; import * as animatable from 'react-native-animatable'; import { initializeAnimations, buildAnimations, reversePercentage, } from './utils'; import styles from './modal.style'; // Override default react-native-animatable animations initializeAnimations(); const 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, panResponderThreshold: 4, swipeThreshold: 100, onModalShow: (() => null), onModalWillShow: (() => null), onModalHide: (() => null), onModalWillHide: (() => null), onBackdropPress: (() => null), onBackButtonPress: (() => null), scrollTo: null, scrollOffset: 0, scrollOffsetMax: 0, scrollHorizontal: false, statusBarTranslucent: false, supportedOrientations: ['portrait', 'landscape'], }; const extractAnimationFromProps = (props) => ({ animationIn: props.animationIn, animationOut: props.animationOut, }); export class ReactNativeModal extends React.Component { constructor(props) { super(props); // 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. this.state = { showContent: true, isVisible: false, deviceWidth: Dimensions.get('window').width, deviceHeight: Dimensions.get('window').height, isSwipeable: !!this.props.swipeDirection, pan: null, }; this.isTransitioning = false; this.inSwipeClosingState = false; this.currentSwipingDirection = null; this.panResponder = null; this.didUpdateDimensionsEmitter = null; this.interactionHandle = null; this.getDeviceHeight = () => this.props.deviceHeight || this.state.deviceHeight; this.getDeviceWidth = () => this.props.deviceWidth || this.state.deviceWidth; this.onBackButtonPress = () => { if (this.props.onBackButtonPress && this.props.isVisible) { this.props.onBackButtonPress(); return true; } return false; }; this.shouldPropagateSwipe = (evt, gestureState) => { return typeof this.props.propagateSwipe === 'function' ? this.props.propagateSwipe(evt, gestureState) : this.props.propagateSwipe; }; this.buildPanResponder = () => { let animEvt = 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.shouldPropagateSwipe(evt, gestureState)) { // The number "4" is just a good tradeoff to make the panResponder // work correctly even when the modal has touchable buttons. // However, if you want to overwrite this and choose for yourself, // set panResponderThreshold in the props. // For reference: // https://github.com/react-native-community/react-native-modal/pull/197 const shouldSetPanResponder = Math.abs(gestureState.dx) >= this.props.panResponderThreshold || Math.abs(gestureState.dy) >= this.props.panResponderThreshold; if (shouldSetPanResponder && this.props.onSwipeStart) { this.props.onSwipeStart(gestureState); } this.currentSwipingDirection = this.getSwipingDirection(gestureState); animEvt = this.createAnimationEventForSwipe(); return shouldSetPanResponder; } return false; }, onStartShouldSetPanResponder: (e, gestureState) => { const hasScrollableView = e._dispatchInstances && e._dispatchInstances.some((instance) => /scrollview|flatlist/i.test(instance.type)); if (hasScrollableView && this.shouldPropagateSwipe(e, gestureState) && 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(gestureState); } // 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, }); animEvt(evt, gestureState); if (this.props.onSwipeMove) { this.props.onSwipeMove(newOpacityFactor, gestureState); } } 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), }, gestureState); return; } // Deprecated. Remove later. if (this.props.onSwipe) { this.inSwipeClosingState = true; this.props.onSwipe(); return; } } //Reset backdrop opacity and modal position if (this.props.onSwipeCancel) { this.props.onSwipeCancel(gestureState); } if (this.backdropRef) { this.backdropRef.transitionTo({ opacity: this.props.backdropOpacity, }); } 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) { this.props.scrollTo({ y: this.props.scrollOffsetMax, animated: true, }); } } }, }); }; this.getAccDistancePerDirection = (gestureState) => { 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; } }; this.getSwipingDirection = (gestureState) => { if (Math.abs(gestureState.dx) > Math.abs(gestureState.dy)) { return gestureState.dx > 0 ? 'right' : 'left'; } return gestureState.dy > 0 ? 'down' : 'up'; }; this.calcDistancePercentage = (gestureState) => { 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; } }; this.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, }); } }; this.isDirectionIncluded = (direction) => { return Array.isArray(this.props.swipeDirection) ? this.props.swipeDirection.includes(direction) : this.props.swipeDirection === direction; }; this.isSwipeDirectionAllowed = ({ dy, dx }) => { 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; }; this.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('window').width; const deviceHeight = Dimensions.get('window').height; if (deviceWidth !== this.state.deviceWidth || deviceHeight !== this.state.deviceHeight) { this.setState({ deviceWidth, deviceHeight }); } } }; this.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(); if (this.interactionHandle == null) { this.interactionHandle = InteractionManager.createInteractionHandle(); } this.contentRef .animate(this.animationIn, this.props.animationInTiming) .then(() => { this.isTransitioning = false; if (this.interactionHandle) { InteractionManager.clearInteractionHandle(this.interactionHandle); this.interactionHandle = null; } if (!this.props.isVisible) { this.close(); } else { this.props.onModalShow(); } }); } }; this.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(); if (this.interactionHandle == null) { this.interactionHandle = InteractionManager.createInteractionHandle(); } this.contentRef .animate(animationOut, this.props.animationOutTiming) .then(() => { this.isTransitioning = false; if (this.interactionHandle) { InteractionManager.clearInteractionHandle(this.interactionHandle); this.interactionHandle = null; } if (this.props.isVisible) { this.open(); } else { this.setState({ showContent: false, }, () => { this.setState({ isVisible: false, }, () => { this.props.onModalHide(); }); }); } }); } }; this.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', opacity: this.props.backdropOpacity, }, ]; const backdropWrapper = (React.createElement(animatable.View, { ref: ref => (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 (React.createElement(TouchableWithoutFeedback, { onPress: onBackdropPress }, backdropWrapper)); }; 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, state) { if (!state.isVisible && nextProps.isVisible) { return { isVisible: true, showContent: true }; } return null; } componentDidMount() { // Show deprecation message if (this.props.onSwipe) { console.warn('`<Modal onSwipe="..." />` is deprecated and will be removed starting from 13.0.0. Use `<Modal onSwipeComplete="..." />` instead.'); } this.didUpdateDimensionsEmitter = DeviceEventEmitter.addListener('didUpdateDimensions', this.handleDimensionsUpdate); if (this.state.isVisible) { this.open(); } BackHandler.addEventListener('hardwareBackPress', this.onBackButtonPress); } componentWillUnmount() { BackHandler.removeEventListener('hardwareBackPress', this.onBackButtonPress); if (this.didUpdateDimensionsEmitter) { this.didUpdateDimensionsEmitter.remove(); } if (this.interactionHandle) { InteractionManager.clearInteractionHandle(this.interactionHandle); this.interactionHandle = null; } } componentDidUpdate(prevProps) { // 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(); } } 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 ? (React.createElement(animatable.View, null)) : (children); const containerView = (React.createElement(animatable.View, Object.assign({}, panHandlers, { ref: ref => (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 (React.createElement(View, { pointerEvents: "box-none", style: [styles.backdrop, styles.containerBox] }, this.makeBackdrop(), containerView)); } return (React.createElement(Modal, Object.assign({ transparent: true, animationType: 'none', visible: this.state.isVisible, onRequestClose: onBackButtonPress }, otherProps), this.makeBackdrop(), avoidKeyboard ? (React.createElement(KeyboardAvoidingView, { behavior: Platform.OS === 'ios' ? 'padding' : undefined, pointerEvents: "box-none", style: computedStyle.concat([{ margin: 0 }]) }, containerView)) : (containerView))); } } ReactNativeModal.propTypes = { animationIn: PropTypes.oneOfType([PropTypes.string, PropTypes.object]), animationInTiming: PropTypes.number, animationOut: PropTypes.oneOfType([PropTypes.string, PropTypes.object]), animationOutTiming: PropTypes.number, avoidKeyboard: PropTypes.bool, coverScreen: PropTypes.bool, hasBackdrop: PropTypes.bool, backdropColor: PropTypes.string, backdropOpacity: PropTypes.number, backdropTransitionInTiming: PropTypes.number, backdropTransitionOutTiming: PropTypes.number, customBackdrop: PropTypes.node, children: PropTypes.node.isRequired, deviceHeight: PropTypes.number, deviceWidth: PropTypes.number, isVisible: PropTypes.bool.isRequired, hideModalContentWhileAnimating: PropTypes.bool, propagateSwipe: PropTypes.oneOfType([PropTypes.bool, PropTypes.func]), onModalShow: PropTypes.func, onModalWillShow: PropTypes.func, onModalHide: PropTypes.func, onModalWillHide: PropTypes.func, onBackButtonPress: PropTypes.func, onBackdropPress: PropTypes.func, panResponderThreshold: PropTypes.number, onSwipeStart: PropTypes.func, onSwipeMove: PropTypes.func, onSwipeComplete: PropTypes.func, onSwipeCancel: PropTypes.func, swipeThreshold: PropTypes.number, swipeDirection: PropTypes.oneOfType([ PropTypes.arrayOf(PropTypes.oneOf(['up', 'down', 'left', 'right'])), PropTypes.oneOf(['up', 'down', 'left', 'right']), ]), useNativeDriver: PropTypes.bool, useNativeDriverForBackdrop: PropTypes.bool, style: PropTypes.any, scrollTo: PropTypes.func, scrollOffset: PropTypes.number, scrollOffsetMax: PropTypes.number, scrollHorizontal: PropTypes.bool, supportedOrientations: PropTypes.arrayOf(PropTypes.oneOf([ 'portrait', 'portrait-upside-down', 'landscape', 'landscape-left', 'landscape-right', ])), }; ReactNativeModal.defaultProps = defaultProps; export default ReactNativeModal;