```html SwiftUI: How to Build Multi-Window Support (iOS 17+, 2026)

How to Build Multi-Window Support in SwiftUI

iOS 17+ Xcode 16+ Advanced APIs: WindowGroup / UISceneSession Updated: May 11, 2026
TL;DR

Declare additional WindowGroup scenes in your App struct, then open them from any view using the @Environment(\.openWindow) action. On iPadOS 17+, each call spawns a separate, independently resizable scene window.

// App entry point
@main
struct MyApp: App {
    var body: some Scene {
        WindowGroup { ContentView() }
        WindowGroup("Detail", id: "detail", for: UUID.self) { $id in
            DetailView(id: id ?? UUID())
        }
    }
}

// Trigger from any view
struct ContentView: View {
    @Environment(\.openWindow) private var openWindow
    var body: some View {
        Button("Open Detail") {
            openWindow(id: "detail", value: UUID())
        }
    }
}

Full implementation

Multi-window support on iPadOS requires each window to be its own Scene. SwiftUI's WindowGroup accepts a typed value parameter so you can carry data into the new window safely. Use handlesExternalEvents(matching:) to route deep-links or Handoff activities to the correct scene, and dismissWindow to close a window from within its own view hierarchy.

import SwiftUI

// MARK: - Data model passed between windows
struct NoteID: Codable, Hashable {
    var id: UUID
    var title: String
}

// MARK: - App entry point
@main
struct NotesApp: App {
    var body: some Scene {
        // Primary window
        WindowGroup {
            NoteListView()
        }

        // Secondary editor window, typed on NoteID
        WindowGroup("Note Editor", id: "note-editor", for: NoteID.self) { $noteID in
            if let noteID {
                NoteEditorView(noteID: noteID)
            } else {
                ContentUnavailableView("No Note", systemImage: "doc")
            }
        }
        .handlesExternalEvents(matching: ["note-editor"])
    }
}

// MARK: - Primary list view
struct NoteListView: View {
    @Environment(\.openWindow) private var openWindow

    let sampleNotes: [NoteID] = [
        NoteID(id: UUID(), title: "Meeting Notes"),
        NoteID(id: UUID(), title: "Shopping List"),
        NoteID(id: UUID(), title: "Ideas")
    ]

    var body: some View {
        NavigationStack {
            List(sampleNotes, id: \.id) { note in
                HStack {
                    VStack(alignment: .leading) {
                        Text(note.title)
                            .font(.headline)
                    }
                    Spacer()
                    Button {
                        openWindow(id: "note-editor", value: note)
                    } label: {
                        Label("Open in New Window", systemImage: "rectangle.on.rectangle")
                            .labelStyle(.iconOnly)
                    }
                    .accessibilityLabel("Open \(note.title) in a new window")
                }
            }
            .navigationTitle("Notes")
        }
    }
}

// MARK: - Secondary editor view
struct NoteEditorView: View {
    let noteID: NoteID
    @Environment(\.dismiss) private var dismiss
    @Environment(\.dismissWindow) private var dismissWindow
    @State private var text = ""

    var body: some View {
        NavigationStack {
            TextEditor(text: $text)
                .padding()
                .navigationTitle(noteID.title)
                .toolbar {
                    ToolbarItem(placement: .confirmationAction) {
                        Button("Done") {
                            // Dismiss the entire scene window
                            dismissWindow(id: "note-editor")
                        }
                    }
                }
        }
        .onAppear { text = "Start writing \(noteID.title)…" }
    }
}

// MARK: - Preview
#Preview {
    NoteListView()
}

#Preview("Editor") {
    NoteEditorView(noteID: NoteID(id: UUID(), title: "Meeting Notes"))
}

How it works

  1. Typed WindowGroup declarationWindowGroup("Note Editor", id: "note-editor", for: NoteID.self) registers a named scene that accepts a NoteID value. The system creates a new UISceneSession for each unique call, so the same note can appear in multiple windows simultaneously.
  2. @Environment(\.openWindow) — The openWindow action is injected by the SwiftUI scene infrastructure. Calling openWindow(id: "note-editor", value: note) on iPadOS 17+ triggers scene creation; on iPhone, the same call is silently ignored (single-window devices), so no guard is needed.
  3. handlesExternalEvents(matching:) — Attaching this modifier to a WindowGroup lets the system route incoming URLs or NSUserActivity objects to an existing or new instance of that scene, enabling deep-link navigation across windows.
  4. @Environment(\.dismissWindow) — Introduced in iOS 17, dismissWindow(id:) programmatically destroys a scene session from anywhere inside the window's view hierarchy — cleaner than accessing UISceneSession directly.
  5. Binding projection $noteID — The window group's closure receives a Binding<NoteID?> rather than a plain value; this lets the view read the initial payload and react to state restoration on relaunch without extra boilerplate.

Variants

Check if multi-window is supported at runtime

On iPhone, openWindow is available but does nothing. Use UIApplication.shared.supportsMultipleScenes to conditionally show the button, keeping the UI clean on single-window devices.

struct OpenWindowButton: View {
    @Environment(\.openWindow) private var openWindow
    let note: NoteID

    // True on iPadOS, false on iPhone
    private var supportsMultiWindow: Bool {
        UIApplication.shared.supportsMultipleScenes
    }

    var body: some View {
        if supportsMultiWindow {
            Button {
                openWindow(id: "note-editor", value: note)
            } label: {
                Label("Open in New Window", systemImage: "rectangle.on.rectangle")
            }
            .accessibilityLabel("Open \(note.title) in new window")
        }
    }
}

Targeting an existing scene instead of opening a new one

Pass UISceneActivationRequestOptions via the UIKit bridge if you need to raise an already-open window rather than spawning a duplicate. Retrieve the matching session from UIApplication.shared.openSessions, filter by scene?.title or your stored scene ID, then call UIApplication.shared.requestSceneSessionActivation(_:userActivity:options:errorHandler:). This is the escape hatch for cases where openWindow always creates a new scene rather than focusing an existing one.

Common pitfalls

Prompt this with Claude Code

When using Soarias or Claude Code directly to implement this:

Implement multi-window support in SwiftUI for iOS 17+.
Use WindowGroup/UISceneSession.
Declare a typed WindowGroup(for:) in the App struct.
Use @Environment(\.openWindow) and @Environment(\.dismissWindow).
Add runtime guard for UIApplication.shared.supportsMultipleScenes.
Make it accessible (VoiceOver labels, meaningful window titles).
Add a #Preview with realistic sample data.

In Soarias's Build phase, drop this prompt into the active feature task to generate scene-aware scaffolding that slots directly into your existing App struct without disrupting your primary WindowGroup.

Related

FAQ

Does multi-window work on iOS 16?

Partially. The WindowGroup API exists on iOS 16, but typed WindowGroup(for: SomeType.self) and @Environment(\.dismissWindow) require iOS 17. More importantly, iPadOS is the only platform where multiple windows are actually presented to the user; on iPhone the system always uses a single scene regardless of your manifest setting or API calls.

How do I pass live data changes from one window to another?

Use a shared @Observable model injected via .environment(myModel) on each WindowGroup root. Because both scenes share the same process, mutations in one window are reflected immediately in the other via the observation system — no notification center or actor bridging needed. For persistent data, pair with SwiftData and a shared ModelContainer.

What's the UIKit equivalent?

In UIKit you configure UISceneConfiguration in your Info.plist and implement UIWindowSceneDelegate. To open a new window call UIApplication.shared.requestSceneSessionActivation(nil, userActivity: activity, options: options). SwiftUI's WindowGroup / openWindow is the declarative wrapper around this machinery — for most apps you never need to touch UISceneSession directly.

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

```