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 alignment(08:11)。Stack や Layout タイプのデフォルトの depth alignment は .back で、子 View は奥側の縁で揃えられます。Trevor が ResizableRobotView と RobotNameCard を VStack に入れたところ、カードがロボットの後ろに隠れてしまいました。VStack を VStackLayout に書き換えることで、.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)
}
}
ポイント:
.depthAlignmentmodifier を使うには、VStackではなくVStackLayout()と書く必要があります。この modifier が Layout 型に対して作用するためです。.frontを指定すると子 View の手前側の縁で揃えられ、テキストカードがモデルの前面に浮いて見えるようになります。- 標準では
.back(デフォルト)/.center/.frontの 3 種類が用意されています。
カスタム DepthAlignmentID(10: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 rotation3DEffect(12: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 と spatialOverlay(17: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 のほうが軽量で適しています。
まとめ: debugBorder3D(18: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/HStackをVStackLayout()/HStackLayout()に書き換え、チェーンで.depthAlignment(.front)を呼び出します。
- なぜやる価値があるか: depth alignment のデフォルトは
-
やること: visionOS プロジェクトに
debugBorder3Dmodifier を実装してコミットする。- なぜやる価値があるか: 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() } }を使います。
- なぜやる価値があるか: ZStack は Z 軸方向に総 depth を広げるため、選択状態のコンテナが厚くなり周囲のレイアウトに影響します。
-
やること: カスタム
DepthAlignmentIDを実装して「重要なコンテンツをより手前に」見せる。- なぜやる価値があるか: 標準の front / center / back の 3 段階だけでは、EC の「主力商品」、SNS の「VIP のアバター」、コレクション app の「Top Pick」など、より細かい階層づけには不足です。
- どこから始めるか:
DepthAlignmentIDプロトコルに準拠した struct を定義し、Layout に対して.depthAlignment(.yourGuide)を呼び、重要な View には.alignmentGuideで個別の位置を指定します。
関連 Session
- Better together: SwiftUI and RealityKit — SwiftUI の 3D View と RealityKit のエンティティをどう連携させるかを解説。Maks と Amanda が BOT-anist を題材に混在パターンを示します。
- Build a SwiftUI app with the new design — SwiftUI における Liquid Glass の使い方。spatial layout と組み合わせれば新しい視覚的階層を作り出せます。
- Build a UIKit app with the new design — 同じ新デザイン言語を UIKit 側へ移行する際のパス。
- Explore prompt design & safety for on-device foundation models — オンデバイス Foundation Models のプロンプト設計と安全性に関するプラクティス。
コメント
GitHub Issues · utterances