Skip to content
152 changes: 63 additions & 89 deletions src/components/Modal/ReanimatedModal/index.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import noop from 'lodash/noop';
import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react';
import React, {useEffect, useEffectEvent, useRef, useState} from 'react';
import type {NativeEventSubscription, ViewStyle} from 'react-native';
// eslint-disable-next-line no-restricted-imports
import {BackHandler, InteractionManager, Modal, StyleSheet, View} from 'react-native';
Expand Down Expand Up @@ -55,37 +55,64 @@ function ReanimatedModal({
shouldReturnFocus,
...props
}: ReanimatedModalProps) {
const [isVisibleState, setIsVisibleState] = useState(isVisible);
const [isContainerOpen, setIsContainerOpen] = useState(false);
const [isTransitioning, setIsTransitioning] = useState(false);
const {windowWidth, windowHeight} = useWindowDimensions();
const styles = useThemeStyles();

const backHandlerListener = useRef<NativeEventSubscription | null>(null);
const handleRef = useRef<number | undefined>(undefined);
const transitionHandleRef = useRef<TransitionHandle | null>(null);

const styles = useThemeStyles();
const isTransitioning = isVisible !== isContainerOpen;
const backdropStyle: ViewStyle = {width: windowWidth, height: windowHeight, backgroundColor: backdropColor};
const modalStyle = {zIndex: StyleSheet.flatten(style)?.zIndex};

const onBackButtonPressHandler = useCallback(() => {
const onBackButtonPressHandler = () => {
if (shouldIgnoreBackHandlerDuringTransition && isTransitioning) {
return false;
}
if (isVisibleState) {
if (isVisible) {
onBackButtonPress();
return true;
}
return false;
}, [isVisibleState, onBackButtonPress, isTransitioning, shouldIgnoreBackHandlerDuringTransition]);
};

const handleEscape = useCallback(
(e: KeyboardEvent) => {
if (e.key !== 'Escape' || onBackButtonPressHandler() !== true) {
return;
}
e.stopImmediatePropagation();
},
[onBackButtonPressHandler],
);
const handleEscape = (e: KeyboardEvent) => {
if (e.key !== 'Escape' || onBackButtonPressHandler() !== true) {
return;
}
e.stopImmediatePropagation();
};

const clearTransitionHandles = () => {
if (handleRef.current) {
InteractionManager.clearInteractionHandle(handleRef.current);
handleRef.current = undefined;
}
if (transitionHandleRef.current) {
TransitionTracker.endTransition(transitionHandleRef.current);
transitionHandleRef.current = null;
}
};

const onOpenCallBack = () => {
setIsContainerOpen(true);
clearTransitionHandles();
onModalShow();
};

const onCloseCallBack = () => {
Comment thread
roryabraham marked this conversation as resolved.
setIsContainerOpen(false);
clearTransitionHandles();

// Because on Android, the Modal's onDismiss callback does not work reliably. There's a reported issue at:
// https://stackoverflow.com/questions/58937956/react-native-modal-ondismiss-not-invoked
// Therefore, we manually call onModalHide() here for Android.
if (getPlatform() === CONST.PLATFORM.ANDROID) {
onModalHide();
}
};

useEffect(() => {
if (getPlatform() === CONST.PLATFORM.WEB) {
Expand All @@ -103,82 +130,29 @@ function ReanimatedModal({
};
}, [handleEscape, onBackButtonPressHandler]);

useEffect(
() => () => {
if (handleRef.current) {
InteractionManager.clearInteractionHandle(handleRef.current);
}
if (transitionHandleRef.current) {
TransitionTracker.endTransition(transitionHandleRef.current);
transitionHandleRef.current = null;
}

setIsVisibleState(false);
setIsContainerOpen(false);
},

[],
);

useEffect(() => {
if (isVisible && !isContainerOpen && !isTransitioning) {
handleRef.current = InteractionManager.createInteractionHandle();
transitionHandleRef.current = TransitionTracker.startTransition();
onModalWillShow();

setIsVisibleState(true);
setIsTransitioning(true);
} else if (!isVisible && isContainerOpen && !isTransitioning) {
if (isTransitioning) {
handleRef.current = InteractionManager.createInteractionHandle();
transitionHandleRef.current = TransitionTracker.startTransition();
onModalWillHide();

blurActiveElement();
setIsVisibleState(false);
setIsTransitioning(true);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isVisible, isContainerOpen, isTransitioning]);

const backdropStyle: ViewStyle = useMemo(() => {
return {width: windowWidth, height: windowHeight, backgroundColor: backdropColor};
}, [windowWidth, windowHeight, backdropColor]);

const onOpenCallBack = useCallback(() => {
setIsTransitioning(false);
setIsContainerOpen(true);
if (handleRef.current) {
InteractionManager.clearInteractionHandle(handleRef.current);
}
if (transitionHandleRef.current) {
TransitionTracker.endTransition(transitionHandleRef.current);
transitionHandleRef.current = null;
}
onModalShow();
}, [onModalShow]);

const onCloseCallBack = useCallback(() => {
setIsTransitioning(false);
setIsContainerOpen(false);
if (handleRef.current) {
InteractionManager.clearInteractionHandle(handleRef.current);
}
if (transitionHandleRef.current) {
TransitionTracker.endTransition(transitionHandleRef.current);
transitionHandleRef.current = null;
}
return () => {
clearTransitionHandles();
};
}, [isTransitioning]);

// Because on Android, the Modal's onDismiss callback does not work reliably. There's a reported issue at:
// https://stackoverflow.com/questions/58937956/react-native-modal-ondismiss-not-invoked
// Therefore, we manually call onModalHide() here for Android.
if (getPlatform() === CONST.PLATFORM.ANDROID) {
onModalHide();
const fireTransitionCallbacks = useEffectEvent(() => {
if (isVisible && !isContainerOpen) {
onModalWillShow();
} else if (!isVisible && isContainerOpen) {
onModalWillHide();
blurActiveElement();
}
}, [onModalHide]);
});

const modalStyle = useMemo(() => {
return {zIndex: StyleSheet.flatten(style)?.zIndex};
}, [style]);
useEffect(() => {
fireTransitionCallbacks();
}, [isVisible, isContainerOpen]);

const containerView = (
<Container
Expand Down Expand Up @@ -212,7 +186,7 @@ function ReanimatedModal({
/>
);

if (!coverScreen && isVisibleState) {
if (!coverScreen && isVisible) {
return (
<View
pointerEvents="box-none"
Expand All @@ -223,8 +197,8 @@ function ReanimatedModal({
</View>
);
}
const isBackdropMounted = isVisibleState || ((isTransitioning || isContainerOpen !== isVisibleState) && getPlatform() === CONST.PLATFORM.WEB);
const modalVisibility = isVisibleState || isTransitioning || isContainerOpen !== isVisibleState;
const isBackdropMounted = isVisible || (isTransitioning && getPlatform() === CONST.PLATFORM.WEB);
const modalVisibility = isVisible || isTransitioning;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve the closing transition when open is cancelled

If a modal is dismissed while its enter animation is still running, onOpenCallBack has not set isContainerOpen yet, so both isVisible and the derived isTransitioning become false. That makes modalVisibility false immediately and skips the normal Container close callback path; on Android this is the path that calls onModalHide() because Modal.onDismiss is documented here as unreliable. This can leave modal cleanup/focus state out of sync when users press the backdrop/back button quickly during opening.

Useful? React with 👍 / 👎.

return (
<LayoutAnimationConfig skipExiting={getPlatform() !== CONST.PLATFORM.WEB}>
<Modal
Expand All @@ -251,7 +225,7 @@ function ReanimatedModal({
pointerEvents="box-none"
style={[style, {margin: 0}]}
>
{isVisibleState && containerView}
{isVisible && containerView}
</KeyboardAvoidingView>
) : (
<FocusTrapForModal
Expand All @@ -260,7 +234,7 @@ function ReanimatedModal({
shouldReturnFocus={shouldReturnFocus ?? !shouldEnableNewFocusManagement}
shouldPreventScroll={shouldPreventScrollOnFocus}
>
{isVisibleState && containerView}
{isVisible && containerView}
</FocusTrapForModal>
)}
</Modal>
Expand Down
Loading