diff --git a/src/components/withTabAnimation.js b/src/components/withTabAnimation.js deleted file mode 100644 index 2af96f0215a..00000000000 --- a/src/components/withTabAnimation.js +++ /dev/null @@ -1,72 +0,0 @@ -import {useTabAnimation} from '@react-navigation/material-top-tabs'; -import PropTypes from 'prop-types'; -import * as React from 'react'; -import getComponentDisplayName from '@libs/getComponentDisplayName'; -import refPropTypes from './refPropTypes'; - -const propTypes = { - /** The HOC takes an optional ref as a prop and passes it as a ref to the wrapped component. - * That way, if a ref is passed to a component wrapped in the HOC, the ref is a reference to the wrapped component, not the HOC. */ - forwardedRef: refPropTypes, - - /* Whether we're in a tab navigator */ - isInTabNavigator: PropTypes.bool.isRequired, -}; - -const defaultProps = { - forwardedRef: () => {}, -}; - -export default function (WrappedComponent) { - // The component with tab animation prop - function WrappedComponentWithTabAnimation(props) { - const animation = useTabAnimation(); - - return ( - - ); - } - - WrappedComponentWithTabAnimation.displayName = `withAnimation(${getComponentDisplayName(WrappedComponent)})`; - - // Return a component with tab animation prop if this component is in tab navigator, otherwise return itself - function WithTabAnimation({forwardedRef, ...rest}) { - if (rest.isInTabNavigator) { - return ( - - ); - } - return ( - - ); - } - - WithTabAnimation.propTypes = propTypes; - WithTabAnimation.defaultProps = defaultProps; - WithTabAnimation.displayName = `withTabAnimation(${getComponentDisplayName(WrappedComponent)})`; - - // eslint-disable-next-line rulesdir/no-negated-variables - const WithTabAnimationWithRef = React.forwardRef((props, ref) => ( - - )); - - WithTabAnimationWithRef.displayName = `withTabAnimationWithRef(${getComponentDisplayName(WrappedComponent)})`; - - return WithTabAnimationWithRef; -} diff --git a/src/hooks/useTabNavigatorFocus/index.js b/src/hooks/useTabNavigatorFocus/index.js new file mode 100644 index 00000000000..f83ec5bd927 --- /dev/null +++ b/src/hooks/useTabNavigatorFocus/index.js @@ -0,0 +1,79 @@ +import {useTabAnimation} from '@react-navigation/material-top-tabs'; +import {useIsFocused} from '@react-navigation/native'; +import {useEffect, useState} from 'react'; +import DomUtils from '@libs/DomUtils'; + +/** + * Custom React hook to determine the focus status of a tab in a Material Top Tab Navigator. + * It evaluates whether the current tab is focused based on the tab's animation position and + * the screen's focus status within a React Navigation environment. + * + * This hook is designed for use with the Material Top Tabs provided by '@react-navigation/material-top-tabs'. + * It leverages the `useTabAnimation` hook from the same package to track the animated position of tabs + * and the `useIsFocused` hook from '@react-navigation/native' to ascertain if the current screen is in focus. + * + * Note: This hook contains a conditional invocation of another hook (`useTabAnimation`), + * which is typically an anti-pattern in React. This is done to account for scenarios where the hook + * might not be used within a Material Top Tabs Navigator context. Proper usage should ensure that + * this hook is only used where appropriate. + * + * @param {Object} params - The parameters object. + * @param {Number} params.tabIndex - The index of the tab for which focus status is being determined. + * @returns {Boolean} Returns `true` if the tab is both animation-focused and screen-focused, otherwise `false`. + * + * @example + * const isTabFocused = useTabNavigatorFocus({ tabIndex: 1 }); + */ +function useTabNavigatorFocus({tabIndex}) { + let tabPositionAnimation = null; + try { + // Retrieve the animation value from the tab navigator, which ranges from 0 to the total number of pages displayed. + // Even a minimal scroll towards the camera page (e.g., a value of 0.001 at start) should activate the camera for immediate responsiveness. + // STOP!!!!!!! This is not a pattern to be followed! We are conditionally rendering this hook becase when used in the edit flow we'll never be inside a tab navigator. + // eslint-disable-next-line react-hooks/rules-of-hooks + tabPositionAnimation = useTabAnimation(); + } catch (error) { + tabPositionAnimation = null; + } + const isPageFocused = useIsFocused(); + // set to true if the hook is not used within the MaterialTopTabs context + // the hook will then return true if the screen is focused + const [isTabFocused, setIsTabFocused] = useState(!tabPositionAnimation); + + useEffect(() => { + if (!tabPositionAnimation) { + return; + } + const index = Number(tabIndex); + + const listenerId = tabPositionAnimation.addListener(({value}) => { + // Activate camera as soon the index is animating towards the `tabIndex` + DomUtils.requestAnimationFrame(() => { + setIsTabFocused(value > index - 1 && value < index + 1); + }); + }); + + // We need to get the position animation value on component initialization to determine + // if the tab is focused or not. Since it's an Animated.Value the only synchronous way + // to retrieve the value is to use a private method. + // eslint-disable-next-line no-underscore-dangle + const initialTabPositionValue = tabPositionAnimation.__getValue(); + + if (typeof initialTabPositionValue === 'number') { + DomUtils.requestAnimationFrame(() => { + setIsTabFocused(initialTabPositionValue > index - 1 && initialTabPositionValue < index + 1); + }); + } + + return () => { + if (!tabPositionAnimation) { + return; + } + tabPositionAnimation.removeListener(listenerId); + }; + }, [tabIndex, tabPositionAnimation]); + + return isTabFocused && isPageFocused; +} + +export default useTabNavigatorFocus; diff --git a/src/libs/DomUtils/index.native.ts b/src/libs/DomUtils/index.native.ts index 9a975822877..0864f1a16ac 100644 --- a/src/libs/DomUtils/index.native.ts +++ b/src/libs/DomUtils/index.native.ts @@ -2,6 +2,15 @@ import GetActiveElement from './types'; const getActiveElement: GetActiveElement = () => null; +const requestAnimationFrame = (callback: () => void) => { + if (!callback) { + return; + } + + callback(); +}; + export default { getActiveElement, + requestAnimationFrame, }; diff --git a/src/libs/DomUtils/index.ts b/src/libs/DomUtils/index.ts index 94dd5454745..6a2eed57fbe 100644 --- a/src/libs/DomUtils/index.ts +++ b/src/libs/DomUtils/index.ts @@ -4,4 +4,5 @@ const getActiveElement: GetActiveElement = () => document.activeElement; export default { getActiveElement, + requestAnimationFrame: window.requestAnimationFrame.bind(window), }; diff --git a/src/pages/iou/ReceiptSelector/NavigationAwareCamera/index.js b/src/pages/iou/ReceiptSelector/NavigationAwareCamera/index.js index e4b24f8a0ad..10b16da13b6 100644 --- a/src/pages/iou/ReceiptSelector/NavigationAwareCamera/index.js +++ b/src/pages/iou/ReceiptSelector/NavigationAwareCamera/index.js @@ -1,17 +1,20 @@ -import {useIsFocused} from '@react-navigation/native'; import PropTypes from 'prop-types'; import React, {useEffect, useRef} from 'react'; import {View} from 'react-native'; import Webcam from 'react-webcam'; +import useTabNavigatorFocus from '@hooks/useTabNavigatorFocus'; const propTypes = { - /* Flag to turn on/off the torch/flashlight - if available */ + /** Flag to turn on/off the torch/flashlight - if available */ torchOn: PropTypes.bool, - /* Callback function when media stream becomes available - user granted camera permissions and camera starts to work */ + /** The index of the tab that contains this camera */ + cameraTabIndex: PropTypes.number.isRequired, + + /** Callback function when media stream becomes available - user granted camera permissions and camera starts to work */ onUserMedia: PropTypes.func, - /* Callback function passing torch/flashlight capability as bool param of the browser */ + /** Callback function passing torch/flashlight capability as bool param of the browser */ onTorchAvailability: PropTypes.func, }; @@ -22,9 +25,11 @@ const defaultProps = { }; // Wraps a camera that will only be active when the tab is focused or as soon as it starts to become focused. -const NavigationAwareCamera = React.forwardRef(({torchOn, onTorchAvailability, ...props}, ref) => { +const NavigationAwareCamera = React.forwardRef(({torchOn, onTorchAvailability, cameraTabIndex, ...props}, ref) => { const trackRef = useRef(null); - const isCameraActive = useIsFocused(); + const shouldShowCamera = useTabNavigatorFocus({ + tabIndex: cameraTabIndex, + }); const handleOnUserMedia = (stream) => { if (props.onUserMedia) { @@ -51,7 +56,7 @@ const NavigationAwareCamera = React.forwardRef(({torchOn, onTorchAvailability, . }); }, [torchOn]); - if (!isCameraActive) { + if (!shouldShowCamera) { return null; } return ( diff --git a/src/pages/iou/ReceiptSelector/NavigationAwareCamera/index.native.js b/src/pages/iou/ReceiptSelector/NavigationAwareCamera/index.native.js index eca8042a696..65c17d3cb7a 100644 --- a/src/pages/iou/ReceiptSelector/NavigationAwareCamera/index.native.js +++ b/src/pages/iou/ReceiptSelector/NavigationAwareCamera/index.native.js @@ -1,77 +1,16 @@ -import {useNavigation} from '@react-navigation/native'; import PropTypes from 'prop-types'; -import React, {useEffect, useState} from 'react'; +import React from 'react'; import {Camera} from 'react-native-vision-camera'; -import withTabAnimation from '@components/withTabAnimation'; -import CONST from '@src/CONST'; +import useTabNavigatorFocus from '@hooks/useTabNavigatorFocus'; const propTypes = { /* The index of the tab that contains this camera */ cameraTabIndex: PropTypes.number.isRequired, - - /* Whether we're in a tab navigator */ - isInTabNavigator: PropTypes.bool.isRequired, - - /** Name of the selected receipt tab */ - selectedTab: PropTypes.string.isRequired, - - /** The tab animation from hook */ - tabAnimation: PropTypes.shape({ - addListener: PropTypes.func, - removeListener: PropTypes.func, - }), -}; - -const defaultProps = { - tabAnimation: undefined, }; // Wraps a camera that will only be active when the tab is focused or as soon as it starts to become focused. -const NavigationAwareCamera = React.forwardRef(({cameraTabIndex, isInTabNavigator, selectedTab, tabAnimation, ...props}, ref) => { - // Get navigation to get initial isFocused value (only needed once during init!) - const navigation = useNavigation(); - const [isCameraActive, setIsCameraActive] = useState(() => navigation.isFocused()); - - // Retrieve the animation value from the tab navigator, which ranges from 0 to the total number of pages displayed. - // Even a minimal scroll towards the camera page (e.g., a value of 0.001 at start) should activate the camera for immediate responsiveness. - - useEffect(() => { - if (!isInTabNavigator) { - return; - } - - const listenerId = tabAnimation.addListener(({value}) => { - if (selectedTab !== CONST.TAB.SCAN) { - return; - } - // Activate camera as soon the index is animating towards the `cameraTabIndex` - setIsCameraActive(value > cameraTabIndex - 1 && value < cameraTabIndex + 1); - }); - - return () => { - tabAnimation.removeListener(listenerId); - }; - }, [cameraTabIndex, tabAnimation, isInTabNavigator, selectedTab]); - - // Note: The useEffect can be removed once VisionCamera V3 is used. - // Its only needed for android, because there is a native cameraX android bug. With out this flow would break the camera: - // 1. Open camera tab - // 2. Take a picture - // 3. Go back from the opened screen - // 4. The camera is not working anymore - useEffect(() => { - const removeBlurListener = navigation.addListener('blur', () => { - setIsCameraActive(false); - }); - const removeFocusListener = navigation.addListener('focus', () => { - setIsCameraActive(true); - }); - - return () => { - removeBlurListener(); - removeFocusListener(); - }; - }, [navigation]); +const NavigationAwareCamera = React.forwardRef(({cameraTabIndex, ...props}, ref) => { + const isCameraActive = useTabNavigatorFocus({tabIndex: cameraTabIndex}); return ( !state, false); - const [isTorchAvailable, setIsTorchAvailable] = useState(true); + const [isTorchAvailable, setIsTorchAvailable] = useState(false); const cameraRef = useRef(null); const hideReciptModal = () => { @@ -200,6 +196,7 @@ function ReceiptSelector({route, transactionID, iou, report}) { torchOn={isFlashLightOn} onTorchAvailability={setIsTorchAvailable} forceScreenshotSourceSize + cameraTabIndex={pageIndex} /> diff --git a/src/pages/iou/ReceiptSelector/index.native.js b/src/pages/iou/ReceiptSelector/index.native.js index 824c242cf02..ef81109ffb9 100644 --- a/src/pages/iou/ReceiptSelector/index.native.js +++ b/src/pages/iou/ReceiptSelector/index.native.js @@ -50,23 +50,15 @@ const propTypes = { /** The id of the transaction we're editing */ transactionID: PropTypes.string, - - /** Whether or not the receipt selector is in a tab navigator for tab animations */ - isInTabNavigator: PropTypes.bool, - - /** Name of the selected receipt tab */ - selectedTab: PropTypes.string, }; const defaultProps = { report: {}, iou: iouDefaultProps, transactionID: '', - isInTabNavigator: true, - selectedTab: '', }; -function ReceiptSelector({route, report, iou, transactionID, isInTabNavigator, selectedTab}) { +function ReceiptSelector({route, report, iou, transactionID}) { const devices = useCameraDevices('wide-angle-camera'); const device = devices.back; @@ -218,8 +210,6 @@ function ReceiptSelector({route, report, iou, transactionID, isInTabNavigator, s zoom={device.neutralZoom} photo cameraTabIndex={pageIndex} - isInTabNavigator={isInTabNavigator} - selectedTab={selectedTab} /> )}