Multiplatform Development for Apple Devices

2025-01-28

In June 2023 as Apple announced the Vision Pro I had an idea that could work well on the headset. A collection of looped videos playing alongside your day to day usage.

I already had an app that could do this called Christmas Chill, something I built when the 1st Apple TV supporting an App Store was made available. It features a collection of looped videos you can use as a festive backdrop.

Each year over Winter I spend a couple of days to improve it, adding new content and improving the codebase. One of the larger changes made to the project came in December 2023, when the UI was migrated from UIKit and Storyboards to SwiftUI.

Well, mostly migrated. I needed an AVPlayer backed view wrapped in a UIViewRepresentable. A great API providing interoperability between UIKit and SwiftUI, if you’re ever in need.

I had been somewhat hesitant to migrate sooner as I had a good understanding of Declarative UI and its concepts from other work with React and Jetpack Compose.

That changed for me with Apple’s introduction of the Apple Vision Pro and its support for SwiftUI. Christmas Chill has been a nice project to keep my Apple Dev knowledge upto date, and I was keen to gain experience expanding the app to different devices.

Once the 2023 migration to SwiftUI for Christmas Chill was complete I set out in 2024 to add support for the Vision Pro. Below is how I went about it and what I recommend if you were trying to do the same for your own apps.

Adding A New Platform

To start, the project needs to be able to build for Vision Pro as a destination, doing this is surprisingly easy! Inside Xcode, select the .xcodeproj file and under the Supported Destinations dropdown, click the plus button.

Add Destination

A dropdown of all the available Apple platforms appears. Hover over the desired platform to add as a destination, in this case Apple Vision, and then click Apple Vision in the newly appearing section.

Add Vision Pro Destination

A small popup will appear to inform you of changes Xcode needs to make to the target. Click Enable.

Enable Destination Support

Next, build the app using the visionOS Simulator. If you have a Vision Pro to hand you can find instructions on how to install it onto your device here.

During compilation, it’s likely Xcode will find compiler errors and / or the app will crash. This is expected and a practice in being patient. From this point you need to fix the errors in your project until the app compiles and no longer crashes.

In my case this took around 30 minutes, partially thanks to doing the hard work of migrating the app from UIKit to SwiftUI previously!

Conditional Compilation Blocks

SwiftUI at its core is a multiplatform framework, meaning just by compiling SwiftUI code for a different platform it will alter its appearance. Taking into account platform styling and various methods of interaction.

Whilst this helps to make quick progress during development, you may want more control over how the app appears and take advantage of each individual platforms strengths. A good example is the Vision Pro’s Immersion capabilities, SwiftUI provides an API for this via ImmersiveSpace, an API only available for visionOS.

If you tried to use this API whilst compiling the project for Apple TV, Xcode will throw an error informing this API is not available.

So what is the solution to avoid this situation? The answer comes from using Conditional Compilation Blocks. Compilation Blocks are sections of code providing instructions for when the compiler should compile code within the block.

Whilst they support a variety of conditions, the most useful for our needs is detecting which platform the code is being compiled for. You can do this with just a few lines of code:

var body: some Scene {
    #if os(tvOS)
        WindowGroup {
            HStack {
                Text("I am running on tvOS!")
            }
        }
    #elseif os(visionOS)
        ImmersiveSpace(id: "MyImmersiveSpace") {

        }
    #endif
}

A nice feature Xcode does to support conditional compilation blocks is make clear what code will compile depending on the platform selected for compilation. It will also slightly fade out code that won’t be compiled.

Build Phases

Dependency Injection via Build Phases

One of the more useful tricks I’ve found is using the Compile Sources and Copy Bundle Resources build phases as a form of dependency injection. These are processes run when the app is built and can be found under the Build Phases tab in the Xcode Project.

Build Phases

Compile Sources does the heavy lifting of compiling your source code into machine code. Whether its Swift, Objective-C, or even C/C++.

Copy Bundle Resources copies all related resources for the app target into the App Bundle. A container of sorts for all the apps code and resources including images, videos, localisable strings, and more.

These two build phases give alot of flexibility to apps as each new target provides their own build phases, including the two steps above. Whitelabel apps that provide a way for businesses to customise their content use this technique, amongst others.

You may find you want to provide different content for your own apps, depending on what platform they run on. Let’s use these build phases to our advantage and provide two different sources of content to do that.

First, let’s use a Swift Protocol to provide a contract that expects to be fulfilled by a struct or class.

protocol ContentManager {
    
    var content: [Content] { get }
}

Next, let’s take a look at two implementers of the protocol. Here is the first:

class TargetAppAContentManager : ContentManager {
        
    var content: [Content] {

            return [
                Content(name:  TargetAppAContentIdentifier.videoOneName.rawValue,
                        image: TargetAppAImagePreviewIdentifier.videoOnePreview.rawValue,
                        video: TargetAppAImageVideoIdentifier.videoOneVideo.rawValue),
                Content(name:  TargetAppAContentIdentifier.videoTwoName.rawValue,
                        image: TargetAppAImagePreviewIdentifier.videoTwoPreview.rawValue,
                        video: TargetAppAImageVideoIdentifier.videoTwoVideo.rawValue),
                Content(name:  TargetAppAContentIdentifier.videoThreeName.rawValue,
                        image: TargetAppAImagePreviewIdentifier.videoThreePreview.rawValue,
                        video: TargetAppAImageVideoIdentifier.videoThreeVideo.rawValue),
        ]

        return contentToShow
    }
}

TargetAppAContentManager is the concrete implementation used for the first app target. It provides an array of Content, which refers to resource names found in the app bundle for the target.

class TargetAppBContentManager : ContentManager {
        
    var content: [Content] {

        return [
            Content(name:  TargetAppBContentIdentifier.videoOneName.rawValue,
                    image: TargetAppBImagePreviewIdentifier.videoOnePreview.rawValue,
                    video: TargetAppBImageVideoIdentifier.videoOneVideo.rawValue),
            Content(name:  TargetAppBContentIdentifier.videoTwoName.rawValue,
                    image: TargetAppBImagePreviewIdentifier.videoTwoPreview.rawValue,
                    video: TargetAppBImageVideoIdentifier.videoTwoVideo.rawValue),
            Content(name:  TargetAppBContentIdentifier.videoThreeName.rawValue,
                    image: TargetAppBImagePreviewIdentifier.videoThreePreview.rawValue,
                    video: TargetAppBImageVideoIdentifier.videoThreeVideo.rawValue),
        ]
    }
}

Next is TargetAppBContentManager, the concrete implementation used for the second app target. It looks very similar to the first implementation, except for App B the identifiers are different.

With both implementations created, you can now refer to them indirectly in your code by setting the type of the object to ContentManager. Check out the example ViewModel below:

@Observable class VideoListViewModel {
    
    var contentManager: ContentManager

    init(contentManager: ContentManager) {
        self.contentManager = contentManager
    }
}

The ViewModel expects a type of ContentManager to passed in via its initialiser. The ViewModel can be passed ether type of ContentManager and continue to function as expected. This also means the ViewModel can be reused across both app targets.

The last thing to do is to ensure the correct ContentManager is added to the Compile Sources phase. In this case, App A is passed TargetAppAContentMananger as part of its sources, and App B is passed TargetAppBContentManager.

Add Content Manager to Compile Sources Build Phase

Adding App Bundle Resources

The last thing left to do is to ensure each app Bundle contain resources with names that match the identifiers used by the app. The easy way is to check the Copy Bundle Resources build phase of each app target and ensure the resources are referred to by the content manager. If not then drag them from your Xcode project into the copy resources phase.

This takes a little bit of time and care to test, as you don’t recieve a compile time error if a resource being referred to isn’t available in the bundle. During runtime you will get a crash!

A good way to automate the check is to write a unit test to confirm all resources being referred to by ContentManager are stored in the bundle. If the test fails when run then you know there is a missing resource in the bundle.

Where To Go Next?

If you’ve got this far you should have a good idea of how to bring your app to other Apple Platforms.

To round off this post, I will leave you with a couple of tips and resources I recommend:

  1. If adding Apple Vision support to an existing app, first migrate as much of your code from UIKit to SwiftUI as possible. Having seen the speed of an existing app working on Vision Pro when migrated to SwiftUI, it is a useful to be able to rely on.

  2. Read through Apple’s guidance on bring existing apps to visionOS. It provides useful tips and suggestions on how to do it and how to take advantage of visionOS features.

  3. If you’re thinking of starting a new multiplatform app yourself there is a Multiplatform tab available in Xcode, providing a number of app templates to use. There is also a video from WWDC 2022 on the subject.

  4. If you would like to see examples of apps working across multiple platforms, I recommend checking out my personal apps Christmas Chill and Ocean Chill. These are two apps working across tvOS and Vision Pro, built from a single codebase. (tvOS support for Ocean Chill coming soon!)