WWDC Quick Look đź’“ By SwiftGGTeam
Dive deep into volumes and immersive spaces

Dive deep into volumes and immersive spaces

Watch original video

Highlight

visionOS 2 adds automatic baseplates, corner resize handles, toolbars, and custom ornaments to volumes, plus enhanced immersion control and spatial tracking for immersive spaces.


Core Content

visionOS has three scene types: Window, Volume, and Immersive Space. This session focuses on Volume and Immersive Space, especially visionOS 2 enhancements.

Volumes improved noticeably in visionOS 2. When you look at a volume, a baseplate automatically appears at the bottom, helping users perceive its boundaries. If content already fills the volume or you draw your own bottom surface, hide the baseplate with .volumeBaseplateVisibility(.hidden).

Another important change: volumes now have corner resize handles. By default, min/max sizes inherit from the content view’s frame. Fixed frame sizes prevent resizing—set min/max values instead and drag handles to scale smoothly. You can also drive volume size changes dynamically via state variables in code.

Toolbars can float in ornaments at the bottom of a volume. In visionOS 2, toolbars automatically move to the nearest side based on where the user stands; window controls do too. Beyond toolbars, visionOS 2 lets volumes add custom ornaments anywhere around the volume, auto-scaling with distance for readability.

As users move around a volume, robots and content should respond. Each side of a volume is a viewpoint; the system moves window controls and ornaments to the side nearest the user. Listen for viewpoint changes with onVolumeViewpointChange so content follows. Restrict to specific sides with supportedVolumeViewpoints if needed.

The Immersive Space section adds more control. visionOS 2 supports custom immersion ranges—specify initial, min, and max values for progressive immersion. When users adjust immersion with the Digital Crown, react with onImmersionChange.

The new SpatialTrackingSession API tracks plane anchors. After creating a floor anchor, use SpatialTapGesture to detect taps in the immersive space and place plants where the user taps. Finally, SurroundingsEffect can dynamically tint passthrough—e.g., when a robot collides with a plant, tint the environment with the pot’s color.


Detailed Content

Volume baseplate

visionOS 2 enables baseplates for volumes by default. They fade in when the user looks at the volume, helping perceive boundaries.

// Baseplate

WindowGroup(id: "RobotExploration") {
    ExplorationView()
        .volumeBaseplateVisibility(.visible) // Default!
}
.windowStyle(.volumetric)
  • volumeBaseplateVisibility(.visible) is default—baseplate fades in on gaze
  • If content fills the volume or you custom-draw the bottom, set .hidden to avoid visual conflict
  • Baseplate helps users find corner resize handles

Volume resizing

Volumes now have corner resize handles—set frame min/max correctly.

// Enabling resizability

WindowGroup(id: "RobotExploration") {
    let initialSize = Size3D(width: 900, height: 500, depth: 900)

    ExplorationView()
        .frame(minWidth: initialSize.width, maxWidth: initialSize.width * 2,
               minHeight: initialSize.height, maxHeight: initialSize.height * 2)
        .frame(minDepth: initialSize.depth, maxDepth: initialSize.depth * 2)
}
.windowStyle(.volumetric)
.windowResizability(.contentSize) // Default!
  • Set minWidth/maxWidth, minHeight/maxHeight, minDepth/maxDepth to allow resizing
  • Fixed frame values disable resizing
  • windowResizability(.contentSize) is default—volume size inherits from content

Programmatic volume resize

Drive volume size dynamically via state variables (06:10).

// Programmatic resize

struct ExplorationView: View {
    @State private var levelScale: Double = 1.0

    var body: some View {
        RealityView { content in
            // Level code here
        } update: { content in
            appState.explorationLevel?.setScale(
                [levelScale, levelScale, levelScale], relativeTo: nil)
        }
        .frame(width: levelSize.value.width * levelScale,
               height: levelSize.value.height * levelScale)
        .frame(depth: levelSize.value.depth * levelScale)
        .overlay { Button("Change Size") { levelScale = levelScale == 1.0 ? 2.0 : 1.0 } }
    }
}
  • State variable levelScale controls scale
  • Frame values change with scale; volume auto-resizes
  • RealityKit entities must scale in sync

Toolbar ornament

Toolbars float at the bottom of a volume (07:39).

// Toolbar ornament

ExplorationView()
.toolbar {
		ToolbarItem {
      	Button("Next Size") {
          	levelScale = levelScale == 1.0 ? 2.0 : 1.0
        }
    }
  	ToolbarItemGroup {
      	Button("Replay") {
          	resetExploration()
        }
      	Button("Exit Game") {
          	exitExploration()
          	openWindow(id: "RobotCreation")
        }
    }
}
  • ToolbarItem and ToolbarItemGroup organize buttons
  • Toolbar auto-moves to the nearest side as the user moves
  • Toolbar auto-scales with distance for readability

Custom ornaments

Beyond toolbars, add custom ornaments around volumes (10:41).

// Ornaments

WindowGroup(id: "RobotExploration") {
    ExplorationView()
    .ornament(attachmentAnchor: .scene(.topBack)) {
        ProgressView()
    }
}
.windowStyle(.volumetric)
  • attachmentAnchor: .scene(.topBack) places ornament at top-back of volume
  • Ornaments follow viewpoint changes
  • Ornaments auto-scale with distance

Listening for viewpoint changes

Make content respond as users move around the volume (12:08).

// Volume viewpoint

struct ExplorationView: View {
    var body: some View {
        RealityView { content in
            // Some RealityKit code
        }
        .onVolumeViewpointChange { oldValue, newValue in
            appState.robot?.currentViewpoint = newValue.squareAzimuth
        }
    }
}
  • onVolumeViewpointChange fires when the active viewpoint changes
  • newValue.squareAzimuth returns one of four normalized values: front, left, right, back
  • Use this to make characters face the user

Restricting supported viewpoints

If content only works from certain sides, limit supported viewpoints (13:43).

// Supported viewpoints
struct ExplorationView: View {
  	let supportedViewpoints: Viewpoint3D.SquareAzimuth.Set = [.front, .left, .right]

  	var body: some View {
      	RealityView { content in
        		// Some RealityKit code
        }
      	.supportedVolumeViewpoints(supportedViewpoints)
      	.onVolumeViewpointChange { _, newValue in
        		appState.robot?.currentViewpoint = newValue.squareAzimuth
        }
    }
}
  • supportedVolumeViewpoints accepts a set of supported sides
  • Unsupported sides won’t trigger ornament/window control moves
  • All four sides supported by default

Viewpoint update strategy

Use updateStrategy to control updates for unsupported viewpoints (14:30).

// Viewpoint update strategy

struct ExplorationView: View {
    let supportedViewpoints: Viewpoint3D.SquareAzimuth.Set = [.front, .left, .right]

    var body: some View {
        RealityView { content in
            // Some RealityKit code
        }
        .supportedVolumeViewpoints(supportedViewpoints)
        .onVolumeViewpointChange(updateStrategy: .all) { _, newValue in
            appState.robot?.currentViewpoint = newValue.squareAzimuth
            if !supportedViewpoints.contains(newValue) {
                appState.robot?.animationState.transition(to: .annoyed)
            }
        }
    }
}
  • updateStrategy: .all ensures all viewpoint changes trigger the callback
  • Detect when user is on an unsupported side and trigger hint animation
  • Default only fires when switching between supported viewpoints

Custom immersion range

visionOS 2 supports custom initial values and ranges for progressive immersion (23:54).

// Customizing immersion
struct BotanistApp: App {
    // Custom immersion amounts
    @State private var immersionStyle: ImmersionStyle = .progressive(0.2...1.0, initialAmount: 0.8)

    var body: some Scene {
        // Immersive Space
        ImmersiveSpace(id: "ImmersiveSpace") {
            ImmersiveSpaceExplorationView()
        }
        .immersionStyle(selection: $immersionStyle, in: .mixed, .progressive, .full)
    }
}
  • .progressive(range, initialAmount:) creates custom progressive immersion
  • 0.2...1.0 is min to max immersion range
  • initialAmount: 0.8 sets initial immersion to 80%

Reacting to immersion changes

Listen for Digital Crown immersion adjustments (25:17).

// Reacting to immersion
struct ImmersiveView: View {
    @State var immersionAmount: Double?

    var body: some View {
        ImmersiveSpaceExplorationView()
            .onImmersionChange { context in
                immersionAmount = context.amount
            }
            .onChange(of: immersionAmount) { oldValue, newValue in
                handleImmersionAmountChanged(newValue: newValue, oldValue: oldValue)
            }
    }
}
  • onImmersionChange provides the new immersion value
  • onChange detects immersion changes and triggers responses
  • Compare old and new values to tell if immersion increased or decreased

Handling immersion changes

Make the robot react to immersion changes (25:39).

// Reacting to immersion
func handleImmersionAmountChanged(newValue: Double?, oldValue: Double?) {
    guard let newValue, let oldValue else {
        return
    }

    if newValue > oldValue {
        // Move the robot outward to react to increasing immersion
        moveRobotOutward()
    } else if newValue < oldValue {
        // Move the robot inward to react to decreasing immersion
        moveRobotInward()
    }
}
  • On increasing immersion, robot moves outward
  • On decreasing immersion, robot moves inward
  • This response enhances user perception of immersion changes

Spatial tracking session

Use SpatialTrackingSession to track plane anchors (26:57).

// Create and run spatial tracking session
struct ImmersiveExplorationView {
    @State var spatialTrackingSession: SpatialTrackingSession
        = SpatialTrackingSession()

    var body: some View {
        RealityView { content in
            // ...
        }
        .task {
            await runSpatialTrackingSession()
        }
    }
}
  • SpatialTrackingSession manages spatial tracking
  • .task starts the session when the immersive space opens
  • User authorization required for plane anchor access

Configure and run tracking

Set plane tracking configuration and run the session (27:11).

// Create and run the spatial tracking session
func runSpatialTrackingSession() async {
    // Configure the session for plane anchor tracking
    let configuration =
        SpatialTrackingSession.Configuration(tracking: [.plane])

    // Run the session to request plane anchor transforms
    let _ = await spatialTrackingSession.run(configuration)
}
  • Configuration(tracking: [.plane]) configures plane tracking
  • run(configuration) requests authorization and starts tracking
  • Return value indicates authorization success

Create floor anchor

Create a horizontal floor anchor entity (27:32).

// Create a floor anchor to track
struct ImmersiveExplorationView {
    @State var spatialTrackingSession: SpatialTrackingSession
        = SpatialTrackingSession()

    let floorAnchor = AnchorEntity(
        .plane(.horizontal, classification: .floor, minimumBounds: .init(x: 0.01, y: 0.01))
    )

    var body: some View {
        RealityView { content in
            content.add(floorAnchor)
        }
        .task {
            await runSpatialTrackingSession()
        }
    }
}
  • .plane(.horizontal, classification: .floor, ...) creates a floor anchor
  • minimumBounds sets minimum detection size
  • Add anchor to RealityView to enable tracking

Detect spatial taps

Use SpatialTapGesture to detect taps in the immersive space (27:54).

// Detect taps on entities in immersive space
RealityView { content in
    // ...
}
.gesture(
    SpatialTapGesture(
        coordinateSpace: .immersiveSpace
    )
    .targetedToAnyEntity()
    .onEnded { value in
        handleTapOnFloor(value: value)
    }
)
  • SpatialTapGesture(coordinateSpace: .immersiveSpace) detects 3D taps
  • .targetedToAnyEntity() applies gesture to any entity
  • onEnded handles tap events

Handle tap to place plant

Convert tap location to floor anchor coordinates and place plant (28:09).

// Handle tap event
func handleTapOnFloor(value: EntityTargetValue<SpatialTapGesture.Value>) {
    let location =
        value.convert(value.location3D, from: .immersiveSpace, to: floorAnchor)

    plantEntity.position = location
    floorAnchor.addChild(plantEntity)
}
  • convert(location3D, from: .immersiveSpace, to: floorAnchor) transforms coordinate spaces
  • Set converted position as plant position
  • Add plant as child of floor anchor

Passthrough tinting

Use SurroundingsEffect to tint passthrough color (30:48).

// Apply effect to tint passthrough
struct ImmersiveExplorationView: View {
    var body: some View {
        RealityView { content in
            // ...
        }
        .preferredSurroundingsEffect(surroundingsEffect)
    }

    // The resolved surroundings effect based on tint color
    var surroundingsEffect: SurroundingsEffect? {
        if let color = appModel.tintColor {
            return SurroundingsEffect.colorMultiply(color)
        } else {
            return nil
        }
    }
}
  • SurroundingsEffect.colorMultiply(color) creates a color multiply effect
  • preferredSurroundingsEffect applies the surroundings effect
  • When robot collides with plant, tint environment with corresponding color

Core Takeaways

  • Enable volume resizing: If volume content isn’t fixed size, use frame(minWidth:maxWidth:minDepth:maxDepth:) instead of fixed frames so users can resize via corner handles. For dynamic adjustment, drive frame changes with state variables—the volume adapts automatically.

  • Reduce main view clutter with ornaments: Move auxiliary UI (progress, status) from the main view to custom ornaments. Ornaments follow the user to the nearest side and auto-scale with distance. Keep only core 3D content in the main view.

  • Make content respond to user viewpoint: Use onVolumeViewpointChange to track user movement around the volume and turn characters toward them. If only certain sides are supported, use supportedVolumeViewpoints and updateStrategy: .all to hint when users reach unsupported sides.

  • Customize immersion experience: visionOS 2 lets you specify initial values and ranges for progressive immersion. For stronger immersion, set a higher initial value (e.g., 80%). Use onImmersionChange to react when users adjust immersion.

  • Enable interaction with spatial tracking: Use SpatialTrackingSession to track plane anchors, paired with SpatialTapGesture for 3D taps—enabling “tap floor to place object” interactions in the real environment.


Comments

GitHub Issues · utterances