WWDC Quick Look 💓 By SwiftGGTeam
Work with windows in SwiftUI

Work with windows in SwiftUI

Watch original video

Highlight

This session focuses on defining, opening, positioning, and sizing windows in SwiftUI, using a cross-platform game app called BOT-anist as the demo. BOT-anist has an editor window and a game Volume on visionOS; the session adds a movie playback window and a control panel window, demonstrating complete window management capabilities.


Core Content

On visionOS and macOS, if your app relies on a single WindowGroup for everything, auxiliary interfaces like control panels, detail pages, and media playback have to be crammed into TabView or NavigationStack. The problem: users cannot freely drag, resize, or place these interfaces side by side. Packing too much into one window makes poor use of space.

SwiftUI’s multi-window capabilities address this. You can define separate WindowGroups for different features, open new windows with openWindow, replace the current window with pushWindow, control initial placement with defaultWindowPlacement, and manage size with defaultSize and windowResizability. These APIs give each feature module its own window that users can move, resize, and close independently — a much better experience than stacking layers in a single window.

This session uses BOT-anist to walk through the full window management workflow. BOT-anist originally had only an editor window and a game Volume; the session adds a movie playback window and a control panel window, covering definition, opening, placement, and size constraints end to end.

Detailed Content

Defining and Opening Windows

BOT-anist’s original Scene definition includes two WindowGroups: editor and game. The game uses .windowStyle(.volumetric) to present as a 3D Volume on visionOS (02:36).

@main
struct BOTanistApp: App {
    var body: some Scene {
        WindowGroup(id: "editor") {
            EditorContentView()
        }

        WindowGroup(id: "game") {
            GameContentView()
        }
        .windowStyle(.volumetric)
    }
}
  • Each WindowGroup is identified by an id for later reference
  • .windowStyle(.volumetric) presents the window as a 3D Volume on visionOS

Adding a movie window requires only a new WindowGroup (03:09):

WindowGroup(id: "movie") {
    MovieContentView()
}

The editor view opens the movie window via the openWindow action from the environment (03:55):

struct EditorContentView: View {
    @Environment(\.openWindow) private var openWindow

    var body: some View {
        Button("Open Movie", systemImage: "tv") {
            openWindow(id: "movie")
        }
    }
}
  • @Environment(\.openWindow) retrieves the open-window action from the environment
  • openWindow(id: "movie") passes the WindowGroup id to specify which window to open

pushWindow: Replace Instead of Stack

The movie window and editor do not need to be visible at the same time — pushWindow is a better fit (04:45):

struct EditorContentView: View {
    @Environment(\.pushWindow) private var pushWindow

    var body: some View {
        Button("Open Movie", systemImage: "tv") {
            pushWindow(id: "movie")
        }
    }
}
  • pushWindow opens a new window while hiding the source window
  • When the new window closes, the source window reappears automatically — no extra logic needed
  • Suitable when content does not need to be viewed alongside the source window

Initial Window Placement

New windows appear in front of the source window (visionOS) or at screen center (macOS) by default. Use defaultWindowPlacement to customize (07:46):

WindowGroup(id: "controller") {
    ControllerContentView()
}
.defaultWindowPlacement { content, context in
    #if os(visionOS)
    return WindowPlacement(.utilityPanel)
    #elseif os(macOS)
    let displayBounds = context.defaultDisplay.visibleRect
    let size = content.sizeThatFits(.unspecified)
    let position = CGPoint(
        x: displayBounds.midX - (size.width / 2),
        y: displayBounds.maxY - size.height - 20
    )
    return WindowPlacement(position, size: size)
    #endif
}
  • WindowPlacement(.utilityPanel) places the window near the user within reach on visionOS
  • On macOS, use context.defaultDisplay.visibleRect to get the safe area and compute position manually
  • content.sizeThatFits(.unspecified) queries the content’s preferred size
  • The content closure parameter is a proxy for the window content; context contains platform-specific display information

Window Size Strategy

defaultSize sets the initial window size (10:12):

WindowGroup(id: "movie") {
    MovieContentView()
}
.defaultSize(width: 1166, height: 680)
  • If defaultWindowPlacement also returns a size, or the scene restores from saved state, defaultSize is ignored
  • A pushed window’s default size equals the source window’s size

Use windowResizability(.contentSize) to constrain resize range based on content min/max (10:49):

WindowGroup(id: "movie") {
    MovieContentView()
        .frame(
            minWidth: 680, maxWidth: 2720,
            minHeight: 680, maxHeight: 1020
        )
}
.windowResizability(.contentSize)
  • minWidth/maxWidth/minHeight/maxHeight in .frame define the content size range
  • .windowResizability(.contentSize) makes the window size follow the content’s min/max constraints
  • The movie window can shrink to a 680×680 square at minimum, and grow up to 2720×1020 at maximum

The control panel window also uses .contentSize, but its content view has a fixed size (no min/max), so the window cannot be manually resized — it adjusts automatically when switching modes (11:37).

Platform-Specific Enhancements

On visionOS, .persistentSystemOverlays(.hidden) hides the window bar and close button so users can focus on content (05:48):

WindowGroup(id: "movie") {
    ...
}
.persistentSystemOverlays(.hidden)

Freeform-style toolbar ornaments and ToolbarTitleMenu are also common window enhancements on visionOS.

Core Takeaways

  • What to do: Split auxiliary features into separate windows. Control panels, settings panels, detail previews, and media players are all candidates for multi-window. Why it’s worth doing: On visionOS and macOS, independent windows let users arrange and resize freely — far more efficient than stacking layers in one window. How to start: Create a new WindowGroup with an id for the auxiliary feature, and open it with openWindow(id:) from the main view.
  • What to do: Choose pushWindow or openWindow based on whether content needs to be visible simultaneously. Why it’s worth doing: pushWindow automatically manages hiding and restoring the source window, avoiding manual close/reopen logic and visual clutter from stacked windows. How to start: Replace @Environment(\.openWindow) with @Environment(\.pushWindow) — the call syntax is the same.
  • What to do: Set a sensible initial position for new windows with defaultWindowPlacement. Why it’s worth doing: Default placement may obscure existing content or sit too far from the user; good initial placement reduces manual adjustment. How to start: Add a .defaultWindowPlacement modifier to the WindowGroup — use .utilityPanel on visionOS, and compute position with context.defaultDisplay.visibleRect on macOS.
  • What to do: Control window resize range with windowResizability(.contentSize) plus content min/max frame. Why it’s worth doing: Prevents windows from being shrunk until content is unusable, or enlarged to consume too much space. How to start: Set minWidth/maxWidth/minHeight/maxHeight in the content view’s .frame, then add .windowResizability(.contentSize) to the WindowGroup.

Comments

GitHub Issues · utterances