WWDC Quick Look 💓 By SwiftGGTeam
Work with windows in SwiftUI

Work with windows in SwiftUI

元の動画を見る

Highlight

このセッションでは SwiftUI におけるウィンドウの定義、オープン、位置、サイズ制御に焦点を当て、クロスプラットフォームゲームアプリ BOT-anist をデモとして使用します。BOT-anist は visionOS 上でエディターウィンドウとゲーム Volume の 2 つの主要シーンを持ち、セッションでは映画再生ウィンドウとコントロールパネルウィンドウを追加し、完全なウィンドウ管理機能を示します。


主要内容

visionOS と macOS では、アプリが 1 つの WindowGroup ですべてを処理している場合、コントロールパネル、詳細ページ、メディア再生などの補助 UI を TabView や NavigationStack に詰め込むしかありません。問題は、ユーザーがこれらの UI を自由にドラッグ、リサイズ、並べて配置できないことです。1 つのウィンドウに詰め込みすぎると、スペースの利用効率が非常に低くなります。

SwiftUI のマルチウィンドウ機能がこの課題を解決します。機能ごとに独立した WindowGroup を定義し、openWindow で新しいウィンドウを開き、pushWindow で現在のウィンドウを置き換え、defaultWindowPlacement で初期位置を制御し、defaultSizewindowResizability でサイズを管理できます。これらの API により各機能モジュールが独自のウィンドウを持ち、ユーザーは独立して移動、リサイズ、閉じることができます。単一ウィンドウ内のレイヤー重ねよりはるかに優れた体験です。

このセッションでは BOT-anist を例に、完全なウィンドウ管理フローをデモします。BOT-anist はもともとエディターウィンドウとゲーム Volume の 2 シーンのみでしたが、セッションでは映画再生ウィンドウとコントロールパネルウィンドウを順次追加し、定義からオープン、配置、サイズ制約までの全プロセスを示します。

詳細

ウィンドウの定義とオープン

BOT-anist の元の Scene 定義には 2 つの WindowGroup があります。エディターとゲームです。ゲームは .windowStyle(.volumetric) で visionOS 上で 3D Volume として表示されます(02:36)。

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

        WindowGroup(id: "game") {
            GameContentView()
        }
        .windowStyle(.volumetric)
    }
}
  • WindowGroupid で識別され、後で id を参照
  • .windowStyle(.volumetric) で visionOS 上で 3D Volume 形式で表示

映画ウィンドウの追加は新しい WindowGroup を追加するだけ(03:09):

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

エディタービューでは環境変数から openWindow アクションを取得して映画ウィンドウを開きます(03:55):

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

    var body: some View {
        Button("Open Movie", systemImage: "tv") {
            openWindow(id: "movie")
        }
    }
}
  • @Environment(\.openWindow) で環境からウィンドウを開くアクションを取得
  • openWindow(id: "movie") で WindowGroup の id を渡して開くウィンドウを指定

pushWindow: 重ねるのではなく置き換え

映画ウィンドウとエディターは同時に表示する必要がないため、pushWindow が適しています(04:45):

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

    var body: some View {
        Button("Open Movie", systemImage: "tv") {
            pushWindow(id: "movie")
        }
    }
}
  • pushWindow は新しいウィンドウを開きながらソースウィンドウを非表示にする
  • 新しいウィンドウを閉じるとソースウィンドウが自動的に復元。追加ロジック不要
  • ソースウィンドウと同時にコンテンツを見る必要がない場面に適している

ウィンドウの初期位置制御

新しいウィンドウはデフォルトでソースウィンドウの前方(visionOS)または画面中央(macOS)に表示されます。defaultWindowPlacement でカスタマイズできます(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) で visionOS 上でユーザー近くの手の届く位置に配置
  • macOS では context.defaultDisplay.visibleRect でセーフエリアを取得し、位置を手動計算
  • content.sizeThatFits(.unspecified) でコンテンツの希望サイズをクエリ
  • クロージャパラメータ content はウィンドウコンテンツのプロキシ、context はプラットフォーム固有の表示情報

ウィンドウサイズ戦略

defaultSize でウィンドウの初期サイズを設定(10:12):

WindowGroup(id: "movie") {
    MovieContentView()
}
.defaultSize(width: 1166, height: 680)
  • defaultWindowPlacement がサイズを返す場合、またはシーンが復元状態から復元される場合、defaultSize は無視される
  • push されたウィンドウのデフォルトサイズはソースウィンドウのサイズと等しい

windowResizability(.contentSize) でコンテンツの min/max に基づくリサイズ範囲を制限(10:49):

WindowGroup(id: "movie") {
    MovieContentView()
        .frame(
            minWidth: 680, maxWidth: 2720,
            minHeight: 680, maxHeight: 1020
        )
}
.windowResizability(.contentSize)
  • .frame の minWidth/maxWidth/minHeight/maxHeight でコンテンツのサイズ範囲を定義
  • .windowResizability(.contentSize) でウィンドウサイズがコンテンツの min/max 制約に従う
  • 映画ウィンドウは最小 680×680 の正方形まで縮小、最大 2720×1020 まで拡大可能

コントロールパネルウィンドウも .contentSize を使用しますが、コンテンツビューは固定サイズ(min/max なし)のため、手動リサイズ不可。モード切り替え時に自動的にサイズ調整されます(11:37)。

プラットフォーム固有の拡張

visionOS では .persistentSystemOverlays(.hidden) でウィンドウバーと閉じるボタンを非表示にし、ユーザーがコンテンツに集中できます(05:48):

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

Freeform スタイルの toolbar ornament や ToolbarTitleMenu も visionOS でよく使われるウィンドウ拡張手段です。

重要ポイント

  • やること: 補助機能を独立したウィンドウに分割する。コントロールパネル、設定パネル、詳細プレビュー、メディアプレーヤーはすべてマルチウィンドウの候補。なぜやる価値があるか: visionOS と macOS では独立ウィンドウによりユーザーが自由に配置・リサイズでき、単一ウィンドウ内のレイヤー重ねよりスペース利用効率がはるかに高い。始め方: 補助機能用に id 付きの新しい WindowGroup を作成し、メインビューから openWindow(id:) で開く。
  • やること: コンテンツを同時に見る必要があるかに応じて pushWindowopenWindow を選択。なぜやる価値があるか: pushWindow はソースウィンドウの非表示と復元を自動管理し、手動の閉じ/再開ロジックやウィンドウの積み重ねによる視覚的混乱を回避。始め方: @Environment(\.openWindow)@Environment(\.pushWindow) に置き換え。呼び出し方法は同じ。
  • やること: defaultWindowPlacement で新しいウィンドウの合理的な初期位置を設定。なぜやる価値があるか: デフォルト位置は既存コンテンツを遮るかユーザーから遠すぎる可能性がある。良い初期位置は手動調整の負担を減らす。始め方: WindowGroup に .defaultWindowPlacement modifier を追加。visionOS では .utilityPanel、macOS では context.defaultDisplay.visibleRect で位置を計算。
  • やること: windowResizability(.contentSize) + コンテンツの min/max frame でウィンドウのリサイズ範囲を制御。なぜやる価値があるか: ウィンドウがコンテンツが使えなくなるまで縮小されたり、過剰なスペースを占有するまで拡大されるのを防ぐ。始め方: コンテンツビューの .frame で minWidth/maxWidth/minHeight/maxHeight を設定し、WindowGroup に .windowResizability(.contentSize) を追加。

関連セッション

コメント

GitHub Issues · utterances