Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[CP Staging] Use modal context provider to fix useResponsiveLayout #43013

Merged
merged 9 commits into from
Jun 4, 2024
4 changes: 2 additions & 2 deletions src/components/ConfirmModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,7 @@ function ConfirmModal({
image,
shouldEnableNewFocusManagement,
}: ConfirmModalProps) {
const {shouldUseNarrowLayout} = useResponsiveLayout();
const {isSmallScreenWidth} = useResponsiveLayout();
const styles = useThemeStyles();

return (
Expand All @@ -109,7 +109,7 @@ function ConfirmModal({
isVisible={isVisible}
shouldSetModalVisibility={shouldSetModalVisibility}
onModalHide={onModalHide}
type={shouldUseNarrowLayout ? CONST.MODAL.MODAL_TYPE.BOTTOM_DOCKED : CONST.MODAL.MODAL_TYPE.CONFIRM}
type={isSmallScreenWidth ? CONST.MODAL.MODAL_TYPE.BOTTOM_DOCKED : CONST.MODAL.MODAL_TYPE.CONFIRM}
innerContainerStyle={image ? styles.pt0 : {}}
shouldEnableNewFocusManagement={shouldEnableNewFocusManagement}
>
Expand Down
122 changes: 65 additions & 57 deletions src/components/Modal/BaseModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import ReactNativeModal from 'react-native-modal';
import ColorSchemeWrapper from '@components/ColorSchemeWrapper';
import useKeyboardState from '@hooks/useKeyboardState';
import usePrevious from '@hooks/usePrevious';
import useResponsiveLayout from '@hooks/useResponsiveLayout';
import useSafeAreaInsets from '@hooks/useSafeAreaInsets';
import useStyleUtils from '@hooks/useStyleUtils';
import useTheme from '@hooks/useTheme';
Expand All @@ -17,6 +16,7 @@ import variables from '@styles/variables';
import * as Modal from '@userActions/Modal';
import CONST from '@src/CONST';
import ModalContent from './ModalContent';
import ModalContext from './ModalContext';
import type BaseModalProps from './types';

function BaseModal(
Expand Down Expand Up @@ -55,8 +55,7 @@ function BaseModal(
const theme = useTheme();
const styles = useThemeStyles();
const StyleUtils = useStyleUtils();
const {windowWidth, windowHeight} = useWindowDimensions();
const {shouldUseNarrowLayout} = useResponsiveLayout();
const {isSmallScreenWidth, windowWidth, windowHeight} = useWindowDimensions();
const keyboardStateContextValue = useKeyboardState();

const safeAreaInsets = useSafeAreaInsets();
Expand Down Expand Up @@ -163,13 +162,13 @@ function BaseModal(
{
windowWidth,
windowHeight,
isSmallScreenWidth: shouldUseNarrowLayout,
isSmallScreenWidth,
},
popoverAnchorPosition,
innerContainerStyle,
outerStyle,
),
[StyleUtils, type, windowWidth, windowHeight, shouldUseNarrowLayout, popoverAnchorPosition, innerContainerStyle, outerStyle],
[StyleUtils, type, windowWidth, windowHeight, isSmallScreenWidth, popoverAnchorPosition, innerContainerStyle, outerStyle],
);

const {
Expand Down Expand Up @@ -200,60 +199,69 @@ function BaseModal(
paddingRight: safeAreaPaddingRight ?? 0,
};

const modalContextValue = useMemo(
() => ({
activeModalType: isVisible ? type : undefined,
}),
[isVisible, type],
);

return (
// this is a workaround for modal not being visible on the new arch in some cases
// it's necessary to have a non-collapseable view as a parent of the modal to prevent
// a conflict between RN core and Reanimated shadow tree operations
// position absolute is needed to prevent the view from interfering with flex layout
<View
collapsable={false}
style={[styles.pAbsolute]}
>
<ReactNativeModal
// Prevent the parent element to capture a click. This is useful when the modal component is put inside a pressable.
onClick={(e) => 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={Modal.closeTop}
onModalShow={handleShowModal}
propagateSwipe={propagateSwipe}
onModalHide={hideModal}
onModalWillShow={saveFocusState}
onDismiss={handleDismissModal}
onSwipeComplete={() => onClose?.()}
swipeDirection={swipeDirection}
isVisible={isVisible}
backdropColor={theme.overlay}
backdropOpacity={!shouldUseCustomBackdrop && hideBackdrop ? 0 : variables.overlayOpacity}
backdropTransitionOutTiming={0}
hasBackdrop={fullscreen}
coverScreen={fullscreen}
style={modalStyle}
deviceHeight={windowHeight}
deviceWidth={windowWidth}
animationIn={animationIn ?? modalStyleAnimationIn}
animationOut={animationOut ?? modalStyleAnimationOut}
useNativeDriver={useNativeDriverProp && useNativeDriver}
useNativeDriverForBackdrop={useNativeDriverForBackdrop && useNativeDriver}
hideModalContentWhileAnimating={hideModalContentWhileAnimating}
animationInTiming={animationInTiming}
animationOutTiming={animationOutTiming}
statusBarTranslucent={statusBarTranslucent}
onLayout={onLayout}
avoidKeyboard={avoidKeyboard}
customBackdrop={shouldUseCustomBackdrop ? <Overlay onPress={handleBackdropPress} /> : undefined}
<ModalContext.Provider value={modalContextValue}>
<View
// this is a workaround for modal not being visible on the new arch in some cases
// it's necessary to have a non-collapseable view as a parent of the modal to prevent
// a conflict between RN core and Reanimated shadow tree operations
// position absolute is needed to prevent the view from interfering with flex layout
collapsable={false}
style={[styles.pAbsolute]}
>
<ModalContent onDismiss={handleDismissModal}>
<View
style={[styles.defaultModalContainer, modalPaddingStyles, modalContainerStyle, !isVisible && styles.pointerEventsNone]}
ref={ref}
>
<ColorSchemeWrapper>{children}</ColorSchemeWrapper>
</View>
</ModalContent>
</ReactNativeModal>
</View>
<ReactNativeModal
// Prevent the parent element to capture a click. This is useful when the modal component is put inside a pressable.
onClick={(e) => 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={Modal.closeTop}
onModalShow={handleShowModal}
propagateSwipe={propagateSwipe}
onModalHide={hideModal}
onModalWillShow={saveFocusState}
onDismiss={handleDismissModal}
onSwipeComplete={() => onClose?.()}
swipeDirection={swipeDirection}
isVisible={isVisible}
backdropColor={theme.overlay}
backdropOpacity={!shouldUseCustomBackdrop && hideBackdrop ? 0 : variables.overlayOpacity}
backdropTransitionOutTiming={0}
hasBackdrop={fullscreen}
coverScreen={fullscreen}
style={modalStyle}
deviceHeight={windowHeight}
deviceWidth={windowWidth}
animationIn={animationIn ?? modalStyleAnimationIn}
animationOut={animationOut ?? modalStyleAnimationOut}
useNativeDriver={useNativeDriverProp && useNativeDriver}
useNativeDriverForBackdrop={useNativeDriverForBackdrop && useNativeDriver}
hideModalContentWhileAnimating={hideModalContentWhileAnimating}
animationInTiming={animationInTiming}
animationOutTiming={animationOutTiming}
statusBarTranslucent={statusBarTranslucent}
onLayout={onLayout}
avoidKeyboard={avoidKeyboard}
customBackdrop={shouldUseCustomBackdrop ? <Overlay onPress={handleBackdropPress} /> : undefined}
>
<ModalContent onDismiss={handleDismissModal}>
<View
style={[styles.defaultModalContainer, modalPaddingStyles, modalContainerStyle, !isVisible && styles.pointerEventsNone]}
ref={ref}
>
<ColorSchemeWrapper>{children}</ColorSchemeWrapper>
</View>
</ModalContent>
</ReactNativeModal>
</View>
</ModalContext.Provider>
);
}

Expand Down
15 changes: 15 additions & 0 deletions src/components/Modal/ModalContext.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import {createContext} from 'react';
import type ModalType from '@src/types/utils/ModalType';

type ModalContextType = {
// The type of the currently displayed modal, or undefined if there is no currently displayed modal.
// Note that React Native can only display one modal at a time.
activeModalType?: ModalType;
};

// This context is meant to inform modal children that they are rendering in a modal (and what type of modal they are rendering in)
// Note that this is different than ONYXKEYS.MODAL.isVisible data point in that that is a global variable for whether a modal is visible or not,
// whereas this context is provided by the BaseModal component, and thus is only available to components rendered inside a modal.
const ModalContext = createContext<ModalContextType>({});

export default ModalContext;
16 changes: 8 additions & 8 deletions src/components/Popover/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ function Popover(props: PopoverProps) {
animationOut = 'fadeOut',
} = props;

const {shouldUseNarrowLayout} = useResponsiveLayout();
const {isSmallScreenWidth} = useResponsiveLayout();
const withoutOverlayRef = useRef(null);
const {close, popover} = React.useContext(PopoverContext);

Expand All @@ -55,7 +55,7 @@ function Popover(props: PopoverProps) {
onClose();
};

if (!fullscreen && !shouldUseNarrowLayout) {
if (!fullscreen && !isSmallScreenWidth) {
return createPortal(
<Modal
// eslint-disable-next-line react/jsx-props-no-spreading
Expand All @@ -74,7 +74,7 @@ function Popover(props: PopoverProps) {
);
}

if (withoutOverlay && !shouldUseNarrowLayout) {
if (withoutOverlay && !isSmallScreenWidth) {
return createPortal(
<PopoverWithoutOverlay
// eslint-disable-next-line react/jsx-props-no-spreading
Expand All @@ -93,11 +93,11 @@ function Popover(props: PopoverProps) {
{...props}
onClose={onCloseWithPopoverContext}
shouldHandleNavigationBack={props.shouldHandleNavigationBack}
type={shouldUseNarrowLayout ? CONST.MODAL.MODAL_TYPE.BOTTOM_DOCKED : CONST.MODAL.MODAL_TYPE.POPOVER}
popoverAnchorPosition={shouldUseNarrowLayout ? undefined : anchorPosition}
fullscreen={shouldUseNarrowLayout ? true : fullscreen}
animationInTiming={disableAnimation && !shouldUseNarrowLayout ? 1 : animationInTiming}
animationOutTiming={disableAnimation && !shouldUseNarrowLayout ? 1 : animationOutTiming}
type={isSmallScreenWidth ? CONST.MODAL.MODAL_TYPE.BOTTOM_DOCKED : CONST.MODAL.MODAL_TYPE.POPOVER}
popoverAnchorPosition={isSmallScreenWidth ? undefined : anchorPosition}
fullscreen={isSmallScreenWidth ? true : fullscreen}
animationInTiming={disableAnimation && !isSmallScreenWidth ? 1 : animationInTiming}
animationOutTiming={disableAnimation && !isSmallScreenWidth ? 1 : animationOutTiming}
onLayout={onLayout}
animationIn={animationIn}
animationOut={animationOut}
Expand Down
4 changes: 2 additions & 2 deletions src/components/PopoverMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,7 @@ function PopoverMenu({
shouldEnableNewFocusManagement,
}: PopoverMenuProps) {
const styles = useThemeStyles();
const {shouldUseNarrowLayout} = useResponsiveLayout();
const {isSmallScreenWidth} = useResponsiveLayout();
const selectedItemIndex = useRef<number | null>(null);

const [currentMenuItems, setCurrentMenuItems] = useState(menuItems);
Expand Down Expand Up @@ -198,7 +198,7 @@ function PopoverMenu({
shouldSetModalVisibility={shouldSetModalVisibility}
shouldEnableNewFocusManagement={shouldEnableNewFocusManagement}
>
<View style={shouldUseNarrowLayout ? {} : styles.createMenuContainer}>
<View style={isSmallScreenWidth ? {} : styles.createMenuContainer}>
{!!headerText && <Text style={[styles.createMenuHeaderText, styles.ml3]}>{headerText}</Text>}
{enteredSubMenuIndexes.length > 0 && renderBackButtonItem()}
{currentMenuItems.map((item, menuIndex) => (
Expand Down
75 changes: 41 additions & 34 deletions src/hooks/useResponsiveLayout.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,14 @@
import {useEffect, useRef, useState} from 'react';
import type {OnyxEntry} from 'react-native-onyx';
import {useOnyx} from 'react-native-onyx';
import Navigation, {navigationRef} from '@libs/Navigation/Navigation';
import ONYXKEYS from '@src/ONYXKEYS';
import type {Modal} from '@src/types/onyx';
import {useContext} from 'react';
import ModalContext from '@components/Modal/ModalContext';
import Navigation from '@libs/Navigation/Navigation';
import CONST from '@src/CONST';
import useRootNavigationState from './useRootNavigationState';
import useWindowDimensions from './useWindowDimensions';

type ResponsiveLayoutResult = {
shouldUseNarrowLayout: boolean;
isSmallScreenWidth: boolean;
isInModal: boolean;
isInNarrowPaneModal: boolean;
isExtraSmallScreenHeight: boolean;
isMediumScreenWidth: boolean;
isLargeScreenWidth: boolean;
Expand All @@ -19,39 +18,47 @@ type ResponsiveLayoutResult = {

/**
* Hook to determine if we are on mobile devices or in the Modal Navigator.
* Use "shouldUseNarrowLayout" for "on mobile or in RHP/LHP", "isSmallScreenWidth" for "on mobile", "isInModal" for "in RHP/LHP".
* Use "shouldUseNarrowLayout" for "on mobile or in RHP/LHP", "isSmallScreenWidth" for "on mobile", "isInNarrowPaneModal" for "in RHP/LHP".
*
* There are two kinds of modals in this app:
* 1. Modal stack navigators from react-navigation
* 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-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-modal, see `ModalType`.
*/
export default function useResponsiveLayout(): ResponsiveLayoutResult {
const {isSmallScreenWidth, isExtraSmallScreenHeight, isExtraSmallScreenWidth, isMediumScreenWidth, isLargeScreenWidth, isSmallScreen} = useWindowDimensions();

const [willAlertModalBecomeVisible] = useOnyx(ONYXKEYS.MODAL, {selector: (value: OnyxEntry<Modal>) => value?.willAlertModalBecomeVisible ?? false});
// 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);

const [isInModal, setIsInModal] = useState(false);
const hasSetIsInModal = useRef(false);
const updateModalStatus = () => {
if (hasSetIsInModal.current) {
return;
}
const isDisplayedInModal = Navigation.isDisplayedInModal();
if (isInModal !== isDisplayedInModal) {
setIsInModal(isDisplayedInModal);
}
hasSetIsInModal.current = true;
};

useEffect(() => {
const unsubscribe = navigationRef?.current?.addListener('state', updateModalStatus);
// This refers to the state of the root navigator, and is true if and only if the topmost navigator is the "left modal navigator" or the "right modal navigator"
const isDisplayedInModalNavigator = !!useRootNavigationState(Navigation.isModalNavigatorActive);

if (navigationRef?.current?.isReady()) {
updateModalStatus();
}
// The component calling this hook is in a "narrow pane modal" if:
const isInNarrowPaneModal =
// it's a child of the right-docked modal
activeModalType === CONST.MODAL.MODAL_TYPE.RIGHT_DOCKED ||
// or there's a "right modal navigator" or "left modal navigator" on the top of the root navigation stack
// and the component calling this hook is not the child of another modal type, such as a confirm modal
(isDisplayedInModalNavigator && !activeModalType);

return () => {
unsubscribe?.();
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const shouldUseNarrowLayout = isSmallScreenWidth || isInNarrowPaneModal;

const shouldUseNarrowLayout = willAlertModalBecomeVisible ? isSmallScreenWidth : isSmallScreenWidth || isInModal;
return {shouldUseNarrowLayout, isSmallScreenWidth, isInModal, isExtraSmallScreenHeight, isExtraSmallScreenWidth, isMediumScreenWidth, isLargeScreenWidth, isSmallScreen};
return {
shouldUseNarrowLayout,
isSmallScreenWidth,
isInNarrowPaneModal,
isExtraSmallScreenHeight,
isExtraSmallScreenWidth,
isMediumScreenWidth,
isLargeScreenWidth,
isSmallScreen,
};
}
30 changes: 30 additions & 0 deletions src/hooks/useRootNavigationState.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import type {EventListenerCallback, NavigationContainerEventMap} from '@react-navigation/native';
import type {NavigationState} from '@react-navigation/routers';
import {useCallback, useSyncExternalStore} from 'react';
import {navigationRef} from '@libs/Navigation/Navigation';

/**
* This hook is a replacement for `useNavigationState` for nested navigators.
* If `useNavigationState` is used within a nested navigator then the state that's returned is the state of the nearest parent navigator,
* not the root navigator state representing the whole app's navigation tree.
*
* Use with caution, because re-rendering any component every time the root navigation state changes can be very costly for performance.
* That's why the selector is mandatory.
*/
function useRootNavigationState<T>(selector: (state: NavigationState) => T): T | undefined {
const getSnapshot = useCallback(() => {
if (!navigationRef?.current) {
return;
}
return selector(navigationRef.current.getRootState());
}, [selector]);

const subscribeToRootState = useCallback((callback: EventListenerCallback<NavigationContainerEventMap, 'state'>) => {
const unsubscribe = navigationRef?.current?.addListener('state', callback);
return () => unsubscribe?.();
}, []);

return useSyncExternalStore(subscribeToRootState, getSnapshot);
}

export default useRootNavigationState;
Loading
Loading