From d7c11c83e9f0dbd576047a15933023999a52e84d Mon Sep 17 00:00:00 2001 From: sumo_slonik Date: Tue, 15 Sep 2026 12:41:18 +0200 Subject: [PATCH 01/71] feat: liquid glass tab bar owned by the tab navigator Single floating capsule, native glass on iOS 26, translucent fallback elsewhere. --- ios/NewExpensify.xcodeproj/project.pbxproj | 2 - ios/Podfile.lock | 20 ++++---- package-lock.json | 12 +++++ package.json | 1 + .../NavigationTabBar/InboxTabButton.tsx | 3 +- .../NavigationTabBar/SearchTabButton.tsx | 3 +- .../NavigationTabBar/TabBarItem.tsx | 19 +++++--- .../NavigationTabBar/WorkspacesTabButton.tsx | 3 +- .../Navigation/NavigationTabBar/index.tsx | 14 +++--- .../BaseNavigationTabBarSurface.tsx | 24 ++++++++++ .../NavigationTabBarSurface/index.ios.tsx | 47 +++++++++++++++++++ .../NavigationTabBarSurface/index.tsx | 3 ++ .../NavigationTabBarSurface/types.ts | 14 ++++++ .../Navigation/TabBarBottomContent/index.tsx | 30 +++++------- .../Tables/WorkspaceListTable/index.tsx | 2 +- .../Navigators/TabNavigatorBar.tsx | 5 +- src/pages/Search/SearchPageNarrow/index.tsx | 4 +- src/pages/home/HomePage.tsx | 4 +- .../inbox/sidebar/NavigationTabBarAvatar.tsx | 15 +++--- src/pages/inbox/sidebar/SidebarLinks.tsx | 8 +++- src/pages/settings/InitialSettingsPage.tsx | 2 +- src/pages/workspace/WorkspaceInitialPage.tsx | 2 +- src/styles/index.ts | 32 +++++++++++-- src/styles/theme/themes/dark.ts | 3 ++ src/styles/theme/themes/light.ts | 3 ++ src/styles/theme/types.ts | 3 ++ src/styles/utils/index.ts | 8 ++-- src/styles/variables.ts | 9 ++++ 28 files changed, 224 insertions(+), 71 deletions(-) create mode 100644 src/components/Navigation/NavigationTabBarSurface/BaseNavigationTabBarSurface.tsx create mode 100644 src/components/Navigation/NavigationTabBarSurface/index.ios.tsx create mode 100644 src/components/Navigation/NavigationTabBarSurface/index.tsx create mode 100644 src/components/Navigation/NavigationTabBarSurface/types.ts diff --git a/ios/NewExpensify.xcodeproj/project.pbxproj b/ios/NewExpensify.xcodeproj/project.pbxproj index 641c3c63d9a9..54548c581a79 100644 --- a/ios/NewExpensify.xcodeproj/project.pbxproj +++ b/ios/NewExpensify.xcodeproj/project.pbxproj @@ -817,7 +817,6 @@ "${PODS_CONFIGURATION_BUILD_DIR}/React-cxxreact/React-cxxreact_privacy.bundle", "${PODS_CONFIGURATION_BUILD_DIR}/React-timing/React-timing_privacy.bundle", "${PODS_CONFIGURATION_BUILD_DIR}/SDWebImage/SDWebImage.bundle", - "${PODS_CONFIGURATION_BUILD_DIR}/Sentry/Sentry.bundle", "${PODS_CONFIGURATION_BUILD_DIR}/TrustKit/TrustKit.bundle", "${PODS_CONFIGURATION_BUILD_DIR}/boost/boost_privacy.bundle", "${PODS_CONFIGURATION_BUILD_DIR}/glog/glog_privacy.bundle", @@ -851,7 +850,6 @@ "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/React-cxxreact_privacy.bundle", "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/React-timing_privacy.bundle", "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/SDWebImage.bundle", - "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/Sentry.bundle", "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/TrustKit.bundle", "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/boost_privacy.bundle", "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/glog_privacy.bundle", diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 0967642080e5..4af082831ca7 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -128,6 +128,8 @@ PODS: - ExpoModulesCore - ExpoFont (57.0.1): - ExpoModulesCore + - ExpoGlassEffect (57.0.3): + - ExpoModulesCore - ExpoImage (57.0.1): - ExpoModulesCore - libavif/libdav1d @@ -4216,7 +4218,7 @@ PODS: - ReactCommon/turbomodule/core - SocketRocket - Yoga - - RNSentry (8.7.0): + - RNSentry (8.25.0): - boost - DoubleConversion - fast_float @@ -4243,7 +4245,6 @@ PODS: - ReactCodegen - ReactCommon/turbomodule/bridging - ReactCommon/turbomodule/core - - Sentry (= 9.8.0) - SocketRocket - Yoga - RNShare (11.0.2): @@ -4431,9 +4432,6 @@ PODS: - SDWebImageWebPCoder (0.14.6): - libwebp (~> 1.0) - SDWebImage/Core (~> 5.17) - - Sentry (9.8.0): - - Sentry/Core (= 9.8.0) - - Sentry/Core (9.8.0) - SocketRocket (0.7.1) - SwiftUIIntrospect (1.3.0) - TrustKit (3.0.7) @@ -4461,6 +4459,7 @@ DEPENDENCIES: - ExpoAudio (from `../node_modules/expo-audio/ios`) - "ExpoDomWebView (from `../node_modules/@expo/dom-webview/ios`)" - ExpoFont (from `../node_modules/expo-font/ios`) + - ExpoGlassEffect (from `../node_modules/expo-glass-effect/ios`) - ExpoImage (from `../node_modules/expo-image/ios`) - ExpoImageManipulator (from `../node_modules/expo-image-manipulator/ios`) - ExpoLocation (from `../node_modules/expo-location/ios`) @@ -4645,7 +4644,6 @@ SPEC REPOS: - SDWebImageAVIFCoder - SDWebImageSVGCoder - SDWebImageWebPCoder - - Sentry - SocketRocket - SwiftUIIntrospect - TrustKit @@ -4675,6 +4673,8 @@ EXTERNAL SOURCES: :path: "../node_modules/@expo/dom-webview/ios" ExpoFont: :path: "../node_modules/expo-font/ios" + ExpoGlassEffect: + :path: "../node_modules/expo-glass-effect/ios" ExpoImage: :path: "../node_modules/expo-image/ios" ExpoImageManipulator: @@ -4971,7 +4971,7 @@ SPEC CHECKSUMS: AirshipServiceExtension: 50d11b2f62c4a490d4e81a1c36f70e2ecb70a27e AppAuth: d4f13a8fe0baf391b2108511793e4b479691fb73 AppLogs: 3bc4e9b141dbf265b9464409caaa40416a9ee0e0 - boost: 7e761d76ca2ce687f7cc98e698152abd03a18f90 + boost: 659a89341ea4ab3df8259733813b52f26d8be9a5 DoubleConversion: cb417026b2400c8f53ae97020b2be961b59470cb EXConstants: fa5647482320b7bde855057f8b86580456d4b9f0 expensify-react-native-background-task: 03c640e1f5649692d058cba48c0a138f024a6dd3 @@ -4981,6 +4981,7 @@ SPEC CHECKSUMS: ExpoAudio: b69f36525132edba8d87a715b3a20c678ef78b30 ExpoDomWebView: beaec034e51bd028428b98bb1f2633ab79c6d095 ExpoFont: 59e1faf66ba9bcd232ae1e2ce58d202a48b6f65e + ExpoGlassEffect: ac454a05346de8610d61dc1324d624893ca22d14 ExpoImage: 4c1e7634db6033185a98959fb028d8d4b1c89695 ExpoImageManipulator: d3c962dabd86b331980f8ec6794c6073b3cb541a ExpoLocation: 187949911b868f72e34a8a6f8f487207ba86388a @@ -5011,7 +5012,7 @@ SPEC CHECKSUMS: GTMAppAuth: f69bd07d68cd3b766125f7e072c45d7340dea0de GTMSessionFetcher: 5aea5ba6bd522a239e236100971f10cb71b96ab6 GzipSwift: 893f3e48e597a1a4f62fafcb6514220fcf8287fa - hermes-engine: 8992dac2fb037a77b783469d993f59f96167417e + hermes-engine: 1637145fb37fdf98c062d0056874f15ddce37e20 libavif: 84bbb62fb232c3018d6f1bab79beea87e35de7b7 libdav1d: 23581a4d8ec811ff171ed5e2e05cd27bad64c39f libwebp: 02b23773aedb6ff1fd38cec7a77b81414c6842a8 @@ -5142,7 +5143,7 @@ SPEC CHECKSUMS: RNReactNativeHapticFeedback: 5f1542065f0b24c9252bd8cf3e83bc9c548182e4 RNReanimated: de3eaf0bd2aec5036554a125eb02c28b59de45ae RNScreens: 2b6107925ee4e14a9b2eb0dfb52fe25223aa64d7 - RNSentry: 67d50593363789905dde212b2fcab30a4f8b1846 + RNSentry: b03603e1f0b5e9840742ee3f72706c3ab836913a RNShare: 1c1fde2c4134b9cf220ffebbd6df9c414036d382 RNSVG: 88b18f5959cffb583527b9611ce17b53e54c1fb6 RNWorklets: 25b8ee140def8e1561209a825b005ee70f3e26f5 @@ -5150,7 +5151,6 @@ SPEC CHECKSUMS: SDWebImageAVIFCoder: afe194a084e851f70228e4be35ef651df0fc5c57 SDWebImageSVGCoder: 15a300a97ec1c8ac958f009c02220ac0402e936c SDWebImageWebPCoder: e38c0a70396191361d60c092933e22c20d5b1380 - Sentry: 88746bf877eff714bc45315a39ad1d1efea2cdda SocketRocket: d4aabe649be1e368d1318fdf28a022d714d65748 SwiftUIIntrospect: fee9aa07293ee280373a591e1824e8ddc869ba5d TrustKit: 1050f3ce0595f8ed775f1237b7f590e3a4f93e91 diff --git a/package-lock.json b/package-lock.json index 83db0be3123d..11c1702f5db9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -72,6 +72,7 @@ "expo-asset": "57.0.7", "expo-audio": "57.0.3", "expo-font": "57.0.1", + "expo-glass-effect": "57.0.3", "expo-image": "57.0.1", "expo-image-manipulator": "57.0.6", "expo-location": "57.0.6", @@ -26150,6 +26151,17 @@ "react-native": "*" } }, + "node_modules/expo-glass-effect": { + "version": "57.0.3", + "resolved": "https://registry.npmjs.org/expo-glass-effect/-/expo-glass-effect-57.0.3.tgz", + "integrity": "sha512-aTh2J5eCxf+rSRMBJTnP8Ib5/k8DYFVDuEX1xqesN9F9aqTXocwMv/aQRVBNUs/iogDWhHIJz2pN97IsrrTcoA==", + "license": "MIT", + "peerDependencies": { + "expo": "*", + "react": "*", + "react-native": "*" + } + }, "node_modules/expo-image": { "version": "57.0.1", "resolved": "https://registry.npmjs.org/expo-image/-/expo-image-57.0.1.tgz", diff --git a/package.json b/package.json index fb86c23b3e47..95abe81f8c8d 100644 --- a/package.json +++ b/package.json @@ -148,6 +148,7 @@ "expo-asset": "57.0.7", "expo-audio": "57.0.3", "expo-font": "57.0.1", + "expo-glass-effect": "57.0.3", "expo-image": "57.0.1", "expo-image-manipulator": "57.0.6", "expo-location": "57.0.6", diff --git a/src/components/Navigation/NavigationTabBar/InboxTabButton.tsx b/src/components/Navigation/NavigationTabBar/InboxTabButton.tsx index b35767100788..057ca46a9816 100644 --- a/src/components/Navigation/NavigationTabBar/InboxTabButton.tsx +++ b/src/components/Navigation/NavigationTabBar/InboxTabButton.tsx @@ -236,7 +236,7 @@ function InboxTabButton({selectedTab, isWideLayout}: InboxTabButtonProps) { accessibilityLabel={accessibilityLabel} accessibilityState={{selected: selectedTab === NAVIGATION_TABS.INBOX}} wrapperStyle={styles.flex1} - style={styles.navigationTabBarItem} + style={[styles.navigationTabBarItem, selectedTab === NAVIGATION_TABS.INBOX && styles.navigationTabBarItemSelected]} sentryLabel={CONST.SENTRY_LABEL.NAVIGATION_TAB_BAR.INBOX} > ); diff --git a/src/components/Navigation/NavigationTabBar/SearchTabButton.tsx b/src/components/Navigation/NavigationTabBar/SearchTabButton.tsx index 72308f304f1f..0d51fb5bbb73 100644 --- a/src/components/Navigation/NavigationTabBar/SearchTabButton.tsx +++ b/src/components/Navigation/NavigationTabBar/SearchTabButton.tsx @@ -84,7 +84,7 @@ function SearchTabButton({selectedTab, isWideLayout}: SearchTabButtonProps) { accessibilityLabel={translate('common.spend')} accessibilityState={searchAccessibilityState} wrapperStyle={styles.flex1} - style={styles.navigationTabBarItem} + style={[styles.navigationTabBarItem, selectedTab === NAVIGATION_TABS.SEARCH && styles.navigationTabBarItemSelected]} sentryLabel={CONST.SENTRY_LABEL.NAVIGATION_TAB_BAR.REPORTS} > ); diff --git a/src/components/Navigation/NavigationTabBar/TabBarItem.tsx b/src/components/Navigation/NavigationTabBar/TabBarItem.tsx index 6d75e37b5ea0..6665ace2a65f 100644 --- a/src/components/Navigation/NavigationTabBar/TabBarItem.tsx +++ b/src/components/Navigation/NavigationTabBar/TabBarItem.tsx @@ -18,6 +18,9 @@ type TabBarItemProps = { isHovered?: boolean; statusIndicatorColor?: string; numberOfLines?: number; + + /** The floating bottom bar has no room for labels, so it renders the icons alone. */ + shouldShowLabel?: boolean; }; function getIconFill(isSelected: boolean, isHovered: boolean, theme: ReturnType) { @@ -30,7 +33,7 @@ function getIconFill(isSelected: boolean, isHovered: boolean, theme: ReturnType< return theme.icon; } -function TabBarItem({icon, label, isSelected, isHovered = false, statusIndicatorColor, numberOfLines = 2}: TabBarItemProps) { +function TabBarItem({icon, label, isSelected, isHovered = false, statusIndicatorColor, numberOfLines = 2, shouldShowLabel = true}: TabBarItemProps) { const theme = useTheme(); const styles = useThemeStyles(); @@ -47,12 +50,14 @@ function TabBarItem({icon, label, isSelected, isHovered = false, statusIndicator )} - - {label} - + {shouldShowLabel && ( + + {label} + + )} ); } diff --git a/src/components/Navigation/NavigationTabBar/WorkspacesTabButton.tsx b/src/components/Navigation/NavigationTabBar/WorkspacesTabButton.tsx index f996c54f0522..d61c5033203f 100644 --- a/src/components/Navigation/NavigationTabBar/WorkspacesTabButton.tsx +++ b/src/components/Navigation/NavigationTabBar/WorkspacesTabButton.tsx @@ -62,7 +62,7 @@ function WorkspacesTabButton({selectedTab, isWideLayout}: WorkspacesTabButtonPro accessibilityLabel={`${translate('common.workspacesTabTitle')}${workspacesTabIndicatorStatus ? `. ${translate('common.yourReviewIsRequired')}` : ''}`} accessibilityState={workspacesAccessibilityState} wrapperStyle={styles.flex1} - style={styles.navigationTabBarItem} + style={[styles.navigationTabBarItem, selectedTab === NAVIGATION_TABS.WORKSPACES && styles.navigationTabBarItemSelected]} sentryLabel={CONST.SENTRY_LABEL.NAVIGATION_TAB_BAR.WORKSPACES} > ); diff --git a/src/components/Navigation/NavigationTabBar/index.tsx b/src/components/Navigation/NavigationTabBar/index.tsx index bf51cac6e9c6..aac5c3a9c23f 100644 --- a/src/components/Navigation/NavigationTabBar/index.tsx +++ b/src/components/Navigation/NavigationTabBar/index.tsx @@ -3,6 +3,7 @@ import FloatingGPSButton from '@components/FloatingGPSButton'; import Hoverable from '@components/Hoverable'; import ImageSVG from '@components/ImageSVG'; import DebugTabView from '@components/Navigation/DebugTabView'; +import NavigationTabBarSurface from '@components/Navigation/NavigationTabBarSurface'; import {PressableWithFeedback} from '@components/Pressable'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; @@ -69,8 +70,8 @@ function NavigationTabBar({selectedTab, shouldShowFloatingButtons = true}: Navig }); }; - // shouldShowFloatingButtons is false for the swipe-back duplicate tab bar rendered via - // bottomContent — gating the debug view on it prevents it from appearing in that copy too. + // The navigator drops shouldShowFloatingButtons while it hides the bar, and the debug view rides along + // so it cannot linger over a screen the bar has already left. const shouldShowDebugTabView = !!isDebugModeEnabled && shouldShowFloatingButtons; if (!shouldUseNarrowLayout) { @@ -153,7 +154,7 @@ function NavigationTabBar({selectedTab, shouldShowFloatingButtons = true}: Navig return ( <> {shouldShowDebugTabView && } - @@ -162,13 +163,14 @@ function NavigationTabBar({selectedTab, shouldShowFloatingButtons = true}: Navig role={CONST.ROLE.TAB} accessibilityLabel={translate('common.home')} wrapperStyle={styles.flex1} - style={styles.navigationTabBarItem} + style={[styles.navigationTabBarItem, selectedTab === NAVIGATION_TABS.HOME && styles.navigationTabBarItemSelected]} sentryLabel={CONST.SENTRY_LABEL.NAVIGATION_TAB_BAR.HOME} > - + {shouldShowFloatingButtons && ( <> diff --git a/src/components/Navigation/NavigationTabBarSurface/BaseNavigationTabBarSurface.tsx b/src/components/Navigation/NavigationTabBarSurface/BaseNavigationTabBarSurface.tsx new file mode 100644 index 000000000000..4b49cd16dd87 --- /dev/null +++ b/src/components/Navigation/NavigationTabBarSurface/BaseNavigationTabBarSurface.tsx @@ -0,0 +1,24 @@ +import useThemeStyles from '@hooks/useThemeStyles'; + +import React from 'react'; +import {View} from 'react-native'; + +import type NavigationTabBarSurfaceProps from './types'; + +/** + * Translucent stand-in for the liquid glass capsule, drawn wherever the native material is unavailable. + */ +function BaseNavigationTabBarSurface({children, style, testID}: NavigationTabBarSurfaceProps) { + const styles = useThemeStyles(); + + return ( + + {children} + + ); +} + +export default BaseNavigationTabBarSurface; diff --git a/src/components/Navigation/NavigationTabBarSurface/index.ios.tsx b/src/components/Navigation/NavigationTabBarSurface/index.ios.tsx new file mode 100644 index 000000000000..bd34679deb00 --- /dev/null +++ b/src/components/Navigation/NavigationTabBarSurface/index.ios.tsx @@ -0,0 +1,47 @@ +import useThemePreference from '@hooks/useThemePreference'; +import useThemeStyles from '@hooks/useThemeStyles'; + +import CONST from '@src/CONST'; + +import {GlassView, isGlassEffectAPIAvailable, isLiquidGlassAvailable} from 'expo-glass-effect'; +import React from 'react'; + +import type NavigationTabBarSurfaceProps from './types'; + +import BaseNavigationTabBarSurface from './BaseNavigationTabBarSurface'; + +const DARK_THEME_PREFERENCES = new Set([CONST.THEME.DARK, CONST.THEME.DARK_CONTRAST]); + +/** + * Liquid glass capsule behind the narrow tab bar. The material arrives with iOS 26, and some of its betas ship + * without the runtime API, so both checks have to pass before the glass view replaces the translucent surface. + */ +function NavigationTabBarSurface({children, style, testID}: NavigationTabBarSurfaceProps) { + const styles = useThemeStyles(); + const themePreference = useThemePreference(); + + if (!isLiquidGlassAvailable() || !isGlassEffectAPIAvailable()) { + return ( + + {children} + + ); + } + + return ( + + {children} + + ); +} + +export default NavigationTabBarSurface; diff --git a/src/components/Navigation/NavigationTabBarSurface/index.tsx b/src/components/Navigation/NavigationTabBarSurface/index.tsx new file mode 100644 index 000000000000..3a0ba046ca38 --- /dev/null +++ b/src/components/Navigation/NavigationTabBarSurface/index.tsx @@ -0,0 +1,3 @@ +import BaseNavigationTabBarSurface from './BaseNavigationTabBarSurface'; + +export default BaseNavigationTabBarSurface; diff --git a/src/components/Navigation/NavigationTabBarSurface/types.ts b/src/components/Navigation/NavigationTabBarSurface/types.ts new file mode 100644 index 000000000000..74276066a1d6 --- /dev/null +++ b/src/components/Navigation/NavigationTabBarSurface/types.ts @@ -0,0 +1,14 @@ +import type {ReactNode} from 'react'; +import type {StyleProp, ViewStyle} from 'react-native'; + +type NavigationTabBarSurfaceProps = { + /** Tab items drawn on top of the surface. */ + children: ReactNode; + + /** Capsule geometry the surface has to take: size, radius and inner padding. */ + style: StyleProp; + + testID?: string; +}; + +export default NavigationTabBarSurfaceProps; diff --git a/src/components/Navigation/TabBarBottomContent/index.tsx b/src/components/Navigation/TabBarBottomContent/index.tsx index a90b227de290..be7d68435fe7 100644 --- a/src/components/Navigation/TabBarBottomContent/index.tsx +++ b/src/components/Navigation/TabBarBottomContent/index.tsx @@ -1,24 +1,16 @@ -import NavigationTabBar from '@components/Navigation/NavigationTabBar'; - -import useResponsiveLayout from '@hooks/useResponsiveLayout'; - -import React from 'react'; +import type {FC} from 'react'; import type TabBarBottomContentProps from './types'; -function TabBarBottomContent({selectedTab}: TabBarBottomContentProps) { - const {shouldUseNarrowLayout} = useResponsiveLayout(); - - if (!shouldUseNarrowLayout) { - return null; - } - - return ( - - ); +/** + * Bottom slot that every tab root screen reserves for the tab bar. The navigator owns the only bar there is, + * so the slot renders nothing: one bar per preloaded screen would stack glass capsules behind the visible one. + * The slot itself stays because ScreenWrapper keys the placement of the bottom-docked offline indicator on it. + */ +function TabBarBottomContent() { + return null; } -export default TabBarBottomContent; +const tabBarBottomContent: FC = TabBarBottomContent; + +export default tabBarBottomContent; diff --git a/src/components/Tables/WorkspaceListTable/index.tsx b/src/components/Tables/WorkspaceListTable/index.tsx index 504682a3e635..a7e5448ff0bc 100644 --- a/src/components/Tables/WorkspaceListTable/index.tsx +++ b/src/components/Tables/WorkspaceListTable/index.tsx @@ -222,7 +222,7 @@ export default function WorkspaceListTable({ref, workspaces, headerComponent, on /> - + ); } diff --git a/src/libs/Navigation/AppNavigator/Navigators/TabNavigatorBar.tsx b/src/libs/Navigation/AppNavigator/Navigators/TabNavigatorBar.tsx index 9b46ffd6e246..058f6f31c033 100644 --- a/src/libs/Navigation/AppNavigator/Navigators/TabNavigatorBar.tsx +++ b/src/libs/Navigation/AppNavigator/Navigators/TabNavigatorBar.tsx @@ -40,7 +40,6 @@ function TabNavigatorBar({state}: Pick) { const selectedTab = ROUTE_TO_NAVIGATION_TAB[activeRoute?.name ?? SCREENS.HOME] ?? NAVIGATION_TABS.HOME; const isAtRoot = isTabRouteAtRoot(activeRoute); // --- Narrow-only animation logic (hooks must run unconditionally per Rules of Hooks) --- - // On native, screens also render the tab bar via bottomContent for swipe-back animations. // Delay showing this navigator's tab bar only when navigating back from a deeper screen // (where the tab bar was hidden). Keep it visible during tab switches so it doesn't flash. // Guard with shouldUseNarrowLayout so prevShouldHide stays false in wide layout, @@ -73,7 +72,9 @@ function TabNavigatorBar({state}: Pick) { // Negative marginTop overlays the tab bar on content (zero flex space) to prevent layout shifts. return ( {shouldShowFloatingButtons && ( <> diff --git a/src/components/Navigation/NavigationTabBarSurface/BaseNavigationTabBarSurface.tsx b/src/components/Navigation/NavigationTabBarSurface/BaseNavigationTabBarSurface.tsx deleted file mode 100644 index 4b49cd16dd87..000000000000 --- a/src/components/Navigation/NavigationTabBarSurface/BaseNavigationTabBarSurface.tsx +++ /dev/null @@ -1,24 +0,0 @@ -import useThemeStyles from '@hooks/useThemeStyles'; - -import React from 'react'; -import {View} from 'react-native'; - -import type NavigationTabBarSurfaceProps from './types'; - -/** - * Translucent stand-in for the liquid glass capsule, drawn wherever the native material is unavailable. - */ -function BaseNavigationTabBarSurface({children, style, testID}: NavigationTabBarSurfaceProps) { - const styles = useThemeStyles(); - - return ( - - {children} - - ); -} - -export default BaseNavigationTabBarSurface; diff --git a/src/components/Navigation/NavigationTabBarSurface/index.ios.tsx b/src/components/Navigation/NavigationTabBarSurface/index.ios.tsx deleted file mode 100644 index bd34679deb00..000000000000 --- a/src/components/Navigation/NavigationTabBarSurface/index.ios.tsx +++ /dev/null @@ -1,47 +0,0 @@ -import useThemePreference from '@hooks/useThemePreference'; -import useThemeStyles from '@hooks/useThemeStyles'; - -import CONST from '@src/CONST'; - -import {GlassView, isGlassEffectAPIAvailable, isLiquidGlassAvailable} from 'expo-glass-effect'; -import React from 'react'; - -import type NavigationTabBarSurfaceProps from './types'; - -import BaseNavigationTabBarSurface from './BaseNavigationTabBarSurface'; - -const DARK_THEME_PREFERENCES = new Set([CONST.THEME.DARK, CONST.THEME.DARK_CONTRAST]); - -/** - * Liquid glass capsule behind the narrow tab bar. The material arrives with iOS 26, and some of its betas ship - * without the runtime API, so both checks have to pass before the glass view replaces the translucent surface. - */ -function NavigationTabBarSurface({children, style, testID}: NavigationTabBarSurfaceProps) { - const styles = useThemeStyles(); - const themePreference = useThemePreference(); - - if (!isLiquidGlassAvailable() || !isGlassEffectAPIAvailable()) { - return ( - - {children} - - ); - } - - return ( - - {children} - - ); -} - -export default NavigationTabBarSurface; diff --git a/src/components/Navigation/NavigationTabBarSurface/index.tsx b/src/components/Navigation/NavigationTabBarSurface/index.tsx deleted file mode 100644 index 3a0ba046ca38..000000000000 --- a/src/components/Navigation/NavigationTabBarSurface/index.tsx +++ /dev/null @@ -1,3 +0,0 @@ -import BaseNavigationTabBarSurface from './BaseNavigationTabBarSurface'; - -export default BaseNavigationTabBarSurface; diff --git a/src/components/Navigation/NavigationTabBarSurface/types.ts b/src/components/Navigation/NavigationTabBarSurface/types.ts deleted file mode 100644 index 74276066a1d6..000000000000 --- a/src/components/Navigation/NavigationTabBarSurface/types.ts +++ /dev/null @@ -1,14 +0,0 @@ -import type {ReactNode} from 'react'; -import type {StyleProp, ViewStyle} from 'react-native'; - -type NavigationTabBarSurfaceProps = { - /** Tab items drawn on top of the surface. */ - children: ReactNode; - - /** Capsule geometry the surface has to take: size, radius and inner padding. */ - style: StyleProp; - - testID?: string; -}; - -export default NavigationTabBarSurfaceProps; diff --git a/src/components/Tables/WorkspaceListTable/index.tsx b/src/components/Tables/WorkspaceListTable/index.tsx index a7e5448ff0bc..c33b533989ab 100644 --- a/src/components/Tables/WorkspaceListTable/index.tsx +++ b/src/components/Tables/WorkspaceListTable/index.tsx @@ -1,6 +1,7 @@ import type {CompareItemsCallback, FilterConfig, IsItemInFilterCallback, IsItemInSearchCallback, TableColumn, TableData, TableHandle} from '@components/Table'; import Table, {composeTableListHeader} from '@components/Table'; +import useFloatingTabBarContentInsetStyle from '@hooks/useFloatingTabBarContentInsetStyle'; import {useMemoizedLazyIllustrations} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; import usePermissions from '@hooks/usePermissions'; @@ -73,6 +74,7 @@ export default function WorkspaceListTable({ref, workspaces, headerComponent, on const {isRestrictedPolicyCreation} = usePreferredPolicy(); const illustrations = useMemoizedLazyIllustrations(['PlanetWithMobileApp']); const {shouldUseNarrowLayout, isMediumScreenWidth} = useResponsiveLayout(); + const floatingTabBarContentInsetStyle = useFloatingTabBarContentInsetStyle(); const shouldUseNarrowTableLayout = shouldUseNarrowLayout || isMediumScreenWidth; const workspaceTableColumns: Array> = [ @@ -222,7 +224,7 @@ export default function WorkspaceListTable({ref, workspaces, headerComponent, on /> - + ); } diff --git a/src/hooks/useFloatingTabBarContentInsetStyle/index.native.ts b/src/hooks/useFloatingTabBarContentInsetStyle/index.native.ts new file mode 100644 index 000000000000..34bcc8a5a9a4 --- /dev/null +++ b/src/hooks/useFloatingTabBarContentInsetStyle/index.native.ts @@ -0,0 +1,8 @@ +import type {StyleProp, ViewStyle} from 'react-native'; + +/** The native tab bar reports its own height through the safe area, so no extra content padding is needed. */ +function useFloatingTabBarContentInsetStyle(): StyleProp { + return undefined; +} + +export default useFloatingTabBarContentInsetStyle; diff --git a/src/hooks/useFloatingTabBarContentInsetStyle/index.ts b/src/hooks/useFloatingTabBarContentInsetStyle/index.ts new file mode 100644 index 000000000000..ce42dd197c1b --- /dev/null +++ b/src/hooks/useFloatingTabBarContentInsetStyle/index.ts @@ -0,0 +1,18 @@ +import useResponsiveLayout from '@hooks/useResponsiveLayout'; +import useThemeStyles from '@hooks/useThemeStyles'; + +import type {StyleProp, ViewStyle} from 'react-native'; + +/** + * Bottom padding a tab root screen's scrollable content needs so its last row settles above the floating tab + * bar instead of under it. Mobile web draws that bar itself; the native platforms get the same room from the + * safe area their own tab bar reports, so there the hook adds nothing. + */ +function useFloatingTabBarContentInsetStyle(): StyleProp { + const styles = useThemeStyles(); + const {shouldUseNarrowLayout} = useResponsiveLayout(); + + return shouldUseNarrowLayout ? styles.floatingTabBarContentInset : undefined; +} + +export default useFloatingTabBarContentInsetStyle; diff --git a/src/libs/Navigation/AppNavigator/Navigators/TAB_SCREENS.native.ts b/src/libs/Navigation/AppNavigator/Navigators/TAB_SCREENS.native.ts new file mode 100644 index 000000000000..bfd8d0f176f5 --- /dev/null +++ b/src/libs/Navigation/AppNavigator/Navigators/TAB_SCREENS.native.ts @@ -0,0 +1,12 @@ +/** + * Ordered list of screen names registered inside TabNavigator. + * This must match the Tab.Screen order in TabNavigator.native.tsx, where the order is also the order the native + * tab bar draws its items in, so Workspaces comes before Account. + * Used by getAdaptedStateFromPath to build complete tab navigator state for deep-links. + */ +import NAVIGATORS from '@src/NAVIGATORS'; +import SCREENS from '@src/SCREENS'; + +const TAB_SCREENS = [SCREENS.HOME, NAVIGATORS.REPORTS_SPLIT_NAVIGATOR, NAVIGATORS.SEARCH_FULLSCREEN_NAVIGATOR, NAVIGATORS.WORKSPACE_NAVIGATOR, NAVIGATORS.SETTINGS_SPLIT_NAVIGATOR] as const; + +export default TAB_SCREENS; diff --git a/src/libs/Navigation/AppNavigator/Navigators/TabNavigator.native.tsx b/src/libs/Navigation/AppNavigator/Navigators/TabNavigator.native.tsx index 8b0b7c7e1856..d760d6b26ffd 100644 --- a/src/libs/Navigation/AppNavigator/Navigators/TabNavigator.native.tsx +++ b/src/libs/Navigation/AppNavigator/Navigators/TabNavigator.native.tsx @@ -1,54 +1,191 @@ +import buildingsIcon from '@assets/images/native-tab-icons/buildings.png'; +import homeIcon from '@assets/images/native-tab-icons/home.png'; +import inboxIcon from '@assets/images/native-tab-icons/inbox.png'; +import profileIcon from '@assets/images/native-tab-icons/profile.png'; +import receiptMultipleIcon from '@assets/images/native-tab-icons/receipt-multiple.png'; + +import FloatingCameraButton from '@components/FloatingCameraButton'; +import FloatingGPSButton from '@components/FloatingGPSButton'; +import {useFullScreenBlockingViewState} from '@components/FullScreenBlockingViewContextProvider'; +import DebugTabView from '@components/Navigation/DebugTabView'; +import NavigationTabBar from '@components/Navigation/NavigationTabBar'; +import NAVIGATION_TABS from '@components/Navigation/NavigationTabBar/NAVIGATION_TABS'; +import ROUTE_TO_NAVIGATION_TAB from '@components/Navigation/NavigationTabBar/ROUTE_TO_NAVIGATION_TAB'; + +import useAccountTabIndicatorStatus from '@hooks/useAccountTabIndicatorStatus'; +import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; +import useLocalize from '@hooks/useLocalize'; +import useOnyx from '@hooks/useOnyx'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; +import {useSidebarOrderedReportsState} from '@hooks/useSidebarOrderedReports'; import useTheme from '@hooks/useTheme'; +import useThemeStyles from '@hooks/useThemeStyles'; +import useWorkspacesTabIndicatorStatus from '@hooks/useWorkspacesTabIndicatorStatus'; import {getPreservedNavigatorState, setPreservedNavigatorState} from '@libs/Navigation/AppNavigator/createSplitNavigator/usePreserveNavigatorState'; -import {bottomTabScreenLayoutWrapper} from '@libs/Navigation/PlatformStackNavigation/ScreenLayout'; +import isTabRouteAtRoot from '@libs/Navigation/helpers/isTabRouteAtRoot'; +import {nativeBottomTabScreenLayoutWrapper} from '@libs/Navigation/PlatformStackNavigation/ScreenLayout'; import type {TabNavigatorParamList} from '@libs/Navigation/types'; +import cancelTabNavigationSpans, {INBOX_TAB_SPAN_IDS, REPORTS_TAB_SPAN_IDS} from '@libs/telemetry/cancelTabNavigationSpans'; +import {getAvatarURL} from '@libs/UserAvatarUtils'; import HomePage from '@pages/home/HomePage'; -import InsightsPage from '@pages/Insights/InsightsPage'; +import NavigationTabBarFloatingActionButton from '@pages/inbox/sidebar/NavigationTabBarFloatingActionButton'; + +import variables from '@styles/variables'; +import CONST from '@src/CONST'; import NAVIGATORS from '@src/NAVIGATORS'; +import ONYXKEYS from '@src/ONYXKEYS'; import SCREENS from '@src/SCREENS'; -/** - * Tab Navigator containing Home, Inbox (Reports), Search, Insights, Settings, and Workspaces pages. - */ -import type {BottomTabBarProps} from '@react-navigation/bottom-tabs'; -import type {NavigationAction, NavigationState, Router, TabNavigationState} from '@react-navigation/native'; +import type {NativeBottomTabIcon, NativeBottomTabNavigatorProps} from '@react-navigation/bottom-tabs/unstable'; +import type {NavigationAction, NavigationState, PartialState, Router, TabNavigationState} from '@react-navigation/native'; -import {createBottomTabNavigator} from '@react-navigation/bottom-tabs'; +import {createNativeBottomTabNavigator} from '@react-navigation/bottom-tabs/unstable'; import {findFocusedRoute, useNavigation, useNavigationState, useRoute} from '@react-navigation/native'; -import React, {useEffect} from 'react'; +import {ClipOp, ImageFormat, Skia} from '@shopify/react-native-skia'; +import React, {useEffect, useState} from 'react'; +import {View} from 'react-native'; import ReportsSplitNavigator from './ReportsSplitNavigator'; import SearchFullscreenNavigator from './SearchFullscreenNavigator'; import SettingsSplitNavigator from './SettingsSplitNavigator'; -import TabNavigatorBar from './TabNavigatorBar'; import WorkspaceNavigator from './WorkspaceNavigator'; -const renderTabBar = ({state}: BottomTabBarProps) => ; +/** + * Tab Navigator backed by the platform's own tab bar: UITabBar on iOS, which brings the liquid glass material + * with it on iOS 26, and a Material BottomNavigationView on Android. Wide layouts keep the JS side bar, since + * neither native bar can be moved to the side of the screen. + */ +const Tab = createNativeBottomTabNavigator(); -const Tab = createBottomTabNavigator(); +/** Template icons, so each platform tints them with the active and inactive colors taken from our theme. */ +const HOME_TAB_ICON = {type: 'image', source: homeIcon} as const satisfies NativeBottomTabIcon; +const INBOX_TAB_ICON = {type: 'image', source: inboxIcon} as const satisfies NativeBottomTabIcon; +const SPEND_TAB_ICON = {type: 'image', source: receiptMultipleIcon} as const satisfies NativeBottomTabIcon; +const WORKSPACES_TAB_ICON = {type: 'image', source: buildingsIcon} as const satisfies NativeBottomTabIcon; +const ACCOUNT_TAB_ICON = {type: 'image', source: profileIcon} as const satisfies NativeBottomTabIcon; /** * Root-level tab screens where the swipe-back gesture should be disabled. * Swiping from these screens would pop the entire TAB_NAVIGATOR, which feels wrong. * WORKSPACE.INITIAL is intentionally excluded — swiping back from it returns to the workspace list. */ -const TAB_ROOT_SCREENS_WITHOUT_GESTURE = new Set([SCREENS.HOME, SCREENS.INBOX, SCREENS.SEARCH.ROOT, SCREENS.INSIGHTS, SCREENS.SETTINGS.ROOT]); +const TAB_ROOT_SCREENS_WITHOUT_GESTURE = new Set([SCREENS.HOME, SCREENS.INBOX, SCREENS.SEARCH.ROOT, SCREENS.SETTINGS.ROOT]); + +type NativeTabLayoutProps = Parameters>[0]; + +/** stale === false distinguishes a fully realized NavigationState from a PartialState. */ +function isRealizedNavigationState(state: NavigationState | PartialState | undefined): state is NavigationState { + return state?.stale === false; +} + +/** + * The account tab shows the user's avatar, and a tab icon has to be a square image, so the avatar is cropped to a + * circle off-screen and handed over as a data URI. + */ +async function createCircularAvatarIcon(uri: string): Promise { + const response = await fetch(uri); + const encodedImage = Skia.Data.fromBytes(new Uint8Array(await response.arrayBuffer())); + const image = Skia.Image.MakeImageFromEncoded(encodedImage); + const iconSize = variables.iconBottomBar * 3; + const surface = Skia.Surface.MakeOffscreen(iconSize, iconSize); + + if (!image || !surface) { + image?.dispose(); + surface?.dispose(); + return undefined; + } + + const sourceSize = Math.min(image.width(), image.height()); + const sourceX = (image.width() - sourceSize) / 2; + const sourceY = (image.height() - sourceSize) / 2; + const circle = Skia.Path.Make(); + circle.addCircle(iconSize / 2, iconSize / 2, iconSize / 2); + + const canvas = surface.getCanvas(); + canvas.clipPath(circle, ClipOp.Intersect, true); + canvas.drawImageRect(image, Skia.XYWHRect(sourceX, sourceY, sourceSize, sourceSize), Skia.XYWHRect(0, 0, iconSize, iconSize), Skia.Paint()); + surface.flush(); + + const snapshot = surface.makeImageSnapshot(); + const base64 = snapshot.encodeToBase64(ImageFormat.PNG, 100); + + circle.dispose(); + image.dispose(); + snapshot.dispose(); + surface.dispose(); + + return `data:image/png;base64,${base64}`; +} + +/** + * Wraps the tab screens so the floating buttons, the debug view and the wide-layout side bar can be drawn over + * them. The native bar is part of the navigator itself, so it is switched off through the active tab's options + * instead of being unmounted. + */ +function NativeTabLayout({children, state, descriptors}: NativeTabLayoutProps) { + const {shouldUseNarrowLayout} = useResponsiveLayout(); + const {isBlockingViewVisible} = useFullScreenBlockingViewState(); + const [isDebugModeEnabled] = useOnyx(ONYXKEYS.IS_DEBUG_MODE_ENABLED); + const styles = useThemeStyles(); + const theme = useTheme(); + const activeRoute = state.routes[state.index]; + const selectedTab = ROUTE_TO_NAVIGATION_TAB[activeRoute?.name ?? SCREENS.HOME] ?? NAVIGATION_TABS.HOME; + const shouldShowNativeTabBar = shouldUseNarrowLayout && isTabRouteAtRoot(activeRoute) && !isBlockingViewVisible; + const activeTabNavigation = activeRoute ? descriptors[activeRoute.key]?.navigation : undefined; + + // setOptions replaces tabBarStyle wholesale, so the background travels with the visibility flag. The + // background only lands on Android and iOS 18 and below; iOS 26 keeps its own glass material. + useEffect(() => { + activeTabNavigation?.setOptions({tabBarStyle: {display: shouldShowNativeTabBar ? 'flex' : 'none', backgroundColor: theme.appBG}}); + }, [activeTabNavigation, shouldShowNativeTabBar, theme.appBG]); + + if (!shouldUseNarrowLayout) { + return ( + + + + + {children} + + ); + } + + return ( + + {children} + {!!isDebugModeEnabled && shouldShowNativeTabBar && } + {shouldShowNativeTabBar && ( + + + + + + + + )} + + ); +} -const TAB_SCREEN_OPTIONS_BASE = { - headerShown: false, - lazy: true, - animation: 'none' as const, - freezeOnBlur: true, - tabBarPosition: 'bottom' as const, -} as const; +const renderNativeTabLayout = (props: NativeTabLayoutProps) => ; function TabNavigator() { const {shouldUseNarrowLayout} = useResponsiveLayout(); + const {translate} = useLocalize(); const theme = useTheme(); + const {chatTabBrickRoad} = useSidebarOrderedReportsState(); + const {indicatorColor: workspacesIndicatorColor, status: workspacesIndicatorStatus} = useWorkspacesTabIndicatorStatus(); + const {indicatorColor: accountIndicatorColor, status: accountIndicatorStatus} = useAccountTabIndicatorStatus(); + const currentUserPersonalDetails = useCurrentUserPersonalDetails(); const navigation = useNavigation(); const parentNavigation = navigation.getParent(); const focusedRouteName = useNavigationState((state) => findFocusedRoute(state)?.name); @@ -56,7 +193,42 @@ function TabNavigator() { // The Tab.Navigator's own state lives at `parentState.routes[i].state`. We can't read it via // `useNavigationState((s) => s)` here because TabNavigator's body runs before // mounts, so the nearest navigation listener context is still the parent stack's. - const tabState = useNavigationState((parentState) => parentState.routes.find((r) => r.key === route.key)?.state as NavigationState | undefined); + const tabState = useNavigationState((parentState) => parentState.routes.find((parentRoute) => parentRoute.key === route.key)?.state); + const activeTabRouteName = isRealizedNavigationState(tabState) ? tabState.routes[tabState.index]?.name : SCREENS.HOME; + const selectedTab = ROUTE_TO_NAVIGATION_TAB[activeTabRouteName ?? SCREENS.HOME] ?? NAVIGATION_TABS.HOME; + + const avatarSource = getAvatarURL({ + avatarSource: currentUserPersonalDetails.avatar, + accountID: currentUserPersonalDetails.accountID, + }); + const avatarURI = typeof avatarSource === 'string' ? avatarSource : undefined; + const [circularAvatar, setCircularAvatar] = useState<{source: string; uri: string}>(); + const circularAvatarURI = circularAvatar && circularAvatar.source === avatarURI ? circularAvatar.uri : undefined; + const accountTabIcon: NativeBottomTabIcon = circularAvatarURI + ? {type: 'image', source: {uri: circularAvatarURI, width: variables.iconBottomBar, height: variables.iconBottomBar, scale: 3}, tinted: false} + : ACCOUNT_TAB_ICON; + + useEffect(() => { + let isActive = true; + if (!avatarURI) { + return () => { + isActive = false; + }; + } + + createCircularAvatarIcon(avatarURI) + .then((result) => { + if (!isActive || !result) { + return; + } + setCircularAvatar({source: avatarURI, uri: result}); + }) + .catch(() => {}); + + return () => { + isActive = false; + }; + }, [avatarURI]); useEffect(() => { if (!shouldUseNarrowLayout || !parentNavigation) { @@ -67,13 +239,24 @@ function TabNavigator() { }, [focusedRouteName, shouldUseNarrowLayout, parentNavigation]); useEffect(() => { - // stale === false distinguishes a fully realized NavigationState from a PartialState. - if (!tabState || tabState.stale !== false) { + if (!isRealizedNavigationState(tabState)) { return; } setPreservedNavigatorState(route.key, tabState); }, [tabState, route.key]); + // Cancel any in-flight tab-navigation span that doesn't match the new focused tab. + // The span for the new tab is started by the tab button before navigation, so we keep it via `except`. + useEffect(() => { + let spans; + if (selectedTab === NAVIGATION_TABS.INBOX) { + spans = INBOX_TAB_SPAN_IDS; + } else if (selectedTab === NAVIGATION_TABS.SEARCH) { + spans = REPORTS_TAB_SPAN_IDS; + } + cancelTabNavigationSpans(spans); + }, [selectedTab]); + // The slicing optimization in useCustomRootStackNavigatorState can unmount and later remount // this TAB_NAVIGATOR. Without restoration it would default to index 0. We restore the saved // state by overriding the bottom-tab router's getInitialState — the same pattern SplitRouter @@ -88,41 +271,62 @@ function TabNavigator() { }); const screenOptions = { - ...TAB_SCREEN_OPTIONS_BASE, - sceneStyle: {flex: 1, backgroundColor: theme.appBG}, + headerShown: false, + lazy: true, + tabBarActiveTintColor: theme.iconMenu, + tabBarInactiveTintColor: theme.icon, + tabBarControllerMode: 'tabBar' as const, + // The bar stays put while the content scrolls, instead of collapsing the way iOS 26 does by default. + tabBarMinimizeBehavior: 'never' as const, }; return ( - ); diff --git a/src/libs/Navigation/AppNavigator/Navigators/TabNavigatorBar.tsx b/src/libs/Navigation/AppNavigator/Navigators/TabNavigatorBar.tsx index 058f6f31c033..b7dba78fe5d6 100644 --- a/src/libs/Navigation/AppNavigator/Navigators/TabNavigatorBar.tsx +++ b/src/libs/Navigation/AppNavigator/Navigators/TabNavigatorBar.tsx @@ -72,9 +72,7 @@ function TabNavigatorBar({state}: Pick) { // Negative marginTop overlays the tab bar on content (zero flex space) to prevent layout shifts. return ( ); @@ -250,17 +287,36 @@ function TabNavigator() { const activeTabRouteName = isRealizedNavigationState(tabState) ? tabState.routes[tabState.index]?.name : SCREENS.HOME; const selectedTab = ROUTE_TO_NAVIGATION_TAB[activeTabRouteName ?? SCREENS.HOME] ?? NAVIGATION_TABS.HOME; - const [tintedIcons, setTintedIcons] = useState<{inactiveColor: string; activeColor: string; icons: Record}>(); - const tintedIconsForTheme = tintedIcons?.inactiveColor === theme.icon && tintedIcons.activeColor === theme.iconMenu ? tintedIcons.icons : undefined; + let inboxDotColor: string | undefined; + if (chatTabBrickRoad) { + inboxDotColor = chatTabBrickRoad === CONST.BRICK_ROAD_INDICATOR_STATUS.INFO ? theme.iconSuccessFill : theme.danger; + } + const workspacesDotColor = workspacesIndicatorStatus ? workspacesIndicatorColor : undefined; + const accountDotColor = accountIndicatorStatus ? accountIndicatorColor : undefined; + const dotColors: Record = { + [NAVIGATORS.REPORTS_SPLIT_NAVIGATOR]: inboxDotColor, + [NAVIGATORS.WORKSPACE_NAVIGATOR]: workspacesDotColor, + [NAVIGATORS.SETTINGS_SPLIT_NAVIGATOR]: accountDotColor, + }; + // The signature covers everything baked into the bitmaps, so a status that comes or goes redraws them. + const iconsSignature = `${theme.icon}|${theme.iconMenu}|${inboxDotColor}|${workspacesDotColor}|${accountDotColor}`; + + const [tintedIcons, setTintedIcons] = useState<{signature: string; icons: Record}>(); + const tintedIconsForTheme = tintedIcons?.signature === iconsSignature ? tintedIcons.icons : undefined; useEffect(() => { let isActive = true; const inactiveColor = theme.icon; const activeColor = theme.iconMenu; + const signature = iconsSignature; Promise.all( TAB_ICONS.map(([name, source]) => - Promise.all([createTintedIcon(source, inactiveColor), createTintedIcon(source, activeColor)]).then(([inactive, active]) => ({name, inactive, active})), + Promise.all([createTabIcon(source, inactiveColor, dotColors[name]), createTabIcon(source, activeColor, dotColors[name])]).then(([inactive, active]) => ({ + name, + inactive, + active, + })), ), ) .then((results) => { @@ -273,14 +329,15 @@ function TabNavigator() { icons[result.name] = {inactive: result.inactive, active: result.active}; } } - setTintedIcons({inactiveColor, activeColor, icons}); + setTintedIcons({signature, icons}); }) .catch(() => {}); return () => { isActive = false; }; - }, [theme.icon, theme.iconMenu]); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [iconsSignature]); /** Falls back to the template icon until both tinted copies are ready, so a tab is never left without one. */ const getTabBarIcon = (name: string, fallbackIcon: NativeBottomTabIcon) => { @@ -296,10 +353,12 @@ function TabNavigator() { accountID: currentUserPersonalDetails.accountID, }); const avatarURI = typeof avatarSource === 'string' ? avatarSource : undefined; - const [circularAvatar, setCircularAvatar] = useState<{source: string; uri: string}>(); - const circularAvatarURI = circularAvatar && circularAvatar.source === avatarURI ? circularAvatar.uri : undefined; + const avatarSignature = `${avatarURI}|${accountDotColor}`; + const [circularAvatar, setCircularAvatar] = useState<{signature: string; uri: string}>(); + const circularAvatarURI = circularAvatar?.signature === avatarSignature ? circularAvatar.uri : undefined; + const accountIconSize = variables.iconBottomBar + variables.nativeTabIconDotRadius * 2; const accountTabIcon: NativeBottomTabIcon = circularAvatarURI - ? {type: 'image', source: {uri: circularAvatarURI, width: variables.iconBottomBar, height: variables.iconBottomBar, scale: 3}, tinted: false} + ? {type: 'image', source: {uri: circularAvatarURI, width: accountIconSize, height: accountIconSize, scale: variables.nativeTabIconScale}, tinted: false} : ACCOUNT_TAB_ICON; useEffect(() => { @@ -310,19 +369,21 @@ function TabNavigator() { }; } - createCircularAvatarIcon(avatarURI) + const signature = avatarSignature; + createCircularAvatarIcon(avatarURI, accountDotColor) .then((result) => { if (!isActive || !result) { return; } - setCircularAvatar({source: avatarURI, uri: result}); + setCircularAvatar({signature, uri: result}); }) .catch(() => {}); return () => { isActive = false; }; - }, [avatarURI]); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [avatarSignature]); useEffect(() => { if (!shouldUseNarrowLayout || !parentNavigation) { @@ -396,8 +457,6 @@ function TabNavigator() { options={{ tabBarLabel: translate('common.inbox'), tabBarIcon: getTabBarIcon(NAVIGATORS.REPORTS_SPLIT_NAVIGATOR, INBOX_TAB_ICON), - tabBarBadge: chatTabBrickRoad ? ' ' : undefined, - tabBarBadgeStyle: {backgroundColor: chatTabBrickRoad === CONST.BRICK_ROAD_INDICATOR_STATUS.INFO ? theme.iconSuccessFill : theme.danger}, }} /> diff --git a/src/styles/variables.ts b/src/styles/variables.ts index 0da088c279fb..7e6a081a81bb 100644 --- a/src/styles/variables.ts +++ b/src/styles/variables.ts @@ -104,6 +104,10 @@ export default { iconSizeMegaLarge: 105, iconSizeMenuItem: 32, iconBottomBar: 24, + /** Radius of the status dot drawn into a native tab bar icon, and the room reserved for it around the glyph. */ + nativeTabIconDotRadius: 4, + /** Pixel density the native tab bar icons are rasterized at. */ + nativeTabIconScale: 3, iconHeader: 48, iconSection: 68, iouAmountTextSize: 40, From 0c8fac937905afd7446856405f1e0582b4ce2876 Mon Sep 17 00:00:00 2001 From: sumo_slonik Date: Wed, 16 Sep 2026 14:41:31 +0200 Subject: [PATCH 10/71] fix: let UIKit animate hiding the native tab bar instead of blinking it --- patches/react-native-screens/details.md | 7 ++++++ ...02+animate-hiding-the-native-tab-bar.patch | 24 +++++++++++++++++++ 2 files changed, 31 insertions(+) create mode 100644 patches/react-native-screens/react-native-screens+4.25.0+002+animate-hiding-the-native-tab-bar.patch diff --git a/patches/react-native-screens/details.md b/patches/react-native-screens/details.md index 27ee4da38a75..f00bd6eb1620 100644 --- a/patches/react-native-screens/details.md +++ b/patches/react-native-screens/details.md @@ -7,3 +7,10 @@ - E/App issue: [#93842](https://github.com/Expensify/App/issues/93842) - PR introducing patch: [#98632](https://github.com/Expensify/App/pull/98632) + +### [react-native-screens+4.25.0+002+animate-hiding-the-native-tab-bar.patch](react-native-screens+4.25.0+002+animate-hiding-the-native-tab-bar.patch) + +- Reason: The App hides the native tab bar on screens that are not a tab root, which React Navigation forwards to RNScreens as `tabBarHidden` (it derives the flag from `tabBarStyle.display === 'none'`). RNScreens calls UIKit's `setTabBarHidden:animated:` with `animated:NO`, so the bar blinks in and out instead of travelling with the screen that hid it. The patch passes `YES` at both call sites, which lets UIKit run its own show/hide animation. The pre-iOS 18 branch is left alone: it assigns `tabBar.hidden` directly and has no animated counterpart. +- Upstream PR/issue: not reported yet. +- E/App issue: n/a — found while building the native tab bar in `TabNavigator.native.tsx`. +- PR introducing patch: n/a diff --git a/patches/react-native-screens/react-native-screens+4.25.0+002+animate-hiding-the-native-tab-bar.patch b/patches/react-native-screens/react-native-screens+4.25.0+002+animate-hiding-the-native-tab-bar.patch new file mode 100644 index 000000000000..039851fb2235 --- /dev/null +++ b/patches/react-native-screens/react-native-screens+4.25.0+002+animate-hiding-the-native-tab-bar.patch @@ -0,0 +1,24 @@ +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 b92035c..f6c85fe 100644 +--- a/node_modules/react-native-screens/ios/tabs/host/RNSTabsHostComponentView.mm ++++ b/node_modules/react-native-screens/ios/tabs/host/RNSTabsHostComponentView.mm +@@ -298,7 +298,8 @@ - (void)updateProps:(const facebook::react::Props::Shared &)props + _tabBarHidden = newComponentProps.tabBarHidden; + #if RNS_IPHONE_OS_VERSION_AVAILABLE(18_0) + if (@available(iOS 18.0, *)) { +- [_controller setTabBarHidden:_tabBarHidden animated:NO]; ++ // Animated so the bar slides out with the screen that hid it instead of blinking away. ++ [_controller setTabBarHidden:_tabBarHidden animated:YES]; + } else + #endif // RNS_IPHONE_OS_VERSION_AVAILABLE(18_0) + { +@@ -488,7 +489,8 @@ - (void)setTabBarHidden:(BOOL)tabBarHidden + _tabBarHidden = tabBarHidden; + #if RNS_IPHONE_OS_VERSION_AVAILABLE(18_0) + if (@available(iOS 18.0, *)) { +- [_controller setTabBarHidden:_tabBarHidden animated:NO]; ++ // Animated so the bar slides out with the screen that hid it instead of blinking away. ++ [_controller setTabBarHidden:_tabBarHidden animated:YES]; + } else + #endif // RNS_IPHONE_OS_VERSION_AVAILABLE(18_0) + { From 9d243e9a4552b307e63bbe8450088cc78f8befaf Mon Sep 17 00:00:00 2001 From: sumo_slonik Date: Wed, 16 Sep 2026 16:10:25 +0200 Subject: [PATCH 11/71] fix: match the floating button fade to the system tab bar animation --- .../AppNavigator/Navigators/TabNavigator.native.tsx | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/libs/Navigation/AppNavigator/Navigators/TabNavigator.native.tsx b/src/libs/Navigation/AppNavigator/Navigators/TabNavigator.native.tsx index 74ec34100ba8..842a7e53aa30 100644 --- a/src/libs/Navigation/AppNavigator/Navigators/TabNavigator.native.tsx +++ b/src/libs/Navigation/AppNavigator/Navigators/TabNavigator.native.tsx @@ -248,10 +248,12 @@ function NativeTabLayout({children, state, descriptors}: NativeTabLayoutProps) { {children} {!!isDebugModeEnabled && shouldShowNativeTabBar && } {shouldShowNativeTabBar && ( - // The buttons belong to the bar, so they fade with it rather than appearing in place. + // The buttons belong to the bar, so they fade with it rather than appearing in place. UIKit animates + // the bar itself over roughly the 0.35s it gives every bar, which the FAB's own timing already + // matches on the way in; it leaves faster so it stops covering the bar that is still sliding out. From 022c63a20052eeeb58076c30c5a9a67eb9670319 Mon Sep 17 00:00:00 2001 From: sumo_slonik Date: Tue, 22 Sep 2026 15:32:56 +0200 Subject: [PATCH 12/71] fix: drop the beta gate the merge left in the inner insights component --- src/pages/Insights/InsightsPage.tsx | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/pages/Insights/InsightsPage.tsx b/src/pages/Insights/InsightsPage.tsx index c6e6503dcfe6..55642e590a60 100644 --- a/src/pages/Insights/InsightsPage.tsx +++ b/src/pages/Insights/InsightsPage.tsx @@ -31,9 +31,7 @@ type InsightsPageProps = BottomTabScreenProps Date: Tue, 22 Sep 2026 15:33:06 +0200 Subject: [PATCH 13/71] fix: draw the Android tab bar like the iOS one Untint icons, label every tab, move visibility to screen options, tear down the color scheme coordinator. --- patches/react-native-screens/details.md | 16 ++++++ ...+4.25.0+003+no-android-tab-icon-tint.patch | 34 ++++++++++++ ...ardown-tabs-color-scheme-coordinator.patch | 14 +++++ .../Navigators/TabNavigator.native.tsx | 55 ++++++++++--------- 4 files changed, 92 insertions(+), 27 deletions(-) create mode 100644 patches/react-native-screens/react-native-screens+4.25.0+003+no-android-tab-icon-tint.patch create mode 100644 patches/react-native-screens/react-native-screens+4.25.0+004+teardown-tabs-color-scheme-coordinator.patch diff --git a/patches/react-native-screens/details.md b/patches/react-native-screens/details.md index f00bd6eb1620..de7bbee3521e 100644 --- a/patches/react-native-screens/details.md +++ b/patches/react-native-screens/details.md @@ -14,3 +14,19 @@ - Upstream PR/issue: not reported yet. - E/App issue: n/a — found while building the native tab bar in `TabNavigator.native.tsx`. - PR introducing patch: n/a + + +### [react-native-screens+4.25.0+003+no-android-tab-icon-tint.patch](react-native-screens+4.25.0+003+no-android-tab-icon-tint.patch) + +- Reason: The App builds every native tab icon off-screen in Skia and hands it over already colored, because iOS 26 ignores the inactive icon color from `UITabBarItemAppearance`. React Navigation marks those icons `tinted: false`, but `getPlatformIcon` in `@react-navigation/bottom-tabs` only honors the flag on iOS: Android always receives the plain `imageSource`, and `TabsAppearanceApplicator` then assigns `bottomNavigationView.itemIconTintList` unconditionally. A `ColorStateList` tint is `SRC_IN`, so it flattens the whole bitmap to one color. On the glyph tabs this is invisible, since Material repaints them in the same color Skia used, but the account tab shows the user's avatar and turns into a solid silhouette, gray when the tab is not selected and green when it is, and the status dots painted into the bitmaps lose their own colors too. The patch drops the icon tint list so Android draws the bitmap as supplied, which is what `tinted: false` already means on iOS. Label colors are untouched and keep coming from `tabBarItemTitleFontColor`. +- Upstream PR/issue: not reported yet. +- E/App issue: n/a, found while building the native tab bar in `TabNavigator.native.tsx`. +- PR introducing patch: n/a + + +### [react-native-screens+4.25.0+004+teardown-tabs-color-scheme-coordinator.patch](react-native-screens+4.25.0+004+teardown-tabs-color-scheme-coordinator.patch) + +- Reason: `TabsContainer.onAttachedToWindow` calls `colorSchemeCoordinator.setup(...)`, and that setup opens with `check(!isSetUp)`. `ColorSchemeCoordinator.teardown()` is the only thing that clears the flag, and it is never called anywhere in the package, so the coordinator stays marked as set up once the container has been attached a single time. The first time the fragment manager re-adds the container's view, `onAttachedToWindow` runs again and the check throws `[RNScreens] ColorSchemeCoordinator's setup method must not be called again without calling teardown() first`, with `ColorSchemeCoordinator.kt:57` under `TabsContainer.kt:270` under `FragmentStateManager.addViewToContainer`. In a debug build that surfaces as a red box over a dead React tree; in release the `IllegalStateException` propagates. The patch calls `teardown()` from `onDetachedFromWindow`, next to the fragment manager teardown that is already there, so attach and detach are symmetric. +- Upstream PR/issue: not reported yet. +- E/App issue: n/a, found while building the native tab bar in `TabNavigator.native.tsx`. +- PR introducing patch: n/a diff --git a/patches/react-native-screens/react-native-screens+4.25.0+003+no-android-tab-icon-tint.patch b/patches/react-native-screens/react-native-screens+4.25.0+003+no-android-tab-icon-tint.patch new file mode 100644 index 000000000000..7e22783f334b --- /dev/null +++ b/patches/react-native-screens/react-native-screens+4.25.0+003+no-android-tab-icon-tint.patch @@ -0,0 +1,34 @@ +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 10cd91b..cef93d9 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 +@@ -63,24 +63,11 @@ internal class TabsAppearanceApplicator( + bottomNavigationView.itemTextColor = ColorStateList(states, fontColors) + + // Icon color +- val iconDisabledColor = +- tabBarAppearance?.disabled?.tabBarItemIconColor +- ?: resolveColorAttr(context, R.attr.colorOnSurfaceVariant) +- +- val iconFocusedColor = +- tabBarAppearance?.focused?.tabBarItemIconColor +- ?: resolveColorAttr(context, R.attr.colorOnSurfaceVariant) +- +- val iconSelectedColor = +- tabBarAppearance?.selected?.tabBarItemIconColor +- ?: resolveColorAttr(context, R.attr.colorOnSecondaryContainer) +- +- val iconNormalColor = +- tabBarAppearance?.normal?.tabBarItemIconColor +- ?: resolveColorAttr(context, R.attr.colorOnSurfaceVariant) +- +- val iconColors = intArrayOf(iconDisabledColor, iconSelectedColor, iconFocusedColor, iconNormalColor) +- bottomNavigationView.itemIconTintList = ColorStateList(states, iconColors) ++ // Every tab icon arrives as a bitmap that already carries its own colors: the recolored glyph, the status ++ // dot painted into it and the user's avatar on the account tab. A tint list paints over all of it with a ++ // single color, so no tint is applied and the bitmap is drawn as supplied, which is how `tinted: false` ++ // already behaves on iOS. ++ bottomNavigationView.itemIconTintList = null + + // LabelVisibilityMode + // From docs: can be one of LABEL_VISIBILITY_AUTO, LABEL_VISIBILITY_SELECTED, LABEL_VISIBILITY_LABELED, or LABEL_VISIBILITY_UNLABELED diff --git a/patches/react-native-screens/react-native-screens+4.25.0+004+teardown-tabs-color-scheme-coordinator.patch b/patches/react-native-screens/react-native-screens+4.25.0+004+teardown-tabs-color-scheme-coordinator.patch new file mode 100644 index 000000000000..71b7b36538fe --- /dev/null +++ b/patches/react-native-screens/react-native-screens+4.25.0+004+teardown-tabs-color-scheme-coordinator.patch @@ -0,0 +1,14 @@ +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 3342c41..2cfa9ad 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 +@@ -275,6 +275,9 @@ class TabsContainer internal constructor( + override fun onDetachedFromWindow() { + super.onDetachedFromWindow() + teardownFragmentManager() ++ // Mirrors the setup() in onAttachedToWindow. Without it the coordinator stays marked as set up, and the ++ // next attach trips its own `check(!isSetUp)`. ++ colorSchemeCoordinator.teardown() + } + + override fun onConfigurationChanged(newConfig: Configuration?) { diff --git a/src/libs/Navigation/AppNavigator/Navigators/TabNavigator.native.tsx b/src/libs/Navigation/AppNavigator/Navigators/TabNavigator.native.tsx index 842a7e53aa30..4cdecf60c4d3 100644 --- a/src/libs/Navigation/AppNavigator/Navigators/TabNavigator.native.tsx +++ b/src/libs/Navigation/AppNavigator/Navigators/TabNavigator.native.tsx @@ -110,7 +110,9 @@ function drawStatusDot(canvas: SkCanvas, canvasWidth: number, scale: number, col * whichever tab is selected — measured to hold for `badgeBackgroundColor` and for `UITabBarItem.badgeColor` alike. * The image handed to an item is honored, though, so the glyph is recolored off-screen and the status dot is drawn * into it. Both selection states go through this, because RNScreens rejects a tab whose icon and selectedIcon - * differ in type. Every icon reserves the same room for the dot so the glyphs stay aligned across tabs. + * differ in type. The canvas is the glyph's own size, so Android's fixed icon slot draws the glyph at full size + * instead of shrinking it to fit reserved room, and the dot overlaps the glyph's top right corner the way the + * indicator on the mobile web bar does. */ async function createTabIcon(source: ImageSourcePropType, color: string, dotColor: string | undefined): Promise { const asset = Image.resolveAssetSource(source); @@ -122,9 +124,8 @@ async function createTabIcon(source: ImageSourcePropType, color: string, dotColo const encodedImage = Skia.Data.fromBytes(new Uint8Array(await response.arrayBuffer())); const image = Skia.Image.MakeImageFromEncoded(encodedImage); const scale = asset.scale ?? 1; - const padding = variables.nativeTabIconDotRadius * 2 * scale; - const canvasWidth = image ? image.width() + padding : 0; - const canvasHeight = image ? image.height() + padding : 0; + const canvasWidth = image?.width() ?? 0; + const canvasHeight = image?.height() ?? 0; const surface = image ? Skia.Surface.MakeOffscreen(canvasWidth, canvasHeight) : null; if (!image || !surface) { @@ -138,8 +139,7 @@ async function createTabIcon(source: ImageSourcePropType, color: string, dotColo paint.setColorFilter(Skia.ColorFilter.MakeBlend(Skia.Color(color), BlendMode.SrcIn)); const canvas = surface.getCanvas(); - // The glyph sits in the bottom left so the reserved room ends up under the dot. - canvas.drawImageRect(image, Skia.XYWHRect(0, 0, image.width(), image.height()), Skia.XYWHRect(0, padding, image.width(), image.height()), paint); + canvas.drawImageRect(image, Skia.XYWHRect(0, 0, image.width(), image.height()), Skia.XYWHRect(0, 0, image.width(), image.height()), paint); if (dotColor) { drawStatusDot(canvas, canvasWidth, scale, dotColor); @@ -159,17 +159,15 @@ async function createTabIcon(source: ImageSourcePropType, color: string, dotColo /** * The account tab shows the user's avatar, and a tab icon has to be a square image, so the avatar is cropped to a - * circle off-screen and handed over as a data URI. It reserves the same room for the status dot as every other tab - * icon, so the avatar lines up with the glyphs next to it. + * circle off-screen and handed over as a data URI. It fills the same canvas as every other tab icon, so the avatar + * is drawn at the size of the glyphs next to it. */ async function createCircularAvatarIcon(uri: string, dotColor: string | undefined): Promise { const response = await fetch(uri); const encodedImage = Skia.Data.fromBytes(new Uint8Array(await response.arrayBuffer())); const image = Skia.Image.MakeImageFromEncoded(encodedImage); const scale = variables.nativeTabIconScale; - const avatarSize = variables.iconBottomBar * scale; - const padding = variables.nativeTabIconDotRadius * 2 * scale; - const canvasSize = avatarSize + padding; + const canvasSize = variables.iconBottomBar * scale; const surface = Skia.Surface.MakeOffscreen(canvasSize, canvasSize); if (!image || !surface) { @@ -182,12 +180,12 @@ async function createCircularAvatarIcon(uri: string, dotColor: string | undefine const sourceX = (image.width() - sourceSize) / 2; const sourceY = (image.height() - sourceSize) / 2; const circle = Skia.Path.Make(); - circle.addCircle(avatarSize / 2, padding + avatarSize / 2, avatarSize / 2); + circle.addCircle(canvasSize / 2, canvasSize / 2, canvasSize / 2); const canvas = surface.getCanvas(); canvas.save(); canvas.clipPath(circle, ClipOp.Intersect, true); - canvas.drawImageRect(image, Skia.XYWHRect(sourceX, sourceY, sourceSize, sourceSize), Skia.XYWHRect(0, padding, avatarSize, avatarSize), Skia.Paint()); + canvas.drawImageRect(image, Skia.XYWHRect(sourceX, sourceY, sourceSize, sourceSize), Skia.XYWHRect(0, 0, canvasSize, canvasSize), Skia.Paint()); canvas.restore(); if (dotColor) { @@ -209,25 +207,17 @@ async function createCircularAvatarIcon(uri: string, dotColor: string | undefine /** * Wraps the tab screens so the floating buttons, the debug view and the wide-layout side bar can be drawn over - * them. The native bar is part of the navigator itself, so it is switched off through the active tab's options - * instead of being unmounted. + * them. The native bar is part of the navigator itself, so it is switched off through `tabBarStyle` in the + * navigator's screen options instead of being unmounted. */ -function NativeTabLayout({children, state, descriptors}: NativeTabLayoutProps) { +function NativeTabLayout({children, state}: NativeTabLayoutProps) { const {shouldUseNarrowLayout} = useResponsiveLayout(); const {isBlockingViewVisible} = useFullScreenBlockingViewState(); const [isDebugModeEnabled] = useOnyx(ONYXKEYS.IS_DEBUG_MODE_ENABLED); const styles = useThemeStyles(); - const theme = useTheme(); const activeRoute = state.routes[state.index]; const selectedTab = ROUTE_TO_NAVIGATION_TAB[activeRoute?.name ?? SCREENS.HOME] ?? NAVIGATION_TABS.HOME; const shouldShowNativeTabBar = shouldUseNarrowLayout && isTabRouteAtRoot(activeRoute) && !isBlockingViewVisible; - const activeTabNavigation = activeRoute ? descriptors[activeRoute.key]?.navigation : undefined; - - // setOptions replaces tabBarStyle wholesale, so the background travels with the visibility flag. The - // background only lands on Android and iOS 18 and below; iOS 26 keeps its own glass material. - useEffect(() => { - activeTabNavigation?.setOptions({tabBarStyle: {display: shouldShowNativeTabBar ? 'flex' : 'none', backgroundColor: theme.appBG}}); - }, [activeTabNavigation, shouldShowNativeTabBar, theme.appBG]); if (!shouldUseNarrowLayout) { return ( @@ -272,6 +262,7 @@ const renderNativeTabLayout = (props: NativeTabLayoutProps) => s)` here because TabNavigator's body runs before // 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 activeTabRouteName = isRealizedNavigationState(tabState) ? tabState.routes[tabState.index]?.name : SCREENS.HOME; + const activeTabRoute = isRealizedNavigationState(tabState) ? tabState.routes[tabState.index] : undefined; + const activeTabRouteName = isRealizedNavigationState(tabState) ? activeTabRoute?.name : SCREENS.HOME; const selectedTab = ROUTE_TO_NAVIGATION_TAB[activeTabRouteName ?? SCREENS.HOME] ?? NAVIGATION_TABS.HOME; + const shouldShowNativeTabBar = shouldUseNarrowLayout && isTabRouteAtRoot(activeTabRoute) && !isBlockingViewVisible; let inboxDotColor: string | undefined; if (chatTabBrickRoad) { @@ -358,9 +351,8 @@ function TabNavigator() { const avatarSignature = `${avatarURI}|${accountDotColor}`; const [circularAvatar, setCircularAvatar] = useState<{signature: string; uri: string}>(); const circularAvatarURI = circularAvatar?.signature === avatarSignature ? circularAvatar.uri : undefined; - const accountIconSize = variables.iconBottomBar + variables.nativeTabIconDotRadius * 2; const accountTabIcon: NativeBottomTabIcon = circularAvatarURI - ? {type: 'image', source: {uri: circularAvatarURI, width: accountIconSize, height: accountIconSize, scale: variables.nativeTabIconScale}, tinted: false} + ? {type: 'image', source: {uri: circularAvatarURI, width: variables.iconBottomBar, height: variables.iconBottomBar, scale: variables.nativeTabIconScale}, tinted: false} : ACCOUNT_TAB_ICON; useEffect(() => { @@ -435,6 +427,15 @@ function TabNavigator() { lazy: false, tabBarActiveTintColor: theme.iconMenu, tabBarInactiveTintColor: theme.icon, + // Every tab shares one style, so the bar reads the current visibility in the same render that changed it. + // Pushed through setOptions instead, each tab kept the value it was left with, and the bar spent the first + // frame after a tab switch in the previous tab's state. The background only lands on Android and iOS 18 and + // below; iOS 26 keeps its own glass material. + tabBarStyle: {display: shouldShowNativeTabBar ? ('flex' as const) : ('none' as const), backgroundColor: theme.appBG}, + // Android defaults to showing the label on the selected tab only once a bar has more than three items, + // which leaves the other tabs centering a lone icon at a different height. Labelling every tab matches + // what iOS draws and keeps all five icons on one baseline. + tabBarLabelVisibilityMode: 'labeled' as const, tabBarControllerMode: 'tabBar' as const, // The bar stays put while the content scrolls, instead of collapsing the way iOS 26 does by default. tabBarMinimizeBehavior: 'none' as const, From e09d3fd7259dc69b86250016f047194af77bfdfa Mon Sep 17 00:00:00 2001 From: sumo_slonik Date: Tue, 22 Sep 2026 16:50:20 +0200 Subject: [PATCH 14/71] fix: restore tab content after an opaque push The tab fragment is removed and never re-added on the pop. Also reset the color scheme coordinator. --- patches/react-native-screens/details.md | 5 +-- ...s+4.25.0+004+tabs-container-reattach.patch | 34 +++++++++++++++++++ ...ardown-tabs-color-scheme-coordinator.patch | 14 -------- 3 files changed, 37 insertions(+), 16 deletions(-) create mode 100644 patches/react-native-screens/react-native-screens+4.25.0+004+tabs-container-reattach.patch delete mode 100644 patches/react-native-screens/react-native-screens+4.25.0+004+teardown-tabs-color-scheme-coordinator.patch diff --git a/patches/react-native-screens/details.md b/patches/react-native-screens/details.md index de7bbee3521e..34e5f510797e 100644 --- a/patches/react-native-screens/details.md +++ b/patches/react-native-screens/details.md @@ -24,9 +24,10 @@ - PR introducing patch: n/a -### [react-native-screens+4.25.0+004+teardown-tabs-color-scheme-coordinator.patch](react-native-screens+4.25.0+004+teardown-tabs-color-scheme-coordinator.patch) +### [react-native-screens+4.25.0+004+tabs-container-reattach.patch](react-native-screens+4.25.0+004+tabs-container-reattach.patch) -- Reason: `TabsContainer.onAttachedToWindow` calls `colorSchemeCoordinator.setup(...)`, and that setup opens with `check(!isSetUp)`. `ColorSchemeCoordinator.teardown()` is the only thing that clears the flag, and it is never called anywhere in the package, so the coordinator stays marked as set up once the container has been attached a single time. The first time the fragment manager re-adds the container's view, `onAttachedToWindow` runs again and the check throws `[RNScreens] ColorSchemeCoordinator's setup method must not be called again without calling teardown() first`, with `ColorSchemeCoordinator.kt:57` under `TabsContainer.kt:270` under `FragmentStateManager.addViewToContainer`. In a debug build that surfaces as a red box over a dead React tree; in release the `IllegalStateException` propagates. The patch calls `teardown()` from `onDetachedFromWindow`, next to the fragment manager teardown that is already there, so attach and detach are symmetric. +- Reason: Pushing any opaque screen onto the root native stack over the tab navigator leaves the tab content permanently blank once that screen is popped. `ScreenStack.kt` computes a null `visibleBottom` when the new top is opaque and then removes every wrapper below it, so the whole `TAB_NAVIGATOR` `ScreenStackFragment` is removed rather than detached, taking its child FragmentManager and the selected tab's `TabsScreenFragment` with it. On the pop, `TabsContainer.onAttachedToWindow` calls `setupFragmentManager()`, which hands back a fresh and empty child FragmentManager, and then `flushPendingUpdates()`, which is guarded by `invalidationFlags.any()`. Nothing invalidated the container, because the JS side sends no new navigation state when the selected tab did not change, so the flush is a no-op and the content FrameLayout stays empty forever. The bar keeps working because it is a plain child View rather than a fragment, and switching tabs repairs it because `onMenuItemSelected` runs a real fragment transaction. Reproduced on an Android emulator from the Spend tab, where a row opens an RHP, and from the Inbox tab, where the report header opens the details RHP: after back the window has zero text nodes and is visually blank. The patch invalidates the container before the flush on re-attach so the selected tab's fragment is added to the new FragmentManager. +- The same re-attach also trips `ColorSchemeCoordinator.setup`, which opens with `check(!isSetUp)`. `teardown()` is the only thing that clears the flag and the package never calls it, and the re-attach does not always come with a matching detach, so the patch calls `teardown()` both from `onDetachedFromWindow` and immediately before `setup()`. Without the second call the pop surfaces `[RNScreens] ColorSchemeCoordinator's setup method must not be called again without calling teardown() first` as a red box in debug, and an `IllegalStateException` in release. - Upstream PR/issue: not reported yet. - E/App issue: n/a, found while building the native tab bar in `TabNavigator.native.tsx`. - PR introducing patch: n/a diff --git a/patches/react-native-screens/react-native-screens+4.25.0+004+tabs-container-reattach.patch b/patches/react-native-screens/react-native-screens+4.25.0+004+tabs-container-reattach.patch new file mode 100644 index 000000000000..9232a3c76fde --- /dev/null +++ b/patches/react-native-screens/react-native-screens+4.25.0+004+tabs-container-reattach.patch @@ -0,0 +1,34 @@ +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 3342c41..ee3829e 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 +@@ -267,6 +267,21 @@ class TabsContainer internal constructor( + setupFragmentManager() + flushPendingUpdates() + ++ // Pushing an opaque screen over the tab host removes this view's own fragment, which destroys the child ++ // FragmentManager that held the selected tab. setupFragmentManager hands back a fresh, empty one and ++ // nothing else re-adds the tab, because navState still names it and so the JS side sends no update. ++ // Without this the content area stays empty until another tab is picked. ++ val selectedFragment = if (navState.isNotEmpty()) getFragmentForScreenKey(navState.selectedScreenKey) else null ++ if (selectedFragment != null && !selectedFragment.isAdded) { ++ requireFragmentManager ++ .createTransactionWithReordering() ++ .add(contentView.id, selectedFragment) ++ .commitNowAllowingStateLoss() ++ } ++ ++ // A re-attach does not always come with a matching detach, and setup() throws on a second call, so the ++ // coordinator is reset first. teardown() does nothing when it was never set up. ++ colorSchemeCoordinator.teardown() + colorSchemeCoordinator.setup(this) { uiNightMode -> + applyDayNightUiMode(uiNightMode) + } +@@ -275,6 +290,7 @@ class TabsContainer internal constructor( + override fun onDetachedFromWindow() { + super.onDetachedFromWindow() + teardownFragmentManager() ++ colorSchemeCoordinator.teardown() + } + + override fun onConfigurationChanged(newConfig: Configuration?) { diff --git a/patches/react-native-screens/react-native-screens+4.25.0+004+teardown-tabs-color-scheme-coordinator.patch b/patches/react-native-screens/react-native-screens+4.25.0+004+teardown-tabs-color-scheme-coordinator.patch deleted file mode 100644 index 71b7b36538fe..000000000000 --- a/patches/react-native-screens/react-native-screens+4.25.0+004+teardown-tabs-color-scheme-coordinator.patch +++ /dev/null @@ -1,14 +0,0 @@ -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 3342c41..2cfa9ad 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 -@@ -275,6 +275,9 @@ class TabsContainer internal constructor( - override fun onDetachedFromWindow() { - super.onDetachedFromWindow() - teardownFragmentManager() -+ // Mirrors the setup() in onAttachedToWindow. Without it the coordinator stays marked as set up, and the -+ // next attach trips its own `check(!isSetUp)`. -+ colorSchemeCoordinator.teardown() - } - - override fun onConfigurationChanged(newConfig: Configuration?) { From 67db700a35f9b60cd9fa60207563546740c5cb0c Mon Sep 17 00:00:00 2001 From: sumo_slonik Date: Tue, 22 Sep 2026 16:50:27 +0200 Subject: [PATCH 15/71] fix: show skeleton rows while Search loads The page skeleton stops at the first cached data and the list rendered a zero-sized view in the gap. --- src/components/Search/index.tsx | 28 ++++++++------------- src/pages/Search/SearchPageNarrow/index.tsx | 7 ++++++ 2 files changed, 18 insertions(+), 17 deletions(-) diff --git a/src/components/Search/index.tsx b/src/components/Search/index.tsx index aea1479aff19..0f5594a1f725 100644 --- a/src/components/Search/index.tsx +++ b/src/components/Search/index.tsx @@ -50,7 +50,6 @@ import { isTransactionGroupListItemType, isTransactionListItemType, isTransactionReportGroupListItemType, - isTransactionSearchType, shouldShowEmptyState, shouldShowYear as shouldShowYearUtil, } from '@libs/SearchUIUtils'; @@ -396,7 +395,7 @@ function Search({ const hasUnresolvedErrors = hasErrors && responseStatusCode === null; const isWaitingForInitialData = !shouldUseLiveData && !isOffline && (!isDataLoaded || isSearchLoadingWithNoResults || hasUnresolvedErrors || isCardFeedsLoading); const shouldShowLoadingState = isDeferringHeavyWork || isWaitingForInitialData; - const shouldShowRowSkeleton = (!skeletonWasDisplayed || shouldShowLoadingState) && showPendingExpensePlaceholder && !hasErrors; + const shouldShowRowSkeleton = !hasErrors && (shouldShowLoadingState || (!skeletonWasDisplayed && showPendingExpensePlaceholder)); const shouldShowLoadingMoreItems = !shouldShowLoadingState && searchResults?.search?.isLoading && searchResults?.search?.offset > 0; @@ -919,6 +918,11 @@ function Search({ // Deferred layout only needs the base work (no scroll handling, no content-ready signal). const onDeferredLayout = onLayoutBase; + const handleSkeletonLayout = useCallback(() => { + onDeferredLayout(); + onSkeletonLayout(); + }, [onDeferredLayout, onSkeletonLayout]); + const onLayout = useCallback(() => { onLayoutBase(); handleSelectionListScroll(stableSortedData, searchListRef.current); @@ -1078,25 +1082,15 @@ function Search({ [clearSelectedTransactions, queryJSON, onSortPressedCallback, navigation], ); - // When heavy work is deferred (e.g. during the RHP dismiss animation after - // submitting an expense), skip the expensive render below. The ancestor - // SearchPage (via SearchPageNarrow / SearchPageWide) renders a SearchStaticList - // overlay that covers this component, so the user sees real-looking content. - // The minimal View fires onLayout to flush the deferred API write and set - // hasHadFirstLayout. - if (isDeferringHeavyWork && searchResults?.data && isTransactionSearchType(type)) { - // Zero-sized View - onLayout still fires on RN, which is all we need here. - return ; - } - - // This is a performance optimization for the submit-expense->search path only. - // The SearchPage skeleton (useSearchLoadingState) doesn't cover this case because - // Search must mount for its onLayout to flush the deferred CreateMoneyRequest API write, which would block the JS thread causing a slowdown on post expense creation navigation + // Stands in for the list while the heavy grouping and sorting work is deferred or the first response + // has not arrived. The page level skeleton (useSearchLoadingState) stops at the first defined `data`, + // so on a warm snapshot this is the only thing between the header and the rows. Its onLayout also + // flushes the deferred API write and sets hasHadFirstLayout. if (shouldShowRowSkeleton) { return ( ); diff --git a/src/pages/Search/SearchPageNarrow/index.tsx b/src/pages/Search/SearchPageNarrow/index.tsx index b4898118cd4b..595f9cd7b742 100644 --- a/src/pages/Search/SearchPageNarrow/index.tsx +++ b/src/pages/Search/SearchPageNarrow/index.tsx @@ -13,6 +13,7 @@ import SearchPageHeaderNarrow from '@components/Search/SearchPageHeader/SearchPa import SearchSelectionFooter from '@components/Search/SearchSelectionFooter'; import SearchWithNavigationDeferredMount from '@components/Search/SearchWithNavigationDeferredMount'; import type {SearchParams, SearchQueryJSON} from '@components/Search/types'; +import SearchRowSkeleton from '@components/Skeletons/SearchRowSkeleton'; import useAndroidBackButtonHandler from '@hooks/useAndroidBackButtonHandler'; import useEndSubmitNavigationSpans from '@hooks/useEndSubmitNavigationSpans'; @@ -327,6 +328,12 @@ function SearchPageNarrow({ hasFilterBars={hasFilterBars} /> )} + {!isInteractive && !searchOverlayContent && ( + + )} {shouldRenderLayoutProbe && } {!!searchOverlayContent && ( Date: Wed, 23 Sep 2026 13:20:30 +0200 Subject: [PATCH 16/71] feat: floating tab bar on Android, painted labels on iOS Android leaves the Material bar for the JS one; iOS paints its labels into the icon bitmaps. --- .../NavigationTabBar/InboxTabButton.tsx | 3 +- .../NavigationTabBar/SearchTabButton.tsx | 3 +- .../NavigationTabBar/TabBarItem.tsx | 25 ++- .../NavigationTabBar/WorkspacesTabButton.tsx | 3 +- .../Navigation/NavigationTabBar/index.tsx | 5 +- .../{index.native.ts => index.ios.ts} | 0 ...B_SCREENS.native.ts => TAB_SCREENS.ios.ts} | 0 ...igator.native.tsx => TabNavigator.ios.tsx} | 157 +++++++++++++----- .../AppNavigator/Navigators/TabNavigator.tsx | 55 +----- .../Navigators/tabScreens.native.tsx | 18 ++ .../AppNavigator/Navigators/tabScreens.tsx | 62 +++++++ .../inbox/sidebar/NavigationTabBarAvatar.tsx | 23 ++- src/styles/index.ts | 13 +- src/styles/theme/themes/dark.ts | 2 +- src/styles/theme/themes/light.ts | 2 +- src/styles/variables.ts | 5 + 16 files changed, 246 insertions(+), 130 deletions(-) rename src/hooks/useFloatingTabBarContentInsetStyle/{index.native.ts => index.ios.ts} (100%) rename src/libs/Navigation/AppNavigator/Navigators/{TAB_SCREENS.native.ts => TAB_SCREENS.ios.ts} (100%) rename src/libs/Navigation/AppNavigator/Navigators/{TabNavigator.native.tsx => TabNavigator.ios.tsx} (75%) create mode 100644 src/libs/Navigation/AppNavigator/Navigators/tabScreens.native.tsx create mode 100644 src/libs/Navigation/AppNavigator/Navigators/tabScreens.tsx diff --git a/src/components/Navigation/NavigationTabBar/InboxTabButton.tsx b/src/components/Navigation/NavigationTabBar/InboxTabButton.tsx index 25595f4b014b..e884e8de7956 100644 --- a/src/components/Navigation/NavigationTabBar/InboxTabButton.tsx +++ b/src/components/Navigation/NavigationTabBar/InboxTabButton.tsx @@ -257,7 +257,7 @@ function InboxTabButton({selectedTab, isWideLayout}: InboxTabButtonProps) { accessibilityLabel={accessibilityLabel} accessibilityState={{selected: selectedTab === NAVIGATION_TABS.INBOX}} wrapperStyle={styles.flex1} - style={[styles.navigationTabBarItem, selectedTab === NAVIGATION_TABS.INBOX && styles.navigationTabBarItemSelected]} + style={styles.navigationTabBarItem} sentryLabel={CONST.SENTRY_LABEL.NAVIGATION_TAB_BAR.INBOX} > ); diff --git a/src/components/Navigation/NavigationTabBar/SearchTabButton.tsx b/src/components/Navigation/NavigationTabBar/SearchTabButton.tsx index 0d51fb5bbb73..72308f304f1f 100644 --- a/src/components/Navigation/NavigationTabBar/SearchTabButton.tsx +++ b/src/components/Navigation/NavigationTabBar/SearchTabButton.tsx @@ -84,7 +84,7 @@ function SearchTabButton({selectedTab, isWideLayout}: SearchTabButtonProps) { accessibilityLabel={translate('common.spend')} accessibilityState={searchAccessibilityState} wrapperStyle={styles.flex1} - style={[styles.navigationTabBarItem, selectedTab === NAVIGATION_TABS.SEARCH && styles.navigationTabBarItemSelected]} + style={styles.navigationTabBarItem} sentryLabel={CONST.SENTRY_LABEL.NAVIGATION_TAB_BAR.REPORTS} > ); diff --git a/src/components/Navigation/NavigationTabBar/TabBarItem.tsx b/src/components/Navigation/NavigationTabBar/TabBarItem.tsx index 6665ace2a65f..8955980f9339 100644 --- a/src/components/Navigation/NavigationTabBar/TabBarItem.tsx +++ b/src/components/Navigation/NavigationTabBar/TabBarItem.tsx @@ -1,6 +1,7 @@ import Icon from '@components/Icon'; import Text from '@components/Text'; +import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -18,9 +19,6 @@ type TabBarItemProps = { isHovered?: boolean; statusIndicatorColor?: string; numberOfLines?: number; - - /** The floating bottom bar has no room for labels, so it renders the icons alone. */ - shouldShowLabel?: boolean; }; function getIconFill(isSelected: boolean, isHovered: boolean, theme: ReturnType) { @@ -33,12 +31,13 @@ function getIconFill(isSelected: boolean, isHovered: boolean, theme: ReturnType< return theme.icon; } -function TabBarItem({icon, label, isSelected, isHovered = false, statusIndicatorColor, numberOfLines = 2, shouldShowLabel = true}: TabBarItemProps) { +function TabBarItem({icon, label, isSelected, isHovered = false, statusIndicatorColor, numberOfLines = 2}: TabBarItemProps) { const theme = useTheme(); const styles = useThemeStyles(); + const {shouldUseNarrowLayout} = useResponsiveLayout(); return ( - <> + )} - {shouldShowLabel && ( - - {label} - - )} - + + {label} + + ); } diff --git a/src/components/Navigation/NavigationTabBar/WorkspacesTabButton.tsx b/src/components/Navigation/NavigationTabBar/WorkspacesTabButton.tsx index d61c5033203f..f996c54f0522 100644 --- a/src/components/Navigation/NavigationTabBar/WorkspacesTabButton.tsx +++ b/src/components/Navigation/NavigationTabBar/WorkspacesTabButton.tsx @@ -62,7 +62,7 @@ function WorkspacesTabButton({selectedTab, isWideLayout}: WorkspacesTabButtonPro accessibilityLabel={`${translate('common.workspacesTabTitle')}${workspacesTabIndicatorStatus ? `. ${translate('common.yourReviewIsRequired')}` : ''}`} accessibilityState={workspacesAccessibilityState} wrapperStyle={styles.flex1} - style={[styles.navigationTabBarItem, selectedTab === NAVIGATION_TABS.WORKSPACES && styles.navigationTabBarItemSelected]} + style={styles.navigationTabBarItem} sentryLabel={CONST.SENTRY_LABEL.NAVIGATION_TAB_BAR.WORKSPACES} > ); diff --git a/src/components/Navigation/NavigationTabBar/index.tsx b/src/components/Navigation/NavigationTabBar/index.tsx index 823af72d6ac7..51892bd4610d 100644 --- a/src/components/Navigation/NavigationTabBar/index.tsx +++ b/src/components/Navigation/NavigationTabBar/index.tsx @@ -162,14 +162,13 @@ function NavigationTabBar({selectedTab, shouldShowFloatingButtons = true}: Navig role={CONST.ROLE.TAB} accessibilityLabel={translate('common.home')} wrapperStyle={styles.flex1} - style={[styles.navigationTabBarItem, selectedTab === NAVIGATION_TABS.HOME && styles.navigationTabBarItemSelected]} + style={styles.navigationTabBarItem} sentryLabel={CONST.SENTRY_LABEL.NAVIGATION_TAB_BAR.HOME} > {!isInsightsTabVisible && ( diff --git a/src/hooks/useFloatingTabBarContentInsetStyle/index.native.ts b/src/hooks/useFloatingTabBarContentInsetStyle/index.ios.ts similarity index 100% rename from src/hooks/useFloatingTabBarContentInsetStyle/index.native.ts rename to src/hooks/useFloatingTabBarContentInsetStyle/index.ios.ts diff --git a/src/libs/Navigation/AppNavigator/Navigators/TAB_SCREENS.native.ts b/src/libs/Navigation/AppNavigator/Navigators/TAB_SCREENS.ios.ts similarity index 100% rename from src/libs/Navigation/AppNavigator/Navigators/TAB_SCREENS.native.ts rename to src/libs/Navigation/AppNavigator/Navigators/TAB_SCREENS.ios.ts diff --git a/src/libs/Navigation/AppNavigator/Navigators/TabNavigator.native.tsx b/src/libs/Navigation/AppNavigator/Navigators/TabNavigator.ios.tsx similarity index 75% rename from src/libs/Navigation/AppNavigator/Navigators/TabNavigator.native.tsx rename to src/libs/Navigation/AppNavigator/Navigators/TabNavigator.ios.tsx index 4cdecf60c4d3..f70d11d6f598 100644 --- a/src/libs/Navigation/AppNavigator/Navigators/TabNavigator.native.tsx +++ b/src/libs/Navigation/AppNavigator/Navigators/TabNavigator.ios.tsx @@ -32,6 +32,7 @@ import {getAvatarURL} from '@libs/UserAvatarUtils'; import HomePage from '@pages/home/HomePage'; import NavigationTabBarFloatingActionButton from '@pages/inbox/sidebar/NavigationTabBarFloatingActionButton'; +import FontUtils from '@styles/utils/FontUtils'; import variables from '@styles/variables'; import CONST from '@src/CONST'; @@ -46,7 +47,7 @@ import type {ImageSourcePropType} from 'react-native'; import {createNativeBottomTabNavigator} from '@react-navigation/bottom-tabs/unstable'; import {findFocusedRoute, useNavigation, useNavigationState, useRoute} from '@react-navigation/native'; -import {BlendMode, ClipOp, ImageFormat, Skia} from '@shopify/react-native-skia'; +import {BlendMode, ClipOp, FontWeight, ImageFormat, Skia} from '@shopify/react-native-skia'; import React, {useEffect, useState} from 'react'; import {Image, View} from 'react-native'; import Animated, {FadeIn, FadeOut} from 'react-native-reanimated'; @@ -96,12 +97,45 @@ function isRealizedNavigationState(state: NavigationState | PartialState { +async function createTabIcon( + source: ImageSourcePropType, + color: string, + dotColor: string | undefined, + label: string, + labelColor: string, + isSelected: boolean, +): Promise { const asset = Image.resolveAssetSource(source); if (!asset?.uri) { return undefined; @@ -124,13 +165,21 @@ async function createTabIcon(source: ImageSourcePropType, color: string, dotColo const encodedImage = Skia.Data.fromBytes(new Uint8Array(await response.arrayBuffer())); const image = Skia.Image.MakeImageFromEncoded(encodedImage); const scale = asset.scale ?? 1; - const canvasWidth = image?.width() ?? 0; - const canvasHeight = image?.height() ?? 0; - const surface = image ? Skia.Surface.MakeOffscreen(canvasWidth, canvasHeight) : null; - if (!image || !surface) { - image?.dispose(); - surface?.dispose(); + if (!image) { + return undefined; + } + + const glyphWidth = image.width(); + const glyphHeight = image.height(); + const gap = variables.nativeTabIconLabelGap * scale; + const labelSize = measureLabel(label, isSelected, scale); + const canvasWidth = Math.ceil(Math.max(glyphWidth, labelSize.width)); + const canvasHeight = Math.ceil(glyphHeight + gap + labelSize.height); + const surface = Skia.Surface.MakeOffscreen(canvasWidth, canvasHeight); + + if (!surface) { + image.dispose(); return undefined; } @@ -139,12 +188,15 @@ async function createTabIcon(source: ImageSourcePropType, color: string, dotColo paint.setColorFilter(Skia.ColorFilter.MakeBlend(Skia.Color(color), BlendMode.SrcIn)); const canvas = surface.getCanvas(); - canvas.drawImageRect(image, Skia.XYWHRect(0, 0, image.width(), image.height()), Skia.XYWHRect(0, 0, image.width(), image.height()), paint); + const glyphLeft = (canvasWidth - glyphWidth) / 2; + canvas.drawImageRect(image, Skia.XYWHRect(0, 0, glyphWidth, glyphHeight), Skia.XYWHRect(glyphLeft, 0, glyphWidth, glyphHeight), paint); if (dotColor) { - drawStatusDot(canvas, canvasWidth, scale, dotColor); + drawStatusDot(canvas, glyphLeft + glyphWidth, scale, dotColor); } + drawLabel(canvas, label, labelColor, isSelected, scale, canvasWidth, glyphHeight + gap); + surface.flush(); const snapshot = surface.makeImageSnapshot(); @@ -162,13 +214,17 @@ async function createTabIcon(source: ImageSourcePropType, color: string, dotColo * circle off-screen and handed over as a data URI. It fills the same canvas as every other tab icon, so the avatar * is drawn at the size of the glyphs next to it. */ -async function createCircularAvatarIcon(uri: string, dotColor: string | undefined): Promise { +async function createCircularAvatarIcon(uri: string, dotColor: string | undefined, label: string, labelColor: string, isSelected: boolean): Promise { const response = await fetch(uri); const encodedImage = Skia.Data.fromBytes(new Uint8Array(await response.arrayBuffer())); const image = Skia.Image.MakeImageFromEncoded(encodedImage); const scale = variables.nativeTabIconScale; - const canvasSize = variables.iconBottomBar * scale; - const surface = Skia.Surface.MakeOffscreen(canvasSize, canvasSize); + const avatarSize = variables.iconBottomBar * scale; + const gap = variables.nativeTabIconLabelGap * scale; + const labelSize = measureLabel(label, isSelected, scale); + const canvasWidth = Math.ceil(Math.max(avatarSize, labelSize.width)); + const canvasHeight = Math.ceil(avatarSize + gap + labelSize.height); + const surface = Skia.Surface.MakeOffscreen(canvasWidth, canvasHeight); if (!image || !surface) { image?.dispose(); @@ -179,19 +235,22 @@ async function createCircularAvatarIcon(uri: string, dotColor: string | undefine const sourceSize = Math.min(image.width(), image.height()); const sourceX = (image.width() - sourceSize) / 2; const sourceY = (image.height() - sourceSize) / 2; + const avatarLeft = (canvasWidth - avatarSize) / 2; const circle = Skia.Path.Make(); - circle.addCircle(canvasSize / 2, canvasSize / 2, canvasSize / 2); + circle.addCircle(avatarLeft + avatarSize / 2, avatarSize / 2, avatarSize / 2); const canvas = surface.getCanvas(); canvas.save(); canvas.clipPath(circle, ClipOp.Intersect, true); - canvas.drawImageRect(image, Skia.XYWHRect(sourceX, sourceY, sourceSize, sourceSize), Skia.XYWHRect(0, 0, canvasSize, canvasSize), Skia.Paint()); + canvas.drawImageRect(image, Skia.XYWHRect(sourceX, sourceY, sourceSize, sourceSize), Skia.XYWHRect(avatarLeft, 0, avatarSize, avatarSize), Skia.Paint()); canvas.restore(); if (dotColor) { - drawStatusDot(canvas, canvasSize, scale, dotColor); + drawStatusDot(canvas, avatarLeft + avatarSize, scale, dotColor); } + drawLabel(canvas, label, labelColor, isSelected, scale, canvasWidth, avatarSize + gap); + surface.flush(); const snapshot = surface.makeImageSnapshot(); @@ -202,7 +261,7 @@ async function createCircularAvatarIcon(uri: string, dotColor: string | undefine snapshot.dispose(); surface.dispose(); - return `data:image/png;base64,${base64}`; + return {uri: `data:image/png;base64,${base64}`, width: canvasWidth / scale, height: canvasHeight / scale}; } /** @@ -293,8 +352,16 @@ function TabNavigator() { [NAVIGATORS.WORKSPACE_NAVIGATOR]: workspacesDotColor, [NAVIGATORS.SETTINGS_SPLIT_NAVIGATOR]: accountDotColor, }; - // The signature covers everything baked into the bitmaps, so a status that comes or goes redraws them. - const iconsSignature = `${theme.icon}|${theme.iconMenu}|${inboxDotColor}|${workspacesDotColor}|${accountDotColor}`; + const tabLabels: Record = { + [SCREENS.HOME]: translate('common.home'), + [NAVIGATORS.REPORTS_SPLIT_NAVIGATOR]: translate('common.inbox'), + [NAVIGATORS.SEARCH_FULLSCREEN_NAVIGATOR]: translate('common.spend'), + [NAVIGATORS.WORKSPACE_NAVIGATOR]: translate('common.workspacesTabTitle'), + [NAVIGATORS.SETTINGS_SPLIT_NAVIGATOR]: translate('initialSettingsPage.account'), + }; + // The signature covers everything baked into the bitmaps, so a status that comes or goes, a theme swap or a + // language change redraws them. + const iconsSignature = [theme.icon, theme.iconMenu, theme.text, theme.textSupporting, inboxDotColor, workspacesDotColor, accountDotColor, ...Object.values(tabLabels)].join('|'); const [tintedIcons, setTintedIcons] = useState<{signature: string; icons: Record}>(); const tintedIconsForTheme = tintedIcons?.signature === iconsSignature ? tintedIcons.icons : undefined; @@ -303,11 +370,18 @@ function TabNavigator() { let isActive = true; const inactiveColor = theme.icon; const activeColor = theme.iconMenu; + // The label colors the bar drew before it became native: the supporting tone when idle, the plain text + // tone when selected. Both come from the theme, so light and dark each get their own pair. + const inactiveLabelColor = theme.textSupporting; + const activeLabelColor = theme.text; const signature = iconsSignature; Promise.all( TAB_ICONS.map(([name, source]) => - Promise.all([createTabIcon(source, inactiveColor, dotColors[name]), createTabIcon(source, activeColor, dotColors[name])]).then(([inactive, active]) => ({ + Promise.all([ + createTabIcon(source, inactiveColor, dotColors[name], tabLabels[name], inactiveLabelColor, false), + createTabIcon(source, activeColor, dotColors[name], tabLabels[name], activeLabelColor, true), + ]).then(([inactive, active]) => ({ name, inactive, active, @@ -349,11 +423,13 @@ function TabNavigator() { }); const avatarURI = typeof avatarSource === 'string' ? avatarSource : undefined; const avatarSignature = `${avatarURI}|${accountDotColor}`; - const [circularAvatar, setCircularAvatar] = useState<{signature: string; uri: string}>(); - const circularAvatarURI = circularAvatar?.signature === avatarSignature ? circularAvatar.uri : undefined; - const accountTabIcon: NativeBottomTabIcon = circularAvatarURI - ? {type: 'image', source: {uri: circularAvatarURI, width: variables.iconBottomBar, height: variables.iconBottomBar, scale: variables.nativeTabIconScale}, tinted: false} - : ACCOUNT_TAB_ICON; + const [circularAvatar, setCircularAvatar] = useState<{signature: string; active: AvatarTabIcon; inactive: AvatarTabIcon}>(); + const avatarPair = circularAvatar?.signature === avatarSignature ? circularAvatar : undefined; + const toAvatarIcon = (icon: AvatarTabIcon): NativeBottomTabIcon => ({ + type: 'image', + source: {uri: icon.uri, width: icon.width, height: icon.height, scale: variables.nativeTabIconScale}, + tinted: false, + }); useEffect(() => { let isActive = true; @@ -364,12 +440,13 @@ function TabNavigator() { } const signature = avatarSignature; - createCircularAvatarIcon(avatarURI, accountDotColor) - .then((result) => { - if (!isActive || !result) { + const label = tabLabels[NAVIGATORS.SETTINGS_SPLIT_NAVIGATOR]; + Promise.all([createCircularAvatarIcon(avatarURI, accountDotColor, label, theme.textSupporting, false), createCircularAvatarIcon(avatarURI, accountDotColor, label, theme.text, true)]) + .then(([inactive, active]) => { + if (!isActive || !inactive || !active) { return; } - setCircularAvatar({signature, uri: result}); + setCircularAvatar({signature, active, inactive}); }) .catch(() => {}); @@ -377,7 +454,7 @@ function TabNavigator() { isActive = false; }; // eslint-disable-next-line react-hooks/exhaustive-deps - }, [avatarSignature]); + }, [avatarSignature, iconsSignature]); useEffect(() => { if (!shouldUseNarrowLayout || !parentNavigation) { @@ -452,26 +529,26 @@ function TabNavigator() { @@ -479,8 +556,10 @@ function TabNavigator() { name={NAVIGATORS.SETTINGS_SPLIT_NAVIGATOR} component={SettingsSplitNavigator} options={{ - tabBarLabel: translate('initialSettingsPage.account'), - tabBarIcon: circularAvatarURI ? accountTabIcon : getTabBarIcon(NAVIGATORS.SETTINGS_SPLIT_NAVIGATOR, accountTabIcon), + tabBarLabel: '', + tabBarIcon: avatarPair + ? ({focused}: {focused: boolean}) => toAvatarIcon(focused ? avatarPair.active : avatarPair.inactive) + : getTabBarIcon(NAVIGATORS.SETTINGS_SPLIT_NAVIGATOR, ACCOUNT_TAB_ICON), }} /> diff --git a/src/libs/Navigation/AppNavigator/Navigators/TabNavigator.tsx b/src/libs/Navigation/AppNavigator/Navigators/TabNavigator.tsx index d77a12be149d..0e535d21c788 100644 --- a/src/libs/Navigation/AppNavigator/Navigators/TabNavigator.tsx +++ b/src/libs/Navigation/AppNavigator/Navigators/TabNavigator.tsx @@ -1,14 +1,9 @@ -import ActivityIndicator from '@components/ActivityIndicator'; - import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useTheme from '@hooks/useTheme'; -import useThemeStyles from '@hooks/useThemeStyles'; import {bottomTabScreenLayoutWrapper} from '@libs/Navigation/PlatformStackNavigation/ScreenLayout'; import type {TabNavigatorParamList} from '@libs/Navigation/types'; -import {getSpan} from '@libs/telemetry/activeSpans'; -import CONST from '@src/CONST'; import NAVIGATORS from '@src/NAVIGATORS'; import SCREENS from '@src/SCREENS'; @@ -19,58 +14,12 @@ import type {BottomTabBarProps} from '@react-navigation/bottom-tabs'; import {createBottomTabNavigator} from '@react-navigation/bottom-tabs'; import {findFocusedRoute, useNavigation, useNavigationState} from '@react-navigation/native'; -import React, {lazy, Suspense, useEffect} from 'react'; -import {View} from 'react-native'; +import React, {useEffect} from 'react'; // Do not lazy load Search navigator for performance reasons import SearchFullscreenNavigator from './SearchFullscreenNavigator'; import TabNavigatorBar from './TabNavigatorBar'; - -const LazyHomePage = lazy(() => import('@pages/home/HomePage')); -const LazyReportsSplitNavigator = lazy(() => import('./ReportsSplitNavigator')); -const LazySettingsSplitNavigator = lazy(() => import('./SettingsSplitNavigator')); -const LazyWorkspaceNavigator = lazy(() => import('./WorkspaceNavigator')); -const LazyInsightsPage = lazy(() => import('@pages/Insights/InsightsPage')); - -type LazyFallbackProps = { - /** Sentry span to tag when this fallback renders. */ - tabSpanName?: string; -}; - -function LazyFallback({tabSpanName}: LazyFallbackProps) { - const styles = useThemeStyles(); - - // Lets Sentry split slow tab navigations into "lazy chunk fetch" vs "screen render" buckets. - useEffect(() => { - if (!tabSpanName) { - return; - } - getSpan(tabSpanName)?.setAttribute(CONST.TELEMETRY.ATTRIBUTE_LAZY_TAB_FALLBACK_SHOWN, true); - }, [tabSpanName]); - - return ( - - - - ); -} - -function withSuspense

>(LazyComponent: React.LazyExoticComponent>, tabSpanName?: string) { - function SuspenseWrapper(props: P) { - return ( - }> - - - ); - } - return SuspenseWrapper; -} - -const HomePageScreen = withSuspense(LazyHomePage); -const ReportsSplitNavigatorScreen = withSuspense(LazyReportsSplitNavigator, CONST.TELEMETRY.SPAN_NAVIGATE_TO_INBOX_TAB); -const SettingsSplitNavigatorScreen = withSuspense(LazySettingsSplitNavigator); -const WorkspaceNavigatorScreen = withSuspense(LazyWorkspaceNavigator); -const InsightsPageScreen = withSuspense(LazyInsightsPage); +import {HomePageScreen, InsightsPageScreen, ReportsSplitNavigatorScreen, SettingsSplitNavigatorScreen, WorkspaceNavigatorScreen} from './tabScreens'; const renderTabBar = ({state}: BottomTabBarProps) => ; diff --git a/src/libs/Navigation/AppNavigator/Navigators/tabScreens.native.tsx b/src/libs/Navigation/AppNavigator/Navigators/tabScreens.native.tsx new file mode 100644 index 000000000000..eb463eb261e7 --- /dev/null +++ b/src/libs/Navigation/AppNavigator/Navigators/tabScreens.native.tsx @@ -0,0 +1,18 @@ +import HomePage from '@pages/home/HomePage'; +import InsightsPage from '@pages/Insights/InsightsPage'; + +import ReportsSplitNavigator from './ReportsSplitNavigator'; +import SettingsSplitNavigator from './SettingsSplitNavigator'; +import WorkspaceNavigator from './WorkspaceNavigator'; + +/** + * The tab screens handed over as they are. Code splitting buys nothing here, the whole bundle is already on the + * device, and going through `lazy` would put a spinner in front of a tab the first time it is opened. + */ +const HomePageScreen = HomePage; +const ReportsSplitNavigatorScreen = ReportsSplitNavigator; +const SettingsSplitNavigatorScreen = SettingsSplitNavigator; +const WorkspaceNavigatorScreen = WorkspaceNavigator; +const InsightsPageScreen = InsightsPage; + +export {HomePageScreen, ReportsSplitNavigatorScreen, SettingsSplitNavigatorScreen, WorkspaceNavigatorScreen, InsightsPageScreen}; diff --git a/src/libs/Navigation/AppNavigator/Navigators/tabScreens.tsx b/src/libs/Navigation/AppNavigator/Navigators/tabScreens.tsx new file mode 100644 index 000000000000..bcc19d174e6a --- /dev/null +++ b/src/libs/Navigation/AppNavigator/Navigators/tabScreens.tsx @@ -0,0 +1,62 @@ +import ActivityIndicator from '@components/ActivityIndicator'; + +import useThemeStyles from '@hooks/useThemeStyles'; + +import {getSpan} from '@libs/telemetry/activeSpans'; + +import CONST from '@src/CONST'; + +import React, {lazy, Suspense, useEffect} from 'react'; +import {View} from 'react-native'; + +/** + * The tab screens, code split so a tab's chunk is fetched the first time it is opened. Native has no chunks to + * fetch, so it takes the sibling module that hands the components over directly and never shows the fallback. + */ +const LazyHomePage = lazy(() => import('@pages/home/HomePage')); +const LazyReportsSplitNavigator = lazy(() => import('./ReportsSplitNavigator')); +const LazySettingsSplitNavigator = lazy(() => import('./SettingsSplitNavigator')); +const LazyWorkspaceNavigator = lazy(() => import('./WorkspaceNavigator')); +const LazyInsightsPage = lazy(() => import('@pages/Insights/InsightsPage')); + +type LazyFallbackProps = { + /** Sentry span to tag when this fallback renders. */ + tabSpanName?: string; +}; + +function LazyFallback({tabSpanName}: LazyFallbackProps) { + const styles = useThemeStyles(); + + // Lets Sentry split slow tab navigations into "lazy chunk fetch" vs "screen render" buckets. + useEffect(() => { + if (!tabSpanName) { + return; + } + getSpan(tabSpanName)?.setAttribute(CONST.TELEMETRY.ATTRIBUTE_LAZY_TAB_FALLBACK_SHOWN, true); + }, [tabSpanName]); + + return ( + + + + ); +} + +function withSuspense

>(LazyComponent: React.LazyExoticComponent>, tabSpanName?: string) { + function SuspenseWrapper(props: P) { + return ( + }> + + + ); + } + return SuspenseWrapper; +} + +const HomePageScreen = withSuspense(LazyHomePage); +const ReportsSplitNavigatorScreen = withSuspense(LazyReportsSplitNavigator, CONST.TELEMETRY.SPAN_NAVIGATE_TO_INBOX_TAB); +const SettingsSplitNavigatorScreen = withSuspense(LazySettingsSplitNavigator); +const WorkspaceNavigatorScreen = withSuspense(LazyWorkspaceNavigator); +const InsightsPageScreen = withSuspense(LazyInsightsPage); + +export {HomePageScreen, ReportsSplitNavigatorScreen, SettingsSplitNavigatorScreen, WorkspaceNavigatorScreen, InsightsPageScreen}; diff --git a/src/pages/inbox/sidebar/NavigationTabBarAvatar.tsx b/src/pages/inbox/sidebar/NavigationTabBarAvatar.tsx index 1c24fc717298..e994fcbfa44c 100644 --- a/src/pages/inbox/sidebar/NavigationTabBarAvatar.tsx +++ b/src/pages/inbox/sidebar/NavigationTabBarAvatar.tsx @@ -44,23 +44,22 @@ function NavigationTabBarAvatar({onPress, isSelected = false, style}: Navigation sentryLabel={CONST.SENTRY_LABEL.NAVIGATION_TAB_BAR.ACCOUNT} > {({hovered}) => ( - <> + - {/* The floating bottom bar has no room for labels, so it renders the avatar alone. */} - {!shouldUseNarrowLayout && ( - - {translate('initialSettingsPage.account')} - - )} - + + {translate('initialSettingsPage.account')} + + )} ); diff --git a/src/styles/index.ts b/src/styles/index.ts index c6cba1767ed4..ee636f4cc117 100644 --- a/src/styles/index.ts +++ b/src/styles/index.ts @@ -751,9 +751,21 @@ const staticStyles = (theme: ThemeColors) => borderColor: theme.floatingTabBarBorder, }, + // Wraps a tab's icon and label so the selected pill can be as wide as they are rather than as wide as + // the tab's share of the bar. A long label then pushes the pill past its tab instead of spilling out of it. + navigationTabBarItemContent: { + height: '100%', + justifyContent: 'center', + alignItems: 'center', + }, + navigationTabBarItemSelected: { backgroundColor: theme.floatingTabBarSelectedBG, borderRadius: variables.componentBorderRadiusCircle, + // The padding widens the pill while the matching negative margin keeps it from taking that width + // away from the label, so the pill reaches past its tab instead of squeezing what it wraps. + paddingHorizontal: variables.floatingTabBarSelectedOverhang, + marginHorizontal: -variables.floatingTabBarSelectedOverhang, }, // Ends a tab root screen's scrollable content above the floating bar, so its last row stays reachable @@ -767,7 +779,6 @@ const staticStyles = (theme: ThemeColors) => display: 'flex', justifyContent: 'center', alignItems: 'center', - paddingHorizontal: 4, }, // Zero-height strip anchored above the native tab bar, so the floating buttons can be positioned diff --git a/src/styles/theme/themes/dark.ts b/src/styles/theme/themes/dark.ts index 92aaafef4ea5..0cbe9f264b74 100644 --- a/src/styles/theme/themes/dark.ts +++ b/src/styles/theme/themes/dark.ts @@ -58,7 +58,7 @@ const darkTheme = { signInSidebar: colors.green800, sidebar: colors.productDark100, sidebarHover: colors.productDark300, - floatingTabBarBG: 'rgba(6,27,9,0.65)', + floatingTabBarBG: 'rgba(6,27,9,0.88)', floatingTabBarBorder: colors.productDark400, floatingTabBarSelectedBG: 'rgba(26,61,50,0.50)', heading: colors.productDark900, diff --git a/src/styles/theme/themes/light.ts b/src/styles/theme/themes/light.ts index acdd7a244db9..269b935f4ad0 100644 --- a/src/styles/theme/themes/light.ts +++ b/src/styles/theme/themes/light.ts @@ -58,7 +58,7 @@ const lightTheme = { signInSidebar: colors.green800, sidebar: colors.productLight100, sidebarHover: colors.productLight300, - floatingTabBarBG: 'rgba(252,251,249,0.65)', + floatingTabBarBG: 'rgba(252,251,249,0.88)', floatingTabBarBorder: colors.productLight400, floatingTabBarSelectedBG: 'rgba(230,225,218,0.50)', heading: colors.productLight900, diff --git a/src/styles/variables.ts b/src/styles/variables.ts index 844c3f315fd5..5224493122f5 100644 --- a/src/styles/variables.ts +++ b/src/styles/variables.ts @@ -36,6 +36,9 @@ export default { bottomTabHeight: 72, floatingTabBarHeight, floatingTabBarHorizontalInset: 16, + // How far the selected pill reaches past the icon and label. Measured off the iOS glass bubble, where the + // account tab's 121px label sits in a 222px bubble, so 17pt a side. + floatingTabBarSelectedOverhang: 17, floatingTabBarBottomInset, // Vertical space the floating bar takes over the content it hides, so scrollable content can end above it. floatingTabBarContentInset: floatingTabBarHeight + floatingTabBarBottomInset, @@ -109,6 +112,8 @@ export default { iconBottomBar: 24, /** Radius of the status dot drawn into a native tab bar icon, and the room reserved for it around the glyph. */ nativeTabIconDotRadius: 4, + // Gap between the glyph and the label baked under it, matching the spacing the JS bar uses. + nativeTabIconLabelGap: 6, /** Pixel density the native tab bar icons are rasterized at. */ nativeTabIconScale: 3, iconHeader: 48, From 713268a248c5b66864e3625112e2cf44611dec5f Mon Sep 17 00:00:00 2001 From: sumo_slonik Date: Wed, 23 Sep 2026 14:32:51 +0200 Subject: [PATCH 17/71] fix: remove dead tab bar footer slot, android patches, style duplicates Restore Android tab state, gate Insights on registered tabs, sum LHN inset. --- package-lock.json | 1 - package.json | 1 - patches/react-native-screens/details.md | 18 +--------- ...+4.25.0+003+no-android-tab-icon-tint.patch | 34 ------------------- ...s+4.25.0+004+tabs-container-reattach.patch | 34 ------------------- patches/react-navigation/details.md | 10 +++--- .../NavigationTabBar/TabBarItem.tsx | 2 +- .../Navigation/NavigationTabBar/index.tsx | 6 +++- .../Navigation/TabBarBottomContent/index.tsx | 16 --------- .../Navigation/TabBarBottomContent/types.ts | 9 ----- src/components/WorkspaceListLayout.tsx | 4 --- .../index.ts | 4 +-- .../Navigators/TAB_SCREENS.ios.ts | 2 +- .../Navigators/TabNavigator.ios.tsx | 17 +++------- .../AppNavigator/Navigators/TabNavigator.tsx | 32 ++++++++++++++++- src/pages/Insights/InsightsDashboard.tsx | 3 -- src/pages/Search/SearchPageNarrow/index.tsx | 6 ---- src/pages/domain/DomainInitialPage.tsx | 4 --- src/pages/home/HomePage.tsx | 4 --- src/pages/inbox/sidebar/BaseSidebarScreen.tsx | 4 --- .../inbox/sidebar/NavigationTabBarAvatar.tsx | 2 +- src/pages/inbox/sidebar/SidebarLinks.tsx | 10 ++++-- src/pages/settings/InitialSettingsPage.tsx | 5 --- src/pages/workspace/WorkspaceInitialPage.tsx | 4 --- src/styles/index.ts | 12 ++----- src/styles/theme/themes/dark.ts | 1 - src/styles/theme/themes/light.ts | 1 - src/styles/theme/types.ts | 1 - tests/ui/BaseSidebarScreenTest.tsx | 1 - tests/ui/InitialSettingsPageTest.tsx | 8 ----- tests/ui/WorkspaceInitialPageTest.tsx | 1 - 31 files changed, 61 insertions(+), 196 deletions(-) delete mode 100644 patches/react-native-screens/react-native-screens+4.25.0+003+no-android-tab-icon-tint.patch delete mode 100644 patches/react-native-screens/react-native-screens+4.25.0+004+tabs-container-reattach.patch delete mode 100644 src/components/Navigation/TabBarBottomContent/index.tsx delete mode 100644 src/components/Navigation/TabBarBottomContent/types.ts diff --git a/package-lock.json b/package-lock.json index e905a6b83c31..1e05d3c26ea8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -47,7 +47,6 @@ "@react-native-picker/picker": "2.11.3", "@react-navigation/bottom-tabs": "7.16.2", "@react-navigation/core": "7.21.12", - "@react-navigation/elements": "2.9.19", "@react-navigation/material-top-tabs": "7.4.19", "@react-navigation/native": "7.2.6", "@react-navigation/native-stack": "7.14.5", diff --git a/package.json b/package.json index 418dce1ac839..3ef520d45993 100644 --- a/package.json +++ b/package.json @@ -123,7 +123,6 @@ "@react-native-picker/picker": "2.11.3", "@react-navigation/bottom-tabs": "7.16.2", "@react-navigation/core": "7.21.12", - "@react-navigation/elements": "2.9.19", "@react-navigation/material-top-tabs": "7.4.19", "@react-navigation/native": "7.2.6", "@react-navigation/native-stack": "7.14.5", diff --git a/patches/react-native-screens/details.md b/patches/react-native-screens/details.md index 34e5f510797e..3900d5eaec4a 100644 --- a/patches/react-native-screens/details.md +++ b/patches/react-native-screens/details.md @@ -12,22 +12,6 @@ - Reason: The App hides the native tab bar on screens that are not a tab root, which React Navigation forwards to RNScreens as `tabBarHidden` (it derives the flag from `tabBarStyle.display === 'none'`). RNScreens calls UIKit's `setTabBarHidden:animated:` with `animated:NO`, so the bar blinks in and out instead of travelling with the screen that hid it. The patch passes `YES` at both call sites, which lets UIKit run its own show/hide animation. The pre-iOS 18 branch is left alone: it assigns `tabBar.hidden` directly and has no animated counterpart. - Upstream PR/issue: not reported yet. -- E/App issue: n/a — found while building the native tab bar in `TabNavigator.native.tsx`. +- E/App issue: n/a — found while building the native tab bar in `TabNavigator.ios.tsx`. - PR introducing patch: n/a - -### [react-native-screens+4.25.0+003+no-android-tab-icon-tint.patch](react-native-screens+4.25.0+003+no-android-tab-icon-tint.patch) - -- Reason: The App builds every native tab icon off-screen in Skia and hands it over already colored, because iOS 26 ignores the inactive icon color from `UITabBarItemAppearance`. React Navigation marks those icons `tinted: false`, but `getPlatformIcon` in `@react-navigation/bottom-tabs` only honors the flag on iOS: Android always receives the plain `imageSource`, and `TabsAppearanceApplicator` then assigns `bottomNavigationView.itemIconTintList` unconditionally. A `ColorStateList` tint is `SRC_IN`, so it flattens the whole bitmap to one color. On the glyph tabs this is invisible, since Material repaints them in the same color Skia used, but the account tab shows the user's avatar and turns into a solid silhouette, gray when the tab is not selected and green when it is, and the status dots painted into the bitmaps lose their own colors too. The patch drops the icon tint list so Android draws the bitmap as supplied, which is what `tinted: false` already means on iOS. Label colors are untouched and keep coming from `tabBarItemTitleFontColor`. -- Upstream PR/issue: not reported yet. -- E/App issue: n/a, found while building the native tab bar in `TabNavigator.native.tsx`. -- PR introducing patch: n/a - - -### [react-native-screens+4.25.0+004+tabs-container-reattach.patch](react-native-screens+4.25.0+004+tabs-container-reattach.patch) - -- Reason: Pushing any opaque screen onto the root native stack over the tab navigator leaves the tab content permanently blank once that screen is popped. `ScreenStack.kt` computes a null `visibleBottom` when the new top is opaque and then removes every wrapper below it, so the whole `TAB_NAVIGATOR` `ScreenStackFragment` is removed rather than detached, taking its child FragmentManager and the selected tab's `TabsScreenFragment` with it. On the pop, `TabsContainer.onAttachedToWindow` calls `setupFragmentManager()`, which hands back a fresh and empty child FragmentManager, and then `flushPendingUpdates()`, which is guarded by `invalidationFlags.any()`. Nothing invalidated the container, because the JS side sends no new navigation state when the selected tab did not change, so the flush is a no-op and the content FrameLayout stays empty forever. The bar keeps working because it is a plain child View rather than a fragment, and switching tabs repairs it because `onMenuItemSelected` runs a real fragment transaction. Reproduced on an Android emulator from the Spend tab, where a row opens an RHP, and from the Inbox tab, where the report header opens the details RHP: after back the window has zero text nodes and is visually blank. The patch invalidates the container before the flush on re-attach so the selected tab's fragment is added to the new FragmentManager. -- The same re-attach also trips `ColorSchemeCoordinator.setup`, which opens with `check(!isSetUp)`. `teardown()` is the only thing that clears the flag and the package never calls it, and the re-attach does not always come with a matching detach, so the patch calls `teardown()` both from `onDetachedFromWindow` and immediately before `setup()`. Without the second call the pop surfaces `[RNScreens] ColorSchemeCoordinator's setup method must not be called again without calling teardown() first` as a red box in debug, and an `IllegalStateException` in release. -- Upstream PR/issue: not reported yet. -- E/App issue: n/a, found while building the native tab bar in `TabNavigator.native.tsx`. -- PR introducing patch: n/a diff --git a/patches/react-native-screens/react-native-screens+4.25.0+003+no-android-tab-icon-tint.patch b/patches/react-native-screens/react-native-screens+4.25.0+003+no-android-tab-icon-tint.patch deleted file mode 100644 index 7e22783f334b..000000000000 --- a/patches/react-native-screens/react-native-screens+4.25.0+003+no-android-tab-icon-tint.patch +++ /dev/null @@ -1,34 +0,0 @@ -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 10cd91b..cef93d9 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 -@@ -63,24 +63,11 @@ internal class TabsAppearanceApplicator( - bottomNavigationView.itemTextColor = ColorStateList(states, fontColors) - - // Icon color -- val iconDisabledColor = -- tabBarAppearance?.disabled?.tabBarItemIconColor -- ?: resolveColorAttr(context, R.attr.colorOnSurfaceVariant) -- -- val iconFocusedColor = -- tabBarAppearance?.focused?.tabBarItemIconColor -- ?: resolveColorAttr(context, R.attr.colorOnSurfaceVariant) -- -- val iconSelectedColor = -- tabBarAppearance?.selected?.tabBarItemIconColor -- ?: resolveColorAttr(context, R.attr.colorOnSecondaryContainer) -- -- val iconNormalColor = -- tabBarAppearance?.normal?.tabBarItemIconColor -- ?: resolveColorAttr(context, R.attr.colorOnSurfaceVariant) -- -- val iconColors = intArrayOf(iconDisabledColor, iconSelectedColor, iconFocusedColor, iconNormalColor) -- bottomNavigationView.itemIconTintList = ColorStateList(states, iconColors) -+ // Every tab icon arrives as a bitmap that already carries its own colors: the recolored glyph, the status -+ // dot painted into it and the user's avatar on the account tab. A tint list paints over all of it with a -+ // single color, so no tint is applied and the bitmap is drawn as supplied, which is how `tinted: false` -+ // already behaves on iOS. -+ bottomNavigationView.itemIconTintList = null - - // LabelVisibilityMode - // From docs: can be one of LABEL_VISIBILITY_AUTO, LABEL_VISIBILITY_SELECTED, LABEL_VISIBILITY_LABELED, or LABEL_VISIBILITY_UNLABELED diff --git a/patches/react-native-screens/react-native-screens+4.25.0+004+tabs-container-reattach.patch b/patches/react-native-screens/react-native-screens+4.25.0+004+tabs-container-reattach.patch deleted file mode 100644 index 9232a3c76fde..000000000000 --- a/patches/react-native-screens/react-native-screens+4.25.0+004+tabs-container-reattach.patch +++ /dev/null @@ -1,34 +0,0 @@ -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 3342c41..ee3829e 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 -@@ -267,6 +267,21 @@ class TabsContainer internal constructor( - setupFragmentManager() - flushPendingUpdates() - -+ // Pushing an opaque screen over the tab host removes this view's own fragment, which destroys the child -+ // FragmentManager that held the selected tab. setupFragmentManager hands back a fresh, empty one and -+ // nothing else re-adds the tab, because navState still names it and so the JS side sends no update. -+ // Without this the content area stays empty until another tab is picked. -+ val selectedFragment = if (navState.isNotEmpty()) getFragmentForScreenKey(navState.selectedScreenKey) else null -+ if (selectedFragment != null && !selectedFragment.isAdded) { -+ requireFragmentManager -+ .createTransactionWithReordering() -+ .add(contentView.id, selectedFragment) -+ .commitNowAllowingStateLoss() -+ } -+ -+ // A re-attach does not always come with a matching detach, and setup() throws on a second call, so the -+ // coordinator is reset first. teardown() does nothing when it was never set up. -+ colorSchemeCoordinator.teardown() - colorSchemeCoordinator.setup(this) { uiNightMode -> - applyDayNightUiMode(uiNightMode) - } -@@ -275,6 +290,7 @@ class TabsContainer internal constructor( - override fun onDetachedFromWindow() { - super.onDetachedFromWindow() - teardownFragmentManager() -+ colorSchemeCoordinator.teardown() - } - - override fun onConfigurationChanged(newConfig: Configuration?) { diff --git a/patches/react-navigation/details.md b/patches/react-navigation/details.md index 7e5f8c7caa01..ff4aae0c72e9 100644 --- a/patches/react-navigation/details.md +++ b/patches/react-navigation/details.md @@ -1,12 +1,12 @@ # `@react-navigation` patches ### @react-navigation+package-name+7+fix-failing-jest-by-disabling-esmodule.patch -#### [@react-navigation+bottom-tabs+7.15.5+001+fix-failing-jest-by-disabling-esmodule.patch](@react-navigation+bottom-tabs+7.15.5+001+fix-failing-jest-by-disabling-esmodule.patch) +#### [@react-navigation+bottom-tabs+7.16.2+001+fix-failing-jest-by-disabling-esmodule.patch](@react-navigation+bottom-tabs+7.16.2+001+fix-failing-jest-by-disabling-esmodule.patch) #### [@react-navigation+core+7.21.12+001+fix-failing-jest-by-disabling-esmodule.patch](@react-navigation+core+7.21.12+001+fix-failing-jest-by-disabling-esmodule.patch) -#### [@react-navigation+elements+2.9.14+001+fix-failing-jest-by-disabling-esmodule.patch](@react-navigation+elements+2.9.14+001+fix-failing-jest-by-disabling-esmodule.patch) +#### [@react-navigation+elements+2.9.19+001+fix-failing-jest-by-disabling-esmodule.patch](@react-navigation+elements+2.9.19+001+fix-failing-jest-by-disabling-esmodule.patch) #### [@react-navigation+material-top-tabs+7.4.19+001+fix-failing-jest-by-disabling-esmodule.patch](@react-navigation+material-top-tabs+7.4.19+001+fix-failing-jest-by-disabling-esmodule.patch) #### [@react-navigation+native-stack+7.14.5+002+fix-failing-jest-by-disabling-esmodule.patch](@react-navigation+native-stack+7.14.5+002+fix-failing-jest-by-disabling-esmodule.patch) -#### [@react-navigation+native+7.1.33+002+fix-failing-jest-by-disabling-esmodule.patch](@react-navigation+native+7.1.33+002+fix-failing-jest-by-disabling-esmodule.patch) +#### [@react-navigation+native+7.2.6+002+fix-failing-jest-by-disabling-esmodule.patch](@react-navigation+native+7.2.6+002+fix-failing-jest-by-disabling-esmodule.patch) #### [@react-navigation+routers+7.6.4+001+fix-failing-jest-by-disabling-esmodule.patch](@react-navigation+routers+7.6.4+001+fix-failing-jest-by-disabling-esmodule.patch) #### [@react-navigation+stack+7.8.5+004+fix-failing-jest-by-disabling-esmodule.patch](@react-navigation+stack+7.8.5+004+fix-failing-jest-by-disabling-esmodule.patch) @@ -24,7 +24,7 @@ - PR Introducing Patch: [#37891](https://github.com/Expensify/App/pull/37891) - PR Updating Patch: [#64155](https://github.com/Expensify/App/pull/64155), [#98097](https://github.com/Expensify/App/pull/98097) -### [@react-navigation+native+7.1.33+001+initial.patch](@react-navigation+native+7.1.33+001+initial.patch) +### [@react-navigation+native+7.2.6+001+initial.patch](@react-navigation+native+7.2.6+001+initial.patch) - Reason: Allows us to use some more advanced navigation actions without messing up the browser history - Upstream PR/issue: https://github.com/react-navigation/react-navigation/pull/12751 (`route.history` + `pushParams`, added upstream for this use case); originating issue https://github.com/react-navigation/react-navigation/issues/12460. On the v8 upgrade, evaluate adopting `pushParams` to shrink this patch. @@ -49,7 +49,7 @@ - PR Updating Patch: [#33280](https://github.com/Expensify/App/pull/33280) [#37421](https://github.com/Expensify/App/pull/37421) [#49539](https://github.com/Expensify/App/pull/49539) [#64155](https://github.com/Expensify/App/pull/64155) [#65119](https://github.com/Expensify/App/issues/65119) - Note: Not fully covered by the public `detachPreviousScreen` option (this also forces `activityState`). v8 replaces `detachInactiveScreens`/`detachPreviousScreen`/`freezeOnBlur` with a single `inactiveBehavior` option — re-evaluate this patch then. -### [@react-navigation+native+7.1.33+003+increase-history-go-popstate-fallback-timeout.patch](@react-navigation+native+7.1.33+003+increase-history-go-popstate-fallback-timeout.patch) +### [@react-navigation+native+7.2.6+003+increase-history-go-popstate-fallback-timeout.patch](@react-navigation+native+7.2.6+003+increase-history-go-popstate-fallback-timeout.patch) - Reason: diff --git a/src/components/Navigation/NavigationTabBar/TabBarItem.tsx b/src/components/Navigation/NavigationTabBar/TabBarItem.tsx index 8955980f9339..bbf07ed6c0d9 100644 --- a/src/components/Navigation/NavigationTabBar/TabBarItem.tsx +++ b/src/components/Navigation/NavigationTabBar/TabBarItem.tsx @@ -37,7 +37,7 @@ function TabBarItem({icon, label, isSelected, isHovered = false, statusIndicator const {shouldUseNarrowLayout} = useResponsiveLayout(); return ( - + = TabBarBottomContent; - -export default tabBarBottomContent; diff --git a/src/components/Navigation/TabBarBottomContent/types.ts b/src/components/Navigation/TabBarBottomContent/types.ts deleted file mode 100644 index 30f7b28d1650..000000000000 --- a/src/components/Navigation/TabBarBottomContent/types.ts +++ /dev/null @@ -1,9 +0,0 @@ -import type NAVIGATION_TABS from '@components/Navigation/NavigationTabBar/NAVIGATION_TABS'; - -import type {ValueOf} from 'type-fest'; - -type TabBarBottomContentProps = { - selectedTab: ValueOf; -}; - -export default TabBarBottomContentProps; diff --git a/src/components/WorkspaceListLayout.tsx b/src/components/WorkspaceListLayout.tsx index ce180ea0d91d..e5a3ab23b321 100644 --- a/src/components/WorkspaceListLayout.tsx +++ b/src/components/WorkspaceListLayout.tsx @@ -16,8 +16,6 @@ import React from 'react'; import {View} from 'react-native'; import {useDebugTabViewHeight} from './Navigation/DebugTabView'; -import NAVIGATION_TABS from './Navigation/NavigationTabBar/NAVIGATION_TABS'; -import TabBarBottomContent from './Navigation/TabBarBottomContent'; import TopBarWithLoadingBar from './Navigation/TopBarWithLoadingBar'; import OfflineIndicator from './OfflineIndicator'; import ScreenWrapper from './ScreenWrapper'; @@ -119,8 +117,6 @@ function WorkspaceListLayout({children, activeTabKey, headerButton, headerCompon shouldEnableMaxHeight shouldEnablePickerAvoiding={false} enableEdgeToEdgeBottomSafeAreaPadding={false} - bottomContentStyle={styles.overflowVisible} - bottomContent={} > diff --git a/src/hooks/useFloatingTabBarContentInsetStyle/index.ts b/src/hooks/useFloatingTabBarContentInsetStyle/index.ts index ce42dd197c1b..12c4971b5243 100644 --- a/src/hooks/useFloatingTabBarContentInsetStyle/index.ts +++ b/src/hooks/useFloatingTabBarContentInsetStyle/index.ts @@ -5,8 +5,8 @@ import type {StyleProp, ViewStyle} from 'react-native'; /** * Bottom padding a tab root screen's scrollable content needs so its last row settles above the floating tab - * bar instead of under it. Mobile web draws that bar itself; the native platforms get the same room from the - * safe area their own tab bar reports, so there the hook adds nothing. + * bar instead of under it. Android and mobile web draw that bar in JS; iOS takes the sibling module, because + * UITabBar reports its own height through the safe area and needs no extra room. */ function useFloatingTabBarContentInsetStyle(): StyleProp { const styles = useThemeStyles(); diff --git a/src/libs/Navigation/AppNavigator/Navigators/TAB_SCREENS.ios.ts b/src/libs/Navigation/AppNavigator/Navigators/TAB_SCREENS.ios.ts index bfd8d0f176f5..575fc430519b 100644 --- a/src/libs/Navigation/AppNavigator/Navigators/TAB_SCREENS.ios.ts +++ b/src/libs/Navigation/AppNavigator/Navigators/TAB_SCREENS.ios.ts @@ -1,6 +1,6 @@ /** * Ordered list of screen names registered inside TabNavigator. - * This must match the Tab.Screen order in TabNavigator.native.tsx, where the order is also the order the native + * This must match the Tab.Screen order in TabNavigator.ios.tsx, where the order is also the order the native * tab bar draws its items in, so Workspaces comes before Account. * Used by getAdaptedStateFromPath to build complete tab navigator state for deep-links. */ diff --git a/src/libs/Navigation/AppNavigator/Navigators/TabNavigator.ios.tsx b/src/libs/Navigation/AppNavigator/Navigators/TabNavigator.ios.tsx index f70d11d6f598..b90b80cde843 100644 --- a/src/libs/Navigation/AppNavigator/Navigators/TabNavigator.ios.tsx +++ b/src/libs/Navigation/AppNavigator/Navigators/TabNavigator.ios.tsx @@ -58,9 +58,8 @@ import SettingsSplitNavigator from './SettingsSplitNavigator'; import WorkspaceNavigator from './WorkspaceNavigator'; /** - * Tab Navigator backed by the platform's own tab bar: UITabBar on iOS, which brings the liquid glass material - * with it on iOS 26, and a Material BottomNavigationView on Android. Wide layouts keep the JS side bar, since - * neither native bar can be moved to the side of the screen. + * Tab Navigator backed by UITabBar, which brings the liquid glass material with it on iOS 26. Wide layouts keep + * the JS side bar, since UITabBar cannot be moved to the side of the screen. */ const Tab = createNativeBottomTabNavigator(); @@ -422,7 +421,7 @@ function TabNavigator() { accountID: currentUserPersonalDetails.accountID, }); const avatarURI = typeof avatarSource === 'string' ? avatarSource : undefined; - const avatarSignature = `${avatarURI}|${accountDotColor}`; + const avatarSignature = [avatarURI, accountDotColor, tabLabels[NAVIGATORS.SETTINGS_SPLIT_NAVIGATOR], theme.textSupporting, theme.text].join('|'); const [circularAvatar, setCircularAvatar] = useState<{signature: string; active: AvatarTabIcon; inactive: AvatarTabIcon}>(); const avatarPair = circularAvatar?.signature === avatarSignature ? circularAvatar : undefined; const toAvatarIcon = (icon: AvatarTabIcon): NativeBottomTabIcon => ({ @@ -454,7 +453,7 @@ function TabNavigator() { isActive = false; }; // eslint-disable-next-line react-hooks/exhaustive-deps - }, [avatarSignature, iconsSignature]); + }, [avatarSignature]); useEffect(() => { if (!shouldUseNarrowLayout || !parentNavigation) { @@ -498,10 +497,6 @@ function TabNavigator() { const screenOptions = { headerShown: false, - // The native bar swaps the visible tab itself, so a tab that mounts on first focus hands the bar an empty - // container to show while its tree renders. Unfocused tabs render through a transition instead, which keeps - // them off the first paint and still leaves each one ready to draw its own skeleton the moment it is picked. - lazy: false, tabBarActiveTintColor: theme.iconMenu, tabBarInactiveTintColor: theme.icon, // Every tab shares one style, so the bar reads the current visibility in the same render that changed it. @@ -509,10 +504,6 @@ function TabNavigator() { // frame after a tab switch in the previous tab's state. The background only lands on Android and iOS 18 and // below; iOS 26 keeps its own glass material. tabBarStyle: {display: shouldShowNativeTabBar ? ('flex' as const) : ('none' as const), backgroundColor: theme.appBG}, - // Android defaults to showing the label on the selected tab only once a bar has more than three items, - // which leaves the other tabs centering a lone icon at a different height. Labelling every tab matches - // what iOS draws and keeps all five icons on one baseline. - tabBarLabelVisibilityMode: 'labeled' as const, tabBarControllerMode: 'tabBar' as const, // The bar stays put while the content scrolls, instead of collapsing the way iOS 26 does by default. tabBarMinimizeBehavior: 'none' as const, diff --git a/src/libs/Navigation/AppNavigator/Navigators/TabNavigator.tsx b/src/libs/Navigation/AppNavigator/Navigators/TabNavigator.tsx index 0e535d21c788..3a1ffb8b3186 100644 --- a/src/libs/Navigation/AppNavigator/Navigators/TabNavigator.tsx +++ b/src/libs/Navigation/AppNavigator/Navigators/TabNavigator.tsx @@ -1,6 +1,7 @@ import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useTheme from '@hooks/useTheme'; +import {getPreservedNavigatorState, setPreservedNavigatorState} from '@libs/Navigation/AppNavigator/createSplitNavigator/usePreserveNavigatorState'; import {bottomTabScreenLayoutWrapper} from '@libs/Navigation/PlatformStackNavigation/ScreenLayout'; import type {TabNavigatorParamList} from '@libs/Navigation/types'; @@ -11,9 +12,10 @@ import SCREENS from '@src/SCREENS'; * Tab Navigator containing Home, Inbox (Reports), Search, Insights, Settings, and Workspaces pages. */ import type {BottomTabBarProps} from '@react-navigation/bottom-tabs'; +import type {NavigationAction, Router, TabNavigationState} from '@react-navigation/native'; import {createBottomTabNavigator} from '@react-navigation/bottom-tabs'; -import {findFocusedRoute, useNavigation, useNavigationState} from '@react-navigation/native'; +import {findFocusedRoute, useNavigation, useNavigationState, useRoute} from '@react-navigation/native'; import React, {useEffect} from 'react'; // Do not lazy load Search navigator for performance reasons @@ -45,6 +47,12 @@ function TabNavigator() { const navigation = useNavigation(); const parentNavigation = navigation.getParent(); const focusedRouteName = useNavigationState((state) => findFocusedRoute(state)?.name); + const route = useRoute(); + // The Tab.Navigator's own state lives at `parentState.routes[i].state`. We can't read it via + // `useNavigationState((s) => s)` here because TabNavigator's body runs before + // mounts, so the nearest navigation listener context is still the parent stack's. + const tabState = useNavigationState((parentState) => parentState.routes.find((r) => r.key === route.key)?.state); + useEffect(() => { if (!shouldUseNarrowLayout || !parentNavigation) { return; @@ -53,6 +61,27 @@ function TabNavigator() { parentNavigation.setOptions({gestureEnabled: !isRootScreen}); }, [focusedRouteName, shouldUseNarrowLayout, parentNavigation]); + useEffect(() => { + // stale === false distinguishes a fully realized NavigationState from a PartialState. + if (!tabState || tabState.stale !== false) { + return; + } + setPreservedNavigatorState(route.key, tabState); + }, [tabState, route.key]); + + // The slicing optimization in useCustomRootStackNavigatorState can unmount and later remount + // this TAB_NAVIGATOR. Without restoration it would default to index 0. We restore the saved + // state by overriding the bottom-tab router's getInitialState - the same pattern SplitRouter + // uses for its split navigators. + const tabRouterOverride = ( + originalRouter: Router, Action>, + ): Partial, Action>> => ({ + getInitialState: (configOptions) => { + const preserved = getPreservedNavigatorState>(route.key); + return preserved ? originalRouter.getRehydratedState(preserved, configOptions) : originalRouter.getInitialState(configOptions); + }, + }); + const screenOptions = { ...TAB_SCREEN_OPTIONS_BASE, sceneStyle: {flex: 1, backgroundColor: theme.appBG}, @@ -65,6 +94,7 @@ function TabNavigator() { tabBar={renderTabBar} screenOptions={screenOptions} screenLayout={bottomTabScreenLayoutWrapper} + UNSTABLE_router={tabRouterOverride} > } testID="InsightsPage" > ; - function SearchPageNarrow({ queryJSON, searchResults, @@ -267,8 +263,6 @@ function SearchPageNarrow({ shouldEnableMaxHeight offlineIndicatorStyle={styles.mtAuto} shouldShowOfflineIndicator={!!searchResults} - bottomContent={tabBarContent} - bottomContentStyle={styles.overflowVisible} > {!isMobileSelectionModeEnabled ? ( diff --git a/src/pages/domain/DomainInitialPage.tsx b/src/pages/domain/DomainInitialPage.tsx index 543a56135663..f8996096591e 100644 --- a/src/pages/domain/DomainInitialPage.tsx +++ b/src/pages/domain/DomainInitialPage.tsx @@ -1,8 +1,6 @@ import FullPageNotFoundView from '@components/BlockingViews/FullPageNotFoundView'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; import HighlightableMenuItem from '@components/HighlightableMenuItem'; -import NAVIGATION_TABS from '@components/Navigation/NavigationTabBar/NAVIGATION_TABS'; -import TabBarBottomContent from '@components/Navigation/TabBarBottomContent'; import ScreenWrapper from '@components/ScreenWrapper'; import ScrollView from '@components/ScrollView'; @@ -104,8 +102,6 @@ function DomainInitialPage({route}: DomainInitialPageProps) { } - bottomContentStyle={styles.overflowVisible} > Navigation.dismissModal()} diff --git a/src/pages/home/HomePage.tsx b/src/pages/home/HomePage.tsx index 868536b929cb..02cf216faa56 100644 --- a/src/pages/home/HomePage.tsx +++ b/src/pages/home/HomePage.tsx @@ -1,6 +1,4 @@ -import NAVIGATION_TABS from '@components/Navigation/NavigationTabBar/NAVIGATION_TABS'; import QuickCreationActionsBar from '@components/Navigation/QuickCreationActionsBar'; -import TabBarBottomContent from '@components/Navigation/TabBarBottomContent'; import TopBar from '@components/Navigation/TopBar'; import ReceiptScanDropZone from '@components/ReceiptScanDropZone'; import ScreenWrapper from '@components/ScreenWrapper'; @@ -139,8 +137,6 @@ function HomePage() { shouldShowOfflineIndicatorInWideScreen testID="HomePage" enableEdgeToEdgeBottomSafeAreaPadding={false} - bottomContent={} - bottomContentStyle={styles.overflowVisible} > } - bottomContentStyle={styles.overflowVisible} > {({insets}) => ( <> diff --git a/src/pages/inbox/sidebar/NavigationTabBarAvatar.tsx b/src/pages/inbox/sidebar/NavigationTabBarAvatar.tsx index e994fcbfa44c..ffb69bac45a4 100644 --- a/src/pages/inbox/sidebar/NavigationTabBarAvatar.tsx +++ b/src/pages/inbox/sidebar/NavigationTabBarAvatar.tsx @@ -44,7 +44,7 @@ function NavigationTabBarAvatar({onPress, isSelected = false, style}: Navigation sentryLabel={CONST.SENTRY_LABEL.NAVIGATION_TAB_BAR.ACCOUNT} > {({hovered}) => ( - + ; const styles = useThemeStyles(); const {isExecuting, singleExecution} = useSingleExecution(); const {translate} = useLocalize(); @@ -158,8 +155,6 @@ function InitialSettingsPage({currentUserPersonalDetails}: InitialSettingsPagePr includeSafeAreaPaddingBottom testID="InitialSettingsPage" shouldEnableKeyboardAvoidingView={false} - bottomContent={tabBarContent} - bottomContentStyle={styles.overflowVisible} > } - bottomContentStyle={styles.overflowVisible} > navigationTabBarSurface: { backgroundColor: theme.floatingTabBarBG, borderWidth: variables.hairlineBorderWidth, - borderColor: theme.floatingTabBarBorder, - }, - - // Wraps a tab's icon and label so the selected pill can be as wide as they are rather than as wide as - // the tab's share of the bar. A long label then pushes the pill past its tab instead of spilling out of it. - navigationTabBarItemContent: { - height: '100%', - justifyContent: 'center', - alignItems: 'center', + borderColor: theme.border, }, navigationTabBarItemSelected: { @@ -774,6 +766,8 @@ const staticStyles = (theme: ThemeColors) => paddingBottom: variables.floatingTabBarContentInset, }, + // Also wraps a tab's icon and label, where it sizes to them rather than to the tab's share of the bar, + // so a long label pushes the selected pill past its tab instead of spilling out of it. navigationTabBarItem: { height: '100%', display: 'flex', diff --git a/src/styles/theme/themes/dark.ts b/src/styles/theme/themes/dark.ts index 0cbe9f264b74..da2b8ef071fa 100644 --- a/src/styles/theme/themes/dark.ts +++ b/src/styles/theme/themes/dark.ts @@ -59,7 +59,6 @@ const darkTheme = { sidebar: colors.productDark100, sidebarHover: colors.productDark300, floatingTabBarBG: 'rgba(6,27,9,0.88)', - floatingTabBarBorder: colors.productDark400, floatingTabBarSelectedBG: 'rgba(26,61,50,0.50)', heading: colors.productDark900, textLight: colors.productDark900, diff --git a/src/styles/theme/themes/light.ts b/src/styles/theme/themes/light.ts index 269b935f4ad0..9eb7daf68418 100644 --- a/src/styles/theme/themes/light.ts +++ b/src/styles/theme/themes/light.ts @@ -59,7 +59,6 @@ const lightTheme = { sidebar: colors.productLight100, sidebarHover: colors.productLight300, floatingTabBarBG: 'rgba(252,251,249,0.88)', - floatingTabBarBorder: colors.productLight400, floatingTabBarSelectedBG: 'rgba(230,225,218,0.50)', heading: colors.productLight900, textLight: colors.white, diff --git a/src/styles/theme/types.ts b/src/styles/theme/types.ts index 18edcdcb7cb5..2ebe4017eb6d 100644 --- a/src/styles/theme/types.ts +++ b/src/styles/theme/types.ts @@ -66,7 +66,6 @@ type ThemeColors = { sidebar: Color; sidebarHover: Color; floatingTabBarBG: Color; - floatingTabBarBorder: Color; floatingTabBarSelectedBG: Color; heading: Color; textLight: Color; diff --git a/tests/ui/BaseSidebarScreenTest.tsx b/tests/ui/BaseSidebarScreenTest.tsx index 1daf35d456c8..eaf55a117f57 100644 --- a/tests/ui/BaseSidebarScreenTest.tsx +++ b/tests/ui/BaseSidebarScreenTest.tsx @@ -43,7 +43,6 @@ jest.mock('@components/ScreenWrapper', () => { }); jest.mock('@components/Navigation/TopBarWithLoadingBar', () => () => null); -jest.mock('@components/Navigation/TabBarBottomContent', () => () => null); jest.mock('@pages/inbox/sidebar/InboxTabSelector', () => () => null); jest.mock('@components/OptionsListSkeletonView', () => { const ReactModule = jest.requireActual('react'); diff --git a/tests/ui/InitialSettingsPageTest.tsx b/tests/ui/InitialSettingsPageTest.tsx index a5cef1a0dfff..b5f8f38ece19 100644 --- a/tests/ui/InitialSettingsPageTest.tsx +++ b/tests/ui/InitialSettingsPageTest.tsx @@ -85,14 +85,6 @@ jest.mock('@components/AccountSwitcherSkeletonView', () => { return MockAccountSwitcherSkeletonView; }); -jest.mock('@components/Navigation/TabBarBottomContent', () => { - function MockTabBarBottomContent() { - return null; - } - MockTabBarBottomContent.displayName = 'TabBarBottomContent'; - return MockTabBarBottomContent; -}); - jest.mock('@components/Navigation/TopBarWithLoadingBar', () => { function MockTopBarWithLoadingBar() { return null; diff --git a/tests/ui/WorkspaceInitialPageTest.tsx b/tests/ui/WorkspaceInitialPageTest.tsx index d056856626cb..bd6e7a5f51b4 100644 --- a/tests/ui/WorkspaceInitialPageTest.tsx +++ b/tests/ui/WorkspaceInitialPageTest.tsx @@ -66,7 +66,6 @@ jest.mock('@hooks/useWorkspaceAccountID', () => jest.fn()); jest.mock('@components/BlockingViews/FullPageNotFoundView', () => (props: {children: React.ReactNode; shouldShow: boolean}) => mockFullPageNotFoundView(props)); jest.mock('@components/HeaderWithBackButton', () => jest.fn()); jest.mock('@components/HighlightableMenuItem', () => jest.fn()); -jest.mock('@components/Navigation/TabBarBottomContent', () => jest.fn()); jest.mock( '@components/OfflineWithFeedback', () => From 0415ee99c45adaa6c459a571f8211bc67283981f Mon Sep 17 00:00:00 2001 From: sumo_slonik Date: Wed, 23 Sep 2026 14:44:45 +0200 Subject: [PATCH 18/71] fix: fold tab bar surface style into the container, correct stale comments --- .../Navigation/NavigationTabBar/index.tsx | 2 +- .../Navigators/TabNavigator.ios.tsx | 9 ++++----- src/styles/index.ts | 20 ++++++++----------- 3 files changed, 13 insertions(+), 18 deletions(-) diff --git a/src/components/Navigation/NavigationTabBar/index.tsx b/src/components/Navigation/NavigationTabBar/index.tsx index 40b8002f93fe..1d6da466e63d 100644 --- a/src/components/Navigation/NavigationTabBar/index.tsx +++ b/src/components/Navigation/NavigationTabBar/index.tsx @@ -158,7 +158,7 @@ function NavigationTabBar({selectedTab, shouldShowFloatingButtons = true}: Navig <> {shouldShowDebugTabView && } overflow: 'visible', }, - // The narrow tab bar is a capsule floating above the screen content. Only mobile web renders it, since - // the native platforms hand the bottom bar over to UIKit and to Material. + // The narrow tab bar is a capsule floating above the screen content, drawn by Android and mobile web. + // iOS hands the bottom bar over to UIKit, which paints its own material, so nothing here reaches it. navigationTabBarContainer: { flexDirection: 'row', height: variables.floatingTabBarHeight, marginHorizontal: variables.floatingTabBarHorizontalInset, borderRadius: variables.componentBorderRadiusCircle, + backgroundColor: theme.floatingTabBarBG, + borderWidth: variables.hairlineBorderWidth, + borderColor: theme.border, // Insets the row so the selected item's capsule keeps a margin inside the bar's rounded edge. ...spacing.p1, // Clips each item's hover and press background to the capsule's rounded ends. overflow: 'hidden', }, - // Mobile web has no native tab bar to inherit a material from, so the capsule paints its own - // translucent surface over the content it floats above. - navigationTabBarSurface: { - backgroundColor: theme.floatingTabBarBG, - borderWidth: variables.hairlineBorderWidth, - borderColor: theme.border, - }, - navigationTabBarItemSelected: { backgroundColor: theme.floatingTabBarSelectedBG, borderRadius: variables.componentBorderRadiusCircle, @@ -766,8 +761,9 @@ const staticStyles = (theme: ThemeColors) => paddingBottom: variables.floatingTabBarContentInset, }, - // Also wraps a tab's icon and label, where it sizes to them rather than to the tab's share of the bar, - // so a long label pushes the selected pill past its tab instead of spilling out of it. + // Used both for a tab's pressable and for the wrapper around its icon and label. As the wrapper it sizes + // to those two rather than to the tab's share of the bar, so a long label pushes the selected pill past + // its tab instead of spilling out of it. navigationTabBarItem: { height: '100%', display: 'flex', From 877e3e704b2c6f68192731a9802b9d0975cba162 Mon Sep 17 00:00:00 2001 From: sumo_slonik Date: Wed, 23 Sep 2026 15:55:54 +0200 Subject: [PATCH 19/71] feat: scale down floating tab bar icons and blur what the bar covers Icons go to 20x20 with a tighter label gap; Android and mWeb blur the backdrop. --- package-lock.json | 12 +++++++++ package.json | 1 + .../TabBarBlur/index.android.tsx | 26 +++++++++++++++++++ .../NavigationTabBar/TabBarBlur/index.tsx | 23 ++++++++++++++++ .../NavigationTabBar/TabBarItem.tsx | 13 +++++++--- .../Navigation/NavigationTabBar/index.tsx | 2 ++ src/styles/index.ts | 9 +++++++ src/styles/theme/themes/dark.ts | 2 +- src/styles/theme/themes/light.ts | 2 +- src/styles/variables.ts | 6 +++++ 10 files changed, 91 insertions(+), 5 deletions(-) create mode 100644 src/components/Navigation/NavigationTabBar/TabBarBlur/index.android.tsx create mode 100644 src/components/Navigation/NavigationTabBar/TabBarBlur/index.tsx diff --git a/package-lock.json b/package-lock.json index 1e05d3c26ea8..8c5be366abbc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -71,6 +71,7 @@ "expo": "57.0.8", "expo-asset": "57.0.7", "expo-audio": "57.0.3", + "expo-blur": "57.0.3", "expo-font": "57.0.1", "expo-image": "57.0.1", "expo-image-manipulator": "57.0.6", @@ -27115,6 +27116,17 @@ "react-native": "*" } }, + "node_modules/expo-blur": { + "version": "57.0.3", + "resolved": "https://registry.npmjs.org/expo-blur/-/expo-blur-57.0.3.tgz", + "integrity": "sha512-uyBeJwxIuXzWXXNrnRub5pcF8VzQsiOz0VBTWDpMG2UztmaFyfAz6pI/GCwRtWpHhiOpLoJj79WLdg1GMB+9cg==", + "license": "MIT", + "peerDependencies": { + "expo": "*", + "react": "*", + "react-native": "*" + } + }, "node_modules/expo-constants": { "version": "57.0.7", "resolved": "https://registry.npmjs.org/expo-constants/-/expo-constants-57.0.7.tgz", diff --git a/package.json b/package.json index 3ef520d45993..cd7cf6199a2a 100644 --- a/package.json +++ b/package.json @@ -147,6 +147,7 @@ "expo": "57.0.8", "expo-asset": "57.0.7", "expo-audio": "57.0.3", + "expo-blur": "57.0.3", "expo-font": "57.0.1", "expo-image": "57.0.1", "expo-image-manipulator": "57.0.6", diff --git a/src/components/Navigation/NavigationTabBar/TabBarBlur/index.android.tsx b/src/components/Navigation/NavigationTabBar/TabBarBlur/index.android.tsx new file mode 100644 index 000000000000..895bc2c90dd8 --- /dev/null +++ b/src/components/Navigation/NavigationTabBar/TabBarBlur/index.android.tsx @@ -0,0 +1,26 @@ +import useTheme from '@hooks/useTheme'; +import useThemeStyles from '@hooks/useThemeStyles'; + +import variables from '@styles/variables'; + +import {BlurView} from 'expo-blur'; +import React from 'react'; + +/** + * Blurs whatever the floating tab bar covers, so the bar reads as a glass capsule the way UIKit draws it on iOS. + * Android has no backdrop filter of its own, so the blur comes from a native view behind the row. + */ +function TabBarBlur() { + const styles = useThemeStyles(); + const theme = useTheme(); + + return ( + + ); +} + +export default TabBarBlur; diff --git a/src/components/Navigation/NavigationTabBar/TabBarBlur/index.tsx b/src/components/Navigation/NavigationTabBar/TabBarBlur/index.tsx new file mode 100644 index 000000000000..cb0af14f37cc --- /dev/null +++ b/src/components/Navigation/NavigationTabBar/TabBarBlur/index.tsx @@ -0,0 +1,23 @@ +import useThemeStyles from '@hooks/useThemeStyles'; + +import variables from '@styles/variables'; + +import type {ViewStyle} from 'react-native'; + +import React from 'react'; +import {View} from 'react-native'; + +/** + * Blurs whatever the floating tab bar covers, so the bar reads as a glass capsule the way UIKit draws it on iOS. + * The web build gets the effect from the CSS backdrop filter, which react-native-web forwards and prefixes but + * React Native's own style types do not carry. + */ +const backdropFilterStyle = {backdropFilter: `blur(${variables.floatingTabBarBlurRadius}px)`} as ViewStyle; + +function TabBarBlur() { + const styles = useThemeStyles(); + + return ; +} + +export default TabBarBlur; diff --git a/src/components/Navigation/NavigationTabBar/TabBarItem.tsx b/src/components/Navigation/NavigationTabBar/TabBarItem.tsx index bbf07ed6c0d9..87613dbe3f72 100644 --- a/src/components/Navigation/NavigationTabBar/TabBarItem.tsx +++ b/src/components/Navigation/NavigationTabBar/TabBarItem.tsx @@ -35,6 +35,7 @@ function TabBarItem({icon, label, isSelected, isHovered = false, statusIndicator const theme = useTheme(); const styles = useThemeStyles(); const {shouldUseNarrowLayout} = useResponsiveLayout(); + const iconSize = shouldUseNarrowLayout ? variables.iconFloatingTabBar : variables.iconBottomBar; return ( @@ -42,8 +43,8 @@ function TabBarItem({icon, label, isSelected, isHovered = false, statusIndicator {!!statusIndicatorColor && ( @@ -51,7 +52,13 @@ function TabBarItem({icon, label, isSelected, isHovered = false, statusIndicator {label} diff --git a/src/components/Navigation/NavigationTabBar/index.tsx b/src/components/Navigation/NavigationTabBar/index.tsx index 1d6da466e63d..85de538cc13a 100644 --- a/src/components/Navigation/NavigationTabBar/index.tsx +++ b/src/components/Navigation/NavigationTabBar/index.tsx @@ -35,6 +35,7 @@ import InboxTabButton from './InboxTabButton'; import InsightsTabButton from './InsightsTabButton'; import NAVIGATION_TABS from './NAVIGATION_TABS'; import SearchTabButton from './SearchTabButton'; +import TabBarBlur from './TabBarBlur'; import TabBarItem from './TabBarItem'; import WorkspacesTabButton from './WorkspacesTabButton'; @@ -161,6 +162,7 @@ function NavigationTabBar({selectedTab, shouldShowFloatingButtons = true}: Navig style={styles.navigationTabBarContainer} testID="NavigationTabBar" > + overflow: 'hidden', }, + // Sits behind the row and inside the container's rounded clip, so the blur stops at the capsule's edge. + navigationTabBarBlur: { + position: 'absolute', + top: 0, + right: 0, + bottom: 0, + left: 0, + }, + navigationTabBarItemSelected: { backgroundColor: theme.floatingTabBarSelectedBG, borderRadius: variables.componentBorderRadiusCircle, diff --git a/src/styles/theme/themes/dark.ts b/src/styles/theme/themes/dark.ts index da2b8ef071fa..7848f94d9776 100644 --- a/src/styles/theme/themes/dark.ts +++ b/src/styles/theme/themes/dark.ts @@ -58,7 +58,7 @@ const darkTheme = { signInSidebar: colors.green800, sidebar: colors.productDark100, sidebarHover: colors.productDark300, - floatingTabBarBG: 'rgba(6,27,9,0.88)', + floatingTabBarBG: 'rgba(6,27,9,0.70)', floatingTabBarSelectedBG: 'rgba(26,61,50,0.50)', heading: colors.productDark900, textLight: colors.productDark900, diff --git a/src/styles/theme/themes/light.ts b/src/styles/theme/themes/light.ts index 9eb7daf68418..77eb5620a411 100644 --- a/src/styles/theme/themes/light.ts +++ b/src/styles/theme/themes/light.ts @@ -58,7 +58,7 @@ const lightTheme = { signInSidebar: colors.green800, sidebar: colors.productLight100, sidebarHover: colors.productLight300, - floatingTabBarBG: 'rgba(252,251,249,0.88)', + floatingTabBarBG: 'rgba(252,251,249,0.70)', floatingTabBarSelectedBG: 'rgba(230,225,218,0.50)', heading: colors.productLight900, textLight: colors.white, diff --git a/src/styles/variables.ts b/src/styles/variables.ts index 09b9b29f37ac..b09479591c19 100644 --- a/src/styles/variables.ts +++ b/src/styles/variables.ts @@ -40,6 +40,10 @@ export default { // account tab's 121px label sits in a 222px bubble, so 17pt a side. floatingTabBarSelectedOverhang: 17, floatingTabBarBottomInset, + // Blur the bar lays over what it covers. The web radius is a CSS blur; the Android one is expo-blur's own + // 0 to 100 intensity, which is not the same unit, so the two are tuned to match rather than shared. + floatingTabBarBlurRadius: 24, + floatingTabBarBlurIntensity: 65, // Vertical space the floating bar takes over the content it hides, so scrollable content can end above it. floatingTabBarContentInset: floatingTabBarHeight + floatingTabBarBottomInset, // styles.p3 (12) on each side of the DebugTabView row plus the View button (componentSizeNormal). @@ -110,6 +114,8 @@ export default { iconSizeMegaLarge: 105, iconSizeMenuItem: 32, iconBottomBar: 24, + /** Glyph size in the floating tab bar, smaller than the side bar's so each item keeps padding above and below it. */ + iconFloatingTabBar: 20, /** Radius of the status dot drawn into a native tab bar icon, and the room reserved for it around the glyph. */ nativeTabIconDotRadius: 4, // Gap between the glyph and the label baked under it, matching the spacing the JS bar uses. From 69a666ef0bea4c4a335afd005be6e65ed937548c Mon Sep 17 00:00:00 2001 From: sumo_slonik Date: Thu, 24 Sep 2026 10:43:43 +0200 Subject: [PATCH 20/71] feat: blur the focused tab scene under the Android tab bar Adds a milky glass tint over a light blur. --- cspell.json | 1 + .../TabBarBlurTarget/index.android.tsx | 41 ++++++++++ .../TabBarBlur/TabBarBlurTarget/index.tsx | 10 +++ .../TabBarBlur/TabBarBlurTarget/types.ts | 8 ++ .../TabBarBlur/TabBarBlurTargetContext.tsx | 49 ++++++++++++ .../TabBarBlur/index.android.tsx | 14 +++- .../AppNavigator/Navigators/TabNavigator.tsx | 77 +++++++++++-------- src/styles/variables.ts | 5 +- 8 files changed, 168 insertions(+), 37 deletions(-) create mode 100644 src/components/Navigation/NavigationTabBar/TabBarBlur/TabBarBlurTarget/index.android.tsx create mode 100644 src/components/Navigation/NavigationTabBar/TabBarBlur/TabBarBlurTarget/index.tsx create mode 100644 src/components/Navigation/NavigationTabBar/TabBarBlur/TabBarBlurTarget/types.ts create mode 100644 src/components/Navigation/NavigationTabBar/TabBarBlur/TabBarBlurTargetContext.tsx diff --git a/cspell.json b/cspell.json index 2125b4863441..e88d52c10dca 100644 --- a/cspell.json +++ b/cspell.json @@ -609,6 +609,7 @@ "devportal", "diems", "dimen", + "dimezis", "directfeeds", "dispatchable", "displaystatus", diff --git a/src/components/Navigation/NavigationTabBar/TabBarBlur/TabBarBlurTarget/index.android.tsx b/src/components/Navigation/NavigationTabBar/TabBarBlur/TabBarBlurTarget/index.android.tsx new file mode 100644 index 000000000000..a870e2783ee9 --- /dev/null +++ b/src/components/Navigation/NavigationTabBar/TabBarBlur/TabBarBlurTarget/index.android.tsx @@ -0,0 +1,41 @@ +import {useTabBarBlurTargetActions} from '@components/Navigation/NavigationTabBar/TabBarBlur/TabBarBlurTargetContext'; + +import useThemeStyles from '@hooks/useThemeStyles'; + +import type {View} from 'react-native'; + +import {useIsFocused} from '@react-navigation/native'; +import {BlurTargetView} from 'expo-blur'; +import React, {useEffect, useRef} from 'react'; + +import type TabBarBlurTargetProps from './types'; + +/** + * Wraps a tab scene in the view the floating tab bar's blur samples from. The blur has to point at a sibling of + * itself, not a parent, so the scene is the target and the bar stays outside it. + */ +function TabBarBlurTarget({children}: TabBarBlurTargetProps) { + const styles = useThemeStyles(); + const targetRef = useRef(null); + const isFocused = useIsFocused(); + const {setBlurTarget} = useTabBarBlurTargetActions(); + + useEffect(() => { + if (!isFocused) { + return; + } + setBlurTarget(targetRef); + }, [isFocused, setBlurTarget]); + + return ( + + {children} + + ); +} + +export default TabBarBlurTarget; diff --git a/src/components/Navigation/NavigationTabBar/TabBarBlur/TabBarBlurTarget/index.tsx b/src/components/Navigation/NavigationTabBar/TabBarBlur/TabBarBlurTarget/index.tsx new file mode 100644 index 000000000000..703f6303bce4 --- /dev/null +++ b/src/components/Navigation/NavigationTabBar/TabBarBlur/TabBarBlurTarget/index.tsx @@ -0,0 +1,10 @@ +import type TabBarBlurTargetProps from './types'; + +/** + * The web bar blurs through a CSS backdrop filter, which needs no view to sample from, so the scene renders as is. + */ +function TabBarBlurTarget({children}: TabBarBlurTargetProps) { + return children; +} + +export default TabBarBlurTarget; diff --git a/src/components/Navigation/NavigationTabBar/TabBarBlur/TabBarBlurTarget/types.ts b/src/components/Navigation/NavigationTabBar/TabBarBlur/TabBarBlurTarget/types.ts new file mode 100644 index 000000000000..2260a842c8a3 --- /dev/null +++ b/src/components/Navigation/NavigationTabBar/TabBarBlur/TabBarBlurTarget/types.ts @@ -0,0 +1,8 @@ +import type React from 'react'; + +type TabBarBlurTargetProps = { + /** The tab scene the floating tab bar lays over */ + children: React.ReactNode; +}; + +export default TabBarBlurTargetProps; diff --git a/src/components/Navigation/NavigationTabBar/TabBarBlur/TabBarBlurTargetContext.tsx b/src/components/Navigation/NavigationTabBar/TabBarBlur/TabBarBlurTargetContext.tsx new file mode 100644 index 000000000000..7006e767a9d7 --- /dev/null +++ b/src/components/Navigation/NavigationTabBar/TabBarBlur/TabBarBlurTargetContext.tsx @@ -0,0 +1,49 @@ +import type {RefObject} from 'react'; +import type {View} from 'react-native'; + +import React, {createContext, useContext, useState} from 'react'; + +type BlurTarget = RefObject | null; + +type TabBarBlurTargetActionsContextType = { + setBlurTarget: (blurTarget: BlurTarget) => void; +}; + +type TabBarBlurTargetContextProviderProps = { + children: React.ReactNode; +}; + +const TabBarBlurTargetStateContext = createContext(null); + +const TabBarBlurTargetActionsContext = createContext({ + setBlurTarget: () => {}, +}); + +/** + * Tells the floating tab bar which view it lays over. Each tab scene registers its own view when it gains focus, + * so the bar always blurs the scene the user is looking at rather than one that is mounted but hidden. + */ +function TabBarBlurTargetContextProvider({children}: TabBarBlurTargetContextProviderProps) { + const [blurTarget, setBlurTarget] = useState(null); + + // Because of the React Compiler we don't need to memoize it manually + // eslint-disable-next-line react/jsx-no-constructed-context-values + const actionsContextValue = {setBlurTarget}; + + return ( + + {children} + + ); +} + +function useTabBarBlurTarget(): BlurTarget { + return useContext(TabBarBlurTargetStateContext); +} + +function useTabBarBlurTargetActions(): TabBarBlurTargetActionsContextType { + return useContext(TabBarBlurTargetActionsContext); +} + +export default TabBarBlurTargetContextProvider; +export {useTabBarBlurTarget, useTabBarBlurTargetActions}; diff --git a/src/components/Navigation/NavigationTabBar/TabBarBlur/index.android.tsx b/src/components/Navigation/NavigationTabBar/TabBarBlur/index.android.tsx index 895bc2c90dd8..f260e2a9ef69 100644 --- a/src/components/Navigation/NavigationTabBar/TabBarBlur/index.android.tsx +++ b/src/components/Navigation/NavigationTabBar/TabBarBlur/index.android.tsx @@ -3,21 +3,31 @@ import useThemeStyles from '@hooks/useThemeStyles'; import variables from '@styles/variables'; +import CONST from '@src/CONST'; + import {BlurView} from 'expo-blur'; import React from 'react'; +import {useTabBarBlurTarget} from './TabBarBlurTargetContext'; + /** * Blurs whatever the floating tab bar covers, so the bar reads as a glass capsule the way UIKit draws it on iOS. - * Android has no backdrop filter of its own, so the blur comes from a native view behind the row. + * Android has no backdrop filter of its own, so the blur comes from a native view that samples the focused tab + * scene. Until a scene registers itself there is nothing to sample, and the view paints only its tint. The tints + * are expo-blur's white and dark grey materials, which give the milky wash UIKit's glass lays over what it blurs. */ function TabBarBlur() { const styles = useThemeStyles(); const theme = useTheme(); + const blurTarget = useTabBarBlurTarget(); return ( ); diff --git a/src/libs/Navigation/AppNavigator/Navigators/TabNavigator.tsx b/src/libs/Navigation/AppNavigator/Navigators/TabNavigator.tsx index 3a1ffb8b3186..372b7fa4f939 100644 --- a/src/libs/Navigation/AppNavigator/Navigators/TabNavigator.tsx +++ b/src/libs/Navigation/AppNavigator/Navigators/TabNavigator.tsx @@ -1,3 +1,6 @@ +import TabBarBlurTarget from '@components/Navigation/NavigationTabBar/TabBarBlur/TabBarBlurTarget'; +import TabBarBlurTargetContextProvider from '@components/Navigation/NavigationTabBar/TabBarBlur/TabBarBlurTargetContext'; + import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useTheme from '@hooks/useTheme'; @@ -11,8 +14,8 @@ import SCREENS from '@src/SCREENS'; /** * Tab Navigator containing Home, Inbox (Reports), Search, Insights, Settings, and Workspaces pages. */ -import type {BottomTabBarProps} from '@react-navigation/bottom-tabs'; -import type {NavigationAction, Router, TabNavigationState} from '@react-navigation/native'; +import type {BottomTabBarProps, BottomTabNavigationOptions, BottomTabNavigationProp} from '@react-navigation/bottom-tabs'; +import type {NavigationAction, ParamListBase, Router, ScreenLayoutArgs, TabNavigationState} from '@react-navigation/native'; import {createBottomTabNavigator} from '@react-navigation/bottom-tabs'; import {findFocusedRoute, useNavigation, useNavigationState, useRoute} from '@react-navigation/native'; @@ -25,6 +28,10 @@ import {HomePageScreen, InsightsPageScreen, ReportsSplitNavigatorScreen, Setting const renderTabBar = ({state}: BottomTabBarProps) => ; +const renderTabScreenLayout = (args: ScreenLayoutArgs>) => ( + {bottomTabScreenLayoutWrapper(args)} +); + const Tab = createBottomTabNavigator(); /** @@ -89,38 +96,40 @@ function TabNavigator() { }; return ( - - - - - - - - + + + + + + + + + + ); } diff --git a/src/styles/variables.ts b/src/styles/variables.ts index b09479591c19..4d037198bb8d 100644 --- a/src/styles/variables.ts +++ b/src/styles/variables.ts @@ -43,7 +43,10 @@ export default { // Blur the bar lays over what it covers. The web radius is a CSS blur; the Android one is expo-blur's own // 0 to 100 intensity, which is not the same unit, so the two are tuned to match rather than shared. floatingTabBarBlurRadius: 24, - floatingTabBarBlurIntensity: 65, + floatingTabBarBlurIntensity: 60, + // expo-blur on Android derives its radius as intensity / factor, so this puts the radius at 5, soft enough that + // shapes and colors under the bar still show through, without changing the tint the intensity sets. + floatingTabBarBlurReductionFactor: 12, // Vertical space the floating bar takes over the content it hides, so scrollable content can end above it. floatingTabBarContentInset: floatingTabBarHeight + floatingTabBarBottomInset, // styles.p3 (12) on each side of the DebugTabView row plus the View button (componentSizeNormal). From 7adbdd0a732731a6cc000dd2b6536eb101972b57 Mon Sep 17 00:00:00 2001 From: sumo_slonik Date: Wed, 30 Sep 2026 10:28:57 +0200 Subject: [PATCH 21/71] feat: native Material tab bar on Android, flat floating bar on mobile web Drops the blur layer and expo-blur, and removes dead reveal and lazy-split code. --- cspell.json | 1 - package-lock.json | 12 - package.json | 1 - patches/react-native-screens/details.md | 19 +- ...s+4.25.0+003+tabs-container-reattach.patch | 34 +++ ...s+4.25.0+004+bold-selected-tab-label.patch | 28 ++ ...s+7.16.2+002+android-tab-item-colors.patch | 113 ++++++++ patches/react-navigation/details.md | 10 + .../TabBarBlurTarget/index.android.tsx | 41 --- .../TabBarBlur/TabBarBlurTarget/index.tsx | 10 - .../TabBarBlur/TabBarBlurTarget/types.ts | 8 - .../TabBarBlur/TabBarBlurTargetContext.tsx | 49 ---- .../TabBarBlur/index.android.tsx | 36 --- .../NavigationTabBar/TabBarBlur/index.tsx | 23 -- .../Navigation/NavigationTabBar/index.tsx | 2 - .../{index.ios.ts => index.native.ts} | 2 +- .../index.ts | 4 +- ...B_SCREENS.ios.ts => TAB_SCREENS.native.ts} | 2 +- .../Navigators/TabNavigator.android.tsx | 270 ++++++++++++++++++ .../Navigators/TabNavigator.ios.tsx | 92 +++--- .../AppNavigator/Navigators/TabNavigator.tsx | 158 +++++----- .../Navigators/TabNavigatorBar.tsx | 35 ++- .../Navigators/tabScreens.native.tsx | 18 -- .../AppNavigator/Navigators/tabScreens.tsx | 62 ---- .../PlatformStackNavigation/ScreenLayout.tsx | 18 +- src/libs/Navigation/closingScreens.ts | 42 --- .../helpers/getLeafScreenNameAfterPop.ts | 30 -- .../Navigation/helpers/isTabRouteAtRoot.ts | 1 - .../inbox/sidebar/NavigationTabBarAvatar.tsx | 11 +- src/styles/index.ts | 44 +-- src/styles/theme/themes/dark.ts | 3 +- src/styles/theme/themes/light.ts | 3 +- src/styles/theme/types.ts | 2 +- src/styles/variables.ts | 16 +- .../buildTabNavigatorNestedStateTests.ts | 27 +- 35 files changed, 706 insertions(+), 521 deletions(-) create mode 100644 patches/react-native-screens/react-native-screens+4.25.0+003+tabs-container-reattach.patch create mode 100644 patches/react-native-screens/react-native-screens+4.25.0+004+bold-selected-tab-label.patch create mode 100644 patches/react-navigation/@react-navigation+bottom-tabs+7.16.2+002+android-tab-item-colors.patch delete mode 100644 src/components/Navigation/NavigationTabBar/TabBarBlur/TabBarBlurTarget/index.android.tsx delete mode 100644 src/components/Navigation/NavigationTabBar/TabBarBlur/TabBarBlurTarget/index.tsx delete mode 100644 src/components/Navigation/NavigationTabBar/TabBarBlur/TabBarBlurTarget/types.ts delete mode 100644 src/components/Navigation/NavigationTabBar/TabBarBlur/TabBarBlurTargetContext.tsx delete mode 100644 src/components/Navigation/NavigationTabBar/TabBarBlur/index.android.tsx delete mode 100644 src/components/Navigation/NavigationTabBar/TabBarBlur/index.tsx rename src/hooks/useFloatingTabBarContentInsetStyle/{index.ios.ts => index.native.ts} (54%) rename src/libs/Navigation/AppNavigator/Navigators/{TAB_SCREENS.ios.ts => TAB_SCREENS.native.ts} (79%) create mode 100644 src/libs/Navigation/AppNavigator/Navigators/TabNavigator.android.tsx delete mode 100644 src/libs/Navigation/AppNavigator/Navigators/tabScreens.native.tsx delete mode 100644 src/libs/Navigation/AppNavigator/Navigators/tabScreens.tsx delete mode 100644 src/libs/Navigation/closingScreens.ts delete mode 100644 src/libs/Navigation/helpers/getLeafScreenNameAfterPop.ts diff --git a/cspell.json b/cspell.json index e88d52c10dca..2125b4863441 100644 --- a/cspell.json +++ b/cspell.json @@ -609,7 +609,6 @@ "devportal", "diems", "dimen", - "dimezis", "directfeeds", "dispatchable", "displaystatus", diff --git a/package-lock.json b/package-lock.json index 8c5be366abbc..1e05d3c26ea8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -71,7 +71,6 @@ "expo": "57.0.8", "expo-asset": "57.0.7", "expo-audio": "57.0.3", - "expo-blur": "57.0.3", "expo-font": "57.0.1", "expo-image": "57.0.1", "expo-image-manipulator": "57.0.6", @@ -27116,17 +27115,6 @@ "react-native": "*" } }, - "node_modules/expo-blur": { - "version": "57.0.3", - "resolved": "https://registry.npmjs.org/expo-blur/-/expo-blur-57.0.3.tgz", - "integrity": "sha512-uyBeJwxIuXzWXXNrnRub5pcF8VzQsiOz0VBTWDpMG2UztmaFyfAz6pI/GCwRtWpHhiOpLoJj79WLdg1GMB+9cg==", - "license": "MIT", - "peerDependencies": { - "expo": "*", - "react": "*", - "react-native": "*" - } - }, "node_modules/expo-constants": { "version": "57.0.7", "resolved": "https://registry.npmjs.org/expo-constants/-/expo-constants-57.0.7.tgz", diff --git a/package.json b/package.json index cd7cf6199a2a..3ef520d45993 100644 --- a/package.json +++ b/package.json @@ -147,7 +147,6 @@ "expo": "57.0.8", "expo-asset": "57.0.7", "expo-audio": "57.0.3", - "expo-blur": "57.0.3", "expo-font": "57.0.1", "expo-image": "57.0.1", "expo-image-manipulator": "57.0.6", diff --git a/patches/react-native-screens/details.md b/patches/react-native-screens/details.md index 3900d5eaec4a..84486ba44601 100644 --- a/patches/react-native-screens/details.md +++ b/patches/react-native-screens/details.md @@ -7,11 +7,24 @@ - E/App issue: [#93842](https://github.com/Expensify/App/issues/93842) - PR introducing patch: [#98632](https://github.com/Expensify/App/pull/98632) - ### [react-native-screens+4.25.0+002+animate-hiding-the-native-tab-bar.patch](react-native-screens+4.25.0+002+animate-hiding-the-native-tab-bar.patch) - Reason: The App hides the native tab bar on screens that are not a tab root, which React Navigation forwards to RNScreens as `tabBarHidden` (it derives the flag from `tabBarStyle.display === 'none'`). RNScreens calls UIKit's `setTabBarHidden:animated:` with `animated:NO`, so the bar blinks in and out instead of travelling with the screen that hid it. The patch passes `YES` at both call sites, which lets UIKit run its own show/hide animation. The pre-iOS 18 branch is left alone: it assigns `tabBar.hidden` directly and has no animated counterpart. - Upstream PR/issue: not reported yet. -- E/App issue: n/a — found while building the native tab bar in `TabNavigator.ios.tsx`. -- PR introducing patch: n/a +- E/App issue: [#101169](https://github.com/Expensify/App/issues/101169) +- PR introducing patch: [#101339](https://github.com/Expensify/App/pull/101339) + +### [react-native-screens+4.25.0+003+tabs-container-reattach.patch](react-native-screens+4.25.0+003+tabs-container-reattach.patch) + +- Reason: Pushing any opaque screen onto the root native stack over the tab navigator leaves the tab content permanently blank once that screen is popped. `ScreenStack.kt` computes a null `visibleBottom` when the new top is opaque and then removes every wrapper below it, so the whole `TAB_NAVIGATOR` `ScreenStackFragment` is removed rather than detached, taking its child FragmentManager and the selected tab's `TabsScreenFragment` with it. On the pop, `TabsContainer.onAttachedToWindow` calls `setupFragmentManager()`, which hands back a fresh and empty child FragmentManager, and then `flushPendingUpdates()`, which is guarded by `invalidationFlags.any()`. Nothing invalidated the container, because the JS side sends no new navigation state when the selected tab did not change, so the flush is a no-op and the content FrameLayout stays empty forever. The patch re-adds the selected tab's fragment to the new FragmentManager on re-attach. +- The same re-attach also trips `ColorSchemeCoordinator.setup`, which opens with `check(!isSetUp)`. `teardown()` is the only thing that clears the flag and the package never calls it, and the re-attach does not always come with a matching detach, so the patch calls `teardown()` both from `onDetachedFromWindow` and immediately before `setup()`. +- Upstream PR/issue: not reported yet. +- E/App issue: [#101169](https://github.com/Expensify/App/issues/101169) +- PR introducing patch: [#101339](https://github.com/Expensify/App/pull/101339) + +### [react-native-screens+4.25.0+004+bold-selected-tab-label.patch](react-native-screens+4.25.0+004+bold-selected-tab-label.patch) +- Reason: The Android tab bar takes one font weight for every label, while the design marks the selected tab with a bold label, the same way the JS side bar does. Material's `BottomNavigationView` draws each item with two labels, a small one shown while unselected and a large one shown while selected, so the patch gives the large label the bold weight of the same font family. +- Upstream PR/issue: not reported yet. +- E/App issue: [#101169](https://github.com/Expensify/App/issues/101169) +- PR introducing patch: [#101339](https://github.com/Expensify/App/pull/101339) diff --git a/patches/react-native-screens/react-native-screens+4.25.0+003+tabs-container-reattach.patch b/patches/react-native-screens/react-native-screens+4.25.0+003+tabs-container-reattach.patch new file mode 100644 index 000000000000..9232a3c76fde --- /dev/null +++ b/patches/react-native-screens/react-native-screens+4.25.0+003+tabs-container-reattach.patch @@ -0,0 +1,34 @@ +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 3342c41..ee3829e 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 +@@ -267,6 +267,21 @@ class TabsContainer internal constructor( + setupFragmentManager() + flushPendingUpdates() + ++ // Pushing an opaque screen over the tab host removes this view's own fragment, which destroys the child ++ // FragmentManager that held the selected tab. setupFragmentManager hands back a fresh, empty one and ++ // nothing else re-adds the tab, because navState still names it and so the JS side sends no update. ++ // Without this the content area stays empty until another tab is picked. ++ val selectedFragment = if (navState.isNotEmpty()) getFragmentForScreenKey(navState.selectedScreenKey) else null ++ if (selectedFragment != null && !selectedFragment.isAdded) { ++ requireFragmentManager ++ .createTransactionWithReordering() ++ .add(contentView.id, selectedFragment) ++ .commitNowAllowingStateLoss() ++ } ++ ++ // A re-attach does not always come with a matching detach, and setup() throws on a second call, so the ++ // coordinator is reset first. teardown() does nothing when it was never set up. ++ colorSchemeCoordinator.teardown() + colorSchemeCoordinator.setup(this) { uiNightMode -> + applyDayNightUiMode(uiNightMode) + } +@@ -275,6 +290,7 @@ class TabsContainer internal constructor( + override fun onDetachedFromWindow() { + super.onDetachedFromWindow() + teardownFragmentManager() ++ colorSchemeCoordinator.teardown() + } + + override fun onConfigurationChanged(newConfig: Configuration?) { diff --git a/patches/react-native-screens/react-native-screens+4.25.0+004+bold-selected-tab-label.patch b/patches/react-native-screens/react-native-screens+4.25.0+004+bold-selected-tab-label.patch new file mode 100644 index 000000000000..a5287c5822ec --- /dev/null +++ b/patches/react-native-screens/react-native-screens+4.25.0+004+bold-selected-tab-label.patch @@ -0,0 +1,28 @@ +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 +--- 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 +@@ -145,6 +145,15 @@ + context.assets, + ) + ++ // The selected tab's label is drawn bold, the way the app marks a selected tab everywhere else. ++ val activeFontFamily = ++ ReactFontManager.getInstance().getTypeface( ++ tabBarAppearance?.tabBarItemTitleFontFamily ?: "", ++ maxOf(fontWeight, 700), ++ isFontStyleItalic, ++ context.assets, ++ ) ++ + /* + Short explanation about computations we're doing below. + R.dimen, has defined value in SP, getDimension converts it to pixels, and by default +@@ -167,7 +176,7 @@ + + // Active + largeLabel.setTextSize(TypedValue.COMPLEX_UNIT_PX, largeFontSize) +- largeLabel.typeface = fontFamily ++ largeLabel.typeface = activeFontFamily + } + } + diff --git a/patches/react-navigation/@react-navigation+bottom-tabs+7.16.2+002+android-tab-item-colors.patch b/patches/react-navigation/@react-navigation+bottom-tabs+7.16.2+002+android-tab-item-colors.patch new file mode 100644 index 000000000000..f7e43f14b342 --- /dev/null +++ b/patches/react-navigation/@react-navigation+bottom-tabs+7.16.2+002+android-tab-item-colors.patch @@ -0,0 +1,113 @@ +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 1183c51..303635c 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 +@@ -160,7 +160,7 @@ export function NativeBottomTabView({ + ios: PlatformColor('label'), + default: colors.text + }); +- const activeIndicatorColor = currentOptions?.tabBarActiveIndicatorColor ?? typeof activeTintColor === 'string' ? Color(activeTintColor)?.alpha(0.1).string() : undefined; ++ const activeIndicatorColor = currentOptions?.tabBarActiveIndicatorColor ?? (typeof activeTintColor === 'string' ? Color(activeTintColor)?.alpha(0.1).string() : undefined); + const onTransitionStart = ({ + closing, + route +@@ -253,11 +253,11 @@ export function NativeBottomTabView({ + tabBarItemBadgeBackgroundColor: badgeBackgroundColor + }; + const normalTabItemAppearance = { +- tabBarItemTitleFontColor: inactiveTintColor ?? fontColor, ++ tabBarItemTitleFontColor: currentOptions.tabBarInactiveLabelColor ?? inactiveTintColor ?? fontColor, + tabBarItemIconColor: inactiveTintColor + }; + const selectedTabItemAppearance = { +- tabBarItemTitleFontColor: activeTintColor, ++ tabBarItemTitleFontColor: currentOptions.tabBarActiveLabelColor ?? activeTintColor, + tabBarItemIconColor: activeTintColor + }; + const icon = typeof tabBarIcon === 'function' ? getPlatformIcon(tabBarIcon({ +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 5a502c2..a071119 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 +@@ -156,6 +156,18 @@ export type NativeBottomTabNavigationOptions = NativeHeaderOptions & { + * @platform android + */ + tabBarInactiveTintColor?: ColorValue; ++ /** ++ * Color for the label in the active tab, overriding `tabBarActiveTintColor` for the label only. ++ * ++ * @platform android ++ */ ++ tabBarActiveLabelColor?: ColorValue; ++ /** ++ * Color for the label in the inactive tabs, overriding `tabBarInactiveTintColor` for the label only. ++ * ++ * @platform android ++ */ ++ tabBarInactiveLabelColor?: ColorValue; + /** + * Background color of the active indicator. + * +diff --git a/node_modules/@react-navigation/bottom-tabs/src/unstable/NativeBottomTabView.native.tsx b/node_modules/@react-navigation/bottom-tabs/src/unstable/NativeBottomTabView.native.tsx +index 287dea3..0f06b2b 100644 +--- a/node_modules/@react-navigation/bottom-tabs/src/unstable/NativeBottomTabView.native.tsx ++++ b/node_modules/@react-navigation/bottom-tabs/src/unstable/NativeBottomTabView.native.tsx +@@ -262,10 +262,10 @@ export function NativeBottomTabView({ state, navigation, descriptors }: Props) { + }); + + const activeIndicatorColor = +- (currentOptions?.tabBarActiveIndicatorColor ?? +- typeof activeTintColor === 'string') ++ currentOptions?.tabBarActiveIndicatorColor ?? ++ (typeof activeTintColor === 'string' + ? Color(activeTintColor)?.alpha(0.1).string() +- : undefined; ++ : undefined); + + const onTransitionStart = ({ + closing, +@@ -387,13 +387,17 @@ export function NativeBottomTabView({ state, navigation, descriptors }: Props) { + + const normalTabItemAppearance: TabsScreenItemStateAppearanceAndroid = + { +- tabBarItemTitleFontColor: inactiveTintColor ?? fontColor, ++ tabBarItemTitleFontColor: ++ currentOptions.tabBarInactiveLabelColor ?? ++ inactiveTintColor ?? ++ fontColor, + tabBarItemIconColor: inactiveTintColor, + }; + + const selectedTabItemAppearance: TabsScreenItemStateAppearanceAndroid = + { +- tabBarItemTitleFontColor: activeTintColor, ++ tabBarItemTitleFontColor: ++ currentOptions.tabBarActiveLabelColor ?? activeTintColor, + tabBarItemIconColor: activeTintColor, + }; + +diff --git a/node_modules/@react-navigation/bottom-tabs/src/unstable/types.tsx b/node_modules/@react-navigation/bottom-tabs/src/unstable/types.tsx +index a2471f5..d9df793 100644 +--- a/node_modules/@react-navigation/bottom-tabs/src/unstable/types.tsx ++++ b/node_modules/@react-navigation/bottom-tabs/src/unstable/types.tsx +@@ -200,6 +200,20 @@ export type NativeBottomTabNavigationOptions = NativeHeaderOptions & { + */ + tabBarInactiveTintColor?: ColorValue; + ++ /** ++ * Color for the label in the active tab, overriding `tabBarActiveTintColor` for the label only. ++ * ++ * @platform android ++ */ ++ tabBarActiveLabelColor?: ColorValue; ++ ++ /** ++ * Color for the label in the inactive tabs, overriding `tabBarInactiveTintColor` for the label only. ++ * ++ * @platform android ++ */ ++ tabBarInactiveLabelColor?: ColorValue; ++ + /** + * Background color of the active indicator. + * diff --git a/patches/react-navigation/details.md b/patches/react-navigation/details.md index ff4aae0c72e9..2a12aec1e444 100644 --- a/patches/react-navigation/details.md +++ b/patches/react-navigation/details.md @@ -67,3 +67,13 @@ - Upstream PR/issue: https://github.com/react-navigation/react-navigation/issues/11145 - E/App issue: [#94571](https://github.com/Expensify/App/issues/94571) - PR Introducing Patch: [#95980](https://github.com/Expensify/App/pull/95980) + +### [@react-navigation+bottom-tabs+7.16.2+002+android-tab-item-colors.patch](@react-navigation+bottom-tabs+7.16.2+002+android-tab-item-colors.patch) + +- Reason: Lets the native Android tab bar take the colors from the design, where the selected tab's icon and active indicator are green and its label keeps the regular text color. + - `tabBarActiveIndicatorColor` was never applied: a missing pair of parentheses made `??` bind to the `typeof` check, so the indicator always got `tabBarActiveTintColor` at 10% alpha. The patch restores the intended precedence. + - `tabBarActiveTintColor` and `tabBarInactiveTintColor` each color an icon and its label together. The patch adds Android-only `tabBarActiveLabelColor` and `tabBarInactiveLabelColor`, which override the tint for the label only. The native `TabsScreenItemStateAppearanceAndroid` already takes separate title and icon colors, so nothing native changes. +- Upstream PR/issue: not reported yet. +- E/App issue: [#101169](https://github.com/Expensify/App/issues/101169) +- PR Introducing Patch: [#101339](https://github.com/Expensify/App/pull/101339) +- PR Updating Patch: N/A diff --git a/src/components/Navigation/NavigationTabBar/TabBarBlur/TabBarBlurTarget/index.android.tsx b/src/components/Navigation/NavigationTabBar/TabBarBlur/TabBarBlurTarget/index.android.tsx deleted file mode 100644 index a870e2783ee9..000000000000 --- a/src/components/Navigation/NavigationTabBar/TabBarBlur/TabBarBlurTarget/index.android.tsx +++ /dev/null @@ -1,41 +0,0 @@ -import {useTabBarBlurTargetActions} from '@components/Navigation/NavigationTabBar/TabBarBlur/TabBarBlurTargetContext'; - -import useThemeStyles from '@hooks/useThemeStyles'; - -import type {View} from 'react-native'; - -import {useIsFocused} from '@react-navigation/native'; -import {BlurTargetView} from 'expo-blur'; -import React, {useEffect, useRef} from 'react'; - -import type TabBarBlurTargetProps from './types'; - -/** - * Wraps a tab scene in the view the floating tab bar's blur samples from. The blur has to point at a sibling of - * itself, not a parent, so the scene is the target and the bar stays outside it. - */ -function TabBarBlurTarget({children}: TabBarBlurTargetProps) { - const styles = useThemeStyles(); - const targetRef = useRef(null); - const isFocused = useIsFocused(); - const {setBlurTarget} = useTabBarBlurTargetActions(); - - useEffect(() => { - if (!isFocused) { - return; - } - setBlurTarget(targetRef); - }, [isFocused, setBlurTarget]); - - return ( - - {children} - - ); -} - -export default TabBarBlurTarget; diff --git a/src/components/Navigation/NavigationTabBar/TabBarBlur/TabBarBlurTarget/index.tsx b/src/components/Navigation/NavigationTabBar/TabBarBlur/TabBarBlurTarget/index.tsx deleted file mode 100644 index 703f6303bce4..000000000000 --- a/src/components/Navigation/NavigationTabBar/TabBarBlur/TabBarBlurTarget/index.tsx +++ /dev/null @@ -1,10 +0,0 @@ -import type TabBarBlurTargetProps from './types'; - -/** - * The web bar blurs through a CSS backdrop filter, which needs no view to sample from, so the scene renders as is. - */ -function TabBarBlurTarget({children}: TabBarBlurTargetProps) { - return children; -} - -export default TabBarBlurTarget; diff --git a/src/components/Navigation/NavigationTabBar/TabBarBlur/TabBarBlurTarget/types.ts b/src/components/Navigation/NavigationTabBar/TabBarBlur/TabBarBlurTarget/types.ts deleted file mode 100644 index 2260a842c8a3..000000000000 --- a/src/components/Navigation/NavigationTabBar/TabBarBlur/TabBarBlurTarget/types.ts +++ /dev/null @@ -1,8 +0,0 @@ -import type React from 'react'; - -type TabBarBlurTargetProps = { - /** The tab scene the floating tab bar lays over */ - children: React.ReactNode; -}; - -export default TabBarBlurTargetProps; diff --git a/src/components/Navigation/NavigationTabBar/TabBarBlur/TabBarBlurTargetContext.tsx b/src/components/Navigation/NavigationTabBar/TabBarBlur/TabBarBlurTargetContext.tsx deleted file mode 100644 index 7006e767a9d7..000000000000 --- a/src/components/Navigation/NavigationTabBar/TabBarBlur/TabBarBlurTargetContext.tsx +++ /dev/null @@ -1,49 +0,0 @@ -import type {RefObject} from 'react'; -import type {View} from 'react-native'; - -import React, {createContext, useContext, useState} from 'react'; - -type BlurTarget = RefObject | null; - -type TabBarBlurTargetActionsContextType = { - setBlurTarget: (blurTarget: BlurTarget) => void; -}; - -type TabBarBlurTargetContextProviderProps = { - children: React.ReactNode; -}; - -const TabBarBlurTargetStateContext = createContext(null); - -const TabBarBlurTargetActionsContext = createContext({ - setBlurTarget: () => {}, -}); - -/** - * Tells the floating tab bar which view it lays over. Each tab scene registers its own view when it gains focus, - * so the bar always blurs the scene the user is looking at rather than one that is mounted but hidden. - */ -function TabBarBlurTargetContextProvider({children}: TabBarBlurTargetContextProviderProps) { - const [blurTarget, setBlurTarget] = useState(null); - - // Because of the React Compiler we don't need to memoize it manually - // eslint-disable-next-line react/jsx-no-constructed-context-values - const actionsContextValue = {setBlurTarget}; - - return ( - - {children} - - ); -} - -function useTabBarBlurTarget(): BlurTarget { - return useContext(TabBarBlurTargetStateContext); -} - -function useTabBarBlurTargetActions(): TabBarBlurTargetActionsContextType { - return useContext(TabBarBlurTargetActionsContext); -} - -export default TabBarBlurTargetContextProvider; -export {useTabBarBlurTarget, useTabBarBlurTargetActions}; diff --git a/src/components/Navigation/NavigationTabBar/TabBarBlur/index.android.tsx b/src/components/Navigation/NavigationTabBar/TabBarBlur/index.android.tsx deleted file mode 100644 index f260e2a9ef69..000000000000 --- a/src/components/Navigation/NavigationTabBar/TabBarBlur/index.android.tsx +++ /dev/null @@ -1,36 +0,0 @@ -import useTheme from '@hooks/useTheme'; -import useThemeStyles from '@hooks/useThemeStyles'; - -import variables from '@styles/variables'; - -import CONST from '@src/CONST'; - -import {BlurView} from 'expo-blur'; -import React from 'react'; - -import {useTabBarBlurTarget} from './TabBarBlurTargetContext'; - -/** - * Blurs whatever the floating tab bar covers, so the bar reads as a glass capsule the way UIKit draws it on iOS. - * Android has no backdrop filter of its own, so the blur comes from a native view that samples the focused tab - * scene. Until a scene registers itself there is nothing to sample, and the view paints only its tint. The tints - * are expo-blur's white and dark grey materials, which give the milky wash UIKit's glass lays over what it blurs. - */ -function TabBarBlur() { - const styles = useThemeStyles(); - const theme = useTheme(); - const blurTarget = useTabBarBlurTarget(); - - return ( - - ); -} - -export default TabBarBlur; diff --git a/src/components/Navigation/NavigationTabBar/TabBarBlur/index.tsx b/src/components/Navigation/NavigationTabBar/TabBarBlur/index.tsx deleted file mode 100644 index cb0af14f37cc..000000000000 --- a/src/components/Navigation/NavigationTabBar/TabBarBlur/index.tsx +++ /dev/null @@ -1,23 +0,0 @@ -import useThemeStyles from '@hooks/useThemeStyles'; - -import variables from '@styles/variables'; - -import type {ViewStyle} from 'react-native'; - -import React from 'react'; -import {View} from 'react-native'; - -/** - * Blurs whatever the floating tab bar covers, so the bar reads as a glass capsule the way UIKit draws it on iOS. - * The web build gets the effect from the CSS backdrop filter, which react-native-web forwards and prefixes but - * React Native's own style types do not carry. - */ -const backdropFilterStyle = {backdropFilter: `blur(${variables.floatingTabBarBlurRadius}px)`} as ViewStyle; - -function TabBarBlur() { - const styles = useThemeStyles(); - - return ; -} - -export default TabBarBlur; diff --git a/src/components/Navigation/NavigationTabBar/index.tsx b/src/components/Navigation/NavigationTabBar/index.tsx index 85de538cc13a..1d6da466e63d 100644 --- a/src/components/Navigation/NavigationTabBar/index.tsx +++ b/src/components/Navigation/NavigationTabBar/index.tsx @@ -35,7 +35,6 @@ import InboxTabButton from './InboxTabButton'; import InsightsTabButton from './InsightsTabButton'; import NAVIGATION_TABS from './NAVIGATION_TABS'; import SearchTabButton from './SearchTabButton'; -import TabBarBlur from './TabBarBlur'; import TabBarItem from './TabBarItem'; import WorkspacesTabButton from './WorkspacesTabButton'; @@ -162,7 +161,6 @@ function NavigationTabBar({selectedTab, shouldShowFloatingButtons = true}: Navig style={styles.navigationTabBarContainer} testID="NavigationTabBar" > - { return undefined; } diff --git a/src/hooks/useFloatingTabBarContentInsetStyle/index.ts b/src/hooks/useFloatingTabBarContentInsetStyle/index.ts index 12c4971b5243..11b196072f9b 100644 --- a/src/hooks/useFloatingTabBarContentInsetStyle/index.ts +++ b/src/hooks/useFloatingTabBarContentInsetStyle/index.ts @@ -5,8 +5,8 @@ import type {StyleProp, ViewStyle} from 'react-native'; /** * Bottom padding a tab root screen's scrollable content needs so its last row settles above the floating tab - * bar instead of under it. Android and mobile web draw that bar in JS; iOS takes the sibling module, because - * UITabBar reports its own height through the safe area and needs no extra room. + * bar instead of under it. Mobile web draws that bar in JS; iOS and Android take the sibling module, because + * the native bar keeps the content clear of itself and needs no extra room. */ function useFloatingTabBarContentInsetStyle(): StyleProp { const styles = useThemeStyles(); diff --git a/src/libs/Navigation/AppNavigator/Navigators/TAB_SCREENS.ios.ts b/src/libs/Navigation/AppNavigator/Navigators/TAB_SCREENS.native.ts similarity index 79% rename from src/libs/Navigation/AppNavigator/Navigators/TAB_SCREENS.ios.ts rename to src/libs/Navigation/AppNavigator/Navigators/TAB_SCREENS.native.ts index 575fc430519b..bf40bfd4cc23 100644 --- a/src/libs/Navigation/AppNavigator/Navigators/TAB_SCREENS.ios.ts +++ b/src/libs/Navigation/AppNavigator/Navigators/TAB_SCREENS.native.ts @@ -1,6 +1,6 @@ /** * Ordered list of screen names registered inside TabNavigator. - * This must match the Tab.Screen order in TabNavigator.ios.tsx, where the order is also the order the native + * This must match the Tab.Screen order in TabNavigator.ios.tsx and TabNavigator.android.tsx, where the order is also the order the native * tab bar draws its items in, so Workspaces comes before Account. * Used by getAdaptedStateFromPath to build complete tab navigator state for deep-links. */ diff --git a/src/libs/Navigation/AppNavigator/Navigators/TabNavigator.android.tsx b/src/libs/Navigation/AppNavigator/Navigators/TabNavigator.android.tsx new file mode 100644 index 000000000000..ebbc2ed08711 --- /dev/null +++ b/src/libs/Navigation/AppNavigator/Navigators/TabNavigator.android.tsx @@ -0,0 +1,270 @@ +import buildingsIcon from '@assets/images/native-tab-icons/buildings.png'; +import homeIcon from '@assets/images/native-tab-icons/home.png'; +import inboxIcon from '@assets/images/native-tab-icons/inbox.png'; +import profileIcon from '@assets/images/native-tab-icons/profile.png'; +import receiptMultipleIcon from '@assets/images/native-tab-icons/receipt-multiple.png'; + +import FloatingCameraButton from '@components/FloatingCameraButton'; +import FloatingGPSButton from '@components/FloatingGPSButton'; +import {useFullScreenBlockingViewState} from '@components/FullScreenBlockingViewContextProvider'; +import DebugTabView from '@components/Navigation/DebugTabView'; +import NavigationTabBar from '@components/Navigation/NavigationTabBar'; +import NAVIGATION_TABS from '@components/Navigation/NavigationTabBar/NAVIGATION_TABS'; +import ROUTE_TO_NAVIGATION_TAB from '@components/Navigation/NavigationTabBar/ROUTE_TO_NAVIGATION_TAB'; + +import useAccountTabIndicatorStatus from '@hooks/useAccountTabIndicatorStatus'; +import useLocalize from '@hooks/useLocalize'; +import useOnyx from '@hooks/useOnyx'; +import useResponsiveLayout from '@hooks/useResponsiveLayout'; +import useSafeAreaInsets from '@hooks/useSafeAreaInsets'; +import {useSidebarOrderedReportsState} from '@hooks/useSidebarOrderedReports'; +import useTheme from '@hooks/useTheme'; +import useThemeStyles from '@hooks/useThemeStyles'; +import useWorkspacesTabIndicatorStatus from '@hooks/useWorkspacesTabIndicatorStatus'; + +import {getPreservedNavigatorState, setPreservedNavigatorState} from '@libs/Navigation/AppNavigator/createSplitNavigator/usePreserveNavigatorState'; +import isTabRouteAtRoot from '@libs/Navigation/helpers/isTabRouteAtRoot'; +import {nativeBottomTabScreenLayoutWrapper} from '@libs/Navigation/PlatformStackNavigation/ScreenLayout'; +import type {TabNavigatorParamList} from '@libs/Navigation/types'; +import cancelTabNavigationSpans, {INBOX_TAB_SPAN_IDS, REPORTS_TAB_SPAN_IDS} from '@libs/telemetry/cancelTabNavigationSpans'; + +import HomePage from '@pages/home/HomePage'; +import NavigationTabBarFloatingActionButton from '@pages/inbox/sidebar/NavigationTabBarFloatingActionButton'; + +import CONST from '@src/CONST'; +import NAVIGATORS from '@src/NAVIGATORS'; +import ONYXKEYS from '@src/ONYXKEYS'; +import SCREENS from '@src/SCREENS'; + +import type {NativeBottomTabIcon, NativeBottomTabNavigationOptions, NativeBottomTabNavigatorProps} from '@react-navigation/bottom-tabs/unstable'; +import type {NavigationAction, NavigationState, PartialState, Router, TabNavigationState} from '@react-navigation/native'; + +import {createNativeBottomTabNavigator} from '@react-navigation/bottom-tabs/unstable'; +import {findFocusedRoute, useNavigation, useNavigationState, useRoute} from '@react-navigation/native'; +import React, {useEffect} from 'react'; +import {View} from 'react-native'; +import Animated, {FadeIn, FadeOut} from 'react-native-reanimated'; + +import ReportsSplitNavigator from './ReportsSplitNavigator'; +import SearchFullscreenNavigator from './SearchFullscreenNavigator'; +import SettingsSplitNavigator from './SettingsSplitNavigator'; +import WorkspaceNavigator from './WorkspaceNavigator'; + +/** + * Tab Navigator backed by Material's BottomNavigationView, with the Material 3 active indicator pill, ripples, and + * Material badges for the status dots. Wide layouts keep the JS side bar, since the native bar cannot be moved to + * the side of the screen. + */ +const Tab = createNativeBottomTabNavigator(); + +/** + * Plain glyphs, recolored by the bar's own icon tint for both selection states. The account tab uses the profile + * glyph rather than the avatar, because Android tints every tab icon and would flatten a photo to a silhouette. + */ +const HOME_TAB_ICON = {type: 'image', source: homeIcon} as const satisfies NativeBottomTabIcon; +const INBOX_TAB_ICON = {type: 'image', source: inboxIcon} as const satisfies NativeBottomTabIcon; +const SPEND_TAB_ICON = {type: 'image', source: receiptMultipleIcon} as const satisfies NativeBottomTabIcon; +const WORKSPACES_TAB_ICON = {type: 'image', source: buildingsIcon} as const satisfies NativeBottomTabIcon; +const ACCOUNT_TAB_ICON = {type: 'image', source: profileIcon} as const satisfies NativeBottomTabIcon; + +/** An empty badge value makes Material draw its small dot badge instead of a number. */ +const STATUS_DOT_BADGE = ''; + +/** + * Root-level tab screens where the swipe-back gesture should be disabled. + * Swiping from these screens would pop the entire TAB_NAVIGATOR, which feels wrong. + * WORKSPACE.INITIAL is intentionally excluded — swiping back from it returns to the workspace list. + */ +const TAB_ROOT_SCREENS_WITHOUT_GESTURE = new Set([SCREENS.HOME, SCREENS.INBOX, SCREENS.SEARCH.ROOT, SCREENS.SETTINGS.ROOT]); + +type NativeTabLayoutProps = Parameters>[0]; + +/** stale === false distinguishes a fully realized NavigationState from a PartialState. */ +function isRealizedNavigationState(state: NavigationState | PartialState | undefined): state is NavigationState { + return state?.stale === false; +} + +/** Shows a status as a Material dot badge in its own color, or no badge at all. */ +function getStatusBadgeOptions(dotColor: string | undefined): Pick { + if (!dotColor) { + return {tabBarBadge: undefined}; + } + return {tabBarBadge: STATUS_DOT_BADGE, tabBarBadgeStyle: {backgroundColor: dotColor}}; +} + +/** + * Wraps the tab screens so the floating buttons, the debug view and the wide-layout side bar can be drawn over + * them. The native bar is part of the navigator itself, so it is switched off through `tabBarStyle` in the + * navigator's screen options instead of being unmounted. + */ +function NativeTabLayout({children, state, descriptors}: NativeTabLayoutProps) { + const {shouldUseNarrowLayout} = useResponsiveLayout(); + const [isDebugModeEnabled] = useOnyx(ONYXKEYS.IS_DEBUG_MODE_ENABLED); + const styles = useThemeStyles(); + const {bottom: bottomInset} = useSafeAreaInsets(); + const activeRoute = state.routes[state.index]; + const selectedTab = ROUTE_TO_NAVIGATION_TAB[activeRoute?.name ?? SCREENS.HOME] ?? NAVIGATION_TABS.HOME; + // The buttons follow whatever the navigator decided for the bar itself. + const shouldShowNativeTabBar = shouldUseNarrowLayout && !!activeRoute && descriptors[activeRoute.key]?.options.tabBarStyle?.display !== 'none'; + + if (!shouldUseNarrowLayout) { + return ( + + + + + {children} + + ); + } + + return ( + + {children} + {!!isDebugModeEnabled && shouldShowNativeTabBar && } + {shouldShowNativeTabBar && ( + + + + + + + + )} + + ); +} + +const renderNativeTabLayout = (props: NativeTabLayoutProps) => ; + +function TabNavigator() { + const {shouldUseNarrowLayout} = useResponsiveLayout(); + const {isBlockingViewVisible} = useFullScreenBlockingViewState(); + const {translate} = useLocalize(); + const theme = useTheme(); + const styles = useThemeStyles(); + const {chatTabBrickRoad} = useSidebarOrderedReportsState(); + const {indicatorColor: workspacesIndicatorColor, status: workspacesIndicatorStatus} = useWorkspacesTabIndicatorStatus(); + const {indicatorColor: accountIndicatorColor, status: accountIndicatorStatus} = useAccountTabIndicatorStatus(); + const navigation = useNavigation(); + const parentNavigation = navigation.getParent(); + const focusedRouteName = useNavigationState((state) => findFocusedRoute(state)?.name); + const route = useRoute(); + // The Tab.Navigator's own state lives at `parentState.routes[i].state`. We can't read it via + // `useNavigationState((s) => s)` here because TabNavigator's body runs before + // 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 = isRealizedNavigationState(tabState) ? tabState.routes[tabState.index] : undefined; + const activeTabRouteName = isRealizedNavigationState(tabState) ? activeTabRoute?.name : SCREENS.HOME; + const selectedTab = ROUTE_TO_NAVIGATION_TAB[activeTabRouteName ?? SCREENS.HOME] ?? NAVIGATION_TABS.HOME; + const shouldShowNativeTabBar = shouldUseNarrowLayout && isTabRouteAtRoot(activeTabRoute) && !isBlockingViewVisible; + + let inboxDotColor: string | undefined; + if (chatTabBrickRoad) { + inboxDotColor = chatTabBrickRoad === CONST.BRICK_ROAD_INDICATOR_STATUS.INFO ? theme.iconSuccessFill : theme.danger; + } + const workspacesDotColor = workspacesIndicatorStatus ? workspacesIndicatorColor : undefined; + const accountDotColor = accountIndicatorStatus ? accountIndicatorColor : undefined; + + useEffect(() => { + if (!shouldUseNarrowLayout || !parentNavigation) { + return; + } + const isRootScreen = TAB_ROOT_SCREENS_WITHOUT_GESTURE.has(focusedRouteName ?? ''); + parentNavigation.setOptions({gestureEnabled: !isRootScreen}); + }, [focusedRouteName, shouldUseNarrowLayout, parentNavigation]); + + useEffect(() => { + if (!isRealizedNavigationState(tabState)) { + return; + } + setPreservedNavigatorState(route.key, tabState); + }, [tabState, route.key]); + + // Cancel any in-flight tab-navigation span that doesn't match the new focused tab. + // The span for the new tab is started by the tab button before navigation, so we keep it via `except`. + useEffect(() => { + let spans; + if (selectedTab === NAVIGATION_TABS.INBOX) { + spans = INBOX_TAB_SPAN_IDS; + } else if (selectedTab === NAVIGATION_TABS.SEARCH) { + spans = REPORTS_TAB_SPAN_IDS; + } + cancelTabNavigationSpans(spans); + }, [selectedTab]); + + // The slicing optimization in useCustomRootStackNavigatorState can unmount and later remount + // this TAB_NAVIGATOR. Without restoration it would default to index 0. We restore the saved + // state by overriding the bottom-tab router's getInitialState — the same pattern SplitRouter + // uses for its split navigators. + const tabRouterOverride = ( + originalRouter: Router, Action>, + ): Partial, Action>> => ({ + getInitialState: (configOptions) => { + const preserved = getPreservedNavigatorState>(route.key); + return preserved ? originalRouter.getRehydratedState(preserved, configOptions) : originalRouter.getInitialState(configOptions); + }, + }); + + // Colors and the label face come from the Expensify theme and match the side bar on wide layouts: the + // selected tab has the menu icon color and a bold label in the regular text color, the others the plain icon + // color and a supporting text label. The bar takes the raised surface tone with no top border. + const screenOptions = { + headerShown: false, + tabBarActiveTintColor: theme.iconMenu, + tabBarInactiveTintColor: theme.icon, + tabBarActiveLabelColor: theme.text, + tabBarInactiveLabelColor: theme.textSupporting, + tabBarLabelStyle: {fontFamily: styles.textSmall.fontFamily, fontSize: styles.textSmall.fontSize}, + tabBarActiveIndicatorColor: theme.androidTabBarActiveIndicatorBG, + tabBarLabelVisibilityMode: 'labeled' as const, + // Every tab shares one style, so the bar reads the current visibility in the same render that changed it. + tabBarStyle: {display: shouldShowNativeTabBar ? ('flex' as const) : ('none' as const), backgroundColor: theme.highlightBG}, + }; + + return ( + + + + + + + + ); +} + +export default TabNavigator; diff --git a/src/libs/Navigation/AppNavigator/Navigators/TabNavigator.ios.tsx b/src/libs/Navigation/AppNavigator/Navigators/TabNavigator.ios.tsx index 5fb6b230a614..f91dc85244fa 100644 --- a/src/libs/Navigation/AppNavigator/Navigators/TabNavigator.ios.tsx +++ b/src/libs/Navigation/AppNavigator/Navigators/TabNavigator.ios.tsx @@ -88,6 +88,19 @@ const TAB_ROOT_SCREENS_WITHOUT_GESTURE = new Set([SCREENS.HOME, SCREENS. type NativeTabLayoutProps = Parameters>[0]; +/** The recolored tab icons, with the signature of everything baked into them. */ +type TintedTabIcons = {signature: string; icons: Record}; + +/** The account tab's avatar in both selection states, with the signature of everything baked into them. */ +type CircularAvatarIcons = {signature: string; uri: string; active: AvatarTabIcon; inactive: AvatarTabIcon}; + +/** + * The last icons drawn, kept outside the component so a remounted navigator starts from them instead of waiting + * for Skia again. While a new theme or language is being drawn, the bar keeps showing these. + */ +let lastTintedIcons: TintedTabIcons | undefined; +let lastCircularAvatar: CircularAvatarIcons | undefined; + /** stale === false distinguishes a fully realized NavigationState from a PartialState. */ function isRealizedNavigationState(state: NavigationState | PartialState | undefined): state is NavigationState { return state?.stale === false; @@ -100,14 +113,14 @@ type TintedTabIconPair = {active: NativeBottomTabIcon; inactive: NativeBottomTab type AvatarTabIcon = {uri: string; width: number; height: number}; /** Paints the status dot in the glyph's top right corner, where a native badge would have sat. */ -function drawStatusDot(canvas: SkCanvas, glyphRight: number, scale: number, color: string) { +function drawStatusDot(canvas: SkCanvas, glyphRight: number, glyphTop: number, scale: number, color: string) { const radius = variables.nativeTabIconDotRadius * scale; const paint = Skia.Paint(); paint.setColor(Skia.Color(color)); - canvas.drawCircle(glyphRight - radius, radius, radius, paint); + canvas.drawCircle(glyphRight - radius, glyphTop + radius, radius, paint); } -/** The label's own face, matching what the bar drew before it became native: Expensify Neue, bold when selected. */ +/** The label's face: Expensify Neue, bold when selected. */ function getLabelFont(isSelected: boolean, scale: number) { const typeface = Skia.FontMgr.System().matchFamilyStyle(FontUtils.fontFamily.single.EXP_NEUE.fontFamily, { weight: isSelected ? FontWeight.Bold : FontWeight.Normal, @@ -168,12 +181,16 @@ async function createTabIcon( return undefined; } - const glyphWidth = image.width(); - const glyphHeight = image.height(); + // The glyph assets are 24 pt and are drawn at the floating bar's glyph size, the one the JS bar uses. + const glyphWidth = variables.iconFloatingTabBar * scale; + const glyphHeight = variables.iconFloatingTabBar * scale; + // The account avatar is taller than a glyph, so every glyph sits this far down to share its centre, and + // every label lands at the same height. + const glyphTop = ((variables.avatarFloatingTabBar - variables.iconFloatingTabBar) / 2) * scale; const gap = variables.nativeTabIconLabelGap * scale; const labelSize = measureLabel(label, isSelected, scale); const canvasWidth = Math.ceil(Math.max(glyphWidth, labelSize.width)); - const canvasHeight = Math.ceil(glyphHeight + gap + labelSize.height); + const canvasHeight = Math.ceil(glyphTop + glyphHeight + gap + labelSize.height); const surface = Skia.Surface.MakeOffscreen(canvasWidth, canvasHeight); if (!surface) { @@ -187,13 +204,13 @@ async function createTabIcon( const canvas = surface.getCanvas(); const glyphLeft = (canvasWidth - glyphWidth) / 2; - canvas.drawImageRect(image, Skia.XYWHRect(0, 0, glyphWidth, glyphHeight), Skia.XYWHRect(glyphLeft, 0, glyphWidth, glyphHeight), paint); + canvas.drawImageRect(image, Skia.XYWHRect(0, 0, image.width(), image.height()), Skia.XYWHRect(glyphLeft, glyphTop, glyphWidth, glyphHeight), paint); if (dotColor) { - drawStatusDot(canvas, glyphLeft + glyphWidth, scale, dotColor); + drawStatusDot(canvas, glyphLeft + glyphWidth, glyphTop, scale, dotColor); } - drawLabel(canvas, label, labelColor, isSelected, scale, canvasWidth, glyphHeight + gap); + drawLabel(canvas, label, labelColor, isSelected, scale, canvasWidth, glyphTop + glyphHeight + gap); surface.flush(); @@ -209,16 +226,18 @@ async function createTabIcon( /** * The account tab shows the user's avatar, and a tab icon has to be a square image, so the avatar is cropped to a - * circle off-screen and handed over as a data URI. It fills the same canvas as every other tab icon, so the avatar - * is drawn at the size of the glyphs next to it. + * circle off-screen and handed over as a data URI. Its canvas is as tall as every other tab icon's, with the avatar + * a little larger than the glyphs next to it, since a circle reads smaller than a glyph of the same box. */ async function createCircularAvatarIcon(uri: string, dotColor: string | undefined, label: string, labelColor: string, isSelected: boolean): Promise { const response = await fetch(uri); const encodedImage = Skia.Data.fromBytes(new Uint8Array(await response.arrayBuffer())); const image = Skia.Image.MakeImageFromEncoded(encodedImage); const scale = variables.nativeTabIconScale; - const avatarSize = variables.iconBottomBar * scale; - const gap = variables.nativeTabIconLabelGap * scale; + const avatarSize = variables.avatarFloatingTabBar * scale; + // The avatar overhangs a glyph by the same amount above and below, so its gap to the label shrinks by + // that much and the label lands where every other tab's does. + const gap = (variables.nativeTabIconLabelGap - (variables.avatarFloatingTabBar - variables.iconFloatingTabBar) / 2) * scale; const labelSize = measureLabel(label, isSelected, scale); const canvasWidth = Math.ceil(Math.max(avatarSize, labelSize.width)); const canvasHeight = Math.ceil(avatarSize + gap + labelSize.height); @@ -244,7 +263,7 @@ async function createCircularAvatarIcon(uri: string, dotColor: string | undefine canvas.restore(); if (dotColor) { - drawStatusDot(canvas, avatarLeft + avatarSize, scale, dotColor); + drawStatusDot(canvas, avatarLeft + avatarSize, 0, scale, dotColor); } drawLabel(canvas, label, labelColor, isSelected, scale, canvasWidth, avatarSize + gap); @@ -267,14 +286,14 @@ async function createCircularAvatarIcon(uri: string, dotColor: string | undefine * them. The native bar is part of the navigator itself, so it is switched off through `tabBarStyle` in the * navigator's screen options instead of being unmounted. */ -function NativeTabLayout({children, state}: NativeTabLayoutProps) { +function NativeTabLayout({children, state, descriptors}: NativeTabLayoutProps) { const {shouldUseNarrowLayout} = useResponsiveLayout(); - const {isBlockingViewVisible} = useFullScreenBlockingViewState(); const [isDebugModeEnabled] = useOnyx(ONYXKEYS.IS_DEBUG_MODE_ENABLED); const styles = useThemeStyles(); const activeRoute = state.routes[state.index]; const selectedTab = ROUTE_TO_NAVIGATION_TAB[activeRoute?.name ?? SCREENS.HOME] ?? NAVIGATION_TABS.HOME; - const shouldShowNativeTabBar = shouldUseNarrowLayout && isTabRouteAtRoot(activeRoute) && !isBlockingViewVisible; + // The buttons follow whatever the navigator decided for the bar itself. + const shouldShowNativeTabBar = shouldUseNarrowLayout && !!activeRoute && descriptors[activeRoute.key]?.options.tabBarStyle?.display !== 'none'; if (!shouldUseNarrowLayout) { return ( @@ -361,15 +380,17 @@ function TabNavigator() { // language change redraws them. const iconsSignature = [theme.icon, theme.iconMenu, theme.text, theme.textSupporting, inboxDotColor, workspacesDotColor, accountDotColor, ...Object.values(tabLabels)].join('|'); - const [tintedIcons, setTintedIcons] = useState<{signature: string; icons: Record}>(); - const tintedIconsForTheme = tintedIcons?.signature === iconsSignature ? tintedIcons.icons : undefined; + const [tintedIcons, setTintedIcons] = useState(lastTintedIcons); + // The template icons the bar would fall back to render black on iOS 26, which ignores the inactive tint, so + // the bar is held back until the first recolored set exists. + const areTabIconsReady = !!tintedIcons; useEffect(() => { let isActive = true; const inactiveColor = theme.icon; const activeColor = theme.iconMenu; - // The label colors the bar drew before it became native: the supporting tone when idle, the plain text - // tone when selected. Both come from the theme, so light and dark each get their own pair. + // The supporting tone when idle, the plain text tone when selected, taken from the theme so light and dark + // each get their own pair. const inactiveLabelColor = theme.textSupporting; const activeLabelColor = theme.text; const signature = iconsSignature; @@ -396,9 +417,16 @@ function TabNavigator() { icons[result.name] = {inactive: result.inactive, active: result.active}; } } - setTintedIcons({signature, icons}); + lastTintedIcons = {signature, icons}; + setTintedIcons(lastTintedIcons); }) - .catch(() => {}); + .catch(() => { + // A failed draw still lets the bar show, on the template icons, rather than keeping it hidden. + if (!isActive) { + return; + } + setTintedIcons((previous) => previous ?? {signature, icons: {}}); + }); return () => { isActive = false; @@ -406,9 +434,9 @@ function TabNavigator() { // eslint-disable-next-line react-hooks/exhaustive-deps }, [iconsSignature]); - /** Falls back to the template icon until both tinted copies are ready, so a tab is never left without one. */ + /** Falls back to the template icon for a tab whose tinted copies could not be drawn, so it is never left without one. */ const getTabBarIcon = (name: string, fallbackIcon: NativeBottomTabIcon) => { - const pair = tintedIconsForTheme?.[name]; + const pair = tintedIcons?.icons[name]; if (!pair) { return fallbackIcon; } @@ -421,8 +449,9 @@ function TabNavigator() { }); const avatarURI = typeof avatarSource === 'string' ? avatarSource : undefined; const avatarSignature = [avatarURI, accountDotColor, tabLabels[NAVIGATORS.SETTINGS_SPLIT_NAVIGATOR], theme.textSupporting, theme.text].join('|'); - const [circularAvatar, setCircularAvatar] = useState<{signature: string; active: AvatarTabIcon; inactive: AvatarTabIcon}>(); - const avatarPair = circularAvatar?.signature === avatarSignature ? circularAvatar : undefined; + const [circularAvatar, setCircularAvatar] = useState(lastCircularAvatar); + // An avatar drawn for another user is never shown, while a stale dot or label is, until the redraw lands. + const avatarPair = circularAvatar?.uri === avatarURI ? circularAvatar : undefined; const toAvatarIcon = (icon: AvatarTabIcon): NativeBottomTabIcon => ({ type: 'image', source: {uri: icon.uri, width: icon.width, height: icon.height, scale: variables.nativeTabIconScale}, @@ -444,7 +473,8 @@ function TabNavigator() { if (!isActive || !inactive || !active) { return; } - setCircularAvatar({signature, active, inactive}); + lastCircularAvatar = {signature, uri: avatarURI, active, inactive}; + setCircularAvatar(lastCircularAvatar); }) .catch(() => {}); @@ -499,10 +529,8 @@ function TabNavigator() { tabBarActiveTintColor: theme.iconMenu, tabBarInactiveTintColor: theme.icon, // Every tab shares one style, so the bar reads the current visibility in the same render that changed it. - // Pushed through setOptions instead, each tab kept the value it was left with, and the bar spent the first - // frame after a tab switch in the previous tab's state. The background only lands on iOS 18 and below; - // iOS 26 keeps its own glass material. - tabBarStyle: {display: shouldShowNativeTabBar ? ('flex' as const) : ('none' as const), backgroundColor: theme.appBG}, + // The background only lands on iOS 18 and below; iOS 26 keeps its own glass material. + tabBarStyle: {display: shouldShowNativeTabBar && areTabIconsReady ? ('flex' as const) : ('none' as const), backgroundColor: theme.appBG}, tabBarControllerMode: 'tabBar' as const, // The bar stays put while the content scrolls, instead of collapsing the way iOS 26 does by default. tabBarMinimizeBehavior: 'none' as const, diff --git a/src/libs/Navigation/AppNavigator/Navigators/TabNavigator.tsx b/src/libs/Navigation/AppNavigator/Navigators/TabNavigator.tsx index 372b7fa4f939..d77a12be149d 100644 --- a/src/libs/Navigation/AppNavigator/Navigators/TabNavigator.tsx +++ b/src/libs/Navigation/AppNavigator/Navigators/TabNavigator.tsx @@ -1,36 +1,78 @@ -import TabBarBlurTarget from '@components/Navigation/NavigationTabBar/TabBarBlur/TabBarBlurTarget'; -import TabBarBlurTargetContextProvider from '@components/Navigation/NavigationTabBar/TabBarBlur/TabBarBlurTargetContext'; +import ActivityIndicator from '@components/ActivityIndicator'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useTheme from '@hooks/useTheme'; +import useThemeStyles from '@hooks/useThemeStyles'; -import {getPreservedNavigatorState, setPreservedNavigatorState} from '@libs/Navigation/AppNavigator/createSplitNavigator/usePreserveNavigatorState'; import {bottomTabScreenLayoutWrapper} from '@libs/Navigation/PlatformStackNavigation/ScreenLayout'; import type {TabNavigatorParamList} from '@libs/Navigation/types'; +import {getSpan} from '@libs/telemetry/activeSpans'; +import CONST from '@src/CONST'; import NAVIGATORS from '@src/NAVIGATORS'; import SCREENS from '@src/SCREENS'; /** * Tab Navigator containing Home, Inbox (Reports), Search, Insights, Settings, and Workspaces pages. */ -import type {BottomTabBarProps, BottomTabNavigationOptions, BottomTabNavigationProp} from '@react-navigation/bottom-tabs'; -import type {NavigationAction, ParamListBase, Router, ScreenLayoutArgs, TabNavigationState} from '@react-navigation/native'; +import type {BottomTabBarProps} from '@react-navigation/bottom-tabs'; import {createBottomTabNavigator} from '@react-navigation/bottom-tabs'; -import {findFocusedRoute, useNavigation, useNavigationState, useRoute} from '@react-navigation/native'; -import React, {useEffect} from 'react'; +import {findFocusedRoute, useNavigation, useNavigationState} from '@react-navigation/native'; +import React, {lazy, Suspense, useEffect} from 'react'; +import {View} from 'react-native'; // Do not lazy load Search navigator for performance reasons import SearchFullscreenNavigator from './SearchFullscreenNavigator'; import TabNavigatorBar from './TabNavigatorBar'; -import {HomePageScreen, InsightsPageScreen, ReportsSplitNavigatorScreen, SettingsSplitNavigatorScreen, WorkspaceNavigatorScreen} from './tabScreens'; -const renderTabBar = ({state}: BottomTabBarProps) => ; +const LazyHomePage = lazy(() => import('@pages/home/HomePage')); +const LazyReportsSplitNavigator = lazy(() => import('./ReportsSplitNavigator')); +const LazySettingsSplitNavigator = lazy(() => import('./SettingsSplitNavigator')); +const LazyWorkspaceNavigator = lazy(() => import('./WorkspaceNavigator')); +const LazyInsightsPage = lazy(() => import('@pages/Insights/InsightsPage')); + +type LazyFallbackProps = { + /** Sentry span to tag when this fallback renders. */ + tabSpanName?: string; +}; + +function LazyFallback({tabSpanName}: LazyFallbackProps) { + const styles = useThemeStyles(); + + // Lets Sentry split slow tab navigations into "lazy chunk fetch" vs "screen render" buckets. + useEffect(() => { + if (!tabSpanName) { + return; + } + getSpan(tabSpanName)?.setAttribute(CONST.TELEMETRY.ATTRIBUTE_LAZY_TAB_FALLBACK_SHOWN, true); + }, [tabSpanName]); + + return ( + + + + ); +} + +function withSuspense

>(LazyComponent: React.LazyExoticComponent>, tabSpanName?: string) { + function SuspenseWrapper(props: P) { + return ( + }> + + + ); + } + return SuspenseWrapper; +} -const renderTabScreenLayout = (args: ScreenLayoutArgs>) => ( - {bottomTabScreenLayoutWrapper(args)} -); +const HomePageScreen = withSuspense(LazyHomePage); +const ReportsSplitNavigatorScreen = withSuspense(LazyReportsSplitNavigator, CONST.TELEMETRY.SPAN_NAVIGATE_TO_INBOX_TAB); +const SettingsSplitNavigatorScreen = withSuspense(LazySettingsSplitNavigator); +const WorkspaceNavigatorScreen = withSuspense(LazyWorkspaceNavigator); +const InsightsPageScreen = withSuspense(LazyInsightsPage); + +const renderTabBar = ({state}: BottomTabBarProps) => ; const Tab = createBottomTabNavigator(); @@ -54,12 +96,6 @@ function TabNavigator() { const navigation = useNavigation(); const parentNavigation = navigation.getParent(); const focusedRouteName = useNavigationState((state) => findFocusedRoute(state)?.name); - const route = useRoute(); - // The Tab.Navigator's own state lives at `parentState.routes[i].state`. We can't read it via - // `useNavigationState((s) => s)` here because TabNavigator's body runs before - // mounts, so the nearest navigation listener context is still the parent stack's. - const tabState = useNavigationState((parentState) => parentState.routes.find((r) => r.key === route.key)?.state); - useEffect(() => { if (!shouldUseNarrowLayout || !parentNavigation) { return; @@ -68,27 +104,6 @@ function TabNavigator() { parentNavigation.setOptions({gestureEnabled: !isRootScreen}); }, [focusedRouteName, shouldUseNarrowLayout, parentNavigation]); - useEffect(() => { - // stale === false distinguishes a fully realized NavigationState from a PartialState. - if (!tabState || tabState.stale !== false) { - return; - } - setPreservedNavigatorState(route.key, tabState); - }, [tabState, route.key]); - - // The slicing optimization in useCustomRootStackNavigatorState can unmount and later remount - // this TAB_NAVIGATOR. Without restoration it would default to index 0. We restore the saved - // state by overriding the bottom-tab router's getInitialState - the same pattern SplitRouter - // uses for its split navigators. - const tabRouterOverride = ( - originalRouter: Router, Action>, - ): Partial, Action>> => ({ - getInitialState: (configOptions) => { - const preserved = getPreservedNavigatorState>(route.key); - return preserved ? originalRouter.getRehydratedState(preserved, configOptions) : originalRouter.getInitialState(configOptions); - }, - }); - const screenOptions = { ...TAB_SCREEN_OPTIONS_BASE, sceneStyle: {flex: 1, backgroundColor: theme.appBG}, @@ -96,40 +111,37 @@ function TabNavigator() { }; return ( - - - - - - - - - - + + + + + + + + ); } diff --git a/src/libs/Navigation/AppNavigator/Navigators/TabNavigatorBar.tsx b/src/libs/Navigation/AppNavigator/Navigators/TabNavigatorBar.tsx index 3dbea5a6399a..9b46ffd6e246 100644 --- a/src/libs/Navigation/AppNavigator/Navigators/TabNavigatorBar.tsx +++ b/src/libs/Navigation/AppNavigator/Navigators/TabNavigatorBar.tsx @@ -3,14 +3,13 @@ import NavigationTabBar from '@components/Navigation/NavigationTabBar'; import NAVIGATION_TABS from '@components/Navigation/NavigationTabBar/NAVIGATION_TABS'; import ROUTE_TO_NAVIGATION_TAB from '@components/Navigation/NavigationTabBar/ROUTE_TO_NAVIGATION_TAB'; +import usePrevious from '@hooks/usePrevious'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useSafeAreaPaddings from '@hooks/useSafeAreaPaddings'; import useStyleUtils from '@hooks/useStyleUtils'; import useThemeStyles from '@hooks/useThemeStyles'; -import {getIsAnyScreenClosing, subscribeToClosingScreens} from '@libs/Navigation/closingScreens'; -import getLeafScreenNameAfterPop from '@libs/Navigation/helpers/getLeafScreenNameAfterPop'; -import isTabRouteAtRoot, {isAtTabRootLevel} from '@libs/Navigation/helpers/isTabRouteAtRoot'; +import isTabRouteAtRoot from '@libs/Navigation/helpers/isTabRouteAtRoot'; import cancelTabNavigationSpans, {INBOX_TAB_SPAN_IDS, REPORTS_TAB_SPAN_IDS} from '@libs/telemetry/cancelTabNavigationSpans'; import SCREENS from '@src/SCREENS'; @@ -18,7 +17,7 @@ import SCREENS from '@src/SCREENS'; import type {BottomTabBarProps} from '@react-navigation/bottom-tabs'; import type {ValueOf} from 'type-fest'; -import React, {useEffect, useSyncExternalStore} from 'react'; +import React, {useEffect, useState} from 'react'; import {View} from 'react-native'; const NAVIGATION_TAB_TO_SPANS: Partial, readonly string[]>> = { @@ -40,15 +39,27 @@ function TabNavigatorBar({state}: Pick) { const activeRoute = state.routes[state.index]; const selectedTab = ROUTE_TO_NAVIGATION_TAB[activeRoute?.name ?? SCREENS.HOME] ?? NAVIGATION_TABS.HOME; const isAtRoot = isTabRouteAtRoot(activeRoute); - // --- Narrow-only visibility logic (hooks must run unconditionally per Rules of Hooks) --- + // --- Narrow-only animation logic (hooks must run unconditionally per Rules of Hooks) --- + // On native, screens also render the tab bar via bottomContent for swipe-back animations. + // Delay showing this navigator's tab bar only when navigating back from a deeper screen + // (where the tab bar was hidden). Keep it visible during tab switches so it doesn't flash. + // Guard with shouldUseNarrowLayout so prevShouldHide stays false in wide layout, + // preventing false shouldApplyDelay triggers on layout transitions (e.g. web resize). const shouldHide = shouldUseNarrowLayout && (!isAtRoot || isBlockingViewVisible); + const prevTabIndex = usePrevious(state.index); + const prevShouldHide = usePrevious(shouldHide); + const stateKey = `${state.index}-${isAtRoot}`; + const [animationDoneKey, setAnimationDoneKey] = useState(stateKey); - // Navigation state only changes once a pop commits, which on an interactive swipe is the moment the gesture - // ends. Waiting for it makes the bar drop into place after the screen has already arrived. Watching for the - // pop instead puts the bar under the incoming screen for the whole gesture, and an aborted swipe settles the - // screen without a state change, so the bar goes back to hidden on its own. - const isAnyScreenClosing = useSyncExternalStore(subscribeToClosingScreens, getIsAnyScreenClosing, getIsAnyScreenClosing); - const shouldRevealDuringPop = isAnyScreenClosing && !isBlockingViewVisible && isAtTabRootLevel(getLeafScreenNameAfterPop(activeRoute?.state)); + const tabChanged = prevTabIndex !== state.index; + const shouldApplyDelay = !tabChanged && prevShouldHide && !shouldHide; + + useEffect(() => { + const frameId = requestAnimationFrame(() => { + setAnimationDoneKey(stateKey); + }); + return () => cancelAnimationFrame(frameId); + }, [stateKey]); // Cancel any in-flight tab-navigation span that doesn't match the new focused tab. // The span for the new tab is started by the tab button before navigation, so we keep it via `except`. @@ -56,7 +67,7 @@ function TabNavigatorBar({state}: Pick) { cancelTabNavigationSpans(NAVIGATION_TAB_TO_SPANS[selectedTab]); }, [selectedTab]); - const isHidden = shouldHide && !shouldRevealDuringPop; + const isHidden = shouldHide || (shouldApplyDelay && animationDoneKey !== stateKey); if (shouldUseNarrowLayout) { // Negative marginTop overlays the tab bar on content (zero flex space) to prevent layout shifts. diff --git a/src/libs/Navigation/AppNavigator/Navigators/tabScreens.native.tsx b/src/libs/Navigation/AppNavigator/Navigators/tabScreens.native.tsx deleted file mode 100644 index eb463eb261e7..000000000000 --- a/src/libs/Navigation/AppNavigator/Navigators/tabScreens.native.tsx +++ /dev/null @@ -1,18 +0,0 @@ -import HomePage from '@pages/home/HomePage'; -import InsightsPage from '@pages/Insights/InsightsPage'; - -import ReportsSplitNavigator from './ReportsSplitNavigator'; -import SettingsSplitNavigator from './SettingsSplitNavigator'; -import WorkspaceNavigator from './WorkspaceNavigator'; - -/** - * The tab screens handed over as they are. Code splitting buys nothing here, the whole bundle is already on the - * device, and going through `lazy` would put a spinner in front of a tab the first time it is opened. - */ -const HomePageScreen = HomePage; -const ReportsSplitNavigatorScreen = ReportsSplitNavigator; -const SettingsSplitNavigatorScreen = SettingsSplitNavigator; -const WorkspaceNavigatorScreen = WorkspaceNavigator; -const InsightsPageScreen = InsightsPage; - -export {HomePageScreen, ReportsSplitNavigatorScreen, SettingsSplitNavigatorScreen, WorkspaceNavigatorScreen, InsightsPageScreen}; diff --git a/src/libs/Navigation/AppNavigator/Navigators/tabScreens.tsx b/src/libs/Navigation/AppNavigator/Navigators/tabScreens.tsx deleted file mode 100644 index bcc19d174e6a..000000000000 --- a/src/libs/Navigation/AppNavigator/Navigators/tabScreens.tsx +++ /dev/null @@ -1,62 +0,0 @@ -import ActivityIndicator from '@components/ActivityIndicator'; - -import useThemeStyles from '@hooks/useThemeStyles'; - -import {getSpan} from '@libs/telemetry/activeSpans'; - -import CONST from '@src/CONST'; - -import React, {lazy, Suspense, useEffect} from 'react'; -import {View} from 'react-native'; - -/** - * The tab screens, code split so a tab's chunk is fetched the first time it is opened. Native has no chunks to - * fetch, so it takes the sibling module that hands the components over directly and never shows the fallback. - */ -const LazyHomePage = lazy(() => import('@pages/home/HomePage')); -const LazyReportsSplitNavigator = lazy(() => import('./ReportsSplitNavigator')); -const LazySettingsSplitNavigator = lazy(() => import('./SettingsSplitNavigator')); -const LazyWorkspaceNavigator = lazy(() => import('./WorkspaceNavigator')); -const LazyInsightsPage = lazy(() => import('@pages/Insights/InsightsPage')); - -type LazyFallbackProps = { - /** Sentry span to tag when this fallback renders. */ - tabSpanName?: string; -}; - -function LazyFallback({tabSpanName}: LazyFallbackProps) { - const styles = useThemeStyles(); - - // Lets Sentry split slow tab navigations into "lazy chunk fetch" vs "screen render" buckets. - useEffect(() => { - if (!tabSpanName) { - return; - } - getSpan(tabSpanName)?.setAttribute(CONST.TELEMETRY.ATTRIBUTE_LAZY_TAB_FALLBACK_SHOWN, true); - }, [tabSpanName]); - - return ( - - - - ); -} - -function withSuspense

>(LazyComponent: React.LazyExoticComponent>, tabSpanName?: string) { - function SuspenseWrapper(props: P) { - return ( - }> - - - ); - } - return SuspenseWrapper; -} - -const HomePageScreen = withSuspense(LazyHomePage); -const ReportsSplitNavigatorScreen = withSuspense(LazyReportsSplitNavigator, CONST.TELEMETRY.SPAN_NAVIGATE_TO_INBOX_TAB); -const SettingsSplitNavigatorScreen = withSuspense(LazySettingsSplitNavigator); -const WorkspaceNavigatorScreen = withSuspense(LazyWorkspaceNavigator); -const InsightsPageScreen = withSuspense(LazyInsightsPage); - -export {HomePageScreen, ReportsSplitNavigatorScreen, SettingsSplitNavigatorScreen, WorkspaceNavigatorScreen, InsightsPageScreen}; diff --git a/src/libs/Navigation/PlatformStackNavigation/ScreenLayout.tsx b/src/libs/Navigation/PlatformStackNavigation/ScreenLayout.tsx index 0efebb03962b..2b74f7b4b002 100644 --- a/src/libs/Navigation/PlatformStackNavigation/ScreenLayout.tsx +++ b/src/libs/Navigation/PlatformStackNavigation/ScreenLayout.tsx @@ -1,4 +1,3 @@ -import {markScreenClosing, markScreenSettled} from '@libs/Navigation/closingScreens'; import TransitionTracker from '@libs/Navigation/TransitionTracker'; import type {TransitionHandle} from '@libs/Navigation/TransitionTracker'; @@ -14,7 +13,7 @@ import type {PlatformSpecificNavigationOptions, PlatformStackNavigationOptions} // it's used with. Keeping this minimal means passing a real (properly-typed) navigation prop into it - e.g. from // bottomTabScreenLayoutWrapper below - needs no unsafe cast, since every navigator's `addListener` structurally satisfies it. type TransitionAwareNavigation = { - addListener(type: 'transitionStart' | 'transitionEnd', callback: (event: {data?: {closing?: boolean}}) => void): () => void; + addListener(type: 'transitionStart' | 'transitionEnd', callback: () => void): () => void; }; // screenLayout is invoked as a render function (not JSX), so we need this wrapper to create a proper React component boundary for hooks. @@ -57,27 +56,19 @@ type ScreenLayoutProps = ScreenLayoutArgs< TransitionAwareNavigation >; -function ScreenLayout({children, navigation, route}: ScreenLayoutProps) { +function ScreenLayout({children, navigation}: ScreenLayoutProps) { const transitionHandleRef = useRef(null); // Net-count overlapping starts so a single handle spans rapid back/forward re-fires — no decrement-to-zero seam for `runAfterTransitions` to flush through, and `transitionEnd` for the wrong leg can't end the active one. const pendingTransitionsRef = useRef(0); useLayoutEffect(() => { - const transitionStartListener = navigation.addListener('transitionStart', (event) => { - // A closing transition starts as soon as an interactive swipe does, which is the only chance anything - // drawn outside the screens has to react before the pop commits. - if (event.data?.closing) { - markScreenClosing(route.key); - } else { - markScreenSettled(route.key); - } + const transitionStartListener = navigation.addListener('transitionStart', () => { pendingTransitionsRef.current += 1; if (!transitionHandleRef.current) { transitionHandleRef.current = TransitionTracker.startTransition('navigation'); } }); const transitionEndListener = navigation.addListener('transitionEnd', () => { - markScreenSettled(route.key); if (pendingTransitionsRef.current > 0) { pendingTransitionsRef.current -= 1; } @@ -90,7 +81,6 @@ function ScreenLayout({children, navigation, route}: ScreenLayoutProps) { return () => { transitionStartListener(); transitionEndListener(); - markScreenSettled(route.key); const handleToEnd = transitionHandleRef.current; transitionHandleRef.current = null; pendingTransitionsRef.current = 0; @@ -102,7 +92,7 @@ function ScreenLayout({children, navigation, route}: ScreenLayoutProps) { TransitionTracker.endTransition(handleToEnd); }); }; - }, [navigation, route.key]); + }, [navigation]); return children; } diff --git a/src/libs/Navigation/closingScreens.ts b/src/libs/Navigation/closingScreens.ts deleted file mode 100644 index e27f5d7382f3..000000000000 --- a/src/libs/Navigation/closingScreens.ts +++ /dev/null @@ -1,42 +0,0 @@ -/** - * Tracks which screens are in the middle of closing, so UI drawn outside the screens can react to a pop while it - * is still running. Navigation state only changes once a pop commits, which on an interactive iOS swipe is the - * moment the gesture ends, far too late for anything that has to travel with the screen. - */ -const closingRouteKeys = new Set(); -const listeners = new Set<() => void>(); - -function emit() { - for (const listener of listeners) { - listener(); - } -} - -function subscribeToClosingScreens(listener: () => void): () => void { - listeners.add(listener); - return () => { - listeners.delete(listener); - }; -} - -function getIsAnyScreenClosing(): boolean { - return closingRouteKeys.size > 0; -} - -function markScreenClosing(routeKey: string) { - if (closingRouteKeys.has(routeKey)) { - return; - } - closingRouteKeys.add(routeKey); - emit(); -} - -/** Called when the screen settles, which covers a finished pop as much as a gesture the user aborted. */ -function markScreenSettled(routeKey: string) { - if (!closingRouteKeys.delete(routeKey)) { - return; - } - emit(); -} - -export {subscribeToClosingScreens, getIsAnyScreenClosing, markScreenClosing, markScreenSettled}; diff --git a/src/libs/Navigation/helpers/getLeafScreenNameAfterPop.ts b/src/libs/Navigation/helpers/getLeafScreenNameAfterPop.ts deleted file mode 100644 index 689327b2cfb5..000000000000 --- a/src/libs/Navigation/helpers/getLeafScreenNameAfterPop.ts +++ /dev/null @@ -1,30 +0,0 @@ -import type {NavigationState, PartialState} from '@react-navigation/native'; - -import getFocusedLeafScreenName from './getFocusedLeafScreenName'; - -/** - * Leaf screen name the focused route chain would resolve to if the deepest stack with something to pop popped once. - * Returns undefined when nothing in the chain can be popped. - */ -function getLeafScreenNameAfterPop(state: NavigationState | PartialState | undefined): string | undefined { - if (!state?.routes?.length) { - return undefined; - } - // PartialState (e.g. from getStateFromPath on cold-start) may omit index; React Navigation treats the last route as focused. - const focusedIndex = state.index ?? state.routes.length - 1; - const focused = state.routes[focusedIndex]; - - // The innermost stack is the one the gesture pops, so it wins over anything above it. - const deeperName = focused?.state ? getLeafScreenNameAfterPop(focused.state) : undefined; - if (deeperName) { - return deeperName; - } - - if (focusedIndex <= 0) { - return undefined; - } - const previous = state.routes[focusedIndex - 1]; - return previous?.state ? getFocusedLeafScreenName(previous.state) : previous?.name; -} - -export default getLeafScreenNameAfterPop; diff --git a/src/libs/Navigation/helpers/isTabRouteAtRoot.ts b/src/libs/Navigation/helpers/isTabRouteAtRoot.ts index b1de3b57e942..d24291aeaa13 100644 --- a/src/libs/Navigation/helpers/isTabRouteAtRoot.ts +++ b/src/libs/Navigation/helpers/isTabRouteAtRoot.ts @@ -58,4 +58,3 @@ function isTabRouteAtRoot(route: TabRouteLike | undefined): boolean { } export default isTabRouteAtRoot; -export {isAtTabRootLevel}; diff --git a/src/pages/inbox/sidebar/NavigationTabBarAvatar.tsx b/src/pages/inbox/sidebar/NavigationTabBarAvatar.tsx index ffb69bac45a4..ddca563685f4 100644 --- a/src/pages/inbox/sidebar/NavigationTabBarAvatar.tsx +++ b/src/pages/inbox/sidebar/NavigationTabBarAvatar.tsx @@ -45,17 +45,24 @@ function NavigationTabBarAvatar({onPress, isSelected = false, style}: Navigation > {({hovered}) => ( - + {translate('initialSettingsPage.account')} diff --git a/src/styles/index.ts b/src/styles/index.ts index b7511bc36852..086027555672 100644 --- a/src/styles/index.ts +++ b/src/styles/index.ts @@ -730,14 +730,14 @@ const staticStyles = (theme: ThemeColors) => overflow: 'visible', }, - // The narrow tab bar is a capsule floating above the screen content, drawn by Android and mobile web. - // iOS hands the bottom bar over to UIKit, which paints its own material, so nothing here reaches it. + // The narrow tab bar on mobile web is a flat capsule floating above the screen content. iOS and Android hand + // the bottom bar over to their native tab bars, so nothing here reaches them. navigationTabBarContainer: { flexDirection: 'row', height: variables.floatingTabBarHeight, marginHorizontal: variables.floatingTabBarHorizontalInset, borderRadius: variables.componentBorderRadiusCircle, - backgroundColor: theme.floatingTabBarBG, + backgroundColor: theme.appBG, borderWidth: variables.hairlineBorderWidth, borderColor: theme.border, // Insets the row so the selected item's capsule keeps a margin inside the bar's rounded edge. @@ -746,33 +746,23 @@ const staticStyles = (theme: ThemeColors) => overflow: 'hidden', }, - // Sits behind the row and inside the container's rounded clip, so the blur stops at the capsule's edge. - navigationTabBarBlur: { - position: 'absolute', - top: 0, - right: 0, - bottom: 0, - left: 0, - }, - navigationTabBarItemSelected: { backgroundColor: theme.floatingTabBarSelectedBG, borderRadius: variables.componentBorderRadiusCircle, - // The padding widens the pill while the matching negative margin keeps it from taking that width - // away from the label, so the pill reaches past its tab instead of squeezing what it wraps. + // Spans the whole tab and a little past it, the way the native iOS bar draws its selection. The padding + // matches the negative margin, so the label keeps the tab's width to wrap in, selected or not. + alignSelf: 'stretch', paddingHorizontal: variables.floatingTabBarSelectedOverhang, marginHorizontal: -variables.floatingTabBarSelectedOverhang, }, // Ends a tab root screen's scrollable content above the floating bar, so its last row stays reachable - // while everything scrolled past it still passes behind the glass. + // while everything scrolled past it still passes under the bar. floatingTabBarContentInset: { paddingBottom: variables.floatingTabBarContentInset, }, - // Used both for a tab's pressable and for the wrapper around its icon and label. As the wrapper it sizes - // to those two rather than to the tab's share of the bar, so a long label pushes the selected pill past - // its tab instead of spilling out of it. + // Used both for a tab's pressable and for the wrapper around its icon and label. navigationTabBarItem: { height: '100%', display: 'flex', @@ -780,6 +770,12 @@ const staticStyles = (theme: ThemeColors) => alignItems: 'center', }, + // Lets the floating bar's avatar overflow the glyph box evenly above and below, so its label lines up + // with the labels of the icons next to it and its centre with theirs. + navigationTabBarAvatar: { + marginVertical: (variables.iconFloatingTabBar - variables.avatarFloatingTabBar) / 2, + }, + // Zero-height strip anchored above the native tab bar, so the floating buttons can be positioned // against it without taking part in the tab screen's layout. nativeTabBarFloatingButtons: { @@ -6838,6 +6834,18 @@ const staticStyles = (theme: ThemeColors) => const dynamicStyles = (theme: ThemeColors) => ({ + // Android's native bar is Material's 80 dp row plus the system gesture inset, so the strip the floating + // buttons hang from sits above both, with Material's 16 dp gap between the FAB and the bar. + nativeAndroidTabBarFloatingButtons: (bottomInset: number) => + ({ + position: 'absolute', + right: 0, + bottom: variables.androidNativeTabBarHeight + bottomInset + spacing.gap4.gap, + left: 0, + height: 0, + zIndex: 10, + }) satisfies ViewStyle, + getSplitListItemAmountStyle: (inputMarginLeft: number, amountWidth: number | string) => ({ marginLeft: inputMarginLeft, width: amountWidth, diff --git a/src/styles/theme/themes/dark.ts b/src/styles/theme/themes/dark.ts index 7848f94d9776..9407c5c5355e 100644 --- a/src/styles/theme/themes/dark.ts +++ b/src/styles/theme/themes/dark.ts @@ -58,8 +58,9 @@ const darkTheme = { signInSidebar: colors.green800, sidebar: colors.productDark100, sidebarHover: colors.productDark300, - floatingTabBarBG: 'rgba(6,27,9,0.70)', floatingTabBarSelectedBG: 'rgba(26,61,50,0.50)', + // green700 at 50% + androidTabBarActiveIndicatorBG: 'rgba(8,82,57,0.50)', heading: colors.productDark900, textLight: colors.productDark900, textDark: colors.productDark100, diff --git a/src/styles/theme/themes/light.ts b/src/styles/theme/themes/light.ts index 77eb5620a411..85cb7bfc20c8 100644 --- a/src/styles/theme/themes/light.ts +++ b/src/styles/theme/themes/light.ts @@ -58,8 +58,9 @@ const lightTheme = { signInSidebar: colors.green800, sidebar: colors.productLight100, sidebarHover: colors.productLight300, - floatingTabBarBG: 'rgba(252,251,249,0.70)', floatingTabBarSelectedBG: 'rgba(230,225,218,0.50)', + // green100 at 50% + androidTabBarActiveIndicatorBG: 'rgba(177,242,214,0.50)', heading: colors.productLight900, textLight: colors.white, textDark: colors.productLight900, diff --git a/src/styles/theme/types.ts b/src/styles/theme/types.ts index 2ebe4017eb6d..3586eb5b2f96 100644 --- a/src/styles/theme/types.ts +++ b/src/styles/theme/types.ts @@ -65,8 +65,8 @@ type ThemeColors = { signInSidebar: Color; sidebar: Color; sidebarHover: Color; - floatingTabBarBG: Color; floatingTabBarSelectedBG: Color; + androidTabBarActiveIndicatorBG: Color; heading: Color; textLight: Color; textDark: Color; diff --git a/src/styles/variables.ts b/src/styles/variables.ts index 4d037198bb8d..d30f9ec7f6d6 100644 --- a/src/styles/variables.ts +++ b/src/styles/variables.ts @@ -36,17 +36,9 @@ export default { bottomTabHeight: 72, floatingTabBarHeight, floatingTabBarHorizontalInset: 16, - // How far the selected pill reaches past the icon and label. Measured off the iOS glass bubble, where the - // account tab's 121px label sits in a 222px bubble, so 17pt a side. - floatingTabBarSelectedOverhang: 17, + // How far the selected pill reaches past its tab on each side, whatever the label's length. + floatingTabBarSelectedOverhang: 4, floatingTabBarBottomInset, - // Blur the bar lays over what it covers. The web radius is a CSS blur; the Android one is expo-blur's own - // 0 to 100 intensity, which is not the same unit, so the two are tuned to match rather than shared. - floatingTabBarBlurRadius: 24, - floatingTabBarBlurIntensity: 60, - // expo-blur on Android derives its radius as intensity / factor, so this puts the radius at 5, soft enough that - // shapes and colors under the bar still show through, without changing the tint the intensity sets. - floatingTabBarBlurReductionFactor: 12, // Vertical space the floating bar takes over the content it hides, so scrollable content can end above it. floatingTabBarContentInset: floatingTabBarHeight + floatingTabBarBottomInset, // styles.p3 (12) on each side of the DebugTabView row plus the View button (componentSizeNormal). @@ -119,12 +111,16 @@ export default { iconBottomBar: 24, /** Glyph size in the floating tab bar, smaller than the side bar's so each item keeps padding above and below it. */ iconFloatingTabBar: 20, + /** Account avatar size in the floating tab bar. A circle reads smaller than a glyph of the same box, so it is larger. */ + avatarFloatingTabBar: avatarSizes.avatarSizeXSmall, /** Radius of the status dot drawn into a native tab bar icon, and the room reserved for it around the glyph. */ nativeTabIconDotRadius: 4, // Gap between the glyph and the label baked under it, matching the spacing the JS bar uses. nativeTabIconLabelGap: 6, /** Pixel density the native tab bar icons are rasterized at. */ nativeTabIconScale: 3, + /** Height of Material's BottomNavigationView row on Android, without the gesture inset under it. */ + androidNativeTabBarHeight: 80, iconHeader: 48, iconSection: 68, iouAmountTextSize: 40, diff --git a/tests/navigation/buildTabNavigatorNestedStateTests.ts b/tests/navigation/buildTabNavigatorNestedStateTests.ts index 2220c0f73ac8..aaedcaff3ada 100644 --- a/tests/navigation/buildTabNavigatorNestedStateTests.ts +++ b/tests/navigation/buildTabNavigatorNestedStateTests.ts @@ -5,23 +5,22 @@ import NAVIGATORS from '@src/NAVIGATORS'; import SCREENS from '@src/SCREENS'; describe('buildTabNavigatorNestedState', () => { - it('returns state with all 6 tab routes', () => { + it('returns state with every tab route, in the order the navigator registers them', () => { + // Given the tab screens the navigator registers on the current platform + // When the nested state is built for one of them const result = buildTabNavigatorNestedState({name: NAVIGATORS.REPORTS_SPLIT_NAVIGATOR}); - expect(result.routes).toHaveLength(TAB_SCREENS.length); - const routeNames = result.routes.map((r) => r.name); - expect(routeNames).toEqual([ - SCREENS.HOME, - NAVIGATORS.REPORTS_SPLIT_NAVIGATOR, - NAVIGATORS.SEARCH_FULLSCREEN_NAVIGATOR, - SCREENS.INSIGHTS, - NAVIGATORS.SETTINGS_SPLIT_NAVIGATOR, - NAVIGATORS.WORKSPACE_NAVIGATOR, - ]); + + // Then it holds a route for every tab, in the same order, since the native tab bars draw their items in it + expect(result.routes.map((r) => r.name)).toEqual([...TAB_SCREENS]); }); - it('sets index to the position of the selected tab (SETTINGS = 4)', () => { + it('sets index to the position of the selected tab', () => { + // Given the settings tab, whose position differs between platforms + // When the nested state is built for it const result = buildTabNavigatorNestedState({name: NAVIGATORS.SETTINGS_SPLIT_NAVIGATOR}); - expect(result.index).toBe(4); + + // Then the index points at that tab's own route + expect(result.index).toBe(TAB_SCREENS.indexOf(NAVIGATORS.SETTINGS_SPLIT_NAVIGATOR)); }); it('falls back to index 0 for an unknown tab name', () => { @@ -40,7 +39,7 @@ describe('buildTabNavigatorNestedState', () => { it('copies params from selectedTabRoute onto the matched route only', () => { const params = {screen: 'SomeScreen'}; const result = buildTabNavigatorNestedState({name: NAVIGATORS.SETTINGS_SPLIT_NAVIGATOR, params}); - expect(result.routes.at(4)?.params).toEqual(params); + expect(result.routes.at(TAB_SCREENS.indexOf(NAVIGATORS.SETTINGS_SPLIT_NAVIGATOR))?.params).toEqual(params); expect(result.routes.at(0)?.params).toBeUndefined(); expect(result.routes.at(1)?.params).toBeUndefined(); }); From 4f891cc67bd1685c363e681d004aa2ecee70c006 Mon Sep 17 00:00:00 2001 From: sumo_slonik Date: Wed, 30 Sep 2026 11:16:09 +0200 Subject: [PATCH 22/71] refactor: share native tab navigator code, lay out the mWeb bar via bottomContent Drops the content inset hook and ties the dark indicator to selectedOptionBG. --- .../Navigation/NavigationTabBar/index.tsx | 8 +- .../TabBarBottomContent/index.native.tsx | 6 + .../Navigation/TabBarBottomContent/index.tsx | 33 +++ .../Navigation/TabBarBottomContent/types.ts | 9 + .../Tables/WorkspaceListTable/index.tsx | 4 +- src/components/WorkspaceListLayout.tsx | 4 + .../index.native.ts | 8 - .../index.ts | 18 -- .../NativeTabNavigator/NATIVE_TAB_ICONS.ts | 21 ++ .../NativeTabNavigator/NativeTabLayout.tsx | 84 +++++++ .../useNativeTabNavigator.ts | 110 +++++++++ .../Navigators/TabNavigator.android.tsx | 217 +++--------------- .../Navigators/TabNavigator.ios.tsx | 208 ++--------------- src/pages/Insights/InsightsDashboard.tsx | 7 +- src/pages/Search/SearchPageNarrow/index.tsx | 10 +- src/pages/domain/DomainInitialPage.tsx | 8 +- src/pages/home/HomePage.tsx | 8 +- src/pages/inbox/sidebar/BaseSidebarScreen.tsx | 4 + src/pages/inbox/sidebar/SidebarLinks.tsx | 10 +- src/pages/settings/InitialSettingsPage.tsx | 9 +- src/pages/workspace/WorkspaceInitialPage.tsx | 8 +- src/styles/index.ts | 26 +-- src/styles/theme/themes/dark.ts | 2 +- src/styles/theme/themes/light.ts | 2 +- src/styles/utils/index.ts | 2 - src/styles/variables.ts | 8 +- tests/ui/BaseSidebarScreenTest.tsx | 1 + tests/ui/InitialSettingsPageTest.tsx | 8 + tests/ui/WorkspaceInitialPageTest.tsx | 1 + 29 files changed, 394 insertions(+), 450 deletions(-) create mode 100644 src/components/Navigation/TabBarBottomContent/index.native.tsx create mode 100644 src/components/Navigation/TabBarBottomContent/index.tsx create mode 100644 src/components/Navigation/TabBarBottomContent/types.ts delete mode 100644 src/hooks/useFloatingTabBarContentInsetStyle/index.native.ts delete mode 100644 src/hooks/useFloatingTabBarContentInsetStyle/index.ts create mode 100644 src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/NATIVE_TAB_ICONS.ts create mode 100644 src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/NativeTabLayout.tsx create mode 100644 src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/useNativeTabNavigator.ts diff --git a/src/components/Navigation/NavigationTabBar/index.tsx b/src/components/Navigation/NavigationTabBar/index.tsx index 1d6da466e63d..1a4755b490bc 100644 --- a/src/components/Navigation/NavigationTabBar/index.tsx +++ b/src/components/Navigation/NavigationTabBar/index.tsx @@ -48,8 +48,8 @@ function NavigationTabBar({selectedTab, shouldShowFloatingButtons = true}: Navig const {translate} = useLocalize(); const [isDebugModeEnabled] = useOnyx(ONYXKEYS.IS_DEBUG_MODE_ENABLED); const {isBetaEnabled} = usePermissions(); - // iOS drives its bar from the native tab navigator, which registers five items and leaves Insights out, so the - // wide layout's own bar must not offer a tab the navigator cannot route to. + // The native tab navigators register five items and leave Insights out, so the wide layout's own bar must not + // offer a tab the navigator cannot route to. const isInsightsTabVisible = isBetaEnabled(CONST.BETAS.INSIGHTS_PAGE) && (TAB_SCREENS as readonly string[]).includes(SCREENS.INSIGHTS); const expensifyIcons = useMemoizedLazyExpensifyIcons(['ExpensifyAppIcon', 'Home']); @@ -73,8 +73,8 @@ function NavigationTabBar({selectedTab, shouldShowFloatingButtons = true}: Navig }); }; - // The navigator drops shouldShowFloatingButtons while it hides the bar, and the debug view rides along - // so it cannot linger over a screen the bar has already left. + // shouldShowFloatingButtons is false for the swipe-back duplicate tab bar rendered via + // bottomContent — gating the debug view on it prevents it from appearing in that copy too. const shouldShowDebugTabView = !!isDebugModeEnabled && shouldShowFloatingButtons; if (!shouldUseNarrowLayout) { diff --git a/src/components/Navigation/TabBarBottomContent/index.native.tsx b/src/components/Navigation/TabBarBottomContent/index.native.tsx new file mode 100644 index 000000000000..b423035245d5 --- /dev/null +++ b/src/components/Navigation/TabBarBottomContent/index.native.tsx @@ -0,0 +1,6 @@ +/** The native tab bars keep the screen content clear of themselves, so a tab root screen needs no copy of the bar. */ +function TabBarBottomContent() { + return null; +} + +export default TabBarBottomContent; diff --git a/src/components/Navigation/TabBarBottomContent/index.tsx b/src/components/Navigation/TabBarBottomContent/index.tsx new file mode 100644 index 000000000000..94ce7c321fc2 --- /dev/null +++ b/src/components/Navigation/TabBarBottomContent/index.tsx @@ -0,0 +1,33 @@ +import NavigationTabBar from '@components/Navigation/NavigationTabBar'; + +import useResponsiveLayout from '@hooks/useResponsiveLayout'; +import useThemeStyles from '@hooks/useThemeStyles'; + +import React from 'react'; +import {View} from 'react-native'; + +import type TabBarBottomContentProps from './types'; + +/** + * A copy of the floating tab bar in a tab root screen's own layout, so the screen's content ends above the bar the + * navigator draws over it, and the bar travels with the screen while it slides in or out. + */ +function TabBarBottomContent({selectedTab}: TabBarBottomContentProps) { + const {shouldUseNarrowLayout} = useResponsiveLayout(); + const styles = useThemeStyles(); + + if (!shouldUseNarrowLayout) { + return null; + } + + return ( + + + + ); +} + +export default TabBarBottomContent; diff --git a/src/components/Navigation/TabBarBottomContent/types.ts b/src/components/Navigation/TabBarBottomContent/types.ts new file mode 100644 index 000000000000..30f7b28d1650 --- /dev/null +++ b/src/components/Navigation/TabBarBottomContent/types.ts @@ -0,0 +1,9 @@ +import type NAVIGATION_TABS from '@components/Navigation/NavigationTabBar/NAVIGATION_TABS'; + +import type {ValueOf} from 'type-fest'; + +type TabBarBottomContentProps = { + selectedTab: ValueOf; +}; + +export default TabBarBottomContentProps; diff --git a/src/components/Tables/WorkspaceListTable/index.tsx b/src/components/Tables/WorkspaceListTable/index.tsx index c33b533989ab..504682a3e635 100644 --- a/src/components/Tables/WorkspaceListTable/index.tsx +++ b/src/components/Tables/WorkspaceListTable/index.tsx @@ -1,7 +1,6 @@ import type {CompareItemsCallback, FilterConfig, IsItemInFilterCallback, IsItemInSearchCallback, TableColumn, TableData, TableHandle} from '@components/Table'; import Table, {composeTableListHeader} from '@components/Table'; -import useFloatingTabBarContentInsetStyle from '@hooks/useFloatingTabBarContentInsetStyle'; import {useMemoizedLazyIllustrations} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; import usePermissions from '@hooks/usePermissions'; @@ -74,7 +73,6 @@ export default function WorkspaceListTable({ref, workspaces, headerComponent, on const {isRestrictedPolicyCreation} = usePreferredPolicy(); const illustrations = useMemoizedLazyIllustrations(['PlanetWithMobileApp']); const {shouldUseNarrowLayout, isMediumScreenWidth} = useResponsiveLayout(); - const floatingTabBarContentInsetStyle = useFloatingTabBarContentInsetStyle(); const shouldUseNarrowTableLayout = shouldUseNarrowLayout || isMediumScreenWidth; const workspaceTableColumns: Array> = [ @@ -224,7 +222,7 @@ export default function WorkspaceListTable({ref, workspaces, headerComponent, on /> - + ); } diff --git a/src/components/WorkspaceListLayout.tsx b/src/components/WorkspaceListLayout.tsx index e5a3ab23b321..ce180ea0d91d 100644 --- a/src/components/WorkspaceListLayout.tsx +++ b/src/components/WorkspaceListLayout.tsx @@ -16,6 +16,8 @@ import React from 'react'; import {View} from 'react-native'; import {useDebugTabViewHeight} from './Navigation/DebugTabView'; +import NAVIGATION_TABS from './Navigation/NavigationTabBar/NAVIGATION_TABS'; +import TabBarBottomContent from './Navigation/TabBarBottomContent'; import TopBarWithLoadingBar from './Navigation/TopBarWithLoadingBar'; import OfflineIndicator from './OfflineIndicator'; import ScreenWrapper from './ScreenWrapper'; @@ -117,6 +119,8 @@ function WorkspaceListLayout({children, activeTabKey, headerButton, headerCompon shouldEnableMaxHeight shouldEnablePickerAvoiding={false} enableEdgeToEdgeBottomSafeAreaPadding={false} + bottomContentStyle={styles.overflowVisible} + bottomContent={} > diff --git a/src/hooks/useFloatingTabBarContentInsetStyle/index.native.ts b/src/hooks/useFloatingTabBarContentInsetStyle/index.native.ts deleted file mode 100644 index dafac08c0109..000000000000 --- a/src/hooks/useFloatingTabBarContentInsetStyle/index.native.ts +++ /dev/null @@ -1,8 +0,0 @@ -import type {StyleProp, ViewStyle} from 'react-native'; - -/** The native tab bar keeps content clear of itself, through the safe area on iOS and by laying it out above the bar on Android, so no extra content padding is needed. */ -function useFloatingTabBarContentInsetStyle(): StyleProp { - return undefined; -} - -export default useFloatingTabBarContentInsetStyle; diff --git a/src/hooks/useFloatingTabBarContentInsetStyle/index.ts b/src/hooks/useFloatingTabBarContentInsetStyle/index.ts deleted file mode 100644 index 11b196072f9b..000000000000 --- a/src/hooks/useFloatingTabBarContentInsetStyle/index.ts +++ /dev/null @@ -1,18 +0,0 @@ -import useResponsiveLayout from '@hooks/useResponsiveLayout'; -import useThemeStyles from '@hooks/useThemeStyles'; - -import type {StyleProp, ViewStyle} from 'react-native'; - -/** - * Bottom padding a tab root screen's scrollable content needs so its last row settles above the floating tab - * bar instead of under it. Mobile web draws that bar in JS; iOS and Android take the sibling module, because - * the native bar keeps the content clear of itself and needs no extra room. - */ -function useFloatingTabBarContentInsetStyle(): StyleProp { - const styles = useThemeStyles(); - const {shouldUseNarrowLayout} = useResponsiveLayout(); - - return shouldUseNarrowLayout ? styles.floatingTabBarContentInset : undefined; -} - -export default useFloatingTabBarContentInsetStyle; diff --git a/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/NATIVE_TAB_ICONS.ts b/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/NATIVE_TAB_ICONS.ts new file mode 100644 index 000000000000..ffbdb2f47cb9 --- /dev/null +++ b/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/NATIVE_TAB_ICONS.ts @@ -0,0 +1,21 @@ +import buildingsIcon from '@assets/images/native-tab-icons/buildings.png'; +import homeIcon from '@assets/images/native-tab-icons/home.png'; +import inboxIcon from '@assets/images/native-tab-icons/inbox.png'; +import profileIcon from '@assets/images/native-tab-icons/profile.png'; +import receiptMultipleIcon from '@assets/images/native-tab-icons/receipt-multiple.png'; + +import NAVIGATORS from '@src/NAVIGATORS'; +import SCREENS from '@src/SCREENS'; + +import type {NativeBottomTabIcon} from '@react-navigation/bottom-tabs/unstable'; + +/** The glyph each native tab draws, keyed by the tab's route name. The bar tints them for both selection states. */ +const NATIVE_TAB_ICONS = { + [SCREENS.HOME]: {type: 'image', source: homeIcon}, + [NAVIGATORS.REPORTS_SPLIT_NAVIGATOR]: {type: 'image', source: inboxIcon}, + [NAVIGATORS.SEARCH_FULLSCREEN_NAVIGATOR]: {type: 'image', source: receiptMultipleIcon}, + [NAVIGATORS.WORKSPACE_NAVIGATOR]: {type: 'image', source: buildingsIcon}, + [NAVIGATORS.SETTINGS_SPLIT_NAVIGATOR]: {type: 'image', source: profileIcon}, +} as const satisfies Record; + +export default NATIVE_TAB_ICONS; diff --git a/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/NativeTabLayout.tsx b/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/NativeTabLayout.tsx new file mode 100644 index 000000000000..e6f5048de235 --- /dev/null +++ b/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/NativeTabLayout.tsx @@ -0,0 +1,84 @@ +import FloatingCameraButton from '@components/FloatingCameraButton'; +import FloatingGPSButton from '@components/FloatingGPSButton'; +import DebugTabView from '@components/Navigation/DebugTabView'; +import NavigationTabBar from '@components/Navigation/NavigationTabBar'; +import NAVIGATION_TABS from '@components/Navigation/NavigationTabBar/NAVIGATION_TABS'; +import ROUTE_TO_NAVIGATION_TAB from '@components/Navigation/NavigationTabBar/ROUTE_TO_NAVIGATION_TAB'; + +import useOnyx from '@hooks/useOnyx'; +import useResponsiveLayout from '@hooks/useResponsiveLayout'; +import useSafeAreaInsets from '@hooks/useSafeAreaInsets'; +import useThemeStyles from '@hooks/useThemeStyles'; + +import NavigationTabBarFloatingActionButton from '@pages/inbox/sidebar/NavigationTabBarFloatingActionButton'; + +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import SCREENS from '@src/SCREENS'; + +import type {NativeBottomTabNavigatorProps} from '@react-navigation/bottom-tabs/unstable'; + +import React from 'react'; +import {View} from 'react-native'; +import Animated, {FadeIn, FadeOut} from 'react-native-reanimated'; + +type NativeTabLayoutProps = Parameters>[0] & { + /** Distance from the bottom edge of the screen to the strip the floating buttons hang from, given the bottom safe area inset. */ + getFloatingButtonsBottom: (bottomInset: number) => number; +}; + +/** + * Wraps the tab screens so the floating buttons, the debug view and the wide-layout side bar can be drawn over + * them. The native bar is part of the navigator itself, so it is switched off through `tabBarStyle` in the + * navigator's screen options instead of being unmounted. + */ +function NativeTabLayout({children, state, descriptors, getFloatingButtonsBottom}: NativeTabLayoutProps) { + const {shouldUseNarrowLayout} = useResponsiveLayout(); + const [isDebugModeEnabled] = useOnyx(ONYXKEYS.IS_DEBUG_MODE_ENABLED); + const styles = useThemeStyles(); + const {bottom: bottomInset} = useSafeAreaInsets(); + const activeRoute = state.routes[state.index]; + const selectedTab = ROUTE_TO_NAVIGATION_TAB[activeRoute?.name ?? SCREENS.HOME] ?? NAVIGATION_TABS.HOME; + // The buttons follow whatever the navigator decided for the bar itself. + const shouldShowNativeTabBar = shouldUseNarrowLayout && !!activeRoute && descriptors[activeRoute.key]?.options.tabBarStyle?.display !== 'none'; + + if (!shouldUseNarrowLayout) { + return ( + + + + + {children} + + ); + } + + return ( + + {children} + {!!isDebugModeEnabled && shouldShowNativeTabBar && } + {shouldShowNativeTabBar && ( + // The buttons belong to the bar, so they fade with it rather than appearing in place. They leave + // faster than they come in, so they stop covering a bar that is still sliding out. + + + + + + + + )} + + ); +} + +export default NativeTabLayout; +export type {NativeTabLayoutProps}; diff --git a/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/useNativeTabNavigator.ts b/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/useNativeTabNavigator.ts new file mode 100644 index 000000000000..e1f8c06975f0 --- /dev/null +++ b/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/useNativeTabNavigator.ts @@ -0,0 +1,110 @@ +import {useFullScreenBlockingViewState} from '@components/FullScreenBlockingViewContextProvider'; +import NAVIGATION_TABS from '@components/Navigation/NavigationTabBar/NAVIGATION_TABS'; +import ROUTE_TO_NAVIGATION_TAB from '@components/Navigation/NavigationTabBar/ROUTE_TO_NAVIGATION_TAB'; + +import useAccountTabIndicatorStatus from '@hooks/useAccountTabIndicatorStatus'; +import useResponsiveLayout from '@hooks/useResponsiveLayout'; +import {useSidebarOrderedReportsState} from '@hooks/useSidebarOrderedReports'; +import useTheme from '@hooks/useTheme'; +import useWorkspacesTabIndicatorStatus from '@hooks/useWorkspacesTabIndicatorStatus'; + +import {getPreservedNavigatorState, setPreservedNavigatorState} from '@libs/Navigation/AppNavigator/createSplitNavigator/usePreserveNavigatorState'; +import isTabRouteAtRoot from '@libs/Navigation/helpers/isTabRouteAtRoot'; +import type {TabNavigatorParamList} from '@libs/Navigation/types'; +import cancelTabNavigationSpans, {INBOX_TAB_SPAN_IDS, REPORTS_TAB_SPAN_IDS} from '@libs/telemetry/cancelTabNavigationSpans'; + +import CONST from '@src/CONST'; +import SCREENS from '@src/SCREENS'; + +import type {NavigationAction, NavigationState, PartialState, Router, TabNavigationState} from '@react-navigation/native'; +import type {ValueOf} from 'type-fest'; + +import {findFocusedRoute, useNavigation, useNavigationState, useRoute} from '@react-navigation/native'; +import {useEffect} from 'react'; + +/** + * Root-level tab screens where the swipe-back gesture should be disabled. + * Swiping from these screens would pop the entire TAB_NAVIGATOR, which feels wrong. + * WORKSPACE.INITIAL is intentionally excluded, since swiping back from it returns to the workspace list. + */ +const TAB_ROOT_SCREENS_WITHOUT_GESTURE = new Set([SCREENS.HOME, SCREENS.INBOX, SCREENS.SEARCH.ROOT, SCREENS.SETTINGS.ROOT]); + +const NAVIGATION_TAB_TO_SPANS: Partial, readonly string[]>> = { + [NAVIGATION_TABS.INBOX]: INBOX_TAB_SPAN_IDS, + [NAVIGATION_TABS.SEARCH]: REPORTS_TAB_SPAN_IDS, +}; + +/** stale === false distinguishes a fully realized NavigationState from a PartialState. */ +function isRealizedNavigationState(state: NavigationState | PartialState | undefined): state is NavigationState { + return state?.stale === false; +} + +/** + * The state both native tab navigators share: whether the native bar shows, the status dot color of each tab that + * has one, and a router override that restores the tab state after the navigator is remounted. It also keeps the + * parent stack's swipe-back gesture, the preserved tab state and the tab-navigation spans in sync with the focused tab. + */ +function useNativeTabNavigator() { + const {shouldUseNarrowLayout} = useResponsiveLayout(); + const {isBlockingViewVisible} = useFullScreenBlockingViewState(); + const theme = useTheme(); + const {chatTabBrickRoad} = useSidebarOrderedReportsState(); + const {indicatorColor: workspacesIndicatorColor, status: workspacesIndicatorStatus} = useWorkspacesTabIndicatorStatus(); + const {indicatorColor: accountIndicatorColor, status: accountIndicatorStatus} = useAccountTabIndicatorStatus(); + const navigation = useNavigation(); + const parentNavigation = navigation.getParent(); + const focusedRouteName = useNavigationState((state) => findFocusedRoute(state)?.name); + const route = useRoute(); + // The Tab.Navigator's own state lives at `parentState.routes[i].state`. We can't read it via + // `useNavigationState((s) => s)` here because the navigator's body runs before + // 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 = isRealizedNavigationState(tabState) ? tabState.routes[tabState.index] : undefined; + const selectedTab = ROUTE_TO_NAVIGATION_TAB[activeTabRoute?.name ?? SCREENS.HOME] ?? NAVIGATION_TABS.HOME; + const shouldShowNativeTabBar = shouldUseNarrowLayout && isTabRouteAtRoot(activeTabRoute) && !isBlockingViewVisible; + + let inboxDotColor: string | undefined; + if (chatTabBrickRoad) { + inboxDotColor = chatTabBrickRoad === CONST.BRICK_ROAD_INDICATOR_STATUS.INFO ? theme.iconSuccessFill : theme.danger; + } + const workspacesDotColor = workspacesIndicatorStatus ? workspacesIndicatorColor : undefined; + const accountDotColor = accountIndicatorStatus ? accountIndicatorColor : undefined; + + useEffect(() => { + if (!shouldUseNarrowLayout || !parentNavigation) { + return; + } + const isRootScreen = TAB_ROOT_SCREENS_WITHOUT_GESTURE.has(focusedRouteName ?? ''); + parentNavigation.setOptions({gestureEnabled: !isRootScreen}); + }, [focusedRouteName, shouldUseNarrowLayout, parentNavigation]); + + useEffect(() => { + if (!isRealizedNavigationState(tabState)) { + return; + } + setPreservedNavigatorState(route.key, tabState); + }, [tabState, route.key]); + + // Cancel any in-flight tab-navigation span that doesn't match the new focused tab. + // The span for the new tab is started by the tab button before navigation, so we keep it via `except`. + useEffect(() => { + cancelTabNavigationSpans(NAVIGATION_TAB_TO_SPANS[selectedTab]); + }, [selectedTab]); + + // The slicing optimization in useCustomRootStackNavigatorState can unmount and later remount + // this TAB_NAVIGATOR. Without restoration it would default to index 0. We restore the saved + // state by overriding the bottom-tab router's getInitialState, the same pattern SplitRouter + // uses for its split navigators. + const tabRouterOverride = ( + originalRouter: Router, Action>, + ): Partial, Action>> => ({ + getInitialState: (configOptions) => { + const preserved = getPreservedNavigatorState>(route.key); + return preserved ? originalRouter.getRehydratedState(preserved, configOptions) : originalRouter.getInitialState(configOptions); + }, + }); + + return {shouldShowNativeTabBar, inboxDotColor, workspacesDotColor, accountDotColor, tabRouterOverride}; +} + +export default useNativeTabNavigator; diff --git a/src/libs/Navigation/AppNavigator/Navigators/TabNavigator.android.tsx b/src/libs/Navigation/AppNavigator/Navigators/TabNavigator.android.tsx index ebbc2ed08711..9387848965af 100644 --- a/src/libs/Navigation/AppNavigator/Navigators/TabNavigator.android.tsx +++ b/src/libs/Navigation/AppNavigator/Navigators/TabNavigator.android.tsx @@ -1,50 +1,27 @@ -import buildingsIcon from '@assets/images/native-tab-icons/buildings.png'; -import homeIcon from '@assets/images/native-tab-icons/home.png'; -import inboxIcon from '@assets/images/native-tab-icons/inbox.png'; -import profileIcon from '@assets/images/native-tab-icons/profile.png'; -import receiptMultipleIcon from '@assets/images/native-tab-icons/receipt-multiple.png'; - -import FloatingCameraButton from '@components/FloatingCameraButton'; -import FloatingGPSButton from '@components/FloatingGPSButton'; -import {useFullScreenBlockingViewState} from '@components/FullScreenBlockingViewContextProvider'; -import DebugTabView from '@components/Navigation/DebugTabView'; -import NavigationTabBar from '@components/Navigation/NavigationTabBar'; -import NAVIGATION_TABS from '@components/Navigation/NavigationTabBar/NAVIGATION_TABS'; -import ROUTE_TO_NAVIGATION_TAB from '@components/Navigation/NavigationTabBar/ROUTE_TO_NAVIGATION_TAB'; - -import useAccountTabIndicatorStatus from '@hooks/useAccountTabIndicatorStatus'; import useLocalize from '@hooks/useLocalize'; -import useOnyx from '@hooks/useOnyx'; -import useResponsiveLayout from '@hooks/useResponsiveLayout'; -import useSafeAreaInsets from '@hooks/useSafeAreaInsets'; -import {useSidebarOrderedReportsState} from '@hooks/useSidebarOrderedReports'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; -import useWorkspacesTabIndicatorStatus from '@hooks/useWorkspacesTabIndicatorStatus'; -import {getPreservedNavigatorState, setPreservedNavigatorState} from '@libs/Navigation/AppNavigator/createSplitNavigator/usePreserveNavigatorState'; -import isTabRouteAtRoot from '@libs/Navigation/helpers/isTabRouteAtRoot'; import {nativeBottomTabScreenLayoutWrapper} from '@libs/Navigation/PlatformStackNavigation/ScreenLayout'; import type {TabNavigatorParamList} from '@libs/Navigation/types'; -import cancelTabNavigationSpans, {INBOX_TAB_SPAN_IDS, REPORTS_TAB_SPAN_IDS} from '@libs/telemetry/cancelTabNavigationSpans'; import HomePage from '@pages/home/HomePage'; -import NavigationTabBarFloatingActionButton from '@pages/inbox/sidebar/NavigationTabBarFloatingActionButton'; -import CONST from '@src/CONST'; +import variables from '@styles/variables'; + import NAVIGATORS from '@src/NAVIGATORS'; -import ONYXKEYS from '@src/ONYXKEYS'; import SCREENS from '@src/SCREENS'; -import type {NativeBottomTabIcon, NativeBottomTabNavigationOptions, NativeBottomTabNavigatorProps} from '@react-navigation/bottom-tabs/unstable'; -import type {NavigationAction, NavigationState, PartialState, Router, TabNavigationState} from '@react-navigation/native'; +import type {NativeBottomTabNavigationOptions} from '@react-navigation/bottom-tabs/unstable'; import {createNativeBottomTabNavigator} from '@react-navigation/bottom-tabs/unstable'; -import {findFocusedRoute, useNavigation, useNavigationState, useRoute} from '@react-navigation/native'; -import React, {useEffect} from 'react'; -import {View} from 'react-native'; -import Animated, {FadeIn, FadeOut} from 'react-native-reanimated'; +import React from 'react'; + +import type {NativeTabLayoutProps} from './NativeTabNavigator/NativeTabLayout'; +import NATIVE_TAB_ICONS from './NativeTabNavigator/NATIVE_TAB_ICONS'; +import NativeTabLayout from './NativeTabNavigator/NativeTabLayout'; +import useNativeTabNavigator from './NativeTabNavigator/useNativeTabNavigator'; import ReportsSplitNavigator from './ReportsSplitNavigator'; import SearchFullscreenNavigator from './SearchFullscreenNavigator'; import SettingsSplitNavigator from './SettingsSplitNavigator'; @@ -57,33 +34,9 @@ import WorkspaceNavigator from './WorkspaceNavigator'; */ const Tab = createNativeBottomTabNavigator(); -/** - * Plain glyphs, recolored by the bar's own icon tint for both selection states. The account tab uses the profile - * glyph rather than the avatar, because Android tints every tab icon and would flatten a photo to a silhouette. - */ -const HOME_TAB_ICON = {type: 'image', source: homeIcon} as const satisfies NativeBottomTabIcon; -const INBOX_TAB_ICON = {type: 'image', source: inboxIcon} as const satisfies NativeBottomTabIcon; -const SPEND_TAB_ICON = {type: 'image', source: receiptMultipleIcon} as const satisfies NativeBottomTabIcon; -const WORKSPACES_TAB_ICON = {type: 'image', source: buildingsIcon} as const satisfies NativeBottomTabIcon; -const ACCOUNT_TAB_ICON = {type: 'image', source: profileIcon} as const satisfies NativeBottomTabIcon; - /** An empty badge value makes Material draw its small dot badge instead of a number. */ const STATUS_DOT_BADGE = ''; -/** - * Root-level tab screens where the swipe-back gesture should be disabled. - * Swiping from these screens would pop the entire TAB_NAVIGATOR, which feels wrong. - * WORKSPACE.INITIAL is intentionally excluded — swiping back from it returns to the workspace list. - */ -const TAB_ROOT_SCREENS_WITHOUT_GESTURE = new Set([SCREENS.HOME, SCREENS.INBOX, SCREENS.SEARCH.ROOT, SCREENS.SETTINGS.ROOT]); - -type NativeTabLayoutProps = Parameters>[0]; - -/** stale === false distinguishes a fully realized NavigationState from a PartialState. */ -function isRealizedNavigationState(state: NavigationState | PartialState | undefined): state is NavigationState { - return state?.stale === false; -} - /** Shows a status as a Material dot badge in its own color, or no badge at all. */ function getStatusBadgeOptions(dotColor: string | undefined): Pick { if (!dotColor) { @@ -92,132 +45,26 @@ function getStatusBadgeOptions(dotColor: string | undefined): Pick - - - - {children} - - ); - } - - return ( - - {children} - {!!isDebugModeEnabled && shouldShowNativeTabBar && } - {shouldShowNativeTabBar && ( - - - - - - - - )} - - ); -} +/** Material's bar sits above the system gesture inset, so the floating buttons clear both. */ +const getFloatingButtonsBottom = (bottomInset: number) => variables.androidNativeTabBarFloatingButtonsBottom + bottomInset; -const renderNativeTabLayout = (props: NativeTabLayoutProps) => ; +const renderNativeTabLayout = (props: Omit) => ( + +); function TabNavigator() { - const {shouldUseNarrowLayout} = useResponsiveLayout(); - const {isBlockingViewVisible} = useFullScreenBlockingViewState(); const {translate} = useLocalize(); const theme = useTheme(); const styles = useThemeStyles(); - const {chatTabBrickRoad} = useSidebarOrderedReportsState(); - const {indicatorColor: workspacesIndicatorColor, status: workspacesIndicatorStatus} = useWorkspacesTabIndicatorStatus(); - const {indicatorColor: accountIndicatorColor, status: accountIndicatorStatus} = useAccountTabIndicatorStatus(); - const navigation = useNavigation(); - const parentNavigation = navigation.getParent(); - const focusedRouteName = useNavigationState((state) => findFocusedRoute(state)?.name); - const route = useRoute(); - // The Tab.Navigator's own state lives at `parentState.routes[i].state`. We can't read it via - // `useNavigationState((s) => s)` here because TabNavigator's body runs before - // 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 = isRealizedNavigationState(tabState) ? tabState.routes[tabState.index] : undefined; - const activeTabRouteName = isRealizedNavigationState(tabState) ? activeTabRoute?.name : SCREENS.HOME; - const selectedTab = ROUTE_TO_NAVIGATION_TAB[activeTabRouteName ?? SCREENS.HOME] ?? NAVIGATION_TABS.HOME; - const shouldShowNativeTabBar = shouldUseNarrowLayout && isTabRouteAtRoot(activeTabRoute) && !isBlockingViewVisible; - - let inboxDotColor: string | undefined; - if (chatTabBrickRoad) { - inboxDotColor = chatTabBrickRoad === CONST.BRICK_ROAD_INDICATOR_STATUS.INFO ? theme.iconSuccessFill : theme.danger; - } - const workspacesDotColor = workspacesIndicatorStatus ? workspacesIndicatorColor : undefined; - const accountDotColor = accountIndicatorStatus ? accountIndicatorColor : undefined; - - useEffect(() => { - if (!shouldUseNarrowLayout || !parentNavigation) { - return; - } - const isRootScreen = TAB_ROOT_SCREENS_WITHOUT_GESTURE.has(focusedRouteName ?? ''); - parentNavigation.setOptions({gestureEnabled: !isRootScreen}); - }, [focusedRouteName, shouldUseNarrowLayout, parentNavigation]); - - useEffect(() => { - if (!isRealizedNavigationState(tabState)) { - return; - } - setPreservedNavigatorState(route.key, tabState); - }, [tabState, route.key]); - - // Cancel any in-flight tab-navigation span that doesn't match the new focused tab. - // The span for the new tab is started by the tab button before navigation, so we keep it via `except`. - useEffect(() => { - let spans; - if (selectedTab === NAVIGATION_TABS.INBOX) { - spans = INBOX_TAB_SPAN_IDS; - } else if (selectedTab === NAVIGATION_TABS.SEARCH) { - spans = REPORTS_TAB_SPAN_IDS; - } - cancelTabNavigationSpans(spans); - }, [selectedTab]); - - // The slicing optimization in useCustomRootStackNavigatorState can unmount and later remount - // this TAB_NAVIGATOR. Without restoration it would default to index 0. We restore the saved - // state by overriding the bottom-tab router's getInitialState — the same pattern SplitRouter - // uses for its split navigators. - const tabRouterOverride = ( - originalRouter: Router, Action>, - ): Partial, Action>> => ({ - getInitialState: (configOptions) => { - const preserved = getPreservedNavigatorState>(route.key); - return preserved ? originalRouter.getRehydratedState(preserved, configOptions) : originalRouter.getInitialState(configOptions); - }, - }); + const {shouldShowNativeTabBar, inboxDotColor, workspacesDotColor, accountDotColor, tabRouterOverride} = useNativeTabNavigator(); // Colors and the label face come from the Expensify theme and match the side bar on wide layouts: the // selected tab has the menu icon color and a bold label in the regular text color, the others the plain icon // color and a supporting text label. The bar takes the raised surface tone with no top border. - const screenOptions = { + const screenOptions: NativeBottomTabNavigationOptions = { headerShown: false, tabBarActiveTintColor: theme.iconMenu, tabBarInactiveTintColor: theme.icon, @@ -225,9 +72,9 @@ function TabNavigator() { tabBarInactiveLabelColor: theme.textSupporting, tabBarLabelStyle: {fontFamily: styles.textSmall.fontFamily, fontSize: styles.textSmall.fontSize}, tabBarActiveIndicatorColor: theme.androidTabBarActiveIndicatorBG, - tabBarLabelVisibilityMode: 'labeled' as const, + tabBarLabelVisibilityMode: 'labeled', // Every tab shares one style, so the bar reads the current visibility in the same render that changed it. - tabBarStyle: {display: shouldShowNativeTabBar ? ('flex' as const) : ('none' as const), backgroundColor: theme.highlightBG}, + tabBarStyle: {display: shouldShowNativeTabBar ? 'flex' : 'none', backgroundColor: theme.highlightBG}, }; return ( @@ -241,27 +88,39 @@ function TabNavigator() { ); diff --git a/src/libs/Navigation/AppNavigator/Navigators/TabNavigator.ios.tsx b/src/libs/Navigation/AppNavigator/Navigators/TabNavigator.ios.tsx index f91dc85244fa..218a97fd29f0 100644 --- a/src/libs/Navigation/AppNavigator/Navigators/TabNavigator.ios.tsx +++ b/src/libs/Navigation/AppNavigator/Navigators/TabNavigator.ios.tsx @@ -1,57 +1,33 @@ -import buildingsIcon from '@assets/images/native-tab-icons/buildings.png'; -import homeIcon from '@assets/images/native-tab-icons/home.png'; -import inboxIcon from '@assets/images/native-tab-icons/inbox.png'; -import profileIcon from '@assets/images/native-tab-icons/profile.png'; -import receiptMultipleIcon from '@assets/images/native-tab-icons/receipt-multiple.png'; - -import FloatingCameraButton from '@components/FloatingCameraButton'; -import FloatingGPSButton from '@components/FloatingGPSButton'; -import {useFullScreenBlockingViewState} from '@components/FullScreenBlockingViewContextProvider'; -import DebugTabView from '@components/Navigation/DebugTabView'; -import NavigationTabBar from '@components/Navigation/NavigationTabBar'; -import NAVIGATION_TABS from '@components/Navigation/NavigationTabBar/NAVIGATION_TABS'; -import ROUTE_TO_NAVIGATION_TAB from '@components/Navigation/NavigationTabBar/ROUTE_TO_NAVIGATION_TAB'; - -import useAccountTabIndicatorStatus from '@hooks/useAccountTabIndicatorStatus'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; import useLocalize from '@hooks/useLocalize'; -import useOnyx from '@hooks/useOnyx'; -import useResponsiveLayout from '@hooks/useResponsiveLayout'; -import {useSidebarOrderedReportsState} from '@hooks/useSidebarOrderedReports'; import useTheme from '@hooks/useTheme'; -import useThemeStyles from '@hooks/useThemeStyles'; -import useWorkspacesTabIndicatorStatus from '@hooks/useWorkspacesTabIndicatorStatus'; -import {getPreservedNavigatorState, setPreservedNavigatorState} from '@libs/Navigation/AppNavigator/createSplitNavigator/usePreserveNavigatorState'; -import isTabRouteAtRoot from '@libs/Navigation/helpers/isTabRouteAtRoot'; import {nativeBottomTabScreenLayoutWrapper} from '@libs/Navigation/PlatformStackNavigation/ScreenLayout'; import type {TabNavigatorParamList} from '@libs/Navigation/types'; -import cancelTabNavigationSpans, {INBOX_TAB_SPAN_IDS, REPORTS_TAB_SPAN_IDS} from '@libs/telemetry/cancelTabNavigationSpans'; import {getAvatarURL} from '@libs/UserAvatarUtils'; import HomePage from '@pages/home/HomePage'; -import NavigationTabBarFloatingActionButton from '@pages/inbox/sidebar/NavigationTabBarFloatingActionButton'; import FontUtils from '@styles/utils/FontUtils'; import variables from '@styles/variables'; -import CONST from '@src/CONST'; import NAVIGATORS from '@src/NAVIGATORS'; -import ONYXKEYS from '@src/ONYXKEYS'; import SCREENS from '@src/SCREENS'; -import type {NativeBottomTabIcon, NativeBottomTabNavigatorProps} from '@react-navigation/bottom-tabs/unstable'; -import type {NavigationAction, NavigationState, PartialState, Router, TabNavigationState} from '@react-navigation/native'; +import type {NativeBottomTabIcon} from '@react-navigation/bottom-tabs/unstable'; import type {SkCanvas} from '@shopify/react-native-skia'; import type {ImageSourcePropType} from 'react-native'; import {createNativeBottomTabNavigator} from '@react-navigation/bottom-tabs/unstable'; -import {findFocusedRoute, useNavigation, useNavigationState, useRoute} from '@react-navigation/native'; import {BlendMode, ClipOp, FontWeight, ImageFormat, Skia} from '@shopify/react-native-skia'; import React, {useEffect, useState} from 'react'; -import {Image, View} from 'react-native'; -import Animated, {FadeIn, FadeOut} from 'react-native-reanimated'; +import {Image} from 'react-native'; +import type {NativeTabLayoutProps} from './NativeTabNavigator/NativeTabLayout'; + +import NATIVE_TAB_ICONS from './NativeTabNavigator/NATIVE_TAB_ICONS'; +import NativeTabLayout from './NativeTabNavigator/NativeTabLayout'; +import useNativeTabNavigator from './NativeTabNavigator/useNativeTabNavigator'; import ReportsSplitNavigator from './ReportsSplitNavigator'; import SearchFullscreenNavigator from './SearchFullscreenNavigator'; import SettingsSplitNavigator from './SettingsSplitNavigator'; @@ -63,31 +39,6 @@ import WorkspaceNavigator from './WorkspaceNavigator'; */ const Tab = createNativeBottomTabNavigator(); -/** Template icons, so the selected tab picks up the active color through the bar's own tint. */ -const HOME_TAB_ICON = {type: 'image', source: homeIcon} as const satisfies NativeBottomTabIcon; -const INBOX_TAB_ICON = {type: 'image', source: inboxIcon} as const satisfies NativeBottomTabIcon; -const SPEND_TAB_ICON = {type: 'image', source: receiptMultipleIcon} as const satisfies NativeBottomTabIcon; -const WORKSPACES_TAB_ICON = {type: 'image', source: buildingsIcon} as const satisfies NativeBottomTabIcon; -const ACCOUNT_TAB_ICON = {type: 'image', source: profileIcon} as const satisfies NativeBottomTabIcon; - -/** Every template icon the bar draws, paired with the tab it belongs to so the tinted copies can be looked up again. */ -const TAB_ICONS = [ - [SCREENS.HOME, homeIcon], - [NAVIGATORS.REPORTS_SPLIT_NAVIGATOR, inboxIcon], - [NAVIGATORS.SEARCH_FULLSCREEN_NAVIGATOR, receiptMultipleIcon], - [NAVIGATORS.WORKSPACE_NAVIGATOR, buildingsIcon], - [NAVIGATORS.SETTINGS_SPLIT_NAVIGATOR, profileIcon], -] as const; - -/** - * Root-level tab screens where the swipe-back gesture should be disabled. - * Swiping from these screens would pop the entire TAB_NAVIGATOR, which feels wrong. - * WORKSPACE.INITIAL is intentionally excluded — swiping back from it returns to the workspace list. - */ -const TAB_ROOT_SCREENS_WITHOUT_GESTURE = new Set([SCREENS.HOME, SCREENS.INBOX, SCREENS.SEARCH.ROOT, SCREENS.SETTINGS.ROOT]); - -type NativeTabLayoutProps = Parameters>[0]; - /** The recolored tab icons, with the signature of everything baked into them. */ type TintedTabIcons = {signature: string; icons: Record}; @@ -101,11 +52,6 @@ type CircularAvatarIcons = {signature: string; uri: string; active: AvatarTabIco let lastTintedIcons: TintedTabIcons | undefined; let lastCircularAvatar: CircularAvatarIcons | undefined; -/** stale === false distinguishes a fully realized NavigationState from a PartialState. */ -function isRealizedNavigationState(state: NavigationState | PartialState | undefined): state is NavigationState { - return state?.stale === false; -} - /** The recolored copies of one tab icon, one per selection state. */ type TintedTabIconPair = {active: NativeBottomTabIcon; inactive: NativeBottomTabIcon}; @@ -281,89 +227,23 @@ async function createCircularAvatarIcon(uri: string, dotColor: string | undefine return {uri: `data:image/png;base64,${base64}`, width: canvasWidth / scale, height: canvasHeight / scale}; } -/** - * Wraps the tab screens so the floating buttons, the debug view and the wide-layout side bar can be drawn over - * them. The native bar is part of the navigator itself, so it is switched off through `tabBarStyle` in the - * navigator's screen options instead of being unmounted. - */ -function NativeTabLayout({children, state, descriptors}: NativeTabLayoutProps) { - const {shouldUseNarrowLayout} = useResponsiveLayout(); - const [isDebugModeEnabled] = useOnyx(ONYXKEYS.IS_DEBUG_MODE_ENABLED); - const styles = useThemeStyles(); - const activeRoute = state.routes[state.index]; - const selectedTab = ROUTE_TO_NAVIGATION_TAB[activeRoute?.name ?? SCREENS.HOME] ?? NAVIGATION_TABS.HOME; - // The buttons follow whatever the navigator decided for the bar itself. - const shouldShowNativeTabBar = shouldUseNarrowLayout && !!activeRoute && descriptors[activeRoute.key]?.options.tabBarStyle?.display !== 'none'; - - if (!shouldUseNarrowLayout) { - return ( - - - - - {children} - - ); - } +/** Every glyph the bar draws, paired with the tab it belongs to so the tinted copies can be looked up again. */ +const TAB_ICONS = Object.entries(NATIVE_TAB_ICONS).map(([name, icon]) => [name, icon.source] as const); - return ( - - {children} - {!!isDebugModeEnabled && shouldShowNativeTabBar && } - {shouldShowNativeTabBar && ( - // The buttons belong to the bar, so they fade with it rather than appearing in place. UIKit animates - // the bar itself over roughly the 0.35s it gives every bar, which the FAB's own timing already - // matches on the way in; it leaves faster so it stops covering the bar that is still sliding out. - - - - - - - - )} - - ); -} +const getFloatingButtonsBottom = () => variables.iosNativeTabBarFloatingButtonsBottom; -const renderNativeTabLayout = (props: NativeTabLayoutProps) => ; +const renderNativeTabLayout = (props: Omit) => ( + +); function TabNavigator() { - const {shouldUseNarrowLayout} = useResponsiveLayout(); - const {isBlockingViewVisible} = useFullScreenBlockingViewState(); const {translate} = useLocalize(); const theme = useTheme(); - const {chatTabBrickRoad} = useSidebarOrderedReportsState(); - const {indicatorColor: workspacesIndicatorColor, status: workspacesIndicatorStatus} = useWorkspacesTabIndicatorStatus(); - const {indicatorColor: accountIndicatorColor, status: accountIndicatorStatus} = useAccountTabIndicatorStatus(); const currentUserPersonalDetails = useCurrentUserPersonalDetails(); - const navigation = useNavigation(); - const parentNavigation = navigation.getParent(); - const focusedRouteName = useNavigationState((state) => findFocusedRoute(state)?.name); - const route = useRoute(); - // The Tab.Navigator's own state lives at `parentState.routes[i].state`. We can't read it via - // `useNavigationState((s) => s)` here because TabNavigator's body runs before - // 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 = isRealizedNavigationState(tabState) ? tabState.routes[tabState.index] : undefined; - const activeTabRouteName = isRealizedNavigationState(tabState) ? activeTabRoute?.name : SCREENS.HOME; - const selectedTab = ROUTE_TO_NAVIGATION_TAB[activeTabRouteName ?? SCREENS.HOME] ?? NAVIGATION_TABS.HOME; - const shouldShowNativeTabBar = shouldUseNarrowLayout && isTabRouteAtRoot(activeTabRoute) && !isBlockingViewVisible; - - let inboxDotColor: string | undefined; - if (chatTabBrickRoad) { - inboxDotColor = chatTabBrickRoad === CONST.BRICK_ROAD_INDICATOR_STATUS.INFO ? theme.iconSuccessFill : theme.danger; - } - const workspacesDotColor = workspacesIndicatorStatus ? workspacesIndicatorColor : undefined; - const accountDotColor = accountIndicatorStatus ? accountIndicatorColor : undefined; + const {shouldShowNativeTabBar, inboxDotColor, workspacesDotColor, accountDotColor, tabRouterOverride} = useNativeTabNavigator(); const dotColors: Record = { [NAVIGATORS.REPORTS_SPLIT_NAVIGATOR]: inboxDotColor, [NAVIGATORS.WORKSPACE_NAVIGATOR]: workspacesDotColor, @@ -435,10 +315,10 @@ function TabNavigator() { }, [iconsSignature]); /** Falls back to the template icon for a tab whose tinted copies could not be drawn, so it is never left without one. */ - const getTabBarIcon = (name: string, fallbackIcon: NativeBottomTabIcon) => { + const getTabBarIcon = (name: keyof typeof NATIVE_TAB_ICONS) => { const pair = tintedIcons?.icons[name]; if (!pair) { - return fallbackIcon; + return NATIVE_TAB_ICONS[name]; } return ({focused}: {focused: boolean}) => (focused ? pair.active : pair.inactive); }; @@ -484,46 +364,6 @@ function TabNavigator() { // eslint-disable-next-line react-hooks/exhaustive-deps }, [avatarSignature]); - useEffect(() => { - if (!shouldUseNarrowLayout || !parentNavigation) { - return; - } - const isRootScreen = TAB_ROOT_SCREENS_WITHOUT_GESTURE.has(focusedRouteName ?? ''); - parentNavigation.setOptions({gestureEnabled: !isRootScreen}); - }, [focusedRouteName, shouldUseNarrowLayout, parentNavigation]); - - useEffect(() => { - if (!isRealizedNavigationState(tabState)) { - return; - } - setPreservedNavigatorState(route.key, tabState); - }, [tabState, route.key]); - - // Cancel any in-flight tab-navigation span that doesn't match the new focused tab. - // The span for the new tab is started by the tab button before navigation, so we keep it via `except`. - useEffect(() => { - let spans; - if (selectedTab === NAVIGATION_TABS.INBOX) { - spans = INBOX_TAB_SPAN_IDS; - } else if (selectedTab === NAVIGATION_TABS.SEARCH) { - spans = REPORTS_TAB_SPAN_IDS; - } - cancelTabNavigationSpans(spans); - }, [selectedTab]); - - // The slicing optimization in useCustomRootStackNavigatorState can unmount and later remount - // this TAB_NAVIGATOR. Without restoration it would default to index 0. We restore the saved - // state by overriding the bottom-tab router's getInitialState — the same pattern SplitRouter - // uses for its split navigators. - const tabRouterOverride = ( - originalRouter: Router, Action>, - ): Partial, Action>> => ({ - getInitialState: (configOptions) => { - const preserved = getPreservedNavigatorState>(route.key); - return preserved ? originalRouter.getRehydratedState(preserved, configOptions) : originalRouter.getInitialState(configOptions); - }, - }); - const screenOptions = { headerShown: false, tabBarActiveTintColor: theme.iconMenu, @@ -547,27 +387,27 @@ function TabNavigator() { toAvatarIcon(focused ? avatarPair.active : avatarPair.inactive) - : getTabBarIcon(NAVIGATORS.SETTINGS_SPLIT_NAVIGATOR, ACCOUNT_TAB_ICON), + : getTabBarIcon(NAVIGATORS.SETTINGS_SPLIT_NAVIGATOR), }} /> diff --git a/src/pages/Insights/InsightsDashboard.tsx b/src/pages/Insights/InsightsDashboard.tsx index f2de7991eab1..943dfa433a3e 100644 --- a/src/pages/Insights/InsightsDashboard.tsx +++ b/src/pages/Insights/InsightsDashboard.tsx @@ -1,11 +1,12 @@ import BlockingView from '@components/BlockingViews/BlockingView'; import FullPageErrorView from '@components/BlockingViews/FullPageErrorView'; +import NAVIGATION_TABS from '@components/Navigation/NavigationTabBar/NAVIGATION_TABS'; +import TabBarBottomContent from '@components/Navigation/TabBarBottomContent'; import TopBar from '@components/Navigation/TopBar'; import ScreenWrapper from '@components/ScreenWrapper'; import ScrollView from '@components/ScrollView'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; -import useFloatingTabBarContentInsetStyle from '@hooks/useFloatingTabBarContentInsetStyle'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; @@ -55,7 +56,6 @@ type InsightsDashboardContentProps = { function InsightsDashboardContent({dashboardID, hash, state, filters, onRetry, onGroupByChange}: InsightsDashboardContentProps) { const styles = useThemeStyles(); - const floatingTabBarContentInsetStyle = useFloatingTabBarContentInsetStyle(); const theme = useTheme(); const {translate} = useLocalize(); const {shouldUseNarrowLayout} = useResponsiveLayout(); @@ -107,7 +107,7 @@ function InsightsDashboardContent({dashboardID, hash, state, filters, onRetry, o return ( @@ -180,6 +180,7 @@ function InsightsDashboard({dashboardID}: {dashboardID: InsightsDashboardID}) { } testID="InsightsPage" > ; + function SearchPageNarrow({ queryJSON, searchResults, @@ -97,7 +100,6 @@ function SearchPageNarrow({ const {translate} = useLocalize(); const {windowHeight} = useWindowDimensions(); const styles = useThemeStyles(); - const floatingTabBarContentInsetStyle = useFloatingTabBarContentInsetStyle(); const StyleUtils = useStyleUtils(); const {clearSelectedTransactions} = useSearchSelectionActions(); const {shouldUseLiveData} = useSearchResultsContext(); @@ -244,7 +246,7 @@ function SearchPageNarrow({ const isDataLoaded = shouldUseLiveData || isSearchDataLoaded(searchResults, queryJSON); // Use the request state because `isLoading` also covers temporary UI loading that should not keep this bar visible. const shouldShowLoadingState = !isOffline && (!isDataLoaded || isSearchPending(searchResults)); - const contentContainerStyle = [!isMobileSelectionModeEnabled && styles.searchListContentContainerStyles(hasFilterBars), floatingTabBarContentInsetStyle]; + const contentContainerStyle = !isMobileSelectionModeEnabled ? styles.searchListContentContainerStyles(hasFilterBars) : undefined; const shouldRenderLayoutProbe = (isOverlayActive || !isHeaderInteractive) && !searchOverlayContent; @@ -263,6 +265,8 @@ function SearchPageNarrow({ shouldEnableMaxHeight offlineIndicatorStyle={styles.mtAuto} shouldShowOfflineIndicator={!!searchResults} + bottomContent={tabBarContent} + bottomContentStyle={styles.overflowVisible} > {!isMobileSelectionModeEnabled ? ( diff --git a/src/pages/domain/DomainInitialPage.tsx b/src/pages/domain/DomainInitialPage.tsx index f8996096591e..64187a0751d4 100644 --- a/src/pages/domain/DomainInitialPage.tsx +++ b/src/pages/domain/DomainInitialPage.tsx @@ -1,12 +1,13 @@ import FullPageNotFoundView from '@components/BlockingViews/FullPageNotFoundView'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; import HighlightableMenuItem from '@components/HighlightableMenuItem'; +import NAVIGATION_TABS from '@components/Navigation/NavigationTabBar/NAVIGATION_TABS'; +import TabBarBottomContent from '@components/Navigation/TabBarBottomContent'; import ScreenWrapper from '@components/ScreenWrapper'; import ScrollView from '@components/ScrollView'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; import useDocumentTitle from '@hooks/useDocumentTitle'; -import useFloatingTabBarContentInsetStyle from '@hooks/useFloatingTabBarContentInsetStyle'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; @@ -46,7 +47,6 @@ function DomainInitialPage({route}: DomainInitialPageProps) { const {singleExecution, isExecuting} = useSingleExecution(); const activeRoute = useNavigationState((state) => findFocusedRoute(state)?.name); const {shouldUseNarrowLayout} = useResponsiveLayout(); - const floatingTabBarContentInsetStyle = useFloatingTabBarContentInsetStyle(); const {translate} = useLocalize(); const {accountID: currentUserAccountID} = useCurrentUserPersonalDetails(); const domainAccountID = route.params?.domainAccountID; @@ -102,6 +102,8 @@ function DomainInitialPage({route}: DomainInitialPageProps) { } + bottomContentStyle={styles.overflowVisible} > Navigation.dismissModal()} @@ -119,7 +121,7 @@ function DomainInitialPage({route}: DomainInitialPageProps) { shouldDisplayAccountButton /> - + {/* Ideally we should use MenuList component for MenuItems with singleExecution/Navigation actions. diff --git a/src/pages/home/HomePage.tsx b/src/pages/home/HomePage.tsx index 37e64daae6d3..55dd8d0cfda3 100644 --- a/src/pages/home/HomePage.tsx +++ b/src/pages/home/HomePage.tsx @@ -1,11 +1,12 @@ +import NAVIGATION_TABS from '@components/Navigation/NavigationTabBar/NAVIGATION_TABS'; import QuickCreationActionsBar from '@components/Navigation/QuickCreationActionsBar'; +import TabBarBottomContent from '@components/Navigation/TabBarBottomContent'; import TopBar from '@components/Navigation/TopBar'; import ReceiptScanDropZone from '@components/ReceiptScanDropZone'; import ScreenWrapper from '@components/ScreenWrapper'; import ScrollView from '@components/ScrollView'; import useDocumentTitle from '@hooks/useDocumentTitle'; -import useFloatingTabBarContentInsetStyle from '@hooks/useFloatingTabBarContentInsetStyle'; import {useAppLoadSkeletonVisibility} from '@hooks/useInFlightRequests'; import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; @@ -40,7 +41,6 @@ const RIGHT_COLUMN_TEST_ID = 'homePageRightColumn'; function HomePage() { const {shouldUseNarrowLayout} = useResponsiveLayout(); - const floatingTabBarContentInsetStyle = useFloatingTabBarContentInsetStyle(); const styles = useThemeStyles(); const {translate} = useLocalize(); useDocumentTitle(translate('common.home')); @@ -141,6 +141,8 @@ function HomePage() { shouldShowOfflineIndicatorInWideScreen testID="HomePage" enableEdgeToEdgeBottomSafeAreaPadding={false} + bottomContent={} + bottomContentStyle={styles.overflowVisible} > diff --git a/src/pages/inbox/sidebar/BaseSidebarScreen.tsx b/src/pages/inbox/sidebar/BaseSidebarScreen.tsx index d59654609135..dd92d4ecd347 100644 --- a/src/pages/inbox/sidebar/BaseSidebarScreen.tsx +++ b/src/pages/inbox/sidebar/BaseSidebarScreen.tsx @@ -1,3 +1,5 @@ +import NAVIGATION_TABS from '@components/Navigation/NavigationTabBar/NAVIGATION_TABS'; +import TabBarBottomContent from '@components/Navigation/TabBarBottomContent'; import TopBarWithLoadingBar from '@components/Navigation/TopBarWithLoadingBar'; import OptionsListSkeletonView from '@components/OptionsListSkeletonView'; import ScreenWrapper from '@components/ScreenWrapper'; @@ -38,6 +40,8 @@ function BaseSidebarScreen() { shouldEnableKeyboardAvoidingView={false} style={[styles.sidebar, isMobile() ? styles.userSelectNone : {}]} testID="BaseSidebarScreen" + bottomContent={} + bottomContentStyle={styles.overflowVisible} > {({insets}) => ( <> diff --git a/src/pages/inbox/sidebar/SidebarLinks.tsx b/src/pages/inbox/sidebar/SidebarLinks.tsx index b3a8b235c55c..6af8512410a8 100644 --- a/src/pages/inbox/sidebar/SidebarLinks.tsx +++ b/src/pages/inbox/sidebar/SidebarLinks.tsx @@ -2,7 +2,6 @@ import LHNEmptyState from '@components/LHNOptionsList/LHNEmptyState'; import LHNOptionsList from '@components/LHNOptionsList/LHNOptionsList'; import OptionsListSkeletonView from '@components/OptionsListSkeletonView'; -import useFloatingTabBarContentInsetStyle from '@hooks/useFloatingTabBarContentInsetStyle'; import useOnyx from '@hooks/useOnyx'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import {useSidebarOrderedReportsActions} from '@hooks/useSidebarOrderedReports'; @@ -16,8 +15,6 @@ import {cancelSpan} from '@libs/telemetry/activeSpans'; import * as ReportActionContextMenu from '@pages/inbox/report/ContextMenu/ReportActionContextMenu'; -import variables from '@styles/variables'; - import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; @@ -47,7 +44,6 @@ function SidebarLinks({insets, optionListItems, hasReportData, priorityMode = CO const styles = useThemeStyles(); const StyleUtils = useStyleUtils(); const {shouldUseNarrowLayout, isInLandscapeMode} = useResponsiveLayout(); - const floatingTabBarContentInsetStyle = useFloatingTabBarContentInsetStyle(); const {setStickyReportID} = useSidebarOrderedReportsActions(); const [isLoadingReportData = true] = useOnyx(ONYXKEYS.IS_LOADING_REPORT_DATA); @@ -83,11 +79,7 @@ function SidebarLinks({insets, optionListItems, hasReportData, priorityMode = CO const viewMode = priorityMode === CONST.PRIORITY_MODE.GSD ? CONST.OPTION_MODE.COMPACT : CONST.OPTION_MODE.DEFAULT; - // The floating tab bar covers the end of the list above the home indicator, so its footprint adds to the safe - // area padding instead of replacing it. Only the platforms that draw that bar in JS report an inset. - const safeAreaMarginBottom = StyleUtils.getSafeAreaMargins(insets).marginBottom; - const safeAreaPaddingBottom = typeof safeAreaMarginBottom === 'number' ? safeAreaMarginBottom : 0; - const contentContainerStyles = StyleSheet.flatten([styles.pt2, {paddingBottom: safeAreaPaddingBottom + (floatingTabBarContentInsetStyle ? variables.floatingTabBarContentInset : 0)}]); + const contentContainerStyles = StyleSheet.flatten([styles.pt2, {paddingBottom: StyleUtils.getSafeAreaMargins(insets).marginBottom}]); const shouldShowEmptyLHN = optionListItems.length === 0; diff --git a/src/pages/settings/InitialSettingsPage.tsx b/src/pages/settings/InitialSettingsPage.tsx index a7ae7b41251d..0940a3450b95 100755 --- a/src/pages/settings/InitialSettingsPage.tsx +++ b/src/pages/settings/InitialSettingsPage.tsx @@ -1,6 +1,8 @@ import AccountSwitcher from '@components/AccountSwitcher'; import AccountSwitcherButton from '@components/AccountSwitcherButton'; import AccountSwitcherSkeletonView from '@components/AccountSwitcherSkeletonView'; +import NAVIGATION_TABS from '@components/Navigation/NavigationTabBar/NAVIGATION_TABS'; +import TabBarBottomContent from '@components/Navigation/TabBarBottomContent'; import TopBarWithLoadingBar from '@components/Navigation/TopBarWithLoadingBar'; import ScreenWrapper from '@components/ScreenWrapper'; import {ScrollOffsetContext} from '@components/ScrollOffsetContextProvider'; @@ -9,7 +11,6 @@ import Text from '@components/Text'; import type {WithCurrentUserPersonalDetailsProps} from '@components/withCurrentUserPersonalDetails'; import withCurrentUserPersonalDetails from '@components/withCurrentUserPersonalDetails'; -import useFloatingTabBarContentInsetStyle from '@hooks/useFloatingTabBarContentInsetStyle'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; import usePrevious from '@hooks/usePrevious'; @@ -46,8 +47,8 @@ type InitialSettingsPageProps = WithCurrentUserPersonalDetailsProps; function InitialSettingsPage({currentUserPersonalDetails}: InitialSettingsPageProps) { const {shouldUseNarrowLayout, isInLandscapeMode} = useResponsiveLayout(); - const floatingTabBarContentInsetStyle = useFloatingTabBarContentInsetStyle(); const [canSwitchAccounts = false] = useOnyx(ONYXKEYS.ACCOUNT, {selector: canSwitchAccountsSelector}); + const tabBarContent = ; const styles = useThemeStyles(); const {isExecuting, singleExecution} = useSingleExecution(); const {translate} = useLocalize(); @@ -153,6 +154,8 @@ function InitialSettingsPage({currentUserPersonalDetails}: InitialSettingsPagePr includeSafeAreaPaddingBottom testID="InitialSettingsPage" shouldEnableKeyboardAvoidingView={false} + bottomContent={tabBarContent} + bottomContentStyle={styles.overflowVisible} > {headerContent} diff --git a/src/pages/workspace/WorkspaceInitialPage.tsx b/src/pages/workspace/WorkspaceInitialPage.tsx index e09d970530f4..5beb127fe3d2 100644 --- a/src/pages/workspace/WorkspaceInitialPage.tsx +++ b/src/pages/workspace/WorkspaceInitialPage.tsx @@ -1,13 +1,14 @@ import FullPageNotFoundView from '@components/BlockingViews/FullPageNotFoundView'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; import HighlightableMenuItem from '@components/HighlightableMenuItem'; +import NAVIGATION_TABS from '@components/Navigation/NavigationTabBar/NAVIGATION_TABS'; +import TabBarBottomContent from '@components/Navigation/TabBarBottomContent'; import OfflineWithFeedback from '@components/OfflineWithFeedback'; import ScreenWrapper from '@components/ScreenWrapper'; import ScrollView from '@components/ScrollView'; import useCardFeedErrors from '@hooks/useCardFeedErrors'; import {useCurrencyListActions} from '@hooks/useCurrencyList'; -import useFloatingTabBarContentInsetStyle from '@hooks/useFloatingTabBarContentInsetStyle'; import useGetReceiptPartnersIntegrationData from '@hooks/useGetReceiptPartnersIntegrationData'; import useIsWorkspacesTabFocused from '@hooks/useIsWorkspacesTabFocused'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; @@ -68,7 +69,6 @@ function dismissError(policyID: string | undefined, pendingAction: PendingAction function WorkspaceInitialPage({policyDraft, policy: policyProp, route}: WorkspaceInitialPageProps) { const styles = useThemeStyles(); const {shouldUseNarrowLayout} = useResponsiveLayout(); - const floatingTabBarContentInsetStyle = useFloatingTabBarContentInsetStyle(); const {translate} = useLocalize(); const {convertToDisplayString} = useCurrencyListActions(); const {isBetaEnabled} = usePermissions(); @@ -224,6 +224,8 @@ function WorkspaceInitialPage({policyDraft, policy: policyProp, route}: Workspac } + bottomContentStyle={styles.overflowVisible} > - + dismissError(policyID, policy?.pendingAction)} diff --git a/src/styles/index.ts b/src/styles/index.ts index edafb3dc2af1..5365e6d571b3 100644 --- a/src/styles/index.ts +++ b/src/styles/index.ts @@ -768,10 +768,9 @@ const staticStyles = (theme: ThemeColors) => marginHorizontal: -variables.floatingTabBarSelectedOverhang, }, - // Ends a tab root screen's scrollable content above the floating bar, so its last row stays reachable - // while everything scrolled past it still passes under the bar. - floatingTabBarContentInset: { - paddingBottom: variables.floatingTabBarContentInset, + // The gap between the floating bar and the bottom edge of the screen. + floatingTabBarBottomInset: { + paddingBottom: variables.floatingTabBarBottomInset, }, // Used both for a tab's pressable and for the wrapper around its icon and label. @@ -788,17 +787,6 @@ const staticStyles = (theme: ThemeColors) => marginVertical: (variables.iconFloatingTabBar - variables.avatarFloatingTabBar) / 2, }, - // Zero-height strip anchored above the native tab bar, so the floating buttons can be positioned - // against it without taking part in the tab screen's layout. - nativeTabBarFloatingButtons: { - position: 'absolute', - right: 0, - bottom: variables.bottomTabHeight + spacing.gap3.gap, - left: 0, - height: 0, - zIndex: 10, - }, - navigationTabBarFABItem: { display: 'flex', justifyContent: 'center', @@ -6916,13 +6904,13 @@ const staticStyles = (theme: ThemeColors) => const dynamicStyles = (theme: ThemeColors) => ({ - // Android's native bar is Material's 80 dp row plus the system gesture inset, so the strip the floating - // buttons hang from sits above both, with Material's 16 dp gap between the FAB and the bar. - nativeAndroidTabBarFloatingButtons: (bottomInset: number) => + // Zero-height strip anchored above the native tab bar, so the floating buttons can be positioned + // against it without taking part in the tab screen's layout. + nativeTabBarFloatingButtons: (bottom: number) => ({ position: 'absolute', right: 0, - bottom: variables.androidNativeTabBarHeight + bottomInset + spacing.gap4.gap, + bottom, left: 0, height: 0, zIndex: 10, diff --git a/src/styles/theme/themes/dark.ts b/src/styles/theme/themes/dark.ts index 8d3426cd5cf1..ead5cb0e6631 100644 --- a/src/styles/theme/themes/dark.ts +++ b/src/styles/theme/themes/dark.ts @@ -59,7 +59,7 @@ const darkTheme = { sidebar: colors.productDark100, sidebarHover: colors.productDark300, floatingTabBarSelectedBG: 'rgba(26,61,50,0.50)', - // green700 at 50% + // selectedOptionBG (green700) at 50% androidTabBarActiveIndicatorBG: 'rgba(8,82,57,0.50)', heading: colors.productDark900, textLight: colors.productDark900, diff --git a/src/styles/theme/themes/light.ts b/src/styles/theme/themes/light.ts index 080a8525e420..481c0c1ff453 100644 --- a/src/styles/theme/themes/light.ts +++ b/src/styles/theme/themes/light.ts @@ -59,7 +59,7 @@ const lightTheme = { sidebar: colors.productLight100, sidebarHover: colors.productLight300, floatingTabBarSelectedBG: 'rgba(230,225,218,0.50)', - // green100 at 50% + // selectedOptionBG (green100) at 50% androidTabBarActiveIndicatorBG: 'rgba(177,242,214,0.50)', heading: colors.productLight900, textLight: colors.white, diff --git a/src/styles/utils/index.ts b/src/styles/utils/index.ts index ad157d46470f..860fc9f0f785 100644 --- a/src/styles/utils/index.ts +++ b/src/styles/utils/index.ts @@ -2521,8 +2521,6 @@ const createStyleUtils = (theme: ThemeColors, styles: ThemeStyles) => ({ } }, - // The negative top margin keeps the bar out of the layout flow, so the screen content it floats above - // reaches the bottom edge of the screen instead of ending where the bar starts. getTabBarNarrowStyle: (safeAreaPaddingBottom: number): ViewStyle => ({ overflow: 'visible', marginTop: -(variables.floatingTabBarHeight + variables.floatingTabBarBottomInset + safeAreaPaddingBottom), diff --git a/src/styles/variables.ts b/src/styles/variables.ts index 285d1d37f55c..e3c99ec84149 100644 --- a/src/styles/variables.ts +++ b/src/styles/variables.ts @@ -39,8 +39,6 @@ export default { // How far the selected pill reaches past its tab on each side, whatever the label's length. floatingTabBarSelectedOverhang: 4, floatingTabBarBottomInset, - // Vertical space the floating bar takes over the content it hides, so scrollable content can end above it. - floatingTabBarContentInset: floatingTabBarHeight + floatingTabBarBottomInset, // styles.p3 (12) on each side of the DebugTabView row plus the View button (componentSizeNormal). debugTabViewHeight: 64, contentHeaderHeight: getValueUsingPixelRatio(72, 100), @@ -119,8 +117,10 @@ export default { nativeTabIconLabelGap: 6, /** Pixel density the native tab bar icons are rasterized at. */ nativeTabIconScale: 3, - /** Height of Material's BottomNavigationView row on Android, without the gesture inset under it. */ - androidNativeTabBarHeight: 80, + /** Material's 80 dp BottomNavigationView row plus the 16 dp Material keeps between a FAB and the bar, without the gesture inset under it. */ + androidNativeTabBarFloatingButtonsBottom: 96, + /** UITabBar with the home indicator inset under it, plus a 12 pt gap to the floating buttons. */ + iosNativeTabBarFloatingButtonsBottom: 84, iconHeader: 48, iconSection: 68, iouAmountTextSize: 40, diff --git a/tests/ui/BaseSidebarScreenTest.tsx b/tests/ui/BaseSidebarScreenTest.tsx index eaf55a117f57..1daf35d456c8 100644 --- a/tests/ui/BaseSidebarScreenTest.tsx +++ b/tests/ui/BaseSidebarScreenTest.tsx @@ -43,6 +43,7 @@ jest.mock('@components/ScreenWrapper', () => { }); jest.mock('@components/Navigation/TopBarWithLoadingBar', () => () => null); +jest.mock('@components/Navigation/TabBarBottomContent', () => () => null); jest.mock('@pages/inbox/sidebar/InboxTabSelector', () => () => null); jest.mock('@components/OptionsListSkeletonView', () => { const ReactModule = jest.requireActual('react'); diff --git a/tests/ui/InitialSettingsPageTest.tsx b/tests/ui/InitialSettingsPageTest.tsx index b5f8f38ece19..a5cef1a0dfff 100644 --- a/tests/ui/InitialSettingsPageTest.tsx +++ b/tests/ui/InitialSettingsPageTest.tsx @@ -85,6 +85,14 @@ jest.mock('@components/AccountSwitcherSkeletonView', () => { return MockAccountSwitcherSkeletonView; }); +jest.mock('@components/Navigation/TabBarBottomContent', () => { + function MockTabBarBottomContent() { + return null; + } + MockTabBarBottomContent.displayName = 'TabBarBottomContent'; + return MockTabBarBottomContent; +}); + jest.mock('@components/Navigation/TopBarWithLoadingBar', () => { function MockTopBarWithLoadingBar() { return null; diff --git a/tests/ui/WorkspaceInitialPageTest.tsx b/tests/ui/WorkspaceInitialPageTest.tsx index bd6e7a5f51b4..d056856626cb 100644 --- a/tests/ui/WorkspaceInitialPageTest.tsx +++ b/tests/ui/WorkspaceInitialPageTest.tsx @@ -66,6 +66,7 @@ jest.mock('@hooks/useWorkspaceAccountID', () => jest.fn()); jest.mock('@components/BlockingViews/FullPageNotFoundView', () => (props: {children: React.ReactNode; shouldShow: boolean}) => mockFullPageNotFoundView(props)); jest.mock('@components/HeaderWithBackButton', () => jest.fn()); jest.mock('@components/HighlightableMenuItem', () => jest.fn()); +jest.mock('@components/Navigation/TabBarBottomContent', () => jest.fn()); jest.mock( '@components/OfflineWithFeedback', () => From aab43168cc2ef02bd45720156ab6bcda28e1cd0e Mon Sep 17 00:00:00 2001 From: sumo_slonik Date: Wed, 30 Sep 2026 13:34:43 +0200 Subject: [PATCH 23/71] fix: start tab navigation spans from native tab bar presses --- .../NavigationTabBar/InboxTabButton.tsx | 17 +---- .../NavigationTabBar/SearchTabButton.tsx | 10 +-- .../NativeTabNavigator/tabScreenListeners.ts | 37 +++++++++++ .../useNativeTabNavigator.ts | 7 ++- .../Navigators/TabNavigator.android.tsx | 3 +- .../Navigators/TabNavigator.ios.tsx | 3 +- src/libs/telemetry/startTabNavigationSpans.ts | 37 +++++++++++ tests/unit/nativeTabScreenListenersTest.ts | 63 +++++++++++++++++++ 8 files changed, 150 insertions(+), 27 deletions(-) create mode 100644 src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/tabScreenListeners.ts create mode 100644 src/libs/telemetry/startTabNavigationSpans.ts create mode 100644 tests/unit/nativeTabScreenListenersTest.ts diff --git a/src/components/Navigation/NavigationTabBar/InboxTabButton.tsx b/src/components/Navigation/NavigationTabBar/InboxTabButton.tsx index e884e8de7956..1476cc214c74 100644 --- a/src/components/Navigation/NavigationTabBar/InboxTabButton.tsx +++ b/src/components/Navigation/NavigationTabBar/InboxTabButton.tsx @@ -12,7 +12,8 @@ import {getTabNavigatorStateKey, isReportsTabPreloaded} from '@libs/Navigation/h import Navigation, {startOpenReportSpan} from '@libs/Navigation/Navigation'; import navigationRef from '@libs/Navigation/navigationRef'; import {isDeletedAction} from '@libs/ReportActionsUtils'; -import {getSpan, startSpan} from '@libs/telemetry/activeSpans'; +import {getSpan} from '@libs/telemetry/activeSpans'; +import {startNavigateToInboxTabSpan} from '@libs/telemetry/startTabNavigationSpans'; import CONST from '@src/CONST'; import NAVIGATORS from '@src/NAVIGATORS'; @@ -33,20 +34,6 @@ import getStringParam from './getStringParam'; import NAVIGATION_TABS from './NAVIGATION_TABS'; import TabBarItem from './TabBarItem'; -function startNavigateToInboxTabSpan({isWideLayout}: {isWideLayout: boolean}) { - startSpan(CONST.TELEMETRY.SPAN_NAVIGATE_TO_INBOX_TAB, { - name: CONST.TELEMETRY.SPAN_NAVIGATE_TO_INBOX_TAB, - op: CONST.TELEMETRY.SPAN_NAVIGATE_TO_INBOX_TAB, - forceTransaction: true, - // Read before the tab navigation is dispatched, because jumping to the tab drops its preloaded key. - attributes: { - [CONST.TELEMETRY.ATTRIBUTE_WIDE_LAYOUT]: isWideLayout, - [CONST.TELEMETRY.ATTRIBUTE_IS_PRELOADED]: isReportsTabPreloaded(navigationRef.getRootState()), - [CONST.TELEMETRY.ATTRIBUTE_WAITED_ON_OPEN_REPORT]: false, - }, - }); -} - function markNavigateToInboxTabWaitedOnOpenReport() { getSpan(CONST.TELEMETRY.SPAN_NAVIGATE_TO_INBOX_TAB)?.setAttribute(CONST.TELEMETRY.ATTRIBUTE_WAITED_ON_OPEN_REPORT, true); } diff --git a/src/components/Navigation/NavigationTabBar/SearchTabButton.tsx b/src/components/Navigation/NavigationTabBar/SearchTabButton.tsx index 72308f304f1f..6cb649aca30f 100644 --- a/src/components/Navigation/NavigationTabBar/SearchTabButton.tsx +++ b/src/components/Navigation/NavigationTabBar/SearchTabButton.tsx @@ -8,8 +8,7 @@ import useThemeStyles from '@hooks/useThemeStyles'; import clearSelectedText from '@libs/clearSelectedText/clearSelectedText'; import interceptAnonymousUser from '@libs/interceptAnonymousUser'; import Navigation from '@libs/Navigation/Navigation'; -import {startSpan} from '@libs/telemetry/activeSpans'; -import {startNavigateToReportsSpans} from '@libs/telemetry/navigateToReportsSpans'; +import {startNavigateToReportsTabSpans} from '@libs/telemetry/startTabNavigationSpans'; import navigationRef from '@navigation/navigationRef'; @@ -44,12 +43,7 @@ function SearchTabButton({selectedTab, isWideLayout}: SearchTabButtonProps) { } clearSelectedText(); interceptAnonymousUser(() => { - startSpan(CONST.TELEMETRY.SPAN_NAVIGATE_TO_REPORTS, { - name: CONST.TELEMETRY.SPAN_NAVIGATE_TO_REPORTS, - op: CONST.TELEMETRY.SPAN_NAVIGATE_TO_REPORTS, - forceTransaction: true, - }); - startNavigateToReportsSpans(); + startNavigateToReportsTabSpans(); Navigation.navigate(getSearchTabRoute(navigationRef.getRootState(), lastSearchParams, lastExpensesSearchQuery)); }); diff --git a/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/tabScreenListeners.ts b/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/tabScreenListeners.ts new file mode 100644 index 000000000000..85c0f54871b4 --- /dev/null +++ b/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/tabScreenListeners.ts @@ -0,0 +1,37 @@ +import NAVIGATION_TABS from '@components/Navigation/NavigationTabBar/NAVIGATION_TABS'; +import ROUTE_TO_NAVIGATION_TAB from '@components/Navigation/NavigationTabBar/ROUTE_TO_NAVIGATION_TAB'; + +import {startNavigateToInboxTabSpan, startNavigateToReportsTabSpans} from '@libs/telemetry/startTabNavigationSpans'; + +import type {ValueOf} from 'type-fest'; + +/** + * Starts the tab-navigation spans of the tabs that have them. The native bar never renders InboxTabButton or + * SearchTabButton, which start these spans on the JS tab bar, so its taps start them here instead. + */ +const NAVIGATION_TAB_TO_SPAN_START: Partial, () => void>> = { + [NAVIGATION_TABS.INBOX]: () => startNavigateToInboxTabSpan({isWideLayout: false}), + [NAVIGATION_TABS.SEARCH]: startNavigateToReportsTabSpans, +}; + +type TabScreenListenerProps = { + route: {name: string}; + navigation: {isFocused: () => boolean}; +}; + +/** + * Native tabs emit `tabPress` when a user tap reaches JS, before the navigator switches to that tab. A tap on the + * already focused tab also emits it, and starts no span, the same as a JS tab button pressed on its own tab. + */ +function tabScreenListeners({route, navigation}: TabScreenListenerProps) { + return { + tabPress: () => { + if (navigation.isFocused()) { + return; + } + NAVIGATION_TAB_TO_SPAN_START[ROUTE_TO_NAVIGATION_TAB[route.name]]?.(); + }, + }; +} + +export default tabScreenListeners; diff --git a/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/useNativeTabNavigator.ts b/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/useNativeTabNavigator.ts index e1f8c06975f0..3b72abe07786 100644 --- a/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/useNativeTabNavigator.ts +++ b/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/useNativeTabNavigator.ts @@ -22,6 +22,8 @@ import type {ValueOf} from 'type-fest'; import {findFocusedRoute, useNavigation, useNavigationState, useRoute} from '@react-navigation/native'; import {useEffect} from 'react'; +import tabScreenListeners from './tabScreenListeners'; + /** * Root-level tab screens where the swipe-back gesture should be disabled. * Swiping from these screens would pop the entire TAB_NAVIGATOR, which feels wrong. @@ -42,7 +44,8 @@ function isRealizedNavigationState(state: NavigationState | PartialState = { [NAVIGATORS.REPORTS_SPLIT_NAVIGATOR]: inboxDotColor, [NAVIGATORS.WORKSPACE_NAVIGATOR]: workspacesDotColor, @@ -383,6 +383,7 @@ function TabNavigator() { screenLayout={nativeBottomTabScreenLayoutWrapper} screenOptions={screenOptions} UNSTABLE_router={tabRouterOverride} + screenListeners={tabScreenListeners} > ({ + startNavigateToInboxTabSpan: jest.fn(), + startNavigateToReportsTabSpans: jest.fn(), +})); + +function pressTab(routeName: string, isFocused: boolean) { + tabScreenListeners({route: {name: routeName}, navigation: {isFocused: () => isFocused}}).tabPress(); +} + +describe('tabScreenListeners', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('starts the inbox tab span when the Inbox tab is pressed from another tab', () => { + // Given the native bar, which never renders InboxTabButton, so the span has to start from the tab press + // When the user presses Inbox while another tab is focused + pressTab(NAVIGATORS.REPORTS_SPLIT_NAVIGATOR, false); + + // Then the same span the JS tab button starts is started, tagged as a narrow layout tap + expect(startNavigateToInboxTabSpan).toHaveBeenCalledWith({isWideLayout: false}); + expect(startNavigateToReportsTabSpans).not.toHaveBeenCalled(); + }); + + it('starts the reports tab spans when the Spend tab is pressed from another tab', () => { + // Given the native bar, which never renders SearchTabButton + // When the user presses Spend while another tab is focused + pressTab(NAVIGATORS.SEARCH_FULLSCREEN_NAVIGATOR, false); + + // Then the legacy span and its FirstPaint/ContentLoad split are started, as on the JS tab button + expect(startNavigateToReportsTabSpans).toHaveBeenCalledTimes(1); + expect(startNavigateToInboxTabSpan).not.toHaveBeenCalled(); + }); + + it('starts no span when the already focused tab is pressed again', () => { + // Given native tabs also emit tabPress for a tap on the focused tab + // When the user presses Inbox and Spend while each is already focused + pressTab(NAVIGATORS.REPORTS_SPLIT_NAVIGATOR, true); + pressTab(NAVIGATORS.SEARCH_FULLSCREEN_NAVIGATOR, true); + + // Then nothing starts, because no navigation follows that would end the spans + expect(startNavigateToInboxTabSpan).not.toHaveBeenCalled(); + expect(startNavigateToReportsTabSpans).not.toHaveBeenCalled(); + }); + + it('starts no span for tabs without a navigation span', () => { + // Given Home, Workspaces and Account have no tab navigation span + // When the user presses each of them from another tab + pressTab(SCREENS.HOME, false); + pressTab(NAVIGATORS.WORKSPACE_NAVIGATOR, false); + pressTab(NAVIGATORS.SETTINGS_SPLIT_NAVIGATOR, false); + + // Then no tab navigation span starts + expect(startNavigateToInboxTabSpan).not.toHaveBeenCalled(); + expect(startNavigateToReportsTabSpans).not.toHaveBeenCalled(); + }); +}); From 2782a2250d29efd17fc890a97e6817b9df028292 Mon Sep 17 00:00:00 2001 From: sumo_slonik Date: Wed, 30 Sep 2026 13:39:01 +0200 Subject: [PATCH 24/71] fix: larger iOS native tab icons, softer Android tab pill --- .../AppNavigator/Navigators/TabNavigator.ios.tsx | 12 ++++++------ src/styles/theme/themes/dark.ts | 4 ++-- src/styles/theme/themes/light.ts | 4 ++-- src/styles/variables.ts | 4 ++++ 4 files changed, 14 insertions(+), 10 deletions(-) diff --git a/src/libs/Navigation/AppNavigator/Navigators/TabNavigator.ios.tsx b/src/libs/Navigation/AppNavigator/Navigators/TabNavigator.ios.tsx index fdab8b684cc6..12827d2f55bf 100644 --- a/src/libs/Navigation/AppNavigator/Navigators/TabNavigator.ios.tsx +++ b/src/libs/Navigation/AppNavigator/Navigators/TabNavigator.ios.tsx @@ -127,12 +127,12 @@ async function createTabIcon( return undefined; } - // The glyph assets are 24 pt and are drawn at the floating bar's glyph size, the one the JS bar uses. - const glyphWidth = variables.iconFloatingTabBar * scale; - const glyphHeight = variables.iconFloatingTabBar * scale; + // The glyph assets are 24 pt and are drawn at the native bar's glyph size. + const glyphWidth = variables.iconNativeTabBarIOS * scale; + const glyphHeight = variables.iconNativeTabBarIOS * scale; // The account avatar is taller than a glyph, so every glyph sits this far down to share its centre, and // every label lands at the same height. - const glyphTop = ((variables.avatarFloatingTabBar - variables.iconFloatingTabBar) / 2) * scale; + const glyphTop = ((variables.avatarNativeTabBarIOS - variables.iconNativeTabBarIOS) / 2) * scale; const gap = variables.nativeTabIconLabelGap * scale; const labelSize = measureLabel(label, isSelected, scale); const canvasWidth = Math.ceil(Math.max(glyphWidth, labelSize.width)); @@ -180,10 +180,10 @@ async function createCircularAvatarIcon(uri: string, dotColor: string | undefine const encodedImage = Skia.Data.fromBytes(new Uint8Array(await response.arrayBuffer())); const image = Skia.Image.MakeImageFromEncoded(encodedImage); const scale = variables.nativeTabIconScale; - const avatarSize = variables.avatarFloatingTabBar * scale; + const avatarSize = variables.avatarNativeTabBarIOS * scale; // The avatar overhangs a glyph by the same amount above and below, so its gap to the label shrinks by // that much and the label lands where every other tab's does. - const gap = (variables.nativeTabIconLabelGap - (variables.avatarFloatingTabBar - variables.iconFloatingTabBar) / 2) * scale; + const gap = (variables.nativeTabIconLabelGap - (variables.avatarNativeTabBarIOS - variables.iconNativeTabBarIOS) / 2) * scale; const labelSize = measureLabel(label, isSelected, scale); const canvasWidth = Math.ceil(Math.max(avatarSize, labelSize.width)); const canvasHeight = Math.ceil(avatarSize + gap + labelSize.height); diff --git a/src/styles/theme/themes/dark.ts b/src/styles/theme/themes/dark.ts index ead5cb0e6631..92281a8a91b3 100644 --- a/src/styles/theme/themes/dark.ts +++ b/src/styles/theme/themes/dark.ts @@ -59,8 +59,8 @@ const darkTheme = { sidebar: colors.productDark100, sidebarHover: colors.productDark300, floatingTabBarSelectedBG: 'rgba(26,61,50,0.50)', - // selectedOptionBG (green700) at 50% - androidTabBarActiveIndicatorBG: 'rgba(8,82,57,0.50)', + // selectedOptionBG (green700) at 40% + androidTabBarActiveIndicatorBG: 'rgba(8,82,57,0.40)', heading: colors.productDark900, textLight: colors.productDark900, textDark: colors.productDark100, diff --git a/src/styles/theme/themes/light.ts b/src/styles/theme/themes/light.ts index 481c0c1ff453..1960210b9bb5 100644 --- a/src/styles/theme/themes/light.ts +++ b/src/styles/theme/themes/light.ts @@ -59,8 +59,8 @@ const lightTheme = { sidebar: colors.productLight100, sidebarHover: colors.productLight300, floatingTabBarSelectedBG: 'rgba(230,225,218,0.50)', - // selectedOptionBG (green100) at 50% - androidTabBarActiveIndicatorBG: 'rgba(177,242,214,0.50)', + // selectedOptionBG (green100) at 40% + androidTabBarActiveIndicatorBG: 'rgba(177,242,214,0.40)', heading: colors.productLight900, textLight: colors.white, textDark: colors.productLight900, diff --git a/src/styles/variables.ts b/src/styles/variables.ts index e3c99ec84149..b8f92b71b591 100644 --- a/src/styles/variables.ts +++ b/src/styles/variables.ts @@ -111,6 +111,10 @@ export default { iconFloatingTabBar: 20, /** Account avatar size in the floating tab bar. A circle reads smaller than a glyph of the same box, so it is larger. */ avatarFloatingTabBar: avatarSizes.avatarSizeXSmall, + /** Glyph size in the iOS native tab bar. UITabBar gives each item more room than the JS bar, so its glyphs are larger. */ + iconNativeTabBarIOS: 22, + /** Account avatar size in the iOS native tab bar, larger than its glyphs by the same amount as on the JS bar. */ + avatarNativeTabBarIOS: 26, /** Radius of the status dot drawn into a native tab bar icon, and the room reserved for it around the glyph. */ nativeTabIconDotRadius: 4, // Gap between the glyph and the label baked under it, matching the spacing the JS bar uses. From 44c3fb4faf02273534c9de887d543a8a16b702eb Mon Sep 17 00:00:00 2001 From: sumo_slonik Date: Wed, 30 Sep 2026 15:29:21 +0200 Subject: [PATCH 25/71] feat: Insights tab on the native bar with the beta, Account avatar on Android --- assets/images/native-tab-icons/insights.png | Bin 0 -> 371 bytes .../images/native-tab-icons/insights@2x.png | Bin 0 -> 649 bytes .../images/native-tab-icons/insights@3x.png | Bin 0 -> 975 bytes patches/react-native-screens/details.md | 7 + ...+4.28.0+003+no-android-tab-icon-tint.patch | 33 +++ ...tom-tabs+7.16.2+003+hidden-tab-items.patch | 137 ++++++++++++ patches/react-navigation/details.md | 8 + src/components/Navigation/TopBar.tsx | 34 ++- .../Navigation/TopBarWithLoadingBar.tsx | 3 +- .../index.native.ts | 16 ++ src/hooks/useIsSettingsDrawnOverTabs/index.ts | 6 + .../NativeTabNavigator/NATIVE_TAB_ICONS.ts | 2 + .../NativeTabNavigator/useAndroidTabIcons.ts | 209 ++++++++++++++++++ .../useNativeTabNavigator.ts | 27 ++- .../Navigators/TAB_SCREENS.native.ts | 12 +- .../Navigators/TabNavigator.android.tsx | 27 ++- .../Navigators/TabNavigator.ios.tsx | 13 +- src/pages/settings/InitialSettingsPage.tsx | 15 +- 18 files changed, 530 insertions(+), 19 deletions(-) create mode 100644 assets/images/native-tab-icons/insights.png create mode 100644 assets/images/native-tab-icons/insights@2x.png create mode 100644 assets/images/native-tab-icons/insights@3x.png create mode 100644 patches/react-native-screens/react-native-screens+4.28.0+003+no-android-tab-icon-tint.patch create mode 100644 patches/react-navigation/@react-navigation+bottom-tabs+7.16.2+003+hidden-tab-items.patch create mode 100644 src/hooks/useIsSettingsDrawnOverTabs/index.native.ts create mode 100644 src/hooks/useIsSettingsDrawnOverTabs/index.ts create mode 100644 src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/useAndroidTabIcons.ts diff --git a/assets/images/native-tab-icons/insights.png b/assets/images/native-tab-icons/insights.png new file mode 100644 index 0000000000000000000000000000000000000000..9787cdd1bbd8e30249f39e1b95ab6162a3bbf8a4 GIT binary patch literal 371 zcmV-(0gV2MP)^0B}qgpGB5UnNKOzb816pX%Ch!OR4K)g)PY6rM z*3lwYL9TRR5@G#C;5XPpaOA>_46pZ|Gt+xsxSN?f=fKR|83>OCFbBp!8z_f@s=x&> z06NzA1E8$@1#W>1hgE+VhzNIWwjsnT+ zI6-gABIO}i03{oK<^We70?C#wnB^cCnc`zdxbhVInBvO-K~qbTQu^fF6u$%ryy87? z!NfzL9CE3jmrXopEKqveH*uANplu5(IS7uT=kS#w{4?&LPcnk9fO^txy30UtpLSjM zfZ;#jubPn>QqT?9mRq0i(wxc60saBhe8AB-)Q{nl?s$1tq%oya|6IkL#~Sy*R`8!b Re}Di0002ovPDHLkV1h7Vo!|ff literal 0 HcmV?d00001 diff --git a/assets/images/native-tab-icons/insights@2x.png b/assets/images/native-tab-icons/insights@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..22c775d075e71ab6947e9c65b6d76dbc56987924 GIT binary patch literal 649 zcmV;40(Sk0P)MQ>x(7ie#rH8##nXS`D{v4)O?wbndi)QV zBB$0vIeF3)y?8!*5PnNvaRay2hgsa(nxT-+4GE$RoWYW+B`>E<;5e2Q?TNJ#PMcM2 z?K{(ygZ887IUgP0rKqA+aof`Mpq{5l*;I5dxvWd8l_T^IZ6aJTx!aVfF?}sP(l$*) jy`Z1n$a;+IivaZ>(AHorVxlk-00000NkvXXu0mjfu`Uvc literal 0 HcmV?d00001 diff --git a/assets/images/native-tab-icons/insights@3x.png b/assets/images/native-tab-icons/insights@3x.png new file mode 100644 index 0000000000000000000000000000000000000000..3d5f365b1ec41bdc0c1923241fe482ae5d80a197 GIT binary patch literal 975 zcmV;=12FuFP)nPyg6%jiW>ZCdqMM2fu=C23K#wykBU z>+H2_Ll>zO*ha$tx&d z7a$|^-Z>31>6#%Td@6 zfQFU;v8bJ_0OSP$JbXB15zO$B@L5QKvjX^7?f*S3MjWiE)Y!;0BubH z<<_ z1M_gC<-j)ugDloRe0(b2C=HG#VYc6xY;+AL6Vi7DgN|zZ>DDGZt2!ZbQm-skf@pq# zwzxvyR`x5UpemPj(x9?MV5jt3s!=U)PXU|s z0Om2UU9Q7OoJxQ-z)2~J#|5QNYRGlIua|JCQaM)XkOR6`1pu*+>?L5g%yp4ZACj&q xa{1AEX}xKZCNA6Qwn2WT?#`c7sZtS_{sKvC8Y-o+g315@002ovPDHLkV1injpa1{> literal 0 HcmV?d00001 diff --git a/patches/react-native-screens/details.md b/patches/react-native-screens/details.md index a8cc5f438fde..dd61413a37ac 100644 --- a/patches/react-native-screens/details.md +++ b/patches/react-native-screens/details.md @@ -13,3 +13,10 @@ - Upstream PR/issue: not reported yet. - E/App issue: [#101169](https://github.com/Expensify/App/issues/101169) - PR introducing patch: [#101339](https://github.com/Expensify/App/pull/101339) + +### [react-native-screens+4.28.0+003+no-android-tab-icon-tint.patch](react-native-screens+4.28.0+003+no-android-tab-icon-tint.patch) + +- Reason: The Android account tab shows the user's avatar. `TabsAppearanceApplicator` assigns `bottomNavigationView.itemIconTintList` unconditionally, and a `ColorStateList` tint is `SRC_IN`, so it flattens the avatar to a solid silhouette in the tint color. React Navigation's `tinted: false` only reaches iOS, since `getPlatformIcon` in `@react-navigation/bottom-tabs` always hands Android a plain `imageSource`. The patch drops the icon tint list so Android draws every icon bitmap as supplied. The App recolors the glyphs off-screen in Skia for both selection states, so they keep the design's colors. Label colors are untouched and keep coming from `tabBarItemTitleFontColor`. +- Upstream PR/issue: not reported yet. +- E/App issue: [#101169](https://github.com/Expensify/App/issues/101169) +- PR introducing patch: [#101339](https://github.com/Expensify/App/pull/101339) diff --git a/patches/react-native-screens/react-native-screens+4.28.0+003+no-android-tab-icon-tint.patch b/patches/react-native-screens/react-native-screens+4.28.0+003+no-android-tab-icon-tint.patch new file mode 100644 index 000000000000..0e2a6405da10 --- /dev/null +++ b/patches/react-native-screens/react-native-screens+4.28.0+003+no-android-tab-icon-tint.patch @@ -0,0 +1,33 @@ +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 70a24c4..874eb63 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 +@@ -63,24 +63,10 @@ internal class TabsAppearanceApplicator( + bottomNavigationView.itemTextColor = ColorStateList(states, fontColors) + + // Icon color +- val iconDisabledColor = +- tabBarAppearance?.disabled?.tabBarItemIconColor +- ?: resolveColorAttr(context, R.attr.colorOnSurfaceVariant) +- +- val iconFocusedColor = +- tabBarAppearance?.focused?.tabBarItemIconColor +- ?: resolveColorAttr(context, R.attr.colorOnSurfaceVariant) +- +- val iconSelectedColor = +- tabBarAppearance?.selected?.tabBarItemIconColor +- ?: resolveColorAttr(context, R.attr.colorOnSecondaryContainer) +- +- val iconNormalColor = +- tabBarAppearance?.normal?.tabBarItemIconColor +- ?: resolveColorAttr(context, R.attr.colorOnSurfaceVariant) +- +- val iconColors = intArrayOf(iconDisabledColor, iconSelectedColor, iconFocusedColor, iconNormalColor) +- bottomNavigationView.itemIconTintList = ColorStateList(states, iconColors) ++ // Every tab icon arrives as a bitmap that already carries its own colors: the recolored glyph and the user's ++ // avatar on the account tab. A tint list paints over all of it with a single color, so no tint is applied and ++ // the bitmap is drawn as supplied, which is how `tinted: false` already behaves on iOS. ++ bottomNavigationView.itemIconTintList = null + + // LabelVisibilityMode + // From docs: can be one of LABEL_VISIBILITY_AUTO, LABEL_VISIBILITY_SELECTED, LABEL_VISIBILITY_LABELED, or LABEL_VISIBILITY_UNLABELED diff --git a/patches/react-navigation/@react-navigation+bottom-tabs+7.16.2+003+hidden-tab-items.patch b/patches/react-navigation/@react-navigation+bottom-tabs+7.16.2+003+hidden-tab-items.patch new file mode 100644 index 000000000000..7f4710b1bc8a --- /dev/null +++ b/patches/react-navigation/@react-navigation+bottom-tabs+7.16.2+003+hidden-tab-items.patch @@ -0,0 +1,137 @@ +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 303635c..6ccaad5 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 +@@ -4,11 +4,12 @@ import { getLabel, SafeAreaProviderCompat, Screen as ScreenContent } from '@reac + import { CommonActions, NavigationMetaContext, StackActions, useTheme } from '@react-navigation/native'; + import Color from 'color'; + import * as React from 'react'; +-import { Platform, PlatformColor } from 'react-native'; ++import { Platform, PlatformColor, StyleSheet, View } from 'react-native'; ++import { Freeze } from 'react-freeze'; + import { Tabs } from 'react-native-screens'; + import { Deferred } from "./Deferred.js"; + import { NativeScreen } from "./NativeScreen/NativeScreen.js"; +-import { jsx as _jsx } from "react/jsx-runtime"; ++import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime"; + const meta = { + type: 'native-tabs' + }; +@@ -32,9 +33,23 @@ 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. ++ const isRouteHidden = route => descriptors[route.key]?.options.tabBarItemHidden === true; ++ const isFocusedRouteHidden = isRouteHidden(state.routes[state.index]); ++ const lastVisibleRouteKeyRef = React.useRef(undefined); ++ if (!isFocusedRouteHidden) { ++ lastVisibleRouteKeyRef.current = focusedRouteKey; ++ } ++ const nativeSelectedRouteKey = isFocusedRouteHidden ? lastVisibleRouteKeyRef.current ?? state.routes.find(route => !isRouteHidden(route))?.key ?? focusedRouteKey : focusedRouteKey; ++ // Hidden routes stay mounted once visited, frozen while another tab is focused, the same as the native tabs. ++ const visitedHiddenRouteKeysRef = React.useRef(new Set()); ++ if (isFocusedRouteHidden) { ++ visitedHiddenRouteKeysRef.current.add(focusedRouteKey); ++ } + const [nativeState, dispatch] = React.useReducer(reducer, { + confirmed: { +- routeKey: focusedRouteKey, ++ routeKey: nativeSelectedRouteKey, + provenance: 0 + } + }); +@@ -189,17 +204,17 @@ 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__*/_jsx(SafeAreaProviderCompat, { +- children: /*#__PURE__*/_jsx(Tabs.Host, { ++ return /*#__PURE__*/_jsxs(SafeAreaProviderCompat, { ++ children: [/*#__PURE__*/_jsx(Tabs.Host, { + navStateRequest: { +- selectedScreenKey: focusedRouteKey, ++ selectedScreenKey: nativeSelectedRouteKey, + baseProvenance: nativeState.confirmed.provenance + }, + rejectStaleNavStateUpdates: true, + onTabSelected: onTabSelected, + onTabSelectionRejected: onTabSelectionRejected, + onTabSelectionPrevented: onTabSelectionPrevented, +- tabBarHidden: shouldHideTabBar, ++ tabBarHidden: shouldHideTabBar || isFocusedRouteHidden, + colorScheme: dark ? 'dark' : 'light', + ios: { + bottomAccessory: bottomAccessory ? environment => bottomAccessory({ +@@ -210,6 +225,9 @@ export function NativeBottomTabView({ + tabBarTintColor: activeTintColor + }, + children: state.routes.map((route, index) => { ++ if (isRouteHidden(route)) { ++ return null; ++ } + const { + options, + render, +@@ -363,9 +381,43 @@ export function NativeBottomTabView({ + }) + }, route.key); + }) +- }) ++ }, "native-tabs-host"), ...state.routes.map((route, index) => { ++ if (!isRouteHidden(route) || !visitedHiddenRouteKeysRef.current.has(route.key)) { ++ return null; ++ } ++ const { ++ options, ++ render, ++ navigation ++ } = descriptors[route.key]; ++ const isFocused = state.index === index; ++ return /*#__PURE__*/_jsx(View, { ++ style: [StyleSheet.absoluteFill, { ++ backgroundColor: colors.background ++ }, !isFocused && styles.hiddenRoute], ++ pointerEvents: isFocused ? 'auto' : 'none', ++ children: /*#__PURE__*/_jsx(Freeze, { ++ freeze: !isFocused, ++ children: /*#__PURE__*/_jsx(ScreenWithHeader, { ++ isFocused: isFocused, ++ route: route, ++ navigation: navigation, ++ options: options, ++ children: /*#__PURE__*/_jsx(NavigationMetaContext.Provider, { ++ value: meta, ++ children: render() ++ }) ++ }) ++ }) ++ }, route.key); ++ })] + }); + } ++const styles = StyleSheet.create({ ++ hiddenRoute: { ++ display: 'none' ++ } ++}); + function ScreenWithHeader({ + isFocused, + route, +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 a071119..390f594 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 +@@ -78,6 +78,11 @@ 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. ++ */ ++ tabBarItemHidden?: boolean; + /** + * Title text for the screen. + */ diff --git a/patches/react-navigation/details.md b/patches/react-navigation/details.md index cf27750b5f07..ba47f69be53b 100644 --- a/patches/react-navigation/details.md +++ b/patches/react-navigation/details.md @@ -78,6 +78,14 @@ - PR Introducing Patch: [#101339](https://github.com/Expensify/App/pull/101339) - PR Updating Patch: N/A +### [@react-navigation+bottom-tabs+7.16.2+003+hidden-tab-items.patch](@react-navigation+bottom-tabs+7.16.2+003+hidden-tab-items.patch) + +- Reason: Adds a `tabBarItemHidden` option to the native bottom tabs. The App registers six tabs but the native bar can show at most five (Material's `BottomNavigationView` throws past five, and `UITabBar` folds the rest into a "More" tab), and which five depends on the Insights beta: Insights with it, Account without it. A native tab bar cannot hide one of its items, so a route with `tabBarItemHidden` gets no `Tabs.Screen` at all. When such a route is focused, it is drawn in JS over the native host with the tab bar hidden, while the host keeps the last focused visible tab selected underneath. A hidden route stays mounted once visited and is frozen while another tab is focused. Nothing native changes. +- Upstream PR/issue: not reported yet. +- E/App issue: [#101169](https://github.com/Expensify/App/issues/101169) +- PR Introducing Patch: [#101339](https://github.com/Expensify/App/pull/101339) +- PR Updating Patch: N/A + ### [@react-navigation+stack+7.8.5+005+clip-inactive-card-inside-card.patch](@react-navigation+stack+7.8.5+005+clip-inactive-card-inside-card.patch) - Reason: On the web floating RHP each stacked card draws its own drop shadow through the interpolator's `cardStyle` (`getRHPExtendedCardFrame`). `CardA11yWrapper` puts `overflow: hidden` on every card that is not on top, and the wrapper is sized to the navigator frame, so it clips the shadow of the card under the focused one. Opening an expense over an expense report cut the report card's left and bottom shadow. The patch moves that clip from `CardA11yWrapper` to the `Animated.View` in `Card` that receives `cardStyle`. That view paints the shadow, and `overflow: hidden` does not clip an element's own shadow, so the shadow stays and the content is still clipped. `CardContainer` passes `active` down to `Card`. diff --git a/src/components/Navigation/TopBar.tsx b/src/components/Navigation/TopBar.tsx index 16163c9ad1c2..90dff1001aab 100644 --- a/src/components/Navigation/TopBar.tsx +++ b/src/components/Navigation/TopBar.tsx @@ -1,3 +1,4 @@ +import Icon from '@components/Icon'; import LoadingBar from '@components/LoadingBar'; import {PressableWithoutFeedback} from '@components/Pressable'; import SearchButton from '@components/Search/SearchRouter/SearchButton'; @@ -7,8 +8,10 @@ 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'; @@ -37,12 +40,25 @@ 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}: TopBarProps) { +function TopBar({ + breadcrumbLabel, + shouldDisplaySearch = true, + shouldDisplayHelpButton = false, + cancelSearch, + shouldShowLoadingBar, + children, + breadcrumbAnimatedStyle, + onBackButtonPress, +}: 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); @@ -58,11 +74,25 @@ function TopBar({breadcrumbLabel, shouldDisplaySearch = true, shouldDisplayHelpB 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, children}: TopBarWithLoadingBarProps) { +function TopBarWithLoadingBar({breadcrumbLabel, shouldDisplaySearch, shouldDisplayHelpButton, cancelSearch, onBackButtonPress, children}: TopBarWithLoadingBarProps) { const shouldShowLoadingBar = useLoadingBarVisibility(); return ( {children} diff --git a/src/hooks/useIsSettingsDrawnOverTabs/index.native.ts b/src/hooks/useIsSettingsDrawnOverTabs/index.native.ts new file mode 100644 index 000000000000..8220fdd02a41 --- /dev/null +++ b/src/hooks/useIsSettingsDrawnOverTabs/index.native.ts @@ -0,0 +1,16 @@ +import usePermissions from '@hooks/usePermissions'; +import useResponsiveLayout from '@hooks/useResponsiveLayout'; + +import CONST from '@src/CONST'; + +/** + * With the Insights beta the native tab bar has no Account item, so 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 && isBetaEnabled(CONST.BETAS.INSIGHTS_PAGE); +} + +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/NATIVE_TAB_ICONS.ts b/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/NATIVE_TAB_ICONS.ts index ffbdb2f47cb9..c462369d097e 100644 --- a/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/NATIVE_TAB_ICONS.ts +++ b/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/NATIVE_TAB_ICONS.ts @@ -1,6 +1,7 @@ import buildingsIcon from '@assets/images/native-tab-icons/buildings.png'; import homeIcon from '@assets/images/native-tab-icons/home.png'; import inboxIcon from '@assets/images/native-tab-icons/inbox.png'; +import insightsIcon from '@assets/images/native-tab-icons/insights.png'; import profileIcon from '@assets/images/native-tab-icons/profile.png'; import receiptMultipleIcon from '@assets/images/native-tab-icons/receipt-multiple.png'; @@ -14,6 +15,7 @@ const NATIVE_TAB_ICONS = { [SCREENS.HOME]: {type: 'image', source: homeIcon}, [NAVIGATORS.REPORTS_SPLIT_NAVIGATOR]: {type: 'image', source: inboxIcon}, [NAVIGATORS.SEARCH_FULLSCREEN_NAVIGATOR]: {type: 'image', source: receiptMultipleIcon}, + [SCREENS.INSIGHTS]: {type: 'image', source: insightsIcon}, [NAVIGATORS.WORKSPACE_NAVIGATOR]: {type: 'image', source: buildingsIcon}, [NAVIGATORS.SETTINGS_SPLIT_NAVIGATOR]: {type: 'image', source: profileIcon}, } as const satisfies Record; diff --git a/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/useAndroidTabIcons.ts b/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/useAndroidTabIcons.ts new file mode 100644 index 000000000000..26a496748561 --- /dev/null +++ b/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/useAndroidTabIcons.ts @@ -0,0 +1,209 @@ +import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; +import useTheme from '@hooks/useTheme'; + +import {getAvatarURL} from '@libs/UserAvatarUtils'; + +import variables from '@styles/variables'; + +import type {NativeBottomTabIcon} from '@react-navigation/bottom-tabs/unstable'; +import type {ImageSourcePropType} from 'react-native'; + +import {BlendMode, ClipOp, ImageFormat, Skia} from '@shopify/react-native-skia'; +import {useEffect, useState} from 'react'; +import {Image} from 'react-native'; + +import NATIVE_TAB_ICONS from './NATIVE_TAB_ICONS'; + +/** The recolored copies of one glyph, one per selection state. */ +type TintedTabIconPair = {active: NativeBottomTabIcon; inactive: NativeBottomTabIcon}; + +/** The recolored glyphs, with the signature of the colors baked into them. */ +type TintedTabIcons = {signature: string; icons: Record}; + +/** The account avatar cropped to a circle, with the URI it was drawn from. */ +type CircularAvatarIcon = {uri: string; icon: NativeBottomTabIcon}; + +/** + * The last icons drawn, kept outside the hook so a remounted navigator starts from them instead of waiting for + * Skia again. + */ +let lastTintedIcons: TintedTabIcons | undefined; +let lastCircularAvatar: CircularAvatarIcon | undefined; + +/** Every glyph the bar draws, paired with the tab it belongs to so the tinted copies can be looked up again. */ +const TAB_ICONS = Object.entries(NATIVE_TAB_ICONS).map(([name, icon]) => [name, icon.source] as const); + +/** + * Material draws the icons without a tint (react-native-screens patch 003), so each glyph is recolored off-screen + * in the theme color of its selection state. The canvas is the glyph's own size, so Android's fixed icon slot + * draws it at full size. + */ +async function createTintedGlyph(source: ImageSourcePropType, color: string): Promise { + const asset = Image.resolveAssetSource(source); + if (!asset?.uri) { + return undefined; + } + + const response = await fetch(asset.uri); + const encodedImage = Skia.Data.fromBytes(new Uint8Array(await response.arrayBuffer())); + const image = Skia.Image.MakeImageFromEncoded(encodedImage); + const scale = asset.scale ?? 1; + const canvasWidth = image?.width() ?? 0; + const canvasHeight = image?.height() ?? 0; + const surface = image ? Skia.Surface.MakeOffscreen(canvasWidth, canvasHeight) : null; + + if (!image || !surface) { + image?.dispose(); + surface?.dispose(); + return undefined; + } + + const paint = Skia.Paint(); + // SrcIn keeps the glyph's alpha and replaces every colored pixel with the theme color. + paint.setColorFilter(Skia.ColorFilter.MakeBlend(Skia.Color(color), BlendMode.SrcIn)); + surface.getCanvas().drawImageRect(image, Skia.XYWHRect(0, 0, canvasWidth, canvasHeight), Skia.XYWHRect(0, 0, canvasWidth, canvasHeight), paint); + surface.flush(); + + const snapshot = surface.makeImageSnapshot(); + const base64 = snapshot.encodeToBase64(ImageFormat.PNG, 100); + + image.dispose(); + snapshot.dispose(); + surface.dispose(); + + return {type: 'image', source: {uri: `data:image/png;base64,${base64}`, width: canvasWidth / scale, height: canvasHeight / scale, scale}}; +} + +/** + * The account tab shows the user's avatar, and a tab icon has to be a square image, so the avatar is cropped to a + * circle off-screen and handed over as a data URI, at the size of the glyphs next to it. The selected tab is + * marked by the active indicator pill, so one image serves both selection states. + */ +async function createCircularAvatarIcon(uri: string): Promise { + const response = await fetch(uri); + const encodedImage = Skia.Data.fromBytes(new Uint8Array(await response.arrayBuffer())); + const image = Skia.Image.MakeImageFromEncoded(encodedImage); + const scale = variables.nativeTabIconScale; + const canvasSize = variables.iconBottomBar * scale; + const surface = Skia.Surface.MakeOffscreen(canvasSize, canvasSize); + + if (!image || !surface) { + image?.dispose(); + surface?.dispose(); + return undefined; + } + + const sourceSize = Math.min(image.width(), image.height()); + const sourceX = (image.width() - sourceSize) / 2; + const sourceY = (image.height() - sourceSize) / 2; + const circle = Skia.Path.Make(); + circle.addCircle(canvasSize / 2, canvasSize / 2, canvasSize / 2); + + const canvas = surface.getCanvas(); + canvas.clipPath(circle, ClipOp.Intersect, true); + canvas.drawImageRect(image, Skia.XYWHRect(sourceX, sourceY, sourceSize, sourceSize), Skia.XYWHRect(0, 0, canvasSize, canvasSize), Skia.Paint()); + surface.flush(); + + const snapshot = surface.makeImageSnapshot(); + const base64 = snapshot.encodeToBase64(ImageFormat.PNG, 100); + + circle.dispose(); + image.dispose(); + snapshot.dispose(); + surface.dispose(); + + return {type: 'image', source: {uri: `data:image/png;base64,${base64}`, width: variables.iconBottomBar, height: variables.iconBottomBar, scale}}; +} + +/** + * The icons of the Android tab bar: each glyph recolored for both selection states, and the user's avatar on the + * account tab. `areTabIconsReady` stays false until the first recolored set exists, because the plain black glyphs + * would draw black. + */ +function useAndroidTabIcons() { + const theme = useTheme(); + const currentUserPersonalDetails = useCurrentUserPersonalDetails(); + const inactiveColor = theme.icon; + const activeColor = theme.iconMenu; + const iconsSignature = [inactiveColor, activeColor].join('|'); + const [tintedIcons, setTintedIcons] = useState(lastTintedIcons); + + const avatarSource = getAvatarURL({avatarSource: currentUserPersonalDetails.avatar, accountID: currentUserPersonalDetails.accountID}); + const avatarURI = typeof avatarSource === 'string' ? avatarSource : undefined; + const [circularAvatar, setCircularAvatar] = useState(lastCircularAvatar); + + useEffect(() => { + let isActive = true; + const signature = iconsSignature; + + Promise.all( + TAB_ICONS.map(([name, source]) => + Promise.all([createTintedGlyph(source, inactiveColor), createTintedGlyph(source, activeColor)]).then(([inactive, active]) => ({name, inactive, active})), + ), + ) + .then((results) => { + if (!isActive) { + return; + } + const icons: Record = {}; + for (const result of results) { + if (result.inactive && result.active) { + icons[result.name] = {inactive: result.inactive, active: result.active}; + } + } + lastTintedIcons = {signature, icons}; + setTintedIcons(lastTintedIcons); + }) + .catch(() => { + // A failed draw still lets the bar show, on the plain black glyphs, rather than keeping it hidden. + if (!isActive) { + return; + } + setTintedIcons((previous) => previous ?? {signature, icons: {}}); + }); + + return () => { + isActive = false; + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [iconsSignature]); + + useEffect(() => { + let isActive = true; + if (!avatarURI) { + return () => { + isActive = false; + }; + } + + createCircularAvatarIcon(avatarURI) + .then((icon) => { + if (!isActive || !icon) { + return; + } + lastCircularAvatar = {uri: avatarURI, icon}; + setCircularAvatar(lastCircularAvatar); + }) + .catch(() => {}); + + return () => { + isActive = false; + }; + }, [avatarURI]); + + /** Falls back to the plain black glyph for a tab whose recolored copies could not be drawn. */ + const getTabBarIcon = (name: keyof typeof NATIVE_TAB_ICONS) => { + const pair = tintedIcons?.icons[name]; + if (!pair) { + return NATIVE_TAB_ICONS[name]; + } + return ({focused}: {focused: boolean}) => (focused ? pair.active : pair.inactive); + }; + + // An avatar drawn for another user is never shown; until the current one is drawn, the account glyph stands in. + const accountAvatarIcon = circularAvatar?.uri === avatarURI ? circularAvatar?.icon : undefined; + + return {getTabBarIcon, accountAvatarIcon, areTabIconsReady: !!tintedIcons}; +} + +export default useAndroidTabIcons; diff --git a/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/useNativeTabNavigator.ts b/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/useNativeTabNavigator.ts index 3b72abe07786..8a48826fff1a 100644 --- a/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/useNativeTabNavigator.ts +++ b/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/useNativeTabNavigator.ts @@ -3,6 +3,7 @@ import NAVIGATION_TABS from '@components/Navigation/NavigationTabBar/NAVIGATION_ import ROUTE_TO_NAVIGATION_TAB from '@components/Navigation/NavigationTabBar/ROUTE_TO_NAVIGATION_TAB'; import useAccountTabIndicatorStatus from '@hooks/useAccountTabIndicatorStatus'; +import usePermissions from '@hooks/usePermissions'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import {useSidebarOrderedReportsState} from '@hooks/useSidebarOrderedReports'; import useTheme from '@hooks/useTheme'; @@ -10,10 +11,13 @@ import useWorkspacesTabIndicatorStatus from '@hooks/useWorkspacesTabIndicatorSta import {getPreservedNavigatorState, setPreservedNavigatorState} from '@libs/Navigation/AppNavigator/createSplitNavigator/usePreserveNavigatorState'; import isTabRouteAtRoot from '@libs/Navigation/helpers/isTabRouteAtRoot'; +import Navigation from '@libs/Navigation/Navigation'; import type {TabNavigatorParamList} from '@libs/Navigation/types'; import cancelTabNavigationSpans, {INBOX_TAB_SPAN_IDS, REPORTS_TAB_SPAN_IDS} from '@libs/telemetry/cancelTabNavigationSpans'; import CONST from '@src/CONST'; +import NAVIGATORS from '@src/NAVIGATORS'; +import ROUTES from '@src/ROUTES'; import SCREENS from '@src/SCREENS'; import type {NavigationAction, NavigationState, PartialState, Router, TabNavigationState} from '@react-navigation/native'; @@ -29,7 +33,7 @@ import tabScreenListeners from './tabScreenListeners'; * Swiping from these screens would pop the entire TAB_NAVIGATOR, which feels wrong. * WORKSPACE.INITIAL is intentionally excluded, since swiping back from it returns to the workspace list. */ -const TAB_ROOT_SCREENS_WITHOUT_GESTURE = new Set([SCREENS.HOME, SCREENS.INBOX, SCREENS.SEARCH.ROOT, SCREENS.SETTINGS.ROOT]); +const TAB_ROOT_SCREENS_WITHOUT_GESTURE = new Set([SCREENS.HOME, SCREENS.INBOX, SCREENS.SEARCH.ROOT, SCREENS.INSIGHTS, SCREENS.SETTINGS.ROOT]); const NAVIGATION_TAB_TO_SPANS: Partial, readonly string[]>> = { [NAVIGATION_TABS.INBOX]: INBOX_TAB_SPAN_IDS, @@ -43,13 +47,16 @@ function isRealizedNavigationState(state: NavigationState | PartialState parentState.routes.find((parentRoute) => parentRoute.key === route.key)?.state); const activeTabRoute = isRealizedNavigationState(tabState) ? tabState.routes[tabState.index] : undefined; const selectedTab = ROUTE_TO_NAVIGATION_TAB[activeTabRoute?.name ?? SCREENS.HOME] ?? NAVIGATION_TABS.HOME; - const shouldShowNativeTabBar = shouldUseNarrowLayout && isTabRouteAtRoot(activeTabRoute) && !isBlockingViewVisible; + // 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 = isInsightsTabVisible ? activeTabRoute?.name === NAVIGATORS.SETTINGS_SPLIT_NAVIGATOR : activeTabRoute?.name === SCREENS.INSIGHTS; + const shouldShowNativeTabBar = shouldUseNarrowLayout && isTabRouteAtRoot(activeTabRoute) && !isBlockingViewVisible && !isActiveTabWithoutBarItem; let inboxDotColor: string | undefined; if (chatTabBrickRoad) { @@ -88,6 +97,16 @@ function useNativeTabNavigator() { setPreservedNavigatorState(route.key, tabState); }, [tabState, route.key]); + // Without the beta, Insights has no bar item and its page is not found, so a restored or deep-linked Insights tab + // would be a full screen with no way out. Home takes its place. + const isInsightsTabFocusedWithoutBeta = !isInsightsTabVisible && activeTabRoute?.name === SCREENS.INSIGHTS; + useEffect(() => { + if (!isInsightsTabFocusedWithoutBeta) { + return; + } + Navigation.navigate(ROUTES.HOME); + }, [isInsightsTabFocusedWithoutBeta]); + // Cancel any in-flight tab-navigation span that doesn't match the new focused tab. // The span for the new tab is started by the tab button before navigation, so we keep it via `except`. useEffect(() => { @@ -107,7 +126,7 @@ function useNativeTabNavigator() { }, }); - return {shouldShowNativeTabBar, inboxDotColor, workspacesDotColor, accountDotColor, tabRouterOverride, tabScreenListeners}; + return {shouldShowNativeTabBar, inboxDotColor, workspacesDotColor, accountDotColor, isInsightsTabVisible, tabRouterOverride, tabScreenListeners}; } export default useNativeTabNavigator; diff --git a/src/libs/Navigation/AppNavigator/Navigators/TAB_SCREENS.native.ts b/src/libs/Navigation/AppNavigator/Navigators/TAB_SCREENS.native.ts index bf40bfd4cc23..6fa999c662e8 100644 --- a/src/libs/Navigation/AppNavigator/Navigators/TAB_SCREENS.native.ts +++ b/src/libs/Navigation/AppNavigator/Navigators/TAB_SCREENS.native.ts @@ -1,12 +1,20 @@ /** * Ordered list of screen names registered inside TabNavigator. * This must match the Tab.Screen order in TabNavigator.ios.tsx and TabNavigator.android.tsx, where the order is also the order the native - * tab bar draws its items in, so Workspaces comes before Account. + * tab bar draws its items in. Insights and Account never show together: the bar draws Home, Inbox, Spend, Insights, Workspaces with + * the Insights beta and Home, Inbox, Spend, Workspaces, Account without it. * Used by getAdaptedStateFromPath to build complete tab navigator state for deep-links. */ import NAVIGATORS from '@src/NAVIGATORS'; import SCREENS from '@src/SCREENS'; -const TAB_SCREENS = [SCREENS.HOME, NAVIGATORS.REPORTS_SPLIT_NAVIGATOR, NAVIGATORS.SEARCH_FULLSCREEN_NAVIGATOR, NAVIGATORS.WORKSPACE_NAVIGATOR, NAVIGATORS.SETTINGS_SPLIT_NAVIGATOR] as const; +const TAB_SCREENS = [ + SCREENS.HOME, + NAVIGATORS.REPORTS_SPLIT_NAVIGATOR, + NAVIGATORS.SEARCH_FULLSCREEN_NAVIGATOR, + SCREENS.INSIGHTS, + NAVIGATORS.WORKSPACE_NAVIGATOR, + NAVIGATORS.SETTINGS_SPLIT_NAVIGATOR, +] as const; export default TAB_SCREENS; diff --git a/src/libs/Navigation/AppNavigator/Navigators/TabNavigator.android.tsx b/src/libs/Navigation/AppNavigator/Navigators/TabNavigator.android.tsx index 315a6f82a8ed..dff68eb75705 100644 --- a/src/libs/Navigation/AppNavigator/Navigators/TabNavigator.android.tsx +++ b/src/libs/Navigation/AppNavigator/Navigators/TabNavigator.android.tsx @@ -6,9 +6,11 @@ import {nativeBottomTabScreenLayoutWrapper} from '@libs/Navigation/PlatformStack import type {TabNavigatorParamList} from '@libs/Navigation/types'; import HomePage from '@pages/home/HomePage'; +import InsightsPage from '@pages/Insights/InsightsPage'; import variables from '@styles/variables'; +import CONST from '@src/CONST'; import NAVIGATORS from '@src/NAVIGATORS'; import SCREENS from '@src/SCREENS'; @@ -19,8 +21,8 @@ import React from 'react'; import type {NativeTabLayoutProps} from './NativeTabNavigator/NativeTabLayout'; -import NATIVE_TAB_ICONS from './NativeTabNavigator/NATIVE_TAB_ICONS'; import NativeTabLayout from './NativeTabNavigator/NativeTabLayout'; +import useAndroidTabIcons from './NativeTabNavigator/useAndroidTabIcons'; import useNativeTabNavigator from './NativeTabNavigator/useNativeTabNavigator'; import ReportsSplitNavigator from './ReportsSplitNavigator'; import SearchFullscreenNavigator from './SearchFullscreenNavigator'; @@ -59,7 +61,8 @@ function TabNavigator() { const {translate} = useLocalize(); const theme = useTheme(); const styles = useThemeStyles(); - const {shouldShowNativeTabBar, inboxDotColor, workspacesDotColor, accountDotColor, tabRouterOverride, tabScreenListeners} = useNativeTabNavigator(); + const {shouldShowNativeTabBar, inboxDotColor, workspacesDotColor, accountDotColor, isInsightsTabVisible, tabRouterOverride, tabScreenListeners} = useNativeTabNavigator(); + const {getTabBarIcon, accountAvatarIcon, areTabIconsReady} = useAndroidTabIcons(); // Colors and the label face come from the Expensify theme and match the side bar on wide layouts: the // selected tab has the menu icon color and a bold label in the regular text color, the others the plain icon @@ -74,7 +77,7 @@ function TabNavigator() { tabBarActiveIndicatorColor: theme.androidTabBarActiveIndicatorBG, tabBarLabelVisibilityMode: 'labeled', // Every tab shares one style, so the bar reads the current visibility in the same render that changed it. - tabBarStyle: {display: shouldShowNativeTabBar ? 'flex' : 'none', backgroundColor: theme.highlightBG}, + tabBarStyle: {display: shouldShowNativeTabBar && areTabIconsReady ? 'flex' : 'none', backgroundColor: theme.highlightBG}, }; return ( @@ -89,28 +92,35 @@ function TabNavigator() { + @@ -119,7 +129,8 @@ function TabNavigator() { component={SettingsSplitNavigator} options={{ tabBarLabel: translate('initialSettingsPage.account'), - tabBarIcon: NATIVE_TAB_ICONS[NAVIGATORS.SETTINGS_SPLIT_NAVIGATOR], + tabBarIcon: accountAvatarIcon ?? getTabBarIcon(NAVIGATORS.SETTINGS_SPLIT_NAVIGATOR), + tabBarItemHidden: isInsightsTabVisible, ...getStatusBadgeOptions(accountDotColor), }} /> diff --git a/src/libs/Navigation/AppNavigator/Navigators/TabNavigator.ios.tsx b/src/libs/Navigation/AppNavigator/Navigators/TabNavigator.ios.tsx index 12827d2f55bf..61be5183804f 100644 --- a/src/libs/Navigation/AppNavigator/Navigators/TabNavigator.ios.tsx +++ b/src/libs/Navigation/AppNavigator/Navigators/TabNavigator.ios.tsx @@ -7,10 +7,12 @@ import type {TabNavigatorParamList} from '@libs/Navigation/types'; import {getAvatarURL} from '@libs/UserAvatarUtils'; import HomePage from '@pages/home/HomePage'; +import InsightsPage from '@pages/Insights/InsightsPage'; import FontUtils from '@styles/utils/FontUtils'; import variables from '@styles/variables'; +import CONST from '@src/CONST'; import NAVIGATORS from '@src/NAVIGATORS'; import SCREENS from '@src/SCREENS'; @@ -243,7 +245,7 @@ function TabNavigator() { const {translate} = useLocalize(); const theme = useTheme(); const currentUserPersonalDetails = useCurrentUserPersonalDetails(); - const {shouldShowNativeTabBar, inboxDotColor, workspacesDotColor, accountDotColor, tabRouterOverride, tabScreenListeners} = useNativeTabNavigator(); + const {shouldShowNativeTabBar, inboxDotColor, workspacesDotColor, accountDotColor, isInsightsTabVisible, tabRouterOverride, tabScreenListeners} = useNativeTabNavigator(); const dotColors: Record = { [NAVIGATORS.REPORTS_SPLIT_NAVIGATOR]: inboxDotColor, [NAVIGATORS.WORKSPACE_NAVIGATOR]: workspacesDotColor, @@ -253,6 +255,7 @@ function TabNavigator() { [SCREENS.HOME]: translate('common.home'), [NAVIGATORS.REPORTS_SPLIT_NAVIGATOR]: translate('common.inbox'), [NAVIGATORS.SEARCH_FULLSCREEN_NAVIGATOR]: translate('common.spend'), + [SCREENS.INSIGHTS]: translate('common.insights'), [NAVIGATORS.WORKSPACE_NAVIGATOR]: translate('common.workspacesTabTitle'), [NAVIGATORS.SETTINGS_SPLIT_NAVIGATOR]: translate('initialSettingsPage.account'), }; @@ -403,6 +406,13 @@ function TabNavigator() { component={SearchFullscreenNavigator} options={{tabBarLabel: '', tabBarIcon: getTabBarIcon(NAVIGATORS.SEARCH_FULLSCREEN_NAVIGATOR)}} /> + toAvatarIcon(focused ? avatarPair.active : avatarPair.inactive) : getTabBarIcon(NAVIGATORS.SETTINGS_SPLIT_NAVIGATOR), diff --git a/src/pages/settings/InitialSettingsPage.tsx b/src/pages/settings/InitialSettingsPage.tsx index 0940a3450b95..4eb636cbf27c 100755 --- a/src/pages/settings/InitialSettingsPage.tsx +++ b/src/pages/settings/InitialSettingsPage.tsx @@ -11,6 +11,7 @@ import Text from '@components/Text'; import type {WithCurrentUserPersonalDetailsProps} from '@components/withCurrentUserPersonalDetails'; import withCurrentUserPersonalDetails from '@components/withCurrentUserPersonalDetails'; +import useIsSettingsDrawnOverTabs from '@hooks/useIsSettingsDrawnOverTabs'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; import usePrevious from '@hooks/usePrevious'; @@ -27,13 +28,14 @@ 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 {isEmptyObject} from '@src/types/utils/EmptyObject'; 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 {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'; @@ -53,6 +55,16 @@ 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. + const goBackFromAccount = () => { + if (navigation.canGoBack()) { + navigation.goBack(); + return; + } + Navigation.navigate(ROUTES.HOME); + }; const isScreenFocused = useIsSidebarRouteActive(NAVIGATORS.SETTINGS_SPLIT_NAVIGATOR, shouldUseNarrowLayout); const previousUserPersonalDetails = usePrevious(currentUserPersonalDetails); const {accountMenuItemsData, generalMenuItemsData} = useInitialSettingsPageMenuData(currentUserPersonalDetails); @@ -161,6 +173,7 @@ 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 c81bbef6c6e38909f94d9f194c31c4ece2291b06 Mon Sep 17 00:00:00 2001 From: sumo_slonik Date: Wed, 30 Sep 2026 16:13:00 +0200 Subject: [PATCH 26/71] clearing up pr --- ...02+active-indicator-color-precedence.patch | 31 ++ ...s+7.16.2+002+android-tab-item-colors.patch | 113 ------ patches/react-navigation/details.md | 6 +- .../Navigation/NavigationTabBar/index.tsx | 6 +- src/components/Search/index.tsx | 28 +- .../NativeTabNavigator/tabIconRasterizer.ts | 93 +++++ .../NativeTabNavigator/useAndroidTabIcons.ts | 177 ++-------- .../NativeTabNavigator/useIOSTabIcons.ts | 227 ++++++++++++ .../useRasterizedTabIcons.ts | 46 +++ .../Navigators/TabNavigator.android.tsx | 11 +- .../Navigators/TabNavigator.ios.tsx | 328 +----------------- src/pages/Search/SearchPageNarrow/index.tsx | 7 - 12 files changed, 463 insertions(+), 610 deletions(-) create mode 100644 patches/react-navigation/@react-navigation+bottom-tabs+7.16.2+002+active-indicator-color-precedence.patch delete mode 100644 patches/react-navigation/@react-navigation+bottom-tabs+7.16.2+002+android-tab-item-colors.patch create mode 100644 src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/tabIconRasterizer.ts create mode 100644 src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/useIOSTabIcons.ts create mode 100644 src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/useRasterizedTabIcons.ts diff --git a/patches/react-navigation/@react-navigation+bottom-tabs+7.16.2+002+active-indicator-color-precedence.patch b/patches/react-navigation/@react-navigation+bottom-tabs+7.16.2+002+active-indicator-color-precedence.patch new file mode 100644 index 000000000000..0526d0fd3623 --- /dev/null +++ b/patches/react-navigation/@react-navigation+bottom-tabs+7.16.2+002+active-indicator-color-precedence.patch @@ -0,0 +1,31 @@ +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 1183c51..303635c 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 +@@ -160,7 +160,7 @@ export function NativeBottomTabView({ + ios: PlatformColor('label'), + default: colors.text + }); +- const activeIndicatorColor = currentOptions?.tabBarActiveIndicatorColor ?? typeof activeTintColor === 'string' ? Color(activeTintColor)?.alpha(0.1).string() : undefined; ++ const activeIndicatorColor = currentOptions?.tabBarActiveIndicatorColor ?? (typeof activeTintColor === 'string' ? Color(activeTintColor)?.alpha(0.1).string() : undefined); + const onTransitionStart = ({ + closing, + route +diff --git a/node_modules/@react-navigation/bottom-tabs/src/unstable/NativeBottomTabView.native.tsx b/node_modules/@react-navigation/bottom-tabs/src/unstable/NativeBottomTabView.native.tsx +index 287dea3..0f06b2b 100644 +--- a/node_modules/@react-navigation/bottom-tabs/src/unstable/NativeBottomTabView.native.tsx ++++ b/node_modules/@react-navigation/bottom-tabs/src/unstable/NativeBottomTabView.native.tsx +@@ -262,10 +262,10 @@ export function NativeBottomTabView({ state, navigation, descriptors }: Props) { + }); + + const activeIndicatorColor = +- (currentOptions?.tabBarActiveIndicatorColor ?? +- typeof activeTintColor === 'string') ++ currentOptions?.tabBarActiveIndicatorColor ?? ++ (typeof activeTintColor === 'string' + ? Color(activeTintColor)?.alpha(0.1).string() +- : undefined; ++ : undefined); + + const onTransitionStart = ({ + closing, diff --git a/patches/react-navigation/@react-navigation+bottom-tabs+7.16.2+002+android-tab-item-colors.patch b/patches/react-navigation/@react-navigation+bottom-tabs+7.16.2+002+android-tab-item-colors.patch deleted file mode 100644 index f7e43f14b342..000000000000 --- a/patches/react-navigation/@react-navigation+bottom-tabs+7.16.2+002+android-tab-item-colors.patch +++ /dev/null @@ -1,113 +0,0 @@ -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 1183c51..303635c 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 -@@ -160,7 +160,7 @@ export function NativeBottomTabView({ - ios: PlatformColor('label'), - default: colors.text - }); -- const activeIndicatorColor = currentOptions?.tabBarActiveIndicatorColor ?? typeof activeTintColor === 'string' ? Color(activeTintColor)?.alpha(0.1).string() : undefined; -+ const activeIndicatorColor = currentOptions?.tabBarActiveIndicatorColor ?? (typeof activeTintColor === 'string' ? Color(activeTintColor)?.alpha(0.1).string() : undefined); - const onTransitionStart = ({ - closing, - route -@@ -253,11 +253,11 @@ export function NativeBottomTabView({ - tabBarItemBadgeBackgroundColor: badgeBackgroundColor - }; - const normalTabItemAppearance = { -- tabBarItemTitleFontColor: inactiveTintColor ?? fontColor, -+ tabBarItemTitleFontColor: currentOptions.tabBarInactiveLabelColor ?? inactiveTintColor ?? fontColor, - tabBarItemIconColor: inactiveTintColor - }; - const selectedTabItemAppearance = { -- tabBarItemTitleFontColor: activeTintColor, -+ tabBarItemTitleFontColor: currentOptions.tabBarActiveLabelColor ?? activeTintColor, - tabBarItemIconColor: activeTintColor - }; - const icon = typeof tabBarIcon === 'function' ? getPlatformIcon(tabBarIcon({ -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 5a502c2..a071119 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 -@@ -156,6 +156,18 @@ export type NativeBottomTabNavigationOptions = NativeHeaderOptions & { - * @platform android - */ - tabBarInactiveTintColor?: ColorValue; -+ /** -+ * Color for the label in the active tab, overriding `tabBarActiveTintColor` for the label only. -+ * -+ * @platform android -+ */ -+ tabBarActiveLabelColor?: ColorValue; -+ /** -+ * Color for the label in the inactive tabs, overriding `tabBarInactiveTintColor` for the label only. -+ * -+ * @platform android -+ */ -+ tabBarInactiveLabelColor?: ColorValue; - /** - * Background color of the active indicator. - * -diff --git a/node_modules/@react-navigation/bottom-tabs/src/unstable/NativeBottomTabView.native.tsx b/node_modules/@react-navigation/bottom-tabs/src/unstable/NativeBottomTabView.native.tsx -index 287dea3..0f06b2b 100644 ---- a/node_modules/@react-navigation/bottom-tabs/src/unstable/NativeBottomTabView.native.tsx -+++ b/node_modules/@react-navigation/bottom-tabs/src/unstable/NativeBottomTabView.native.tsx -@@ -262,10 +262,10 @@ export function NativeBottomTabView({ state, navigation, descriptors }: Props) { - }); - - const activeIndicatorColor = -- (currentOptions?.tabBarActiveIndicatorColor ?? -- typeof activeTintColor === 'string') -+ currentOptions?.tabBarActiveIndicatorColor ?? -+ (typeof activeTintColor === 'string' - ? Color(activeTintColor)?.alpha(0.1).string() -- : undefined; -+ : undefined); - - const onTransitionStart = ({ - closing, -@@ -387,13 +387,17 @@ export function NativeBottomTabView({ state, navigation, descriptors }: Props) { - - const normalTabItemAppearance: TabsScreenItemStateAppearanceAndroid = - { -- tabBarItemTitleFontColor: inactiveTintColor ?? fontColor, -+ tabBarItemTitleFontColor: -+ currentOptions.tabBarInactiveLabelColor ?? -+ inactiveTintColor ?? -+ fontColor, - tabBarItemIconColor: inactiveTintColor, - }; - - const selectedTabItemAppearance: TabsScreenItemStateAppearanceAndroid = - { -- tabBarItemTitleFontColor: activeTintColor, -+ tabBarItemTitleFontColor: -+ currentOptions.tabBarActiveLabelColor ?? activeTintColor, - tabBarItemIconColor: activeTintColor, - }; - -diff --git a/node_modules/@react-navigation/bottom-tabs/src/unstable/types.tsx b/node_modules/@react-navigation/bottom-tabs/src/unstable/types.tsx -index a2471f5..d9df793 100644 ---- a/node_modules/@react-navigation/bottom-tabs/src/unstable/types.tsx -+++ b/node_modules/@react-navigation/bottom-tabs/src/unstable/types.tsx -@@ -200,6 +200,20 @@ export type NativeBottomTabNavigationOptions = NativeHeaderOptions & { - */ - tabBarInactiveTintColor?: ColorValue; - -+ /** -+ * Color for the label in the active tab, overriding `tabBarActiveTintColor` for the label only. -+ * -+ * @platform android -+ */ -+ tabBarActiveLabelColor?: ColorValue; -+ -+ /** -+ * Color for the label in the inactive tabs, overriding `tabBarInactiveTintColor` for the label only. -+ * -+ * @platform android -+ */ -+ tabBarInactiveLabelColor?: ColorValue; -+ - /** - * Background color of the active indicator. - * diff --git a/patches/react-navigation/details.md b/patches/react-navigation/details.md index ba47f69be53b..5ba1a6ebee87 100644 --- a/patches/react-navigation/details.md +++ b/patches/react-navigation/details.md @@ -68,11 +68,9 @@ - E/App issue: [#94571](https://github.com/Expensify/App/issues/94571) - PR Introducing Patch: [#95980](https://github.com/Expensify/App/pull/95980) -### [@react-navigation+bottom-tabs+7.16.2+002+android-tab-item-colors.patch](@react-navigation+bottom-tabs+7.16.2+002+android-tab-item-colors.patch) +### [@react-navigation+bottom-tabs+7.16.2+002+active-indicator-color-precedence.patch](@react-navigation+bottom-tabs+7.16.2+002+active-indicator-color-precedence.patch) -- Reason: Lets the native Android tab bar take the colors from the design, where the selected tab's icon and active indicator are green and its label keeps the regular text color. - - `tabBarActiveIndicatorColor` was never applied: a missing pair of parentheses made `??` bind to the `typeof` check, so the indicator always got `tabBarActiveTintColor` at 10% alpha. The patch restores the intended precedence. - - `tabBarActiveTintColor` and `tabBarInactiveTintColor` each color an icon and its label together. The patch adds Android-only `tabBarActiveLabelColor` and `tabBarInactiveLabelColor`, which override the tint for the label only. The native `TabsScreenItemStateAppearanceAndroid` already takes separate title and icon colors, so nothing native changes. +- Reason: `tabBarActiveIndicatorColor` was never applied on the native Android tab bar: a missing pair of parentheses made `??` bind to the `typeof` check, so the indicator always got `tabBarActiveTintColor` at 10% alpha. The patch restores the intended precedence, so the active indicator pill takes the color from the design. - Upstream PR/issue: not reported yet. - E/App issue: [#101169](https://github.com/Expensify/App/issues/101169) - PR Introducing Patch: [#101339](https://github.com/Expensify/App/pull/101339) diff --git a/src/components/Navigation/NavigationTabBar/index.tsx b/src/components/Navigation/NavigationTabBar/index.tsx index 1a4755b490bc..f934146a96da 100644 --- a/src/components/Navigation/NavigationTabBar/index.tsx +++ b/src/components/Navigation/NavigationTabBar/index.tsx @@ -14,7 +14,6 @@ import useStyleUtils from '@hooks/useStyleUtils'; import useThemeStyles from '@hooks/useThemeStyles'; import interceptAnonymousUser from '@libs/interceptAnonymousUser'; -import TAB_SCREENS from '@libs/Navigation/AppNavigator/Navigators/TAB_SCREENS'; import Navigation from '@libs/Navigation/Navigation'; import NavigationTabBarAvatar from '@pages/inbox/sidebar/NavigationTabBarAvatar'; @@ -24,7 +23,6 @@ import SupportalSwitcherButton from '@pages/inbox/sidebar/SupportalSwitcherButto import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; -import SCREENS from '@src/SCREENS'; import type {ValueOf} from 'type-fest'; @@ -48,9 +46,7 @@ function NavigationTabBar({selectedTab, shouldShowFloatingButtons = true}: Navig const {translate} = useLocalize(); const [isDebugModeEnabled] = useOnyx(ONYXKEYS.IS_DEBUG_MODE_ENABLED); const {isBetaEnabled} = usePermissions(); - // The native tab navigators register five items and leave Insights out, so the wide layout's own bar must not - // offer a tab the navigator cannot route to. - const isInsightsTabVisible = isBetaEnabled(CONST.BETAS.INSIGHTS_PAGE) && (TAB_SCREENS as readonly string[]).includes(SCREENS.INSIGHTS); + const isInsightsTabVisible = isBetaEnabled(CONST.BETAS.INSIGHTS_PAGE); const expensifyIcons = useMemoizedLazyExpensifyIcons(['ExpensifyAppIcon', 'Home']); const {shouldUseNarrowLayout} = useResponsiveLayout(); diff --git a/src/components/Search/index.tsx b/src/components/Search/index.tsx index 3a963469f934..af5560e39649 100644 --- a/src/components/Search/index.tsx +++ b/src/components/Search/index.tsx @@ -50,6 +50,7 @@ import { isTransactionGroupListItemType, isTransactionListItemType, isTransactionReportGroupListItemType, + isTransactionSearchType, shouldShowEmptyState, shouldShowYear as shouldShowYearUtil, } from '@libs/SearchUIUtils'; @@ -416,7 +417,7 @@ function Search({ const hasUnresolvedErrors = hasErrors && responseStatusCode === null; const isWaitingForInitialData = !shouldUseLiveData && !isOffline && (!isDataLoaded || isSearchLoadingWithNoResults || hasUnresolvedErrors || isCardFeedsLoading); const shouldShowLoadingState = isDeferringHeavyWork || isWaitingForInitialData; - const shouldShowRowSkeleton = !hasErrors && (shouldShowLoadingState || (!skeletonWasDisplayed && showPendingExpensePlaceholder)); + const shouldShowRowSkeleton = (!skeletonWasDisplayed || shouldShowLoadingState) && showPendingExpensePlaceholder && !hasErrors; const isLoadingMorePage = shouldUseLiveData ? isLivePageInFlight : !!searchResults?.search?.isLoading; const shouldShowLoadingMoreItems = !shouldShowLoadingState && isLoadingMorePage && (searchResults?.search?.offset ?? 0) > 0; @@ -1000,11 +1001,6 @@ function Search({ // Deferred layout only needs the base work (no scroll handling, no content-ready signal). const onDeferredLayout = onLayoutBase; - const handleSkeletonLayout = useCallback(() => { - onDeferredLayout(); - onSkeletonLayout(); - }, [onDeferredLayout, onSkeletonLayout]); - const onLayout = useCallback(() => { onLayoutBase(); handleSelectionListScroll(stableSortedData, searchListRef.current); @@ -1164,15 +1160,25 @@ function Search({ [clearSelectedTransactions, queryJSON, onSortPressedCallback, navigation], ); - // Stands in for the list while the heavy grouping and sorting work is deferred or the first response - // has not arrived. The page level skeleton (useSearchLoadingState) stops at the first defined `data`, - // so on a warm snapshot this is the only thing between the header and the rows. Its onLayout also - // flushes the deferred API write and sets hasHadFirstLayout. + // When heavy work is deferred (e.g. during the RHP dismiss animation after + // submitting an expense), skip the expensive render below. The ancestor + // SearchPage (via SearchPageNarrow / SearchPageWide) renders a SearchStaticList + // overlay that covers this component, so the user sees real-looking content. + // The minimal View fires onLayout to flush the deferred API write and set + // hasHadFirstLayout. + if (isDeferringHeavyWork && searchResults?.data && isTransactionSearchType(type)) { + // Zero-sized View - onLayout still fires on RN, which is all we need here. + return ; + } + + // This is a performance optimization for the submit-expense->search path only. + // The SearchPage skeleton (useSearchLoadingState) doesn't cover this case because + // Search must mount for its onLayout to flush the deferred CreateMoneyRequest API write, which would block the JS thread causing a slowdown on post expense creation navigation if (shouldShowRowSkeleton) { return ( ); diff --git a/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/tabIconRasterizer.ts b/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/tabIconRasterizer.ts new file mode 100644 index 000000000000..2930b251442f --- /dev/null +++ b/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/tabIconRasterizer.ts @@ -0,0 +1,93 @@ +import type {NativeBottomTabIcon} from '@react-navigation/bottom-tabs/unstable'; +import type {SkCanvas, SkImage, SkSurface} from '@shopify/react-native-skia'; +import type {ImageSourcePropType} from 'react-native'; + +import {BlendMode, ClipOp, ImageFormat, Skia} from '@shopify/react-native-skia'; +import {Image} from 'react-native'; + +import NATIVE_TAB_ICONS from './NATIVE_TAB_ICONS'; + +/** The recolored copies of one glyph, one per selection state. */ +type TabIconPair = {active: NativeBottomTabIcon; inactive: NativeBottomTabIcon}; + +/** Every glyph the bar draws, paired with the tab it belongs to so the recolored copies can be looked up again. */ +const TAB_ICONS = Object.entries(NATIVE_TAB_ICONS).map(([name, icon]) => [name, icon.source] as const); + +/** Downloads and decodes an image, or resolves to null when it cannot be decoded. */ +async function decodeImage(uri: string): Promise { + const response = await fetch(uri); + return Skia.Image.MakeImageFromEncoded(Skia.Data.fromBytes(new Uint8Array(await response.arrayBuffer()))); +} + +/** Decodes a bundled glyph together with the pixel density it was resolved at. */ +async function decodeGlyph(source: ImageSourcePropType): Promise<{image: SkImage; scale: number} | undefined> { + const asset = Image.resolveAssetSource(source); + if (!asset?.uri) { + return undefined; + } + const image = await decodeImage(asset.uri); + return image ? {image, scale: asset.scale ?? 1} : undefined; +} + +/** A paint that keeps the glyph's alpha and replaces every colored pixel with the given color. */ +function createTintPaint(color: string) { + const paint = Skia.Paint(); + paint.setColorFilter(Skia.ColorFilter.MakeBlend(Skia.Color(color), BlendMode.SrcIn)); + return paint; +} + +/** Draws the centred square of the image, cropped to a circle of the given size. */ +function drawCircularImage(canvas: SkCanvas, image: SkImage, left: number, top: number, size: number) { + const sourceSize = Math.min(image.width(), image.height()); + const circle = Skia.Path.Make(); + circle.addCircle(left + size / 2, top + size / 2, size / 2); + + canvas.save(); + canvas.clipPath(circle, ClipOp.Intersect, true); + canvas.drawImageRect( + image, + Skia.XYWHRect((image.width() - sourceSize) / 2, (image.height() - sourceSize) / 2, sourceSize, sourceSize), + Skia.XYWHRect(left, top, size, size), + Skia.Paint(), + ); + canvas.restore(); + circle.dispose(); +} + +/** Encodes what was drawn on the surface as a PNG data URI, which a tab icon can take, and disposes the surface. */ +function encodeSurface(surface: SkSurface): string { + surface.flush(); + const snapshot = surface.makeImageSnapshot(); + const base64 = snapshot.encodeToBase64(ImageFormat.PNG, 100); + snapshot.dispose(); + surface.dispose(); + return `data:image/png;base64,${base64}`; +} + +/** Draws both selection states of every glyph, leaving out a tab whose copies could not be drawn. */ +async function drawTabIconPairs( + drawIcon: (name: string, source: ImageSourcePropType, isSelected: boolean) => Promise, +): Promise> { + const results = await Promise.all( + TAB_ICONS.map(([name, source]) => Promise.all([drawIcon(name, source, false), drawIcon(name, source, true)]).then(([inactive, active]) => ({name, inactive, active}))), + ); + const icons: Record = {}; + for (const result of results) { + if (result.inactive && result.active) { + icons[result.name] = {inactive: result.inactive, active: result.active}; + } + } + return icons; +} + +/** The tab's recolored icon for its selection state, or the plain glyph for a tab whose copies could not be drawn. */ +function getTabBarIcon(icons: Record | undefined, name: keyof typeof NATIVE_TAB_ICONS) { + const pair = icons?.[name]; + if (!pair) { + return NATIVE_TAB_ICONS[name]; + } + return ({focused}: {focused: boolean}) => (focused ? pair.active : pair.inactive); +} + +export {createTintPaint, decodeGlyph, decodeImage, drawCircularImage, drawTabIconPairs, encodeSurface, getTabBarIcon}; +export type {TabIconPair}; diff --git a/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/useAndroidTabIcons.ts b/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/useAndroidTabIcons.ts index 26a496748561..8470d3776f9f 100644 --- a/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/useAndroidTabIcons.ts +++ b/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/useAndroidTabIcons.ts @@ -8,30 +8,17 @@ import variables from '@styles/variables'; import type {NativeBottomTabIcon} from '@react-navigation/bottom-tabs/unstable'; import type {ImageSourcePropType} from 'react-native'; -import {BlendMode, ClipOp, ImageFormat, Skia} from '@shopify/react-native-skia'; -import {useEffect, useState} from 'react'; -import {Image} from 'react-native'; +import {Skia} from '@shopify/react-native-skia'; -import NATIVE_TAB_ICONS from './NATIVE_TAB_ICONS'; +import type NATIVE_TAB_ICONS from './NATIVE_TAB_ICONS'; +import type {TabIconPair} from './tabIconRasterizer'; +import type {LastDrawnTabIcons} from './useRasterizedTabIcons'; -/** The recolored copies of one glyph, one per selection state. */ -type TintedTabIconPair = {active: NativeBottomTabIcon; inactive: NativeBottomTabIcon}; +import {createTintPaint, decodeGlyph, decodeImage, drawCircularImage, drawTabIconPairs, encodeSurface, getTabBarIcon} from './tabIconRasterizer'; +import useRasterizedTabIcons from './useRasterizedTabIcons'; -/** The recolored glyphs, with the signature of the colors baked into them. */ -type TintedTabIcons = {signature: string; icons: Record}; - -/** The account avatar cropped to a circle, with the URI it was drawn from. */ -type CircularAvatarIcon = {uri: string; icon: NativeBottomTabIcon}; - -/** - * The last icons drawn, kept outside the hook so a remounted navigator starts from them instead of waiting for - * Skia again. - */ -let lastTintedIcons: TintedTabIcons | undefined; -let lastCircularAvatar: CircularAvatarIcon | undefined; - -/** Every glyph the bar draws, paired with the tab it belongs to so the tinted copies can be looked up again. */ -const TAB_ICONS = Object.entries(NATIVE_TAB_ICONS).map(([name, icon]) => [name, icon.source] as const); +const lastTintedIcons: LastDrawnTabIcons> = {}; +const lastCircularAvatar: LastDrawnTabIcons<{uri: string; icon: NativeBottomTabIcon}> = {}; /** * Material draws the icons without a tint (react-native-screens patch 003), so each glyph is recolored off-screen @@ -39,39 +26,25 @@ const TAB_ICONS = Object.entries(NATIVE_TAB_ICONS).map(([name, icon]) => [name, * draws it at full size. */ async function createTintedGlyph(source: ImageSourcePropType, color: string): Promise { - const asset = Image.resolveAssetSource(source); - if (!asset?.uri) { + const glyph = await decodeGlyph(source); + if (!glyph) { return undefined; } - const response = await fetch(asset.uri); - const encodedImage = Skia.Data.fromBytes(new Uint8Array(await response.arrayBuffer())); - const image = Skia.Image.MakeImageFromEncoded(encodedImage); - const scale = asset.scale ?? 1; - const canvasWidth = image?.width() ?? 0; - const canvasHeight = image?.height() ?? 0; - const surface = image ? Skia.Surface.MakeOffscreen(canvasWidth, canvasHeight) : null; - - if (!image || !surface) { - image?.dispose(); - surface?.dispose(); + const {image, scale} = glyph; + const canvasWidth = image.width(); + const canvasHeight = image.height(); + const surface = Skia.Surface.MakeOffscreen(canvasWidth, canvasHeight); + if (!surface) { + image.dispose(); return undefined; } - const paint = Skia.Paint(); - // SrcIn keeps the glyph's alpha and replaces every colored pixel with the theme color. - paint.setColorFilter(Skia.ColorFilter.MakeBlend(Skia.Color(color), BlendMode.SrcIn)); - surface.getCanvas().drawImageRect(image, Skia.XYWHRect(0, 0, canvasWidth, canvasHeight), Skia.XYWHRect(0, 0, canvasWidth, canvasHeight), paint); - surface.flush(); - - const snapshot = surface.makeImageSnapshot(); - const base64 = snapshot.encodeToBase64(ImageFormat.PNG, 100); - + const rect = Skia.XYWHRect(0, 0, canvasWidth, canvasHeight); + surface.getCanvas().drawImageRect(image, rect, rect, createTintPaint(color)); image.dispose(); - snapshot.dispose(); - surface.dispose(); - return {type: 'image', source: {uri: `data:image/png;base64,${base64}`, width: canvasWidth / scale, height: canvasHeight / scale, scale}}; + return {type: 'image', source: {uri: encodeSurface(surface), width: canvasWidth / scale, height: canvasHeight / scale, scale}}; } /** @@ -80,12 +53,10 @@ async function createTintedGlyph(source: ImageSourcePropType, color: string): Pr * marked by the active indicator pill, so one image serves both selection states. */ async function createCircularAvatarIcon(uri: string): Promise { - const response = await fetch(uri); - const encodedImage = Skia.Data.fromBytes(new Uint8Array(await response.arrayBuffer())); - const image = Skia.Image.MakeImageFromEncoded(encodedImage); + const image = await decodeImage(uri); const scale = variables.nativeTabIconScale; const canvasSize = variables.iconBottomBar * scale; - const surface = Skia.Surface.MakeOffscreen(canvasSize, canvasSize); + const surface = image ? Skia.Surface.MakeOffscreen(canvasSize, canvasSize) : null; if (!image || !surface) { image?.dispose(); @@ -93,26 +64,10 @@ async function createCircularAvatarIcon(uri: string): Promise(lastTintedIcons); + + // A failed draw still lets the bar show, on the plain black glyphs, rather than keeping it hidden. + const tintedIcons = useRasterizedTabIcons( + lastTintedIcons, + [inactiveColor, activeColor].join('|'), + () => drawTabIconPairs((name, source, isSelected) => createTintedGlyph(source, isSelected ? activeColor : inactiveColor)), + {}, + ); const avatarSource = getAvatarURL({avatarSource: currentUserPersonalDetails.avatar, accountID: currentUserPersonalDetails.accountID}); const avatarURI = typeof avatarSource === 'string' ? avatarSource : undefined; - const [circularAvatar, setCircularAvatar] = useState(lastCircularAvatar); - - useEffect(() => { - let isActive = true; - const signature = iconsSignature; - - Promise.all( - TAB_ICONS.map(([name, source]) => - Promise.all([createTintedGlyph(source, inactiveColor), createTintedGlyph(source, activeColor)]).then(([inactive, active]) => ({name, inactive, active})), - ), - ) - .then((results) => { - if (!isActive) { - return; - } - const icons: Record = {}; - for (const result of results) { - if (result.inactive && result.active) { - icons[result.name] = {inactive: result.inactive, active: result.active}; - } - } - lastTintedIcons = {signature, icons}; - setTintedIcons(lastTintedIcons); - }) - .catch(() => { - // A failed draw still lets the bar show, on the plain black glyphs, rather than keeping it hidden. - if (!isActive) { - return; - } - setTintedIcons((previous) => previous ?? {signature, icons: {}}); - }); - - return () => { - isActive = false; - }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [iconsSignature]); - - useEffect(() => { - let isActive = true; - if (!avatarURI) { - return () => { - isActive = false; - }; - } - - createCircularAvatarIcon(avatarURI) - .then((icon) => { - if (!isActive || !icon) { - return; - } - lastCircularAvatar = {uri: avatarURI, icon}; - setCircularAvatar(lastCircularAvatar); - }) - .catch(() => {}); - - return () => { - isActive = false; - }; - }, [avatarURI]); - - /** Falls back to the plain black glyph for a tab whose recolored copies could not be drawn. */ - const getTabBarIcon = (name: keyof typeof NATIVE_TAB_ICONS) => { - const pair = tintedIcons?.icons[name]; - if (!pair) { - return NATIVE_TAB_ICONS[name]; - } - return ({focused}: {focused: boolean}) => (focused ? pair.active : pair.inactive); - }; + const circularAvatar = useRasterizedTabIcons(lastCircularAvatar, avatarURI, (uri) => createCircularAvatarIcon(uri).then((icon) => icon && {uri, icon})); // An avatar drawn for another user is never shown; until the current one is drawn, the account glyph stands in. const accountAvatarIcon = circularAvatar?.uri === avatarURI ? circularAvatar?.icon : undefined; - return {getTabBarIcon, accountAvatarIcon, areTabIconsReady: !!tintedIcons}; + return { + getTabBarIcon: (name: keyof typeof NATIVE_TAB_ICONS) => getTabBarIcon(tintedIcons, name), + accountAvatarIcon, + areTabIconsReady: !!tintedIcons, + }; } export default useAndroidTabIcons; diff --git a/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/useIOSTabIcons.ts b/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/useIOSTabIcons.ts new file mode 100644 index 000000000000..550e515c683a --- /dev/null +++ b/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/useIOSTabIcons.ts @@ -0,0 +1,227 @@ +import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; +import useLocalize from '@hooks/useLocalize'; +import useTheme from '@hooks/useTheme'; + +import {getAvatarURL} from '@libs/UserAvatarUtils'; + +import FontUtils from '@styles/utils/FontUtils'; +import variables from '@styles/variables'; + +import NAVIGATORS from '@src/NAVIGATORS'; +import SCREENS from '@src/SCREENS'; + +import type {NativeBottomTabIcon} from '@react-navigation/bottom-tabs/unstable'; +import type {SkCanvas} from '@shopify/react-native-skia'; +import type {ImageSourcePropType} from 'react-native'; + +import {FontWeight, Skia} from '@shopify/react-native-skia'; + +import type NATIVE_TAB_ICONS from './NATIVE_TAB_ICONS'; +import type {TabIconPair} from './tabIconRasterizer'; +import type {LastDrawnTabIcons} from './useRasterizedTabIcons'; + +import {createTintPaint, decodeGlyph, decodeImage, drawCircularImage, drawTabIconPairs, encodeSurface, getTabBarIcon} from './tabIconRasterizer'; +import useRasterizedTabIcons from './useRasterizedTabIcons'; + +/** The account avatar drawn with its label, and the size it came out at. */ +type AvatarTabIcon = {uri: string; width: number; height: number}; + +/** The account tab's avatar in both selection states, with the URI it was drawn from. */ +type CircularAvatarIcons = {uri: string; active: AvatarTabIcon; inactive: AvatarTabIcon}; + +const lastTintedIcons: LastDrawnTabIcons> = {}; +const lastCircularAvatar: LastDrawnTabIcons = {}; + +/** Paints the status dot in the glyph's top right corner, where a native badge would have sat. */ +function drawStatusDot(canvas: SkCanvas, glyphRight: number, glyphTop: number, scale: number, color: string) { + const radius = variables.nativeTabIconDotRadius * scale; + const paint = Skia.Paint(); + paint.setColor(Skia.Color(color)); + canvas.drawCircle(glyphRight - radius, glyphTop + radius, radius, paint); +} + +/** The label's face: Expensify Neue, bold when selected. */ +function getLabelFont(isSelected: boolean, scale: number) { + const typeface = Skia.FontMgr.System().matchFamilyStyle(FontUtils.fontFamily.single.EXP_NEUE.fontFamily, { + weight: isSelected ? FontWeight.Bold : FontWeight.Normal, + }); + return Skia.Font(typeface, variables.fontSizeSmall * scale); +} + +/** + * Draws the label under the glyph and reports how much room it took. iOS 26 discards the title color an item + * appearance carries while honoring its font, and neither `unselectedItemTintColor` nor re-asserting it after + * layout gets through, so the text is painted into the image the item is handed instead. + */ +function drawLabel(canvas: SkCanvas, label: string, color: string, isSelected: boolean, scale: number, canvasWidth: number, top: number) { + const font = getLabelFont(isSelected, scale); + const paint = Skia.Paint(); + paint.setColor(Skia.Color(color)); + paint.setAntiAlias(true); + const width = font.measureText(label).width; + const metrics = font.getMetrics(); + canvas.drawText(label, (canvasWidth - width) / 2, top - metrics.ascent, paint, font); +} + +/** Width and height the label occupies, so a canvas can be sized before anything is drawn into it. */ +function measureLabel(label: string, isSelected: boolean, scale: number) { + const font = getLabelFont(isSelected, scale); + const metrics = font.getMetrics(); + return {width: font.measureText(label).width, height: metrics.descent - metrics.ascent}; +} + +/** + * iOS 26 draws the bar's glass material itself and honors very little of what a tab item is told about its colors. + * The inactive icon color from `UITabBarItemAppearance` never arrives, and every badge is painted in the color of + * whichever tab is selected — measured to hold for `badgeBackgroundColor` and for `UITabBarItem.badgeColor` alike. + * The image handed to an item is honored, though, so the glyph is recolored off-screen and the status dot is drawn + * into it. Both selection states go through this, because RNScreens rejects a tab whose icon and selectedIcon + * differ in type. The canvas is only as big as what it holds, and the dot overlaps the glyph's top right corner + * the way the indicator on the JS bar does. + */ +async function createTabIcon( + source: ImageSourcePropType, + color: string, + dotColor: string | undefined, + label: string, + labelColor: string, + isSelected: boolean, +): Promise { + const glyph = await decodeGlyph(source); + if (!glyph) { + return undefined; + } + + const {image, scale} = glyph; + // The glyph assets are 24 pt and are drawn at the native bar's glyph size. + const glyphWidth = variables.iconNativeTabBarIOS * scale; + const glyphHeight = variables.iconNativeTabBarIOS * scale; + // The account avatar is taller than a glyph, so every glyph sits this far down to share its centre, and + // every label lands at the same height. + const glyphTop = ((variables.avatarNativeTabBarIOS - variables.iconNativeTabBarIOS) / 2) * scale; + const gap = variables.nativeTabIconLabelGap * scale; + const labelSize = measureLabel(label, isSelected, scale); + const canvasWidth = Math.ceil(Math.max(glyphWidth, labelSize.width)); + const canvasHeight = Math.ceil(glyphTop + glyphHeight + gap + labelSize.height); + const surface = Skia.Surface.MakeOffscreen(canvasWidth, canvasHeight); + + if (!surface) { + image.dispose(); + return undefined; + } + + const canvas = surface.getCanvas(); + const glyphLeft = (canvasWidth - glyphWidth) / 2; + canvas.drawImageRect(image, Skia.XYWHRect(0, 0, image.width(), image.height()), Skia.XYWHRect(glyphLeft, glyphTop, glyphWidth, glyphHeight), createTintPaint(color)); + + if (dotColor) { + drawStatusDot(canvas, glyphLeft + glyphWidth, glyphTop, scale, dotColor); + } + + drawLabel(canvas, label, labelColor, isSelected, scale, canvasWidth, glyphTop + glyphHeight + gap); + image.dispose(); + + return {type: 'image', source: {uri: encodeSurface(surface), width: canvasWidth / scale, height: canvasHeight / scale, scale}, tinted: false}; +} + +/** + * The account tab shows the user's avatar, and a tab icon has to be a square image, so the avatar is cropped to a + * circle off-screen and handed over as a data URI. Its canvas is as tall as every other tab icon's, with the avatar + * a little larger than the glyphs next to it, since a circle reads smaller than a glyph of the same box. + */ +async function createCircularAvatarIcon(uri: string, dotColor: string | undefined, label: string, labelColor: string, isSelected: boolean): Promise { + const image = await decodeImage(uri); + const scale = variables.nativeTabIconScale; + const avatarSize = variables.avatarNativeTabBarIOS * scale; + // The avatar overhangs a glyph by the same amount above and below, so its gap to the label shrinks by + // that much and the label lands where every other tab's does. + const gap = (variables.nativeTabIconLabelGap - (variables.avatarNativeTabBarIOS - variables.iconNativeTabBarIOS) / 2) * scale; + const labelSize = measureLabel(label, isSelected, scale); + const canvasWidth = Math.ceil(Math.max(avatarSize, labelSize.width)); + const canvasHeight = Math.ceil(avatarSize + gap + labelSize.height); + const surface = Skia.Surface.MakeOffscreen(canvasWidth, canvasHeight); + + if (!image || !surface) { + image?.dispose(); + surface?.dispose(); + return undefined; + } + + const avatarLeft = (canvasWidth - avatarSize) / 2; + const canvas = surface.getCanvas(); + drawCircularImage(canvas, image, avatarLeft, 0, avatarSize); + + if (dotColor) { + drawStatusDot(canvas, avatarLeft + avatarSize, 0, scale, dotColor); + } + + drawLabel(canvas, label, labelColor, isSelected, scale, canvasWidth, avatarSize + gap); + image.dispose(); + + return {uri: encodeSurface(surface), width: canvasWidth / scale, height: canvasHeight / scale}; +} + +function toAvatarIcon(icon: AvatarTabIcon): NativeBottomTabIcon { + return {type: 'image', source: {uri: icon.uri, width: icon.width, height: icon.height, scale: variables.nativeTabIconScale}, tinted: false}; +} + +/** + * The icons of the iOS tab bar: each glyph recolored for both selection states with its label and status dot baked + * in, and the user's avatar on the account tab. `areTabIconsReady` stays false until the first recolored set exists, + * because the template icons the bar would fall back to render black on iOS 26, which ignores the inactive tint. + */ +function useIOSTabIcons(dotColors: Record) { + const {translate} = useLocalize(); + const theme = useTheme(); + const currentUserPersonalDetails = useCurrentUserPersonalDetails(); + const tabLabels: Record = { + [SCREENS.HOME]: translate('common.home'), + [NAVIGATORS.REPORTS_SPLIT_NAVIGATOR]: translate('common.inbox'), + [NAVIGATORS.SEARCH_FULLSCREEN_NAVIGATOR]: translate('common.spend'), + [SCREENS.INSIGHTS]: translate('common.insights'), + [NAVIGATORS.WORKSPACE_NAVIGATOR]: translate('common.workspacesTabTitle'), + [NAVIGATORS.SETTINGS_SPLIT_NAVIGATOR]: translate('initialSettingsPage.account'), + }; + const accountLabel = tabLabels[NAVIGATORS.SETTINGS_SPLIT_NAVIGATOR]; + const accountDotColor = dotColors[NAVIGATORS.SETTINGS_SPLIT_NAVIGATOR]; + + // The signature covers everything baked into the bitmaps, so a status that comes or goes, a theme swap or a + // language change redraws them. The label takes the supporting tone when idle and the plain text tone when + // selected, taken from the theme so light and dark each get their own pair. + const iconsSignature = [theme.icon, theme.iconMenu, theme.text, theme.textSupporting, ...Object.values(dotColors), ...Object.values(tabLabels)].join('|'); + // A failed draw still lets the bar show, on the template icons, rather than keeping it hidden. + const tintedIcons = useRasterizedTabIcons( + lastTintedIcons, + iconsSignature, + () => + drawTabIconPairs((name, source, isSelected) => + createTabIcon(source, isSelected ? theme.iconMenu : theme.icon, dotColors[name], tabLabels[name], isSelected ? theme.text : theme.textSupporting, isSelected), + ), + {}, + ); + + const avatarSource = getAvatarURL({avatarSource: currentUserPersonalDetails.avatar, accountID: currentUserPersonalDetails.accountID}); + const avatarURI = typeof avatarSource === 'string' ? avatarSource : undefined; + const avatarSignature = avatarURI && [avatarURI, accountDotColor, accountLabel, theme.textSupporting, theme.text].join('|'); + const circularAvatar = useRasterizedTabIcons(lastCircularAvatar, avatarSignature, () => { + if (!avatarURI) { + return Promise.resolve(undefined); + } + return Promise.all([ + createCircularAvatarIcon(avatarURI, accountDotColor, accountLabel, theme.textSupporting, false), + createCircularAvatarIcon(avatarURI, accountDotColor, accountLabel, theme.text, true), + ]).then(([inactive, active]) => (inactive && active ? {uri: avatarURI, inactive, active} : undefined)); + }); + + // An avatar drawn for another user is never shown, while a stale dot or label is, until the redraw lands. + const avatarPair = circularAvatar?.uri === avatarURI ? circularAvatar : undefined; + const accountAvatarIcon = avatarPair ? ({focused}: {focused: boolean}) => toAvatarIcon(focused ? avatarPair.active : avatarPair.inactive) : undefined; + + return { + getTabBarIcon: (name: keyof typeof NATIVE_TAB_ICONS) => getTabBarIcon(tintedIcons, name), + accountAvatarIcon, + areTabIconsReady: !!tintedIcons, + }; +} + +export default useIOSTabIcons; diff --git a/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/useRasterizedTabIcons.ts b/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/useRasterizedTabIcons.ts new file mode 100644 index 000000000000..0af2e3565f84 --- /dev/null +++ b/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/useRasterizedTabIcons.ts @@ -0,0 +1,46 @@ +import {useEffect, useState} from 'react'; + +/** + * The last icons drawn, held by the caller outside any component so a remounted navigator starts from them instead + * of waiting for Skia again. While a new set is being drawn, the bar keeps showing these. + */ +type LastDrawnTabIcons = {current?: T}; + +/** + * Draws tab icons off-screen again whenever the signature of everything baked into them changes, and never while + * it is undefined. A failed draw keeps the previous icons, or resolves to `fallbackOnError` when there are none. + */ +function useRasterizedTabIcons(lastDrawn: LastDrawnTabIcons, signature: string | undefined, draw: (signature: string) => Promise, fallbackOnError?: T): T | undefined { + const [icons, setIcons] = useState(lastDrawn.current); + + useEffect(() => { + let isActive = true; + if (signature !== undefined) { + draw(signature) + .then((result) => { + if (!isActive || result === undefined) { + return; + } + // eslint-disable-next-line no-param-reassign + lastDrawn.current = result; + setIcons(result); + }) + .catch(() => { + if (!isActive || fallbackOnError === undefined) { + return; + } + setIcons((previous) => previous ?? fallbackOnError); + }); + } + + return () => { + isActive = false; + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [signature]); + + return icons; +} + +export default useRasterizedTabIcons; +export type {LastDrawnTabIcons}; diff --git a/src/libs/Navigation/AppNavigator/Navigators/TabNavigator.android.tsx b/src/libs/Navigation/AppNavigator/Navigators/TabNavigator.android.tsx index dff68eb75705..dbb7db0b8846 100644 --- a/src/libs/Navigation/AppNavigator/Navigators/TabNavigator.android.tsx +++ b/src/libs/Navigation/AppNavigator/Navigators/TabNavigator.android.tsx @@ -65,14 +65,13 @@ function TabNavigator() { const {getTabBarIcon, accountAvatarIcon, areTabIconsReady} = useAndroidTabIcons(); // Colors and the label face come from the Expensify theme and match the side bar on wide layouts: the - // selected tab has the menu icon color and a bold label in the regular text color, the others the plain icon - // color and a supporting text label. The bar takes the raised surface tone with no top border. + // selected tab has a bold label in the regular text color, the others a supporting text label. The tint only + // reaches the labels, since the icons arrive already recolored (react-native-screens patch 003). The bar takes + // the raised surface tone with no top border. const screenOptions: NativeBottomTabNavigationOptions = { headerShown: false, - tabBarActiveTintColor: theme.iconMenu, - tabBarInactiveTintColor: theme.icon, - tabBarActiveLabelColor: theme.text, - tabBarInactiveLabelColor: theme.textSupporting, + tabBarActiveTintColor: theme.text, + tabBarInactiveTintColor: theme.textSupporting, tabBarLabelStyle: {fontFamily: styles.textSmall.fontFamily, fontSize: styles.textSmall.fontSize}, tabBarActiveIndicatorColor: theme.androidTabBarActiveIndicatorBG, tabBarLabelVisibilityMode: 'labeled', diff --git a/src/libs/Navigation/AppNavigator/Navigators/TabNavigator.ios.tsx b/src/libs/Navigation/AppNavigator/Navigators/TabNavigator.ios.tsx index 61be5183804f..19f5d8dae923 100644 --- a/src/libs/Navigation/AppNavigator/Navigators/TabNavigator.ios.tsx +++ b/src/libs/Navigation/AppNavigator/Navigators/TabNavigator.ios.tsx @@ -1,34 +1,24 @@ -import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; -import useLocalize from '@hooks/useLocalize'; import useTheme from '@hooks/useTheme'; import {nativeBottomTabScreenLayoutWrapper} from '@libs/Navigation/PlatformStackNavigation/ScreenLayout'; import type {TabNavigatorParamList} from '@libs/Navigation/types'; -import {getAvatarURL} from '@libs/UserAvatarUtils'; import HomePage from '@pages/home/HomePage'; import InsightsPage from '@pages/Insights/InsightsPage'; -import FontUtils from '@styles/utils/FontUtils'; import variables from '@styles/variables'; import CONST from '@src/CONST'; import NAVIGATORS from '@src/NAVIGATORS'; import SCREENS from '@src/SCREENS'; -import type {NativeBottomTabIcon} from '@react-navigation/bottom-tabs/unstable'; -import type {SkCanvas} from '@shopify/react-native-skia'; -import type {ImageSourcePropType} from 'react-native'; - import {createNativeBottomTabNavigator} from '@react-navigation/bottom-tabs/unstable'; -import {BlendMode, ClipOp, FontWeight, ImageFormat, Skia} from '@shopify/react-native-skia'; -import React, {useEffect, useState} from 'react'; -import {Image} from 'react-native'; +import React from 'react'; import type {NativeTabLayoutProps} from './NativeTabNavigator/NativeTabLayout'; -import NATIVE_TAB_ICONS from './NativeTabNavigator/NATIVE_TAB_ICONS'; import NativeTabLayout from './NativeTabNavigator/NativeTabLayout'; +import useIOSTabIcons from './NativeTabNavigator/useIOSTabIcons'; import useNativeTabNavigator from './NativeTabNavigator/useNativeTabNavigator'; import ReportsSplitNavigator from './ReportsSplitNavigator'; import SearchFullscreenNavigator from './SearchFullscreenNavigator'; @@ -41,197 +31,6 @@ import WorkspaceNavigator from './WorkspaceNavigator'; */ const Tab = createNativeBottomTabNavigator(); -/** The recolored tab icons, with the signature of everything baked into them. */ -type TintedTabIcons = {signature: string; icons: Record}; - -/** The account tab's avatar in both selection states, with the signature of everything baked into them. */ -type CircularAvatarIcons = {signature: string; uri: string; active: AvatarTabIcon; inactive: AvatarTabIcon}; - -/** - * The last icons drawn, kept outside the component so a remounted navigator starts from them instead of waiting - * for Skia again. While a new theme or language is being drawn, the bar keeps showing these. - */ -let lastTintedIcons: TintedTabIcons | undefined; -let lastCircularAvatar: CircularAvatarIcons | undefined; - -/** The recolored copies of one tab icon, one per selection state. */ -type TintedTabIconPair = {active: NativeBottomTabIcon; inactive: NativeBottomTabIcon}; - -/** The account avatar drawn with its label, and the size it came out at. */ -type AvatarTabIcon = {uri: string; width: number; height: number}; - -/** Paints the status dot in the glyph's top right corner, where a native badge would have sat. */ -function drawStatusDot(canvas: SkCanvas, glyphRight: number, glyphTop: number, scale: number, color: string) { - const radius = variables.nativeTabIconDotRadius * scale; - const paint = Skia.Paint(); - paint.setColor(Skia.Color(color)); - canvas.drawCircle(glyphRight - radius, glyphTop + radius, radius, paint); -} - -/** The label's face: Expensify Neue, bold when selected. */ -function getLabelFont(isSelected: boolean, scale: number) { - const typeface = Skia.FontMgr.System().matchFamilyStyle(FontUtils.fontFamily.single.EXP_NEUE.fontFamily, { - weight: isSelected ? FontWeight.Bold : FontWeight.Normal, - }); - return Skia.Font(typeface, variables.fontSizeSmall * scale); -} - -/** - * Draws the label under the glyph and reports how much room it took. iOS 26 discards the title color an item - * appearance carries while honoring its font, and neither `unselectedItemTintColor` nor re-asserting it after - * layout gets through, so the text is painted into the image the item is handed instead. - */ -function drawLabel(canvas: SkCanvas, label: string, color: string, isSelected: boolean, scale: number, canvasWidth: number, top: number) { - const font = getLabelFont(isSelected, scale); - const paint = Skia.Paint(); - paint.setColor(Skia.Color(color)); - paint.setAntiAlias(true); - const width = font.measureText(label).width; - const metrics = font.getMetrics(); - canvas.drawText(label, (canvasWidth - width) / 2, top - metrics.ascent, paint, font); -} - -/** Width and height the label occupies, so a canvas can be sized before anything is drawn into it. */ -function measureLabel(label: string, isSelected: boolean, scale: number) { - const font = getLabelFont(isSelected, scale); - const metrics = font.getMetrics(); - return {width: font.measureText(label).width, height: metrics.descent - metrics.ascent}; -} - -/** - * iOS 26 draws the bar's glass material itself and honors very little of what a tab item is told about its colors. - * The inactive icon color from `UITabBarItemAppearance` never arrives, and every badge is painted in the color of - * whichever tab is selected — measured to hold for `badgeBackgroundColor` and for `UITabBarItem.badgeColor` alike. - * The image handed to an item is honored, though, so the glyph is recolored off-screen and the status dot is drawn - * into it. Both selection states go through this, because RNScreens rejects a tab whose icon and selectedIcon - * differ in type. The canvas is only as big as what it holds, and the dot overlaps the glyph's top right corner - * the way the indicator on the JS bar does. - */ -async function createTabIcon( - source: ImageSourcePropType, - color: string, - dotColor: string | undefined, - label: string, - labelColor: string, - isSelected: boolean, -): Promise { - const asset = Image.resolveAssetSource(source); - if (!asset?.uri) { - return undefined; - } - - const response = await fetch(asset.uri); - const encodedImage = Skia.Data.fromBytes(new Uint8Array(await response.arrayBuffer())); - const image = Skia.Image.MakeImageFromEncoded(encodedImage); - const scale = asset.scale ?? 1; - - if (!image) { - return undefined; - } - - // The glyph assets are 24 pt and are drawn at the native bar's glyph size. - const glyphWidth = variables.iconNativeTabBarIOS * scale; - const glyphHeight = variables.iconNativeTabBarIOS * scale; - // The account avatar is taller than a glyph, so every glyph sits this far down to share its centre, and - // every label lands at the same height. - const glyphTop = ((variables.avatarNativeTabBarIOS - variables.iconNativeTabBarIOS) / 2) * scale; - const gap = variables.nativeTabIconLabelGap * scale; - const labelSize = measureLabel(label, isSelected, scale); - const canvasWidth = Math.ceil(Math.max(glyphWidth, labelSize.width)); - const canvasHeight = Math.ceil(glyphTop + glyphHeight + gap + labelSize.height); - const surface = Skia.Surface.MakeOffscreen(canvasWidth, canvasHeight); - - if (!surface) { - image.dispose(); - return undefined; - } - - const paint = Skia.Paint(); - // SrcIn keeps the glyph's alpha and replaces every colored pixel with the theme color. - paint.setColorFilter(Skia.ColorFilter.MakeBlend(Skia.Color(color), BlendMode.SrcIn)); - - const canvas = surface.getCanvas(); - const glyphLeft = (canvasWidth - glyphWidth) / 2; - canvas.drawImageRect(image, Skia.XYWHRect(0, 0, image.width(), image.height()), Skia.XYWHRect(glyphLeft, glyphTop, glyphWidth, glyphHeight), paint); - - if (dotColor) { - drawStatusDot(canvas, glyphLeft + glyphWidth, glyphTop, scale, dotColor); - } - - drawLabel(canvas, label, labelColor, isSelected, scale, canvasWidth, glyphTop + glyphHeight + gap); - - surface.flush(); - - const snapshot = surface.makeImageSnapshot(); - const base64 = snapshot.encodeToBase64(ImageFormat.PNG, 100); - - image.dispose(); - snapshot.dispose(); - surface.dispose(); - - return {type: 'image', source: {uri: `data:image/png;base64,${base64}`, width: canvasWidth / scale, height: canvasHeight / scale, scale}, tinted: false}; -} - -/** - * The account tab shows the user's avatar, and a tab icon has to be a square image, so the avatar is cropped to a - * circle off-screen and handed over as a data URI. Its canvas is as tall as every other tab icon's, with the avatar - * a little larger than the glyphs next to it, since a circle reads smaller than a glyph of the same box. - */ -async function createCircularAvatarIcon(uri: string, dotColor: string | undefined, label: string, labelColor: string, isSelected: boolean): Promise { - const response = await fetch(uri); - const encodedImage = Skia.Data.fromBytes(new Uint8Array(await response.arrayBuffer())); - const image = Skia.Image.MakeImageFromEncoded(encodedImage); - const scale = variables.nativeTabIconScale; - const avatarSize = variables.avatarNativeTabBarIOS * scale; - // The avatar overhangs a glyph by the same amount above and below, so its gap to the label shrinks by - // that much and the label lands where every other tab's does. - const gap = (variables.nativeTabIconLabelGap - (variables.avatarNativeTabBarIOS - variables.iconNativeTabBarIOS) / 2) * scale; - const labelSize = measureLabel(label, isSelected, scale); - const canvasWidth = Math.ceil(Math.max(avatarSize, labelSize.width)); - const canvasHeight = Math.ceil(avatarSize + gap + labelSize.height); - const surface = Skia.Surface.MakeOffscreen(canvasWidth, canvasHeight); - - if (!image || !surface) { - image?.dispose(); - surface?.dispose(); - return undefined; - } - - const sourceSize = Math.min(image.width(), image.height()); - const sourceX = (image.width() - sourceSize) / 2; - const sourceY = (image.height() - sourceSize) / 2; - const avatarLeft = (canvasWidth - avatarSize) / 2; - const circle = Skia.Path.Make(); - circle.addCircle(avatarLeft + avatarSize / 2, avatarSize / 2, avatarSize / 2); - - const canvas = surface.getCanvas(); - canvas.save(); - canvas.clipPath(circle, ClipOp.Intersect, true); - canvas.drawImageRect(image, Skia.XYWHRect(sourceX, sourceY, sourceSize, sourceSize), Skia.XYWHRect(avatarLeft, 0, avatarSize, avatarSize), Skia.Paint()); - canvas.restore(); - - if (dotColor) { - drawStatusDot(canvas, avatarLeft + avatarSize, 0, scale, dotColor); - } - - drawLabel(canvas, label, labelColor, isSelected, scale, canvasWidth, avatarSize + gap); - - surface.flush(); - - const snapshot = surface.makeImageSnapshot(); - const base64 = snapshot.encodeToBase64(ImageFormat.PNG, 100); - - circle.dispose(); - image.dispose(); - snapshot.dispose(); - surface.dispose(); - - return {uri: `data:image/png;base64,${base64}`, width: canvasWidth / scale, height: canvasHeight / scale}; -} - -/** Every glyph the bar draws, paired with the tab it belongs to so the tinted copies can be looked up again. */ -const TAB_ICONS = Object.entries(NATIVE_TAB_ICONS).map(([name, icon]) => [name, icon.source] as const); - const getFloatingButtonsBottom = () => variables.iosNativeTabBarFloatingButtonsBottom; const renderNativeTabLayout = (props: Omit) => ( @@ -242,131 +41,14 @@ const renderNativeTabLayout = (props: Omit = { + const {getTabBarIcon, accountAvatarIcon, areTabIconsReady} = useIOSTabIcons({ [NAVIGATORS.REPORTS_SPLIT_NAVIGATOR]: inboxDotColor, [NAVIGATORS.WORKSPACE_NAVIGATOR]: workspacesDotColor, [NAVIGATORS.SETTINGS_SPLIT_NAVIGATOR]: accountDotColor, - }; - const tabLabels: Record = { - [SCREENS.HOME]: translate('common.home'), - [NAVIGATORS.REPORTS_SPLIT_NAVIGATOR]: translate('common.inbox'), - [NAVIGATORS.SEARCH_FULLSCREEN_NAVIGATOR]: translate('common.spend'), - [SCREENS.INSIGHTS]: translate('common.insights'), - [NAVIGATORS.WORKSPACE_NAVIGATOR]: translate('common.workspacesTabTitle'), - [NAVIGATORS.SETTINGS_SPLIT_NAVIGATOR]: translate('initialSettingsPage.account'), - }; - // The signature covers everything baked into the bitmaps, so a status that comes or goes, a theme swap or a - // language change redraws them. - const iconsSignature = [theme.icon, theme.iconMenu, theme.text, theme.textSupporting, inboxDotColor, workspacesDotColor, accountDotColor, ...Object.values(tabLabels)].join('|'); - - const [tintedIcons, setTintedIcons] = useState(lastTintedIcons); - // The template icons the bar would fall back to render black on iOS 26, which ignores the inactive tint, so - // the bar is held back until the first recolored set exists. - const areTabIconsReady = !!tintedIcons; - - useEffect(() => { - let isActive = true; - const inactiveColor = theme.icon; - const activeColor = theme.iconMenu; - // The supporting tone when idle, the plain text tone when selected, taken from the theme so light and dark - // each get their own pair. - const inactiveLabelColor = theme.textSupporting; - const activeLabelColor = theme.text; - const signature = iconsSignature; - - Promise.all( - TAB_ICONS.map(([name, source]) => - Promise.all([ - createTabIcon(source, inactiveColor, dotColors[name], tabLabels[name], inactiveLabelColor, false), - createTabIcon(source, activeColor, dotColors[name], tabLabels[name], activeLabelColor, true), - ]).then(([inactive, active]) => ({ - name, - inactive, - active, - })), - ), - ) - .then((results) => { - if (!isActive) { - return; - } - const icons: Record = {}; - for (const result of results) { - if (result.inactive && result.active) { - icons[result.name] = {inactive: result.inactive, active: result.active}; - } - } - lastTintedIcons = {signature, icons}; - setTintedIcons(lastTintedIcons); - }) - .catch(() => { - // A failed draw still lets the bar show, on the template icons, rather than keeping it hidden. - if (!isActive) { - return; - } - setTintedIcons((previous) => previous ?? {signature, icons: {}}); - }); - - return () => { - isActive = false; - }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [iconsSignature]); - - /** Falls back to the template icon for a tab whose tinted copies could not be drawn, so it is never left without one. */ - const getTabBarIcon = (name: keyof typeof NATIVE_TAB_ICONS) => { - const pair = tintedIcons?.icons[name]; - if (!pair) { - return NATIVE_TAB_ICONS[name]; - } - return ({focused}: {focused: boolean}) => (focused ? pair.active : pair.inactive); - }; - - const avatarSource = getAvatarURL({ - avatarSource: currentUserPersonalDetails.avatar, - accountID: currentUserPersonalDetails.accountID, - }); - const avatarURI = typeof avatarSource === 'string' ? avatarSource : undefined; - const avatarSignature = [avatarURI, accountDotColor, tabLabels[NAVIGATORS.SETTINGS_SPLIT_NAVIGATOR], theme.textSupporting, theme.text].join('|'); - const [circularAvatar, setCircularAvatar] = useState(lastCircularAvatar); - // An avatar drawn for another user is never shown, while a stale dot or label is, until the redraw lands. - const avatarPair = circularAvatar?.uri === avatarURI ? circularAvatar : undefined; - const toAvatarIcon = (icon: AvatarTabIcon): NativeBottomTabIcon => ({ - type: 'image', - source: {uri: icon.uri, width: icon.width, height: icon.height, scale: variables.nativeTabIconScale}, - tinted: false, }); - useEffect(() => { - let isActive = true; - if (!avatarURI) { - return () => { - isActive = false; - }; - } - - const signature = avatarSignature; - const label = tabLabels[NAVIGATORS.SETTINGS_SPLIT_NAVIGATOR]; - Promise.all([createCircularAvatarIcon(avatarURI, accountDotColor, label, theme.textSupporting, false), createCircularAvatarIcon(avatarURI, accountDotColor, label, theme.text, true)]) - .then(([inactive, active]) => { - if (!isActive || !inactive || !active) { - return; - } - lastCircularAvatar = {signature, uri: avatarURI, active, inactive}; - setCircularAvatar(lastCircularAvatar); - }) - .catch(() => {}); - - return () => { - isActive = false; - }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [avatarSignature]); - const screenOptions = { headerShown: false, tabBarActiveTintColor: theme.iconMenu, @@ -427,9 +109,7 @@ function TabNavigator() { options={{ tabBarLabel: '', tabBarItemHidden: isInsightsTabVisible, - tabBarIcon: avatarPair - ? ({focused}: {focused: boolean}) => toAvatarIcon(focused ? avatarPair.active : avatarPair.inactive) - : getTabBarIcon(NAVIGATORS.SETTINGS_SPLIT_NAVIGATOR), + tabBarIcon: accountAvatarIcon ?? getTabBarIcon(NAVIGATORS.SETTINGS_SPLIT_NAVIGATOR), }} /> diff --git a/src/pages/Search/SearchPageNarrow/index.tsx b/src/pages/Search/SearchPageNarrow/index.tsx index edf7b49e5cd3..1e321b3c20e4 100644 --- a/src/pages/Search/SearchPageNarrow/index.tsx +++ b/src/pages/Search/SearchPageNarrow/index.tsx @@ -13,7 +13,6 @@ import SearchPageHeaderNarrow from '@components/Search/SearchPageHeader/SearchPa import SearchSelectionFooter from '@components/Search/SearchSelectionFooter'; import SearchWithNavigationDeferredMount from '@components/Search/SearchWithNavigationDeferredMount'; import type {SearchParams, SearchQueryJSON} from '@components/Search/types'; -import SearchRowSkeleton from '@components/Skeletons/SearchRowSkeleton'; import useAndroidBackButtonHandler from '@hooks/useAndroidBackButtonHandler'; import useEndSubmitNavigationSpans from '@hooks/useEndSubmitNavigationSpans'; @@ -344,12 +343,6 @@ function SearchPageNarrow({ hasFilterBars={hasFilterBars} /> )} - {!isInteractive && !searchOverlayContent && ( - - )} {shouldRenderLayoutProbe && } {!!searchOverlayContent && ( Date: Wed, 30 Sep 2026 16:40:30 +0200 Subject: [PATCH 27/71] clearing up pr --- .../NativeTabNavigator/NATIVE_TAB_ICONS.ts | 1 - .../NativeTabNavigator/NativeTabLayout.tsx | 24 +-- .../NativeTabNavigator/tabIconRasterizer.ts | 9 -- .../NativeTabNavigator/useAndroidTabIcons.ts | 17 +-- .../NativeTabNavigator/useIOSTabIcons.ts | 78 +++------- .../useNativeTabBarOptions/index.ios.ts | 42 ++++++ .../useNativeTabBarOptions/index.ts | 51 +++++++ .../useNativeTabBarOptions/types.ts | 7 + .../useNativeTabNavigator.ts | 36 ++--- .../useRasterizedTabIcons.ts | 10 +- .../Navigators/TAB_SCREENS.native.ts | 20 --- .../AppNavigator/Navigators/TAB_SCREENS.ts | 5 +- .../Navigators/TabNavigator.android.tsx | 140 ------------------ .../Navigators/TabNavigator.ios.tsx | 119 --------------- .../Navigators/TabNavigator.native.tsx | 83 +++++++++++ .../AppNavigator/Navigators/TabNavigator.tsx | 8 +- .../Navigators/TabNavigatorBar.tsx | 3 +- .../PlatformStackNavigation/ScreenLayout.tsx | 23 +-- src/styles/variables.ts | 2 +- .../buildTabNavigatorNestedStateTests.ts | 27 ++-- 20 files changed, 266 insertions(+), 439 deletions(-) create mode 100644 src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/useNativeTabBarOptions/index.ios.ts create mode 100644 src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/useNativeTabBarOptions/index.ts create mode 100644 src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/useNativeTabBarOptions/types.ts delete mode 100644 src/libs/Navigation/AppNavigator/Navigators/TAB_SCREENS.native.ts delete mode 100644 src/libs/Navigation/AppNavigator/Navigators/TabNavigator.android.tsx delete mode 100644 src/libs/Navigation/AppNavigator/Navigators/TabNavigator.ios.tsx create mode 100644 src/libs/Navigation/AppNavigator/Navigators/TabNavigator.native.tsx diff --git a/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/NATIVE_TAB_ICONS.ts b/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/NATIVE_TAB_ICONS.ts index c462369d097e..6e9db9a6eacf 100644 --- a/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/NATIVE_TAB_ICONS.ts +++ b/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/NATIVE_TAB_ICONS.ts @@ -10,7 +10,6 @@ import SCREENS from '@src/SCREENS'; import type {NativeBottomTabIcon} from '@react-navigation/bottom-tabs/unstable'; -/** The glyph each native tab draws, keyed by the tab's route name. The bar tints them for both selection states. */ const NATIVE_TAB_ICONS = { [SCREENS.HOME]: {type: 'image', source: homeIcon}, [NAVIGATORS.REPORTS_SPLIT_NAVIGATOR]: {type: 'image', source: inboxIcon}, diff --git a/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/NativeTabLayout.tsx b/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/NativeTabLayout.tsx index e6f5048de235..60de5f48e070 100644 --- a/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/NativeTabLayout.tsx +++ b/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/NativeTabLayout.tsx @@ -1,7 +1,6 @@ import FloatingCameraButton from '@components/FloatingCameraButton'; import FloatingGPSButton from '@components/FloatingGPSButton'; import DebugTabView from '@components/Navigation/DebugTabView'; -import NavigationTabBar from '@components/Navigation/NavigationTabBar'; import NAVIGATION_TABS from '@components/Navigation/NavigationTabBar/NAVIGATION_TABS'; import ROUTE_TO_NAVIGATION_TAB from '@components/Navigation/NavigationTabBar/ROUTE_TO_NAVIGATION_TAB'; @@ -10,6 +9,8 @@ import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useSafeAreaInsets from '@hooks/useSafeAreaInsets'; import useThemeStyles from '@hooks/useThemeStyles'; +import TabNavigatorBar from '@libs/Navigation/AppNavigator/Navigators/TabNavigatorBar'; + import NavigationTabBarFloatingActionButton from '@pages/inbox/sidebar/NavigationTabBarFloatingActionButton'; import CONST from '@src/CONST'; @@ -22,17 +23,11 @@ import React from 'react'; import {View} from 'react-native'; import Animated, {FadeIn, FadeOut} from 'react-native-reanimated'; -type NativeTabLayoutProps = Parameters>[0] & { - /** Distance from the bottom edge of the screen to the strip the floating buttons hang from, given the bottom safe area inset. */ - getFloatingButtonsBottom: (bottomInset: number) => number; -}; +import {getFloatingButtonsBottom} from './useNativeTabBarOptions'; + +type NativeTabLayoutProps = Parameters>[0]; -/** - * Wraps the tab screens so the floating buttons, the debug view and the wide-layout side bar can be drawn over - * them. The native bar is part of the navigator itself, so it is switched off through `tabBarStyle` in the - * navigator's screen options instead of being unmounted. - */ -function NativeTabLayout({children, state, descriptors, getFloatingButtonsBottom}: NativeTabLayoutProps) { +function NativeTabLayout({children, state, descriptors}: NativeTabLayoutProps) { const {shouldUseNarrowLayout} = useResponsiveLayout(); const [isDebugModeEnabled] = useOnyx(ONYXKEYS.IS_DEBUG_MODE_ENABLED); const styles = useThemeStyles(); @@ -45,12 +40,7 @@ function NativeTabLayout({children, state, descriptors, getFloatingButtonsBottom if (!shouldUseNarrowLayout) { return ( - - - + {children} ); diff --git a/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/tabIconRasterizer.ts b/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/tabIconRasterizer.ts index 2930b251442f..ce6df5edf964 100644 --- a/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/tabIconRasterizer.ts +++ b/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/tabIconRasterizer.ts @@ -7,19 +7,15 @@ import {Image} from 'react-native'; import NATIVE_TAB_ICONS from './NATIVE_TAB_ICONS'; -/** The recolored copies of one glyph, one per selection state. */ type TabIconPair = {active: NativeBottomTabIcon; inactive: NativeBottomTabIcon}; -/** Every glyph the bar draws, paired with the tab it belongs to so the recolored copies can be looked up again. */ const TAB_ICONS = Object.entries(NATIVE_TAB_ICONS).map(([name, icon]) => [name, icon.source] as const); -/** Downloads and decodes an image, or resolves to null when it cannot be decoded. */ async function decodeImage(uri: string): Promise { const response = await fetch(uri); return Skia.Image.MakeImageFromEncoded(Skia.Data.fromBytes(new Uint8Array(await response.arrayBuffer()))); } -/** Decodes a bundled glyph together with the pixel density it was resolved at. */ async function decodeGlyph(source: ImageSourcePropType): Promise<{image: SkImage; scale: number} | undefined> { const asset = Image.resolveAssetSource(source); if (!asset?.uri) { @@ -29,14 +25,12 @@ async function decodeGlyph(source: ImageSourcePropType): Promise<{image: SkImage return image ? {image, scale: asset.scale ?? 1} : undefined; } -/** A paint that keeps the glyph's alpha and replaces every colored pixel with the given color. */ function createTintPaint(color: string) { const paint = Skia.Paint(); paint.setColorFilter(Skia.ColorFilter.MakeBlend(Skia.Color(color), BlendMode.SrcIn)); return paint; } -/** Draws the centred square of the image, cropped to a circle of the given size. */ function drawCircularImage(canvas: SkCanvas, image: SkImage, left: number, top: number, size: number) { const sourceSize = Math.min(image.width(), image.height()); const circle = Skia.Path.Make(); @@ -54,7 +48,6 @@ function drawCircularImage(canvas: SkCanvas, image: SkImage, left: number, top: circle.dispose(); } -/** Encodes what was drawn on the surface as a PNG data URI, which a tab icon can take, and disposes the surface. */ function encodeSurface(surface: SkSurface): string { surface.flush(); const snapshot = surface.makeImageSnapshot(); @@ -64,7 +57,6 @@ function encodeSurface(surface: SkSurface): string { return `data:image/png;base64,${base64}`; } -/** Draws both selection states of every glyph, leaving out a tab whose copies could not be drawn. */ async function drawTabIconPairs( drawIcon: (name: string, source: ImageSourcePropType, isSelected: boolean) => Promise, ): Promise> { @@ -80,7 +72,6 @@ async function drawTabIconPairs( return icons; } -/** The tab's recolored icon for its selection state, or the plain glyph for a tab whose copies could not be drawn. */ function getTabBarIcon(icons: Record | undefined, name: keyof typeof NATIVE_TAB_ICONS) { const pair = icons?.[name]; if (!pair) { diff --git a/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/useAndroidTabIcons.ts b/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/useAndroidTabIcons.ts index 8470d3776f9f..a55c798ba91b 100644 --- a/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/useAndroidTabIcons.ts +++ b/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/useAndroidTabIcons.ts @@ -21,9 +21,8 @@ const lastTintedIcons: LastDrawnTabIcons> = {}; const lastCircularAvatar: LastDrawnTabIcons<{uri: string; icon: NativeBottomTabIcon}> = {}; /** - * Material draws the icons without a tint (react-native-screens patch 003), so each glyph is recolored off-screen - * in the theme color of its selection state. The canvas is the glyph's own size, so Android's fixed icon slot - * draws it at full size. + * Material draws the icons without a tint (react-native-screens patch 003), so each glyph is recolored here. The + * canvas is the glyph's own size, so Android's fixed icon slot draws it at full size. */ async function createTintedGlyph(source: ImageSourcePropType, color: string): Promise { const glyph = await decodeGlyph(source); @@ -47,11 +46,7 @@ async function createTintedGlyph(source: ImageSourcePropType, color: string): Pr return {type: 'image', source: {uri: encodeSurface(surface), width: canvasWidth / scale, height: canvasHeight / scale, scale}}; } -/** - * The account tab shows the user's avatar, and a tab icon has to be a square image, so the avatar is cropped to a - * circle off-screen and handed over as a data URI, at the size of the glyphs next to it. The selected tab is - * marked by the active indicator pill, so one image serves both selection states. - */ +/** The active indicator pill marks the selected tab, so one image serves both selection states. */ async function createCircularAvatarIcon(uri: string): Promise { const image = await decodeImage(uri); const scale = variables.nativeTabIconScale; @@ -70,11 +65,7 @@ async function createCircularAvatarIcon(uri: string): Promise> = {}; const lastCircularAvatar: LastDrawnTabIcons = {}; -/** Paints the status dot in the glyph's top right corner, where a native badge would have sat. */ function drawStatusDot(canvas: SkCanvas, glyphRight: number, glyphTop: number, scale: number, color: string) { const radius = variables.nativeTabIconDotRadius * scale; const paint = Skia.Paint(); @@ -40,7 +34,6 @@ function drawStatusDot(canvas: SkCanvas, glyphRight: number, glyphTop: number, s canvas.drawCircle(glyphRight - radius, glyphTop + radius, radius, paint); } -/** The label's face: Expensify Neue, bold when selected. */ function getLabelFont(isSelected: boolean, scale: number) { const typeface = Skia.FontMgr.System().matchFamilyStyle(FontUtils.fontFamily.single.EXP_NEUE.fontFamily, { weight: isSelected ? FontWeight.Bold : FontWeight.Normal, @@ -49,9 +42,8 @@ function getLabelFont(isSelected: boolean, scale: number) { } /** - * Draws the label under the glyph and reports how much room it took. iOS 26 discards the title color an item - * appearance carries while honoring its font, and neither `unselectedItemTintColor` nor re-asserting it after - * layout gets through, so the text is painted into the image the item is handed instead. + * iOS 26 discards the title color an item appearance carries while honoring its font, and neither + * `unselectedItemTintColor` nor re-asserting it after layout gets through, so the label is painted into the icon. */ function drawLabel(canvas: SkCanvas, label: string, color: string, isSelected: boolean, scale: number, canvasWidth: number, top: number) { const font = getLabelFont(isSelected, scale); @@ -63,7 +55,6 @@ function drawLabel(canvas: SkCanvas, label: string, color: string, isSelected: b canvas.drawText(label, (canvasWidth - width) / 2, top - metrics.ascent, paint, font); } -/** Width and height the label occupies, so a canvas can be sized before anything is drawn into it. */ function measureLabel(label: string, isSelected: boolean, scale: number) { const font = getLabelFont(isSelected, scale); const metrics = font.getMetrics(); @@ -71,13 +62,10 @@ function measureLabel(label: string, isSelected: boolean, scale: number) { } /** - * iOS 26 draws the bar's glass material itself and honors very little of what a tab item is told about its colors. - * The inactive icon color from `UITabBarItemAppearance` never arrives, and every badge is painted in the color of - * whichever tab is selected — measured to hold for `badgeBackgroundColor` and for `UITabBarItem.badgeColor` alike. - * The image handed to an item is honored, though, so the glyph is recolored off-screen and the status dot is drawn - * into it. Both selection states go through this, because RNScreens rejects a tab whose icon and selectedIcon - * differ in type. The canvas is only as big as what it holds, and the dot overlaps the glyph's top right corner - * the way the indicator on the JS bar does. + * iOS 26 never applies the inactive icon color from `UITabBarItemAppearance`, and paints every badge in the color of + * the selected tab (both `badgeBackgroundColor` and `UITabBarItem.badgeColor`), so the glyph is recolored and the + * status dot drawn into the image. Both selection states go through this, because RNScreens rejects a tab whose + * icon and selectedIcon differ in type. */ async function createTabIcon( source: ImageSourcePropType, @@ -93,16 +81,14 @@ async function createTabIcon( } const {image, scale} = glyph; - // The glyph assets are 24 pt and are drawn at the native bar's glyph size. - const glyphWidth = variables.iconNativeTabBarIOS * scale; - const glyphHeight = variables.iconNativeTabBarIOS * scale; + const glyphSize = variables.iconNativeTabBarIOS * scale; // The account avatar is taller than a glyph, so every glyph sits this far down to share its centre, and // every label lands at the same height. const glyphTop = ((variables.avatarNativeTabBarIOS - variables.iconNativeTabBarIOS) / 2) * scale; const gap = variables.nativeTabIconLabelGap * scale; const labelSize = measureLabel(label, isSelected, scale); - const canvasWidth = Math.ceil(Math.max(glyphWidth, labelSize.width)); - const canvasHeight = Math.ceil(glyphTop + glyphHeight + gap + labelSize.height); + const canvasWidth = Math.ceil(Math.max(glyphSize, labelSize.width)); + const canvasHeight = Math.ceil(glyphTop + glyphSize + gap + labelSize.height); const surface = Skia.Surface.MakeOffscreen(canvasWidth, canvasHeight); if (!surface) { @@ -111,25 +97,21 @@ async function createTabIcon( } const canvas = surface.getCanvas(); - const glyphLeft = (canvasWidth - glyphWidth) / 2; - canvas.drawImageRect(image, Skia.XYWHRect(0, 0, image.width(), image.height()), Skia.XYWHRect(glyphLeft, glyphTop, glyphWidth, glyphHeight), createTintPaint(color)); + const glyphLeft = (canvasWidth - glyphSize) / 2; + canvas.drawImageRect(image, Skia.XYWHRect(0, 0, image.width(), image.height()), Skia.XYWHRect(glyphLeft, glyphTop, glyphSize, glyphSize), createTintPaint(color)); if (dotColor) { - drawStatusDot(canvas, glyphLeft + glyphWidth, glyphTop, scale, dotColor); + drawStatusDot(canvas, glyphLeft + glyphSize, glyphTop, scale, dotColor); } - drawLabel(canvas, label, labelColor, isSelected, scale, canvasWidth, glyphTop + glyphHeight + gap); + drawLabel(canvas, label, labelColor, isSelected, scale, canvasWidth, glyphTop + glyphSize + gap); image.dispose(); return {type: 'image', source: {uri: encodeSurface(surface), width: canvasWidth / scale, height: canvasHeight / scale, scale}, tinted: false}; } -/** - * The account tab shows the user's avatar, and a tab icon has to be a square image, so the avatar is cropped to a - * circle off-screen and handed over as a data URI. Its canvas is as tall as every other tab icon's, with the avatar - * a little larger than the glyphs next to it, since a circle reads smaller than a glyph of the same box. - */ -async function createCircularAvatarIcon(uri: string, dotColor: string | undefined, label: string, labelColor: string, isSelected: boolean): Promise { +/** A circle reads smaller than a glyph of the same box, so the avatar is drawn a little larger than the glyphs. */ +async function createCircularAvatarIcon(uri: string, dotColor: string | undefined, label: string, labelColor: string, isSelected: boolean): Promise { const image = await decodeImage(uri); const scale = variables.nativeTabIconScale; const avatarSize = variables.avatarNativeTabBarIOS * scale; @@ -158,36 +140,20 @@ async function createCircularAvatarIcon(uri: string, dotColor: string | undefine drawLabel(canvas, label, labelColor, isSelected, scale, canvasWidth, avatarSize + gap); image.dispose(); - return {uri: encodeSurface(surface), width: canvasWidth / scale, height: canvasHeight / scale}; -} - -function toAvatarIcon(icon: AvatarTabIcon): NativeBottomTabIcon { - return {type: 'image', source: {uri: icon.uri, width: icon.width, height: icon.height, scale: variables.nativeTabIconScale}, tinted: false}; + return {type: 'image', source: {uri: encodeSurface(surface), width: canvasWidth / scale, height: canvasHeight / scale, scale}, tinted: false}; } /** - * The icons of the iOS tab bar: each glyph recolored for both selection states with its label and status dot baked - * in, and the user's avatar on the account tab. `areTabIconsReady` stays false until the first recolored set exists, - * because the template icons the bar would fall back to render black on iOS 26, which ignores the inactive tint. + * `areTabIconsReady` stays false until the first recolored set exists, because the template icons the bar would + * fall back to render black on iOS 26, which ignores the inactive tint. */ -function useIOSTabIcons(dotColors: Record) { - const {translate} = useLocalize(); +function useIOSTabIcons(dotColors: Record, tabLabels: Record) { const theme = useTheme(); const currentUserPersonalDetails = useCurrentUserPersonalDetails(); - const tabLabels: Record = { - [SCREENS.HOME]: translate('common.home'), - [NAVIGATORS.REPORTS_SPLIT_NAVIGATOR]: translate('common.inbox'), - [NAVIGATORS.SEARCH_FULLSCREEN_NAVIGATOR]: translate('common.spend'), - [SCREENS.INSIGHTS]: translate('common.insights'), - [NAVIGATORS.WORKSPACE_NAVIGATOR]: translate('common.workspacesTabTitle'), - [NAVIGATORS.SETTINGS_SPLIT_NAVIGATOR]: translate('initialSettingsPage.account'), - }; const accountLabel = tabLabels[NAVIGATORS.SETTINGS_SPLIT_NAVIGATOR]; const accountDotColor = dotColors[NAVIGATORS.SETTINGS_SPLIT_NAVIGATOR]; - // The signature covers everything baked into the bitmaps, so a status that comes or goes, a theme swap or a - // language change redraws them. The label takes the supporting tone when idle and the plain text tone when - // selected, taken from the theme so light and dark each get their own pair. + // Covers everything baked into the bitmaps, so a status, theme or language change redraws them. const iconsSignature = [theme.icon, theme.iconMenu, theme.text, theme.textSupporting, ...Object.values(dotColors), ...Object.values(tabLabels)].join('|'); // A failed draw still lets the bar show, on the template icons, rather than keeping it hidden. const tintedIcons = useRasterizedTabIcons( @@ -215,7 +181,7 @@ function useIOSTabIcons(dotColors: Record) { // An avatar drawn for another user is never shown, while a stale dot or label is, until the redraw lands. const avatarPair = circularAvatar?.uri === avatarURI ? circularAvatar : undefined; - const accountAvatarIcon = avatarPair ? ({focused}: {focused: boolean}) => toAvatarIcon(focused ? avatarPair.active : avatarPair.inactive) : undefined; + const accountAvatarIcon = avatarPair ? ({focused}: {focused: boolean}) => (focused ? avatarPair.active : avatarPair.inactive) : undefined; return { getTabBarIcon: (name: keyof typeof NATIVE_TAB_ICONS) => getTabBarIcon(tintedIcons, 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 new file mode 100644 index 000000000000..7a90d8ecda07 --- /dev/null +++ b/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/useNativeTabBarOptions/index.ios.ts @@ -0,0 +1,42 @@ +import useTheme from '@hooks/useTheme'; + +import type NATIVE_TAB_ICONS from '@libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/NATIVE_TAB_ICONS'; +import useIOSTabIcons from '@libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/useIOSTabIcons'; + +import variables from '@styles/variables'; + +import NAVIGATORS from '@src/NAVIGATORS'; + +import type {NativeBottomTabNavigationOptions} from '@react-navigation/bottom-tabs/unstable'; + +import type NativeTabBarOptionsParams from './types'; + +const getFloatingButtonsBottom = () => variables.iosNativeTabBarFloatingButtonsBottom; + +function useNativeTabBarOptions({shouldShowNativeTabBar, dotColors, tabLabels}: NativeTabBarOptionsParams) { + const theme = useTheme(); + const {getTabBarIcon, accountAvatarIcon, areTabIconsReady} = useIOSTabIcons(dotColors, tabLabels); + + const screenOptions: NativeBottomTabNavigationOptions = { + headerShown: false, + // The labels are drawn into the icons. + tabBarLabel: '', + tabBarActiveTintColor: theme.iconMenu, + tabBarInactiveTintColor: theme.icon, + // Every tab shares one style, so the bar reads the current visibility in the same render that changed it. + // The background only lands on iOS 18 and below; iOS 26 keeps its own glass material. + tabBarStyle: {display: shouldShowNativeTabBar && areTabIconsReady ? 'flex' : 'none', backgroundColor: theme.appBG}, + tabBarControllerMode: 'tabBar', + // The bar stays put while the content scrolls, instead of collapsing the way iOS 26 does by default. + tabBarMinimizeBehavior: 'none', + }; + + const getTabOptions = (name: keyof typeof NATIVE_TAB_ICONS): NativeBottomTabNavigationOptions => ({ + tabBarIcon: name === NAVIGATORS.SETTINGS_SPLIT_NAVIGATOR && accountAvatarIcon ? accountAvatarIcon : getTabBarIcon(name), + }); + + return {screenOptions, getTabOptions}; +} + +export default useNativeTabBarOptions; +export {getFloatingButtonsBottom}; diff --git a/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/useNativeTabBarOptions/index.ts b/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/useNativeTabBarOptions/index.ts new file mode 100644 index 000000000000..0ae89e4311b5 --- /dev/null +++ b/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/useNativeTabBarOptions/index.ts @@ -0,0 +1,51 @@ +import useTheme from '@hooks/useTheme'; +import useThemeStyles from '@hooks/useThemeStyles'; + +import type NATIVE_TAB_ICONS from '@libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/NATIVE_TAB_ICONS'; +import useAndroidTabIcons from '@libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/useAndroidTabIcons'; + +import variables from '@styles/variables'; + +import NAVIGATORS from '@src/NAVIGATORS'; + +import type {NativeBottomTabNavigationOptions} from '@react-navigation/bottom-tabs/unstable'; + +import type NativeTabBarOptionsParams from './types'; + +/** An empty badge value makes Material draw its small dot badge instead of a number. */ +const STATUS_DOT_BADGE = ''; + +/** Material's bar sits above the system gesture inset, so the floating buttons clear both. */ +const getFloatingButtonsBottom = (bottomInset: number) => variables.androidNativeTabBarFloatingButtonsBottom + bottomInset; + +function useNativeTabBarOptions({shouldShowNativeTabBar, dotColors, tabLabels}: NativeTabBarOptionsParams) { + const theme = useTheme(); + const styles = useThemeStyles(); + const {getTabBarIcon, accountAvatarIcon, areTabIconsReady} = useAndroidTabIcons(); + + // The tint only reaches the labels, since the icons arrive already recolored (react-native-screens patch 003). + const screenOptions: NativeBottomTabNavigationOptions = { + headerShown: false, + tabBarActiveTintColor: theme.text, + tabBarInactiveTintColor: theme.textSupporting, + tabBarLabelStyle: {fontFamily: styles.textSmall.fontFamily, fontSize: styles.textSmall.fontSize}, + tabBarActiveIndicatorColor: theme.androidTabBarActiveIndicatorBG, + tabBarLabelVisibilityMode: 'labeled', + // Every tab shares one style, so the bar reads the current visibility in the same render that changed it. + tabBarStyle: {display: shouldShowNativeTabBar && areTabIconsReady ? 'flex' : 'none', backgroundColor: theme.highlightBG}, + }; + + const getTabOptions = (name: keyof typeof NATIVE_TAB_ICONS): NativeBottomTabNavigationOptions => { + const dotColor = dotColors[name]; + return { + tabBarLabel: tabLabels[name], + tabBarIcon: name === NAVIGATORS.SETTINGS_SPLIT_NAVIGATOR && accountAvatarIcon ? accountAvatarIcon : getTabBarIcon(name), + ...(dotColor ? {tabBarBadge: STATUS_DOT_BADGE, tabBarBadgeStyle: {backgroundColor: dotColor}} : {tabBarBadge: undefined}), + }; + }; + + return {screenOptions, getTabOptions}; +} + +export default useNativeTabBarOptions; +export {getFloatingButtonsBottom}; diff --git a/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/useNativeTabBarOptions/types.ts b/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/useNativeTabBarOptions/types.ts new file mode 100644 index 000000000000..2728ae0e274a --- /dev/null +++ b/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/useNativeTabBarOptions/types.ts @@ -0,0 +1,7 @@ +type NativeTabBarOptionsParams = { + shouldShowNativeTabBar: boolean; + dotColors: Record; + tabLabels: Record; +}; + +export default NativeTabBarOptionsParams; diff --git a/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/useNativeTabNavigator.ts b/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/useNativeTabNavigator.ts index 8a48826fff1a..d19fb99f5aaf 100644 --- a/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/useNativeTabNavigator.ts +++ b/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/useNativeTabNavigator.ts @@ -3,6 +3,7 @@ import NAVIGATION_TABS from '@components/Navigation/NavigationTabBar/NAVIGATION_ import ROUTE_TO_NAVIGATION_TAB from '@components/Navigation/NavigationTabBar/ROUTE_TO_NAVIGATION_TAB'; import useAccountTabIndicatorStatus from '@hooks/useAccountTabIndicatorStatus'; +import useLocalize from '@hooks/useLocalize'; import usePermissions from '@hooks/usePermissions'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import {useSidebarOrderedReportsState} from '@hooks/useSidebarOrderedReports'; @@ -10,10 +11,11 @@ import useTheme from '@hooks/useTheme'; import useWorkspacesTabIndicatorStatus from '@hooks/useWorkspacesTabIndicatorStatus'; import {getPreservedNavigatorState, setPreservedNavigatorState} from '@libs/Navigation/AppNavigator/createSplitNavigator/usePreserveNavigatorState'; +import {NAVIGATION_TAB_TO_SPANS} from '@libs/Navigation/AppNavigator/Navigators/TabNavigatorBar'; import isTabRouteAtRoot from '@libs/Navigation/helpers/isTabRouteAtRoot'; import Navigation from '@libs/Navigation/Navigation'; import type {TabNavigatorParamList} from '@libs/Navigation/types'; -import cancelTabNavigationSpans, {INBOX_TAB_SPAN_IDS, REPORTS_TAB_SPAN_IDS} from '@libs/telemetry/cancelTabNavigationSpans'; +import cancelTabNavigationSpans from '@libs/telemetry/cancelTabNavigationSpans'; import CONST from '@src/CONST'; import NAVIGATORS from '@src/NAVIGATORS'; @@ -21,13 +23,10 @@ import ROUTES from '@src/ROUTES'; import SCREENS from '@src/SCREENS'; import type {NavigationAction, NavigationState, PartialState, Router, TabNavigationState} from '@react-navigation/native'; -import type {ValueOf} from 'type-fest'; import {findFocusedRoute, useNavigation, useNavigationState, useRoute} from '@react-navigation/native'; import {useEffect} from 'react'; -import tabScreenListeners from './tabScreenListeners'; - /** * Root-level tab screens where the swipe-back gesture should be disabled. * Swiping from these screens would pop the entire TAB_NAVIGATOR, which feels wrong. @@ -35,26 +34,16 @@ import tabScreenListeners from './tabScreenListeners'; */ const TAB_ROOT_SCREENS_WITHOUT_GESTURE = new Set([SCREENS.HOME, SCREENS.INBOX, SCREENS.SEARCH.ROOT, SCREENS.INSIGHTS, SCREENS.SETTINGS.ROOT]); -const NAVIGATION_TAB_TO_SPANS: Partial, readonly string[]>> = { - [NAVIGATION_TABS.INBOX]: INBOX_TAB_SPAN_IDS, - [NAVIGATION_TABS.SEARCH]: REPORTS_TAB_SPAN_IDS, -}; - /** stale === false distinguishes a fully realized NavigationState from a PartialState. */ function isRealizedNavigationState(state: NavigationState | PartialState | undefined): state is NavigationState { return state?.stale === false; } -/** - * The state both native tab navigators share: whether the native bar shows, the status dot color of each tab that - * has one, whether Insights or Account gets the last tab item, and a router override that restores the tab state after the navigator is remounted. It also keeps the - * parent stack's swipe-back gesture, the preserved tab state and the tab-navigation spans in sync with the focused tab, - * and returns the screen listeners that start those spans on a native bar tap. - */ function useNativeTabNavigator() { const {shouldUseNarrowLayout} = useResponsiveLayout(); const {isBlockingViewVisible} = useFullScreenBlockingViewState(); const {isBetaEnabled} = usePermissions(); + const {translate} = useLocalize(); // With the Insights beta, Insights takes the Account tab's place in the bar and Account moves to the top bar. const isInsightsTabVisible = isBetaEnabled(CONST.BETAS.INSIGHTS_PAGE); const theme = useTheme(); @@ -79,8 +68,19 @@ function useNativeTabNavigator() { if (chatTabBrickRoad) { inboxDotColor = chatTabBrickRoad === CONST.BRICK_ROAD_INDICATOR_STATUS.INFO ? theme.iconSuccessFill : theme.danger; } - const workspacesDotColor = workspacesIndicatorStatus ? workspacesIndicatorColor : undefined; - const accountDotColor = accountIndicatorStatus ? accountIndicatorColor : undefined; + const dotColors: Record = { + [NAVIGATORS.REPORTS_SPLIT_NAVIGATOR]: inboxDotColor, + [NAVIGATORS.WORKSPACE_NAVIGATOR]: workspacesIndicatorStatus ? workspacesIndicatorColor : undefined, + [NAVIGATORS.SETTINGS_SPLIT_NAVIGATOR]: accountIndicatorStatus ? accountIndicatorColor : undefined, + }; + const tabLabels: Record = { + [SCREENS.HOME]: translate('common.home'), + [NAVIGATORS.REPORTS_SPLIT_NAVIGATOR]: translate('common.inbox'), + [NAVIGATORS.SEARCH_FULLSCREEN_NAVIGATOR]: translate('common.spend'), + [SCREENS.INSIGHTS]: translate('common.insights'), + [NAVIGATORS.WORKSPACE_NAVIGATOR]: translate('common.workspacesTabTitle'), + [NAVIGATORS.SETTINGS_SPLIT_NAVIGATOR]: translate('initialSettingsPage.account'), + }; useEffect(() => { if (!shouldUseNarrowLayout || !parentNavigation) { @@ -126,7 +126,7 @@ function useNativeTabNavigator() { }, }); - return {shouldShowNativeTabBar, inboxDotColor, workspacesDotColor, accountDotColor, isInsightsTabVisible, tabRouterOverride, tabScreenListeners}; + return {shouldShowNativeTabBar, dotColors, tabLabels, isInsightsTabVisible, tabRouterOverride}; } export default useNativeTabNavigator; diff --git a/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/useRasterizedTabIcons.ts b/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/useRasterizedTabIcons.ts index 0af2e3565f84..596a606bc4ec 100644 --- a/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/useRasterizedTabIcons.ts +++ b/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/useRasterizedTabIcons.ts @@ -1,15 +1,9 @@ import {useEffect, useState} from 'react'; -/** - * The last icons drawn, held by the caller outside any component so a remounted navigator starts from them instead - * of waiting for Skia again. While a new set is being drawn, the bar keeps showing these. - */ +/** Held outside any component, so a remounted navigator starts from the last icons instead of waiting for Skia. */ type LastDrawnTabIcons = {current?: T}; -/** - * Draws tab icons off-screen again whenever the signature of everything baked into them changes, and never while - * it is undefined. A failed draw keeps the previous icons, or resolves to `fallbackOnError` when there are none. - */ +/** A failed draw keeps the previous icons, or resolves to `fallbackOnError` when there are none. */ function useRasterizedTabIcons(lastDrawn: LastDrawnTabIcons, signature: string | undefined, draw: (signature: string) => Promise, fallbackOnError?: T): T | undefined { const [icons, setIcons] = useState(lastDrawn.current); diff --git a/src/libs/Navigation/AppNavigator/Navigators/TAB_SCREENS.native.ts b/src/libs/Navigation/AppNavigator/Navigators/TAB_SCREENS.native.ts deleted file mode 100644 index 6fa999c662e8..000000000000 --- a/src/libs/Navigation/AppNavigator/Navigators/TAB_SCREENS.native.ts +++ /dev/null @@ -1,20 +0,0 @@ -/** - * Ordered list of screen names registered inside TabNavigator. - * This must match the Tab.Screen order in TabNavigator.ios.tsx and TabNavigator.android.tsx, where the order is also the order the native - * tab bar draws its items in. Insights and Account never show together: the bar draws Home, Inbox, Spend, Insights, Workspaces with - * the Insights beta and Home, Inbox, Spend, Workspaces, Account without it. - * Used by getAdaptedStateFromPath to build complete tab navigator state for deep-links. - */ -import NAVIGATORS from '@src/NAVIGATORS'; -import SCREENS from '@src/SCREENS'; - -const TAB_SCREENS = [ - SCREENS.HOME, - NAVIGATORS.REPORTS_SPLIT_NAVIGATOR, - NAVIGATORS.SEARCH_FULLSCREEN_NAVIGATOR, - SCREENS.INSIGHTS, - NAVIGATORS.WORKSPACE_NAVIGATOR, - NAVIGATORS.SETTINGS_SPLIT_NAVIGATOR, -] as const; - -export default TAB_SCREENS; diff --git a/src/libs/Navigation/AppNavigator/Navigators/TAB_SCREENS.ts b/src/libs/Navigation/AppNavigator/Navigators/TAB_SCREENS.ts index 84825c7babaa..cbb02a5c88d8 100644 --- a/src/libs/Navigation/AppNavigator/Navigators/TAB_SCREENS.ts +++ b/src/libs/Navigation/AppNavigator/Navigators/TAB_SCREENS.ts @@ -1,6 +1,7 @@ /** * Ordered list of screen names registered inside TabNavigator. - * This must match the Tab.Screen order in TabNavigator.tsx. + * This must match the Tab.Screen order in TabNavigator.tsx and TabNavigator.native.tsx, where it is also the order + * the native tab bar draws its items in. * Used by getAdaptedStateFromPath to build complete tab navigator state for deep-links. */ import NAVIGATORS from '@src/NAVIGATORS'; @@ -11,8 +12,8 @@ const TAB_SCREENS = [ NAVIGATORS.REPORTS_SPLIT_NAVIGATOR, NAVIGATORS.SEARCH_FULLSCREEN_NAVIGATOR, SCREENS.INSIGHTS, - NAVIGATORS.SETTINGS_SPLIT_NAVIGATOR, NAVIGATORS.WORKSPACE_NAVIGATOR, + NAVIGATORS.SETTINGS_SPLIT_NAVIGATOR, ] as const; export default TAB_SCREENS; diff --git a/src/libs/Navigation/AppNavigator/Navigators/TabNavigator.android.tsx b/src/libs/Navigation/AppNavigator/Navigators/TabNavigator.android.tsx deleted file mode 100644 index dbb7db0b8846..000000000000 --- a/src/libs/Navigation/AppNavigator/Navigators/TabNavigator.android.tsx +++ /dev/null @@ -1,140 +0,0 @@ -import useLocalize from '@hooks/useLocalize'; -import useTheme from '@hooks/useTheme'; -import useThemeStyles from '@hooks/useThemeStyles'; - -import {nativeBottomTabScreenLayoutWrapper} from '@libs/Navigation/PlatformStackNavigation/ScreenLayout'; -import type {TabNavigatorParamList} from '@libs/Navigation/types'; - -import HomePage from '@pages/home/HomePage'; -import InsightsPage from '@pages/Insights/InsightsPage'; - -import variables from '@styles/variables'; - -import CONST from '@src/CONST'; -import NAVIGATORS from '@src/NAVIGATORS'; -import SCREENS from '@src/SCREENS'; - -import type {NativeBottomTabNavigationOptions} from '@react-navigation/bottom-tabs/unstable'; - -import {createNativeBottomTabNavigator} from '@react-navigation/bottom-tabs/unstable'; -import React from 'react'; - -import type {NativeTabLayoutProps} from './NativeTabNavigator/NativeTabLayout'; - -import NativeTabLayout from './NativeTabNavigator/NativeTabLayout'; -import useAndroidTabIcons from './NativeTabNavigator/useAndroidTabIcons'; -import useNativeTabNavigator from './NativeTabNavigator/useNativeTabNavigator'; -import ReportsSplitNavigator from './ReportsSplitNavigator'; -import SearchFullscreenNavigator from './SearchFullscreenNavigator'; -import SettingsSplitNavigator from './SettingsSplitNavigator'; -import WorkspaceNavigator from './WorkspaceNavigator'; - -/** - * Tab Navigator backed by Material's BottomNavigationView, with the Material 3 active indicator pill, ripples, and - * Material badges for the status dots. Wide layouts keep the JS side bar, since the native bar cannot be moved to - * the side of the screen. - */ -const Tab = createNativeBottomTabNavigator(); - -/** An empty badge value makes Material draw its small dot badge instead of a number. */ -const STATUS_DOT_BADGE = ''; - -/** Shows a status as a Material dot badge in its own color, or no badge at all. */ -function getStatusBadgeOptions(dotColor: string | undefined): Pick { - if (!dotColor) { - return {tabBarBadge: undefined}; - } - return {tabBarBadge: STATUS_DOT_BADGE, tabBarBadgeStyle: {backgroundColor: dotColor}}; -} - -/** Material's bar sits above the system gesture inset, so the floating buttons clear both. */ -const getFloatingButtonsBottom = (bottomInset: number) => variables.androidNativeTabBarFloatingButtonsBottom + bottomInset; - -const renderNativeTabLayout = (props: Omit) => ( - -); - -function TabNavigator() { - const {translate} = useLocalize(); - const theme = useTheme(); - const styles = useThemeStyles(); - const {shouldShowNativeTabBar, inboxDotColor, workspacesDotColor, accountDotColor, isInsightsTabVisible, tabRouterOverride, tabScreenListeners} = useNativeTabNavigator(); - const {getTabBarIcon, accountAvatarIcon, areTabIconsReady} = useAndroidTabIcons(); - - // Colors and the label face come from the Expensify theme and match the side bar on wide layouts: the - // selected tab has a bold label in the regular text color, the others a supporting text label. The tint only - // reaches the labels, since the icons arrive already recolored (react-native-screens patch 003). The bar takes - // the raised surface tone with no top border. - const screenOptions: NativeBottomTabNavigationOptions = { - headerShown: false, - tabBarActiveTintColor: theme.text, - tabBarInactiveTintColor: theme.textSupporting, - tabBarLabelStyle: {fontFamily: styles.textSmall.fontFamily, fontSize: styles.textSmall.fontSize}, - tabBarActiveIndicatorColor: theme.androidTabBarActiveIndicatorBG, - tabBarLabelVisibilityMode: 'labeled', - // Every tab shares one style, so the bar reads the current visibility in the same render that changed it. - tabBarStyle: {display: shouldShowNativeTabBar && areTabIconsReady ? 'flex' : 'none', backgroundColor: theme.highlightBG}, - }; - - return ( - - - - - - - - - ); -} - -export default TabNavigator; diff --git a/src/libs/Navigation/AppNavigator/Navigators/TabNavigator.ios.tsx b/src/libs/Navigation/AppNavigator/Navigators/TabNavigator.ios.tsx deleted file mode 100644 index 19f5d8dae923..000000000000 --- a/src/libs/Navigation/AppNavigator/Navigators/TabNavigator.ios.tsx +++ /dev/null @@ -1,119 +0,0 @@ -import useTheme from '@hooks/useTheme'; - -import {nativeBottomTabScreenLayoutWrapper} from '@libs/Navigation/PlatformStackNavigation/ScreenLayout'; -import type {TabNavigatorParamList} from '@libs/Navigation/types'; - -import HomePage from '@pages/home/HomePage'; -import InsightsPage from '@pages/Insights/InsightsPage'; - -import variables from '@styles/variables'; - -import CONST from '@src/CONST'; -import NAVIGATORS from '@src/NAVIGATORS'; -import SCREENS from '@src/SCREENS'; - -import {createNativeBottomTabNavigator} from '@react-navigation/bottom-tabs/unstable'; -import React from 'react'; - -import type {NativeTabLayoutProps} from './NativeTabNavigator/NativeTabLayout'; - -import NativeTabLayout from './NativeTabNavigator/NativeTabLayout'; -import useIOSTabIcons from './NativeTabNavigator/useIOSTabIcons'; -import useNativeTabNavigator from './NativeTabNavigator/useNativeTabNavigator'; -import ReportsSplitNavigator from './ReportsSplitNavigator'; -import SearchFullscreenNavigator from './SearchFullscreenNavigator'; -import SettingsSplitNavigator from './SettingsSplitNavigator'; -import WorkspaceNavigator from './WorkspaceNavigator'; - -/** - * Tab Navigator backed by UITabBar, which brings the liquid glass material with it on iOS 26. Wide layouts keep - * the JS side bar, since UITabBar cannot be moved to the side of the screen. - */ -const Tab = createNativeBottomTabNavigator(); - -const getFloatingButtonsBottom = () => variables.iosNativeTabBarFloatingButtonsBottom; - -const renderNativeTabLayout = (props: Omit) => ( - -); - -function TabNavigator() { - const theme = useTheme(); - const {shouldShowNativeTabBar, inboxDotColor, workspacesDotColor, accountDotColor, isInsightsTabVisible, tabRouterOverride, tabScreenListeners} = useNativeTabNavigator(); - const {getTabBarIcon, accountAvatarIcon, areTabIconsReady} = useIOSTabIcons({ - [NAVIGATORS.REPORTS_SPLIT_NAVIGATOR]: inboxDotColor, - [NAVIGATORS.WORKSPACE_NAVIGATOR]: workspacesDotColor, - [NAVIGATORS.SETTINGS_SPLIT_NAVIGATOR]: accountDotColor, - }); - - const screenOptions = { - headerShown: false, - tabBarActiveTintColor: theme.iconMenu, - tabBarInactiveTintColor: theme.icon, - // Every tab shares one style, so the bar reads the current visibility in the same render that changed it. - // The background only lands on iOS 18 and below; iOS 26 keeps its own glass material. - tabBarStyle: {display: shouldShowNativeTabBar && areTabIconsReady ? ('flex' as const) : ('none' as const), backgroundColor: theme.appBG}, - tabBarControllerMode: 'tabBar' as const, - // The bar stays put while the content scrolls, instead of collapsing the way iOS 26 does by default. - tabBarMinimizeBehavior: 'none' as const, - }; - - return ( - - - - - - - - - ); -} - -export default TabNavigator; diff --git a/src/libs/Navigation/AppNavigator/Navigators/TabNavigator.native.tsx b/src/libs/Navigation/AppNavigator/Navigators/TabNavigator.native.tsx new file mode 100644 index 000000000000..3be4da307cad --- /dev/null +++ b/src/libs/Navigation/AppNavigator/Navigators/TabNavigator.native.tsx @@ -0,0 +1,83 @@ +import {bottomTabScreenLayoutWrapper} from '@libs/Navigation/PlatformStackNavigation/ScreenLayout'; +import type {TabNavigatorParamList} from '@libs/Navigation/types'; + +import HomePage from '@pages/home/HomePage'; +import InsightsPage from '@pages/Insights/InsightsPage'; + +import CONST from '@src/CONST'; +import NAVIGATORS from '@src/NAVIGATORS'; +import SCREENS from '@src/SCREENS'; + +import {createNativeBottomTabNavigator} from '@react-navigation/bottom-tabs/unstable'; +import React from 'react'; + +import type {NativeTabLayoutProps} from './NativeTabNavigator/NativeTabLayout'; + +import NativeTabLayout from './NativeTabNavigator/NativeTabLayout'; +import tabScreenListeners from './NativeTabNavigator/tabScreenListeners'; +import useNativeTabBarOptions from './NativeTabNavigator/useNativeTabBarOptions'; +import useNativeTabNavigator from './NativeTabNavigator/useNativeTabNavigator'; +import ReportsSplitNavigator from './ReportsSplitNavigator'; +import SearchFullscreenNavigator from './SearchFullscreenNavigator'; +import SettingsSplitNavigator from './SettingsSplitNavigator'; +import WorkspaceNavigator from './WorkspaceNavigator'; + +/** + * Tab Navigator backed by the platform's own tab bar: UITabBar with the liquid glass material on iOS 26, and + * Material's BottomNavigationView on Android. Wide layouts keep the JS side bar, since neither native bar can be + * moved to the side of the screen. + */ +const Tab = createNativeBottomTabNavigator(); + +const renderNativeTabLayout = (props: NativeTabLayoutProps) => ; + +function TabNavigator() { + const {shouldShowNativeTabBar, dotColors, tabLabels, isInsightsTabVisible, tabRouterOverride} = useNativeTabNavigator(); + const {screenOptions, getTabOptions} = useNativeTabBarOptions({shouldShowNativeTabBar, dotColors, tabLabels}); + + return ( + + + + + + + + + ); +} + +export default TabNavigator; diff --git a/src/libs/Navigation/AppNavigator/Navigators/TabNavigator.tsx b/src/libs/Navigation/AppNavigator/Navigators/TabNavigator.tsx index d77a12be149d..6bf2ea7f9df1 100644 --- a/src/libs/Navigation/AppNavigator/Navigators/TabNavigator.tsx +++ b/src/libs/Navigation/AppNavigator/Navigators/TabNavigator.tsx @@ -133,14 +133,14 @@ function TabNavigator() { name={SCREENS.INSIGHTS} component={InsightsPageScreen} /> - + ); } diff --git a/src/libs/Navigation/AppNavigator/Navigators/TabNavigatorBar.tsx b/src/libs/Navigation/AppNavigator/Navigators/TabNavigatorBar.tsx index 9b46ffd6e246..3943151a081a 100644 --- a/src/libs/Navigation/AppNavigator/Navigators/TabNavigatorBar.tsx +++ b/src/libs/Navigation/AppNavigator/Navigators/TabNavigatorBar.tsx @@ -40,7 +40,7 @@ function TabNavigatorBar({state}: Pick) { const selectedTab = ROUTE_TO_NAVIGATION_TAB[activeRoute?.name ?? SCREENS.HOME] ?? NAVIGATION_TABS.HOME; const isAtRoot = isTabRouteAtRoot(activeRoute); // --- Narrow-only animation logic (hooks must run unconditionally per Rules of Hooks) --- - // On native, screens also render the tab bar via bottomContent for swipe-back animations. + // Screens also render the tab bar via bottomContent for swipe-back animations. // Delay showing this navigator's tab bar only when navigating back from a deeper screen // (where the tab bar was hidden). Keep it visible during tab switches so it doesn't flash. // Guard with shouldUseNarrowLayout so prevShouldHide stays false in wide layout, @@ -96,3 +96,4 @@ function TabNavigatorBar({state}: Pick) { } export default TabNavigatorBar; +export {NAVIGATION_TAB_TO_SPANS}; diff --git a/src/libs/Navigation/PlatformStackNavigation/ScreenLayout.tsx b/src/libs/Navigation/PlatformStackNavigation/ScreenLayout.tsx index 2b74f7b4b002..bf0e8308a45b 100644 --- a/src/libs/Navigation/PlatformStackNavigation/ScreenLayout.tsx +++ b/src/libs/Navigation/PlatformStackNavigation/ScreenLayout.tsx @@ -1,8 +1,8 @@ import TransitionTracker from '@libs/Navigation/TransitionTracker'; import type {TransitionHandle} from '@libs/Navigation/TransitionTracker'; -import type {BottomTabNavigationOptions, BottomTabNavigationProp} from '@react-navigation/bottom-tabs'; -import type {NativeBottomTabNavigationOptions, NativeBottomTabNavigationProp} from '@react-navigation/bottom-tabs/unstable'; +import type {BottomTabNavigationOptions} from '@react-navigation/bottom-tabs'; +import type {NativeBottomTabNavigationOptions} from '@react-navigation/bottom-tabs/unstable'; import type {ParamListBase, ScreenLayoutArgs} from '@react-navigation/native'; import React, {useLayoutEffect, useRef} from 'react'; @@ -27,20 +27,9 @@ function screenLayoutWrapper({navigation, ...rest}: ScreenLayoutArgs>) { - return ( - - ); -} - -// Same again for the native bottom tab navigator, whose options type differs while its navigation prop still -// carries the `addListener` that ScreenLayout needs. -function nativeBottomTabScreenLayoutWrapper({navigation, ...rest}: ScreenLayoutArgs>) { +// Same as screenLayoutWrapper above, but for bottom-tab navigators, JS and native. No cast needed here - their +// `navigation` props' `addListener` structurally satisfies TransitionAwareNavigation. +function bottomTabScreenLayoutWrapper({navigation, ...rest}: ScreenLayoutProps) { return ( { - it('returns state with every tab route, in the order the navigator registers them', () => { - // Given the tab screens the navigator registers on the current platform - // When the nested state is built for one of them + it('returns state with all 6 tab routes', () => { const result = buildTabNavigatorNestedState({name: NAVIGATORS.REPORTS_SPLIT_NAVIGATOR}); - - // Then it holds a route for every tab, in the same order, since the native tab bars draw their items in it - expect(result.routes.map((r) => r.name)).toEqual([...TAB_SCREENS]); + expect(result.routes).toHaveLength(TAB_SCREENS.length); + const routeNames = result.routes.map((r) => r.name); + expect(routeNames).toEqual([ + SCREENS.HOME, + NAVIGATORS.REPORTS_SPLIT_NAVIGATOR, + NAVIGATORS.SEARCH_FULLSCREEN_NAVIGATOR, + SCREENS.INSIGHTS, + NAVIGATORS.WORKSPACE_NAVIGATOR, + NAVIGATORS.SETTINGS_SPLIT_NAVIGATOR, + ]); }); - it('sets index to the position of the selected tab', () => { - // Given the settings tab, whose position differs between platforms - // When the nested state is built for it + it('sets index to the position of the selected tab (SETTINGS = 5)', () => { const result = buildTabNavigatorNestedState({name: NAVIGATORS.SETTINGS_SPLIT_NAVIGATOR}); - - // Then the index points at that tab's own route - expect(result.index).toBe(TAB_SCREENS.indexOf(NAVIGATORS.SETTINGS_SPLIT_NAVIGATOR)); + expect(result.index).toBe(5); }); it('falls back to index 0 for an unknown tab name', () => { @@ -39,7 +40,7 @@ describe('buildTabNavigatorNestedState', () => { it('copies params from selectedTabRoute onto the matched route only', () => { const params = {screen: 'SomeScreen'}; const result = buildTabNavigatorNestedState({name: NAVIGATORS.SETTINGS_SPLIT_NAVIGATOR, params}); - expect(result.routes.at(TAB_SCREENS.indexOf(NAVIGATORS.SETTINGS_SPLIT_NAVIGATOR))?.params).toEqual(params); + expect(result.routes.at(5)?.params).toEqual(params); expect(result.routes.at(0)?.params).toBeUndefined(); expect(result.routes.at(1)?.params).toBeUndefined(); }); From 8d5dfffd8c01a281ac31483d2adbf9134b5ccc80 Mon Sep 17 00:00:00 2001 From: sumo_slonik Date: Wed, 30 Sep 2026 16:57:30 +0200 Subject: [PATCH 28/71] fix: restore Search skeleton rows dropped in the PR cleanup --- src/components/Search/index.tsx | 28 ++++++++------------- src/pages/Search/SearchPageNarrow/index.tsx | 7 ++++++ 2 files changed, 18 insertions(+), 17 deletions(-) diff --git a/src/components/Search/index.tsx b/src/components/Search/index.tsx index af5560e39649..3a963469f934 100644 --- a/src/components/Search/index.tsx +++ b/src/components/Search/index.tsx @@ -50,7 +50,6 @@ import { isTransactionGroupListItemType, isTransactionListItemType, isTransactionReportGroupListItemType, - isTransactionSearchType, shouldShowEmptyState, shouldShowYear as shouldShowYearUtil, } from '@libs/SearchUIUtils'; @@ -417,7 +416,7 @@ function Search({ const hasUnresolvedErrors = hasErrors && responseStatusCode === null; const isWaitingForInitialData = !shouldUseLiveData && !isOffline && (!isDataLoaded || isSearchLoadingWithNoResults || hasUnresolvedErrors || isCardFeedsLoading); const shouldShowLoadingState = isDeferringHeavyWork || isWaitingForInitialData; - const shouldShowRowSkeleton = (!skeletonWasDisplayed || shouldShowLoadingState) && showPendingExpensePlaceholder && !hasErrors; + const shouldShowRowSkeleton = !hasErrors && (shouldShowLoadingState || (!skeletonWasDisplayed && showPendingExpensePlaceholder)); const isLoadingMorePage = shouldUseLiveData ? isLivePageInFlight : !!searchResults?.search?.isLoading; const shouldShowLoadingMoreItems = !shouldShowLoadingState && isLoadingMorePage && (searchResults?.search?.offset ?? 0) > 0; @@ -1001,6 +1000,11 @@ function Search({ // Deferred layout only needs the base work (no scroll handling, no content-ready signal). const onDeferredLayout = onLayoutBase; + const handleSkeletonLayout = useCallback(() => { + onDeferredLayout(); + onSkeletonLayout(); + }, [onDeferredLayout, onSkeletonLayout]); + const onLayout = useCallback(() => { onLayoutBase(); handleSelectionListScroll(stableSortedData, searchListRef.current); @@ -1160,25 +1164,15 @@ function Search({ [clearSelectedTransactions, queryJSON, onSortPressedCallback, navigation], ); - // When heavy work is deferred (e.g. during the RHP dismiss animation after - // submitting an expense), skip the expensive render below. The ancestor - // SearchPage (via SearchPageNarrow / SearchPageWide) renders a SearchStaticList - // overlay that covers this component, so the user sees real-looking content. - // The minimal View fires onLayout to flush the deferred API write and set - // hasHadFirstLayout. - if (isDeferringHeavyWork && searchResults?.data && isTransactionSearchType(type)) { - // Zero-sized View - onLayout still fires on RN, which is all we need here. - return ; - } - - // This is a performance optimization for the submit-expense->search path only. - // The SearchPage skeleton (useSearchLoadingState) doesn't cover this case because - // Search must mount for its onLayout to flush the deferred CreateMoneyRequest API write, which would block the JS thread causing a slowdown on post expense creation navigation + // Stands in for the list while the heavy grouping and sorting work is deferred or the first response + // has not arrived. The page level skeleton (useSearchLoadingState) stops at the first defined `data`, + // so on a warm snapshot this is the only thing between the header and the rows. Its onLayout also + // flushes the deferred API write and sets hasHadFirstLayout. if (shouldShowRowSkeleton) { return ( ); diff --git a/src/pages/Search/SearchPageNarrow/index.tsx b/src/pages/Search/SearchPageNarrow/index.tsx index 1e321b3c20e4..edf7b49e5cd3 100644 --- a/src/pages/Search/SearchPageNarrow/index.tsx +++ b/src/pages/Search/SearchPageNarrow/index.tsx @@ -13,6 +13,7 @@ import SearchPageHeaderNarrow from '@components/Search/SearchPageHeader/SearchPa import SearchSelectionFooter from '@components/Search/SearchSelectionFooter'; import SearchWithNavigationDeferredMount from '@components/Search/SearchWithNavigationDeferredMount'; import type {SearchParams, SearchQueryJSON} from '@components/Search/types'; +import SearchRowSkeleton from '@components/Skeletons/SearchRowSkeleton'; import useAndroidBackButtonHandler from '@hooks/useAndroidBackButtonHandler'; import useEndSubmitNavigationSpans from '@hooks/useEndSubmitNavigationSpans'; @@ -343,6 +344,12 @@ function SearchPageNarrow({ hasFilterBars={hasFilterBars} /> )} + {!isInteractive && !searchOverlayContent && ( + + )} {shouldRenderLayoutProbe && } {!!searchOverlayContent && ( Date: Thu, 1 Oct 2026 09:25:46 +0200 Subject: [PATCH 29/71] fix: tint Android tab icons in release builds, even pill inset on mWeb --- .../Navigators/NativeTabNavigator/tabIconRasterizer.ts | 5 +++-- src/styles/index.ts | 6 ++++-- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/tabIconRasterizer.ts b/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/tabIconRasterizer.ts index ce6df5edf964..d2b534a9c004 100644 --- a/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/tabIconRasterizer.ts +++ b/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/tabIconRasterizer.ts @@ -11,9 +11,10 @@ type TabIconPair = {active: NativeBottomTabIcon; inactive: NativeBottomTabIcon}; const TAB_ICONS = Object.entries(NATIVE_TAB_ICONS).map(([name, icon]) => [name, icon.source] as const); +/** Skia reads URLs, file paths and, in Android release builds, bundled assets that resolve to a drawable name. */ async function decodeImage(uri: string): Promise { - const response = await fetch(uri); - return Skia.Image.MakeImageFromEncoded(Skia.Data.fromBytes(new Uint8Array(await response.arrayBuffer()))); + const data = await Skia.Data.fromURI(uri); + return Skia.Image.MakeImageFromEncoded(data); } async function decodeGlyph(source: ImageSourcePropType): Promise<{image: SkImage; scale: number} | undefined> { diff --git a/src/styles/index.ts b/src/styles/index.ts index 8155b1177963..c5edd6ba705b 100644 --- a/src/styles/index.ts +++ b/src/styles/index.ts @@ -752,8 +752,10 @@ const staticStyles = (theme: ThemeColors) => backgroundColor: theme.appBG, borderWidth: variables.hairlineBorderWidth, borderColor: theme.border, - // Insets the row so the selected item's capsule keeps a margin inside the bar's rounded edge. - ...spacing.p1, + // Insets the row so the selected item's capsule keeps the same margin inside the bar's rounded edge on + // every side, past the overhang it reaches beyond the first and last tabs. + ...spacing.pv1, + paddingHorizontal: spacing.p1.padding + variables.floatingTabBarSelectedOverhang, // Clips each item's hover and press background to the capsule's rounded ends. overflow: 'hidden', }, From 4f32e800d730c96988ea105ad1553430a2e3a7c2 Mon Sep 17 00:00:00 2001 From: sumo_slonik Date: Thu, 1 Oct 2026 12:08:56 +0200 Subject: [PATCH 30/71] fix: keep the avatar on the selected Android Account tab --- .../Navigators/NativeTabNavigator/useAndroidTabIcons.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/useAndroidTabIcons.ts b/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/useAndroidTabIcons.ts index a55c798ba91b..e51b9dbc06fb 100644 --- a/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/useAndroidTabIcons.ts +++ b/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/useAndroidTabIcons.ts @@ -85,7 +85,9 @@ function useAndroidTabIcons() { const circularAvatar = useRasterizedTabIcons(lastCircularAvatar, avatarURI, (uri) => createCircularAvatarIcon(uri).then((icon) => icon && {uri, icon})); // An avatar drawn for another user is never shown; until the current one is drawn, the account glyph stands in. - const accountAvatarIcon = circularAvatar?.uri === avatarURI ? circularAvatar?.icon : undefined; + const avatarIcon = circularAvatar?.uri === avatarURI ? circularAvatar?.icon : undefined; + // A function, because React Navigation derives the selected icon only from a function. + const accountAvatarIcon = avatarIcon ? () => avatarIcon : undefined; return { getTabBarIcon: (name: keyof typeof NATIVE_TAB_ICONS) => getTabBarIcon(tintedIcons, name), From c2c6539cf3eca0d2ba92baa32ba2888825cec125 Mon Sep 17 00:00:00 2001 From: sumo_slonik Date: Thu, 1 Oct 2026 12:08:58 +0200 Subject: [PATCH 31/71] fix: run iOS tab root content under the glass tab bar, inset list ends past it --- .../LHNOptionsList/LHNOptionsList.tsx | 2 ++ .../index.ios.tsx | 30 +++++++++++++++++++ .../useTabRootScreenWrapperProps/index.tsx | 21 +++++++++++++ .../useTabRootScreenWrapperProps/types.ts | 5 ++++ .../BaseSearchList/index.native.tsx | 2 ++ .../Tables/WorkspaceListTable/index.tsx | 2 ++ src/components/WorkspaceListLayout.tsx | 6 ++-- src/pages/Insights/InsightsDashboard.tsx | 9 ++++-- src/pages/Search/SearchPageNarrow/index.tsx | 8 ++--- src/pages/domain/DomainInitialPage.tsx | 12 +++++--- src/pages/home/HomePage.tsx | 8 +++-- src/pages/inbox/sidebar/BaseSidebarScreen.tsx | 6 ++-- src/pages/settings/InitialSettingsPage.tsx | 9 +++--- src/pages/workspace/WorkspaceInitialPage.tsx | 12 +++++--- 14 files changed, 104 insertions(+), 28 deletions(-) create mode 100644 src/components/Navigation/TabBarBottomContent/useTabRootScreenWrapperProps/index.ios.tsx create mode 100644 src/components/Navigation/TabBarBottomContent/useTabRootScreenWrapperProps/index.tsx create mode 100644 src/components/Navigation/TabBarBottomContent/useTabRootScreenWrapperProps/types.ts diff --git a/src/components/LHNOptionsList/LHNOptionsList.tsx b/src/components/LHNOptionsList/LHNOptionsList.tsx index a4dae4fa96b6..9df8ac177a3a 100644 --- a/src/components/LHNOptionsList/LHNOptionsList.tsx +++ b/src/components/LHNOptionsList/LHNOptionsList.tsx @@ -162,6 +162,8 @@ function LHNOptionsList({style, contentContainerStyles, data, onSelectRow, optio , + bottomContentStyle: styles.overflowVisible, + }; +} + +export default useTabRootScreenWrapperProps; diff --git a/src/components/Navigation/TabBarBottomContent/useTabRootScreenWrapperProps/index.tsx b/src/components/Navigation/TabBarBottomContent/useTabRootScreenWrapperProps/index.tsx new file mode 100644 index 000000000000..b632062f193f --- /dev/null +++ b/src/components/Navigation/TabBarBottomContent/useTabRootScreenWrapperProps/index.tsx @@ -0,0 +1,21 @@ +import type TabBarBottomContentProps from '@components/Navigation/TabBarBottomContent/types'; + +import useThemeStyles from '@hooks/useThemeStyles'; + +import React from 'react'; + +import type TabRootScreenWrapperProps from './types'; + +import TabBarBottomContent from '..'; + +/** The ScreenWrapper props that keep a tab root screen's content clear of the tab bar. */ +function useTabRootScreenWrapperProps(selectedTab: TabBarBottomContentProps['selectedTab']): TabRootScreenWrapperProps { + const styles = useThemeStyles(); + + return { + bottomContent: , + bottomContentStyle: styles.overflowVisible, + }; +} + +export default useTabRootScreenWrapperProps; diff --git a/src/components/Navigation/TabBarBottomContent/useTabRootScreenWrapperProps/types.ts b/src/components/Navigation/TabBarBottomContent/useTabRootScreenWrapperProps/types.ts new file mode 100644 index 000000000000..dab83f0477d5 --- /dev/null +++ b/src/components/Navigation/TabBarBottomContent/useTabRootScreenWrapperProps/types.ts @@ -0,0 +1,5 @@ +import type {ScreenWrapperContainerProps} from '@components/ScreenWrapper/ScreenWrapperContainer'; + +type TabRootScreenWrapperProps = Pick; + +export default TabRootScreenWrapperProps; diff --git a/src/components/Search/SearchList/BaseSearchList/index.native.tsx b/src/components/Search/SearchList/BaseSearchList/index.native.tsx index 8164250b667d..95bc5b6a011c 100644 --- a/src/components/Search/SearchList/BaseSearchList/index.native.tsx +++ b/src/components/Search/SearchList/BaseSearchList/index.native.tsx @@ -33,6 +33,8 @@ function BaseSearchList({ return ( `${row.policyID}-${index}`} filters={filterConfig} isItemInFilter={isItemInFilter} + // Lets UIKit inset the end of the list past the translucent iOS tab bar that the content runs under. + contentInsetAdjustmentBehavior="automatic" > {tableHeaderComponent} diff --git a/src/components/WorkspaceListLayout.tsx b/src/components/WorkspaceListLayout.tsx index ce180ea0d91d..3b4762dab1df 100644 --- a/src/components/WorkspaceListLayout.tsx +++ b/src/components/WorkspaceListLayout.tsx @@ -17,7 +17,7 @@ import {View} from 'react-native'; import {useDebugTabViewHeight} from './Navigation/DebugTabView'; import NAVIGATION_TABS from './Navigation/NavigationTabBar/NAVIGATION_TABS'; -import TabBarBottomContent from './Navigation/TabBarBottomContent'; +import useTabRootScreenWrapperProps from './Navigation/TabBarBottomContent/useTabRootScreenWrapperProps'; import TopBarWithLoadingBar from './Navigation/TopBarWithLoadingBar'; import OfflineIndicator from './OfflineIndicator'; import ScreenWrapper from './ScreenWrapper'; @@ -88,6 +88,7 @@ function WorkspaceListHeaderContent({activeTabKey, headerButton, shouldShowHeade function WorkspaceListLayout({children, activeTabKey, headerButton, headerComponent, scrollHeaderWithTable = false}: WorkspaceListLayoutProps) { const styles = useThemeStyles(); + const tabRootScreenWrapperProps = useTabRootScreenWrapperProps(NAVIGATION_TABS.WORKSPACES); const StyleUtils = useStyleUtils(); const {translate} = useLocalize(); @@ -119,8 +120,7 @@ function WorkspaceListLayout({children, activeTabKey, headerButton, headerCompon shouldEnableMaxHeight shouldEnablePickerAvoiding={false} enableEdgeToEdgeBottomSafeAreaPadding={false} - bottomContentStyle={styles.overflowVisible} - bottomContent={} + {...tabRootScreenWrapperProps} > diff --git a/src/pages/Insights/InsightsDashboard.tsx b/src/pages/Insights/InsightsDashboard.tsx index ee11a1be7697..a80c5ee14940 100644 --- a/src/pages/Insights/InsightsDashboard.tsx +++ b/src/pages/Insights/InsightsDashboard.tsx @@ -1,7 +1,7 @@ import BlockingView from '@components/BlockingViews/BlockingView'; import FullPageErrorView from '@components/BlockingViews/FullPageErrorView'; import NAVIGATION_TABS from '@components/Navigation/NavigationTabBar/NAVIGATION_TABS'; -import TabBarBottomContent from '@components/Navigation/TabBarBottomContent'; +import useTabRootScreenWrapperProps from '@components/Navigation/TabBarBottomContent/useTabRootScreenWrapperProps'; import TopBar from '@components/Navigation/TopBar'; import ScreenWrapper from '@components/ScreenWrapper'; import ScrollView from '@components/ScrollView'; @@ -109,6 +109,8 @@ function InsightsDashboardContent({state, headlineChart, supportingCharts, filte return ( {state === INSIGHTS_DASHBOARD_STATE.NO_EXPENSES ? : } @@ -122,6 +124,8 @@ function InsightsDashboardContent({state, headlineChart, supportingCharts, filte return ( @@ -161,6 +165,7 @@ function InsightsDashboardContent({state, headlineChart, supportingCharts, filte function InsightsDashboard({dashboardID}: {dashboardID: InsightsDashboardID}) { const {translate} = useLocalize(); + const tabRootScreenWrapperProps = useTabRootScreenWrapperProps(NAVIGATION_TABS.INSIGHTS); const {isOffline} = useNetwork(); const isFocused = useIsFocused(); const {login} = useCurrentUserPersonalDetails(); @@ -209,7 +214,7 @@ function InsightsDashboard({dashboardID}: {dashboardID: InsightsDashboardID}) { } + {...tabRootScreenWrapperProps} testID="InsightsPage" > ; - function SearchPageNarrow({ queryJSON, searchResults, @@ -100,6 +98,7 @@ function SearchPageNarrow({ const {translate} = useLocalize(); const {windowHeight} = useWindowDimensions(); const styles = useThemeStyles(); + const tabRootScreenWrapperProps = useTabRootScreenWrapperProps(NAVIGATION_TABS.SEARCH); const StyleUtils = useStyleUtils(); const {clearSelectedTransactions} = useSearchSelectionActions(); const {shouldUseLiveData} = useSearchResultsContext(); @@ -265,8 +264,7 @@ function SearchPageNarrow({ shouldEnableMaxHeight offlineIndicatorStyle={styles.mtAuto} shouldShowOfflineIndicator={!!searchResults} - bottomContent={tabBarContent} - bottomContentStyle={styles.overflowVisible} + {...tabRootScreenWrapperProps} > {!isMobileSelectionModeEnabled ? ( diff --git a/src/pages/domain/DomainInitialPage.tsx b/src/pages/domain/DomainInitialPage.tsx index 64187a0751d4..56ddb6722ac1 100644 --- a/src/pages/domain/DomainInitialPage.tsx +++ b/src/pages/domain/DomainInitialPage.tsx @@ -2,7 +2,7 @@ import FullPageNotFoundView from '@components/BlockingViews/FullPageNotFoundView import HeaderWithBackButton from '@components/HeaderWithBackButton'; import HighlightableMenuItem from '@components/HighlightableMenuItem'; import NAVIGATION_TABS from '@components/Navigation/NavigationTabBar/NAVIGATION_TABS'; -import TabBarBottomContent from '@components/Navigation/TabBarBottomContent'; +import useTabRootScreenWrapperProps from '@components/Navigation/TabBarBottomContent/useTabRootScreenWrapperProps'; import ScreenWrapper from '@components/ScreenWrapper'; import ScrollView from '@components/ScrollView'; @@ -43,6 +43,7 @@ type DomainInitialPageProps = PlatformStackScreenProps findFocusedRoute(state)?.name); @@ -102,8 +103,7 @@ function DomainInitialPage({route}: DomainInitialPageProps) { } - bottomContentStyle={styles.overflowVisible} + {...tabRootScreenWrapperProps} > Navigation.dismissModal()} @@ -121,7 +121,11 @@ function DomainInitialPage({route}: DomainInitialPageProps) { shouldDisplayAccountButton /> - + {/* Ideally we should use MenuList component for MenuItems with singleExecution/Navigation actions. diff --git a/src/pages/home/HomePage.tsx b/src/pages/home/HomePage.tsx index 55dd8d0cfda3..ac4e33e4f4f2 100644 --- a/src/pages/home/HomePage.tsx +++ b/src/pages/home/HomePage.tsx @@ -1,6 +1,6 @@ import NAVIGATION_TABS from '@components/Navigation/NavigationTabBar/NAVIGATION_TABS'; import QuickCreationActionsBar from '@components/Navigation/QuickCreationActionsBar'; -import TabBarBottomContent from '@components/Navigation/TabBarBottomContent'; +import useTabRootScreenWrapperProps from '@components/Navigation/TabBarBottomContent/useTabRootScreenWrapperProps'; import TopBar from '@components/Navigation/TopBar'; import ReceiptScanDropZone from '@components/ReceiptScanDropZone'; import ScreenWrapper from '@components/ScreenWrapper'; @@ -42,6 +42,7 @@ const RIGHT_COLUMN_TEST_ID = 'homePageRightColumn'; function HomePage() { const {shouldUseNarrowLayout} = useResponsiveLayout(); const styles = useThemeStyles(); + const tabRootScreenWrapperProps = useTabRootScreenWrapperProps(NAVIGATION_TABS.HOME); const {translate} = useLocalize(); useDocumentTitle(translate('common.home')); const {isOffline} = useNetwork({onReconnect: openHomePage}); @@ -141,8 +142,7 @@ function HomePage() { shouldShowOfflineIndicatorInWideScreen testID="HomePage" enableEdgeToEdgeBottomSafeAreaPadding={false} - bottomContent={} - bottomContentStyle={styles.overflowVisible} + {...tabRootScreenWrapperProps} > } - bottomContentStyle={styles.overflowVisible} + {...tabRootScreenWrapperProps} > {({insets}) => ( <> diff --git a/src/pages/settings/InitialSettingsPage.tsx b/src/pages/settings/InitialSettingsPage.tsx index 4eb636cbf27c..fd6a522b520a 100755 --- a/src/pages/settings/InitialSettingsPage.tsx +++ b/src/pages/settings/InitialSettingsPage.tsx @@ -2,7 +2,7 @@ import AccountSwitcher from '@components/AccountSwitcher'; import AccountSwitcherButton from '@components/AccountSwitcherButton'; import AccountSwitcherSkeletonView from '@components/AccountSwitcherSkeletonView'; import NAVIGATION_TABS from '@components/Navigation/NavigationTabBar/NAVIGATION_TABS'; -import TabBarBottomContent from '@components/Navigation/TabBarBottomContent'; +import useTabRootScreenWrapperProps from '@components/Navigation/TabBarBottomContent/useTabRootScreenWrapperProps'; import TopBarWithLoadingBar from '@components/Navigation/TopBarWithLoadingBar'; import ScreenWrapper from '@components/ScreenWrapper'; import {ScrollOffsetContext} from '@components/ScrollOffsetContextProvider'; @@ -50,8 +50,8 @@ type InitialSettingsPageProps = WithCurrentUserPersonalDetailsProps; function InitialSettingsPage({currentUserPersonalDetails}: InitialSettingsPageProps) { const {shouldUseNarrowLayout, isInLandscapeMode} = useResponsiveLayout(); const [canSwitchAccounts = false] = useOnyx(ONYXKEYS.ACCOUNT, {selector: canSwitchAccountsSelector}); - const tabBarContent = ; const styles = useThemeStyles(); + const tabRootScreenWrapperProps = useTabRootScreenWrapperProps(NAVIGATION_TABS.SETTINGS); const {isExecuting, singleExecution} = useSingleExecution(); const {translate} = useLocalize(); const focusedRouteName = useNavigationState((state) => findFocusedRoute(state)?.name); @@ -166,8 +166,7 @@ function InitialSettingsPage({currentUserPersonalDetails}: InitialSettingsPagePr includeSafeAreaPaddingBottom testID="InitialSettingsPage" shouldEnableKeyboardAvoidingView={false} - bottomContent={tabBarContent} - bottomContentStyle={styles.overflowVisible} + {...tabRootScreenWrapperProps} > } - bottomContentStyle={styles.overflowVisible} + {...tabRootScreenWrapperProps} > - + dismissError(policyID, policy?.pendingAction)} From 0becb2ac76237c313caeeda02c89bf51d038e7e5 Mon Sep 17 00:00:00 2001 From: sumo_slonik Date: Thu, 1 Oct 2026 15:15:31 +0200 Subject: [PATCH 32/71] fix: keep Android tab root content above the Material tab bar --- .../TabBarBottomContent/index.android.tsx | 29 +++++++++++++++++++ .../TabBarBottomContent/index.ios.tsx | 6 ++++ .../TabBarBottomContent/index.native.tsx | 6 ---- src/styles/index.ts | 4 +++ src/styles/variables.ts | 7 +++-- 5 files changed, 44 insertions(+), 8 deletions(-) create mode 100644 src/components/Navigation/TabBarBottomContent/index.android.tsx create mode 100644 src/components/Navigation/TabBarBottomContent/index.ios.tsx delete mode 100644 src/components/Navigation/TabBarBottomContent/index.native.tsx diff --git a/src/components/Navigation/TabBarBottomContent/index.android.tsx b/src/components/Navigation/TabBarBottomContent/index.android.tsx new file mode 100644 index 000000000000..84ef345a43ae --- /dev/null +++ b/src/components/Navigation/TabBarBottomContent/index.android.tsx @@ -0,0 +1,29 @@ +import NAVIGATION_TABS from '@components/Navigation/NavigationTabBar/NAVIGATION_TABS'; + +import useIsSettingsDrawnOverTabs from '@hooks/useIsSettingsDrawnOverTabs'; +import useResponsiveLayout from '@hooks/useResponsiveLayout'; +import useThemeStyles from '@hooks/useThemeStyles'; + +import React from 'react'; +import {View} from 'react-native'; + +import type TabBarBottomContentProps from './types'; + +/** + * Material's bar is drawn over the full-height tab screen, so a tab root screen reserves the bar's row under its + * content. The ScreenWrapper adds the gesture inset the bar sits on below it. + */ +function TabBarBottomContent({selectedTab}: TabBarBottomContentProps) { + const {shouldUseNarrowLayout} = useResponsiveLayout(); + const isSettingsDrawnOverTabs = useIsSettingsDrawnOverTabs(); + const styles = useThemeStyles(); + + // Account drawn over the tabs hides the bar. + if (!shouldUseNarrowLayout || (selectedTab === NAVIGATION_TABS.SETTINGS && isSettingsDrawnOverTabs)) { + return null; + } + + return ; +} + +export default TabBarBottomContent; diff --git a/src/components/Navigation/TabBarBottomContent/index.ios.tsx b/src/components/Navigation/TabBarBottomContent/index.ios.tsx new file mode 100644 index 000000000000..4a2451a606dd --- /dev/null +++ b/src/components/Navigation/TabBarBottomContent/index.ios.tsx @@ -0,0 +1,6 @@ +/** UITabBar is translucent, so a tab root screen's content runs under it rather than ending above a copy of the bar. */ +function TabBarBottomContent() { + return null; +} + +export default TabBarBottomContent; diff --git a/src/components/Navigation/TabBarBottomContent/index.native.tsx b/src/components/Navigation/TabBarBottomContent/index.native.tsx deleted file mode 100644 index b423035245d5..000000000000 --- a/src/components/Navigation/TabBarBottomContent/index.native.tsx +++ /dev/null @@ -1,6 +0,0 @@ -/** The native tab bars keep the screen content clear of themselves, so a tab root screen needs no copy of the bar. */ -function TabBarBottomContent() { - return null; -} - -export default TabBarBottomContent; diff --git a/src/styles/index.ts b/src/styles/index.ts index c5edd6ba705b..22823ad3a4a2 100644 --- a/src/styles/index.ts +++ b/src/styles/index.ts @@ -770,6 +770,10 @@ const staticStyles = (theme: ThemeColors) => marginHorizontal: -variables.floatingTabBarSelectedOverhang, }, + androidNativeTabBarSpacer: { + height: variables.androidNativeTabBarHeight, + }, + // The gap between the floating bar and the bottom edge of the screen. floatingTabBarBottomInset: { paddingBottom: variables.floatingTabBarBottomInset, diff --git a/src/styles/variables.ts b/src/styles/variables.ts index 0ea27e0f0b23..6d10a89da4a8 100644 --- a/src/styles/variables.ts +++ b/src/styles/variables.ts @@ -31,6 +31,7 @@ const avatarSizes = { const floatingTabBarHeight = 60; const floatingTabBarBottomInset = 8; +const androidNativeTabBarHeight = 80; export default { bottomTabHeight: 72, @@ -121,8 +122,10 @@ export default { nativeTabIconLabelGap: 6, /** Pixel density the native tab bar icons are rasterized at. */ nativeTabIconScale: 3, - /** Material's 80 dp BottomNavigationView row plus the 16 dp Material keeps between a FAB and the bar, without the gesture inset under it. */ - androidNativeTabBarFloatingButtonsBottom: 96, + /** Material's BottomNavigationView row, without the gesture inset under it. */ + androidNativeTabBarHeight, + /** Material's bar row plus the 16 dp Material keeps between a FAB and the bar. */ + androidNativeTabBarFloatingButtonsBottom: androidNativeTabBarHeight + 16, /** UITabBar with the home indicator inset under it, plus a 12 pt gap to the floating buttons. */ iosNativeTabBarFloatingButtonsBottom: 84, iconHeader: 48, From f4b79ceca3d31724c4034aa5a0e8e769aec44187 Mon Sep 17 00:00:00 2001 From: sumo_slonik Date: Fri, 2 Oct 2026 10:42:59 +0200 Subject: [PATCH 33/71] feat: Android tab bar on appBG with a soft top shadow instead of a tonal edge --- .../NativeTabBarShadow/index.ios.tsx | 6 +++++ .../NativeTabBarShadow/index.tsx | 24 +++++++++++++++++++ .../NativeTabNavigator/NativeTabLayout.tsx | 2 ++ .../useNativeTabBarOptions/index.ts | 2 +- src/styles/index.ts | 11 +++++++++ src/styles/theme/themes/dark.ts | 1 + src/styles/theme/themes/light.ts | 1 + src/styles/theme/types.ts | 1 + 8 files changed, 47 insertions(+), 1 deletion(-) create mode 100644 src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/NativeTabBarShadow/index.ios.tsx create mode 100644 src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/NativeTabBarShadow/index.tsx diff --git a/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/NativeTabBarShadow/index.ios.tsx b/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/NativeTabBarShadow/index.ios.tsx new file mode 100644 index 000000000000..d3f248535c5b --- /dev/null +++ b/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/NativeTabBarShadow/index.ios.tsx @@ -0,0 +1,6 @@ +/** UITabBar draws its own glass edge over the content. */ +function NativeTabBarShadow() { + return null; +} + +export default NativeTabBarShadow; diff --git a/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/NativeTabBarShadow/index.tsx b/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/NativeTabBarShadow/index.tsx new file mode 100644 index 000000000000..6a99823d730b --- /dev/null +++ b/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/NativeTabBarShadow/index.tsx @@ -0,0 +1,24 @@ +import useSafeAreaInsets from '@hooks/useSafeAreaInsets'; +import useThemeStyles from '@hooks/useThemeStyles'; + +import CONST from '@src/CONST'; + +import React from 'react'; +import Animated, {FadeIn, FadeOut} from 'react-native-reanimated'; + +/** Material's bar shares the screen's background, so a shadow along its top edge marks where the content ends. */ +function NativeTabBarShadow() { + const styles = useThemeStyles(); + const {bottom: bottomInset} = useSafeAreaInsets(); + + return ( + + ); +} + +export default NativeTabBarShadow; diff --git a/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/NativeTabLayout.tsx b/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/NativeTabLayout.tsx index 60de5f48e070..f2d380f1cabe 100644 --- a/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/NativeTabLayout.tsx +++ b/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/NativeTabLayout.tsx @@ -23,6 +23,7 @@ import React from 'react'; import {View} from 'react-native'; import Animated, {FadeIn, FadeOut} from 'react-native-reanimated'; +import NativeTabBarShadow from './NativeTabBarShadow'; import {getFloatingButtonsBottom} from './useNativeTabBarOptions'; type NativeTabLayoutProps = Parameters>[0]; @@ -49,6 +50,7 @@ function NativeTabLayout({children, state, descriptors}: NativeTabLayoutProps) { return ( {children} + {shouldShowNativeTabBar && } {!!isDebugModeEnabled && shouldShowNativeTabBar && } {shouldShowNativeTabBar && ( // The buttons belong to the bar, so they fade with it rather than appearing in place. They leave diff --git a/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/useNativeTabBarOptions/index.ts b/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/useNativeTabBarOptions/index.ts index 0ae89e4311b5..983ccf6a933c 100644 --- a/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/useNativeTabBarOptions/index.ts +++ b/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/useNativeTabBarOptions/index.ts @@ -32,7 +32,7 @@ function useNativeTabBarOptions({shouldShowNativeTabBar, dotColors, tabLabels}: tabBarActiveIndicatorColor: theme.androidTabBarActiveIndicatorBG, tabBarLabelVisibilityMode: 'labeled', // Every tab shares one style, so the bar reads the current visibility in the same render that changed it. - tabBarStyle: {display: shouldShowNativeTabBar && areTabIconsReady ? 'flex' : 'none', backgroundColor: theme.highlightBG}, + tabBarStyle: {display: shouldShowNativeTabBar && areTabIconsReady ? 'flex' : 'none', backgroundColor: theme.appBG}, }; const getTabOptions = (name: keyof typeof NATIVE_TAB_ICONS): NativeBottomTabNavigationOptions => { diff --git a/src/styles/index.ts b/src/styles/index.ts index 22823ad3a4a2..8959a151f370 100644 --- a/src/styles/index.ts +++ b/src/styles/index.ts @@ -6922,6 +6922,17 @@ const dynamicStyles = (theme: ThemeColors) => zIndex: 10, }) satisfies ViewStyle, + // Covers Material's bar and the gesture inset under it, and casts the shadow up over the screen content. + androidNativeTabBarShadow: (bottomInset: number) => + ({ + position: 'absolute', + right: 0, + bottom: 0, + left: 0, + height: variables.androidNativeTabBarHeight + bottomInset, + boxShadow: theme.androidTabBarShadow, + }) satisfies ViewStyle, + getSplitListItemAmountStyle: (inputMarginLeft: number, amountWidth: number | string) => ({ marginLeft: inputMarginLeft, width: amountWidth, diff --git a/src/styles/theme/themes/dark.ts b/src/styles/theme/themes/dark.ts index 92281a8a91b3..0b7713364b01 100644 --- a/src/styles/theme/themes/dark.ts +++ b/src/styles/theme/themes/dark.ts @@ -61,6 +61,7 @@ const darkTheme = { floatingTabBarSelectedBG: 'rgba(26,61,50,0.50)', // selectedOptionBG (green700) at 40% androidTabBarActiveIndicatorBG: 'rgba(8,82,57,0.40)', + androidTabBarShadow: '0px -4px 12px 0px rgba(2,18,4,0.24)', heading: colors.productDark900, textLight: colors.productDark900, textDark: colors.productDark100, diff --git a/src/styles/theme/themes/light.ts b/src/styles/theme/themes/light.ts index 1960210b9bb5..534aaa23ae05 100644 --- a/src/styles/theme/themes/light.ts +++ b/src/styles/theme/themes/light.ts @@ -61,6 +61,7 @@ const lightTheme = { floatingTabBarSelectedBG: 'rgba(230,225,218,0.50)', // selectedOptionBG (green100) at 40% androidTabBarActiveIndicatorBG: 'rgba(177,242,214,0.40)', + androidTabBarShadow: '0px -4px 12px 0px rgba(2,18,4,0.06)', heading: colors.productLight900, textLight: colors.white, textDark: colors.productLight900, diff --git a/src/styles/theme/types.ts b/src/styles/theme/types.ts index 66a4ad6b832d..0c3aab949e1a 100644 --- a/src/styles/theme/types.ts +++ b/src/styles/theme/types.ts @@ -67,6 +67,7 @@ type ThemeColors = { sidebarHover: Color; floatingTabBarSelectedBG: Color; androidTabBarActiveIndicatorBG: Color; + androidTabBarShadow: Color; heading: Color; textLight: Color; textDark: Color; From 75f34ea18ec110ef5e8c19bc896c0e64c025fb7d Mon Sep 17 00:00:00 2001 From: sumo_slonik Date: Fri, 2 Oct 2026 12:39:17 +0200 Subject: [PATCH 34/71] feat: float the mWeb tab bar over tab root content, inset list ends past it --- .../LHNOptionsList/LHNOptionsList.tsx | 4 ++- .../index.native.tsx | 8 ++++++ .../useTabBarContentInsetStyle/index.tsx | 25 +++++++++++++++++++ .../index.android.tsx | 21 ++++++++++++++++ .../useTabRootScreenWrapperProps/index.tsx | 9 +++++-- .../SearchList/BaseSearchList/index.tsx | 4 ++- src/pages/Insights/InsightsDashboard.tsx | 7 ++++-- src/pages/domain/DomainInitialPage.tsx | 4 ++- src/pages/home/HomePage.tsx | 4 ++- src/pages/settings/InitialSettingsPage.tsx | 4 ++- src/pages/workspace/WorkspaceInitialPage.tsx | 4 ++- src/styles/index.ts | 9 +++++++ 12 files changed, 93 insertions(+), 10 deletions(-) create mode 100644 src/components/Navigation/TabBarBottomContent/useTabBarContentInsetStyle/index.native.tsx create mode 100644 src/components/Navigation/TabBarBottomContent/useTabBarContentInsetStyle/index.tsx create mode 100644 src/components/Navigation/TabBarBottomContent/useTabRootScreenWrapperProps/index.android.tsx diff --git a/src/components/LHNOptionsList/LHNOptionsList.tsx b/src/components/LHNOptionsList/LHNOptionsList.tsx index 9df8ac177a3a..391b907c3a54 100644 --- a/src/components/LHNOptionsList/LHNOptionsList.tsx +++ b/src/components/LHNOptionsList/LHNOptionsList.tsx @@ -1,3 +1,4 @@ +import useTabBarContentInsetStyle from '@components/Navigation/TabBarBottomContent/useTabBarContentInsetStyle'; import {ScrollOffsetContext} from '@components/ScrollOffsetContextProvider'; import useNetwork from '@hooks/useNetwork'; @@ -45,6 +46,7 @@ function LHNOptionsList({style, contentContainerStyles, data, onSelectRow, optio const [personalDetails] = useAllPersonalDetails(); const styles = useThemeStyles(); + const contentContainerStyle = useTabBarContentInsetStyle(contentContainerStyles); const estimatedItemSize = optionMode === CONST.OPTION_MODE.COMPACT ? variables.optionRowHeightCompact : variables.optionRowHeight; // When the first item renders we want to call the onFirstItemRendered callback. @@ -167,7 +169,7 @@ function LHNOptionsList({style, contentContainerStyles, data, onSelectRow, optio indicatorStyle="white" keyboardShouldPersistTaps="always" CellRendererComponent={OptionRowRendererComponent} - contentContainerStyle={StyleSheet.flatten(contentContainerStyles)} + contentContainerStyle={StyleSheet.flatten(contentContainerStyle)} data={data} testID="lhn-options-list" keyExtractor={keyExtractor} diff --git a/src/components/Navigation/TabBarBottomContent/useTabBarContentInsetStyle/index.native.tsx b/src/components/Navigation/TabBarBottomContent/useTabBarContentInsetStyle/index.native.tsx new file mode 100644 index 000000000000..2a8564a9d097 --- /dev/null +++ b/src/components/Navigation/TabBarBottomContent/useTabBarContentInsetStyle/index.native.tsx @@ -0,0 +1,8 @@ +import type {StyleProp, ViewStyle} from 'react-native'; + +/** UIKit insets the lists past the iOS tab bar, and Android reserves the bar's row below the content. */ +function useTabBarContentInsetStyle(style?: StyleProp): StyleProp { + return style; +} + +export default useTabBarContentInsetStyle; diff --git a/src/components/Navigation/TabBarBottomContent/useTabBarContentInsetStyle/index.tsx b/src/components/Navigation/TabBarBottomContent/useTabBarContentInsetStyle/index.tsx new file mode 100644 index 000000000000..5920674a1f49 --- /dev/null +++ b/src/components/Navigation/TabBarBottomContent/useTabBarContentInsetStyle/index.tsx @@ -0,0 +1,25 @@ +import useBottomSafeSafeAreaPaddingStyle from '@hooks/useBottomSafeSafeAreaPaddingStyle'; +import useResponsiveLayout from '@hooks/useResponsiveLayout'; + +import variables from '@styles/variables'; + +import type {StyleProp, ViewStyle} from 'react-native'; + +/** + * The content of a narrow tab root screen runs under the floating tab bar, so a list's content container ends with + * room for the bar, letting its last row scroll out from under it. A list that already adds the bottom safe area + * passes `hasBottomSafeAreaPadding`, so the safe area is not added twice. + */ +function useTabBarContentInsetStyle(style?: StyleProp, hasBottomSafeAreaPadding = false): StyleProp { + const {shouldUseNarrowLayout} = useResponsiveLayout(); + const insetStyle = useBottomSafeSafeAreaPaddingStyle({ + style, + addBottomSafeAreaPadding: !hasBottomSafeAreaPadding, + addOfflineIndicatorBottomSafeAreaPadding: false, + additionalPaddingBottom: variables.floatingTabBarHeight + variables.floatingTabBarBottomInset, + }); + + return shouldUseNarrowLayout ? insetStyle : style; +} + +export default useTabBarContentInsetStyle; diff --git a/src/components/Navigation/TabBarBottomContent/useTabRootScreenWrapperProps/index.android.tsx b/src/components/Navigation/TabBarBottomContent/useTabRootScreenWrapperProps/index.android.tsx new file mode 100644 index 000000000000..b632062f193f --- /dev/null +++ b/src/components/Navigation/TabBarBottomContent/useTabRootScreenWrapperProps/index.android.tsx @@ -0,0 +1,21 @@ +import type TabBarBottomContentProps from '@components/Navigation/TabBarBottomContent/types'; + +import useThemeStyles from '@hooks/useThemeStyles'; + +import React from 'react'; + +import type TabRootScreenWrapperProps from './types'; + +import TabBarBottomContent from '..'; + +/** The ScreenWrapper props that keep a tab root screen's content clear of the tab bar. */ +function useTabRootScreenWrapperProps(selectedTab: TabBarBottomContentProps['selectedTab']): TabRootScreenWrapperProps { + const styles = useThemeStyles(); + + return { + bottomContent: , + bottomContentStyle: styles.overflowVisible, + }; +} + +export default useTabRootScreenWrapperProps; diff --git a/src/components/Navigation/TabBarBottomContent/useTabRootScreenWrapperProps/index.tsx b/src/components/Navigation/TabBarBottomContent/useTabRootScreenWrapperProps/index.tsx index b632062f193f..5621ca35e2fd 100644 --- a/src/components/Navigation/TabBarBottomContent/useTabRootScreenWrapperProps/index.tsx +++ b/src/components/Navigation/TabBarBottomContent/useTabRootScreenWrapperProps/index.tsx @@ -1,5 +1,6 @@ import type TabBarBottomContentProps from '@components/Navigation/TabBarBottomContent/types'; +import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useThemeStyles from '@hooks/useThemeStyles'; import React from 'react'; @@ -8,13 +9,17 @@ import type TabRootScreenWrapperProps from './types'; import TabBarBottomContent from '..'; -/** The ScreenWrapper props that keep a tab root screen's content clear of the tab bar. */ +/** + * On narrow layouts the floating tab bar is laid over the bottom of a tab root screen, so the content runs under it + * down to the bottom edge of the screen. Each tab root's list ends with room for the bar. + */ function useTabRootScreenWrapperProps(selectedTab: TabBarBottomContentProps['selectedTab']): TabRootScreenWrapperProps { + const {shouldUseNarrowLayout} = useResponsiveLayout(); const styles = useThemeStyles(); return { bottomContent: , - bottomContentStyle: styles.overflowVisible, + bottomContentStyle: shouldUseNarrowLayout ? styles.floatingTabBarOverlay : styles.overflowVisible, }; } diff --git a/src/components/Search/SearchList/BaseSearchList/index.tsx b/src/components/Search/SearchList/BaseSearchList/index.tsx index 1fbb0c477c13..08a6cd4847e6 100644 --- a/src/components/Search/SearchList/BaseSearchList/index.tsx +++ b/src/components/Search/SearchList/BaseSearchList/index.tsx @@ -1,3 +1,4 @@ +import useTabBarContentInsetStyle from '@components/Navigation/TabBarBottomContent/useTabBarContentInsetStyle'; import type {SearchListItem} from '@components/Search/SearchList/ListItem/types'; import type {ExtendedTargetedEvent} from '@components/SelectionList/ListItem/types'; import {useEditingCellState} from '@components/TransactionItemRow/EditableCell'; @@ -74,6 +75,7 @@ function BaseSearchList({ disabledIndexes, overrideItemLayout, }: BaseSearchListProps) { + const tabBarInsetContentContainerStyle = useTabBarContentInsetStyle(contentContainerStyle); const hasKeyBeenPressed = useRef(false); const isFocused = useIsFocused(); const {focusedCellId, isEditingCell} = useEditingCellState(); @@ -193,7 +195,7 @@ function BaseSearchList({ CellRendererComponent={CellRendererComponent} removeClippedSubviews drawDistance={250} - contentContainerStyle={contentContainerStyle} + contentContainerStyle={tabBarInsetContentContainerStyle} maintainVisibleContentPosition={{disabled: true}} stickyHeaderIndices={stickyHeaderIndices} stickyHeaderConfig={stickyHeaderConfig} diff --git a/src/pages/Insights/InsightsDashboard.tsx b/src/pages/Insights/InsightsDashboard.tsx index a80c5ee14940..0c96b3583719 100644 --- a/src/pages/Insights/InsightsDashboard.tsx +++ b/src/pages/Insights/InsightsDashboard.tsx @@ -1,6 +1,7 @@ import BlockingView from '@components/BlockingViews/BlockingView'; import FullPageErrorView from '@components/BlockingViews/FullPageErrorView'; import NAVIGATION_TABS from '@components/Navigation/NavigationTabBar/NAVIGATION_TABS'; +import useTabBarContentInsetStyle from '@components/Navigation/TabBarBottomContent/useTabBarContentInsetStyle'; import useTabRootScreenWrapperProps from '@components/Navigation/TabBarBottomContent/useTabRootScreenWrapperProps'; import TopBar from '@components/Navigation/TopBar'; import ScreenWrapper from '@components/ScreenWrapper'; @@ -64,6 +65,8 @@ function InsightsDashboardContent({state, headlineChart, supportingCharts, filte const {translate} = useLocalize(); const {shouldUseNarrowLayout} = useResponsiveLayout(); const icons = useMemoizedLazyExpensifyIcons(['OfflineCloud']); + const emptyStateContentContainerStyle = useTabBarContentInsetStyle([styles.flexGrow1, styles.flexShrink0], true); + const contentContainerStyle = useTabBarContentInsetStyle([styles.flexGrow1, styles.ph5, styles.pb5], true); const didRequestFail = state === INSIGHTS_DASHBOARD_STATE.ERROR || state === INSIGHTS_DASHBOARD_STATE.STALE; @@ -108,7 +111,7 @@ function InsightsDashboardContent({state, headlineChart, supportingCharts, filte if (state === INSIGHTS_DASHBOARD_STATE.NO_EXPENSES || state === INSIGHTS_DASHBOARD_STATE.EMPTY) { return ( diff --git a/src/pages/domain/DomainInitialPage.tsx b/src/pages/domain/DomainInitialPage.tsx index 56ddb6722ac1..fb526a6e7c24 100644 --- a/src/pages/domain/DomainInitialPage.tsx +++ b/src/pages/domain/DomainInitialPage.tsx @@ -2,6 +2,7 @@ import FullPageNotFoundView from '@components/BlockingViews/FullPageNotFoundView import HeaderWithBackButton from '@components/HeaderWithBackButton'; import HighlightableMenuItem from '@components/HighlightableMenuItem'; import NAVIGATION_TABS from '@components/Navigation/NavigationTabBar/NAVIGATION_TABS'; +import useTabBarContentInsetStyle from '@components/Navigation/TabBarBottomContent/useTabBarContentInsetStyle'; import useTabRootScreenWrapperProps from '@components/Navigation/TabBarBottomContent/useTabRootScreenWrapperProps'; import ScreenWrapper from '@components/ScreenWrapper'; import ScrollView from '@components/ScrollView'; @@ -44,6 +45,7 @@ function DomainInitialPage({route}: DomainInitialPageProps) { const icons = useMemoizedLazyExpensifyIcons(DOMAIN_MENU_ICON_NAMES); const styles = useThemeStyles(); const tabRootScreenWrapperProps = useTabRootScreenWrapperProps(NAVIGATION_TABS.WORKSPACES); + const contentContainerStyle = useTabBarContentInsetStyle(styles.flexColumn); const waitForNavigate = useWaitForNavigation(); const {singleExecution, isExecuting} = useSingleExecution(); const activeRoute = useNavigationState((state) => findFocusedRoute(state)?.name); @@ -122,7 +124,7 @@ function DomainInitialPage({route}: DomainInitialPageProps) { /> diff --git a/src/pages/home/HomePage.tsx b/src/pages/home/HomePage.tsx index ac4e33e4f4f2..e32d114bbff8 100644 --- a/src/pages/home/HomePage.tsx +++ b/src/pages/home/HomePage.tsx @@ -1,5 +1,6 @@ import NAVIGATION_TABS from '@components/Navigation/NavigationTabBar/NAVIGATION_TABS'; import QuickCreationActionsBar from '@components/Navigation/QuickCreationActionsBar'; +import useTabBarContentInsetStyle from '@components/Navigation/TabBarBottomContent/useTabBarContentInsetStyle'; import useTabRootScreenWrapperProps from '@components/Navigation/TabBarBottomContent/useTabRootScreenWrapperProps'; import TopBar from '@components/Navigation/TopBar'; import ReceiptScanDropZone from '@components/ReceiptScanDropZone'; @@ -43,6 +44,7 @@ function HomePage() { const {shouldUseNarrowLayout} = useResponsiveLayout(); const styles = useThemeStyles(); const tabRootScreenWrapperProps = useTabRootScreenWrapperProps(NAVIGATION_TABS.HOME); + const contentContainerStyle = useTabBarContentInsetStyle(styles.homePageContentContainer, true); const {translate} = useLocalize(); useDocumentTitle(translate('common.home')); const {isOffline} = useNetwork({onReconnect: openHomePage}); @@ -153,7 +155,7 @@ function HomePage() { style={styles.homePageScrollView} // Lets UIKit inset the end of the list past the translucent iOS tab bar that the content runs under. contentInsetAdjustmentBehavior="automatic" - contentContainerStyle={styles.homePageContentContainer} + contentContainerStyle={contentContainerStyle} addBottomSafeAreaPadding keyboardShouldPersistTaps="handled" > diff --git a/src/pages/settings/InitialSettingsPage.tsx b/src/pages/settings/InitialSettingsPage.tsx index fd6a522b520a..19be41b5a75b 100755 --- a/src/pages/settings/InitialSettingsPage.tsx +++ b/src/pages/settings/InitialSettingsPage.tsx @@ -2,6 +2,7 @@ import AccountSwitcher from '@components/AccountSwitcher'; import AccountSwitcherButton from '@components/AccountSwitcherButton'; import AccountSwitcherSkeletonView from '@components/AccountSwitcherSkeletonView'; import NAVIGATION_TABS from '@components/Navigation/NavigationTabBar/NAVIGATION_TABS'; +import useTabBarContentInsetStyle from '@components/Navigation/TabBarBottomContent/useTabBarContentInsetStyle'; import useTabRootScreenWrapperProps from '@components/Navigation/TabBarBottomContent/useTabRootScreenWrapperProps'; import TopBarWithLoadingBar from '@components/Navigation/TopBarWithLoadingBar'; import ScreenWrapper from '@components/ScreenWrapper'; @@ -52,6 +53,7 @@ function InitialSettingsPage({currentUserPersonalDetails}: InitialSettingsPagePr const [canSwitchAccounts = false] = useOnyx(ONYXKEYS.ACCOUNT, {selector: canSwitchAccountsSelector}); const styles = useThemeStyles(); const tabRootScreenWrapperProps = useTabRootScreenWrapperProps(NAVIGATION_TABS.SETTINGS); + const contentContainerStyle = useTabBarContentInsetStyle(styles.w100); const {isExecuting, singleExecution} = useSingleExecution(); const {translate} = useLocalize(); const focusedRouteName = useNavigationState((state) => findFocusedRoute(state)?.name); @@ -187,7 +189,7 @@ function InitialSettingsPage({currentUserPersonalDetails}: InitialSettingsPagePr contentInsetAdjustmentBehavior="automatic" onScroll={onScroll} scrollEventThrottle={CONST.TIMING.MIN_SMOOTH_SCROLL_EVENT_THROTTLE} - contentContainerStyle={[styles.w100]} + contentContainerStyle={contentContainerStyle} showsVerticalScrollIndicator={false} > {headerContent} diff --git a/src/pages/workspace/WorkspaceInitialPage.tsx b/src/pages/workspace/WorkspaceInitialPage.tsx index 1f5e8b3e202e..0456e3fcc2cc 100644 --- a/src/pages/workspace/WorkspaceInitialPage.tsx +++ b/src/pages/workspace/WorkspaceInitialPage.tsx @@ -2,6 +2,7 @@ import FullPageNotFoundView from '@components/BlockingViews/FullPageNotFoundView import HeaderWithBackButton from '@components/HeaderWithBackButton'; import HighlightableMenuItem from '@components/HighlightableMenuItem'; import NAVIGATION_TABS from '@components/Navigation/NavigationTabBar/NAVIGATION_TABS'; +import useTabBarContentInsetStyle from '@components/Navigation/TabBarBottomContent/useTabBarContentInsetStyle'; import useTabRootScreenWrapperProps from '@components/Navigation/TabBarBottomContent/useTabRootScreenWrapperProps'; import OfflineWithFeedback from '@components/OfflineWithFeedback'; import ScreenWrapper from '@components/ScreenWrapper'; @@ -69,6 +70,7 @@ function dismissError(policyID: string | undefined, pendingAction: PendingAction function WorkspaceInitialPage({policyDraft, policy: policyProp, route}: WorkspaceInitialPageProps) { const styles = useThemeStyles(); const tabRootScreenWrapperProps = useTabRootScreenWrapperProps(NAVIGATION_TABS.WORKSPACES); + const contentContainerStyle = useTabBarContentInsetStyle([styles.flexColumn, styles.pb14]); const {shouldUseNarrowLayout} = useResponsiveLayout(); const {translate} = useLocalize(); const {convertToDisplayString} = useCurrencyListActions(); @@ -248,7 +250,7 @@ function WorkspaceInitialPage({policyDraft, policy: policyProp, route}: Workspac /> diff --git a/src/styles/index.ts b/src/styles/index.ts index 8959a151f370..4f02a3b8a223 100644 --- a/src/styles/index.ts +++ b/src/styles/index.ts @@ -774,6 +774,15 @@ const staticStyles = (theme: ThemeColors) => height: variables.androidNativeTabBarHeight, }, + // Lays a tab root screen's copy of the floating bar over the bottom of its content. + floatingTabBarOverlay: { + position: 'absolute', + right: 0, + bottom: 0, + left: 0, + overflow: 'visible', + }, + // The gap between the floating bar and the bottom edge of the screen. floatingTabBarBottomInset: { paddingBottom: variables.floatingTabBarBottomInset, From db655b4f0a6b83ecb9acc4b4a0b1524ff61ef4e4 Mon Sep 17 00:00:00 2001 From: sumo_slonik Date: Mon, 5 Oct 2026 11:52:44 +0200 Subject: [PATCH 35/71] fix: draw iOS tab glyphs as Skia vector paths so resized icons stay smooth --- .../NativeTabNavigator/NATIVE_TAB_GLYPHS.ts | 83 +++++++++++++++++++ .../NativeTabNavigator/tabIconRasterizer.ts | 22 ++--- .../NativeTabNavigator/useAndroidTabIcons.ts | 4 +- .../NativeTabNavigator/useIOSTabIcons.ts | 56 +++++++++---- 4 files changed, 137 insertions(+), 28 deletions(-) create mode 100644 src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/NATIVE_TAB_GLYPHS.ts diff --git a/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/NATIVE_TAB_GLYPHS.ts b/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/NATIVE_TAB_GLYPHS.ts new file mode 100644 index 000000000000..9b5ba3d83ab3 --- /dev/null +++ b/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/NATIVE_TAB_GLYPHS.ts @@ -0,0 +1,83 @@ +import NAVIGATORS from '@src/NAVIGATORS'; +import SCREENS from '@src/SCREENS'; + +import type NATIVE_TAB_ICONS from './NATIVE_TAB_ICONS'; + +type NativeTabGlyphPath = { + d: string; + isEvenOdd?: boolean; +}; + +type NativeTabGlyph = { + /** Side of the square viewBox the paths are drawn in. */ + viewBoxSize: number; + paths: NativeTabGlyphPath[]; +}; + +/** + * Vector copies of the tab bar SVGs in `assets/images`, so Skia draws each glyph at the bar's size instead of resampling + * a bitmap. They have to be kept in sync with those files. + */ +const NATIVE_TAB_GLYPHS = { + // home.svg + [SCREENS.HOME]: { + viewBoxSize: 20, + paths: [ + { + d: 'M9.386 1.477a1 1 0 0 1 1.229 0l7.613 5.922c.487.379.772.962.772 1.579V18a1 1 0 0 1-1 1h-5v-6a1 1 0 0 0-1-1H8a1 1 0 0 0-1 1v6H2a1 1 0 0 1-1-1V8.978c0-.617.285-1.2.772-1.579z', + }, + ], + }, + // inbox.svg + [NAVIGATORS.REPORTS_SPLIT_NAVIGATOR]: { + viewBoxSize: 20, + paths: [ + { + d: 'M4.5 1.5a3 3 0 0 0-3 3v11a3 3 0 0 0 3 3h11a3 3 0 0 0 3-3v-11a3 3 0 0 0-3-3zm-1 3a1 1 0 0 1 1-1h11a1 1 0 0 1 1 1V12H13c-.276 0-.495.226-.549.497a2.5 2.5 0 0 1-4.902 0C7.494 12.226 7.276 12 7 12H3.5z', + isEvenOdd: true, + }, + ], + }, + // receipt-multiple.svg + [NAVIGATORS.SEARCH_FULLSCREEN_NAVIGATOR]: { + viewBoxSize: 20, + paths: [ + { + d: 'M9.72 1.24c.12-.18.36-.21.52-.07l1.15 1c.16.14.41.1.52-.07l.54-.83a.35.35 0 0 1 .64.14l2.18 15.49c.04.32-.33.52-.58.31l-.75-.65a.35.35 0 0 0-.52.07l-.84 1.28c-.12.18-.36.21-.52.07l-1.16-1a.35.35 0 0 0-.52.07l-.84 1.28c-.12.18-.36.21-.52.07l-1.15-1a.35.35 0 0 0-.52.07l-.83 1.28c-.12.18-.36.21-.52.07l-1.16-1a.35.35 0 0 0-.52.07l-.54.83a.35.35 0 0 1-.64-.14L.97 3.11c-.04-.32.33-.52.58-.31l.75.65c.16.14.41.1.52-.07l.83-1.28c.12-.18.36-.21.52-.07l1.15 1c.16.14.41.1.52-.07l.83-1.28c.12-.18.36-.21.52-.07l1.15 1c.16.14.41.1.52-.07l.83-1.28Zm1.37 10.99-5.2.73a.87.87 0 0 0-.74.99c.07.48.51.81.99.75l5.2-.73c.48-.07.81-.51.75-.99a.886.886 0 0 0-.99-.75ZM8.87 9.01 5.4 9.5c-.48.07-.81.51-.75.99.07.48.51.81.99.75l3.47-.49c.48-.07.81-.51.75-.99a.87.87 0 0 0-.99-.74Zm1.24-3.71-5.2.73c-.48.07-.81.51-.75.99.07.48.51.81.99.75l5.2-.73a.87.87 0 0 0 .74-.99.886.886 0 0 0-.99-.75Z', + isEvenOdd: true, + }, + { + d: 'M15.15 3.09c.1.16.32.19.46.07l1.03-.89c.14-.12.36-.09.46.06l.74 1.14c.1.16.32.19.46.07l.67-.58c.22-.19.55 0 .51.28l-1.94 13.79c-.04.28-.41.37-.57.13l-.49-.74s-.02-.03-.04-.04L14.42 2.03l.7 1.07Z', + }, + ], + }, + // pie-chart.svg + [SCREENS.INSIGHTS]: { + viewBoxSize: 24, + paths: [ + {d: 'M15.598.074C14.94-.008 14.4.537 14.4 1.2v8.4h8.4c.663 0 1.208-.54 1.126-1.198A9.605 9.605 0 0 0 15.598.074'}, + {d: 'M22.734 13.198C22.138 18.6 17.56 22.8 12 22.8 6.035 22.8 1.2 17.965 1.2 12c0-5.56 4.2-10.138 9.602-10.734C11.46 1.193 12 1.737 12 2.4V12h9.6c.663 0 1.207.54 1.134 1.198'}, + ], + }, + // buildings.svg + [NAVIGATORS.WORKSPACE_NAVIGATOR]: { + viewBoxSize: 20, + paths: [ + { + d: 'M1 3a2 2 0 0 1 2-2h6a2 2 0 0 1 2 2v2h6a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2H8v-3a1 1 0 0 0-1-1H5a1 1 0 0 0-1 1v3H3a2 2 0 0 1-2-2zm2 1a1 1 0 1 1 2 0 1 1 0 0 1-2 0m0 4a1 1 0 1 1 2 0 1 1 0 0 1-2 0m1 3a1 1 0 1 0 0 2 1 1 0 0 0 0-2m3-7a1 1 0 1 1 2 0 1 1 0 0 1-2 0m1 3a1 1 0 1 0 0 2 1 1 0 0 0 0-2m-1 5a1 1 0 1 1 2 0 1 1 0 0 1-2 0m5-5a1 1 0 1 0 0 2 1 1 0 0 0 0-2m3 1a1 1 0 1 1 2 0 1 1 0 0 1-2 0m1 3a1 1 0 1 0 0 2 1 1 0 0 0 0-2m-1 5a1 1 0 1 1 2 0 1 1 0 0 1-2 0m-3-5a1 1 0 1 0 0 2 1 1 0 0 0 0-2m-1 5a1 1 0 1 1 2 0 1 1 0 0 1-2 0', + isEvenOdd: true, + }, + ], + }, + // profile.svg, with its circle written as a path + [NAVIGATORS.SETTINGS_SPLIT_NAVIGATOR]: { + viewBoxSize: 20, + paths: [ + {d: 'M13 7a3 3 0 1 1-6 0 3 3 0 0 1 6 0z'}, + {d: 'M10 1c-5 0-9 4-9 9s4 9 9 9 9-4 9-9-4-9-9-9m5.9 12.7C14.6 12.1 12.4 11 10 11s-4.6 1.1-5.9 2.7C3.4 12.6 3 11.4 3 10c0-3.9 3.1-7 7-7s7 3.1 7 7c0 1.4-.4 2.6-1.1 3.7'}, + ], + }, +} as const satisfies Record; + +export default NATIVE_TAB_GLYPHS; +export type {NativeTabGlyph}; diff --git a/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/tabIconRasterizer.ts b/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/tabIconRasterizer.ts index d2b534a9c004..94d7cf7d8787 100644 --- a/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/tabIconRasterizer.ts +++ b/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/tabIconRasterizer.ts @@ -1,15 +1,19 @@ +import ObjectUtils from '@src/types/utils/ObjectUtils'; + import type {NativeBottomTabIcon} from '@react-navigation/bottom-tabs/unstable'; import type {SkCanvas, SkImage, SkSurface} from '@shopify/react-native-skia'; import type {ImageSourcePropType} from 'react-native'; -import {BlendMode, ClipOp, ImageFormat, Skia} from '@shopify/react-native-skia'; +import {BlendMode, ClipOp, FilterMode, ImageFormat, MipmapMode, Skia} from '@shopify/react-native-skia'; import {Image} from 'react-native'; import NATIVE_TAB_ICONS from './NATIVE_TAB_ICONS'; type TabIconPair = {active: NativeBottomTabIcon; inactive: NativeBottomTabIcon}; -const TAB_ICONS = Object.entries(NATIVE_TAB_ICONS).map(([name, icon]) => [name, icon.source] as const); +type NativeTabName = keyof typeof NATIVE_TAB_ICONS; + +const TAB_NAMES = ObjectUtils.typedKeys(NATIVE_TAB_ICONS); /** Skia reads URLs, file paths and, in Android release builds, bundled assets that resolve to a drawable name. */ async function decodeImage(uri: string): Promise { @@ -39,10 +43,12 @@ function drawCircularImage(canvas: SkCanvas, image: SkImage, left: number, top: canvas.save(); canvas.clipPath(circle, ClipOp.Intersect, true); - canvas.drawImageRect( + canvas.drawImageRectOptions( image, Skia.XYWHRect((image.width() - sourceSize) / 2, (image.height() - sourceSize) / 2, sourceSize, sourceSize), Skia.XYWHRect(left, top, size, size), + FilterMode.Linear, + MipmapMode.Linear, Skia.Paint(), ); canvas.restore(); @@ -58,12 +64,8 @@ function encodeSurface(surface: SkSurface): string { return `data:image/png;base64,${base64}`; } -async function drawTabIconPairs( - drawIcon: (name: string, source: ImageSourcePropType, isSelected: boolean) => Promise, -): Promise> { - const results = await Promise.all( - TAB_ICONS.map(([name, source]) => Promise.all([drawIcon(name, source, false), drawIcon(name, source, true)]).then(([inactive, active]) => ({name, inactive, active}))), - ); +async function drawTabIconPairs(drawIcon: (name: NativeTabName, isSelected: boolean) => Promise): Promise> { + const results = await Promise.all(TAB_NAMES.map((name) => Promise.all([drawIcon(name, false), drawIcon(name, true)]).then(([inactive, active]) => ({name, inactive, active})))); const icons: Record = {}; for (const result of results) { if (result.inactive && result.active) { @@ -73,7 +75,7 @@ async function drawTabIconPairs( return icons; } -function getTabBarIcon(icons: Record | undefined, name: keyof typeof NATIVE_TAB_ICONS) { +function getTabBarIcon(icons: Record | undefined, name: NativeTabName) { const pair = icons?.[name]; if (!pair) { return NATIVE_TAB_ICONS[name]; diff --git a/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/useAndroidTabIcons.ts b/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/useAndroidTabIcons.ts index e51b9dbc06fb..c43e1d36aad3 100644 --- a/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/useAndroidTabIcons.ts +++ b/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/useAndroidTabIcons.ts @@ -10,10 +10,10 @@ import type {ImageSourcePropType} from 'react-native'; import {Skia} from '@shopify/react-native-skia'; -import type NATIVE_TAB_ICONS from './NATIVE_TAB_ICONS'; import type {TabIconPair} from './tabIconRasterizer'; import type {LastDrawnTabIcons} from './useRasterizedTabIcons'; +import NATIVE_TAB_ICONS from './NATIVE_TAB_ICONS'; import {createTintPaint, decodeGlyph, decodeImage, drawCircularImage, drawTabIconPairs, encodeSurface, getTabBarIcon} from './tabIconRasterizer'; import useRasterizedTabIcons from './useRasterizedTabIcons'; @@ -76,7 +76,7 @@ function useAndroidTabIcons() { const tintedIcons = useRasterizedTabIcons( lastTintedIcons, [inactiveColor, activeColor].join('|'), - () => drawTabIconPairs((name, source, isSelected) => createTintedGlyph(source, isSelected ? activeColor : inactiveColor)), + () => drawTabIconPairs((name, isSelected) => createTintedGlyph(NATIVE_TAB_ICONS[name].source, isSelected ? activeColor : inactiveColor)), {}, ); diff --git a/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/useIOSTabIcons.ts b/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/useIOSTabIcons.ts index ec4cd48a7df8..bbb10095ac77 100644 --- a/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/useIOSTabIcons.ts +++ b/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/useIOSTabIcons.ts @@ -10,15 +10,16 @@ import NAVIGATORS from '@src/NAVIGATORS'; import type {NativeBottomTabIcon} from '@react-navigation/bottom-tabs/unstable'; import type {SkCanvas} from '@shopify/react-native-skia'; -import type {ImageSourcePropType} from 'react-native'; -import {FontWeight, Skia} from '@shopify/react-native-skia'; +import {FillType, FontWeight, Skia} from '@shopify/react-native-skia'; +import type {NativeTabGlyph} from './NATIVE_TAB_GLYPHS'; import type NATIVE_TAB_ICONS from './NATIVE_TAB_ICONS'; import type {TabIconPair} from './tabIconRasterizer'; import type {LastDrawnTabIcons} from './useRasterizedTabIcons'; -import {createTintPaint, decodeGlyph, decodeImage, drawCircularImage, drawTabIconPairs, encodeSurface, getTabBarIcon} from './tabIconRasterizer'; +import NATIVE_TAB_GLYPHS from './NATIVE_TAB_GLYPHS'; +import {decodeImage, drawCircularImage, drawTabIconPairs, encodeSurface, getTabBarIcon} from './tabIconRasterizer'; import useRasterizedTabIcons from './useRasterizedTabIcons'; /** The account tab's avatar in both selection states, with the URI it was drawn from. */ @@ -34,6 +35,29 @@ function drawStatusDot(canvas: SkCanvas, glyphRight: number, glyphTop: number, s canvas.drawCircle(glyphRight - radius, glyphTop + radius, radius, paint); } +function drawGlyph(canvas: SkCanvas, glyph: NativeTabGlyph, left: number, top: number, size: number, color: string) { + const paint = Skia.Paint(); + paint.setColor(Skia.Color(color)); + paint.setAntiAlias(true); + const viewBoxScale = size / glyph.viewBoxSize; + + canvas.save(); + canvas.translate(left, top); + canvas.scale(viewBoxScale, viewBoxScale); + for (const {d, isEvenOdd} of glyph.paths) { + const path = Skia.Path.MakeFromSVGString(d); + if (!path) { + continue; + } + if (isEvenOdd) { + path.setFillType(FillType.EvenOdd); + } + canvas.drawPath(path, paint); + path.dispose(); + } + canvas.restore(); +} + function getLabelFont(isSelected: boolean, scale: number) { const typeface = Skia.FontMgr.System().matchFamilyStyle(FontUtils.fontFamily.single.EXP_NEUE.fontFamily, { weight: isSelected ? FontWeight.Bold : FontWeight.Normal, @@ -63,24 +87,19 @@ function measureLabel(label: string, isSelected: boolean, scale: number) { /** * iOS 26 never applies the inactive icon color from `UITabBarItemAppearance`, and paints every badge in the color of - * the selected tab (both `badgeBackgroundColor` and `UITabBarItem.badgeColor`), so the glyph is recolored and the + * the selected tab (both `badgeBackgroundColor` and `UITabBarItem.badgeColor`), so the glyph is drawn in its color and the * status dot drawn into the image. Both selection states go through this, because RNScreens rejects a tab whose * icon and selectedIcon differ in type. */ async function createTabIcon( - source: ImageSourcePropType, + glyph: NativeTabGlyph, color: string, dotColor: string | undefined, label: string, labelColor: string, isSelected: boolean, ): Promise { - const glyph = await decodeGlyph(source); - if (!glyph) { - return undefined; - } - - const {image, scale} = glyph; + const scale = variables.nativeTabIconScale; const glyphSize = variables.iconNativeTabBarIOS * scale; // The account avatar is taller than a glyph, so every glyph sits this far down to share its centre, and // every label lands at the same height. @@ -92,20 +111,18 @@ async function createTabIcon( const surface = Skia.Surface.MakeOffscreen(canvasWidth, canvasHeight); if (!surface) { - image.dispose(); return undefined; } const canvas = surface.getCanvas(); const glyphLeft = (canvasWidth - glyphSize) / 2; - canvas.drawImageRect(image, Skia.XYWHRect(0, 0, image.width(), image.height()), Skia.XYWHRect(glyphLeft, glyphTop, glyphSize, glyphSize), createTintPaint(color)); + drawGlyph(canvas, glyph, glyphLeft, glyphTop, glyphSize, color); if (dotColor) { drawStatusDot(canvas, glyphLeft + glyphSize, glyphTop, scale, dotColor); } drawLabel(canvas, label, labelColor, isSelected, scale, canvasWidth, glyphTop + glyphSize + gap); - image.dispose(); return {type: 'image', source: {uri: encodeSurface(surface), width: canvasWidth / scale, height: canvasHeight / scale, scale}, tinted: false}; } @@ -160,8 +177,15 @@ function useIOSTabIcons(dotColors: Record, tabLabels lastTintedIcons, iconsSignature, () => - drawTabIconPairs((name, source, isSelected) => - createTabIcon(source, isSelected ? theme.iconMenu : theme.icon, dotColors[name], tabLabels[name], isSelected ? theme.text : theme.textSupporting, isSelected), + drawTabIconPairs((name, isSelected) => + createTabIcon( + NATIVE_TAB_GLYPHS[name], + isSelected ? theme.iconMenu : theme.icon, + dotColors[name], + tabLabels[name], + isSelected ? theme.text : theme.textSupporting, + isSelected, + ), ), {}, ); From 124600e45f946acb1a622267869f7f889078a7f6 Mon Sep 17 00:00:00 2001 From: sumo_slonik Date: Mon, 5 Oct 2026 11:52:46 +0200 Subject: [PATCH 36/71] fix: bold the selected Android tab label and tighten label letter spacing --- patches/react-native-screens/details.md | 9 ++++- ...s+4.28.0+002+bold-selected-tab-label.patch | 35 ++++++++++--------- ...004+android-tab-label-letter-spacing.patch | 30 ++++++++++++++++ 3 files changed, 57 insertions(+), 17 deletions(-) create mode 100644 patches/react-native-screens/react-native-screens+4.28.0+004+android-tab-label-letter-spacing.patch diff --git a/patches/react-native-screens/details.md b/patches/react-native-screens/details.md index dd61413a37ac..cdb1ee57cb0a 100644 --- a/patches/react-native-screens/details.md +++ b/patches/react-native-screens/details.md @@ -9,7 +9,7 @@ ### [react-native-screens+4.28.0+002+bold-selected-tab-label.patch](react-native-screens+4.28.0+002+bold-selected-tab-label.patch) -- Reason: The Android tab bar takes one font weight for every label, while the design marks the selected tab with a bold label, the same way the JS side bar does. Material's `BottomNavigationView` draws each item with two labels, a small one shown while unselected and a large one shown while selected, so the patch gives the large label the bold weight of the same font family. +- Reason: The Android tab bar takes one font weight for every label, while the design marks the selected tab with a bold label, the same way the JS side bar does. Material's `BottomNavigationView` draws each item with two labels, a small one shown while unselected and a large one shown while selected. The patch turns on Material's own `setItemTextAppearanceActiveBoldEnabled`, which keeps the large label bold whenever Material re-applies its text appearance, and calls it before `updateFontStyles`, because enabling it re-applies Material's text appearance and would otherwise replace the app's typeface. `updateFontStyles` then sets the large label's typeface with `Typeface.BOLD`, the way Material applies its own bold, so Android picks the bold face of the same font family. - Upstream PR/issue: not reported yet. - E/App issue: [#101169](https://github.com/Expensify/App/issues/101169) - PR introducing patch: [#101339](https://github.com/Expensify/App/pull/101339) @@ -20,3 +20,10 @@ - Upstream PR/issue: not reported yet. - E/App issue: [#101169](https://github.com/Expensify/App/issues/101169) - PR introducing patch: [#101339](https://github.com/Expensify/App/pull/101339) + +### [react-native-screens+4.28.0+004+android-tab-label-letter-spacing.patch](react-native-screens+4.28.0+004+android-tab-label-letter-spacing.patch) + +- Reason: `TabsAppearanceApplicator.updateFontStyles` replaces the typeface and size of Material's tab labels but keeps the rest of Material 3's `LabelMedium` text appearance, which tracks letters 0.5sp apart. With Expensify Neue the labels read too spread out next to the rest of the app. React Navigation has no option that reaches the native label's letter spacing, so the patch sets a fixed -0.5dp tracking on both labels, converted to the `em` unit `TextView.letterSpacing` expects for each label's size. +- Upstream PR/issue: not reported yet. +- E/App issue: [#101169](https://github.com/Expensify/App/issues/101169) +- PR introducing patch: [#101339](https://github.com/Expensify/App/pull/101339) diff --git a/patches/react-native-screens/react-native-screens+4.28.0+002+bold-selected-tab-label.patch b/patches/react-native-screens/react-native-screens+4.28.0+002+bold-selected-tab-label.patch index a5287c5822ec..b01db811eef2 100644 --- a/patches/react-native-screens/react-native-screens+4.28.0+002+bold-selected-tab-label.patch +++ b/patches/react-native-screens/react-native-screens+4.28.0+002+bold-selected-tab-label.patch @@ -1,28 +1,31 @@ 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 --- 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 -@@ -145,6 +145,15 @@ - context.assets, - ) +@@ -3,6 +3,7 @@ + import android.annotation.SuppressLint + import android.content.Context + import android.content.res.ColorStateList ++import android.graphics.Typeface + import android.graphics.drawable.StateListDrawable + import android.util.TypedValue + import android.view.MenuItem +@@ -95,6 +96,10 @@ -+ // The selected tab's label is drawn bold, the way the app marks a selected tab everywhere else. -+ val activeFontFamily = -+ ReactFontManager.getInstance().getTypeface( -+ tabBarAppearance?.tabBarItemTitleFontFamily ?: "", -+ maxOf(fontWeight, 700), -+ isFontStyleItalic, -+ context.assets, -+ ) + bottomNavigationView.labelVisibilityMode = visibilityMode + ++ // The selected tab's label is drawn bold, the way the app marks a selected tab everywhere else. Material re-applies ++ // its text appearance when enabling this, so it has to run before `updateFontStyles` sets the app's typeface. ++ bottomNavigationView.setItemTextAppearanceActiveBoldEnabled(true) + - /* - Short explanation about computations we're doing below. - R.dimen, has defined value in SP, getDimension converts it to pixels, and by default -@@ -167,7 +176,7 @@ + // Ripple color + val rippleColor = + tabBarAppearance?.tabBarItemRippleColor +@@ -167,7 +172,7 @@ // Active largeLabel.setTextSize(TypedValue.COMPLEX_UNIT_PX, largeFontSize) - largeLabel.typeface = fontFamily -+ largeLabel.typeface = activeFontFamily ++ largeLabel.setTypeface(fontFamily, Typeface.BOLD) } } diff --git a/patches/react-native-screens/react-native-screens+4.28.0+004+android-tab-label-letter-spacing.patch b/patches/react-native-screens/react-native-screens+4.28.0+004+android-tab-label-letter-spacing.patch new file mode 100644 index 000000000000..4986b3563f36 --- /dev/null +++ b/patches/react-native-screens/react-native-screens+4.28.0+004+android-tab-label-letter-spacing.patch @@ -0,0 +1,30 @@ +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 +--- 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 +@@ -152,13 +152,18 @@ + tabBarAppearance?.tabBarItemTitleLargeLabelFontSize?.takeIf { it > 0 }?.let { PixelUtil.toPixelFromSP(it) } + ?: context.resources.getDimension(R.dimen.design_bottom_navigation_text_size) + ++ // Material's label text appearance tracks letters 0.5sp apart, wider than the app's labels. ++ val labelTracking = PixelUtil.toPixelFromDIP(LABEL_TRACKING_DP) ++ + // Inactive + smallLabel.setTextSize(TypedValue.COMPLEX_UNIT_PX, smallFontSize) + smallLabel.typeface = fontFamily ++ smallLabel.letterSpacing = labelTracking / smallFontSize + + // Active + largeLabel.setTextSize(TypedValue.COMPLEX_UNIT_PX, largeFontSize) + largeLabel.setTypeface(fontFamily, Typeface.BOLD) ++ largeLabel.letterSpacing = labelTracking / largeFontSize + } + } + +@@ -228,5 +233,7 @@ + + companion object { + const val TAG = "TabsAppearanceApplicator" ++ ++ private const val LABEL_TRACKING_DP = -0.5f + } + } From 92918cfd3a9dde8e84b16d0194f00fee37883ca0 Mon Sep 17 00:00:00 2001 From: sumo_slonik Date: Mon, 5 Oct 2026 13:02:33 +0200 Subject: [PATCH 37/71] perf: freeze background native tabs, which have no freezeOnBlur --- ...tom-tabs+7.16.2+003+hidden-tab-items.patch | 32 ++++++++--------- patches/react-navigation/details.md | 2 +- .../FreezeWrapper/index.native.tsx | 3 +- .../nativeTabScreenLayout.tsx | 36 +++++++++++++++++++ .../Navigators/TabNavigator.native.tsx | 4 +-- 5 files changed, 54 insertions(+), 23 deletions(-) create mode 100644 src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/nativeTabScreenLayout.tsx diff --git a/patches/react-navigation/@react-navigation+bottom-tabs+7.16.2+003+hidden-tab-items.patch b/patches/react-navigation/@react-navigation+bottom-tabs+7.16.2+003+hidden-tab-items.patch index 7f4710b1bc8a..744455116a73 100644 --- a/patches/react-navigation/@react-navigation+bottom-tabs+7.16.2+003+hidden-tab-items.patch +++ b/patches/react-navigation/@react-navigation+bottom-tabs+7.16.2+003+hidden-tab-items.patch @@ -2,13 +2,12 @@ diff --git a/node_modules/@react-navigation/bottom-tabs/lib/module/unstable/Nati index 303635c..6ccaad5 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 -@@ -4,11 +4,12 @@ import { getLabel, SafeAreaProviderCompat, Screen as ScreenContent } from '@reac +@@ -4,11 +4,11 @@ import { getLabel, SafeAreaProviderCompat, Screen as ScreenContent } from '@reac import { CommonActions, NavigationMetaContext, StackActions, useTheme } from '@react-navigation/native'; import Color from 'color'; import * as React from 'react'; -import { Platform, PlatformColor } from 'react-native'; +import { Platform, PlatformColor, StyleSheet, View } from 'react-native'; -+import { Freeze } from 'react-freeze'; import { Tabs } from 'react-native-screens'; import { Deferred } from "./Deferred.js"; import { NativeScreen } from "./NativeScreen/NativeScreen.js"; @@ -17,7 +16,7 @@ index 303635c..6ccaad5 100644 const meta = { type: 'native-tabs' }; -@@ -32,9 +33,23 @@ export function NativeBottomTabView({ +@@ -32,9 +32,23 @@ export function NativeBottomTabView({ fonts } = useTheme(); const focusedRouteKey = state.routes[state.index].key; @@ -30,7 +29,7 @@ index 303635c..6ccaad5 100644 + lastVisibleRouteKeyRef.current = focusedRouteKey; + } + const nativeSelectedRouteKey = isFocusedRouteHidden ? lastVisibleRouteKeyRef.current ?? state.routes.find(route => !isRouteHidden(route))?.key ?? focusedRouteKey : focusedRouteKey; -+ // Hidden routes stay mounted once visited, frozen while another tab is focused, the same as the native tabs. ++ // Hidden routes stay mounted once visited, the same as the native tabs. + const visitedHiddenRouteKeysRef = React.useRef(new Set()); + if (isFocusedRouteHidden) { + visitedHiddenRouteKeysRef.current.add(focusedRouteKey); @@ -42,7 +41,7 @@ index 303635c..6ccaad5 100644 provenance: 0 } }); -@@ -189,17 +204,17 @@ export function NativeBottomTabView({ +@@ -189,17 +203,17 @@ 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; @@ -64,7 +63,7 @@ index 303635c..6ccaad5 100644 colorScheme: dark ? 'dark' : 'light', ios: { bottomAccessory: bottomAccessory ? environment => bottomAccessory({ -@@ -210,6 +225,9 @@ export function NativeBottomTabView({ +@@ -210,6 +224,9 @@ export function NativeBottomTabView({ tabBarTintColor: activeTintColor }, children: state.routes.map((route, index) => { @@ -74,7 +73,7 @@ index 303635c..6ccaad5 100644 const { options, render, -@@ -363,9 +381,43 @@ export function NativeBottomTabView({ +@@ -363,9 +380,40 @@ export function NativeBottomTabView({ }) }, route.key); }) @@ -94,17 +93,14 @@ index 303635c..6ccaad5 100644 + backgroundColor: colors.background + }, !isFocused && styles.hiddenRoute], + pointerEvents: isFocused ? 'auto' : 'none', -+ children: /*#__PURE__*/_jsx(Freeze, { -+ freeze: !isFocused, -+ children: /*#__PURE__*/_jsx(ScreenWithHeader, { -+ isFocused: isFocused, -+ route: route, -+ navigation: navigation, -+ options: options, -+ children: /*#__PURE__*/_jsx(NavigationMetaContext.Provider, { -+ value: meta, -+ children: render() -+ }) ++ children: /*#__PURE__*/_jsx(ScreenWithHeader, { ++ isFocused: isFocused, ++ route: route, ++ navigation: navigation, ++ options: options, ++ children: /*#__PURE__*/_jsx(NavigationMetaContext.Provider, { ++ value: meta, ++ children: render() + }) + }) + }, route.key); diff --git a/patches/react-navigation/details.md b/patches/react-navigation/details.md index 5ba1a6ebee87..bff313fe3264 100644 --- a/patches/react-navigation/details.md +++ b/patches/react-navigation/details.md @@ -78,7 +78,7 @@ ### [@react-navigation+bottom-tabs+7.16.2+003+hidden-tab-items.patch](@react-navigation+bottom-tabs+7.16.2+003+hidden-tab-items.patch) -- Reason: Adds a `tabBarItemHidden` option to the native bottom tabs. The App registers six tabs but the native bar can show at most five (Material's `BottomNavigationView` throws past five, and `UITabBar` folds the rest into a "More" tab), and which five depends on the Insights beta: Insights with it, Account without it. A native tab bar cannot hide one of its items, so a route with `tabBarItemHidden` gets no `Tabs.Screen` at all. When such a route is focused, it is drawn in JS over the native host with the tab bar hidden, while the host keeps the last focused visible tab selected underneath. A hidden route stays mounted once visited and is frozen while another tab is focused. Nothing native changes. +- Reason: Adds a `tabBarItemHidden` option to the native bottom tabs. The App registers six tabs but the native bar can show at most five (Material's `BottomNavigationView` throws past five, and `UITabBar` folds the rest into a "More" tab), and which five depends on the Insights beta: Insights with it, Account without it. A native tab bar cannot hide one of its items, so a route with `tabBarItemHidden` gets no `Tabs.Screen` at all. When such a route is focused, it is drawn in JS over the native host with the tab bar hidden, while the host keeps the last focused visible tab selected underneath. A hidden route stays mounted once visited. Like every other tab, it is frozen by the App's tab screen layout while another tab is focused. Nothing native changes. - Upstream PR/issue: not reported yet. - E/App issue: [#101169](https://github.com/Expensify/App/issues/101169) - PR Introducing Patch: [#101339](https://github.com/Expensify/App/pull/101339) diff --git a/src/libs/Navigation/AppNavigator/FreezeWrapper/index.native.tsx b/src/libs/Navigation/AppNavigator/FreezeWrapper/index.native.tsx index 50672780db45..3f833f9216d1 100644 --- a/src/libs/Navigation/AppNavigator/FreezeWrapper/index.native.tsx +++ b/src/libs/Navigation/AppNavigator/FreezeWrapper/index.native.tsx @@ -1,7 +1,6 @@ import type ChildrenProps from '@src/types/utils/ChildrenProps'; -// On native, tab-level freezing is handled by the TabNavigator's `freezeOnBlur: true` option, -// which uses react-native-screens to natively freeze inactive tabs. +// On native, tab-level freezing is handled by the TabNavigator's screen layout (nativeTabScreenLayout). // On web, react-native-screens only sets `display: none` without actually freezing the React tree, // so the web FreezeWrapper uses `react-freeze` to suspend rendering of background tabs. function FreezeWrapper({children}: ChildrenProps) { diff --git a/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/nativeTabScreenLayout.tsx b/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/nativeTabScreenLayout.tsx new file mode 100644 index 000000000000..af7c533db7e4 --- /dev/null +++ b/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/nativeTabScreenLayout.tsx @@ -0,0 +1,36 @@ +import {bottomTabScreenLayoutWrapper} from '@libs/Navigation/PlatformStackNavigation/ScreenLayout'; + +import type ChildrenProps from '@src/types/utils/ChildrenProps'; + +import {useIsFocused} from '@react-navigation/native'; +import React, {useLayoutEffect, useState} from 'react'; +import {Freeze} from 'react-freeze'; + +/** + * Native tabs have no `freezeOnBlur`, so a tab in the background would keep re-rendering on every Onyx update. + * A blurred tab freezes one frame after it loses focus, so a tab mounted in the background still renders once. + */ +function NativeTabScreenFreeze({children}: ChildrenProps) { + const isFocused = useIsFocused(); + const [isFrozen, setIsFrozen] = useState(false); + + // Decouple the Suspense render task so it won't be interrupted by React's concurrent mode and stuck in an infinite loop + useLayoutEffect(() => { + if (isFocused) { + // isFocused is the only dependency and does not change as a result of this setState. + // eslint-disable-next-line react-hooks/set-state-in-effect + setIsFrozen(false); + return; + } + const frameID = requestAnimationFrame(() => setIsFrozen(true)); + return () => cancelAnimationFrame(frameID); + }, [isFocused]); + + return {children}; +} + +function nativeTabScreenLayout({children, ...rest}: Parameters[0]) { + return bottomTabScreenLayoutWrapper({...rest, children: {children}}); +} + +export default nativeTabScreenLayout; diff --git a/src/libs/Navigation/AppNavigator/Navigators/TabNavigator.native.tsx b/src/libs/Navigation/AppNavigator/Navigators/TabNavigator.native.tsx index 3be4da307cad..a3c449ce89ad 100644 --- a/src/libs/Navigation/AppNavigator/Navigators/TabNavigator.native.tsx +++ b/src/libs/Navigation/AppNavigator/Navigators/TabNavigator.native.tsx @@ -1,4 +1,3 @@ -import {bottomTabScreenLayoutWrapper} from '@libs/Navigation/PlatformStackNavigation/ScreenLayout'; import type {TabNavigatorParamList} from '@libs/Navigation/types'; import HomePage from '@pages/home/HomePage'; @@ -14,6 +13,7 @@ import React from 'react'; import type {NativeTabLayoutProps} from './NativeTabNavigator/NativeTabLayout'; import NativeTabLayout from './NativeTabNavigator/NativeTabLayout'; +import nativeTabScreenLayout from './NativeTabNavigator/nativeTabScreenLayout'; import tabScreenListeners from './NativeTabNavigator/tabScreenListeners'; import useNativeTabBarOptions from './NativeTabNavigator/useNativeTabBarOptions'; import useNativeTabNavigator from './NativeTabNavigator/useNativeTabNavigator'; @@ -39,7 +39,7 @@ function TabNavigator() { Date: Mon, 5 Oct 2026 13:03:23 +0200 Subject: [PATCH 38/71] perf: give tab bars the Inbox status alone, so LHN updates do not re-render the native tab navigator --- .../Navigation/NavigationTabBar/InboxTabButton.tsx | 4 ++-- src/hooks/useSidebarOrderedReports.tsx | 13 +++++++++++-- .../NativeTabNavigator/useNativeTabNavigator.ts | 4 ++-- tests/unit/InboxTabButtonTest.tsx | 2 +- 4 files changed, 16 insertions(+), 7 deletions(-) diff --git a/src/components/Navigation/NavigationTabBar/InboxTabButton.tsx b/src/components/Navigation/NavigationTabBar/InboxTabButton.tsx index 1476cc214c74..5d5fdcd82215 100644 --- a/src/components/Navigation/NavigationTabBar/InboxTabButton.tsx +++ b/src/components/Navigation/NavigationTabBar/InboxTabButton.tsx @@ -4,7 +4,7 @@ import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; import useRootNavigationState from '@hooks/useRootNavigationState'; -import {useSidebarOrderedReportsState} from '@hooks/useSidebarOrderedReports'; +import {useChatTabBrickRoad} from '@hooks/useSidebarOrderedReports'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -206,7 +206,7 @@ function InboxTabButton({selectedTab, isWideLayout}: InboxTabButtonProps) { const styles = useThemeStyles(); const theme = useTheme(); const {translate} = useLocalize(); - const {chatTabBrickRoad} = useSidebarOrderedReportsState(); + const chatTabBrickRoad = useChatTabBrickRoad(); const expensifyIcons = useMemoizedLazyExpensifyIcons(['Inbox']); let statusIndicatorColor: string | undefined; diff --git a/src/hooks/useSidebarOrderedReports.tsx b/src/hooks/useSidebarOrderedReports.tsx index fca377ea8ed0..633328984cb4 100644 --- a/src/hooks/useSidebarOrderedReports.tsx +++ b/src/hooks/useSidebarOrderedReports.tsx @@ -71,6 +71,9 @@ const SidebarOrderedReportsStateContext = createContext(undefined); + const SidebarOrderedReportsActionsContext = createContext({ clearLHNCache: () => {}, setActiveTab: () => {}, @@ -466,7 +469,9 @@ function SidebarOrderedReportsContextProvider({ return ( - {children} + + {children} + ); } @@ -475,6 +480,10 @@ function useSidebarOrderedReportsState() { return useContext(SidebarOrderedReportsStateContext); } +function useChatTabBrickRoad() { + return useContext(SidebarChatTabBrickRoadContext); +} + function useSidebarOrderedReportsActions() { return useContext(SidebarOrderedReportsActionsContext); } @@ -487,5 +496,5 @@ function useSidebarOrderedReports() { return useMemo(() => ({...state, ...actions}), [state, actions]); } -export {SidebarOrderedReportsContextProvider, useSidebarOrderedReports, useSidebarOrderedReportsState, useSidebarOrderedReportsActions}; +export {SidebarOrderedReportsContextProvider, useChatTabBrickRoad, useSidebarOrderedReports, useSidebarOrderedReportsState, useSidebarOrderedReportsActions}; export type {ReportsToDisplayInLHN}; diff --git a/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/useNativeTabNavigator.ts b/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/useNativeTabNavigator.ts index d19fb99f5aaf..633fcd0e599e 100644 --- a/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/useNativeTabNavigator.ts +++ b/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/useNativeTabNavigator.ts @@ -6,7 +6,7 @@ import useAccountTabIndicatorStatus from '@hooks/useAccountTabIndicatorStatus'; import useLocalize from '@hooks/useLocalize'; import usePermissions from '@hooks/usePermissions'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; -import {useSidebarOrderedReportsState} from '@hooks/useSidebarOrderedReports'; +import {useChatTabBrickRoad} from '@hooks/useSidebarOrderedReports'; import useTheme from '@hooks/useTheme'; import useWorkspacesTabIndicatorStatus from '@hooks/useWorkspacesTabIndicatorStatus'; @@ -47,7 +47,7 @@ function useNativeTabNavigator() { // With the Insights beta, Insights takes the Account tab's place in the bar and Account moves to the top bar. const isInsightsTabVisible = isBetaEnabled(CONST.BETAS.INSIGHTS_PAGE); const theme = useTheme(); - const {chatTabBrickRoad} = useSidebarOrderedReportsState(); + const chatTabBrickRoad = useChatTabBrickRoad(); const {indicatorColor: workspacesIndicatorColor, status: workspacesIndicatorStatus} = useWorkspacesTabIndicatorStatus(); const {indicatorColor: accountIndicatorColor, status: accountIndicatorStatus} = useAccountTabIndicatorStatus(); const navigation = useNavigation(); diff --git a/tests/unit/InboxTabButtonTest.tsx b/tests/unit/InboxTabButtonTest.tsx index 9391613c69c6..8ec1a0319d58 100644 --- a/tests/unit/InboxTabButtonTest.tsx +++ b/tests/unit/InboxTabButtonTest.tsx @@ -53,7 +53,7 @@ jest.mock('@components/Navigation/NavigationTabBar/TabBarItem', () => () => null jest.mock('@components/Navigation/NavigationTabBar/getLastRoute', () => () => mockLastRoute); jest.mock('@hooks/useLazyAsset', () => ({useMemoizedLazyExpensifyIcons: () => ({Inbox: 'inbox-icon'})})); jest.mock('@hooks/useLocalize', () => () => ({translate: (key: string) => key})); -jest.mock('@hooks/useSidebarOrderedReports', () => ({useSidebarOrderedReportsState: () => ({chatTabBrickRoad: undefined})})); +jest.mock('@hooks/useSidebarOrderedReports', () => ({useChatTabBrickRoad: () => undefined})); jest.mock('@hooks/useTheme', () => () => ({danger: 'danger', iconSuccessFill: 'success'})); jest.mock('@hooks/useThemeStyles', () => () => ({ flex1: {}, From 46fe6f771dd424df98571655f19bb283ab9cb569 Mon Sep 17 00:00:00 2001 From: sumo_slonik Date: Mon, 5 Oct 2026 13:06:17 +0200 Subject: [PATCH 39/71] perf: draw native tab icons synchronously from vector glyphs on both platforms, cached per icon --- assets/images/native-tab-icons/buildings.png | Bin 446 -> 0 bytes .../images/native-tab-icons/buildings@2x.png | Bin 842 -> 0 bytes .../images/native-tab-icons/buildings@3x.png | Bin 1287 -> 0 bytes assets/images/native-tab-icons/home.png | Bin 292 -> 0 bytes assets/images/native-tab-icons/home@2x.png | Bin 460 -> 0 bytes assets/images/native-tab-icons/home@3x.png | Bin 610 -> 0 bytes assets/images/native-tab-icons/inbox.png | Bin 278 -> 0 bytes assets/images/native-tab-icons/inbox@2x.png | Bin 464 -> 0 bytes assets/images/native-tab-icons/inbox@3x.png | Bin 660 -> 0 bytes assets/images/native-tab-icons/insights.png | Bin 371 -> 0 bytes .../images/native-tab-icons/insights@2x.png | Bin 649 -> 0 bytes .../images/native-tab-icons/insights@3x.png | Bin 975 -> 0 bytes assets/images/native-tab-icons/profile.png | Bin 493 -> 0 bytes assets/images/native-tab-icons/profile@2x.png | Bin 990 -> 0 bytes assets/images/native-tab-icons/profile@3x.png | Bin 1465 -> 0 bytes .../native-tab-icons/receipt-multiple.png | Bin 625 -> 0 bytes .../native-tab-icons/receipt-multiple@2x.png | Bin 1216 -> 0 bytes .../native-tab-icons/receipt-multiple@3x.png | Bin 1842 -> 0 bytes .../NativeTabNavigator/NATIVE_TAB_GLYPHS.ts | 13 +- .../NativeTabNavigator/NATIVE_TAB_ICONS.ts | 22 -- .../NativeTabNavigator/tabIconRasterizer.ts | 195 ++++++++++++---- .../NativeTabNavigator/useAndroidTabIcons.ts | 99 -------- .../NativeTabNavigator/useIOSTabIcons.ts | 217 ------------------ .../useNativeTabBarOptions/index.ios.ts | 40 +++- .../useNativeTabBarOptions/index.ts | 28 ++- .../useRasterizedTabIcons.ts | 40 ---- .../NativeTabNavigator/useTabAvatarImage.ts | 53 +++++ src/styles/variables.ts | 2 - 28 files changed, 268 insertions(+), 441 deletions(-) delete mode 100644 assets/images/native-tab-icons/buildings.png delete mode 100644 assets/images/native-tab-icons/buildings@2x.png delete mode 100644 assets/images/native-tab-icons/buildings@3x.png delete mode 100644 assets/images/native-tab-icons/home.png delete mode 100644 assets/images/native-tab-icons/home@2x.png delete mode 100644 assets/images/native-tab-icons/home@3x.png delete mode 100644 assets/images/native-tab-icons/inbox.png delete mode 100644 assets/images/native-tab-icons/inbox@2x.png delete mode 100644 assets/images/native-tab-icons/inbox@3x.png delete mode 100644 assets/images/native-tab-icons/insights.png delete mode 100644 assets/images/native-tab-icons/insights@2x.png delete mode 100644 assets/images/native-tab-icons/insights@3x.png delete mode 100644 assets/images/native-tab-icons/profile.png delete mode 100644 assets/images/native-tab-icons/profile@2x.png delete mode 100644 assets/images/native-tab-icons/profile@3x.png delete mode 100644 assets/images/native-tab-icons/receipt-multiple.png delete mode 100644 assets/images/native-tab-icons/receipt-multiple@2x.png delete mode 100644 assets/images/native-tab-icons/receipt-multiple@3x.png delete mode 100644 src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/NATIVE_TAB_ICONS.ts delete mode 100644 src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/useAndroidTabIcons.ts delete mode 100644 src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/useIOSTabIcons.ts delete mode 100644 src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/useRasterizedTabIcons.ts create mode 100644 src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/useTabAvatarImage.ts diff --git a/assets/images/native-tab-icons/buildings.png b/assets/images/native-tab-icons/buildings.png deleted file mode 100644 index 31476c8ea396b7398c1d2614e842ed28b8385925..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 446 zcmV;v0YUzWP)^0a7jc#RCt`-mcK3pQ5431g-Wq>)H+dm0d%M+JOU4}EB-AK;Q>4Y zi6x5N^$6QYjCvEbja5rl=gLXGk&|nzoyj6`l2hDo=APf3`<;Wwx>67Qc zR1v@~5C+T#EhtyCH2Dpv18rBZ2V4MMr%mStB%0XJc+wTym$vM**`k29im$n14WI;^ z3;c!n_)h_X@-z!z^s2Tcz3~w!M~k?V)^S>0e!XgZn+33Lu^4j`%hDE{HZQH> zH1o-#rg4%5=&D=1p@|A!Iqg!~z0;00_qiWnn3?HN*|Lq^P1dKt2N!kjv(I9L|WXY2d+*3smTF&0cPB_Q9XW$3@t_aDlp+b^9ajR4k!S@Kl)SPA#kSKlOF*}K-ea)153cZZmoQ&jgz;1wi^Js4o%+p zZ{SF$0SX-iFn+ny0D&@S4R{1(fjm(0Ol||X22232JenK50GM_(S3Qu&9Nl|Gce57& zSx0k7(X1)gno0{m-qD;@G}}PN(Y;IyKt;mlpiF+<6W=ENjtPia{oS+xgembKz+2#S zzXF6-xL)dKfDrf&9PVd;&{tD@05wUK+f6Q7k%Cr_(9}g!i4iJmGPd^pT(ObTE!k8O7pP2H-nNUAJ*CU2)vlX|tRcFP&_ z^?zN+CS$Y`6{VF}h|x-zyRoI{MjhStBmkSzP&HQnqbDX8Er_fqzN=Qn9+Ky$q2eX$ zE}Zb(rIt2vP0qqQO)|rlr*qU6MO*pM@*NrTPtr}CRu2XM3;^glfO%1I)bn0pG{(Dy zT`5w-P4r)Zae0__#dB(F8Nl@Tdx^G8lY{?qO#Qf{%eMpI8SyNt4>AW7*VYxCS$W&C z?Ep}JpxCE~YnaB&wv(R6CMIlbn@tNqD2-W8+5@e^h7$96S^0toGHCS`Q@a4#<@{cka0} z@6B{Nz5nE_+W*`$zs}r`bMF+QqM{a9D}YVFD9|b)o&_EP23cfU4ZH=M2TTPq2OI#_ zu?Tb{aH1kG>G%_PfSNx!3kAnW%mTMjQ@y>waE!z^z%pvsA(MqkeG*@RDdd?!5>guP0ew%+8@HF7UK2Q=;R z4JyfQ_IN+>`0UTJv{`A>E@`rKq6|RiJi83ncQL>C%HwN$e2*(_UYFrprj&dO12VEj zT;y@LeV0quN*UKzq+6Te{jA_?t#t=o0XhS0bd)T#Rk@K4xi1ChXn^lX3X(0|z8c`O zIX#ie^={y7nShMckSC=Q=qq+*4ZC*#{Y&M^Nyi289LO=P=*0Ju>!d79P1%4*M9!?!1(p4024hE}%No#7iP3()VL z#p*hx(MLUPwmm-kT4iag(x!!9>0))dX626i>sIcVmtvDkTa;w&E9g?ctk1H1r#uC- zeM*~l)$Y=ovI=HKN(||PQ1&l%O!3V~nKZ4CRk`hu)4Dm}u<4ZJz=X~H^RkEeBl7ux zErjm^ehu(+AnlXF`__PZPBIRPXIQQgdJ^}vu=-L~0Q@H%Zd zqMX)vRe(OEn0DnXb``x&x^C6c<;qt|Jp=S&K|tE6{AAb-zbp+YUi(^Q=~xwjS_J{! zm7@(?Ce6}wiZ|>0bH7rgTU3B90{1$u(HC%9%y!5wjdgQhbpvFihCGs}X08b+S#)At z`kW5bodKH#tzLYRX(|MiJfAdOz&9t;g3w)*r+f8LZ>wTJRfX@{HkQkQ%cTlvMi_YY z<`M?*f1)vqS({I5*_IkyY{u=4^uj&B#OOaLt!-2I2Zp>jr(G6hFCIO5wP0OS zGJ7veP{+b!LBRoEnzuiBZp*=9m-EYEJx?KT1e4Q7rdR8p=q9W_8C>RY`{?93#!ocG z6Czw<7@r-us^q_uk$b_c@Ouo`ERP&YU^aH? z;BM}e;vfJoJ=mA+#I?X!9^>0R78GvL#{1IGx;3UCE{mw?YU09MM_GB7JpKWoplWu>fc@(Q(2yGhG3orl6P8;J1Z-^Jb#<2Xre> z;6ebLDcSQpfg1r}rerPap9BKHOvzeS6)-A*3jE5y7l?rf2n!l{IGP8(1+FU|d+mE~ zr3kz_p4shZVhIBFk#J2ULBKxOt_f8j6BTesr~;X&fI~tR$V3Gk5~@HZ{RI-}=Ur^y z@n4sw1wMezv=$pkW*ieM>AtjZs+?s3^TG+$d1rQhuls)Qd!x9MQ_=p<6Hb2@Vshe8w2^&~EA&4? zWlMwd1E~Vfb(5d}$?i=Pwulxewts#=jCUz_jsHaB^ZW_Z+YDL$rA*jdaa#5Q!>t3y zMUUJ$ul_*M^r_*8N?yy)HO%t@jDH;nWwHGHhbb?xi- zr&Lvqjjf#ZxytG};s@q7HpU+Om@bzh_~q(>4eOTFaGtD8OYjIUnzxLzDmlR+yy)9y zmc8Z%3$!io9Sl0g##W+w+OfPs@s!I4o#qk)BW z!U6^i!AKF4NeX+Km+qOQ@MnI+^iLc!%pxwZZkYJ=&}BBRT@2qg#odXnnR%!GF~`kw pju*<7+;lq`*?eC{k!9mgc9lnLtEYcrzXVJ)44$rjF6*2UngHD605$*s diff --git a/assets/images/native-tab-icons/inbox.png b/assets/images/native-tab-icons/inbox.png deleted file mode 100644 index ccab0c1eb69f7dd22a6db32c06e6979017c6b5de..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 278 zcmeAS@N?(olHy`uVBq!ia0vp^5+KaM1|%Pp+x`GjoCO|{#S9EO-XP4l)OOlRpx_Bl z7srr_Id7*r@--QVxX4>uHpMOK){A(3c285+G3ggf{fpTX*}J7CIqh2(s-b$U#Zyi3 z<@-I;1lZ~phy*mqWu$(uXR_~~*PAw#MW;b4sp6^DTpOi?=fB#5Z@VOG7vEn#TV3<7=NFa< XLeF}pdf5pA{lwtu>gTe~DWM4fKHOx@ diff --git a/assets/images/native-tab-icons/inbox@2x.png b/assets/images/native-tab-icons/inbox@2x.png deleted file mode 100644 index 609dbffa777dcd7b1d9427db3b9bed5bf1d698d8..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 464 zcmV;>0WbcEP);y{@a?8;TBr8bi zdllj`bODN%J{IA#b7WxBg!`I1_c4U?!{1!y%=y1zZZii&qRQ0)MnD9pnjG1lrgvej z16RN|pd0cRa0&EW!*2rb4F=Z>egpb0Cm&LHZT$*#^8j225LPGnJS%+1-n+%Z2Vhd; zPdou0V{&`~?KFT|`&_VBB>={| z4gh-|eNl?!0ms0$^$Boa(%z~L zfGVw)8XyWFpw95v^>H^e57Pk5)AdIHA$5W?pVy5p0tl)Td`b@W>`Y1r<`-a%oa)<| zR7l}Ba8%dMq~b8_imy?fE+m3bW9&``${IDVse)VraVLg=QYXOuNMB~(OJ$>73TPH=_i%2a|$*eW8LeRb|2W%6#ldD%R`Zco}u3Df-yd&0ha^q{MjgCy7;G+;~CnZ|eHR>jTd?P3~)5_GNme zd;GrVI!$U{rsvdpPH1G2^dOVCAjAE(eX@|@O~$+l-_JbpbCUh7%Chr_Oo~z!cSZp^W3^MYHe1iolBFSxGDZNo}Uw&nx&-t&$23KDH-#54WtlRNJfBEaD`|unt*^vEW+o$B^k&%3t z57yT0{+a&japgQq^}9?lta5GpmDQ%Ei#!Xw9u)V~i9>P4^!mh7MVVd4Bz^&t8H1;* KpUXO@geCyTs3YJ2 diff --git a/assets/images/native-tab-icons/insights.png b/assets/images/native-tab-icons/insights.png deleted file mode 100644 index 9787cdd1bbd8e30249f39e1b95ab6162a3bbf8a4..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 371 zcmV-(0gV2MP)^0B}qgpGB5UnNKOzb816pX%Ch!OR4K)g)PY6rM z*3lwYL9TRR5@G#C;5XPpaOA>_46pZ|Gt+xsxSN?f=fKR|83>OCFbBp!8z_f@s=x&> z06NzA1E8$@1#W>1hgE+VhzNIWwjsnT+ zI6-gABIO}i03{oK<^We70?C#wnB^cCnc`zdxbhVInBvO-K~qbTQu^fF6u$%ryy87? z!NfzL9CE3jmrXopEKqveH*uANplu5(IS7uT=kS#w{4?&LPcnk9fO^txy30UtpLSjM zfZ;#jubPn>QqT?9mRq0i(wxc60saBhe8AB-)Q{nl?s$1tq%oya|6IkL#~Sy*R`8!b Re}Di0002ovPDHLkV1h7Vo!|ff diff --git a/assets/images/native-tab-icons/insights@2x.png b/assets/images/native-tab-icons/insights@2x.png deleted file mode 100644 index 22c775d075e71ab6947e9c65b6d76dbc56987924..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 649 zcmV;40(Sk0P)MQ>x(7ie#rH8##nXS`D{v4)O?wbndi)QV zBB$0vIeF3)y?8!*5PnNvaRay2hgsa(nxT-+4GE$RoWYW+B`>E<;5e2Q?TNJ#PMcM2 z?K{(ygZ887IUgP0rKqA+aof`Mpq{5l*;I5dxvWd8l_T^IZ6aJTx!aVfF?}sP(l$*) jy`Z1n$a;+IivaZ>(AHorVxlk-00000NkvXXu0mjfu`Uvc diff --git a/assets/images/native-tab-icons/insights@3x.png b/assets/images/native-tab-icons/insights@3x.png deleted file mode 100644 index 3d5f365b1ec41bdc0c1923241fe482ae5d80a197..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 975 zcmV;=12FuFP)nPyg6%jiW>ZCdqMM2fu=C23K#wykBU z>+H2_Ll>zO*ha$tx&d z7a$|^-Z>31>6#%Td@6 zfQFU;v8bJ_0OSP$JbXB15zO$B@L5QKvjX^7?f*S3MjWiE)Y!;0BubH z<<_ z1M_gC<-j)ugDloRe0(b2C=HG#VYc6xY;+AL6Vi7DgN|zZ>DDGZt2!ZbQm-skf@pq# zwzxvyR`x5UpemPj(x9?MV5jt3s!=U)PXU|s z0Om2UU9Q7OoJxQ-z)2~J#|5QNYRGlIua|JCQaM)XkOR6`1pu*+>?L5g%yp4ZACj&q xa{1AEX}xKZCNA6Qwn2WT?#`c7sZtS_{sKvC8Y-o+g315@002ovPDHLkV1injpa1{> diff --git a/assets/images/native-tab-icons/profile.png b/assets/images/native-tab-icons/profile.png deleted file mode 100644 index 23b477c9c67eb099a9cb65d15684e264ae58586f..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 493 zcmV^0pGibPRCt`tmCr6iQ4q#|U6iEQ_!m3_#T$r<$3V9l9)LcAwGe+6 zO%dJziK-=SQeJ?Kgf6CD_Y@DDHxWPuZ)C_53C z>_vDPCHbNSl;TH*0 zt_K1}(L3a7Zm|=R!1*lF#y4XfS&^Tnp&nL460G=Q2hDVCS;v`3H-3Kf15W|s6-}KV zck;WB@dw2q(qIHQ0$yc@z;KJ$Q)v|}c`GtU@5oE*Ca6R%ksp)5rji$AIZf9@)Dn43 z{!pVF#(-m>9@6BxO6Ou7hBPQmO-`Ca>-uu55f+_i@A|SchMV^PwUP<0bs*l5)pXYV zz7J~7LKmWq78bZF(8}#qe3uZ!wQ{=^zl`!G#IYSOqK+ zSuNj=0FPv>&DPU6gMA+G!7|xLU>`8u!)5}g2R>Qsz6Xlq*m)EAufQG&=paVQ&TkS~ zDBpq@3+x5HSr$<^7@s$}O&c&%qca-V4Sd$_s%N&c{Ttl5$Zd*W$MnM%R8;S<8fJBJx{TiLqHid4@F^3fr zmDhVBkpBXV3bCEgxTi`m&O51k8yB39*tEHz-Sy-uGy_#*<^tu*KRw9*kWAx(qpmff zPBTz-kgnM7v4GHYH~`x;`a99!kY=E2iG5HhHN!-_7ue!ybB)H%LEXS6&A`R@V;ayP z%~`LA`uhMdHG;h*8eYd-7qFDfW0~s$bS|`x%wvOwmpWmmAodA1q%|l3c8H%gi8M%$ zRT9!Z=uqt%#JV)9-fqG$Netcwemi8KU%)M(R&>K~#x;%V3fVJ@;VH7a-y;;Ue(4i` zo@|+?!>0uawh~I_2gZz$W9=OT@u2jGyDHlQu|P9cb<0LHYZTPmW6R)SFZFS}I>#-YhBqbW1}f81jwf1&0O_R2St1}`UWqeJ{&66+dZEDcr=z&# zox{aH6vgsNoNn@O<;f-%6v``cI^8Bg%*dBl;&cRT9w|gwih{cfSWB9aBd-NrF%hrw zDN=Dl^(8ChxzFdPS5@gxNn@6gAG%FQNX4wx{HsB&xJc~5ISew0e@nXoP-2-0MF0Q* M07*qoM6N<$f?!p}j{pDw diff --git a/assets/images/native-tab-icons/profile@3x.png b/assets/images/native-tab-icons/profile@3x.png deleted file mode 100644 index a620f6bb3dd49f80468cf839c1baa3d85badae48..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1465 zcmV;q1xEUbP)X(LH%6Vtj-6&G9(f>^MmDu@zO>1xH2 zYO#%0#kzwlwQj_MD=PR!{HASF)8Yc+0*bY*O8u_UC}}EwXicrLYFq7seKK$^gh$@Y znYnY{yYKn_@c(k(y=TsxJIh(-KB%LPI%*hgz%1ZuU9DU~+P#lTR6&g_#n|BY}s(lq@6 zm{BR?ECdb)C_Mta1*}x~If;@0&A?F)CI11|0<(b$DcYSV4&*xE zkVpGRfF8soDu-fS0g64Bfq|Y0 z&|{86<`o(tYaGe02?2^-o`Hcb3(P0|YLRLDJ}0(V1oQ_el_=)&$bC4RBJsA#B6qV! zz=%b_O2V-`qJfO_7m7O_+r1TS4#hfBC}!UHClQSEG0>1gk4qi$tp&&q$HQ%;gGogU zrZCR;5p-~r$_g<;G+_eVaY z@Ux}8#kL1mIPyNRVKN3c0q@Gx+b@k?L%q`q3bL30kDH-sZC)W4}Tf6S?2x7>ThtA_YZPgx?~4NR{sBbSQJH zBRRgcjD{)ZcHn%Aez(U+m^C{p0NYDEx76`=%0+V`a`?ZdHcFE@A zg;frrnp8zz=+gCJ0?;bQ`wKPB;KK{Zw-1FHr#Yk%j7d^rDby&3VtjixHwPWbim8v_ zCFsjRXJYdWB4SF)a!ua&W15Az6lRD!UZ`@KaOgrZLKkp22b~P%5lJg&ik_UsT|S96 zlVmFkmMPPvdEw!58fWZuvwet?7gpqhx&-ZxDrKcPn8lYpAuhnW92jFfKj(OIgsz;A;rJ1UjMlsMThXrQK(VOHCmb&HI%xd{pmpbaGgAn`&3>*Te TRsNQ>00000NkvXXu0mjf$po^` diff --git a/assets/images/native-tab-icons/receipt-multiple.png b/assets/images/native-tab-icons/receipt-multiple.png deleted file mode 100644 index 88c0d4ae12e8f4437282f20c5728546efbdc8113..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 625 zcmV-%0*?KOP)^1BS}O-RCt`t)mw;8Q4|O8pKF9-FhvoCT#8aslhnwA4QW(xaG(CHRTY*os#{ z*o)UOPsbMwKnt3~;Ttrd3Da^1m+=W7OGs*cj4V`J3c82*hUK|~_Bx1bF&1NKg>8Bo z_cFi_JdHnW58RH(OODxyDHVH%<_=aBjJq)-1r+33f<26Z5t5XnGu<$|U>uX!hD5TK zVH-}z>2mD(z&$E(TuL1bi%Q9jj|0$&o<4GLFE}m~^e2kH{o*eq-EqS15?fY%nH(?Z zcNQI7PmEm=j%G}XS{G$kc7VpX)v0b}(ZQ+2*in+$5!hehpeb^c>Sh%QS`uSchOIb* z8_A8X8XKYz@^x?I^$%J550|P7wwvb9PBM2=)v1$eJt8YEiUIE9KihGID+9-4%S7f zWw-KqknF+O2;Z&foLNa`q*JSis$_EVs85K~W@MRUtvjo<{w2Qv*M=gkvHHWL00000 LNkvXXu0mjf(eWO6 diff --git a/assets/images/native-tab-icons/receipt-multiple@2x.png b/assets/images/native-tab-icons/receipt-multiple@2x.png deleted file mode 100644 index e33a87c5e95d7955a3676ced3e691b42cb4c47fb..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1216 zcmV;x1V8(UP)1WfaFhd#Tk5v!oVoRM-oOq=;`#p;bafw6G{* zj#_pjNx~Nb%Rj2w%2^RqZ1iSkDO#+k45g^lBBEwmEh?1)rNq=yu~zxA-FV;Z*Id?FC|29Sg6H@vv11k6zzz`Xn&|VGf2Mz%lt8XTd z1!fe3;0eH0Kv_cHwL)YIaK6>IO8Rp0Fi--V100aHrtfhHWoOn9CiX#()UaP!9Rg#fqf|f z*s@jm8Uehnp05YGMQ^Fat1M|oyduia1LZPxzDjVq4Ok5H29k8iA@PMQ=pLX2ct}2L zT{DdhYJe|+m39CNmGZ~(^=6A8!xV>Y4!xg&+49#sudoBi6p64cS1Jx4M%XQ|1E}k# z0A?x<&qdhXY6no$PXRokILwQ%n`8%2qE>w&#Oz32!27^nhu$j1;f9Fu&msZeM3OWI z(k7q-Xq9zTC*P*V>A<%sV|=IdMn(8d#G9Ts+6FYqp_4lgLNO<0+`oZR(WwmFo;RjB zdE8+%mA*l5)3%(e^mYUU@LHOa87T(6DY;MxP6MWU*4GrJ|4~2ykEA)9;xIBK1HdxiQ^}7)=JcIvti=KPx2F*~&tbF&m@mdHa+G&^c9>9kOmUbWpnqvv0Hw01 zvPG6R6o;Dv^oKfT!%^vOv7}4+ALG&O_T3%c$&*BnBx4`QF7ZQzZMPzIsz>*0Jb2PG zfXVgW1KNSPKs_)#;eKn<@%0fwR4dzG0;gLd?sgcP6#HbtF+C!HI%W5XL(kk0ow(Xz zTuU4lMFfyhc1r`d+(ca9FgC&YV5ZJRz`el1hyYsE823B$t_UHZ@4;F&%RPCJ%=>y- z2ko*hestLSq(gB?D+*+#s^LY<4pJ!DWK|=4iJuei_Co-_EB}L2bWBPfgth_@`VIJ8 zF5jlga2s%+CqJrE9DYsce>*gQxq&2iNz(SREW8G(g}Mq+mpjIrtn@zfTtA(t_k7ij zvXJvZxnt}mrMKKu0B~Z2!$~uJ!;OxyTa;e2rv&4~NePv)oZJ;jxJsr*ldP@3909CU zcDHznU|wu@Bva;=rBNfPKt>Lv4%rrSRp?OJpvJ6qlp%xQs*>%M{w}%fXA30zl>alW zK2ySvLQIvckTOJZ&{gi$u>r(X$qFeG6^AcW`%|$2#8k-&DUFK5tEzogYyhEEvcAfC zsrToeA5;sDtYT#i2yyWfzhMLaJoBlw!G%P0RC# zNgY&gi=!>Frer=c;T*~P*Gyd{GyD3w1cJTlguD*;L-lu9ZHM;2n1?5ofz) zUoMe1ssnh*vKa^Ll#~3J8a{BM%;%^>DPP z51+ksW9Zpi03xQp09Mg09d6d^ge*T+PDD#elr*-OAP?}02m425AE4a8n063=fY0NGj3J{ z+zQ}w%eY4@3bz6ns*HQZVVo?r1L!dPexBj)KH-r4R_60~5(5l%EQMhJwipDRp)G|m z06HBCuLE!;fQJOeanVwPjLQL>XZY&pYI9GEW1KU1GA)fm3>)OT4q=~X!|n>;tAvV6 ziB3HoPxJEc$3O4JC!Ric6>kG7=p|nTpxS1j8x#h=12{=}RxJ>C4GR|)h&p+HQZbF6 ztpIQhfELGm+6J=7@Jr4<%Nn=~Dm4aw1Gt0tnN0;$+=oVJI$Yps;L`!@1K>U7_Z__9 z{|kquK;g)fDl{FA3c&L_+Y;NE{g0>{QLU!Ko&k808)HRV3Y3mKXpTXLUla0t)MX%b zEk@md78`Wv$=qfzFf&H%++r^bKH$ z9XMfs0_TWk^2YAUCq;&!nsPoWX2dkmp%S4l6f&FYQO-FKz$pN(2JkR|HvoLi3Hm4i zsaZ-}o?G(LMJZWnMBE8rI)Fi(m^zp6M7J$XFoW1x>yptKJ)8g%%=lLu8j#CRbEO8XEM3Uo+H zN-CY>W1#7IQiQzp54ie3?!=t{UJH=P{F7Y<6%TsyAm2-ji3x;V!&TG5Wb&B(B zsPgoEOtino^9s-TbP|hktOw%`ep^QxYE3>^l+ZxrY^pfkWA0jCVfXf6PTdq#6)JCM7{>Hus`Xq<@s9vzLsx#!0*&;vr|&v8I%i_}l#rhqzr z%Po8?M}cXxVY$}}=h6Y=jmaLu{Q%%Gj?`Zd9I5*d#b7FGg!A@@W1w-{LBXU%QM`)r zf;m))jfH_JP}r9!52Rvd8G3}w!#luseurE=Eii$GB7&;h&pfo_fxX?sJ zNfjAG-HlGqVE8@-O8XF1ZguEaM9IYnh9xOb+C+^Ujq7O{>a)xhSK6+V#=_hJD&1*# z+qT}s9a%mq8^5Xb0O%;u{2|x!qF4V72CCsnygWTrAX;ZgwQn zCMu~EQv}}au#}bA$_-Jbx4cX-&KCBB7 gp@<@iC;|%p0f1sTB9!jRX8-^I07*qoM6N<$f{Ru}xBvhE diff --git a/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/NATIVE_TAB_GLYPHS.ts b/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/NATIVE_TAB_GLYPHS.ts index 9b5ba3d83ab3..495f8dfcdf42 100644 --- a/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/NATIVE_TAB_GLYPHS.ts +++ b/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/NATIVE_TAB_GLYPHS.ts @@ -1,8 +1,6 @@ import NAVIGATORS from '@src/NAVIGATORS'; import SCREENS from '@src/SCREENS'; -import type NATIVE_TAB_ICONS from './NATIVE_TAB_ICONS'; - type NativeTabGlyphPath = { d: string; isEvenOdd?: boolean; @@ -15,8 +13,8 @@ type NativeTabGlyph = { }; /** - * Vector copies of the tab bar SVGs in `assets/images`, so Skia draws each glyph at the bar's size instead of resampling - * a bitmap. They have to be kept in sync with those files. + * Vector copies of the tab bar SVGs in `assets/images`, so Skia draws each glyph at the bar's size and the device's + * density on both platforms. They have to be kept in sync with those files. */ const NATIVE_TAB_GLYPHS = { // home.svg @@ -77,7 +75,10 @@ const NATIVE_TAB_GLYPHS = { {d: 'M10 1c-5 0-9 4-9 9s4 9 9 9 9-4 9-9-4-9-9-9m5.9 12.7C14.6 12.1 12.4 11 10 11s-4.6 1.1-5.9 2.7C3.4 12.6 3 11.4 3 10c0-3.9 3.1-7 7-7s7 3.1 7 7c0 1.4-.4 2.6-1.1 3.7'}, ], }, -} as const satisfies Record; +} as const satisfies Record; + +/** A tab of the native tab bar, by its route name. */ +type NativeTabName = keyof typeof NATIVE_TAB_GLYPHS; export default NATIVE_TAB_GLYPHS; -export type {NativeTabGlyph}; +export type {NativeTabGlyph, NativeTabName}; diff --git a/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/NATIVE_TAB_ICONS.ts b/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/NATIVE_TAB_ICONS.ts deleted file mode 100644 index 6e9db9a6eacf..000000000000 --- a/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/NATIVE_TAB_ICONS.ts +++ /dev/null @@ -1,22 +0,0 @@ -import buildingsIcon from '@assets/images/native-tab-icons/buildings.png'; -import homeIcon from '@assets/images/native-tab-icons/home.png'; -import inboxIcon from '@assets/images/native-tab-icons/inbox.png'; -import insightsIcon from '@assets/images/native-tab-icons/insights.png'; -import profileIcon from '@assets/images/native-tab-icons/profile.png'; -import receiptMultipleIcon from '@assets/images/native-tab-icons/receipt-multiple.png'; - -import NAVIGATORS from '@src/NAVIGATORS'; -import SCREENS from '@src/SCREENS'; - -import type {NativeBottomTabIcon} from '@react-navigation/bottom-tabs/unstable'; - -const NATIVE_TAB_ICONS = { - [SCREENS.HOME]: {type: 'image', source: homeIcon}, - [NAVIGATORS.REPORTS_SPLIT_NAVIGATOR]: {type: 'image', source: inboxIcon}, - [NAVIGATORS.SEARCH_FULLSCREEN_NAVIGATOR]: {type: 'image', source: receiptMultipleIcon}, - [SCREENS.INSIGHTS]: {type: 'image', source: insightsIcon}, - [NAVIGATORS.WORKSPACE_NAVIGATOR]: {type: 'image', source: buildingsIcon}, - [NAVIGATORS.SETTINGS_SPLIT_NAVIGATOR]: {type: 'image', source: profileIcon}, -} as const satisfies Record; - -export default NATIVE_TAB_ICONS; diff --git a/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/tabIconRasterizer.ts b/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/tabIconRasterizer.ts index 94d7cf7d8787..453a76a3dd6c 100644 --- a/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/tabIconRasterizer.ts +++ b/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/tabIconRasterizer.ts @@ -1,45 +1,106 @@ -import ObjectUtils from '@src/types/utils/ObjectUtils'; +import FontUtils from '@styles/utils/FontUtils'; import type {NativeBottomTabIcon} from '@react-navigation/bottom-tabs/unstable'; -import type {SkCanvas, SkImage, SkSurface} from '@shopify/react-native-skia'; -import type {ImageSourcePropType} from 'react-native'; +import type {SkCanvas, SkFont, SkImage, SkPath} from '@shopify/react-native-skia'; -import {BlendMode, ClipOp, FilterMode, ImageFormat, MipmapMode, Skia} from '@shopify/react-native-skia'; -import {Image} from 'react-native'; +import {ClipOp, FillType, FilterMode, FontWeight, ImageFormat, MipmapMode, Skia} from '@shopify/react-native-skia'; +import {PixelRatio} from 'react-native'; -import NATIVE_TAB_ICONS from './NATIVE_TAB_ICONS'; +import type {NativeTabGlyph, NativeTabName} from './NATIVE_TAB_GLYPHS'; -type TabIconPair = {active: NativeBottomTabIcon; inactive: NativeBottomTabIcon}; +import NATIVE_TAB_GLYPHS from './NATIVE_TAB_GLYPHS'; -type NativeTabName = keyof typeof NATIVE_TAB_ICONS; +type TabIconLayout = { + /** Side of a glyph's box. */ + glyphSize: number; + /** Side of the account avatar's box. */ + avatarSize: number; + /** Radius of the status dot drawn at the content's top right corner. */ + dotRadius: number; + /** Gap between the glyph box and the label drawn under it. */ + labelGap: number; + /** Font size of the label drawn under the glyph. */ + labelFontSize: number; +}; -const TAB_NAMES = ObjectUtils.typedKeys(NATIVE_TAB_ICONS); +type TabIconLabel = { + text: string; + color: string; + isBold: boolean; +}; -/** Skia reads URLs, file paths and, in Android release builds, bundled assets that resolve to a drawable name. */ -async function decodeImage(uri: string): Promise { - const data = await Skia.Data.fromURI(uri); - return Skia.Image.MakeImageFromEncoded(data); +type TabIconParams = { + /** The glyph drawn unless an avatar is given. */ + name: NativeTabName; + color: string; + avatar?: {uri: string; image: SkImage}; + dotColor?: string; + label?: TabIconLabel; +}; + +/** Upper bound on cached icons, enough for every tab in both selection states across a few status and theme changes. */ +const MAX_CACHED_ICONS = 64; + +const iconCache = new Map(); +const glyphPathCache = new Map(); +const labelFontCache = new Map(); + +function getGlyphPaths(name: NativeTabName): SkPath[] { + const cachedPaths = glyphPathCache.get(name); + if (cachedPaths) { + return cachedPaths; + } + const paths: SkPath[] = []; + const glyph: NativeTabGlyph = NATIVE_TAB_GLYPHS[name]; + for (const {d, isEvenOdd} of glyph.paths) { + const path = Skia.Path.MakeFromSVGString(d); + if (!path) { + continue; + } + if (isEvenOdd) { + path.setFillType(FillType.EvenOdd); + } + paths.push(path); + } + glyphPathCache.set(name, paths); + return paths; } -async function decodeGlyph(source: ImageSourcePropType): Promise<{image: SkImage; scale: number} | undefined> { - const asset = Image.resolveAssetSource(source); - if (!asset?.uri) { - return undefined; +function getLabelFont(isBold: boolean, size: number): SkFont { + const key = `${isBold}|${size}`; + const cachedFont = labelFontCache.get(key); + if (cachedFont) { + return cachedFont; } - const image = await decodeImage(asset.uri); - return image ? {image, scale: asset.scale ?? 1} : undefined; + const typeface = Skia.FontMgr.System().matchFamilyStyle(FontUtils.fontFamily.single.EXP_NEUE.fontFamily, { + weight: isBold ? FontWeight.Bold : FontWeight.Normal, + }); + const font = Skia.Font(typeface, size); + labelFontCache.set(key, font); + return font; } -function createTintPaint(color: string) { +function drawGlyph(canvas: SkCanvas, name: NativeTabName, left: number, top: number, size: number, color: string) { const paint = Skia.Paint(); - paint.setColorFilter(Skia.ColorFilter.MakeBlend(Skia.Color(color), BlendMode.SrcIn)); - return paint; + paint.setColor(Skia.Color(color)); + paint.setAntiAlias(true); + const viewBoxScale = size / NATIVE_TAB_GLYPHS[name].viewBoxSize; + + canvas.save(); + canvas.translate(left, top); + canvas.scale(viewBoxScale, viewBoxScale); + for (const path of getGlyphPaths(name)) { + canvas.drawPath(path, paint); + } + canvas.restore(); + paint.dispose(); } function drawCircularImage(canvas: SkCanvas, image: SkImage, left: number, top: number, size: number) { const sourceSize = Math.min(image.width(), image.height()); const circle = Skia.Path.Make(); circle.addCircle(left + size / 2, top + size / 2, size / 2); + const paint = Skia.Paint(); canvas.save(); canvas.clipPath(circle, ClipOp.Intersect, true); @@ -49,39 +110,89 @@ function drawCircularImage(canvas: SkCanvas, image: SkImage, left: number, top: Skia.XYWHRect(left, top, size, size), FilterMode.Linear, MipmapMode.Linear, - Skia.Paint(), + paint, ); canvas.restore(); + paint.dispose(); circle.dispose(); } -function encodeSurface(surface: SkSurface): string { +function drawStatusDot(canvas: SkCanvas, right: number, top: number, radius: number, color: string) { + const paint = Skia.Paint(); + paint.setColor(Skia.Color(color)); + paint.setAntiAlias(true); + canvas.drawCircle(right - radius, top + radius, radius, paint); + paint.dispose(); +} + +function drawTabIcon(layout: TabIconLayout, {name, color, avatar, dotColor, label}: TabIconParams): NativeBottomTabIcon | undefined { + const scale = PixelRatio.get(); + const contentSize = (avatar ? layout.avatarSize : layout.glyphSize) * scale; + // With a label, every tab shares one row as tall as the avatar, so glyphs and the avatar share a centre and + // every label lands at the same height. + const rowSize = label ? Math.max(layout.glyphSize, layout.avatarSize) * scale : contentSize; + const labelTop = (rowSize + layout.glyphSize * scale) / 2 + layout.labelGap * scale; + const font = label ? getLabelFont(label.isBold, layout.labelFontSize * scale) : undefined; + const labelWidth = label && font ? font.measureText(label.text).width : 0; + const labelMetrics = font?.getMetrics(); + const labelHeight = labelMetrics ? labelMetrics.descent - labelMetrics.ascent : 0; + const canvasWidth = Math.ceil(Math.max(contentSize, labelWidth)); + const canvasHeight = Math.ceil(label ? labelTop + labelHeight : rowSize); + + const surface = Skia.Surface.MakeOffscreen(canvasWidth, canvasHeight); + if (!surface) { + return undefined; + } + + const canvas = surface.getCanvas(); + const contentLeft = (canvasWidth - contentSize) / 2; + const contentTop = (rowSize - contentSize) / 2; + if (avatar) { + drawCircularImage(canvas, avatar.image, contentLeft, contentTop, contentSize); + } else { + drawGlyph(canvas, name, contentLeft, contentTop, contentSize, color); + } + if (dotColor) { + drawStatusDot(canvas, contentLeft + contentSize, contentTop, layout.dotRadius * scale, dotColor); + } + if (label && font && labelMetrics) { + const paint = Skia.Paint(); + paint.setColor(Skia.Color(label.color)); + paint.setAntiAlias(true); + canvas.drawText(label.text, (canvasWidth - labelWidth) / 2, labelTop - labelMetrics.ascent, paint, font); + paint.dispose(); + } + surface.flush(); const snapshot = surface.makeImageSnapshot(); const base64 = snapshot.encodeToBase64(ImageFormat.PNG, 100); snapshot.dispose(); surface.dispose(); - return `data:image/png;base64,${base64}`; -} -async function drawTabIconPairs(drawIcon: (name: NativeTabName, isSelected: boolean) => Promise): Promise> { - const results = await Promise.all(TAB_NAMES.map((name) => Promise.all([drawIcon(name, false), drawIcon(name, true)]).then(([inactive, active]) => ({name, inactive, active})))); - const icons: Record = {}; - for (const result of results) { - if (result.inactive && result.active) { - icons[result.name] = {inactive: result.inactive, active: result.active}; - } - } - return icons; + return { + type: 'image', + source: {uri: `data:image/png;base64,${base64}`, width: canvasWidth / scale, height: canvasHeight / scale, scale}, + tinted: false, + }; } -function getTabBarIcon(icons: Record | undefined, name: NativeTabName) { - const pair = icons?.[name]; - if (!pair) { - return NATIVE_TAB_ICONS[name]; +/** + * Draws a tab icon synchronously, so the bar never waits for it, and reuses it for as long as everything drawn into it + * stays the same. A status, theme or language change redraws only the icons it reaches. + */ +function getTabIcon(layout: TabIconLayout, params: TabIconParams): NativeBottomTabIcon | undefined { + const {name, color, avatar, dotColor, label} = params; + const key = [name, avatar?.uri ?? color, dotColor, label?.text, label?.color, label?.isBold, layout.glyphSize, layout.avatarSize].join('|'); + if (iconCache.has(key)) { + return iconCache.get(key); + } + if (iconCache.size >= MAX_CACHED_ICONS) { + iconCache.clear(); } - return ({focused}: {focused: boolean}) => (focused ? pair.active : pair.inactive); + const icon = drawTabIcon(layout, params); + iconCache.set(key, icon); + return icon; } -export {createTintPaint, decodeGlyph, decodeImage, drawCircularImage, drawTabIconPairs, encodeSurface, getTabBarIcon}; -export type {TabIconPair}; +export default getTabIcon; +export type {TabIconLayout, TabIconParams}; diff --git a/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/useAndroidTabIcons.ts b/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/useAndroidTabIcons.ts deleted file mode 100644 index c43e1d36aad3..000000000000 --- a/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/useAndroidTabIcons.ts +++ /dev/null @@ -1,99 +0,0 @@ -import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; -import useTheme from '@hooks/useTheme'; - -import {getAvatarURL} from '@libs/UserAvatarUtils'; - -import variables from '@styles/variables'; - -import type {NativeBottomTabIcon} from '@react-navigation/bottom-tabs/unstable'; -import type {ImageSourcePropType} from 'react-native'; - -import {Skia} from '@shopify/react-native-skia'; - -import type {TabIconPair} from './tabIconRasterizer'; -import type {LastDrawnTabIcons} from './useRasterizedTabIcons'; - -import NATIVE_TAB_ICONS from './NATIVE_TAB_ICONS'; -import {createTintPaint, decodeGlyph, decodeImage, drawCircularImage, drawTabIconPairs, encodeSurface, getTabBarIcon} from './tabIconRasterizer'; -import useRasterizedTabIcons from './useRasterizedTabIcons'; - -const lastTintedIcons: LastDrawnTabIcons> = {}; -const lastCircularAvatar: LastDrawnTabIcons<{uri: string; icon: NativeBottomTabIcon}> = {}; - -/** - * Material draws the icons without a tint (react-native-screens patch 003), so each glyph is recolored here. The - * canvas is the glyph's own size, so Android's fixed icon slot draws it at full size. - */ -async function createTintedGlyph(source: ImageSourcePropType, color: string): Promise { - const glyph = await decodeGlyph(source); - if (!glyph) { - return undefined; - } - - const {image, scale} = glyph; - const canvasWidth = image.width(); - const canvasHeight = image.height(); - const surface = Skia.Surface.MakeOffscreen(canvasWidth, canvasHeight); - if (!surface) { - image.dispose(); - return undefined; - } - - const rect = Skia.XYWHRect(0, 0, canvasWidth, canvasHeight); - surface.getCanvas().drawImageRect(image, rect, rect, createTintPaint(color)); - image.dispose(); - - return {type: 'image', source: {uri: encodeSurface(surface), width: canvasWidth / scale, height: canvasHeight / scale, scale}}; -} - -/** The active indicator pill marks the selected tab, so one image serves both selection states. */ -async function createCircularAvatarIcon(uri: string): Promise { - const image = await decodeImage(uri); - const scale = variables.nativeTabIconScale; - const canvasSize = variables.iconBottomBar * scale; - const surface = image ? Skia.Surface.MakeOffscreen(canvasSize, canvasSize) : null; - - if (!image || !surface) { - image?.dispose(); - surface?.dispose(); - return undefined; - } - - drawCircularImage(surface.getCanvas(), image, 0, 0, canvasSize); - image.dispose(); - - return {type: 'image', source: {uri: encodeSurface(surface), width: variables.iconBottomBar, height: variables.iconBottomBar, scale}}; -} - -/** `areTabIconsReady` stays false until the first recolored set exists, because the plain glyphs draw black. */ -function useAndroidTabIcons() { - const theme = useTheme(); - const currentUserPersonalDetails = useCurrentUserPersonalDetails(); - const inactiveColor = theme.icon; - const activeColor = theme.iconMenu; - - // A failed draw still lets the bar show, on the plain black glyphs, rather than keeping it hidden. - const tintedIcons = useRasterizedTabIcons( - lastTintedIcons, - [inactiveColor, activeColor].join('|'), - () => drawTabIconPairs((name, isSelected) => createTintedGlyph(NATIVE_TAB_ICONS[name].source, isSelected ? activeColor : inactiveColor)), - {}, - ); - - const avatarSource = getAvatarURL({avatarSource: currentUserPersonalDetails.avatar, accountID: currentUserPersonalDetails.accountID}); - const avatarURI = typeof avatarSource === 'string' ? avatarSource : undefined; - const circularAvatar = useRasterizedTabIcons(lastCircularAvatar, avatarURI, (uri) => createCircularAvatarIcon(uri).then((icon) => icon && {uri, icon})); - - // An avatar drawn for another user is never shown; until the current one is drawn, the account glyph stands in. - const avatarIcon = circularAvatar?.uri === avatarURI ? circularAvatar?.icon : undefined; - // A function, because React Navigation derives the selected icon only from a function. - const accountAvatarIcon = avatarIcon ? () => avatarIcon : undefined; - - return { - getTabBarIcon: (name: keyof typeof NATIVE_TAB_ICONS) => getTabBarIcon(tintedIcons, name), - accountAvatarIcon, - areTabIconsReady: !!tintedIcons, - }; -} - -export default useAndroidTabIcons; diff --git a/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/useIOSTabIcons.ts b/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/useIOSTabIcons.ts deleted file mode 100644 index bbb10095ac77..000000000000 --- a/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/useIOSTabIcons.ts +++ /dev/null @@ -1,217 +0,0 @@ -import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; -import useTheme from '@hooks/useTheme'; - -import {getAvatarURL} from '@libs/UserAvatarUtils'; - -import FontUtils from '@styles/utils/FontUtils'; -import variables from '@styles/variables'; - -import NAVIGATORS from '@src/NAVIGATORS'; - -import type {NativeBottomTabIcon} from '@react-navigation/bottom-tabs/unstable'; -import type {SkCanvas} from '@shopify/react-native-skia'; - -import {FillType, FontWeight, Skia} from '@shopify/react-native-skia'; - -import type {NativeTabGlyph} from './NATIVE_TAB_GLYPHS'; -import type NATIVE_TAB_ICONS from './NATIVE_TAB_ICONS'; -import type {TabIconPair} from './tabIconRasterizer'; -import type {LastDrawnTabIcons} from './useRasterizedTabIcons'; - -import NATIVE_TAB_GLYPHS from './NATIVE_TAB_GLYPHS'; -import {decodeImage, drawCircularImage, drawTabIconPairs, encodeSurface, getTabBarIcon} from './tabIconRasterizer'; -import useRasterizedTabIcons from './useRasterizedTabIcons'; - -/** The account tab's avatar in both selection states, with the URI it was drawn from. */ -type CircularAvatarIcons = TabIconPair & {uri: string}; - -const lastTintedIcons: LastDrawnTabIcons> = {}; -const lastCircularAvatar: LastDrawnTabIcons = {}; - -function drawStatusDot(canvas: SkCanvas, glyphRight: number, glyphTop: number, scale: number, color: string) { - const radius = variables.nativeTabIconDotRadius * scale; - const paint = Skia.Paint(); - paint.setColor(Skia.Color(color)); - canvas.drawCircle(glyphRight - radius, glyphTop + radius, radius, paint); -} - -function drawGlyph(canvas: SkCanvas, glyph: NativeTabGlyph, left: number, top: number, size: number, color: string) { - const paint = Skia.Paint(); - paint.setColor(Skia.Color(color)); - paint.setAntiAlias(true); - const viewBoxScale = size / glyph.viewBoxSize; - - canvas.save(); - canvas.translate(left, top); - canvas.scale(viewBoxScale, viewBoxScale); - for (const {d, isEvenOdd} of glyph.paths) { - const path = Skia.Path.MakeFromSVGString(d); - if (!path) { - continue; - } - if (isEvenOdd) { - path.setFillType(FillType.EvenOdd); - } - canvas.drawPath(path, paint); - path.dispose(); - } - canvas.restore(); -} - -function getLabelFont(isSelected: boolean, scale: number) { - const typeface = Skia.FontMgr.System().matchFamilyStyle(FontUtils.fontFamily.single.EXP_NEUE.fontFamily, { - weight: isSelected ? FontWeight.Bold : FontWeight.Normal, - }); - return Skia.Font(typeface, variables.fontSizeSmall * scale); -} - -/** - * iOS 26 discards the title color an item appearance carries while honoring its font, and neither - * `unselectedItemTintColor` nor re-asserting it after layout gets through, so the label is painted into the icon. - */ -function drawLabel(canvas: SkCanvas, label: string, color: string, isSelected: boolean, scale: number, canvasWidth: number, top: number) { - const font = getLabelFont(isSelected, scale); - const paint = Skia.Paint(); - paint.setColor(Skia.Color(color)); - paint.setAntiAlias(true); - const width = font.measureText(label).width; - const metrics = font.getMetrics(); - canvas.drawText(label, (canvasWidth - width) / 2, top - metrics.ascent, paint, font); -} - -function measureLabel(label: string, isSelected: boolean, scale: number) { - const font = getLabelFont(isSelected, scale); - const metrics = font.getMetrics(); - return {width: font.measureText(label).width, height: metrics.descent - metrics.ascent}; -} - -/** - * iOS 26 never applies the inactive icon color from `UITabBarItemAppearance`, and paints every badge in the color of - * the selected tab (both `badgeBackgroundColor` and `UITabBarItem.badgeColor`), so the glyph is drawn in its color and the - * status dot drawn into the image. Both selection states go through this, because RNScreens rejects a tab whose - * icon and selectedIcon differ in type. - */ -async function createTabIcon( - glyph: NativeTabGlyph, - color: string, - dotColor: string | undefined, - label: string, - labelColor: string, - isSelected: boolean, -): Promise { - const scale = variables.nativeTabIconScale; - const glyphSize = variables.iconNativeTabBarIOS * scale; - // The account avatar is taller than a glyph, so every glyph sits this far down to share its centre, and - // every label lands at the same height. - const glyphTop = ((variables.avatarNativeTabBarIOS - variables.iconNativeTabBarIOS) / 2) * scale; - const gap = variables.nativeTabIconLabelGap * scale; - const labelSize = measureLabel(label, isSelected, scale); - const canvasWidth = Math.ceil(Math.max(glyphSize, labelSize.width)); - const canvasHeight = Math.ceil(glyphTop + glyphSize + gap + labelSize.height); - const surface = Skia.Surface.MakeOffscreen(canvasWidth, canvasHeight); - - if (!surface) { - return undefined; - } - - const canvas = surface.getCanvas(); - const glyphLeft = (canvasWidth - glyphSize) / 2; - drawGlyph(canvas, glyph, glyphLeft, glyphTop, glyphSize, color); - - if (dotColor) { - drawStatusDot(canvas, glyphLeft + glyphSize, glyphTop, scale, dotColor); - } - - drawLabel(canvas, label, labelColor, isSelected, scale, canvasWidth, glyphTop + glyphSize + gap); - - return {type: 'image', source: {uri: encodeSurface(surface), width: canvasWidth / scale, height: canvasHeight / scale, scale}, tinted: false}; -} - -/** A circle reads smaller than a glyph of the same box, so the avatar is drawn a little larger than the glyphs. */ -async function createCircularAvatarIcon(uri: string, dotColor: string | undefined, label: string, labelColor: string, isSelected: boolean): Promise { - const image = await decodeImage(uri); - const scale = variables.nativeTabIconScale; - const avatarSize = variables.avatarNativeTabBarIOS * scale; - // The avatar overhangs a glyph by the same amount above and below, so its gap to the label shrinks by - // that much and the label lands where every other tab's does. - const gap = (variables.nativeTabIconLabelGap - (variables.avatarNativeTabBarIOS - variables.iconNativeTabBarIOS) / 2) * scale; - const labelSize = measureLabel(label, isSelected, scale); - const canvasWidth = Math.ceil(Math.max(avatarSize, labelSize.width)); - const canvasHeight = Math.ceil(avatarSize + gap + labelSize.height); - const surface = Skia.Surface.MakeOffscreen(canvasWidth, canvasHeight); - - if (!image || !surface) { - image?.dispose(); - surface?.dispose(); - return undefined; - } - - const avatarLeft = (canvasWidth - avatarSize) / 2; - const canvas = surface.getCanvas(); - drawCircularImage(canvas, image, avatarLeft, 0, avatarSize); - - if (dotColor) { - drawStatusDot(canvas, avatarLeft + avatarSize, 0, scale, dotColor); - } - - drawLabel(canvas, label, labelColor, isSelected, scale, canvasWidth, avatarSize + gap); - image.dispose(); - - return {type: 'image', source: {uri: encodeSurface(surface), width: canvasWidth / scale, height: canvasHeight / scale, scale}, tinted: false}; -} - -/** - * `areTabIconsReady` stays false until the first recolored set exists, because the template icons the bar would - * fall back to render black on iOS 26, which ignores the inactive tint. - */ -function useIOSTabIcons(dotColors: Record, tabLabels: Record) { - const theme = useTheme(); - const currentUserPersonalDetails = useCurrentUserPersonalDetails(); - const accountLabel = tabLabels[NAVIGATORS.SETTINGS_SPLIT_NAVIGATOR]; - const accountDotColor = dotColors[NAVIGATORS.SETTINGS_SPLIT_NAVIGATOR]; - - // Covers everything baked into the bitmaps, so a status, theme or language change redraws them. - const iconsSignature = [theme.icon, theme.iconMenu, theme.text, theme.textSupporting, ...Object.values(dotColors), ...Object.values(tabLabels)].join('|'); - // A failed draw still lets the bar show, on the template icons, rather than keeping it hidden. - const tintedIcons = useRasterizedTabIcons( - lastTintedIcons, - iconsSignature, - () => - drawTabIconPairs((name, isSelected) => - createTabIcon( - NATIVE_TAB_GLYPHS[name], - isSelected ? theme.iconMenu : theme.icon, - dotColors[name], - tabLabels[name], - isSelected ? theme.text : theme.textSupporting, - isSelected, - ), - ), - {}, - ); - - const avatarSource = getAvatarURL({avatarSource: currentUserPersonalDetails.avatar, accountID: currentUserPersonalDetails.accountID}); - const avatarURI = typeof avatarSource === 'string' ? avatarSource : undefined; - const avatarSignature = avatarURI && [avatarURI, accountDotColor, accountLabel, theme.textSupporting, theme.text].join('|'); - const circularAvatar = useRasterizedTabIcons(lastCircularAvatar, avatarSignature, () => { - if (!avatarURI) { - return Promise.resolve(undefined); - } - return Promise.all([ - createCircularAvatarIcon(avatarURI, accountDotColor, accountLabel, theme.textSupporting, false), - createCircularAvatarIcon(avatarURI, accountDotColor, accountLabel, theme.text, true), - ]).then(([inactive, active]) => (inactive && active ? {uri: avatarURI, inactive, active} : undefined)); - }); - - // An avatar drawn for another user is never shown, while a stale dot or label is, until the redraw lands. - const avatarPair = circularAvatar?.uri === avatarURI ? circularAvatar : undefined; - const accountAvatarIcon = avatarPair ? ({focused}: {focused: boolean}) => (focused ? avatarPair.active : avatarPair.inactive) : undefined; - - return { - getTabBarIcon: (name: keyof typeof NATIVE_TAB_ICONS) => getTabBarIcon(tintedIcons, name), - accountAvatarIcon, - areTabIconsReady: !!tintedIcons, - }; -} - -export default useIOSTabIcons; 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 7a90d8ecda07..907897c35e41 100644 --- a/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/useNativeTabBarOptions/index.ios.ts +++ b/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/useNativeTabBarOptions/index.ios.ts @@ -1,7 +1,9 @@ import useTheme from '@hooks/useTheme'; -import type NATIVE_TAB_ICONS from '@libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/NATIVE_TAB_ICONS'; -import useIOSTabIcons from '@libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/useIOSTabIcons'; +import type {NativeTabName} from '@libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/NATIVE_TAB_GLYPHS'; +import getTabIcon from '@libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/tabIconRasterizer'; +import type {TabIconLayout} from '@libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/tabIconRasterizer'; +import useTabAvatarImage from '@libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/useTabAvatarImage'; import variables from '@styles/variables'; @@ -13,9 +15,23 @@ import type NativeTabBarOptionsParams from './types'; const getFloatingButtonsBottom = () => variables.iosNativeTabBarFloatingButtonsBottom; +const TAB_ICON_LAYOUT: TabIconLayout = { + glyphSize: variables.iconNativeTabBarIOS, + // A circle reads smaller than a glyph of the same box, so the avatar is drawn a little larger than the glyphs. + avatarSize: variables.avatarNativeTabBarIOS, + dotRadius: variables.nativeTabIconDotRadius, + labelGap: variables.nativeTabIconLabelGap, + labelFontSize: variables.fontSizeSmall, +}; + +/** + * iOS 26 never applies the inactive icon color or the title color from `UITabBarItemAppearance`, and paints every badge + * 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, dotColors, tabLabels}: NativeTabBarOptionsParams) { const theme = useTheme(); - const {getTabBarIcon, accountAvatarIcon, areTabIconsReady} = useIOSTabIcons(dotColors, tabLabels); + const avatar = useTabAvatarImage(); const screenOptions: NativeBottomTabNavigationOptions = { headerShown: false, @@ -25,15 +41,25 @@ function useNativeTabBarOptions({shouldShowNativeTabBar, dotColors, tabLabels}: tabBarInactiveTintColor: theme.icon, // Every tab shares one style, so the bar reads the current visibility in the same render that changed it. // The background only lands on iOS 18 and below; iOS 26 keeps its own glass material. - tabBarStyle: {display: shouldShowNativeTabBar && areTabIconsReady ? 'flex' : 'none', backgroundColor: theme.appBG}, + tabBarStyle: {display: shouldShowNativeTabBar ? 'flex' : 'none', backgroundColor: theme.appBG}, tabBarControllerMode: 'tabBar', // The bar stays put while the content scrolls, instead of collapsing the way iOS 26 does by default. tabBarMinimizeBehavior: 'none', }; - const getTabOptions = (name: keyof typeof NATIVE_TAB_ICONS): NativeBottomTabNavigationOptions => ({ - tabBarIcon: name === NAVIGATORS.SETTINGS_SPLIT_NAVIGATOR && accountAvatarIcon ? accountAvatarIcon : getTabBarIcon(name), - }); + const getTabOptions = (name: NativeTabName): NativeBottomTabNavigationOptions => { + const getIcon = (isSelected: boolean) => + getTabIcon(TAB_ICON_LAYOUT, { + 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}, + }); + const inactiveIcon = getIcon(false); + const activeIcon = getIcon(true); + return {tabBarIcon: inactiveIcon && activeIcon ? ({focused}) => (focused ? activeIcon : inactiveIcon) : undefined}; + }; return {screenOptions, getTabOptions}; } diff --git a/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/useNativeTabBarOptions/index.ts b/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/useNativeTabBarOptions/index.ts index 983ccf6a933c..4e0c5803e67b 100644 --- a/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/useNativeTabBarOptions/index.ts +++ b/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/useNativeTabBarOptions/index.ts @@ -1,8 +1,10 @@ import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; -import type NATIVE_TAB_ICONS from '@libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/NATIVE_TAB_ICONS'; -import useAndroidTabIcons from '@libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/useAndroidTabIcons'; +import type {NativeTabName} from '@libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/NATIVE_TAB_GLYPHS'; +import getTabIcon from '@libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/tabIconRasterizer'; +import type {TabIconLayout} from '@libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/tabIconRasterizer'; +import useTabAvatarImage from '@libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/useTabAvatarImage'; import variables from '@styles/variables'; @@ -18,10 +20,19 @@ const STATUS_DOT_BADGE = ''; /** Material's bar sits above the system gesture inset, so the floating buttons clear both. */ const getFloatingButtonsBottom = (bottomInset: number) => variables.androidNativeTabBarFloatingButtonsBottom + bottomInset; +/** Material draws each icon in its fixed icon slot, with the label and the status badge of its own. */ +const TAB_ICON_LAYOUT: TabIconLayout = { + glyphSize: variables.iconBottomBar, + avatarSize: variables.iconBottomBar, + dotRadius: 0, + labelGap: 0, + labelFontSize: 0, +}; + function useNativeTabBarOptions({shouldShowNativeTabBar, dotColors, tabLabels}: NativeTabBarOptionsParams) { const theme = useTheme(); const styles = useThemeStyles(); - const {getTabBarIcon, accountAvatarIcon, areTabIconsReady} = useAndroidTabIcons(); + const avatar = useTabAvatarImage(); // The tint only reaches the labels, since the icons arrive already recolored (react-native-screens patch 003). const screenOptions: NativeBottomTabNavigationOptions = { @@ -32,14 +43,19 @@ function useNativeTabBarOptions({shouldShowNativeTabBar, dotColors, tabLabels}: tabBarActiveIndicatorColor: theme.androidTabBarActiveIndicatorBG, tabBarLabelVisibilityMode: 'labeled', // Every tab shares one style, so the bar reads the current visibility in the same render that changed it. - tabBarStyle: {display: shouldShowNativeTabBar && areTabIconsReady ? 'flex' : 'none', backgroundColor: theme.appBG}, + tabBarStyle: {display: shouldShowNativeTabBar ? 'flex' : 'none', backgroundColor: theme.appBG}, }; - const getTabOptions = (name: keyof typeof NATIVE_TAB_ICONS): NativeBottomTabNavigationOptions => { + const getTabOptions = (name: NativeTabName): NativeBottomTabNavigationOptions => { + // The active indicator pill marks the selected tab, so the avatar is the same image in both selection states. + const tabAvatar = name === NAVIGATORS.SETTINGS_SPLIT_NAVIGATOR ? avatar : undefined; + const inactiveIcon = getTabIcon(TAB_ICON_LAYOUT, {name, color: theme.icon, avatar: tabAvatar}); + const activeIcon = getTabIcon(TAB_ICON_LAYOUT, {name, color: theme.iconMenu, avatar: tabAvatar}); const dotColor = dotColors[name]; return { tabBarLabel: tabLabels[name], - tabBarIcon: name === NAVIGATORS.SETTINGS_SPLIT_NAVIGATOR && accountAvatarIcon ? accountAvatarIcon : getTabBarIcon(name), + // A function, because React Navigation derives the selected icon only from a function. + tabBarIcon: inactiveIcon && activeIcon ? ({focused}) => (focused ? activeIcon : inactiveIcon) : undefined, ...(dotColor ? {tabBarBadge: STATUS_DOT_BADGE, tabBarBadgeStyle: {backgroundColor: dotColor}} : {tabBarBadge: undefined}), }; }; diff --git a/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/useRasterizedTabIcons.ts b/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/useRasterizedTabIcons.ts deleted file mode 100644 index 596a606bc4ec..000000000000 --- a/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/useRasterizedTabIcons.ts +++ /dev/null @@ -1,40 +0,0 @@ -import {useEffect, useState} from 'react'; - -/** Held outside any component, so a remounted navigator starts from the last icons instead of waiting for Skia. */ -type LastDrawnTabIcons = {current?: T}; - -/** A failed draw keeps the previous icons, or resolves to `fallbackOnError` when there are none. */ -function useRasterizedTabIcons(lastDrawn: LastDrawnTabIcons, signature: string | undefined, draw: (signature: string) => Promise, fallbackOnError?: T): T | undefined { - const [icons, setIcons] = useState(lastDrawn.current); - - useEffect(() => { - let isActive = true; - if (signature !== undefined) { - draw(signature) - .then((result) => { - if (!isActive || result === undefined) { - return; - } - // eslint-disable-next-line no-param-reassign - lastDrawn.current = result; - setIcons(result); - }) - .catch(() => { - if (!isActive || fallbackOnError === undefined) { - return; - } - setIcons((previous) => previous ?? fallbackOnError); - }); - } - - return () => { - isActive = false; - }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [signature]); - - return icons; -} - -export default useRasterizedTabIcons; -export type {LastDrawnTabIcons}; diff --git a/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/useTabAvatarImage.ts b/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/useTabAvatarImage.ts new file mode 100644 index 000000000000..075384c5fed9 --- /dev/null +++ b/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/useTabAvatarImage.ts @@ -0,0 +1,53 @@ +import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; + +import {getAvatarURL} from '@libs/UserAvatarUtils'; + +import type {SkImage} from '@shopify/react-native-skia'; + +import {Skia} from '@shopify/react-native-skia'; +import {useEffect, useState} from 'react'; + +type TabAvatarImage = {uri: string; image: SkImage}; + +/** Held outside any component, so a status or theme redraw and a remounted navigator reuse the decoded avatar. */ +let lastDecodedAvatar: TabAvatarImage | undefined; + +/** + * The current user's avatar, decoded once per URI for the account tab icon. Until it is decoded, and for avatars that + * are not a remote image, the account glyph stands in. + */ +function useTabAvatarImage(): TabAvatarImage | undefined { + const {avatar, accountID} = useCurrentUserPersonalDetails(); + const avatarSource = getAvatarURL({avatarSource: avatar, accountID}); + const uri = typeof avatarSource === 'string' ? avatarSource : undefined; + const [decodedAvatar, setDecodedAvatar] = useState(lastDecodedAvatar); + + useEffect(() => { + if (!uri || decodedAvatar?.uri === uri) { + return; + } + let isActive = true; + Skia.Data.fromURI(uri) + .then((data) => { + const image = Skia.Image.MakeImageFromEncoded(data); + if (!image) { + return; + } + lastDecodedAvatar = {uri, image}; + if (isActive) { + setDecodedAvatar(lastDecodedAvatar); + } + }) + // A failed decode keeps the account glyph. + .catch(() => undefined); + return () => { + isActive = false; + }; + // Only a new URI needs decoding; the decoded avatar is compared to skip one that is already decoded. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [uri]); + + return decodedAvatar?.uri === uri ? decodedAvatar : undefined; +} + +export default useTabAvatarImage; diff --git a/src/styles/variables.ts b/src/styles/variables.ts index 6d10a89da4a8..ee8d0aac2d66 100644 --- a/src/styles/variables.ts +++ b/src/styles/variables.ts @@ -120,8 +120,6 @@ export default { nativeTabIconDotRadius: 4, // Gap between the glyph and the label baked under it, matching the spacing the JS bar uses. nativeTabIconLabelGap: 6, - /** Pixel density the native tab bar icons are rasterized at. */ - nativeTabIconScale: 3, /** Material's BottomNavigationView row, without the gesture inset under it. */ androidNativeTabBarHeight, /** Material's bar row plus the 16 dp Material keeps between a FAB and the bar. */ From d8ab0f4f74e81ddd86ac653d8770ccf5da80a750 Mon Sep 17 00:00:00 2001 From: sumo_slonik Date: Mon, 5 Oct 2026 13:08:35 +0200 Subject: [PATCH 40/71] refactor: one tab root scroll props hook, platform tab bar insets without empty bottom content --- .../LHNOptionsList/LHNOptionsList.tsx | 9 +++--- .../TabBarBottomContent/index.android.tsx | 29 ------------------- .../TabBarBottomContent/index.ios.tsx | 6 ---- .../index.native.tsx | 8 ----- .../index.android.tsx | 22 +++++++++----- .../index.ios.tsx | 20 ++----------- .../useTabRootScrollProps/index.native.tsx | 14 +++++++++ .../index.tsx | 11 ++++--- .../BaseSearchList/index.native.tsx | 6 ++-- .../SearchList/BaseSearchList/index.tsx | 6 ++-- .../Tables/WorkspaceListTable/index.tsx | 5 ++-- src/pages/Insights/InsightsDashboard.tsx | 14 ++++----- src/pages/domain/DomainInitialPage.tsx | 10 ++----- src/pages/home/HomePage.tsx | 8 ++--- src/pages/settings/InitialSettingsPage.tsx | 8 ++--- src/pages/workspace/WorkspaceInitialPage.tsx | 10 ++----- 16 files changed, 68 insertions(+), 118 deletions(-) delete mode 100644 src/components/Navigation/TabBarBottomContent/index.android.tsx delete mode 100644 src/components/Navigation/TabBarBottomContent/index.ios.tsx delete mode 100644 src/components/Navigation/TabBarBottomContent/useTabBarContentInsetStyle/index.native.tsx create mode 100644 src/components/Navigation/TabBarBottomContent/useTabRootScrollProps/index.native.tsx rename src/components/Navigation/TabBarBottomContent/{useTabBarContentInsetStyle => useTabRootScrollProps}/index.tsx (65%) diff --git a/src/components/LHNOptionsList/LHNOptionsList.tsx b/src/components/LHNOptionsList/LHNOptionsList.tsx index 391b907c3a54..fb948e8a49dd 100644 --- a/src/components/LHNOptionsList/LHNOptionsList.tsx +++ b/src/components/LHNOptionsList/LHNOptionsList.tsx @@ -1,4 +1,4 @@ -import useTabBarContentInsetStyle from '@components/Navigation/TabBarBottomContent/useTabBarContentInsetStyle'; +import useTabRootScrollProps from '@components/Navigation/TabBarBottomContent/useTabRootScrollProps'; import {ScrollOffsetContext} from '@components/ScrollOffsetContextProvider'; import useNetwork from '@hooks/useNetwork'; @@ -46,7 +46,7 @@ function LHNOptionsList({style, contentContainerStyles, data, onSelectRow, optio const [personalDetails] = useAllPersonalDetails(); const styles = useThemeStyles(); - const contentContainerStyle = useTabBarContentInsetStyle(contentContainerStyles); + const tabRootScrollProps = useTabRootScrollProps(contentContainerStyles); const estimatedItemSize = optionMode === CONST.OPTION_MODE.COMPACT ? variables.optionRowHeightCompact : variables.optionRowHeight; // When the first item renders we want to call the onFirstItemRendered callback. @@ -164,12 +164,11 @@ function LHNOptionsList({style, contentContainerStyles, data, onSelectRow, optio ; -} - -export default TabBarBottomContent; diff --git a/src/components/Navigation/TabBarBottomContent/index.ios.tsx b/src/components/Navigation/TabBarBottomContent/index.ios.tsx deleted file mode 100644 index 4a2451a606dd..000000000000 --- a/src/components/Navigation/TabBarBottomContent/index.ios.tsx +++ /dev/null @@ -1,6 +0,0 @@ -/** UITabBar is translucent, so a tab root screen's content runs under it rather than ending above a copy of the bar. */ -function TabBarBottomContent() { - return null; -} - -export default TabBarBottomContent; diff --git a/src/components/Navigation/TabBarBottomContent/useTabBarContentInsetStyle/index.native.tsx b/src/components/Navigation/TabBarBottomContent/useTabBarContentInsetStyle/index.native.tsx deleted file mode 100644 index 2a8564a9d097..000000000000 --- a/src/components/Navigation/TabBarBottomContent/useTabBarContentInsetStyle/index.native.tsx +++ /dev/null @@ -1,8 +0,0 @@ -import type {StyleProp, ViewStyle} from 'react-native'; - -/** UIKit insets the lists past the iOS tab bar, and Android reserves the bar's row below the content. */ -function useTabBarContentInsetStyle(style?: StyleProp): StyleProp { - return style; -} - -export default useTabBarContentInsetStyle; diff --git a/src/components/Navigation/TabBarBottomContent/useTabRootScreenWrapperProps/index.android.tsx b/src/components/Navigation/TabBarBottomContent/useTabRootScreenWrapperProps/index.android.tsx index b632062f193f..0ecf905ca0d3 100644 --- a/src/components/Navigation/TabBarBottomContent/useTabRootScreenWrapperProps/index.android.tsx +++ b/src/components/Navigation/TabBarBottomContent/useTabRootScreenWrapperProps/index.android.tsx @@ -1,21 +1,29 @@ +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'; import React from 'react'; +import {View} from 'react-native'; import type TabRootScreenWrapperProps from './types'; -import TabBarBottomContent from '..'; - -/** The ScreenWrapper props that keep a tab root screen's content clear of the tab bar. */ +/** + * 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. + */ function useTabRootScreenWrapperProps(selectedTab: TabBarBottomContentProps['selectedTab']): TabRootScreenWrapperProps { + const {shouldUseNarrowLayout} = useResponsiveLayout(); + const isSettingsDrawnOverTabs = useIsSettingsDrawnOverTabs(); const styles = useThemeStyles(); - return { - bottomContent: , - bottomContentStyle: styles.overflowVisible, - }; + if (!shouldUseNarrowLayout || (selectedTab === NAVIGATION_TABS.SETTINGS && isSettingsDrawnOverTabs)) { + return {}; + } + + return {bottomContent: }; } export default useTabRootScreenWrapperProps; diff --git a/src/components/Navigation/TabBarBottomContent/useTabRootScreenWrapperProps/index.ios.tsx b/src/components/Navigation/TabBarBottomContent/useTabRootScreenWrapperProps/index.ios.tsx index 8a77d0dcc4f2..c7d86f32d2c4 100644 --- a/src/components/Navigation/TabBarBottomContent/useTabRootScreenWrapperProps/index.ios.tsx +++ b/src/components/Navigation/TabBarBottomContent/useTabRootScreenWrapperProps/index.ios.tsx @@ -1,30 +1,14 @@ -import type TabBarBottomContentProps from '@components/Navigation/TabBarBottomContent/types'; - import useResponsiveLayout from '@hooks/useResponsiveLayout'; -import useThemeStyles from '@hooks/useThemeStyles'; - -import React from 'react'; import type TabRootScreenWrapperProps from './types'; -import TabBarBottomContent from '..'; - /** * 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 adds no bottom padding. Each tab root's list lets UIKit inset its end past the bar. */ -function useTabRootScreenWrapperProps(selectedTab: TabBarBottomContentProps['selectedTab']): TabRootScreenWrapperProps { +function useTabRootScreenWrapperProps(): TabRootScreenWrapperProps { const {shouldUseNarrowLayout} = useResponsiveLayout(); - const styles = useThemeStyles(); - - if (shouldUseNarrowLayout) { - return {includeSafeAreaPaddingBottom: false}; - } - - return { - bottomContent: , - bottomContentStyle: styles.overflowVisible, - }; + return shouldUseNarrowLayout ? {includeSafeAreaPaddingBottom: false} : {}; } export default useTabRootScreenWrapperProps; diff --git a/src/components/Navigation/TabBarBottomContent/useTabRootScrollProps/index.native.tsx b/src/components/Navigation/TabBarBottomContent/useTabRootScrollProps/index.native.tsx new file mode 100644 index 000000000000..3ab149092092 --- /dev/null +++ b/src/components/Navigation/TabBarBottomContent/useTabRootScrollProps/index.native.tsx @@ -0,0 +1,14 @@ +import type {ScrollViewProps, StyleProp, ViewStyle} from 'react-native'; + +type TabRootScrollProps = Pick; + +/** + * UIKit insets the end of the list past the translucent iOS tab bar that the content runs under, and Android reserves + * the bar's row below the content, so the content container keeps its style. + */ +function useTabRootScrollProps(style?: StyleProp): TabRootScrollProps { + return {contentContainerStyle: style, contentInsetAdjustmentBehavior: 'automatic'}; +} + +export default useTabRootScrollProps; +export type {TabRootScrollProps}; diff --git a/src/components/Navigation/TabBarBottomContent/useTabBarContentInsetStyle/index.tsx b/src/components/Navigation/TabBarBottomContent/useTabRootScrollProps/index.tsx similarity index 65% rename from src/components/Navigation/TabBarBottomContent/useTabBarContentInsetStyle/index.tsx rename to src/components/Navigation/TabBarBottomContent/useTabRootScrollProps/index.tsx index 5920674a1f49..75b760461ef3 100644 --- a/src/components/Navigation/TabBarBottomContent/useTabBarContentInsetStyle/index.tsx +++ b/src/components/Navigation/TabBarBottomContent/useTabRootScrollProps/index.tsx @@ -3,14 +3,16 @@ import useResponsiveLayout from '@hooks/useResponsiveLayout'; import variables from '@styles/variables'; -import type {StyleProp, ViewStyle} from 'react-native'; +import type {ScrollViewProps, StyleProp, ViewStyle} from 'react-native'; + +type TabRootScrollProps = Pick; /** * The content of a narrow tab root screen runs under the floating tab bar, so a list's content container ends with * room for the bar, letting its last row scroll out from under it. A list that already adds the bottom safe area * passes `hasBottomSafeAreaPadding`, so the safe area is not added twice. */ -function useTabBarContentInsetStyle(style?: StyleProp, hasBottomSafeAreaPadding = false): StyleProp { +function useTabRootScrollProps(style?: StyleProp, hasBottomSafeAreaPadding = false): TabRootScrollProps { const {shouldUseNarrowLayout} = useResponsiveLayout(); const insetStyle = useBottomSafeSafeAreaPaddingStyle({ style, @@ -19,7 +21,8 @@ function useTabBarContentInsetStyle(style?: StyleProp, hasBottomSafeA additionalPaddingBottom: variables.floatingTabBarHeight + variables.floatingTabBarBottomInset, }); - return shouldUseNarrowLayout ? insetStyle : style; + return {contentContainerStyle: shouldUseNarrowLayout ? insetStyle : style}; } -export default useTabBarContentInsetStyle; +export default useTabRootScrollProps; +export type {TabRootScrollProps}; diff --git a/src/components/Search/SearchList/BaseSearchList/index.native.tsx b/src/components/Search/SearchList/BaseSearchList/index.native.tsx index 95bc5b6a011c..97fe20695996 100644 --- a/src/components/Search/SearchList/BaseSearchList/index.native.tsx +++ b/src/components/Search/SearchList/BaseSearchList/index.native.tsx @@ -1,3 +1,4 @@ +import useTabRootScrollProps from '@components/Navigation/TabBarBottomContent/useTabRootScrollProps'; import type {SearchListItem} from '@components/Search/SearchList/ListItem/types'; import {FlashList} from '@shopify/flash-list'; @@ -23,6 +24,7 @@ function BaseSearchList({ stickyHeaderIndices, getItemType, }: BaseSearchListProps) { + const tabRootScrollProps = useTabRootScrollProps(contentContainerStyle); const renderItemWithoutKeyboardFocus = useCallback( ({item, index}: {item: SearchListItem; index: number}) => { return renderItem(item, index, false, undefined); @@ -33,8 +35,6 @@ function BaseSearchList({ return ( `${row.policyID}-${index}`} filters={filterConfig} isItemInFilter={isItemInFilter} - // Lets UIKit inset the end of the list past the translucent iOS tab bar that the content runs under. - contentInsetAdjustmentBehavior="automatic" + contentInsetAdjustmentBehavior={contentInsetAdjustmentBehavior} > {tableHeaderComponent} diff --git a/src/pages/Insights/InsightsDashboard.tsx b/src/pages/Insights/InsightsDashboard.tsx index 0c96b3583719..aff5aac08db7 100644 --- a/src/pages/Insights/InsightsDashboard.tsx +++ b/src/pages/Insights/InsightsDashboard.tsx @@ -1,8 +1,8 @@ import BlockingView from '@components/BlockingViews/BlockingView'; import FullPageErrorView from '@components/BlockingViews/FullPageErrorView'; import NAVIGATION_TABS from '@components/Navigation/NavigationTabBar/NAVIGATION_TABS'; -import useTabBarContentInsetStyle from '@components/Navigation/TabBarBottomContent/useTabBarContentInsetStyle'; import useTabRootScreenWrapperProps from '@components/Navigation/TabBarBottomContent/useTabRootScreenWrapperProps'; +import useTabRootScrollProps from '@components/Navigation/TabBarBottomContent/useTabRootScrollProps'; import TopBar from '@components/Navigation/TopBar'; import ScreenWrapper from '@components/ScreenWrapper'; import ScrollView from '@components/ScrollView'; @@ -65,8 +65,8 @@ function InsightsDashboardContent({state, headlineChart, supportingCharts, filte const {translate} = useLocalize(); const {shouldUseNarrowLayout} = useResponsiveLayout(); const icons = useMemoizedLazyExpensifyIcons(['OfflineCloud']); - const emptyStateContentContainerStyle = useTabBarContentInsetStyle([styles.flexGrow1, styles.flexShrink0], true); - const contentContainerStyle = useTabBarContentInsetStyle([styles.flexGrow1, styles.ph5, styles.pb5], true); + const emptyStateScrollProps = useTabRootScrollProps([styles.flexGrow1, styles.flexShrink0], true); + const scrollProps = useTabRootScrollProps([styles.flexGrow1, styles.ph5, styles.pb5], true); const didRequestFail = state === INSIGHTS_DASHBOARD_STATE.ERROR || state === INSIGHTS_DASHBOARD_STATE.STALE; @@ -111,9 +111,7 @@ function InsightsDashboardContent({state, headlineChart, supportingCharts, filte if (state === INSIGHTS_DASHBOARD_STATE.NO_EXPENSES || state === INSIGHTS_DASHBOARD_STATE.EMPTY) { return ( {state === INSIGHTS_DASHBOARD_STATE.NO_EXPENSES ? : } @@ -127,9 +125,7 @@ function InsightsDashboardContent({state, headlineChart, supportingCharts, filte return ( diff --git a/src/pages/domain/DomainInitialPage.tsx b/src/pages/domain/DomainInitialPage.tsx index fb526a6e7c24..a5686293daeb 100644 --- a/src/pages/domain/DomainInitialPage.tsx +++ b/src/pages/domain/DomainInitialPage.tsx @@ -2,8 +2,8 @@ import FullPageNotFoundView from '@components/BlockingViews/FullPageNotFoundView import HeaderWithBackButton from '@components/HeaderWithBackButton'; import HighlightableMenuItem from '@components/HighlightableMenuItem'; import NAVIGATION_TABS from '@components/Navigation/NavigationTabBar/NAVIGATION_TABS'; -import useTabBarContentInsetStyle from '@components/Navigation/TabBarBottomContent/useTabBarContentInsetStyle'; import useTabRootScreenWrapperProps from '@components/Navigation/TabBarBottomContent/useTabRootScreenWrapperProps'; +import useTabRootScrollProps from '@components/Navigation/TabBarBottomContent/useTabRootScrollProps'; import ScreenWrapper from '@components/ScreenWrapper'; import ScrollView from '@components/ScrollView'; @@ -45,7 +45,7 @@ function DomainInitialPage({route}: DomainInitialPageProps) { const icons = useMemoizedLazyExpensifyIcons(DOMAIN_MENU_ICON_NAMES); const styles = useThemeStyles(); const tabRootScreenWrapperProps = useTabRootScreenWrapperProps(NAVIGATION_TABS.WORKSPACES); - const contentContainerStyle = useTabBarContentInsetStyle(styles.flexColumn); + const tabRootScrollProps = useTabRootScrollProps(styles.flexColumn); const waitForNavigate = useWaitForNavigation(); const {singleExecution, isExecuting} = useSingleExecution(); const activeRoute = useNavigationState((state) => findFocusedRoute(state)?.name); @@ -123,11 +123,7 @@ function DomainInitialPage({route}: DomainInitialPageProps) { shouldDisplayAccountButton /> - + {/* Ideally we should use MenuList component for MenuItems with singleExecution/Navigation actions. diff --git a/src/pages/home/HomePage.tsx b/src/pages/home/HomePage.tsx index e32d114bbff8..9027fee12d26 100644 --- a/src/pages/home/HomePage.tsx +++ b/src/pages/home/HomePage.tsx @@ -1,7 +1,7 @@ import NAVIGATION_TABS from '@components/Navigation/NavigationTabBar/NAVIGATION_TABS'; import QuickCreationActionsBar from '@components/Navigation/QuickCreationActionsBar'; -import useTabBarContentInsetStyle from '@components/Navigation/TabBarBottomContent/useTabBarContentInsetStyle'; import useTabRootScreenWrapperProps from '@components/Navigation/TabBarBottomContent/useTabRootScreenWrapperProps'; +import useTabRootScrollProps from '@components/Navigation/TabBarBottomContent/useTabRootScrollProps'; import TopBar from '@components/Navigation/TopBar'; import ReceiptScanDropZone from '@components/ReceiptScanDropZone'; import ScreenWrapper from '@components/ScreenWrapper'; @@ -44,7 +44,7 @@ function HomePage() { const {shouldUseNarrowLayout} = useResponsiveLayout(); const styles = useThemeStyles(); const tabRootScreenWrapperProps = useTabRootScreenWrapperProps(NAVIGATION_TABS.HOME); - const contentContainerStyle = useTabBarContentInsetStyle(styles.homePageContentContainer, true); + const tabRootScrollProps = useTabRootScrollProps(styles.homePageContentContainer, true); const {translate} = useLocalize(); useDocumentTitle(translate('common.home')); const {isOffline} = useNetwork({onReconnect: openHomePage}); @@ -153,9 +153,7 @@ function HomePage() { /> diff --git a/src/pages/settings/InitialSettingsPage.tsx b/src/pages/settings/InitialSettingsPage.tsx index 19be41b5a75b..a55c35407e76 100755 --- a/src/pages/settings/InitialSettingsPage.tsx +++ b/src/pages/settings/InitialSettingsPage.tsx @@ -2,8 +2,8 @@ import AccountSwitcher from '@components/AccountSwitcher'; import AccountSwitcherButton from '@components/AccountSwitcherButton'; import AccountSwitcherSkeletonView from '@components/AccountSwitcherSkeletonView'; import NAVIGATION_TABS from '@components/Navigation/NavigationTabBar/NAVIGATION_TABS'; -import useTabBarContentInsetStyle from '@components/Navigation/TabBarBottomContent/useTabBarContentInsetStyle'; import useTabRootScreenWrapperProps from '@components/Navigation/TabBarBottomContent/useTabRootScreenWrapperProps'; +import useTabRootScrollProps from '@components/Navigation/TabBarBottomContent/useTabRootScrollProps'; import TopBarWithLoadingBar from '@components/Navigation/TopBarWithLoadingBar'; import ScreenWrapper from '@components/ScreenWrapper'; import {ScrollOffsetContext} from '@components/ScrollOffsetContextProvider'; @@ -53,7 +53,7 @@ function InitialSettingsPage({currentUserPersonalDetails}: InitialSettingsPagePr const [canSwitchAccounts = false] = useOnyx(ONYXKEYS.ACCOUNT, {selector: canSwitchAccountsSelector}); const styles = useThemeStyles(); const tabRootScreenWrapperProps = useTabRootScreenWrapperProps(NAVIGATION_TABS.SETTINGS); - const contentContainerStyle = useTabBarContentInsetStyle(styles.w100); + const tabRootScrollProps = useTabRootScrollProps(styles.w100); const {isExecuting, singleExecution} = useSingleExecution(); const {translate} = useLocalize(); const focusedRouteName = useNavigationState((state) => findFocusedRoute(state)?.name); @@ -185,11 +185,9 @@ function InitialSettingsPage({currentUserPersonalDetails}: InitialSettingsPagePr {headerContent} diff --git a/src/pages/workspace/WorkspaceInitialPage.tsx b/src/pages/workspace/WorkspaceInitialPage.tsx index 0456e3fcc2cc..ebb5f1581075 100644 --- a/src/pages/workspace/WorkspaceInitialPage.tsx +++ b/src/pages/workspace/WorkspaceInitialPage.tsx @@ -2,8 +2,8 @@ import FullPageNotFoundView from '@components/BlockingViews/FullPageNotFoundView import HeaderWithBackButton from '@components/HeaderWithBackButton'; import HighlightableMenuItem from '@components/HighlightableMenuItem'; import NAVIGATION_TABS from '@components/Navigation/NavigationTabBar/NAVIGATION_TABS'; -import useTabBarContentInsetStyle from '@components/Navigation/TabBarBottomContent/useTabBarContentInsetStyle'; import useTabRootScreenWrapperProps from '@components/Navigation/TabBarBottomContent/useTabRootScreenWrapperProps'; +import useTabRootScrollProps from '@components/Navigation/TabBarBottomContent/useTabRootScrollProps'; import OfflineWithFeedback from '@components/OfflineWithFeedback'; import ScreenWrapper from '@components/ScreenWrapper'; import ScrollView from '@components/ScrollView'; @@ -70,7 +70,7 @@ function dismissError(policyID: string | undefined, pendingAction: PendingAction function WorkspaceInitialPage({policyDraft, policy: policyProp, route}: WorkspaceInitialPageProps) { const styles = useThemeStyles(); const tabRootScreenWrapperProps = useTabRootScreenWrapperProps(NAVIGATION_TABS.WORKSPACES); - const contentContainerStyle = useTabBarContentInsetStyle([styles.flexColumn, styles.pb14]); + const tabRootScrollProps = useTabRootScrollProps([styles.flexColumn, styles.pb14]); const {shouldUseNarrowLayout} = useResponsiveLayout(); const {translate} = useLocalize(); const {convertToDisplayString} = useCurrencyListActions(); @@ -249,11 +249,7 @@ function WorkspaceInitialPage({policyDraft, policy: policyProp, route}: Workspac shouldDisplayAccountButton /> - + dismissError(policyID, policy?.pendingAction)} From 5f852c01a838a2ddcf670e1c3b0396132b3bed9f Mon Sep 17 00:00:00 2001 From: sumo_slonik Date: Mon, 5 Oct 2026 13:09:09 +0200 Subject: [PATCH 41/71] refactor: one rule for the tab the native bar has no item for --- src/hooks/useIsSettingsDrawnOverTabs/index.native.ts | 7 +++++-- .../NativeTabNavigator/getTabWithoutBarItem.ts | 12 ++++++++++++ .../NativeTabNavigator/useNativeTabNavigator.ts | 7 ++++--- .../AppNavigator/Navigators/TabNavigator.native.tsx | 6 +++--- 4 files changed, 24 insertions(+), 8 deletions(-) create mode 100644 src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/getTabWithoutBarItem.ts diff --git a/src/hooks/useIsSettingsDrawnOverTabs/index.native.ts b/src/hooks/useIsSettingsDrawnOverTabs/index.native.ts index 8220fdd02a41..ba0c059aafa2 100644 --- a/src/hooks/useIsSettingsDrawnOverTabs/index.native.ts +++ b/src/hooks/useIsSettingsDrawnOverTabs/index.native.ts @@ -1,16 +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'; /** - * With the Insights beta the native tab bar has no Account item, so the Account tab is drawn over the other tabs + * 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 && isBetaEnabled(CONST.BETAS.INSIGHTS_PAGE); + return shouldUseNarrowLayout && getTabWithoutBarItem(isBetaEnabled(CONST.BETAS.INSIGHTS_PAGE)) === NAVIGATORS.SETTINGS_SPLIT_NAVIGATOR; } export default useIsSettingsDrawnOverTabs; diff --git a/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/getTabWithoutBarItem.ts b/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/getTabWithoutBarItem.ts new file mode 100644 index 000000000000..502b8506ea4e --- /dev/null +++ b/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/getTabWithoutBarItem.ts @@ -0,0 +1,12 @@ +import NAVIGATORS from '@src/NAVIGATORS'; +import SCREENS from '@src/SCREENS'; + +/** + * The native bar shows at most five of the six tabs. With the Insights beta, Insights takes the Account tab's place + * and Account moves to the top bar; without it, Insights has no item. + */ +function getTabWithoutBarItem(isInsightsBetaEnabled: boolean) { + return isInsightsBetaEnabled ? NAVIGATORS.SETTINGS_SPLIT_NAVIGATOR : SCREENS.INSIGHTS; +} + +export default getTabWithoutBarItem; diff --git a/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/useNativeTabNavigator.ts b/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/useNativeTabNavigator.ts index 633fcd0e599e..ab1f97e132d9 100644 --- a/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/useNativeTabNavigator.ts +++ b/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/useNativeTabNavigator.ts @@ -11,6 +11,7 @@ import useTheme from '@hooks/useTheme'; import useWorkspacesTabIndicatorStatus from '@hooks/useWorkspacesTabIndicatorStatus'; import {getPreservedNavigatorState, setPreservedNavigatorState} from '@libs/Navigation/AppNavigator/createSplitNavigator/usePreserveNavigatorState'; +import getTabWithoutBarItem from '@libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/getTabWithoutBarItem'; import {NAVIGATION_TAB_TO_SPANS} from '@libs/Navigation/AppNavigator/Navigators/TabNavigatorBar'; import isTabRouteAtRoot from '@libs/Navigation/helpers/isTabRouteAtRoot'; import Navigation from '@libs/Navigation/Navigation'; @@ -44,8 +45,8 @@ function useNativeTabNavigator() { const {isBlockingViewVisible} = useFullScreenBlockingViewState(); const {isBetaEnabled} = usePermissions(); const {translate} = useLocalize(); - // With the Insights beta, Insights takes the Account tab's place in the bar and Account moves to the top bar. const isInsightsTabVisible = isBetaEnabled(CONST.BETAS.INSIGHTS_PAGE); + const tabWithoutBarItem = getTabWithoutBarItem(isInsightsTabVisible); const theme = useTheme(); const chatTabBrickRoad = useChatTabBrickRoad(); const {indicatorColor: workspacesIndicatorColor, status: workspacesIndicatorStatus} = useWorkspacesTabIndicatorStatus(); @@ -61,7 +62,7 @@ function useNativeTabNavigator() { const activeTabRoute = isRealizedNavigationState(tabState) ? tabState.routes[tabState.index] : undefined; const selectedTab = ROUTE_TO_NAVIGATION_TAB[activeTabRoute?.name ?? SCREENS.HOME] ?? NAVIGATION_TABS.HOME; // 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 = isInsightsTabVisible ? activeTabRoute?.name === NAVIGATORS.SETTINGS_SPLIT_NAVIGATOR : activeTabRoute?.name === SCREENS.INSIGHTS; + const isActiveTabWithoutBarItem = activeTabRoute?.name === tabWithoutBarItem; const shouldShowNativeTabBar = shouldUseNarrowLayout && isTabRouteAtRoot(activeTabRoute) && !isBlockingViewVisible && !isActiveTabWithoutBarItem; let inboxDotColor: string | undefined; @@ -126,7 +127,7 @@ function useNativeTabNavigator() { }, }); - return {shouldShowNativeTabBar, dotColors, tabLabels, isInsightsTabVisible, tabRouterOverride}; + return {shouldShowNativeTabBar, dotColors, tabLabels, tabWithoutBarItem, tabRouterOverride}; } export default useNativeTabNavigator; diff --git a/src/libs/Navigation/AppNavigator/Navigators/TabNavigator.native.tsx b/src/libs/Navigation/AppNavigator/Navigators/TabNavigator.native.tsx index a3c449ce89ad..cbf3a2fb1130 100644 --- a/src/libs/Navigation/AppNavigator/Navigators/TabNavigator.native.tsx +++ b/src/libs/Navigation/AppNavigator/Navigators/TabNavigator.native.tsx @@ -32,7 +32,7 @@ const Tab = createNativeBottomTabNavigator(); const renderNativeTabLayout = (props: NativeTabLayoutProps) => ; function TabNavigator() { - const {shouldShowNativeTabBar, dotColors, tabLabels, isInsightsTabVisible, tabRouterOverride} = useNativeTabNavigator(); + const {shouldShowNativeTabBar, dotColors, tabLabels, tabWithoutBarItem, tabRouterOverride} = useNativeTabNavigator(); const {screenOptions, getTabOptions} = useNativeTabBarOptions({shouldShowNativeTabBar, dotColors, tabLabels}); return ( @@ -64,7 +64,7 @@ function TabNavigator() { component={InsightsPage} // The Insights tab button opens the Spend dashboard, so a tap on the native tab lands on it too. initialParams={{dashboardID: CONST.INSIGHTS.DASHBOARD.SPEND}} - options={{...getTabOptions(SCREENS.INSIGHTS), tabBarItemHidden: !isInsightsTabVisible}} + options={{...getTabOptions(SCREENS.INSIGHTS), tabBarItemHidden: tabWithoutBarItem === SCREENS.INSIGHTS}} /> ); From c46d04881697c86818ea8f057bf112638c08cbfb Mon Sep 17 00:00:00 2001 From: sumo_slonik Date: Mon, 5 Oct 2026 13:09:53 +0200 Subject: [PATCH 42/71] chore: merge the two Android tab label patches for react-native-screens into one --- patches/react-native-screens/details.md | 13 +++----- ....0+002+android-tab-label-typography.patch} | 26 ++++++++++++++-- ...+4.28.0+003+no-android-tab-icon-tint.patch | 4 +-- ...004+android-tab-label-letter-spacing.patch | 30 ------------------- 4 files changed, 29 insertions(+), 44 deletions(-) rename patches/react-native-screens/{react-native-screens+4.28.0+002+bold-selected-tab-label.patch => react-native-screens+4.28.0+002+android-tab-label-typography.patch} (57%) delete mode 100644 patches/react-native-screens/react-native-screens+4.28.0+004+android-tab-label-letter-spacing.patch diff --git a/patches/react-native-screens/details.md b/patches/react-native-screens/details.md index cdb1ee57cb0a..10e08d0a2a47 100644 --- a/patches/react-native-screens/details.md +++ b/patches/react-native-screens/details.md @@ -7,9 +7,11 @@ - E/App issue: [#101169](https://github.com/Expensify/App/issues/101169) - PR introducing patch: [#101339](https://github.com/Expensify/App/pull/101339) -### [react-native-screens+4.28.0+002+bold-selected-tab-label.patch](react-native-screens+4.28.0+002+bold-selected-tab-label.patch) +### [react-native-screens+4.28.0+002+android-tab-label-typography.patch](react-native-screens+4.28.0+002+android-tab-label-typography.patch) -- Reason: The Android tab bar takes one font weight for every label, while the design marks the selected tab with a bold label, the same way the JS side bar does. Material's `BottomNavigationView` draws each item with two labels, a small one shown while unselected and a large one shown while selected. The patch turns on Material's own `setItemTextAppearanceActiveBoldEnabled`, which keeps the large label bold whenever Material re-applies its text appearance, and calls it before `updateFontStyles`, because enabling it re-applies Material's text appearance and would otherwise replace the app's typeface. `updateFontStyles` then sets the large label's typeface with `Typeface.BOLD`, the way Material applies its own bold, so Android picks the bold face of the same font family. +- Reason: Two changes to the Android tab labels, both in `TabsAppearanceApplicator`, which React Navigation has no option for. + - Bold selected label. The Android tab bar takes one font weight for every label, while the design marks the selected tab with a bold label, the same way the JS side bar does. Material's `BottomNavigationView` draws each item with two labels, a small one shown while unselected and a large one shown while selected. The patch turns on Material's own `setItemTextAppearanceActiveBoldEnabled`, which keeps the large label bold whenever Material re-applies its text appearance, and calls it before `updateFontStyles`, because enabling it re-applies Material's text appearance and would otherwise replace the app's typeface. `updateFontStyles` then sets the large label's typeface with `Typeface.BOLD`, the way Material applies its own bold, so Android picks the bold face of the same font family. + - Letter spacing. `updateFontStyles` replaces the typeface and size of Material's tab labels but keeps the rest of Material 3's `LabelMedium` text appearance, which tracks letters 0.5sp apart. With Expensify Neue the labels read too spread out next to the rest of the app, so the patch sets a fixed -0.5dp tracking on both labels, converted to the `em` unit `TextView.letterSpacing` expects for each label's size. - Upstream PR/issue: not reported yet. - E/App issue: [#101169](https://github.com/Expensify/App/issues/101169) - PR introducing patch: [#101339](https://github.com/Expensify/App/pull/101339) @@ -20,10 +22,3 @@ - Upstream PR/issue: not reported yet. - E/App issue: [#101169](https://github.com/Expensify/App/issues/101169) - PR introducing patch: [#101339](https://github.com/Expensify/App/pull/101339) - -### [react-native-screens+4.28.0+004+android-tab-label-letter-spacing.patch](react-native-screens+4.28.0+004+android-tab-label-letter-spacing.patch) - -- Reason: `TabsAppearanceApplicator.updateFontStyles` replaces the typeface and size of Material's tab labels but keeps the rest of Material 3's `LabelMedium` text appearance, which tracks letters 0.5sp apart. With Expensify Neue the labels read too spread out next to the rest of the app. React Navigation has no option that reaches the native label's letter spacing, so the patch sets a fixed -0.5dp tracking on both labels, converted to the `em` unit `TextView.letterSpacing` expects for each label's size. -- Upstream PR/issue: not reported yet. -- E/App issue: [#101169](https://github.com/Expensify/App/issues/101169) -- PR introducing patch: [#101339](https://github.com/Expensify/App/pull/101339) diff --git a/patches/react-native-screens/react-native-screens+4.28.0+002+bold-selected-tab-label.patch b/patches/react-native-screens/react-native-screens+4.28.0+002+android-tab-label-typography.patch similarity index 57% rename from patches/react-native-screens/react-native-screens+4.28.0+002+bold-selected-tab-label.patch rename to patches/react-native-screens/react-native-screens+4.28.0+002+android-tab-label-typography.patch index b01db811eef2..f0be85344b86 100644 --- a/patches/react-native-screens/react-native-screens+4.28.0+002+bold-selected-tab-label.patch +++ b/patches/react-native-screens/react-native-screens+4.28.0+002+android-tab-label-typography.patch @@ -1,7 +1,8 @@ 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 10cd91b..9238416 100644 --- a/node_modules/react-native-screens/android/src/main/java/com/swmansion/rnscreens/gamma/tabs/appearance/TabsAppearanceApplicator.kt +++ b/node_modules/react-native-screens/android/src/main/java/com/swmansion/rnscreens/gamma/tabs/appearance/TabsAppearanceApplicator.kt -@@ -3,6 +3,7 @@ +@@ -3,6 +3,7 @@ package com.swmansion.rnscreens.gamma.tabs.appearance import android.annotation.SuppressLint import android.content.Context import android.content.res.ColorStateList @@ -9,7 +10,7 @@ diff --git a/node_modules/react-native-screens/android/src/main/java/com/swmansi import android.graphics.drawable.StateListDrawable import android.util.TypedValue import android.view.MenuItem -@@ -95,6 +96,10 @@ +@@ -95,6 +96,10 @@ internal class TabsAppearanceApplicator( bottomNavigationView.labelVisibilityMode = visibilityMode @@ -20,12 +21,31 @@ diff --git a/node_modules/react-native-screens/android/src/main/java/com/swmansi // Ripple color val rippleColor = tabBarAppearance?.tabBarItemRippleColor -@@ -167,7 +172,7 @@ +@@ -161,13 +166,18 @@ internal class TabsAppearanceApplicator( + tabBarAppearance?.tabBarItemTitleLargeLabelFontSize?.takeIf { it > 0 }?.let { PixelUtil.toPixelFromSP(it) } + ?: context.resources.getDimension(R.dimen.design_bottom_navigation_text_size) + ++ // Material's label text appearance tracks letters 0.5sp apart, wider than the app's labels. ++ val labelTracking = PixelUtil.toPixelFromDIP(LABEL_TRACKING_DP) ++ + // Inactive + smallLabel.setTextSize(TypedValue.COMPLEX_UNIT_PX, smallFontSize) + smallLabel.typeface = fontFamily ++ smallLabel.letterSpacing = labelTracking / smallFontSize // Active largeLabel.setTextSize(TypedValue.COMPLEX_UNIT_PX, largeFontSize) - largeLabel.typeface = fontFamily + largeLabel.setTypeface(fontFamily, Typeface.BOLD) ++ largeLabel.letterSpacing = labelTracking / largeFontSize } } +@@ -237,5 +247,7 @@ internal class TabsAppearanceApplicator( + + companion object { + const val TAG = "TabsAppearanceApplicator" ++ ++ private const val LABEL_TRACKING_DP = -0.5f + } + } diff --git a/patches/react-native-screens/react-native-screens+4.28.0+003+no-android-tab-icon-tint.patch b/patches/react-native-screens/react-native-screens+4.28.0+003+no-android-tab-icon-tint.patch index 0e2a6405da10..95dc7a725632 100644 --- a/patches/react-native-screens/react-native-screens+4.28.0+003+no-android-tab-icon-tint.patch +++ b/patches/react-native-screens/react-native-screens+4.28.0+003+no-android-tab-icon-tint.patch @@ -1,8 +1,8 @@ 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 70a24c4..874eb63 100644 +index 9238416..d7d2024 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 -@@ -63,24 +63,10 @@ internal class TabsAppearanceApplicator( +@@ -64,24 +64,10 @@ internal class TabsAppearanceApplicator( bottomNavigationView.itemTextColor = ColorStateList(states, fontColors) // Icon color diff --git a/patches/react-native-screens/react-native-screens+4.28.0+004+android-tab-label-letter-spacing.patch b/patches/react-native-screens/react-native-screens+4.28.0+004+android-tab-label-letter-spacing.patch deleted file mode 100644 index 4986b3563f36..000000000000 --- a/patches/react-native-screens/react-native-screens+4.28.0+004+android-tab-label-letter-spacing.patch +++ /dev/null @@ -1,30 +0,0 @@ -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 ---- 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 -@@ -152,13 +152,18 @@ - tabBarAppearance?.tabBarItemTitleLargeLabelFontSize?.takeIf { it > 0 }?.let { PixelUtil.toPixelFromSP(it) } - ?: context.resources.getDimension(R.dimen.design_bottom_navigation_text_size) - -+ // Material's label text appearance tracks letters 0.5sp apart, wider than the app's labels. -+ val labelTracking = PixelUtil.toPixelFromDIP(LABEL_TRACKING_DP) -+ - // Inactive - smallLabel.setTextSize(TypedValue.COMPLEX_UNIT_PX, smallFontSize) - smallLabel.typeface = fontFamily -+ smallLabel.letterSpacing = labelTracking / smallFontSize - - // Active - largeLabel.setTextSize(TypedValue.COMPLEX_UNIT_PX, largeFontSize) - largeLabel.setTypeface(fontFamily, Typeface.BOLD) -+ largeLabel.letterSpacing = labelTracking / largeFontSize - } - } - -@@ -228,5 +233,7 @@ - - companion object { - const val TAG = "TabsAppearanceApplicator" -+ -+ private const val LABEL_TRACKING_DP = -0.5f - } - } From 901bff341b4c8475e8ca7201fa058b4788887b77 Mon Sep 17 00:00:00 2001 From: sumo_slonik Date: Mon, 5 Oct 2026 13:11:14 +0200 Subject: [PATCH 43/71] refactor: fade the Android tab bar shadow and the floating buttons in one container --- .../NativeTabBarShadow/index.tsx | 8 ++----- .../NativeTabNavigator/NativeTabLayout.tsx | 23 +++++++++++-------- src/styles/index.ts | 1 - 3 files changed, 16 insertions(+), 16 deletions(-) diff --git a/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/NativeTabBarShadow/index.tsx b/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/NativeTabBarShadow/index.tsx index 6a99823d730b..2ad21697e685 100644 --- a/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/NativeTabBarShadow/index.tsx +++ b/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/NativeTabBarShadow/index.tsx @@ -1,10 +1,8 @@ import useSafeAreaInsets from '@hooks/useSafeAreaInsets'; import useThemeStyles from '@hooks/useThemeStyles'; -import CONST from '@src/CONST'; - import React from 'react'; -import Animated, {FadeIn, FadeOut} from 'react-native-reanimated'; +import {View} from 'react-native'; /** Material's bar shares the screen's background, so a shadow along its top edge marks where the content ends. */ function NativeTabBarShadow() { @@ -12,9 +10,7 @@ function NativeTabBarShadow() { const {bottom: bottomInset} = useSafeAreaInsets(); return ( - diff --git a/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/NativeTabLayout.tsx b/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/NativeTabLayout.tsx index f2d380f1cabe..b605ffa7fa47 100644 --- a/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/NativeTabLayout.tsx +++ b/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/NativeTabLayout.tsx @@ -20,7 +20,7 @@ import SCREENS from '@src/SCREENS'; import type {NativeBottomTabNavigatorProps} from '@react-navigation/bottom-tabs/unstable'; import React from 'react'; -import {View} from 'react-native'; +import {StyleSheet, View} from 'react-native'; import Animated, {FadeIn, FadeOut} from 'react-native-reanimated'; import NativeTabBarShadow from './NativeTabBarShadow'; @@ -50,22 +50,27 @@ function NativeTabLayout({children, state, descriptors}: NativeTabLayoutProps) { return ( {children} - {shouldShowNativeTabBar && } {!!isDebugModeEnabled && shouldShowNativeTabBar && } {shouldShowNativeTabBar && ( - // The buttons belong to the bar, so they fade with it rather than appearing in place. They leave - // faster than they come in, so they stop covering a bar that is still sliding out. + // The shadow and the buttons belong to the bar, so they fade with it rather than appearing in place. + // They leave faster than they come in, so they stop covering a bar that is still sliding out. - - + + + + + + + - - )} diff --git a/src/styles/index.ts b/src/styles/index.ts index 4f02a3b8a223..16d6b8c98c43 100644 --- a/src/styles/index.ts +++ b/src/styles/index.ts @@ -6928,7 +6928,6 @@ const dynamicStyles = (theme: ThemeColors) => bottom, left: 0, height: 0, - zIndex: 10, }) satisfies ViewStyle, // Covers Material's bar and the gesture inset under it, and casts the shadow up over the screen content. From 29c26522155010d9b25aa2a5d384222edeb96fda Mon Sep 17 00:00:00 2001 From: sumo_slonik Date: Mon, 5 Oct 2026 13:13:20 +0200 Subject: [PATCH 44/71] revert: move the Search skeleton rows change to its own branch, fix/search-skeleton-rows --- src/components/Search/index.tsx | 28 +++++++++++++-------- src/pages/Search/SearchPageNarrow/index.tsx | 7 ------ 2 files changed, 17 insertions(+), 18 deletions(-) diff --git a/src/components/Search/index.tsx b/src/components/Search/index.tsx index ecdbf0664574..d51b2e0c2487 100644 --- a/src/components/Search/index.tsx +++ b/src/components/Search/index.tsx @@ -51,6 +51,7 @@ import { isTransactionGroupListItemType, isTransactionListItemType, isTransactionReportGroupListItemType, + isTransactionSearchType, shouldShowEmptyState, shouldShowYear as shouldShowYearUtil, } from '@libs/SearchUIUtils'; @@ -418,7 +419,7 @@ function Search({ const hasUnresolvedErrors = hasErrors && responseStatusCode === null; const isWaitingForInitialData = !shouldUseLiveData && !isOffline && (!isDataLoaded || isSearchLoadingWithNoResults || hasUnresolvedErrors || isCardFeedsLoading); const shouldShowLoadingState = isDeferringHeavyWork || isWaitingForInitialData; - const shouldShowRowSkeleton = !hasErrors && (shouldShowLoadingState || (!skeletonWasDisplayed && showPendingExpensePlaceholder)); + const shouldShowRowSkeleton = (!skeletonWasDisplayed || shouldShowLoadingState) && showPendingExpensePlaceholder && !hasErrors; const isLoadingMorePage = shouldUseLiveData ? isLivePageInFlight : !!searchResults?.search?.isLoading; const shouldShowLoadingMoreItems = !shouldShowLoadingState && isLoadingMorePage && (searchResults?.search?.offset ?? 0) > 0; @@ -1011,11 +1012,6 @@ function Search({ // Deferred layout only needs the base work (no scroll handling, no content-ready signal). const onDeferredLayout = onLayoutBase; - const handleSkeletonLayout = useCallback(() => { - onDeferredLayout(); - onSkeletonLayout(); - }, [onDeferredLayout, onSkeletonLayout]); - const onLayout = useCallback(() => { onLayoutBase(); handleSelectionListScroll(stableSortedData, searchListRef.current); @@ -1175,15 +1171,25 @@ function Search({ [clearSelectedTransactions, queryJSON, onSortPressedCallback, navigation], ); - // Stands in for the list while the heavy grouping and sorting work is deferred or the first response - // has not arrived. The page level skeleton (useSearchLoadingState) stops at the first defined `data`, - // so on a warm snapshot this is the only thing between the header and the rows. Its onLayout also - // flushes the deferred API write and sets hasHadFirstLayout. + // When heavy work is deferred (e.g. during the RHP dismiss animation after + // submitting an expense), skip the expensive render below. The ancestor + // SearchPage (via SearchPageNarrow / SearchPageWide) renders a SearchStaticList + // overlay that covers this component, so the user sees real-looking content. + // The minimal View fires onLayout to flush the deferred API write and set + // hasHadFirstLayout. + if (isDeferringHeavyWork && searchResults?.data && isTransactionSearchType(type)) { + // Zero-sized View - onLayout still fires on RN, which is all we need here. + return ; + } + + // This is a performance optimization for the submit-expense->search path only. + // The SearchPage skeleton (useSearchLoadingState) doesn't cover this case because + // Search must mount for its onLayout to flush the deferred CreateMoneyRequest API write, which would block the JS thread causing a slowdown on post expense creation navigation if (shouldShowRowSkeleton) { return ( ); diff --git a/src/pages/Search/SearchPageNarrow/index.tsx b/src/pages/Search/SearchPageNarrow/index.tsx index 1e5523b5326c..e310ae2aa9e2 100644 --- a/src/pages/Search/SearchPageNarrow/index.tsx +++ b/src/pages/Search/SearchPageNarrow/index.tsx @@ -13,7 +13,6 @@ import SearchPageHeaderNarrow from '@components/Search/SearchPageHeader/SearchPa import SearchSelectionFooter from '@components/Search/SearchSelectionFooter'; import SearchWithNavigationDeferredMount from '@components/Search/SearchWithNavigationDeferredMount'; import type {SearchParams, SearchQueryJSON} from '@components/Search/types'; -import SearchRowSkeleton from '@components/Skeletons/SearchRowSkeleton'; import useAndroidBackButtonHandler from '@hooks/useAndroidBackButtonHandler'; import useEndSubmitNavigationSpans from '@hooks/useEndSubmitNavigationSpans'; @@ -342,12 +341,6 @@ function SearchPageNarrow({ hasFilterBars={hasFilterBars} /> )} - {!isInteractive && !searchOverlayContent && ( - - )} {shouldRenderLayoutProbe && } {!!searchOverlayContent && ( Date: Mon, 5 Oct 2026 13:17:14 +0200 Subject: [PATCH 45/71] chore: relative import for the tab without a bar item rule --- .../Navigators/NativeTabNavigator/useNativeTabNavigator.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/useNativeTabNavigator.ts b/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/useNativeTabNavigator.ts index ab1f97e132d9..98f371ae25e8 100644 --- a/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/useNativeTabNavigator.ts +++ b/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/useNativeTabNavigator.ts @@ -11,7 +11,6 @@ import useTheme from '@hooks/useTheme'; import useWorkspacesTabIndicatorStatus from '@hooks/useWorkspacesTabIndicatorStatus'; import {getPreservedNavigatorState, setPreservedNavigatorState} from '@libs/Navigation/AppNavigator/createSplitNavigator/usePreserveNavigatorState'; -import getTabWithoutBarItem from '@libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/getTabWithoutBarItem'; import {NAVIGATION_TAB_TO_SPANS} from '@libs/Navigation/AppNavigator/Navigators/TabNavigatorBar'; import isTabRouteAtRoot from '@libs/Navigation/helpers/isTabRouteAtRoot'; import Navigation from '@libs/Navigation/Navigation'; @@ -28,6 +27,8 @@ import type {NavigationAction, NavigationState, PartialState, Router, TabNavigat import {findFocusedRoute, useNavigation, useNavigationState, useRoute} from '@react-navigation/native'; import {useEffect} from 'react'; +import getTabWithoutBarItem from './getTabWithoutBarItem'; + /** * Root-level tab screens where the swipe-back gesture should be disabled. * Swiping from these screens would pop the entire TAB_NAVIGATOR, which feels wrong. From 4a3e8d13b405364343b10d59156b0feef02150dd Mon Sep 17 00:00:00 2001 From: sumo_slonik Date: Mon, 5 Oct 2026 13:37:04 +0200 Subject: [PATCH 46/71] perf: no icon draws for the tab without a bar item, gesture effect reads a boolean --- .../NativeTabNavigator/useNativeTabNavigator.ts | 7 +++---- .../Navigators/TabNavigator.native.tsx | 17 +++++++++++------ 2 files changed, 14 insertions(+), 10 deletions(-) diff --git a/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/useNativeTabNavigator.ts b/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/useNativeTabNavigator.ts index 98f371ae25e8..41e2ff283aa2 100644 --- a/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/useNativeTabNavigator.ts +++ b/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/useNativeTabNavigator.ts @@ -54,7 +54,7 @@ function useNativeTabNavigator() { const {indicatorColor: accountIndicatorColor, status: accountIndicatorStatus} = useAccountTabIndicatorStatus(); const navigation = useNavigation(); const parentNavigation = navigation.getParent(); - const focusedRouteName = useNavigationState((state) => findFocusedRoute(state)?.name); + const isFocusedRouteTabRoot = useNavigationState((state) => TAB_ROOT_SCREENS_WITHOUT_GESTURE.has(findFocusedRoute(state)?.name ?? '')); const route = useRoute(); // The Tab.Navigator's own state lives at `parentState.routes[i].state`. We can't read it via // `useNavigationState((s) => s)` here because the navigator's body runs before @@ -88,9 +88,8 @@ function useNativeTabNavigator() { if (!shouldUseNarrowLayout || !parentNavigation) { return; } - const isRootScreen = TAB_ROOT_SCREENS_WITHOUT_GESTURE.has(focusedRouteName ?? ''); - parentNavigation.setOptions({gestureEnabled: !isRootScreen}); - }, [focusedRouteName, shouldUseNarrowLayout, parentNavigation]); + parentNavigation.setOptions({gestureEnabled: !isFocusedRouteTabRoot}); + }, [isFocusedRouteTabRoot, shouldUseNarrowLayout, parentNavigation]); useEffect(() => { if (!isRealizedNavigationState(tabState)) { diff --git a/src/libs/Navigation/AppNavigator/Navigators/TabNavigator.native.tsx b/src/libs/Navigation/AppNavigator/Navigators/TabNavigator.native.tsx index cbf3a2fb1130..364f6ecca22b 100644 --- a/src/libs/Navigation/AppNavigator/Navigators/TabNavigator.native.tsx +++ b/src/libs/Navigation/AppNavigator/Navigators/TabNavigator.native.tsx @@ -10,6 +10,7 @@ import SCREENS from '@src/SCREENS'; import {createNativeBottomTabNavigator} from '@react-navigation/bottom-tabs/unstable'; import React from 'react'; +import type {NativeTabName} from './NativeTabNavigator/NATIVE_TAB_GLYPHS'; import type {NativeTabLayoutProps} from './NativeTabNavigator/NativeTabLayout'; import NativeTabLayout from './NativeTabNavigator/NativeTabLayout'; @@ -29,11 +30,15 @@ import WorkspaceNavigator from './WorkspaceNavigator'; */ const Tab = createNativeBottomTabNavigator(); +const HIDDEN_TAB_OPTIONS = {tabBarItemHidden: true}; + const renderNativeTabLayout = (props: NativeTabLayoutProps) => ; function TabNavigator() { const {shouldShowNativeTabBar, dotColors, tabLabels, tabWithoutBarItem, tabRouterOverride} = useNativeTabNavigator(); const {screenOptions, getTabOptions} = useNativeTabBarOptions({shouldShowNativeTabBar, dotColors, tabLabels}); + // A tab with no bar item draws no icon. + const getOptions = (name: NativeTabName) => (name === tabWithoutBarItem ? HIDDEN_TAB_OPTIONS : getTabOptions(name)); return ( ); From 35304d70d2b96c702bbfa2981d0294305eace449 Mon Sep 17 00:00:00 2001 From: sumo_slonik Date: Mon, 5 Oct 2026 13:37:06 +0200 Subject: [PATCH 47/71] refactor: one tab root scroll props call in the Insights dashboard --- src/pages/Insights/InsightsDashboard.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/pages/Insights/InsightsDashboard.tsx b/src/pages/Insights/InsightsDashboard.tsx index aff5aac08db7..403a810142ba 100644 --- a/src/pages/Insights/InsightsDashboard.tsx +++ b/src/pages/Insights/InsightsDashboard.tsx @@ -65,8 +65,8 @@ function InsightsDashboardContent({state, headlineChart, supportingCharts, filte const {translate} = useLocalize(); const {shouldUseNarrowLayout} = useResponsiveLayout(); const icons = useMemoizedLazyExpensifyIcons(['OfflineCloud']); - const emptyStateScrollProps = useTabRootScrollProps([styles.flexGrow1, styles.flexShrink0], true); - const scrollProps = useTabRootScrollProps([styles.flexGrow1, styles.ph5, styles.pb5], true); + const isEmptyState = state === INSIGHTS_DASHBOARD_STATE.NO_EXPENSES || state === INSIGHTS_DASHBOARD_STATE.EMPTY; + const scrollProps = useTabRootScrollProps(isEmptyState ? [styles.flexGrow1, styles.flexShrink0] : [styles.flexGrow1, styles.ph5, styles.pb5], true); const didRequestFail = state === INSIGHTS_DASHBOARD_STATE.ERROR || state === INSIGHTS_DASHBOARD_STATE.STALE; @@ -108,10 +108,10 @@ function InsightsDashboardContent({state, headlineChart, supportingCharts, filte ); } - if (state === INSIGHTS_DASHBOARD_STATE.NO_EXPENSES || state === INSIGHTS_DASHBOARD_STATE.EMPTY) { + if (isEmptyState) { return ( {state === INSIGHTS_DASHBOARD_STATE.NO_EXPENSES ? : } From 743cc0e6a74f468f3d9fac71a6728164e473108e Mon Sep 17 00:00:00 2001 From: sumo_slonik Date: Mon, 5 Oct 2026 13:37:08 +0200 Subject: [PATCH 48/71] chore: drop the unused src hunk from the bottom-tabs indicator color patch --- ...002+active-indicator-color-precedence.patch | 18 ------------------ 1 file changed, 18 deletions(-) diff --git a/patches/react-navigation/@react-navigation+bottom-tabs+7.16.2+002+active-indicator-color-precedence.patch b/patches/react-navigation/@react-navigation+bottom-tabs+7.16.2+002+active-indicator-color-precedence.patch index 0526d0fd3623..ee75c7d7fdea 100644 --- a/patches/react-navigation/@react-navigation+bottom-tabs+7.16.2+002+active-indicator-color-precedence.patch +++ b/patches/react-navigation/@react-navigation+bottom-tabs+7.16.2+002+active-indicator-color-precedence.patch @@ -11,21 +11,3 @@ index 1183c51..303635c 100644 const onTransitionStart = ({ closing, route -diff --git a/node_modules/@react-navigation/bottom-tabs/src/unstable/NativeBottomTabView.native.tsx b/node_modules/@react-navigation/bottom-tabs/src/unstable/NativeBottomTabView.native.tsx -index 287dea3..0f06b2b 100644 ---- a/node_modules/@react-navigation/bottom-tabs/src/unstable/NativeBottomTabView.native.tsx -+++ b/node_modules/@react-navigation/bottom-tabs/src/unstable/NativeBottomTabView.native.tsx -@@ -262,10 +262,10 @@ export function NativeBottomTabView({ state, navigation, descriptors }: Props) { - }); - - const activeIndicatorColor = -- (currentOptions?.tabBarActiveIndicatorColor ?? -- typeof activeTintColor === 'string') -+ currentOptions?.tabBarActiveIndicatorColor ?? -+ (typeof activeTintColor === 'string' - ? Color(activeTintColor)?.alpha(0.1).string() -- : undefined; -+ : undefined); - - const onTransitionStart = ({ - closing, From 4ba41ea69548e232e98d30319bfa07ba87307983 Mon Sep 17 00:00:00 2001 From: sumo_slonik Date: Mon, 5 Oct 2026 13:50:15 +0200 Subject: [PATCH 49/71] docs: note when the tab bar hide and indicator color patches can go --- 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 10e08d0a2a47..1119bd04d244 100644 --- a/patches/react-native-screens/details.md +++ b/patches/react-native-screens/details.md @@ -3,7 +3,7 @@ ### [react-native-screens+4.28.0+001+animate-hiding-the-native-tab-bar.patch](react-native-screens+4.28.0+001+animate-hiding-the-native-tab-bar.patch) - Reason: The App hides the native tab bar on screens that are not a tab root, which React Navigation forwards to RNScreens as `tabBarHidden` (it derives the flag from `tabBarStyle.display === 'none'`). RNScreens calls UIKit's `setTabBarHidden:animated:` with `animated:NO`, so the bar blinks in and out instead of travelling with the screen that hid it. The patch passes `YES`, which lets UIKit run its own show/hide animation. The pre-iOS 18 branch is left alone: it assigns `tabBar.hidden` directly and has no animated counterpart. -- Upstream PR/issue: not reported yet. +- Upstream PR/issue: fixed upstream by [software-mansion/react-native-screens#4632](https://github.com/software-mansion/react-native-screens/pull/4632), which animates by default behind a new `ios.tabBarHiddenAnimationEnabled` prop, and [#4673](https://github.com/software-mansion/react-native-screens/pull/4673), which skips the animation on the first render. Both are merged after the 4.28.0 release and labeled for the v4 backport. **This patch can be removed on the bump to the first release that contains them (4.29.0).** - E/App issue: [#101169](https://github.com/Expensify/App/issues/101169) - PR introducing patch: [#101339](https://github.com/Expensify/App/pull/101339) diff --git a/patches/react-navigation/details.md b/patches/react-navigation/details.md index bff313fe3264..f53e554c6f9c 100644 --- a/patches/react-navigation/details.md +++ b/patches/react-navigation/details.md @@ -71,7 +71,7 @@ ### [@react-navigation+bottom-tabs+7.16.2+002+active-indicator-color-precedence.patch](@react-navigation+bottom-tabs+7.16.2+002+active-indicator-color-precedence.patch) - Reason: `tabBarActiveIndicatorColor` was never applied on the native Android tab bar: a missing pair of parentheses made `??` bind to the `typeof` check, so the indicator always got `tabBarActiveTintColor` at 10% alpha. The patch restores the intended precedence, so the active indicator pill takes the color from the design. -- Upstream PR/issue: not reported yet. +- Upstream PR/issue: not reported. The rewritten native tab view on the v8 branch no longer has the bug, but it is still present on the `7.x` branch as of 7.20.0. **This patch can be removed on the upgrade to v8.** - E/App issue: [#101169](https://github.com/Expensify/App/issues/101169) - PR Introducing Patch: [#101339](https://github.com/Expensify/App/pull/101339) - PR Updating Patch: N/A From 4d060b22fb974522c27deb55e3d99d7a2e23a644 Mon Sep 17 00:00:00 2001 From: sumo_slonik Date: Mon, 5 Oct 2026 13:56:06 +0200 Subject: [PATCH 50/71] fix: name the iOS native tabs for VoiceOver, note in patch docs which patches go on upgrade --- patches/react-native-screens/details.md | 4 +-- ...tom-tabs+7.16.2+003+hidden-tab-items.patch | 28 ++++++++++++++++--- patches/react-navigation/details.md | 6 ++-- .../useNativeTabBarOptions/index.ios.ts | 6 +++- 4 files changed, 34 insertions(+), 10 deletions(-) diff --git a/patches/react-native-screens/details.md b/patches/react-native-screens/details.md index 1119bd04d244..fa3513685927 100644 --- a/patches/react-native-screens/details.md +++ b/patches/react-native-screens/details.md @@ -2,8 +2,8 @@ ### [react-native-screens+4.28.0+001+animate-hiding-the-native-tab-bar.patch](react-native-screens+4.28.0+001+animate-hiding-the-native-tab-bar.patch) -- Reason: The App hides the native tab bar on screens that are not a tab root, which React Navigation forwards to RNScreens as `tabBarHidden` (it derives the flag from `tabBarStyle.display === 'none'`). RNScreens calls UIKit's `setTabBarHidden:animated:` with `animated:NO`, so the bar blinks in and out instead of travelling with the screen that hid it. The patch passes `YES`, which lets UIKit run its own show/hide animation. The pre-iOS 18 branch is left alone: it assigns `tabBar.hidden` directly and has no animated counterpart. -- Upstream PR/issue: fixed upstream by [software-mansion/react-native-screens#4632](https://github.com/software-mansion/react-native-screens/pull/4632), which animates by default behind a new `ios.tabBarHiddenAnimationEnabled` prop, and [#4673](https://github.com/software-mansion/react-native-screens/pull/4673), which skips the animation on the first render. Both are merged after the 4.28.0 release and labeled for the v4 backport. **This patch can be removed on the bump to the first release that contains them (4.29.0).** +- Reason: The App hides the native tab bar on screens that are not a tab root, which React Navigation forwards to RNScreens as `tabBarHidden` (it derives the flag from `tabBarStyle.display === 'none'`). RNScreens calls UIKit's `setTabBarHidden:animated:` with `animated:NO`, so the bar blinks in and out instead of travelling with the screen that hid it. The patch passes `YES`, which lets UIKit run its own show/hide animation. The pre-iOS 18 branch is left alone: it assigns `tabBar.hidden` directly and has no animated counterpart. **This patch can be removed on the bump to react-native-screens 4.29.0**, the first release with the upstream fix linked below. +- Upstream PR/issue: fixed upstream by [software-mansion/react-native-screens#4632](https://github.com/software-mansion/react-native-screens/pull/4632), which animates by default behind a new `ios.tabBarHiddenAnimationEnabled` prop, and [#4673](https://github.com/software-mansion/react-native-screens/pull/4673), which skips the animation on the first render. Both are merged after the 4.28.0 release and labeled for the v4 backport. - E/App issue: [#101169](https://github.com/Expensify/App/issues/101169) - PR introducing patch: [#101339](https://github.com/Expensify/App/pull/101339) diff --git a/patches/react-navigation/@react-navigation+bottom-tabs+7.16.2+003+hidden-tab-items.patch b/patches/react-navigation/@react-navigation+bottom-tabs+7.16.2+003+hidden-tab-items.patch index 744455116a73..ae32aafb76b4 100644 --- a/patches/react-navigation/@react-navigation+bottom-tabs+7.16.2+003+hidden-tab-items.patch +++ b/patches/react-navigation/@react-navigation+bottom-tabs+7.16.2+003+hidden-tab-items.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 303635c..6ccaad5 100644 +index 09ec68f..bd6204e 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 @@ -4,11 +4,11 @@ import { getLabel, SafeAreaProviderCompat, Screen as ScreenContent } from '@reac @@ -73,7 +73,23 @@ index 303635c..6ccaad5 100644 const { options, render, -@@ -363,9 +380,40 @@ export function NativeBottomTabView({ +@@ -221,6 +238,7 @@ export function NativeBottomTabView({ + title, + lazy = false, + tabBarLabel, ++ tabBarAccessibilityLabel, + tabBarSelectionEnabled, + tabBarBadgeStyle, + tabBarIcon, +@@ -287,6 +305,7 @@ export function NativeBottomTabView({ + preventNativeSelection: tabBarSelectionEnabled === false, + badgeValue: tabBarBadge?.toString(), + title: tabTitle, ++ tabBarItemAccessibilityLabel: tabBarAccessibilityLabel, + specialEffects: { + repeatedTabSelection: { + popToRoot: true, +@@ -363,9 +382,40 @@ export function NativeBottomTabView({ }) }, route.key); }) @@ -116,10 +132,10 @@ index 303635c..6ccaad5 100644 isFocused, route, 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 a071119..390f594 100644 +index 5a502c2..a2f2e85 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 -@@ -78,6 +78,11 @@ type IconIOSSfSymbol = { +@@ -78,6 +78,15 @@ type IconIOSSfSymbol = { }; export type NativeBottomTabIcon = IconIOSSfSymbol | IconImage; export type NativeBottomTabNavigationOptions = NativeHeaderOptions & { @@ -128,6 +144,10 @@ index a071119..390f594 100644 + * native tabs and the tab bar is hidden. + */ + tabBarItemHidden?: boolean; ++ /** ++ * Accessibility label for the tab bar item, read by the screen reader. Useful when the tab has no visible label. ++ */ ++ tabBarAccessibilityLabel?: string; /** * Title text for the screen. */ diff --git a/patches/react-navigation/details.md b/patches/react-navigation/details.md index f53e554c6f9c..f97be9f631f8 100644 --- a/patches/react-navigation/details.md +++ b/patches/react-navigation/details.md @@ -70,15 +70,15 @@ ### [@react-navigation+bottom-tabs+7.16.2+002+active-indicator-color-precedence.patch](@react-navigation+bottom-tabs+7.16.2+002+active-indicator-color-precedence.patch) -- Reason: `tabBarActiveIndicatorColor` was never applied on the native Android tab bar: a missing pair of parentheses made `??` bind to the `typeof` check, so the indicator always got `tabBarActiveTintColor` at 10% alpha. The patch restores the intended precedence, so the active indicator pill takes the color from the design. -- Upstream PR/issue: not reported. The rewritten native tab view on the v8 branch no longer has the bug, but it is still present on the `7.x` branch as of 7.20.0. **This patch can be removed on the upgrade to v8.** +- Reason: `tabBarActiveIndicatorColor` was never applied on the native Android tab bar: a missing pair of parentheses made `??` bind to the `typeof` check, so the indicator always got `tabBarActiveTintColor` at 10% alpha. The patch restores the intended precedence, so the active indicator pill takes the color from the design. **This patch can be removed on the upgrade to React Navigation v8**, whose rewritten native tab view no longer has the bug. +- Upstream PR/issue: not reported. Fixed on the v8 branch, still present on the `7.x` branch as of 7.20.0. - E/App issue: [#101169](https://github.com/Expensify/App/issues/101169) - PR Introducing Patch: [#101339](https://github.com/Expensify/App/pull/101339) - PR Updating Patch: N/A ### [@react-navigation+bottom-tabs+7.16.2+003+hidden-tab-items.patch](@react-navigation+bottom-tabs+7.16.2+003+hidden-tab-items.patch) -- Reason: Adds a `tabBarItemHidden` option to the native bottom tabs. The App registers six tabs but the native bar can show at most five (Material's `BottomNavigationView` throws past five, and `UITabBar` folds the rest into a "More" tab), and which five depends on the Insights beta: Insights with it, Account without it. A native tab bar cannot hide one of its items, so a route with `tabBarItemHidden` gets no `Tabs.Screen` at all. When such a route is focused, it is drawn in JS over the native host with the tab bar hidden, while the host keeps the last focused visible tab selected underneath. A hidden route stays mounted once visited. Like every other tab, it is frozen by the App's tab screen layout while another tab is focused. Nothing native changes. +- Reason: Adds a `tabBarItemHidden` option to the native bottom tabs. The App registers six tabs but the native bar can show at most five (Material's `BottomNavigationView` throws past five, and `UITabBar` folds the rest into a "More" tab), and which five depends on the Insights beta: Insights with it, Account without it. A native tab bar cannot hide one of its items, so a route with `tabBarItemHidden` gets no `Tabs.Screen` at all. When such a route is focused, it is drawn in JS over the native host with the tab bar hidden, while the host keeps the last focused visible tab selected underneath. A hidden route stays mounted once visited. Like every other tab, it is frozen by the App's tab screen layout while another tab is focused. Nothing native changes. The patch also forwards a `tabBarAccessibilityLabel` option to RNScreens' `tabBarItemAccessibilityLabel`. The iOS tab labels are drawn into the icon images, so without it VoiceOver announces the tabs with no name. **This part can be removed on the upgrade to React Navigation v8**, which forwards the same option. The `tabBarItemHidden` part has no upstream counterpart and stays. - Upstream PR/issue: not reported yet. - E/App issue: [#101169](https://github.com/Expensify/App/issues/101169) - PR Introducing Patch: [#101339](https://github.com/Expensify/App/pull/101339) 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 907897c35e41..d3c29b934b92 100644 --- a/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/useNativeTabBarOptions/index.ios.ts +++ b/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/useNativeTabBarOptions/index.ios.ts @@ -58,7 +58,11 @@ function useNativeTabBarOptions({shouldShowNativeTabBar, dotColors, tabLabels}: }); const inactiveIcon = getIcon(false); const activeIcon = getIcon(true); - return {tabBarIcon: inactiveIcon && activeIcon ? ({focused}) => (focused ? activeIcon : inactiveIcon) : undefined}; + return { + tabBarIcon: inactiveIcon && activeIcon ? ({focused}) => (focused ? activeIcon : inactiveIcon) : undefined, + // The label is drawn into the icon, so VoiceOver reads it from here. + tabBarAccessibilityLabel: tabLabels[name], + }; }; return {screenOptions, getTabOptions}; From 43a71c083803d0a8c800125a7cb7c2ab694fad53 Mon Sep 17 00:00:00 2001 From: sumo_slonik Date: Mon, 5 Oct 2026 14:16:29 +0200 Subject: [PATCH 51/71] revert: stop freezing background native tabs, the native bar shows a tab before JS unfreezes it --- patches/react-navigation/details.md | 2 +- .../FreezeWrapper/index.native.tsx | 3 +- .../nativeTabScreenLayout.tsx | 36 ------------------- .../Navigators/TabNavigator.native.tsx | 4 +-- 4 files changed, 5 insertions(+), 40 deletions(-) delete mode 100644 src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/nativeTabScreenLayout.tsx diff --git a/patches/react-navigation/details.md b/patches/react-navigation/details.md index f97be9f631f8..8555434a0409 100644 --- a/patches/react-navigation/details.md +++ b/patches/react-navigation/details.md @@ -78,7 +78,7 @@ ### [@react-navigation+bottom-tabs+7.16.2+003+hidden-tab-items.patch](@react-navigation+bottom-tabs+7.16.2+003+hidden-tab-items.patch) -- Reason: Adds a `tabBarItemHidden` option to the native bottom tabs. The App registers six tabs but the native bar can show at most five (Material's `BottomNavigationView` throws past five, and `UITabBar` folds the rest into a "More" tab), and which five depends on the Insights beta: Insights with it, Account without it. A native tab bar cannot hide one of its items, so a route with `tabBarItemHidden` gets no `Tabs.Screen` at all. When such a route is focused, it is drawn in JS over the native host with the tab bar hidden, while the host keeps the last focused visible tab selected underneath. A hidden route stays mounted once visited. Like every other tab, it is frozen by the App's tab screen layout while another tab is focused. Nothing native changes. The patch also forwards a `tabBarAccessibilityLabel` option to RNScreens' `tabBarItemAccessibilityLabel`. The iOS tab labels are drawn into the icon images, so without it VoiceOver announces the tabs with no name. **This part can be removed on the upgrade to React Navigation v8**, which forwards the same option. The `tabBarItemHidden` part has no upstream counterpart and stays. +- Reason: Adds a `tabBarItemHidden` option to the native bottom tabs. The App registers six tabs but the native bar can show at most five (Material's `BottomNavigationView` throws past five, and `UITabBar` folds the rest into a "More" tab), and which five depends on the Insights beta: Insights with it, Account without it. A native tab bar cannot hide one of its items, so a route with `tabBarItemHidden` gets no `Tabs.Screen` at all. When such a route is focused, it is drawn in JS over the native host with the tab bar hidden, while the host keeps the last focused visible tab selected underneath. A hidden route stays mounted once visited. Nothing native changes. The patch also forwards a `tabBarAccessibilityLabel` option to RNScreens' `tabBarItemAccessibilityLabel`. The iOS tab labels are drawn into the icon images, so without it VoiceOver announces the tabs with no name. **This part can be removed on the upgrade to React Navigation v8**, which forwards the same option. The `tabBarItemHidden` part has no upstream counterpart and stays. - Upstream PR/issue: not reported yet. - E/App issue: [#101169](https://github.com/Expensify/App/issues/101169) - PR Introducing Patch: [#101339](https://github.com/Expensify/App/pull/101339) diff --git a/src/libs/Navigation/AppNavigator/FreezeWrapper/index.native.tsx b/src/libs/Navigation/AppNavigator/FreezeWrapper/index.native.tsx index 3f833f9216d1..a794924c3020 100644 --- a/src/libs/Navigation/AppNavigator/FreezeWrapper/index.native.tsx +++ b/src/libs/Navigation/AppNavigator/FreezeWrapper/index.native.tsx @@ -1,6 +1,7 @@ import type ChildrenProps from '@src/types/utils/ChildrenProps'; -// On native, tab-level freezing is handled by the TabNavigator's screen layout (nativeTabScreenLayout). +// On native, background tabs are not frozen: the native tab bar shows a tab before JS can unfreeze it, so a frozen +// tab would flash an empty screen on every switch. // On web, react-native-screens only sets `display: none` without actually freezing the React tree, // so the web FreezeWrapper uses `react-freeze` to suspend rendering of background tabs. function FreezeWrapper({children}: ChildrenProps) { diff --git a/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/nativeTabScreenLayout.tsx b/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/nativeTabScreenLayout.tsx deleted file mode 100644 index af7c533db7e4..000000000000 --- a/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/nativeTabScreenLayout.tsx +++ /dev/null @@ -1,36 +0,0 @@ -import {bottomTabScreenLayoutWrapper} from '@libs/Navigation/PlatformStackNavigation/ScreenLayout'; - -import type ChildrenProps from '@src/types/utils/ChildrenProps'; - -import {useIsFocused} from '@react-navigation/native'; -import React, {useLayoutEffect, useState} from 'react'; -import {Freeze} from 'react-freeze'; - -/** - * Native tabs have no `freezeOnBlur`, so a tab in the background would keep re-rendering on every Onyx update. - * A blurred tab freezes one frame after it loses focus, so a tab mounted in the background still renders once. - */ -function NativeTabScreenFreeze({children}: ChildrenProps) { - const isFocused = useIsFocused(); - const [isFrozen, setIsFrozen] = useState(false); - - // Decouple the Suspense render task so it won't be interrupted by React's concurrent mode and stuck in an infinite loop - useLayoutEffect(() => { - if (isFocused) { - // isFocused is the only dependency and does not change as a result of this setState. - // eslint-disable-next-line react-hooks/set-state-in-effect - setIsFrozen(false); - return; - } - const frameID = requestAnimationFrame(() => setIsFrozen(true)); - return () => cancelAnimationFrame(frameID); - }, [isFocused]); - - return {children}; -} - -function nativeTabScreenLayout({children, ...rest}: Parameters[0]) { - return bottomTabScreenLayoutWrapper({...rest, children: {children}}); -} - -export default nativeTabScreenLayout; diff --git a/src/libs/Navigation/AppNavigator/Navigators/TabNavigator.native.tsx b/src/libs/Navigation/AppNavigator/Navigators/TabNavigator.native.tsx index 364f6ecca22b..229031fa720c 100644 --- a/src/libs/Navigation/AppNavigator/Navigators/TabNavigator.native.tsx +++ b/src/libs/Navigation/AppNavigator/Navigators/TabNavigator.native.tsx @@ -1,3 +1,4 @@ +import {bottomTabScreenLayoutWrapper} from '@libs/Navigation/PlatformStackNavigation/ScreenLayout'; import type {TabNavigatorParamList} from '@libs/Navigation/types'; import HomePage from '@pages/home/HomePage'; @@ -14,7 +15,6 @@ import type {NativeTabName} from './NativeTabNavigator/NATIVE_TAB_GLYPHS'; import type {NativeTabLayoutProps} from './NativeTabNavigator/NativeTabLayout'; import NativeTabLayout from './NativeTabNavigator/NativeTabLayout'; -import nativeTabScreenLayout from './NativeTabNavigator/nativeTabScreenLayout'; import tabScreenListeners from './NativeTabNavigator/tabScreenListeners'; import useNativeTabBarOptions from './NativeTabNavigator/useNativeTabBarOptions'; import useNativeTabNavigator from './NativeTabNavigator/useNativeTabNavigator'; @@ -44,7 +44,7 @@ function TabNavigator() { Date: Mon, 5 Oct 2026 14:26:23 +0200 Subject: [PATCH 52/71] fix: show Search skeleton rows on the first visit to a background mounted Spend tab --- src/pages/Search/SearchPageNarrow/index.tsx | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/pages/Search/SearchPageNarrow/index.tsx b/src/pages/Search/SearchPageNarrow/index.tsx index e310ae2aa9e2..1e5523b5326c 100644 --- a/src/pages/Search/SearchPageNarrow/index.tsx +++ b/src/pages/Search/SearchPageNarrow/index.tsx @@ -13,6 +13,7 @@ import SearchPageHeaderNarrow from '@components/Search/SearchPageHeader/SearchPa import SearchSelectionFooter from '@components/Search/SearchSelectionFooter'; import SearchWithNavigationDeferredMount from '@components/Search/SearchWithNavigationDeferredMount'; import type {SearchParams, SearchQueryJSON} from '@components/Search/types'; +import SearchRowSkeleton from '@components/Skeletons/SearchRowSkeleton'; import useAndroidBackButtonHandler from '@hooks/useAndroidBackButtonHandler'; import useEndSubmitNavigationSpans from '@hooks/useEndSubmitNavigationSpans'; @@ -341,6 +342,12 @@ function SearchPageNarrow({ hasFilterBars={hasFilterBars} /> )} + {!isInteractive && !searchOverlayContent && ( + + )} {shouldRenderLayoutProbe && } {!!searchOverlayContent && ( Date: Mon, 5 Oct 2026 15:47:10 +0200 Subject: [PATCH 53/71] fix: derive the natively selected tab from the history, so a beta change that hides it no longer crashes the Android tab bar --- ...tom-tabs+7.16.2+003+hidden-tab-items.patch | 22 +++++++++---------- 1 file changed, 10 insertions(+), 12 deletions(-) diff --git a/patches/react-navigation/@react-navigation+bottom-tabs+7.16.2+003+hidden-tab-items.patch b/patches/react-navigation/@react-navigation+bottom-tabs+7.16.2+003+hidden-tab-items.patch index ae32aafb76b4..0363ebe03089 100644 --- a/patches/react-navigation/@react-navigation+bottom-tabs+7.16.2+003+hidden-tab-items.patch +++ b/patches/react-navigation/@react-navigation+bottom-tabs+7.16.2+003+hidden-tab-items.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 09ec68f..bd6204e 100644 +index 09ec68f..6645270 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 @@ -4,11 +4,11 @@ import { getLabel, SafeAreaProviderCompat, Screen as ScreenContent } from '@reac @@ -16,7 +16,7 @@ index 09ec68f..bd6204e 100644 const meta = { type: 'native-tabs' }; -@@ -32,9 +32,23 @@ export function NativeBottomTabView({ +@@ -32,9 +32,21 @@ export function NativeBottomTabView({ fonts } = useTheme(); const focusedRouteKey = state.routes[state.index].key; @@ -24,11 +24,9 @@ index 09ec68f..bd6204e 100644 + // and the tab bar is hidden, and the host keeps the last focused visible tab selected underneath. + const isRouteHidden = route => descriptors[route.key]?.options.tabBarItemHidden === true; + const isFocusedRouteHidden = isRouteHidden(state.routes[state.index]); -+ const lastVisibleRouteKeyRef = React.useRef(undefined); -+ if (!isFocusedRouteHidden) { -+ lastVisibleRouteKeyRef.current = focusedRouteKey; -+ } -+ const nativeSelectedRouteKey = isFocusedRouteHidden ? lastVisibleRouteKeyRef.current ?? state.routes.find(route => !isRouteHidden(route))?.key ?? focusedRouteKey : focusedRouteKey; ++ // 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; + // Hidden routes stay mounted once visited, the same as the native tabs. + const visitedHiddenRouteKeysRef = React.useRef(new Set()); + if (isFocusedRouteHidden) { @@ -41,7 +39,7 @@ index 09ec68f..bd6204e 100644 provenance: 0 } }); -@@ -189,17 +203,17 @@ export function NativeBottomTabView({ +@@ -189,17 +201,17 @@ 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; @@ -63,7 +61,7 @@ index 09ec68f..bd6204e 100644 colorScheme: dark ? 'dark' : 'light', ios: { bottomAccessory: bottomAccessory ? environment => bottomAccessory({ -@@ -210,6 +224,9 @@ export function NativeBottomTabView({ +@@ -210,6 +222,9 @@ export function NativeBottomTabView({ tabBarTintColor: activeTintColor }, children: state.routes.map((route, index) => { @@ -73,7 +71,7 @@ index 09ec68f..bd6204e 100644 const { options, render, -@@ -221,6 +238,7 @@ export function NativeBottomTabView({ +@@ -221,6 +236,7 @@ export function NativeBottomTabView({ title, lazy = false, tabBarLabel, @@ -81,7 +79,7 @@ index 09ec68f..bd6204e 100644 tabBarSelectionEnabled, tabBarBadgeStyle, tabBarIcon, -@@ -287,6 +305,7 @@ export function NativeBottomTabView({ +@@ -287,6 +303,7 @@ export function NativeBottomTabView({ preventNativeSelection: tabBarSelectionEnabled === false, badgeValue: tabBarBadge?.toString(), title: tabTitle, @@ -89,7 +87,7 @@ index 09ec68f..bd6204e 100644 specialEffects: { repeatedTabSelection: { popToRoot: true, -@@ -363,9 +382,40 @@ export function NativeBottomTabView({ +@@ -363,9 +380,40 @@ export function NativeBottomTabView({ }) }, route.key); }) From 2a9acc3c13351a5c490f96db6e9ebd1c1944d697 Mon Sep 17 00:00:00 2001 From: sumo_slonik Date: Mon, 5 Oct 2026 15:47:12 +0200 Subject: [PATCH 54/71] fix: replace instead of push when leaving Insights without the beta or deep linked Account, so back does not loop --- .../NativeTabNavigator/useNativeTabNavigator.ts | 9 +++++---- src/pages/settings/InitialSettingsPage.tsx | 11 ++++++++++- 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/useNativeTabNavigator.ts b/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/useNativeTabNavigator.ts index 41e2ff283aa2..1bb5d86d0304 100644 --- a/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/useNativeTabNavigator.ts +++ b/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/useNativeTabNavigator.ts @@ -44,7 +44,7 @@ function isRealizedNavigationState(state: NavigationState | PartialState { if (!isInsightsTabFocusedWithoutBeta) { return; } - Navigation.navigate(ROUTES.HOME); + Navigation.navigate(ROUTES.HOME, {forceReplace: true}); }, [isInsightsTabFocusedWithoutBeta]); // Cancel any in-flight tab-navigation span that doesn't match the new focused tab. diff --git a/src/pages/settings/InitialSettingsPage.tsx b/src/pages/settings/InitialSettingsPage.tsx index a55c35407e76..089c655f2050 100755 --- a/src/pages/settings/InitialSettingsPage.tsx +++ b/src/pages/settings/InitialSettingsPage.tsx @@ -12,6 +12,7 @@ import Text from '@components/Text'; import type {WithCurrentUserPersonalDetailsProps} from '@components/withCurrentUserPersonalDetails'; import withCurrentUserPersonalDetails from '@components/withCurrentUserPersonalDetails'; +import useAndroidBackButtonHandler from '@hooks/useAndroidBackButtonHandler'; import useIsSettingsDrawnOverTabs from '@hooks/useIsSettingsDrawnOverTabs'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; @@ -60,13 +61,21 @@ function InitialSettingsPage({currentUserPersonalDetails}: InitialSettingsPagePr 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, Home replaces Account so that going back does not reopen it. const goBackFromAccount = () => { if (navigation.canGoBack()) { navigation.goBack(); return; } - Navigation.navigate(ROUTES.HOME); + Navigation.navigate(ROUTES.HOME, {forceReplace: true}); }; + 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); From 3855aca0e56d80b99e29054882ee9b420abd54cb Mon Sep 17 00:00:00 2001 From: sumo_slonik Date: Mon, 5 Oct 2026 15:47:26 +0200 Subject: [PATCH 55/71] fix: native tab taps run the JS tab button rules, Inbox opens at its list and anonymous users get sign-in --- .../NavigationTabBar/InboxTabButton.tsx | 5 +- .../NavigationTabBar/navigateToInboxTab.ts | 12 +++++ .../NativeTabNavigator/tabScreenListeners.ts | 34 ++++++++---- .../useNativeTabNavigator.ts | 9 ++-- .../Navigators/TabNavigator.native.tsx | 7 +-- .../Navigators/TabNavigatorBar.tsx | 9 +--- .../telemetry/cancelTabNavigationSpans.ts | 10 +++- src/selectors/Session.ts | 4 +- tests/unit/nativeTabScreenListenersTest.ts | 53 ++++++++++--------- 9 files changed, 91 insertions(+), 52 deletions(-) create mode 100644 src/components/Navigation/NavigationTabBar/navigateToInboxTab.ts diff --git a/src/components/Navigation/NavigationTabBar/InboxTabButton.tsx b/src/components/Navigation/NavigationTabBar/InboxTabButton.tsx index 5d5fdcd82215..39257ebeba21 100644 --- a/src/components/Navigation/NavigationTabBar/InboxTabButton.tsx +++ b/src/components/Navigation/NavigationTabBar/InboxTabButton.tsx @@ -31,6 +31,7 @@ import React, {useEffect, useRef} from 'react'; import getLastRoute from './getLastRoute'; import getReusableReportsTabStateKey from './getReusableReportsTabStateKey'; import getStringParam from './getStringParam'; +import navigateToInboxTab from './navigateToInboxTab'; import NAVIGATION_TABS from './NAVIGATION_TABS'; import TabBarItem from './TabBarItem'; @@ -232,9 +233,7 @@ function InboxTabButton({selectedTab, isWideLayout}: InboxTabButtonProps) { if (selectedTab === NAVIGATION_TABS.INBOX) { return; } - - startNavigateToInboxTabSpan({isWideLayout: false}); - Navigation.navigate(ROUTES.INBOX); + navigateToInboxTab(); }; return ( diff --git a/src/components/Navigation/NavigationTabBar/navigateToInboxTab.ts b/src/components/Navigation/NavigationTabBar/navigateToInboxTab.ts new file mode 100644 index 000000000000..254b83ef7a10 --- /dev/null +++ b/src/components/Navigation/NavigationTabBar/navigateToInboxTab.ts @@ -0,0 +1,12 @@ +import Navigation from '@libs/Navigation/Navigation'; +import {startNavigateToInboxTabSpan} from '@libs/telemetry/startTabNavigationSpans'; + +import ROUTES from '@src/ROUTES'; + +/** Opens the Inbox tab at its chat list from the narrow tab bar, whatever report was left open in it. */ +function navigateToInboxTab() { + startNavigateToInboxTabSpan({isWideLayout: false}); + Navigation.navigate(ROUTES.INBOX); +} + +export default navigateToInboxTab; diff --git a/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/tabScreenListeners.ts b/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/tabScreenListeners.ts index 85c0f54871b4..a5d450535e04 100644 --- a/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/tabScreenListeners.ts +++ b/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/tabScreenListeners.ts @@ -1,27 +1,42 @@ +import navigateToInboxTab from '@components/Navigation/NavigationTabBar/navigateToInboxTab'; import NAVIGATION_TABS from '@components/Navigation/NavigationTabBar/NAVIGATION_TABS'; import ROUTE_TO_NAVIGATION_TAB from '@components/Navigation/NavigationTabBar/ROUTE_TO_NAVIGATION_TAB'; -import {startNavigateToInboxTabSpan, startNavigateToReportsTabSpans} from '@libs/telemetry/startTabNavigationSpans'; +import interceptAnonymousUser from '@libs/interceptAnonymousUser'; +import {startNavigateToReportsTabSpans} from '@libs/telemetry/startTabNavigationSpans'; import type {ValueOf} from 'type-fest'; +type NavigationTab = ValueOf; + +/** Tabs the JS tab buttons keep from anonymous users, who get the sign-in modal instead. */ +const ANONYMOUS_GATED_TABS = new Set([NAVIGATION_TABS.SEARCH, NAVIGATION_TABS.INSIGHTS, NAVIGATION_TABS.SETTINGS]); + /** - * Starts the tab-navigation spans of the tabs that have them. The native bar never renders InboxTabButton or - * SearchTabButton, which start these spans on the JS tab bar, so its taps start them here instead. + * What a tap on the native bar does on top of the tab switch, mirroring the JS tab buttons the native bar replaces. + * Inbox opens at its chat list, so it is switched by JS navigation rather than by the native selection. */ -const NAVIGATION_TAB_TO_SPAN_START: Partial, () => void>> = { - [NAVIGATION_TABS.INBOX]: () => startNavigateToInboxTabSpan({isWideLayout: false}), - [NAVIGATION_TABS.SEARCH]: startNavigateToReportsTabSpans, +const NAVIGATION_TAB_PRESS_HANDLERS: Partial void>> = { + [NAVIGATION_TABS.INBOX]: navigateToInboxTab, + [NAVIGATION_TABS.SEARCH]: () => interceptAnonymousUser(startNavigateToReportsTabSpans), + [NAVIGATION_TABS.INSIGHTS]: () => interceptAnonymousUser(() => {}), + [NAVIGATION_TABS.SETTINGS]: () => interceptAnonymousUser(() => {}), }; +/** Whether the native bar switches to the tab itself, or leaves the switch to the tab's press handler. */ +function isNativeTabSelectionEnabled(routeName: string, isAnonymousUser: boolean) { + const tab = ROUTE_TO_NAVIGATION_TAB[routeName]; + return tab !== NAVIGATION_TABS.INBOX && !(isAnonymousUser && ANONYMOUS_GATED_TABS.has(tab)); +} + type TabScreenListenerProps = { route: {name: string}; navigation: {isFocused: () => boolean}; }; /** - * Native tabs emit `tabPress` when a user tap reaches JS, before the navigator switches to that tab. A tap on the - * already focused tab also emits it, and starts no span, the same as a JS tab button pressed on its own tab. + * Native tabs emit `tabPress` when a user tap reaches JS, both for a tab the bar switched to and for one whose + * selection is disabled. A tap on the already focused tab does nothing, the same as a JS tab button pressed on its own tab. */ function tabScreenListeners({route, navigation}: TabScreenListenerProps) { return { @@ -29,9 +44,10 @@ function tabScreenListeners({route, navigation}: TabScreenListenerProps) { if (navigation.isFocused()) { return; } - NAVIGATION_TAB_TO_SPAN_START[ROUTE_TO_NAVIGATION_TAB[route.name]]?.(); + NAVIGATION_TAB_PRESS_HANDLERS[ROUTE_TO_NAVIGATION_TAB[route.name]]?.(); }, }; } export default tabScreenListeners; +export {isNativeTabSelectionEnabled}; diff --git a/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/useNativeTabNavigator.ts b/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/useNativeTabNavigator.ts index 1bb5d86d0304..d3c419584453 100644 --- a/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/useNativeTabNavigator.ts +++ b/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/useNativeTabNavigator.ts @@ -4,6 +4,7 @@ import ROUTE_TO_NAVIGATION_TAB from '@components/Navigation/NavigationTabBar/ROU import useAccountTabIndicatorStatus from '@hooks/useAccountTabIndicatorStatus'; import useLocalize from '@hooks/useLocalize'; +import useOnyx from '@hooks/useOnyx'; import usePermissions from '@hooks/usePermissions'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import {useChatTabBrickRoad} from '@hooks/useSidebarOrderedReports'; @@ -11,20 +12,21 @@ import useTheme from '@hooks/useTheme'; import useWorkspacesTabIndicatorStatus from '@hooks/useWorkspacesTabIndicatorStatus'; import {getPreservedNavigatorState, setPreservedNavigatorState} from '@libs/Navigation/AppNavigator/createSplitNavigator/usePreserveNavigatorState'; -import {NAVIGATION_TAB_TO_SPANS} from '@libs/Navigation/AppNavigator/Navigators/TabNavigatorBar'; import isTabRouteAtRoot from '@libs/Navigation/helpers/isTabRouteAtRoot'; import Navigation from '@libs/Navigation/Navigation'; import type {TabNavigatorParamList} from '@libs/Navigation/types'; -import cancelTabNavigationSpans from '@libs/telemetry/cancelTabNavigationSpans'; +import cancelTabNavigationSpans, {NAVIGATION_TAB_TO_SPANS} from '@libs/telemetry/cancelTabNavigationSpans'; 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 type {NavigationAction, NavigationState, PartialState, Router, TabNavigationState} from '@react-navigation/native'; import {findFocusedRoute, useNavigation, useNavigationState, useRoute} from '@react-navigation/native'; +import {isAnonymousSessionSelector} from '@selectors/Session'; import {useEffect} from 'react'; import getTabWithoutBarItem from './getTabWithoutBarItem'; @@ -46,6 +48,7 @@ function useNativeTabNavigator() { const {isBlockingViewVisible} = useFullScreenBlockingViewState(); const {isBetaEnabled, isBetaEnabledOrUnknown} = usePermissions(); const {translate} = useLocalize(); + const [isAnonymousUser = false] = useOnyx(ONYXKEYS.SESSION, {selector: isAnonymousSessionSelector}); const isInsightsTabVisible = isBetaEnabled(CONST.BETAS.INSIGHTS_PAGE); const tabWithoutBarItem = getTabWithoutBarItem(isInsightsTabVisible); const theme = useTheme(); @@ -128,7 +131,7 @@ function useNativeTabNavigator() { }, }); - return {shouldShowNativeTabBar, dotColors, tabLabels, tabWithoutBarItem, tabRouterOverride}; + return {shouldShowNativeTabBar, dotColors, tabLabels, tabWithoutBarItem, tabRouterOverride, isAnonymousUser}; } export default useNativeTabNavigator; diff --git a/src/libs/Navigation/AppNavigator/Navigators/TabNavigator.native.tsx b/src/libs/Navigation/AppNavigator/Navigators/TabNavigator.native.tsx index 229031fa720c..9a537675168b 100644 --- a/src/libs/Navigation/AppNavigator/Navigators/TabNavigator.native.tsx +++ b/src/libs/Navigation/AppNavigator/Navigators/TabNavigator.native.tsx @@ -15,7 +15,7 @@ import type {NativeTabName} from './NativeTabNavigator/NATIVE_TAB_GLYPHS'; import type {NativeTabLayoutProps} from './NativeTabNavigator/NativeTabLayout'; import NativeTabLayout from './NativeTabNavigator/NativeTabLayout'; -import tabScreenListeners from './NativeTabNavigator/tabScreenListeners'; +import tabScreenListeners, {isNativeTabSelectionEnabled} from './NativeTabNavigator/tabScreenListeners'; import useNativeTabBarOptions from './NativeTabNavigator/useNativeTabBarOptions'; import useNativeTabNavigator from './NativeTabNavigator/useNativeTabNavigator'; import ReportsSplitNavigator from './ReportsSplitNavigator'; @@ -35,10 +35,11 @@ const HIDDEN_TAB_OPTIONS = {tabBarItemHidden: true}; const renderNativeTabLayout = (props: NativeTabLayoutProps) => ; function TabNavigator() { - const {shouldShowNativeTabBar, dotColors, tabLabels, tabWithoutBarItem, tabRouterOverride} = useNativeTabNavigator(); + const {shouldShowNativeTabBar, dotColors, tabLabels, tabWithoutBarItem, tabRouterOverride, isAnonymousUser} = useNativeTabNavigator(); const {screenOptions, getTabOptions} = useNativeTabBarOptions({shouldShowNativeTabBar, dotColors, tabLabels}); // A tab with no bar item draws no icon. - const getOptions = (name: NativeTabName) => (name === tabWithoutBarItem ? HIDDEN_TAB_OPTIONS : getTabOptions(name)); + const getOptions = (name: NativeTabName) => + name === tabWithoutBarItem ? HIDDEN_TAB_OPTIONS : {...getTabOptions(name), tabBarSelectionEnabled: isNativeTabSelectionEnabled(name, isAnonymousUser)}; return ( , readonly string[]>> = { - [NAVIGATION_TABS.INBOX]: INBOX_TAB_SPAN_IDS, - [NAVIGATION_TABS.SEARCH]: REPORTS_TAB_SPAN_IDS, -}; - /** * Custom tab bar rendered by the BottomTabNavigator. Only receives `state` (not the * full BottomTabBarProps) to avoid `descriptors` thrashing memoization. @@ -96,4 +90,3 @@ function TabNavigatorBar({state}: Pick) { } export default TabNavigatorBar; -export {NAVIGATION_TAB_TO_SPANS}; diff --git a/src/libs/telemetry/cancelTabNavigationSpans.ts b/src/libs/telemetry/cancelTabNavigationSpans.ts index 364a0cf2ab0e..42c3c7a3261b 100644 --- a/src/libs/telemetry/cancelTabNavigationSpans.ts +++ b/src/libs/telemetry/cancelTabNavigationSpans.ts @@ -1,5 +1,9 @@ +import NAVIGATION_TABS from '@components/Navigation/NavigationTabBar/NAVIGATION_TABS'; + import CONST from '@src/CONST'; +import type {ValueOf} from 'type-fest'; + import {cancelSpan} from './activeSpans'; // Spans grouped by the tab they belong to. The Reports tab owns three (legacy plus the FirstPaint/ContentLoad @@ -11,6 +15,10 @@ const REPORTS_TAB_SPAN_IDS: readonly string[] = [ ]; const INBOX_TAB_SPAN_IDS: readonly string[] = [CONST.TELEMETRY.SPAN_NAVIGATE_TO_INBOX_TAB]; const TAB_NAVIGATION_SPAN_IDS: readonly string[] = [...REPORTS_TAB_SPAN_IDS, ...INBOX_TAB_SPAN_IDS]; +const NAVIGATION_TAB_TO_SPANS: Partial, readonly string[]>> = { + [NAVIGATION_TABS.INBOX]: INBOX_TAB_SPAN_IDS, + [NAVIGATION_TABS.SEARCH]: REPORTS_TAB_SPAN_IDS, +}; /** * Cancels running tab-navigation spans so an abandoned tap does not leave one ticking until the user returns. @@ -26,4 +34,4 @@ function cancelTabNavigationSpans(preserve: readonly string[] = []) { } export default cancelTabNavigationSpans; -export {REPORTS_TAB_SPAN_IDS, INBOX_TAB_SPAN_IDS, TAB_NAVIGATION_SPAN_IDS}; +export {REPORTS_TAB_SPAN_IDS, INBOX_TAB_SPAN_IDS, TAB_NAVIGATION_SPAN_IDS, NAVIGATION_TAB_TO_SPANS}; diff --git a/src/selectors/Session.ts b/src/selectors/Session.ts index c1798bac7d6d..37e3232ed5f6 100644 --- a/src/selectors/Session.ts +++ b/src/selectors/Session.ts @@ -7,6 +7,8 @@ const isSupportalSessionSelector = (session: OnyxEntry) => session?.aut const isDelegateSessionSelector = (session: OnyxEntry) => session?.authTokenType === CONST.AUTH_TOKEN_TYPES.DELEGATE; +const isAnonymousSessionSelector = (session: OnyxEntry) => session?.authTokenType === CONST.AUTH_TOKEN_TYPES.ANONYMOUS; + const emailSelector = (session: OnyxEntry) => session?.email; const accountIDSelector = (session: OnyxEntry) => session?.accountID; @@ -15,4 +17,4 @@ const sessionEmailAndAccountIDSelector = (session: OnyxEntry) => ({emai const authTokenSelector = (session: OnyxEntry) => session?.authToken; -export {emailSelector, accountIDSelector, sessionEmailAndAccountIDSelector, authTokenSelector, isSupportalSessionSelector, isDelegateSessionSelector}; +export {emailSelector, accountIDSelector, sessionEmailAndAccountIDSelector, authTokenSelector, isSupportalSessionSelector, isDelegateSessionSelector, isAnonymousSessionSelector}; diff --git a/tests/unit/nativeTabScreenListenersTest.ts b/tests/unit/nativeTabScreenListenersTest.ts index f16f5c891c7b..964dd46201f7 100644 --- a/tests/unit/nativeTabScreenListenersTest.ts +++ b/tests/unit/nativeTabScreenListenersTest.ts @@ -1,9 +1,14 @@ -import tabScreenListeners from '@libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/tabScreenListeners'; -import {startNavigateToInboxTabSpan, startNavigateToReportsTabSpans} from '@libs/telemetry/startTabNavigationSpans'; +import navigateToInboxTab from '@components/Navigation/NavigationTabBar/navigateToInboxTab'; + +import interceptAnonymousUser from '@libs/interceptAnonymousUser'; +import tabScreenListeners, {isNativeTabSelectionEnabled} from '@libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/tabScreenListeners'; +import {startNavigateToReportsTabSpans} from '@libs/telemetry/startTabNavigationSpans'; import NAVIGATORS from '@src/NAVIGATORS'; import SCREENS from '@src/SCREENS'; +jest.mock('@components/Navigation/NavigationTabBar/navigateToInboxTab', () => jest.fn()); +jest.mock('@libs/interceptAnonymousUser', () => jest.fn((callback: () => void) => callback())); jest.mock('@libs/telemetry/startTabNavigationSpans', () => ({ startNavigateToInboxTabSpan: jest.fn(), startNavigateToReportsTabSpans: jest.fn(), @@ -18,46 +23,46 @@ describe('tabScreenListeners', () => { jest.clearAllMocks(); }); - it('starts the inbox tab span when the Inbox tab is pressed from another tab', () => { - // Given the native bar, which never renders InboxTabButton, so the span has to start from the tab press + it('opens the Inbox chat list when Inbox is pressed from another tab', () => { + // Given the native bar, whose Inbox selection is left to JS so that it opens at the chat list like InboxTabButton // When the user presses Inbox while another tab is focused pressTab(NAVIGATORS.REPORTS_SPLIT_NAVIGATOR, false); - // Then the same span the JS tab button starts is started, tagged as a narrow layout tap - expect(startNavigateToInboxTabSpan).toHaveBeenCalledWith({isWideLayout: false}); - expect(startNavigateToReportsTabSpans).not.toHaveBeenCalled(); + // Then the shared Inbox navigation runs + expect(navigateToInboxTab).toHaveBeenCalledTimes(1); }); - it('starts the reports tab spans when the Spend tab is pressed from another tab', () => { + it('checks for an anonymous user before starting the Spend spans', () => { // Given the native bar, which never renders SearchTabButton // When the user presses Spend while another tab is focused pressTab(NAVIGATORS.SEARCH_FULLSCREEN_NAVIGATOR, false); - // Then the legacy span and its FirstPaint/ContentLoad split are started, as on the JS tab button + // Then the spans start behind the same anonymous user check the JS tab button uses + expect(interceptAnonymousUser).toHaveBeenCalledWith(startNavigateToReportsTabSpans); expect(startNavigateToReportsTabSpans).toHaveBeenCalledTimes(1); - expect(startNavigateToInboxTabSpan).not.toHaveBeenCalled(); }); - it('starts no span when the already focused tab is pressed again', () => { + it('does nothing when the already focused tab is pressed again', () => { // Given native tabs also emit tabPress for a tap on the focused tab // When the user presses Inbox and Spend while each is already focused pressTab(NAVIGATORS.REPORTS_SPLIT_NAVIGATOR, true); pressTab(NAVIGATORS.SEARCH_FULLSCREEN_NAVIGATOR, true); - // Then nothing starts, because no navigation follows that would end the spans - expect(startNavigateToInboxTabSpan).not.toHaveBeenCalled(); - expect(startNavigateToReportsTabSpans).not.toHaveBeenCalled(); + // Then nothing runs, the same as a JS tab button pressed on its own tab + expect(navigateToInboxTab).not.toHaveBeenCalled(); + expect(interceptAnonymousUser).not.toHaveBeenCalled(); }); - it('starts no span for tabs without a navigation span', () => { - // Given Home, Workspaces and Account have no tab navigation span - // When the user presses each of them from another tab - pressTab(SCREENS.HOME, false); - pressTab(NAVIGATORS.WORKSPACE_NAVIGATOR, false); - pressTab(NAVIGATORS.SETTINGS_SPLIT_NAVIGATOR, false); - - // Then no tab navigation span starts - expect(startNavigateToInboxTabSpan).not.toHaveBeenCalled(); - expect(startNavigateToReportsTabSpans).not.toHaveBeenCalled(); + it('leaves the switch to JS only for Inbox and, for anonymous users, the tabs they are kept from', () => { + // Given the tabs whose press has to run JS before any switch + // When the native selection is decided for a signed-in and for an anonymous user + // Then Inbox is always switched by JS, and Spend, Insights and Account only for an anonymous user + expect(isNativeTabSelectionEnabled(NAVIGATORS.REPORTS_SPLIT_NAVIGATOR, false)).toBe(false); + expect(isNativeTabSelectionEnabled(NAVIGATORS.SEARCH_FULLSCREEN_NAVIGATOR, false)).toBe(true); + expect(isNativeTabSelectionEnabled(NAVIGATORS.SEARCH_FULLSCREEN_NAVIGATOR, true)).toBe(false); + expect(isNativeTabSelectionEnabled(SCREENS.INSIGHTS, true)).toBe(false); + expect(isNativeTabSelectionEnabled(NAVIGATORS.SETTINGS_SPLIT_NAVIGATOR, true)).toBe(false); + expect(isNativeTabSelectionEnabled(SCREENS.HOME, true)).toBe(true); + expect(isNativeTabSelectionEnabled(NAVIGATORS.WORKSPACE_NAVIGATOR, true)).toBe(true); }); }); From 433cc0023f66bc87f1fcdaee7115dde4c68a4229 Mon Sep 17 00:00:00 2001 From: sumo_slonik Date: Mon, 5 Oct 2026 15:47:28 +0200 Subject: [PATCH 56/71] fix: still background Spend skeleton, no tab bar animation before it is on screen, iOS floating buttons above the safe area --- ...8.0+001+animate-hiding-the-native-tab-bar.patch | 7 ++++--- .../useNativeTabBarOptions/index.ios.ts | 3 ++- src/pages/Search/SearchPageNarrow/index.tsx | 5 ++++- src/styles/variables.ts | 14 +++++++------- 4 files changed, 17 insertions(+), 12 deletions(-) diff --git a/patches/react-native-screens/react-native-screens+4.28.0+001+animate-hiding-the-native-tab-bar.patch b/patches/react-native-screens/react-native-screens+4.28.0+001+animate-hiding-the-native-tab-bar.patch index d0d1588f1646..632508daac5b 100644 --- a/patches/react-native-screens/react-native-screens+4.28.0+001+animate-hiding-the-native-tab-bar.patch +++ b/patches/react-native-screens/react-native-screens+4.28.0+001+animate-hiding-the-native-tab-bar.patch @@ -1,13 +1,14 @@ diff --git a/node_modules/react-native-screens/ios/tabs/host/RNSTabsHostComponentView.mm b/node_modules/react-native-screens/ios/tabs/host/RNSTabsHostComponentView.mm --- a/node_modules/react-native-screens/ios/tabs/host/RNSTabsHostComponentView.mm +++ b/node_modules/react-native-screens/ios/tabs/host/RNSTabsHostComponentView.mm -@@ -244,7 +244,8 @@ +@@ -244,7 +244,9 @@ _tabBarHidden = newComponentProps.tabBarHidden; #if RNS_IPHONE_OS_VERSION_AVAILABLE(18_0) if (@available(iOS 18.0, *)) { - [_controller setTabBarHidden:_tabBarHidden animated:NO]; -+ // Animated so the bar slides out with the screen that hid it instead of blinking away. -+ [_controller setTabBarHidden:_tabBarHidden animated:YES]; ++ // Animated so the bar slides out with the screen that hid it instead of blinking away, except before the bar ++ // is on screen, where the first render would animate it out. ++ [_controller setTabBarHidden:_tabBarHidden animated:[[_controller tabBar] window] != nil]; } else #endif // RNS_IPHONE_OS_VERSION_AVAILABLE(18_0) { 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 d3c29b934b92..135056a4332b 100644 --- a/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/useNativeTabBarOptions/index.ios.ts +++ b/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/useNativeTabBarOptions/index.ios.ts @@ -13,7 +13,8 @@ import type {NativeBottomTabNavigationOptions} from '@react-navigation/bottom-ta import type NativeTabBarOptionsParams from './types'; -const getFloatingButtonsBottom = () => variables.iosNativeTabBarFloatingButtonsBottom; +/** UITabBar sits above the home indicator inset, so the floating buttons clear both. */ +const getFloatingButtonsBottom = (bottomInset: number) => variables.iosNativeTabBarFloatingButtonsBottom + bottomInset; const TAB_ICON_LAYOUT: TabIconLayout = { glyphSize: variables.iconNativeTabBarIOS, diff --git a/src/pages/Search/SearchPageNarrow/index.tsx b/src/pages/Search/SearchPageNarrow/index.tsx index 1e5523b5326c..c565d5c75d6b 100644 --- a/src/pages/Search/SearchPageNarrow/index.tsx +++ b/src/pages/Search/SearchPageNarrow/index.tsx @@ -187,6 +187,8 @@ function SearchPageNarrow({ }); const [isInteractive, setIsInteractive] = useState(!useStaticRendering); const [isHeaderInteractive, setIsHeaderInteractive] = useState(!useStaticRendering); + // A page mounted in the background, as native tabs mount Spend, keeps its skeleton still until it is first shown. + const [hasBeenFocused, setHasBeenFocused] = useState(!useStaticRendering); const isHeaderInteractiveRef = useRef(isHeaderInteractive); const [, startTransition] = useTransition(); useEffect(() => { @@ -209,6 +211,7 @@ function SearchPageNarrow({ // useFocusEffect avoids the extra re-renders that useIsFocused causes on every focus change. useFocusEffect( useCallback(() => { + setHasBeenFocused(true); if (isInteractive) { return; } @@ -344,7 +347,7 @@ function SearchPageNarrow({ )} {!isInteractive && !searchOverlayContent && ( )} diff --git a/src/styles/variables.ts b/src/styles/variables.ts index ee8d0aac2d66..5e9e38376af8 100644 --- a/src/styles/variables.ts +++ b/src/styles/variables.ts @@ -108,24 +108,24 @@ export default { iconSizeMegaLarge: 105, iconSizeMenuItem: 32, iconBottomBar: 24, - /** Glyph size in the floating tab bar, smaller than the side bar's so each item keeps padding above and below it. */ + /** Glyph size in the floating tab bar. */ iconFloatingTabBar: 20, - /** Account avatar size in the floating tab bar. A circle reads smaller than a glyph of the same box, so it is larger. */ + /** Account avatar size in the floating tab bar. */ avatarFloatingTabBar: avatarSizes.avatarSizeXSmall, - /** Glyph size in the iOS native tab bar. UITabBar gives each item more room than the JS bar, so its glyphs are larger. */ + /** Glyph size in the iOS native tab bar. */ iconNativeTabBarIOS: 22, - /** Account avatar size in the iOS native tab bar, larger than its glyphs by the same amount as on the JS bar. */ + /** Account avatar size in the iOS native tab bar. */ avatarNativeTabBarIOS: 26, /** Radius of the status dot drawn into an iOS native tab bar icon. */ nativeTabIconDotRadius: 4, - // Gap between the glyph and the label baked under it, matching the spacing the JS bar uses. + /** Gap between the glyph and the label drawn under it in an iOS native tab bar icon. */ nativeTabIconLabelGap: 6, /** Material's BottomNavigationView row, without the gesture inset under it. */ androidNativeTabBarHeight, /** Material's bar row plus the 16 dp Material keeps between a FAB and the bar. */ androidNativeTabBarFloatingButtonsBottom: androidNativeTabBarHeight + 16, - /** UITabBar with the home indicator inset under it, plus a 12 pt gap to the floating buttons. */ - iosNativeTabBarFloatingButtonsBottom: 84, + /** UITabBar's row above the home indicator inset, plus the gap to the floating buttons. */ + iosNativeTabBarFloatingButtonsBottom: 50, iconHeader: 48, iconSection: 68, iouAmountTextSize: 40, From 0eb4f2895401fc64b139797b31e126e562d520e3 Mon Sep 17 00:00:00 2001 From: sumo_slonik Date: Mon, 5 Oct 2026 15:47:30 +0200 Subject: [PATCH 57/71] chore: shared tab root scroll props type, constraint-only comments, patch docs explain why nothing is reported upstream --- patches/react-native-screens/details.md | 4 ++-- patches/react-navigation/details.md | 9 +++++---- .../useTabRootScrollProps/index.native.tsx | 5 ++--- .../TabBarBottomContent/useTabRootScrollProps/index.tsx | 5 ++--- .../TabBarBottomContent/useTabRootScrollProps/types.ts | 5 +++++ src/styles/index.ts | 6 ++---- 6 files changed, 18 insertions(+), 16 deletions(-) create mode 100644 src/components/Navigation/TabBarBottomContent/useTabRootScrollProps/types.ts diff --git a/patches/react-native-screens/details.md b/patches/react-native-screens/details.md index fa3513685927..80b8f9a1bb67 100644 --- a/patches/react-native-screens/details.md +++ b/patches/react-native-screens/details.md @@ -12,13 +12,13 @@ - Reason: Two changes to the Android tab labels, both in `TabsAppearanceApplicator`, which React Navigation has no option for. - Bold selected label. The Android tab bar takes one font weight for every label, while the design marks the selected tab with a bold label, the same way the JS side bar does. Material's `BottomNavigationView` draws each item with two labels, a small one shown while unselected and a large one shown while selected. The patch turns on Material's own `setItemTextAppearanceActiveBoldEnabled`, which keeps the large label bold whenever Material re-applies its text appearance, and calls it before `updateFontStyles`, because enabling it re-applies Material's text appearance and would otherwise replace the app's typeface. `updateFontStyles` then sets the large label's typeface with `Typeface.BOLD`, the way Material applies its own bold, so Android picks the bold face of the same font family. - Letter spacing. `updateFontStyles` replaces the typeface and size of Material's tab labels but keeps the rest of Material 3's `LabelMedium` text appearance, which tracks letters 0.5sp apart. With Expensify Neue the labels read too spread out next to the rest of the app, so the patch sets a fixed -0.5dp tracking on both labels, converted to the `em` unit `TextView.letterSpacing` expects for each label's size. -- Upstream PR/issue: not reported yet. +- Upstream PR/issue: not reported, because both changes are the App's typography (a bold selected label and its letter spacing) rather than a library defect, and RNScreens has no option for either. - E/App issue: [#101169](https://github.com/Expensify/App/issues/101169) - PR introducing patch: [#101339](https://github.com/Expensify/App/pull/101339) ### [react-native-screens+4.28.0+003+no-android-tab-icon-tint.patch](react-native-screens+4.28.0+003+no-android-tab-icon-tint.patch) - Reason: The Android account tab shows the user's avatar. `TabsAppearanceApplicator` assigns `bottomNavigationView.itemIconTintList` unconditionally, and a `ColorStateList` tint is `SRC_IN`, so it flattens the avatar to a solid silhouette in the tint color. React Navigation's `tinted: false` only reaches iOS, since `getPlatformIcon` in `@react-navigation/bottom-tabs` always hands Android a plain `imageSource`. The patch drops the icon tint list so Android draws every icon bitmap as supplied. The App recolors the glyphs off-screen in Skia for both selection states, so they keep the design's colors. Label colors are untouched and keep coming from `tabBarItemTitleFontColor`. -- Upstream PR/issue: not reported yet. +- Upstream PR/issue: not reported, because dropping the tint for every icon fits only a bar whose icons all arrive pre-colored, as the App's do. The disabled and focused icon colors go with it, which the App does not use. - E/App issue: [#101169](https://github.com/Expensify/App/issues/101169) - PR introducing patch: [#101339](https://github.com/Expensify/App/pull/101339) diff --git a/patches/react-navigation/details.md b/patches/react-navigation/details.md index 8555434a0409..b186911582fb 100644 --- a/patches/react-navigation/details.md +++ b/patches/react-navigation/details.md @@ -30,7 +30,7 @@ - Upstream PR/issue: https://github.com/react-navigation/react-navigation/pull/12751 (`route.history` + `pushParams`, added upstream for this use case); originating issue https://github.com/react-navigation/react-navigation/issues/12460. On the v8 upgrade, evaluate adopting `pushParams` to shrink this patch. - E/App issue: [#21356](https://github.com/Expensify/App/issues/21356) - PR Introducing Patch: [#24165](https://github.com/Expensify/App/pull/24165) -- PR Updating Patch: [#32087](https://github.com/Expensify/App/pull/32087) [#42465](https://github.com/Expensify/App/pull/42465) [#64155](https://github.com/Expensify/App/pull/64155) +- PR Updating Patch: [#32087](https://github.com/Expensify/App/pull/32087) [#42465](https://github.com/Expensify/App/pull/42465) [#64155](https://github.com/Expensify/App/pull/64155) [#101339](https://github.com/Expensify/App/pull/101339) ### [@react-navigation+stack+7.8.5+001+edge-drag-gesture.patch](@react-navigation+stack+7.8.5+001+edge-drag-gesture.patch) @@ -67,19 +67,20 @@ - Upstream PR/issue: https://github.com/react-navigation/react-navigation/issues/11145 - E/App issue: [#94571](https://github.com/Expensify/App/issues/94571) - PR Introducing Patch: [#95980](https://github.com/Expensify/App/pull/95980) +- PR Updating Patch: [#101339](https://github.com/Expensify/App/pull/101339) ### [@react-navigation+bottom-tabs+7.16.2+002+active-indicator-color-precedence.patch](@react-navigation+bottom-tabs+7.16.2+002+active-indicator-color-precedence.patch) - Reason: `tabBarActiveIndicatorColor` was never applied on the native Android tab bar: a missing pair of parentheses made `??` bind to the `typeof` check, so the indicator always got `tabBarActiveTintColor` at 10% alpha. The patch restores the intended precedence, so the active indicator pill takes the color from the design. **This patch can be removed on the upgrade to React Navigation v8**, whose rewritten native tab view no longer has the bug. -- Upstream PR/issue: not reported. Fixed on the v8 branch, still present on the `7.x` branch as of 7.20.0. +- Upstream PR/issue: not reported, because the v8 branch already fixes it and the fix only matters until the App moves to v8. Still present on the `7.x` branch as of 7.20.0. - E/App issue: [#101169](https://github.com/Expensify/App/issues/101169) - PR Introducing Patch: [#101339](https://github.com/Expensify/App/pull/101339) - PR Updating Patch: N/A ### [@react-navigation+bottom-tabs+7.16.2+003+hidden-tab-items.patch](@react-navigation+bottom-tabs+7.16.2+003+hidden-tab-items.patch) -- Reason: Adds a `tabBarItemHidden` option to the native bottom tabs. The App registers six tabs but the native bar can show at most five (Material's `BottomNavigationView` throws past five, and `UITabBar` folds the rest into a "More" tab), and which five depends on the Insights beta: Insights with it, Account without it. A native tab bar cannot hide one of its items, so a route with `tabBarItemHidden` gets no `Tabs.Screen` at all. When such a route is focused, it is drawn in JS over the native host with the tab bar hidden, while the host keeps the last focused visible tab selected underneath. A hidden route stays mounted once visited. Nothing native changes. The patch also forwards a `tabBarAccessibilityLabel` option to RNScreens' `tabBarItemAccessibilityLabel`. The iOS tab labels are drawn into the icon images, so without it VoiceOver announces the tabs with no name. **This part can be removed on the upgrade to React Navigation v8**, which forwards the same option. The `tabBarItemHidden` part has no upstream counterpart and stays. -- Upstream PR/issue: not reported yet. +- Reason: Adds a `tabBarItemHidden` option to the native bottom tabs. The App registers six tabs but the native bar can show at most five (Material's `BottomNavigationView` throws past five, and `UITabBar` folds the rest into a "More" tab), and which five depends on the Insights beta: Insights with it, Account without it. A native tab bar cannot hide one of its items, so a route with `tabBarItemHidden` gets no `Tabs.Screen` at all. When such a route is focused, it is drawn in JS over the native host with the tab bar hidden, while the host keeps the last focused visible tab selected underneath. A hidden route stays mounted once visited. Nothing native changes. The host's selection is derived from the tab history, skipping routes that are hidden by then, so a beta change that hides the selected route cannot leave the host pointing at a removed tab. A route that moves between hidden and visible is remounted, and a hidden route ignores preloading and emits no transition events. The patch also forwards a `tabBarAccessibilityLabel` option to RNScreens' `tabBarItemAccessibilityLabel`. The iOS tab labels are drawn into the icon images, so without it VoiceOver announces the tabs with no name. **This part can be removed on the upgrade to React Navigation v8**, which forwards the same option. The `tabBarItemHidden` part has no upstream counterpart and stays. +- Upstream PR/issue: not reported, because hiding an item from a native tab bar has no native counterpart and this works around it in JS for the App's tab set; the accessibility label part is already in v8. - E/App issue: [#101169](https://github.com/Expensify/App/issues/101169) - PR Introducing Patch: [#101339](https://github.com/Expensify/App/pull/101339) - PR Updating Patch: N/A diff --git a/src/components/Navigation/TabBarBottomContent/useTabRootScrollProps/index.native.tsx b/src/components/Navigation/TabBarBottomContent/useTabRootScrollProps/index.native.tsx index 3ab149092092..65aaa5912a7a 100644 --- a/src/components/Navigation/TabBarBottomContent/useTabRootScrollProps/index.native.tsx +++ b/src/components/Navigation/TabBarBottomContent/useTabRootScrollProps/index.native.tsx @@ -1,6 +1,6 @@ -import type {ScrollViewProps, StyleProp, ViewStyle} from 'react-native'; +import type {StyleProp, ViewStyle} from 'react-native'; -type TabRootScrollProps = Pick; +import type TabRootScrollProps from './types'; /** * UIKit insets the end of the list past the translucent iOS tab bar that the content runs under, and Android reserves @@ -11,4 +11,3 @@ function useTabRootScrollProps(style?: StyleProp): TabRootScrollProps } export default useTabRootScrollProps; -export type {TabRootScrollProps}; diff --git a/src/components/Navigation/TabBarBottomContent/useTabRootScrollProps/index.tsx b/src/components/Navigation/TabBarBottomContent/useTabRootScrollProps/index.tsx index 75b760461ef3..c75a5c774fc8 100644 --- a/src/components/Navigation/TabBarBottomContent/useTabRootScrollProps/index.tsx +++ b/src/components/Navigation/TabBarBottomContent/useTabRootScrollProps/index.tsx @@ -3,9 +3,9 @@ import useResponsiveLayout from '@hooks/useResponsiveLayout'; import variables from '@styles/variables'; -import type {ScrollViewProps, StyleProp, ViewStyle} from 'react-native'; +import type {StyleProp, ViewStyle} from 'react-native'; -type TabRootScrollProps = Pick; +import type TabRootScrollProps from './types'; /** * The content of a narrow tab root screen runs under the floating tab bar, so a list's content container ends with @@ -25,4 +25,3 @@ function useTabRootScrollProps(style?: StyleProp, hasBottomSafeAreaPa } export default useTabRootScrollProps; -export type {TabRootScrollProps}; diff --git a/src/components/Navigation/TabBarBottomContent/useTabRootScrollProps/types.ts b/src/components/Navigation/TabBarBottomContent/useTabRootScrollProps/types.ts new file mode 100644 index 000000000000..61dc387fad90 --- /dev/null +++ b/src/components/Navigation/TabBarBottomContent/useTabRootScrollProps/types.ts @@ -0,0 +1,5 @@ +import type {ScrollViewProps} from 'react-native'; + +type TabRootScrollProps = Pick; + +export default TabRootScrollProps; diff --git a/src/styles/index.ts b/src/styles/index.ts index 16d6b8c98c43..68c4704bcec3 100644 --- a/src/styles/index.ts +++ b/src/styles/index.ts @@ -742,8 +742,7 @@ const staticStyles = (theme: ThemeColors) => overflow: 'visible', }, - // The narrow tab bar on mobile web is a flat capsule floating above the screen content. iOS and Android hand - // the bottom bar over to their native tab bars, so nothing here reaches them. + // Only the mobile web bar uses this; iOS and Android draw native tab bars. navigationTabBarContainer: { flexDirection: 'row', height: variables.floatingTabBarHeight, @@ -763,8 +762,7 @@ const staticStyles = (theme: ThemeColors) => navigationTabBarItemSelected: { backgroundColor: theme.floatingTabBarSelectedBG, borderRadius: variables.componentBorderRadiusCircle, - // Spans the whole tab and a little past it, the way the native iOS bar draws its selection. The padding - // matches the negative margin, so the label keeps the tab's width to wrap in, selected or not. + // The padding matches the negative margin, so the label keeps the tab's width to wrap in, selected or not. alignSelf: 'stretch', paddingHorizontal: variables.floatingTabBarSelectedOverhang, marginHorizontal: -variables.floatingTabBarSelectedOverhang, From ac4fd1059efce8f40f5b70d4fe1bef295798f4bb Mon Sep 17 00:00:00 2001 From: sumo_slonik Date: Tue, 6 Oct 2026 10:27:28 +0200 Subject: [PATCH 58/71] fix: Android selected tab no longer sits higher than the others, tab labels drop Material's letter spacing --- patches/react-native-screens/details.md | 4 +-- ...8.0+002+android-tab-label-typography.patch | 33 ++++--------------- 2 files changed, 9 insertions(+), 28 deletions(-) diff --git a/patches/react-native-screens/details.md b/patches/react-native-screens/details.md index 80b8f9a1bb67..8d9ce30ef917 100644 --- a/patches/react-native-screens/details.md +++ b/patches/react-native-screens/details.md @@ -10,8 +10,8 @@ ### [react-native-screens+4.28.0+002+android-tab-label-typography.patch](react-native-screens+4.28.0+002+android-tab-label-typography.patch) - Reason: Two changes to the Android tab labels, both in `TabsAppearanceApplicator`, which React Navigation has no option for. - - Bold selected label. The Android tab bar takes one font weight for every label, while the design marks the selected tab with a bold label, the same way the JS side bar does. Material's `BottomNavigationView` draws each item with two labels, a small one shown while unselected and a large one shown while selected. The patch turns on Material's own `setItemTextAppearanceActiveBoldEnabled`, which keeps the large label bold whenever Material re-applies its text appearance, and calls it before `updateFontStyles`, because enabling it re-applies Material's text appearance and would otherwise replace the app's typeface. `updateFontStyles` then sets the large label's typeface with `Typeface.BOLD`, the way Material applies its own bold, so Android picks the bold face of the same font family. - - Letter spacing. `updateFontStyles` replaces the typeface and size of Material's tab labels but keeps the rest of Material 3's `LabelMedium` text appearance, which tracks letters 0.5sp apart. With Expensify Neue the labels read too spread out next to the rest of the app, so the patch sets a fixed -0.5dp tracking on both labels, converted to the `em` unit `TextView.letterSpacing` expects for each label's size. + - Bold selected label. The Android tab bar takes one font weight for every label, while the design marks the selected tab with a bold label, the same way the JS side bar does. Material's `BottomNavigationView` draws each item with two labels, a small one shown while unselected and a large one shown while selected. `updateFontStyles` sets the large label's typeface with `Typeface.BOLD`, the way Material applies its own bold, so Android picks the bold face of the same font family. Material's `setItemTextAppearanceActiveBoldEnabled` is not used for this: it re-applies Material's 12sp text size to the large label alone and recomputes the selected item's offset from the difference between the two labels' sizes, so every selected tab would sit higher than the others. + - Letter spacing. `updateFontStyles` replaces the typeface and size of Material's tab labels but keeps the rest of Material 3's `LabelMedium` text appearance, which tracks letters 0.5sp apart. With Expensify Neue the labels read too spread out next to the rest of the app, so the patch sets the letter spacing of both labels to 0. - Upstream PR/issue: not reported, because both changes are the App's typography (a bold selected label and its letter spacing) rather than a library defect, and RNScreens has no option for either. - E/App issue: [#101169](https://github.com/Expensify/App/issues/101169) - PR introducing patch: [#101339](https://github.com/Expensify/App/pull/101339) diff --git a/patches/react-native-screens/react-native-screens+4.28.0+002+android-tab-label-typography.patch b/patches/react-native-screens/react-native-screens+4.28.0+002+android-tab-label-typography.patch index f0be85344b86..a532d6a859e5 100644 --- a/patches/react-native-screens/react-native-screens+4.28.0+002+android-tab-label-typography.patch +++ b/patches/react-native-screens/react-native-screens+4.28.0+002+android-tab-label-typography.patch @@ -1,5 +1,5 @@ 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 10cd91b..9238416 100644 +index 10cd91b..6f059bd 100644 --- a/node_modules/react-native-screens/android/src/main/java/com/swmansion/rnscreens/gamma/tabs/appearance/TabsAppearanceApplicator.kt +++ b/node_modules/react-native-screens/android/src/main/java/com/swmansion/rnscreens/gamma/tabs/appearance/TabsAppearanceApplicator.kt @@ -3,6 +3,7 @@ package com.swmansion.rnscreens.gamma.tabs.appearance @@ -10,42 +10,23 @@ index 10cd91b..9238416 100644 import android.graphics.drawable.StateListDrawable import android.util.TypedValue import android.view.MenuItem -@@ -95,6 +96,10 @@ internal class TabsAppearanceApplicator( - - bottomNavigationView.labelVisibilityMode = visibilityMode - -+ // The selected tab's label is drawn bold, the way the app marks a selected tab everywhere else. Material re-applies -+ // its text appearance when enabling this, so it has to run before `updateFontStyles` sets the app's typeface. -+ bottomNavigationView.setItemTextAppearanceActiveBoldEnabled(true) -+ - // Ripple color - val rippleColor = - tabBarAppearance?.tabBarItemRippleColor -@@ -161,13 +166,18 @@ internal class TabsAppearanceApplicator( +@@ -161,13 +162,18 @@ internal class TabsAppearanceApplicator( tabBarAppearance?.tabBarItemTitleLargeLabelFontSize?.takeIf { it > 0 }?.let { PixelUtil.toPixelFromSP(it) } ?: context.resources.getDimension(R.dimen.design_bottom_navigation_text_size) -+ // Material's label text appearance tracks letters 0.5sp apart, wider than the app's labels. -+ val labelTracking = PixelUtil.toPixelFromDIP(LABEL_TRACKING_DP) -+ ++ // Both labels drop the 0.5sp letter tracking of Material's text appearance, which reads wider than the app's labels. // Inactive smallLabel.setTextSize(TypedValue.COMPLEX_UNIT_PX, smallFontSize) smallLabel.typeface = fontFamily -+ smallLabel.letterSpacing = labelTracking / smallFontSize ++ smallLabel.letterSpacing = 0f // Active ++ // 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.typeface = fontFamily + largeLabel.setTypeface(fontFamily, Typeface.BOLD) -+ largeLabel.letterSpacing = labelTracking / largeFontSize ++ largeLabel.letterSpacing = 0f } } -@@ -237,5 +247,7 @@ internal class TabsAppearanceApplicator( - - companion object { - const val TAG = "TabsAppearanceApplicator" -+ -+ private const val LABEL_TRACKING_DP = -0.5f - } - } From 9aefaeac6e88b001621454fd9b642163cd42ad39 Mon Sep 17 00:00:00 2001 From: sumo_slonik Date: Tue, 6 Oct 2026 10:27:30 +0200 Subject: [PATCH 59/71] fix: navigating to the split sidebar already on top is a handled no-op, so the first Inbox visit logs no navigation error --- .../createSplitNavigator/SplitRouter.ts | 3 +- tests/unit/SplitRouterTest.ts | 79 +++++++++++++++++++ 2 files changed, 81 insertions(+), 1 deletion(-) create mode 100644 tests/unit/SplitRouterTest.ts diff --git a/src/libs/Navigation/AppNavigator/createSplitNavigator/SplitRouter.ts b/src/libs/Navigation/AppNavigator/createSplitNavigator/SplitRouter.ts index 3af6e3f40087..75f82f60e761 100644 --- a/src/libs/Navigation/AppNavigator/createSplitNavigator/SplitRouter.ts +++ b/src/libs/Navigation/AppNavigator/createSplitNavigator/SplitRouter.ts @@ -168,11 +168,12 @@ function SplitRouter(options: SplitNavigatorRouterOptions) { return result ? getRehydratedState(result, configOptions) : result; } if (isPushingSidebarOnCentralPane(state, action, options)) { - if (getIsNarrowLayout()) { + if (getIsNarrowLayout() && state.index > 0) { const newAction = StackActions.popToTop(); return stackRouter.getStateForAction(state, newAction, configOptions); } // On wide screen do nothing as we want to keep the central pane screen and the sidebar is visible. + // On narrow screen the sidebar is already on top, and the stack router reports popToTop with nothing to pop as unhandled. return state; } diff --git a/tests/unit/SplitRouterTest.ts b/tests/unit/SplitRouterTest.ts new file mode 100644 index 000000000000..04429342cbff --- /dev/null +++ b/tests/unit/SplitRouterTest.ts @@ -0,0 +1,79 @@ +import getIsNarrowLayout from '@libs/getIsNarrowLayout'; +import SplitRouter from '@libs/Navigation/AppNavigator/createSplitNavigator/SplitRouter'; + +import SCREENS from '@src/SCREENS'; + +import type {ParamListBase, RouterConfigOptions, StackNavigationState} from '@react-navigation/native'; + +import {CommonActions} from '@react-navigation/native'; + +jest.mock('@libs/getIsNarrowLayout', () => jest.fn()); + +const mockedGetIsNarrowLayout = jest.mocked(getIsNarrowLayout); + +const ROUTE_NAMES = [SCREENS.INBOX, SCREENS.REPORT]; + +const CONFIG_OPTIONS: RouterConfigOptions = { + routeNames: ROUTE_NAMES, + routeParamList: {}, + routeGetIdList: {}, +}; + +const router = SplitRouter({ + sidebarScreen: SCREENS.INBOX, + defaultCentralScreen: SCREENS.REPORT, + parentRoute: {key: 'ReportsSplitNavigator-key', name: 'ReportsSplitNavigator'}, +}); + +function buildState(routeNames: string[]): StackNavigationState { + return { + stale: false, + type: 'stack', + key: 'stack-key', + index: routeNames.length - 1, + routeNames: ROUTE_NAMES, + preloadedRoutes: [], + routes: routeNames.map((name) => ({key: `${name}-key`, name})), + }; +} + +describe('SplitRouter', () => { + beforeEach(() => { + mockedGetIsNarrowLayout.mockReturnValue(true); + }); + + it('handles navigating to the sidebar on a narrow layout when the sidebar is already the only screen', () => { + // Given a narrow layout split navigator that shows only its sidebar, as the Inbox tab does at its chat list + const state = buildState([SCREENS.INBOX]); + + // When the sidebar is navigated to again, which happens when the tab's navigation params are applied to an already mounted navigator + const result = router.getStateForAction(state, CommonActions.navigate(SCREENS.INBOX, undefined, {pop: true}), CONFIG_OPTIONS); + + // Then the action is handled without changes, because an unhandled (null) result makes React Navigation log an error and ignore the navigation + expect(result).toBe(state); + }); + + it('pops back to the sidebar on a narrow layout when a central screen is on top of it', () => { + // Given a narrow layout split navigator with a report opened over the sidebar + const state = buildState([SCREENS.INBOX, SCREENS.REPORT]); + + // When the sidebar is navigated to + const result = router.getStateForAction(state, CommonActions.navigate(SCREENS.INBOX, undefined, {pop: true}), CONFIG_OPTIONS); + + // Then the report is popped so the sidebar is the only screen left + expect(result?.index).toBe(0); + expect(result?.routes.map((route) => route.name)).toEqual([SCREENS.INBOX]); + }); + + it('keeps the central screen on a wide layout when the sidebar is navigated to', () => { + // Given a wide layout split navigator, where the sidebar is drawn next to the report opened over it + mockedGetIsNarrowLayout.mockReturnValue(false); + const state = buildState([SCREENS.INBOX, SCREENS.REPORT]); + + // When the sidebar is navigated to + const result = router.getStateForAction(state, CommonActions.navigate(SCREENS.INBOX, undefined, {pop: true}), CONFIG_OPTIONS); + + // Then nothing changes, because the sidebar is already visible and popping would close the report next to it + expect(result).toBe(state); + }); +}); From cd5a62b5316925f723a27e6dd1612e025960bb8b Mon Sep 17 00:00:00 2001 From: sumo_slonik Date: Tue, 6 Oct 2026 11:37:01 +0200 Subject: [PATCH 60/71] fix: Android tab dots drawn into the icon keep their own color, native tab dots get a cutout from the icon --- .../NativeTabNavigator/tabIconRasterizer.ts | 21 +++++++++++++------ .../useNativeTabBarOptions/index.ios.ts | 2 ++ .../useNativeTabBarOptions/index.ts | 16 +++++++------- src/styles/variables.ts | 4 +++- 4 files changed, 28 insertions(+), 15 deletions(-) diff --git a/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/tabIconRasterizer.ts b/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/tabIconRasterizer.ts index 453a76a3dd6c..0a233bee9249 100644 --- a/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/tabIconRasterizer.ts +++ b/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/tabIconRasterizer.ts @@ -3,7 +3,7 @@ import FontUtils from '@styles/utils/FontUtils'; import type {NativeBottomTabIcon} from '@react-navigation/bottom-tabs/unstable'; import type {SkCanvas, SkFont, SkImage, SkPath} from '@shopify/react-native-skia'; -import {ClipOp, FillType, FilterMode, FontWeight, ImageFormat, MipmapMode, Skia} from '@shopify/react-native-skia'; +import {BlendMode, ClipOp, FillType, FilterMode, FontWeight, ImageFormat, MipmapMode, Skia} from '@shopify/react-native-skia'; import {PixelRatio} from 'react-native'; import type {NativeTabGlyph, NativeTabName} from './NATIVE_TAB_GLYPHS'; @@ -17,6 +17,8 @@ type TabIconLayout = { avatarSize: number; /** Radius of the status dot drawn at the content's top right corner. */ dotRadius: number; + /** Width of the transparent ring cut out of the content around the status dot. */ + dotCutout: number; /** Gap between the glyph box and the label drawn under it. */ labelGap: number; /** Font size of the label drawn under the glyph. */ @@ -117,11 +119,18 @@ function drawCircularImage(canvas: SkCanvas, image: SkImage, left: number, top: circle.dispose(); } -function drawStatusDot(canvas: SkCanvas, right: number, top: number, radius: number, color: string) { +function drawStatusDot(canvas: SkCanvas, right: number, top: number, radius: number, cutout: number, color: string) { + const centerX = right - radius; + const centerY = top + radius; const paint = Skia.Paint(); - paint.setColor(Skia.Color(color)); paint.setAntiAlias(true); - canvas.drawCircle(right - radius, top + radius, radius, paint); + if (cutout > 0) { + paint.setBlendMode(BlendMode.Clear); + canvas.drawCircle(centerX, centerY, radius + cutout, paint); + paint.setBlendMode(BlendMode.SrcOver); + } + paint.setColor(Skia.Color(color)); + canvas.drawCircle(centerX, centerY, radius, paint); paint.dispose(); } @@ -153,7 +162,7 @@ function drawTabIcon(layout: TabIconLayout, {name, color, avatar, dotColor, labe drawGlyph(canvas, name, contentLeft, contentTop, contentSize, color); } if (dotColor) { - drawStatusDot(canvas, contentLeft + contentSize, contentTop, layout.dotRadius * scale, dotColor); + drawStatusDot(canvas, contentLeft + contentSize, contentTop, layout.dotRadius * scale, layout.dotCutout * scale, dotColor); } if (label && font && labelMetrics) { const paint = Skia.Paint(); @@ -182,7 +191,7 @@ function drawTabIcon(layout: TabIconLayout, {name, color, avatar, dotColor, labe */ function getTabIcon(layout: TabIconLayout, params: TabIconParams): NativeBottomTabIcon | undefined { const {name, color, avatar, dotColor, label} = params; - const key = [name, avatar?.uri ?? color, dotColor, label?.text, label?.color, label?.isBold, layout.glyphSize, layout.avatarSize].join('|'); + const key = [name, avatar?.uri ?? color, dotColor, label?.text, label?.color, label?.isBold, layout.glyphSize, layout.avatarSize, layout.dotRadius, layout.dotCutout].join('|'); if (iconCache.has(key)) { return iconCache.get(key); } 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 135056a4332b..8ab8500bf4d5 100644 --- a/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/useNativeTabBarOptions/index.ios.ts +++ b/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/useNativeTabBarOptions/index.ios.ts @@ -21,6 +21,8 @@ const TAB_ICON_LAYOUT: TabIconLayout = { // A circle reads smaller than a glyph of the same box, so the avatar is drawn a little larger than the glyphs. avatarSize: variables.avatarNativeTabBarIOS, dotRadius: variables.nativeTabIconDotRadius, + // A cutout rather than a border in the bar's color, because the iOS 26 bar is glass and has no single color. + dotCutout: variables.nativeTabIconDotCutout, labelGap: variables.nativeTabIconLabelGap, labelFontSize: variables.fontSizeSmall, }; diff --git a/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/useNativeTabBarOptions/index.ts b/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/useNativeTabBarOptions/index.ts index 4e0c5803e67b..abfeb0175b61 100644 --- a/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/useNativeTabBarOptions/index.ts +++ b/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/useNativeTabBarOptions/index.ts @@ -14,17 +14,18 @@ import type {NativeBottomTabNavigationOptions} from '@react-navigation/bottom-ta import type NativeTabBarOptionsParams from './types'; -/** An empty badge value makes Material draw its small dot badge instead of a number. */ -const STATUS_DOT_BADGE = ''; - /** Material's bar sits above the system gesture inset, so the floating buttons clear both. */ const getFloatingButtonsBottom = (bottomInset: number) => variables.androidNativeTabBarFloatingButtonsBottom + bottomInset; -/** Material draws each icon in its fixed icon slot, with the label and the status badge of its own. */ +/** + * Material draws each icon in its fixed icon slot, with a label of its own. The status dot is drawn into the icon rather + * than as a Material badge, because RNScreens paints every badge in the badge color of the selected tab. + */ const TAB_ICON_LAYOUT: TabIconLayout = { glyphSize: variables.iconBottomBar, avatarSize: variables.iconBottomBar, - dotRadius: 0, + dotRadius: variables.nativeTabIconDotRadius, + dotCutout: variables.nativeTabIconDotCutout, labelGap: 0, labelFontSize: 0, }; @@ -49,14 +50,13 @@ function useNativeTabBarOptions({shouldShowNativeTabBar, dotColors, tabLabels}: const getTabOptions = (name: NativeTabName): NativeBottomTabNavigationOptions => { // The active indicator pill marks the selected tab, so the avatar is the same image in both selection states. const tabAvatar = name === NAVIGATORS.SETTINGS_SPLIT_NAVIGATOR ? avatar : undefined; - const inactiveIcon = getTabIcon(TAB_ICON_LAYOUT, {name, color: theme.icon, avatar: tabAvatar}); - const activeIcon = getTabIcon(TAB_ICON_LAYOUT, {name, color: theme.iconMenu, avatar: tabAvatar}); const dotColor = dotColors[name]; + const inactiveIcon = getTabIcon(TAB_ICON_LAYOUT, {name, color: theme.icon, avatar: tabAvatar, dotColor}); + const activeIcon = getTabIcon(TAB_ICON_LAYOUT, {name, color: theme.iconMenu, avatar: tabAvatar, dotColor}); return { tabBarLabel: tabLabels[name], // A function, because React Navigation derives the selected icon only from a function. tabBarIcon: inactiveIcon && activeIcon ? ({focused}) => (focused ? activeIcon : inactiveIcon) : undefined, - ...(dotColor ? {tabBarBadge: STATUS_DOT_BADGE, tabBarBadgeStyle: {backgroundColor: dotColor}} : {tabBarBadge: undefined}), }; }; diff --git a/src/styles/variables.ts b/src/styles/variables.ts index 5e9e38376af8..283e58a12929 100644 --- a/src/styles/variables.ts +++ b/src/styles/variables.ts @@ -116,8 +116,10 @@ export default { iconNativeTabBarIOS: 22, /** Account avatar size in the iOS native tab bar. */ avatarNativeTabBarIOS: 26, - /** Radius of the status dot drawn into an iOS native tab bar icon. */ + /** Radius of the status dot drawn into a native tab bar icon. */ nativeTabIconDotRadius: 4, + /** Width of the ring cut out of a native tab bar icon around its status dot, as the JS tab bar's dot border does. */ + nativeTabIconDotCutout: 2, /** Gap between the glyph and the label drawn under it in an iOS native tab bar icon. */ nativeTabIconLabelGap: 6, /** Material's BottomNavigationView row, without the gesture inset under it. */ From 71ffd6e40913e84eccf9c06fe376fa93158ddeba Mon Sep 17 00:00:00 2001 From: sumo_slonik Date: Tue, 6 Oct 2026 15:59:39 +0200 Subject: [PATCH 61/71] chore: no double safe area under the mWeb Inbox list, one owner for tab span cancel, unused exports dropped --- src/components/LHNOptionsList/LHNOptionsList.tsx | 2 +- .../Navigators/NativeTabNavigator/tabIconRasterizer.ts | 2 +- .../NativeTabNavigator/useNativeTabNavigator.ts | 9 ++++++--- src/libs/telemetry/cancelTabNavigationSpans.ts | 2 +- 4 files changed, 9 insertions(+), 6 deletions(-) diff --git a/src/components/LHNOptionsList/LHNOptionsList.tsx b/src/components/LHNOptionsList/LHNOptionsList.tsx index fb948e8a49dd..e970dc0ce6e5 100644 --- a/src/components/LHNOptionsList/LHNOptionsList.tsx +++ b/src/components/LHNOptionsList/LHNOptionsList.tsx @@ -46,7 +46,7 @@ function LHNOptionsList({style, contentContainerStyles, data, onSelectRow, optio const [personalDetails] = useAllPersonalDetails(); const styles = useThemeStyles(); - const tabRootScrollProps = useTabRootScrollProps(contentContainerStyles); + const tabRootScrollProps = useTabRootScrollProps(contentContainerStyles, true); const estimatedItemSize = optionMode === CONST.OPTION_MODE.COMPACT ? variables.optionRowHeightCompact : variables.optionRowHeight; // When the first item renders we want to call the onFirstItemRendered callback. diff --git a/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/tabIconRasterizer.ts b/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/tabIconRasterizer.ts index 0a233bee9249..e864e7f13531 100644 --- a/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/tabIconRasterizer.ts +++ b/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/tabIconRasterizer.ts @@ -204,4 +204,4 @@ function getTabIcon(layout: TabIconLayout, params: TabIconParams): NativeBottomT } export default getTabIcon; -export type {TabIconLayout, TabIconParams}; +export type {TabIconLayout}; diff --git a/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/useNativeTabNavigator.ts b/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/useNativeTabNavigator.ts index d3c419584453..0d56423ac62a 100644 --- a/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/useNativeTabNavigator.ts +++ b/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/useNativeTabNavigator.ts @@ -112,11 +112,14 @@ function useNativeTabNavigator() { Navigation.navigate(ROUTES.HOME, {forceReplace: true}); }, [isInsightsTabFocusedWithoutBeta]); - // Cancel any in-flight tab-navigation span that doesn't match the new focused tab. - // The span for the new tab is started by the tab button before navigation, so we keep it via `except`. + // Cancel any in-flight tab-navigation span that doesn't match the new focused tab. The new tab's span is started at + // the tap, before navigation, so it is kept. On wide layouts the JS side bar does this. useEffect(() => { + if (!shouldUseNarrowLayout) { + return; + } cancelTabNavigationSpans(NAVIGATION_TAB_TO_SPANS[selectedTab]); - }, [selectedTab]); + }, [selectedTab, shouldUseNarrowLayout]); // The slicing optimization in useCustomRootStackNavigatorState can unmount and later remount // this TAB_NAVIGATOR. Without restoration it would default to index 0. We restore the saved diff --git a/src/libs/telemetry/cancelTabNavigationSpans.ts b/src/libs/telemetry/cancelTabNavigationSpans.ts index 42c3c7a3261b..604131421a1c 100644 --- a/src/libs/telemetry/cancelTabNavigationSpans.ts +++ b/src/libs/telemetry/cancelTabNavigationSpans.ts @@ -34,4 +34,4 @@ function cancelTabNavigationSpans(preserve: readonly string[] = []) { } export default cancelTabNavigationSpans; -export {REPORTS_TAB_SPAN_IDS, INBOX_TAB_SPAN_IDS, TAB_NAVIGATION_SPAN_IDS, NAVIGATION_TAB_TO_SPANS}; +export {TAB_NAVIGATION_SPAN_IDS, NAVIGATION_TAB_TO_SPANS}; From 9b011418221b9b51c4972e04f569cba8a3312924 Mon Sep 17 00:00:00 2001 From: sumo_slonik Date: Tue, 6 Oct 2026 16:38:46 +0200 Subject: [PATCH 62/71] fix: native Workspaces and first Spend taps follow the JS tab buttons, background tabs send no boot requests, CPU tab icons --- patches/react-native-screens/details.md | 2 +- .../NavigationTabBar/getSearchTabRoute.ts | 24 ++++-- .../index.android.tsx | 7 +- .../index.ios.tsx | 6 +- .../useTabRootScreenWrapperProps/index.tsx | 8 +- .../useTabRootScreenWrapperProps/types.ts | 4 + .../useTabRootScrollProps/index.native.tsx | 8 +- .../useTabRootScrollProps/index.tsx | 11 +-- .../useTabRootScrollProps/types.ts | 6 +- src/hooks/useHasTabBeenShown.ts | 37 +++++++++ .../NativeTabNavigator/NativeTabLayout.tsx | 22 ++++- .../SpendTabPressListener.tsx | 53 ++++++++++++ .../WorkspacesTabPressListener.tsx | 25 ++++++ .../NativeTabNavigator/tabIconRasterizer.ts | 2 +- .../NativeTabNavigator/tabScreenListeners.ts | 25 ++++-- .../useNativeTabBarOptions/index.ios.ts | 4 +- .../useNativeTabBarOptions/index.ts | 4 +- .../useNativeTabBarOptions/types.ts | 2 + .../useNativeTabNavigator.ts | 11 ++- .../NativeTabNavigator/useTabAvatarImage.ts | 14 ++-- .../Navigators/TabNavigator.native.tsx | 6 +- src/pages/Search/SearchPage.tsx | 7 +- src/pages/settings/InitialSettingsPage.tsx | 7 +- src/styles/theme/types.ts | 4 +- tests/ui/InitialSettingsPageTest.tsx | 2 + tests/unit/nativeTabScreenListenersTest.ts | 55 ++++++------ tests/unit/useHasTabBeenShownTest.ts | 83 +++++++++++++++++++ 27 files changed, 352 insertions(+), 87 deletions(-) create mode 100644 src/hooks/useHasTabBeenShown.ts create mode 100644 src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/SpendTabPressListener.tsx create mode 100644 src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/WorkspacesTabPressListener.tsx create mode 100644 tests/unit/useHasTabBeenShownTest.ts diff --git a/patches/react-native-screens/details.md b/patches/react-native-screens/details.md index 8d9ce30ef917..bd772bb16417 100644 --- a/patches/react-native-screens/details.md +++ b/patches/react-native-screens/details.md @@ -2,7 +2,7 @@ ### [react-native-screens+4.28.0+001+animate-hiding-the-native-tab-bar.patch](react-native-screens+4.28.0+001+animate-hiding-the-native-tab-bar.patch) -- Reason: The App hides the native tab bar on screens that are not a tab root, which React Navigation forwards to RNScreens as `tabBarHidden` (it derives the flag from `tabBarStyle.display === 'none'`). RNScreens calls UIKit's `setTabBarHidden:animated:` with `animated:NO`, so the bar blinks in and out instead of travelling with the screen that hid it. The patch passes `YES`, which lets UIKit run its own show/hide animation. The pre-iOS 18 branch is left alone: it assigns `tabBar.hidden` directly and has no animated counterpart. **This patch can be removed on the bump to react-native-screens 4.29.0**, the first release with the upstream fix linked below. +- Reason: The App hides the native tab bar on screens that are not a tab root, which React Navigation forwards to RNScreens as `tabBarHidden` (it derives the flag from `tabBarStyle.display === 'none'`). RNScreens calls UIKit's `setTabBarHidden:animated:` with `animated:NO`, so the bar blinks in and out instead of travelling with the screen that hid it. The patch animates the change once the bar is in a window, which lets UIKit run its own show/hide animation without animating the bar out on the first render. The pre-iOS 18 branch is left alone: it assigns `tabBar.hidden` directly and has no animated counterpart. **This patch can be removed on the bump to react-native-screens 4.29.0**, the first release with the upstream fix linked below. - Upstream PR/issue: fixed upstream by [software-mansion/react-native-screens#4632](https://github.com/software-mansion/react-native-screens/pull/4632), which animates by default behind a new `ios.tabBarHiddenAnimationEnabled` prop, and [#4673](https://github.com/software-mansion/react-native-screens/pull/4673), which skips the animation on the first render. Both are merged after the 4.28.0 release and labeled for the v4 backport. - E/App issue: [#101169](https://github.com/Expensify/App/issues/101169) - PR introducing patch: [#101339](https://github.com/Expensify/App/pull/101339) diff --git a/src/components/Navigation/NavigationTabBar/getSearchTabRoute.ts b/src/components/Navigation/NavigationTabBar/getSearchTabRoute.ts index e76c98b3398b..408f925a2d47 100644 --- a/src/components/Navigation/NavigationTabBar/getSearchTabRoute.ts +++ b/src/components/Navigation/NavigationTabBar/getSearchTabRoute.ts @@ -14,6 +14,19 @@ import type {OnyxEntry} from 'react-native-onyx'; import getLastRoute from './getLastRoute'; +/** The latest Spend search kept in Onyx, for a Spend tab that has not shown a search yet. */ +function getStoredSearchTabRoute(lastSearchParams: OnyxEntry, lastExpensesSearchQuery: string | undefined) { + const lastQueryJSON = lastSearchParams?.queryJSON; + const lastQueryFromOnyx = lastQueryJSON ? buildSearchQueryString(lastQueryJSON) : undefined; + if (lastQueryFromOnyx) { + // The persisted search key belongs to the persisted query, so it only travels with it. + return ROUTES.SEARCH_ROOT.getRoute({query: lastQueryFromOnyx, searchKey: lastSearchParams?.searchKey}); + } + + const defaultSearchQuery = buildCannedSearchQuery({type: CONST.SEARCH.DATA_TYPES.EXPENSE}); + return ROUTES.SEARCH_ROOT.getRoute({query: getValidLastQuery(lastExpensesSearchQuery, defaultSearchQuery), searchKey: CONST.SEARCH.SEARCH_KEYS.EXPENSES}); +} + function getSearchTabRoute(rootState: NavigationState, lastSearchParams: OnyxEntry, lastExpensesSearchQuery: string | undefined) { const lastSearchRoute = getLastRoute(rootState, NAVIGATORS.SEARCH_FULLSCREEN_NAVIGATOR, SCREENS.SEARCH.ROOT); @@ -28,15 +41,8 @@ function getSearchTabRoute(rootState: NavigationState, lastSearchParams: OnyxEnt } } - const lastQueryJSON = lastSearchParams?.queryJSON; - const lastQueryFromOnyx = lastQueryJSON ? buildSearchQueryString(lastQueryJSON) : undefined; - if (lastQueryFromOnyx) { - // The persisted search key belongs to the persisted query, so it only travels with it. - return ROUTES.SEARCH_ROOT.getRoute({query: lastQueryFromOnyx, searchKey: lastSearchParams?.searchKey}); - } - - const defaultSearchQuery = buildCannedSearchQuery({type: CONST.SEARCH.DATA_TYPES.EXPENSE}); - return ROUTES.SEARCH_ROOT.getRoute({query: getValidLastQuery(lastExpensesSearchQuery, defaultSearchQuery), searchKey: CONST.SEARCH.SEARCH_KEYS.EXPENSES}); + return getStoredSearchTabRoute(lastSearchParams, lastExpensesSearchQuery); } export default getSearchTabRoute; +export {getStoredSearchTabRoute}; diff --git a/src/components/Navigation/TabBarBottomContent/useTabRootScreenWrapperProps/index.android.tsx b/src/components/Navigation/TabBarBottomContent/useTabRootScreenWrapperProps/index.android.tsx index 0ecf905ca0d3..888cb620fc4d 100644 --- a/src/components/Navigation/TabBarBottomContent/useTabRootScreenWrapperProps/index.android.tsx +++ b/src/components/Navigation/TabBarBottomContent/useTabRootScreenWrapperProps/index.android.tsx @@ -1,5 +1,4 @@ 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'; @@ -8,13 +7,13 @@ import useThemeStyles from '@hooks/useThemeStyles'; import React from 'react'; import {View} from 'react-native'; -import type TabRootScreenWrapperProps from './types'; +import type {UseTabRootScreenWrapperProps} 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. */ -function useTabRootScreenWrapperProps(selectedTab: TabBarBottomContentProps['selectedTab']): TabRootScreenWrapperProps { +const useTabRootScreenWrapperProps: UseTabRootScreenWrapperProps = (selectedTab) => { const {shouldUseNarrowLayout} = useResponsiveLayout(); const isSettingsDrawnOverTabs = useIsSettingsDrawnOverTabs(); const styles = useThemeStyles(); @@ -24,6 +23,6 @@ function useTabRootScreenWrapperProps(selectedTab: TabBarBottomContentProps['sel } return {bottomContent: }; -} +}; export default useTabRootScreenWrapperProps; diff --git a/src/components/Navigation/TabBarBottomContent/useTabRootScreenWrapperProps/index.ios.tsx b/src/components/Navigation/TabBarBottomContent/useTabRootScreenWrapperProps/index.ios.tsx index c7d86f32d2c4..300d65a857ce 100644 --- a/src/components/Navigation/TabBarBottomContent/useTabRootScreenWrapperProps/index.ios.tsx +++ b/src/components/Navigation/TabBarBottomContent/useTabRootScreenWrapperProps/index.ios.tsx @@ -1,14 +1,14 @@ import useResponsiveLayout from '@hooks/useResponsiveLayout'; -import type TabRootScreenWrapperProps from './types'; +import type {UseTabRootScreenWrapperProps} 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 adds no bottom padding. Each tab root's list lets UIKit inset its end past the bar. */ -function useTabRootScreenWrapperProps(): TabRootScreenWrapperProps { +const useTabRootScreenWrapperProps: UseTabRootScreenWrapperProps = () => { const {shouldUseNarrowLayout} = useResponsiveLayout(); return shouldUseNarrowLayout ? {includeSafeAreaPaddingBottom: false} : {}; -} +}; export default useTabRootScreenWrapperProps; diff --git a/src/components/Navigation/TabBarBottomContent/useTabRootScreenWrapperProps/index.tsx b/src/components/Navigation/TabBarBottomContent/useTabRootScreenWrapperProps/index.tsx index 5621ca35e2fd..ecf13966f9f0 100644 --- a/src/components/Navigation/TabBarBottomContent/useTabRootScreenWrapperProps/index.tsx +++ b/src/components/Navigation/TabBarBottomContent/useTabRootScreenWrapperProps/index.tsx @@ -1,11 +1,9 @@ -import type TabBarBottomContentProps from '@components/Navigation/TabBarBottomContent/types'; - import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useThemeStyles from '@hooks/useThemeStyles'; import React from 'react'; -import type TabRootScreenWrapperProps from './types'; +import type {UseTabRootScreenWrapperProps} from './types'; import TabBarBottomContent from '..'; @@ -13,7 +11,7 @@ import TabBarBottomContent from '..'; * On narrow layouts the floating tab bar is laid over the bottom of a tab root screen, so the content runs under it * down to the bottom edge of the screen. Each tab root's list ends with room for the bar. */ -function useTabRootScreenWrapperProps(selectedTab: TabBarBottomContentProps['selectedTab']): TabRootScreenWrapperProps { +const useTabRootScreenWrapperProps: UseTabRootScreenWrapperProps = (selectedTab) => { const {shouldUseNarrowLayout} = useResponsiveLayout(); const styles = useThemeStyles(); @@ -21,6 +19,6 @@ function useTabRootScreenWrapperProps(selectedTab: TabBarBottomContentProps['sel bottomContent: , bottomContentStyle: shouldUseNarrowLayout ? styles.floatingTabBarOverlay : styles.overflowVisible, }; -} +}; export default useTabRootScreenWrapperProps; diff --git a/src/components/Navigation/TabBarBottomContent/useTabRootScreenWrapperProps/types.ts b/src/components/Navigation/TabBarBottomContent/useTabRootScreenWrapperProps/types.ts index dab83f0477d5..0a371761226f 100644 --- a/src/components/Navigation/TabBarBottomContent/useTabRootScreenWrapperProps/types.ts +++ b/src/components/Navigation/TabBarBottomContent/useTabRootScreenWrapperProps/types.ts @@ -1,5 +1,9 @@ +import type TabBarBottomContentProps from '@components/Navigation/TabBarBottomContent/types'; import type {ScreenWrapperContainerProps} from '@components/ScreenWrapper/ScreenWrapperContainer'; type TabRootScreenWrapperProps = Pick; +type UseTabRootScreenWrapperProps = (selectedTab: TabBarBottomContentProps['selectedTab']) => TabRootScreenWrapperProps; + export default TabRootScreenWrapperProps; +export type {UseTabRootScreenWrapperProps}; diff --git a/src/components/Navigation/TabBarBottomContent/useTabRootScrollProps/index.native.tsx b/src/components/Navigation/TabBarBottomContent/useTabRootScrollProps/index.native.tsx index 65aaa5912a7a..4217cc7534fe 100644 --- a/src/components/Navigation/TabBarBottomContent/useTabRootScrollProps/index.native.tsx +++ b/src/components/Navigation/TabBarBottomContent/useTabRootScrollProps/index.native.tsx @@ -1,13 +1,9 @@ -import type {StyleProp, ViewStyle} from 'react-native'; - -import type TabRootScrollProps from './types'; +import type {UseTabRootScrollProps} from './types'; /** * UIKit insets the end of the list past the translucent iOS tab bar that the content runs under, and Android reserves * the bar's row below the content, so the content container keeps its style. */ -function useTabRootScrollProps(style?: StyleProp): TabRootScrollProps { - return {contentContainerStyle: style, contentInsetAdjustmentBehavior: 'automatic'}; -} +const useTabRootScrollProps: UseTabRootScrollProps = (style) => ({contentContainerStyle: style, contentInsetAdjustmentBehavior: 'automatic'}); export default useTabRootScrollProps; diff --git a/src/components/Navigation/TabBarBottomContent/useTabRootScrollProps/index.tsx b/src/components/Navigation/TabBarBottomContent/useTabRootScrollProps/index.tsx index c75a5c774fc8..109b00972f09 100644 --- a/src/components/Navigation/TabBarBottomContent/useTabRootScrollProps/index.tsx +++ b/src/components/Navigation/TabBarBottomContent/useTabRootScrollProps/index.tsx @@ -3,16 +3,13 @@ import useResponsiveLayout from '@hooks/useResponsiveLayout'; import variables from '@styles/variables'; -import type {StyleProp, ViewStyle} from 'react-native'; - -import type TabRootScrollProps from './types'; +import type {UseTabRootScrollProps} from './types'; /** * The content of a narrow tab root screen runs under the floating tab bar, so a list's content container ends with - * room for the bar, letting its last row scroll out from under it. A list that already adds the bottom safe area - * passes `hasBottomSafeAreaPadding`, so the safe area is not added twice. + * room for the bar, letting its last row scroll out from under it. */ -function useTabRootScrollProps(style?: StyleProp, hasBottomSafeAreaPadding = false): TabRootScrollProps { +const useTabRootScrollProps: UseTabRootScrollProps = (style, hasBottomSafeAreaPadding = false) => { const {shouldUseNarrowLayout} = useResponsiveLayout(); const insetStyle = useBottomSafeSafeAreaPaddingStyle({ style, @@ -22,6 +19,6 @@ function useTabRootScrollProps(style?: StyleProp, hasBottomSafeAreaPa }); return {contentContainerStyle: shouldUseNarrowLayout ? insetStyle : style}; -} +}; export default useTabRootScrollProps; diff --git a/src/components/Navigation/TabBarBottomContent/useTabRootScrollProps/types.ts b/src/components/Navigation/TabBarBottomContent/useTabRootScrollProps/types.ts index 61dc387fad90..460d2c42ce86 100644 --- a/src/components/Navigation/TabBarBottomContent/useTabRootScrollProps/types.ts +++ b/src/components/Navigation/TabBarBottomContent/useTabRootScrollProps/types.ts @@ -1,5 +1,9 @@ -import type {ScrollViewProps} from 'react-native'; +import type {ScrollViewProps, StyleProp, ViewStyle} from 'react-native'; type TabRootScrollProps = Pick; +/** A list that already adds the bottom safe area passes `hasBottomSafeAreaPadding`, so the safe area is not added twice. */ +type UseTabRootScrollProps = (style?: StyleProp, hasBottomSafeAreaPadding?: boolean) => TabRootScrollProps; + export default TabRootScrollProps; +export type {UseTabRootScrollProps}; diff --git a/src/hooks/useHasTabBeenShown.ts b/src/hooks/useHasTabBeenShown.ts new file mode 100644 index 000000000000..f4ee1cf3de54 --- /dev/null +++ b/src/hooks/useHasTabBeenShown.ts @@ -0,0 +1,37 @@ +import {getTabNavigatorState} from '@libs/Navigation/helpers/tabNavigatorUtils'; + +import type NAVIGATORS from '@src/NAVIGATORS'; + +import type {NavigationState} from '@react-navigation/native'; +import type {ValueOf} from 'type-fest'; + +import {useState} from 'react'; + +import useRootNavigationState from './useRootNavigationState'; + +type TabNavigatorName = ValueOf; + +/** Whether the tab is shown or under a screen drawn over it, rather than a background tab of the tab navigator. */ +function isTabShown(rootState: NavigationState | undefined, tabName: TabNavigatorName) { + if (rootState?.routes.some((route) => route.name === tabName)) { + return true; + } + const tabState = getTabNavigatorState(rootState); + return !tabState || tabState.routes.at(tabState.index ?? 0)?.name === tabName; +} + +/** + * Whether the tab has been shown since the screen mounted. Native tab bars mount every tab at startup, so a tab root + * screen uses it to hold back the requests and writes it makes on mount until the user first opens the tab, as it does + * when it mounts with the tab. + */ +function useHasTabBeenShown(tabName: TabNavigatorName) { + const isShown = useRootNavigationState((rootState) => isTabShown(rootState, tabName)); + const [hasBeenShown, setHasBeenShown] = useState(isShown); + if (isShown && !hasBeenShown) { + setHasBeenShown(true); + } + return hasBeenShown; +} + +export default useHasTabBeenShown; diff --git a/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/NativeTabLayout.tsx b/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/NativeTabLayout.tsx index b605ffa7fa47..f60d00a097b1 100644 --- a/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/NativeTabLayout.tsx +++ b/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/NativeTabLayout.tsx @@ -14,6 +14,7 @@ import TabNavigatorBar from '@libs/Navigation/AppNavigator/Navigators/TabNavigat import NavigationTabBarFloatingActionButton from '@pages/inbox/sidebar/NavigationTabBarFloatingActionButton'; import CONST from '@src/CONST'; +import NAVIGATORS from '@src/NAVIGATORS'; import ONYXKEYS from '@src/ONYXKEYS'; import SCREENS from '@src/SCREENS'; @@ -24,10 +25,15 @@ import {StyleSheet, View} from 'react-native'; import Animated, {FadeIn, FadeOut} from 'react-native-reanimated'; import NativeTabBarShadow from './NativeTabBarShadow'; +import SpendTabPressListener from './SpendTabPressListener'; import {getFloatingButtonsBottom} from './useNativeTabBarOptions'; +import WorkspacesTabPressListener from './WorkspacesTabPressListener'; type NativeTabLayoutProps = Parameters>[0]; +/** A tab's own navigation, which receives the tab's `tabPress` events. */ +type NativeTabNavigation = NativeTabLayoutProps['descriptors'][string]['navigation']; + function NativeTabLayout({children, state, descriptors}: NativeTabLayoutProps) { const {shouldUseNarrowLayout} = useResponsiveLayout(); const [isDebugModeEnabled] = useOnyx(ONYXKEYS.IS_DEBUG_MODE_ENABLED); @@ -37,6 +43,13 @@ function NativeTabLayout({children, state, descriptors}: NativeTabLayoutProps) { const selectedTab = ROUTE_TO_NAVIGATION_TAB[activeRoute?.name ?? SCREENS.HOME] ?? NAVIGATION_TABS.HOME; // The buttons follow whatever the navigator decided for the bar itself. const shouldShowNativeTabBar = shouldUseNarrowLayout && !!activeRoute && descriptors[activeRoute.key]?.options.tabBarStyle?.display !== 'none'; + const getTabDescriptor = (routeName: string) => { + const route = state.routes.find((tabRoute) => tabRoute.name === routeName); + return route ? descriptors[route.key] : undefined; + }; + const spendNavigation = getTabDescriptor(NAVIGATORS.SEARCH_FULLSCREEN_NAVIGATOR)?.navigation; + const spendOptions = getTabDescriptor(NAVIGATORS.SEARCH_FULLSCREEN_NAVIGATOR)?.options; + const workspacesNavigation = getTabDescriptor(NAVIGATORS.WORKSPACE_NAVIGATOR)?.navigation; if (!shouldUseNarrowLayout) { return ( @@ -50,6 +63,13 @@ function NativeTabLayout({children, state, descriptors}: NativeTabLayoutProps) { return ( {children} + {!!spendNavigation && ( + + )} + {!!workspacesNavigation && } {!!isDebugModeEnabled && shouldShowNativeTabBar && } {shouldShowNativeTabBar && ( // The shadow and the buttons belong to the bar, so they fade with it rather than appearing in place. @@ -78,4 +98,4 @@ function NativeTabLayout({children, state, descriptors}: NativeTabLayoutProps) { } export default NativeTabLayout; -export type {NativeTabLayoutProps}; +export type {NativeTabLayoutProps, NativeTabNavigation}; diff --git a/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/SpendTabPressListener.tsx b/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/SpendTabPressListener.tsx new file mode 100644 index 000000000000..9b540106dfc1 --- /dev/null +++ b/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/SpendTabPressListener.tsx @@ -0,0 +1,53 @@ +import {getStoredSearchTabRoute} from '@components/Navigation/NavigationTabBar/getSearchTabRoute'; + +import useOnyx from '@hooks/useOnyx'; + +import interceptAnonymousUser from '@libs/interceptAnonymousUser'; +import Navigation from '@libs/Navigation/Navigation'; +import {startNavigateToReportsTabSpans} from '@libs/telemetry/startTabNavigationSpans'; + +import ONYXKEYS from '@src/ONYXKEYS'; +import {lastExpensesSearchQuerySelector} from '@src/selectors/SearchFilters'; + +import {useEffect} from 'react'; + +import type {NativeTabNavigation} from './NativeTabLayout'; + +type SpendTabPressListenerProps = { + /** The Spend tab's own navigation. */ + navigation: NativeTabNavigation; + + /** Whether the native bar leaves the switch to Spend to this listener, until Spend is first selected. */ + isSwitchedByJS: boolean; +}; + +/** + * Runs the Spend tab button's navigation for a tap on the native Spend tab. The first visit opens the latest search kept + * in Onyx, as the JS tab button does, because the tab mounted at startup shows the default one; later visits are switched + * natively and only start the spans. It is its own component, so the search data it reads re-renders only this listener. + */ +function SpendTabPressListener({navigation, isSwitchedByJS}: SpendTabPressListenerProps) { + const [lastSearchParams] = useOnyx(ONYXKEYS.REPORT_NAVIGATION_LAST_SEARCH_QUERY); + const [lastExpensesSearchQuery] = useOnyx(ONYXKEYS.SEARCH_FILTERS, {selector: lastExpensesSearchQuerySelector}); + + useEffect( + () => + navigation.addListener('tabPress', () => { + if (navigation.isFocused()) { + return; + } + interceptAnonymousUser(() => { + startNavigateToReportsTabSpans(); + if (!isSwitchedByJS) { + return; + } + Navigation.navigate(getStoredSearchTabRoute(lastSearchParams, lastExpensesSearchQuery)); + }); + }), + [navigation, isSwitchedByJS, lastSearchParams, lastExpensesSearchQuery], + ); + + return null; +} + +export default SpendTabPressListener; diff --git a/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/WorkspacesTabPressListener.tsx b/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/WorkspacesTabPressListener.tsx new file mode 100644 index 000000000000..b78762fadfb4 --- /dev/null +++ b/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/WorkspacesTabPressListener.tsx @@ -0,0 +1,25 @@ +import useRestoreWorkspacesTabOnNavigate from '@hooks/useRestoreWorkspacesTabOnNavigate'; + +import {useEffect} from 'react'; + +import type {NativeTabNavigation} from './NativeTabLayout'; + +type WorkspacesTabPressListenerProps = { + /** The Workspaces tab's own navigation. */ + navigation: NativeTabNavigation; +}; + +/** + * Runs the Workspaces tab button's navigation for a tap on the native Workspaces tab, focused or not: it restores the + * last workspace or domain, falls back to the list for one that is gone, and keeps anonymous users out. It is its own + * component, so the policy and domain collections it reads re-render only this listener. + */ +function WorkspacesTabPressListener({navigation}: WorkspacesTabPressListenerProps) { + const navigateToWorkspaces = useRestoreWorkspacesTabOnNavigate(); + + useEffect(() => navigation.addListener('tabPress', navigateToWorkspaces), [navigation, navigateToWorkspaces]); + + return null; +} + +export default WorkspacesTabPressListener; diff --git a/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/tabIconRasterizer.ts b/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/tabIconRasterizer.ts index e864e7f13531..fa55746b019c 100644 --- a/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/tabIconRasterizer.ts +++ b/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/tabIconRasterizer.ts @@ -148,7 +148,7 @@ function drawTabIcon(layout: TabIconLayout, {name, color, avatar, dotColor, labe const canvasWidth = Math.ceil(Math.max(contentSize, labelWidth)); const canvasHeight = Math.ceil(label ? labelTop + labelHeight : rowSize); - const surface = Skia.Surface.MakeOffscreen(canvasWidth, canvasHeight); + const surface = Skia.Surface.Make(canvasWidth, canvasHeight); if (!surface) { return undefined; } diff --git a/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/tabScreenListeners.ts b/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/tabScreenListeners.ts index a5d450535e04..3cc992d09a9d 100644 --- a/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/tabScreenListeners.ts +++ b/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/tabScreenListeners.ts @@ -3,7 +3,8 @@ import NAVIGATION_TABS from '@components/Navigation/NavigationTabBar/NAVIGATION_ import ROUTE_TO_NAVIGATION_TAB from '@components/Navigation/NavigationTabBar/ROUTE_TO_NAVIGATION_TAB'; import interceptAnonymousUser from '@libs/interceptAnonymousUser'; -import {startNavigateToReportsTabSpans} from '@libs/telemetry/startTabNavigationSpans'; + +import NAVIGATORS from '@src/NAVIGATORS'; import type {ValueOf} from 'type-fest'; @@ -12,21 +13,35 @@ type NavigationTab = ValueOf; /** Tabs the JS tab buttons keep from anonymous users, who get the sign-in modal instead. */ const ANONYMOUS_GATED_TABS = new Set([NAVIGATION_TABS.SEARCH, NAVIGATION_TABS.INSIGHTS, NAVIGATION_TABS.SETTINGS]); +/** + * Tabs switched by their own JS navigation rather than by the native selection, as their JS tab buttons do: Inbox opens + * at its chat list, and Workspaces restores the last workspace or falls back to the list. + */ +const JS_SWITCHED_TABS = new Set([NAVIGATION_TABS.INBOX, NAVIGATION_TABS.WORKSPACES]); + /** * What a tap on the native bar does on top of the tab switch, mirroring the JS tab buttons the native bar replaces. - * Inbox opens at its chat list, so it is switched by JS navigation rather than by the native selection. + * Spend and Workspaces are handled by SpendTabPressListener and WorkspacesTabPressListener, which need Onyx data. */ const NAVIGATION_TAB_PRESS_HANDLERS: Partial void>> = { [NAVIGATION_TABS.INBOX]: navigateToInboxTab, - [NAVIGATION_TABS.SEARCH]: () => interceptAnonymousUser(startNavigateToReportsTabSpans), [NAVIGATION_TABS.INSIGHTS]: () => interceptAnonymousUser(() => {}), [NAVIGATION_TABS.SETTINGS]: () => interceptAnonymousUser(() => {}), }; +type NativeTabSelectionParams = { + isAnonymousUser: boolean; + /** Spend's first visit opens the latest search kept in Onyx, which the tab mounted at startup does not show yet. */ + hasSpendBeenSelected: boolean; +}; + /** Whether the native bar switches to the tab itself, or leaves the switch to the tab's press handler. */ -function isNativeTabSelectionEnabled(routeName: string, isAnonymousUser: boolean) { +function isNativeTabSelectionEnabled(routeName: string, {isAnonymousUser, hasSpendBeenSelected}: NativeTabSelectionParams) { + if (routeName === NAVIGATORS.SEARCH_FULLSCREEN_NAVIGATOR && !hasSpendBeenSelected) { + return false; + } const tab = ROUTE_TO_NAVIGATION_TAB[routeName]; - return tab !== NAVIGATION_TABS.INBOX && !(isAnonymousUser && ANONYMOUS_GATED_TABS.has(tab)); + return !JS_SWITCHED_TABS.has(tab) && !(isAnonymousUser && ANONYMOUS_GATED_TABS.has(tab)); } type TabScreenListenerProps = { 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 8ab8500bf4d5..686535daa74e 100644 --- a/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/useNativeTabBarOptions/index.ios.ts +++ b/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/useNativeTabBarOptions/index.ios.ts @@ -32,9 +32,9 @@ const 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, dotColors, tabLabels}: NativeTabBarOptionsParams) { +function useNativeTabBarOptions({shouldShowNativeTabBar, isAccountAvatarShown, dotColors, tabLabels}: NativeTabBarOptionsParams) { const theme = useTheme(); - const avatar = useTabAvatarImage(); + const avatar = useTabAvatarImage(isAccountAvatarShown); const screenOptions: NativeBottomTabNavigationOptions = { headerShown: false, diff --git a/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/useNativeTabBarOptions/index.ts b/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/useNativeTabBarOptions/index.ts index abfeb0175b61..41a9ac389e2e 100644 --- a/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/useNativeTabBarOptions/index.ts +++ b/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/useNativeTabBarOptions/index.ts @@ -30,10 +30,10 @@ const TAB_ICON_LAYOUT: TabIconLayout = { labelFontSize: 0, }; -function useNativeTabBarOptions({shouldShowNativeTabBar, dotColors, tabLabels}: NativeTabBarOptionsParams) { +function useNativeTabBarOptions({shouldShowNativeTabBar, isAccountAvatarShown, dotColors, tabLabels}: NativeTabBarOptionsParams) { const theme = useTheme(); const styles = useThemeStyles(); - const avatar = useTabAvatarImage(); + const avatar = useTabAvatarImage(isAccountAvatarShown); // The tint only reaches the labels, since the icons arrive already recolored (react-native-screens patch 003). const screenOptions: NativeBottomTabNavigationOptions = { diff --git a/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/useNativeTabBarOptions/types.ts b/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/useNativeTabBarOptions/types.ts index 2728ae0e274a..4091ff2f6522 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 account tab has an item in a narrow layout's bar, so its avatar is drawn. */ + isAccountAvatarShown: boolean; dotColors: Record; tabLabels: Record; }; diff --git a/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/useNativeTabNavigator.ts b/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/useNativeTabNavigator.ts index 0d56423ac62a..f99a5108c89a 100644 --- a/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/useNativeTabNavigator.ts +++ b/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/useNativeTabNavigator.ts @@ -27,7 +27,7 @@ import type {NavigationAction, NavigationState, PartialState, Router, TabNavigat import {findFocusedRoute, useNavigation, useNavigationState, useRoute} from '@react-navigation/native'; import {isAnonymousSessionSelector} from '@selectors/Session'; -import {useEffect} from 'react'; +import {useEffect, useState} from 'react'; import getTabWithoutBarItem from './getTabWithoutBarItem'; @@ -68,6 +68,11 @@ function useNativeTabNavigator() { // 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; + const isSpendSelected = activeTabRoute?.name === NAVIGATORS.SEARCH_FULLSCREEN_NAVIGATOR; + const [hasSpendBeenSelected, setHasSpendBeenSelected] = useState(isSpendSelected); + if (isSpendSelected && !hasSpendBeenSelected) { + setHasSpendBeenSelected(true); + } let inboxDotColor: string | undefined; if (chatTabBrickRoad) { @@ -134,7 +139,9 @@ function useNativeTabNavigator() { }, }); - return {shouldShowNativeTabBar, dotColors, tabLabels, tabWithoutBarItem, tabRouterOverride, isAnonymousUser}; + const isAccountAvatarShown = shouldUseNarrowLayout && tabWithoutBarItem !== NAVIGATORS.SETTINGS_SPLIT_NAVIGATOR; + + return {shouldShowNativeTabBar, isAccountAvatarShown, dotColors, tabLabels, tabWithoutBarItem, tabRouterOverride, isAnonymousUser, hasSpendBeenSelected}; } export default useNativeTabNavigator; diff --git a/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/useTabAvatarImage.ts b/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/useTabAvatarImage.ts index 075384c5fed9..0e97954f4866 100644 --- a/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/useTabAvatarImage.ts +++ b/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/useTabAvatarImage.ts @@ -13,23 +13,25 @@ type TabAvatarImage = {uri: string; image: SkImage}; let lastDecodedAvatar: TabAvatarImage | undefined; /** - * The current user's avatar, decoded once per URI for the account tab icon. Until it is decoded, and for avatars that - * are not a remote image, the account glyph stands in. + * The current user's avatar, decoded once per URI for the account tab icon while that icon is shown. Until it is + * decoded, and for avatars that are not a remote image, the account glyph stands in. */ -function useTabAvatarImage(): TabAvatarImage | undefined { +function useTabAvatarImage(isShown: boolean): TabAvatarImage | undefined { const {avatar, accountID} = useCurrentUserPersonalDetails(); const avatarSource = getAvatarURL({avatarSource: avatar, accountID}); const uri = typeof avatarSource === 'string' ? avatarSource : undefined; const [decodedAvatar, setDecodedAvatar] = useState(lastDecodedAvatar); + const decodedUri = decodedAvatar?.uri; useEffect(() => { - if (!uri || decodedAvatar?.uri === uri) { + if (!isShown || !uri || decodedUri === uri) { return; } let isActive = true; Skia.Data.fromURI(uri) .then((data) => { const image = Skia.Image.MakeImageFromEncoded(data); + data.dispose(); if (!image) { return; } @@ -43,9 +45,7 @@ function useTabAvatarImage(): TabAvatarImage | undefined { return () => { isActive = false; }; - // Only a new URI needs decoding; the decoded avatar is compared to skip one that is already decoded. - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [uri]); + }, [isShown, uri, decodedUri]); return decodedAvatar?.uri === uri ? decodedAvatar : undefined; } diff --git a/src/libs/Navigation/AppNavigator/Navigators/TabNavigator.native.tsx b/src/libs/Navigation/AppNavigator/Navigators/TabNavigator.native.tsx index 9a537675168b..e46af455278f 100644 --- a/src/libs/Navigation/AppNavigator/Navigators/TabNavigator.native.tsx +++ b/src/libs/Navigation/AppNavigator/Navigators/TabNavigator.native.tsx @@ -35,11 +35,11 @@ const HIDDEN_TAB_OPTIONS = {tabBarItemHidden: true}; const renderNativeTabLayout = (props: NativeTabLayoutProps) => ; function TabNavigator() { - const {shouldShowNativeTabBar, dotColors, tabLabels, tabWithoutBarItem, tabRouterOverride, isAnonymousUser} = useNativeTabNavigator(); - const {screenOptions, getTabOptions} = useNativeTabBarOptions({shouldShowNativeTabBar, dotColors, tabLabels}); + const {shouldShowNativeTabBar, isAccountAvatarShown, dotColors, tabLabels, tabWithoutBarItem, tabRouterOverride, isAnonymousUser, hasSpendBeenSelected} = useNativeTabNavigator(); + const {screenOptions, getTabOptions} = useNativeTabBarOptions({shouldShowNativeTabBar, isAccountAvatarShown, dotColors, tabLabels}); // A tab with no bar item draws no icon. const getOptions = (name: NativeTabName) => - name === tabWithoutBarItem ? HIDDEN_TAB_OPTIONS : {...getTabOptions(name), tabBarSelectionEnabled: isNativeTabSelectionEnabled(name, isAnonymousUser)}; + name === tabWithoutBarItem ? HIDDEN_TAB_OPTIONS : {...getTabOptions(name), tabBarSelectionEnabled: isNativeTabSelectionEnabled(name, {isAnonymousUser, hasSpendBeenSelected})}; return ( (undefined); - useSearchPageSetup(currentSearchQueryJSON); + // Until Spend is first shown, it neither requests a search nor saves its default query over the latest search that the + // Spend tab reopens. + const hasSpendBeenShown = useHasTabBeenShown(NAVIGATORS.SEARCH_FULLSCREEN_NAVIGATOR); + useSearchPageSetup(hasSpendBeenShown ? currentSearchQueryJSON : undefined); useSeedMyExpensesSearch(); useReleaseOptionListCaches(); diff --git a/src/pages/settings/InitialSettingsPage.tsx b/src/pages/settings/InitialSettingsPage.tsx index 089c655f2050..f4989443a0b0 100755 --- a/src/pages/settings/InitialSettingsPage.tsx +++ b/src/pages/settings/InitialSettingsPage.tsx @@ -13,6 +13,7 @@ import type {WithCurrentUserPersonalDetailsProps} from '@components/withCurrentU 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'; @@ -90,9 +91,13 @@ function InitialSettingsPage({currentUserPersonalDetails}: InitialSettingsPagePr Navigation.clearPreloadedRoutes(); }, [hasAccountBeenSwitched]); + const hasAccountBeenShown = useHasTabBeenShown(NAVIGATORS.SETTINGS_SPLIT_NAVIGATOR); useEffect(() => { + if (!hasAccountBeenShown) { + return; + } openInitialSettingsPage(); - }, []); + }, [hasAccountBeenShown]); const getMenuItemsSection = (menuItemsData: MenuSection, sectionStyle: StyleProp) => { return ( diff --git a/src/styles/theme/types.ts b/src/styles/theme/types.ts index 0c3aab949e1a..6b9da58c6a12 100644 --- a/src/styles/theme/types.ts +++ b/src/styles/theme/types.ts @@ -2,6 +2,7 @@ import type {NavBarButtonStyle} from '@libs/NavBarManager/types'; import type CONST from '@src/CONST'; +import type {ViewStyle} from 'react-native'; import type {ValueOf} from 'type-fest'; import type {ColorScheme, StatusBarStyle} from '..'; @@ -67,7 +68,8 @@ type ThemeColors = { sidebarHover: Color; floatingTabBarSelectedBG: Color; androidTabBarActiveIndicatorBG: Color; - androidTabBarShadow: Color; + /** A CSS box-shadow, not a color. */ + androidTabBarShadow: NonNullable; heading: Color; textLight: Color; textDark: Color; diff --git a/tests/ui/InitialSettingsPageTest.tsx b/tests/ui/InitialSettingsPageTest.tsx index a5cef1a0dfff..a0a0d8959c4e 100644 --- a/tests/ui/InitialSettingsPageTest.tsx +++ b/tests/ui/InitialSettingsPageTest.tsx @@ -67,6 +67,8 @@ jest.mock('@userActions/App', () => ({ jest.mock('@libs/Navigation/helpers/useIsSidebarRouteActive', () => jest.fn(() => false)); +jest.mock('@hooks/useHasTabBeenShown', () => jest.fn(() => true)); + jest.mock('@hooks/useSubscriptionPlan', () => jest.fn(() => null)); jest.mock('@components/AccountSwitcher', () => { diff --git a/tests/unit/nativeTabScreenListenersTest.ts b/tests/unit/nativeTabScreenListenersTest.ts index 964dd46201f7..5a38a08f2a35 100644 --- a/tests/unit/nativeTabScreenListenersTest.ts +++ b/tests/unit/nativeTabScreenListenersTest.ts @@ -2,17 +2,12 @@ import navigateToInboxTab from '@components/Navigation/NavigationTabBar/navigate import interceptAnonymousUser from '@libs/interceptAnonymousUser'; import tabScreenListeners, {isNativeTabSelectionEnabled} from '@libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/tabScreenListeners'; -import {startNavigateToReportsTabSpans} from '@libs/telemetry/startTabNavigationSpans'; import NAVIGATORS from '@src/NAVIGATORS'; import SCREENS from '@src/SCREENS'; jest.mock('@components/Navigation/NavigationTabBar/navigateToInboxTab', () => jest.fn()); jest.mock('@libs/interceptAnonymousUser', () => jest.fn((callback: () => void) => callback())); -jest.mock('@libs/telemetry/startTabNavigationSpans', () => ({ - startNavigateToInboxTabSpan: jest.fn(), - startNavigateToReportsTabSpans: jest.fn(), -})); function pressTab(routeName: string, isFocused: boolean) { tabScreenListeners({route: {name: routeName}, navigation: {isFocused: () => isFocused}}).tabPress(); @@ -32,37 +27,47 @@ describe('tabScreenListeners', () => { expect(navigateToInboxTab).toHaveBeenCalledTimes(1); }); - it('checks for an anonymous user before starting the Spend spans', () => { - // Given the native bar, which never renders SearchTabButton - // When the user presses Spend while another tab is focused - pressTab(NAVIGATORS.SEARCH_FULLSCREEN_NAVIGATOR, false); + it('checks for an anonymous user when Account is pressed from another tab', () => { + // Given the native bar, which never renders the JS Account button that keeps anonymous users out + // When the user presses Account while another tab is focused + pressTab(NAVIGATORS.SETTINGS_SPLIT_NAVIGATOR, false); - // Then the spans start behind the same anonymous user check the JS tab button uses - expect(interceptAnonymousUser).toHaveBeenCalledWith(startNavigateToReportsTabSpans); - expect(startNavigateToReportsTabSpans).toHaveBeenCalledTimes(1); + // Then the same anonymous user check runs + expect(interceptAnonymousUser).toHaveBeenCalledTimes(1); }); it('does nothing when the already focused tab is pressed again', () => { // Given native tabs also emit tabPress for a tap on the focused tab - // When the user presses Inbox and Spend while each is already focused + // When the user presses Inbox and Account while each is already focused pressTab(NAVIGATORS.REPORTS_SPLIT_NAVIGATOR, true); - pressTab(NAVIGATORS.SEARCH_FULLSCREEN_NAVIGATOR, true); + pressTab(NAVIGATORS.SETTINGS_SPLIT_NAVIGATOR, true); // Then nothing runs, the same as a JS tab button pressed on its own tab expect(navigateToInboxTab).not.toHaveBeenCalled(); expect(interceptAnonymousUser).not.toHaveBeenCalled(); }); - it('leaves the switch to JS only for Inbox and, for anonymous users, the tabs they are kept from', () => { - // Given the tabs whose press has to run JS before any switch - // When the native selection is decided for a signed-in and for an anonymous user - // Then Inbox is always switched by JS, and Spend, Insights and Account only for an anonymous user - expect(isNativeTabSelectionEnabled(NAVIGATORS.REPORTS_SPLIT_NAVIGATOR, false)).toBe(false); - expect(isNativeTabSelectionEnabled(NAVIGATORS.SEARCH_FULLSCREEN_NAVIGATOR, false)).toBe(true); - expect(isNativeTabSelectionEnabled(NAVIGATORS.SEARCH_FULLSCREEN_NAVIGATOR, true)).toBe(false); - expect(isNativeTabSelectionEnabled(SCREENS.INSIGHTS, true)).toBe(false); - expect(isNativeTabSelectionEnabled(NAVIGATORS.SETTINGS_SPLIT_NAVIGATOR, true)).toBe(false); - expect(isNativeTabSelectionEnabled(SCREENS.HOME, true)).toBe(true); - expect(isNativeTabSelectionEnabled(NAVIGATORS.WORKSPACE_NAVIGATOR, true)).toBe(true); + it('leaves the switch to JS for Inbox and Workspaces and, for anonymous users, the tabs they are kept from', () => { + // Given a signed-in and an anonymous user who have both already been to Spend + const signedIn = {isAnonymousUser: false, hasSpendBeenSelected: true}; + const anonymous = {isAnonymousUser: true, hasSpendBeenSelected: true}; + + // When the native selection is decided for each tab + // Then Inbox and Workspaces are always switched by JS, and Spend, Insights and Account only for an anonymous user + expect(isNativeTabSelectionEnabled(NAVIGATORS.REPORTS_SPLIT_NAVIGATOR, signedIn)).toBe(false); + expect(isNativeTabSelectionEnabled(NAVIGATORS.WORKSPACE_NAVIGATOR, signedIn)).toBe(false); + expect(isNativeTabSelectionEnabled(NAVIGATORS.SEARCH_FULLSCREEN_NAVIGATOR, signedIn)).toBe(true); + expect(isNativeTabSelectionEnabled(NAVIGATORS.SEARCH_FULLSCREEN_NAVIGATOR, anonymous)).toBe(false); + expect(isNativeTabSelectionEnabled(SCREENS.INSIGHTS, anonymous)).toBe(false); + expect(isNativeTabSelectionEnabled(NAVIGATORS.SETTINGS_SPLIT_NAVIGATOR, anonymous)).toBe(false); + expect(isNativeTabSelectionEnabled(SCREENS.HOME, anonymous)).toBe(true); + expect(isNativeTabSelectionEnabled(NAVIGATORS.WORKSPACE_NAVIGATOR, anonymous)).toBe(false); + }); + + it('leaves the first switch to Spend to JS', () => { + // Given a signed-in user who has not been to Spend yet, whose tab was mounted at startup with the default search + // When the native selection is decided for Spend + // Then JS switches to it, so the first visit opens the latest search kept in Onyx as the JS tab button does + expect(isNativeTabSelectionEnabled(NAVIGATORS.SEARCH_FULLSCREEN_NAVIGATOR, {isAnonymousUser: false, hasSpendBeenSelected: false})).toBe(false); }); }); diff --git a/tests/unit/useHasTabBeenShownTest.ts b/tests/unit/useHasTabBeenShownTest.ts new file mode 100644 index 000000000000..8317bb3d88e4 --- /dev/null +++ b/tests/unit/useHasTabBeenShownTest.ts @@ -0,0 +1,83 @@ +import {renderHook} from '@testing-library/react-native'; + +import useHasTabBeenShown from '@hooks/useHasTabBeenShown'; +import useRootNavigationState from '@hooks/useRootNavigationState'; + +import NAVIGATORS from '@src/NAVIGATORS'; +import SCREENS from '@src/SCREENS'; + +import type {NavigationState} from '@react-navigation/native'; + +jest.mock('@hooks/useRootNavigationState', () => jest.fn()); + +const mockedUseRootNavigationState = jest.mocked(useRootNavigationState); + +let rootState: NavigationState | undefined; + +function buildRootState(selectedTab: string): NavigationState { + const tabRoutes = [SCREENS.HOME, NAVIGATORS.SEARCH_FULLSCREEN_NAVIGATOR].map((name) => ({key: `${name}-key`, name})); + return { + stale: false, + type: 'stack', + key: 'root-key', + index: 0, + routeNames: [NAVIGATORS.TAB_NAVIGATOR], + routes: [ + { + key: 'tab-navigator-key', + name: NAVIGATORS.TAB_NAVIGATOR, + state: { + stale: false, + type: 'tab', + key: 'tab-key', + index: tabRoutes.findIndex((route) => route.name === selectedTab), + routeNames: tabRoutes.map((route) => route.name), + routes: tabRoutes, + }, + }, + ], + } as NavigationState; +} + +describe('useHasTabBeenShown', () => { + beforeEach(() => { + mockedUseRootNavigationState.mockImplementation((selector) => selector(rootState)); + }); + + it('stays false while the tab is mounted in the background', () => { + // Given native tabs that mounted Spend at startup while Home is selected + rootState = buildRootState(SCREENS.HOME); + + // When Spend's page reads whether it has been shown + const {result} = renderHook(() => useHasTabBeenShown(NAVIGATORS.SEARCH_FULLSCREEN_NAVIGATOR)); + + // Then it has not, so the page holds back what it requests on mount + expect(result.current).toBe(false); + }); + + it('stays true once the tab has been selected, after the user leaves it', () => { + // Given Spend mounted in the background + rootState = buildRootState(SCREENS.HOME); + const {result, rerender} = renderHook(() => useHasTabBeenShown(NAVIGATORS.SEARCH_FULLSCREEN_NAVIGATOR)); + + // When the user selects Spend and then goes back to Home + rootState = buildRootState(NAVIGATORS.SEARCH_FULLSCREEN_NAVIGATOR); + rerender({}); + rootState = buildRootState(SCREENS.HOME); + rerender({}); + + // Then Spend counts as shown, so its page keeps reacting to its query like a page mounted with the tab + expect(result.current).toBe(true); + }); + + it('is true before navigation is ready', () => { + // Given no navigation state yet, as when the page mounts with the tab on web + rootState = undefined; + + // When the page reads whether its tab has been shown + const {result} = renderHook(() => useHasTabBeenShown(NAVIGATORS.SETTINGS_SPLIT_NAVIGATOR)); + + // Then it counts as shown, so nothing is held back when the tab is not a background one + expect(result.current).toBe(true); + }); +}); From 61a832a1b6def0c71ba4b04b34a11062378fd5d0 Mon Sep 17 00:00:00 2001 From: sumo_slonik Date: Tue, 6 Oct 2026 17:04:11 +0200 Subject: [PATCH 63/71] fix: mock native tab icons under the Skia jest mock, platform hook types as type defaults, inline Android bar height --- jest/setup.ts | 4 ++++ .../useTabRootScreenWrapperProps/index.android.tsx | 2 +- .../useTabRootScreenWrapperProps/index.ios.tsx | 2 +- .../useTabRootScreenWrapperProps/index.tsx | 2 +- .../useTabRootScreenWrapperProps/types.ts | 3 +-- .../useTabRootScrollProps/index.native.tsx | 2 +- .../TabBarBottomContent/useTabRootScrollProps/index.tsx | 2 +- .../TabBarBottomContent/useTabRootScrollProps/types.ts | 3 +-- src/styles/variables.ts | 7 +++---- 9 files changed, 14 insertions(+), 13 deletions(-) diff --git a/jest/setup.ts b/jest/setup.ts index 9afaa8f219a1..951ef6577055 100644 --- a/jest/setup.ts +++ b/jest/setup.ts @@ -355,6 +355,10 @@ jest.mock('@shopify/react-native-skia', () => ({ listFontFamilies: jest.fn(() => []), })); +// The native tab bar icons are drawn with Skia's imperative API, which the mock above leaves out, so the tabs get no icons. +jest.mock('@libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/tabIconRasterizer', () => jest.fn(() => undefined)); +jest.mock('@libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/useTabAvatarImage', () => jest.fn(() => undefined)); + jest.mock('@sbaiahmed1/react-native-biometrics', () => ({ isSensorAvailable: jest.fn(() => Promise.resolve({available: false})), createKeys: jest.fn(() => Promise.resolve({publicKey: ''})), diff --git a/src/components/Navigation/TabBarBottomContent/useTabRootScreenWrapperProps/index.android.tsx b/src/components/Navigation/TabBarBottomContent/useTabRootScreenWrapperProps/index.android.tsx index 888cb620fc4d..eec0c28909ad 100644 --- a/src/components/Navigation/TabBarBottomContent/useTabRootScreenWrapperProps/index.android.tsx +++ b/src/components/Navigation/TabBarBottomContent/useTabRootScreenWrapperProps/index.android.tsx @@ -7,7 +7,7 @@ import useThemeStyles from '@hooks/useThemeStyles'; import React from 'react'; import {View} from 'react-native'; -import type {UseTabRootScreenWrapperProps} from './types'; +import type UseTabRootScreenWrapperProps 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 diff --git a/src/components/Navigation/TabBarBottomContent/useTabRootScreenWrapperProps/index.ios.tsx b/src/components/Navigation/TabBarBottomContent/useTabRootScreenWrapperProps/index.ios.tsx index 300d65a857ce..daf68dba45ab 100644 --- a/src/components/Navigation/TabBarBottomContent/useTabRootScreenWrapperProps/index.ios.tsx +++ b/src/components/Navigation/TabBarBottomContent/useTabRootScreenWrapperProps/index.ios.tsx @@ -1,6 +1,6 @@ import useResponsiveLayout from '@hooks/useResponsiveLayout'; -import type {UseTabRootScreenWrapperProps} from './types'; +import type UseTabRootScreenWrapperProps from './types'; /** * On narrow layouts the content of a tab root screen runs under the translucent UITabBar, down to the bottom edge of diff --git a/src/components/Navigation/TabBarBottomContent/useTabRootScreenWrapperProps/index.tsx b/src/components/Navigation/TabBarBottomContent/useTabRootScreenWrapperProps/index.tsx index ecf13966f9f0..03efdffbd947 100644 --- a/src/components/Navigation/TabBarBottomContent/useTabRootScreenWrapperProps/index.tsx +++ b/src/components/Navigation/TabBarBottomContent/useTabRootScreenWrapperProps/index.tsx @@ -3,7 +3,7 @@ import useThemeStyles from '@hooks/useThemeStyles'; import React from 'react'; -import type {UseTabRootScreenWrapperProps} from './types'; +import type UseTabRootScreenWrapperProps from './types'; import TabBarBottomContent from '..'; diff --git a/src/components/Navigation/TabBarBottomContent/useTabRootScreenWrapperProps/types.ts b/src/components/Navigation/TabBarBottomContent/useTabRootScreenWrapperProps/types.ts index 0a371761226f..f9c3518d6824 100644 --- a/src/components/Navigation/TabBarBottomContent/useTabRootScreenWrapperProps/types.ts +++ b/src/components/Navigation/TabBarBottomContent/useTabRootScreenWrapperProps/types.ts @@ -5,5 +5,4 @@ type TabRootScreenWrapperProps = Pick TabRootScreenWrapperProps; -export default TabRootScreenWrapperProps; -export type {UseTabRootScreenWrapperProps}; +export default UseTabRootScreenWrapperProps; diff --git a/src/components/Navigation/TabBarBottomContent/useTabRootScrollProps/index.native.tsx b/src/components/Navigation/TabBarBottomContent/useTabRootScrollProps/index.native.tsx index 4217cc7534fe..563fea8b3b56 100644 --- a/src/components/Navigation/TabBarBottomContent/useTabRootScrollProps/index.native.tsx +++ b/src/components/Navigation/TabBarBottomContent/useTabRootScrollProps/index.native.tsx @@ -1,4 +1,4 @@ -import type {UseTabRootScrollProps} from './types'; +import type UseTabRootScrollProps from './types'; /** * UIKit insets the end of the list past the translucent iOS tab bar that the content runs under, and Android reserves diff --git a/src/components/Navigation/TabBarBottomContent/useTabRootScrollProps/index.tsx b/src/components/Navigation/TabBarBottomContent/useTabRootScrollProps/index.tsx index 109b00972f09..c7db6bceef17 100644 --- a/src/components/Navigation/TabBarBottomContent/useTabRootScrollProps/index.tsx +++ b/src/components/Navigation/TabBarBottomContent/useTabRootScrollProps/index.tsx @@ -3,7 +3,7 @@ import useResponsiveLayout from '@hooks/useResponsiveLayout'; import variables from '@styles/variables'; -import type {UseTabRootScrollProps} from './types'; +import type UseTabRootScrollProps from './types'; /** * The content of a narrow tab root screen runs under the floating tab bar, so a list's content container ends with diff --git a/src/components/Navigation/TabBarBottomContent/useTabRootScrollProps/types.ts b/src/components/Navigation/TabBarBottomContent/useTabRootScrollProps/types.ts index 460d2c42ce86..790b2f40efe7 100644 --- a/src/components/Navigation/TabBarBottomContent/useTabRootScrollProps/types.ts +++ b/src/components/Navigation/TabBarBottomContent/useTabRootScrollProps/types.ts @@ -5,5 +5,4 @@ type TabRootScrollProps = Pick, hasBottomSafeAreaPadding?: boolean) => TabRootScrollProps; -export default TabRootScrollProps; -export type {UseTabRootScrollProps}; +export default UseTabRootScrollProps; diff --git a/src/styles/variables.ts b/src/styles/variables.ts index 0183243a8db6..a86fce69cfcd 100644 --- a/src/styles/variables.ts +++ b/src/styles/variables.ts @@ -31,7 +31,6 @@ const avatarSizes = { const floatingTabBarHeight = 60; const floatingTabBarBottomInset = 8; -const androidNativeTabBarHeight = 80; export default { bottomTabHeight: 72, @@ -123,9 +122,9 @@ export default { /** Gap between the glyph and the label drawn under it in an iOS native tab bar icon. */ nativeTabIconLabelGap: 6, /** Material's BottomNavigationView row, without the gesture inset under it. */ - androidNativeTabBarHeight, - /** Material's bar row plus the 16 dp Material keeps between a FAB and the bar. */ - androidNativeTabBarFloatingButtonsBottom: androidNativeTabBarHeight + 16, + androidNativeTabBarHeight: 80, + /** Material's bar row (androidNativeTabBarHeight) plus the 16 dp Material keeps between a FAB and the bar. */ + androidNativeTabBarFloatingButtonsBottom: 96, /** UITabBar's row above the home indicator inset, plus the gap to the floating buttons. */ iosNativeTabBarFloatingButtonsBottom: 50, iconHeader: 48, From 412eefe971662f8f2d36087c1235a032175c48e5 Mon Sep 17 00:00:00 2001 From: sumo_slonik Date: Tue, 6 Oct 2026 17:21:24 +0200 Subject: [PATCH 64/71] refactor: one listener for the native Spend and Workspaces taps, platform hook typing reverted, tests cut to one per fix --- .../index.android.tsx | 7 +- .../index.ios.tsx | 6 +- .../useTabRootScreenWrapperProps/index.tsx | 8 +- .../useTabRootScreenWrapperProps/types.ts | 5 +- .../useTabRootScrollProps/index.native.tsx | 8 +- .../useTabRootScrollProps/index.tsx | 11 ++- .../useTabRootScrollProps/types.ts | 7 +- .../NativeTabNavigator/NativeTabLayout.tsx | 27 ++----- .../SpendTabPressListener.tsx | 53 -------------- .../NativeTabNavigator/TabPressListeners.tsx | 58 +++++++++++++++ .../WorkspacesTabPressListener.tsx | 25 ------- .../NativeTabNavigator/tabScreenListeners.ts | 2 +- tests/unit/SplitRouterTest.ts | 73 ++++--------------- tests/unit/nativeTabScreenListenersTest.ts | 60 ++++----------- tests/unit/useHasTabBeenShownTest.ts | 70 +++--------------- 15 files changed, 132 insertions(+), 288 deletions(-) delete mode 100644 src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/SpendTabPressListener.tsx create mode 100644 src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/TabPressListeners.tsx delete mode 100644 src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/WorkspacesTabPressListener.tsx diff --git a/src/components/Navigation/TabBarBottomContent/useTabRootScreenWrapperProps/index.android.tsx b/src/components/Navigation/TabBarBottomContent/useTabRootScreenWrapperProps/index.android.tsx index eec0c28909ad..0ecf905ca0d3 100644 --- a/src/components/Navigation/TabBarBottomContent/useTabRootScreenWrapperProps/index.android.tsx +++ b/src/components/Navigation/TabBarBottomContent/useTabRootScreenWrapperProps/index.android.tsx @@ -1,4 +1,5 @@ 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'; @@ -7,13 +8,13 @@ import useThemeStyles from '@hooks/useThemeStyles'; import React from 'react'; import {View} from 'react-native'; -import type UseTabRootScreenWrapperProps from './types'; +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. */ -const useTabRootScreenWrapperProps: UseTabRootScreenWrapperProps = (selectedTab) => { +function useTabRootScreenWrapperProps(selectedTab: TabBarBottomContentProps['selectedTab']): TabRootScreenWrapperProps { const {shouldUseNarrowLayout} = useResponsiveLayout(); const isSettingsDrawnOverTabs = useIsSettingsDrawnOverTabs(); const styles = useThemeStyles(); @@ -23,6 +24,6 @@ const useTabRootScreenWrapperProps: UseTabRootScreenWrapperProps = (selectedTab) } return {bottomContent: }; -}; +} export default useTabRootScreenWrapperProps; diff --git a/src/components/Navigation/TabBarBottomContent/useTabRootScreenWrapperProps/index.ios.tsx b/src/components/Navigation/TabBarBottomContent/useTabRootScreenWrapperProps/index.ios.tsx index daf68dba45ab..c7d86f32d2c4 100644 --- a/src/components/Navigation/TabBarBottomContent/useTabRootScreenWrapperProps/index.ios.tsx +++ b/src/components/Navigation/TabBarBottomContent/useTabRootScreenWrapperProps/index.ios.tsx @@ -1,14 +1,14 @@ import useResponsiveLayout from '@hooks/useResponsiveLayout'; -import type UseTabRootScreenWrapperProps from './types'; +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 adds no bottom padding. Each tab root's list lets UIKit inset its end past the bar. */ -const useTabRootScreenWrapperProps: UseTabRootScreenWrapperProps = () => { +function useTabRootScreenWrapperProps(): TabRootScreenWrapperProps { const {shouldUseNarrowLayout} = useResponsiveLayout(); return shouldUseNarrowLayout ? {includeSafeAreaPaddingBottom: false} : {}; -}; +} export default useTabRootScreenWrapperProps; diff --git a/src/components/Navigation/TabBarBottomContent/useTabRootScreenWrapperProps/index.tsx b/src/components/Navigation/TabBarBottomContent/useTabRootScreenWrapperProps/index.tsx index 03efdffbd947..5621ca35e2fd 100644 --- a/src/components/Navigation/TabBarBottomContent/useTabRootScreenWrapperProps/index.tsx +++ b/src/components/Navigation/TabBarBottomContent/useTabRootScreenWrapperProps/index.tsx @@ -1,9 +1,11 @@ +import type TabBarBottomContentProps from '@components/Navigation/TabBarBottomContent/types'; + import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useThemeStyles from '@hooks/useThemeStyles'; import React from 'react'; -import type UseTabRootScreenWrapperProps from './types'; +import type TabRootScreenWrapperProps from './types'; import TabBarBottomContent from '..'; @@ -11,7 +13,7 @@ import TabBarBottomContent from '..'; * On narrow layouts the floating tab bar is laid over the bottom of a tab root screen, so the content runs under it * down to the bottom edge of the screen. Each tab root's list ends with room for the bar. */ -const useTabRootScreenWrapperProps: UseTabRootScreenWrapperProps = (selectedTab) => { +function useTabRootScreenWrapperProps(selectedTab: TabBarBottomContentProps['selectedTab']): TabRootScreenWrapperProps { const {shouldUseNarrowLayout} = useResponsiveLayout(); const styles = useThemeStyles(); @@ -19,6 +21,6 @@ const useTabRootScreenWrapperProps: UseTabRootScreenWrapperProps = (selectedTab) bottomContent: , bottomContentStyle: shouldUseNarrowLayout ? styles.floatingTabBarOverlay : styles.overflowVisible, }; -}; +} export default useTabRootScreenWrapperProps; diff --git a/src/components/Navigation/TabBarBottomContent/useTabRootScreenWrapperProps/types.ts b/src/components/Navigation/TabBarBottomContent/useTabRootScreenWrapperProps/types.ts index f9c3518d6824..dab83f0477d5 100644 --- a/src/components/Navigation/TabBarBottomContent/useTabRootScreenWrapperProps/types.ts +++ b/src/components/Navigation/TabBarBottomContent/useTabRootScreenWrapperProps/types.ts @@ -1,8 +1,5 @@ -import type TabBarBottomContentProps from '@components/Navigation/TabBarBottomContent/types'; import type {ScreenWrapperContainerProps} from '@components/ScreenWrapper/ScreenWrapperContainer'; type TabRootScreenWrapperProps = Pick; -type UseTabRootScreenWrapperProps = (selectedTab: TabBarBottomContentProps['selectedTab']) => TabRootScreenWrapperProps; - -export default UseTabRootScreenWrapperProps; +export default TabRootScreenWrapperProps; diff --git a/src/components/Navigation/TabBarBottomContent/useTabRootScrollProps/index.native.tsx b/src/components/Navigation/TabBarBottomContent/useTabRootScrollProps/index.native.tsx index 563fea8b3b56..65aaa5912a7a 100644 --- a/src/components/Navigation/TabBarBottomContent/useTabRootScrollProps/index.native.tsx +++ b/src/components/Navigation/TabBarBottomContent/useTabRootScrollProps/index.native.tsx @@ -1,9 +1,13 @@ -import type UseTabRootScrollProps from './types'; +import type {StyleProp, ViewStyle} from 'react-native'; + +import type TabRootScrollProps from './types'; /** * UIKit insets the end of the list past the translucent iOS tab bar that the content runs under, and Android reserves * the bar's row below the content, so the content container keeps its style. */ -const useTabRootScrollProps: UseTabRootScrollProps = (style) => ({contentContainerStyle: style, contentInsetAdjustmentBehavior: 'automatic'}); +function useTabRootScrollProps(style?: StyleProp): TabRootScrollProps { + return {contentContainerStyle: style, contentInsetAdjustmentBehavior: 'automatic'}; +} export default useTabRootScrollProps; diff --git a/src/components/Navigation/TabBarBottomContent/useTabRootScrollProps/index.tsx b/src/components/Navigation/TabBarBottomContent/useTabRootScrollProps/index.tsx index c7db6bceef17..c75a5c774fc8 100644 --- a/src/components/Navigation/TabBarBottomContent/useTabRootScrollProps/index.tsx +++ b/src/components/Navigation/TabBarBottomContent/useTabRootScrollProps/index.tsx @@ -3,13 +3,16 @@ import useResponsiveLayout from '@hooks/useResponsiveLayout'; import variables from '@styles/variables'; -import type UseTabRootScrollProps from './types'; +import type {StyleProp, ViewStyle} from 'react-native'; + +import type TabRootScrollProps from './types'; /** * The content of a narrow tab root screen runs under the floating tab bar, so a list's content container ends with - * room for the bar, letting its last row scroll out from under it. + * room for the bar, letting its last row scroll out from under it. A list that already adds the bottom safe area + * passes `hasBottomSafeAreaPadding`, so the safe area is not added twice. */ -const useTabRootScrollProps: UseTabRootScrollProps = (style, hasBottomSafeAreaPadding = false) => { +function useTabRootScrollProps(style?: StyleProp, hasBottomSafeAreaPadding = false): TabRootScrollProps { const {shouldUseNarrowLayout} = useResponsiveLayout(); const insetStyle = useBottomSafeSafeAreaPaddingStyle({ style, @@ -19,6 +22,6 @@ const useTabRootScrollProps: UseTabRootScrollProps = (style, hasBottomSafeAreaPa }); return {contentContainerStyle: shouldUseNarrowLayout ? insetStyle : style}; -}; +} export default useTabRootScrollProps; diff --git a/src/components/Navigation/TabBarBottomContent/useTabRootScrollProps/types.ts b/src/components/Navigation/TabBarBottomContent/useTabRootScrollProps/types.ts index 790b2f40efe7..61dc387fad90 100644 --- a/src/components/Navigation/TabBarBottomContent/useTabRootScrollProps/types.ts +++ b/src/components/Navigation/TabBarBottomContent/useTabRootScrollProps/types.ts @@ -1,8 +1,5 @@ -import type {ScrollViewProps, StyleProp, ViewStyle} from 'react-native'; +import type {ScrollViewProps} from 'react-native'; type TabRootScrollProps = Pick; -/** A list that already adds the bottom safe area passes `hasBottomSafeAreaPadding`, so the safe area is not added twice. */ -type UseTabRootScrollProps = (style?: StyleProp, hasBottomSafeAreaPadding?: boolean) => TabRootScrollProps; - -export default UseTabRootScrollProps; +export default TabRootScrollProps; diff --git a/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/NativeTabLayout.tsx b/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/NativeTabLayout.tsx index f60d00a097b1..8dc42d5dfe64 100644 --- a/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/NativeTabLayout.tsx +++ b/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/NativeTabLayout.tsx @@ -14,7 +14,6 @@ import TabNavigatorBar from '@libs/Navigation/AppNavigator/Navigators/TabNavigat import NavigationTabBarFloatingActionButton from '@pages/inbox/sidebar/NavigationTabBarFloatingActionButton'; import CONST from '@src/CONST'; -import NAVIGATORS from '@src/NAVIGATORS'; import ONYXKEYS from '@src/ONYXKEYS'; import SCREENS from '@src/SCREENS'; @@ -25,15 +24,11 @@ import {StyleSheet, View} from 'react-native'; import Animated, {FadeIn, FadeOut} from 'react-native-reanimated'; import NativeTabBarShadow from './NativeTabBarShadow'; -import SpendTabPressListener from './SpendTabPressListener'; +import TabPressListeners from './TabPressListeners'; import {getFloatingButtonsBottom} from './useNativeTabBarOptions'; -import WorkspacesTabPressListener from './WorkspacesTabPressListener'; type NativeTabLayoutProps = Parameters>[0]; -/** A tab's own navigation, which receives the tab's `tabPress` events. */ -type NativeTabNavigation = NativeTabLayoutProps['descriptors'][string]['navigation']; - function NativeTabLayout({children, state, descriptors}: NativeTabLayoutProps) { const {shouldUseNarrowLayout} = useResponsiveLayout(); const [isDebugModeEnabled] = useOnyx(ONYXKEYS.IS_DEBUG_MODE_ENABLED); @@ -43,13 +38,6 @@ function NativeTabLayout({children, state, descriptors}: NativeTabLayoutProps) { const selectedTab = ROUTE_TO_NAVIGATION_TAB[activeRoute?.name ?? SCREENS.HOME] ?? NAVIGATION_TABS.HOME; // The buttons follow whatever the navigator decided for the bar itself. const shouldShowNativeTabBar = shouldUseNarrowLayout && !!activeRoute && descriptors[activeRoute.key]?.options.tabBarStyle?.display !== 'none'; - const getTabDescriptor = (routeName: string) => { - const route = state.routes.find((tabRoute) => tabRoute.name === routeName); - return route ? descriptors[route.key] : undefined; - }; - const spendNavigation = getTabDescriptor(NAVIGATORS.SEARCH_FULLSCREEN_NAVIGATOR)?.navigation; - const spendOptions = getTabDescriptor(NAVIGATORS.SEARCH_FULLSCREEN_NAVIGATOR)?.options; - const workspacesNavigation = getTabDescriptor(NAVIGATORS.WORKSPACE_NAVIGATOR)?.navigation; if (!shouldUseNarrowLayout) { return ( @@ -63,13 +51,10 @@ function NativeTabLayout({children, state, descriptors}: NativeTabLayoutProps) { return ( {children} - {!!spendNavigation && ( - - )} - {!!workspacesNavigation && } + {!!isDebugModeEnabled && shouldShowNativeTabBar && } {shouldShowNativeTabBar && ( // The shadow and the buttons belong to the bar, so they fade with it rather than appearing in place. @@ -98,4 +83,4 @@ function NativeTabLayout({children, state, descriptors}: NativeTabLayoutProps) { } export default NativeTabLayout; -export type {NativeTabLayoutProps, NativeTabNavigation}; +export type {NativeTabLayoutProps}; diff --git a/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/SpendTabPressListener.tsx b/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/SpendTabPressListener.tsx deleted file mode 100644 index 9b540106dfc1..000000000000 --- a/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/SpendTabPressListener.tsx +++ /dev/null @@ -1,53 +0,0 @@ -import {getStoredSearchTabRoute} from '@components/Navigation/NavigationTabBar/getSearchTabRoute'; - -import useOnyx from '@hooks/useOnyx'; - -import interceptAnonymousUser from '@libs/interceptAnonymousUser'; -import Navigation from '@libs/Navigation/Navigation'; -import {startNavigateToReportsTabSpans} from '@libs/telemetry/startTabNavigationSpans'; - -import ONYXKEYS from '@src/ONYXKEYS'; -import {lastExpensesSearchQuerySelector} from '@src/selectors/SearchFilters'; - -import {useEffect} from 'react'; - -import type {NativeTabNavigation} from './NativeTabLayout'; - -type SpendTabPressListenerProps = { - /** The Spend tab's own navigation. */ - navigation: NativeTabNavigation; - - /** Whether the native bar leaves the switch to Spend to this listener, until Spend is first selected. */ - isSwitchedByJS: boolean; -}; - -/** - * Runs the Spend tab button's navigation for a tap on the native Spend tab. The first visit opens the latest search kept - * in Onyx, as the JS tab button does, because the tab mounted at startup shows the default one; later visits are switched - * natively and only start the spans. It is its own component, so the search data it reads re-renders only this listener. - */ -function SpendTabPressListener({navigation, isSwitchedByJS}: SpendTabPressListenerProps) { - const [lastSearchParams] = useOnyx(ONYXKEYS.REPORT_NAVIGATION_LAST_SEARCH_QUERY); - const [lastExpensesSearchQuery] = useOnyx(ONYXKEYS.SEARCH_FILTERS, {selector: lastExpensesSearchQuerySelector}); - - useEffect( - () => - navigation.addListener('tabPress', () => { - if (navigation.isFocused()) { - return; - } - interceptAnonymousUser(() => { - startNavigateToReportsTabSpans(); - if (!isSwitchedByJS) { - return; - } - Navigation.navigate(getStoredSearchTabRoute(lastSearchParams, lastExpensesSearchQuery)); - }); - }), - [navigation, isSwitchedByJS, lastSearchParams, lastExpensesSearchQuery], - ); - - return null; -} - -export default SpendTabPressListener; diff --git a/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/TabPressListeners.tsx b/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/TabPressListeners.tsx new file mode 100644 index 000000000000..50fa3f285e8d --- /dev/null +++ b/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/TabPressListeners.tsx @@ -0,0 +1,58 @@ +import {getStoredSearchTabRoute} from '@components/Navigation/NavigationTabBar/getSearchTabRoute'; + +import useOnyx from '@hooks/useOnyx'; +import useRestoreWorkspacesTabOnNavigate from '@hooks/useRestoreWorkspacesTabOnNavigate'; + +import interceptAnonymousUser from '@libs/interceptAnonymousUser'; +import Navigation from '@libs/Navigation/Navigation'; +import {startNavigateToReportsTabSpans} from '@libs/telemetry/startTabNavigationSpans'; + +import NAVIGATORS from '@src/NAVIGATORS'; +import ONYXKEYS from '@src/ONYXKEYS'; +import {lastExpensesSearchQuerySelector} from '@src/selectors/SearchFilters'; + +import {useEffect} from 'react'; + +import type {NativeTabLayoutProps} from './NativeTabLayout'; + +/** + * The native bar's taps on Spend and Workspaces, which run the JS tab buttons' navigation and need Onyx data, so the + * data re-renders only this component. Spend's first visit opens the latest search kept in Onyx, because the tab + * mounted at startup shows the default one. Workspaces restores the last workspace, or the list for one that is gone. + */ +function TabPressListeners({state, descriptors}: Pick) { + const [lastSearchParams] = useOnyx(ONYXKEYS.REPORT_NAVIGATION_LAST_SEARCH_QUERY); + const [lastExpensesSearchQuery] = useOnyx(ONYXKEYS.SEARCH_FILTERS, {selector: lastExpensesSearchQuerySelector}); + const navigateToWorkspaces = useRestoreWorkspacesTabOnNavigate(); + const getDescriptor = (routeName: string) => { + const route = state.routes.find((tabRoute) => tabRoute.name === routeName); + return route ? descriptors[route.key] : undefined; + }; + const spendDescriptor = getDescriptor(NAVIGATORS.SEARCH_FULLSCREEN_NAVIGATOR); + const spendNavigation = spendDescriptor?.navigation; + const isSpendSwitchedByJS = spendDescriptor?.options.tabBarSelectionEnabled === false; + const workspacesNavigation = getDescriptor(NAVIGATORS.WORKSPACE_NAVIGATOR)?.navigation; + + useEffect( + () => + spendNavigation?.addListener('tabPress', () => { + if (spendNavigation.isFocused()) { + return; + } + interceptAnonymousUser(() => { + startNavigateToReportsTabSpans(); + if (isSpendSwitchedByJS) { + Navigation.navigate(getStoredSearchTabRoute(lastSearchParams, lastExpensesSearchQuery)); + } + }); + }), + [spendNavigation, isSpendSwitchedByJS, lastSearchParams, lastExpensesSearchQuery], + ); + + // Runs on the focused tab too, where it returns from a workspace to the list. + useEffect(() => workspacesNavigation?.addListener('tabPress', navigateToWorkspaces), [workspacesNavigation, navigateToWorkspaces]); + + return null; +} + +export default TabPressListeners; diff --git a/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/WorkspacesTabPressListener.tsx b/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/WorkspacesTabPressListener.tsx deleted file mode 100644 index b78762fadfb4..000000000000 --- a/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/WorkspacesTabPressListener.tsx +++ /dev/null @@ -1,25 +0,0 @@ -import useRestoreWorkspacesTabOnNavigate from '@hooks/useRestoreWorkspacesTabOnNavigate'; - -import {useEffect} from 'react'; - -import type {NativeTabNavigation} from './NativeTabLayout'; - -type WorkspacesTabPressListenerProps = { - /** The Workspaces tab's own navigation. */ - navigation: NativeTabNavigation; -}; - -/** - * Runs the Workspaces tab button's navigation for a tap on the native Workspaces tab, focused or not: it restores the - * last workspace or domain, falls back to the list for one that is gone, and keeps anonymous users out. It is its own - * component, so the policy and domain collections it reads re-render only this listener. - */ -function WorkspacesTabPressListener({navigation}: WorkspacesTabPressListenerProps) { - const navigateToWorkspaces = useRestoreWorkspacesTabOnNavigate(); - - useEffect(() => navigation.addListener('tabPress', navigateToWorkspaces), [navigation, navigateToWorkspaces]); - - return null; -} - -export default WorkspacesTabPressListener; diff --git a/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/tabScreenListeners.ts b/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/tabScreenListeners.ts index 3cc992d09a9d..07534edf8143 100644 --- a/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/tabScreenListeners.ts +++ b/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/tabScreenListeners.ts @@ -21,7 +21,7 @@ const JS_SWITCHED_TABS = new Set([NAVIGATION_TABS.INBOX, NAVIGATI /** * What a tap on the native bar does on top of the tab switch, mirroring the JS tab buttons the native bar replaces. - * Spend and Workspaces are handled by SpendTabPressListener and WorkspacesTabPressListener, which need Onyx data. + * Spend and Workspaces are handled by TabPressListeners, which need Onyx data. */ const NAVIGATION_TAB_PRESS_HANDLERS: Partial void>> = { [NAVIGATION_TABS.INBOX]: navigateToInboxTab, diff --git a/tests/unit/SplitRouterTest.ts b/tests/unit/SplitRouterTest.ts index 04429342cbff..024c1bb67095 100644 --- a/tests/unit/SplitRouterTest.ts +++ b/tests/unit/SplitRouterTest.ts @@ -3,77 +3,32 @@ import SplitRouter from '@libs/Navigation/AppNavigator/createSplitNavigator/Spli import SCREENS from '@src/SCREENS'; -import type {ParamListBase, RouterConfigOptions, StackNavigationState} from '@react-navigation/native'; +import type {ParamListBase, StackNavigationState} from '@react-navigation/native'; import {CommonActions} from '@react-navigation/native'; jest.mock('@libs/getIsNarrowLayout', () => jest.fn()); -const mockedGetIsNarrowLayout = jest.mocked(getIsNarrowLayout); - -const ROUTE_NAMES = [SCREENS.INBOX, SCREENS.REPORT]; - -const CONFIG_OPTIONS: RouterConfigOptions = { - routeNames: ROUTE_NAMES, - routeParamList: {}, - routeGetIdList: {}, -}; - -const router = SplitRouter({ - sidebarScreen: SCREENS.INBOX, - defaultCentralScreen: SCREENS.REPORT, - parentRoute: {key: 'ReportsSplitNavigator-key', name: 'ReportsSplitNavigator'}, -}); - -function buildState(routeNames: string[]): StackNavigationState { - return { - stale: false, - type: 'stack', - key: 'stack-key', - index: routeNames.length - 1, - routeNames: ROUTE_NAMES, - preloadedRoutes: [], - routes: routeNames.map((name) => ({key: `${name}-key`, name})), - }; -} - describe('SplitRouter', () => { - beforeEach(() => { - mockedGetIsNarrowLayout.mockReturnValue(true); - }); - it('handles navigating to the sidebar on a narrow layout when the sidebar is already the only screen', () => { // Given a narrow layout split navigator that shows only its sidebar, as the Inbox tab does at its chat list - const state = buildState([SCREENS.INBOX]); + jest.mocked(getIsNarrowLayout).mockReturnValue(true); + const router = SplitRouter({sidebarScreen: SCREENS.INBOX, defaultCentralScreen: SCREENS.REPORT, parentRoute: {key: 'split-key', name: 'ReportsSplitNavigator'}}); + const routeNames = [SCREENS.INBOX, SCREENS.REPORT]; + const state: StackNavigationState = { + stale: false, + type: 'stack', + key: 'stack-key', + index: 0, + routeNames, + preloadedRoutes: [], + routes: [{key: 'inbox-key', name: SCREENS.INBOX}], + }; // When the sidebar is navigated to again, which happens when the tab's navigation params are applied to an already mounted navigator - const result = router.getStateForAction(state, CommonActions.navigate(SCREENS.INBOX, undefined, {pop: true}), CONFIG_OPTIONS); + const result = router.getStateForAction(state, CommonActions.navigate(SCREENS.INBOX, undefined, {pop: true}), {routeNames, routeParamList: {}, routeGetIdList: {}}); // Then the action is handled without changes, because an unhandled (null) result makes React Navigation log an error and ignore the navigation expect(result).toBe(state); }); - - it('pops back to the sidebar on a narrow layout when a central screen is on top of it', () => { - // Given a narrow layout split navigator with a report opened over the sidebar - const state = buildState([SCREENS.INBOX, SCREENS.REPORT]); - - // When the sidebar is navigated to - const result = router.getStateForAction(state, CommonActions.navigate(SCREENS.INBOX, undefined, {pop: true}), CONFIG_OPTIONS); - - // Then the report is popped so the sidebar is the only screen left - expect(result?.index).toBe(0); - expect(result?.routes.map((route) => route.name)).toEqual([SCREENS.INBOX]); - }); - - it('keeps the central screen on a wide layout when the sidebar is navigated to', () => { - // Given a wide layout split navigator, where the sidebar is drawn next to the report opened over it - mockedGetIsNarrowLayout.mockReturnValue(false); - const state = buildState([SCREENS.INBOX, SCREENS.REPORT]); - - // When the sidebar is navigated to - const result = router.getStateForAction(state, CommonActions.navigate(SCREENS.INBOX, undefined, {pop: true}), CONFIG_OPTIONS); - - // Then nothing changes, because the sidebar is already visible and popping would close the report next to it - expect(result).toBe(state); - }); }); diff --git a/tests/unit/nativeTabScreenListenersTest.ts b/tests/unit/nativeTabScreenListenersTest.ts index 5a38a08f2a35..8b990fef099f 100644 --- a/tests/unit/nativeTabScreenListenersTest.ts +++ b/tests/unit/nativeTabScreenListenersTest.ts @@ -1,73 +1,39 @@ import navigateToInboxTab from '@components/Navigation/NavigationTabBar/navigateToInboxTab'; -import interceptAnonymousUser from '@libs/interceptAnonymousUser'; import tabScreenListeners, {isNativeTabSelectionEnabled} from '@libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/tabScreenListeners'; import NAVIGATORS from '@src/NAVIGATORS'; import SCREENS from '@src/SCREENS'; jest.mock('@components/Navigation/NavigationTabBar/navigateToInboxTab', () => jest.fn()); -jest.mock('@libs/interceptAnonymousUser', () => jest.fn((callback: () => void) => callback())); - -function pressTab(routeName: string, isFocused: boolean) { - tabScreenListeners({route: {name: routeName}, navigation: {isFocused: () => isFocused}}).tabPress(); -} describe('tabScreenListeners', () => { - beforeEach(() => { - jest.clearAllMocks(); - }); - - it('opens the Inbox chat list when Inbox is pressed from another tab', () => { + it('opens the Inbox chat list only when Inbox is pressed from another tab', () => { // Given the native bar, whose Inbox selection is left to JS so that it opens at the chat list like InboxTabButton - // When the user presses Inbox while another tab is focused - pressTab(NAVIGATORS.REPORTS_SPLIT_NAVIGATOR, false); - - // Then the shared Inbox navigation runs - expect(navigateToInboxTab).toHaveBeenCalledTimes(1); - }); + const pressInbox = (isFocused: boolean) => tabScreenListeners({route: {name: NAVIGATORS.REPORTS_SPLIT_NAVIGATOR}, navigation: {isFocused: () => isFocused}}).tabPress(); - it('checks for an anonymous user when Account is pressed from another tab', () => { - // Given the native bar, which never renders the JS Account button that keeps anonymous users out - // When the user presses Account while another tab is focused - pressTab(NAVIGATORS.SETTINGS_SPLIT_NAVIGATOR, false); + // When the user presses Inbox while it is focused and then from another tab + pressInbox(true); + pressInbox(false); - // Then the same anonymous user check runs - expect(interceptAnonymousUser).toHaveBeenCalledTimes(1); - }); - - it('does nothing when the already focused tab is pressed again', () => { - // Given native tabs also emit tabPress for a tap on the focused tab - // When the user presses Inbox and Account while each is already focused - pressTab(NAVIGATORS.REPORTS_SPLIT_NAVIGATOR, true); - pressTab(NAVIGATORS.SETTINGS_SPLIT_NAVIGATOR, true); - - // Then nothing runs, the same as a JS tab button pressed on its own tab - expect(navigateToInboxTab).not.toHaveBeenCalled(); - expect(interceptAnonymousUser).not.toHaveBeenCalled(); + // Then only the press from another tab runs the shared Inbox navigation, as the JS tab button does + expect(navigateToInboxTab).toHaveBeenCalledTimes(1); }); - it('leaves the switch to JS for Inbox and Workspaces and, for anonymous users, the tabs they are kept from', () => { - // Given a signed-in and an anonymous user who have both already been to Spend + it('leaves to JS the switches whose JS tab buttons do more than switching', () => { + // Given a signed-in user who has been to Spend, one who has not, and an anonymous user const signedIn = {isAnonymousUser: false, hasSpendBeenSelected: true}; + const beforeSpend = {isAnonymousUser: false, hasSpendBeenSelected: false}; const anonymous = {isAnonymousUser: true, hasSpendBeenSelected: true}; - // When the native selection is decided for each tab - // Then Inbox and Workspaces are always switched by JS, and Spend, Insights and Account only for an anonymous user + // When the native selection is decided + // Then Inbox and Workspaces are always switched by JS, Spend's first visit is, and anonymous users are kept from Spend, Insights and Account expect(isNativeTabSelectionEnabled(NAVIGATORS.REPORTS_SPLIT_NAVIGATOR, signedIn)).toBe(false); expect(isNativeTabSelectionEnabled(NAVIGATORS.WORKSPACE_NAVIGATOR, signedIn)).toBe(false); expect(isNativeTabSelectionEnabled(NAVIGATORS.SEARCH_FULLSCREEN_NAVIGATOR, signedIn)).toBe(true); - expect(isNativeTabSelectionEnabled(NAVIGATORS.SEARCH_FULLSCREEN_NAVIGATOR, anonymous)).toBe(false); + expect(isNativeTabSelectionEnabled(NAVIGATORS.SEARCH_FULLSCREEN_NAVIGATOR, beforeSpend)).toBe(false); expect(isNativeTabSelectionEnabled(SCREENS.INSIGHTS, anonymous)).toBe(false); expect(isNativeTabSelectionEnabled(NAVIGATORS.SETTINGS_SPLIT_NAVIGATOR, anonymous)).toBe(false); expect(isNativeTabSelectionEnabled(SCREENS.HOME, anonymous)).toBe(true); - expect(isNativeTabSelectionEnabled(NAVIGATORS.WORKSPACE_NAVIGATOR, anonymous)).toBe(false); - }); - - it('leaves the first switch to Spend to JS', () => { - // Given a signed-in user who has not been to Spend yet, whose tab was mounted at startup with the default search - // When the native selection is decided for Spend - // Then JS switches to it, so the first visit opens the latest search kept in Onyx as the JS tab button does - expect(isNativeTabSelectionEnabled(NAVIGATORS.SEARCH_FULLSCREEN_NAVIGATOR, {isAnonymousUser: false, hasSpendBeenSelected: false})).toBe(false); }); }); diff --git a/tests/unit/useHasTabBeenShownTest.ts b/tests/unit/useHasTabBeenShownTest.ts index 8317bb3d88e4..ab5d0105203b 100644 --- a/tests/unit/useHasTabBeenShownTest.ts +++ b/tests/unit/useHasTabBeenShownTest.ts @@ -10,74 +10,28 @@ import type {NavigationState} from '@react-navigation/native'; jest.mock('@hooks/useRootNavigationState', () => jest.fn()); -const mockedUseRootNavigationState = jest.mocked(useRootNavigationState); - -let rootState: NavigationState | undefined; - -function buildRootState(selectedTab: string): NavigationState { - const tabRoutes = [SCREENS.HOME, NAVIGATORS.SEARCH_FULLSCREEN_NAVIGATOR].map((name) => ({key: `${name}-key`, name})); - return { - stale: false, - type: 'stack', - key: 'root-key', - index: 0, - routeNames: [NAVIGATORS.TAB_NAVIGATOR], - routes: [ - { - key: 'tab-navigator-key', - name: NAVIGATORS.TAB_NAVIGATOR, - state: { - stale: false, - type: 'tab', - key: 'tab-key', - index: tabRoutes.findIndex((route) => route.name === selectedTab), - routeNames: tabRoutes.map((route) => route.name), - routes: tabRoutes, - }, - }, - ], - } as NavigationState; +function buildRootState(selectedTabIndex: number): NavigationState { + const routeNames = [SCREENS.HOME, NAVIGATORS.SEARCH_FULLSCREEN_NAVIGATOR]; + const tabState: NavigationState = {stale: false, type: 'tab', key: 'tab', index: selectedTabIndex, routeNames, routes: routeNames.map((name) => ({key: name, name}))}; + return {stale: false, type: 'stack', key: 'root', index: 0, routeNames: [NAVIGATORS.TAB_NAVIGATOR], routes: [{key: 'tabs', name: NAVIGATORS.TAB_NAVIGATOR, state: tabState}]}; } describe('useHasTabBeenShown', () => { - beforeEach(() => { - mockedUseRootNavigationState.mockImplementation((selector) => selector(rootState)); - }); - - it('stays false while the tab is mounted in the background', () => { + it('stays false for a background tab and true once the tab has been selected', () => { // Given native tabs that mounted Spend at startup while Home is selected - rootState = buildRootState(SCREENS.HOME); - - // When Spend's page reads whether it has been shown - const {result} = renderHook(() => useHasTabBeenShown(NAVIGATORS.SEARCH_FULLSCREEN_NAVIGATOR)); - - // Then it has not, so the page holds back what it requests on mount - expect(result.current).toBe(false); - }); - - it('stays true once the tab has been selected, after the user leaves it', () => { - // Given Spend mounted in the background - rootState = buildRootState(SCREENS.HOME); + let rootState = buildRootState(0); + jest.mocked(useRootNavigationState).mockImplementation((selector) => selector(rootState)); const {result, rerender} = renderHook(() => useHasTabBeenShown(NAVIGATORS.SEARCH_FULLSCREEN_NAVIGATOR)); + const wasShownInBackground = result.current; // When the user selects Spend and then goes back to Home - rootState = buildRootState(NAVIGATORS.SEARCH_FULLSCREEN_NAVIGATOR); + rootState = buildRootState(1); rerender({}); - rootState = buildRootState(SCREENS.HOME); + rootState = buildRootState(0); rerender({}); - // Then Spend counts as shown, so its page keeps reacting to its query like a page mounted with the tab - expect(result.current).toBe(true); - }); - - it('is true before navigation is ready', () => { - // Given no navigation state yet, as when the page mounts with the tab on web - rootState = undefined; - - // When the page reads whether its tab has been shown - const {result} = renderHook(() => useHasTabBeenShown(NAVIGATORS.SETTINGS_SPLIT_NAVIGATOR)); - - // Then it counts as shown, so nothing is held back when the tab is not a background one + // Then Spend held back its mount work in the background and keeps it running after the user leaves it + expect(wasShownInBackground).toBe(false); expect(result.current).toBe(true); }); }); From 4c9148e7120f17fc024604f9a857022b6b3043af Mon Sep 17 00:00:00 2001 From: sumo_slonik Date: Tue, 6 Oct 2026 23:31:35 +0200 Subject: [PATCH 65/71] fix: native bar switches to Inbox, Spend and Workspaces itself when they already show what their tab buttons open, so the iOS bubble no longer bounces back --- .../NavigationTabBar/getSearchTabRoute.ts | 10 ++-- .../NativeTabNavigator/TabPressListeners.tsx | 58 +++++++++--------- .../NativeTabNavigator/tabScreenListeners.ts | 59 ++++++++++++------- .../useIsWorkspacesTabRestored.ts | 50 ++++++++++++++++ .../useNativeTabNavigator.ts | 14 ++--- .../Navigators/TabNavigator.native.tsx | 7 ++- tests/unit/nativeTabScreenListenersTest.ts | 44 ++++++++------ 7 files changed, 162 insertions(+), 80 deletions(-) create mode 100644 src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/useIsWorkspacesTabRestored.ts diff --git a/src/components/Navigation/NavigationTabBar/getSearchTabRoute.ts b/src/components/Navigation/NavigationTabBar/getSearchTabRoute.ts index 408f925a2d47..bfbc7bb71b85 100644 --- a/src/components/Navigation/NavigationTabBar/getSearchTabRoute.ts +++ b/src/components/Navigation/NavigationTabBar/getSearchTabRoute.ts @@ -15,16 +15,16 @@ import type {OnyxEntry} from 'react-native-onyx'; import getLastRoute from './getLastRoute'; /** The latest Spend search kept in Onyx, for a Spend tab that has not shown a search yet. */ -function getStoredSearchTabRoute(lastSearchParams: OnyxEntry, lastExpensesSearchQuery: string | undefined) { +function getStoredSearchTabParams(lastSearchParams: OnyxEntry, lastExpensesSearchQuery: string | undefined) { const lastQueryJSON = lastSearchParams?.queryJSON; const lastQueryFromOnyx = lastQueryJSON ? buildSearchQueryString(lastQueryJSON) : undefined; if (lastQueryFromOnyx) { // The persisted search key belongs to the persisted query, so it only travels with it. - return ROUTES.SEARCH_ROOT.getRoute({query: lastQueryFromOnyx, searchKey: lastSearchParams?.searchKey}); + return {query: lastQueryFromOnyx, searchKey: lastSearchParams?.searchKey}; } const defaultSearchQuery = buildCannedSearchQuery({type: CONST.SEARCH.DATA_TYPES.EXPENSE}); - return ROUTES.SEARCH_ROOT.getRoute({query: getValidLastQuery(lastExpensesSearchQuery, defaultSearchQuery), searchKey: CONST.SEARCH.SEARCH_KEYS.EXPENSES}); + return {query: getValidLastQuery(lastExpensesSearchQuery, defaultSearchQuery), searchKey: CONST.SEARCH.SEARCH_KEYS.EXPENSES}; } function getSearchTabRoute(rootState: NavigationState, lastSearchParams: OnyxEntry, lastExpensesSearchQuery: string | undefined) { @@ -41,8 +41,8 @@ function getSearchTabRoute(rootState: NavigationState, lastSearchParams: OnyxEnt } } - return getStoredSearchTabRoute(lastSearchParams, lastExpensesSearchQuery); + return ROUTES.SEARCH_ROOT.getRoute(getStoredSearchTabParams(lastSearchParams, lastExpensesSearchQuery)); } export default getSearchTabRoute; -export {getStoredSearchTabRoute}; +export {getStoredSearchTabParams}; diff --git a/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/TabPressListeners.tsx b/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/TabPressListeners.tsx index 50fa3f285e8d..31dad7553434 100644 --- a/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/TabPressListeners.tsx +++ b/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/TabPressListeners.tsx @@ -1,57 +1,61 @@ -import {getStoredSearchTabRoute} from '@components/Navigation/NavigationTabBar/getSearchTabRoute'; +import {getStoredSearchTabParams} from '@components/Navigation/NavigationTabBar/getSearchTabRoute'; +import useHasTabBeenShown from '@hooks/useHasTabBeenShown'; import useOnyx from '@hooks/useOnyx'; import useRestoreWorkspacesTabOnNavigate from '@hooks/useRestoreWorkspacesTabOnNavigate'; -import interceptAnonymousUser from '@libs/interceptAnonymousUser'; -import Navigation from '@libs/Navigation/Navigation'; -import {startNavigateToReportsTabSpans} from '@libs/telemetry/startTabNavigationSpans'; +import navigationRef from '@libs/Navigation/navigationRef'; import NAVIGATORS from '@src/NAVIGATORS'; import ONYXKEYS from '@src/ONYXKEYS'; import {lastExpensesSearchQuerySelector} from '@src/selectors/SearchFilters'; +import {CommonActions} from '@react-navigation/native'; import {useEffect} from 'react'; import type {NativeTabLayoutProps} from './NativeTabLayout'; /** - * The native bar's taps on Spend and Workspaces, which run the JS tab buttons' navigation and need Onyx data, so the - * data re-renders only this component. Spend's first visit opens the latest search kept in Onyx, because the tab - * mounted at startup shows the default one. Workspaces restores the last workspace, or the list for one that is gone. + * The native bar's Spend and Workspaces handling that needs Onyx data, so the data re-renders only this component. + * Spend's first visit opens the latest search kept in Onyx, so the tab mounted at startup with the default search gets + * it before it is first shown and the native bar can switch to it. Workspaces restores the last workspace, or the list + * for one that is gone, whenever the tab does not already show it. */ function TabPressListeners({state, descriptors}: Pick) { const [lastSearchParams] = useOnyx(ONYXKEYS.REPORT_NAVIGATION_LAST_SEARCH_QUERY); const [lastExpensesSearchQuery] = useOnyx(ONYXKEYS.SEARCH_FILTERS, {selector: lastExpensesSearchQuerySelector}); + const hasSpendBeenShown = useHasTabBeenShown(NAVIGATORS.SEARCH_FULLSCREEN_NAVIGATOR); const navigateToWorkspaces = useRestoreWorkspacesTabOnNavigate(); - const getDescriptor = (routeName: string) => { - const route = state.routes.find((tabRoute) => tabRoute.name === routeName); - return route ? descriptors[route.key] : undefined; - }; - const spendDescriptor = getDescriptor(NAVIGATORS.SEARCH_FULLSCREEN_NAVIGATOR); - const spendNavigation = spendDescriptor?.navigation; - const isSpendSwitchedByJS = spendDescriptor?.options.tabBarSelectionEnabled === false; - const workspacesNavigation = getDescriptor(NAVIGATORS.WORKSPACE_NAVIGATOR)?.navigation; + const getTabRoute = (routeName: string) => state.routes.find((tabRoute) => tabRoute.name === routeName); + const spendNavigatorState = getTabRoute(NAVIGATORS.SEARCH_FULLSCREEN_NAVIGATOR)?.state; + const spendNavigatorKey = spendNavigatorState?.key; + const spendRootRoute = spendNavigatorState?.routes.at(0); + const spendRootRouteKey = spendRootRoute?.key; + const spendRootQuery = spendRootRoute?.params && 'q' in spendRootRoute.params ? spendRootRoute.params.q : undefined; + const {query: storedQuery, searchKey: storedSearchKey} = getStoredSearchTabParams(lastSearchParams, lastExpensesSearchQuery); + const workspacesDescriptor = descriptors[getTabRoute(NAVIGATORS.WORKSPACE_NAVIGATOR)?.key ?? '']; + const workspacesNavigation = workspacesDescriptor?.navigation; + const isWorkspacesSwitchedByJS = workspacesDescriptor?.options.tabBarSelectionEnabled === false; + + useEffect(() => { + if (hasSpendBeenShown || !spendNavigatorKey || !spendRootRouteKey || spendRootQuery === storedQuery) { + return; + } + navigationRef.dispatch({...CommonActions.setParams({q: storedQuery, searchKey: storedSearchKey}), source: spendRootRouteKey, target: spendNavigatorKey}); + }, [hasSpendBeenShown, spendNavigatorKey, spendRootRouteKey, spendRootQuery, storedQuery, storedSearchKey]); + // Runs on the focused tab too, where it returns from a workspace to the list. useEffect( () => - spendNavigation?.addListener('tabPress', () => { - if (spendNavigation.isFocused()) { + workspacesNavigation?.addListener('tabPress', () => { + if (!workspacesNavigation.isFocused() && !isWorkspacesSwitchedByJS) { return; } - interceptAnonymousUser(() => { - startNavigateToReportsTabSpans(); - if (isSpendSwitchedByJS) { - Navigation.navigate(getStoredSearchTabRoute(lastSearchParams, lastExpensesSearchQuery)); - } - }); + navigateToWorkspaces(); }), - [spendNavigation, isSpendSwitchedByJS, lastSearchParams, lastExpensesSearchQuery], + [workspacesNavigation, isWorkspacesSwitchedByJS, navigateToWorkspaces], ); - // Runs on the focused tab too, where it returns from a workspace to the list. - useEffect(() => workspacesNavigation?.addListener('tabPress', navigateToWorkspaces), [workspacesNavigation, navigateToWorkspaces]); - return null; } diff --git a/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/tabScreenListeners.ts b/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/tabScreenListeners.ts index 07534edf8143..50b556171740 100644 --- a/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/tabScreenListeners.ts +++ b/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/tabScreenListeners.ts @@ -3,49 +3,68 @@ import NAVIGATION_TABS from '@components/Navigation/NavigationTabBar/NAVIGATION_ import ROUTE_TO_NAVIGATION_TAB from '@components/Navigation/NavigationTabBar/ROUTE_TO_NAVIGATION_TAB'; import interceptAnonymousUser from '@libs/interceptAnonymousUser'; +import getFocusedLeafScreenName from '@libs/Navigation/helpers/getFocusedLeafScreenName'; +import {startNavigateToInboxTabSpan, startNavigateToReportsTabSpans} from '@libs/telemetry/startTabNavigationSpans'; -import NAVIGATORS from '@src/NAVIGATORS'; +import SCREENS from '@src/SCREENS'; +import type {NavigationState, PartialState} from '@react-navigation/native'; import type {ValueOf} from 'type-fest'; type NavigationTab = ValueOf; /** Tabs the JS tab buttons keep from anonymous users, who get the sign-in modal instead. */ -const ANONYMOUS_GATED_TABS = new Set([NAVIGATION_TABS.SEARCH, NAVIGATION_TABS.INSIGHTS, NAVIGATION_TABS.SETTINGS]); +const ANONYMOUS_GATED_TABS = new Set([NAVIGATION_TABS.SEARCH, NAVIGATION_TABS.INSIGHTS, NAVIGATION_TABS.WORKSPACES, NAVIGATION_TABS.SETTINGS]); -/** - * Tabs switched by their own JS navigation rather than by the native selection, as their JS tab buttons do: Inbox opens - * at its chat list, and Workspaces restores the last workspace or falls back to the list. - */ -const JS_SWITCHED_TABS = new Set([NAVIGATION_TABS.INBOX, NAVIGATION_TABS.WORKSPACES]); +type TabRoute = { + name: string; + state?: NavigationState | PartialState; +}; + +/** Whether Inbox shows its chat list, which is where its JS tab button opens it. */ +function isInboxTabAtChatList(inboxRoute: TabRoute | undefined) { + return getFocusedLeafScreenName(inboxRoute?.state) === SCREENS.INBOX; +} /** * What a tap on the native bar does on top of the tab switch, mirroring the JS tab buttons the native bar replaces. - * Spend and Workspaces are handled by TabPressListeners, which need Onyx data. + * Workspaces is handled by TabPressListeners, which needs Onyx data. */ -const NAVIGATION_TAB_PRESS_HANDLERS: Partial void>> = { - [NAVIGATION_TABS.INBOX]: navigateToInboxTab, +const NAVIGATION_TAB_PRESS_HANDLERS: Partial void>> = { + [NAVIGATION_TABS.INBOX]: (route) => (isInboxTabAtChatList(route) ? startNavigateToInboxTabSpan({isWideLayout: false}) : navigateToInboxTab()), + [NAVIGATION_TABS.SEARCH]: () => interceptAnonymousUser(startNavigateToReportsTabSpans), [NAVIGATION_TABS.INSIGHTS]: () => interceptAnonymousUser(() => {}), [NAVIGATION_TABS.SETTINGS]: () => interceptAnonymousUser(() => {}), }; type NativeTabSelectionParams = { isAnonymousUser: boolean; - /** Spend's first visit opens the latest search kept in Onyx, which the tab mounted at startup does not show yet. */ - hasSpendBeenSelected: boolean; + isInboxAtChatList: boolean; + /** Whether Workspaces shows what its JS tab button restores, see useIsWorkspacesTabRestored. */ + isWorkspacesTabRestored: boolean; }; -/** Whether the native bar switches to the tab itself, or leaves the switch to the tab's press handler. */ -function isNativeTabSelectionEnabled(routeName: string, {isAnonymousUser, hasSpendBeenSelected}: NativeTabSelectionParams) { - if (routeName === NAVIGATORS.SEARCH_FULLSCREEN_NAVIGATOR && !hasSpendBeenSelected) { +/** + * Whether the native bar switches to the tab itself, or leaves the switch to the tab's press handler. The native switch + * is kept whenever the tab already shows what its JS tab button opens, because a prevented selection makes the iOS bar + * slide back to the current tab before JS moves it to the new one. + */ +function isNativeTabSelectionEnabled(routeName: string, {isAnonymousUser, isInboxAtChatList, isWorkspacesTabRestored}: NativeTabSelectionParams) { + const tab = ROUTE_TO_NAVIGATION_TAB[routeName]; + if (isAnonymousUser && ANONYMOUS_GATED_TABS.has(tab)) { return false; } - const tab = ROUTE_TO_NAVIGATION_TAB[routeName]; - return !JS_SWITCHED_TABS.has(tab) && !(isAnonymousUser && ANONYMOUS_GATED_TABS.has(tab)); + if (tab === NAVIGATION_TABS.INBOX) { + return isInboxAtChatList; + } + if (tab === NAVIGATION_TABS.WORKSPACES) { + return isWorkspacesTabRestored; + } + return true; } type TabScreenListenerProps = { - route: {name: string}; + route: TabRoute; navigation: {isFocused: () => boolean}; }; @@ -59,10 +78,10 @@ function tabScreenListeners({route, navigation}: TabScreenListenerProps) { if (navigation.isFocused()) { return; } - NAVIGATION_TAB_PRESS_HANDLERS[ROUTE_TO_NAVIGATION_TAB[route.name]]?.(); + NAVIGATION_TAB_PRESS_HANDLERS[ROUTE_TO_NAVIGATION_TAB[route.name]]?.(route); }, }; } export default tabScreenListeners; -export {isNativeTabSelectionEnabled}; +export {isInboxTabAtChatList, isNativeTabSelectionEnabled}; diff --git a/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/useIsWorkspacesTabRestored.ts b/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/useIsWorkspacesTabRestored.ts new file mode 100644 index 000000000000..928dd7accded --- /dev/null +++ b/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/useIsWorkspacesTabRestored.ts @@ -0,0 +1,50 @@ +import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; +import useOnyx from '@hooks/useOnyx'; + +import getFocusedLeafScreenName from '@libs/Navigation/helpers/getFocusedLeafScreenName'; +import {isPendingDeletePolicy, shouldShowPolicy} from '@libs/PolicyUtils'; + +import NAVIGATORS from '@src/NAVIGATORS'; +import ONYXKEYS from '@src/ONYXKEYS'; +import SCREENS from '@src/SCREENS'; +import type {Domain, Policy} from '@src/types/onyx'; + +import type {NavigationState, PartialState} from '@react-navigation/native'; +import type {OnyxEntry} from 'react-native-onyx'; + +type WorkspacesTabRoute = { + state?: NavigationState | PartialState; +}; + +const domainExistsSelector = (domain: OnyxEntry) => !!domain?.accountID; + +/** + * Whether the Workspaces tab shows what its JS tab button restores on narrow layouts, so a plain switch to the tab + * lands on the same page: the workspaces list, the initial page of a workspace that is still shown, or a domain that + * still exists. Any other page is replaced by the tab button, see navigateToWorkspacesPage. + */ +function useIsWorkspacesTabRestored(workspacesTabRoute: WorkspacesTabRoute | undefined) { + const {login: currentUserLogin} = useCurrentUserPersonalDetails(); + const navigatorState = workspacesTabRoute?.state; + const topRoute = navigatorState?.routes.at(navigatorState.index ?? -1); + const splitRootParams = topRoute?.state?.routes.at(0)?.params; + const policyID = topRoute?.name === NAVIGATORS.WORKSPACE_SPLIT_NAVIGATOR && splitRootParams && 'policyID' in splitRootParams ? String(splitRootParams.policyID) : undefined; + const domainAccountID = + topRoute?.name === NAVIGATORS.DOMAIN_SPLIT_NAVIGATOR && splitRootParams && 'domainAccountID' in splitRootParams ? String(splitRootParams.domainAccountID) : undefined; + const isPolicyShownSelector = (policy: OnyxEntry) => shouldShowPolicy(policy, false, currentUserLogin) && !isPendingDeletePolicy(policy); + const [isPolicyShown = false] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`, {selector: isPolicyShownSelector}); + const [doesDomainExist = false] = useOnyx(`${ONYXKEYS.COLLECTION.DOMAIN}${domainAccountID}`, {selector: domainExistsSelector}); + + switch (topRoute?.name) { + case SCREENS.WORKSPACES_LIST: + return true; + case NAVIGATORS.WORKSPACE_SPLIT_NAVIGATOR: + return !!policyID && isPolicyShown && getFocusedLeafScreenName(topRoute.state) === SCREENS.WORKSPACE.INITIAL; + case NAVIGATORS.DOMAIN_SPLIT_NAVIGATOR: + return !!domainAccountID && doesDomainExist; + default: + return false; + } +} + +export default useIsWorkspacesTabRestored; diff --git a/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/useNativeTabNavigator.ts b/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/useNativeTabNavigator.ts index f99a5108c89a..eb65e080c3ad 100644 --- a/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/useNativeTabNavigator.ts +++ b/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/useNativeTabNavigator.ts @@ -27,9 +27,11 @@ import type {NavigationAction, NavigationState, PartialState, Router, TabNavigat import {findFocusedRoute, useNavigation, useNavigationState, useRoute} from '@react-navigation/native'; import {isAnonymousSessionSelector} from '@selectors/Session'; -import {useEffect, useState} from 'react'; +import {useEffect} from 'react'; import getTabWithoutBarItem from './getTabWithoutBarItem'; +import {isInboxTabAtChatList} from './tabScreenListeners'; +import useIsWorkspacesTabRestored from './useIsWorkspacesTabRestored'; /** * Root-level tab screens where the swipe-back gesture should be disabled. @@ -68,11 +70,9 @@ function useNativeTabNavigator() { // 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; - const isSpendSelected = activeTabRoute?.name === NAVIGATORS.SEARCH_FULLSCREEN_NAVIGATOR; - const [hasSpendBeenSelected, setHasSpendBeenSelected] = useState(isSpendSelected); - if (isSpendSelected && !hasSpendBeenSelected) { - setHasSpendBeenSelected(true); - } + 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)); let inboxDotColor: string | undefined; if (chatTabBrickRoad) { @@ -141,7 +141,7 @@ function useNativeTabNavigator() { const isAccountAvatarShown = shouldUseNarrowLayout && tabWithoutBarItem !== NAVIGATORS.SETTINGS_SPLIT_NAVIGATOR; - return {shouldShowNativeTabBar, isAccountAvatarShown, dotColors, tabLabels, tabWithoutBarItem, tabRouterOverride, isAnonymousUser, hasSpendBeenSelected}; + return {shouldShowNativeTabBar, isAccountAvatarShown, dotColors, tabLabels, tabWithoutBarItem, tabRouterOverride, isAnonymousUser, isInboxAtChatList, isWorkspacesTabRestored}; } export default useNativeTabNavigator; diff --git a/src/libs/Navigation/AppNavigator/Navigators/TabNavigator.native.tsx b/src/libs/Navigation/AppNavigator/Navigators/TabNavigator.native.tsx index e46af455278f..6191e9d9b678 100644 --- a/src/libs/Navigation/AppNavigator/Navigators/TabNavigator.native.tsx +++ b/src/libs/Navigation/AppNavigator/Navigators/TabNavigator.native.tsx @@ -35,11 +35,14 @@ const HIDDEN_TAB_OPTIONS = {tabBarItemHidden: true}; const renderNativeTabLayout = (props: NativeTabLayoutProps) => ; function TabNavigator() { - const {shouldShowNativeTabBar, isAccountAvatarShown, dotColors, tabLabels, tabWithoutBarItem, tabRouterOverride, isAnonymousUser, hasSpendBeenSelected} = useNativeTabNavigator(); + const {shouldShowNativeTabBar, isAccountAvatarShown, dotColors, tabLabels, tabWithoutBarItem, tabRouterOverride, isAnonymousUser, isInboxAtChatList, isWorkspacesTabRestored} = + useNativeTabNavigator(); const {screenOptions, getTabOptions} = useNativeTabBarOptions({shouldShowNativeTabBar, isAccountAvatarShown, dotColors, tabLabels}); // A tab with no bar item draws no icon. const getOptions = (name: NativeTabName) => - name === tabWithoutBarItem ? HIDDEN_TAB_OPTIONS : {...getTabOptions(name), tabBarSelectionEnabled: isNativeTabSelectionEnabled(name, {isAnonymousUser, hasSpendBeenSelected})}; + name === tabWithoutBarItem + ? HIDDEN_TAB_OPTIONS + : {...getTabOptions(name), tabBarSelectionEnabled: isNativeTabSelectionEnabled(name, {isAnonymousUser, isInboxAtChatList, isWorkspacesTabRestored})}; return ( jest.fn()); +jest.mock('@libs/telemetry/startTabNavigationSpans'); describe('tabScreenListeners', () => { - it('opens the Inbox chat list only when Inbox is pressed from another tab', () => { - // Given the native bar, whose Inbox selection is left to JS so that it opens at the chat list like InboxTabButton - const pressInbox = (isFocused: boolean) => tabScreenListeners({route: {name: NAVIGATORS.REPORTS_SPLIT_NAVIGATOR}, navigation: {isFocused: () => isFocused}}).tabPress(); - - // When the user presses Inbox while it is focused and then from another tab - pressInbox(true); - pressInbox(false); - - // Then only the press from another tab runs the shared Inbox navigation, as the JS tab button does + it('opens the Inbox chat list only when Inbox is pressed from another tab with a report open', () => { + // Given Inbox left with a report open, which the native bar does not switch to, so JS opens it at the chat list like InboxTabButton + const inboxWithReport = {name: NAVIGATORS.REPORTS_SPLIT_NAVIGATOR, state: {routes: [{name: SCREENS.INBOX}, {name: SCREENS.REPORT}], index: 1}}; + const inboxAtChatList = {name: NAVIGATORS.REPORTS_SPLIT_NAVIGATOR, state: {routes: [{name: SCREENS.INBOX}], index: 0}}; + const pressInbox = (route: Parameters[0]['route'], isFocused: boolean) => tabScreenListeners({route, navigation: {isFocused: () => isFocused}}).tabPress(); + + // When the user presses Inbox while it is focused, from another tab at the chat list, and from another tab with a report open + pressInbox(inboxWithReport, true); + pressInbox(inboxAtChatList, false); + pressInbox(inboxWithReport, false); + + // Then only the last press runs the shared Inbox navigation, since the native switch already lands on the chat list expect(navigateToInboxTab).toHaveBeenCalledTimes(1); }); - it('leaves to JS the switches whose JS tab buttons do more than switching', () => { - // Given a signed-in user who has been to Spend, one who has not, and an anonymous user - const signedIn = {isAnonymousUser: false, hasSpendBeenSelected: true}; - const beforeSpend = {isAnonymousUser: false, hasSpendBeenSelected: false}; - const anonymous = {isAnonymousUser: true, hasSpendBeenSelected: true}; + it('leaves to JS only the switches that would land somewhere else than the JS tab buttons', () => { + // Given a signed-in user whose Inbox and Workspaces tabs show what their tab buttons open, one whose do not, and an anonymous user + const ready = {isAnonymousUser: false, isInboxAtChatList: true, isWorkspacesTabRestored: true}; + const unrestored = {isAnonymousUser: false, isInboxAtChatList: false, isWorkspacesTabRestored: false}; + const anonymous = {...ready, isAnonymousUser: true}; // When the native selection is decided - // Then Inbox and Workspaces are always switched by JS, Spend's first visit is, and anonymous users are kept from Spend, Insights and Account - expect(isNativeTabSelectionEnabled(NAVIGATORS.REPORTS_SPLIT_NAVIGATOR, signedIn)).toBe(false); - expect(isNativeTabSelectionEnabled(NAVIGATORS.WORKSPACE_NAVIGATOR, signedIn)).toBe(false); - expect(isNativeTabSelectionEnabled(NAVIGATORS.SEARCH_FULLSCREEN_NAVIGATOR, signedIn)).toBe(true); - expect(isNativeTabSelectionEnabled(NAVIGATORS.SEARCH_FULLSCREEN_NAVIGATOR, beforeSpend)).toBe(false); + // Then Inbox and Workspaces are switched natively only when ready, Spend always is, and anonymous users are kept from Spend, Insights, Workspaces and Account + expect(isNativeTabSelectionEnabled(NAVIGATORS.REPORTS_SPLIT_NAVIGATOR, ready)).toBe(true); + expect(isNativeTabSelectionEnabled(NAVIGATORS.WORKSPACE_NAVIGATOR, ready)).toBe(true); + expect(isNativeTabSelectionEnabled(NAVIGATORS.REPORTS_SPLIT_NAVIGATOR, unrestored)).toBe(false); + expect(isNativeTabSelectionEnabled(NAVIGATORS.WORKSPACE_NAVIGATOR, unrestored)).toBe(false); + expect(isNativeTabSelectionEnabled(NAVIGATORS.SEARCH_FULLSCREEN_NAVIGATOR, unrestored)).toBe(true); expect(isNativeTabSelectionEnabled(SCREENS.INSIGHTS, anonymous)).toBe(false); + expect(isNativeTabSelectionEnabled(NAVIGATORS.WORKSPACE_NAVIGATOR, anonymous)).toBe(false); expect(isNativeTabSelectionEnabled(NAVIGATORS.SETTINGS_SPLIT_NAVIGATOR, anonymous)).toBe(false); expect(isNativeTabSelectionEnabled(SCREENS.HOME, anonymous)).toBe(true); }); From 434ec5aa526100365083438b85679048c30d108d Mon Sep 17 00:00:00 2001 From: sumo_slonik Date: Wed, 7 Oct 2026 14:28:52 +0200 Subject: [PATCH 66/71] fix: iOS tab labels fall back for non-Latin scripts and truncate to the tab width, tabs keep their state across the breakpoint, wide Spend opens last search --- .../NativeTabNavigator/NativeTabLayout.tsx | 15 ++--- .../NativeTabNavigator/TabPressListeners.tsx | 4 +- .../NativeTabNavigator/tabIconRasterizer.ts | 63 +++++++++++-------- .../useNativeTabBarOptions/index.ios.ts | 7 ++- src/styles/variables.ts | 2 + 5 files changed, 51 insertions(+), 40 deletions(-) diff --git a/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/NativeTabLayout.tsx b/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/NativeTabLayout.tsx index 8dc42d5dfe64..11884432fdb6 100644 --- a/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/NativeTabLayout.tsx +++ b/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/NativeTabLayout.tsx @@ -39,18 +39,11 @@ function NativeTabLayout({children, state, descriptors}: NativeTabLayoutProps) { // The buttons follow whatever the navigator decided for the bar itself. const shouldShowNativeTabBar = shouldUseNarrowLayout && !!activeRoute && descriptors[activeRoute.key]?.options.tabBarStyle?.display !== 'none'; - if (!shouldUseNarrowLayout) { - return ( - - - {children} - - ); - } - + // The tabs stay at the same place in the tree in both layouts, so crossing the breakpoint does not remount them. return ( - - {children} + + {!shouldUseNarrowLayout && } + {children} ) { const [lastSearchParams] = useOnyx(ONYXKEYS.REPORT_NAVIGATION_LAST_SEARCH_QUERY); diff --git a/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/tabIconRasterizer.ts b/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/tabIconRasterizer.ts index fa55746b019c..4c2efd1d9bd5 100644 --- a/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/tabIconRasterizer.ts +++ b/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/tabIconRasterizer.ts @@ -1,9 +1,9 @@ import FontUtils from '@styles/utils/FontUtils'; import type {NativeBottomTabIcon} from '@react-navigation/bottom-tabs/unstable'; -import type {SkCanvas, SkFont, SkImage, SkPath} from '@shopify/react-native-skia'; +import type {SkCanvas, SkImage, SkParagraph, SkPath} from '@shopify/react-native-skia'; -import {BlendMode, ClipOp, FillType, FilterMode, FontWeight, ImageFormat, MipmapMode, Skia} from '@shopify/react-native-skia'; +import {BlendMode, ClipOp, FillType, FilterMode, FontWeight, ImageFormat, MipmapMode, Skia, TextAlign} from '@shopify/react-native-skia'; import {PixelRatio} from 'react-native'; import type {NativeTabGlyph, NativeTabName} from './NATIVE_TAB_GLYPHS'; @@ -29,6 +29,8 @@ type TabIconLabel = { text: string; color: string; isBold: boolean; + /** Width of the tab's slot in the bar, past which the label is truncated with an ellipsis. */ + maxWidth: number; }; type TabIconParams = { @@ -45,7 +47,6 @@ const MAX_CACHED_ICONS = 64; const iconCache = new Map(); const glyphPathCache = new Map(); -const labelFontCache = new Map(); function getGlyphPaths(name: NativeTabName): SkPath[] { const cachedPaths = glyphPathCache.get(name); @@ -68,18 +69,19 @@ function getGlyphPaths(name: NativeTabName): SkPath[] { return paths; } -function getLabelFont(isBold: boolean, size: number): SkFont { - const key = `${isBold}|${size}`; - const cachedFont = labelFontCache.get(key); - if (cachedFont) { - return cachedFont; - } - const typeface = Skia.FontMgr.System().matchFamilyStyle(FontUtils.fontFamily.single.EXP_NEUE.fontFamily, { - weight: isBold ? FontWeight.Bold : FontWeight.Normal, - }); - const font = Skia.Font(typeface, size); - labelFontCache.set(key, font); - return font; +/** A paragraph rather than a single font, because only a paragraph falls back to a system font for scripts Expensify Neue lacks. */ +function makeLabelParagraph({text, color, isBold, maxWidth}: TabIconLabel, fontSize: number, scale: number): SkParagraph { + const paragraph = Skia.ParagraphBuilder.Make({maxLines: 1, ellipsis: '…', textAlign: TextAlign.Left}) + .pushStyle({ + color: Skia.Color(color), + fontFamilies: [FontUtils.fontFamily.single.EXP_NEUE.fontFamily], + fontSize: fontSize * scale, + fontStyle: {weight: isBold ? FontWeight.Bold : FontWeight.Normal}, + }) + .addText(text) + .build(); + paragraph.layout(maxWidth * scale); + return paragraph; } function drawGlyph(canvas: SkCanvas, name: NativeTabName, left: number, top: number, size: number, color: string) { @@ -141,15 +143,15 @@ function drawTabIcon(layout: TabIconLayout, {name, color, avatar, dotColor, labe // every label lands at the same height. const rowSize = label ? Math.max(layout.glyphSize, layout.avatarSize) * scale : contentSize; const labelTop = (rowSize + layout.glyphSize * scale) / 2 + layout.labelGap * scale; - const font = label ? getLabelFont(label.isBold, layout.labelFontSize * scale) : undefined; - const labelWidth = label && font ? font.measureText(label.text).width : 0; - const labelMetrics = font?.getMetrics(); - const labelHeight = labelMetrics ? labelMetrics.descent - labelMetrics.ascent : 0; + const paragraph = label ? makeLabelParagraph(label, layout.labelFontSize, scale) : undefined; + const labelWidth = paragraph?.getLongestLine() ?? 0; + const labelHeight = paragraph?.getHeight() ?? 0; const canvasWidth = Math.ceil(Math.max(contentSize, labelWidth)); const canvasHeight = Math.ceil(label ? labelTop + labelHeight : rowSize); const surface = Skia.Surface.Make(canvasWidth, canvasHeight); if (!surface) { + paragraph?.dispose(); return undefined; } @@ -164,12 +166,9 @@ function drawTabIcon(layout: TabIconLayout, {name, color, avatar, dotColor, labe if (dotColor) { drawStatusDot(canvas, contentLeft + contentSize, contentTop, layout.dotRadius * scale, layout.dotCutout * scale, dotColor); } - if (label && font && labelMetrics) { - const paint = Skia.Paint(); - paint.setColor(Skia.Color(label.color)); - paint.setAntiAlias(true); - canvas.drawText(label.text, (canvasWidth - labelWidth) / 2, labelTop - labelMetrics.ascent, paint, font); - paint.dispose(); + if (paragraph) { + paragraph.paint(canvas, (canvasWidth - labelWidth) / 2, labelTop); + paragraph.dispose(); } surface.flush(); @@ -191,7 +190,19 @@ function drawTabIcon(layout: TabIconLayout, {name, color, avatar, dotColor, labe */ function getTabIcon(layout: TabIconLayout, params: TabIconParams): NativeBottomTabIcon | undefined { const {name, color, avatar, dotColor, label} = params; - const key = [name, avatar?.uri ?? color, dotColor, label?.text, label?.color, label?.isBold, layout.glyphSize, layout.avatarSize, layout.dotRadius, layout.dotCutout].join('|'); + const key = [ + name, + avatar?.uri ?? color, + dotColor, + label?.text, + label?.color, + label?.isBold, + label?.maxWidth, + layout.glyphSize, + layout.avatarSize, + layout.dotRadius, + layout.dotCutout, + ].join('|'); if (iconCache.has(key)) { return iconCache.get(key); } 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 686535daa74e..e6f46ed27404 100644 --- a/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/useNativeTabBarOptions/index.ios.ts +++ b/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/useNativeTabBarOptions/index.ios.ts @@ -1,4 +1,5 @@ import useTheme from '@hooks/useTheme'; +import useWindowDimensions from '@hooks/useWindowDimensions'; import type {NativeTabName} from '@libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/NATIVE_TAB_GLYPHS'; import getTabIcon from '@libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/tabIconRasterizer'; @@ -35,6 +36,10 @@ const TAB_ICON_LAYOUT: TabIconLayout = { function useNativeTabBarOptions({shouldShowNativeTabBar, isAccountAvatarShown, dotColors, tabLabels}: NativeTabBarOptionsParams) { const theme = useTheme(); const avatar = useTabAvatarImage(isAccountAvatarShown); + const {windowWidth} = useWindowDimensions(); + // One of the tabs never has a bar item, so the bar splits its width between the others. + const barItemCount = Object.keys(tabLabels).length - 1; + const labelMaxWidth = (windowWidth - 2 * variables.iosNativeTabBarHorizontalInset) / barItemCount; const screenOptions: NativeBottomTabNavigationOptions = { headerShown: false, @@ -57,7 +62,7 @@ function useNativeTabBarOptions({shouldShowNativeTabBar, isAccountAvatarShown, d 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}, + label: {text: tabLabels[name], color: isSelected ? theme.text : theme.textSupporting, isBold: isSelected, maxWidth: labelMaxWidth}, }); const inactiveIcon = getIcon(false); const activeIcon = getIcon(true); diff --git a/src/styles/variables.ts b/src/styles/variables.ts index a86fce69cfcd..d5eb6116b771 100644 --- a/src/styles/variables.ts +++ b/src/styles/variables.ts @@ -121,6 +121,8 @@ export default { nativeTabIconDotCutout: 2, /** Gap between the glyph and the label drawn under it in an iOS native tab bar icon. */ nativeTabIconLabelGap: 6, + /** Space between the screen edge and the first tab of the iOS 26 floating tab bar. */ + iosNativeTabBarHorizontalInset: 25, /** Material's BottomNavigationView row, without the gesture inset under it. */ androidNativeTabBarHeight: 80, /** Material's bar row (androidNativeTabBarHeight) plus the 16 dp Material keeps between a FAB and the bar. */ From 1b99159ffb4e1736227fe529eb6e219455ab87b9 Mon Sep 17 00:00:00 2001 From: sumo_slonik Date: Wed, 7 Oct 2026 14:35:47 +0200 Subject: [PATCH 67/71] refactor: review fixes, narrow overlay split out, tab options from screenOptions, Android options file, offline indicator above the mWeb bar --- .../useTabRootScreenWrapperProps/index.tsx | 3 +- .../useTabRootScreenWrapperProps/types.ts | 4 +- .../NativeTabBarOverlay.tsx | 65 ++++++++++++++++++ .../NativeTabNavigator/NativeTabLayout.tsx | 53 ++------------- .../useNativeTabBarOptions/index.android.ts | 67 +++++++++++++++++++ .../useNativeTabBarOptions/index.ts | 61 ++--------------- .../Navigators/TabNavigator.native.tsx | 20 +++--- src/pages/Search/SearchPageNarrow/index.tsx | 2 +- src/pages/settings/InitialSettingsPage.tsx | 7 ++ src/styles/index.ts | 5 ++ src/styles/variables.ts | 7 +- 11 files changed, 174 insertions(+), 120 deletions(-) create mode 100644 src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/NativeTabBarOverlay.tsx create mode 100644 src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/useNativeTabBarOptions/index.android.ts diff --git a/src/components/Navigation/TabBarBottomContent/useTabRootScreenWrapperProps/index.tsx b/src/components/Navigation/TabBarBottomContent/useTabRootScreenWrapperProps/index.tsx index 5621ca35e2fd..5bffa89b7a7c 100644 --- a/src/components/Navigation/TabBarBottomContent/useTabRootScreenWrapperProps/index.tsx +++ b/src/components/Navigation/TabBarBottomContent/useTabRootScreenWrapperProps/index.tsx @@ -11,7 +11,7 @@ import TabBarBottomContent from '..'; /** * On narrow layouts the floating tab bar is laid over the bottom of a tab root screen, so the content runs under it - * down to the bottom edge of the screen. Each tab root's list ends with room for the bar. + * down to the bottom edge of the screen. Each tab root's list and the offline indicator end with room for the bar. */ function useTabRootScreenWrapperProps(selectedTab: TabBarBottomContentProps['selectedTab']): TabRootScreenWrapperProps { const {shouldUseNarrowLayout} = useResponsiveLayout(); @@ -20,6 +20,7 @@ function useTabRootScreenWrapperProps(selectedTab: TabBarBottomContentProps['sel return { bottomContent: , bottomContentStyle: shouldUseNarrowLayout ? styles.floatingTabBarOverlay : styles.overflowVisible, + offlineIndicatorStyle: shouldUseNarrowLayout ? styles.floatingTabBarOfflineIndicator : undefined, }; } diff --git a/src/components/Navigation/TabBarBottomContent/useTabRootScreenWrapperProps/types.ts b/src/components/Navigation/TabBarBottomContent/useTabRootScreenWrapperProps/types.ts index dab83f0477d5..7c3807848145 100644 --- a/src/components/Navigation/TabBarBottomContent/useTabRootScreenWrapperProps/types.ts +++ b/src/components/Navigation/TabBarBottomContent/useTabRootScreenWrapperProps/types.ts @@ -1,5 +1,7 @@ import type {ScreenWrapperContainerProps} from '@components/ScreenWrapper/ScreenWrapperContainer'; +import type {ScreenWrapperOfflineIndicatorsProps} from '@components/ScreenWrapper/ScreenWrapperOfflineIndicators'; -type TabRootScreenWrapperProps = Pick; +type TabRootScreenWrapperProps = Pick & + Pick; export default TabRootScreenWrapperProps; diff --git a/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/NativeTabBarOverlay.tsx b/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/NativeTabBarOverlay.tsx new file mode 100644 index 000000000000..827233ecc9b2 --- /dev/null +++ b/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/NativeTabBarOverlay.tsx @@ -0,0 +1,65 @@ +import FloatingCameraButton from '@components/FloatingCameraButton'; +import FloatingGPSButton from '@components/FloatingGPSButton'; +import DebugTabView from '@components/Navigation/DebugTabView'; +import NAVIGATION_TABS from '@components/Navigation/NavigationTabBar/NAVIGATION_TABS'; +import ROUTE_TO_NAVIGATION_TAB from '@components/Navigation/NavigationTabBar/ROUTE_TO_NAVIGATION_TAB'; + +import useOnyx from '@hooks/useOnyx'; +import useSafeAreaInsets from '@hooks/useSafeAreaInsets'; +import useThemeStyles from '@hooks/useThemeStyles'; + +import NavigationTabBarFloatingActionButton from '@pages/inbox/sidebar/NavigationTabBarFloatingActionButton'; + +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import SCREENS from '@src/SCREENS'; + +import React from 'react'; +import {StyleSheet, View} from 'react-native'; +import Animated, {FadeIn, FadeOut} from 'react-native-reanimated'; + +import type {NativeTabLayoutProps} from './NativeTabLayout'; + +import NativeTabBarShadow from './NativeTabBarShadow'; +import {getFloatingButtonsBottom} from './useNativeTabBarOptions'; + +/** What a narrow layout draws over the tabs along with the native bar, kept apart so wide layouts skip its hooks. */ +function NativeTabBarOverlay({state, descriptors}: Pick) { + const [isDebugModeEnabled] = useOnyx(ONYXKEYS.IS_DEBUG_MODE_ENABLED); + const styles = useThemeStyles(); + const {bottom: bottomInset} = useSafeAreaInsets(); + const activeRoute = state.routes[state.index]; + const selectedTab = ROUTE_TO_NAVIGATION_TAB[activeRoute?.name ?? SCREENS.HOME] ?? NAVIGATION_TABS.HOME; + // The buttons follow whatever the navigator decided for the bar itself. + const shouldShowNativeTabBar = !!activeRoute && descriptors[activeRoute.key]?.options.tabBarStyle?.display !== 'none'; + + return ( + <> + {!!isDebugModeEnabled && shouldShowNativeTabBar && } + {shouldShowNativeTabBar && ( + // The shadow and the buttons belong to the bar, so they fade with it rather than appearing in place. + // They leave faster than they come in, so they stop covering a bar that is still sliding out. + + + + + + + + + + + )} + + ); +} + +export default NativeTabBarOverlay; diff --git a/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/NativeTabLayout.tsx b/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/NativeTabLayout.tsx index 11884432fdb6..777c74986afd 100644 --- a/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/NativeTabLayout.tsx +++ b/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/NativeTabLayout.tsx @@ -1,43 +1,21 @@ -import FloatingCameraButton from '@components/FloatingCameraButton'; -import FloatingGPSButton from '@components/FloatingGPSButton'; -import DebugTabView from '@components/Navigation/DebugTabView'; -import NAVIGATION_TABS from '@components/Navigation/NavigationTabBar/NAVIGATION_TABS'; -import ROUTE_TO_NAVIGATION_TAB from '@components/Navigation/NavigationTabBar/ROUTE_TO_NAVIGATION_TAB'; - -import useOnyx from '@hooks/useOnyx'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; -import useSafeAreaInsets from '@hooks/useSafeAreaInsets'; import useThemeStyles from '@hooks/useThemeStyles'; import TabNavigatorBar from '@libs/Navigation/AppNavigator/Navigators/TabNavigatorBar'; -import NavigationTabBarFloatingActionButton from '@pages/inbox/sidebar/NavigationTabBarFloatingActionButton'; - -import CONST from '@src/CONST'; -import ONYXKEYS from '@src/ONYXKEYS'; -import SCREENS from '@src/SCREENS'; - import type {NativeBottomTabNavigatorProps} from '@react-navigation/bottom-tabs/unstable'; import React from 'react'; -import {StyleSheet, View} from 'react-native'; -import Animated, {FadeIn, FadeOut} from 'react-native-reanimated'; +import {View} from 'react-native'; -import NativeTabBarShadow from './NativeTabBarShadow'; +import NativeTabBarOverlay from './NativeTabBarOverlay'; import TabPressListeners from './TabPressListeners'; -import {getFloatingButtonsBottom} from './useNativeTabBarOptions'; type NativeTabLayoutProps = Parameters>[0]; function NativeTabLayout({children, state, descriptors}: NativeTabLayoutProps) { const {shouldUseNarrowLayout} = useResponsiveLayout(); - const [isDebugModeEnabled] = useOnyx(ONYXKEYS.IS_DEBUG_MODE_ENABLED); const styles = useThemeStyles(); - const {bottom: bottomInset} = useSafeAreaInsets(); - const activeRoute = state.routes[state.index]; - const selectedTab = ROUTE_TO_NAVIGATION_TAB[activeRoute?.name ?? SCREENS.HOME] ?? NAVIGATION_TABS.HOME; - // The buttons follow whatever the navigator decided for the bar itself. - const shouldShowNativeTabBar = shouldUseNarrowLayout && !!activeRoute && descriptors[activeRoute.key]?.options.tabBarStyle?.display !== 'none'; // The tabs stay at the same place in the tree in both layouts, so crossing the breakpoint does not remount them. return ( @@ -48,28 +26,11 @@ function NativeTabLayout({children, state, descriptors}: NativeTabLayoutProps) { state={state} descriptors={descriptors} /> - {!!isDebugModeEnabled && shouldShowNativeTabBar && } - {shouldShowNativeTabBar && ( - // The shadow and the buttons belong to the bar, so they fade with it rather than appearing in place. - // They leave faster than they come in, so they stop covering a bar that is still sliding out. - - - - - - - - - - + {shouldUseNarrowLayout && ( + )} ); diff --git a/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/useNativeTabBarOptions/index.android.ts b/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/useNativeTabBarOptions/index.android.ts new file mode 100644 index 000000000000..41a9ac389e2e --- /dev/null +++ b/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/useNativeTabBarOptions/index.android.ts @@ -0,0 +1,67 @@ +import useTheme from '@hooks/useTheme'; +import useThemeStyles from '@hooks/useThemeStyles'; + +import type {NativeTabName} from '@libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/NATIVE_TAB_GLYPHS'; +import getTabIcon from '@libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/tabIconRasterizer'; +import type {TabIconLayout} from '@libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/tabIconRasterizer'; +import useTabAvatarImage from '@libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/useTabAvatarImage'; + +import variables from '@styles/variables'; + +import NAVIGATORS from '@src/NAVIGATORS'; + +import type {NativeBottomTabNavigationOptions} from '@react-navigation/bottom-tabs/unstable'; + +import type NativeTabBarOptionsParams from './types'; + +/** Material's bar sits above the system gesture inset, so the floating buttons clear both. */ +const getFloatingButtonsBottom = (bottomInset: number) => variables.androidNativeTabBarFloatingButtonsBottom + bottomInset; + +/** + * Material draws each icon in its fixed icon slot, with a label of its own. The status dot is drawn into the icon rather + * than as a Material badge, because RNScreens paints every badge in the badge color of the selected tab. + */ +const TAB_ICON_LAYOUT: TabIconLayout = { + glyphSize: variables.iconBottomBar, + avatarSize: variables.iconBottomBar, + dotRadius: variables.nativeTabIconDotRadius, + dotCutout: variables.nativeTabIconDotCutout, + labelGap: 0, + labelFontSize: 0, +}; + +function useNativeTabBarOptions({shouldShowNativeTabBar, isAccountAvatarShown, dotColors, tabLabels}: NativeTabBarOptionsParams) { + const theme = useTheme(); + const styles = useThemeStyles(); + const avatar = useTabAvatarImage(isAccountAvatarShown); + + // The tint only reaches the labels, since the icons arrive already recolored (react-native-screens patch 003). + const screenOptions: NativeBottomTabNavigationOptions = { + headerShown: false, + tabBarActiveTintColor: theme.text, + tabBarInactiveTintColor: theme.textSupporting, + tabBarLabelStyle: {fontFamily: styles.textSmall.fontFamily, fontSize: styles.textSmall.fontSize}, + tabBarActiveIndicatorColor: theme.androidTabBarActiveIndicatorBG, + tabBarLabelVisibilityMode: 'labeled', + // Every tab shares one style, so the bar reads the current visibility in the same render that changed it. + tabBarStyle: {display: shouldShowNativeTabBar ? 'flex' : 'none', backgroundColor: theme.appBG}, + }; + + const getTabOptions = (name: NativeTabName): NativeBottomTabNavigationOptions => { + // The active indicator pill marks the selected tab, so the avatar is the same image in both selection states. + const tabAvatar = name === NAVIGATORS.SETTINGS_SPLIT_NAVIGATOR ? avatar : undefined; + const dotColor = dotColors[name]; + const inactiveIcon = getTabIcon(TAB_ICON_LAYOUT, {name, color: theme.icon, avatar: tabAvatar, dotColor}); + const activeIcon = getTabIcon(TAB_ICON_LAYOUT, {name, color: theme.iconMenu, avatar: tabAvatar, dotColor}); + return { + tabBarLabel: tabLabels[name], + // A function, because React Navigation derives the selected icon only from a function. + tabBarIcon: inactiveIcon && activeIcon ? ({focused}) => (focused ? activeIcon : inactiveIcon) : undefined, + }; + }; + + return {screenOptions, getTabOptions}; +} + +export default useNativeTabBarOptions; +export {getFloatingButtonsBottom}; diff --git a/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/useNativeTabBarOptions/index.ts b/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/useNativeTabBarOptions/index.ts index 41a9ac389e2e..2401d88a0b31 100644 --- a/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/useNativeTabBarOptions/index.ts +++ b/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/useNativeTabBarOptions/index.ts @@ -1,67 +1,18 @@ -import useTheme from '@hooks/useTheme'; -import useThemeStyles from '@hooks/useThemeStyles'; - import type {NativeTabName} from '@libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/NATIVE_TAB_GLYPHS'; -import getTabIcon from '@libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/tabIconRasterizer'; -import type {TabIconLayout} from '@libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/tabIconRasterizer'; -import useTabAvatarImage from '@libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/useTabAvatarImage'; - -import variables from '@styles/variables'; - -import NAVIGATORS from '@src/NAVIGATORS'; import type {NativeBottomTabNavigationOptions} from '@react-navigation/bottom-tabs/unstable'; import type NativeTabBarOptionsParams from './types'; -/** Material's bar sits above the system gesture inset, so the floating buttons clear both. */ -const getFloatingButtonsBottom = (bottomInset: number) => variables.androidNativeTabBarFloatingButtonsBottom + bottomInset; - -/** - * Material draws each icon in its fixed icon slot, with a label of its own. The status dot is drawn into the icon rather - * than as a Material badge, because RNScreens paints every badge in the badge color of the selected tab. - */ -const TAB_ICON_LAYOUT: TabIconLayout = { - glyphSize: variables.iconBottomBar, - avatarSize: variables.iconBottomBar, - dotRadius: variables.nativeTabIconDotRadius, - dotCutout: variables.nativeTabIconDotCutout, - labelGap: 0, - labelFontSize: 0, +type UseNativeTabBarOptions = (params: NativeTabBarOptionsParams) => { + screenOptions: NativeBottomTabNavigationOptions; + getTabOptions: (name: NativeTabName) => NativeBottomTabNavigationOptions; }; -function useNativeTabBarOptions({shouldShowNativeTabBar, isAccountAvatarShown, dotColors, tabLabels}: NativeTabBarOptionsParams) { - const theme = useTheme(); - const styles = useThemeStyles(); - const avatar = useTabAvatarImage(isAccountAvatarShown); - - // The tint only reaches the labels, since the icons arrive already recolored (react-native-screens patch 003). - const screenOptions: NativeBottomTabNavigationOptions = { - headerShown: false, - tabBarActiveTintColor: theme.text, - tabBarInactiveTintColor: theme.textSupporting, - tabBarLabelStyle: {fontFamily: styles.textSmall.fontFamily, fontSize: styles.textSmall.fontSize}, - tabBarActiveIndicatorColor: theme.androidTabBarActiveIndicatorBG, - tabBarLabelVisibilityMode: 'labeled', - // Every tab shares one style, so the bar reads the current visibility in the same render that changed it. - tabBarStyle: {display: shouldShowNativeTabBar ? 'flex' : 'none', backgroundColor: theme.appBG}, - }; - - const getTabOptions = (name: NativeTabName): NativeBottomTabNavigationOptions => { - // The active indicator pill marks the selected tab, so the avatar is the same image in both selection states. - const tabAvatar = name === NAVIGATORS.SETTINGS_SPLIT_NAVIGATOR ? avatar : undefined; - const dotColor = dotColors[name]; - const inactiveIcon = getTabIcon(TAB_ICON_LAYOUT, {name, color: theme.icon, avatar: tabAvatar, dotColor}); - const activeIcon = getTabIcon(TAB_ICON_LAYOUT, {name, color: theme.iconMenu, avatar: tabAvatar, dotColor}); - return { - tabBarLabel: tabLabels[name], - // A function, because React Navigation derives the selected icon only from a function. - tabBarIcon: inactiveIcon && activeIcon ? ({focused}) => (focused ? activeIcon : inactiveIcon) : undefined, - }; - }; +/** Only iOS and Android have a native tab bar, so other platforms get no options. */ +const useNativeTabBarOptions: UseNativeTabBarOptions = () => ({screenOptions: {}, getTabOptions: () => ({})}); - return {screenOptions, getTabOptions}; -} +const getFloatingButtonsBottom = (bottomInset: number) => bottomInset; export default useNativeTabBarOptions; export {getFloatingButtonsBottom}; diff --git a/src/libs/Navigation/AppNavigator/Navigators/TabNavigator.native.tsx b/src/libs/Navigation/AppNavigator/Navigators/TabNavigator.native.tsx index 6191e9d9b678..6972f020693c 100644 --- a/src/libs/Navigation/AppNavigator/Navigators/TabNavigator.native.tsx +++ b/src/libs/Navigation/AppNavigator/Navigators/TabNavigator.native.tsx @@ -39,51 +39,49 @@ function TabNavigator() { useNativeTabNavigator(); const {screenOptions, getTabOptions} = useNativeTabBarOptions({shouldShowNativeTabBar, isAccountAvatarShown, dotColors, tabLabels}); // A tab with no bar item draws no icon. - const getOptions = (name: NativeTabName) => - name === tabWithoutBarItem - ? HIDDEN_TAB_OPTIONS - : {...getTabOptions(name), tabBarSelectionEnabled: isNativeTabSelectionEnabled(name, {isAnonymousUser, isInboxAtChatList, isWorkspacesTabRestored})}; + const getScreenOptions = ({route}: {route: {name: NativeTabName}}) => + route.name === tabWithoutBarItem + ? {...screenOptions, ...HIDDEN_TAB_OPTIONS} + : { + ...screenOptions, + ...getTabOptions(route.name), + tabBarSelectionEnabled: isNativeTabSelectionEnabled(route.name, {isAnonymousUser, isInboxAtChatList, isWorkspacesTabRestored}), + }; return ( ); diff --git a/src/pages/Search/SearchPageNarrow/index.tsx b/src/pages/Search/SearchPageNarrow/index.tsx index c565d5c75d6b..7a7be17a0238 100644 --- a/src/pages/Search/SearchPageNarrow/index.tsx +++ b/src/pages/Search/SearchPageNarrow/index.tsx @@ -265,9 +265,9 @@ function SearchPageNarrow({ {!isMobileSelectionModeEnabled ? ( diff --git a/src/pages/settings/InitialSettingsPage.tsx b/src/pages/settings/InitialSettingsPage.tsx index f4989443a0b0..310ed058aabd 100755 --- a/src/pages/settings/InitialSettingsPage.tsx +++ b/src/pages/settings/InitialSettingsPage.tsx @@ -61,6 +61,7 @@ function InitialSettingsPage({currentUserPersonalDetails}: InitialSettingsPagePr 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, Home replaces Account so that going back does not reopen it. const goBackFromAccount = () => { @@ -68,15 +69,19 @@ function InitialSettingsPage({currentUserPersonalDetails}: InitialSettingsPagePr navigation.goBack(); return; } + Navigation.navigate(ROUTES.HOME, {forceReplace: true}); }; + 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); @@ -92,10 +97,12 @@ function InitialSettingsPage({currentUserPersonalDetails}: InitialSettingsPagePr }, [hasAccountBeenSwitched]); const hasAccountBeenShown = useHasTabBeenShown(NAVIGATORS.SETTINGS_SPLIT_NAVIGATOR); + useEffect(() => { if (!hasAccountBeenShown) { return; } + openInitialSettingsPage(); }, [hasAccountBeenShown]); diff --git a/src/styles/index.ts b/src/styles/index.ts index 8551d8717e9d..7c69917bb011 100644 --- a/src/styles/index.ts +++ b/src/styles/index.ts @@ -790,6 +790,11 @@ const staticStyles = (theme: ThemeColors) => overflow: 'visible', }, + // The floating bar is laid over the bottom of a tab root screen, so the offline indicator sits above it. + floatingTabBarOfflineIndicator: { + marginBottom: variables.floatingTabBarHeight + variables.floatingTabBarBottomInset, + }, + // The gap between the floating bar and the bottom edge of the screen. floatingTabBarBottomInset: { paddingBottom: variables.floatingTabBarBottomInset, diff --git a/src/styles/variables.ts b/src/styles/variables.ts index d5eb6116b771..02fa82cd4e6e 100644 --- a/src/styles/variables.ts +++ b/src/styles/variables.ts @@ -29,16 +29,13 @@ const avatarSizes = { avatarBorderWidthLarge: 4, } as const; -const floatingTabBarHeight = 60; -const floatingTabBarBottomInset = 8; - export default { bottomTabHeight: 72, - floatingTabBarHeight, + floatingTabBarHeight: 60, floatingTabBarHorizontalInset: 16, // How far the selected pill reaches past its tab on each side, whatever the label's length. floatingTabBarSelectedOverhang: 4, - floatingTabBarBottomInset, + floatingTabBarBottomInset: 8, // styles.p3 (12) on each side of the DebugTabView row plus the View button (componentSizeNormal). debugTabViewHeight: 64, contentHeaderHeight: getValueUsingPixelRatio(72, 100), From b62774122e5cb7114e7746176d8262e639e9fe57 Mon Sep 17 00:00:00 2001 From: sumo_slonik Date: Wed, 7 Oct 2026 15:07:16 +0200 Subject: [PATCH 68/71] chore: bump bottom-tabs to 7.18.16 and native to 7.3.16, the newest without core 7.21.13 typing getRootState as possibly undefined --- package-lock.json | 40 ++++++++++++------- package.json | 5 ++- ...-failing-jest-by-disabling-esmodule.patch} | 0 ...2+active-indicator-color-precedence.patch} | 0 ...m-tabs+7.18.16+003+hidden-tab-items.patch} | 22 +++++----- ...-failing-jest-by-disabling-esmodule.patch} | 0 ...avigation+native+7.3.16+001+initial.patch} | 39 +++++++++--------- ...-failing-jest-by-disabling-esmodule.patch} | 0 ...istory-go-popstate-fallback-timeout.patch} | 8 ++-- patches/react-navigation/details.md | 15 +++---- 10 files changed, 71 insertions(+), 58 deletions(-) rename patches/react-navigation/{@react-navigation+bottom-tabs+7.16.2+001+fix-failing-jest-by-disabling-esmodule.patch => @react-navigation+bottom-tabs+7.18.16+001+fix-failing-jest-by-disabling-esmodule.patch} (100%) rename patches/react-navigation/{@react-navigation+bottom-tabs+7.16.2+002+active-indicator-color-precedence.patch => @react-navigation+bottom-tabs+7.18.16+002+active-indicator-color-precedence.patch} (100%) rename patches/react-navigation/{@react-navigation+bottom-tabs+7.16.2+003+hidden-tab-items.patch => @react-navigation+bottom-tabs+7.18.16+003+hidden-tab-items.patch} (92%) rename patches/react-navigation/{@react-navigation+elements+2.9.19+001+fix-failing-jest-by-disabling-esmodule.patch => @react-navigation+elements+2.9.38+001+fix-failing-jest-by-disabling-esmodule.patch} (100%) rename patches/react-navigation/{@react-navigation+native+7.2.6+001+initial.patch => @react-navigation+native+7.3.16+001+initial.patch} (86%) rename patches/react-navigation/{@react-navigation+native+7.2.6+002+fix-failing-jest-by-disabling-esmodule.patch => @react-navigation+native+7.3.16+002+fix-failing-jest-by-disabling-esmodule.patch} (100%) rename patches/react-navigation/{@react-navigation+native+7.2.6+003+increase-history-go-popstate-fallback-timeout.patch => @react-navigation+native+7.3.16+003+increase-history-go-popstate-fallback-timeout.patch} (87%) diff --git a/package-lock.json b/package-lock.json index 57c19f91599b..032b736a5426 100644 --- a/package-lock.json +++ b/package-lock.json @@ -45,10 +45,10 @@ "@react-native-firebase/app": "^22.2.1", "@react-native-google-signin/google-signin": "^10.0.1", "@react-native-picker/picker": "2.11.3", - "@react-navigation/bottom-tabs": "7.16.2", + "@react-navigation/bottom-tabs": "7.18.16", "@react-navigation/core": "7.21.12", "@react-navigation/material-top-tabs": "7.4.19", - "@react-navigation/native": "7.2.6", + "@react-navigation/native": "7.3.16", "@react-navigation/native-stack": "7.14.5", "@react-navigation/stack": "7.8.5", "@react-ng/bounds-observer": "^0.2.1", @@ -15105,17 +15105,17 @@ } }, "node_modules/@react-navigation/bottom-tabs": { - "version": "7.16.2", - "resolved": "https://registry.npmjs.org/@react-navigation/bottom-tabs/-/bottom-tabs-7.16.2.tgz", - "integrity": "sha512-Lbp++BGMc7SQXnyKuO/JrQJIhFH0zyB5v4kIEbnzDJLJfgubd5hoSe+QfCqy4YHfLA4phC4Xf/6Q2Ic8x7datQ==", + "version": "7.18.16", + "resolved": "https://registry.npmjs.org/@react-navigation/bottom-tabs/-/bottom-tabs-7.18.16.tgz", + "integrity": "sha512-5tBFuxvsCQBvYauDZK6u/uPdD86i/caWgBzABlZfTh8W2J7YZSHjT9SWOsquSMmR6dpcNj6idMd4utCNzuOnZg==", "license": "MIT", "dependencies": { - "@react-navigation/elements": "^2.9.19", + "@react-navigation/elements": "^2.9.38", "color": "^4.2.3", "sf-symbols-typescript": "^2.1.0" }, "peerDependencies": { - "@react-navigation/native": "^7.2.5", + "@react-navigation/native": "^7.3.16", "react": ">= 18.2.0", "react-native": "*", "react-native-safe-area-context": ">= 4.0.0", @@ -15163,9 +15163,9 @@ } }, "node_modules/@react-navigation/elements": { - "version": "2.9.19", - "resolved": "https://registry.npmjs.org/@react-navigation/elements/-/elements-2.9.19.tgz", - "integrity": "sha512-gBUvCZuUkOGw1KpLQEZIkByUz8RYPwXeoA6mZFJy9K1mxd8GdqHDMFCIoB0lfPz9rgrHj99RvtdlGZ/ZzkZv2A==", + "version": "2.9.38", + "resolved": "https://registry.npmjs.org/@react-navigation/elements/-/elements-2.9.38.tgz", + "integrity": "sha512-M/2HWDiSO4zO7VyfAnovrTPWOAzTC+1DnVT7ZWZW93+/KVRF9OYlqixIUpPenQ0iQh2j1EaQ4zOe/134Arisqw==", "license": "MIT", "dependencies": { "color": "^4.2.3", @@ -15174,7 +15174,7 @@ }, "peerDependencies": { "@react-native-masked-view/masked-view": ">= 0.2.0", - "@react-navigation/native": "^7.2.5", + "@react-navigation/native": "^7.3.16", "react": ">= 18.2.0", "react-native": "*", "react-native-safe-area-context": ">= 4.0.0" @@ -15204,15 +15204,16 @@ } }, "node_modules/@react-navigation/native": { - "version": "7.2.6", - "resolved": "https://registry.npmjs.org/@react-navigation/native/-/native-7.2.6.tgz", - "integrity": "sha512-Lvxb8ShLnsuK1pjFoN1jym/8c68IvJQlkrDhQXDJq10o3lU36Oxsmc9BdtN+Z4yJRYSbx3Qh+Wpom75QehC4og==", + "version": "7.3.16", + "resolved": "https://registry.npmjs.org/@react-navigation/native/-/native-7.3.16.tgz", + "integrity": "sha512-Wy/6mai2HpA5AEVFk7JFfvv4RUArwuN2UenP/fc3NK7kOyfSlNu1tmb/3JDPpMzPIRbF1MPB8PKAHjVB29qNEQ==", "license": "MIT", "dependencies": { - "@react-navigation/core": "^7.18.0", + "@react-navigation/core": "^7.21.12", "escape-string-regexp": "^4.0.0", "fast-deep-equal": "^3.1.3", "nanoid": "^3.3.11", + "standard-navigation": "^0.0.8", "use-latest-callback": "^0.2.4" }, "peerDependencies": { @@ -40264,6 +40265,15 @@ "node": ">=8" } }, + "node_modules/standard-navigation": { + "version": "0.0.8", + "resolved": "https://registry.npmjs.org/standard-navigation/-/standard-navigation-0.0.8.tgz", + "integrity": "sha512-TyVbo7INUDWtsUWDFn8RR7kwR87U0S4xHfLfbbnyeC581TmmyqQ+eM+nPw8rQTSD8QitRVcYfPaSHr/QJiUy1g==", + "license": "MIT", + "peerDependencies": { + "react": "*" + } + }, "node_modules/statuses": { "version": "2.0.1", "license": "MIT", diff --git a/package.json b/package.json index 4b2b5480cf40..afe4c68bf06b 100644 --- a/package.json +++ b/package.json @@ -121,10 +121,10 @@ "@react-native-firebase/app": "^22.2.1", "@react-native-google-signin/google-signin": "^10.0.1", "@react-native-picker/picker": "2.11.3", - "@react-navigation/bottom-tabs": "7.16.2", + "@react-navigation/bottom-tabs": "7.18.16", "@react-navigation/core": "7.21.12", "@react-navigation/material-top-tabs": "7.4.19", - "@react-navigation/native": "7.2.6", + "@react-navigation/native": "7.3.16", "@react-navigation/native-stack": "7.14.5", "@react-navigation/stack": "7.8.5", "@react-ng/bounds-observer": "^0.2.1", @@ -418,6 +418,7 @@ "node-abi": "^3.78.0", "pdfjs-dist": "^5.5.207", "lightningcss": "1.30.1", + "@react-navigation/elements": "2.9.38", "@fullstory/react-native": { "expo": "57.0.8" }, diff --git a/patches/react-navigation/@react-navigation+bottom-tabs+7.16.2+001+fix-failing-jest-by-disabling-esmodule.patch b/patches/react-navigation/@react-navigation+bottom-tabs+7.18.16+001+fix-failing-jest-by-disabling-esmodule.patch similarity index 100% rename from patches/react-navigation/@react-navigation+bottom-tabs+7.16.2+001+fix-failing-jest-by-disabling-esmodule.patch rename to patches/react-navigation/@react-navigation+bottom-tabs+7.18.16+001+fix-failing-jest-by-disabling-esmodule.patch diff --git a/patches/react-navigation/@react-navigation+bottom-tabs+7.16.2+002+active-indicator-color-precedence.patch b/patches/react-navigation/@react-navigation+bottom-tabs+7.18.16+002+active-indicator-color-precedence.patch similarity index 100% rename from patches/react-navigation/@react-navigation+bottom-tabs+7.16.2+002+active-indicator-color-precedence.patch rename to patches/react-navigation/@react-navigation+bottom-tabs+7.18.16+002+active-indicator-color-precedence.patch diff --git a/patches/react-navigation/@react-navigation+bottom-tabs+7.16.2+003+hidden-tab-items.patch b/patches/react-navigation/@react-navigation+bottom-tabs+7.18.16+003+hidden-tab-items.patch similarity index 92% rename from patches/react-navigation/@react-navigation+bottom-tabs+7.16.2+003+hidden-tab-items.patch rename to patches/react-navigation/@react-navigation+bottom-tabs+7.18.16+003+hidden-tab-items.patch index 0363ebe03089..9dd8d44e6bd1 100644 --- a/patches/react-navigation/@react-navigation+bottom-tabs+7.16.2+003+hidden-tab-items.patch +++ b/patches/react-navigation/@react-navigation+bottom-tabs+7.18.16+003+hidden-tab-items.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 09ec68f..6645270 100644 +index e6ea55b..4d577fe 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 @@ -4,11 +4,11 @@ import { getLabel, SafeAreaProviderCompat, Screen as ScreenContent } from '@reac @@ -59,9 +59,9 @@ index 09ec68f..6645270 100644 - tabBarHidden: shouldHideTabBar, + tabBarHidden: shouldHideTabBar || isFocusedRouteHidden, colorScheme: dark ? 'dark' : 'light', - ios: { - bottomAccessory: bottomAccessory ? environment => bottomAccessory({ -@@ -210,6 +222,9 @@ export function NativeBottomTabView({ + nativeContainerStyle: { + backgroundColor: colors.background +@@ -213,6 +225,9 @@ export function NativeBottomTabView({ tabBarTintColor: activeTintColor }, children: state.routes.map((route, index) => { @@ -71,7 +71,7 @@ index 09ec68f..6645270 100644 const { options, render, -@@ -221,6 +236,7 @@ export function NativeBottomTabView({ +@@ -224,6 +239,7 @@ export function NativeBottomTabView({ title, lazy = false, tabBarLabel, @@ -79,15 +79,15 @@ index 09ec68f..6645270 100644 tabBarSelectionEnabled, tabBarBadgeStyle, tabBarIcon, -@@ -287,6 +303,7 @@ export function NativeBottomTabView({ +@@ -290,6 +306,7 @@ export function NativeBottomTabView({ preventNativeSelection: tabBarSelectionEnabled === false, badgeValue: tabBarBadge?.toString(), title: tabTitle, + tabBarItemAccessibilityLabel: tabBarAccessibilityLabel, - specialEffects: { - repeatedTabSelection: { - popToRoot: true, -@@ -363,9 +380,40 @@ export function NativeBottomTabView({ + nativeContainerStyle: { + backgroundColor: colors.background + }, +@@ -369,9 +386,40 @@ export function NativeBottomTabView({ }) }, route.key); }) @@ -130,7 +130,7 @@ index 09ec68f..6645270 100644 isFocused, route, 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 5a502c2..a2f2e85 100644 +index ac25ed1..c05b04b 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 @@ -78,6 +78,15 @@ type IconIOSSfSymbol = { diff --git a/patches/react-navigation/@react-navigation+elements+2.9.19+001+fix-failing-jest-by-disabling-esmodule.patch b/patches/react-navigation/@react-navigation+elements+2.9.38+001+fix-failing-jest-by-disabling-esmodule.patch similarity index 100% rename from patches/react-navigation/@react-navigation+elements+2.9.19+001+fix-failing-jest-by-disabling-esmodule.patch rename to patches/react-navigation/@react-navigation+elements+2.9.38+001+fix-failing-jest-by-disabling-esmodule.patch diff --git a/patches/react-navigation/@react-navigation+native+7.2.6+001+initial.patch b/patches/react-navigation/@react-navigation+native+7.3.16+001+initial.patch similarity index 86% rename from patches/react-navigation/@react-navigation+native+7.2.6+001+initial.patch rename to patches/react-navigation/@react-navigation+native+7.3.16+001+initial.patch index 36dc2bcd4e8c..123cb28cce74 100644 --- a/patches/react-navigation/@react-navigation+native+7.2.6+001+initial.patch +++ b/patches/react-navigation/@react-navigation+native+7.3.16+001+initial.patch @@ -1,5 +1,5 @@ diff --git a/node_modules/@react-navigation/native/lib/module/createMemoryHistory.js b/node_modules/@react-navigation/native/lib/module/createMemoryHistory.js -index 2ca4c08..0c204b9 100644 +index 58a543b..c475462 100644 --- a/node_modules/@react-navigation/native/lib/module/createMemoryHistory.js +++ b/node_modules/@react-navigation/native/lib/module/createMemoryHistory.js @@ -1,6 +1,7 @@ @@ -7,7 +7,7 @@ index 2ca4c08..0c204b9 100644 import { nanoid } from 'nanoid/non-secure'; +import { findFocusedRouteKey } from "./findFocusedRouteKey.js"; - const getPathWithoutHash = path => path.split('#')[0]; + const getPathWithoutHash = path => path.split('#')[0] ?? ''; export function createMemoryHistory() { let index = 0; @@ -19,6 +20,9 @@ export function createMemoryHistory() { @@ -20,7 +20,7 @@ index 2ca4c08..0c204b9 100644 get index() { // We store an id in the state instead of an index // Index could get out of sync with in-memory values if page reloads -@@ -33,14 +37,15 @@ export function createMemoryHistory() { +@@ -33,7 +37,8 @@ export function createMemoryHistory() { return items[index]; }, backIndex({ @@ -30,15 +30,16 @@ index 2ca4c08..0c204b9 100644 }) { const pathWithoutHash = getPathWithoutHash(path); - // We need to find the index from the element before current to get closest path to go back to - for (let i = index - 1; i >= 0; i--) { - const item = items[i]; +@@ -43,7 +48,7 @@ export function createMemoryHistory() { + if (item == null) { + continue; + } - if (item.path === pathWithoutHash) { + if (item.path === pathWithoutHash && findFocusedRouteKey(item.state) === findFocusedRouteKey(state)) { return i; } } -@@ -82,7 +87,7 @@ export function createMemoryHistory() { +@@ -85,7 +90,7 @@ export function createMemoryHistory() { // Need to keep the hash part of the path if there was no previous history entry // or the previous history entry had the same path @@ -62,18 +63,18 @@ index 0000000..290e96f +} +//# sourceMappingURL=findFocusedRouteKey.js.map diff --git a/node_modules/@react-navigation/native/lib/module/useLinking.js b/node_modules/@react-navigation/native/lib/module/useLinking.js -index 6005f78..a932e78 100644 +index 66b73b5..6c201fd 100644 --- a/node_modules/@react-navigation/native/lib/module/useLinking.js +++ b/node_modules/@react-navigation/native/lib/module/useLinking.js -@@ -4,6 +4,7 @@ import { findFocusedRoute, getActionFromState as getActionFromStateDefault, getP +@@ -4,6 +4,7 @@ import { CommonActions, findFocusedRoute, getActionFromState as getActionFromSta import isEqual from 'fast-deep-equal'; import * as React from 'react'; import { createMemoryHistory } from "./createMemoryHistory.js"; +import { findFocusedRouteKey } from "./findFocusedRouteKey.js"; import { ServerContext } from "./ServerContext.js"; - const getRoutesUntilIndex = state => state.routes.slice(0, state.index + 1); -@@ -65,6 +66,43 @@ export const series = cb => { + /** +@@ -114,6 +115,43 @@ export const series = cb => { return callback; }; const linkingHandlers = []; @@ -117,17 +118,17 @@ index 6005f78..a932e78 100644 export function useLinking(ref, { enabled = true, config, -@@ -298,6 +336,9 @@ export function useLinking(ref, { - // Otherwise it's likely a change triggered by `popstate` - path !== pendingPath) { - const historyDelta = getHistoryLength(focusedState) - getHistoryLength(previousFocusedState); +@@ -409,6 +447,9 @@ export function useLinking(ref, { + const historyDelta = getHistoryLength(focusedState) - getHistoryLength(previousFocusedState) - ( + // Subtract the delta already applied by the browser to sync only the remaining changes + pendingPopStateDelta ?? 0); + + // The historyDelta and historyDeltaByKeys may differ if the new state has an entry that didn't exist in previous state -+ const historyDeltaByKeys = getHistoryDeltaByKeys(focusedState, previousFocusedState); ++ const historyDeltaByKeys = getHistoryDeltaByKeys(focusedState, previousFocusedState) - (pendingPopStateDelta ?? 0); if (historyDelta > 0) { // If history length is increased, we should pushState // Note that path might not actually change here, for example, drawer open should pushState -@@ -309,7 +350,8 @@ export function useLinking(ref, { +@@ -420,7 +461,8 @@ export function useLinking(ref, { // If history length is decreased, i.e. entries were removed, we want to go back const nextIndex = history.backIndex({ @@ -137,7 +138,7 @@ index 6005f78..a932e78 100644 }); const currentIndex = history.index; try { -@@ -318,11 +360,26 @@ export function useLinking(ref, { +@@ -429,11 +471,26 @@ export function useLinking(ref, { history.get(nextIndex)) { // An existing entry for this path exists and it's less than current index, go back to that await history.go(nextIndex - currentIndex); @@ -165,7 +166,7 @@ index 6005f78..a932e78 100644 } // Store the updated state as well as fix the path if incorrect -@@ -335,10 +392,22 @@ export function useLinking(ref, { +@@ -446,10 +503,22 @@ export function useLinking(ref, { } } else { // If history length is unchanged, we want to replaceState diff --git a/patches/react-navigation/@react-navigation+native+7.2.6+002+fix-failing-jest-by-disabling-esmodule.patch b/patches/react-navigation/@react-navigation+native+7.3.16+002+fix-failing-jest-by-disabling-esmodule.patch similarity index 100% rename from patches/react-navigation/@react-navigation+native+7.2.6+002+fix-failing-jest-by-disabling-esmodule.patch rename to patches/react-navigation/@react-navigation+native+7.3.16+002+fix-failing-jest-by-disabling-esmodule.patch diff --git a/patches/react-navigation/@react-navigation+native+7.2.6+003+increase-history-go-popstate-fallback-timeout.patch b/patches/react-navigation/@react-navigation+native+7.3.16+003+increase-history-go-popstate-fallback-timeout.patch similarity index 87% rename from patches/react-navigation/@react-navigation+native+7.2.6+003+increase-history-go-popstate-fallback-timeout.patch rename to patches/react-navigation/@react-navigation+native+7.3.16+003+increase-history-go-popstate-fallback-timeout.patch index bda764b7f963..062073b6a427 100644 --- a/patches/react-navigation/@react-navigation+native+7.2.6+003+increase-history-go-popstate-fallback-timeout.patch +++ b/patches/react-navigation/@react-navigation+native+7.3.16+003+increase-history-go-popstate-fallback-timeout.patch @@ -1,8 +1,8 @@ diff --git a/node_modules/@react-navigation/native/lib/module/createMemoryHistory.js b/node_modules/@react-navigation/native/lib/module/createMemoryHistory.js -index 07d02ef..3f7bc78 100644 +index c475462..f8e7d1c 100644 --- a/node_modules/@react-navigation/native/lib/module/createMemoryHistory.js +++ b/node_modules/@react-navigation/native/lib/module/createMemoryHistory.js -@@ -178,10 +178,14 @@ export function createMemoryHistory() { +@@ -188,10 +188,14 @@ export function createMemoryHistory() { cb: done }); @@ -18,8 +18,8 @@ index 07d02ef..3f7bc78 100644 + // back/forward navigation and reset the app to a stale route // We're using a hacky timeout since there doesn't seem to be way to know for sure const timer = setTimeout(() => { - const foundIndex = pending.findIndex(it => it.ref === done); -@@ -190,7 +194,7 @@ export function createMemoryHistory() { + window.removeEventListener('popstate', onPopState); +@@ -201,7 +205,7 @@ export function createMemoryHistory() { pending.splice(foundIndex, 1); } index = this.index; diff --git a/patches/react-navigation/details.md b/patches/react-navigation/details.md index b186911582fb..43ccd25ca5ae 100644 --- a/patches/react-navigation/details.md +++ b/patches/react-navigation/details.md @@ -1,12 +1,12 @@ # `@react-navigation` patches ### @react-navigation+package-name+7+fix-failing-jest-by-disabling-esmodule.patch -#### [@react-navigation+bottom-tabs+7.16.2+001+fix-failing-jest-by-disabling-esmodule.patch](@react-navigation+bottom-tabs+7.16.2+001+fix-failing-jest-by-disabling-esmodule.patch) +#### [@react-navigation+bottom-tabs+7.18.16+001+fix-failing-jest-by-disabling-esmodule.patch](@react-navigation+bottom-tabs+7.18.16+001+fix-failing-jest-by-disabling-esmodule.patch) #### [@react-navigation+core+7.21.12+001+fix-failing-jest-by-disabling-esmodule.patch](@react-navigation+core+7.21.12+001+fix-failing-jest-by-disabling-esmodule.patch) -#### [@react-navigation+elements+2.9.19+001+fix-failing-jest-by-disabling-esmodule.patch](@react-navigation+elements+2.9.19+001+fix-failing-jest-by-disabling-esmodule.patch) +#### [@react-navigation+elements+2.9.38+001+fix-failing-jest-by-disabling-esmodule.patch](@react-navigation+elements+2.9.38+001+fix-failing-jest-by-disabling-esmodule.patch) #### [@react-navigation+material-top-tabs+7.4.19+001+fix-failing-jest-by-disabling-esmodule.patch](@react-navigation+material-top-tabs+7.4.19+001+fix-failing-jest-by-disabling-esmodule.patch) #### [@react-navigation+native-stack+7.14.5+002+fix-failing-jest-by-disabling-esmodule.patch](@react-navigation+native-stack+7.14.5+002+fix-failing-jest-by-disabling-esmodule.patch) -#### [@react-navigation+native+7.2.6+002+fix-failing-jest-by-disabling-esmodule.patch](@react-navigation+native+7.2.6+002+fix-failing-jest-by-disabling-esmodule.patch) +#### [@react-navigation+native+7.3.16+002+fix-failing-jest-by-disabling-esmodule.patch](@react-navigation+native+7.3.16+002+fix-failing-jest-by-disabling-esmodule.patch) #### [@react-navigation+routers+7.6.4+001+fix-failing-jest-by-disabling-esmodule.patch](@react-navigation+routers+7.6.4+001+fix-failing-jest-by-disabling-esmodule.patch) #### [@react-navigation+stack+7.8.5+004+fix-failing-jest-by-disabling-esmodule.patch](@react-navigation+stack+7.8.5+004+fix-failing-jest-by-disabling-esmodule.patch) @@ -24,7 +24,7 @@ - PR Introducing Patch: [#37891](https://github.com/Expensify/App/pull/37891) - PR Updating Patch: [#64155](https://github.com/Expensify/App/pull/64155), [#98097](https://github.com/Expensify/App/pull/98097) -### [@react-navigation+native+7.2.6+001+initial.patch](@react-navigation+native+7.2.6+001+initial.patch) +### [@react-navigation+native+7.3.16+001+initial.patch](@react-navigation+native+7.3.16+001+initial.patch) - Reason: Allows us to use some more advanced navigation actions without messing up the browser history - Upstream PR/issue: https://github.com/react-navigation/react-navigation/pull/12751 (`route.history` + `pushParams`, added upstream for this use case); originating issue https://github.com/react-navigation/react-navigation/issues/12460. On the v8 upgrade, evaluate adopting `pushParams` to shrink this patch. @@ -49,7 +49,7 @@ - PR Updating Patch: [#33280](https://github.com/Expensify/App/pull/33280) [#37421](https://github.com/Expensify/App/pull/37421) [#49539](https://github.com/Expensify/App/pull/49539) [#64155](https://github.com/Expensify/App/pull/64155) [#65119](https://github.com/Expensify/App/issues/65119) - Note: Not fully covered by the public `detachPreviousScreen` option (this also forces `activityState`). v8 replaces `detachInactiveScreens`/`detachPreviousScreen`/`freezeOnBlur` with a single `inactiveBehavior` option — re-evaluate this patch then. -### [@react-navigation+native+7.2.6+003+increase-history-go-popstate-fallback-timeout.patch](@react-navigation+native+7.2.6+003+increase-history-go-popstate-fallback-timeout.patch) +### [@react-navigation+native+7.3.16+003+increase-history-go-popstate-fallback-timeout.patch](@react-navigation+native+7.3.16+003+increase-history-go-popstate-fallback-timeout.patch) - Reason: @@ -68,8 +68,9 @@ - E/App issue: [#94571](https://github.com/Expensify/App/issues/94571) - PR Introducing Patch: [#95980](https://github.com/Expensify/App/pull/95980) - PR Updating Patch: [#101339](https://github.com/Expensify/App/pull/101339) +- Note: Upstream raised the timeout to 1000ms in `@react-navigation/native` 7.5.0, so this patch can be dropped on the bump to 7.5.0 or newer. -### [@react-navigation+bottom-tabs+7.16.2+002+active-indicator-color-precedence.patch](@react-navigation+bottom-tabs+7.16.2+002+active-indicator-color-precedence.patch) +### [@react-navigation+bottom-tabs+7.18.16+002+active-indicator-color-precedence.patch](@react-navigation+bottom-tabs+7.18.16+002+active-indicator-color-precedence.patch) - Reason: `tabBarActiveIndicatorColor` was never applied on the native Android tab bar: a missing pair of parentheses made `??` bind to the `typeof` check, so the indicator always got `tabBarActiveTintColor` at 10% alpha. The patch restores the intended precedence, so the active indicator pill takes the color from the design. **This patch can be removed on the upgrade to React Navigation v8**, whose rewritten native tab view no longer has the bug. - Upstream PR/issue: not reported, because the v8 branch already fixes it and the fix only matters until the App moves to v8. Still present on the `7.x` branch as of 7.20.0. @@ -77,7 +78,7 @@ - PR Introducing Patch: [#101339](https://github.com/Expensify/App/pull/101339) - PR Updating Patch: N/A -### [@react-navigation+bottom-tabs+7.16.2+003+hidden-tab-items.patch](@react-navigation+bottom-tabs+7.16.2+003+hidden-tab-items.patch) +### [@react-navigation+bottom-tabs+7.18.16+003+hidden-tab-items.patch](@react-navigation+bottom-tabs+7.18.16+003+hidden-tab-items.patch) - Reason: Adds a `tabBarItemHidden` option to the native bottom tabs. The App registers six tabs but the native bar can show at most five (Material's `BottomNavigationView` throws past five, and `UITabBar` folds the rest into a "More" tab), and which five depends on the Insights beta: Insights with it, Account without it. A native tab bar cannot hide one of its items, so a route with `tabBarItemHidden` gets no `Tabs.Screen` at all. When such a route is focused, it is drawn in JS over the native host with the tab bar hidden, while the host keeps the last focused visible tab selected underneath. A hidden route stays mounted once visited. Nothing native changes. The host's selection is derived from the tab history, skipping routes that are hidden by then, so a beta change that hides the selected route cannot leave the host pointing at a removed tab. A route that moves between hidden and visible is remounted, and a hidden route ignores preloading and emits no transition events. The patch also forwards a `tabBarAccessibilityLabel` option to RNScreens' `tabBarItemAccessibilityLabel`. The iOS tab labels are drawn into the icon images, so without it VoiceOver announces the tabs with no name. **This part can be removed on the upgrade to React Navigation v8**, which forwards the same option. The `tabBarItemHidden` part has no upstream counterpart and stays. - Upstream PR/issue: not reported, because hiding an item from a native tab bar has no native counterpart and this works around it in JS for the App's tab set; the accessibility label part is already in v8. From c1aabf4f82aec5fef8979439ccf25d065ede60df Mon Sep 17 00:00:00 2001 From: sumo_slonik Date: Wed, 7 Oct 2026 15:28:32 +0200 Subject: [PATCH 69/71] fix: iOS offline indicator sits above the native tab bar on every tab root, measured from the screen bottom --- .../useTabRootScreenWrapperProps/index.ios.tsx | 11 +++++++++-- src/styles/index.ts | 10 ++++++++++ 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/src/components/Navigation/TabBarBottomContent/useTabRootScreenWrapperProps/index.ios.tsx b/src/components/Navigation/TabBarBottomContent/useTabRootScreenWrapperProps/index.ios.tsx index c7d86f32d2c4..4af99cca3d8c 100644 --- a/src/components/Navigation/TabBarBottomContent/useTabRootScreenWrapperProps/index.ios.tsx +++ b/src/components/Navigation/TabBarBottomContent/useTabRootScreenWrapperProps/index.ios.tsx @@ -1,14 +1,21 @@ import useResponsiveLayout from '@hooks/useResponsiveLayout'; +import useSafeAreaInsets from '@hooks/useSafeAreaInsets'; +import useThemeStyles from '@hooks/useThemeStyles'; + +import {getFloatingButtonsBottom} from '@libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/useNativeTabBarOptions'; 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 adds no bottom padding. Each tab root's list lets UIKit inset its end past the bar. + * the screen, so the ScreenWrapper adds no bottom padding. Each tab root's list lets UIKit inset its end past the bar, + * and the offline indicator sits above the bar. */ function useTabRootScreenWrapperProps(): TabRootScreenWrapperProps { const {shouldUseNarrowLayout} = useResponsiveLayout(); - return shouldUseNarrowLayout ? {includeSafeAreaPaddingBottom: false} : {}; + const styles = useThemeStyles(); + const {bottom: bottomInset} = useSafeAreaInsets(); + return shouldUseNarrowLayout ? {includeSafeAreaPaddingBottom: false, offlineIndicatorStyle: styles.iosNativeTabBarOfflineIndicator(getFloatingButtonsBottom(bottomInset))} : {}; } export default useTabRootScreenWrapperProps; diff --git a/src/styles/index.ts b/src/styles/index.ts index 7c69917bb011..d6a644d1597a 100644 --- a/src/styles/index.ts +++ b/src/styles/index.ts @@ -6978,6 +6978,16 @@ const staticStyles = (theme: ThemeColors) => const dynamicStyles = (theme: ThemeColors) => ({ + // Measured from the bottom of the screen, because only some tab roots pad the indicator's container with the inset. + iosNativeTabBarOfflineIndicator: (bottom: number) => + ({ + position: 'absolute', + right: 0, + bottom, + left: 0, + backgroundColor: theme.appBG, + }) satisfies ViewStyle, + // Zero-height strip anchored above the native tab bar, so the floating buttons can be positioned // against it without taking part in the tab screen's layout. nativeTabBarFloatingButtons: (bottom: number) => From a8947b33a4f499e7bbfd406bd38512288352e65b Mon Sep 17 00:00:00 2001 From: sumo_slonik Date: Thu, 8 Oct 2026 14:11:39 +0200 Subject: [PATCH 70/71] fix: mWeb tab labels truncate inside their slot on one line, clear of the selected pill next to them --- .../Navigation/NavigationTabBar/TabBarItem.tsx | 2 +- src/components/Navigation/NavigationTabBar/index.tsx | 1 + src/pages/inbox/sidebar/NavigationTabBarAvatar.tsx | 10 ++++++++-- src/styles/index.ts | 11 ++++++++--- src/styles/variables.ts | 2 ++ 5 files changed, 20 insertions(+), 6 deletions(-) diff --git a/src/components/Navigation/NavigationTabBar/TabBarItem.tsx b/src/components/Navigation/NavigationTabBar/TabBarItem.tsx index 87613dbe3f72..dcf891ed9d51 100644 --- a/src/components/Navigation/NavigationTabBar/TabBarItem.tsx +++ b/src/components/Navigation/NavigationTabBar/TabBarItem.tsx @@ -38,7 +38,7 @@ function TabBarItem({icon, label, isSelected, isHovered = false, statusIndicator const iconSize = shouldUseNarrowLayout ? variables.iconFloatingTabBar : variables.iconBottomBar; return ( - + {({hovered}) => ( - + overflow: 'hidden', }, + // Stretches every tab to its slot, so a long label truncates inside the slot instead of spilling into its neighbors. + navigationTabBarItemNarrow: { + alignSelf: 'stretch', + paddingHorizontal: variables.floatingTabBarLabelInset, + }, + navigationTabBarItemSelected: { backgroundColor: theme.floatingTabBarSelectedBG, borderRadius: variables.componentBorderRadiusCircle, - // The padding matches the negative margin, so the label keeps the tab's width to wrap in, selected or not. - alignSelf: 'stretch', - paddingHorizontal: variables.floatingTabBarSelectedOverhang, + // The extra padding matches the negative margin, so the label keeps the same width selected or not. + paddingHorizontal: variables.floatingTabBarLabelInset + variables.floatingTabBarSelectedOverhang, marginHorizontal: -variables.floatingTabBarSelectedOverhang, }, diff --git a/src/styles/variables.ts b/src/styles/variables.ts index 02fa82cd4e6e..4ebc7dced2e1 100644 --- a/src/styles/variables.ts +++ b/src/styles/variables.ts @@ -35,6 +35,8 @@ export default { floatingTabBarHorizontalInset: 16, // How far the selected pill reaches past its tab on each side, whatever the label's length. floatingTabBarSelectedOverhang: 4, + // Space between a label's truncation edge and its tab's edge, so a neighbor's selected pill never reaches the label. + floatingTabBarLabelInset: 8, floatingTabBarBottomInset: 8, // styles.p3 (12) on each side of the DebugTabView row plus the View button (componentSizeNormal). debugTabViewHeight: 64, From e3e2dcebc68a0c347e8dba65e753f868564b0d59 Mon Sep 17 00:00:00 2001 From: sumo_slonik Date: Thu, 8 Oct 2026 14:26:47 +0200 Subject: [PATCH 71/71] fix: iOS tab labels leave room for the selected pill next to them --- .../NativeTabNavigator/useNativeTabBarOptions/index.ios.ts | 2 +- src/styles/variables.ts | 3 +++ 2 files changed, 4 insertions(+), 1 deletion(-) 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 e6f46ed27404..4b20eaeaa88c 100644 --- a/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/useNativeTabBarOptions/index.ios.ts +++ b/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/useNativeTabBarOptions/index.ios.ts @@ -39,7 +39,7 @@ function useNativeTabBarOptions({shouldShowNativeTabBar, isAccountAvatarShown, d const {windowWidth} = useWindowDimensions(); // One of the tabs never has a bar item, so the bar splits its width between the others. const barItemCount = Object.keys(tabLabels).length - 1; - const labelMaxWidth = (windowWidth - 2 * variables.iosNativeTabBarHorizontalInset) / barItemCount; + const labelMaxWidth = (windowWidth - 2 * variables.iosNativeTabBarHorizontalInset) / barItemCount - variables.iosNativeTabBarLabelInset; const screenOptions: NativeBottomTabNavigationOptions = { headerShown: false, diff --git a/src/styles/variables.ts b/src/styles/variables.ts index 4ebc7dced2e1..15f28dd6fb25 100644 --- a/src/styles/variables.ts +++ b/src/styles/variables.ts @@ -122,6 +122,9 @@ export default { nativeTabIconLabelGap: 6, /** Space between the screen edge and the first tab of the iOS 26 floating tab bar. */ iosNativeTabBarHorizontalInset: 25, + // UITabBar sizes its items to their icons and gives the selected pill a minimum width, so a label as wide as an even + // share of the bar lets that pill reach over the label next to it. + iosNativeTabBarLabelInset: 8, /** Material's BottomNavigationView row, without the gesture inset under it. */ androidNativeTabBarHeight: 80, /** Material's bar row (androidNativeTabBarHeight) plus the 16 dp Material keeps between a FAB and the bar. */