Spatial SwiftUI: Volume Ornaments

Ornaments on Volumes work much like Ornaments on Windows. Volumes have more anchors and a feature to reorient them when them when a user moves around the volume.

Overview

Volumes have the same anchor points that we saw on windows. Volumes also have anchors for the front and back.

A virtual art gallery displaying abstract paintings and sculptures, featuring a 3D interface menu for adjusting ornament settings.

It helps to think of these as three collections of anchors on three planes.

  1. Front: TopLeadingFront, TopFront, TopTrailingFront and so on…
  2. Standard / window: Leading, Center, Trailing, and so on…
  3. Back: BackLeadingFront, BackFront, BackTrailingFront and so on…

The back anchors are particularly useful. We can design our volumes with controls and information at the back while our 3D content takes center stage.

When a user moves around a volume any ornaments will re-orient and reposition based on the users viewpoint. This means the ornaments will always appear in the same formation relative to each other, instead of relative to the content. We’ll look at a way to customize this behavior in the future.

Full Example Code

struct Example043: View {

    static let info = ItemInfo(
        title: "Spatial SwiftUI: Volume Ornaments",
        description: "Ornaments on Volumes work much like Ornaments on Windows. Volumes have more anchors and a feature to reorient them when them when a user moves around the volume.",
        scene: .volume
    )

    @State private var ornamentAnchor: UnitPoint3D = .topFront
    @State private var showBounds = true
    @State private var isAutoMode = false
    @State private var autoModeTimer: Timer?
    @State private var animationPhase = 0

    private static let autoAnchors: [UnitPoint3D] = {
        let perimeter: [UnitPoint] = [
            .topLeading, .top, .topTrailing, .trailing,
            .bottomTrailing, .bottom, .bottomLeading, .leading, .topLeading
        ]
        let planes: [UnitPoint3D] = [.front, .center, .back]
        return planes.flatMap { plane in
            perimeter.map { UnitPoint3D(x: $0.x, y: $0.y, z: plane.z) }
        }
    }()

    private func startAutoMode() {
        stopAutoMode()
        animationPhase = 0
        ornamentAnchor = Self.autoAnchors[0]

        autoModeTimer = Timer.scheduledTimer(withTimeInterval: 2.5, repeats: true) { _ in
            Task { @MainActor in
                guard isAutoMode else { return }

                animationPhase = (animationPhase + 1) % Self.autoAnchors.count
                withAnimation(.easeInOut(duration: 2.0)) {
                    ornamentAnchor = Self.autoAnchors[animationPhase]
                }
            }
        }
    }

    private func stopAutoMode() {
        autoModeTimer?.invalidate()
        autoModeTimer = nil
    }

    var body: some View {
        GeometryReader3D { geometry in
            Color.clear
                .frame(width: geometry.size.width, height: geometry.size.height)
                .frame(depth: geometry.size.depth)
                .debugBorder3D(showBounds ? .white : .clear)
        }
        .ornament(attachmentAnchor: .scene(.center)) {
            Example043Controls(
                ornamentAnchor: $ornamentAnchor,
                showBounds: $showBounds,
                isAutoMode: $isAutoMode
            )
        }
        .ornament(attachmentAnchor: .scene(ornamentAnchor)) {
            Text("Ornament")
                .padding(20)
                .background(.stepBlue)
                .clipShape(RoundedRectangle(cornerRadius: 20))
        }
        .onChange(of: isAutoMode) { _, newValue in
            if newValue {
                startAutoMode()
            } else {
                stopAutoMode()
            }
        }
        .onDisappear {
            stopAutoMode()
            isAutoMode = false
        }
    }
}

private struct Example043Controls: View {
    @Binding var ornamentAnchor: UnitPoint3D
    @Binding var showBounds: Bool
    @Binding var isAutoMode: Bool

    private func setDepth(_ point: UnitPoint3D) {
        withAnimation {
            ornamentAnchor.z = point.z
        }
    }

    var body: some View {
        VStack(spacing: 20) {
            HStack(spacing: 32) {
                VStack {
                    Text("Anchor")
                        .font(.headline)
                    Grid {
                        GridRow {
                            Example043AnchorButton(anchor: $ornamentAnchor, point: .topLeading, symbol: "arrow.up.left", title: "Top Leading")
                            Example043AnchorButton(anchor: $ornamentAnchor, point: .top, symbol: "arrow.up", title: "Top")
                            Example043AnchorButton(anchor: $ornamentAnchor, point: .topTrailing, symbol: "arrow.up.right", title: "Top Trailing")
                        }
                        GridRow {
                            Example043AnchorButton(anchor: $ornamentAnchor, point: .leading, symbol: "arrow.left", title: "Leading")
                            Example043AnchorButton(anchor: $ornamentAnchor, point: .center, symbol: "square", title: "Center")
                            Example043AnchorButton(anchor: $ornamentAnchor, point: .trailing, symbol: "arrow.right", title: "Trailing")
                        }
                        GridRow {
                            Example043AnchorButton(anchor: $ornamentAnchor, point: .bottomLeading, symbol: "arrow.down.left", title: "Bottom Leading")
                            Example043AnchorButton(anchor: $ornamentAnchor, point: .bottom, symbol: "arrow.down", title: "Bottom")
                            Example043AnchorButton(anchor: $ornamentAnchor, point: .bottomTrailing, symbol: "arrow.down.right", title: "Bottom Trailing")
                        }
                    }
                }

                VStack {
                    Text("Depth")
                        .font(.headline)
                    Button("Front") { setDepth(.front) }
                    Button("Center") { setDepth(.center) }
                    Button("Back") { setDepth(.back) }
                }
            }
            .disabled(isAutoMode)

            Toggle("Auto Mode", isOn: $isAutoMode)

            Button(showBounds ? "Hide Bounds" : "Show Bounds") {
                showBounds.toggle()
            }
        }
        .padding(24)
        .glassBackgroundEffect()
    }
}

private struct Example043AnchorButton: View {
    @Binding var anchor: UnitPoint3D
    let point: UnitPoint
    let symbol: String
    let title: LocalizedStringKey

    var body: some View {
        Button {
            withAnimation {
                anchor.x = point.x
                anchor.y = point.y
            }
        } label: {
            Image(systemName: symbol)
        }
        .accessibilityLabel(Text(title))
    }
}

Support our work so we can continue to bring you new examples and articles.

Questions or feedback?