diff --git a/.changeset/large-hats-teach.md b/.changeset/large-hats-teach.md new file mode 100644 index 0000000..d7474e7 --- /dev/null +++ b/.changeset/large-hats-teach.md @@ -0,0 +1,5 @@ +--- +'react-native-bottom-tabs': patch +--- + +Fix active and inactive tint color behavior on iOS 26 Liquid Glass diff --git a/apps/example/src/Examples/TintColors.tsx b/apps/example/src/Examples/TintColors.tsx index c5f4433..2cf72e4 100644 --- a/apps/example/src/Examples/TintColors.tsx +++ b/apps/example/src/Examples/TintColors.tsx @@ -4,6 +4,7 @@ import { Article } from '../Screens/Article'; import { Albums } from '../Screens/Albums'; import { Contacts } from '../Screens/Contacts'; import { Chat } from '../Screens/Chat'; +import { Platform } from 'react-native'; const renderScene = SceneMap({ article: Article, @@ -12,6 +13,8 @@ const renderScene = SceneMap({ chat: Chat, }); +const isAndroid = Platform.OS === 'android'; + export default function TintColorsExample() { const [index, setIndex] = useState(0); const [routes] = useState([ @@ -31,9 +34,11 @@ export default function TintColorsExample() { }, { key: 'contacts', - focusedIcon: require('../../assets/icons/person_dark.png'), + focusedIcon: isAndroid + ? require('../../assets/icons/person_dark.png') + : { sfSymbol: 'person.fill' }, title: 'Contacts', - activeTintColor: 'yellow', + activeTintColor: 'blue', }, { key: 'chat', diff --git a/docs/docs/docs/guides/standalone-usage.md b/docs/docs/docs/guides/standalone-usage.md index 959a84f..3d877a4 100644 --- a/docs/docs/docs/guides/standalone-usage.md +++ b/docs/docs/docs/guides/standalone-usage.md @@ -174,12 +174,20 @@ Color for the active tab. - Type: `ColorValue` +:::warning +On iOS 26 (Liquid Glass), using this prop applies a workaround that bakes tab labels into images for correct tinting. This disables Dynamic Type (accessibility font sizes) and the Bold Text accessibility setting for tab labels. +::: + #### `tabBarInactiveTintColor` Color for inactive tabs. - Type: `ColorValue` +:::warning +On iOS 26 (Liquid Glass), using this prop applies a workaround that bakes tab labels into images for correct tinting. This disables Dynamic Type (accessibility font sizes) and the Bold Text accessibility setting for tab labels. +::: + #### `tabBarStyle` Object containing styles for the tab bar. diff --git a/docs/docs/docs/guides/usage-with-react-navigation.mdx b/docs/docs/docs/guides/usage-with-react-navigation.mdx index 8272217..683be2a 100644 --- a/docs/docs/docs/guides/usage-with-react-navigation.mdx +++ b/docs/docs/docs/guides/usage-with-react-navigation.mdx @@ -124,10 +124,18 @@ It's recommended to use `transparent` or `opaque` without lazy loading as the ta Color for the active tab. +:::warning +On iOS 26 (Liquid Glass), using this prop applies a workaround that bakes tab labels into images for correct tinting. This disables Dynamic Type (accessibility font sizes) and the Bold Text accessibility setting for tab labels. +::: + #### `tabBarInactiveTintColor` Color for the inactive tabs. +:::warning +On iOS 26 (Liquid Glass), using this prop applies a workaround that bakes tab labels into images for correct tinting. This disables Dynamic Type (accessibility font sizes) and the Bold Text accessibility setting for tab labels. +::: + #### `tabBarStyle` Object containing styles for the tab bar. @@ -240,6 +248,10 @@ Label text of the tab displayed in the navigation bar. When undefined, scene tit Color for the active tab. +:::warning +On iOS 26 (Liquid Glass), using this prop applies a workaround that bakes tab labels into images for correct tinting. This disables Dynamic Type (accessibility font sizes) and the Bold Text accessibility setting for tab labels. +::: + :::note The `tabBarInactiveTintColor` is not supported on route level due to native limitations. Use `inactiveTintColor` in the `Tab.Navigator` instead. ::: diff --git a/packages/react-native-bottom-tabs/ios/TabViewImpl.swift b/packages/react-native-bottom-tabs/ios/TabViewImpl.swift index 29eb3bb..2a5abdc 100644 --- a/packages/react-native-bottom-tabs/ios/TabViewImpl.swift +++ b/packages/react-native-bottom-tabs/ios/TabViewImpl.swift @@ -69,6 +69,8 @@ struct TabViewImpl: View { tabBar = tabController #else tabBar = tabController.tabBar + updateTabBarAppearance(props: props, tabBar: tabController.tabBar) + updateTabBarImages(props: props, tabBar: tabController.tabBar) if !props.tabBarHidden { onTabBarMeasured( Int(tabController.tabBar.frame.size.height) @@ -112,6 +114,19 @@ struct TabViewImpl: View { } #if !os(macOS) + private func updateTabBarImages(props: TabViewProps, tabBar: UITabBar?) { + guard shouldApplyLiquidGlassTintWorkaround(), + let tabBar, + let items = tabBar.items else { return } + + configureTabBarItemImages(items: items, props: props) + + DispatchQueue.main.async { [weak tabBar] in + guard let tabBar, let items = tabBar.items else { return } + configureTabBarItemImages(items: items, props: props) + } + } + private func updateTabBarAppearance(props: TabViewProps, tabBar: UITabBar?) { guard let tabBar else { return } @@ -129,6 +144,7 @@ struct TabViewImpl: View { #if !os(macOS) private func configureTransparentAppearance(tabBar: UITabBar, props: TabViewProps) { tabBar.barTintColor = props.barTintColor + tabBar.tintColor = props.selectedActiveTintColor #if !os(visionOS) tabBar.isTranslucent = props.translucent #endif @@ -136,7 +152,7 @@ struct TabViewImpl: View { guard let items = tabBar.items else { return } - let attributes = TabBarFontSize.createNormalStateAttributes( + let fontAttributes = TabBarFontSize.createNormalStateAttributes( fontSize: props.fontSize, fontFamily: props.fontFamily, fontWeight: props.fontWeight, @@ -144,12 +160,15 @@ struct TabViewImpl: View { ) items.forEach { item in - item.setTitleTextAttributes(attributes, for: .normal) + item.setTitleTextAttributes(fontAttributes, for: .normal) + item.setTitleTextAttributes(selectedAttributes(props: props), for: .selected) } } private func configureStandardAppearance(tabBar: UITabBar, props: TabViewProps) { let appearance = UITabBarAppearance() + tabBar.tintColor = props.selectedActiveTintColor + tabBar.unselectedItemTintColor = props.inactiveTintColor // Configure background switch props.scrollEdgeAppearance { @@ -180,8 +199,12 @@ struct TabViewImpl: View { if let inactiveTintColor = props.inactiveTintColor { itemAppearance.normal.iconColor = inactiveTintColor } + if let activeTintColor = props.selectedActiveTintColor { + itemAppearance.selected.iconColor = activeTintColor + } itemAppearance.normal.titleTextAttributes = attributes + itemAppearance.selected.titleTextAttributes = selectedAttributes(props: props) // Apply item appearance to all layouts appearance.stackedLayoutAppearance = itemAppearance @@ -194,6 +217,157 @@ struct TabViewImpl: View { tabBar.scrollEdgeAppearance = appearance.copy() } } + + 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 }) else { continue } + + let tabActiveColor = tabData.activeTintColor ?? props.activeTintColor + let assetIcon = props.icons[itemIndex] + let icon = assetIcon ?? makeSFSymbolImage(named: tabData.sfSymbol) + let shouldRenderLabelIntoImage = props.hasCustomTintColors && props.labeled && tabData.role != .search && icon != nil + + item.accessibilityLabel = tabData.title + + if shouldRenderLabelIntoImage, let icon { + item.title = "" + item.titlePositionAdjustment = UIOffset(horizontal: 0, vertical: 100) + item.image = makeTabBarItemImage( + icon: icon, + title: tabData.title, + color: props.inactiveTintColor, + props: props + ) + item.selectedImage = makeTabBarItemImage( + icon: icon, + title: tabData.title, + color: tabActiveColor, + props: props + ) + continue + } + + item.title = props.labeled ? tabData.title : nil + item.titlePositionAdjustment = UIOffset(horizontal: 0, vertical: 0) + + if let icon { + item.image = props.inactiveTintColor.map { + icon.withTintColor($0, renderingMode: .alwaysOriginal) + } ?? icon + item.selectedImage = tabActiveColor.map { + icon.withTintColor($0, renderingMode: .alwaysOriginal) + } ?? icon + } + + item.setTitleTextAttributes( + TabBarFontSize.createFontAttributes( + size: props.fontSize.map(CGFloat.init) ?? TabBarFontSize.defaultSize, + family: props.fontFamily, + weight: props.fontWeight, + color: tabActiveColor + ), + for: .selected + ) + } + } + + private func makeSFSymbolImage(named sfSymbol: String?) -> UIImage? { + guard let sfSymbol, !sfSymbol.isEmpty else { return nil } + + return UIImage(systemName: sfSymbol) + } + + private func selectedAttributes(props: TabViewProps) -> [NSAttributedString.Key: Any] { + TabBarFontSize.createFontAttributes( + size: props.fontSize.map(CGFloat.init) ?? TabBarFontSize.defaultSize, + family: props.fontFamily, + weight: props.fontWeight, + color: props.selectedActiveTintColor + ) + } + + private func shouldApplyLiquidGlassTintWorkaround() -> Bool { + #if os(iOS) + if #available(iOS 26.0, *) { + return true + } + #endif + + return false + } + + private func makeTabBarItemImage( + icon: UIImage, + title: String, + color: UIColor?, + props: TabViewProps + ) -> UIImage { + let color = color ?? .label + let iconSize = CGSize(width: 27, height: 27) + let font = TabBarFontSize.createFontAttributes( + size: props.fontSize.map(CGFloat.init) ?? TabBarFontSize.defaultSize, + family: props.fontFamily, + weight: props.fontWeight + )[.font] as? UIFont ?? UIFont.boldSystemFont(ofSize: TabBarFontSize.defaultSize) + let paragraphStyle = NSMutableParagraphStyle() + paragraphStyle.alignment = .center + let attributes: [NSAttributedString.Key: Any] = [ + .font: font, + .foregroundColor: color, + .paragraphStyle: paragraphStyle, + ] + let titleSize = (title as NSString).size(withAttributes: attributes) + let imageSize = CGSize( + width: max(iconSize.width, ceil(titleSize.width)) + 8, + height: iconSize.height + 3 + ceil(titleSize.height) + ) + let format = UIGraphicsImageRendererFormat() + format.scale = UIScreen.main.scale + + let image = UIGraphicsImageRenderer(size: imageSize, format: format).image { _ in + let tintedIcon = icon.withTintColor(color, renderingMode: .alwaysOriginal) + let iconFrame = aspectFitRect( + size: tintedIcon.size, + in: CGRect( + x: (imageSize.width - iconSize.width) / 2, + y: 0, + width: iconSize.width, + height: iconSize.height + ) + ) + + tintedIcon.draw(in: iconFrame) + + (title as NSString).draw( + in: CGRect( + x: 0, + y: iconSize.height + 3, + width: imageSize.width, + height: ceil(titleSize.height) + ), + withAttributes: attributes + ) + } + + return image.withRenderingMode(.alwaysOriginal) + } + + private func aspectFitRect(size: CGSize, in rect: CGRect) -> CGRect { + guard size.width > 0, size.height > 0 else { + return rect + } + + let scale = min(rect.width / size.width, rect.height / size.height) + let fittedSize = CGSize(width: size.width * scale, height: size.height * scale) + + return CGRect( + x: rect.minX + (rect.width - fittedSize.width) / 2, + y: rect.minY + (rect.height - fittedSize.height) / 2, + width: fittedSize.width, + height: fittedSize.height + ) + } #endif extension View { @@ -246,18 +420,32 @@ extension View { } .onChange(of: props.inactiveTintColor) { _ in updateTabBarAppearance(props: props, tabBar: tabBar) + updateTabBarImages(props: props, tabBar: tabBar) } - .onChange(of: props.selectedActiveTintColor) { _ in + .onChange(of: props.activeTintColor) { _ in updateTabBarAppearance(props: props, tabBar: tabBar) + updateTabBarImages(props: props, tabBar: tabBar) + } + .onChange(of: props.selectedActiveTintColor) { newValue in + tabBar?.tintColor = newValue + } + .onChange(of: props.iconsRevision) { _ in + updateTabBarImages(props: props, tabBar: tabBar) + } + .onChange(of: props.labeled) { _ in + updateTabBarImages(props: props, tabBar: tabBar) } .onChange(of: props.fontSize) { _ in updateTabBarAppearance(props: props, tabBar: tabBar) + updateTabBarImages(props: props, tabBar: tabBar) } .onChange(of: props.fontFamily) { _ in updateTabBarAppearance(props: props, tabBar: tabBar) + updateTabBarImages(props: props, tabBar: tabBar) } .onChange(of: props.fontWeight) { _ in updateTabBarAppearance(props: props, tabBar: tabBar) + updateTabBarImages(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 e9dc5d9..612408d 100644 --- a/packages/react-native-bottom-tabs/ios/TabViewProps.swift +++ b/packages/react-native-bottom-tabs/ios/TabViewProps.swift @@ -56,6 +56,7 @@ class TabViewProps: ObservableObject { @Published var items: [TabInfo] = [] @Published var selectedPage: String? @Published var icons: [Int: PlatformImage] = [:] + @Published var iconsRevision: Int = 0 @Published var sidebarAdaptable: Bool? @Published var labeled: Bool = false @Published var minimizeBehavior: MinimizeBehavior? @@ -82,6 +83,12 @@ class TabViewProps: ObservableObject { return activeTintColor } + var hasCustomTintColors: Bool { + activeTintColor != nil + || inactiveTintColor != nil + || items.contains(where: { $0.activeTintColor != nil }) + } + var filteredItems: [TabInfo] { items.filter { !$0.hidden || $0.key == selectedPage diff --git a/packages/react-native-bottom-tabs/ios/TabViewProvider.swift b/packages/react-native-bottom-tabs/ios/TabViewProvider.swift index deac524..f4b0146 100644 --- a/packages/react-native-bottom-tabs/ios/TabViewProvider.swift +++ b/packages/react-native-bottom-tabs/ios/TabViewProvider.swift @@ -261,7 +261,13 @@ public final class TabInfo: NSObject { guard let image else { return } DispatchQueue.main.async { [weak self] in guard let self else { return } - props.icons[index] = image.resizeImageTo(size: iconSize) + let icon = image.resizeImageTo(size: iconSize) + #if os(macOS) + props.icons[index] = icon + #else + props.icons[index] = icon?.withRenderingMode(.alwaysTemplate) + #endif + props.iconsRevision += 1 } }) }