How to implement Stage Manager support in SwiftUI
Declare UIApplicationSupportsMultipleScenes = true in Info.plist, define your scenes with WindowGroup in your App struct, and use the openWindow environment action to open new windows. Pair with adaptive layouts so your UI reflows gracefully as Stage Manager windows are resized.
@main
struct MyApp: App {
var body: some Scene {
WindowGroup {
ContentView()
}
.windowResizability(.contentMinSize)
WindowGroup("Detail", id: "detail", for: Item.ID.self) { $id in
DetailView(itemID: id)
}
.windowResizability(.contentMinSize)
}
}
// Open a second window from any view:
struct ContentView: View {
@Environment(\.openWindow) private var openWindow
var body: some View {
Button("Open Detail") {
openWindow(id: "detail", value: someItem.id)
}
}
}
Full implementation
The complete solution wires together three pieces: the multi-scene Info.plist key, a well-structured App struct with typed WindowGroup scenes, and an adaptive content view that reflows as the Stage Manager window grows or shrinks. The containerRelativeFrame modifier (iOS 17+) lets each column or tile respond to its container size rather than the full screen, making it the ideal primitive for Stage Manager layouts.
// ── MyApp.swift ──────────────────────────────────────────────
import SwiftUI
@main
struct MyApp: App {
var body: some Scene {
// Primary window — shown on launch and in Stage Manager
WindowGroup {
ContentView()
}
.windowResizability(.contentMinSize) // honour SwiftUI minimum
// Secondary typed window opened on demand
WindowGroup(
"Item Detail",
id: "item-detail",
for: Item.ID.self
) { $itemID in
if let id = itemID {
ItemDetailView(itemID: id)
} else {
ContentUnavailableView("No item", systemImage: "tray")
}
}
.windowResizability(.contentMinSize)
.defaultSize(width: 420, height: 600)
}
}
// ── Item model ────────────────────────────────────────────────
struct Item: Identifiable, Hashable {
let id: UUID
var title: String
}
// ── ContentView.swift ─────────────────────────────────────────
import SwiftUI
struct ContentView: View {
@Environment(\.openWindow) private var openWindow
@Environment(\.horizontalSizeClass) private var hSizeClass
private let items: [Item] = (1...12).map {
Item(id: UUID(), title: "Item \($0)")
}
// Adapt column count: 1 in compact, 2-3 in regular / larger Stage Manager window
private var columns: [GridItem] {
let count = hSizeClass == .compact ? 1 : 3
return Array(repeating: GridItem(.flexible(), spacing: 12), count: count)
}
var body: some View {
NavigationStack {
ScrollView {
LazyVGrid(columns: columns, spacing: 12) {
ForEach(items) { item in
ItemCard(item: item) {
openWindow(id: "item-detail", value: item.id)
}
}
}
.padding()
}
.navigationTitle("My App")
.toolbar {
ToolbarItem(placement: .primaryAction) {
Button("New Window", systemImage: "rectangle.stack.badge.plus") {
openWindow(id: "item-detail", value: items.first?.id)
}
}
}
}
}
}
// ── ItemCard.swift ─────────────────────────────────────────────
struct ItemCard: View {
let item: Item
let onOpen: () -> Void
var body: some View {
RoundedRectangle(cornerRadius: 16, style: .continuous)
.fill(Color(.secondarySystemBackground))
.overlay(
VStack(alignment: .leading, spacing: 8) {
Text(item.title)
.font(.headline)
Button("Open in Window", action: onOpen)
.font(.footnote)
.buttonStyle(.bordered)
}
.padding()
, alignment: .topLeading
)
.containerRelativeFrame(.horizontal, count: 1, spacing: 0)
.frame(height: 120)
.accessibilityElement(children: .combine)
.accessibilityLabel(item.title)
.accessibilityAddTraits(.isButton)
}
}
// ── ItemDetailView.swift ───────────────────────────────────────
struct ItemDetailView: View {
let itemID: Item.ID
var body: some View {
NavigationStack {
Text("Detail for \(itemID)")
.navigationTitle("Detail")
.navigationBarTitleDisplayMode(.inline)
}
}
}
// ── Preview ────────────────────────────────────────────────────
#Preview("Compact") {
ContentView()
.environment(\.horizontalSizeClass, .compact)
}
#Preview("Regular") {
ContentView()
.environment(\.horizontalSizeClass, .regular)
}
How it works
- Multi-scene entitlement: Setting
UIApplicationSupportsMultipleScenes = truein Info.plist tells iPadOS the app can host more than one scene simultaneously. Without this key Stage Manager will still show your app, but only ever in a single window —openWindowsilently does nothing. - Typed
WindowGroup: The secondaryWindowGroup("Item Detail", id: "item-detail", for: Item.ID.self)declaration registers a scene that can be opened with a strongly typed value. iPadOS deduplicates by value — opening the sameItem.IDtwice brings the existing window to the front rather than creating a duplicate. .windowResizability(.contentMinSize): This modifier tells Stage Manager to respect the minimum intrinsic size your SwiftUI hierarchy reports, preventing the window from being squashed below a size your layout can't handle.- Adaptive grid with
horizontalSizeClass:@Environment(\.horizontalSizeClass)dynamically reports.compactor.regularas the Stage Manager window is resized. The grid column count responds instantly — no manual geometry tracking required. containerRelativeFrame: Applied to each card, this modifier (iOS 17+) sizes children relative to their scroll-view container rather than the full screen, so cards fill the Stage Manager window width correctly at any size without hard-coded constants.
Variants
Detect when your scene is active vs. backgrounded
struct ContentView: View {
@Environment(\.scenePhase) private var scenePhase
var body: some View {
Text("Hello, Stage Manager")
.onChange(of: scenePhase) { _, newPhase in
switch newPhase {
case .active:
// Window moved into focus — resume timers, refresh data
print("Scene active")
case .inactive:
// Window is visible but not focused (e.g., behind another window)
print("Scene inactive")
case .background:
// Window is hidden or app moved to background
print("Scene in background")
@unknown default:
break
}
}
}
}
Set a default window size and position
Use .defaultSize and .defaultPosition on your WindowGroup to control where and how large a new scene appears when first opened. Both are hints — Stage Manager respects the user's subsequent repositioning and stores it per-scene.
WindowGroup("Settings", id: "settings") {
SettingsView()
}
.windowResizability(.contentSize) // lock to content size
.defaultSize(width: 360, height: 520)
.defaultPosition(.center)
Common pitfalls
- Forgetting
UIApplicationSupportsMultipleScenes: Stage Manager can still display your app, but calls toopenWindoware silently ignored on iPadOS 16 and earlier. On iPadOS 17+ the system logs a warning but still does nothing. Add the Info.plist key first. - Using
GeometryReaderinstead ofcontainerRelativeFrame:GeometryReaderreports the full window size, not the container's share of it — fine for full-screen layouts, but it breaks grid items that should each be a fraction of the window. UsecontainerRelativeFrame(.horizontal, count:spacing:)for adaptive tiles. - Hardcoding breakpoints instead of reacting to size class: A Stage Manager window can be any width, not just compact/regular extremes. Complement size-class checks with
GeometryReaderorcontainerRelativeFrameat the leaf level so layouts interpolate smoothly rather than snapping at a single breakpoint. - Missing VoiceOver on "Open in Window" controls: Users navigating with VoiceOver in Stage Manager still interact with each window independently. Ensure every button that opens a new window has a descriptive
.accessibilityLabelsuch as"Open Item 3 in a new window"so the action is unambiguous.
Prompt this with Claude Code
When using Soarias or Claude Code directly to implement this:
Implement stage manager support in SwiftUI for iOS 17+. Use WindowGroup with typed IDs, openWindow, windowResizability, containerRelativeFrame, and horizontalSizeClass for adaptive layouts. Enable UIApplicationSupportsMultipleScenes in Info.plist. Make it accessible (VoiceOver labels on all window-opening buttons). Add a #Preview with realistic sample data for both compact and regular size classes.
Paste this prompt into Soarias during the Build phase after your screens are scaffolded — Claude Code will wire up the scene declarations, adaptive grid, and multi-window plumbing in one pass, leaving you to review the diff and ship.
Related
FAQ
Does Stage Manager support work on iOS 16?
Stage Manager itself requires iPadOS 16 on M1 iPad Pro/Air or iPadOS 17 on all supported iPads. However, WindowGroup multi-scene support is available from iPadOS 14+. You can safely target iOS 16 as your deployment minimum — Stage Manager will activate automatically when the device and OS support it, while older devices get a standard single-window experience.
How do I prevent duplicate windows for the same item?
Use typed WindowGroup with a for: parameter and pass a stable Hashable identifier (like a UUID or a database row ID) to openWindow(id:value:). iPadOS automatically de-duplicates: if a scene with that exact value is already open, Stage Manager raises it to the front instead of creating a new one.
What's the UIKit equivalent?
In UIKit you'd implement UISceneDelegate + UIWindowSceneDelegate, configure UISceneConfiguration in Info.plist, and request new scenes via UIApplication.shared.requestSceneSessionActivation(_:userActivity:options:). SwiftUI's WindowGroup + openWindow is the idiomatic replacement and handles scene lifecycle bookkeeping automatically.
Last reviewed: 2026-05-11 by the Soarias team.