WWDC Quick Look đź’“ By SwiftGGTeam
Meet SwiftUI spatial layout

Meet SwiftUI spatial layout

Watch original video

Highlight

visionOS 26 extends the SwiftUI layout system into three dimensions. Each view now computes a depth and a Z position alongside width, height, X, and Y. The existing 2D tools — VStack, HStack, ZStack, alignment — work the same as before.


Core content

Trevor ran into a concrete problem in BOT-anist, a robot collection app. He wanted a rotating robot carousel that arranged several Model3D views in a horizontal ring. He wanted to attach a description card to each robot, but the card kept getting hidden behind the model. He wanted to rotate a rocket by 45 degrees, but the rotated model bumped into the cards in the HStack. RealityKit can do all of this, but it forces you to write scenes, entities, and components — far from the declarative style of SwiftUI. In many cases the developer just wants to arrange a few 3D views with the familiar VStack / HStack / ZStack.

The visionOS 26 answer is direct: extend the existing 2D SwiftUI layout system into 3D. Every view now carries a depth and a Z position. Model3D has a fixed three-dimensional size, like Image. RealityView has flexible depth, like Color. ZStack now stacks the depth of its children along the Z axis. On top of this unified model, Apple adds four new tools: depth alignment, rotation3DLayout (a rotation that affects layout), SpatialContainer, and spatialOverlay (stacking views in the same 3D space). Trevor uses them to build BOT-anist’s “Automaton Arrangements” step by step — from a single robot profile, to a three-robot depth podium, to a horizontal rotating carousel, to an interactive selection ring. Not a line of RealityKit code in the whole walkthrough.


Detailed content

3D view basics (03:02). On visionOS, SwiftUI carries the 2D layout rules into 3D. Every view has a depth. Flat views like Image, Color, and Text have depth 0 and behave the same as on iOS. Model3D has a fixed depth, like Image. RealityView, GeometryReader3D, and a resizable Model3D have flexible depth, like 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)
}

Key points:

  • Image / Text / Color have depth 0 on visionOS, the same as on iOS.
  • debugBorder3D is a small utility modifier Trevor wrote to visualize 3D frames; he shows the implementation at the end of the session.
  • An HStack in 3D automatically takes the maximum depth of its children as its own depth.

A Window has a fixed depth proposal — anything that exceeds it gets clipped by the system. A Volume has adjustable depth. For when to use a Window versus a Volume, see the HIG’s Designing for visionOS.

Depth alignment (08:11). The default depth alignment for Stack and Layout types is .back, so children align to the back edge. Trevor puts ResizableRobotView and RobotNameCard in a VStack, and the card hides behind the robot. Switching VStack to VStackLayout lets him add .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)
  }
}

Key points:

  • To use the .depthAlignment modifier, write VStackLayout() instead of VStack, because the modifier applies to Layout types.
  • .front aligns children to the front edge, so the text card floats in front of the model.
  • The three standard alignments are .back (default), .center, and .front.

Custom DepthAlignmentID (10:27). When the three standard alignments are not enough, you can define your own. Trevor wants a “depth podium” — first place at the front, second in the middle, third at the back.

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

Key points:

  • Conforming to DepthAlignmentID only requires a defaultValue method that returns the view’s position on the alignment guide.
  • The default value uses context[.front], so children align to the front edge by default.
  • Adding a static constant on DepthAlignment lets you use .depthPodium just like .front.

Then, inside an HStack, apply .depthAlignment(.depthPodium), and override the middle and back robots with .alignmentGuide(.depthPodium) { $0[DepthAlignment.center] } and $0[DepthAlignment.back]. The result is three robots staggered along Z to form a podium.

rotation3DLayout vs rotation3DEffect (12:00). rotation3DEffect is a visual effect — it does not affect layout. The HStack does not know the rocket has been rotated 90 degrees, so it collides with the next card. rotation3DLayout updates the view’s frame in the layout system. The parent layout sees the rotated geometry and wraps it in a tight axis-aligned bounding box.

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

Key points:

  • rotation3DLayout takes an angle and an axis, with a signature similar to rotation3DEffect.
  • The difference: it updates the frame the layout system sees, so the HStack makes room for the rotated rocket.
  • When the isRotated state animates, the surrounding views slide to follow.

Building the carousel (14:42). Trevor reuses MyRadialLayout from the older session Compose custom layouts with SwiftUI. It was written for 2D, but it works as-is on visionOS. To turn the upright ring into a horizontal carousel lying flat:

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

Key points:

  • The outer rotation tilts MyRadialLayout 90 degrees, so the ring lies flat and its old height becomes depth.
  • The inner rotation tilts each robot back by -90 degrees so they stand upright again.
  • VStack { Spacer(); MyRadialLayout { ... } } pins the carousel to the bottom of the volume — the same Spacer trick from 2D works in 3D.

SpatialContainer and spatialOverlay (17:00). ZStack stacks children along Z, and depths add up. To put several views in the same 3D space (like nesting dolls), use SpatialContainer with a 3D alignment guide such as .topTrailingBack or .bottomLeadingFront. For overlaying a single view on another, use spatialOverlay; the overlaid view scales to match the target’s size. Trevor uses the latter to add a selection state to the carousel:

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

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

Key points:

  • The selection ring and the robot share one 3D space; the ring aligns to .bottom, at the robot’s feet.
  • The ring’s size follows the robot automatically — no manual sizing.
  • For “drop one extra view into the same space,” spatialOverlay is lighter than SpatialContainer.

Wrap-up: debugBorder3D (18:32). The 3D border tool used throughout the session is itself a composition of the new APIs. One spatialOverlay holds two ZStacks. The outer ZStack draws the front and back 2D borders. The inner ZStack draws the top and bottom edges, and rotation3DLayout(.degrees(90), axis: .y) turns it sideways for the left and right faces. Six faces make a full 3D bounding box.


Key takeaways

  • What to do: upgrade the 3D demo views in your existing SwiftUI app to layout-aware versions.

    • Why it matters: rotated models built with rotation3DEffect often collide with neighboring cards or spill out of the volume. Replacing them with rotation3DLayout lets HStack/VStack make room automatically, and the UI stops clipping through itself.
    • How to start: search the codebase for rotation3DEffect, then decide case by case — keep it for pure visual animation, or replace it with rotation3DLayout when layout needs to react.
  • What to do: add .depthAlignment(.front) to any Model3D paired with a description card on visionOS.

    • Why it matters: the default depth alignment is .back, so the card hides behind the model and the user can’t read it. With .front, the description floats in front of the model.
    • How to start: rewrite VStack / HStack as VStackLayout() / HStackLayout(), then chain .depthAlignment(.front).
  • What to do: implement and commit a debugBorder3D modifier in your visionOS project.

    • Why it matters: 3D layout is harder to debug than 2D. The eye can’t easily judge how much volume each view occupies, and a 3D border is the cheapest visualization tool.
    • How to start: copy the implementation from the session at 18:32 into a DEBUG-only View extension.
  • What to do: build selection visuals with spatialOverlay instead of ZStack.

    • Why it matters: ZStack stretches the total depth along Z, so a selected container grows thicker and disturbs nearby layout. spatialOverlay shares one 3D space, and the overlay view sizes itself to the host.
    • How to start: at the selection toggle, replace the ZStack with .spatialOverlay(alignment: .bottom) { if isSelected { RingView() } }.
  • What to do: define a custom DepthAlignmentID to express “important things go further forward.”

    • Why it matters: the three standard tiers — front / center / back — are not enough. A “featured product” in commerce, a “VIP avatar” in social apps, and a “Top Pick” in a collection app all need finer layering.
    • How to start: define a struct that conforms to DepthAlignmentID, call .depthAlignment(.yourGuide) on the Layout, and use .alignmentGuide on key views to set their offsets.

Comments

GitHub Issues · utterances