diff --git a/.changeset/ios-prominent-tab-role.md b/.changeset/ios-prominent-tab-role.md
new file mode 100644
index 00000000..340cc1d1
--- /dev/null
+++ b/.changeset/ios-prominent-tab-role.md
@@ -0,0 +1,5 @@
+---
+'react-native-bottom-tabs': minor
+---
+
+Add the `prominent` tab role (iOS 27+), and fix iOS 27 tabs keeping their selected tint after switching tabs or minimizing the tab bar
diff --git a/docs/docs/docs/guides/standalone-usage.mdx b/docs/docs/docs/guides/standalone-usage.mdx
index d9f02c43..29c06bde 100644
--- a/docs/docs/docs/guides/standalone-usage.mdx
+++ b/docs/docs/docs/guides/standalone-usage.mdx
@@ -241,7 +241,7 @@ Each route in the `routes` array can have the following properties:
- `activeTintColor`: Custom active tint color for this specific tab
- `lazy`: Whether to lazy load this tab's content
- `freezeOnBlur`: Whether to freeze the tab's content when it's not visible
-- `role`: A value that defines the purpose of the tab
+- `role`: A value that defines the purpose of the tab on iOS: `'search'` (iOS 18+) or `'prominent'` (iOS 27+, displayed as a regular tab on earlier versions)
- `style`: Style object for the component wrapping the screen content
- `preventsDefault`: Whether to prevent default tab switching behavior when pressed
diff --git a/docs/docs/docs/guides/usage-with-react-navigation.mdx b/docs/docs/docs/guides/usage-with-react-navigation.mdx
index 78e49d55..8ff246ac 100644
--- a/docs/docs/docs/guides/usage-with-react-navigation.mdx
+++ b/docs/docs/docs/guides/usage-with-react-navigation.mdx
@@ -398,11 +398,12 @@ Test ID for the tab item. This can be used to find the tab item in the native vi
#### `role`
-A value that defines the purpose of the tab. This can be used to pin and separate search tabs
+A value that defines the purpose of the tab.
Available options:
-- `search` - The search role.
+- `search` - Marks the tab as the search tab, which the system can pin to the trailing end of the tab bar. On iOS 27+, it may also receive the prominent treatment when no tab has the `prominent` role.
+- `prominent` - Gives the tab more visual emphasis than the others. Only one tab can be prominent. Requires building with the iOS 27 SDK, and on earlier iOS versions the tab is displayed as a regular tab.
#### `sceneStyle`
diff --git a/packages/example-shared/src/App.tsx b/packages/example-shared/src/App.tsx
index 5e70681a..420d3427 100644
--- a/packages/example-shared/src/App.tsx
+++ b/packages/example-shared/src/App.tsx
@@ -28,6 +28,7 @@ import SixTabs from './Examples/SixTabs';
import FourTabsRTL from './Examples/FourTabsRTL';
import MaterialBottomTabs from './Examples/MaterialBottomTabs';
import SFSymbols from './Examples/SFSymbols';
+import Roles from './Examples/Roles';
import LabeledTabs from './Examples/Labeled';
import NativeBottomTabs from './Examples/NativeBottomTabs';
import TintColorsExample from './Examples/TintColors';
@@ -99,6 +100,7 @@ const examples = [
{ component: FourTabs, name: 'Four Tabs' },
{ component: FiveTabs, name: 'Five Tabs' },
{ component: SixTabs, name: 'Six Tabs' },
+ { component: Roles, name: 'Roles', platform: 'ios' },
{
component: SFSymbols,
name: 'SF Symbols',
diff --git a/packages/example-shared/src/Examples/Roles.tsx b/packages/example-shared/src/Examples/Roles.tsx
new file mode 100644
index 00000000..ddfbff4b
--- /dev/null
+++ b/packages/example-shared/src/Examples/Roles.tsx
@@ -0,0 +1,107 @@
+import { useMemo, useState } from 'react';
+import { Button, StyleSheet, Switch, Text, View } from 'react-native';
+import TabView, { SceneMap } from 'react-native-bottom-tabs';
+import type { TabRole } from 'react-native-bottom-tabs';
+import { Article } from '../Screens/Article';
+import { Albums } from '../Screens/Albums';
+import { Contacts } from '../Screens/Contacts';
+
+const renderScene = SceneMap({
+ article: Article,
+ albums: Albums,
+ role: Contacts,
+});
+
+export default function Roles() {
+ const [role, setRole] = useState('search');
+ const [bakedTintColors, setBakedTintColors] = useState(false);
+ const [index, setIndex] = useState(0);
+ const routes = useMemo(
+ () => [
+ {
+ key: 'article',
+ title: 'Article',
+ focusedIcon: { sfSymbol: 'document' },
+ },
+ {
+ key: 'albums',
+ title: 'Albums',
+ focusedIcon: { sfSymbol: 'square.grid.2x2' },
+ },
+ {
+ key: 'role',
+ title: role === 'search' ? 'Search' : 'Contacts',
+ focusedIcon: {
+ sfSymbol:
+ role === 'search' ? 'magnifyingglass' : 'person.crop.circle',
+ },
+ role,
+ testID: `roles-${role}-tab`,
+ },
+ ],
+ [role]
+ );
+
+ const selectRole = (nextRole: TabRole) => {
+ setIndex(0);
+ setRole(nextRole);
+ };
+
+ return (
+
+
+
+
+
+ {role === 'search'
+ ? 'Search role (iOS 18+). May receive prominent styling when no tab has the prominent role.'
+ : 'Prominent role (iOS 27+). Emphasizes the Contacts tab; falls back to an ordinary tab on older iOS versions.'}
+
+
+ Experimental baked tint colors
+
+
+
+
+
+ );
+}
+
+const styles = StyleSheet.create({
+ container: {
+ flex: 1,
+ },
+ controls: {
+ padding: 12,
+ gap: 12,
+ },
+ row: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ justifyContent: 'space-between',
+ },
+});
diff --git a/packages/react-native-bottom-tabs/ios/TabView/NewTabView.swift b/packages/react-native-bottom-tabs/ios/TabView/NewTabView.swift
index 2f18f210..bc9e6eff 100644
--- a/packages/react-native-bottom-tabs/ios/TabView/NewTabView.swift
+++ b/packages/react-native-bottom-tabs/ios/TabView/NewTabView.swift
@@ -28,7 +28,11 @@ struct NewTabView: AnyTabView {
let isFocused = props.selectedPage == tabData.key
if !tabData.hidden || isFocused {
- let icon = props.icons[index]
+ #if os(macOS)
+ let labelImage: PlatformImage? = nil
+ #else
+ let labelImage = props.itemImages.images(for: tabData, props: props)?.labelImage
+ #endif
let context = TabAppearContext(
index: index,
@@ -38,7 +42,7 @@ struct NewTabView: AnyTabView {
onSelect: onSelect
)
- Tab(value: tabData.key, role: tabData.role?.convert()) {
+ Tab(value: tabData.key, role: tabData.role.flatMap { $0.convert() }) {
RepresentableView(view: child.view)
.ignoresSafeArea(.container, edges: .all)
.tabAppear(using: context)
@@ -46,10 +50,11 @@ struct NewTabView: AnyTabView {
} label: {
TabItem(
title: tabData.title,
- icon: icon,
+ icon: labelImage ?? props.icons[index],
sfSymbol: tabData.sfSymbol,
labeled: props.labeled,
- iconRenderingMode: tabData.iconRenderingMode
+ // Rendered label images already have their final rendering mode.
+ iconRenderingMode: labelImage == nil ? tabData.iconRenderingMode : nil
)
}
#if !os(tvOS)
diff --git a/packages/react-native-bottom-tabs/ios/TabViewImpl.swift b/packages/react-native-bottom-tabs/ios/TabViewImpl.swift
index 68053e1f..ad493310 100644
--- a/packages/react-native-bottom-tabs/ios/TabViewImpl.swift
+++ b/packages/react-native-bottom-tabs/ios/TabViewImpl.swift
@@ -219,64 +219,34 @@ struct TabViewImpl: View {
}
}
+ /// Configures the parts of the tab bar items that SwiftUI can't express.
+ ///
+ /// The unselected image is owned by SwiftUI through the tab label (see `TabBarItemImages.labelImage`),
+ /// so it's never assigned here. Assigning item images while the tab bar animates (e.g. minimizing or
+ /// switching tabs) breaks its selection styling on iOS 27, so `selectedImage` is only assigned when it
+ /// changes, and SwiftUI updates its images only when a label changes.
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 }
+ guard let tabData = props.filteredItems[safe: tabBarIndex] else { continue }
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
+ let images = props.itemImages.images(for: tabData, props: props)
item.accessibilityLabel = tabData.title
- if useBakedTintColors, shouldRenderLabelIntoImage, let icon {
- let selectedIcon = focusedIcon ?? icon
+ if let images {
+ assignSelectedImage(images.selectedImage, to: item)
+ }
+
+ if images?.rendersLabelIntoImage == true {
item.title = ""
item.titlePositionAdjustment = UIOffset(horizontal: 0, vertical: 100)
- item.image = makeTabBarItemImage(
- icon: icon,
- title: tabData.title,
- color: props.inactiveTintColor,
- preservesOriginalIconColors: preservesOriginalIconColors,
- props: props
- )
- item.selectedImage = makeTabBarItemImage(
- icon: selectedIcon,
- title: tabData.title,
- color: tabActiveColor,
- preservesOriginalIconColors: preservesOriginalIconColors,
- props: props
- )
continue
}
item.title = props.labeled ? tabData.title : nil
item.titlePositionAdjustment = UIOffset(horizontal: 0, vertical: 0)
- if let 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(
TabBarFontSize.createFontAttributes(
size: props.fontSize.map(CGFloat.init) ?? TabBarFontSize.defaultSize,
@@ -289,8 +259,147 @@ struct TabViewImpl: View {
}
}
- private func preservesOriginalIconColors(tabData: TabInfo) -> Bool {
- tabData.iconRenderingMode == "original"
+ private func assignSelectedImage(_ image: UIImage, to item: UITabBarItem) {
+ // `UITabBarItem.selectedImage` returns a copy, so compare against the last assigned instance instead.
+ let assignedImage = objc_getAssociatedObject(item, &AssociatedKeys.selectedImage) as? UIImage
+ guard assignedImage !== image else { return }
+
+ item.selectedImage = image
+ objc_setAssociatedObject(item, &AssociatedKeys.selectedImage, image, .OBJC_ASSOCIATION_RETAIN_NONATOMIC)
+ }
+
+ private enum AssociatedKeys {
+ static var selectedImage: UInt8 = 0
+ }
+
+ struct TabBarItemImages {
+ /// Replaces the icon of the tab's SwiftUI label, or `nil` to keep the tab's own icon.
+ let labelImage: UIImage?
+ let selectedImage: UIImage
+ let rendersLabelIntoImage: Bool
+ }
+
+ final class TabBarItemImageCache {
+ private var entries: [String: (inputs: Inputs, images: TabBarItemImages?)] = [:]
+
+ func images(for tabData: TabInfo, props: TabViewProps) -> TabBarItemImages? {
+ guard let itemIndex = props.items.firstIndex(where: { $0.key == tabData.key }) else { return nil }
+
+ let assetIcon = props.icons[itemIndex]
+ let focusedAssetIcon = props.focusedIcons[itemIndex]
+ let useBakedTintColors = shouldUseExperimentalBakedTintColors(props: props)
+ let hasEffectiveRole: Bool
+ if #available(iOS 18, macOS 15, visionOS 2, tvOS 18, *) {
+ hasEffectiveRole = tabData.role?.convert() != nil
+ } else {
+ hasEffectiveRole = false
+ }
+ let inputs = Inputs(
+ icon: assetIcon.map(IconSource.image) ?? .sfSymbol(tabData.sfSymbol),
+ focusedIcon: focusedAssetIcon.map(IconSource.image) ?? tabData.focusedSfSymbol.map(IconSource.sfSymbol),
+ title: tabData.title,
+ inactiveTintColor: props.inactiveTintColor,
+ activeTintColor: tabData.activeTintColor ?? props.activeTintColor,
+ preservesOriginalIconColors: tabData.iconRenderingMode == "original",
+ useBakedTintColors: useBakedTintColors,
+ rendersLabelIntoImage: useBakedTintColors && props.hasCustomTintColors && props.labeled && !hasEffectiveRole,
+ fontSize: props.fontSize,
+ fontFamily: props.fontFamily,
+ fontWeight: props.fontWeight
+ )
+
+ if let entry = entries[tabData.key], entry.inputs == inputs {
+ return entry.images
+ }
+
+ let images = render(
+ icon: assetIcon ?? makeSFSymbolImage(named: tabData.sfSymbol),
+ focusedIcon: focusedAssetIcon ?? makeSFSymbolImage(named: tabData.focusedSfSymbol),
+ inputs: inputs,
+ props: props
+ )
+ entries[tabData.key] = (inputs, images)
+ return images
+ }
+
+ private func render(
+ icon: UIImage?,
+ focusedIcon: UIImage?,
+ inputs: Inputs,
+ props: TabViewProps
+ ) -> TabBarItemImages? {
+ guard let icon else { return nil }
+ let selectedIcon = focusedIcon ?? icon
+
+ if inputs.rendersLabelIntoImage {
+ return TabBarItemImages(
+ labelImage: makeTabBarItemImage(
+ icon: icon,
+ title: inputs.title,
+ color: inputs.inactiveTintColor,
+ preservesOriginalIconColors: inputs.preservesOriginalIconColors,
+ props: props
+ ),
+ selectedImage: makeTabBarItemImage(
+ icon: selectedIcon,
+ title: inputs.title,
+ color: inputs.activeTintColor,
+ preservesOriginalIconColors: inputs.preservesOriginalIconColors,
+ props: props
+ ),
+ rendersLabelIntoImage: true
+ )
+ }
+
+ return TabBarItemImages(
+ // Without baked tint colors, the tab's own icon is already what the label shows.
+ labelImage: inputs.useBakedTintColors
+ ? renderTabBarIcon(
+ icon,
+ color: inputs.inactiveTintColor,
+ preservesOriginalIconColors: inputs.preservesOriginalIconColors,
+ forceTintColor: true
+ )
+ : nil,
+ selectedImage: renderTabBarIcon(
+ selectedIcon,
+ color: inputs.activeTintColor,
+ preservesOriginalIconColors: inputs.preservesOriginalIconColors,
+ forceTintColor: inputs.useBakedTintColors
+ ),
+ rendersLabelIntoImage: false
+ )
+ }
+
+ private enum IconSource: Equatable {
+ case image(UIImage)
+ case sfSymbol(String)
+
+ static func == (lhs: IconSource, rhs: IconSource) -> Bool {
+ switch (lhs, rhs) {
+ case let (.image(lhs), .image(rhs)):
+ return lhs === rhs
+ case let (.sfSymbol(lhs), .sfSymbol(rhs)):
+ return lhs == rhs
+ default:
+ return false
+ }
+ }
+ }
+
+ private struct Inputs: Equatable {
+ let icon: IconSource
+ let focusedIcon: IconSource?
+ let title: String
+ let inactiveTintColor: UIColor?
+ let activeTintColor: UIColor?
+ let preservesOriginalIconColors: Bool
+ let useBakedTintColors: Bool
+ let rendersLabelIntoImage: Bool
+ let fontSize: Int?
+ let fontFamily: String?
+ let fontWeight: String?
+ }
}
private func renderTabBarIcon(
diff --git a/packages/react-native-bottom-tabs/ios/TabViewProps.swift b/packages/react-native-bottom-tabs/ios/TabViewProps.swift
index dd4c3c88..d3453f57 100644
--- a/packages/react-native-bottom-tabs/ios/TabViewProps.swift
+++ b/packages/react-native-bottom-tabs/ios/TabViewProps.swift
@@ -30,12 +30,22 @@ internal enum MinimizeBehavior: String {
public enum TabBarRole: String {
case search
+ case prominent
@available(iOS 18, macOS 15, visionOS 2, tvOS 18, *)
- func convert() -> TabRole {
+ func convert() -> TabRole? {
switch self {
case .search:
return .search
+ case .prominent:
+ #if compiler(>=6.4)
+ if #available(iOS 27, macOS 27, visionOS 27, tvOS 27, *) {
+ return .prominent
+ }
+ #endif
+ // `.prominent` role was introduced in iOS 27 SDK; ignore it on older OS versions
+ // instead of crashing or silently mapping to the wrong role.
+ return nil
}
}
}
@@ -73,6 +83,9 @@ class TabViewProps: ObservableObject {
@Published var fontFamily: String?
@Published var fontWeight: String?
@Published var tabBarHidden: Bool = false
+ #if !os(macOS)
+ let itemImages = TabBarItemImageCache()
+ #endif
var selectedActiveTintColor: PlatformColor? {
if let selectedPage,
diff --git a/packages/react-native-bottom-tabs/src/types.ts b/packages/react-native-bottom-tabs/src/types.ts
index 3f4eda80..84223786 100644
--- a/packages/react-native-bottom-tabs/src/types.ts
+++ b/packages/react-native-bottom-tabs/src/types.ts
@@ -5,7 +5,7 @@ export type IconSource = string | ImageSourcePropType;
export type AppleIcon = { sfSymbol: SFSymbol };
-export type TabRole = 'search';
+export type TabRole = 'search' | 'prominent';
export type IconRenderingMode = 'automatic' | 'original';