Skip to content

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 @State bindings for selectedCategory and selectedFruit drive content in each column
  • How NavigationSplitViewVisibility and navigationSplitViewStyle control 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

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

  1. @State private var visibility: NavigationSplitViewVisibility = .all — Controls which columns are shown. Starting with .all means all three panels are visible when the app launches. On iPhone, this is effectively ignored.

  2. @State private var selectedCategory: FoodCategory? = nil — The cursor for the sidebar. When this is nil, the content column shows the placeholder text. When set to .fruits, the fruits list appears in the content column.

  3. List(FoodCategory.allCases, id: \.rawValue, selection: $selectedCategory) — The selection parameter creates a two-way binding. Tapping a row updates selectedCategory; setting selectedCategory programmatically highlights that row. FoodCategory.allCases uses CaseIterable to automatically include all enum cases.

  4. NavigationLink(category.rawValue.capitalized, value: category) — Value-based NavigationLink inside the List. When tapped, it sets selectedCategory to the category value and the NavigationSplitView advances to the content column.

  5. if let selectedCategory { switch selectedCategory { ... } } — The content column reacts to the selected category. Using a switch is idiomatic when handling all cases of an enum — the compiler ensures you handle every case (you'd get a warning if you added a new FoodCategory case and forgot to handle it here).

  6. .navigationSplitViewStyle(.balanced) — Applied to the NavigationSplitView, this style gives each visible column equal width. .prominentDetail would make the detail column take more space, useful for content-heavy detail views.

Common Mistakes

Mistake: Using NavigationSplitView where NavigationStack is more appropriate
NavigationSplitView 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

  • NavigationSplitView builds 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 — nil means nothing selected
  • Use List(selection: $binding) with NavigationLink(value:) to connect list rows to the split view's selection system

Last updated: June 27, 2026

Released under the MIT License.