Building Step Into Labs
An Xcode project to develop small experiments and demos for Step Into Vision.
Labs is one of the top level sections of Step Into Vision. The Example Code section is a place to document how things work. Labs allows me to tinker on my ideas. It is a place for learning my way around RealityKit. My earlier version of this was called Canvatorium Visio, which you can read about on my personal site. I created 43 labs in that series, but decided to phase it out in favor of Step Into Vision.
The goal is to have a simple Xcode project where I can quickly set up and test ideas, build small UI concepts, and generally tinker with the tools. I really don’t like creating a whole Xcode project just to to explore a small idea. Doing so slows me down and it clutters up my projects folder and my app grid on Apple Vision Pro. Instead, I need a single project where I can create a lab–a SwiftUI file that will contain all the code for an idea. That lab can be presented as a Window, Volume, or Immersive Space.
This will change a lot in the future, but I wanted to share how I set this up for now.
The app launches with a main window that I call the Directory. This will list all the labs in the project and will highlight featured/active labs at the top.

When we tap on lab, we navigate to the detail view. From here we can read the lab details and use a button to open and close the lab. More on that later.
A video of the detail view opening and closing Lab 001
All the code for this example Lab is in a SwiftUI file called Lab001. I created an Xcode file template to speed up the creation of these files.
// Step Into Vision - Labs
//
// Title: Lab001
//
// Subtitle: Example of a 2D Window
//
// Description: Testing out the window
//
// Type: Window
//
// Created by Joseph Simpson on 10/3/24.
import SwiftUI
struct Lab001: View {
var body: some View {
// A regular SwiftUI View.
VStack {
Text("A Regular Window")
.font(.title)
Text("2D content made with SwiftUI")
}
}
}
#Preview {
Lab001()
}
An important detail is that the code in these files don’t really know how they are presented. They could be opened as a Window, Volume, or Immersive Space. That data is encoded into a model helper and is handled when we open the lab.
The biggest weakness of this design is the data model. I have a hardcoded array of labs with their metadata. I have to remember to keep the text from the SwiftUI file and this model up to date. At some point, I’m going to look for a way to automate the creation of this from the contents of the Labs folder. For now, this will have to do.
var labData: [Lab] = [
Lab(title: "Lab 001",
type: .WINDOW,
date: Date("10/3/2024"),
isFeatured: true,
subtitle: "Example of a 2D Window",
description: "Testing out the window")
,Lab(title: "Lab 002",
type: .VOLUME,
date: Date("10/3/2024"),
isFeatured: false,
subtitle: "Example of a 3D Volume",
description: "Testing out the volume")
,Lab(title: "Lab 003",
type: .SPACE,
date: Date("10/3/2024"),
isFeatured: false,
subtitle: "Example of anImmersive Space",
description: "Testing out the immersive space")
]At the app level, I create a shared instance of the data model and pass it to each scene. The Directory view uses this data model to create it’s view hierarchy.
struct StepIntoApp: App {
@State private var modelData = ModelData()
var body: some Scene {
// Main window
WindowGroup {
Directory()
.environment(modelData)
}
.defaultSize(CGSize(width: 700, height: 640))
// Router scenes...
}
}I also setup a handful or scene types to handle opening the labs based in their type. For example, when I need to open a labs as a window, I can call openWindow with the id “RouterWindow” and the title of the lab as the value.
struct StepIntoApp: App {
@State private var appModel = AppModel()
@State private var modelData = ModelData()
@State private var exampleImmersionStyle: ImmersionStyle = .full
var body: some Scene {
// Main window
WindowGroup {
Directory()
.environment(appModel)
.environment(modelData)
}
.defaultSize(CGSize(width: 700, height: 640))
// Router scenes
// 1. Window: Use this window group to open 2D windows with Lab Content based on the Router
WindowGroup(id: "RouterWindow", for: String.self, content: { $route in
LabRouter(route: $route)
})
.defaultSize(CGSize(width: 680, height: 400))
.defaultWindowPlacement { _, context in
if let mainWindow = context.windows.first {
return WindowPlacement(.leading(mainWindow))
}
return WindowPlacement(.none)
}
// 2. Volume: Use this window group to open 3D Volumes
WindowGroup(id: "RouterVolume", for: String.self, content: { $route in
let initialSize = Size3D(width: 500, height: 500, depth: 500)
LabRouter(route: $route)
.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)
.defaultWindowPlacement { _, context in
if let mainWindow = context.windows.first {
return WindowPlacement(.trailing(mainWindow))
}
return WindowPlacement(.none)
}
.windowResizability(.contentMinSize)
// 3. Space: Use this immersive scene to open a lab in a full space
ImmersiveSpace(id: "RouterSpace", for: String.self, content: { $route in
LabRouter(route: $route)
})
// 4. Space Full: Use this immersive scene to open a lab in a full space
ImmersiveSpace(id: "RouterSpaceFull", for: String.self, content: { $route in
LabRouter(route: $route)
})
.immersionStyle(selection: $exampleImmersionStyle, in: .full)
}
}As you can see, these scenes all use the LabRouter as the root view. This is another pain point as I need to manually add each route when creating labs.
struct LabRouter: View {
@Binding var route: String?
@ViewBuilder
var body: some View {
switch route {
case "Lab 001": Lab001()
case "Lab 002": Lab002()
case "Lab 003": Lab003()
case .none, .some:
// fallback
}
}
}
We can look at the detail view to see how to open each lab based on its type. When we toggle showLabContent a task is fired to check the type of the lab and call a related helper function to open a Window, Volume, or Immersive Space. I have some basic state handling too, but I need to improve this.
truct LabDetail: View {
// Pass a lab as a parameter
var lab: Lab
// 2D Windows
@Environment(\.openWindow) private var openWindow
@Environment(\.dismissWindow) private var dismissWindow
// Immersive Spaces
@Environment(\.openImmersiveSpace) var openImmersiveSpace
@Environment(\.dismissImmersiveSpace) var dismissImmersiveSpace
// Local state
@State private var showLabContent = false
@State private var labIsOpen = false
@State private var currentRoute: String?
init(lab: Lab) {
self.lab = lab
_currentRoute = State(initialValue: lab.title)
}
var body: some View {
List {
Section() {
VStack(alignment: .leading) {
Text("\(lab.title) - \(lab.subtitle)")
.font(.title)
Text("\(lab.date.formatted(date: .long, time: .omitted))")
.font(.subheadline)
}
Text(.init(lab.description))
if(lab.success == false) {
HStack {
Text("This lab was marked as a failure")
Spacer()
Image(systemName: "x.circle.fill")
.foregroundColor(.red)
.padding(4)
}
}
}
Section() {
HStack(alignment: .center, content: {
Text(lab.type.rawValue)
Spacer()
Toggle(isOn: $showLabContent) {
Text(showLabContent ? "Close Lab" : "Open Lab")
}
.toggleStyle(.button)
})
.padding(.vertical, 6)
}
}
.listStyle(.grouped)
.onChange(of: showLabContent) { _, newValue in
Task {
if(lab.type == .WINDOW) {
handleWindow()
} else if (lab.type == .VOLUME) {
handleVolume()
} else if (lab.type == .SPACE) {
await handleSpace(newValue: newValue)
} else if (lab.type == .SPACE_FULL) {
await handleSpaceFull(newValue: newValue)
}
}
}
.navigationTitle("Step Into Labs")
}
func handleWindow() {
if(labIsOpen) {
dismissWindow(id: "RouterWindow")
labIsOpen = false
showLabContent = false
} else {
openWindow(id: "RouterWindow", value: lab.title)
labIsOpen = true
}
}
func handleVolume() {
if(labIsOpen) {
dismissWindow(id: "RouterVolume")
labIsOpen = false
showLabContent = false
} else {
openWindow(id: "RouterVolume", value: lab.title)
labIsOpen = true
}
}
func handleSpace(newValue: Bool) async {
if newValue {
switch await openImmersiveSpace(id: "RouterSpace", value: lab.title) {
case .opened:
labIsOpen = true
case .error, .userCancelled:
fallthrough
@unknown default:
labIsOpen = false
showLabContent = false
}
} else if labIsOpen {
await dismissImmersiveSpace()
labIsOpen = false
}
}
func handleSpaceFull(newValue: Bool) async {
if newValue {
switch await openImmersiveSpace(id: "RouterSpaceFull", value: lab.title) {
case .opened:
labIsOpen = true
case .error, .userCancelled:
fallthrough
@unknown default:
labIsOpen = false
showLabContent = false
}
} else if labIsOpen {
await dismissImmersiveSpace()
labIsOpen = false
}
}
}I’m sure this structure could be improved a lot. Let me know how you would handle this.
Now that this is setup and working I’m ready to get back to tinkering on my ideas.
You can get the entire project on GitHub. Keep in mind that this is very much a work in progress.

Follow Step Into Vision