Skip to content
Draft
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
5 changes: 5 additions & 0 deletions .changeset/sidebar-adaptable-large-iphone.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'react-native-bottom-tabs': minor
---

Enable `sidebarAdaptable` on iOS for large enough screens.
2 changes: 1 addition & 1 deletion docs/docs/docs/getting-started/how-is-it-different.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ No need for custom scroll to top handling.

### Sidebar Adaptable

TabView can turn in to a side bar on tvOS, iPadOS and macOS. This is controlled by `sidebarAdaptable` prop.
TabView can turn into a sidebar on tvOS, iPadOS, macOS, and on iOS when the screen is large enough. This is controlled by the `sidebarAdaptable` prop. On iOS 27 (built with Xcode 27 or later), the bottom tab bar becomes a sidebar on large screens such as an unfolded iPhone Duo and switches back as the window shrinks.

<video controls width="100%" src='https://github.com/user-attachments/assets/4093900f-2996-4c3a-8e15-de5b59aa53cc' />

Expand Down
6 changes: 5 additions & 1 deletion docs/docs/docs/guides/standalone-usage.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -120,10 +120,14 @@ Whether to show labels in tabs. When `false`, only icons will be displayed.
A tab bar style that adapts to each platform:

- iPadOS: Top tab bar that can adapt into a sidebar
- iOS: Bottom tab bar
- iOS: Bottom tab bar. On iOS 27+ (built with Xcode 27 or later), large enough screens such as an unfolded iPhone Duo show a sidebar instead
- macOS/tvOS: Sidebar
- visionOS: Ornament with sidebar for secondary tabs

Set `sidebarAdaptable` to `true` to opt in. Defaults to `false`.

On iOS 27+, the placement follows the window size: the sidebar appears once the window is at least 920pt wide with a regular vertical size class (for example an unfolded iPhone Duo, or Resize Mode in Xcode's Device Hub), and the bottom tab bar returns when the window shrinks. Regular phones, including landscape, keep the bottom tab bar.

#### `disablePageAnimations` <Badge text="iOS" type="info" />

Whether to disable animations between tabs.
Expand Down
6 changes: 5 additions & 1 deletion docs/docs/docs/guides/usage-with-react-navigation.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -169,10 +169,14 @@ A tab bar style that adapts to each platform.
Tab views using the sidebar adaptable style have an appearance

- iPadOS displays a top tab bar that can adapt into a sidebar.
- iOS displays a bottom tab bar.
- iOS displays a bottom tab bar. On iOS 27+ (built with Xcode 27 or later), large enough screens such as an unfolded iPhone Duo display a sidebar instead.
- macOS and tvOS always show a sidebar.
- visionOS shows an ornament and also shows a sidebar for secondary tabs within a `TabSection`.

Set `sidebarAdaptable` on `Tab.Navigator` to opt in. Defaults to `false`.

On iOS 27+, the placement follows the window size: the sidebar appears once the window is at least 920pt wide with a regular vertical size class (for example an unfolded iPhone Duo, or Resize Mode in Xcode's Device Hub), and the bottom tab bar returns when the window shrinks. Regular phones, including landscape, keep the bottom tab bar.

#### `hapticFeedbackEnabled`

Whether to enable haptic feedback on tab press. Defaults to false.
Expand Down
17 changes: 13 additions & 4 deletions packages/react-native-bottom-tabs/ios/TabView/NewTabView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ struct NewTabView: AnyTabView {

Tab(value: tabData.key, role: tabData.role?.convert()) {
RepresentableView(view: child.view)
.ignoresSafeArea(.container, edges: .all)
.modifier(TabContentLayoutModifier(onLayout: onLayout))
.tabAppear(using: context)
.hideTabBar(props.tabBarHidden)
} label: {
Expand All @@ -61,13 +61,22 @@ struct NewTabView: AnyTabView {
}
}
.environment(\.layoutDirection, effectiveLayoutDirection)
.measureView { size in
onLayout(size)
}
.modifier(ConditionalBottomAccessoryModifier(props: props))
}
}

@available(iOS 18, macOS 15, visionOS 2, tvOS 18, *)
private struct TabContentLayoutModifier: ViewModifier {
@Environment(\.tabBarPlacement) private var tabBarPlacement
var onLayout: (CGSize) -> Void

func body(content: Content) -> some View {
content
.ignoresSafeArea(.container, edges: tabBarPlacement == .sidebar ? .vertical : .all)
.measureView(onLayout: onLayout)
}
}

struct ConditionalBottomAccessoryModifier: ViewModifier {
@ObservedObject var props: TabViewProps

Expand Down
23 changes: 23 additions & 0 deletions packages/react-native-bottom-tabs/ios/TabViewImpl.swift
Original file line number Diff line number Diff line change
Expand Up @@ -219,7 +219,7 @@
}
}

private func configureTabBarItemImages(items: [UITabBarItem], props: TabViewProps) {

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

View workflow job for this annotation

GitHub Actions / swift-lint

Function Body Length Violation: Function body should span 50 lines or less excluding comments and whitespace: currently spans 61 lines (function_body_length)
for (tabBarIndex, item) in items.enumerated() {
guard let tabData = props.filteredItems[safe: tabBarIndex],
let itemIndex = props.items.firstIndex(where: { $0.key == tabData.key })
Expand Down Expand Up @@ -359,9 +359,9 @@
let attributes: [NSAttributedString.Key: Any] = [
.font: font,
.foregroundColor: color,
.paragraphStyle: paragraphStyle,

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

View workflow job for this annotation

GitHub Actions / swift-lint

Trailing Comma Violation: Collection literals should not have trailing commas (trailing_comma)
]
let titleSize = (title as NSString).size(withAttributes: attributes)

Check warning on line 364 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)
let imageSize = CGSize(
width: max(iconSize.width, ceil(titleSize.width)) + 8,
height: iconSize.height + 3 + ceil(titleSize.height)
Expand All @@ -388,7 +388,7 @@

tintedIcon.draw(in: iconFrame)

(title as NSString).draw(

Check warning on line 391 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)
in: CGRect(
x: 0,
y: iconSize.height + 3,
Expand Down Expand Up @@ -426,6 +426,7 @@
if enabled {
#if compiler(>=6.0)
self.tabViewStyle(.sidebarAdaptable)
.modifier(AdaptiveSidebarPlacementModifier())
#else
self
#endif
Expand Down Expand Up @@ -564,3 +565,25 @@
}
}
}

private struct AdaptiveSidebarPlacementModifier: ViewModifier {
@ViewBuilder
func body(content: Content) -> some View {
#if os(iOS) && compiler(>=6.4)
if #available(iOS 27.0, *) {
content
.defaultTabBarPlacement(.sidebar)
.introspectTabView { controller in
guard controller.traitCollection.userInterfaceIdiom == .phone else { return }
// Tile the selected scene next to the sidebar so React Native measures the
// visible area instead of drawing underneath an overlapping sidebar.
controller.sidebar.preferredLayout = .tile
}
} else {
content
}
#else
content
#endif
}
}

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

View workflow job for this annotation

GitHub Actions / swift-lint

File Length Violation: File should contain 400 lines or less: currently contains 589 (file_length)
36 changes: 36 additions & 0 deletions packages/react-native-bottom-tabs/ios/TabViewProvider.swift
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,9 @@ public final class TabInfo: NSObject {
@objc public var sidebarAdaptable: Bool = false {
didSet {
props.sidebarAdaptable = sidebarAdaptable
#if os(iOS)
setNeedsLayout()
#endif
}
}

Expand Down Expand Up @@ -208,6 +211,39 @@ public final class TabInfo: NSObject {
override public func layoutSubviews() {
super.layoutSubviews()
setupView()
#if os(iOS)
updateSidebarSizeClassOverride()
#endif
}
#endif

#if os(iOS)
/// Minimum width, in points, at which an iPhone window is wide enough for the sidebar.
/// UIKit keeps the iPhone sidebar collapsed below roughly 905pt even in a regular width, and
/// hides the tab bar at the same time, so stay a little above that. Phones never reach this
/// width in portrait, and landscape phones are excluded by their compact vertical size class.
private static let sidebarMinimumWidth: CGFloat = 920

private func updateSidebarSizeClassOverride() {
guard #available(iOS 27.0, *), let hostingController else { return }

let overrides = hostingController.traitOverrides
guard sidebarAdaptable, traitCollection.userInterfaceIdiom == .phone else {
if overrides.contains(UITraitHorizontalSizeClass.self) {
hostingController.traitOverrides.remove(UITraitHorizontalSizeClass.self)
}
return
}

let fitsSidebar =
traitCollection.verticalSizeClass == .regular
&& bounds.width >= Self.sidebarMinimumWidth
let desired: UIUserInterfaceSizeClass = fitsSidebar ? .regular : .compact

if !overrides.contains(UITraitHorizontalSizeClass.self)
|| overrides.horizontalSizeClass != desired {
hostingController.traitOverrides.horizontalSizeClass = desired
}
}
#endif

Expand Down
4 changes: 3 additions & 1 deletion packages/react-native-bottom-tabs/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,9 @@
},
"jest": {
"preset": "react-native",
"modulePathIgnorePatterns": ["<rootDir>/lib/"]
"modulePathIgnorePatterns": [
"<rootDir>/lib/"
]
},
"keywords": [
"react-native",
Expand Down
4 changes: 3 additions & 1 deletion packages/react-native-bottom-tabs/src/TabView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,9 @@
* that varies depending on the platform:
* Tab views using the sidebar adaptable style have an appearance
* - iPadOS displays a top tab bar that can adapt into a sidebar.
* - iOS displays a bottom tab bar.
* - iOS displays a bottom tab bar. On iOS 27+ (built with Xcode 27+), large enough
* screens such as an unfolded iPhone Duo display a sidebar instead, and the
* bottom tab bar returns when the window shrinks.
* - macOS and tvOS always show a sidebar.
* - visionOS shows an ornament and also shows a sidebar for secondary tabs within a `TabSection`.
*/
Expand Down Expand Up @@ -307,7 +309,7 @@

if (!loaded.includes(focusedKey)) {
// Set the current tab to be loaded if it was not loaded before
setLoaded((loaded) => [...loaded, focusedKey]);

Check warning on line 312 in packages/react-native-bottom-tabs/src/TabView.tsx

View workflow job for this annotation

GitHub Actions / lint

'loaded' is already declared in the upper scope on line 308 column 10
}

const icons = React.useMemo(
Expand Down
Loading