diff --git a/package-lock.json b/package-lock.json index c8b4b2ba2082..74abd69ab118 100644 --- a/package-lock.json +++ b/package-lock.json @@ -99,7 +99,7 @@ "react-native-pdf": "^6.7.3", "react-native-performance": "^5.1.0", "react-native-permissions": "^3.9.3", - "react-native-picker-select": "git+https://github.com/Expensify/react-native-picker-select.git#0d15d4618f58e99c1261921111e68ee85bb3c2a8", + "react-native-picker-select": "git+https://github.com/Expensify/react-native-picker-select.git#7a407cd4174d9838a944c1c2e1cb4a9737ac69c5", "react-native-plaid-link-sdk": "10.8.0", "react-native-qrcode-svg": "^6.2.0", "react-native-quick-sqlite": "^8.0.0-beta.2", @@ -47631,8 +47631,8 @@ }, "node_modules/react-native-picker-select": { "version": "8.1.0", - "resolved": "git+ssh://git@github.com/Expensify/react-native-picker-select.git#0d15d4618f58e99c1261921111e68ee85bb3c2a8", - "integrity": "sha512-ly0ZCt3K4RX7t9lfSb2OSGAw0cv8UqdMoxNfh5j+KujYYq+N8VsI9O/lmqquNeX/AMp5hM3fjetEWue4nZw/hA==", + "resolved": "git+ssh://git@github.com/Expensify/react-native-picker-select.git#7a407cd4174d9838a944c1c2e1cb4a9737ac69c5", + "integrity": "sha512-NpXXyK+UuANYOysjUb9pCoq9SookRYPfpOcM4shxOD4+2Fkh7TYt2LBUpAdBicMHmtaR43RWXVQk9pMimOhg2w==", "license": "MIT", "dependencies": { "lodash.isequal": "^4.5.0" @@ -90460,9 +90460,9 @@ "requires": {} }, "react-native-picker-select": { - "version": "git+ssh://git@github.com/Expensify/react-native-picker-select.git#0d15d4618f58e99c1261921111e68ee85bb3c2a8", - "integrity": "sha512-ly0ZCt3K4RX7t9lfSb2OSGAw0cv8UqdMoxNfh5j+KujYYq+N8VsI9O/lmqquNeX/AMp5hM3fjetEWue4nZw/hA==", - "from": "react-native-picker-select@git+https://github.com/Expensify/react-native-picker-select.git#0d15d4618f58e99c1261921111e68ee85bb3c2a8", + "version": "git+ssh://git@github.com/Expensify/react-native-picker-select.git#7a407cd4174d9838a944c1c2e1cb4a9737ac69c5", + "integrity": "sha512-NpXXyK+UuANYOysjUb9pCoq9SookRYPfpOcM4shxOD4+2Fkh7TYt2LBUpAdBicMHmtaR43RWXVQk9pMimOhg2w==", + "from": "react-native-picker-select@git+https://github.com/Expensify/react-native-picker-select.git#7a407cd4174d9838a944c1c2e1cb4a9737ac69c5", "requires": { "lodash.isequal": "^4.5.0" } diff --git a/package.json b/package.json index bd4b8ffacedf..be84402b8683 100644 --- a/package.json +++ b/package.json @@ -147,7 +147,7 @@ "react-native-pdf": "^6.7.3", "react-native-performance": "^5.1.0", "react-native-permissions": "^3.9.3", - "react-native-picker-select": "git+https://github.com/Expensify/react-native-picker-select.git#0d15d4618f58e99c1261921111e68ee85bb3c2a8", + "react-native-picker-select": "git+https://github.com/Expensify/react-native-picker-select.git#7a407cd4174d9838a944c1c2e1cb4a9737ac69c5", "react-native-plaid-link-sdk": "10.8.0", "react-native-qrcode-svg": "^6.2.0", "react-native-quick-sqlite": "^8.0.0-beta.2", diff --git a/src/components/ScreenWrapper.tsx b/src/components/ScreenWrapper.tsx new file mode 100644 index 000000000000..9a7dc8de633a --- /dev/null +++ b/src/components/ScreenWrapper.tsx @@ -0,0 +1,251 @@ +import {useNavigation} from '@react-navigation/native'; +import {StackNavigationProp} from '@react-navigation/stack'; +import React, {ForwardedRef, forwardRef, ReactNode, useEffect, useRef, useState} from 'react'; +import {DimensionValue, Keyboard, PanResponder, StyleProp, View, ViewStyle} from 'react-native'; +import {PickerAvoidingView} from 'react-native-picker-select'; +import {EdgeInsets} from 'react-native-safe-area-context'; +import useEnvironment from '@hooks/useEnvironment'; +import useInitialDimensions from '@hooks/useInitialWindowDimensions'; +import useKeyboardState from '@hooks/useKeyboardState'; +import useNetwork from '@hooks/useNetwork'; +import useThemeStyles from '@hooks/useThemeStyles'; +import useWindowDimensions from '@hooks/useWindowDimensions'; +import * as Browser from '@libs/Browser'; +import {RootStackParamList} from '@libs/Navigation/types'; +import toggleTestToolsModal from '@userActions/TestTool'; +import CONST from '@src/CONST'; +import CustomDevMenu from './CustomDevMenu'; +import HeaderGap from './HeaderGap'; +import KeyboardAvoidingView from './KeyboardAvoidingView'; +import OfflineIndicator from './OfflineIndicator'; +import SafeAreaConsumer from './SafeAreaConsumer'; +import TestToolsModal from './TestToolsModal'; + +type ChildrenProps = { + insets?: EdgeInsets; + safeAreaPaddingBottomStyle?: { + paddingBottom?: DimensionValue; + }; + didScreenTransitionEnd: boolean; +}; + +type ScreenWrapperProps = { + /** Returns a function as a child to pass insets to or a node to render without insets */ + children: ReactNode | React.FC; + + /** A unique ID to find the screen wrapper in tests */ + testID: string; + + /** Additional styles to add */ + style?: StyleProp; + + /** Additional styles for header gap */ + headerGapStyles?: StyleProp; + + /** Styles for the offline indicator */ + offlineIndicatorStyle?: StyleProp; + + /** Whether to include padding bottom */ + includeSafeAreaPaddingBottom?: boolean; + + /** Whether to include padding top */ + includePaddingTop?: boolean; + + /** Called when navigated Screen's transition is finished. It does not fire when user exit the page. */ + onEntryTransitionEnd?: () => void; + + /** The behavior to pass to the KeyboardAvoidingView, requires some trial and error depending on the layout/devices used. + * Search 'switch(behavior)' in ./node_modules/react-native/Libraries/Components/Keyboard/KeyboardAvoidingView.js for more context */ + keyboardAvoidingViewBehavior?: 'padding' | 'height' | 'position'; + + /** Whether KeyboardAvoidingView should be enabled. Use false for screens where this functionality is not necessary */ + shouldEnableKeyboardAvoidingView?: boolean; + + /** Whether picker modal avoiding should be enabled. Should be enabled when there's a picker at the bottom of a + * scrollable form, gives a subtly better UX if disabled on non-scrollable screens with a submit button */ + shouldEnablePickerAvoiding?: boolean; + + /** Whether to dismiss keyboard before leaving a screen */ + shouldDismissKeyboardBeforeClose?: boolean; + + /** Whether to use the maxHeight (true) or use the 100% of the height (false) */ + shouldEnableMaxHeight?: boolean; + + /** Whether to use the minHeight. Use true for screens where the window height are changing because of Virtual Keyboard */ + shouldEnableMinHeight?: boolean; + + /** Whether to show offline indicator */ + shouldShowOfflineIndicator?: boolean; + + /** + * The navigation prop is passed by the navigator. It is used to trigger the onEntryTransitionEnd callback + * when the screen transition ends. + * + * This is required because transitionEnd event doesn't trigger in the testing environment. + */ + navigation?: StackNavigationProp; +}; + +function ScreenWrapper( + { + shouldEnableMaxHeight = false, + shouldEnableMinHeight = false, + includePaddingTop = true, + keyboardAvoidingViewBehavior = 'padding', + includeSafeAreaPaddingBottom = true, + shouldEnableKeyboardAvoidingView = true, + shouldEnablePickerAvoiding = true, + headerGapStyles, + children, + shouldShowOfflineIndicator = true, + offlineIndicatorStyle, + style, + shouldDismissKeyboardBeforeClose = true, + onEntryTransitionEnd, + testID, + navigation: navigationProp, + }: ScreenWrapperProps, + ref: ForwardedRef, +) { + /** + * We are only passing navigation as prop from + * ReportScreenWrapper -> ReportScreen -> ScreenWrapper + * + * so in other places where ScreenWrapper is used, we need to + * fallback to useNavigation. + */ + const navigationFallback = useNavigation>(); + const navigation = navigationProp ?? navigationFallback; + const {windowHeight, isSmallScreenWidth} = useWindowDimensions(); + const {initialHeight} = useInitialDimensions(); + const styles = useThemeStyles(); + const keyboardState = useKeyboardState(); + const {isDevelopment} = useEnvironment(); + const {isOffline} = useNetwork(); + const [didScreenTransitionEnd, setDidScreenTransitionEnd] = useState(false); + const maxHeight = shouldEnableMaxHeight ? windowHeight : undefined; + const minHeight = shouldEnableMinHeight && !Browser.isSafari() ? initialHeight : undefined; + const isKeyboardShown = keyboardState?.isKeyboardShown ?? false; + + const isKeyboardShownRef = useRef(false); + + isKeyboardShownRef.current = keyboardState?.isKeyboardShown ?? false; + + const panResponder = useRef( + PanResponder.create({ + // eslint-disable-next-line @typescript-eslint/naming-convention + onStartShouldSetPanResponderCapture: (_e, gestureState) => gestureState.numberActiveTouches === CONST.TEST_TOOL.NUMBER_OF_TAPS, + onPanResponderRelease: toggleTestToolsModal, + }), + ).current; + + const keyboardDissmissPanResponder = useRef( + PanResponder.create({ + // eslint-disable-next-line @typescript-eslint/naming-convention + onMoveShouldSetPanResponderCapture: (_e, gestureState) => { + const isHorizontalSwipe = Math.abs(gestureState.dx) > Math.abs(gestureState.dy); + const shouldDismissKeyboard = shouldDismissKeyboardBeforeClose && isKeyboardShown && Browser.isMobile(); + + return isHorizontalSwipe && shouldDismissKeyboard; + }, + onPanResponderGrant: Keyboard.dismiss, + }), + ).current; + + useEffect(() => { + const unsubscribeTransitionEnd = navigation.addListener('transitionEnd', (event) => { + // Prevent firing the prop callback when user is exiting the page. + if (event?.data?.closing) { + return; + } + + setDidScreenTransitionEnd(true); + onEntryTransitionEnd?.(); + }); + + // We need to have this prop to remove keyboard before going away from the screen, to avoid previous screen look weird for a brief moment, + // also we need to have generic control in future - to prevent closing keyboard for some rare cases in which beforeRemove has limitations + // described here https://reactnavigation.org/docs/preventing-going-back/#limitations + const beforeRemoveSubscription = shouldDismissKeyboardBeforeClose + ? navigation.addListener('beforeRemove', () => { + if (!isKeyboardShownRef.current) { + return; + } + Keyboard.dismiss(); + }) + : undefined; + + return () => { + unsubscribeTransitionEnd(); + + if (beforeRemoveSubscription) { + beforeRemoveSubscription(); + } + }; + // Rule disabled because this effect is only for component did mount & will component unmount lifecycle event + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + return ( + + {({insets, paddingTop, paddingBottom, safeAreaPaddingBottomStyle}) => { + const paddingStyle: StyleProp = {}; + + if (includePaddingTop) { + paddingStyle.paddingTop = paddingTop; + } + + // We always need the safe area padding bottom if we're showing the offline indicator since it is bottom-docked. + if (includeSafeAreaPaddingBottom || (isOffline && shouldShowOfflineIndicator)) { + paddingStyle.paddingBottom = paddingBottom; + } + + return ( + + + + + + {isDevelopment && } + {isDevelopment && } + { + // If props.children is a function, call it to provide the insets to the children. + typeof children === 'function' + ? children({ + insets, + safeAreaPaddingBottomStyle, + didScreenTransitionEnd, + }) + : children + } + {isSmallScreenWidth && shouldShowOfflineIndicator && } + + + + + ); + }} + + ); +} + +ScreenWrapper.displayName = 'ScreenWrapper'; + +export default forwardRef(ScreenWrapper); diff --git a/src/components/ScreenWrapper/index.js b/src/components/ScreenWrapper/index.js deleted file mode 100644 index 432139353c56..000000000000 --- a/src/components/ScreenWrapper/index.js +++ /dev/null @@ -1,195 +0,0 @@ -import {useNavigation} from '@react-navigation/native'; -import lodashGet from 'lodash/get'; -import React, {useEffect, useRef, useState} from 'react'; -import {Keyboard, PanResponder, View} from 'react-native'; -import {PickerAvoidingView} from 'react-native-picker-select'; -import _ from 'underscore'; -import CustomDevMenu from '@components/CustomDevMenu'; -import HeaderGap from '@components/HeaderGap'; -import KeyboardAvoidingView from '@components/KeyboardAvoidingView'; -import OfflineIndicator from '@components/OfflineIndicator'; -import SafeAreaConsumer from '@components/SafeAreaConsumer'; -import TestToolsModal from '@components/TestToolsModal'; -import useEnvironment from '@hooks/useEnvironment'; -import useInitialDimensions from '@hooks/useInitialWindowDimensions'; -import useKeyboardState from '@hooks/useKeyboardState'; -import useNetwork from '@hooks/useNetwork'; -import useThemeStyles from '@hooks/useThemeStyles'; -import useWindowDimensions from '@hooks/useWindowDimensions'; -import * as Browser from '@libs/Browser'; -import toggleTestToolsModal from '@userActions/TestTool'; -import CONST from '@src/CONST'; -import {defaultProps, propTypes} from './propTypes'; - -const ScreenWrapper = React.forwardRef( - ( - { - shouldEnableMaxHeight, - shouldEnableMinHeight, - includePaddingTop, - keyboardAvoidingViewBehavior, - includeSafeAreaPaddingBottom, - shouldEnableKeyboardAvoidingView, - shouldEnablePickerAvoiding, - headerGapStyles, - children, - shouldShowOfflineIndicator, - offlineIndicatorStyle, - style, - shouldDismissKeyboardBeforeClose, - onEntryTransitionEnd, - testID, - - /** - * The navigation prop is passed by the navigator. It is used to trigger the onEntryTransitionEnd callback - * when the screen transition ends. - * - * This is required because transitionEnd event doesn't trigger in the testing environment. - */ - navigation: navigationProp, - }, - ref, - ) => { - /** - * We are only passing navigation as prop from - * ReportScreenWrapper -> ReportScreen -> ScreenWrapper - * - * so in other places where ScreenWrapper is used, we need to - * fallback to useNavigation. - */ - const navigationFallback = useNavigation(); - const navigation = navigationProp || navigationFallback; - const {windowHeight, isSmallScreenWidth} = useWindowDimensions(); - const {initialHeight} = useInitialDimensions(); - const styles = useThemeStyles(); - const keyboardState = useKeyboardState(); - const {isDevelopment} = useEnvironment(); - const {isOffline} = useNetwork(); - const [didScreenTransitionEnd, setDidScreenTransitionEnd] = useState(false); - const maxHeight = shouldEnableMaxHeight ? windowHeight : undefined; - const minHeight = shouldEnableMinHeight && !Browser.isSafari() ? initialHeight : undefined; - const isKeyboardShown = lodashGet(keyboardState, 'isKeyboardShown', false); - - const isKeyboardShownRef = useRef(); - - isKeyboardShownRef.current = lodashGet(keyboardState, 'isKeyboardShown', false); - - const panResponder = useRef( - PanResponder.create({ - onStartShouldSetPanResponderCapture: (_e, gestureState) => gestureState.numberActiveTouches === CONST.TEST_TOOL.NUMBER_OF_TAPS, - onPanResponderRelease: toggleTestToolsModal, - }), - ).current; - - const keyboardDissmissPanResponder = useRef( - PanResponder.create({ - onMoveShouldSetPanResponderCapture: (_e, gestureState) => { - const isHorizontalSwipe = Math.abs(gestureState.dx) > Math.abs(gestureState.dy); - const shouldDismissKeyboard = shouldDismissKeyboardBeforeClose && isKeyboardShown && Browser.isMobile(); - - return isHorizontalSwipe && shouldDismissKeyboard; - }, - onPanResponderGrant: Keyboard.dismiss, - }), - ).current; - - useEffect(() => { - const unsubscribeTransitionEnd = navigation.addListener('transitionEnd', (event) => { - // Prevent firing the prop callback when user is exiting the page. - if (lodashGet(event, 'data.closing')) { - return; - } - - setDidScreenTransitionEnd(true); - onEntryTransitionEnd(); - }); - - // We need to have this prop to remove keyboard before going away from the screen, to avoid previous screen look weird for a brief moment, - // also we need to have generic control in future - to prevent closing keyboard for some rare cases in which beforeRemove has limitations - // described here https://reactnavigation.org/docs/preventing-going-back/#limitations - const beforeRemoveSubscription = shouldDismissKeyboardBeforeClose - ? navigation.addListener('beforeRemove', () => { - if (!isKeyboardShownRef.current) { - return; - } - Keyboard.dismiss(); - }) - : undefined; - - return () => { - unsubscribeTransitionEnd(); - - if (beforeRemoveSubscription) { - beforeRemoveSubscription(); - } - }; - // Rule disabled because this effect is only for component did mount & will component unmount lifecycle event - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - - return ( - - {({insets, paddingTop, paddingBottom, safeAreaPaddingBottomStyle}) => { - const paddingStyle = {}; - - if (includePaddingTop) { - paddingStyle.paddingTop = paddingTop; - } - - // We always need the safe area padding bottom if we're showing the offline indicator since it is bottom-docked. - if (includeSafeAreaPaddingBottom || (isOffline && shouldShowOfflineIndicator)) { - paddingStyle.paddingBottom = paddingBottom; - } - - return ( - - - - - - {isDevelopment && } - {isDevelopment && } - { - // If props.children is a function, call it to provide the insets to the children. - _.isFunction(children) - ? children({ - insets, - safeAreaPaddingBottomStyle, - didScreenTransitionEnd, - }) - : children - } - {isSmallScreenWidth && shouldShowOfflineIndicator && } - - - - - ); - }} - - ); - }, -); - -ScreenWrapper.displayName = 'ScreenWrapper'; -ScreenWrapper.propTypes = propTypes; -ScreenWrapper.defaultProps = defaultProps; - -export default ScreenWrapper; diff --git a/src/components/ScreenWrapper/propTypes.js b/src/components/ScreenWrapper/propTypes.js deleted file mode 100644 index c98968bb112b..000000000000 --- a/src/components/ScreenWrapper/propTypes.js +++ /dev/null @@ -1,68 +0,0 @@ -import PropTypes from 'prop-types'; -import stylePropTypes from '@styles/stylePropTypes'; - -const propTypes = { - /** Array of additional styles to add */ - style: PropTypes.arrayOf(PropTypes.object), - - /** Returns a function as a child to pass insets to or a node to render without insets */ - children: PropTypes.oneOfType([PropTypes.node, PropTypes.func]).isRequired, - - /** A unique ID to find the screen wrapper in tests */ - testID: PropTypes.string.isRequired, - - /** Whether to include padding bottom */ - includeSafeAreaPaddingBottom: PropTypes.bool, - - /** Whether to include padding top */ - includePaddingTop: PropTypes.bool, - - /** Called when navigated Screen's transition is finished. It does not fire when user exit the page. */ - onEntryTransitionEnd: PropTypes.func, - - /** The behavior to pass to the KeyboardAvoidingView, requires some trial and error depending on the layout/devices used. - * Search 'switch(behavior)' in ./node_modules/react-native/Libraries/Components/Keyboard/KeyboardAvoidingView.js for more context */ - keyboardAvoidingViewBehavior: PropTypes.oneOf(['padding', 'height', 'position']), - - /** Whether KeyboardAvoidingView should be enabled. Use false for screens where this functionality is not necessary */ - shouldEnableKeyboardAvoidingView: PropTypes.bool, - - /** Whether picker modal avoiding should be enabled. Should be enabled when there's a picker at the bottom of a - * scrollable form, gives a subtly better UX if disabled on non-scrollable screens with a submit button */ - shouldEnablePickerAvoiding: PropTypes.bool, - - /** Whether to dismiss keyboard before leaving a screen */ - shouldDismissKeyboardBeforeClose: PropTypes.bool, - - /** Whether to use the maxHeight (true) or use the 100% of the height (false) */ - shouldEnableMaxHeight: PropTypes.bool, - - /** Whether to use the minHeight. Use true for screens where the window height are changing because of Virtual Keyboard */ - shouldEnableMinHeight: PropTypes.bool, - - /** Array of additional styles for header gap */ - headerGapStyles: PropTypes.arrayOf(PropTypes.object), - - /** Whether to show offline indicator */ - shouldShowOfflineIndicator: PropTypes.bool, - - /** Styles for the offline indicator */ - offlineIndicatorStyle: stylePropTypes, -}; - -const defaultProps = { - style: [], - includeSafeAreaPaddingBottom: true, - shouldDismissKeyboardBeforeClose: true, - includePaddingTop: true, - onEntryTransitionEnd: () => {}, - keyboardAvoidingViewBehavior: 'padding', - shouldEnableKeyboardAvoidingView: true, - shouldEnableMaxHeight: false, - shouldEnablePickerAvoiding: true, - shouldShowOfflineIndicator: true, - offlineIndicatorStyle: [], - headerGapStyles: [], -}; - -export {propTypes, defaultProps};