
-
SwiftUIの空間レイアウトの紹介
SwiftUIを使用して空間体験を構築するための新しいツールを紹介します。visionOSにおける3D SwiftUIビューの基本、奥行き/深度のアライメントを使って既存のレイアウトをカスタマイズする方法、モディファイアを使用して空間内にビューを配置したり、回転したりする方法を学びましょう。空間コンテナを使って、同じ3D空間内に複数のビューを整列させ、イマーシブで魅力的なアプリを作成する方法についても解説します。
関連する章
リソース
- Canyon Crosser: Building a volumetric hike-planning app
- Human Interface Guidelines: Designing for visionOS
関連ビデオ
WWDC25
WWDC22
WWDC19
-
このビデオを検索
「SwiftUIの空間レイアウトについて」へ ようこそ 私はSwiftUIチームのエンジニア Trevorです このセッションでは SwiftUIを使って 楽しい空間体験を構築するための 手法をご紹介します
私はBOT-anistという お気に入りのアプリを拡張して SwiftUIの新しい空間レイアウト機能を 実現しました
このアプリでは 様々な構成要素 色 素材を使って 楽しいロボットを カスタマイズできます 新しく作成したロボットを使って 自分の仮想庭園を手入れできます この小さなロボットを作るのが大好きです 最近 私の作品をカタログ化するための 新しいビューの開発に 取り組んでいます
ロボットをカスタマイズできるだけでなく 保存して様々なロボットを 収集できるのです
ロボットをブラウズするための 新しい3Dシーンをお見せします これらの体験はすべて SwiftUIを使って作りました
visionOSで3D体験を構築していれば RealityKitを 使ったかもしれません
RealityKitは 3Dアプリを構築するための 素晴らしいフレームワークで 特に 物理シミュレーションのような 複雑な動作を得意とします
SwiftUIを使用してきた人なら 使い慣れた宣言型構文を使っても ビルドできます また RealityKitの機能をアプリの 一部でのみ使う場合もあるでしょう visionOS 26では SwiftUIの既存の2Dレイアウトツールや アイデアを使って 3Dアプリを構築できます
SwiftUIレイアウトを使えば アニメーション サイズ変更 状態管理のサポートが 組み込まれているため カルーセルからロボットを 削除すると SwiftUIによって他のすべての ロボットの位置やサイズが空間のサイズに 対応してアニメーション化されます
ボリュームのサイズを変更すると カルーセルと 中のロボットのサイズが 自動的に変更されます ロボットレイアウトの構築に使用した 新しいツールについて説明する前に
まず SwiftUIレイアウトシステムの この3D拡張機能は 既存の2Dレイアウト概念に基づいています
初めてSwiftUIレイアウトを扱う方は 「Building custom views with SwiftUI」と 「Compose custom layouts with SwiftUI」 を先にご確認ください このビデオでは visionOSにおける 3D SwiftUIビューの基本 奥行きアライメントを使って既存の レイアウトをカスタマイズする方法 レイアウトシステム内でビューを回転させる 新しいモディファイアであるrotation3DLayout 同じ3D空間内に複数のビューを 整列させる機能である SpatialContainerとspatialOverlayについて 説明します
ビューとレイアウトシステムについて 説明しましょう
SwiftUIはアプリのビューごとに 幅 高さ X位置とY位置を計算します
サイズを変更できない画像などの 一部のビューにはアセットのサイズと一致する 固定フレームが使用されます
このColorのような一部のビューには 親から提供された すべての空間を占有する 柔軟なフレームが使用されます
レイアウトによって子が 最終フレームに編成されます 黄色で示されているVStackのフレームは 利用可能な空間と 含まれる子により決まります この例では 高さは中にある2つの 画像ビューの合計になっています visionOSも動作は同じですが ビューが3Dである点が異なります レイアウトシステムの動作は同じでありながら 2次元ではなく 3次元に 応用されているわけです
つまり 各ビューの 幅と高さに加えて SwiftUIでは奥行きとZ位置も 計算されます iOSで2Dフレームを視覚化するのに borderモディファイアをよく使いますが
visionOSでは 3Dフレームを 視覚化するために独自の debugBorder3Dモディファイアを作成しました このモディファイアの作成方法については このセッションで習う APIを使ってこのビデオの最後に 説明します
debugBorder3Dでも Model3Dの動作が 画像と同じであることがわかりますが 2次元ではなく3次元であり 幅 高さ 奥行きが 固定されています ビューはすべて3Dですが 奥行きがゼロになる場合もあります
画像 色 テキストなど 平面体験の構築に使用する ビューの多くは 奥行きがゼロとなるため 動作がiOSと同じになります
Colorがデフォルトで提案された 利用可能なすべての幅や高さを 占有するように 一部のビューには 柔軟な奥行きが使われます visionOSでは RealityViewのような特定のビューが デフォルトで利用可能な すべての奥行きを占有します
GeometryReader3Dには 同様の柔軟なサイズ調整動作があり
サイズ変更可能なモディファイアが 適用されたModel3Dと同様に動作します これにより ロボットが ウィンドウの幅に合わせて 引き伸ばされています このアスペクト比では 顔が少し長く見えるので 利用可能な空間に合わせて 拡大縮小しつつ 元の比率に 戻したいと思います
resizable()に加えて 新しいscaledToFit3D モディファイアを使うことで モデルのアスペクト比を 維持しながら 利用可能な幅 高さ 奥行きに合わせて ロボットのサイズを拡大または縮小できます
では 利用可能な奥行きは どう決まるのでしょうか ウィンドウのコンテンツは 幅や高さと同じように ルート奥行きの提案を受け取ります 幅や高さとは異なり サイズ変更可能ですが 奥行きの提案はウィンドウで固定されています この範囲を超えるとコンテンツが 一部表示されない場合があります
同様に ボリュームでもコンテンツの幅 高さ 奥行きが提案されますが 深さもサイズ変更可能です ヒューマンインターフェイスガイドラインの 「Designing for visionOS」で ボリュームまたはウィンドウの 使い分けについてご確認ください
一部のビューでは 含まれているビューの 奥行きの提案内容を変更できます VStackでサブビューの高さが 構成されるように ZStackでは奥行きが構成されます このZStackの奥行きは 前後に並ぶ両方のロボットが 納まるために必要な奥行きです
VStackが利用可能な空間 子の数 子のタイプといった 要素に基づいて サブビューに様々な高さを 提案するのと同様に ZStackも同じ要素に基づいて 子に様々な奥行きを 提案する場合があります ここでは RealityViewがZStackで ロボットを前方に押し出し シーンで利用可能な奥行きを すべて使います
既存のレイアウトタイプとスタックは visionOSの3Dであるため 理にかなったデフォルト動作が 奥行きにいくつか適用されます この例では HStackが親からの 奥行きの提案を受け 2つのモデルをぴったりと 中に納めるために 独自の奥行きを確立します
HStackでは これら2つのロボットの 背面がデフォルトで並べられます
この概念を奥行きアライメントと呼びます 奥行きアライメントは 3Dビューと奥行きに効果的に対応するため 既存のSwiftUIレイアウトタイプを カスタマイズできる新しいツールです 垂直方向や水平方向のアラインメントに 取り組んだことがあれば なじみがある概念でしょう それぞれの名前と説明とともに お気に入りのロボットを表示できる新しい ボリュメトリックウインドウを作成します 再利用しやすくするため まずロボットのModel3Dコードを 更新しましょう
納まるように拡大縮小できる Model3Dから始めます
ロボットのモデルをプリロードできる 新しいModel3DAssetタイプを 使用するよう リファクタリングします アプリ全体で使用できる 新しいResizableRobotViewに これらすべてを含めます ここではdebugBorder3Dも削除しておきます
VStackを使用してResizableRobotViewと ロボットの詳細が記載された RobotNameCardが含まれる RobotProfileを作成します
ここで問題があります
このカードはVStackの後ろに 配置されているため 読みにくく ロボットモデルの影に 少し隠れてしまっています
HStackでコンテンツを 中央 上 下端に配置する 設定ができるように visionOSでビューの奥行きでの 配置方法を設定するとよいでしょう
デフォルトでは スタックとレイアウトタイプは 背面の奥行きアライメントを使用しています visionOS 26では任意のレイアウトタイプの DepthAlignmentを カスタマイズできます
VStackLayoutを使用するように RobotProfileを更新して
depthAlignmentモディファイアを 適用できる状態にし .frontアライメントを指定します
.centerや.backのガイドを 使うこともできますが
ロボットカードを読みやすくするには .frontが適切だと思います
これでZapper Ironheartという名前と このロボットに関する 詳しい知識がしっかりと 頭に入りました
前面 背面 中央といった 標準の構成が必要な場合 前面 背面 中央の奥行きアライメントを 使うと便利ですが より複雑な動作が必要な場合は どうすればよいでしょうか
HStackで3つのロボットプロファイル ビューを設定し お気に入りの3体のロボットを表示する ボリュームを作成しています Greg-gear Mendelが一番のお気に入りなので このビューでほかの2体よりも 少し目立つように 設定したいと思います
depthPodiumを使って お気に入りのロボットほど 近くに配置する方法を 使おうと思っています ロボット1 2 3の順に 近くに配置していきます
上から奥行きを見た場合 最初のロボットの背面が 2番目のロボットの中央 そして3番目のロボットの前面と 揃っている状態です これにはカスタム奥行きアライメントが 必要になります
まず DepthAlignmentIDプロトコルに 準拠する新しい構造体を定義します
このアライメントの デフォルト値として 1つの要件を実装します
DepthPodiumAlignmentのデフォルトとして 前面のアライメントガイドを使用します
次に 新しいDepthAlignmentIDを使う 静的な定数を 奥行きアライメントに定義します
これで各ロボットを含むHStackの 奥行きアライメントとして このdepthPodiumアライメントガイドを 使用できるようになりました
これにより このガイドで指定した デフォルト値によって すべてのロボットの前面が 揃えられます
次に 最後のロボットのdepthPodium アライメントガイドの 奥行きが ガイドの中央になるよう カスタマイズします
中央のロボットを変更させ 背面が depthPodiumガイドに揃うようにします
先頭のロボットは引き続き このアライメントのデフォルトである 前面のガイドを使用します
シミュレータで見てみましょう
奥行きを使ってずらして配置したことで Greg-gear Mendelが一番のお気に入りだと 一目でわかるようになりました
奥行きアライメントは 既存のレイアウト内で 奥行きの位置を微調整する場合に 最適です では さらに奥行きを重視した構築は どうすればよいでしょうか より高度な3Dユースケースに最適な ツールが回転レイアウトです ビューに視覚効果を適用し 特定の軸を 中心にビューを回転させる 既存のrotation3DEffectモディファイアは おなじみかもしれません
基本的な回転操作に最適な モディファイアです
しかし HStackでモデルに 説明カードを付けて配置し Z軸に沿ってロケットを 90度回転させると ロケットがカードにぶつかって ボリュームから突き出てしまいます
デバッグワイヤーフレームを回転効果の 前後に適用すると 何が起こっているのかを 理解しやすくなります 実線の赤色のワイヤーフレームは 効果によって回転していますが レイアウトシステムがロケットの ジオメトリの位置として認識しているのは 破線の青色のワイヤーフレーム部分です HStackでは青色のフレームを基準にサイズが 設定され コンテンツが配置されるため 回転と連動しません これは視覚効果がレイアウトに 影響を与えないためです つまり HStackでは rotation3DEffectを使用した際のロケットの 回転ジオメトリが認識されていません
これは scaleEffectやオフセットを含む すべての視覚効果に
当てはまります
いずれの場合も レイアウトシステムでは モディファイアを基にビューのサイズや配置が 調整されることはありません これは周囲のフレームに 影響を与えることなく 1つのビューをアニメーション化する場合は 最適ですが
影響を与えたい場合 回転ロケットをどう修正できるでしょうか
朗報です visionOS 26では レイアウトシステム内の回転ビューの フレームを変更する 新しいrotation3DLayoutモディファイアが 導入されます ロケットモデルに適用するとHStackで サイズと配置が調整され ロケットと情報カードに十分なスペースが 割り当てられます
rotation3DLayoutでは任意の角度と 軸での回転がサポートされるため 宇宙に向けて飛び立っているかのように ロケットを 45度回転させることができます
rotation3DLayoutモディファイアの使用前後に デバッグワイヤーフレームを適用してみます 回転したロケットのフレームが 赤色で示されており 修正後のビューのフレームが 青色のワイヤーフレームで 示されています 青色のバウンディングボックスは 親と整合した軸であり 回転した赤色のフレーム内に ぴったりと納まっています
では rotation3DLayoutを使って ビデオの冒頭でお見せした ロボットのカルーセルを構築する方法を 見ていきましょう
「Compose custom layouts with SwiftUI」の RadialLayoutを使用します
このカスタムレイアウトタイプでは ビューが円の中に配置されます 円周は利用可能な幅と 高さによって定義されます
MyRadialLayoutはiOSに2Dビューを 配置するために記述されたものですが
visionOSでもうまく機能します
ペットの2D画像ではなくロボットの 3Dモデルを配置する場合でも
ForEachを使って各ロボットの サイズ変更可能なModel3Dを カスタムレイアウト内に配置できます
これでも問題ありませんが 垂直方向に並んでいます 私が目指すのはロボットをボリューム内で 水平方向に並べることです
rotation3DLayoutを 放射状レイアウトに適用し X軸に沿ってビューを 90度回転させます 先ほどはカルーセルの高さだった要素で レイアウトシステム内の回転ビューの奥行きを 定義できます カルーセルは正しい 向きになりましたが ロボットは横たわっています
X軸に沿って-90度回転させる2番目の rotation3DEffectを使えば ForEach内で各ロボットを逆回転させて 立たせることができます 横たわっていたドロイドたちが 立ち上がりました 最後に1点だけ修正します カルーセルはボリュームの高さに対し 中央揃えになっていますが カルーセルをボリュームのベースプレートと 同じ高さにしたいと思います
debugBorder3Dを カルーセル全体に適用すると わかりやすくなります
2Dレイアウトの場合と同じ戦略を 使用できます 上にスペーサを置いてVStack内で カルーセルを押し下げます ロボットをボリュームの下側に配置でき いい感じです 3Dレイアウトユーティリティベルトの もう1つのツールのペアを紹介しましょう それがSpatialContainerと spatialOverlayです ロボットのカルーセルにもう一つ 追加したい機能があります タップするとロボットを選択でき コントロールメニューと モデル下部にある リングが表示されて 選択されていることがわかります このリングもModel3Dとして 表されるため リングをロボットと同じ 3D空間に入れ込み 軸に沿って積み重ならないように 設定します モデルを同じ3D空間に配置する 新しいツールが必要です
新しいSpatialContainer APIでは 入れ子人形のように 複数のビューを 同じ3D空間内に配置できます
すべてのビューに3Dアライメントを 適用できます これがすべての子を.bottomFront アライメントガイドに従って並べた場合
これが.topTrailingBackガイド に従って並べた場合です
spatialOverlayも同様のツールで 別のビューと同じ3D空間にある 1つのビューを オーバーレイできます
SpatialContainerと同様に 3Dアライメントがサポートされています
並べるビューはロボットと 選択リングの2つだけです 私にとってロボットの ジオメトリが最も重要です ロボットのサイズに合わせて リングのサイズを変更でき 満足です ここでspatialOverlayを使用して 選択したロボットのビジュアルを実装します
spatialOverlayモディファイアを ロボットモデルに追加します 選択済みとしてマークされたら サイズ変更可能なリングビューを コンテンツとして配置します .bottomアライメントを使って リングの底とロボットの底を 揃えます
ロボットのカルーセルが いい感じになりました 既存の構成可能なSwiftUI APIを フル活用すればさらに簡単に改善できます
debugBorder3Dモディファイアを実装して 全体の学習内容を 振り返ってみましょう
これは先ほどModel3Dに適用して見せた モディファイアです
Viewのextensionとして debugBorder3Dメソッドを定義します 変更したコンテンツに spatialOverlayを適用することで 適用先のビューと同じ 3D空間に 境界線をレンダリングします
2D境界線 スペーサ 別の2D境界線が含まれる ZStackを組み込みます
次に ZStack全体にrotation3DLayoutを 適用することで 先頭と末尾のビューに 境界線を配置します
最後に この内部ZStackを 背面と前面に2D境界線がある 別のZStack内に 配置します これですべての端に境界線が付きました
新しい3D APIを使って既存の 2D SwiftUIモディファイアを構成し まったく新しいものを作成できる点が 気に入っています
2Dコンテキストで慣れ親しんでいる 多数のレイアウトツールや モディファイアに対応した 3Dアナログをご用意しています これらのAPIについては ドキュメントをご覧ください
SwiftUIは 3Dアプリを構築するための 素晴らしいツールですが 依然として同じアプリ内で RealityKitとSwiftUIを 組み合わせて使う必要がある ユースケースも多く存在します
SwiftUIのコンテンツが3Dになったことで RealityKitコードを扱う必要がある 場合もあります 友人のMaksとAmandaが両方の フレームワークを一緒に使用する BOTanistの優れた追加機能を 構築しました 詳しくは「Better Together: SwiftUI and RealityKit」をご覧ください 皆さんの3Dアプリを見るのが 楽しみで仕方ありません
-
-
3:02 - Robot Image Frame
// Some views have fixed frames Image("RobotHead") .border(.red)
-
3:05 - Color Frame
// Some views have flexible frames Color.blue .border(.red)
-
3:15 - Layout Composed Frame
// Layouts compose the frames of their children VStack { Image("RobotHead") .border(.red) Image("RobotHead") .border(.red) } .border(.yellow)
-
4:00 - Model3D Frame
// Some views have fixed depth Model3D(named: "Robot") .debugBorder3D(.red)
-
4:25 - Zero Depth Views
// Many views have 0 depth HStack { Image("RobotHead") .debugBorder3D(.red) Text("Hello! I'm a piece of text. I have 0 depth.") .debugBorder3D(.red) Color.blue .debugBorder3D(.red) .frame(width: 200, height: 200) }
-
4:41 - RealityView Depth
// RealityView takes up all available space including depth RealityView { content in // Setup RealityView content } .debugBorder3D(.red)
-
4:56 - GeometryReader3D Depth
// GeometryReader3D uses all available depth GeometryReader3D { proxy in // GeometryReader3D content } .debugBorder3D(.red)
-
5:01 - Model3D scaledToFit3D
// Scaling a Model3D to fit available space Model3D(url: robotURL) {aresolved in resolved.resizable() }aplaceholder: { ProgressView() } .scaledToFit3D() .debugBorder3D(.red)
-
6:15 - ZStack depth
// ZStack composes subview depths ZStack { Model3D(named: "LargeRobot") .debugBorder3D(.red) Model3D(named: "BabyBot") .debugBorder3D(.red) } .debugBorder3D(.yellow)
-
6:33 - ZStack with RealityView
// ZStack composes subview depths ZStack { RealityView { ... } .debugBorder3D(.red) Model3D(named: "BabyBot") .debugBorder3D(.red) } .debugBorder3D(.yellow)
-
6:57 - Layouts are 3D
// HStack also composes subview depths HStack { Model3D(named: "LargeRobot") .debugBorder3D(.red) Model3D(named: "BabyBot") .debugBorder3D(.red) } .debugBorder3D(.yellow)
-
7:50 - ResizableRobotView
struct ResizableRobotView: View { let asset: Model3DAsset var body: some View { Model3D(asset: asset) { resolved in resolved .resizable() } .scaledToFit3D() } }
-
8:11 - Robot Profile 1
//`Layout` types back align views by default struct RobotProfile: View { let robot: Robot var body: some View { VStack { ResizableRobotView(asset: robot.model3DAsset) RobotNameCard(robot: robot) } .frame(width: 300) } }
-
8:38 - Customizing Vertical Alignment
// Customizing vertical alignment HStack(alignment: .bottom) { Image("RobotHead") .border(.red) Color.blue .frame(width: 100, height: 100) .border(.red) } .border(.yellow)
-
8:52 - Customizing Depth Alignment
// Customizing depth alignments struct RobotProfile: View { let robot: Robot var body: some View { VStackLayout().depthAlignment(.front) { ResizableRobotView(asset: robot.model3DAsset) RobotNameCard(robot: robot) } .frame(width: 300) } }
-
9:45 - Robot Favorite Row
struct FavoriteRobotsRow: View { let robots: [Robot] var body: some View { HStack { RobotProfile(robot: robots[2]) RobotProfile(robot: robots[0]) RobotProfile(robot: robots[1]) } } }
-
10:27 - Custom Depth Alignment ID
// Defining a custom depth alignment guide struct DepthPodiumAlignment: DepthAlignmentID { static func defaultValue(in context: ViewDimensions3D) -> CGFloat { context[.front] } } extension DepthAlignment { static let depthPodium = DepthAlignment(DepthPodiumAlignment.self) }
-
10:51 - Customizing Depth Alignment Guides
// Views can customize their alignment guides struct FavoritesRow: View { let robots: [Robot] var body: some View { HStackLayout().depthAlignment(.depthPodium) { RobotProfile(robot: robots[2]) RobotProfile(robot: robots[0]) .alignmentGuide(.depthPodium) { $0[DepthAlignment.back] } RobotProfile(robot: robots[1]) .alignmentGuide(.depthPodium) { $0[DepthAlignment.center] } } } }
-
12:00 - Rotation3DEffect
// Rotate views using visual effects Model3D(named: "ToyRocket") .rotation3DEffect(.degrees(45), axis: .z)
-
12:10 - Rotation3DLayout
// Rotate using any axis or angle HStackLayout().depthAlignment(.front) { RocketDetailsCard() Model3D(named: "ToyRocket") .rotation3DLayout(.degrees(isRotated ? 45 : 0), axis: .z) }
-
14:42 - Pet Radial Layout
// Custom radial Layout struct PetRadialLayout: View { let pets: [Pet] var body: some View { MyRadialLayout { ForEach(pets) { pet in PetImage(pet: pet) } } } }
-
14:56 - Rotated Robot Carousel
struct RobotCarousel: View { let robots: [Robot] var body: some View { VStack { Spacer() MyRadialLayout { ForEach(robots) { robot in ResizableRobotView(asset: robot.model3DAsset) .rotation3DLayout(.degrees(-90), axis: .x) } } .rotation3DLayout(.degrees(90), axis: .x) } }
-
17:00 - Spatial Container
// Aligning views in 3D space SpatialContainer(alignment: .topTrailingBack) { LargeBox() MediumBox() SmallBox() }
-
17:35 - Spatial Overlay
// Aligning overlayed content LargeBox() .spatialOverlay(alignment: .bottomLeadingFront) { SmallBox() }
-
17:47 - Selection Ring Spatial Overlay
struct RobotCarouselItem: View { let robot: Robot let isSelected: Bool var body: some View { ResizableRobotView(asset: robot.model3DAsset) .spatialOverlay(alignment; .bottom) { if isSelected { ResizableSelectionRingModel() } } }
-
18:32 - DebugBorder3D
extension View { func debugBorder3D(_ color: Color) -> some View { spatialOverlay { ZStack { Color.clear.border(color, width: 4) ZStack { Color.clear.border(color, width: 4) Spacer() Color.clear.border(color, width: 4) } .rotation3DLayout(.degrees(90), axis: .y) Color.clear.border(color, width: 4) } } }
-