From 9e99dcc7934f6c5a42340a139dd7138263454d54 Mon Sep 17 00:00:00 2001 From: Thiago Brezinski Date: Thu, 18 Jun 2026 12:15:04 +0100 Subject: [PATCH 1/3] feat: use focused tab icons for selected state --- .../java/com/rcttabview/RCTTabViewManager.kt | 3 ++ .../ios/RCTTabViewComponentView.mm | 13 ++++++- .../ios/TabViewImpl.swift | 10 ++++-- .../ios/TabViewProps.swift | 1 + .../ios/TabViewProvider.swift | 34 +++++++++++++++---- .../react-native-bottom-tabs/src/TabView.tsx | 30 ++++++++++++++++ .../src/TabViewNativeComponent.ts | 2 ++ 7 files changed, 83 insertions(+), 10 deletions(-) diff --git a/packages/react-native-bottom-tabs/android/src/main/java/com/rcttabview/RCTTabViewManager.kt b/packages/react-native-bottom-tabs/android/src/main/java/com/rcttabview/RCTTabViewManager.kt index 95644dbb..647b96c2 100644 --- a/packages/react-native-bottom-tabs/android/src/main/java/com/rcttabview/RCTTabViewManager.kt +++ b/packages/react-native-bottom-tabs/android/src/main/java/com/rcttabview/RCTTabViewManager.kt @@ -124,6 +124,9 @@ class RCTTabViewManager(context: ReactApplicationContext) : view.setIcons(value) } + override fun setFocusedIcons(view: ReactBottomNavigationView?, value: ReadableArray?) { + } + override fun setLabeled(view: ReactBottomNavigationView?, value: Boolean) { if (view != null) view.setLabeled(value) diff --git a/packages/react-native-bottom-tabs/ios/RCTTabViewComponentView.mm b/packages/react-native-bottom-tabs/ios/RCTTabViewComponentView.mm index bd8edcef..722326a8 100644 --- a/packages/react-native-bottom-tabs/ios/RCTTabViewComponentView.mm +++ b/packages/react-native-bottom-tabs/ios/RCTTabViewComponentView.mm @@ -35,6 +35,7 @@ return lhs.key == rhs.key && lhs.title == rhs.title && lhs.sfSymbol == rhs.sfSymbol && + lhs.focusedSfSymbol == rhs.focusedSfSymbol && lhs.badge == rhs.badge && lhs.activeTintColor == rhs.activeTintColor && lhs.hidden == rhs.hidden && @@ -116,6 +117,16 @@ - (void)updateProps:(Props::Shared const &)props oldProps:(Props::Shared const & _tabViewProvider.icons = iconsArray; } + if (oldViewProps.focusedIcons != newViewProps.focusedIcons) { + auto focusedIconsArray = [[NSMutableArray alloc] init]; + for (auto &source: newViewProps.focusedIcons) { + auto imageSource = [[RCTImageSource alloc] initWithURLRequest:NSURLRequestFromImageSource(source) size:CGSizeMake(source.size.width, source.size.height) scale:source.scale]; + [focusedIconsArray addObject:imageSource]; + } + + _tabViewProvider.focusedIcons = focusedIconsArray; + } + if (oldViewProps.sidebarAdaptable != newViewProps.sidebarAdaptable) { _tabViewProvider.sidebarAdaptable = newViewProps.sidebarAdaptable; } @@ -196,6 +207,7 @@ - (void)updateProps:(Props::Shared const &)props oldProps:(Props::Shared const & title:RCTNSStringFromString(item.title) badge:RCTNSStringFromStringNilIfEmpty(item.badge) sfSymbol:RCTNSStringFromStringNilIfEmpty(item.sfSymbol) + focusedSfSymbol:RCTNSStringFromStringNilIfEmpty(item.focusedSfSymbol) activeTintColor:RCTUIColorFromSharedColor(item.activeTintColor) hidden:item.hidden testID:RCTNSStringFromStringNilIfEmpty(item.testID) @@ -265,4 +277,3 @@ - (void)onLayoutWithSize:(CGSize)size reactTag:(NSNumber *)reactTag { } #endif // RCT_NEW_ARCH_ENABLED - diff --git a/packages/react-native-bottom-tabs/ios/TabViewImpl.swift b/packages/react-native-bottom-tabs/ios/TabViewImpl.swift index 14732fdd..8328b9b3 100644 --- a/packages/react-native-bottom-tabs/ios/TabViewImpl.swift +++ b/packages/react-native-bottom-tabs/ios/TabViewImpl.swift @@ -229,12 +229,15 @@ struct TabViewImpl: View { let tabActiveColor = tabData.activeTintColor ?? props.activeTintColor let assetIcon = props.icons[itemIndex] let icon = assetIcon ?? makeSFSymbolImage(named: tabData.sfSymbol) + let focusedIcon = + props.focusedIcons[itemIndex] ?? makeSFSymbolImage(named: tabData.focusedSfSymbol) ?? icon let shouldRenderLabelIntoImage = props.hasCustomTintColors && props.labeled && tabData.role != .search && icon != nil item.accessibilityLabel = tabData.title if shouldRenderLabelIntoImage, let icon { + let selectedIcon = focusedIcon ?? icon item.title = "" item.titlePositionAdjustment = UIOffset(horizontal: 0, vertical: 100) item.image = makeTabBarItemImage( @@ -244,7 +247,7 @@ struct TabViewImpl: View { props: props ) item.selectedImage = makeTabBarItemImage( - icon: icon, + icon: selectedIcon, title: tabData.title, color: tabActiveColor, props: props @@ -256,14 +259,15 @@ struct TabViewImpl: View { item.titlePositionAdjustment = UIOffset(horizontal: 0, vertical: 0) if let icon { + let selectedIcon = focusedIcon ?? icon item.image = props.inactiveTintColor.map { icon.withTintColor($0, renderingMode: .alwaysOriginal) } ?? icon item.selectedImage = tabActiveColor.map { - icon.withTintColor($0, renderingMode: .alwaysOriginal) - } ?? icon + selectedIcon.withTintColor($0, renderingMode: .alwaysOriginal) + } ?? selectedIcon } item.setTitleTextAttributes( diff --git a/packages/react-native-bottom-tabs/ios/TabViewProps.swift b/packages/react-native-bottom-tabs/ios/TabViewProps.swift index 85e4d8d1..dd4c3c88 100644 --- a/packages/react-native-bottom-tabs/ios/TabViewProps.swift +++ b/packages/react-native-bottom-tabs/ios/TabViewProps.swift @@ -55,6 +55,7 @@ class TabViewProps: ObservableObject { @Published var items: [TabInfo] = [] @Published var selectedPage: String? @Published var icons: [Int: PlatformImage] = [:] + @Published var focusedIcons: [Int: PlatformImage] = [:] @Published var iconsRevision: Int = 0 @Published var sidebarAdaptable: Bool? @Published var labeled: Bool = false diff --git a/packages/react-native-bottom-tabs/ios/TabViewProvider.swift b/packages/react-native-bottom-tabs/ios/TabViewProvider.swift index 799f3c0c..9f95e4e4 100644 --- a/packages/react-native-bottom-tabs/ios/TabViewProvider.swift +++ b/packages/react-native-bottom-tabs/ios/TabViewProvider.swift @@ -8,6 +8,7 @@ public final class TabInfo: NSObject { public let title: String public let badge: String? public let sfSymbol: String + public let focusedSfSymbol: String? public let activeTintColor: PlatformColor? public let hidden: Bool public let testID: String? @@ -19,6 +20,7 @@ public final class TabInfo: NSObject { title: String, badge: String?, sfSymbol: String, + focusedSfSymbol: String?, activeTintColor: PlatformColor?, hidden: Bool, testID: String?, @@ -29,6 +31,7 @@ public final class TabInfo: NSObject { self.title = title self.badge = badge self.sfSymbol = sfSymbol + self.focusedSfSymbol = focusedSfSymbol self.activeTintColor = activeTintColor self.hidden = hidden self.testID = testID @@ -61,7 +64,13 @@ public final class TabInfo: NSObject { @objc public var icons: NSArray? { didSet { - loadIcons(icons) + loadIcons(icons, focused: false) + } + } + + @objc public var focusedIcons: NSArray? { + didSet { + loadIcons(focusedIcons, focused: true) } } @@ -179,7 +188,8 @@ public final class TabInfo: NSObject { @objc public func setImageLoader(_ imageLoader: RCTImageLoader) { self.imageLoader = imageLoader - loadIcons(icons) + loadIcons(icons, focused: false) + loadIcons(focusedIcons, focused: true) } override public func didUpdateReactSubviews() { @@ -243,7 +253,7 @@ public final class TabInfo: NSObject { props.children.remove(at: index) } - private func loadIcons(_ icons: NSArray?) { + private func loadIcons(_ icons: NSArray?, focused: Bool) { guard let imageLoader else { return } // TODO: Diff the arrays and update only changed items. @@ -270,13 +280,25 @@ public final class TabInfo: NSObject { let icon = image.resizeImageTo(size: iconSize) #if os(iOS) if props.experimentalBakedTintColors { - props.icons[index] = icon?.withRenderingMode(.alwaysTemplate) + if focused { + props.focusedIcons[index] = icon?.withRenderingMode(.alwaysTemplate) + } else { + props.icons[index] = icon?.withRenderingMode(.alwaysTemplate) + } props.iconsRevision += 1 } else { - props.icons[index] = icon + if focused { + props.focusedIcons[index] = icon + } else { + props.icons[index] = icon + } } #else - props.icons[index] = icon + if focused { + props.focusedIcons[index] = icon + } else { + props.icons[index] = icon + } #endif } }) diff --git a/packages/react-native-bottom-tabs/src/TabView.tsx b/packages/react-native-bottom-tabs/src/TabView.tsx index e4f007e1..fe614d75 100644 --- a/packages/react-native-bottom-tabs/src/TabView.tsx +++ b/packages/react-native-bottom-tabs/src/TabView.tsx @@ -309,11 +309,24 @@ const TabView = ({ [focusedKey, getIcon, trimmedRoutes] ); + const focusedIcons = React.useMemo( + () => + trimmedRoutes.map((route) => + getIcon({ + route, + focused: true, + }) + ), + [getIcon, trimmedRoutes] + ); + const items: TabViewItems = React.useMemo( () => trimmedRoutes.map((route, index) => { const icon = icons[index]; const isSfSymbol = isAppleSymbol(icon); + const focusedIcon = focusedIcons[index]; + const isFocusedSfSymbol = isAppleSymbol(focusedIcon); if (Platform.OS === 'android' && isSfSymbol) { console.warn( @@ -325,6 +338,7 @@ const TabView = ({ key: route.key, title: getLabelText({ route }) ?? route.key, sfSymbol: isSfSymbol ? icon.sfSymbol : undefined, + focusedSfSymbol: isFocusedSfSymbol ? focusedIcon.sfSymbol : undefined, badge: getBadge?.({ route }), badgeBackgroundColor: processColor( getBadgeBackgroundColor?.({ route }) @@ -340,6 +354,7 @@ const TabView = ({ [ trimmedRoutes, icons, + focusedIcons, getLabelText, getBadge, getBadgeBackgroundColor, @@ -364,6 +379,18 @@ const TabView = ({ [icons] ); + const resolvedFocusedIconAssets: ImageSource[] = React.useMemo( + () => + // Pass empty object for icons that are not provided to avoid index mismatch on native side. + focusedIcons.map((icon) => + icon && !isAppleSymbol(icon) + ? // @ts-expect-error: TODO: Migrate of deep imports + Image.resolveAssetSource(icon) + : { uri: '' } + ), + [focusedIcons] + ); + const jumpTo = useLatestCallback((key: string) => { const index = trimmedRoutes.findIndex((route) => route.key === key); @@ -417,6 +444,9 @@ const TabView = ({ items={items} // When rendering a custom tab bar, icons can be React elements, which will not be properly resolved. icons={renderCustomTabBar ? undefined : resolvedIconAssets} + focusedIcons={ + renderCustomTabBar ? undefined : resolvedFocusedIconAssets + } selectedPage={focusedKey} tabBarHidden={tabBarHidden ?? !!renderCustomTabBar} onTabLongPress={handleTabLongPress} diff --git a/packages/react-native-bottom-tabs/src/TabViewNativeComponent.ts b/packages/react-native-bottom-tabs/src/TabViewNativeComponent.ts index 4a4e81ad..43bac3cb 100644 --- a/packages/react-native-bottom-tabs/src/TabViewNativeComponent.ts +++ b/packages/react-native-bottom-tabs/src/TabViewNativeComponent.ts @@ -26,6 +26,7 @@ export type TabViewItems = ReadonlyArray<{ key: string; title: string; sfSymbol?: string; + focusedSfSymbol?: string; badge?: string; badgeBackgroundColor?: ProcessedColorValue | null; badgeTextColor?: ProcessedColorValue | null; @@ -44,6 +45,7 @@ export interface TabViewProps extends ViewProps { onTabBarMeasured?: DirectEventHandler; onNativeLayout?: DirectEventHandler; icons?: ReadonlyArray; + focusedIcons?: ReadonlyArray; tabBarHidden?: boolean; labeled?: boolean; sidebarAdaptable?: boolean; From 6caf8a119bacd16c93de139582ec2f17ebed2c66 Mon Sep 17 00:00:00 2001 From: Thiago Brezinski Date: Fri, 19 Jun 2026 10:24:29 +0100 Subject: [PATCH 2/3] fix: apply focused tab icons outside baked tint mode --- .../ios/TabViewImpl.swift | 99 +++++++++---------- .../ios/TabViewProvider.swift | 3 +- 2 files changed, 46 insertions(+), 56 deletions(-) diff --git a/packages/react-native-bottom-tabs/ios/TabViewImpl.swift b/packages/react-native-bottom-tabs/ios/TabViewImpl.swift index 6ed26c4f..68053e1f 100644 --- a/packages/react-native-bottom-tabs/ios/TabViewImpl.swift +++ b/packages/react-native-bottom-tabs/ios/TabViewImpl.swift @@ -71,7 +71,7 @@ struct TabViewImpl: View { #else tabBar = tabController.tabBar updateTabBarAppearance(props: props, tabBar: tabController.tabBar) - updateExperimentalBakedTintColors(props: props, tabBar: tabController.tabBar) + updateTabBarItemImages(props: props, tabBar: tabController.tabBar) if !props.tabBarHidden { onTabBarMeasured( Int(tabController.tabBar.frame.size.height) @@ -115,17 +115,16 @@ struct TabViewImpl: View { } #if !os(macOS) - private func updateExperimentalBakedTintColors(props: TabViewProps, tabBar: UITabBar?) { - guard shouldUseExperimentalBakedTintColors(props: props), - let tabBar, + private func updateTabBarItemImages(props: TabViewProps, tabBar: UITabBar?) { + guard let tabBar, let items = tabBar.items else { return } - configureExperimentalBakedTintColors(items: items, props: props) + configureTabBarItemImages(items: items, props: props) DispatchQueue.main.async { [weak tabBar] in guard let tabBar, let items = tabBar.items else { return } - configureExperimentalBakedTintColors(items: items, props: props) + configureTabBarItemImages(items: items, props: props) } } @@ -220,7 +219,7 @@ struct TabViewImpl: View { } } - private func configureExperimentalBakedTintColors(items: [UITabBarItem], props: TabViewProps) { + private func configureTabBarItemImages(items: [UITabBarItem], props: TabViewProps) { for (tabBarIndex, item) in items.enumerated() { guard let tabData = props.filteredItems[safe: tabBarIndex], let itemIndex = props.items.firstIndex(where: { $0.key == tabData.key }) @@ -232,12 +231,13 @@ struct TabViewImpl: View { let focusedIcon = props.focusedIcons[itemIndex] ?? makeSFSymbolImage(named: tabData.focusedSfSymbol) ?? icon let preservesOriginalIconColors = preservesOriginalIconColors(tabData: tabData) + let useBakedTintColors = shouldUseExperimentalBakedTintColors(props: props) let shouldRenderLabelIntoImage = props.hasCustomTintColors && props.labeled && tabData.role != .search && icon != nil item.accessibilityLabel = tabData.title - if shouldRenderLabelIntoImage, let icon { + if useBakedTintColors, shouldRenderLabelIntoImage, let icon { let selectedIcon = focusedIcon ?? icon item.title = "" item.titlePositionAdjustment = UIOffset(horizontal: 0, vertical: 100) @@ -263,19 +263,18 @@ struct TabViewImpl: View { if let icon { let selectedIcon = focusedIcon ?? icon - if preservesOriginalIconColors { - item.image = icon.withRenderingMode(.alwaysOriginal) - item.selectedImage = selectedIcon.withRenderingMode(.alwaysOriginal) - } else { - item.image = - props.inactiveTintColor.map { - icon.withTintColor($0, renderingMode: .alwaysOriginal) - } ?? icon - item.selectedImage = - tabActiveColor.map { - selectedIcon.withTintColor($0, renderingMode: .alwaysOriginal) - } ?? selectedIcon - } + item.image = renderTabBarIcon( + icon, + color: props.inactiveTintColor, + preservesOriginalIconColors: preservesOriginalIconColors, + forceTintColor: useBakedTintColors + ) + item.selectedImage = renderTabBarIcon( + selectedIcon, + color: tabActiveColor, + preservesOriginalIconColors: preservesOriginalIconColors, + forceTintColor: useBakedTintColors + ) } item.setTitleTextAttributes( @@ -290,31 +289,25 @@ struct TabViewImpl: View { } } - private func resetExperimentalBakedTintColors(props: TabViewProps, tabBar: UITabBar?) { - guard let tabBar, - let items = tabBar.items - else { return } - - for (tabBarIndex, item) in items.enumerated() { - guard let tabData = props.filteredItems[safe: tabBarIndex], - let itemIndex = props.items.firstIndex(where: { $0.key == tabData.key }) - else { continue } + private func preservesOriginalIconColors(tabData: TabInfo) -> Bool { + tabData.iconRenderingMode == "original" + } - let assetIcon = props.icons[itemIndex] - let icon = assetIcon ?? makeSFSymbolImage(named: tabData.sfSymbol) - let originalIcon = icon.map { - preservesOriginalIconColors(tabData: tabData) ? $0.withRenderingMode(.alwaysOriginal) : $0 - } + private func renderTabBarIcon( + _ icon: UIImage, + color: UIColor?, + preservesOriginalIconColors: Bool, + forceTintColor: Bool + ) -> UIImage { + if preservesOriginalIconColors { + return icon.withRenderingMode(.alwaysOriginal) + } - item.title = props.labeled ? tabData.title : nil - item.titlePositionAdjustment = UIOffset(horizontal: 0, vertical: 0) - item.image = originalIcon - item.selectedImage = originalIcon + guard forceTintColor, let color else { + return icon } - } - private func preservesOriginalIconColors(tabData: TabInfo) -> Bool { - tabData.iconRenderingMode == "original" + return icon.withTintColor(color, renderingMode: .alwaysOriginal) } private func makeSFSymbolImage(named sfSymbol: String?) -> UIImage? { @@ -476,40 +469,36 @@ extension View { } .onChange(of: props.inactiveTintColor) { _ in updateTabBarAppearance(props: props, tabBar: tabBar) - updateExperimentalBakedTintColors(props: props, tabBar: tabBar) + updateTabBarItemImages(props: props, tabBar: tabBar) } .onChange(of: props.activeTintColor) { _ in updateTabBarAppearance(props: props, tabBar: tabBar) - updateExperimentalBakedTintColors(props: props, tabBar: tabBar) + updateTabBarItemImages(props: props, tabBar: tabBar) } .onChange(of: props.selectedActiveTintColor) { newValue in tabBar?.tintColor = newValue } .onChange(of: props.iconsRevision) { _ in - updateExperimentalBakedTintColors(props: props, tabBar: tabBar) + updateTabBarItemImages(props: props, tabBar: tabBar) } .onChange(of: props.labeled) { _ in - updateExperimentalBakedTintColors(props: props, tabBar: tabBar) + updateTabBarItemImages(props: props, tabBar: tabBar) } .onChange(of: props.fontSize) { _ in updateTabBarAppearance(props: props, tabBar: tabBar) - updateExperimentalBakedTintColors(props: props, tabBar: tabBar) + updateTabBarItemImages(props: props, tabBar: tabBar) } .onChange(of: props.fontFamily) { _ in updateTabBarAppearance(props: props, tabBar: tabBar) - updateExperimentalBakedTintColors(props: props, tabBar: tabBar) + updateTabBarItemImages(props: props, tabBar: tabBar) } .onChange(of: props.fontWeight) { _ in updateTabBarAppearance(props: props, tabBar: tabBar) - updateExperimentalBakedTintColors(props: props, tabBar: tabBar) + updateTabBarItemImages(props: props, tabBar: tabBar) } - .onChange(of: props.experimentalBakedTintColors) { newValue in + .onChange(of: props.experimentalBakedTintColors) { _ in updateTabBarAppearance(props: props, tabBar: tabBar) - if newValue { - updateExperimentalBakedTintColors(props: props, tabBar: tabBar) - } else { - resetExperimentalBakedTintColors(props: props, tabBar: tabBar) - } + updateTabBarItemImages(props: props, tabBar: tabBar) } .onChange(of: props.tabBarHidden) { newValue in tabBar?.isHidden = newValue diff --git a/packages/react-native-bottom-tabs/ios/TabViewProvider.swift b/packages/react-native-bottom-tabs/ios/TabViewProvider.swift index 242b0a73..e5663453 100644 --- a/packages/react-native-bottom-tabs/ios/TabViewProvider.swift +++ b/packages/react-native-bottom-tabs/ios/TabViewProvider.swift @@ -288,7 +288,6 @@ public final class TabInfo: NSObject { } else { props.icons[index] = icon?.withRenderingMode(.alwaysTemplate) } - props.iconsRevision += 1 } else { if focused { props.focusedIcons[index] = icon @@ -296,12 +295,14 @@ public final class TabInfo: NSObject { props.icons[index] = icon } } + props.iconsRevision += 1 #else if focused { props.focusedIcons[index] = icon } else { props.icons[index] = icon } + props.iconsRevision += 1 #endif } }) From a970b6f8133d1ecc2fa880ee1a741f130404825c Mon Sep 17 00:00:00 2001 From: Thiago Brezinski Date: Fri, 19 Jun 2026 15:41:10 +0100 Subject: [PATCH 3/3] fix: keep iOS base tab icons unfocused --- packages/react-native-bottom-tabs/src/TabView.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/react-native-bottom-tabs/src/TabView.tsx b/packages/react-native-bottom-tabs/src/TabView.tsx index d6ba4bf1..0acfb842 100644 --- a/packages/react-native-bottom-tabs/src/TabView.tsx +++ b/packages/react-native-bottom-tabs/src/TabView.tsx @@ -315,7 +315,9 @@ const TabView = ({ trimmedRoutes.map((route) => getIcon({ route, - focused: route.key === focusedKey, + // iOS uses UITabBarItem.selectedImage for selected and Liquid Glass hover states. + // Keep the base image unfocused so a selected tab can render unfocused while another tab is hovered. + focused: Platform.OS === 'ios' ? false : route.key === focusedKey, }) ), [focusedKey, getIcon, trimmedRoutes]