How to Implement Master-Detail in SwiftUI
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
-
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. -
Selection binding. Both
List(categories, selection: $selectedCategory)andList(items, selection: $selectedItem)use optional@Statebindings. 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 tonil). -
ContentUnavailableView. Introduced in iOS 17, this system view renders a centred icon + headline + body placeholder. It replaces the common pattern of wrapping aVStackin aSpacer-padded frame, and automatically adapts to dark mode and accessibility text sizes. -
Column visibility. The
@State private var columnVisibility: NavigationSplitViewVisibility = .allbinding lets you programmatically show or hide the sidebar at runtime — useful for toolbar buttons or responding to trait changes. Other values include.detailOnlyand.doubleColumn. -
.navigationSplitViewStyle(.balanced). Applied to the rootNavigationSplitView, this modifier distributes column widths evenly. The default.automaticstyle gives the detail column more space;.prominentDetailmaximises 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
-
Avoid
NavigationViewon iOS 17+.NavigationViewwas soft-deprecated in iOS 16 and shows a purple runtime warning in Xcode 16. Always useNavigationSplitVieworNavigationStack; the former automatically collapses to a stack on iPhone. -
Missing
.tag(_:)causes selection to never update. EachListrow must be tagged with the same type as the selection binding. Forgetting.tag(item)means tapping a row sets the binding tonilsilently. Use theList(_:selection:) { item in … .tag(item) }form consistently. -
Selection state is not persisted across launches by default. If your app should restore the last-opened item, encode
selectedItemintoSceneStorageor your persistence layer manually —NavigationSplitViewdoes not do this automatically (unlikeNavigationStack's path-based restoration). -
Sidebar visibility on iPhone is ignored. On compact-width devices the
columnVisibilitybinding is read-only — SwiftUI controls visibility via the back button, not your state. Only manipulatecolumnVisibilityconditionally usinghorizontalSizeClassto avoid unexpected behaviour.
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.