-
Notifications
You must be signed in to change notification settings - Fork 48
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
base: main
Are you sure you want to change the base?
Changes from all commits
4e6764d
5de6709
2a15cde
592141e
ed6f9e0
c1c1205
5ec3e2f
dfec076
2f444ac
69837bd
f0dfc85
c610cda
8fd1f7b
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
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 } | ||
} |
Original file line number | Diff line number | Diff line change | ||
---|---|---|---|---|
@@ -0,0 +1,56 @@ | ||||
import SwiftUI | ||||
|
||||
struct LegacyTabView: 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 | ||||
renderTabItem(at: index) | ||||
} | ||||
.measureView { size in | ||||
onLayout(size) | ||||
} | ||||
} | ||||
.hideTabBar(props.tabBarHidden) | ||||
} | ||||
|
||||
@ViewBuilder | ||||
private func renderTabItem(at index: Int) -> some View { | ||||
if let tabData = props.items[safe: index] { | ||||
let isFocused = props.selectedPage == tabData.key | ||||
|
||||
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) | ||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The
Suggested change
Copilot uses AI. Check for mistakes. Positive FeedbackNegative Feedback |
||||
.tabBadge(tabData.badge) | ||||
.tabAppear(using: context) | ||||
} | ||||
} | ||||
} | ||||
} | ||||
} |
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 | ||||||||||||||||||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We can look for a
Suggested change
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Of course we'd have to add the 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 ?? ""
)
)
} There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yeah There is currently only one role: .search but I think we could do 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 There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 ( There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 There was a problem hiding this comment. Choose a reason for hiding this commentThe 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.,
Suggested change
Copilot uses AI. Check for mistakes. Positive FeedbackNegative Feedback |
||||||||||||||||||||||
|
||||||||||||||||||||||
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) | ||||||||||||||||||||||
okwasniewski marked this conversation as resolved.
Show resolved
Hide resolved
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Why it's commented out? There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think that's because it's supposed to be https://developer.apple.com/documentation/swiftui/view/badge(_:)-6k2u9 This compiled for me .badge(tabData.badge.isEmpty ? nil : Text(tabData.badge)) There was a problem hiding this comment. Choose a reason for hiding this commentThe 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? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||||||||||||||||||||||
.accessibilityIdentifier(tabData.testID ?? "") | ||||||||||||||||||||||
} | ||||||||||||||||||||||
} | ||||||||||||||||||||||
} | ||||||||||||||||||||||
} | ||||||||||||||||||||||
.measureView { size in | ||||||||||||||||||||||
onLayout(size) | ||||||||||||||||||||||
} | ||||||||||||||||||||||
Comment on lines
+50
to
+52
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
||||||||||||||||||||||
.hideTabBar(props.tabBarHidden) | ||||||||||||||||||||||
} | ||||||||||||||||||||||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -13,21 +13,37 @@ | |
#else | ||
@Weak var tabBar: UITabBar? | ||
#endif | ||
|
||
|
||
@ViewBuilder | ||
var tabContent: some View { | ||
if #available(iOS 18, macOS 15, visionOS 2, tvOS 18, *) { | ||
NewTabView( | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The Copilot uses AI. Check for mistakes. Positive FeedbackNegative Feedback There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We need to fix this |
||
props: props, | ||
onLayout: onLayout, | ||
onSelect: onSelect, | ||
updateTabBarAppearance: { | ||
updateTabBarAppearance(props: props, tabBar: tabBar) | ||
} | ||
) | ||
} else { | ||
LegacyTabView( | ||
props: props, | ||
onLayout: onLayout, | ||
onSelect: onSelect, | ||
updateTabBarAppearance: { | ||
updateTabBarAppearance(props: props, tabBar: tabBar) | ||
} | ||
) | ||
} | ||
} | ||
|
||
var onSelect: (_ key: String) -> Void | ||
var onLongPress: (_ key: String) -> Void | ||
var onLayout: (_ size: CGSize) -> Void | ||
var onTabBarMeasured: (_ height: Int) -> Void | ||
|
||
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] | ||
|
@@ -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 { | ||
|
@@ -155,7 +132,7 @@ | |
attributes[.font] = RCTFont.update( | ||
nil, | ||
withFamily: family, | ||
size: NSNumber(value: size), | ||
weight: weight, | ||
style: nil, | ||
variant: nil, | ||
|
Uh oh!
There was an error while loading. Please reload this page.