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 6451db01..7f597216 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 @@ -126,6 +126,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 f25b3585..09ab604b 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.iconRenderingMode == rhs.iconRenderingMode && @@ -117,6 +118,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; } @@ -197,6 +208,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) iconRenderingMode:RCTNSStringFromStringNilIfEmpty(item.iconRenderingMode) hidden:item.hidden diff --git a/packages/react-native-bottom-tabs/ios/TabViewImpl.swift b/packages/react-native-bottom-tabs/ios/TabViewImpl.swift index dee9afa4..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 }) @@ -229,13 +228,17 @@ 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 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) item.image = makeTabBarItemImage( @@ -246,7 +249,7 @@ struct TabViewImpl: View { props: props ) item.selectedImage = makeTabBarItemImage( - icon: icon, + icon: selectedIcon, title: tabData.title, color: tabActiveColor, preservesOriginalIconColors: preservesOriginalIconColors, @@ -259,20 +262,19 @@ struct TabViewImpl: View { item.titlePositionAdjustment = UIOffset(horizontal: 0, vertical: 0) if let icon { - if preservesOriginalIconColors { - let originalIcon = icon.withRenderingMode(.alwaysOriginal) - item.image = originalIcon - item.selectedImage = originalIcon - } else { - item.image = - props.inactiveTintColor.map { - icon.withTintColor($0, renderingMode: .alwaysOriginal) - } ?? icon - item.selectedImage = - tabActiveColor.map { - icon.withTintColor($0, renderingMode: .alwaysOriginal) - } ?? icon - } + let selectedIcon = focusedIcon ?? icon + item.image = renderTabBarIcon( + icon, + color: props.inactiveTintColor, + preservesOriginalIconColors: preservesOriginalIconColors, + forceTintColor: useBakedTintColors + ) + item.selectedImage = renderTabBarIcon( + selectedIcon, + color: tabActiveColor, + preservesOriginalIconColors: preservesOriginalIconColors, + forceTintColor: useBakedTintColors + ) } item.setTitleTextAttributes( @@ -287,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? { @@ -473,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/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 e6dd2a2e..e5663453 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 iconRenderingMode: String? public let hidden: Bool @@ -20,6 +21,7 @@ public final class TabInfo: NSObject { title: String, badge: String?, sfSymbol: String, + focusedSfSymbol: String?, activeTintColor: PlatformColor?, iconRenderingMode: String?, hidden: Bool, @@ -31,6 +33,7 @@ public final class TabInfo: NSObject { self.title = title self.badge = badge self.sfSymbol = sfSymbol + self.focusedSfSymbol = focusedSfSymbol self.activeTintColor = activeTintColor self.iconRenderingMode = iconRenderingMode self.hidden = hidden @@ -64,7 +67,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) } } @@ -182,7 +191,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() { @@ -246,7 +256,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. @@ -273,13 +283,26 @@ public final class TabInfo: NSObject { let icon = image.resizeImageTo(size: iconSize) #if os(iOS) if props.experimentalBakedTintColors { - props.icons[index] = icon?.withRenderingMode(.alwaysTemplate) - props.iconsRevision += 1 + if focused { + props.focusedIcons[index] = icon?.withRenderingMode(.alwaysTemplate) + } else { + props.icons[index] = icon?.withRenderingMode(.alwaysTemplate) + } } else { - props.icons[index] = icon + if focused { + props.focusedIcons[index] = icon + } else { + props.icons[index] = icon + } } + props.iconsRevision += 1 #else - props.icons[index] = icon + if focused { + props.focusedIcons[index] = icon + } else { + props.icons[index] = icon + } + props.iconsRevision += 1 #endif } }) diff --git a/packages/react-native-bottom-tabs/src/TabView.tsx b/packages/react-native-bottom-tabs/src/TabView.tsx index d9a1ccab..0acfb842 100644 --- a/packages/react-native-bottom-tabs/src/TabView.tsx +++ b/packages/react-native-bottom-tabs/src/TabView.tsx @@ -315,17 +315,32 @@ 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] ); + 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( @@ -337,6 +352,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 }) @@ -353,6 +369,7 @@ const TabView = ({ [ trimmedRoutes, icons, + focusedIcons, getLabelText, getBadge, getBadgeBackgroundColor, @@ -378,6 +395,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); @@ -431,6 +460,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 387ab9f4..50ecf7c4 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; @@ -45,6 +46,7 @@ export interface TabViewProps extends ViewProps { onTabBarMeasured?: DirectEventHandler; onNativeLayout?: DirectEventHandler; icons?: ReadonlyArray; + focusedIcons?: ReadonlyArray; tabBarHidden?: boolean; labeled?: boolean; sidebarAdaptable?: boolean;