Creating Views in SwiftUI for VisionOS

Michael Zheng
6 min readJan 4, 2024

--

A simple guide to creating views in SwiftUI to use for your VisionOS app

Apps built in SwiftUI

Since VisionOS is built on top of SwiftUI, we utilize the same techniques to create views to navigate through our Vision Pro apps.

In this article, we will go through the various different views and components we can add to our Vision Pro app in order to make navigation and presenting data easier. We will also go through how this looks like in the simulator, as well as picking the right tools for the job you have at hand.

Some of the elements we will explore include:

  • VStack, HStack, and ZStack
  • NavigationStack
  • NavigationSplitView

Each will have an example included in it so that you can see its various use cases.

VStack, HStack, and ZStack

In SwiftUI, we can use (V, H, Z)-Stacks in order to arrange our items in various predefined ways to present to our users. The different letters in the Stack represent the orientation at which the items are listed in. For instance, the V stands for vertical, and so items will be listed going from up to down. Likewise, the H and Z Stacks list items from left to right, and from background to foreground respectively.

For the Vision Pro, these behaviours are the exact same, it’s just the use case that changes. One example is that now, these stacks can be used for item selection. Each item in the Stack can correspond to a given 3D asset, which can be made to appear on click.

For instance, we can have this VStack as part of a view to display a list of buttons, which open assets when clicked:

struct StoryView: View {
// bunch of boolean state variables

var body : some View {
NavigationStack {
VStack {
Text("Go on an adventure through the different art eras!")
.font(.title)
}

VStack {
Toggle("Prehistoric", isOn: $showPrehistoric)
.toggleStyle(.button)

Toggle("Abstract", isOn: $showAbstract)
.toggleStyle(.button)
.disabled(dataModel.stage < 1)

Toggle("Pop Art", isOn: $showPopArt)
.toggleStyle(.button)
.disabled(dataModel.stage < 2)

Toggle("Surrealism", isOn: $showSurrealism)
.toggleStyle(.button)
.disabled(dataModel.stage < 3)

Toggle("Baroque", isOn: $showBaroque)
.toggleStyle(.button)
.disabled(dataModel.stage < 4)

Toggle("Romanticism", isOn: $showRomanticism)
.toggleStyle(.button)
.disabled(dataModel.stage < 5)

}
}
}
}

The result looks something like this:

Just like the VStack, the HStack is used for the same purposes, except displays items horizontally. For instance, this code:

struct FreestyleView: View {
@State private var selectedDifficulty: String?
let difficulty = ["Easy", "Medium", "Hard"]

var body : some View {
NavigationStack {
VStack {
Text("Pick Your Difficulty")
.font(.extraLargeTitle)
.padding()

HStack {
ForEach(difficulty, id: \.self) { option in
Button(action: {
self.selectedDifficulty = option
}) {
Text(option)
.foregroundColor(.white)
.frame(width: 100, height: 30) // Adjust width and height as needed
.background(selectedDifficulty == option ? Color.gray.opacity(0.7) : Color.clear)
.cornerRadius(10)
}
.padding(1) // Smaller padding
.buttonStyle(PlainButtonStyle()) // Remove button styling
}
}.padding()

Toggle("Start", isOn: $showFreestyleMode)
.toggleStyle(.button)
.disabled(selectedDifficulty == nil)
}
}
}
}

Roughly translates to displaying this:

In both instances, we have multiple options to choose from. The key here is to notice that we use different ways of initializing our buttons but at the end of the day they still represent the same data. They are encapsulated by the two different Stacks, thus allowing them to display items in different orientations.

The ZStack is special in that it essentially allows for “layering” of items. I personally haven’t found use cases for this component yet, but in the Apple documentation much of the use is the ability to create layered art.

ZStack output from example in Apple Documentation

Navigation Stack

The navigation stack is essential for if you want to be able to toggle between showing different views to your users. For those of us that have used React / React Native in the past, this is very much similar to the stack navigator.

However, the way that SwiftUI implements it is a bit different. Instead of declaring ahead of time which pages are sort of “related together” in order to place them in the same navigation stack, SwiftUI takes a more lax approach. You can declare a navigation stack within a given view, and given a boolean condition, at runtime decide to open up to a different window view. The navigation stack also implicitly gives you a “back” button for navigation.

Here’s an example that builds on previous code:

struct FreestyleView: View {
@Environment(\.openImmersiveSpace) var openImmersiveSpace
@Environment(\.dismissImmersiveSpace) var dismissImmersiveSpace

@State private var showFreestyleMode = false
@State private var freestyleModeShown = false
@State private var selectedDifficulty: String?
let difficulty = ["Easy", "Medium", "Hard"]

var body : some View {
NavigationStack { // navigation stack used here
VStack {
...
}.padding()

}.onChange(of: showFreestyleMode) { _, newValue in
Task {
if newValue && selectedDifficulty == "Easy" {
switch await openImmersiveSpace(id: "EasyMode") {
case .opened:
freestyleModeShown = true
case .error, .userCancelled:
fallthrough
@unknown default:
freestyleModeShown = false
showFreestyleMode = false
}
} else if freestyleModeShown {
await dismissImmersiveSpace()
freestyleModeShown = false
}
}
}
// more on change triggers for other difficulties
}
}

We see here that we tag on an “.onChange” statement to the navigation stack which listens to the changing of a variable, showFreestyleMode. It then opens up a given space depending on which difficulty was selected.

Navigation Spilt View

This is a component for more of a “niche” use case. It essentially “compartmentalizes” the current view window to have a side menu and a main viewing window. This view is great for if you want the user to be able to make a selection off to the side, and have some sort of corresponding reaction within the main window that gets triggered.

The effect looks like this:

Screenshot from Vision Pro Simulator

The corresponding code looks like this:

struct ContentView: View {
// state variables

var body: some View {

// View is split between music side menu and main control menu
NavigationSplitView {
List {
ForEach(["Jazz", "Classic Rock", "Piano", "Opera" ], id: \.self) { item in
ListItemView(title: item,
isHovered: hoveredItem == item,
isSelected: selectedItem == item)
.hoverEffect(.automatic)
.onTapGesture {
musicPlaying = true
selectedItem = item
let state = musicSelection.from(musicChoice: selectedItem)
dataModel.playMusic(music: state, currMusicPlaying: musicPlaying)
}
}
}.toolbar {
ToolbarItem(placement: .topBarLeading) {
VStack (alignment: .leading) {
Text("Music Library")
.font(.largeTitle)
}
}
}

// Main menu content
} detail: {
Toggle("Show Fireplace", isOn: $showFireplaceView)
.toggleStyle(.button)
.onChange(of: showFireplaceView) {_, isShowing in
if isShowing {
openWindow(id: "FireplaceView")
dataModel.playFireplaceSounds();
} else {
dismissWindow(id: "FireplaceView")
dataModel.stopFireplaceSounds();
}
}

// bunch of other code
}
}
}
}

We see that we have defined a Navigation Split View in our view body. Then as part of our toolbar section, we have a title highlighting what is a part of it. Our main menu content is defined within the “detail” section, which comprises of a button to make a fireplace appear, as well as other functionalities not displayed.

I just talked about a few components you can use to customize your views. These are typically the main ones you’ll be using most of the time. In order to make the most of your app, I’d highly suggest using some variety of these views within your Vision Pro app in order to make the interface more appealing and intuitive to users.

There are plenty of other components to go over in SwiftUI which I will be making articles for to show you how you can adapt them for your use case within VisionOS for building Vision Pro apps. If you liked this and found it useful, like and subscribe for more content!

--

--