Migrating my SwiftUI App to VisionOS in 2 Hours
How I migrated my SwiftUI app CrossCraft to support visionOS for the Day 1 Release of the Apple Vision Pro. It took effectively about 2 hours in total, this article summarizes my key learnings along the way.
Just a few months ago, I released CrossCraft: Custom Crosswords, an app written entirely in SwiftUI and available on iOS, iPadOS, and macOS. For the launch of the Vision Pro, I set myself a challenge to migrate it to the new visionOS platform – but I started the migration just 3 days before launch day!
So the question was if I would be able to pull it off in this short amount of time. But luckily it turned out to be easy enough, so my app was ready on Day 1! 👇
The following are all of my learnings that could help you migrate your apps, too!
3rd-Party Frameworks
After adding the "Apple Vision" destination to my project, the first thing I did was selecting the "Apple Vision Pro" simulator and starting a build.
As I was expecting, the build failed. Because not all frameworks support the visionOS platform yet. But adding basic support was easy. Here are the 4 steps:
- Fork the dependency, remove it from your project & add your fork with the
main
branch instead. - Open the
Package.swift
file in the fork, bump the Swift tools version at the top of the file to5.9
and add.visionOS(.v1)
to the supportedplatforms
array. - Search for any mentions of
#if os(iOS)
and change them to#if os(iOS) || os(visionOS)
to avoid building the macOS path, preferring the iOS path. - Select the "Apple Vision Pro" simulator and build the project to confirm.
If you get an error due to missing APIs, make sure to add #if !os(visionOS)
checks at the right places. Most APIs should be available though, as visionOS is a fork of iPadOS as Apple officially confirmed. If a feature is not there, it will either come soon or it doesn't make sense on the platform anyway.
In my case, only for my own ReviewKit library I had to disable some code around SKReviewController
which isn't available on visionOS yet. So my library effectively does nothing when building for visionOS
, but my iOS & Mac apps will continue asking users to the rate the app. I could have fixed that with a custom UI, but I decided to wait for this years WWDC first, hoping we'll get it there already.
Don't forget to post a Pull Request to the original repo if you forked it, so others in the community can profit from your fix as well. The more people do this, the less dependencies you have to add platform support yourself. 💪
Testing my App in the Simulator
After fixing all the dependencies, I built my app and it succeeded! 🥳
Unfortunately, I was not done yet. First off, while the app was launching, I saw that there was no App Icon shown although I have one in my project. Also, after it launched, I immediately discovered a bunch of other issues. Here's an overview:
- The App Icon was missing
- When moving the cursor (= gaze), the Hover Shape was off in some places
- The Layout & Sizes of many windows, modals, and my UI elements were off
- My Accent Color did not have a legible contrast to the glassy background
All of these points will affect every single app migrating to visionOS. For me, small adjustments helped fix them though. Let me share my learnings one by one.
App Icon
It turns out, visionOS has its own App Icon style. They are circular like on watchOS, but they consist of multiple layers to create a sense of depth, like on tvOS. You add a visionOS app icon by pressing the + button and choosing "visionOS App Icon". Then, you need to provide at least a "Front" and "Back" layer image of size 1024 x 1024. The "Middle" layer is optional.
In my case, my app icon already consisted of a background layer and an icon in the foreground, so it was not a big deal to export them separately. I just had to remove the "Middle" layer in the right pane. But because I had a shadow applied to my foreground icon, and the Human Interface Guidelines state that we should "avoid using soft or feathered edges" for the non-background layers, I had to remove the shadow. The system will add a slight shadow on hover automatically.
Speaking of hover, Xcode provides a preview of how your app icon will look like at the top, and when you hover your mouse over it, it simulates the 3D hover effect when users will look at your app icon on the Vision Pro, which is really handy!
Hover Effects
In visionOS, one of the things that are easy to miss in the Simulator but extremely important when actually using the device are proper hover effects. As you select elements on the device with your eyes, it is important your app gives feedback about which element is currently selected. This works great out of the box with Stringly-based control APIs in SwiftUI, such as Button("Click me") { ... }
.
But as soon as you provide a custom label
parameter to a button, or even have your entirely custom controls, you will need to provide the exact shape of your control to the system. For example, I'm using a custom control I call HPicker
which I use instead of the default drop-down Picker
when I have only 2-4 options to choose from. It ended up looking like this when hovering over an option:
Adjusting it to follow the shape of the options was easy enough:
The .contentShape
and .hoverEffect
modifiers are what I added for a proper hover effect. Replace .rect(cornerRadius: 12.5)
with whatever the shape of your custom control is. Note that I wrapped them in a #if !os(macOS)
check as my app supports macOS, but .hoverEffect
is not available on it. Also, note that placing these modifiers outside the Button
did not work for me, they have to be placed inside the label definition to work properly.
In some situations, you might notice that you have a hover effect where you don't expect one. For me, this was the case when I provided a Button
inside another view that already is recognized as a control, like this DisclosureGroup
:
You can turn off the hover effect by just adding the .hoverEffectDisabled()
modifier. In my case above, the inner button was only added for macOS (because a DisclosureGroup
doesn't toggle when pressing the label on that platform). So my fix was to only use a Button
inside on macOS and to use a simple Label
else.
Making all controls have a proper hover effect was actually the most time-consuming task of the migration and took ~40 minutes. It would probably have been much faster if SwiftUI previews worked in my project, but for some reason they wouldn't build for me, and when I tried, my Mac would start hanging. 🤷♂️
Layout System
While visionOS is based on iPadOS and therefore renders things like Form
views similar to the iPad, it's important to understand that there's actually a key difference when it comes to the layout system compared to iOS/iPadOS:
On Apple Vision apps are opened in an infinite canvas, there's no fixed screen width or height your views can derive their size from. This is a key difference you need to understand. If you have developed apps for macOS, you will already be familiar with this difference. In many ways, the layout system is much closer to that of macOS where monitors can also have differing sizes and windows are very rarely opened using the full-screen space like they do on iOS & iPadOS.
So, if your app already supports macOS, you can simply opt for the #if os(macOS)
branches that you will most probably have many of already when it comes to sizing or window management. Just replace with #if os(macOS) || os(visionOS)
.
The main difference even to macOS is that windows have rounded corners with a large corner radius. So I found I had to add extra padding to the top & bottom of my window root views, e.g. using .padding(.vertical, 10)
.
If you don't have your app optimized for macOS yet, here are some key learnings:
- You need to provide
.frame(minWidth: 400, minHeight: 300)
for your views all over the place, otherwise your windows or modals might have sizes that don't work for your UI. Make sure to check them all and provide proper values. - While you should specify
minWidth
andminHeight
for your views so users can't resize them to become too small for your content, you might additionally want to provide a larger.defaultSize(width: 800, height: 600)
on yourWindowGroup
scene to default to a larger size than the minimum. - If you have modal views that cover your entire screen and also need that space, you will want to consider moving these modals to their own windows instead. Utilize the
@Environment(\.openWindow) var openWindow
property to open new windows on visionOS (and macOS) and specify additionalWindowGroup
views. See my article about window management in SwiftUI 4 to learn more. - You can decide to keep the modals for your initial migration instead of using external windows, which would be the proper solution. But note that unlike on macOS, modal sheets in visionOS are not resizable. So at least make sure to provide a size that works well for your sheet for all kinds of potentially dynamic data shown in the modal. That's what I did for "playing a puzzle" in CrossCraft.
Colors
Note that any controls with a white background will not play well with the hover effect, because the effect uses a white overlay. White on top of white isn't visible. I ran into this for my crossword puzzle game mode, where users press on tiles to enter characters. Note how the cursor is on a tile but the hover isn't visible:
My quick fix was to add the modifier .opacity(0.85)
making my white backgrounds 15% transparent, which helped already. More would be better, but white is an expected "crossword" color, so I tried to keep it as white as possible.
I also noticed that many mid-contrast colors, including the system default "blue" accent color, have a really bad contrast on the default window glass background. Make sure to make these colors brighter for visionOS. You can add a specific variant for "Apple Vision" using the Attributes inspector with a color selected.
Conclusion
If you have an app that's already on iPadOS & macOS, you're in a very good spot to add support for visionOS. You will be able to reuse all your SwiftUI code. When it comes to window management, you should opt for the macOS version. For everything else, opt for the iPadOS version. Then, ensure all your custom controls have a proper hover effect. Make some layout & UI adjustments like adding padding, making colors brighter, or splitting your app icon to a front & back part.
The entire process took effectively 2 hours for me. I live-streamed the entire process, you can find my recordings with the "wait for build" & "chat" removed in the following two YouTube videos, each roughly an hour long. Note that I added time codes for the different steps outlined above so you can dive into specifics:
A drag & drop translator for String Catalog files – it's really easy.
Get it now to machine-translate your app to up to 150 languages!