Skip to content

feat: add Tab api support on newer OS #364

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 13 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions packages/react-native-bottom-tabs/ios/TabAppearModifier.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import SwiftUI

struct TabAppearContext {
let index: Int
let tabData: TabInfo
let props: TabViewProps
let updateTabBarAppearance: () -> Void
let onSelect: (_ key: String) -> Void
}

struct TabAppearModifier: ViewModifier {
let context: TabAppearContext

func body(content: Content) -> some View {
content.onAppear {
#if !os(macOS)
context.updateTabBarAppearance()
#endif

#if os(iOS)
if context.index >= 4, context.props.selectedPage != context.tabData.key {
context.onSelect(context.tabData.key)
}
#endif
}
}
}

extension View {
func tabAppear(using context: TabAppearContext) -> some View {
self.modifier(TabAppearModifier(context: context))
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import SwiftUI

public protocol AnyTabView: View {
var onLayout: (_ size: CGSize) -> Void { get }
var onSelect: (_ key: String) -> Void { get }
var updateTabBarAppearance: () -> Void { get }
}
56 changes: 56 additions & 0 deletions packages/react-native-bottom-tabs/ios/TabView/LegacyTabView.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import SwiftUI

struct LegacyTabView: AnyTabView {
@ObservedObject var props: TabViewProps

Check warning on line 5 in packages/react-native-bottom-tabs/ios/TabView/LegacyTabView.swift

View workflow job for this annotation

GitHub Actions / swift-lint

Trailing Whitespace Violation: Lines should not have trailing whitespace (trailing_whitespace)
var onLayout: (CGSize) -> Void
var onSelect: (String) -> Void
var updateTabBarAppearance: () -> Void

Check warning on line 9 in packages/react-native-bottom-tabs/ios/TabView/LegacyTabView.swift

View workflow job for this annotation

GitHub Actions / swift-lint

Trailing Whitespace Violation: Lines should not have trailing whitespace (trailing_whitespace)
@ViewBuilder
var body: some View {
TabView(selection: $props.selectedPage) {
ForEach(props.children.indices, id: \.self) { index in
renderTabItem(at: index)
}
.measureView { size in
onLayout(size)
}
}
.hideTabBar(props.tabBarHidden)
}

Check warning on line 22 in packages/react-native-bottom-tabs/ios/TabView/LegacyTabView.swift

View workflow job for this annotation

GitHub Actions / swift-lint

Trailing Whitespace Violation: Lines should not have trailing whitespace (trailing_whitespace)
@ViewBuilder
private func renderTabItem(at index: Int) -> some View {
if let tabData = props.items[safe: index] {
let isFocused = props.selectedPage == tabData.key

Check warning on line 27 in packages/react-native-bottom-tabs/ios/TabView/LegacyTabView.swift

View workflow job for this annotation

GitHub Actions / swift-lint

Trailing Whitespace Violation: Lines should not have trailing whitespace (trailing_whitespace)
if !tabData.hidden || isFocused {
let icon = props.icons[index]
let child = props.children[safe: index] ?? PlatformView()
let context = TabAppearContext(
index: index,
tabData: tabData,
props: props,
updateTabBarAppearance: updateTabBarAppearance,
onSelect: onSelect
)

RepresentableView(view: child)
.ignoresSafeArea(.container, edges: .all)
.tabItem {
TabItem(
title: tabData.title,
icon: icon,
sfSymbol: tabData.sfSymbol,
labeled: props.labeled
)
.accessibilityIdentifier(tabData.testID ?? "")
.tag(tabData.key)
Copy link
Preview

Copilot AI Jul 6, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The .tag modifier is placed inside the .tabItem closure, so it may not be applied correctly to the TabView item. Move .tag(tabData.key) outside of the .tabItem { … } builder to ensure the TabView recognizes the tag.

Suggested change
.tag(tabData.key)

Copilot uses AI. Check for mistakes.

.tabBadge(tabData.badge)
.tabAppear(using: context)
}
}
}
}
}
55 changes: 55 additions & 0 deletions packages/react-native-bottom-tabs/ios/TabView/NewTabView.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import SwiftUI

@available(iOS 18, macOS 15, visionOS 2, tvOS 18, *)
struct NewTabView: AnyTabView {
@ObservedObject var props: TabViewProps

var onLayout: (CGSize) -> Void
var onSelect: (String) -> Void
var updateTabBarAppearance: () -> Void

@ViewBuilder
var body: some View {
TabView(selection: $props.selectedPage) {
ForEach(props.children.indices, id: \.self) { index in
if let tabData = props.items[safe: index] {
let isFocused = props.selectedPage == tabData.key

if !tabData.hidden || isFocused {
let icon = props.icons[index]
let role: TabRole? = nil

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can look for a String of search (To make this backwards-compatible <18.0) and then convert that into a TabRole.search

Suggested change
let role: TabRole? = nil
var role: TabRole? {
if tabData.tabRole == "search" {
return TabRole.search
}
return nil
}

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Of course we'd have to add the tabRole prop to TabViewProvider as well

diff --git a/packages/react-native-bottom-tabs/ios/TabViewProvider.swift b/packages/react-native-bottom-tabs/ios/TabViewProvider.swift
index 135c9a9..63669b5 100644
--- a/packages/react-native-bottom-tabs/ios/TabViewProvider.swift
+++ b/packages/react-native-bottom-tabs/ios/TabViewProvider.swift
@@ -13,7 +13,8 @@ public final class TabInfo: NSObject {
   public let activeTintColor: PlatformColor?
   public let hidden: Bool
   public let testID: String?
-
+  public let tabRole: String?
+  
   public init(
     key: String,
     title: String,
@@ -21,7 +22,8 @@ public final class TabInfo: NSObject {
     sfSymbol: String,
     activeTintColor: PlatformColor?,
     hidden: Bool,
-    testID: String?
+    testID: String?,
+    tabRole: String?
   ) {
     self.key = key
     self.title = title
@@ -30,6 +32,7 @@ public final class TabInfo: NSObject {
     self.activeTintColor = activeTintColor
     self.hidden = hidden
     self.testID = testID
+    self.tabRole = tabRole
     super.init()
   }
 }
@@ -267,7 +270,8 @@ public final class TabInfo: NSObject {
             sfSymbol: itemDict["sfSymbol"] as? String ?? "",
             activeTintColor: RCTConvert.uiColor(itemDict["activeTintColor"] as? NSNumber),
             hidden: itemDict["hidden"] as? Bool ?? false,
-            testID: itemDict["testID"] as? String ?? ""
+            testID: itemDict["testID"] as? String ?? "",
+            tabRole: itemDict["tabRole"] as? String ?? ""
           )
         )
       }

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

makes sense to me, however i don't know if setting tabRole to a String? would be the right move, maybe using an Enum would make it more typesafe and future proof. Also, similarly to tabBarCustomization, it's probably better to add it in a follow up pr after this one is merged.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah String? isn't super ideal but the TabRole enum is only available in iOS18+ which would mean splitting into NewTabViewProvider and LegacyTabViewProvider as well as the split you already have.

There is currently only one role: .search but I think we could do TabRole(rawValue: tabData.tabRole) though which would still future proof it in case of future roles.

For sure can be done in a followup PR, it would obviously build upon yours and I couldn't figure out how to get GitHub to stack PRs

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

you're right, that's probably the easiest way atm, and i guess we could implement the enum on the TS types (type TabRole = 'search';) so typescript users will have a typesafe way of adding a tab role. As for pr stacking i don't know either, we might have to wait for this one to be merged first

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would remove passing role in this PR completely and create a separate PR implementing it. For PR stacking you can create a new branch off this PR and create a new PR pointing to this branch.

Making it a string sounds good to me, we can use TypeScript to enforce users to pass search and if in the future Apple adds more roles we will be ready.

Copy link
Preview

Copilot AI Jul 6, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The TabRole is hardcoded to nil, which prevents assigning roles like .search from your tabData. Consider mapping the role from tabData (e.g., let role: TabRole? = tabData.role) to restore role support.

Suggested change
let role: TabRole? = nil
let role: TabRole? = tabData.role

Copilot uses AI. Check for mistakes.


let platformChild = props.children[safe: index] ?? PlatformView()
let child = RepresentableView(view: platformChild)
let context = TabAppearContext(
index: index,
tabData: tabData,
props: props,
updateTabBarAppearance: updateTabBarAppearance,
onSelect: onSelect
)

Tab(value: tabData.key, role: role) {
child
.ignoresSafeArea(.container, edges: .all)
.tabAppear(using: context)
} label: {
TabItem(
title: tabData.title,
icon: icon,
sfSymbol: tabData.sfSymbol,
labeled: props.labeled
)
}
//.badge(tabData.badge)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why it's commented out?

Copy link
Author

@47PADO47 47PADO47 Jun 20, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

as i wrote in the pr description, i commented it out because otherwise on iOS, even if tabData.badge is an empty string, a badge is created on the tab bar (see screenshot attached below). this behaviour can be seen on both ios 18 and 26, however it doesn't happen on ipad or other platforms. i tried using an if statement (like copilot suggested) but it broke the ForEach with Cannot convert value of type 'Range<Array<PlatformView>.Index>' (aka 'Range<Int>') to expected argument type 'Binding<C>'. i also tried using view modifiers, moving that part to a separate functio, using tabData.badge.isEmpty ? nil : tabData.badge (which does not work because .badge does not accept nil as value) and tabData.badge.isEmpty ? 0 : tabData.badge (which does not work because a variable can't be of type Int or String), but nothing worked and it seems like the only way to remove it is either not call the function or call it with 0 (which can't be done since you either call it with a number or a string). let me know your thoughts on this and how we could resolve it, because honestly i have no idea

(Screenshot) Here's what happens if i uncomment .badge(): only the first tab should have a badge, but a badge is created for all of them

Simulator Screenshot - iPhone 16 Pro - 2025-06-20 at 15 57 45

Copy link

@hennessyevan hennessyevan Jul 4, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think that's because it's supposed to be Text? not String?

https://developer.apple.com/documentation/swiftui/view/badge(_:)-6k2u9

This compiled for me

  .badge(tabData.badge.isEmpty ? nil : Text(tabData.badge))

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

thanks for pointing it out. when developing i only saw and looked into the one below, which didn't specify text nor string

https://developer.apple.com/documentation/swiftui/tabcontent/badge(_:)

currently i can't test your implementation, however if you say it works with Text then this might be the solution. can you confirm the problem is gone on both ios 18.5 and ios 26 simulators?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@47PADO47 I've created a follow up PR addressing the behavior of the empty badge prop: #378

It should unblock this PR!

.accessibilityIdentifier(tabData.testID ?? "")
}
}
}
}
.measureView { size in
onLayout(size)
}
Comment on lines +50 to +52
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you check if measureView returns the same values? Before we applied it to the ForEach, now it's applied to the TabView. I'm afraid this can cause issues and return different values

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

hi, when testing i haven't had any problems with measureView applied to TabView, and found that it would always return one value/the same value as if measureView was applied to Tab child. also i tried moving it up to the ForEach, but it gave this error Referencing instance method 'measureView(onLayout:)' on 'ForEach' requires that 'some TabContent<String>' conform to 'View' which i did not know how to resolve. Let me know if you think we should rework it and apply to the ForEach anyway, otherwise i'd leave it like this since it seems like nothing changes

.hideTabBar(props.tabBarHidden)
}
}
75 changes: 26 additions & 49 deletions packages/react-native-bottom-tabs/ios/TabViewImpl.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,21 +13,37 @@
#else
@Weak var tabBar: UITabBar?
#endif


Check warning on line 16 in packages/react-native-bottom-tabs/ios/TabViewImpl.swift

View workflow job for this annotation

GitHub Actions / swift-lint

Trailing Whitespace Violation: Lines should not have trailing whitespace (trailing_whitespace)
@ViewBuilder
var tabContent: some View {
if #available(iOS 18, macOS 15, visionOS 2, tvOS 18, *) {
NewTabView(
Copy link
Preview

Copilot AI Jul 6, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The onLongPress closure from TabViewImpl isn’t forwarded into NewTabView, so long-press events will no longer trigger. Pass onLongPress into both NewTabView and LegacyTabView if you need to support it.

Copilot uses AI. Check for mistakes.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We need to fix this

props: props,
onLayout: onLayout,
onSelect: onSelect,
updateTabBarAppearance: {

Check warning on line 24 in packages/react-native-bottom-tabs/ios/TabViewImpl.swift

View workflow job for this annotation

GitHub Actions / swift-lint

Trailing Closure Violation: Trailing closure syntax should be used whenever possible (trailing_closure)
updateTabBarAppearance(props: props, tabBar: tabBar)
}
)
} else {
LegacyTabView(
props: props,
onLayout: onLayout,
onSelect: onSelect,
updateTabBarAppearance: {

Check warning on line 33 in packages/react-native-bottom-tabs/ios/TabViewImpl.swift

View workflow job for this annotation

GitHub Actions / swift-lint

Trailing Closure Violation: Trailing closure syntax should be used whenever possible (trailing_closure)
updateTabBarAppearance(props: props, tabBar: tabBar)
}
)
}
}

Check warning on line 39 in packages/react-native-bottom-tabs/ios/TabViewImpl.swift

View workflow job for this annotation

GitHub Actions / swift-lint

Trailing Whitespace Violation: Lines should not have trailing whitespace (trailing_whitespace)
var onSelect: (_ key: String) -> Void
var onLongPress: (_ key: String) -> Void
var onLayout: (_ size: CGSize) -> Void
var onTabBarMeasured: (_ height: Int) -> Void

Check warning on line 44 in packages/react-native-bottom-tabs/ios/TabViewImpl.swift

View workflow job for this annotation

GitHub Actions / swift-lint

Trailing Whitespace Violation: Lines should not have trailing whitespace (trailing_whitespace)
var body: some View {
TabView(selection: $props.selectedPage) {
ForEach(props.children.indices, id: \.self) { index in
renderTabItem(at: index)
}
.measureView { size in
onLayout(size)
}
}
tabContent
#if !os(tvOS) && !os(macOS) && !os(visionOS)
.onTabItemEvent { index, isLongPress in
let item = props.filteredItems[safe: index]
Expand Down Expand Up @@ -74,45 +90,6 @@
}
}

@ViewBuilder
private func renderTabItem(at index: Int) -> some View {
let tabData = props.items[safe: index]
let isHidden = tabData?.hidden ?? false
let isFocused = props.selectedPage == tabData?.key

if !isHidden || isFocused {
let child = props.children[safe: index] ?? PlatformView()
let icon = props.icons[index]

RepresentableView(view: child)
.ignoresSafeArea(.container, edges: .all)
.tabItem {
TabItem(
title: tabData?.title,
icon: icon,
sfSymbol: tabData?.sfSymbol,
labeled: props.labeled
)
.accessibilityIdentifier(tabData?.testID ?? "")
}
.tag(tabData?.key)
.tabBadge(tabData?.badge)
.hideTabBar(props.tabBarHidden)
.onAppear {
#if !os(macOS)
updateTabBarAppearance(props: props, tabBar: tabBar)
#endif

#if os(iOS)
guard index >= 4,
let key = tabData?.key,
props.selectedPage != key else { return }
onSelect(key)
#endif
}
}
}

func emitHapticFeedback(longPress: Bool = false) {
#if os(iOS)
if !props.hapticFeedbackEnabled {
Expand Down Expand Up @@ -155,7 +132,7 @@
attributes[.font] = RCTFont.update(
nil,
withFamily: family,
size: NSNumber(value: size),

Check warning on line 135 in packages/react-native-bottom-tabs/ios/TabViewImpl.swift

View workflow job for this annotation

GitHub Actions / swift-lint

Legacy Objective-C Reference Type Violation: Prefer Swift value types to bridged Objective-C reference types (legacy_objc_type)
weight: weight,
style: nil,
variant: nil,
Expand Down
Loading