-
Notifications
You must be signed in to change notification settings - Fork 0
Book 13 Multi Window And NavigationSplitView
Part IV — The Application · Claude's Xcode 26 Swift Bible
← Book-12-Sheets-Alerts-And-Confirmations · Chapters and Appendices · Book-14-Clipboard-DragDrop-ShareSheet →
Claude's Xcode 26 Swift Bible -- Part IV: The Application
This chapter covers multi-window apps, the NavigationSplitView three-column reader pattern, opening and closing windows from code, and persisting window state across launches. The patterns apply to LockBox (Appendix D) and QuickNote (Appendix C) when those apps move beyond a single screen toward sidebars, detail panes, and multiple open windows.
Two of the apps shipping alongside this book are built on the
NavigationSplitViewpattern this chapter teaches. Inkwell (the reader app you may be holding right now if you're on iPad) uses a sidebar-and-detail layout to navigate the book's Parts, Books, Chapters, and Pages. Claudes LockBox uses a three-column variant — Folders sidebar → Items in selected Folder → Item Detail — that reads cleanly on iPhone (stacked drill-in) and iPad / Mac (all three columns visible). Browse Inkwell'sContentView.swiftvia its Under the Hood tab, or clone Claudes LockBox at github.com/fluhartyml/Claudes-LockBox and readContentView.swift,SidebarView.swift,ItemListView.swift,ItemDetailView.swift. Both apps put the same chapter into production form. See Build-Along 00 (Inkwell — the meta chapter) and Build-Along 03 (LockBox) for the full walkthroughs.
X26 rounds the corners of every window so the chrome echoes the rounded buttons, sheets, and controls inside. iPadOS picks up two new behaviors. iPad windows now show the close, minimize, and resize controls on their title bar. And iPad windows resize as a continuous gesture — drag the corner and the window follows your finger smoothly, all the way down to its minimum size, instead of snapping to one of a handful of preset widths[[v1]].
For your code, three practical upgrades.
Drop any code that pins windows to a fixed size or refuses arbitrary resize gestures. The X26 expectation is that the user picks the dimensions, your content reflows, and you provide safe areas so layout guides have something to work against. The system uses those safe areas to position window controls and the title bar without colliding with your views — if you don't declare them, that automatic adjustment can't run.
Stay on the stock NavigationSplitView and the column resize behavior comes wired up. Drag a divider and the columns rebalance smoothly across every intermediate width, not just at named breakpoints. The trade is straightforward: you don't write the animation code, but you also don't get to override how the columns negotiate space at half-widths — the system makes that call.
X26 adds the backgroundExtensionEffect() modifier. It fakes the visual of a wide image running edge-to-edge under your sidebar — no actual content scrolls under the sidebar, the modifier just samples your hero view, blurs the sample, and paints it into the strip the sidebar occupies. The sidebar text stays sharp; the photo behind it looks unbroken. Apple's reference example is a product page where a landmark photo appears to span the whole window even though only the detail column is filled.
NavigationSplitView {
SidebarView()
} detail: {
HeroImage(landmark: landmark)
.backgroundExtensionEffect()
}Reach for backgroundExtensionEffect() when a single visual asset deserves the whole window: a header photo, a video poster frame, an oversized illustration on a marketing detail page. Anywhere the design wins by reading as edge-to-edge even though the sidebar is still on screen.
If you're adding an inspector panel, use .inspector(isPresented:content:). Building a third column by hand was reasonable in earlier OS versions; in X26 the stock modifier offers significantly more for free. The stock modifier sits inside NavigationSplitView, picks up safe areas, resizes fluidly with the rest of the window, and floats on the Liquid Glass surface the way the system expects — none of which a hand-rolled column gets for free.
[v1] Apple Developer Documentation, *Adopting Liquid Glass*. [developer.apple.com/documentation/TechnologyOverviews/adopting-liquid-glass](https://developer.apple.com/documentation/TechnologyOverviews/adopting-liquid-glass) — verified 2026-04-29.
Every Apple app has one App type as its entry point. Inside it you declare one or more scenes -- the top-level containers that SwiftUI manages for you. A scene ends up as a window on Mac and iPad, and as the app's root screen on iPhone.
The simplest app has exactly one scene:
import SwiftUI
@main
struct MyApp: App {
var body: some Scene {
WindowGroup {
ContentView()
}
}
}WindowGroup is the scene type for "a window the user can open more than one of." On Mac, File > New Window opens a second one. On iPad, it takes part in Split View / Slide Over / Stage Manager. On iPhone there is only one window; WindowGroup still works, it just never gets a sibling.
You will meet three other scene types over a typical app's lifetime:
-
Window-- a single-instance window. Think Preferences on Mac; you don't want two of them open at once. -
DocumentGroup-- a scene wired to a file document type. Each open document gets its own window automatically. Covered in Book 11. -
Settings-- Mac's Preferences scene. Shows under the app menu as⌘,.
For most of this chapter we stay with WindowGroup, which covers the common case.
NavigationSplitView is SwiftUI's answer to the three-column layout you see in Mail, Notes, Files, and every well-built Mac / iPad app. It gives you a sidebar on the left, a content column in the middle, and a detail pane on the right.
The simplest form has two columns -- sidebar and detail:
struct Library: View {
let books = ["Swift in 26 Days", "SwiftUI by Example", "The Pragmatic Programmer"]
@State private var selected: String?
var body: some View {
NavigationSplitView {
List(books, id: \.self, selection: $selected) { title in
Text(title)
}
.navigationTitle("Library")
} detail: {
if let book = selected {
Text(book)
.font(.title)
} else {
Text("Select a book")
.foregroundStyle(.secondary)
}
}
}
}On iPad and Mac: sidebar and detail appear side by side. On iPhone: the sidebar is the first screen and the detail is pushed when you tap a row.
The selection: binding is what makes the detail update when the user picks something in the sidebar. No NavigationLink needed.
When your app has a real hierarchy -- categories, items, item-detail -- reach for the three-column form:
struct Mailbox: View {
let folders = ["Inbox", "Sent", "Drafts"]
@State private var folder: String?
@State private var messageID: Int?
var body: some View {
NavigationSplitView {
List(folders, id: \.self, selection: $folder) { Text($0) }
.navigationTitle("Mailboxes")
} content: {
if let folder {
List(1...20, id: \.self, selection: $messageID) { i in
Text("\(folder) message #\(i)")
}
.navigationTitle(folder)
} else {
Text("Pick a mailbox").foregroundStyle(.secondary)
}
} detail: {
if let messageID {
Text("Message body #\(messageID)").font(.title2)
} else {
Text("Pick a message").foregroundStyle(.secondary)
}
}
}
}Three columns on Mac and iPad Pro. Two columns on iPad regular width. Single column pushed through a stack on iPhone. You write the layout once; SwiftUI adapts it to the device.
NavigationSplitView takes a binding for column visibility. The cases cover the possible states:
-
.all-- every column visible (only actually shown when there is room). -
.automatic-- SwiftUI picks based on size class. -
.detailOnly-- sidebar and content hidden, detail takes the whole window. -
.doubleColumn-- sidebar hidden, content + detail visible.
The binding lets you drive the UI in code:
struct Reader: View {
@State private var columns: NavigationSplitViewVisibility = .automatic
var body: some View {
NavigationSplitView(columnVisibility: $columns) {
sidebar
} detail: {
detail
}
.toolbar {
ToolbarItem {
Button("Focus") { columns = .detailOnly }
}
}
}
private var sidebar: some View { Text("Sidebar") }
private var detail: some View { Text("Detail") }
}Tapping Focus hides the sidebar so the reader can concentrate. The system still lets the user reveal it again via edge swipe (iOS) or the default sidebar icon (iPad / Mac).
On Mac and iPad you can open additional windows of your app's scenes. There are two sides to this: letting the user do it, and doing it in code.
WindowGroup already provides this. On Mac, File > New Window appears automatically. On iPad, the user can drag the app's Dock icon to open a second window in Split View. You don't write any code.
Use the openWindow environment action.
struct Root: View {
@Environment(\.openWindow) private var openWindow
var body: some View {
Button("New Note Window") {
openWindow(id: "note")
}
}
}For this to work, the scene has to have an id: parameter on the App:
@main
struct NotesApp: App {
var body: some Scene {
WindowGroup("Library", id: "library") {
LibraryView()
}
WindowGroup("Note", id: "note") {
NoteView()
}
}
}Now openWindow(id: "note") creates a new window with a fresh NoteView.
If the opened window needs a value -- say, "open this specific note" -- give the scene a for: type:
@main
struct NotesApp: App {
var body: some Scene {
WindowGroup("Library") { LibraryView() }
WindowGroup(for: Note.ID.self) { $noteID in
if let id = noteID { NoteView(noteID: id) }
}
}
}Then open it with a value:
@Environment(\.openWindow) private var openWindow
// ...
openWindow(value: note.id)SwiftUI picks the WindowGroup whose for: type matches. The window is restored across launches because the value conforms to Codable.
From inside the window you want to close:
@Environment(\.dismissWindow) private var dismissWindow
Button("Close") { dismissWindow() }From outside, pass the same id: or value: you opened with:
dismissWindow(id: "note")Users expect the app to come back the way they left it -- the same window open, same document, same scroll position. SwiftUI restores most of this automatically as long as you use the right property wrappers.
@SceneStorage is the one you reach for most. It's like @State except SwiftUI persists it per scene across launches:
struct NoteView: View {
@SceneStorage("draft") private var draft = ""
@SceneStorage("scrollPosition") private var scroll = 0
var body: some View {
TextEditor(text: $draft)
}
}Close the window, relaunch, the text is still there.
For app-wide preferences -- "last-used color scheme," "font size" -- use @AppStorage instead; that writes into UserDefaults and is shared across every window.
Putting the pieces together, here is a small app with a library window and a detail window that opens when you click a book.
import SwiftUI
struct Book: Identifiable, Hashable, Codable {
let id: Int
let title: String
}
@main
struct BookShelfApp: App {
var body: some Scene {
WindowGroup("Library", id: "library") {
LibraryView()
}
WindowGroup(for: Book.self) { $book in
if let book = book {
BookDetailView(book: book)
}
}
}
}
struct LibraryView: View {
@Environment(\.openWindow) private var openWindow
private let books = [
Book(id: 1, title: "Swift in 26 Days"),
Book(id: 2, title: "SwiftUI by Example"),
Book(id: 3, title: "The Pragmatic Programmer"),
]
var body: some View {
NavigationSplitView {
List(books) { book in
Button(book.title) { openWindow(value: book) }
.buttonStyle(.plain)
}
.navigationTitle("Library")
} detail: {
Text("Pick a book to open it in its own window.")
.foregroundStyle(.secondary)
.padding()
}
}
}
struct BookDetailView: View {
let book: Book
@SceneStorage("progress") private var progress = 0.0
var body: some View {
VStack(alignment: .leading) {
Text(book.title).font(.largeTitle)
Slider(value: $progress, in: 0...1)
Text("Progress: \(Int(progress * 100))%")
}
.padding()
.frame(minWidth: 400, minHeight: 240)
}
}Clicking a book opens its detail in its own window. The slider position is preserved per-window across launches because @SceneStorage is scoped to the scene.
Book 14 covers moving content between windows, apps, and devices: clipboard, drag and drop, and share sheets.
← Book-12-Sheets-Alerts-And-Confirmations · Chapters and Appendices · Book-14-Clipboard-DragDrop-ShareSheet →
Feedback: Found something off? Open an issue · Discuss it · Email Michael
Claude's X26 Swift6 Bible | GPL v3 | Built with Claude by Anthropic | Repo