WWDC Quick Look 💓 By SwiftGGTeam
Meet SwiftUI spatial layout

Meet SwiftUI spatial layout

元の動画を見る

Highlight

visionOS 26 では SwiftUI のレイアウトシステムが 3 次元へと拡張されました。各 View は width / height / X / Y に加えて depth と Z position も算出されるようになり、これまで 2D で使ってきた VStack、HStack、ZStack、alignment といったツールをそのまま流用できます。


主要内容

Trevor はロボットコレクション app である BOT-anist の開発で、具体的な悩みに直面していました。複数の Model3D を円状に水平配置した回転式のロボット carousel を作りたい。各ロボットに説明カードを添えたいが、カードがモデルに隠れて見えなくなってしまう。ロケットに 45 度の回転をかけたら、HStack 内のカードが回転後のロケットモデルとぶつかってしまう。RealityKit を使えば実現できるとはいえ、シーンやエンティティ、コンポーネントを書く必要があり、SwiftUI の宣言的な書き方からは遠ざかります。多くの場面では、開発者は使い慣れた VStack / HStack / ZStack で 3D View をいくつか並べたいだけなのです。

visionOS 26 のアプローチは、既存の SwiftUI 2D レイアウトシステムをそのまま 3 次元へ拡張することです。各 View は depth と Z 位置を追加で算出し、Model3D は Image のように固定の 3 次元サイズを持ち、RealityView は Color のように柔軟な depth を持ちます。ZStack は子 View の depth を Z 軸方向に組み合わせるようになりました。この統一モデルの上に、Apple は 4 つの新しいツールを追加しています。depth alignment(深度方向の整列)、rotation3DLayout(レイアウトに影響を与える回転)、SpatialContainer と spatialOverlay(同一の 3D 空間に複数 View を重ねる)です。Trevor はこれらを駆使して、BOT-anist の「Automaton Arrangements」を一歩ずつ組み上げていきます。単体のロボット profile から始まり、3 体が前後に並ぶ表彰台、水平に回転する carousel、そして選択リング付きのインタラクションまで。すべてのプロセスで、RealityKit のコードは 1 行も登場しません。


詳細内容

3D View の基礎03:02)。SwiftUI は visionOS 上で、2D のレイアウトルールをそのまま 3 次元に持ち込みます。すべての View が depth を持ちます。Image / Color / Text のような平面 View の depth は 0 で、挙動は iOS と同じです。Model3D は固定の depth を持ち、Image に近い性質です。RealityView、GeometryReader3D、resizable な Model3D は柔軟な depth を持ち、Color に近い性質を持ちます。

// 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)
}

ポイント:

  • Image / Text / Color は visionOS 上では depth が 0 で、iOS と同じ挙動になります。
  • debugBorder3D は Trevor が独自に作成したユーティリティ modifier で、3D frame を可視化するためのものです。実装は session の終盤で紹介されます。
  • HStack は 3D 空間において、子 View の最大 depth に合わせて自身の depth を自動的に決定します。

Window の depth proposal は固定で、はみ出した部分はシステムによってクリップされます。Volume の depth は調整可能です。Volume と Window の使い分けについては HIG の Designing for visionOS を参照してください。

Depth alignment08:11)。Stack や Layout タイプのデフォルトの depth alignment は .back で、子 View は奥側の縁で揃えられます。Trevor が ResizableRobotView と RobotNameCard を VStack に入れたところ、カードがロボットの後ろに隠れてしまいました。VStackVStackLayout に書き換えることで、.depthAlignment(.front) を付けられるようになります。

// 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)
  }
}

ポイント:

  • .depthAlignment modifier を使うには、VStack ではなく VStackLayout() と書く必要があります。この modifier が Layout 型に対して作用するためです。
  • .front を指定すると子 View の手前側の縁で揃えられ、テキストカードがモデルの前面に浮いて見えるようになります。
  • 標準では .back(デフォルト)/ .center / .front の 3 種類が用意されています。

カスタム DepthAlignmentID10:27)。標準の 3 種類では足りない場合、独自の整列ルールを定義できます。Trevor が作りたかったのは「奥行き方向の表彰台」で、1 位が最も手前、2 位が中央、3 位が最も奥に来る配置です。

// 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)
}

ポイント:

  • DepthAlignmentID プロトコルの実装に必要なのは defaultValue メソッド 1 つだけで、その整列線上での View の位置を返します。
  • デフォルト値として context[.front] を使い、子 View が標準では手前側の縁で揃うようにしています。
  • DepthAlignment に static な定数を追加すれば、.front と同じ感覚で .depthPodium を使えるようになります。

その後 HStack で .depthAlignment(.depthPodium) を指定し、中央と奥のロボットに対して .alignmentGuide(.depthPodium) { $0[DepthAlignment.center] }$0[DepthAlignment.back] を呼ぶことで、3 体が前後にずれた表彰台の効果が得られます。

rotation3DLayout vs rotation3DEffect12:00)。rotation3DEffect はあくまでビジュアル上のエフェクトで、レイアウトには影響しません。HStack はロケットが 90 度回転していることを知らないため、隣のカードと衝突してしまいます。一方 rotation3DLayout は View のレイアウトシステム上の frame を更新し、親レイアウトが回転後のジオメトリを認識して、軸に揃った bounding box でぴったり包めるようにします。

// Rotate using any axis or angle
HStackLayout().depthAlignment(.front) {
  RocketDetailsCard()
  Model3D(named: "ToyRocket")
    .rotation3DLayout(.degrees(isRotated ? 45 : 0), axis: .z)
}

ポイント:

  • rotation3DLayout は角度と軸を受け取り、シグネチャは rotation3DEffect とよく似ています。
  • 違いは、レイアウトシステムから見える frame が更新される点です。HStack は回転後のロケットのために自然にスペースを空けてくれます。
  • アニメーション state である isRotated を切り替えると、周囲の View 位置もそれに合わせてスムーズに調整されます。

carousel の実装14:42)。Trevor は古い session 「Compose custom layouts with SwiftUI」で登場した MyRadialLayout をそのまま再利用しました。元々 2D 向けに書かれたものですが、visionOS でもそのまま動作します。垂直方向のリングを水平に寝かせた 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)
    }
  }
}

ポイント:

  • 外側で MyRadialLayout を 90 度回転させると、リング全体が水平に寝て、元々の高さが depth に変わります。
  • 内側では各ロボットを -90 度逆回転させ、ロボット自身は直立した状態を保ちます。
  • VStack { Spacer(); MyRadialLayout { ... } } というパターンで carousel を volume の底面に貼り付けています。2D で使ってきた Spacer のテクニックは 3D でもそのまま通用します。

SpatialContainer と spatialOverlay17:00)。ZStack は Z 軸方向に子 View を積み重ね、depth が累積されていきます。複数の View を同じ 3D 空間に占めさせたい(マトリョーシカのような構造にしたい)場合は、SpatialContainer を使って .topTrailingBack.bottomLeadingFront といった 3D の整列線を指定します。1 つの View に対する重ね合わせには spatialOverlay を使います。重ねた View は対象 View のサイズに自動的にスケールされます。Trevor は carousel に選択状態を加えるとき、後者だけを使っています。

struct RobotCarouselItem: View {
  let robot: Robot
  let isSelected: Bool

  var body: some View {
    ResizableRobotView(asset: robot.model3DAsset)
      .spatialOverlay(alignment: .bottom) {
        if isSelected {
          ResizableSelectionRingModel()
        }
      }
  }
}

ポイント:

  • 選択リングとロボットは同じ 3D 空間を共有しており、リングは .bottom でロボットの足元に揃えられます。
  • リングのサイズはロボットに自動で追従し、サイズを手書きする必要はありません。
  • このような「同じ空間に付加 View を 1 つ重ねる」用途では、SpatialContainer よりも spatialOverlay のほうが軽量で適しています。

まとめ: debugBorder3D18:32)。session 全体で使われていた 3D ボーダーのユーティリティそのものが、新 API の組み合わせで作られています。1 つの spatialOverlay の中に 2 つの ZStack を入れ、外側で前後 2 面の 2D ボーダーを描き、内側で上下 2 辺を描き、最後に rotation3DLayout(.degrees(90), axis: .y) で内側を左右の面に折り返します。この 6 面で完全な 3D の bounding box が組み上がります。


コアな学び

  • やること: 既存の SwiftUI app にある 3D デモ View を、レイアウトを認識できるバージョンへとアップデートする。

    • なぜやる価値があるか: これまで rotation3DEffect で回していたモデルは隣のカードとぶつかったり volume からはみ出したりしがちでしたが、rotation3DLayout に置き換えれば HStack/VStack が自動でスペースを空けてくれて、UI が “貫通” することがなくなります。
    • どこから始めるか: コードベース内の rotation3DEffect を検索し、用途が純粋なビジュアルアニメーション(残す)か、レイアウトに影響させたい(rotation3DLayout に置き換える)かを判断します。
  • やること: visionOS で説明カードを伴う Model3D に .depthAlignment(.front) を付ける。

    • なぜやる価値があるか: depth alignment のデフォルトは .back で、テキストカードがモデルに隠れて読めなくなります。.front に変えれば説明文がモデルの手前に浮かびます。
    • どこから始めるか: VStack / HStackVStackLayout() / HStackLayout() に書き換え、チェーンで .depthAlignment(.front) を呼び出します。
  • やること: visionOS プロジェクトに debugBorder3D modifier を実装してコミットする。

    • なぜやる価値があるか: 3D レイアウトは 2D よりも調整が難しく、各 View が実際にどれだけのボリュームを占めているか目で判断しづらいため、3D ボーダーは最も低コストな可視化ツールになります。
    • どこから始めるか: session の 18:32 の実装をそのまま写し、DEBUG ビルド限定の View extension に置きます。
  • やること: 選択状態のビジュアルを ZStack ではなく spatialOverlay で重ねる。

    • なぜやる価値があるか: ZStack は Z 軸方向に総 depth を広げるため、選択状態のコンテナが厚くなり周囲のレイアウトに影響します。spatialOverlay は同じ 3D 空間を共有し、付加 View はホストのサイズに自動で適合します。
    • どこから始めるか: 選択状態の切り替え箇所で、ZStack 形式の代わりに .spatialOverlay(alignment: .bottom) { if isSelected { RingView() } } を使います。
  • やること: カスタム DepthAlignmentID を実装して「重要なコンテンツをより手前に」見せる。

    • なぜやる価値があるか: 標準の front / center / back の 3 段階だけでは、EC の「主力商品」、SNS の「VIP のアバター」、コレクション app の「Top Pick」など、より細かい階層づけには不足です。
    • どこから始めるか: DepthAlignmentID プロトコルに準拠した struct を定義し、Layout に対して .depthAlignment(.yourGuide) を呼び、重要な View には .alignmentGuide で個別の位置を指定します。

関連 Session

コメント

GitHub Issues · utterances