João Gabriel
João GabrielDecember 1, 2024

Displaying SwiftUI content on an external screen in iOS

Learn how to use scene delegates to present content on an external display with iOS using SwiftUI.
Display extra content on an external display with SwiftUI

This brief article will dive into how you can use SwiftUI, UIKit and scene delegates to present content on an external display with iOS.


As a reader of our developer blog, I believe you might be familiar with development for big screens with macOS. Today, we'll talk about a different type of development for big screens — we'll talk about using external displays with iOS.

A brief history

Mirroring the content of an iOS device to an external display has been possible since 2010 with the introduction of AirPlay. The following year, it became possible to use an adapter to directly connect an iPhone to an external display.

Basic mirroring isn't that big of a deal: it only shows whatever your phone's main screen is already showing. Some specific apps though, such as Photos, use an external display connection to show alternative, non-interactive content, such as full screen pictures and videos.

This has always been possible, but has gotten much easier in 2019 with iOS 13 with the introduction of the UIScene API. And in 2023, with the very awaited switch of the iPhone to USB-C, it became even easier to connect an iPhone to a monitor or TV. Let's see how we can leverage that today!

Start by creating a new SwiftUI project in Xcode and naming it as you wish. Then, we'll need to make some changes to the project's configuration.

Editing your Info.plist

First, you need to properly configure your Info.plist to support multiple screens. First, under the "Application Scene Manifest" (UIApplicationSceneManifest) key, set "Enable Multiple Windows" (UIApplicationSupportsMultipleScenes) to YES.

Then, under the "Scene Configuration" key, add a new item with the key "External Display Session Role Non-Interactive" (UIWindowSceneSessionRoleExternalDisplayNonInteractive), set its "Configuration Name" (UISceneConfigurationName) to "External Display" and its "Delegate Class Name" (UISceneDelegateClassName) to $(PRODUCT_MODULE_NAME).SceneDelegate.

This will inform the system of how your app will handle an external display. This is what your Info.plist should look like in the graphical editor after these changes:

Info.plist configuration

Fun fact: this is the same configuration property that allows your app to support multiple windows on iPadOS, or immersive spaces and on visionOS.


Creating a new scene delegate

To create a scene delegate for a SwiftUI app, first we need to specify a custom app delegate. Let's first create a new AppDelegate.swift file:

AppDelegate.swift
import SwiftUI
 
class AppDelegate: NSObject, UIApplicationDelegate {
    func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil) -> Bool {
        return true
    }
}

Then, on our @main entry point, we need to specify that we're using a custom app delegate:

ExternalDisplayApp.swift
@main
struct ExternalDisplayApp: App {
    @UIApplicationDelegateAdaptor(AppDelegate.self) var delegate
 
    var body: some Scene {
        WindowGroup {
            ContentView()
        }
    }
}

Now, we can create a new SceneDelegate.swift file, and add the code to handle the external display:

SceneDelegate.swift
class SceneDelegate: UIResponder, UIWindowSceneDelegate {
    var window: UIWindow?
 
    func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
        guard let windowScene = scene as? UIWindowScene else { return }
 
        if session.role == .windowExternalDisplayNonInteractive {
            let window = UIWindow(windowScene: windowScene)
            window.rootViewController = UIHostingController(rootView: ExternalDisplayView()) // Here we specify the view we want to display on the external screen
            self.window = window
            window.makeKeyAndVisible()
        }
    }
}

Notice how we're setting the rootViewController of the window to a UIHostingController with the view we want to display on the external screen. In this case, we're using a view called ExternalView. Let's create that view as well:

ExternalDisplayView.swift
struct ExternalDisplayView: View {
    var body: some View {
        Text("Hello, world!")
            .font(.system(size: 96, weight: .bold))
    }
}

Finally, add the following method to your AppDelegate:

AppDelegate.swift
func application(_ application: UIApplication,
                    configurationForConnecting connectingSceneSession: UISceneSession,
                    options: UIScene.ConnectionOptions) -> UISceneConfiguration {
    let sceneConfiguration = UISceneConfiguration(name: nil, sessionRole: connectingSceneSession.role)
    sceneConfiguration.delegateClass = SceneDelegate.self // Here we specify the scene delegate we just created
    return sceneConfiguration
}

And that's it! You can check that it works by connecting your iPhone to an external display and running the app. You should see the text "Hello, world!" displayed on the external screen.

If you can't connect an iPhone to an external display, you can create one in the Simulator, just go to the "I/O" menu, select "External Displays" and choose your desired resolution.

Interfacing with the external display

Now comes the fun part: implementing something useful for the external diplay. You can do pretty much anything you want, but remember: this view will not be interactive. In this example, we'll implement a simple counter that will be shown on the external display.

Let's start by writing a DataModel to hold the counter value:

DataModel.swift
class DataModel: ObservableObject {
    @Published var counter = 0
 
    func increment() {
        counter += 1
    }
 
    static var shared = DataModel() // Let's make it a singleton
}

For the sake of simplicity, we've made the DataModel a singleton.

Now, let's modify our ContentView to display a button to increment it:

ContentView.swift
import SwiftUI
 
struct ContentView: View {
    @ObservedObject var dataModel = DataModel.shared
 
    var body: some View {
        Button(action: dataModel.increment) {
            Text("Increment")
        }
    }
}

Finally, let's modify our ExternalDisplayView to display the counter:

ExternalDisplayView.swift
import SwiftUI
 
struct ExternalView: View {
    @ObservedObject var dataModel = DataModel.shared
 
    var body: some View {
        Text("\(dataModel.counter)")
            .font(.system(size: 96, weight: .bold))
            .contentTransition(.numericText()) // Let's add a transition to the text
            .animation(.default, value: dataModel.counter) // And specify an animation
    }
}

We've also added a numericText transition and animation to the text, so that it animates when the counter changes.

And that's it! You can now run the app and see the counter incrementing on the external display.

Counter incrementing on the external display

If you can, you can also try the app outside of the iOS Simulator, by connecting your iPhone to an external display. Now that you've seen it work, think of all the ways you can use this feature to enhance the user experience of your app!