diff --git a/.changeset/large-hats-teach.md b/.changeset/large-hats-teach.md new file mode 100644 index 00000000..6a1b6f8e --- /dev/null +++ b/.changeset/large-hats-teach.md @@ -0,0 +1,5 @@ +--- +'react-native-bottom-tabs': patch +--- + +Add `experimental_bakedTintColors` to opt into the iOS 26 Liquid Glass active and inactive tint color workaround. diff --git a/apps/example/src/Examples/TintColors.tsx b/apps/example/src/Examples/TintColors.tsx index c5f44333..538e0a51 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', @@ -52,6 +57,7 @@ export default function TintColorsExample() { renderScene={renderScene} tabBarActiveTintColor="red" tabBarInactiveTintColor="orange" + experimental_bakedTintColors={false} scrollEdgeAppearance="default" /> ); diff --git a/docs/docs/docs/guides/standalone-usage.md b/docs/docs/docs/guides/standalone-usage.md index 959a84f1..d795e38d 100644 --- a/docs/docs/docs/guides/standalone-usage.md +++ b/docs/docs/docs/guides/standalone-usage.md @@ -174,12 +174,28 @@ Color for the active tab. - Type: `ColorValue` +:::warning +On iOS 26 (Liquid Glass), enable `experimental_bakedTintColors` to apply 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), enable `experimental_bakedTintColors` to apply 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. +::: + +#### `experimental_bakedTintColors` + +Enables the iOS 26 Liquid Glass workaround for active and inactive tint colors. When enabled with custom active or inactive tint colors, tab labels are baked into images so the icon and label receive the same tint during normal selection and Liquid Glass tab scrubbing. + +:::warning +This disables Dynamic Type (accessibility font sizes) and the Bold Text accessibility setting for tab labels, and icon sizing can vary with label width. +::: + #### `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 8272217d..a4c33b86 100644 --- a/docs/docs/docs/guides/usage-with-react-navigation.mdx +++ b/docs/docs/docs/guides/usage-with-react-navigation.mdx @@ -124,10 +124,26 @@ 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), enable `experimental_bakedTintColors` to apply 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), enable `experimental_bakedTintColors` to apply 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. +::: + +#### `experimental_bakedTintColors` + +Enables the iOS 26 Liquid Glass workaround for active and inactive tint colors. When enabled with custom active or inactive tint colors, tab labels are baked into images so the icon and label receive the same tint during normal selection and Liquid Glass tab scrubbing. + +:::warning +This disables Dynamic Type (accessibility font sizes) and the Bold Text accessibility setting for tab labels, and icon sizing can vary with label width. +::: + #### `tabBarStyle` Object containing styles for the tab bar. @@ -240,6 +256,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), enable `experimental_bakedTintColors` to apply 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/android/src/main/java/com/rcttabview/RCTTabViewManager.kt b/packages/react-native-bottom-tabs/android/src/main/java/com/rcttabview/RCTTabViewManager.kt index 3f130e11..95644dbb 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 @@ -151,6 +151,9 @@ class RCTTabViewManager(context: ReactApplicationContext) : view.setInactiveTintColor(value) } + override fun setExperimentalBakedTintColors(view: ReactBottomNavigationView?, value: Boolean) { + } + override fun setActiveIndicatorColor(view: ReactBottomNavigationView?, value: Int?) { if (view != null && value != null) { val color = ColorStateList.valueOf(value) diff --git a/packages/react-native-bottom-tabs/ios/RCTTabViewComponentView.mm b/packages/react-native-bottom-tabs/ios/RCTTabViewComponentView.mm index 92cab0e7..bd8edcef 100644 --- a/packages/react-native-bottom-tabs/ios/RCTTabViewComponentView.mm +++ b/packages/react-native-bottom-tabs/ios/RCTTabViewComponentView.mm @@ -156,6 +156,10 @@ - (void)updateProps:(Props::Shared const &)props oldProps:(Props::Shared const & _tabViewProvider.inactiveTintColor = RCTUIColorFromSharedColor(newViewProps.inactiveTintColor); } + if (oldViewProps.experimentalBakedTintColors != newViewProps.experimentalBakedTintColors) { + _tabViewProvider.experimentalBakedTintColors = newViewProps.experimentalBakedTintColors; + } + if (oldViewProps.hapticFeedbackEnabled != newViewProps.hapticFeedbackEnabled) { _tabViewProvider.hapticFeedbackEnabled = newViewProps.hapticFeedbackEnabled; } @@ -262,4 +266,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 29eb3bb1..138d9f04 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) + updateExperimentalBakedTintColors(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 updateExperimentalBakedTintColors(props: TabViewProps, tabBar: UITabBar?) { + guard shouldUseExperimentalBakedTintColors(props: props), + let tabBar, + let items = tabBar.items else { return } + + configureExperimentalBakedTintColors(items: items, props: props) + + DispatchQueue.main.async { [weak tabBar] in + guard let tabBar, let items = tabBar.items else { return } + configureExperimentalBakedTintColors(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,179 @@ struct TabViewImpl: View { tabBar.scrollEdgeAppearance = appearance.copy() } } + + private func configureExperimentalBakedTintColors(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 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 } + + let assetIcon = props.icons[itemIndex] + let icon = assetIcon ?? makeSFSymbolImage(named: tabData.sfSymbol) + + item.title = props.labeled ? tabData.title : nil + item.titlePositionAdjustment = UIOffset(horizontal: 0, vertical: 0) + item.image = icon + item.selectedImage = icon + } + } + + 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 shouldUseExperimentalBakedTintColors(props: TabViewProps) -> Bool { + guard props.experimentalBakedTintColors else { + return false + } + + #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 +442,40 @@ extension View { } .onChange(of: props.inactiveTintColor) { _ in updateTabBarAppearance(props: props, tabBar: tabBar) + updateExperimentalBakedTintColors(props: props, tabBar: tabBar) } - .onChange(of: props.selectedActiveTintColor) { _ in + .onChange(of: props.activeTintColor) { _ in updateTabBarAppearance(props: props, tabBar: tabBar) + updateExperimentalBakedTintColors(props: props, tabBar: tabBar) + } + .onChange(of: props.selectedActiveTintColor) { newValue in + tabBar?.tintColor = newValue + } + .onChange(of: props.iconsRevision) { _ in + updateExperimentalBakedTintColors(props: props, tabBar: tabBar) + } + .onChange(of: props.labeled) { _ in + updateExperimentalBakedTintColors(props: props, tabBar: tabBar) } .onChange(of: props.fontSize) { _ in updateTabBarAppearance(props: props, tabBar: tabBar) + updateExperimentalBakedTintColors(props: props, tabBar: tabBar) } .onChange(of: props.fontFamily) { _ in updateTabBarAppearance(props: props, tabBar: tabBar) + updateExperimentalBakedTintColors(props: props, tabBar: tabBar) } .onChange(of: props.fontWeight) { _ in updateTabBarAppearance(props: props, tabBar: tabBar) + updateExperimentalBakedTintColors(props: props, tabBar: tabBar) + } + .onChange(of: props.experimentalBakedTintColors) { newValue in + updateTabBarAppearance(props: props, tabBar: tabBar) + if newValue { + updateExperimentalBakedTintColors(props: props, tabBar: tabBar) + } else { + resetExperimentalBakedTintColors(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 e9dc5d9d..3827785e 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? @@ -63,6 +64,7 @@ class TabViewProps: ObservableObject { @Published var barTintColor: PlatformColor? @Published var activeTintColor: PlatformColor? @Published var inactiveTintColor: PlatformColor? + @Published var experimentalBakedTintColors: Bool = false @Published var translucent: Bool = true @Published var disablePageAnimations: Bool = false @Published var hapticFeedbackEnabled: Bool = false @@ -82,6 +84,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 deac524d..799f3c0c 100644 --- a/packages/react-native-bottom-tabs/ios/TabViewProvider.swift +++ b/packages/react-native-bottom-tabs/ios/TabViewProvider.swift @@ -136,6 +136,12 @@ public final class TabInfo: NSObject { } } + @objc public var experimentalBakedTintColors: Bool = false { + didSet { + props.experimentalBakedTintColors = experimentalBakedTintColors + } + } + @objc public var fontFamily: NSString? { didSet { props.fontFamily = fontFamily as? String @@ -261,7 +267,17 @@ 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(iOS) + if props.experimentalBakedTintColors { + props.icons[index] = icon?.withRenderingMode(.alwaysTemplate) + props.iconsRevision += 1 + } else { + props.icons[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 4e93f090..8ae9941c 100644 --- a/packages/react-native-bottom-tabs/src/TabView.tsx +++ b/packages/react-native-bottom-tabs/src/TabView.tsx @@ -79,6 +79,13 @@ interface Props { * Inactive tab color. */ tabBarInactiveTintColor?: ColorValue; + /** + * Enables the iOS 26 Liquid Glass tint color workaround that bakes tab labels + * into images. This can affect icon sizing when labels have different widths. + * + * @platform ios + */ + experimental_bakedTintColors?: boolean; /** * State for the tab view. * @@ -251,6 +258,7 @@ const TabView = ({ tabLabelStyle, renderBottomAccessoryView, layoutDirection = 'locale', + experimental_bakedTintColors: experimentalBakedTintColors = false, ...props }: Props) => { // @ts-ignore @@ -413,6 +421,7 @@ const TabView = ({ layoutDirection={layoutDirection} activeTintColor={activeTintColor} inactiveTintColor={inactiveTintColor} + experimentalBakedTintColors={experimentalBakedTintColors} barTintColor={tabBarStyle?.backgroundColor} rippleColor={rippleColor} labeled={labeled} diff --git a/packages/react-native-bottom-tabs/src/TabViewNativeComponent.ts b/packages/react-native-bottom-tabs/src/TabViewNativeComponent.ts index 50082c55..4a4e81ad 100644 --- a/packages/react-native-bottom-tabs/src/TabViewNativeComponent.ts +++ b/packages/react-native-bottom-tabs/src/TabViewNativeComponent.ts @@ -53,6 +53,7 @@ export interface TabViewProps extends ViewProps { rippleColor?: ColorValue; activeTintColor?: ColorValue; inactiveTintColor?: ColorValue; + experimentalBakedTintColors?: WithDefault; disablePageAnimations?: boolean; activeIndicatorColor?: ColorValue; hapticFeedbackEnabled?: boolean;