diff --git a/patches/react-native-screens/details.md b/patches/react-native-screens/details.md index d903512dcac5..5a3591e0662a 100644 --- a/patches/react-native-screens/details.md +++ b/patches/react-native-screens/details.md @@ -104,3 +104,10 @@ - Upstream PR/issue: [#4985](https://github.com/software-mansion/react-native-screens/pull/4985) - E/App issue: https://github.com/Expensify/App/issues/104284 - PR introducing patch: [#104368](https://github.com/Expensify/App/pull/104368) + +### [react-native-screens+4.28.0+007+tab-bar-item-selection-hidden.patch](react-native-screens+4.28.0+007+tab-bar-item-selection-hidden.patch) + +- Reason: Adds an iOS `tabBarItemSelectionHidden` prop to `Tabs.Host` that hides the iOS 26 liquid glass selection, which UIKit offers no API for. On Android the selected label is not bold while the active indicator is transparent. Used for Account, which has no bar item with the Insights beta (React Navigation patch 005). +- Upstream PR/issue: not reported, because the iOS part hides private UIKit views by class name, which RNScreens would not take as is. +- E/App issue: [#104197](https://github.com/Expensify/App/issues/104197) +- PR introducing patch: [#104491](https://github.com/Expensify/App/pull/104491) diff --git a/patches/react-native-screens/react-native-screens+4.28.0+007+tab-bar-item-selection-hidden.patch b/patches/react-native-screens/react-native-screens+4.28.0+007+tab-bar-item-selection-hidden.patch new file mode 100644 index 000000000000..8d1531ee8458 --- /dev/null +++ b/patches/react-native-screens/react-native-screens+4.28.0+007+tab-bar-item-selection-hidden.patch @@ -0,0 +1,112 @@ +diff --git a/node_modules/react-native-screens/android/src/main/java/com/swmansion/rnscreens/gamma/tabs/appearance/TabsAppearanceApplicator.kt b/node_modules/react-native-screens/android/src/main/java/com/swmansion/rnscreens/gamma/tabs/appearance/TabsAppearanceApplicator.kt +index f7fc390..b5e82e2 100644 +--- a/node_modules/react-native-screens/android/src/main/java/com/swmansion/rnscreens/gamma/tabs/appearance/TabsAppearanceApplicator.kt ++++ b/node_modules/react-native-screens/android/src/main/java/com/swmansion/rnscreens/gamma/tabs/appearance/TabsAppearanceApplicator.kt +@@ -3,6 +3,7 @@ package com.swmansion.rnscreens.gamma.tabs.appearance + import android.annotation.SuppressLint + import android.content.Context + import android.content.res.ColorStateList ++import android.graphics.Color + import android.graphics.Typeface + import android.graphics.drawable.StateListDrawable + import android.util.TypedValue +@@ -162,7 +163,7 @@ internal class TabsAppearanceApplicator( + // Bold is set here rather than with `setItemTextAppearanceActiveBoldEnabled`, which re-applies Material's text size + // to this label alone, so Material lifts the selected item by the size difference between the two labels. + largeLabel.setTextSize(TypedValue.COMPLEX_UNIT_PX, largeFontSize) +- largeLabel.setTypeface(fontFamily, Typeface.BOLD) ++ largeLabel.setTypeface(fontFamily, if (tabBarAppearance?.tabBarItemActiveIndicatorColor == Color.TRANSPARENT) Typeface.NORMAL else Typeface.BOLD) + largeLabel.letterSpacing = 0f + } + } +diff --git a/node_modules/react-native-screens/ios/tabs/host/RNSTabBarController.h b/node_modules/react-native-screens/ios/tabs/host/RNSTabBarController.h +index d76842a..bc62e29 100644 +--- a/node_modules/react-native-screens/ios/tabs/host/RNSTabBarController.h ++++ b/node_modules/react-native-screens/ios/tabs/host/RNSTabBarController.h +@@ -133,6 +133,8 @@ NS_ASSUME_NONNULL_BEGIN + */ + @property (nonatomic, readwrite) BOOL rejectStaleNavigationStateUpdates; + ++@property (nonatomic, readwrite) BOOL tabBarItemSelectionHidden; ++ + /** + * Tell the controller that react provided tabs have changed (count / instances) & the child + * view controllers need to be updated. +diff --git a/node_modules/react-native-screens/ios/tabs/host/RNSTabBarController.mm b/node_modules/react-native-screens/ios/tabs/host/RNSTabBarController.mm +index 06c1957..bb2dcb0 100644 +--- a/node_modules/react-native-screens/ios/tabs/host/RNSTabBarController.mm ++++ b/node_modules/react-native-screens/ios/tabs/host/RNSTabBarController.mm +@@ -177,6 +177,34 @@ - (void)detachFromParentContainerItem + + #pragma mark - UIKit callbacks + ++- (void)viewDidLayoutSubviews ++{ ++ [super viewDidLayoutSubviews]; ++ if (_tabBarItemSelectionHidden) { ++ [self updateTabBarItemSelectionIn:self.tabBar]; ++ } ++} ++ ++- (void)setTabBarItemSelectionHidden:(BOOL)tabBarItemSelectionHidden ++{ ++ _tabBarItemSelectionHidden = tabBarItemSelectionHidden; ++ [self updateTabBarItemSelectionIn:self.tabBar]; ++} ++ ++// Private views that draw the iOS 26 liquid glass selection. ++- (void)updateTabBarItemSelectionIn:(UIView *)view ++{ ++ for (UIView *subview in view.subviews) { ++ NSString *className = NSStringFromClass(subview.class); ++ if ([className hasSuffix:@"LiquidLensView"] || [className hasSuffix:@"SelectedContentView"] || ++ [className hasSuffix:@"DestOutView"]) { ++ subview.hidden = _tabBarItemSelectionHidden; ++ } else { ++ [self updateTabBarItemSelectionIn:subview]; ++ } ++ } ++} ++ + - (void)didMoveToParentViewController:(UIViewController *)parent + { + [super didMoveToParentViewController:parent]; +@@ -353,6 +381,11 @@ - (void)userDidRepeatViewControllerSelection:(nonnull UIViewController *)viewCon + + - (void)userDidSelectViewController:(nonnull UIViewController *)viewController + { ++ // The tab the user picked shows its selection right away, without waiting for JS to turn the prop off. ++ if (_tabBarItemSelectionHidden) { ++ self.tabBarItemSelectionHidden = NO; ++ } ++ + // At this moment the `UITabBarController` model is already updated. + RCTAssert(self.selectedViewController == viewController, + @"[RNScreens] Expected UIKit to update selectedViewController"); +diff --git a/node_modules/react-native-screens/ios/tabs/host/RNSTabsHostComponentView.mm b/node_modules/react-native-screens/ios/tabs/host/RNSTabsHostComponentView.mm +index 60c9ab2..fa2afb9 100644 +--- a/node_modules/react-native-screens/ios/tabs/host/RNSTabsHostComponentView.mm ++++ b/node_modules/react-native-screens/ios/tabs/host/RNSTabsHostComponentView.mm +@@ -254,6 +254,10 @@ - (void)updateProps:(const facebook::react::Props::Shared &)props + } + } + ++ if (newComponentProps.tabBarItemSelectionHidden != oldComponentProps.tabBarItemSelectionHidden) { ++ _controller.tabBarItemSelectionHidden = newComponentProps.tabBarItemSelectionHidden; ++ } ++ + if (newComponentProps.bottomAccessoryHidden != oldComponentProps.bottomAccessoryHidden) { + _bottomAccessoryHidden = newComponentProps.bottomAccessoryHidden; + #if RNS_TABS_BOTTOM_ACCESSORY_AVAILABLE +diff --git a/node_modules/react-native-screens/src/fabric/tabs/TabsHostIOSNativeComponent.ts b/node_modules/react-native-screens/src/fabric/tabs/TabsHostIOSNativeComponent.ts +index fcd0b7f..64b2a09 100644 +--- a/node_modules/react-native-screens/src/fabric/tabs/TabsHostIOSNativeComponent.ts ++++ b/node_modules/react-native-screens/src/fabric/tabs/TabsHostIOSNativeComponent.ts +@@ -77,6 +77,7 @@ export interface NativeProps extends ViewProps { + + // General + tabBarHidden?: CT.WithDefault; ++ tabBarItemSelectionHidden?: CT.WithDefault; + nativeContainerBackgroundColor?: ColorValue | undefined; + colorScheme?: CT.WithDefault; + diff --git a/patches/react-navigation/@react-navigation+bottom-tabs+7.19.2+005+hidden-tab-under-tab-bar.patch b/patches/react-navigation/@react-navigation+bottom-tabs+7.19.2+005+hidden-tab-under-tab-bar.patch new file mode 100644 index 000000000000..ea1e8be53660 --- /dev/null +++ b/patches/react-navigation/@react-navigation+bottom-tabs+7.19.2+005+hidden-tab-under-tab-bar.patch @@ -0,0 +1,138 @@ +diff --git a/node_modules/@react-navigation/bottom-tabs/lib/module/unstable/NativeBottomTabView.native.js b/node_modules/@react-navigation/bottom-tabs/lib/module/unstable/NativeBottomTabView.native.js +index 8f34943..5144b70 100644 +--- a/node_modules/@react-navigation/bottom-tabs/lib/module/unstable/NativeBottomTabView.native.js ++++ b/node_modules/@react-navigation/bottom-tabs/lib/module/unstable/NativeBottomTabView.native.js +@@ -32,13 +32,12 @@ export function NativeBottomTabView({ + fonts + } = useTheme(); + const focusedRouteKey = state.routes[state.index].key; +- // A route with `tabBarItemHidden` gets no native tab item. While it is focused, it is drawn over the native host +- // and the tab bar is hidden, and the host keeps the last focused visible tab selected underneath. ++ // A route with `tabBarItemHidden` gets no native tab item. It is drawn inside the first visible tab, which the host ++ // selects with no item drawn as selected while the hidden route is focused. + const isRouteHidden = route => descriptors[route.key]?.options.tabBarItemHidden === true; + const isFocusedRouteHidden = isRouteHidden(state.routes[state.index]); +- // Derived from the history rather than remembered, so a route that became hidden since it was focused is skipped. +- const isRouteKeyVisible = key => state.routes.some(route => route.key === key && !isRouteHidden(route)); +- const nativeSelectedRouteKey = isFocusedRouteHidden ? state.history.findLast(entry => isRouteKeyVisible(entry.key))?.key ?? state.routes.find(route => !isRouteHidden(route))?.key ?? focusedRouteKey : focusedRouteKey; ++ const hiddenRoutesHostKey = state.routes.find(route => !isRouteHidden(route))?.key; ++ const nativeSelectedRouteKey = isFocusedRouteHidden ? hiddenRoutesHostKey ?? focusedRouteKey : focusedRouteKey; + // Native tabs are identified by route name, which survives a route getting a new key. Keyed by route key, a re-keyed + // route replaces its native tab, and Android never removes the old one, so the bar shows the tab twice. + const getRouteForScreenKey = screenKey => state.routes.find(route => route.name === screenKey); +@@ -104,9 +103,10 @@ export function NativeBottomTabView({ + target: route.key + }); + } +- // While a hidden route is focused, native keeps the tab underneath it selected, so native reporting that tab is +- // not a request to leave the hidden route. +- if (actionOrigin === 'programmatic-js' || nativeSelectedRouteKey === route.key) { ++ // While a hidden route is focused, native keeps the tab it is drawn in selected. Native reporting that tab is not a ++ // request to leave the hidden route, but the user tapping it is. ++ const isUserLeavingHiddenRoute = isFocusedRouteHidden && actionOrigin === 'user'; ++ if (actionOrigin === 'programmatic-js' || (nativeSelectedRouteKey === route.key && !isUserLeavingHiddenRoute)) { + dispatch({ + type: 'CONFIRM_STATE', + confirmed +@@ -219,7 +219,8 @@ export function NativeBottomTabView({ + onTabSelected: onTabSelected, + onTabSelectionRejected: onTabSelectionRejected, + onTabSelectionPrevented: onTabSelectionPrevented, +- tabBarHidden: shouldHideTabBar || isFocusedRouteHidden, ++ tabBarHidden: shouldHideTabBar, ++ tabBarItemSelectionHidden: isFocusedRouteHidden, + colorScheme: dark ? 'dark' : 'light', + nativeContainerStyle: { + backgroundColor: colors.background +@@ -243,6 +244,9 @@ export function NativeBottomTabView({ + } = descriptors[route.key]; + const isFocused = state.index === index; + const isPreloaded = state.preloadedRouteKeys.includes(route.key); ++ // Only the tab the hidden routes are drawn in is drawn unselected, so a tab the user taps natively shows as ++ // selected right away. ++ const isSelectionHidden = isFocusedRouteHidden && route.key === hiddenRoutesHostKey; + const { + title, + lazy = false, +@@ -291,7 +295,7 @@ export function NativeBottomTabView({ + focused: false + })) : tabBarIcon != null ? getPlatformIcon(tabBarIcon) : undefined; + const selectedIcon = typeof tabBarIcon === 'function' ? getPlatformIcon(tabBarIcon({ +- focused: true ++ focused: !isSelectionHidden + })) : undefined; + return /*#__PURE__*/_jsx(Tabs.Screen, { + onWillDisappear: () => onTransitionStart({ +@@ -320,8 +324,8 @@ export function NativeBottomTabView({ + }, + specialEffects: { + repeatedTabSelection: { +- popToRoot: true, +- scrollToTop: true ++ popToRoot: !isSelectionHidden, ++ scrollToTop: !isSelectionHidden + } + }, + android: { +@@ -332,8 +336,8 @@ export function NativeBottomTabView({ + tabBarItemRippleColor: currentOptions?.tabBarRippleColor, + tabBarItemLabelVisibilityMode: currentOptions?.tabBarLabelVisibilityMode, + normal: normalTabItemAppearance, +- selected: selectedTabItemAppearance, +- tabBarItemActiveIndicatorColor: activeIndicatorColor, ++ selected: isSelectionHidden ? normalTabItemAppearance : selectedTabItemAppearance, ++ tabBarItemActiveIndicatorColor: isSelectionHidden ? 'transparent' : activeIndicatorColor, + tabBarItemActiveIndicatorEnabled: currentOptions?.tabBarActiveIndicatorEnabled, + tabBarItemTitleFontFamily: fontFamily, + tabBarItemTitleFontWeight: fontWeight, +@@ -379,7 +383,7 @@ export function NativeBottomTabView({ + overrideScrollViewContentInsetAdjustmentBehavior + }, + // Keyed by route key, so a re-keyed route still remounts its content inside the same native tab. +- children: /*#__PURE__*/_jsx(Deferred, { ++ children: [/*#__PURE__*/_jsx(Deferred, { + lazy: lazy, + visible: isFocused || isPreloaded, + children: /*#__PURE__*/_jsx(ScreenWithHeader, { +@@ -392,10 +396,13 @@ export function NativeBottomTabView({ + children: render() + }) + }) +- }, route.key) ++ }, route.key), route.key === hiddenRoutesHostKey ? renderHiddenRoutes() : null] + }, route.name); + }) +- }, "native-tabs-host"), ...state.routes.map((route, index) => { ++ }, "native-tabs-host")] ++ }); ++ function renderHiddenRoutes() { ++ return state.routes.map((route, index) => { + if (!isRouteHidden(route) || !visitedHiddenRouteKeysRef.current.has(route.key)) { + return null; + } +@@ -421,8 +428,8 @@ export function NativeBottomTabView({ + }) + }) + }, route.key); +- })] +- }); ++ }); ++ } + } + const styles = StyleSheet.create({ + hiddenRoute: { +diff --git a/node_modules/@react-navigation/bottom-tabs/lib/typescript/src/unstable/types.d.ts b/node_modules/@react-navigation/bottom-tabs/lib/typescript/src/unstable/types.d.ts +index c05b04b..27f0df5 100644 +--- a/node_modules/@react-navigation/bottom-tabs/lib/typescript/src/unstable/types.d.ts ++++ b/node_modules/@react-navigation/bottom-tabs/lib/typescript/src/unstable/types.d.ts +@@ -79,8 +79,8 @@ type IconIOSSfSymbol = { + export type NativeBottomTabIcon = IconIOSSfSymbol | IconImage; + export type NativeBottomTabNavigationOptions = NativeHeaderOptions & { + /** +- * Whether the tab gets no item in the native tab bar. While a hidden tab is focused, it is drawn over the +- * native tabs and the tab bar is hidden. ++ * Whether the tab gets no item in the native tab bar. While a hidden tab is focused, it is drawn inside the first ++ * visible tab and the tab bar shows no item as selected. + */ + tabBarItemHidden?: boolean; + /** diff --git a/patches/react-navigation/details.md b/patches/react-navigation/details.md index 7d55986533fa..c3ebb125e636 100644 --- a/patches/react-navigation/details.md +++ b/patches/react-navigation/details.md @@ -87,6 +87,14 @@ - PR Introducing Patch: [#104238](https://github.com/Expensify/App/pull/104238) - PR Updating Patch: N/A +### [@react-navigation+bottom-tabs+7.19.2+005+hidden-tab-under-tab-bar.patch](@react-navigation+bottom-tabs+7.19.2+005+hidden-tab-under-tab-bar.patch) + +- Reason: Keeps the native tab bar, with no item drawn as selected, while a `tabBarItemHidden` route (patch 003) is focused. This replaces two parts of patch 003: the hidden routes are drawn inside the first visible tab instead of over the host with the tab bar hidden, and the host selects that first visible tab instead of the last visible one in the tab history. The user tapping that tab leaves the hidden route, while native reporting it on its own still confirms the state as in patch 003. The RNScreens prop that hides the selection is added by its patch 007. +- Upstream PR/issue: not reported, because it builds on patch 003, which has no upstream counterpart. +- E/App issue: [#104197](https://github.com/Expensify/App/issues/104197) +- PR Introducing Patch: [#104491](https://github.com/Expensify/App/pull/104491) +- PR Updating Patch: N/A + ### Removed patches - `@react-navigation+native+7.1.33+003+increase-history-go-popstate-fallback-timeout.patch` was dropped: upstream raised the `createMemoryHistory.go()` fallback timeout from 100ms to 1000ms in 7.3.18 (https://github.com/react-navigation/react-navigation/pull/13217), with the same Firefox rationale as our patch. Original context: [#94571](https://github.com/Expensify/App/issues/94571) / [#95980](https://github.com/Expensify/App/pull/95980), upstream issue https://github.com/react-navigation/react-navigation/issues/11145. diff --git a/src/components/Navigation/TabBarBottomContent/useTabRootScreenWrapperProps/index.android.tsx b/src/components/Navigation/TabBarBottomContent/useTabRootScreenWrapperProps/index.android.tsx index 0ecf905ca0d3..e282c7d35a88 100644 --- a/src/components/Navigation/TabBarBottomContent/useTabRootScreenWrapperProps/index.android.tsx +++ b/src/components/Navigation/TabBarBottomContent/useTabRootScreenWrapperProps/index.android.tsx @@ -1,7 +1,3 @@ -import NAVIGATION_TABS from '@components/Navigation/NavigationTabBar/NAVIGATION_TABS'; -import type TabBarBottomContentProps from '@components/Navigation/TabBarBottomContent/types'; - -import useIsSettingsDrawnOverTabs from '@hooks/useIsSettingsDrawnOverTabs'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -12,14 +8,13 @@ import type TabRootScreenWrapperProps from './types'; /** * Material's bar is drawn over the full-height tab screen, so a narrow tab root screen reserves the bar's row under its - * content, and the ScreenWrapper adds the gesture inset the bar sits on below it. Account drawn over the tabs hides the bar. + * content, and the ScreenWrapper adds the gesture inset the bar sits on below it. */ -function useTabRootScreenWrapperProps(selectedTab: TabBarBottomContentProps['selectedTab']): TabRootScreenWrapperProps { +function useTabRootScreenWrapperProps(): TabRootScreenWrapperProps { const {shouldUseNarrowLayout} = useResponsiveLayout(); - const isSettingsDrawnOverTabs = useIsSettingsDrawnOverTabs(); const styles = useThemeStyles(); - if (!shouldUseNarrowLayout || (selectedTab === NAVIGATION_TABS.SETTINGS && isSettingsDrawnOverTabs)) { + if (!shouldUseNarrowLayout) { return {}; } diff --git a/src/components/Navigation/TabBarBottomContent/useTabRootScreenWrapperProps/index.ios.tsx b/src/components/Navigation/TabBarBottomContent/useTabRootScreenWrapperProps/index.ios.tsx index c7176d66e24b..b76e16f2ed64 100644 --- a/src/components/Navigation/TabBarBottomContent/useTabRootScreenWrapperProps/index.ios.tsx +++ b/src/components/Navigation/TabBarBottomContent/useTabRootScreenWrapperProps/index.ios.tsx @@ -1,8 +1,5 @@ -import NAVIGATION_TABS from '@components/Navigation/NavigationTabBar/NAVIGATION_TABS'; -import type TabBarBottomContentProps from '@components/Navigation/TabBarBottomContent/types'; import useTabBarCoveredHeight from '@components/Navigation/TabBarBottomContent/useTabBarCoveredHeight'; -import useIsSettingsDrawnOverTabs from '@hooks/useIsSettingsDrawnOverTabs'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -12,15 +9,14 @@ import type TabRootScreenWrapperProps from './types'; * On narrow layouts the content of a tab root screen runs under the translucent UITabBar, down to the bottom edge of * the screen, so the ScreenWrapper runs edge to edge and adds no bottom padding, to the screen or to the offline * indicator. Each tab root's list lets UIKit inset its end past the bar, and the offline indicator sits on the bar with - * its background running down to the bottom edge. Account drawn over the tabs hides the bar. + * its background running down to the bottom edge. */ -function useTabRootScreenWrapperProps(selectedTab: TabBarBottomContentProps['selectedTab']): TabRootScreenWrapperProps { +function useTabRootScreenWrapperProps(): TabRootScreenWrapperProps { const {shouldUseNarrowLayout} = useResponsiveLayout(); - const isSettingsDrawnOverTabs = useIsSettingsDrawnOverTabs(); const styles = useThemeStyles(); const tabBarCoveredHeight = useTabBarCoveredHeight(); - if (!shouldUseNarrowLayout || (selectedTab === NAVIGATION_TABS.SETTINGS && isSettingsDrawnOverTabs)) { + if (!shouldUseNarrowLayout) { return {}; } diff --git a/src/components/Navigation/TopBar.tsx b/src/components/Navigation/TopBar.tsx index 90dff1001aab..16163c9ad1c2 100644 --- a/src/components/Navigation/TopBar.tsx +++ b/src/components/Navigation/TopBar.tsx @@ -1,4 +1,3 @@ -import Icon from '@components/Icon'; import LoadingBar from '@components/LoadingBar'; import {PressableWithoutFeedback} from '@components/Pressable'; import SearchButton from '@components/Search/SearchRouter/SearchButton'; @@ -8,10 +7,8 @@ import {useWideRHPState} from '@components/WideRHPContextProvider'; import useContentHeaderHeight from '@hooks/useContentHeaderHeight'; import useIsInLandscapeMode from '@hooks/useIsInLandscapeMode'; -import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; -import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import SignInButton from '@pages/inbox/sidebar/SignInButton'; @@ -40,25 +37,12 @@ type TopBarProps = { cancelSearch?: () => void; children?: React.ReactNode; breadcrumbAnimatedStyle?: StyleProp>; - /** Shows a back arrow before the breadcrumb that calls this when pressed. */ - onBackButtonPress?: () => void; }; const authTokenTypeSelector = (session: OnyxEntry) => session && {authTokenType: session.authTokenType}; -function TopBar({ - breadcrumbLabel, - shouldDisplaySearch = true, - shouldDisplayHelpButton = false, - cancelSearch, - shouldShowLoadingBar, - children, - breadcrumbAnimatedStyle, - onBackButtonPress, -}: TopBarProps) { +function TopBar({breadcrumbLabel, shouldDisplaySearch = true, shouldDisplayHelpButton = false, cancelSearch, shouldShowLoadingBar, children, breadcrumbAnimatedStyle}: TopBarProps) { const styles = useThemeStyles(); - const theme = useTheme(); - const icons = useMemoizedLazyExpensifyIcons(['BackArrow']); const {translate} = useLocalize(); const [session] = useOnyx(ONYXKEYS.SESSION, {selector: authTokenTypeSelector}); const isAnonymousUser = isAnonymousUserUtil(session); @@ -74,25 +58,11 @@ function TopBar({ return ( Keyboard.dismiss() : undefined} > - {!!onBackButtonPress && ( - - - - )} ; * A thin wrapper around TopBar that encapsulates useLoadingBarVisibility(). * Use this in places where shouldShowLoadingBar is NOT passed explicitly by the parent. */ -function TopBarWithLoadingBar({breadcrumbLabel, shouldDisplaySearch, shouldDisplayHelpButton, cancelSearch, onBackButtonPress, children}: TopBarWithLoadingBarProps) { +function TopBarWithLoadingBar({breadcrumbLabel, shouldDisplaySearch, shouldDisplayHelpButton, cancelSearch, children}: TopBarWithLoadingBarProps) { const shouldShowLoadingBar = useLoadingBarVisibility(); return ( {children} diff --git a/src/hooks/useIsSettingsDrawnOverTabs/index.native.ts b/src/hooks/useIsSettingsDrawnOverTabs/index.native.ts index ba0c059aafa2..18adf3c373f0 100644 --- a/src/hooks/useIsSettingsDrawnOverTabs/index.native.ts +++ b/src/hooks/useIsSettingsDrawnOverTabs/index.native.ts @@ -7,8 +7,8 @@ import CONST from '@src/CONST'; import NAVIGATORS from '@src/NAVIGATORS'; /** - * When the native tab bar has no Account item, the Account tab is drawn over the other tabs - * as a full screen with the bar hidden, and it needs its own way back. + * When the native tab bar has no Account item, the Account tab is drawn inside another tab and has no history of its + * own, so the Android back button needs its own way back. */ function useIsSettingsDrawnOverTabs() { const {shouldUseNarrowLayout} = useResponsiveLayout(); diff --git a/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/useNativeTabNavigator.ts b/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/useNativeTabNavigator.ts index 1cd5ff3dcc68..d9d779c1e842 100644 --- a/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/useNativeTabNavigator.ts +++ b/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/useNativeTabNavigator.ts @@ -62,9 +62,10 @@ function useNativeTabNavigator() { // mounts, so the nearest navigation listener context is still the parent stack's. const tabState = useNavigationState((parentState) => parentState.routes.find((parentRoute) => parentRoute.key === route.key)?.state); const activeTabRoute = getActiveTabRoute(tabState); - // A tab with no item in the bar is drawn over the other tabs as a full screen, so the bar hides while it is focused. - const isActiveTabWithoutBarItem = activeTabRoute?.name === tabWithoutBarItem; - const shouldShowNativeTabBar = shouldUseNarrowLayout && isTabRouteAtRoot(activeTabRoute) && !isBlockingViewVisible && !isActiveTabWithoutBarItem; + // Insights without the beta is a full screen until Home replaces it, so the bar hides while it is focused. Account + // without a bar item keeps the bar, which then shows no tab as selected. + const isInsightsFocusedWithoutBarItem = !isInsightsTabVisible && activeTabRoute?.name === SCREENS.INSIGHTS; + const shouldShowNativeTabBar = shouldUseNarrowLayout && isTabRouteAtRoot(activeTabRoute) && !isBlockingViewVisible && !isInsightsFocusedWithoutBarItem; const getTabRoute = (tabName: string) => (isRealizedNavigationState(tabState) ? tabState.routes.find((tabRoute) => tabRoute.name === tabName) : undefined); const isInboxAtChatList = isInboxTabAtChatList(getTabRoute(NAVIGATORS.REPORTS_SPLIT_NAVIGATOR)); const isWorkspacesTabRestored = useIsWorkspacesTabRestored(getTabRoute(NAVIGATORS.WORKSPACE_NAVIGATOR)); diff --git a/src/pages/settings/InitialSettingsPage.tsx b/src/pages/settings/InitialSettingsPage.tsx index 3ddc8918d50b..c8d1731971a7 100755 --- a/src/pages/settings/InitialSettingsPage.tsx +++ b/src/pages/settings/InitialSettingsPage.tsx @@ -207,7 +207,6 @@ function InitialSettingsPage({currentUserPersonalDetails}: InitialSettingsPagePr breadcrumbLabel={translate('initialSettingsPage.account')} shouldDisplaySearch={shouldUseNarrowLayout} shouldDisplayHelpButton={shouldUseNarrowLayout} - onBackButtonPress={isDrawnOverTabs ? goBackFromAccount : undefined} > {!shouldUseNarrowLayout && !isPersonalDetailsEmpty && ( /* The top bar row ends 12px from the screen edge, so add 8px to sit the button 20px in. */ diff --git a/tests/unit/NativeBottomTabViewTest.tsx b/tests/unit/NativeBottomTabViewTest.tsx index d8e985001132..dfe23ab0bec7 100644 --- a/tests/unit/NativeBottomTabViewTest.tsx +++ b/tests/unit/NativeBottomTabViewTest.tsx @@ -69,17 +69,34 @@ function buildTabState(inboxKey: string): TabNavigationState { }; } +// Account focused with no item in the native bar, as with the Insights beta +function buildHiddenAccountTabState(): TabNavigationState { + return { + type: 'tab', + key: 'tab', + index: 1, + stale: false, + routeNames: ['Inbox', 'Account'], + routes: [ + {name: 'Inbox', key: 'Inbox-key'}, + {name: 'Account', key: 'Account-key'}, + ], + history: [{type: 'route', key: 'Account-key'}], + preloadedRouteKeys: [], + }; +} + function buildNavigation() { return {dispatch: jest.fn(), emit: jest.fn(), getState: jest.fn()}; } -function buildDescriptors(state: TabNavigationState): NativeBottomTabViewProps['descriptors'] { +function buildDescriptors(state: TabNavigationState, hiddenRouteNames: string[]): NativeBottomTabViewProps['descriptors'] { const descriptors = Object.fromEntries( state.routes.map((route) => [ route.key, { route, - options: {}, + options: {tabBarItemHidden: hiddenRouteNames.includes(route.name)}, navigation: {}, render: () => , }, @@ -90,7 +107,7 @@ function buildDescriptors(state: TabNavigationState): NativeBotto return descriptors as unknown as NativeBottomTabViewProps['descriptors']; } -function renderTabView(state: TabNavigationState, navigation: ReturnType) { +function renderTabView(state: TabNavigationState, navigation: ReturnType, hiddenRouteNames: string[] = []) { return ( , navigation: Ret // The view calls only the navigation methods the mock provides. // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion navigation={navigation as unknown as NativeBottomTabViewProps['navigation']} - descriptors={buildDescriptors(state)} + descriptors={buildDescriptors(state, hiddenRouteNames)} /> ); @@ -141,4 +158,18 @@ describe('NativeBottomTabView', () => { expect(navigation.emit).toHaveBeenCalledWith({type: 'tabPress', target: 'Spend-key'}); expect(navigation.dispatch).toHaveBeenCalledWith(expect.objectContaining({type: 'NAVIGATE', payload: expect.objectContaining({name: 'Spend'}), target: 'tab'})); }); + + it('leaves a focused hidden route when the user taps the tab it is drawn in', () => { + // Given Account focused, drawn in the natively selected Inbox tab + const navigation = buildNavigation(); + render(renderTabView(buildHiddenAccountTabState(), navigation, ['Account'])); + + // When the user taps Inbox, which native reports as a repeated selection of the selected tab + act(() => { + mockLatestHostProps?.onTabSelected({nativeEvent: {selectedScreenKey: 'Inbox', provenance: 1, actionOrigin: 'user'}}); + }); + + // Then JS navigates to Inbox, since no item is drawn as selected and the tap is the user leaving Account + expect(navigation.dispatch).toHaveBeenCalledWith(expect.objectContaining({type: 'NAVIGATE', payload: expect.objectContaining({name: 'Inbox'}), target: 'tab'})); + }); });