Implementing volumes in visionOS

Implementing volumes in visionOS

Learn how to embrace volumes for immersive 3D experiences in visionOS.

Content in visionOS apps can be presented through windows or volumes, each characterized by distinctive attributes. In general, windows typically showcase 2D or 3D content like a media gallery in Photos, while volumes are perfect for presenting immersive 3D objects and content.

Volumes present 3D content, allowing users to view it from any perspective without taking over the entire space. Volumes serve as containers for immersive experiences, enabling the exploration of realistic 3D object representations.

A volume in visionOS.

Implementing volumes

Let’s explore the steps to design a visionOS app using a volume. We will build a simple visionOS app made of a PrimaryWindowView that opens through a button a SecondaryVolumeView.

Primary window and secondary volume.

The journey begins by initiating a new project within visionOS. Ensure you have set up the necessary configurations and named the project appropriately.

When configuring the Immersive Space Renderer, opt for the None setting. While Apple offers templates for every possible immersive space, opting for the None setting allows us to understand the underlying processes from scratch.

Xcode - Immersive Space Renderer option.

We will use a .usdz 3D model file, a single, self-contained file format that includes all the necessary information to display a 3D object or scene without any external dependencies.

Bringing the file into Xcode couldn't be easier — drag and drop it into the primary project folder. Remember to ensure that 'copy item if needed' is selected when the import window pops up in Xcode.

Let's start by creating for our convenience a new SwiftUI file called CubeView

import SwiftUI
import RealityKit

struct CubeView: View {
    
    @State private var angle: Angle = .degrees(0)
    
    var body: some View {
    
        VStack(spacing: 18.0) {
            Model3D(named: "GlassCube") { model in
                switch model {
                case .empty:
                    ProgressView()
                    
                case .success(let resolvedModel3D):
                    resolvedModel3D
                        .scaleEffect(0.4)
                        .rotation3DEffect(angle, axis: .x)
                        .rotation3DEffect(angle, axis: .y)
                        .animation(.linear(duration: 18).repeatForever(), value: angle)
                        .onAppear {
                            angle = .degrees(359)
                        }
                        
                case .failure(let error):
                    Text(error.localizedDescription)
                    
                @unknown default:
                    EmptyView()
                }
            }
        }
        
    }
}

#Preview {
    CubeView()
}

CubeView uses SwiftUI and RealityKit to create a view displaying a rotating 3D cube. It uses a Model3D view to load a 3D model named GlassCube. The cube rotates continuously around the x and y axes.

The initial preview showcases the cube within a window. In the upcoming setup, we'll configure the code to display the cube within a spatial volume.

Xcode - Model3D Cube preview.

Before moving on, let's add in our shared ViewModel a secondaryVolumeIsShowing variable. This variable will act as a new controller, determining whether the secondary volume is displayed or not.

import Foundation

@Observable
class ViewModel {
    var secondaryVolumeIsShowing: Bool = false
}

Let's create the SecondaryVolumeView first. This view will serve as the secondary volume, distinct from the primary window, providing an additional space for content display or interaction.

import SwiftUI

struct SecondaryVolumeView: View {
    
    @Environment(ViewModel.self) private var model
    
    var body: some View {
    
        ZStack(alignment: .bottom) {
            CubeView()
            
            Text("This is a volume")
                .padding()
                .glassBackgroundEffect(in: .capsule)
        }
        .onDisappear {
            model.secondaryVolumeIsShowing.toggle()
        }
        
    }
}

#Preview {
    SecondaryVolumeView()
        .environment(ViewModel())
}

With the SecondaryVolumeView in place, attention shifts to creating thePrimaryWindowView. This primary interface serves as the main hub of interaction. We need a simple button capable of opening/closing the SecondaryWindowView we have just created.

import SwiftUI

struct PrimaryWindowView: View {
    
    @Environment(ViewModel.self) var model
    
    @Environment(\.openWindow) private var openWindow
    @Environment(\.dismissWindow) private var dismissWindow
    
    var body: some View {
    
        @Bindable var model = model
        
        VStack(spacing: 18.0) {
            Image(systemName: "1.circle.fill")
                .resizable()
                .aspectRatio(contentMode: .fit)
                .frame(width: 100,height: 100)
                .fontWeight(.light)
                .padding()
                
            Text("this is the primary window")
                .font(.title)
                .fontWeight(.light)
                
            Toggle("Open the secondary volume", isOn: $model.secondaryVolumeIsShowing)
                .toggleStyle(.button)
                .onChange(of: model.secondaryVolumeIsShowing) { _, isShowing in
                    if isShowing {
                        openWindow(id: "secondaryVolume")
                    } else {
                        dismissWindow(id: "secondaryVolume")
                    }
                }
        }
        
    }
}

#Preview(windowStyle: .automatic) {
    PrimaryWindowView()
        .environment(ViewModel())
}

The last step is to update the structure that conforms to the App protocol, the file automatically generated by Xcode that provides the entry point into your app.

import SwiftUI

@main
struct ImplementingWindowsAndVolumesApp: App {
    
    @State private var model = ViewModel()
    
    var body: some Scene {
    
        WindowGroup {
            PrimaryWindowView()
                .environment(model)
        }
        
        WindowGroup(id: "secondaryVolume") {
            SecondaryVolumeView()
                .environment(model)
        }
        .windowStyle(.volumetric)
        .defaultSize(width: 0.1, height: 0.1, depth: 0.1, in: .meters)
        
    }
}

We're tweaking the style of this window using .windowStyle(.volumetric). What this does is it changes how the window looks and behaves. Instead of being flat and regular like most windows, this one's going to have some depth and volume, giving it a 3D-like appearance.

With .defaultSize(width: 0.1, height: 0.1, depth: 0.1, in: .meters) we are setting the default size of this window using. We are defining this window's initial size in a 3D space.

Primary window and secondary volume.

Wrapping up the coding segment, we've comprehensively established the structure for opening 3D objects as volumes within our application. Following this schema, you can implement as many volumes as necessary in your app. 

Volumes considerations

Volumes and windows share certain characteristics within the shared space but exhibit distinctive traits that define their roles in spatial interfaces.

  • Volumes contain 360° degrees 3D objects.
  • Volumes do not display frames around 3D objects.
  • A volume provides the same management controls to reposition or close a window.
  • In the Shared Space, both a volume and a window have their initial position determined by the system.
  • Volumes can also use a glass background.
  • Windows are versatile enough to display 3D elements, yet volumes are generally preferred for showcasing 3D content.

Embrace the future with Apple's visionOS, an infinite canvas that transforms how you create the apps you love. Stay tuned and explore with us the latest advancements of this transformative digital experience.