Enhanced Tab View and Adaptive Sidebar: iOS 18's Redesigned Navigation
If you have ever shipped an iPad app where your TabView felt like an oversized phone interface, iOS 18 finally
addresses that tension. Apple redesigned TabView from the ground up: iPhone gets a floating, translucent tab bar, and
iPad gets a sidebar that materializes from those same tab definitions — all from one declaration.
This post covers the new Tab type, TabSection for grouping, the .sidebarAdaptable tab view style, and the
programmatic selection API. We will not cover NavigationSplitView internals or deep-link routing — those live in
Navigation Architecture in SwiftUI.
Contents
- The Problem
- The New Tab API
- Grouping with TabSection
- Adaptive Sidebar with sidebarAdaptable
- Programmatic Tab Selection
- Advanced Usage
- Performance Considerations
- When to Use (and When Not To)
- Summary
The Problem
Before iOS 18, building a TabView that worked well on both iPhone and iPad meant maintaining two separate navigation
hierarchies. The classic TabView with .tabItem looked fine on a phone, but on iPad that bottom tab bar wasted
valuable screen real estate and offered no way to expose secondary destinations without resorting to a completely
different NavigationSplitView setup.
Here is what a typical pre-iOS 18 implementation looked like:
// Pre-iOS 18: separate code paths for iPhone and iPad
struct PixarStudioApp: View {
@State private var selectedTab = 0
var body: some View {
if UIDevice.current.userInterfaceIdiom == .pad {
NavigationSplitView {
SidebarView(selection: $selectedTab)
} detail: {
DetailView(tab: selectedTab)
}
} else {
TabView(selection: $selectedTab) {
MoviesView()
.tabItem { Label("Movies", systemImage: "film") }
.tag(0)
CharactersView()
.tabItem { Label("Characters", systemImage: "person.3") }
.tag(1)
SettingsView()
.tabItem { Label("Settings", systemImage: "gear") }
.tag(2)
}
}
}
}
This approach has three problems. First, you are maintaining two navigation structures that must stay in sync. Second,
the UIDevice check is a code smell that ignores multitasking contexts where an iPad might render at a compact size.
Third, adding a new tab means touching both branches.
iOS 18 eliminates all of this with a unified API.
The New Tab API
iOS 18 introduces the Tab type as the declarative building
block for tab-based navigation. Instead of applying .tabItem modifiers to child views, you wrap each destination in a
Tab that carries its own title, image, and optional role.
@available(iOS 18.0, *)
struct PixarStudioView: View {
var body: some View {
TabView {
Tab("Movies", systemImage: "film") {
MoviesView()
}
Tab("Characters", systemImage: "person.3") {
CharactersView()
}
Tab("Studios", systemImage: "building.2") {
StudiosView()
}
Tab(role: .search) {
SearchView()
}
}
}
}
The role: .search parameter creates a dedicated search tab that the system positions in the trailing slot on iPad’s
sidebar and integrates with the search UI on iPhone. This is the same role used in apps like Music and App Store.
Apple Docs:
Tab— SwiftUI
Type-Safe Selection with Enums
The old integer-tagged selection was fragile. The new API works with any Hashable type, so you can use an enum for
compile-time safety:
enum StudioTab: Hashable {
case movies
case characters
case studios
case settings
}
@available(iOS 18.0, *)
struct PixarStudioView: View {
@State private var selectedTab: StudioTab = .movies
var body: some View {
TabView(selection: $selectedTab) {
Tab("Movies", systemImage: "film", value: .movies) {
MoviesView()
}
Tab("Characters", systemImage: "person.3", value: .characters) {
CharactersView()
}
Tab("Studios", systemImage: "building.2", value: .studios) {
StudiosView()
}
Tab("Settings", systemImage: "gear", value: .settings) {
SettingsView()
}
}
}
}
Each Tab now declares its value, and the TabView binds selection to the same enum type. Adding a new case to the
enum immediately flags any switch statements that need updating — no more magic integers that silently break.
Grouping with TabSection
Real-world apps rarely have a flat list of tabs. A Pixar studio management app might separate content browsing from
administrative tools. TabSection lets you group
related tabs under a collapsible header, but only when the tab view renders as a sidebar:
@available(iOS 18.0, *)
struct PixarStudioView: View {
@State private var selectedTab: StudioTab = .movies
var body: some View {
TabView(selection: $selectedTab) {
Tab("Movies", systemImage: "film", value: .movies) {
MoviesView()
}
Tab("Characters", systemImage: "person.3", value: .characters) {
CharactersView()
}
TabSection("Production") {
Tab("Render Farm", systemImage: "cpu", value: .renderFarm) {
RenderFarmView()
}
Tab("Storyboards", systemImage: "rectangle.3.group",
value: .storyboards) {
StoryboardsView()
}
}
TabSection("Admin") {
Tab("Team", systemImage: "person.2", value: .team) {
TeamView()
}
Tab("Settings", systemImage: "gear", value: .settings) {
SettingsView()
}
}
}
}
}
On iPhone, TabSection headers are invisible — the system flattens the hierarchy and shows only the top-level tabs in
the bar. On iPad in sidebar mode, sections render as collapsible groups with bold headers, giving your app a native
sidebar feel without any additional layout code.
Tip: Keep your primary destinations (the tabs most users need) as top-level
Tabentries outside anyTabSection. This ensures they always appear in the iPhone tab bar. Tabs inside sections may be relegated to the “More” overflow when the bar runs out of space.
Adaptive Sidebar with sidebarAdaptable
The centerpiece of the iOS 18 TabView redesign is the
.sidebarAdaptable tab view style.
Apply it and your tab view automatically transitions between a tab bar on iPhone (or compact iPad) and a sidebar on
regular-width iPad:
@available(iOS 18.0, *)
struct PixarStudioView: View {
@State private var selectedTab: StudioTab = .movies
var body: some View {
TabView(selection: $selectedTab) {
Tab("Movies", systemImage: "film", value: .movies) {
MoviesView()
}
Tab("Characters", systemImage: "person.3", value: .characters) {
CharactersView()
}
// ... additional tabs and sections
}
.tabViewStyle(.sidebarAdaptable) // <-- One modifier, two layouts
}
}
Without .sidebarAdaptable, the TabView renders a standard tab bar on all platforms. With it, the system handles the
transition based on the horizontal size class. On iPad, the sidebar is toggleable — users can collapse it to reclaim
screen space, and the tab bar reappears at the top as a compact strip.
Note: The
.sidebarAdaptablestyle was introduced in iOS 18 and requires a minimum deployment target of iOS 18.0. On earlier OS versions, you will need to maintain the conditionalNavigationSplitViewpattern described in The Problem.
Customizing the Sidebar Header
You can add a header view to the sidebar using the Tab initializer with a custom label. The sidebar also respects
SwiftUI’s standard list styling, so you can apply .listStyle modifiers to the internal list if needed:
TabView(selection: $selectedTab) {
Tab("Movies", systemImage: "film", value: .movies) {
MoviesView()
}
// ...
}
.tabViewStyle(.sidebarAdaptable)
The sidebar automatically uses your app’s accent color for the selected item and renders SF Symbols at the appropriate weight. You do not need to customize these unless your design system requires it.
Programmatic Tab Selection
Driving tab selection from code — for deep links, onboarding flows, or push notification responses — is straightforward with the new binding-based API:
@available(iOS 18.0, *)
struct PixarStudioView: View {
@State private var selectedTab: StudioTab = .movies
var body: some View {
TabView(selection: $selectedTab) {
Tab("Movies", systemImage: "film", value: .movies) {
MoviesView(onCharacterTapped: { _ in
selectedTab = .characters
})
}
Tab("Characters", systemImage: "person.3", value: .characters) {
CharactersView()
}
}
.tabViewStyle(.sidebarAdaptable)
.onOpenURL { url in
// Handle deep links by switching tabs
if url.host == "characters" {
selectedTab = .characters
}
}
}
}
Because selectedTab is a plain @State property with a Hashable type, you can mutate it from anywhere: a button
action, an onOpenURL handler, or even a notification observer. The tab view animates the transition automatically.
Tip: If you need to coordinate tab selection with child navigation stacks, store both the
selectedTaband each tab’sNavigationPathin an@Observablerouter object. This keeps your navigation state centralized and testable. See Navigation Architecture in SwiftUI for the full pattern.
Advanced Usage
Badge Support
Badges work with the new Tab type just as they did with .tabItem. Apply the .badge modifier directly to a Tab:
Tab("Characters", systemImage: "person.3", value: .characters) {
CharactersView()
}
.badge(unreadCount) // Shows count on both tab bar and sidebar
The badge renders on the tab bar icon (iPhone) and as a trailing count in the sidebar row (iPad). The system automatically hides the badge when the count is zero.
Hiding Tabs Conditionally
You may want to show certain tabs only for specific user roles or feature flags. Because Tab is a regular SwiftUI
view, you can use standard conditional logic:
TabView(selection: $selectedTab) {
Tab("Movies", systemImage: "film", value: .movies) {
MoviesView()
}
if userRole == .director {
Tab("Dailies", systemImage: "play.rectangle", value: .dailies) {
DailiesReviewView()
}
}
Tab("Settings", systemImage: "gear", value: .settings) {
SettingsView()
}
}
.tabViewStyle(.sidebarAdaptable)
Warning: Avoid toggling tabs based on rapidly changing state. Each time the tab list changes, the
TabViewrebuilds its internal structure, which can cause the selected tab to reset if the current selection is removed from the hierarchy.
Custom Tab Bar Visibility
iOS 18 gives you finer control over tab bar visibility with the .tabBarMinimizeBehavior modifier. While this modifier
becomes even more capable in iOS 26, in iOS 18 you can already control whether the tab bar hides on scroll:
TabView(selection: $selectedTab) {
// ... tabs
}
.tabViewStyle(.sidebarAdaptable)
The floating tab bar on iPhone is translucent by default and automatically adapts its material to the content scrolling beneath it.
Combining with NavigationStack
Each tab’s content can contain its own NavigationStack. This is the recommended pattern for apps that need both
tab-based and hierarchical navigation:
Tab("Movies", systemImage: "film", value: .movies) {
NavigationStack {
MovieListView()
.navigationTitle("Pixar Movies")
.navigationDestination(for: Movie.self) { movie in
MovieDetailView(movie: movie)
}
}
}
The NavigationStack lives inside the Tab closure, which means each tab maintains its own independent navigation
history. When the user switches tabs and comes back, the navigation state is preserved.
Performance Considerations
The new TabView is lazily loaded by default. Tab content views are not instantiated until the user navigates to them
for the first time. After first load, views remain in memory for the lifetime of the TabView. This is the same
behavior as the pre-iOS 18 TabView, but it is worth keeping in mind for two reasons.
First, if your tab contains an @Observable model that kicks off a network request in its initializer, that request
will not fire until the user visits the tab. If you need data to be available before the user arrives, initialize the
model at the TabView level and pass it down.
Second, the sidebar rendering path on iPad creates additional view hierarchy compared to the flat tab bar. In profiling
with Instruments, the difference is negligible for typical app structures (5-15 tabs), but if you are dynamically
generating dozens of tabs from a data source, consider paginating or using a List with NavigationSplitView directly
instead.
Apple Docs:
TabView— SwiftUI
The WWDC 2024 session Improve your tab and sidebar experience on iPad walks through the performance characteristics in detail and demonstrates Instruments profiling for sidebar transitions.
When to Use (and When Not To)
| Scenario | Recommendation |
|---|---|
| Universal app (iPhone + iPad) with 3-7 primary destinations | Use Tab with .sidebarAdaptable. The primary use case. |
| iPhone-only app with a simple tab bar | Use Tab without .sidebarAdaptable. Type-safe selection still pays off. |
| Document-based app with a primary sidebar, no tab bar | Use NavigationSplitView directly. TabView sidebar is not for document browsing. |
| More than 15 destinations | Prefer NavigationSplitView with a custom List. TabView sidebar lacks search filtering. |
Migrating from UIKit UITabBarController | Adopt Tab incrementally. Bridge remaining screens with UIViewControllerRepresentable. |
Summary
- iOS 18 replaces
.tabItemwith theTabtype, giving each destination a structured declaration with title, image, and optional role. TabSectiongroups related tabs under collapsible headers in sidebar mode while remaining invisible on iPhone.- The
.sidebarAdaptabletab view style unifies tab bar and sidebar navigation in a single declaration — no moreUIDevicechecks or duplicated hierarchies. - Programmatic selection uses a
Hashablebinding instead of fragile integer tags, making deep links and onboarding flows type-safe. - Tab content is lazily loaded on first visit and persisted in memory, so plan your data fetching accordingly.
The iOS 18 TabView APIs lay the groundwork for the even more ambitious navigation changes in iOS 26. Once you are
comfortable with Tab and .sidebarAdaptable, explore
SwiftUI Navigation Migration for iOS 26 to see how tabBarMinimizeBehavior,
tabViewBottomAccessory, and glass effects build on top of these foundations.