 
							- 
							
							SwiftUIでのウインドウの操作visionOS、macOS、iPadOSで、シングルウインドウとマルチウインドウの優れたアプリを構築する方法を学びましょう。ウインドウを開く/閉じる操作をプログラムで実行したり、位置やサイズを調整したり、ウインドウを別のウインドウと交換したりできるツールをご紹介します。また、ユーザーが各自のワークフロー内でアプリを使用しやすいウインドウを実現するための、デザインの原則についても確認します。 関連する章- 0:00 - Introduction
- 1:19 - Fundamentals
- 6:15 - Placement
- 9:43 - Sizing
- 12:03 - Next steps
 リソース関連ビデオWWDC24WWDC23
- 
							このビデオを検索こんにちは SwiftUI担当のAndrewです 本セッションでは SwiftUIアプリの ウインドウについて説明します ウインドウとは アプリのコンテンツを 格納する入れ物です ウインドウにより 使い慣れた操作方法で アプリの様々な部分を管理できます 位置の変更や サイズの変更 閉じるなどの操作です 今回の説明には BOT-anistを使います 私が同僚と開発している SwiftUIアプリです この画面は Simulatorでの BOT-anistのロボットエディタです ロボットをカスタマイズする画面です プレイヤーは このロボットがゲーム内で 植物の世話をするのを手伝います BOT-anistはiOS iPadOS visionOS macOSで パーソナライズされた体験を提供します 今回説明する概念はマルチウインドウの プラットフォームにも適用可能ですが 本ビデオでは visionOSのみを対象とします まず ウインドウの定義と作成 および使用の方法について説明します 次に ウインドウの初期位置を 制御する方法を解説します また ウインドウのサイズ変更の 各種方法をご紹介します まずは基本事項です 独立した複数の ウインドウを使うと アプリの 様々な部分を同時に扱えます 同じインターフェイスのインスタンスを 複数作成すると効果的な場合もあります システムコントロールを使えば 各ウインドウを個別に操作できます サイズの変更や位置の変更 拡大などの操作が可能です また ウインドウでは プラットフォーム固有の機能を利用できます 例えば visionOSでは ボリュメトリックウインドウスタイルにより ウインドウに3Dコンテンツを表示できます 複数ウインドウの使用にも利点がありますが TabViewのように最上位メニューのみを 表示する単一のビューは ユーザー体験をシンプル化します 「Elevate your windowed app for spatial computing」では TabViewやその他の最上位ビューの 詳細について解説しています visionOSで複数ウインドウの使用が 適している場合の詳細については 「Design for spatial user interfaces」をご覧ください visionOSのBOT-anistには 主要なシーンが2つあります エディタウインドウと ゲームのボリュームです 各シーンはWindowGroupで 定義されています アプリでは ロボットエディタの WindowGroupの インスタンスが開きます このウインドウのボタンで ゲームのWindowGroupの インスタンスが開きます windowStyleを .volumetricにして ウインドウをvisionOSの ボリュームにします BOT-anistに新しい機能を 2つ追加しましょう 1つ目は ロボットに関する動画を表示する 新しいウインドウを開く機能です この動画はポータルに含まれる 3Dシーンになります アプリのbodyに 3Dシーンビューを格納する 新しいWindowGroupを追加します このWindowGroupを識別できるように 「movie」というIDを指定しています このIDはウインドウを開く際に使用します このIDを Environmentアクションに渡します これらのアクションは SwiftUIの 階層構造のどこからでも使用できます ウインドウ管理には 複数の Environmentアクションを使用します ウインドウを開くには openWindowを使用します 閉じるには dismissWindowを使用します pushWindowを使うと ウインドウを 開いて元のウインドウを非表示にできます ここではopenWindowを使って 新しい動画ウインドウを開きます ロボットエディタのビューで Environmentから openWindowアクションを 取得するために openWindowのキーパスを指定して Environmentプロパティを作成します 次に 新しいボタンで openWindowアクションを実行しますが ここで 先ほどWindowGroup用に 定義したIDである「movie」を指定します これで エディタのボタンをタップすると 動画のポータルが 別ウインドウで表示されるようになりました こうして見てみると 動画のビューと同時に エディタを 表示しておく必要はないと思えます そこで Environmentアクションの pushWindowを ウインドウ表示に使用します この方法では 新しいウインドウが 元のウインドウと入れ替わりに開きます 新しいウインドウを閉じると 元のウインドウが再度表示されます 動画ウインドウ表示時に エディタを非表示にするには Environmentプロパティの キーパスを openWindowから pushWindowに変更し 前と異なるアクションを呼び出すように ボタンを更新します これで TVのボタンをタップすると 動画ウインドウがプッシュされ エディタウインドウが消えるようになります これで デザインしたロボットが 演技を始めるのを 気を散らされることなく 見られるようになりました 閉じるボタンをタップすると エディタに戻ります この動作を実現するために ロジックの追加は必要ありません ウインドウを開く時に 表示しておく 必要のないコンテンツがある場合は このアクションの使用を検討してください ウインドウを定義して開いたら プラットフォーム固有の機能を使って さらに快適になるように機能を強化できます 例えば フリーボードで ツールバーオーナメントを使って ウインドウの下端に沿って コントロールを表示する方法や ToolbarTitleMenuで 画面を混雑させずに ドキュメントの関連アクションを 表示する方法などです ウインドウバーと閉じるボタンは デフォルトで必ず表示されます しかし 動画ビューでは .persistentSystem Overlaysモディファイアを使って これらを非表示にしました ユーザーが 動画に集中しやすくなるためです これらのAPIは visionOSのウインドウの 機能を強化する優れた手段です macOSでの ウインドウの調整については 「Tailor macOS windows with SwiftUI」をご覧ください 動画ウインドウの表示を改善できたので 次に ゲームで使用するオプションの コントロールパネルを追加します このパネルには ロボットを動かすための コントロールと ジャンプや手を振るなどのアクションを 実行するボタンを表示します コントロールを表示する 新しいWindowGroupを追加しました また ゲームのボリュームに openWindowの呼び出しを追加します ゲームでボタンをタップすると 新しい ウインドウでコントロールが表示されます ゲームのボリュームから独立して 位置を変えられる点が優れています しかし 初めてウインドウを開くと 表示がボリュームに重なるか 位置が遠い場合もあります visionOSでは コントロールパネルなどの 新しいウインドウは 元のウインドウの前面に配置されます 一方macOSでは 新しいウインドウは 画面の中央に表示されます この動作は defaultWindowPlacement モディファイアでカスタマイズでき ウインドウの初期位置とサイズを プログラムで設定できます ウインドウの位置やサイズの変更方法は プラットフォームごとに複数あります 配置には 前面や背面など ほかのウインドウを基準とする相対配置や visionOSのutilityPanel のようにユーザーを基準とする 相対配置があります 後者ではウインドウを ユーザーの近くの 通常は直接タッチできる範囲に配置します または macOSでの右上エリアのように 画面に対する相対配置もあります visionOSでゲームコントロールを プレイヤーの近くに表示するには defaultWindowPlacement モディファイアをcontrollerの WindowGroupに適用します ここから .utilityPanelの 位置を指定して WindowPlacementを返します この戻り値をif条件でラップし この配置が visionOSの場合のみ 適用されるようにします これで ウインドウの初回起動時に コントロールが近くに表示されます また プレイヤーは必要に応じて ウインドウを初期位置から動かせます この新しいコントロールでは まったく新しいやり方で ロボットを操作できます 例えば このボタンをタップすると BOT-anistが手を振ります visionOSでのコントローラウインドウ のデザインを改善できました 次はmacOSで このウインドウの位置を 手動で計算します defaultWindowPlacement モディファイアはcontextを提供します プラットフォームによって ここに含まれる情報は異なります macOSでは contextに格納されるのは デフォルトのディスプレイに関する情報です これにアクセスして .visibleRectを取得します これは コンテンツを 問題なく配置できる場所を表します sizeThatFitsメソッドを使用して ウインドウのコンテンツに基づき 必要となるサイズを参照します displayBounds変数と size変数を使用して ディスプレイの下端の少し上の 水平方向の中央に表示されるよう 位置を計算します これで 算出した位置とサイズを WindowPlacementとして返せます macOSでも コントロールが適切に 配置されるようになりました プレイヤーはプレイ中に ウインドウの位置を自由に変更でき 別の画面に配置することもできます 優れたウインドウ配置にできました コンテンツが常に 最適に表示されるようにするために ウインドウサイズの変更方法にも 変更を加えましょう ウインドウには システムにより決定される 初期サイズがあります このデフォルトのサイズは いくつかの方法で変更できます 画面のサイズやほかのウインドウに応じて サイズが決まる場合は defaultWindowPlacement APIを使用して初期サイズを指定できます これは macOSの コントローラウインドウの場合と同様です defaultSizeモディファイアで 初期サイズを変更する方法もあります なお このデフォルトのサイズは サイズが 別途制約されている場合は使用されません WindowPlacement APIで サイズが指定されている場合や シーンが復元された場合です 先ほど追加した動画ウインドウのように プッシュされるウインドウの場合 defaultSizeは 元のウインドウのサイズと同じになります この例では 元のウインドウは ロボットエディタです このデフォルトサイズに問題はありませんが プレイヤーは動画ウインドウのサイズを 変更したいかもしれません 動画が常に適切に表示されるように 一定の制限を設けましょう movieのWindowGroupの .windowResizabilityに .contentSizeを指定すると そのウインドウは 含まれるコンテンツの 最小/最大サイズに基づく制限を受けます MovieContentViewに minWidthとmaxWidth およびminHeightと maxHeightを追加します これで動画ウインドウのサイズを 最小限にすると正方形になり 拡大も合理的な範囲に 制限されるようになりました 一日中 BOT-anistを見ていられそうです しかし コントロールウインドウに 手を入れる必要があります 非常に大きなサイズに変更可能なので ボリュームの邪魔になります ウインドウのサイズを そこに含まれる コンテンツのサイズに 合うようにするべきです movieの WindowGroupの場合と同様に windowResizability モディファイアを controllerの WindowGroupにも追加します これで コントローラのモードを変更すると コンテンツに合わせて ウインドウのサイズが変わります このウインドウのサイズを プレイヤーは変更できません 各モードのビューのサイズは固定で 最小/最大サイズが 指定されているのではないためです BOT-anistは 本当に良くなりましたね visionOSおよびmacOS向けに 素晴らしい改善をアプリに加えられました みなさんのアプリでも ウインドウと それをサポートするAPIをご活用ください ウインドウと最上位ビューのどちらが アプリに適しているか検討しましょう WindowPlacement APIは 最初のレイアウトを指定する際に有用です コンテンツに合わせてウインドウサイズを 設定し ユーザーによる変更を制限できます ウインドウに関する プラットフォーム固有の機能により ユーザーによるアプリ利用が より快適になります ご視聴ありがとうございましたアプリで ウインドウの機能をぜひご活用ください 
- 
							- 
										
										2:36 - BOT-anist scenes @main struct BOTanistApp: App { var body: some Scene { WindowGroup(id: "editor") { EditorContentView() } WindowGroup(id: "game") { GameContentView() } .windowStyle(.volumetric) } }
- 
										
										3:09 - Creating the movie WindowGroup @main struct BOTanistApp: App { var body: some Scene { WindowGroup(id: "editor") { EditorContentView() } WindowGroup(id: "game") { GameContentView() } .windowStyle(.volumetric) WindowGroup(id: "movie") { MovieContentView() } } }
- 
										
										3:55 - Opening a movie window struct EditorContentView: View { @Environment(\.openWindow) private var openWindow var body: some View { Button("Open Movie", systemImage: "tv") { openWindow(id: "movie") } } }
- 
										
										4:45 - Pushing a movie window struct EditorContentView: View { @Environment(\.pushWindow) private var pushWindow var body: some View { Button("Open Movie", systemImage: "tv") { pushWindow(id: "movie") } } }
- 
										
										5:34 - Toolbar CanvasView() .toolbar { ToolbarItem { Button(...) } ... }
- 
										
										5:40 - Title menu CanvasView() .toolbar { ToolbarTitleMenu { Button(...) } ... }
- 
										
										5:48 - Hiding window controls WindowGroup(id: "movie") { ... } .persistentSystemOverlays(.hidden)
- 
										
										6:28 - Creating the controller window @main struct BOTanistApp: App { var body: some Scene { ... WindowGroup(id: "movie") { MovieContentView() } WindowGroup(id: "controller") { ControllerContentView() } } }
- 
										
										6:34 - Opening the controller window struct GameContentView: View { @Environment(\.openWindow) private var openWindow var body: some View { ... Button("Open Controller", systemImage: "gamecontroller.fill") { openWindow(id: "controller") } } }
- 
										
										7:46 - Positioning the controller window WindowGroup(id: "controller") { ControllerContentView() } .defaultWindowPlacement { content, context in #if os(visionOS) return WindowPlacement(.utilityPanel) #elseif os(macOS) ... #endif }
- 
										
										8:45 - Positioning the controller window continued 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 }
- 
										
										10:12 - Default size @main struct BOTanistApp: App { var body: some Scene { ... WindowGroup(id: "movie") { MovieContentView() } .defaultSize(width: 1166, height: 680) } }
- 
										
										10:49 - Setting resize limits on the movie window @main struct BOTanistApp: App { var body: some Scene { ... WindowGroup(id: "movie") { MovieContentView() .frame( minWidth: 680, maxWidth: 2720, minHeight: 680, maxHeight: 1020 ) } .windowResizability(.contentSize) } }
- 
										
										11:37 - Controller window resizability @main struct BOTanistApp: App { var body: some Scene { ... WindowGroup(id: "controller") { ControllerContentView() } .windowResizability(.contentSize) } }
 
-