From 1794627a158de460ba867fb588fb93eab9298d9d Mon Sep 17 00:00:00 2001 From: Neil Marcellini Date: Tue, 16 Sep 2025 14:20:35 -0700 Subject: [PATCH] Revert "Remove the `react-native-modal` library" --- .eslintrc.js | 4 + config/webpack/webpack.common.ts | 2 + package-lock.json | 20 +++ package.json | 1 + ...-native-animatable+1.3.3+001+initial.patch | 23 +++ ...atable+1.3.3+002+fixAnimationFlicker.patch | 49 +++++++ patches/react-native-modal/details.md | 22 +++ ...eact-native-modal+13.0.1+001+initial.patch | 82 +++++++++++ ...13.0.1+002+fix-modal-flicker-on-open.patch | 21 +++ ...002+modal-navigation-bar-translucent.patch | 58 ++++++++ src/components/AttachmentComposerModal.tsx | 1 + src/components/AttachmentModal.tsx | 1 + src/components/Modal/BaseModal.tsx | 137 +++++++++++++++++- src/components/Modal/ModalContent.tsx | 29 ++++ .../Modal/ReanimatedModal/index.tsx | 5 +- src/components/Modal/ReanimatedModal/types.ts | 21 ++- src/components/Modal/ReanimatedModal/utils.ts | 8 +- src/components/Modal/index.android.tsx | 8 +- src/components/Modal/index.ios.tsx | 1 + src/components/Modal/index.tsx | 2 + src/components/Modal/types.ts | 34 ++++- src/components/Popover/index.tsx | 1 + src/components/PopoverMenu.tsx | 6 +- .../PopoverWithMeasuredContentBase.tsx | 2 + src/components/PopoverWithoutOverlay/types.ts | 2 +- .../Search/SearchRouter/SearchRouterModal.tsx | 1 + .../SidePanel/HelpComponents/HelpContent.tsx | 5 +- .../SidePanel/HelpModal/index.ios.tsx | 1 + src/components/TestDrive/TestDriveDemo.tsx | 1 + src/hooks/useResponsiveLayout/index.native.ts | 10 +- src/hooks/useResponsiveLayout/index.ts | 10 +- .../AttachmentModalContainer/index.tsx | 13 ++ .../utils/generators/ModalStyleUtils.ts | 8 +- 33 files changed, 542 insertions(+), 47 deletions(-) create mode 100644 patches/react-native-animatable+1.3.3+001+initial.patch create mode 100644 patches/react-native-animatable+1.3.3+002+fixAnimationFlicker.patch create mode 100644 patches/react-native-modal/details.md create mode 100644 patches/react-native-modal/react-native-modal+13.0.1+001+initial.patch create mode 100644 patches/react-native-modal/react-native-modal+13.0.1+002+fix-modal-flicker-on-open.patch create mode 100644 patches/react-native-modal/react-native-modal+13.0.1+002+modal-navigation-bar-translucent.patch create mode 100644 src/components/Modal/ModalContent.tsx diff --git a/.eslintrc.js b/.eslintrc.js index 858df25d62542..a5d36dec42c3e 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -97,6 +97,10 @@ const restrictedImportPaths = [ importNames: ['isEqual'], message: "Please use 'deepEqual' from 'fast-equals' instead.", }, + { + name: 'react-native-animatable', + message: "Please use 'react-native-reanimated' instead.", + }, { name: 'react-native-onyx', importNames: ['useOnyx'], diff --git a/config/webpack/webpack.common.ts b/config/webpack/webpack.common.ts index 38ed6cfc8bac6..691d5a4200e1d 100644 --- a/config/webpack/webpack.common.ts +++ b/config/webpack/webpack.common.ts @@ -27,6 +27,7 @@ type PreloadWebpackPluginClass = Class; const PreloadWebpackPlugin = require('@vue/preload-webpack-plugin') as PreloadWebpackPluginClass; const includeModules = [ + 'react-native-animatable', 'react-native-reanimated', 'react-native-picker-select', 'react-native-web', @@ -36,6 +37,7 @@ const includeModules = [ '@react-navigation/native', '@react-navigation/native-stack', '@react-navigation/stack', + 'react-native-modal', 'react-native-gesture-handler', 'react-native-google-places-autocomplete', 'react-native-qrcode-svg', diff --git a/package-lock.json b/package-lock.json index 43fbc9e3688f5..5a4a48f939bd3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -111,6 +111,7 @@ "react-native-keyboard-controller": "1.18.5", "react-native-launch-arguments": "^4.0.2", "react-native-localize": "^2.2.6", + "react-native-modal": "^13.0.0", "react-native-nitro-modules": "0.26.2", "react-native-nitro-sqlite": "9.1.10", "react-native-onyx": "2.0.138", @@ -32305,6 +32306,13 @@ "react-native": "*" } }, + "node_modules/react-native-animatable": { + "version": "1.3.3", + "license": "MIT", + "dependencies": { + "prop-types": "^15.7.2" + } + }, "node_modules/react-native-app-logs": { "version": "0.3.1", "license": "MIT", @@ -32554,6 +32562,18 @@ } } }, + "node_modules/react-native-modal": { + "version": "13.0.1", + "license": "MIT", + "dependencies": { + "prop-types": "^15.6.2", + "react-native-animatable": "1.3.3" + }, + "peerDependencies": { + "react": "*", + "react-native": ">=0.65.0" + } + }, "node_modules/react-native-nitro-modules": { "version": "0.26.2", "hasInstallScript": true, diff --git a/package.json b/package.json index c5766f87ef5b3..12a2c70339d75 100644 --- a/package.json +++ b/package.json @@ -181,6 +181,7 @@ "react-native-keyboard-controller": "1.18.5", "react-native-launch-arguments": "^4.0.2", "react-native-localize": "^2.2.6", + "react-native-modal": "^13.0.0", "react-native-nitro-modules": "0.26.2", "react-native-nitro-sqlite": "9.1.10", "react-native-onyx": "2.0.138", diff --git a/patches/react-native-animatable+1.3.3+001+initial.patch b/patches/react-native-animatable+1.3.3+001+initial.patch new file mode 100644 index 0000000000000..ebb4398342c1b --- /dev/null +++ b/patches/react-native-animatable+1.3.3+001+initial.patch @@ -0,0 +1,23 @@ +diff --git a/node_modules/react-native-animatable/createAnimatableComponent.js b/node_modules/react-native-animatable/createAnimatableComponent.js +index 2847e12..331d44f 100644 +--- a/node_modules/react-native-animatable/createAnimatableComponent.js ++++ b/node_modules/react-native-animatable/createAnimatableComponent.js +@@ -465,7 +465,9 @@ export default function createAnimatableComponent(WrappedComponent) { + const needsZeroClamping = + ZERO_CLAMPED_STYLE_PROPERTIES.indexOf(property) !== -1; + if (needsInterpolation) { +- transitionValue.setValue(0); ++ transitionValue = new Animated.Value(0); ++ transitionValues[property] = transitionValue; ++ + transitionStyle[property] = transitionValue.interpolate({ + inputRange: [0, 1], + outputRange: [fromValue, toValue], +@@ -546,7 +548,6 @@ export default function createAnimatableComponent(WrappedComponent) { + transitions.to[property] = toValue; + } + }); +- + if (Object.keys(transitions.from).length) { + this.transition(transitions.from, transitions.to, duration, easing); + } diff --git a/patches/react-native-animatable+1.3.3+002+fixAnimationFlicker.patch b/patches/react-native-animatable+1.3.3+002+fixAnimationFlicker.patch new file mode 100644 index 0000000000000..8649793b4ca79 --- /dev/null +++ b/patches/react-native-animatable+1.3.3+002+fixAnimationFlicker.patch @@ -0,0 +1,49 @@ +diff --git a/node_modules/react-native-animatable/createAnimatableComponent.js b/node_modules/react-native-animatable/createAnimatableComponent.js +index 331d44f..fd92b8e 100644 +--- a/node_modules/react-native-animatable/createAnimatableComponent.js ++++ b/node_modules/react-native-animatable/createAnimatableComponent.js +@@ -362,14 +362,17 @@ export default function createAnimatableComponent(WrappedComponent) { + + setAnimation(animation, callback) { + const compiledAnimation = getCompiledAnimation(animation); ++ const animationValue = new Animated.Value(0); ++ + this.setState( +- state => ({ ++ { + animationStyle: makeInterpolatedStyle( + compiledAnimation, +- state.animationValue, ++ animationValue, + ), + compiledAnimation, +- }), ++ animationValue, ++ }, + callback, + ); + } +@@ -401,7 +404,6 @@ export default function createAnimatableComponent(WrappedComponent) { + let currentIteration = iteration || 0; + const fromValue = getAnimationOrigin(currentIteration, direction); + const toValue = getAnimationTarget(currentIteration, direction); +- animationValue.setValue(fromValue); + + if (typeof easing === 'string') { + easing = EASING_FUNCTIONS[easing]; +@@ -422,7 +424,6 @@ export default function createAnimatableComponent(WrappedComponent) { + useNativeDriver, + delay: iterationDelay || 0, + }; +- + Animated.timing(animationValue, config).start(endState => { + currentIteration += 1; + if ( +@@ -467,7 +468,6 @@ export default function createAnimatableComponent(WrappedComponent) { + if (needsInterpolation) { + transitionValue = new Animated.Value(0); + transitionValues[property] = transitionValue; +- + transitionStyle[property] = transitionValue.interpolate({ + inputRange: [0, 1], + outputRange: [fromValue, toValue], diff --git a/patches/react-native-modal/details.md b/patches/react-native-modal/details.md new file mode 100644 index 0000000000000..6b0946da85680 --- /dev/null +++ b/patches/react-native-modal/details.md @@ -0,0 +1,22 @@ +# `react-native-modal` patches + +### [react-native-modal+13.0.1+001+initial.patch](react-native-modal+13.0.1+001+initial.patch) +- Reason: Add ESC key to close the modal +- Upstream PR/issue: N/A +- E/App issue: [#11930](https://github.com/Expensify/App/issues/11930) +- PR Introducing Patch: [#12864](https://github.com/Expensify/App/pull/12864) +- PR Updating Patch: [#18221](https://github.com/Expensify/App/pull/18221), [#48160](https://github.com/Expensify/App/pull/48160) + +### [react-native-modal+13.0.1+002+fix-modal-flicker-on-open.patch](react-native-modal+13.0.1+002+fix-modal-flicker-on-open.patch) +- Reason: Fix modal flickers on iOS +- Upstream PR/issue: N/A +- E/App issue: [#48911](https://github.com/Expensify/App/issues/48911) +- PR Introducing Patch: [#51475](https://github.com/Expensify/App/pull/51475) +- PR Updating Patch: N/A + +### [react-native-modal+13.0.1+002+modal-navigation-bar-translucent.patch](react-native-modal+13.0.1+002+modal-navigation-bar-translucent.patch) +- Reason: Fix navigationBarTranslucent property +- Upstream PR/issue: N/A +- E/App issue: [#52116](https://github.com/Expensify/App/issues/52116) +- PR Introducing Patch: [#52392](https://github.com/Expensify/App/pull/52392) +- PR Updating Patch: [#55861](https://github.com/Expensify/App/pull/55861) diff --git a/patches/react-native-modal/react-native-modal+13.0.1+001+initial.patch b/patches/react-native-modal/react-native-modal+13.0.1+001+initial.patch new file mode 100644 index 0000000000000..818b7f0b3d544 --- /dev/null +++ b/patches/react-native-modal/react-native-modal+13.0.1+001+initial.patch @@ -0,0 +1,82 @@ +diff --git a/node_modules/react-native-modal/dist/modal.d.ts b/node_modules/react-native-modal/dist/modal.d.ts +index b63bcfc..bd6419e 100644 +--- a/node_modules/react-native-modal/dist/modal.d.ts ++++ b/node_modules/react-native-modal/dist/modal.d.ts +@@ -161,6 +161,7 @@ export declare class ReactNativeModal extends React.Component + getDeviceHeight: () => number; + getDeviceWidth: () => number; + onBackButtonPress: () => boolean; ++ handleEscape: (e: KeyboardEvent) => void; + shouldPropagateSwipe: (evt: GestureResponderEvent, gestureState: PanResponderGestureState) => boolean; + buildPanResponder: () => void; + getAccDistancePerDirection: (gestureState: PanResponderGestureState) => number; +diff --git a/node_modules/react-native-modal/dist/modal.js b/node_modules/react-native-modal/dist/modal.js +index 80f4e75..5c9d275 100644 +--- a/node_modules/react-native-modal/dist/modal.js ++++ b/node_modules/react-native-modal/dist/modal.js +@@ -75,6 +75,13 @@ export class ReactNativeModal extends React.Component { + } + return false; + }; ++ this.handleEscape = (e) => { ++ if (e.key === 'Escape') { ++ if (this.onBackButtonPress() === true) { ++ e.stopImmediatePropagation(); ++ } ++ } ++ }; + this.shouldPropagateSwipe = (evt, gestureState) => { + return typeof this.props.propagateSwipe === 'function' + ? this.props.propagateSwipe(evt, gestureState) +@@ -383,7 +390,9 @@ export class ReactNativeModal extends React.Component { + this.setState({ + isVisible: false, + }, () => { +- this.props.onModalHide(); ++ if (Platform.OS !== 'ios') { ++ this.props.onModalHide(); ++ } + }); + }); + } +@@ -453,10 +462,18 @@ export class ReactNativeModal extends React.Component { + if (this.state.isVisible) { + this.open(); + } ++ if (Platform.OS === 'web') { ++ document?.body?.addEventListener?.('keyup', this.handleEscape, true); ++ return; ++ } + BackHandler.addEventListener('hardwareBackPress', this.onBackButtonPress); + } + componentWillUnmount() { +- BackHandler.removeEventListener('hardwareBackPress', this.onBackButtonPress); ++ if (Platform.OS === 'web') { ++ document?.body?.removeEventListener?.('keyup', this.handleEscape, true); ++ } else { ++ BackHandler.removeEventListener('hardwareBackPress', this.onBackButtonPress); ++ } + if (this.didUpdateDimensionsEmitter) { + this.didUpdateDimensionsEmitter.remove(); + } +@@ -490,7 +507,7 @@ export class ReactNativeModal extends React.Component { + } + 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 { animationIn, animationInTiming, animationOut, animationOutTiming, avoidKeyboard, coverScreen, hasBackdrop, backdropColor, backdropOpacity, backdropTransitionInTiming, backdropTransitionOutTiming, customBackdrop, children, isVisible, onModalShow, onBackButtonPress, useNativeDriver, propagateSwipe, style, onDismiss, ...otherProps } = this.props; + const { testID, ...containerProps } = otherProps; + const computedStyle = [ + { margin: this.getDeviceWidth() * 0.05, transform: [{ translateY: 0 }] }, +@@ -523,9 +540,9 @@ export class ReactNativeModal extends React.Component { + this.makeBackdrop(), + containerView)); + } +- return (React.createElement(Modal, Object.assign({ transparent: true, animationType: 'none', visible: this.state.isVisible, onRequestClose: onBackButtonPress }, otherProps), ++ return (React.createElement(Modal, Object.assign({ transparent: true, animationType: 'none', visible: this.state.isVisible, onRequestClose: onBackButtonPress, onDismiss: () => {onDismiss();if (Platform.OS === 'ios'){this.props.onModalHide();}} }, otherProps), + this.makeBackdrop(), +- avoidKeyboard ? (React.createElement(KeyboardAvoidingView, { behavior: Platform.OS === 'ios' ? 'padding' : undefined, pointerEvents: "box-none", style: computedStyle.concat([{ margin: 0 }]) }, containerView)) : (containerView))); ++ avoidKeyboard ? (React.createElement(KeyboardAvoidingView, { behavior: 'padding', pointerEvents: "box-none", style: computedStyle.concat([{ margin: 0 }]) }, containerView)) : (containerView))); + } + } + ReactNativeModal.propTypes = { diff --git a/patches/react-native-modal/react-native-modal+13.0.1+002+fix-modal-flicker-on-open.patch b/patches/react-native-modal/react-native-modal+13.0.1+002+fix-modal-flicker-on-open.patch new file mode 100644 index 0000000000000..98c4b83ef20a4 --- /dev/null +++ b/patches/react-native-modal/react-native-modal+13.0.1+002+fix-modal-flicker-on-open.patch @@ -0,0 +1,21 @@ +diff --git a/node_modules/react-native-modal/dist/modal.js b/node_modules/react-native-modal/dist/modal.js +index 46277ea..2e70c0c 100644 +--- a/node_modules/react-native-modal/dist/modal.js ++++ b/node_modules/react-native-modal/dist/modal.js +@@ -4,6 +4,7 @@ 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 = { +@@ -535,7 +536,7 @@ export class ReactNativeModal extends React.Component { + 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)); ++ const containerView = (React.createElement(animatable.View, Object.assign({}, panHandlers, { ref: ref => (this.contentRef = ref), style: [panPosition, computedStyle], pointerEvents: "box-none", useNativeDriver: useNativeDriver, animation: animationIn }, 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) { diff --git a/patches/react-native-modal/react-native-modal+13.0.1+002+modal-navigation-bar-translucent.patch b/patches/react-native-modal/react-native-modal+13.0.1+002+modal-navigation-bar-translucent.patch new file mode 100644 index 0000000000000..edeff44728832 --- /dev/null +++ b/patches/react-native-modal/react-native-modal+13.0.1+002+modal-navigation-bar-translucent.patch @@ -0,0 +1,58 @@ +diff --git a/node_modules/react-native-modal/dist/modal.d.ts b/node_modules/react-native-modal/dist/modal.d.ts +index bd6419e..029762c 100644 +--- a/node_modules/react-native-modal/dist/modal.d.ts ++++ b/node_modules/react-native-modal/dist/modal.d.ts +@@ -46,6 +46,7 @@ declare const defaultProps: { + scrollOffsetMax: number; + scrollHorizontal: boolean; + statusBarTranslucent: boolean; ++ navigationBarTranslucent: boolean; + supportedOrientations: ("landscape" | "portrait" | "portrait-upside-down" | "landscape-left" | "landscape-right")[]; + }; + export declare type ModalProps = ViewProps & { +@@ -137,6 +138,7 @@ export declare class ReactNativeModal extends React.Component + scrollOffsetMax: number; + scrollHorizontal: boolean; + statusBarTranslucent: boolean; ++ navigationBarTranslucent: boolean; + supportedOrientations: ("landscape" | "portrait" | "portrait-upside-down" | "landscape-left" | "landscape-right")[]; + }; + state: State; +diff --git a/node_modules/react-native-modal/dist/modal.js b/node_modules/react-native-modal/dist/modal.js +index 2e70c0c..7a39705 100644 +--- a/node_modules/react-native-modal/dist/modal.js ++++ b/node_modules/react-native-modal/dist/modal.js +@@ -39,6 +39,7 @@ const defaultProps = { + scrollOffsetMax: 0, + scrollHorizontal: false, + statusBarTranslucent: false, ++ navigationBarTranslucent: false, + supportedOrientations: ['portrait', 'landscape'], + }; + const extractAnimationFromProps = (props) => ({ +@@ -69,6 +70,7 @@ export class ReactNativeModal extends React.Component { + this.interactionHandle = null; + this.getDeviceHeight = () => this.props.deviceHeight || this.state.deviceHeight; + this.getDeviceWidth = () => this.props.deviceWidth || this.state.deviceWidth; ++ this.backHandlerEventSubscription = null; + this.onBackButtonPress = () => { + if (this.props.onBackButtonPress && this.props.isVisible) { + this.props.onBackButtonPress(); +@@ -467,13 +469,15 @@ export class ReactNativeModal extends React.Component { + document?.body?.addEventListener?.('keyup', this.handleEscape, true); + return; + } +- BackHandler.addEventListener('hardwareBackPress', this.onBackButtonPress); ++ this.backHandlerEventSubscription = BackHandler.addEventListener('hardwareBackPress', this.onBackButtonPress); + } + componentWillUnmount() { + if (Platform.OS === 'web') { + document?.body?.removeEventListener?.('keyup', this.handleEscape, true); + } else { +- BackHandler.removeEventListener('hardwareBackPress', this.onBackButtonPress); ++ if (this.backHandlerEventSubscription) { ++ this.backHandlerEventSubscription.remove(); ++ } + } + if (this.didUpdateDimensionsEmitter) { + this.didUpdateDimensionsEmitter.remove(); diff --git a/src/components/AttachmentComposerModal.tsx b/src/components/AttachmentComposerModal.tsx index 35bcfb10b4d76..8825746efdb70 100644 --- a/src/components/AttachmentComposerModal.tsx +++ b/src/components/AttachmentComposerModal.tsx @@ -225,6 +225,7 @@ function AttachmentComposerModal({onConfirm, onModalShow = () => {}, onModalHide setCurrentAttachment(null); setPage(0); }} + propagateSwipe initialFocus={() => { if (!submitRef.current) { return false; diff --git a/src/components/AttachmentModal.tsx b/src/components/AttachmentModal.tsx index b20a26152db93..df3a963d2f876 100644 --- a/src/components/AttachmentModal.tsx +++ b/src/components/AttachmentModal.tsx @@ -450,6 +450,7 @@ function AttachmentModal({ }); } }} + propagateSwipe initialFocus={() => { if (!submitRef.current) { return false; diff --git a/src/components/Modal/BaseModal.tsx b/src/components/Modal/BaseModal.tsx index ceda868d53a0f..f9bb8e501f8cc 100644 --- a/src/components/Modal/BaseModal.tsx +++ b/src/components/Modal/BaseModal.tsx @@ -3,7 +3,11 @@ import type {LayoutChangeEvent} from 'react-native'; // Animated required for side panel navigation // eslint-disable-next-line no-restricted-imports import {Animated, View} from 'react-native'; +import type {ModalProps as ReactNativeModalProps} from 'react-native-modal'; +import ReactNativeModal from 'react-native-modal'; +import type {ValueOf} from 'type-fest'; import ColorSchemeWrapper from '@components/ColorSchemeWrapper'; +import FocusTrapForModal from '@components/FocusTrap/FocusTrapForModal'; import NavigationBar from '@components/NavigationBar'; import ScreenWrapperOfflineIndicatorContext from '@components/ScreenWrapper/ScreenWrapperOfflineIndicatorContext'; import useKeyboardState from '@hooks/useKeyboardState'; @@ -22,9 +26,93 @@ import Overlay from '@libs/Navigation/AppNavigator/Navigators/Overlay'; import Navigation from '@libs/Navigation/Navigation'; import {areAllModalsHidden, closeTop, onModalDidClose, setCloseModal, setModalVisibility, willAlertModalBecomeVisible} from '@userActions/Modal'; import CONST from '@src/CONST'; +import ModalContent from './ModalContent'; import ModalContext from './ModalContext'; import ReanimatedModal from './ReanimatedModal'; +import type ReanimatedModalProps from './ReanimatedModal/types'; import type BaseModalProps from './types'; +import type {FocusTrapOptions} from './types'; + +const REANIMATED_MODAL_TYPES: Array> = [ + CONST.MODAL.MODAL_TYPE.BOTTOM_DOCKED, + CONST.MODAL.MODAL_TYPE.FULLSCREEN, + CONST.MODAL.MODAL_TYPE.POPOVER, + CONST.MODAL.MODAL_TYPE.RIGHT_DOCKED, + CONST.MODAL.MODAL_TYPE.CENTERED, + CONST.MODAL.MODAL_TYPE.CENTERED_SMALL, + CONST.MODAL.MODAL_TYPE.CENTERED_UNSWIPEABLE, + CONST.MODAL.MODAL_TYPE.CENTERED_SWIPEABLE_TO_RIGHT, + CONST.MODAL.MODAL_TYPE.CONFIRM, +]; + +type ModalComponentProps = (ReactNativeModalProps | ReanimatedModalProps) & { + type?: ValueOf; + shouldUseReanimatedModal?: boolean; + shouldPreventScrollOnFocus?: boolean; + initialFocus?: FocusTrapOptions['initialFocus']; + isVisible: boolean; + isKeyboardActive: boolean; + saveFocusState: () => void; +}; + +function ModalComponent({ + type, + shouldUseReanimatedModal, + isVisible, + shouldPreventScrollOnFocus, + initialFocus, + children, + saveFocusState, + onDismiss = () => {}, + isKeyboardActive, + ...props +}: ModalComponentProps) { + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + if ((type && REANIMATED_MODAL_TYPES.includes(type)) || shouldUseReanimatedModal) { + return ( + + + {children} + + {!isKeyboardActive && } + + ); + } + + return ( + + + + {children} + + + {!isKeyboardActive && } + + ); +} function BaseModal( { @@ -39,9 +127,12 @@ function BaseModal( onModalShow = () => {}, onModalWillShow, onModalWillHide, + propagateSwipe, fullscreen = true, animationIn, animationOut, + useNativeDriver, + useNativeDriverForBackdrop, hideModalContentWhileAnimating = false, animationInTiming, animationOutTiming, @@ -61,10 +152,12 @@ function BaseModal( swipeThreshold = 150, swipeDirection, shouldPreventScrollOnFocus = false, + disableAnimationIn = false, enableEdgeToEdgeBottomSafeAreaPadding, shouldApplySidePanelOffset = type === CONST.MODAL.MODAL_TYPE.RIGHT_DOCKED, hasBackdrop, backdropOpacity, + shouldUseReanimatedModal = false, shouldDisableBottomSafeAreaPadding = false, shouldIgnoreBackHandlerDuringTransition = false, forwardedFSClass = CONST.FULLSTORY.CLASS.UNMASK, @@ -83,7 +176,11 @@ function BaseModal( const {isSmallScreenWidth, shouldUseNarrowLayout, isInNarrowPaneModal} = useResponsiveLayout(); const {sidePanelOffset} = useSidePanel(); - const sidePanelAnimatedStyle = shouldApplySidePanelOffset && !isSmallScreenWidth ? {transform: [{translateX: Animated.multiply(sidePanelOffset.current, -1)}]} : undefined; + const sidePanelStyle = !shouldUseReanimatedModal && shouldApplySidePanelOffset && !isSmallScreenWidth ? {paddingRight: sidePanelOffset.current} : undefined; + const sidePanelAnimatedStyle = + (shouldUseReanimatedModal || type === CONST.MODAL.MODAL_TYPE.POPOVER || type === CONST.MODAL.MODAL_TYPE.RIGHT_DOCKED) && shouldApplySidePanelOffset && !isSmallScreenWidth + ? {transform: [{translateX: Animated.multiply(sidePanelOffset.current, -1)}]} + : undefined; const keyboardStateContextValue = useKeyboardState(); const [modalOverlapsWithTopSafeArea, setModalOverlapsWithTopSafeArea] = useState(false); @@ -286,6 +383,25 @@ function BaseModal( [isVisible, type], ); + const animationInProps = useMemo(() => { + // disableAnimationIn applies only to legacy modals. This should be removed once we fully migrate to `reanimated-modal`. + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + if (disableAnimationIn && ((type && !REANIMATED_MODAL_TYPES.includes(type)) || !shouldUseReanimatedModal)) { + // We need to apply these animation props to completely disable the "animation in". Simply setting it to 0 and undefined will not work. + // Based on: https://github.com/react-native-modal/react-native-modal/issues/191 + return { + animationIn: {from: {opacity: 1}, to: {opacity: 1}}, + animationInTiming: 0, + }; + } + + return { + animationIn: animationIn ?? modalStyleAnimationIn, + animationInDelay, + animationInTiming, + }; + }, [animationIn, animationInDelay, animationInTiming, disableAnimationIn, modalStyleAnimationIn, shouldUseReanimatedModal, type]); + // In Modals we need to reset the ScreenWrapperOfflineIndicatorContext to allow nested ScreenWrapper components to render offline indicators, // except if we are in a narrow pane navigator. In this case, we use the narrow pane's original values. const {isInNarrowPane} = useContext(NarrowPaneContext); @@ -308,13 +424,15 @@ function BaseModal( collapsable={false} style={[styles.pAbsolute, {zIndex: 1}]} > - e.stopPropagation()} onBackdropPress={handleBackdropPress} // Note: Escape key on web/desktop will trigger onBackButtonPress callback + // eslint-disable-next-line react/jsx-props-no-multi-spaces onBackButtonPress={closeTop} onModalShow={handleShowModal} + propagateSwipe={propagateSwipe} onModalHide={hideModal} onModalWillShow={() => { saveFocusState(); @@ -333,14 +451,15 @@ function BaseModal( backdropTransitionOutTiming={0} hasBackdrop={hasBackdrop ?? fullscreen} coverScreen={fullscreen} - style={modalStyle} + style={[modalStyle, sidePanelStyle]} deviceHeight={windowHeight} deviceWidth={windowWidth} - animationIn={animationIn ?? modalStyleAnimationIn} - animationInTiming={animationInTiming} - animationInDelay={animationInDelay} + // eslint-disable-next-line react/jsx-props-no-spreading + {...animationInProps} animationOut={animationOut ?? modalStyleAnimationOut} animationOutTiming={animationOutTiming} + useNativeDriver={useNativeDriver} + useNativeDriverForBackdrop={useNativeDriverForBackdrop} hideModalContentWhileAnimating={hideModalContentWhileAnimating} statusBarTranslucent={statusBarTranslucent} navigationBarTranslucent={navigationBarTranslucent} @@ -348,6 +467,9 @@ function BaseModal( avoidKeyboard={avoidKeyboard} customBackdrop={shouldUseCustomBackdrop ? : undefined} type={type} + shouldUseReanimatedModal={shouldUseReanimatedModal} + isKeyboardActive={keyboardStateContextValue?.isKeyboardActive} + saveFocusState={saveFocusState} shouldIgnoreBackHandlerDuringTransition={shouldIgnoreBackHandlerDuringTransition} > {children} - {!keyboardStateContextValue?.isKeyboardActive && } - + diff --git a/src/components/Modal/ModalContent.tsx b/src/components/Modal/ModalContent.tsx new file mode 100644 index 0000000000000..d143aef46ca60 --- /dev/null +++ b/src/components/Modal/ModalContent.tsx @@ -0,0 +1,29 @@ +import type {ReactNode} from 'react'; +import React from 'react'; + +type ModalContentProps = { + /** Modal contents */ + children: ReactNode; + + /** + * Callback method fired after modal content is unmounted. + * isVisible is not enough to cover all modal close cases, + * such as closing the attachment modal through the browser's back button. + * */ + onDismiss: () => void; + + /** Callback method fired after modal content is mounted. */ + onModalWillShow: () => void; +}; + +function ModalContent({children, onDismiss = () => {}, onModalWillShow = () => {}}: ModalContentProps) { + React.useEffect(() => { + onModalWillShow(); + return onDismiss; + // eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps + }, []); + return children; +} +ModalContent.displayName = 'ModalContent'; + +export default ModalContent; diff --git a/src/components/Modal/ReanimatedModal/index.tsx b/src/components/Modal/ReanimatedModal/index.tsx index 3d412445cde3f..8ce6991965ce9 100644 --- a/src/components/Modal/ReanimatedModal/index.tsx +++ b/src/components/Modal/ReanimatedModal/index.tsx @@ -14,6 +14,7 @@ import CONST from '@src/CONST'; import Backdrop from './Backdrop'; import Container from './Container'; import type ReanimatedModalProps from './types'; +import type {AnimationInType, AnimationOutType} from './types'; function ReanimatedModal({ testID, @@ -161,8 +162,8 @@ function ReanimatedModal({ animationInDelay={animationInDelay} onOpenCallBack={onOpenCallBack} onCloseCallBack={onCloseCallBack} - animationIn={animationIn} - animationOut={animationOut} + animationIn={animationIn as AnimationInType} + animationOut={animationOut as AnimationOutType} style={style} type={type} onSwipeComplete={onSwipeComplete} diff --git a/src/components/Modal/ReanimatedModal/types.ts b/src/components/Modal/ReanimatedModal/types.ts index f6ef33396da32..f26de6577106d 100644 --- a/src/components/Modal/ReanimatedModal/types.ts +++ b/src/components/Modal/ReanimatedModal/types.ts @@ -1,5 +1,6 @@ import type {ReactNode} from 'react'; import type {NativeSyntheticEvent, StyleProp, ViewProps, ViewStyle} from 'react-native'; +import type {ModalProps as ReactNativeModalProps} from 'react-native-modal'; import type {SharedValue} from 'react-native-reanimated'; import type {ValueOf} from 'type-fest'; import type {FocusTrapOptions} from '@components/Modal/types'; @@ -26,8 +27,10 @@ type GestureHandlerProps = { swipeDirection?: SwipeDirection | SwipeDirection[]; }; -type AnimationIn = 'fadeIn' | 'slideInUp' | 'slideInRight'; -type AnimationOut = 'fadeOut' | 'slideOutDown' | 'slideOutRight'; +type AnimationInType = 'fadeIn' | 'slideInUp' | 'slideInRight'; +type AnimationOutType = 'fadeOut' | 'slideOutDown' | 'slideOutRight'; + +type AnimationOut = ValueOf>; type ReanimatedModalProps = ViewProps & GestureProps & @@ -57,14 +60,18 @@ type ReanimatedModalProps = ViewProps & /** The presentation style of the modal */ presentationStyle?: 'fullScreen' | 'pageSheet' | 'formSheet' | 'overFullScreen'; + /** Default ModalProps Provided */ + /** Whether to use the native driver for the backdrop animation */ + useNativeDriverForBackdrop?: boolean; + /** Enum for animation type when modal appears */ - animationIn?: AnimationIn; + animationIn?: ValueOf> | AnimationInType; /** Duration of the animation when modal appears */ animationInTiming?: number; /** Enum for animation type when modal disappears */ - animationOut?: AnimationOut; + animationOut?: AnimationOut | AnimationOutType; /** Duration of the animation when modal disappears */ animationOutTiming?: number; @@ -178,11 +185,11 @@ type ContainerProps = { panPosition?: {translateX: SharedValue; translateY: SharedValue}; /** Animation played when modal shows */ - animationIn: AnimationIn; + animationIn: AnimationInType; /** Animation played when modal disappears */ - animationOut: AnimationOut; + animationOut: AnimationOutType; }; export default ReanimatedModalProps; -export type {BackdropProps, ContainerProps, GestureHandlerProps, AnimationIn, AnimationOut, SwipeDirection}; +export type {BackdropProps, ContainerProps, GestureHandlerProps, AnimationOut, AnimationInType, AnimationOutType, SwipeDirection}; diff --git a/src/components/Modal/ReanimatedModal/utils.ts b/src/components/Modal/ReanimatedModal/utils.ts index dd7f0f9ef2835..8077feab14da6 100644 --- a/src/components/Modal/ReanimatedModal/utils.ts +++ b/src/components/Modal/ReanimatedModal/utils.ts @@ -2,11 +2,11 @@ import type {ViewStyle} from 'react-native'; import {Easing} from 'react-native-reanimated'; import type {ValidKeyframeProps} from 'react-native-reanimated/lib/typescript/commonTypes'; import variables from '@styles/variables'; -import type {AnimationIn, AnimationOut} from './types'; +import type {AnimationInType, AnimationOutType} from './types'; const easing = Easing.bezier(0.76, 0.0, 0.24, 1.0).factory(); -function getModalInAnimation(animationType: AnimationIn): ValidKeyframeProps { +function getModalInAnimation(animationType: AnimationInType): ValidKeyframeProps { switch (animationType) { case 'slideInRight': return { @@ -40,7 +40,7 @@ function getModalInAnimation(animationType: AnimationIn): ValidKeyframeProps { /** * @returns A function that takes a number between 0 and 1 and returns a ViewStyle object. */ -function getModalInAnimationStyle(animationType: AnimationIn): (progress: number) => ViewStyle { +function getModalInAnimationStyle(animationType: AnimationInType): (progress: number) => ViewStyle { switch (animationType) { case 'slideInRight': return (progress) => ({transform: [{translateX: `${100 * (1 - progress)}%`}]}); @@ -53,7 +53,7 @@ function getModalInAnimationStyle(animationType: AnimationIn): (progress: number } } -function getModalOutAnimation(animationType: AnimationOut): ValidKeyframeProps { +function getModalOutAnimation(animationType: AnimationOutType): ValidKeyframeProps { switch (animationType) { case 'slideOutRight': return { diff --git a/src/components/Modal/index.android.tsx b/src/components/Modal/index.android.tsx index b26ba6cd0f892..c5d777c096e14 100644 --- a/src/components/Modal/index.android.tsx +++ b/src/components/Modal/index.android.tsx @@ -2,13 +2,17 @@ import React from 'react'; import BaseModal from './BaseModal'; import type BaseModalProps from './types'; -function Modal({children, ...rest}: BaseModalProps) { +// Only want to use useNativeDriver on Android. It has strange flashes issue on IOS +// https://github.com/react-native-modal/react-native-modal#the-modal-flashes-in-a-weird-way-when-animating +function Modal({useNativeDriver = true, ...rest}: BaseModalProps) { return ( - {children} + {rest.children} ); } diff --git a/src/components/Modal/index.ios.tsx b/src/components/Modal/index.ios.tsx index 7510458e2f32a..5430bb6026c85 100644 --- a/src/components/Modal/index.ios.tsx +++ b/src/components/Modal/index.ios.tsx @@ -11,6 +11,7 @@ function Modal({children, ...rest}: BaseModalProps) { return ( {}, type, onModalShow = ( onModalWillHide={onModalWillHide} avoidKeyboard={false} fullscreen={fullscreen} + useNativeDriver={false} + useNativeDriverForBackdrop={false} type={type} > {children} diff --git a/src/components/Modal/types.ts b/src/components/Modal/types.ts index 86fd8590b7a61..3fe95e073b786 100644 --- a/src/components/Modal/types.ts +++ b/src/components/Modal/types.ts @@ -1,10 +1,10 @@ import type {FocusTrapProps} from 'focus-trap-react'; -import type {ViewStyle} from 'react-native'; +import type {GestureResponderEvent, PanResponderGestureState, ViewStyle} from 'react-native'; +import type {Direction, ModalProps as ReactNativeModalProps} from 'react-native-modal'; import type {ValueOf} from 'type-fest'; import type {ForwardedFSClassProps} from '@libs/Fullstory/types'; import type CONST from '@src/CONST'; import type ReanimatedModalProps from './ReanimatedModal/types'; -import type {SwipeDirection} from './ReanimatedModal/types'; type FocusTrapOptions = Exclude; @@ -19,11 +19,15 @@ type WindowState = { shouldGoBack: boolean; }; -type BaseModalProps = Partial & +type BaseModalProps = Partial & + Partial & ForwardedFSClassProps & { /** Decides whether the modal should cover fullscreen. FullScreen modal has backdrop */ fullscreen?: boolean; + /** Should we close modal on outside click */ + shouldCloseOnOutsideClick?: boolean; + /** Should we announce the Modal visibility changes? */ shouldSetModalVisibility?: boolean; @@ -48,7 +52,6 @@ type BaseModalProps = Partial & /** The anchor position of a popover modal. Has no effect on other modal types. */ popoverAnchorPosition?: PopoverAnchorPosition; - /** Styles for the outer most view wrapper */ outerStyle?: ViewStyle; /** Whether the modal should go under the system statusbar */ @@ -63,13 +66,21 @@ type BaseModalProps = Partial & /** Modal container styles */ innerContainerStyle?: ViewStyle; + /** + * Whether the modal should hide its content while animating. On iOS, set to true + * if `useNativeDriver` is also true, to avoid flashes in the UI. + * + * See: https://github.com/react-native-modal/react-native-modal/pull/116 + * */ + hideModalContentWhileAnimating?: boolean; + /** Whether handle navigation back when modal show. */ shouldHandleNavigationBack?: boolean; /** Should we use a custom backdrop for the modal? (This prevents focus issues on desktop) */ shouldUseCustomBackdrop?: boolean; - /** Unique id for the modal */ + /** unique id for the modal */ modalId?: number; /** @@ -84,11 +95,14 @@ type BaseModalProps = Partial & /** Should we apply padding style in modal itself. If this value is false, we will handle it in ScreenWrapper */ shouldUseModalPaddingStyle?: boolean; + /** Whether swipe gestures should propagate to parent components */ + propagateSwipe?: boolean | ((event?: GestureResponderEvent, gestureState?: PanResponderGestureState) => boolean); + /** After swipe more than threshold modal will close */ swipeThreshold?: number; /** In which direction modal will swipe */ - swipeDirection?: SwipeDirection; + swipeDirection?: Direction; /** Used to set the element that should receive the initial focus */ initialFocus?: FocusTrapOptions['initialFocus']; @@ -96,6 +110,9 @@ type BaseModalProps = Partial & /** Whether to prevent the focus trap from scrolling the element into view. */ shouldPreventScrollOnFocus?: boolean; + /** Whether to disable the animation in */ + disableAnimationIn?: boolean; + /** * Temporary flag to disable safe area bottom spacing in modals and to allow edge-to-edge content. * Modals should not always apply bottom safe area padding, instead it should be applied to the scrollable/bottom-docked content directly. @@ -113,6 +130,11 @@ type BaseModalProps = Partial & * Disables the bottom safe area padding in the modal. Used in for scrollable FeatureTrainingModal. */ shouldDisableBottomSafeAreaPadding?: boolean; + + /** + * Whether the modal should use ReanimatedModal implementation. + */ + shouldUseReanimatedModal?: boolean; }; export default BaseModalProps; diff --git a/src/components/Popover/index.tsx b/src/components/Popover/index.tsx index 957712851e702..30c421c7bb658 100644 --- a/src/components/Popover/index.tsx +++ b/src/components/Popover/index.tsx @@ -84,6 +84,7 @@ function Popover(props: PopoverProps) { popoverAnchorPosition={anchorPosition} animationInTiming={disableAnimation ? DISABLED_ANIMATION_DURATION : animationInTiming} animationOutTiming={disableAnimation ? DISABLED_ANIMATION_DURATION : animationOutTiming} + shouldCloseOnOutsideClick onLayout={onLayout} animationIn={animationIn} animationOut={animationOut} diff --git a/src/components/PopoverMenu.tsx b/src/components/PopoverMenu.tsx index 6778afb013c21..e5b5af48e345e 100644 --- a/src/components/PopoverMenu.tsx +++ b/src/components/PopoverMenu.tsx @@ -4,6 +4,7 @@ import type {ReactNode, RefObject} from 'react'; import React, {useCallback, useLayoutEffect, useMemo, useState} from 'react'; import {StyleSheet, View} from 'react-native'; import type {GestureResponderEvent, LayoutChangeEvent, StyleProp, TextStyle, ViewStyle} from 'react-native'; +import type {ModalProps} from 'react-native-modal'; import type {SvgProps} from 'react-native-svg'; import useArrowKeyFocusManager from '@hooks/useArrowKeyFocusManager'; import useKeyboardShortcut from '@hooks/useKeyboardShortcut'; @@ -76,9 +77,9 @@ type PopoverMenuItem = MenuItemProps & { shouldCloseModalOnSelect?: boolean; }; -type ModalAnimationProps = Pick; +type PopoverModalProps = Pick & Pick; -type PopoverMenuProps = Partial & { +type PopoverMenuProps = Partial & { /** Callback method fired when the user requests to close the modal */ onClose: () => void; @@ -440,6 +441,7 @@ function BasePopoverMenu({ withoutOverlay={withoutOverlay} shouldSetModalVisibility={shouldSetModalVisibility} shouldEnableNewFocusManagement={shouldEnableNewFocusManagement} + useNativeDriver restoreFocusType={restoreFocusType} innerContainerStyle={{...styles.pv0, ...innerContainerStyle}} shouldUseModalPaddingStyle={shouldUseModalPaddingStyle} diff --git a/src/components/PopoverWithMeasuredContent/PopoverWithMeasuredContentBase.tsx b/src/components/PopoverWithMeasuredContent/PopoverWithMeasuredContentBase.tsx index 1d1e7abe68117..ac64e071c813d 100644 --- a/src/components/PopoverWithMeasuredContent/PopoverWithMeasuredContentBase.tsx +++ b/src/components/PopoverWithMeasuredContent/PopoverWithMeasuredContentBase.tsx @@ -36,6 +36,7 @@ function PopoverWithMeasuredContentBase({ children, withoutOverlay = false, fullscreen = true, + shouldCloseOnOutsideClick = false, shouldSetModalVisibility = true, statusBarTranslucent = true, navigationBarTranslucent = true, @@ -192,6 +193,7 @@ function PopoverWithMeasuredContentBase({ isVisible={isVisible} withoutOverlay={withoutOverlay} fullscreen={fullscreen} + shouldCloseOnOutsideClick={shouldCloseOnOutsideClick} shouldSetModalVisibility={shouldSetModalVisibility} statusBarTranslucent={statusBarTranslucent} navigationBarTranslucent={navigationBarTranslucent} diff --git a/src/components/PopoverWithoutOverlay/types.ts b/src/components/PopoverWithoutOverlay/types.ts index 09063e110c59b..4e9f5fa805f20 100644 --- a/src/components/PopoverWithoutOverlay/types.ts +++ b/src/components/PopoverWithoutOverlay/types.ts @@ -17,7 +17,7 @@ type PopoverWithoutOverlayProps = ChildrenProps & /** The anchor ref of the popover */ anchorRef: RefObject; - /** Time in milliseconds for the modal entering animation */ + /** A react-native-animatable animation timing for the modal display animation */ animationInTiming?: number; /** Whether disable the animations */ diff --git a/src/components/Search/SearchRouter/SearchRouterModal.tsx b/src/components/Search/SearchRouter/SearchRouterModal.tsx index d6c0af2f717fb..eb2add046c31c 100644 --- a/src/components/Search/SearchRouter/SearchRouterModal.tsx +++ b/src/components/Search/SearchRouter/SearchRouterModal.tsx @@ -28,6 +28,7 @@ function SearchRouterModal() { innerContainerStyle={{paddingTop: viewportOffsetTop}} popoverAnchorPosition={{right: 6, top: 6}} fullscreen + propagateSwipe swipeDirection={shouldUseNarrowLayout ? CONST.SWIPE_DIRECTION.RIGHT : undefined} onClose={closeSearchRouter} onModalHide={() => setShouldHideInputCaret(isMobileWebIOS)} diff --git a/src/components/SidePanel/HelpComponents/HelpContent.tsx b/src/components/SidePanel/HelpComponents/HelpContent.tsx index aa2d769bf4448..62869b05af7c4 100644 --- a/src/components/SidePanel/HelpComponents/HelpContent.tsx +++ b/src/components/SidePanel/HelpComponents/HelpContent.tsx @@ -1,8 +1,10 @@ import {findFocusedRoute} from '@react-navigation/native'; import React, {useEffect, useMemo, useRef, useState} from 'react'; +import {ScrollView} from 'react-native-gesture-handler'; import FullScreenLoadingIndicator from '@components/FullscreenLoadingIndicator'; +// Importing from the react-native-gesture-handler package instead of the `components/ScrollView` to fix scroll issue: +// https://github.com/react-native-modal/react-native-modal/issues/236 import HeaderGap from '@components/HeaderGap'; -import ScrollView from '@components/ScrollView'; import getHelpContent from '@components/SidePanel/getHelpContent'; import useEnvironment from '@hooks/useEnvironment'; import useLocalize from '@hooks/useLocalize'; @@ -110,6 +112,7 @@ function HelpContent({closeSidePanel}: HelpContentProps) { ) : ( {getHelpContent(styles, route, isProduction, expandedIndex, setExpandedIndex)} diff --git a/src/components/SidePanel/HelpModal/index.ios.tsx b/src/components/SidePanel/HelpModal/index.ios.tsx index 61d40751eabdc..4f8cf6c7a5317 100644 --- a/src/components/SidePanel/HelpModal/index.ios.tsx +++ b/src/components/SidePanel/HelpModal/index.ios.tsx @@ -11,6 +11,7 @@ function Help({shouldHideSidePanel, closeSidePanel}: HelpProps) { isVisible={!shouldHideSidePanel} type={CONST.MODAL.MODAL_TYPE.RIGHT_DOCKED} shouldHandleNavigationBack + propagateSwipe swipeDirection={CONST.SWIPE_DIRECTION.RIGHT} > diff --git a/src/components/TestDrive/TestDriveDemo.tsx b/src/components/TestDrive/TestDriveDemo.tsx index 988758ee372b5..d837a2581106f 100644 --- a/src/components/TestDrive/TestDriveDemo.tsx +++ b/src/components/TestDrive/TestDriveDemo.tsx @@ -64,6 +64,7 @@ function TestDriveDemo() { type={CONST.MODAL.MODAL_TYPE.FULLSCREEN} style={styles.backgroundWhite} innerContainerStyle={{...styles.flex1, marginTop: paddingTop, marginBottom: paddingBottom}} + useNativeDriver={false} // We need to disable native driver in order to prevent https://github.com/Expensify/App/issues/61032 > diff --git a/src/hooks/useResponsiveLayout/index.native.ts b/src/hooks/useResponsiveLayout/index.native.ts index 3d1f47854e0b1..4f62e89ff8258 100644 --- a/src/hooks/useResponsiveLayout/index.native.ts +++ b/src/hooks/useResponsiveLayout/index.native.ts @@ -13,14 +13,14 @@ import type ResponsiveLayoutResult from './types'; * * There are two kinds of modals in this app: * 1. Modal stack navigators from react-navigation - * 2. Modal components that use react-native-reanimated + * 2. Modal components that use react-native-modal * * This hook is designed to handle both. `shouldUseNarrowLayout` will return `true` if any of the following are true: * 1. The device screen width is narrow - * 2. The consuming component is the child of a "right docked" react-native-reanimated component - * 3. The consuming component is a screen in a modal stack navigator and not a child of a "non-right-docked" react-native-reanimated component. + * 2. The consuming component is the child of a "right docked" react-native-modal component + * 3. The consuming component is a screen in a modal stack navigator and not a child of a "non-right-docked" react-native-modal component. * - * For more details on the various modal types we've defined for this app and implemented using react-native-reanimated, see `ModalType`. + * For more details on the various modal types we've defined for this app and implemented using react-native-modal, see `ModalType`. */ export default function useResponsiveLayout(): ResponsiveLayoutResult { const {windowWidth, windowHeight} = useWindowDimensions(); @@ -36,7 +36,7 @@ export default function useResponsiveLayout(): ResponsiveLayoutResult { // we need to always take screen width into consideration, no matter the platform. const onboardingIsMediumOrLargerScreenWidth = windowWidth > variables.mobileResponsiveWidthBreakpoint; - // Note: activeModalType refers to our react-native-reanimated component wrapper, not react-navigation's modal stack navigators. + // Note: activeModalType refers to our react-native-modal component wrapper, not react-navigation's modal stack navigators. // This means it will only be defined if the component calling this hook is a child of a modal component. See BaseModal for the provider. const {activeModalType} = useContext(ModalContext); diff --git a/src/hooks/useResponsiveLayout/index.ts b/src/hooks/useResponsiveLayout/index.ts index 585546927f005..bc5682a577efd 100644 --- a/src/hooks/useResponsiveLayout/index.ts +++ b/src/hooks/useResponsiveLayout/index.ts @@ -14,14 +14,14 @@ import type ResponsiveLayoutResult from './types'; * * There are two kinds of modals in this app: * 1. Modal stack navigators from react-navigation - * 2. Modal components that use react-native-reanimated + * 2. Modal components that use react-native-modal * * This hook is designed to handle both. `shouldUseNarrowLayout` will return `true` if any of the following are true: * 1. The device screen width is narrow - * 2. The consuming component is the child of a "right docked" react-native-reanimated component - * 3. The consuming component is a screen in a modal stack navigator and not a child of a "non-right-docked" react-native-reanimated component. + * 2. The consuming component is the child of a "right docked" react-native-modal component + * 3. The consuming component is a screen in a modal stack navigator and not a child of a "non-right-docked" react-native-modal component. * - * For more details on the various modal types we've defined for this app and implemented using react-native-reanimated, see `ModalType`. + * For more details on the various modal types we've defined for this app and implemented using react-native-modal, see `ModalType`. */ export default function useResponsiveLayout(): ResponsiveLayoutResult { const {windowWidth, windowHeight} = useWindowDimensions(); @@ -39,7 +39,7 @@ export default function useResponsiveLayout(): ResponsiveLayoutResult { const lowerScreenDimension = Math.min(windowWidth, windowHeight); const isSmallScreen = lowerScreenDimension <= variables.mobileResponsiveWidthBreakpoint; - // Note: activeModalType refers to our react-native-reanimated component wrapper, not react-navigation's modal stack navigators. + // Note: activeModalType refers to our react-native-modal component wrapper, not react-navigation's modal stack navigators. // This means it will only be defined if the component calling this hook is a child of a modal component. See BaseModal for the provider. const {activeModalType} = useContext(ModalContext); diff --git a/src/pages/media/AttachmentModalScreen/AttachmentModalContainer/index.tsx b/src/pages/media/AttachmentModalScreen/AttachmentModalContainer/index.tsx index dd98a9097fee0..c1f0c49c4ba72 100644 --- a/src/pages/media/AttachmentModalScreen/AttachmentModalContainer/index.tsx +++ b/src/pages/media/AttachmentModalScreen/AttachmentModalContainer/index.tsx @@ -1,4 +1,5 @@ import React, {useCallback, useContext, useEffect, useState} from 'react'; +import {InteractionManager} from 'react-native'; import Modal from '@components/Modal'; import Navigation from '@libs/Navigation/Navigation'; import AttachmentModalBaseContent from '@pages/media/AttachmentModalScreen/AttachmentModalBaseContent'; @@ -11,6 +12,7 @@ import type AttachmentModalContainerProps from './types'; function AttachmentModalContainer({contentProps, modalType, onShow, onClose, shouldHandleNavigationBack}: AttachmentModalContainerProps) { const [isVisible, setIsVisible] = useState(true); const attachmentsContext = useContext(AttachmentModalContext); + const [shouldDisableAnimationAfterInitialMount, setShouldDisableAnimationAfterInitialMount] = useState(false); /** * Closes the modal. @@ -34,14 +36,25 @@ function AttachmentModalContainer({con [attachmentsContext, onClose], ); + // After the modal has initially been mounted and animated in, + // we don't want to show another animation when the modal type changes or + // when the browser switches to narrow layout. + useEffect(() => { + InteractionManager.runAfterInteractions(() => { + setShouldDisableAnimationAfterInitialMount(true); + }); + }, []); + useEffect(() => { onShow?.(); }, [onShow]); return ( { if (!contentProps.submitRef?.current) { return false; diff --git a/src/styles/utils/generators/ModalStyleUtils.ts b/src/styles/utils/generators/ModalStyleUtils.ts index 03b61e28b485b..30490d7bb73be 100644 --- a/src/styles/utils/generators/ModalStyleUtils.ts +++ b/src/styles/utils/generators/ModalStyleUtils.ts @@ -1,5 +1,5 @@ import type {ViewStyle} from 'react-native'; -import type ReanimatedModalProps from '@components/Modal/ReanimatedModal/types'; +import type {ModalProps} from 'react-native-modal'; import {isMobileSafari} from '@libs/Browser'; import type {ThemeStyles} from '@styles/index'; import variables from '@styles/variables'; @@ -26,9 +26,9 @@ type WindowDimensions = { type GetModalStyles = { modalStyle: ViewStyle; modalContainerStyle: ViewStyle; - swipeDirection: ReanimatedModalProps['swipeDirection']; - animationIn: ReanimatedModalProps['animationIn']; - animationOut: ReanimatedModalProps['animationOut']; + swipeDirection: ModalProps['swipeDirection']; + animationIn: ModalProps['animationIn']; + animationOut: ModalProps['animationOut']; hideBackdrop: boolean; shouldAddTopSafeAreaMargin: boolean; shouldAddBottomSafeAreaMargin: boolean;