How to Build Multi-Window Support in SwiftUI
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
-
Typed
WindowGroupdeclaration —WindowGroup("Note Editor", id: "note-editor", for: NoteID.self)registers a named scene that accepts aNoteIDvalue. The system creates a newUISceneSessionfor each unique call, so the same note can appear in multiple windows simultaneously. -
@Environment(\.openWindow)— TheopenWindowaction is injected by the SwiftUI scene infrastructure. CallingopenWindow(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. -
handlesExternalEvents(matching:)— Attaching this modifier to aWindowGrouplets the system route incoming URLs orNSUserActivityobjects to an existing or new instance of that scene, enabling deep-link navigation across windows. -
@Environment(\.dismissWindow)— Introduced in iOS 17,dismissWindow(id:)programmatically destroys a scene session from anywhere inside the window's view hierarchy — cleaner than accessingUISceneSessiondirectly. -
Binding projection
$noteID— The window group's closure receives aBinding<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
-
Forgetting
UIApplicationSceneManifestin Info.plist. Multi-window requiresEnable Multiple Windows(key:UIApplicationSupportsMultipleScenes) set toYES. Without it, everyopenWindowcall silently fails even on iPadOS. -
openWindowis unavailable on iOS 16. The environment key was introduced in SwiftUI 4 / iOS 16, but typedWindowGroup(for:)that accepts aCodablevalue requires iOS 17. Annotate with@available(iOS 17, *)if you still support iOS 16 in any code path. -
State restoration requires
Codableconformance on your value type. The system persists the window's associated value across launches via scene storage. If your value type isn't fullyCodable, the window reopens with anilbinding on next launch and yourContentUnavailableViewfallback fires unexpectedly. -
Accessibility: set meaningful window titles.
VoiceOver reads the window title in the App Switcher. Use
.navigationTitleinside the scene root and pass a human-readable string as the first argument toWindowGroup; avoid generic labels like "Window 2".
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.