From 768291092201bbf9c79d3f9ed9294814849ba5d5 Mon Sep 17 00:00:00 2001 From: sumo_slonik Date: Tue, 20 Oct 2026 20:04:48 +0200 Subject: [PATCH 1/6] fix: native Account keeps the tab bar with no item selected and no back button under the Insights beta --- patches/react-native-screens/details.md | 7 + ....0+007+tab-bar-item-selection-hidden.patch | 339 ++++++++++++++++++ ...+7.19.2+005+hidden-tab-under-tab-bar.patch | 154 ++++++++ patches/react-navigation/details.md | 8 + .../index.android.tsx | 11 +- .../index.ios.tsx | 10 +- src/components/Navigation/TopBar.tsx | 34 +- .../Navigation/TopBarWithLoadingBar.tsx | 3 +- .../index.native.ts | 19 - src/hooks/useIsSettingsDrawnOverTabs/index.ts | 6 - .../useNativeTabBarOptions/index.android.ts | 4 +- .../useNativeTabBarOptions/index.ios.ts | 33 +- .../useNativeTabBarOptions/types.ts | 2 + .../useNativeTabNavigator.ts | 8 +- .../Navigators/TabNavigator.native.tsx | 5 +- src/pages/settings/InitialSettingsPage.tsx | 41 +-- 16 files changed, 547 insertions(+), 137 deletions(-) create mode 100644 patches/react-native-screens/react-native-screens+4.28.0+007+tab-bar-item-selection-hidden.patch create mode 100644 patches/react-navigation/@react-navigation+bottom-tabs+7.19.2+005+hidden-tab-under-tab-bar.patch delete mode 100644 src/hooks/useIsSettingsDrawnOverTabs/index.native.ts delete mode 100644 src/hooks/useIsSettingsDrawnOverTabs/index.ts diff --git a/patches/react-native-screens/details.md b/patches/react-native-screens/details.md index d903512dcac5..c6adbe37cee3 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 a `tabBarItemSelectionHidden` prop to `Tabs.Host`, which draws the selected tab bar item like the others while the selected tab stays the same. The App shows Account, which has no item in the native bar with the Insights beta, under a visible bar with none of its items selected (React Navigation patch 005). Neither native bar can show itself with no item selected. On iOS, `UITabBarController` always keeps a selected item, `tabBar.selectedItem = nil` throws "Directly modifying a tab bar managed by a tab bar controller is not allowed", and a selected `UITab` with `isHidden` is shown in the bar again (with five visible tabs it even folds one into a "More" tab). On iOS 26 the liquid glass bar marks the selected item with private views: a lens under it, a copy of the items in their selected colors that only shows through the lens, and a view that cuts the selected item out of the plain copy. Their alpha is animated by UIKit, so the patch sets `hidden` on them while the prop is on, re-applies it after each layout and restores them when the prop goes off or the user taps another tab, so the lens animates from the tab the controller keeps selected. A UIKit release that renames these views leaves the selection showing and breaks nothing else. Below iOS 26 the selection is only the item's selected image and colors, which the App draws to match the unselected ones. On Android, the prop turns off Material's active indicator, draws the selected label in the unselected color, and drops the bold that patch 003 gives the selected label. The App draws the icons for both selection states. +- Upstream PR/issue: TBD +- E/App issue: TBD +- PR introducing patch: TBD 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..b94cae94ad0f --- /dev/null +++ b/patches/react-native-screens/react-native-screens+4.28.0+007+tab-bar-item-selection-hidden.patch @@ -0,0 +1,339 @@ +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..1f544b5 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 +@@ -35,6 +35,7 @@ internal class TabsAppearanceApplicator( + context: Context, + tabBarAppearance: TabsAppearance?, + isTabBarHidden: Boolean, ++ isTabBarItemSelectionHidden: Boolean, + ) { + bottomNavigationView.isVisible = !isTabBarHidden + // A GONE child keeps the bounds it had while visible, and React Native's TouchTargetHelper does not check +@@ -64,7 +65,14 @@ internal class TabsAppearanceApplicator( + tabBarAppearance?.normal?.tabBarItemTitleFontColor + ?: resolveColorAttr(context, R.attr.colorSecondary) + +- val fontColors = intArrayOf(fontDisabledColor, fontSelectedColor, fontFocusedColor, fontNormalColor) ++ // A hidden selection draws the selected label in the color of the others. ++ val fontColors = ++ intArrayOf( ++ fontDisabledColor, ++ if (isTabBarItemSelectionHidden) fontNormalColor else fontSelectedColor, ++ fontFocusedColor, ++ fontNormalColor, ++ ) + bottomNavigationView.itemTextColor = ColorStateList(states, fontColors) + + // Icon color +@@ -98,13 +106,14 @@ internal class TabsAppearanceApplicator( + ?: resolveColorAttr(context, R.attr.colorSecondaryContainer) + + bottomNavigationView.isItemActiveIndicatorEnabled = +- tabBarAppearance?.tabBarItemActiveIndicatorEnabled ?: true ++ !isTabBarItemSelectionHidden && (tabBarAppearance?.tabBarItemActiveIndicatorEnabled ?: true) + bottomNavigationView.itemActiveIndicatorColor = ColorStateList.valueOf(activeIndicatorColor) + } + + fun updateFontStyles( + context: Context, + tabBarAppearance: TabsAppearance?, ++ isTabBarItemSelectionHidden: Boolean, + ) { + val bottomNavigationMenuView = bottomNavigationView.getChildAt(0) as ViewGroup + +@@ -162,7 +171,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 (isTabBarItemSelectionHidden) Typeface.NORMAL else Typeface.BOLD) + largeLabel.letterSpacing = 0f + } + } +diff --git a/node_modules/react-native-screens/android/src/main/java/com/swmansion/rnscreens/gamma/tabs/appearance/TabsAppearanceCoordinator.kt b/node_modules/react-native-screens/android/src/main/java/com/swmansion/rnscreens/gamma/tabs/appearance/TabsAppearanceCoordinator.kt +index d827309..7994902 100644 +--- a/node_modules/react-native-screens/android/src/main/java/com/swmansion/rnscreens/gamma/tabs/appearance/TabsAppearanceCoordinator.kt ++++ b/node_modules/react-native-screens/android/src/main/java/com/swmansion/rnscreens/gamma/tabs/appearance/TabsAppearanceCoordinator.kt +@@ -18,9 +18,14 @@ internal class TabsAppearanceCoordinator( + tabsContainer: TabsContainer, + ) { + val selectedTabAppearance = tabsContainer.selectedTab.tabsScreen.appearance +- appearanceApplicator.updateSharedAppearance(context, selectedTabAppearance, tabsContainer.tabBarHidden) ++ appearanceApplicator.updateSharedAppearance( ++ context, ++ selectedTabAppearance, ++ tabsContainer.tabBarHidden, ++ tabsContainer.tabBarItemSelectionHidden, ++ ) + updateMenuItems(context, selectedTabAppearance) +- appearanceApplicator.updateFontStyles(context, selectedTabAppearance) // It needs to be updated after updateMenuItems ++ appearanceApplicator.updateFontStyles(context, selectedTabAppearance, tabsContainer.tabBarItemSelectionHidden) // It needs to be updated after updateMenuItems + } + + private fun updateMenuItems( +diff --git a/node_modules/react-native-screens/android/src/main/java/com/swmansion/rnscreens/gamma/tabs/container/TabsContainer.kt b/node_modules/react-native-screens/android/src/main/java/com/swmansion/rnscreens/gamma/tabs/container/TabsContainer.kt +index b98825c..c681b8a 100644 +--- a/node_modules/react-native-screens/android/src/main/java/com/swmansion/rnscreens/gamma/tabs/container/TabsContainer.kt ++++ b/node_modules/react-native-screens/android/src/main/java/com/swmansion/rnscreens/gamma/tabs/container/TabsContainer.kt +@@ -172,6 +172,16 @@ class TabsContainer internal constructor( + } + } + ++ /** Draws the selected item like the others, so the bar shows no selection. The selected tab does not change. */ ++ internal var tabBarItemSelectionHidden: Boolean by Delegates.observable(false) { _, oldValue, newValue -> ++ if (newValue != oldValue) { ++ invalidationFlags.isNavigationMenuAppearanceInvalidated = true ++ post { ++ flushPendingUpdates() ++ } ++ } ++ } ++ + init { + addView(contentView) + addView(bottomNavigationView) +diff --git a/node_modules/react-native-screens/android/src/main/java/com/swmansion/rnscreens/gamma/tabs/host/TabsHost.kt b/node_modules/react-native-screens/android/src/main/java/com/swmansion/rnscreens/gamma/tabs/host/TabsHost.kt +index 05a6d07..dad98ed 100644 +--- a/node_modules/react-native-screens/android/src/main/java/com/swmansion/rnscreens/gamma/tabs/host/TabsHost.kt ++++ b/node_modules/react-native-screens/android/src/main/java/com/swmansion/rnscreens/gamma/tabs/host/TabsHost.kt +@@ -51,6 +51,8 @@ class TabsHost( + + var tabBarHidden: Boolean by container::tabBarHidden + ++ var tabBarItemSelectionHidden: Boolean by container::tabBarItemSelectionHidden ++ + var nativeContainerBackgroundColor: Int? by Delegates.observable(null) { _, oldValue, newValue -> + if (newValue != oldValue) { + container.background = newValue?.toDrawable() +diff --git a/node_modules/react-native-screens/android/src/main/java/com/swmansion/rnscreens/gamma/tabs/host/TabsHostViewManager.kt b/node_modules/react-native-screens/android/src/main/java/com/swmansion/rnscreens/gamma/tabs/host/TabsHostViewManager.kt +index c640543..67a023e 100644 +--- a/node_modules/react-native-screens/android/src/main/java/com/swmansion/rnscreens/gamma/tabs/host/TabsHostViewManager.kt ++++ b/node_modules/react-native-screens/android/src/main/java/com/swmansion/rnscreens/gamma/tabs/host/TabsHostViewManager.kt +@@ -108,6 +108,13 @@ class TabsHostViewManager : + view.tabBarHidden = value + } + ++ override fun setTabBarItemSelectionHidden( ++ view: TabsHost, ++ value: Boolean, ++ ) { ++ view.tabBarItemSelectionHidden = value ++ } ++ + override fun setNativeContainerBackgroundColor( + view: TabsHost, + value: Int?, +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..5dea64e 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,14 @@ NS_ASSUME_NONNULL_BEGIN + */ + @property (nonatomic, readwrite) BOOL rejectStaleNavigationStateUpdates; + ++/** ++ * Draws the selected tab bar item like the others, so the bar shows no selection while the app displays a screen that ++ * has no item in the bar. The controller keeps its selected view controller. UIKit offers no way to show the bar with ++ * no item selected, so on iOS 26 the liquid glass lens and the selected copy of the items are hidden. Below iOS 26 the ++ * selection is only the item's selected image and colors, which the app supplies to match the unselected ones. ++ */ ++@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..a8cb06a 100644 +--- a/node_modules/react-native-screens/ios/tabs/host/RNSTabBarController.mm ++++ b/node_modules/react-native-screens/ios/tabs/host/RNSTabBarController.mm +@@ -89,6 +89,9 @@ @implementation RNSTabBarController { + RNSTabsNavigationStateObserverRegistry *_observerRegistry; + + RNSParentContainerItemRegistry *_Nonnull _parentContainerRegistry; ++ ++ /// Views of the tab bar hidden to draw no selection, restored when the selection shows again. ++ NSHashTable *_Nonnull _viewsHiddenForTabBarItemSelection; + } + + - (instancetype)init +@@ -102,6 +105,7 @@ - (instancetype)init + _shouldProgressStateOnMoreNavigationControllerPush = NO; + _observerRegistry = [RNSTabsNavigationStateObserverRegistry new]; + _parentContainerRegistry = [RNSParentContainerItemRegistry new]; ++ _viewsHiddenForTabBarItemSelection = [NSHashTable weakObjectsHashTable]; + + // Delegate field retains weakly, no risk of cycle. + self.delegate = self; +@@ -177,6 +181,56 @@ - (void)detachFromParentContainerItem + + #pragma mark - UIKit callbacks + ++- (void)viewDidLayoutSubviews ++{ ++ [super viewDidLayoutSubviews]; ++ // UIKit may rebuild the bar's subviews on layout, e.g. after a size or trait change. ++ [self updateTabBarItemSelectionVisibility]; ++} ++ ++- (void)setTabBarItemSelectionHidden:(BOOL)tabBarItemSelectionHidden ++{ ++ if (_tabBarItemSelectionHidden == tabBarItemSelectionHidden) { ++ return; ++ } ++ _tabBarItemSelectionHidden = tabBarItemSelectionHidden; ++ [self updateTabBarItemSelectionVisibility]; ++} ++ ++- (void)updateTabBarItemSelectionVisibility ++{ ++ if (!_tabBarItemSelectionHidden) { ++ for (UIView *view in _viewsHiddenForTabBarItemSelection) { ++ view.hidden = NO; ++ } ++ [_viewsHiddenForTabBarItemSelection removeAllObjects]; ++ return; ++ } ++ ++ if (@available(iOS 26.0, *)) { ++ [self hideTabBarItemSelectionViewsIn:self.tabBar]; ++ } ++} ++ ++/// The liquid glass bar marks the selected item with private views: a lens under it, a copy of the items in their ++/// selected colors that only shows through the lens, and a view that cuts the selected item out of the plain copy. ++/// UIKit animates their alpha, so they are hidden instead. A UIKit release that renames them leaves the selection on. ++- (void)hideTabBarItemSelectionViewsIn:(UIView *)view ++{ ++ for (UIView *subview in view.subviews) { ++ NSString *className = NSStringFromClass(subview.class); ++ if ([className hasSuffix:@"LiquidLensView"] || [className hasSuffix:@"SelectedContentView"] || ++ [className hasSuffix:@"DestOutView"]) { ++ if (!subview.hidden) { ++ subview.hidden = YES; ++ [_viewsHiddenForTabBarItemSelection addObject:subview]; ++ } ++ continue; ++ } ++ [self hideTabBarItemSelectionViewsIn:subview]; ++ } ++} ++ + - (void)didMoveToParentViewController:(UIViewController *)parent + { + [super didMoveToParentViewController:parent]; +@@ -443,6 +497,9 @@ - (BOOL)tabBarController:(UITabBarController *)tabBarController + } + } + ++ // The lens travels from the item the controller keeps selected, so it shows as soon as the user picks a tab. ++ self.tabBarItemSelectionHidden = NO; ++ + _isHandlingExplicitSelectionUpdate = YES; + return YES; + } +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/lib/typescript/components/tabs/host/TabsHost.types.d.ts b/node_modules/react-native-screens/lib/typescript/components/tabs/host/TabsHost.types.d.ts +index cf2e551..07ce754 100644 +--- a/node_modules/react-native-screens/lib/typescript/components/tabs/host/TabsHost.types.d.ts ++++ b/node_modules/react-native-screens/lib/typescript/components/tabs/host/TabsHost.types.d.ts +@@ -166,6 +166,16 @@ export interface TabsHostPropsBase { + * @platform android, ios + */ + tabBarHidden?: boolean | undefined; ++ /** ++ * @summary Draws the selected tab bar item like the others, so the bar shows no selection. ++ * ++ * Meant for a screen that has no item in the tab bar. The selected tab does not change. ++ * ++ * @default false ++ * ++ * @platform android, ios ++ */ ++ tabBarItemSelectionHidden?: boolean | undefined; + /** + * @summary Allows for native container view customization. + * +diff --git a/node_modules/react-native-screens/lib/typescript/fabric/tabs/TabsHostAndroidNativeComponent.d.ts b/node_modules/react-native-screens/lib/typescript/fabric/tabs/TabsHostAndroidNativeComponent.d.ts +index 19fd4d6..51f5afa 100644 +--- a/node_modules/react-native-screens/lib/typescript/fabric/tabs/TabsHostAndroidNativeComponent.d.ts ++++ b/node_modules/react-native-screens/lib/typescript/fabric/tabs/TabsHostAndroidNativeComponent.d.ts +@@ -30,6 +30,7 @@ export interface NativeProps extends ViewProps { + onTabSelectionRejected?: CT.DirectEventHandler | undefined; + onTabSelectionPrevented?: CT.DirectEventHandler | undefined; + tabBarHidden?: CT.WithDefault; ++ tabBarItemSelectionHidden?: CT.WithDefault; + nativeContainerBackgroundColor?: ColorValue | undefined; + colorScheme?: CT.WithDefault; + tabBarRespectsIMEInsets?: CT.WithDefault; +diff --git a/node_modules/react-native-screens/lib/typescript/fabric/tabs/TabsHostIOSNativeComponent.d.ts b/node_modules/react-native-screens/lib/typescript/fabric/tabs/TabsHostIOSNativeComponent.d.ts +index e756dc3..e52b643 100644 +--- a/node_modules/react-native-screens/lib/typescript/fabric/tabs/TabsHostIOSNativeComponent.d.ts ++++ b/node_modules/react-native-screens/lib/typescript/fabric/tabs/TabsHostIOSNativeComponent.d.ts +@@ -38,6 +38,7 @@ export interface NativeProps extends ViewProps { + onTabSelectionPrevented?: CT.DirectEventHandler | undefined; + onMoreTabSelected?: CT.DirectEventHandler | undefined; + tabBarHidden?: CT.WithDefault; ++ tabBarItemSelectionHidden?: CT.WithDefault; + nativeContainerBackgroundColor?: ColorValue | undefined; + colorScheme?: CT.WithDefault; + layoutDirection?: CT.WithDefault; +diff --git a/node_modules/react-native-screens/src/components/tabs/host/TabsHost.types.ts b/node_modules/react-native-screens/src/components/tabs/host/TabsHost.types.ts +index 1d2851f..ce55e82 100644 +--- a/node_modules/react-native-screens/src/components/tabs/host/TabsHost.types.ts ++++ b/node_modules/react-native-screens/src/components/tabs/host/TabsHost.types.ts +@@ -186,6 +186,16 @@ export interface TabsHostPropsBase { + * @platform android, ios + */ + tabBarHidden?: boolean | undefined; ++ /** ++ * @summary Draws the selected tab bar item like the others, so the bar shows no selection. ++ * ++ * Meant for a screen that has no item in the tab bar. The selected tab does not change. ++ * ++ * @default false ++ * ++ * @platform android, ios ++ */ ++ tabBarItemSelectionHidden?: boolean | undefined; + /** + * @summary Allows for native container view customization. + * +diff --git a/node_modules/react-native-screens/src/fabric/tabs/TabsHostAndroidNativeComponent.ts b/node_modules/react-native-screens/src/fabric/tabs/TabsHostAndroidNativeComponent.ts +index 138da4d..d3b8cef 100644 +--- a/node_modules/react-native-screens/src/fabric/tabs/TabsHostAndroidNativeComponent.ts ++++ b/node_modules/react-native-screens/src/fabric/tabs/TabsHostAndroidNativeComponent.ts +@@ -61,6 +61,7 @@ export interface NativeProps extends ViewProps { + + // General + tabBarHidden?: CT.WithDefault; ++ tabBarItemSelectionHidden?: CT.WithDefault; + nativeContainerBackgroundColor?: ColorValue | undefined; + colorScheme?: CT.WithDefault; + +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..c2424123e857 --- /dev/null +++ b/patches/react-navigation/@react-navigation+bottom-tabs+7.19.2+005+hidden-tab-under-tab-bar.patch @@ -0,0 +1,154 @@ +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 d2d69fe..0a6d1d5 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 +@@ -8,7 +8,7 @@ import { Platform, PlatformColor, StyleSheet, View } from 'react-native'; + import { Tabs } from 'react-native-screens'; + import { Deferred } from "./Deferred.js"; + import { NativeScreen } from "./NativeScreen/NativeScreen.js"; +-import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime"; ++import { jsx as _jsx } from "react/jsx-runtime"; + const meta = { + type: 'native-tabs' + }; +@@ -32,13 +32,14 @@ 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 native tab of the first visible ++ // route, so it sits under the tab bar like the content of any tab. While it is focused, the host selects that tab and ++ // the bar draws no item as selected. + 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; ++ // A fixed tab, so the hidden routes stay mounted in the same place whichever tab they were opened from. ++ 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); +@@ -130,7 +131,8 @@ export function NativeBottomTabView({ + console.error(`Received 'tabSelectionRejected' for route that doesn't exist: ${selectedScreenKey}`); + return; + } +- if (focusedRouteKey === route.key) { ++ // While a hidden route is focused, native has its host tab selected, which JS asked for. ++ if (nativeSelectedRouteKey === route.key) { + dispatch({ + type: 'CONFIRM_STATE', + confirmed +@@ -205,8 +207,8 @@ export function NativeBottomTabView({ + const tabBarMinimizeBehavior = currentOptions.tabBarMinimizeBehavior === 'auto' ? 'automatic' : currentOptions.tabBarMinimizeBehavior === 'none' ? 'never' : currentOptions.tabBarMinimizeBehavior; + const shouldHideTabBar = currentOptions.tabBarStyle?.display === 'none'; + const bottomAccessory = currentOptions.bottomAccessory; +- return /*#__PURE__*/_jsxs(SafeAreaProviderCompat, { +- children: [/*#__PURE__*/_jsx(Tabs.Host, { ++ return /*#__PURE__*/_jsx(SafeAreaProviderCompat, { ++ children: /*#__PURE__*/_jsx(Tabs.Host, { + navStateRequest: { + selectedScreenKey: nativeSelectedScreenKey, + baseProvenance: nativeState.confirmed.provenance +@@ -215,7 +217,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 +@@ -289,6 +292,20 @@ export function NativeBottomTabView({ + const selectedIcon = typeof tabBarIcon === 'function' ? getPlatformIcon(tabBarIcon({ + focused: true + })) : undefined; ++ const content = /*#__PURE__*/_jsx(Deferred, { ++ lazy: lazy, ++ visible: isFocused || isPreloaded, ++ children: /*#__PURE__*/_jsx(ScreenWithHeader, { ++ isFocused: isFocused, ++ route: route, ++ navigation: navigation, ++ options: options, ++ children: /*#__PURE__*/_jsx(NavigationMetaContext.Provider, { ++ value: meta, ++ children: render() ++ }) ++ }) ++ }, route.key); + return /*#__PURE__*/_jsx(Tabs.Screen, { + onWillDisappear: () => onTransitionStart({ + closing: true, +@@ -314,10 +331,11 @@ export function NativeBottomTabView({ + nativeContainerStyle: { + backgroundColor: colors.background + }, ++ // Tapping the host tab while a hidden route is focused opens the host tab, so it is not a repeated selection. + specialEffects: { + repeatedTabSelection: { +- popToRoot: true, +- scrollToTop: true ++ popToRoot: !isFocusedRouteHidden, ++ scrollToTop: !isFocusedRouteHidden + } + }, + android: { +@@ -375,23 +393,13 @@ 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, { +- lazy: lazy, +- visible: isFocused || isPreloaded, +- children: /*#__PURE__*/_jsx(ScreenWithHeader, { +- isFocused: isFocused, +- route: route, +- navigation: navigation, +- options: options, +- children: /*#__PURE__*/_jsx(NavigationMetaContext.Provider, { +- value: meta, +- children: render() +- }) +- }) +- }, route.key) ++ children: route.key === hiddenRoutesHostKey ? [content, ...renderHiddenRoutes()] : content + }, 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; + } +@@ -417,8 +425,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..7daa805 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, 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..7025d6ab0251 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 on screen while a route with `tabBarItemHidden` (patch 003) is focused, the way the JS tab bar on web stays with no tab selected. With the Insights beta, Account has no item in the native bar and opens from the avatar in the top bar, and the design wants the bar to stay with none of its items selected. Patch 003 drew the hidden route over the whole native host and hid the bar. A view drawn over the host also covers the bar, so this patch draws the hidden routes inside the native tab of the first visible route instead, where they sit under the bar like the content of any tab. While a hidden route is focused, the host selects that tab and passes `tabBarItemSelectionHidden` (react-native-screens patch 006), so the bar draws no item as selected. A fixed tab keeps the hidden routes mounted in one place, whichever tab they were opened from. Tapping the host tab while a hidden route is focused opens the host tab, so its repeated-selection effects (pop to root, scroll to top) are turned off for that time. Native also rejects a repeated request for the host tab while a hidden route is focused, and the rejection handler no longer takes that as a reason to move JS to the host tab. +- Upstream PR/issue: not reported, because it builds on patch 003, which has no upstream counterpart. +- E/App issue: TBD +- PR Introducing Patch: TBD +- 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 deleted file mode 100644 index ba0c059aafa2..000000000000 --- a/src/hooks/useIsSettingsDrawnOverTabs/index.native.ts +++ /dev/null @@ -1,19 +0,0 @@ -import usePermissions from '@hooks/usePermissions'; -import useResponsiveLayout from '@hooks/useResponsiveLayout'; - -import getTabWithoutBarItem from '@libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/getTabWithoutBarItem'; - -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. - */ -function useIsSettingsDrawnOverTabs() { - const {shouldUseNarrowLayout} = useResponsiveLayout(); - const {isBetaEnabled} = usePermissions(); - return shouldUseNarrowLayout && getTabWithoutBarItem(isBetaEnabled(CONST.BETAS.INSIGHTS_PAGE)) === NAVIGATORS.SETTINGS_SPLIT_NAVIGATOR; -} - -export default useIsSettingsDrawnOverTabs; diff --git a/src/hooks/useIsSettingsDrawnOverTabs/index.ts b/src/hooks/useIsSettingsDrawnOverTabs/index.ts deleted file mode 100644 index 7229ecc8f572..000000000000 --- a/src/hooks/useIsSettingsDrawnOverTabs/index.ts +++ /dev/null @@ -1,6 +0,0 @@ -/** On web the Account tab stays a tab root screen above the JS tab bar, whether or not the bar shows its item. */ -function useIsSettingsDrawnOverTabs() { - return false; -} - -export default useIsSettingsDrawnOverTabs; diff --git a/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/useNativeTabBarOptions/index.android.ts b/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/useNativeTabBarOptions/index.android.ts index d439553f17ae..a7bb0e788c68 100644 --- a/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/useNativeTabBarOptions/index.android.ts +++ b/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/useNativeTabBarOptions/index.android.ts @@ -27,7 +27,7 @@ const TAB_ICON_LAYOUT: TabIconLayout = { direction: 'stacked', }; -function useNativeTabBarOptions({shouldShowNativeTabBar, isAccountAvatarShown, dotColors, tabLabels}: NativeTabBarOptionsParams) { +function useNativeTabBarOptions({shouldShowNativeTabBar, isTabBarSelectionHidden, isAccountAvatarShown, dotColors, tabLabels}: NativeTabBarOptionsParams) { const theme = useTheme(); const styles = useThemeStyles(); const avatar = useTabAvatarImage(isAccountAvatarShown); @@ -48,7 +48,7 @@ function useNativeTabBarOptions({shouldShowNativeTabBar, isAccountAvatarShown, d // The active indicator pill marks the selected tab, so the avatar is the same image in both selection states. tabBarIcon: getTabBarIcon(TAB_ICON_LAYOUT, (isSelected) => ({ name, - color: isSelected ? theme.iconMenu : theme.icon, + color: isSelected && !isTabBarSelectionHidden ? theme.iconMenu : theme.icon, avatar: name === NAVIGATORS.SETTINGS_SPLIT_NAVIGATOR ? avatar : undefined, dotColor: dotColors[name], })), diff --git a/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/useNativeTabBarOptions/index.ios.ts b/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/useNativeTabBarOptions/index.ios.ts index 81627df46fd6..1b5f39683024 100644 --- a/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/useNativeTabBarOptions/index.ios.ts +++ b/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/useNativeTabBarOptions/index.ios.ts @@ -38,7 +38,7 @@ const LANDSCAPE_TAB_ICON_LAYOUT: TabIconLayout = { * in the color of the selected tab, so each icon is drawn with its own colors, its status dot and its label. Both * selection states are images, because RNScreens rejects a tab whose icon and selectedIcon differ in type. */ -function useNativeTabBarOptions({shouldShowNativeTabBar, isAccountAvatarShown, dotColors, tabLabels}: NativeTabBarOptionsParams) { +function useNativeTabBarOptions({shouldShowNativeTabBar, isTabBarSelectionHidden, isAccountAvatarShown, dotColors, tabLabels}: NativeTabBarOptionsParams) { const theme = useTheme(); const avatar = useTabAvatarImage(isAccountAvatarShown); const {windowWidth} = useWindowDimensions(); @@ -64,20 +64,23 @@ function useNativeTabBarOptions({shouldShowNativeTabBar, isAccountAvatarShown, d }; const getTabOptions = (name: NativeTabName): NativeBottomTabNavigationOptions => ({ - tabBarIcon: getTabBarIcon(tabIconLayout, (isSelected) => ({ - name, - color: isSelected ? theme.iconMenu : theme.icon, - avatar: name === NAVIGATORS.SETTINGS_SPLIT_NAVIGATOR ? avatar : undefined, - dotColor: dotColors[name], - label: { - text: tabLabels[name], - color: isSelected ? theme.text : theme.textSupporting, - isBold: isSelected, - fontSize: fontScale.micro, - gap: variables.nativeTabIconLabelGap, - maxWidth: labelMaxWidth, - }, - })), + tabBarIcon: getTabBarIcon(tabIconLayout, (isSelected) => { + const isDrawnSelected = isSelected && !isTabBarSelectionHidden; + return { + name, + color: isDrawnSelected ? theme.iconMenu : theme.icon, + avatar: name === NAVIGATORS.SETTINGS_SPLIT_NAVIGATOR ? avatar : undefined, + dotColor: dotColors[name], + label: { + text: tabLabels[name], + color: isDrawnSelected ? theme.text : theme.textSupporting, + isBold: isDrawnSelected, + fontSize: fontScale.micro, + gap: variables.nativeTabIconLabelGap, + maxWidth: labelMaxWidth, + }, + }; + }), // The label is drawn into the icon, so VoiceOver reads it from here. tabBarAccessibilityLabel: tabLabels[name], }); diff --git a/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/useNativeTabBarOptions/types.ts b/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/useNativeTabBarOptions/types.ts index 4091ff2f6522..56e6c9ba120c 100644 --- a/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/useNativeTabBarOptions/types.ts +++ b/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/useNativeTabBarOptions/types.ts @@ -1,5 +1,7 @@ type NativeTabBarOptionsParams = { shouldShowNativeTabBar: boolean; + /** Whether the focused tab has no item in the bar, so every item is drawn unselected. */ + isTabBarSelectionHidden: boolean; /** Whether the account tab has an item in a narrow layout's bar, so its avatar is drawn. */ isAccountAvatarShown: boolean; dotColors: Record; diff --git a/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/useNativeTabNavigator.ts b/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/useNativeTabNavigator.ts index 1cd5ff3dcc68..dca640959c25 100644 --- a/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/useNativeTabNavigator.ts +++ b/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/useNativeTabNavigator.ts @@ -62,9 +62,9 @@ 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; + // A tab with no item in the bar keeps the bar, which then draws none of its items as selected. + const isTabBarSelectionHidden = activeTabRoute?.name === tabWithoutBarItem; + const shouldShowNativeTabBar = shouldUseNarrowLayout && isTabRouteAtRoot(activeTabRoute) && !isBlockingViewVisible; 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)); @@ -125,7 +125,7 @@ function useNativeTabNavigator() { const isTabSelectionEnabled = (routeName: string) => isNativeTabSelectionEnabled(routeName, {isAnonymousUser, isInboxAtChatList, isWorkspacesTabRestored}); - return {shouldShowNativeTabBar, isAccountAvatarShown, dotColors, tabLabels, tabWithoutBarItem, tabRouterOverride, isTabSelectionEnabled}; + return {shouldShowNativeTabBar, isTabBarSelectionHidden, isAccountAvatarShown, dotColors, tabLabels, tabWithoutBarItem, tabRouterOverride, isTabSelectionEnabled}; } export default useNativeTabNavigator; diff --git a/src/libs/Navigation/AppNavigator/Navigators/TabNavigator.native.tsx b/src/libs/Navigation/AppNavigator/Navigators/TabNavigator.native.tsx index 6c4b97d1fedc..d3929c91fdc6 100644 --- a/src/libs/Navigation/AppNavigator/Navigators/TabNavigator.native.tsx +++ b/src/libs/Navigation/AppNavigator/Navigators/TabNavigator.native.tsx @@ -35,8 +35,9 @@ const HIDDEN_TAB_OPTIONS = {tabBarItemHidden: true}; const renderNativeTabLayout = (props: NativeTabLayoutProps) => ; function TabNavigator() { - const {shouldShowNativeTabBar, isAccountAvatarShown, dotColors, tabLabels, tabWithoutBarItem, tabRouterOverride, isTabSelectionEnabled} = useNativeTabNavigator(); - const {screenOptions, getTabOptions} = useNativeTabBarOptions({shouldShowNativeTabBar, isAccountAvatarShown, dotColors, tabLabels}); + const {shouldShowNativeTabBar, isTabBarSelectionHidden, isAccountAvatarShown, dotColors, tabLabels, tabWithoutBarItem, tabRouterOverride, isTabSelectionEnabled} = + useNativeTabNavigator(); + const {screenOptions, getTabOptions} = useNativeTabBarOptions({shouldShowNativeTabBar, isTabBarSelectionHidden, isAccountAvatarShown, dotColors, tabLabels}); // A tab with no bar item draws no icon. const getScreenOptions = ({route}: {route: {name: NativeTabName}}) => route.name === tabWithoutBarItem diff --git a/src/pages/settings/InitialSettingsPage.tsx b/src/pages/settings/InitialSettingsPage.tsx index 3ddc8918d50b..57f5de93d4b3 100755 --- a/src/pages/settings/InitialSettingsPage.tsx +++ b/src/pages/settings/InitialSettingsPage.tsx @@ -12,9 +12,7 @@ import Text from '@components/Text'; import type {WithCurrentUserPersonalDetailsProps} from '@components/withCurrentUserPersonalDetails'; import withCurrentUserPersonalDetails from '@components/withCurrentUserPersonalDetails'; -import useAndroidBackButtonHandler from '@hooks/useAndroidBackButtonHandler'; import useHasTabBeenShown from '@hooks/useHasTabBeenShown'; -import useIsSettingsDrawnOverTabs from '@hooks/useIsSettingsDrawnOverTabs'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; import usePrevious from '@hooks/usePrevious'; @@ -25,23 +23,19 @@ import useThemeStyles from '@hooks/useThemeStyles'; import useIsSidebarRouteActive from '@libs/Navigation/helpers/useIsSidebarRouteActive'; import Navigation from '@libs/Navigation/Navigation'; -import type {TabNavigatorParamList} from '@libs/Navigation/types'; import {openInitialSettingsPage} from '@userActions/Wallet'; import CONST from '@src/CONST'; import NAVIGATORS from '@src/NAVIGATORS'; import ONYXKEYS from '@src/ONYXKEYS'; -import ROUTES from '@src/ROUTES'; -import SCREENS from '@src/SCREENS'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; -import type {NativeBottomTabNavigationProp} from '@react-navigation/bottom-tabs/unstable'; import type {ComponentRef} from 'react'; // eslint-disable-next-line no-restricted-imports import type {ScrollView as RNScrollView, ScrollViewProps, StyleProp, ViewStyle} from 'react-native'; -import {CommonActions, findFocusedRoute, useNavigation, useNavigationState, useRoute} from '@react-navigation/native'; +import {findFocusedRoute, useNavigationState, useRoute} from '@react-navigation/native'; import {canSwitchAccountsSelector} from '@selectors/Account'; import React, {useContext, useEffect, useLayoutEffect, useRef} from 'react'; import {View} from 'react-native'; @@ -62,38 +56,6 @@ function InitialSettingsPage({currentUserPersonalDetails}: InitialSettingsPagePr const {isExecuting, singleExecution} = useSingleExecution(); const {translate} = useLocalize(); const focusedRouteName = useNavigationState((state) => findFocusedRoute(state)?.name); - const navigation = useNavigation(); - const isDrawnOverTabs = useIsSettingsDrawnOverTabs(); - - // The tab navigator keeps the full tab history, so going back returns to the tab the user opened Account from. - // With nothing behind it, as after a deep link or a relaunch, the tab history is reset to Home alone so that going - // back does not reopen Account. The tab router drops REPLACE, so `navigate(HOME, {forceReplace: true})` can't do it. - const goBackFromAccount = () => { - if (navigation.canGoBack()) { - navigation.goBack(); - return; - } - - const tabNavigation = navigation.getParent>(); - const tabState = tabNavigation?.getState(); - const homeRoute = tabState?.routes.find((tabRoute) => tabRoute.name === SCREENS.HOME); - if (!tabNavigation || !tabState || !homeRoute) { - Navigation.navigate(ROUTES.HOME); - return; - } - - tabNavigation.dispatch(CommonActions.reset({...tabState, index: tabState.routes.indexOf(homeRoute), history: [{type: 'route', key: homeRoute.key}]})); - }; - - useAndroidBackButtonHandler(() => { - if (!isDrawnOverTabs) { - return false; - } - - goBackFromAccount(); - return true; - }); - const isScreenFocused = useIsSidebarRouteActive(NAVIGATORS.SETTINGS_SPLIT_NAVIGATOR, shouldUseNarrowLayout); const previousUserPersonalDetails = usePrevious(currentUserPersonalDetails); const {accountMenuItemsData, generalMenuItemsData} = useInitialSettingsPageMenuData(currentUserPersonalDetails); @@ -207,7 +169,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. */ From a93b1293a6006a0c22a9f3a26a5de75f05ef3bdc Mon Sep 17 00:00:00 2001 From: sumo_slonik Date: Tue, 20 Oct 2026 20:52:06 +0200 Subject: [PATCH 2/6] refactor: shrink native Account tab bar patches; Android selection hidden via transparent indicator, iOS-only RNS prop --- patches/react-native-screens/details.md | 2 +- ....0+007+tab-bar-item-selection-hidden.patch | 287 ++---------------- ...+7.19.2+005+hidden-tab-under-tab-bar.patch | 116 +++---- patches/react-navigation/details.md | 2 +- .../useNativeTabBarOptions/index.android.ts | 4 +- .../useNativeTabBarOptions/index.ios.ts | 33 +- .../useNativeTabBarOptions/types.ts | 2 - .../useNativeTabNavigator.ts | 4 +- .../Navigators/TabNavigator.native.tsx | 5 +- 9 files changed, 81 insertions(+), 374 deletions(-) diff --git a/patches/react-native-screens/details.md b/patches/react-native-screens/details.md index c6adbe37cee3..7c3a4174dba1 100644 --- a/patches/react-native-screens/details.md +++ b/patches/react-native-screens/details.md @@ -107,7 +107,7 @@ ### [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 a `tabBarItemSelectionHidden` prop to `Tabs.Host`, which draws the selected tab bar item like the others while the selected tab stays the same. The App shows Account, which has no item in the native bar with the Insights beta, under a visible bar with none of its items selected (React Navigation patch 005). Neither native bar can show itself with no item selected. On iOS, `UITabBarController` always keeps a selected item, `tabBar.selectedItem = nil` throws "Directly modifying a tab bar managed by a tab bar controller is not allowed", and a selected `UITab` with `isHidden` is shown in the bar again (with five visible tabs it even folds one into a "More" tab). On iOS 26 the liquid glass bar marks the selected item with private views: a lens under it, a copy of the items in their selected colors that only shows through the lens, and a view that cuts the selected item out of the plain copy. Their alpha is animated by UIKit, so the patch sets `hidden` on them while the prop is on, re-applies it after each layout and restores them when the prop goes off or the user taps another tab, so the lens animates from the tab the controller keeps selected. A UIKit release that renames these views leaves the selection showing and breaks nothing else. Below iOS 26 the selection is only the item's selected image and colors, which the App draws to match the unselected ones. On Android, the prop turns off Material's active indicator, draws the selected label in the unselected color, and drops the bold that patch 003 gives the selected label. The App draws the icons for both selection states. +- 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: TBD - E/App issue: TBD - PR introducing patch: TBD 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 index b94cae94ad0f..1eabced08ffe 100644 --- 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 @@ -1,237 +1,76 @@ 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..1f544b5 100644 +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 -@@ -35,6 +35,7 @@ internal class TabsAppearanceApplicator( - context: Context, - tabBarAppearance: TabsAppearance?, - isTabBarHidden: Boolean, -+ isTabBarItemSelectionHidden: Boolean, - ) { - bottomNavigationView.isVisible = !isTabBarHidden - // A GONE child keeps the bounds it had while visible, and React Native's TouchTargetHelper does not check -@@ -64,7 +65,14 @@ internal class TabsAppearanceApplicator( - tabBarAppearance?.normal?.tabBarItemTitleFontColor - ?: resolveColorAttr(context, R.attr.colorSecondary) - -- val fontColors = intArrayOf(fontDisabledColor, fontSelectedColor, fontFocusedColor, fontNormalColor) -+ // A hidden selection draws the selected label in the color of the others. -+ val fontColors = -+ intArrayOf( -+ fontDisabledColor, -+ if (isTabBarItemSelectionHidden) fontNormalColor else fontSelectedColor, -+ fontFocusedColor, -+ fontNormalColor, -+ ) - bottomNavigationView.itemTextColor = ColorStateList(states, fontColors) - - // Icon color -@@ -98,13 +106,14 @@ internal class TabsAppearanceApplicator( - ?: resolveColorAttr(context, R.attr.colorSecondaryContainer) - - bottomNavigationView.isItemActiveIndicatorEnabled = -- tabBarAppearance?.tabBarItemActiveIndicatorEnabled ?: true -+ !isTabBarItemSelectionHidden && (tabBarAppearance?.tabBarItemActiveIndicatorEnabled ?: true) - bottomNavigationView.itemActiveIndicatorColor = ColorStateList.valueOf(activeIndicatorColor) - } - - fun updateFontStyles( - context: Context, - tabBarAppearance: TabsAppearance?, -+ isTabBarItemSelectionHidden: Boolean, - ) { - val bottomNavigationMenuView = bottomNavigationView.getChildAt(0) as ViewGroup - -@@ -162,7 +171,7 @@ internal class TabsAppearanceApplicator( +@@ -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 (isTabBarItemSelectionHidden) Typeface.NORMAL else 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/android/src/main/java/com/swmansion/rnscreens/gamma/tabs/appearance/TabsAppearanceCoordinator.kt b/node_modules/react-native-screens/android/src/main/java/com/swmansion/rnscreens/gamma/tabs/appearance/TabsAppearanceCoordinator.kt -index d827309..7994902 100644 ---- a/node_modules/react-native-screens/android/src/main/java/com/swmansion/rnscreens/gamma/tabs/appearance/TabsAppearanceCoordinator.kt -+++ b/node_modules/react-native-screens/android/src/main/java/com/swmansion/rnscreens/gamma/tabs/appearance/TabsAppearanceCoordinator.kt -@@ -18,9 +18,14 @@ internal class TabsAppearanceCoordinator( - tabsContainer: TabsContainer, - ) { - val selectedTabAppearance = tabsContainer.selectedTab.tabsScreen.appearance -- appearanceApplicator.updateSharedAppearance(context, selectedTabAppearance, tabsContainer.tabBarHidden) -+ appearanceApplicator.updateSharedAppearance( -+ context, -+ selectedTabAppearance, -+ tabsContainer.tabBarHidden, -+ tabsContainer.tabBarItemSelectionHidden, -+ ) - updateMenuItems(context, selectedTabAppearance) -- appearanceApplicator.updateFontStyles(context, selectedTabAppearance) // It needs to be updated after updateMenuItems -+ appearanceApplicator.updateFontStyles(context, selectedTabAppearance, tabsContainer.tabBarItemSelectionHidden) // It needs to be updated after updateMenuItems - } - - private fun updateMenuItems( -diff --git a/node_modules/react-native-screens/android/src/main/java/com/swmansion/rnscreens/gamma/tabs/container/TabsContainer.kt b/node_modules/react-native-screens/android/src/main/java/com/swmansion/rnscreens/gamma/tabs/container/TabsContainer.kt -index b98825c..c681b8a 100644 ---- a/node_modules/react-native-screens/android/src/main/java/com/swmansion/rnscreens/gamma/tabs/container/TabsContainer.kt -+++ b/node_modules/react-native-screens/android/src/main/java/com/swmansion/rnscreens/gamma/tabs/container/TabsContainer.kt -@@ -172,6 +172,16 @@ class TabsContainer internal constructor( - } - } - -+ /** Draws the selected item like the others, so the bar shows no selection. The selected tab does not change. */ -+ internal var tabBarItemSelectionHidden: Boolean by Delegates.observable(false) { _, oldValue, newValue -> -+ if (newValue != oldValue) { -+ invalidationFlags.isNavigationMenuAppearanceInvalidated = true -+ post { -+ flushPendingUpdates() -+ } -+ } -+ } -+ - init { - addView(contentView) - addView(bottomNavigationView) -diff --git a/node_modules/react-native-screens/android/src/main/java/com/swmansion/rnscreens/gamma/tabs/host/TabsHost.kt b/node_modules/react-native-screens/android/src/main/java/com/swmansion/rnscreens/gamma/tabs/host/TabsHost.kt -index 05a6d07..dad98ed 100644 ---- a/node_modules/react-native-screens/android/src/main/java/com/swmansion/rnscreens/gamma/tabs/host/TabsHost.kt -+++ b/node_modules/react-native-screens/android/src/main/java/com/swmansion/rnscreens/gamma/tabs/host/TabsHost.kt -@@ -51,6 +51,8 @@ class TabsHost( - - var tabBarHidden: Boolean by container::tabBarHidden - -+ var tabBarItemSelectionHidden: Boolean by container::tabBarItemSelectionHidden -+ - var nativeContainerBackgroundColor: Int? by Delegates.observable(null) { _, oldValue, newValue -> - if (newValue != oldValue) { - container.background = newValue?.toDrawable() -diff --git a/node_modules/react-native-screens/android/src/main/java/com/swmansion/rnscreens/gamma/tabs/host/TabsHostViewManager.kt b/node_modules/react-native-screens/android/src/main/java/com/swmansion/rnscreens/gamma/tabs/host/TabsHostViewManager.kt -index c640543..67a023e 100644 ---- a/node_modules/react-native-screens/android/src/main/java/com/swmansion/rnscreens/gamma/tabs/host/TabsHostViewManager.kt -+++ b/node_modules/react-native-screens/android/src/main/java/com/swmansion/rnscreens/gamma/tabs/host/TabsHostViewManager.kt -@@ -108,6 +108,13 @@ class TabsHostViewManager : - view.tabBarHidden = value - } - -+ override fun setTabBarItemSelectionHidden( -+ view: TabsHost, -+ value: Boolean, -+ ) { -+ view.tabBarItemSelectionHidden = value -+ } -+ - override fun setNativeContainerBackgroundColor( - view: TabsHost, - value: Int?, 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..5dea64e 100644 +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,14 @@ NS_ASSUME_NONNULL_BEGIN +@@ -133,6 +133,8 @@ NS_ASSUME_NONNULL_BEGIN */ @property (nonatomic, readwrite) BOOL rejectStaleNavigationStateUpdates; -+/** -+ * Draws the selected tab bar item like the others, so the bar shows no selection while the app displays a screen that -+ * has no item in the bar. The controller keeps its selected view controller. UIKit offers no way to show the bar with -+ * no item selected, so on iOS 26 the liquid glass lens and the selected copy of the items are hidden. Below iOS 26 the -+ * selection is only the item's selected image and colors, which the app supplies to match the unselected ones. -+ */ +@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..a8cb06a 100644 +index 06c1957..95cad83 100644 --- a/node_modules/react-native-screens/ios/tabs/host/RNSTabBarController.mm +++ b/node_modules/react-native-screens/ios/tabs/host/RNSTabBarController.mm -@@ -89,6 +89,9 @@ @implementation RNSTabBarController { - RNSTabsNavigationStateObserverRegistry *_observerRegistry; - - RNSParentContainerItemRegistry *_Nonnull _parentContainerRegistry; -+ -+ /// Views of the tab bar hidden to draw no selection, restored when the selection shows again. -+ NSHashTable *_Nonnull _viewsHiddenForTabBarItemSelection; - } - - - (instancetype)init -@@ -102,6 +105,7 @@ - (instancetype)init - _shouldProgressStateOnMoreNavigationControllerPush = NO; - _observerRegistry = [RNSTabsNavigationStateObserverRegistry new]; - _parentContainerRegistry = [RNSParentContainerItemRegistry new]; -+ _viewsHiddenForTabBarItemSelection = [NSHashTable weakObjectsHashTable]; - - // Delegate field retains weakly, no risk of cycle. - self.delegate = self; -@@ -177,6 +181,56 @@ - (void)detachFromParentContainerItem +@@ -177,6 +177,34 @@ - (void)detachFromParentContainerItem #pragma mark - UIKit callbacks +- (void)viewDidLayoutSubviews +{ + [super viewDidLayoutSubviews]; -+ // UIKit may rebuild the bar's subviews on layout, e.g. after a size or trait change. -+ [self updateTabBarItemSelectionVisibility]; ++ if (_tabBarItemSelectionHidden) { ++ [self updateTabBarItemSelectionIn:self.tabBar]; ++ } +} + +- (void)setTabBarItemSelectionHidden:(BOOL)tabBarItemSelectionHidden +{ -+ if (_tabBarItemSelectionHidden == tabBarItemSelectionHidden) { -+ return; -+ } + _tabBarItemSelectionHidden = tabBarItemSelectionHidden; -+ [self updateTabBarItemSelectionVisibility]; -+} -+ -+- (void)updateTabBarItemSelectionVisibility -+{ -+ if (!_tabBarItemSelectionHidden) { -+ for (UIView *view in _viewsHiddenForTabBarItemSelection) { -+ view.hidden = NO; -+ } -+ [_viewsHiddenForTabBarItemSelection removeAllObjects]; -+ return; -+ } -+ -+ if (@available(iOS 26.0, *)) { -+ [self hideTabBarItemSelectionViewsIn:self.tabBar]; -+ } ++ [self updateTabBarItemSelectionIn:self.tabBar]; +} + -+/// The liquid glass bar marks the selected item with private views: a lens under it, a copy of the items in their -+/// selected colors that only shows through the lens, and a view that cuts the selected item out of the plain copy. -+/// UIKit animates their alpha, so they are hidden instead. A UIKit release that renames them leaves the selection on. -+- (void)hideTabBarItemSelectionViewsIn:(UIView *)view ++// 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"]) { -+ if (!subview.hidden) { -+ subview.hidden = YES; -+ [_viewsHiddenForTabBarItemSelection addObject:subview]; -+ } -+ continue; ++ subview.hidden = _tabBarItemSelectionHidden; ++ } else { ++ [self updateTabBarItemSelectionIn:subview]; + } -+ [self hideTabBarItemSelectionViewsIn:subview]; + } +} + - (void)didMoveToParentViewController:(UIViewController *)parent { [super didMoveToParentViewController:parent]; -@@ -443,6 +497,9 @@ - (BOOL)tabBarController:(UITabBarController *)tabBarController - } - } - -+ // The lens travels from the item the controller keeps selected, so it shows as soon as the user picks a tab. -+ self.tabBarItemSelectionHidden = NO; -+ - _isHandlingExplicitSelectionUpdate = YES; - return YES; - } 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 @@ -247,84 +86,6 @@ index 60c9ab2..fa2afb9 100644 if (newComponentProps.bottomAccessoryHidden != oldComponentProps.bottomAccessoryHidden) { _bottomAccessoryHidden = newComponentProps.bottomAccessoryHidden; #if RNS_TABS_BOTTOM_ACCESSORY_AVAILABLE -diff --git a/node_modules/react-native-screens/lib/typescript/components/tabs/host/TabsHost.types.d.ts b/node_modules/react-native-screens/lib/typescript/components/tabs/host/TabsHost.types.d.ts -index cf2e551..07ce754 100644 ---- a/node_modules/react-native-screens/lib/typescript/components/tabs/host/TabsHost.types.d.ts -+++ b/node_modules/react-native-screens/lib/typescript/components/tabs/host/TabsHost.types.d.ts -@@ -166,6 +166,16 @@ export interface TabsHostPropsBase { - * @platform android, ios - */ - tabBarHidden?: boolean | undefined; -+ /** -+ * @summary Draws the selected tab bar item like the others, so the bar shows no selection. -+ * -+ * Meant for a screen that has no item in the tab bar. The selected tab does not change. -+ * -+ * @default false -+ * -+ * @platform android, ios -+ */ -+ tabBarItemSelectionHidden?: boolean | undefined; - /** - * @summary Allows for native container view customization. - * -diff --git a/node_modules/react-native-screens/lib/typescript/fabric/tabs/TabsHostAndroidNativeComponent.d.ts b/node_modules/react-native-screens/lib/typescript/fabric/tabs/TabsHostAndroidNativeComponent.d.ts -index 19fd4d6..51f5afa 100644 ---- a/node_modules/react-native-screens/lib/typescript/fabric/tabs/TabsHostAndroidNativeComponent.d.ts -+++ b/node_modules/react-native-screens/lib/typescript/fabric/tabs/TabsHostAndroidNativeComponent.d.ts -@@ -30,6 +30,7 @@ export interface NativeProps extends ViewProps { - onTabSelectionRejected?: CT.DirectEventHandler | undefined; - onTabSelectionPrevented?: CT.DirectEventHandler | undefined; - tabBarHidden?: CT.WithDefault; -+ tabBarItemSelectionHidden?: CT.WithDefault; - nativeContainerBackgroundColor?: ColorValue | undefined; - colorScheme?: CT.WithDefault; - tabBarRespectsIMEInsets?: CT.WithDefault; -diff --git a/node_modules/react-native-screens/lib/typescript/fabric/tabs/TabsHostIOSNativeComponent.d.ts b/node_modules/react-native-screens/lib/typescript/fabric/tabs/TabsHostIOSNativeComponent.d.ts -index e756dc3..e52b643 100644 ---- a/node_modules/react-native-screens/lib/typescript/fabric/tabs/TabsHostIOSNativeComponent.d.ts -+++ b/node_modules/react-native-screens/lib/typescript/fabric/tabs/TabsHostIOSNativeComponent.d.ts -@@ -38,6 +38,7 @@ export interface NativeProps extends ViewProps { - onTabSelectionPrevented?: CT.DirectEventHandler | undefined; - onMoreTabSelected?: CT.DirectEventHandler | undefined; - tabBarHidden?: CT.WithDefault; -+ tabBarItemSelectionHidden?: CT.WithDefault; - nativeContainerBackgroundColor?: ColorValue | undefined; - colorScheme?: CT.WithDefault; - layoutDirection?: CT.WithDefault; -diff --git a/node_modules/react-native-screens/src/components/tabs/host/TabsHost.types.ts b/node_modules/react-native-screens/src/components/tabs/host/TabsHost.types.ts -index 1d2851f..ce55e82 100644 ---- a/node_modules/react-native-screens/src/components/tabs/host/TabsHost.types.ts -+++ b/node_modules/react-native-screens/src/components/tabs/host/TabsHost.types.ts -@@ -186,6 +186,16 @@ export interface TabsHostPropsBase { - * @platform android, ios - */ - tabBarHidden?: boolean | undefined; -+ /** -+ * @summary Draws the selected tab bar item like the others, so the bar shows no selection. -+ * -+ * Meant for a screen that has no item in the tab bar. The selected tab does not change. -+ * -+ * @default false -+ * -+ * @platform android, ios -+ */ -+ tabBarItemSelectionHidden?: boolean | undefined; - /** - * @summary Allows for native container view customization. - * -diff --git a/node_modules/react-native-screens/src/fabric/tabs/TabsHostAndroidNativeComponent.ts b/node_modules/react-native-screens/src/fabric/tabs/TabsHostAndroidNativeComponent.ts -index 138da4d..d3b8cef 100644 ---- a/node_modules/react-native-screens/src/fabric/tabs/TabsHostAndroidNativeComponent.ts -+++ b/node_modules/react-native-screens/src/fabric/tabs/TabsHostAndroidNativeComponent.ts -@@ -61,6 +61,7 @@ export interface NativeProps extends ViewProps { - - // General - tabBarHidden?: CT.WithDefault; -+ tabBarItemSelectionHidden?: CT.WithDefault; - nativeContainerBackgroundColor?: ColorValue | undefined; - colorScheme?: CT.WithDefault; - 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 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 index c2424123e857..f2a8b76f850b 100644 --- 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 @@ -1,58 +1,35 @@ 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 d2d69fe..0a6d1d5 100644 +index d2d69fe..ff92664 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 -@@ -8,7 +8,7 @@ import { Platform, PlatformColor, StyleSheet, View } from 'react-native'; - import { Tabs } from 'react-native-screens'; - import { Deferred } from "./Deferred.js"; - import { NativeScreen } from "./NativeScreen/NativeScreen.js"; --import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime"; -+import { jsx as _jsx } from "react/jsx-runtime"; - const meta = { - type: 'native-tabs' - }; -@@ -32,13 +32,14 @@ export function NativeBottomTabView({ +@@ -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 native tab of the first visible -+ // route, so it sits under the tab bar like the content of any tab. While it is focused, the host selects that tab and -+ // the bar draws no item as selected. ++ // 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; -+ // A fixed tab, so the hidden routes stay mounted in the same place whichever tab they were opened from. + 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); -@@ -130,7 +131,8 @@ export function NativeBottomTabView({ +@@ -130,7 +129,7 @@ export function NativeBottomTabView({ console.error(`Received 'tabSelectionRejected' for route that doesn't exist: ${selectedScreenKey}`); return; } - if (focusedRouteKey === route.key) { -+ // While a hidden route is focused, native has its host tab selected, which JS asked for. + if (nativeSelectedRouteKey === route.key) { dispatch({ type: 'CONFIRM_STATE', confirmed -@@ -205,8 +207,8 @@ export function NativeBottomTabView({ - const tabBarMinimizeBehavior = currentOptions.tabBarMinimizeBehavior === 'auto' ? 'automatic' : currentOptions.tabBarMinimizeBehavior === 'none' ? 'never' : currentOptions.tabBarMinimizeBehavior; - const shouldHideTabBar = currentOptions.tabBarStyle?.display === 'none'; - const bottomAccessory = currentOptions.bottomAccessory; -- return /*#__PURE__*/_jsxs(SafeAreaProviderCompat, { -- children: [/*#__PURE__*/_jsx(Tabs.Host, { -+ return /*#__PURE__*/_jsx(SafeAreaProviderCompat, { -+ children: /*#__PURE__*/_jsx(Tabs.Host, { - navStateRequest: { - selectedScreenKey: nativeSelectedScreenKey, - baseProvenance: nativeState.confirmed.provenance -@@ -215,7 +217,8 @@ export function NativeBottomTabView({ +@@ -215,7 +214,8 @@ export function NativeBottomTabView({ onTabSelected: onTabSelected, onTabSelectionRejected: onTabSelectionRejected, onTabSelectionPrevented: onTabSelectionPrevented, @@ -62,32 +39,17 @@ index d2d69fe..0a6d1d5 100644 colorScheme: dark ? 'dark' : 'light', nativeContainerStyle: { backgroundColor: colors.background -@@ -289,6 +292,20 @@ export function NativeBottomTabView({ +@@ -287,7 +287,7 @@ export function NativeBottomTabView({ + focused: false + })) : tabBarIcon != null ? getPlatformIcon(tabBarIcon) : undefined; const selectedIcon = typeof tabBarIcon === 'function' ? getPlatformIcon(tabBarIcon({ - focused: true +- focused: true ++ focused: !isFocusedRouteHidden })) : undefined; -+ const content = /*#__PURE__*/_jsx(Deferred, { -+ lazy: lazy, -+ visible: isFocused || isPreloaded, -+ children: /*#__PURE__*/_jsx(ScreenWithHeader, { -+ isFocused: isFocused, -+ route: route, -+ navigation: navigation, -+ options: options, -+ children: /*#__PURE__*/_jsx(NavigationMetaContext.Provider, { -+ value: meta, -+ children: render() -+ }) -+ }) -+ }, route.key); return /*#__PURE__*/_jsx(Tabs.Screen, { onWillDisappear: () => onTransitionStart({ - closing: true, -@@ -314,10 +331,11 @@ export function NativeBottomTabView({ - nativeContainerStyle: { - backgroundColor: colors.background +@@ -316,8 +316,8 @@ export function NativeBottomTabView({ }, -+ // Tapping the host tab while a hidden route is focused opens the host tab, so it is not a repeated selection. specialEffects: { repeatedTabSelection: { - popToRoot: true, @@ -97,36 +59,43 @@ index d2d69fe..0a6d1d5 100644 } }, android: { -@@ -375,23 +393,13 @@ export function NativeBottomTabView({ +@@ -328,8 +328,8 @@ export function NativeBottomTabView({ + tabBarItemRippleColor: currentOptions?.tabBarRippleColor, + tabBarItemLabelVisibilityMode: currentOptions?.tabBarLabelVisibilityMode, + normal: normalTabItemAppearance, +- selected: selectedTabItemAppearance, +- tabBarItemActiveIndicatorColor: activeIndicatorColor, ++ selected: isFocusedRouteHidden ? normalTabItemAppearance : selectedTabItemAppearance, ++ tabBarItemActiveIndicatorColor: isFocusedRouteHidden ? 'transparent' : activeIndicatorColor, + tabBarItemActiveIndicatorEnabled: currentOptions?.tabBarActiveIndicatorEnabled, + tabBarItemTitleFontFamily: fontFamily, + tabBarItemTitleFontWeight: fontWeight, +@@ -375,7 +375,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, { -- lazy: lazy, -- visible: isFocused || isPreloaded, -- children: /*#__PURE__*/_jsx(ScreenWithHeader, { -- isFocused: isFocused, -- route: route, -- navigation: navigation, -- options: options, -- children: /*#__PURE__*/_jsx(NavigationMetaContext.Provider, { -- value: meta, -- children: render() -- }) -- }) ++ children: [/*#__PURE__*/_jsx(Deferred, { + lazy: lazy, + visible: isFocused || isPreloaded, + children: /*#__PURE__*/_jsx(ScreenWithHeader, { +@@ -388,10 +388,13 @@ export function NativeBottomTabView({ + children: render() + }) + }) - }, route.key) -+ children: route.key === hiddenRoutesHostKey ? [content, ...renderHiddenRoutes()] : content ++ }, route.key), route.key === hiddenRoutesHostKey ? renderHiddenRoutes() : null] }, route.name); }) - }, "native-tabs-host"), ...state.routes.map((route, index) => { -+ }, "native-tabs-host") ++ }, "native-tabs-host")] + }); + function renderHiddenRoutes() { + return state.routes.map((route, index) => { if (!isRouteHidden(route) || !visitedHiddenRouteKeysRef.current.has(route.key)) { return null; } -@@ -417,8 +425,8 @@ export function NativeBottomTabView({ +@@ -417,8 +420,8 @@ export function NativeBottomTabView({ }) }) }, route.key); @@ -137,18 +106,3 @@ index d2d69fe..0a6d1d5 100644 } 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..7daa805 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, 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 7025d6ab0251..a7d7e4f5d5d0 100644 --- a/patches/react-navigation/details.md +++ b/patches/react-navigation/details.md @@ -89,7 +89,7 @@ ### [@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 on screen while a route with `tabBarItemHidden` (patch 003) is focused, the way the JS tab bar on web stays with no tab selected. With the Insights beta, Account has no item in the native bar and opens from the avatar in the top bar, and the design wants the bar to stay with none of its items selected. Patch 003 drew the hidden route over the whole native host and hid the bar. A view drawn over the host also covers the bar, so this patch draws the hidden routes inside the native tab of the first visible route instead, where they sit under the bar like the content of any tab. While a hidden route is focused, the host selects that tab and passes `tabBarItemSelectionHidden` (react-native-screens patch 006), so the bar draws no item as selected. A fixed tab keeps the hidden routes mounted in one place, whichever tab they were opened from. Tapping the host tab while a hidden route is focused opens the host tab, so its repeated-selection effects (pop to root, scroll to top) are turned off for that time. Native also rejects a repeated request for the host tab while a hidden route is focused, and the rejection handler no longer takes that as a reason to move JS to the host tab. +- Reason: Keeps the native tab bar, with no item drawn as selected, while a `tabBarItemHidden` route (patch 003) is focused. The hidden routes are drawn inside the first visible tab, which the host selects meanwhile. - Upstream PR/issue: not reported, because it builds on patch 003, which has no upstream counterpart. - E/App issue: TBD - PR Introducing Patch: TBD diff --git a/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/useNativeTabBarOptions/index.android.ts b/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/useNativeTabBarOptions/index.android.ts index a7bb0e788c68..d439553f17ae 100644 --- a/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/useNativeTabBarOptions/index.android.ts +++ b/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/useNativeTabBarOptions/index.android.ts @@ -27,7 +27,7 @@ const TAB_ICON_LAYOUT: TabIconLayout = { direction: 'stacked', }; -function useNativeTabBarOptions({shouldShowNativeTabBar, isTabBarSelectionHidden, isAccountAvatarShown, dotColors, tabLabels}: NativeTabBarOptionsParams) { +function useNativeTabBarOptions({shouldShowNativeTabBar, isAccountAvatarShown, dotColors, tabLabels}: NativeTabBarOptionsParams) { const theme = useTheme(); const styles = useThemeStyles(); const avatar = useTabAvatarImage(isAccountAvatarShown); @@ -48,7 +48,7 @@ function useNativeTabBarOptions({shouldShowNativeTabBar, isTabBarSelectionHidden // The active indicator pill marks the selected tab, so the avatar is the same image in both selection states. tabBarIcon: getTabBarIcon(TAB_ICON_LAYOUT, (isSelected) => ({ name, - color: isSelected && !isTabBarSelectionHidden ? theme.iconMenu : theme.icon, + color: isSelected ? theme.iconMenu : theme.icon, avatar: name === NAVIGATORS.SETTINGS_SPLIT_NAVIGATOR ? avatar : undefined, dotColor: dotColors[name], })), diff --git a/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/useNativeTabBarOptions/index.ios.ts b/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/useNativeTabBarOptions/index.ios.ts index 1b5f39683024..81627df46fd6 100644 --- a/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/useNativeTabBarOptions/index.ios.ts +++ b/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/useNativeTabBarOptions/index.ios.ts @@ -38,7 +38,7 @@ const LANDSCAPE_TAB_ICON_LAYOUT: TabIconLayout = { * in the color of the selected tab, so each icon is drawn with its own colors, its status dot and its label. Both * selection states are images, because RNScreens rejects a tab whose icon and selectedIcon differ in type. */ -function useNativeTabBarOptions({shouldShowNativeTabBar, isTabBarSelectionHidden, isAccountAvatarShown, dotColors, tabLabels}: NativeTabBarOptionsParams) { +function useNativeTabBarOptions({shouldShowNativeTabBar, isAccountAvatarShown, dotColors, tabLabels}: NativeTabBarOptionsParams) { const theme = useTheme(); const avatar = useTabAvatarImage(isAccountAvatarShown); const {windowWidth} = useWindowDimensions(); @@ -64,23 +64,20 @@ function useNativeTabBarOptions({shouldShowNativeTabBar, isTabBarSelectionHidden }; const getTabOptions = (name: NativeTabName): NativeBottomTabNavigationOptions => ({ - tabBarIcon: getTabBarIcon(tabIconLayout, (isSelected) => { - const isDrawnSelected = isSelected && !isTabBarSelectionHidden; - return { - name, - color: isDrawnSelected ? theme.iconMenu : theme.icon, - avatar: name === NAVIGATORS.SETTINGS_SPLIT_NAVIGATOR ? avatar : undefined, - dotColor: dotColors[name], - label: { - text: tabLabels[name], - color: isDrawnSelected ? theme.text : theme.textSupporting, - isBold: isDrawnSelected, - fontSize: fontScale.micro, - gap: variables.nativeTabIconLabelGap, - maxWidth: labelMaxWidth, - }, - }; - }), + tabBarIcon: getTabBarIcon(tabIconLayout, (isSelected) => ({ + name, + color: isSelected ? theme.iconMenu : theme.icon, + avatar: name === NAVIGATORS.SETTINGS_SPLIT_NAVIGATOR ? avatar : undefined, + dotColor: dotColors[name], + label: { + text: tabLabels[name], + color: isSelected ? theme.text : theme.textSupporting, + isBold: isSelected, + fontSize: fontScale.micro, + gap: variables.nativeTabIconLabelGap, + maxWidth: labelMaxWidth, + }, + })), // The label is drawn into the icon, so VoiceOver reads it from here. tabBarAccessibilityLabel: tabLabels[name], }); diff --git a/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/useNativeTabBarOptions/types.ts b/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/useNativeTabBarOptions/types.ts index 56e6c9ba120c..4091ff2f6522 100644 --- a/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/useNativeTabBarOptions/types.ts +++ b/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/useNativeTabBarOptions/types.ts @@ -1,7 +1,5 @@ type NativeTabBarOptionsParams = { shouldShowNativeTabBar: boolean; - /** Whether the focused tab has no item in the bar, so every item is drawn unselected. */ - isTabBarSelectionHidden: boolean; /** Whether the account tab has an item in a narrow layout's bar, so its avatar is drawn. */ isAccountAvatarShown: boolean; dotColors: Record; diff --git a/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/useNativeTabNavigator.ts b/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/useNativeTabNavigator.ts index dca640959c25..be5ea6fe2db7 100644 --- a/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/useNativeTabNavigator.ts +++ b/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/useNativeTabNavigator.ts @@ -62,8 +62,6 @@ 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 keeps the bar, which then draws none of its items as selected. - const isTabBarSelectionHidden = activeTabRoute?.name === tabWithoutBarItem; const shouldShowNativeTabBar = shouldUseNarrowLayout && isTabRouteAtRoot(activeTabRoute) && !isBlockingViewVisible; const getTabRoute = (tabName: string) => (isRealizedNavigationState(tabState) ? tabState.routes.find((tabRoute) => tabRoute.name === tabName) : undefined); const isInboxAtChatList = isInboxTabAtChatList(getTabRoute(NAVIGATORS.REPORTS_SPLIT_NAVIGATOR)); @@ -125,7 +123,7 @@ function useNativeTabNavigator() { const isTabSelectionEnabled = (routeName: string) => isNativeTabSelectionEnabled(routeName, {isAnonymousUser, isInboxAtChatList, isWorkspacesTabRestored}); - return {shouldShowNativeTabBar, isTabBarSelectionHidden, isAccountAvatarShown, dotColors, tabLabels, tabWithoutBarItem, tabRouterOverride, isTabSelectionEnabled}; + return {shouldShowNativeTabBar, isAccountAvatarShown, dotColors, tabLabels, tabWithoutBarItem, tabRouterOverride, isTabSelectionEnabled}; } export default useNativeTabNavigator; diff --git a/src/libs/Navigation/AppNavigator/Navigators/TabNavigator.native.tsx b/src/libs/Navigation/AppNavigator/Navigators/TabNavigator.native.tsx index d3929c91fdc6..6c4b97d1fedc 100644 --- a/src/libs/Navigation/AppNavigator/Navigators/TabNavigator.native.tsx +++ b/src/libs/Navigation/AppNavigator/Navigators/TabNavigator.native.tsx @@ -35,9 +35,8 @@ const HIDDEN_TAB_OPTIONS = {tabBarItemHidden: true}; const renderNativeTabLayout = (props: NativeTabLayoutProps) => ; function TabNavigator() { - const {shouldShowNativeTabBar, isTabBarSelectionHidden, isAccountAvatarShown, dotColors, tabLabels, tabWithoutBarItem, tabRouterOverride, isTabSelectionEnabled} = - useNativeTabNavigator(); - const {screenOptions, getTabOptions} = useNativeTabBarOptions({shouldShowNativeTabBar, isTabBarSelectionHidden, isAccountAvatarShown, dotColors, tabLabels}); + const {shouldShowNativeTabBar, isAccountAvatarShown, dotColors, tabLabels, tabWithoutBarItem, tabRouterOverride, isTabSelectionEnabled} = useNativeTabNavigator(); + const {screenOptions, getTabOptions} = useNativeTabBarOptions({shouldShowNativeTabBar, isAccountAvatarShown, dotColors, tabLabels}); // A tab with no bar item draws no icon. const getScreenOptions = ({route}: {route: {name: NativeTabName}}) => route.name === tabWithoutBarItem From 0c0fa300e7b27cdc04003fde9881a4714876b422 Mon Sep 17 00:00:00 2001 From: sumo_slonik Date: Wed, 21 Oct 2026 16:58:24 +0200 Subject: [PATCH 3/6] fix: tapping the tab under native Account leaves Account after rebase on bottom-tabs patch 003 --- patches/react-native-screens/details.md | 6 +- ...+7.19.2+005+hidden-tab-under-tab-bar.patch | 46 ++++++++--- patches/react-navigation/details.md | 6 +- tests/unit/NativeBottomTabViewTest.tsx | 79 +++++++++++++++++-- 4 files changed, 113 insertions(+), 24 deletions(-) diff --git a/patches/react-native-screens/details.md b/patches/react-native-screens/details.md index 7c3a4174dba1..a2281c1241d0 100644 --- a/patches/react-native-screens/details.md +++ b/patches/react-native-screens/details.md @@ -108,6 +108,6 @@ ### [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: TBD -- E/App issue: TBD -- PR introducing patch: TBD +- 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: [#104294](https://github.com/Expensify/App/issues/104294) +- PR introducing patch: [#104491](https://github.com/Expensify/App/pull/104491) 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 index f2a8b76f850b..bc3552e1269c 100644 --- 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 @@ -1,5 +1,5 @@ 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 d2d69fe..ff92664 100644 +index 8f34943..ef22d0e 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({ @@ -20,16 +20,21 @@ index d2d69fe..ff92664 100644 // 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); -@@ -130,7 +129,7 @@ export function NativeBottomTabView({ - console.error(`Received 'tabSelectionRejected' for route that doesn't exist: ${selectedScreenKey}`); - return; +@@ -104,9 +103,10 @@ export function NativeBottomTabView({ + target: route.key + }); } -- if (focusedRouteKey === route.key) { -+ if (nativeSelectedRouteKey === 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 -@@ -215,7 +214,8 @@ export function NativeBottomTabView({ +@@ -219,7 +219,8 @@ export function NativeBottomTabView({ onTabSelected: onTabSelected, onTabSelectionRejected: onTabSelectionRejected, onTabSelectionPrevented: onTabSelectionPrevented, @@ -39,7 +44,7 @@ index d2d69fe..ff92664 100644 colorScheme: dark ? 'dark' : 'light', nativeContainerStyle: { backgroundColor: colors.background -@@ -287,7 +287,7 @@ export function NativeBottomTabView({ +@@ -291,7 +292,7 @@ export function NativeBottomTabView({ focused: false })) : tabBarIcon != null ? getPlatformIcon(tabBarIcon) : undefined; const selectedIcon = typeof tabBarIcon === 'function' ? getPlatformIcon(tabBarIcon({ @@ -48,7 +53,7 @@ index d2d69fe..ff92664 100644 })) : undefined; return /*#__PURE__*/_jsx(Tabs.Screen, { onWillDisappear: () => onTransitionStart({ -@@ -316,8 +316,8 @@ export function NativeBottomTabView({ +@@ -320,8 +321,8 @@ export function NativeBottomTabView({ }, specialEffects: { repeatedTabSelection: { @@ -59,7 +64,7 @@ index d2d69fe..ff92664 100644 } }, android: { -@@ -328,8 +328,8 @@ export function NativeBottomTabView({ +@@ -332,8 +333,8 @@ export function NativeBottomTabView({ tabBarItemRippleColor: currentOptions?.tabBarRippleColor, tabBarItemLabelVisibilityMode: currentOptions?.tabBarLabelVisibilityMode, normal: normalTabItemAppearance, @@ -70,7 +75,7 @@ index d2d69fe..ff92664 100644 tabBarItemActiveIndicatorEnabled: currentOptions?.tabBarActiveIndicatorEnabled, tabBarItemTitleFontFamily: fontFamily, tabBarItemTitleFontWeight: fontWeight, -@@ -375,7 +375,7 @@ export function NativeBottomTabView({ +@@ -379,7 +380,7 @@ export function NativeBottomTabView({ overrideScrollViewContentInsetAdjustmentBehavior }, // Keyed by route key, so a re-keyed route still remounts its content inside the same native tab. @@ -79,7 +84,7 @@ index d2d69fe..ff92664 100644 lazy: lazy, visible: isFocused || isPreloaded, children: /*#__PURE__*/_jsx(ScreenWithHeader, { -@@ -388,10 +388,13 @@ export function NativeBottomTabView({ +@@ -392,10 +393,13 @@ export function NativeBottomTabView({ children: render() }) }) @@ -95,7 +100,7 @@ index d2d69fe..ff92664 100644 if (!isRouteHidden(route) || !visitedHiddenRouteKeysRef.current.has(route.key)) { return null; } -@@ -417,8 +420,8 @@ export function NativeBottomTabView({ +@@ -421,8 +425,8 @@ export function NativeBottomTabView({ }) }) }, route.key); @@ -106,3 +111,18 @@ index d2d69fe..ff92664 100644 } 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 a7d7e4f5d5d0..e30509c304b7 100644 --- a/patches/react-navigation/details.md +++ b/patches/react-navigation/details.md @@ -89,10 +89,10 @@ ### [@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. The hidden routes are drawn inside the first visible tab, which the host selects meanwhile. +- 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: TBD -- PR Introducing Patch: TBD +- E/App issue: [#104294](https://github.com/Expensify/App/issues/104294) +- PR Introducing Patch: [#104491](https://github.com/Expensify/App/pull/104491) - PR Updating Patch: N/A ### Removed patches diff --git a/tests/unit/NativeBottomTabViewTest.tsx b/tests/unit/NativeBottomTabViewTest.tsx index d8e985001132..70647fd5e329 100644 --- a/tests/unit/NativeBottomTabViewTest.tsx +++ b/tests/unit/NativeBottomTabViewTest.tsx @@ -8,9 +8,14 @@ import React, {useEffect} from 'react'; type NativeBottomTabViewProps = Parameters[0]; +type MockTabSelectionEvent = {nativeEvent: {selectedScreenKey: string; provenance: number; actionOrigin: string}}; + type MockTabsHostProps = { navStateRequest: {selectedScreenKey: string}; - onTabSelected: (event: {nativeEvent: {selectedScreenKey: string; provenance: number; actionOrigin: string}}) => void; + onTabSelected: (event: MockTabSelectionEvent) => void; + onTabSelectionRejected: (event: MockTabSelectionEvent) => void; + tabBarHidden: boolean; + tabBarItemSelectionHidden: boolean; children: React.ReactNode; }; @@ -69,17 +74,38 @@ 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: 2, + stale: false, + routeNames: ['Inbox', 'Spend', 'Account'], + routes: [ + {name: 'Inbox', key: 'Inbox-key'}, + {name: 'Spend', key: 'Spend-key'}, + {name: 'Account', key: 'Account-key'}, + ], + history: [ + {type: 'route', key: 'Spend-key'}, + {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 +116,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 +167,47 @@ 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('draws a focused hidden route under a visible tab bar with no item shown as selected', () => { + // Given Account focused, which has no item in the native bar + const navigation = buildNavigation(); + + // When the view renders + render(renderTabView(buildHiddenAccountTabState(), navigation, ['Account'])); + + // Then native selects the first visible tab, which Account is drawn in, rather than the last visited one + expect(mockLatestHostProps?.navStateRequest.selectedScreenKey).toBe('Inbox'); + // And the bar stays visible with no item drawn as selected, so the user can leave Account through it + expect(mockLatestHostProps?.tabBarHidden).toBe(false); + expect(mockLatestHostProps?.tabBarItemSelectionHidden).toBe(true); + }); + + 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'})); + }); + + it('stays on a focused hidden route when native reports the tab it is drawn in on its own', () => { + // Given Account focused, drawn in the natively selected Inbox tab + const navigation = buildNavigation(); + render(renderTabView(buildHiddenAccountTabState(), navigation, ['Account'])); + + // When native reports Inbox without a tap, as Android does when it rejects a re-sent request as repeated (#104294) + act(() => { + mockLatestHostProps?.onTabSelected({nativeEvent: {selectedScreenKey: 'Inbox', provenance: 1, actionOrigin: 'programmatic-native'}}); + mockLatestHostProps?.onTabSelectionRejected({nativeEvent: {selectedScreenKey: 'Inbox', provenance: 2, actionOrigin: 'programmatic-js'}}); + }); + + // Then JS does not navigate, so Account does not bounce back to a tab the user did not choose + expect(navigation.dispatch).not.toHaveBeenCalled(); + }); }); From 5bd033f833e575318f01eb9a54a75b8cb27fe07c Mon Sep 17 00:00:00 2001 From: sumo_slonik Date: Wed, 21 Oct 2026 18:13:01 +0200 Subject: [PATCH 4/6] fix: keep Account's Android back handling and Insights' hidden bar without the beta; trim tests to the Home tap --- .../index.native.ts | 19 ++++++++ src/hooks/useIsSettingsDrawnOverTabs/index.ts | 6 +++ .../useNativeTabNavigator.ts | 5 +- src/pages/settings/InitialSettingsPage.tsx | 40 +++++++++++++++- tests/unit/NativeBottomTabViewTest.tsx | 46 ++----------------- 5 files changed, 72 insertions(+), 44 deletions(-) create mode 100644 src/hooks/useIsSettingsDrawnOverTabs/index.native.ts create mode 100644 src/hooks/useIsSettingsDrawnOverTabs/index.ts diff --git a/src/hooks/useIsSettingsDrawnOverTabs/index.native.ts b/src/hooks/useIsSettingsDrawnOverTabs/index.native.ts new file mode 100644 index 000000000000..18adf3c373f0 --- /dev/null +++ b/src/hooks/useIsSettingsDrawnOverTabs/index.native.ts @@ -0,0 +1,19 @@ +import usePermissions from '@hooks/usePermissions'; +import useResponsiveLayout from '@hooks/useResponsiveLayout'; + +import getTabWithoutBarItem from '@libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/getTabWithoutBarItem'; + +import CONST from '@src/CONST'; +import NAVIGATORS from '@src/NAVIGATORS'; + +/** + * 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(); + const {isBetaEnabled} = usePermissions(); + return shouldUseNarrowLayout && getTabWithoutBarItem(isBetaEnabled(CONST.BETAS.INSIGHTS_PAGE)) === NAVIGATORS.SETTINGS_SPLIT_NAVIGATOR; +} + +export default useIsSettingsDrawnOverTabs; diff --git a/src/hooks/useIsSettingsDrawnOverTabs/index.ts b/src/hooks/useIsSettingsDrawnOverTabs/index.ts new file mode 100644 index 000000000000..7229ecc8f572 --- /dev/null +++ b/src/hooks/useIsSettingsDrawnOverTabs/index.ts @@ -0,0 +1,6 @@ +/** On web the Account tab stays a tab root screen above the JS tab bar, whether or not the bar shows its item. */ +function useIsSettingsDrawnOverTabs() { + return false; +} + +export default useIsSettingsDrawnOverTabs; diff --git a/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/useNativeTabNavigator.ts b/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/useNativeTabNavigator.ts index be5ea6fe2db7..d9d779c1e842 100644 --- a/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/useNativeTabNavigator.ts +++ b/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/useNativeTabNavigator.ts @@ -62,7 +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); - const shouldShowNativeTabBar = shouldUseNarrowLayout && isTabRouteAtRoot(activeTabRoute) && !isBlockingViewVisible; + // 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 57f5de93d4b3..c8d1731971a7 100755 --- a/src/pages/settings/InitialSettingsPage.tsx +++ b/src/pages/settings/InitialSettingsPage.tsx @@ -12,7 +12,9 @@ import Text from '@components/Text'; import type {WithCurrentUserPersonalDetailsProps} from '@components/withCurrentUserPersonalDetails'; import withCurrentUserPersonalDetails from '@components/withCurrentUserPersonalDetails'; +import useAndroidBackButtonHandler from '@hooks/useAndroidBackButtonHandler'; import useHasTabBeenShown from '@hooks/useHasTabBeenShown'; +import useIsSettingsDrawnOverTabs from '@hooks/useIsSettingsDrawnOverTabs'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; import usePrevious from '@hooks/usePrevious'; @@ -23,19 +25,23 @@ import useThemeStyles from '@hooks/useThemeStyles'; import useIsSidebarRouteActive from '@libs/Navigation/helpers/useIsSidebarRouteActive'; import Navigation from '@libs/Navigation/Navigation'; +import type {TabNavigatorParamList} from '@libs/Navigation/types'; import {openInitialSettingsPage} from '@userActions/Wallet'; import CONST from '@src/CONST'; import NAVIGATORS from '@src/NAVIGATORS'; import ONYXKEYS from '@src/ONYXKEYS'; +import ROUTES from '@src/ROUTES'; +import SCREENS from '@src/SCREENS'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; +import type {NativeBottomTabNavigationProp} from '@react-navigation/bottom-tabs/unstable'; import type {ComponentRef} from 'react'; // eslint-disable-next-line no-restricted-imports import type {ScrollView as RNScrollView, ScrollViewProps, StyleProp, ViewStyle} from 'react-native'; -import {findFocusedRoute, useNavigationState, useRoute} from '@react-navigation/native'; +import {CommonActions, findFocusedRoute, useNavigation, useNavigationState, useRoute} from '@react-navigation/native'; import {canSwitchAccountsSelector} from '@selectors/Account'; import React, {useContext, useEffect, useLayoutEffect, useRef} from 'react'; import {View} from 'react-native'; @@ -56,6 +62,38 @@ function InitialSettingsPage({currentUserPersonalDetails}: InitialSettingsPagePr const {isExecuting, singleExecution} = useSingleExecution(); const {translate} = useLocalize(); const focusedRouteName = useNavigationState((state) => findFocusedRoute(state)?.name); + const navigation = useNavigation(); + const isDrawnOverTabs = useIsSettingsDrawnOverTabs(); + + // The tab navigator keeps the full tab history, so going back returns to the tab the user opened Account from. + // With nothing behind it, as after a deep link or a relaunch, the tab history is reset to Home alone so that going + // back does not reopen Account. The tab router drops REPLACE, so `navigate(HOME, {forceReplace: true})` can't do it. + const goBackFromAccount = () => { + if (navigation.canGoBack()) { + navigation.goBack(); + return; + } + + const tabNavigation = navigation.getParent>(); + const tabState = tabNavigation?.getState(); + const homeRoute = tabState?.routes.find((tabRoute) => tabRoute.name === SCREENS.HOME); + if (!tabNavigation || !tabState || !homeRoute) { + Navigation.navigate(ROUTES.HOME); + return; + } + + tabNavigation.dispatch(CommonActions.reset({...tabState, index: tabState.routes.indexOf(homeRoute), history: [{type: 'route', key: homeRoute.key}]})); + }; + + useAndroidBackButtonHandler(() => { + if (!isDrawnOverTabs) { + return false; + } + + goBackFromAccount(); + return true; + }); + const isScreenFocused = useIsSidebarRouteActive(NAVIGATORS.SETTINGS_SPLIT_NAVIGATOR, shouldUseNarrowLayout); const previousUserPersonalDetails = usePrevious(currentUserPersonalDetails); const {accountMenuItemsData, generalMenuItemsData} = useInitialSettingsPageMenuData(currentUserPersonalDetails); diff --git a/tests/unit/NativeBottomTabViewTest.tsx b/tests/unit/NativeBottomTabViewTest.tsx index 70647fd5e329..dfe23ab0bec7 100644 --- a/tests/unit/NativeBottomTabViewTest.tsx +++ b/tests/unit/NativeBottomTabViewTest.tsx @@ -8,14 +8,9 @@ import React, {useEffect} from 'react'; type NativeBottomTabViewProps = Parameters[0]; -type MockTabSelectionEvent = {nativeEvent: {selectedScreenKey: string; provenance: number; actionOrigin: string}}; - type MockTabsHostProps = { navStateRequest: {selectedScreenKey: string}; - onTabSelected: (event: MockTabSelectionEvent) => void; - onTabSelectionRejected: (event: MockTabSelectionEvent) => void; - tabBarHidden: boolean; - tabBarItemSelectionHidden: boolean; + onTabSelected: (event: {nativeEvent: {selectedScreenKey: string; provenance: number; actionOrigin: string}}) => void; children: React.ReactNode; }; @@ -79,18 +74,14 @@ function buildHiddenAccountTabState(): TabNavigationState { return { type: 'tab', key: 'tab', - index: 2, + index: 1, stale: false, - routeNames: ['Inbox', 'Spend', 'Account'], + routeNames: ['Inbox', 'Account'], routes: [ {name: 'Inbox', key: 'Inbox-key'}, - {name: 'Spend', key: 'Spend-key'}, {name: 'Account', key: 'Account-key'}, ], - history: [ - {type: 'route', key: 'Spend-key'}, - {type: 'route', key: 'Account-key'}, - ], + history: [{type: 'route', key: 'Account-key'}], preloadedRouteKeys: [], }; } @@ -168,20 +159,6 @@ describe('NativeBottomTabView', () => { expect(navigation.dispatch).toHaveBeenCalledWith(expect.objectContaining({type: 'NAVIGATE', payload: expect.objectContaining({name: 'Spend'}), target: 'tab'})); }); - it('draws a focused hidden route under a visible tab bar with no item shown as selected', () => { - // Given Account focused, which has no item in the native bar - const navigation = buildNavigation(); - - // When the view renders - render(renderTabView(buildHiddenAccountTabState(), navigation, ['Account'])); - - // Then native selects the first visible tab, which Account is drawn in, rather than the last visited one - expect(mockLatestHostProps?.navStateRequest.selectedScreenKey).toBe('Inbox'); - // And the bar stays visible with no item drawn as selected, so the user can leave Account through it - expect(mockLatestHostProps?.tabBarHidden).toBe(false); - expect(mockLatestHostProps?.tabBarItemSelectionHidden).toBe(true); - }); - 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(); @@ -195,19 +172,4 @@ describe('NativeBottomTabView', () => { // 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'})); }); - - it('stays on a focused hidden route when native reports the tab it is drawn in on its own', () => { - // Given Account focused, drawn in the natively selected Inbox tab - const navigation = buildNavigation(); - render(renderTabView(buildHiddenAccountTabState(), navigation, ['Account'])); - - // When native reports Inbox without a tap, as Android does when it rejects a re-sent request as repeated (#104294) - act(() => { - mockLatestHostProps?.onTabSelected({nativeEvent: {selectedScreenKey: 'Inbox', provenance: 1, actionOrigin: 'programmatic-native'}}); - mockLatestHostProps?.onTabSelectionRejected({nativeEvent: {selectedScreenKey: 'Inbox', provenance: 2, actionOrigin: 'programmatic-js'}}); - }); - - // Then JS does not navigate, so Account does not bounce back to a tab the user did not choose - expect(navigation.dispatch).not.toHaveBeenCalled(); - }); }); From 8be8c5c2f00ec5f543032f55bb861806c88afc95 Mon Sep 17 00:00:00 2001 From: sumo_slonik Date: Wed, 21 Oct 2026 18:37:30 +0200 Subject: [PATCH 5/6] fix: tab picked from native Account shows as selected at once; only Home is drawn unselected, iOS lens restored natively --- ....0+007+tab-bar-item-selection-hidden.patch | 14 +++++++- ...+7.19.2+005+hidden-tab-under-tab-bar.patch | 34 ++++++++++++------- 2 files changed, 35 insertions(+), 13 deletions(-) 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 index 1eabced08ffe..8d1531ee8458 100644 --- 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 @@ -33,7 +33,7 @@ index d76842a..bc62e29 100644 * 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..95cad83 100644 +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 @@ -71,6 +71,18 @@ index 06c1957..95cad83 100644 - (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 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 index bc3552e1269c..ea1e8be53660 100644 --- 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 @@ -1,5 +1,5 @@ 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..ef22d0e 100644 +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({ @@ -44,38 +44,48 @@ index 8f34943..ef22d0e 100644 colorScheme: dark ? 'dark' : 'light', nativeContainerStyle: { backgroundColor: colors.background -@@ -291,7 +292,7 @@ export function NativeBottomTabView({ +@@ -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: !isFocusedRouteHidden ++ focused: !isSelectionHidden })) : undefined; return /*#__PURE__*/_jsx(Tabs.Screen, { onWillDisappear: () => onTransitionStart({ -@@ -320,8 +321,8 @@ export function NativeBottomTabView({ +@@ -320,8 +324,8 @@ export function NativeBottomTabView({ }, specialEffects: { repeatedTabSelection: { - popToRoot: true, - scrollToTop: true -+ popToRoot: !isFocusedRouteHidden, -+ scrollToTop: !isFocusedRouteHidden ++ popToRoot: !isSelectionHidden, ++ scrollToTop: !isSelectionHidden } }, android: { -@@ -332,8 +333,8 @@ export function NativeBottomTabView({ +@@ -332,8 +336,8 @@ export function NativeBottomTabView({ tabBarItemRippleColor: currentOptions?.tabBarRippleColor, tabBarItemLabelVisibilityMode: currentOptions?.tabBarLabelVisibilityMode, normal: normalTabItemAppearance, - selected: selectedTabItemAppearance, - tabBarItemActiveIndicatorColor: activeIndicatorColor, -+ selected: isFocusedRouteHidden ? normalTabItemAppearance : selectedTabItemAppearance, -+ tabBarItemActiveIndicatorColor: isFocusedRouteHidden ? 'transparent' : activeIndicatorColor, ++ selected: isSelectionHidden ? normalTabItemAppearance : selectedTabItemAppearance, ++ tabBarItemActiveIndicatorColor: isSelectionHidden ? 'transparent' : activeIndicatorColor, tabBarItemActiveIndicatorEnabled: currentOptions?.tabBarActiveIndicatorEnabled, tabBarItemTitleFontFamily: fontFamily, tabBarItemTitleFontWeight: fontWeight, -@@ -379,7 +380,7 @@ export function NativeBottomTabView({ +@@ -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. @@ -84,7 +94,7 @@ index 8f34943..ef22d0e 100644 lazy: lazy, visible: isFocused || isPreloaded, children: /*#__PURE__*/_jsx(ScreenWithHeader, { -@@ -392,10 +393,13 @@ export function NativeBottomTabView({ +@@ -392,10 +396,13 @@ export function NativeBottomTabView({ children: render() }) }) @@ -100,7 +110,7 @@ index 8f34943..ef22d0e 100644 if (!isRouteHidden(route) || !visitedHiddenRouteKeysRef.current.has(route.key)) { return null; } -@@ -421,8 +425,8 @@ export function NativeBottomTabView({ +@@ -421,8 +428,8 @@ export function NativeBottomTabView({ }) }) }, route.key); From 88ede4f7d6ca6033ae2fef195a3ff169ea777a27 Mon Sep 17 00:00:00 2001 From: sumo_slonik Date: Wed, 21 Oct 2026 18:38:51 +0200 Subject: [PATCH 6/6] docs: point the Account tab bar patches at the Liquid Glass follow-up tracker --- patches/react-native-screens/details.md | 2 +- patches/react-navigation/details.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/patches/react-native-screens/details.md b/patches/react-native-screens/details.md index a2281c1241d0..5a3591e0662a 100644 --- a/patches/react-native-screens/details.md +++ b/patches/react-native-screens/details.md @@ -109,5 +109,5 @@ - 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: [#104294](https://github.com/Expensify/App/issues/104294) +- 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-navigation/details.md b/patches/react-navigation/details.md index e30509c304b7..c3ebb125e636 100644 --- a/patches/react-navigation/details.md +++ b/patches/react-navigation/details.md @@ -91,7 +91,7 @@ - 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: [#104294](https://github.com/Expensify/App/issues/104294) +- 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