```html SwiftUI: How to Master-Detail (iOS 17+, 2026)

How to Implement Master-Detail in SwiftUI

iOS 17+ Xcode 16+ Intermediate APIs: NavigationSplitView Updated: May 11, 2026
TL;DR

Use NavigationSplitView with a sidebar list bound to an optional @State selection to get an adaptive master-detail layout that collapses into a stack on iPhone and expands into columns on iPad.

struct ContentView: View {
    @State private var selectedItem: Item?

    var body: some View {
        NavigationSplitView {
            List(Item.samples, selection: $selectedItem) { item in
                Text(item.title).tag(item)
            }
            .navigationTitle("Items")
        } detail: {
            if let item = selectedItem {
                DetailView(item: item)
            } else {
                ContentUnavailableView("Select an item",
                    systemImage: "sidebar.left")
            }
        }
    }
}

Full implementation

The full example below introduces a three-column split (sidebar → content → detail) — the most powerful form of NavigationSplitView. Each column is independently scrollable, and SwiftUI automatically collapses the layout to a navigation stack on compact-width devices (iPhone) while showing all three columns side by side on regular-width devices (iPad, Mac Catalyst). Column visibility is configurable at runtime via NavigationSplitViewVisibility.

import SwiftUI

// MARK: – Models

struct Category: Identifiable, Hashable {
    let id = UUID()
    let name: String
    let systemImage: String
}

struct Item: Identifiable, Hashable {
    let id = UUID()
    let title: String
    let subtitle: String
    let category: Category

    static func samples(for category: Category) -> [Item] {
        (1...6).map { n in
            Item(title: "\(category.name) Item \(n)",
                 subtitle: "Subtitle \(n)",
                 category: category)
        }
    }
}

// MARK: – Top-level split view

struct ContentView: View {
    private let categories: [Category] = [
        Category(name: "Inbox",    systemImage: "tray"),
        Category(name: "Starred",  systemImage: "star"),
        Category(name: "Sent",     systemImage: "paperplane"),
        Category(name: "Archive",  systemImage: "archivebox"),
    ]

    @State private var selectedCategory: Category?
    @State private var selectedItem: Item?
    @State private var columnVisibility: NavigationSplitViewVisibility = .all

    var body: some View {
        NavigationSplitView(columnVisibility: $columnVisibility) {
            // Sidebar column
            List(categories, selection: $selectedCategory) { category in
                Label(category.name, systemImage: category.systemImage)
                    .tag(category)
            }
            .navigationTitle("Mailboxes")
        } content: {
            // Middle column
            if let category = selectedCategory {
                let items = Item.samples(for: category)
                List(items, selection: $selectedItem) { item in
                    VStack(alignment: .leading, spacing: 2) {
                        Text(item.title).font(.headline)
                        Text(item.subtitle)
                            .font(.subheadline)
                            .foregroundStyle(.secondary)
                    }
                    .padding(.vertical, 4)
                    .tag(item)
                }
                .navigationTitle(category.name)
            } else {
                ContentUnavailableView("Select a mailbox",
                    systemImage: "tray",
                    description: Text("Choose a category from the sidebar."))
            }
        } detail: {
            // Detail column
            if let item = selectedItem {
                DetailView(item: item)
            } else {
                ContentUnavailableView("Select an item",
                    systemImage: "envelope",
                    description: Text("Pick a message to read it here."))
            }
        }
        .navigationSplitViewStyle(.balanced)
    }
}

// MARK: – Detail view

struct DetailView: View {
    let item: Item

    var body: some View {
        ScrollView {
            VStack(alignment: .leading, spacing: 12) {
                Text(item.title)
                    .font(.title)
                    .fontWeight(.bold)
                Text(item.subtitle)
                    .foregroundStyle(.secondary)
                Divider()
                Text("Full content for \(item.title) would appear here.")
                    .font(.body)
            }
            .padding()
            .frame(maxWidth: .infinity, alignment: .leading)
        }
        .navigationTitle(item.title)
        .navigationBarTitleDisplayMode(.inline)
    }
}

// MARK: – Preview

#Preview {
    ContentView()
}

How it works

  1. Three-column initializer. NavigationSplitView(columnVisibility:) { sidebar } content: { list } detail: { detail } maps directly to Apple's sidebar / content / inspector pattern. SwiftUI handles collapsing those columns into a stack automatically on iPhone's compact size class — you write the layout once.
  2. Selection binding. Both List(categories, selection: $selectedCategory) and List(items, selection: $selectedItem) use optional @State bindings. When the user taps a row the corresponding state variable is set, triggering a re-render of downstream columns. Tapping the same row again deselects it (sets the value back to nil).
  3. ContentUnavailableView. Introduced in iOS 17, this system view renders a centred icon + headline + body placeholder. It replaces the common pattern of wrapping a VStack in a Spacer-padded frame, and automatically adapts to dark mode and accessibility text sizes.
  4. Column visibility. The @State private var columnVisibility: NavigationSplitViewVisibility = .all binding lets you programmatically show or hide the sidebar at runtime — useful for toolbar buttons or responding to trait changes. Other values include .detailOnly and .doubleColumn.
  5. .navigationSplitViewStyle(.balanced). Applied to the root NavigationSplitView, this modifier distributes column widths evenly. The default .automatic style gives the detail column more space; .prominentDetail maximises the detail column at the expense of the sidebar.

Variants

Two-column (sidebar + detail only)

For simpler apps that don't need a middle content column, use the two-column overload. Selection is driven directly from the sidebar list into the detail pane.

struct TwoColumnView: View {
    private let items = Item.samples(for:
        Category(name: "Notes", systemImage: "note"))
    @State private var selectedItem: Item?

    var body: some View {
        NavigationSplitView {
            List(items, selection: $selectedItem) { item in
                Text(item.title).tag(item)
            }
            .navigationTitle("Notes")
        } detail: {
            if let item = selectedItem {
                DetailView(item: item)
            } else {
                ContentUnavailableView("No note selected",
                    systemImage: "note.text")
            }
        }
    }
}

#Preview { TwoColumnView() }

Programmatic navigation with deep link support

Pass your selection state up to a parent @Observable view model (or use .onOpenURL) to deep-link into a specific item on launch. Set selectedCategory and selectedItem before the view appears — NavigationSplitView respects binding values set before first render and opens directly to the correct detail column without an animation flash.

Common pitfalls

Prompt this with Claude Code

When using Soarias or Claude Code directly to implement this:

Implement master-detail navigation in SwiftUI for iOS 17+.
Use NavigationSplitView with three columns: sidebar, content list, and detail.
Bind selection with optional @State for both sidebar and list.
Show ContentUnavailableView when nothing is selected.
Make it accessible (VoiceOver labels on list rows and detail content).
Add a #Preview with realistic sample data.

In Soarias's Build phase, paste this prompt into the implementation prompt field after your screens are approved — Claude Code will scaffold the full split-view hierarchy and wire up your existing model types automatically.

Related

FAQ

Does this work on iOS 16?

NavigationSplitView was introduced in iOS 16, so the basic three-column split is available there. However, ContentUnavailableView requires iOS 17+. For iOS 16 targets, replace it with a custom placeholder VStack. The columnVisibility binding and .balanced style are also iOS 16+. Set your deployment target to iOS 17 to use every API in this article without conditionals.

How do I keep the sidebar visible by default on iPad?

Initialize columnVisibility to .all (which is already the default). If the sidebar is collapsing unexpectedly, check that you're not accidentally setting it to .detailOnly in a child view modifier. You can also add a toolbar button that toggles visibility: Button("Sidebar") { columnVisibility = columnVisibility == .all ? .detailOnly : .all }.

What's the UIKit equivalent?

UISplitViewController with style: .tripleColumn is the UIKit counterpart. It requires manually managing viewControllers, push/pop logic, and UISplitViewControllerDelegate for collapse/expand callbacks — all of which NavigationSplitView handles automatically. If you're bridging a UIKit app, you can host NavigationSplitView inside a UIHostingController and set it as the UISplitViewController's primary view controller.

Last reviewed: 2026-05-11 by the Soarias team.

```