How to use NavigationSplitView in SwiftUI | SwiftUI Bootcamp #72
NavigationSplitView is SwiftUI's purpose-built container for iPad, Mac, and visionOS apps — the ones that show a sidebar, a content list, and a detail view all at the same time. It automatically collapses to a stack-based interface on iPhone, so a single piece of code works across all Apple platforms.
What You'll Learn
- How to build a two-column and three-column
NavigationSplitView - How
@Statebindings forselectedCategoryandselectedFruitdrive content in each column - How
NavigationSplitViewVisibilityandnavigationSplitViewStylecontrol column appearance
Mental Model
NavigationSplitView is like the layout of a desktop email app: on the left you have your mailboxes (sidebar), in the middle you have the email list for the selected mailbox (content), and on the right you see the open email (detail). Each panel is independent but interconnected by the selection in the previous panel. On iPhone where there isn't room for three panels, the interface collapses into a NavigationStack-style push/pop flow automatically.
The key insight: selection drives everything. Your @State variables for selected category and selected fruit are the "cursor" in each column. When the user taps in the sidebar, selectedCategory updates. The content column watches selectedCategory and shows the appropriate list. When the user taps in the content column, selectedFruit updates, and the detail column shows the fruit.
Detailed Explanation
NavigationSplitView was introduced in iOS 16 as the replacement for NavigationView in multi-column contexts. It has two initializer variants: two-column (sidebar:detail:) and three-column (sidebar:content:detail:). The three-column variant is what's shown here.
The columnVisibility binding accepts NavigationSplitViewVisibility — .all shows all columns, .detailOnly hides the sidebar and content columns, .doubleColumn shows the content and detail. On iPhone, this binding is largely ignored because only one column can show at a time.
The List(selection:) API is the preferred way to connect list rows to the detail column. Passing $selectedCategory to the selection parameter of List automatically highlights the active row and updates the binding when the user taps. Combine this with NavigationLink(value:) inside the list rows to make each row tappable.
.navigationSplitViewStyle(.balanced) is one of several styles: .automatic lets the system decide, .balanced gives equal weight to all visible columns, .prominentDetail makes the detail column larger at the expense of the sidebar.
Minimum deployment target: iOS 16 is required. NavigationSplitView is primarily designed for iPad, Mac, and visionOS — on iPhone it behaves like a NavigationStack automatically.
Code Structure
NavigationSplitViewBootcamp.swift uses two Swift enums (FoodCategory, Fruit) to model the navigation data. The main view holds three @State properties: column visibility, selected category, and selected fruit. The three-column initializer wires these states to each panel using List(selection:).
Complete Code
NavigationSplitViewBootcamp.swift
import SwiftUI
// NavigationSplitView -> iPad, MacOS, VisionOS
struct NavigationSplitViewBootcamp: View {
// Controls which columns are visible — .all shows sidebar + content + detail
@State private var visibility: NavigationSplitViewVisibility = .all
@State private var selectedCategory: FoodCategory? = nil // nil means nothing selected in sidebar
@State private var selectedFruit: Fruit? = nil // nil means nothing selected in content
var body: some View {
NavigationSplitView(columnVisibility: $visibility) {
// SIDEBAR column — shows a list of categories
List(FoodCategory.allCases, id: \.rawValue, selection: $selectedCategory) { category in
NavigationLink(category.rawValue.capitalized, value: category)
}
.navigationTitle("Categories")
} content: {
// CONTENT column — shows items within the selected category
if let selectedCategory {
Group {
switch selectedCategory {
case .fruits:
// Shows the fruits list when the fruits category is selected
List(Fruit.allCases, id: \.rawValue, selection: $selectedFruit) { fruit in
NavigationLink(fruit.rawValue.capitalized, value: fruit)
}
case .vegetables:
EmptyView() // Placeholder — no vegetables content defined yet
case .meats:
EmptyView() // Placeholder — no meats content defined yet
}
}
.navigationTitle(selectedCategory.rawValue.capitalized)
} else {
Text("Select a category to begin!") // Shown when no category is selected
}
} detail: {
// DETAIL column — shows the full detail for the selected fruit
if let selectedFruit {
Text("You selected: \(selectedFruit.rawValue)")
.font(.largeTitle)
.navigationTitle(selectedFruit.rawValue.capitalized)
} else {
Text("Select something!") // Shown when no fruit is selected
}
}
.navigationSplitViewStyle(.balanced) // Equal column widths across the split view
}
}
#Preview {
NavigationSplitViewBootcamp()
}
// Represents the top-level categories shown in the sidebar
enum FoodCategory: String, CaseIterable {
case fruits, vegetables, meats
}
// Represents the items shown in the content column for the fruits category
enum Fruit: String, CaseIterable {
case apple, banana, orange
}Code Walkthrough
@State private var visibility: NavigationSplitViewVisibility = .all— Controls which columns are shown. Starting with.allmeans all three panels are visible when the app launches. On iPhone, this is effectively ignored.@State private var selectedCategory: FoodCategory? = nil— The cursor for the sidebar. When this isnil, the content column shows the placeholder text. When set to.fruits, the fruits list appears in the content column.List(FoodCategory.allCases, id: \.rawValue, selection: $selectedCategory)— Theselectionparameter creates a two-way binding. Tapping a row updatesselectedCategory; settingselectedCategoryprogrammatically highlights that row.FoodCategory.allCasesusesCaseIterableto automatically include all enum cases.NavigationLink(category.rawValue.capitalized, value: category)— Value-basedNavigationLinkinside theList. When tapped, it setsselectedCategoryto the category value and theNavigationSplitViewadvances to the content column.if let selectedCategory { switch selectedCategory { ... } }— The content column reacts to the selected category. Using aswitchis idiomatic when handling all cases of an enum — the compiler ensures you handle every case (you'd get a warning if you added a newFoodCategorycase and forgot to handle it here)..navigationSplitViewStyle(.balanced)— Applied to theNavigationSplitView, this style gives each visible column equal width..prominentDetailwould make the detail column take more space, useful for content-heavy detail views.
Common Mistakes
Mistake: Using NavigationSplitView where NavigationStack is more appropriateNavigationSplitView is designed for multi-column layouts — iPad, Mac, visionOS. On iPhone it works, but collapses to stack navigation. If you're building an iPhone-only app, NavigationStack is simpler and more predictable.
Mistake: Forgetting that selection in List requires Hashable items
The selection parameter in List(selection:) uses Hashable to identify which item is selected. If your data model doesn't conform to Hashable, the selection binding won't compile. The enum pattern here works because enums with raw values automatically get Hashable conformance.
Mistake: Manually managing column visibility when the system handles it
On iPhone, NavigationSplitView automatically collapses to single-column navigation. Setting visibility = .detailOnly on iPhone has no effect. Don't write platform-branching code to manually hide columns — trust the system's adaptive behavior.
Key Takeaways
NavigationSplitViewbuilds sidebar + content + detail multi-column layouts for iPad, Mac, and visionOS — it collapses to stack navigation on iPhone automatically- Selection state (
@State var selectedItem: Model?) drives which views appear in each column —nilmeans nothing selected - Use
List(selection: $binding)withNavigationLink(value:)to connect list rows to the split view's selection system
Last updated: June 27, 2026