diff --git a/src/CONST/index.ts b/src/CONST/index.ts index eac7a929fbdab..fba7bfe14aab6 100755 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -8171,10 +8171,6 @@ const CONST = { ADD_EXPENSE_APPROVALS: 'addExpenseApprovals', }, - MODAL_EVENTS: { - CLOSED: 'modalClosed', - }, - LIST_BEHAVIOR: { REGULAR: 'regular', INVERTED: 'inverted', diff --git a/src/components/EmojiPicker/EmojiPicker.tsx b/src/components/EmojiPicker/EmojiPicker.tsx index 1fec2354705b4..4a147f34c4d44 100644 --- a/src/components/EmojiPicker/EmojiPicker.tsx +++ b/src/components/EmojiPicker/EmojiPicker.tsx @@ -116,7 +116,7 @@ function EmojiPicker({viewportOffsetTop, ref}: EmojiPickerProps) { // It's possible that the anchor is inside an active modal (e.g., add emoji reaction in report context menu). // So, we need to get the anchor position first before closing the active modal which will also destroy the anchor. - KeyboardUtils.dismiss(true).then(() => + KeyboardUtils.dismiss({shouldSkipSafari: true}).then(() => calculateAnchorPosition(emojiPopoverAnchor?.current, anchorOriginValue).then((value) => { close(() => { onWillShow?.(); diff --git a/src/components/Modal/BaseModal.tsx b/src/components/Modal/BaseModal.tsx index da6a8d1832749..a25239db724ba 100644 --- a/src/components/Modal/BaseModal.tsx +++ b/src/components/Modal/BaseModal.tsx @@ -2,7 +2,7 @@ import React, {useCallback, useContext, useEffect, useMemo, useRef, useState} fr import type {LayoutChangeEvent} from 'react-native'; // Animated required for side panel navigation // eslint-disable-next-line no-restricted-imports -import {Animated, DeviceEventEmitter, View} from 'react-native'; +import {Animated, View} from 'react-native'; import ColorSchemeWrapper from '@components/ColorSchemeWrapper'; import NavigationBar from '@components/NavigationBar'; import ScreenWrapperOfflineIndicatorContext from '@components/ScreenWrapper/ScreenWrapperOfflineIndicatorContext'; @@ -167,8 +167,6 @@ function BaseModal({ [], ); - useEffect(() => () => DeviceEventEmitter.emit(CONST.MODAL_EVENTS.CLOSED), []); - const handleShowModal = useCallback(() => { if (shouldSetModalVisibility) { setModalVisibility(true, type); diff --git a/src/components/Modal/ReanimatedModal/index.tsx b/src/components/Modal/ReanimatedModal/index.tsx index d43aae9bc6046..f22adfb24ee47 100644 --- a/src/components/Modal/ReanimatedModal/index.tsx +++ b/src/components/Modal/ReanimatedModal/index.tsx @@ -11,6 +11,7 @@ import blurActiveElement from '@libs/Accessibility/blurActiveElement'; import getPlatform from '@libs/getPlatform'; import variables from '@styles/variables'; import CONST from '@src/CONST'; +import TransitionTracker from '@libs/Navigation/TransitionTracker'; import Backdrop from './Backdrop'; import Container from './Container'; import type ReanimatedModalProps from './types'; @@ -102,6 +103,7 @@ function ReanimatedModal({ // eslint-disable-next-line @typescript-eslint/no-deprecated InteractionManager.clearInteractionHandle(handleRef.current); } + TransitionTracker.endTransition('modal'); setIsVisibleState(false); setIsContainerOpen(false); @@ -114,6 +116,7 @@ function ReanimatedModal({ if (isVisible && !isContainerOpen && !isTransitioning) { // eslint-disable-next-line @typescript-eslint/no-deprecated handleRef.current = InteractionManager.createInteractionHandle(); + TransitionTracker.startTransition('modal'); onModalWillShow(); setIsVisibleState(true); @@ -121,6 +124,7 @@ function ReanimatedModal({ } else if (!isVisible && isContainerOpen && !isTransitioning) { // eslint-disable-next-line @typescript-eslint/no-deprecated handleRef.current = InteractionManager.createInteractionHandle(); + TransitionTracker.startTransition('modal'); onModalWillHide(); blurActiveElement(); @@ -141,6 +145,7 @@ function ReanimatedModal({ // eslint-disable-next-line @typescript-eslint/no-deprecated InteractionManager.clearInteractionHandle(handleRef.current); } + TransitionTracker.endTransition('modal'); onModalShow(); }, [onModalShow]); @@ -151,6 +156,7 @@ function ReanimatedModal({ // eslint-disable-next-line @typescript-eslint/no-deprecated InteractionManager.clearInteractionHandle(handleRef.current); } + TransitionTracker.endTransition('modal'); // 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 diff --git a/src/libs/Navigation/AppNavigator/Navigators/RightModalNavigator.tsx b/src/libs/Navigation/AppNavigator/Navigators/RightModalNavigator.tsx index d21f399274a91..8a301648120d9 100644 --- a/src/libs/Navigation/AppNavigator/Navigators/RightModalNavigator.tsx +++ b/src/libs/Navigation/AppNavigator/Navigators/RightModalNavigator.tsx @@ -1,8 +1,8 @@ import type {NavigatorScreenParams} from '@react-navigation/native'; import {useFocusEffect} from '@react-navigation/native'; -import React, {useCallback, useEffect, useMemo, useRef} from 'react'; +import React, {useCallback, useMemo, useRef} from 'react'; // eslint-disable-next-line no-restricted-imports -import {Animated, DeviceEventEmitter, InteractionManager} from 'react-native'; +import {Animated, InteractionManager} from 'react-native'; import NoDropZone from '@components/DragAndDrop/NoDropZone'; import {MultifactorAuthenticationContextProviders} from '@components/MultifactorAuthentication/Context'; import { @@ -169,8 +169,6 @@ function RightModalNavigator({navigation, route}: RightModalNavigatorProps) { }, [syncRHPKeys, clearWideRHPKeysAfterTabChanged]), ); - useEffect(() => () => DeviceEventEmitter.emit(CONST.MODAL_EVENTS.CLOSED), []); - return ( diff --git a/src/libs/Navigation/Navigation.ts b/src/libs/Navigation/Navigation.ts index d700e268ff647..e290104ee9baf 100644 --- a/src/libs/Navigation/Navigation.ts +++ b/src/libs/Navigation/Navigation.ts @@ -4,7 +4,7 @@ import {CommonActions, getPathFromState, StackActions} from '@react-navigation/n import {Str} from 'expensify-common'; // eslint-disable-next-line you-dont-need-lodash-underscore/omit import omit from 'lodash/omit'; -import {DeviceEventEmitter, Dimensions, InteractionManager} from 'react-native'; +import {Dimensions} from 'react-native'; import type {OnyxEntry} from 'react-native-onyx'; import Onyx from 'react-native-onyx'; import type {Writable} from 'type-fest'; @@ -38,6 +38,7 @@ import setNavigationActionToMicrotaskQueue from './helpers/setNavigationActionTo import {linkingConfig} from './linkingConfig'; import {SPLIT_TO_SIDEBAR} from './linkingConfig/RELATIONS'; import navigationRef from './navigationRef'; +import TransitionTracker from './TransitionTracker'; import type { NavigationPartialRoute, NavigationRef, @@ -309,6 +310,10 @@ function navigate(route: Route, options?: LinkToOptions) { const targetRoute = route.startsWith(CONST.SAML_REDIRECT_URL) ? ROUTES.HOME : route; linkTo(navigationRef.current, targetRoute, options); closeSidePanelOnNarrowScreen(); + + if (options?.afterTransition) { + TransitionTracker.runAfterTransitions(options.afterTransition); + } } /** * When routes are compared to determine whether the fallback route passed to the goUp function is in the state, @@ -373,10 +378,13 @@ type GoBackOptions = { * In that case we want to goUp to a country picker with any params so we don't compare them. */ compareParams?: boolean; + // Callback to execute after the navigation transition animation completes. + afterTransition?: () => void | undefined; }; const defaultGoBackOptions: Required = { compareParams: true, + afterTransition: () => {}, }; /** @@ -451,6 +459,9 @@ function goBack(backToRoute?: Route, options?: GoBackOptions) { if (backToRoute) { goUp(backToRoute, options); + if (options?.afterTransition) { + TransitionTracker.runAfterTransitions(options.afterTransition); + } return; } @@ -465,6 +476,9 @@ function goBack(backToRoute?: Route, options?: GoBackOptions) { } navigationRef.current?.goBack(); + if (options?.afterTransition) { + TransitionTracker.runAfterTransitions(options.afterTransition); + } } /** @@ -697,60 +711,55 @@ function getTopmostSuperWideRHPReportID(state: NavigationState = navigationRef.g * * @param options - Configuration object * @param options.ref - Navigation ref to use (defaults to navigationRef) - * @param options.callback - Optional callback to execute after the modal has finished closing. - * The callback fires when RightModalNavigator unmounts. + * @param options.afterTransition - Optional callback to execute after the navigation transition animation completes. * * For detailed information about dismissing modals, * see the NAVIGATION.md documentation. */ -const dismissModal = ({ref = navigationRef, callback}: {ref?: NavigationRef; callback?: () => void} = {}) => { +async function dismissModal({ref = navigationRef, afterTransition}: {ref?: NavigationRef; callback?: () => void; afterTransition?: () => void} = {}) { clearSelectedText(); - isNavigationReady().then(() => { - if (callback) { - const subscription = DeviceEventEmitter.addListener(CONST.MODAL_EVENTS.CLOSED, () => { - subscription.remove(); - callback(); - }); - } + await isNavigationReady(); - ref.dispatch({type: CONST.NAVIGATION.ACTION_TYPE.DISMISS_MODAL}); - }); -}; + ref.dispatch({type: CONST.NAVIGATION.ACTION_TYPE.DISMISS_MODAL}); + + if (afterTransition) { + TransitionTracker.runAfterTransitions(afterTransition); + } +} /** * Dismisses the modal and opens the given report. * For detailed information about dismissing modals, * see the NAVIGATION.md documentation. */ -const dismissModalWithReport = ({reportID, reportActionID, referrer, backTo}: ReportsSplitNavigatorParamList[typeof SCREENS.REPORT], ref = navigationRef) => { - isNavigationReady().then(() => { - const topmostSuperWideRHPReportID = getTopmostSuperWideRHPReportID(); - let areReportsIDsDefined = !!topmostSuperWideRHPReportID && !!reportID; +async function dismissModalWithReport({reportID, reportActionID, referrer, backTo}: ReportsSplitNavigatorParamList[typeof SCREENS.REPORT], ref = navigationRef) { + await isNavigationReady(); + const topmostSuperWideRHPReportID = getTopmostSuperWideRHPReportID(); + let areReportsIDsDefined = !!topmostSuperWideRHPReportID && !!reportID; - if (topmostSuperWideRHPReportID === reportID && areReportsIDsDefined) { - dismissToSuperWideRHP(); - return; - } + if (topmostSuperWideRHPReportID === reportID && areReportsIDsDefined) { + dismissToSuperWideRHP(); + return; + } - const topmostReportID = getTopmostReportId(); - areReportsIDsDefined = !!topmostReportID && !!reportID; - const isReportsSplitTopmostFullScreen = ref.getRootState().routes.findLast((route) => isFullScreenName(route.name))?.name === NAVIGATORS.REPORTS_SPLIT_NAVIGATOR; - if (topmostReportID === reportID && areReportsIDsDefined && isReportsSplitTopmostFullScreen) { - dismissModal(); - return; - } - const reportRoute = ROUTES.REPORT_WITH_ID.getRoute(reportID, reportActionID, referrer, backTo); - if (getIsNarrowLayout()) { - navigate(reportRoute, {forceReplace: true}); - return; - } + const topmostReportID = getTopmostReportId(); + areReportsIDsDefined = !!topmostReportID && !!reportID; + const isReportsSplitTopmostFullScreen = ref.getRootState().routes.findLast((route) => isFullScreenName(route.name))?.name === NAVIGATORS.REPORTS_SPLIT_NAVIGATOR; + if (topmostReportID === reportID && areReportsIDsDefined && isReportsSplitTopmostFullScreen) { dismissModal(); - // eslint-disable-next-line @typescript-eslint/no-deprecated - InteractionManager.runAfterInteractions(() => { + return; + } + const reportRoute = ROUTES.REPORT_WITH_ID.getRoute(reportID, reportActionID, referrer, backTo); + if (getIsNarrowLayout()) { + navigate(reportRoute, {forceReplace: true}); + return; + } + dismissModal({ + afterTransition: () => { navigate(reportRoute); - }); + }, }); -}; +} function popRootToTop() { const rootState = navigationRef.getRootState(); diff --git a/src/libs/Navigation/PlatformStackNavigation/ScreenLayout.tsx b/src/libs/Navigation/PlatformStackNavigation/ScreenLayout.tsx new file mode 100644 index 0000000000000..17561a7450c55 --- /dev/null +++ b/src/libs/Navigation/PlatformStackNavigation/ScreenLayout.tsx @@ -0,0 +1,30 @@ +import type {ParamListBase, ScreenLayoutArgs} from '@react-navigation/native'; +import type {StackNavigationOptions} from '@react-navigation/stack'; +import {useLayoutEffect} from 'react'; +import TransitionTracker from '@libs/Navigation/TransitionTracker'; +import type {PlatformStackNavigationOptions, PlatformStackNavigationProp} from './types'; + +function ScreenLayout({ + children, + navigation, + options, + route, +}: ScreenLayoutArgs>) { + useLayoutEffect(() => { + const transitionStartListener = navigation.addListener('transitionStart', () => { + TransitionTracker.startTransition('navigation'); + }); + const transitionEndListener = navigation.addListener('transitionEnd', () => { + TransitionTracker.endTransition('navigation'); + }); + + return () => { + transitionStartListener(); + transitionEndListener(); + }; + }, [navigation, options.animation, route?.name]); + + return children; +} + +export default ScreenLayout; diff --git a/src/libs/Navigation/PlatformStackNavigation/createPlatformStackNavigatorComponent/index.tsx b/src/libs/Navigation/PlatformStackNavigation/createPlatformStackNavigatorComponent/index.tsx index 730e269d507af..3349de27e2f03 100644 --- a/src/libs/Navigation/PlatformStackNavigation/createPlatformStackNavigatorComponent/index.tsx +++ b/src/libs/Navigation/PlatformStackNavigation/createPlatformStackNavigatorComponent/index.tsx @@ -1,6 +1,6 @@ import type {ParamListBase, StackActionHelpers} from '@react-navigation/native'; import {StackRouter, useNavigationBuilder} from '@react-navigation/native'; -import type {StackNavigationEventMap, StackNavigationOptions} from '@react-navigation/stack'; +import type {StackNavigationEventMap, StackNavigationOptions, StackNavigationProp} from '@react-navigation/stack'; import {StackView} from '@react-navigation/stack'; import React, {useMemo} from 'react'; import {addCustomHistoryRouterExtension} from '@libs/Navigation/AppNavigator/customHistory'; @@ -13,6 +13,7 @@ import type { PlatformStackNavigatorProps, PlatformStackRouterOptions, } from '@libs/Navigation/PlatformStackNavigation/types'; +import ScreenLayout from '@libs/Navigation/PlatformStackNavigation/ScreenLayout'; function createPlatformStackNavigatorComponent( displayName: string, @@ -35,6 +36,7 @@ function createPlatformStackNavigatorComponent) { const { @@ -62,6 +64,14 @@ function createPlatformStackNavigatorComponent ( + } + /> + ), }, convertToWebNavigationOptions, ); diff --git a/src/libs/Navigation/TransitionTracker.ts b/src/libs/Navigation/TransitionTracker.ts new file mode 100644 index 0000000000000..da17da924d313 --- /dev/null +++ b/src/libs/Navigation/TransitionTracker.ts @@ -0,0 +1,123 @@ +type CancelHandle = {cancel: () => void}; + +type TransitionType = 'keyboard' | 'navigation' | 'modal' | 'focus'; + +type PendingEntry = {callback: () => void; type?: TransitionType}; + +const MAX_TRANSITION_DURATION_MS = 1000; + +const activeTransitions = new Map(); + +const activeTimeouts: Array<{type: TransitionType; timeout: ReturnType}> = []; + +let pendingCallbacks: PendingEntry[] = []; + +/** + * Invokes and removes pending callbacks. + * + * @param type - When provided, only flushes entries scoped to that type. + * When omitted, flushes all remaining entries. + */ +function flushCallbacks(type?: TransitionType): void { + const remaining: PendingEntry[] = []; + for (const entry of pendingCallbacks) { + if (type === undefined || entry.type === type) { + entry.callback(); + } else { + remaining.push(entry); + } + } + pendingCallbacks = remaining; +} + +/** + * Decrements the active count for the given transition type and flushes matching callbacks. + * Shared by {@link endTransition} (manual) and the auto-timeout. + */ +function decrementAndFlush(type: TransitionType): void { + const current = activeTransitions.get(type) ?? 0; + const next = Math.max(0, current - 1); + + if (next === 0) { + activeTransitions.delete(type); + } else { + activeTransitions.set(type, next); + } + + // Flush callbacks scoped to this specific type + flushCallbacks(type); + + // When all transitions end, flush remaining unscoped callbacks + if (activeTransitions.size === 0) { + flushCallbacks(); + } +} + +/** + * Increments the active count for the given transition type. + * Multiple overlapping transitions of the same type are counted. + * Each transition automatically ends after {@link MAX_TRANSITION_DURATION_MS} as a safety net. + */ +function startTransition(type: TransitionType): void { + const current = activeTransitions.get(type) ?? 0; + activeTransitions.set(type, current + 1); + + const timeout = setTimeout(() => { + const idx = activeTimeouts.findIndex((entry) => entry.timeout === timeout); + if (idx !== -1) { + activeTimeouts.splice(idx, 1); + } + decrementAndFlush(type); + }, MAX_TRANSITION_DURATION_MS); + + activeTimeouts.push({type, timeout}); +} + +/** + * Decrements the active count for the given transition type. + * Clears the corresponding auto-timeout since the transition ended normally. + * When the count reaches zero, flushes callbacks scoped to that type. + * When all transition types are idle, flushes remaining unscoped callbacks. + */ +function endTransition(type: TransitionType): void { + // Clear the oldest timeout for this type (FIFO order matches startTransition order) + const timeoutIdx = activeTimeouts.findIndex((entry) => entry.type === type); + if (timeoutIdx !== -1) { + clearTimeout(activeTimeouts.at(timeoutIdx)?.timeout); + activeTimeouts.splice(timeoutIdx, 1); + } + + decrementAndFlush(type); +} + +/** + * Schedules a callback to run after transitions complete. If no transitions are active + * (or the specified type is idle), the callback fires on the next microtask. + * + * @param callback - The function to invoke once transitions finish. + * @param type - Optional transition type to scope the wait. When provided, the callback + * fires as soon as that specific type finishes, even if other types are still active. + * When omitted, waits for all transition types to be idle. + * @returns A handle with a `cancel` method to prevent the callback from firing. + */ +function runAfterTransitions(callback: () => void, type?: TransitionType): CancelHandle { + const entry: PendingEntry = {callback, type}; + pendingCallbacks.push(entry); + return { + cancel: () => { + const idx = pendingCallbacks.indexOf(entry); + if (idx !== -1) { + pendingCallbacks.splice(idx, 1); + } + }, + }; +} + +const TransitionTracker = { + startTransition, + endTransition, + runAfterTransitions, +}; + +export default TransitionTracker; +export type {TransitionType, CancelHandle}; diff --git a/src/libs/Navigation/helpers/linkTo/types.ts b/src/libs/Navigation/helpers/linkTo/types.ts index 26217f561b9e9..9c7bd4e1cc40a 100644 --- a/src/libs/Navigation/helpers/linkTo/types.ts +++ b/src/libs/Navigation/helpers/linkTo/types.ts @@ -10,7 +10,9 @@ type ActionPayload = { type LinkToOptions = { // To explicitly set the action type to replace. - forceReplace: boolean; + forceReplace?: boolean; + // Callback to execute after the navigation transition animation completes. + afterTransition?: () => void; }; export type {ActionPayload, LinkToOptions}; diff --git a/src/libs/actions/Report/index.ts b/src/libs/actions/Report/index.ts index dd0ad4dd74c8d..1a8f342381699 100644 --- a/src/libs/actions/Report/index.ts +++ b/src/libs/actions/Report/index.ts @@ -1640,7 +1640,7 @@ function navigateToAndOpenReport( if (shouldDismissModal) { Navigation.dismissModal({ - callback: () => { + afterTransition: () => { if (!report?.reportID) { return; } diff --git a/src/pages/NewChatPage.tsx b/src/pages/NewChatPage.tsx index f517894144e4c..9a542411576d2 100755 --- a/src/pages/NewChatPage.tsx +++ b/src/pages/NewChatPage.tsx @@ -379,7 +379,7 @@ function NewChatPage({ref}: NewChatPageProps) { if (option?.reportID) { Navigation.dismissModal({ - callback: () => { + afterTransition: () => { Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(option?.reportID)); }, }); diff --git a/src/pages/media/AttachmentModalScreen/routes/TransactionReceiptModalContent.tsx b/src/pages/media/AttachmentModalScreen/routes/TransactionReceiptModalContent.tsx index 445d433d5daf7..e94306d78571f 100644 --- a/src/pages/media/AttachmentModalScreen/routes/TransactionReceiptModalContent.tsx +++ b/src/pages/media/AttachmentModalScreen/routes/TransactionReceiptModalContent.tsx @@ -316,7 +316,7 @@ function TransactionReceiptModalContent({navigation, route}: AttachmentModalScre text: translate('common.replace'), onSelected: () => { Navigation.dismissModal({ - callback: () => + afterTransition: () => Navigation.navigate( isOdometerImage ? ROUTES.ODOMETER_IMAGE.getRoute(action ?? CONST.IOU.ACTION.CREATE, iouType, transactionID, reportID, imageType) diff --git a/src/pages/workspace/categories/WorkspaceCategoriesSettingsPage.tsx b/src/pages/workspace/categories/WorkspaceCategoriesSettingsPage.tsx index 103b2251afe27..9afb88691ae0c 100644 --- a/src/pages/workspace/categories/WorkspaceCategoriesSettingsPage.tsx +++ b/src/pages/workspace/categories/WorkspaceCategoriesSettingsPage.tsx @@ -1,5 +1,5 @@ import React, {useCallback, useMemo, useState} from 'react'; -import {InteractionManager, Keyboard, View} from 'react-native'; +import {View} from 'react-native'; import CategorySelectorModal from '@components/CategorySelector/CategorySelectorModal'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; import ScreenWrapper from '@components/ScreenWrapper'; @@ -26,6 +26,7 @@ import {clearPolicyErrorField, setWorkspaceDefaultSpendCategory} from '@userActi import CONST from '@src/CONST'; import ROUTES from '@src/ROUTES'; import SCREENS from '@src/SCREENS'; +import KeyboardUtils from '@src/utils/keyboard'; type WorkspaceCategoriesSettingsPageProps = WithPolicyConnectionsProps & ( @@ -80,10 +81,12 @@ function WorkspaceCategoriesSettingsPage({policy, route}: WorkspaceCategoriesSet setWorkspaceDefaultSpendCategory(policyID, currentGroupID, selectedCategory.keyForList); } - Keyboard.dismiss(); - // eslint-disable-next-line @typescript-eslint/no-deprecated - InteractionManager.runAfterInteractions(() => { - setIsSelectorModalVisible(false); + console.log('test: dismissing keyboard'); + KeyboardUtils.dismiss({ + afterTransition: () => { + console.log('test: after interactions'); + return setIsSelectorModalVisible(false); + }, }); }; diff --git a/src/pages/workspace/members/WorkspaceInviteMessageComponent.tsx b/src/pages/workspace/members/WorkspaceInviteMessageComponent.tsx index adf9f06691940..b5d71bc9f12a1 100644 --- a/src/pages/workspace/members/WorkspaceInviteMessageComponent.tsx +++ b/src/pages/workspace/members/WorkspaceInviteMessageComponent.tsx @@ -148,7 +148,7 @@ function WorkspaceInviteMessageComponent({ } if ((backTo as string)?.endsWith('members')) { - Navigation.setNavigationActionToMicrotaskQueue(() => Navigation.dismissModal()); + Navigation.dismissModal(); return; } @@ -157,8 +157,8 @@ function WorkspaceInviteMessageComponent({ return; } - Navigation.setNavigationActionToMicrotaskQueue(() => { - Navigation.dismissModal({callback: () => Navigation.navigate(ROUTES.WORKSPACE_MEMBERS.getRoute(policyID))}); + Navigation.dismissModal({ + afterTransition: () => Navigation.navigate(ROUTES.WORKSPACE_MEMBERS.getRoute(policyID)), }); }; diff --git a/src/pages/workspace/tags/WorkspaceCreateTagPage.tsx b/src/pages/workspace/tags/WorkspaceCreateTagPage.tsx index ec0b14d9a995f..774e43e3aa52f 100644 --- a/src/pages/workspace/tags/WorkspaceCreateTagPage.tsx +++ b/src/pages/workspace/tags/WorkspaceCreateTagPage.tsx @@ -1,5 +1,4 @@ import React, {useCallback} from 'react'; -import {Keyboard} from 'react-native'; import FormProvider from '@components/Form/FormProvider'; import InputWrapper from '@components/Form/InputWrapper'; import type {FormInputErrors, FormOnyxValues} from '@components/Form/types'; @@ -24,6 +23,7 @@ import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; import SCREENS from '@src/SCREENS'; import INPUT_IDS from '@src/types/form/WorkspaceTagForm'; +import KeyboardUtils from '@src/utils/keyboard'; type WorkspaceCreateTagPageProps = | PlatformStackScreenProps @@ -70,8 +70,9 @@ function WorkspaceCreateTagPage({route}: WorkspaceCreateTagPageProps) { const createTag = useCallback( (values: FormOnyxValues) => { createPolicyTag(policyID, values.tagName.trim(), policyTags, setupTagsTaskReport, setupCategoriesAndTagsTaskReport, policyHasCustomCategories); - Keyboard.dismiss(); - Navigation.goBack(isQuickSettingsFlow ? ROUTES.SETTINGS_TAGS_ROOT.getRoute(policyID, backTo) : undefined); + KeyboardUtils.dismiss({ + afterTransition: () => Navigation.goBack(isQuickSettingsFlow ? ROUTES.SETTINGS_TAGS_ROOT.getRoute(policyID, backTo) : undefined), + }); }, [policyID, policyTags, isQuickSettingsFlow, backTo, setupTagsTaskReport, setupCategoriesAndTagsTaskReport, policyHasCustomCategories], ); diff --git a/src/utils/keyboard/index.android.ts b/src/utils/keyboard/index.android.ts index b15d81367e302..b3460dd2317ef 100644 --- a/src/utils/keyboard/index.android.ts +++ b/src/utils/keyboard/index.android.ts @@ -1,5 +1,7 @@ import {Keyboard} from 'react-native'; import {KeyboardEvents} from 'react-native-keyboard-controller'; +import TransitionTracker from '@libs/Navigation/TransitionTracker'; +import type {DismissKeyboardOptions} from './types'; type SimplifiedKeyboardEvent = { height?: number; @@ -21,7 +23,7 @@ const subscribeKeyboardVisibilityChange = (cb: (isVisible: boolean) => void) => }; // eslint-disable-next-line @typescript-eslint/no-unused-vars -const dismiss = (shouldSkipSafari?: boolean): Promise => { +const dismiss = (options?: DismissKeyboardOptions): Promise => { return new Promise((resolve) => { if (!isVisible) { resolve(); @@ -31,10 +33,15 @@ const dismiss = (shouldSkipSafari?: boolean): Promise => { const subscription = Keyboard.addListener('keyboardDidHide', () => { resolve(); + TransitionTracker.endTransition('keyboard'); subscription.remove(); }); Keyboard.dismiss(); + TransitionTracker.startTransition('keyboard'); + if (options?.afterTransition) { + TransitionTracker.runAfterTransitions(options.afterTransition); + } }); }; diff --git a/src/utils/keyboard/index.ts b/src/utils/keyboard/index.ts index 3ff8096680c62..4c0f1c03202e3 100644 --- a/src/utils/keyboard/index.ts +++ b/src/utils/keyboard/index.ts @@ -1,4 +1,6 @@ +import TransitionTracker from '@libs/Navigation/TransitionTracker'; import {Keyboard} from 'react-native'; +import type {DismissKeyboardOptions} from './types'; type SimplifiedKeyboardEvent = { height?: number; @@ -20,7 +22,7 @@ const subscribeKeyboardVisibilityChange = (cb: (isVisible: boolean) => void) => }; // eslint-disable-next-line @typescript-eslint/no-unused-vars -const dismiss = (shouldSkipSafari?: boolean): Promise => { +const dismiss = (options?: DismissKeyboardOptions): Promise => { return new Promise((resolve) => { if (!isVisible) { resolve(); @@ -30,10 +32,16 @@ const dismiss = (shouldSkipSafari?: boolean): Promise => { const subscription = Keyboard.addListener('keyboardDidHide', () => { resolve(); + TransitionTracker.endTransition('keyboard'); subscription.remove(); }); + TransitionTracker.startTransition('keyboard'); Keyboard.dismiss(); + + if (options?.afterTransition) { + TransitionTracker.runAfterTransitions(options.afterTransition); + } }); }; diff --git a/src/utils/keyboard/index.website.ts b/src/utils/keyboard/index.website.ts index 3c40a2eced2ea..3c49a40873387 100644 --- a/src/utils/keyboard/index.website.ts +++ b/src/utils/keyboard/index.website.ts @@ -1,6 +1,8 @@ import {Keyboard} from 'react-native'; import {isMobile, isMobileSafari} from '@libs/Browser'; import CONST from '@src/CONST'; +import TransitionTracker from '@libs/Navigation/TransitionTracker'; +import type {DismissKeyboardOptions} from './types'; let isVisible = false; const initialViewportHeight = window?.visualViewport?.height; @@ -34,9 +36,9 @@ const handleResize = () => { window.visualViewport?.addEventListener('resize', handleResize); -const dismiss = (shouldSkipSafari = false): Promise => { +const dismiss = (options?: DismissKeyboardOptions): Promise => { return new Promise((resolve) => { - if (shouldSkipSafari && isMobileSafari()) { + if (options?.shouldSkipSafari && isMobileSafari()) { resolve(); return; } @@ -58,11 +60,16 @@ const dismiss = (shouldSkipSafari = false): Promise => { } window.visualViewport?.removeEventListener('resize', handleDismissResize); + TransitionTracker.endTransition('keyboard'); return resolve(); }; window.visualViewport?.addEventListener('resize', handleDismissResize); Keyboard.dismiss(); + TransitionTracker.startTransition('keyboard'); + if (options?.afterTransition) { + TransitionTracker.runAfterTransitions(options.afterTransition); + } }); }; diff --git a/src/utils/keyboard/types.ts b/src/utils/keyboard/types.ts new file mode 100644 index 0000000000000..374054755852c --- /dev/null +++ b/src/utils/keyboard/types.ts @@ -0,0 +1,7 @@ +type DismissKeyboardOptions = { + shouldSkipSafari?: boolean; + afterTransition?: () => void; +}; + +// eslint-disable-next-line import/prefer-default-export +export type {DismissKeyboardOptions};