SwiftUI Supporting External Screens

How do you make a SwiftUI app show content on an external screen?

I’ve covered using scene delegates in a UIKit app to show content on an external screen. See iOS Scene Delegates and External Screens for a recap. In this post I’ll do the same for a SwiftUI app while exploring some of the limitations with bridging between the SwiftUI and UIKit worlds.

SwiftUI App Setup

My app has a single screen where the user enters a message that I want to display as large banner text when they have an external screen connected:

iPhone simulator and external display showing hello message

As with the UIKit app, my example app has a store observable object with a published property for the text message:

final class MessageStore: ObservableObject {
  @Published var message: String
  
  init(_ message: String) {
    self.message = message
  }
}

The main content view shows a text editor with a binding to the message property published by the store:

// ContentView.swift
struct ContentView: View {
  @EnvironmentObject var store: MessageStore
    
  var body: some View {
    ZStack {
      Color.yellow
        .ignoresSafeArea()
      VStack {
        Text("Enter message to display")
          .font(.title)
        TextEditor(text: $store.message)
      }
      .padding()
    }
  }
}

I create the message store in the App structure and pass it in the environment to the content view:

// BannerApp.swift
@main
struct BannerApp: App {
  @StateObject var store = MessageStore("")

  var body: some Scene {
    WindowGroup {
      ContentView()
        .environmentObject(store)
    }
  }
}

A new SwiftUI project doesn’t have an app or scene delegate by default. Since iOS 14, you create scenes in the App structure. SwiftUI has some built-in scene types like WindowGroup or DocumentGroup. What’s missing is any ability to mark a scene as being for an external window. For that we need to fall back to UIKit scene delegates.

Adding An External Scene

Here’s the Application Scene Manifest in the Info.plist for a new SwiftUI project:

Info.plist application scene manifest with enable multiple windows set to yes

It’s the opposite of what we see in the default Xcode UIKit app template. It does opt-in to multiple windows but doesn’t define a scene delegate. We need to add one for the external display.

Add the “Scene Configuration” dictionary to the “Application Scene Manifest” section of the Info.plist file. Then add an “External Display Session Role” entry to the scene configuration. We’re not using a storyboard so we only need to name the configuration and the delegate class name:

Info.plist scene configuration with external display session role

I have an external view that shows the message as a larger banner:

// ExternalView.swift
struct ExternalView: View {
  @EnvironmentObject var store: MessageStore

  var body: some View {
    Text(store.message)
      .font(Font.system(size: 144,
                      weight: .regular,
                      design: .monospaced))
      .frame(maxWidth: .infinity)
      .background(.regularMaterial,
                  in: RoundedRectangle(cornerRadius: 12))
  }
}

We can create a window for the external scene in the scene delegate. Since our root view is a SwiftUI view we need a UIHostingController as the root view controller. There’s a problem with this approach. The external view has a dependency on the store observable object. We need to pass the store to the view when creating it in the scene delegate:

// SceneDelegate.swift
final class SceneDelegate: UIResponder, UIWindowSceneDelegate {
  var window: UIWindow?
    
  func scene(_ scene: UIScene,
    willConnectTo session: UISceneSession,
    options connectionOptions: UIScene.ConnectionOptions) {

    guard let scene = scene as? UIWindowScene else {
      return
    }

    let content = ExternalView()
      .environmentObject(store)   // Where does store come from?
    window = UIWindow(windowScene: scene)
    window?.rootViewController = UIHostingController(rootView: content)
    window?.isHidden = false
  }
}

In the UIKit example, I created the store in the app delegate and was able to access that from the scene delegate. Using the SwiftUI App lifecycle there’s no direct access to the store object I created in the App structure from the scene delegate. I hope Apple will add a way to mark a WindowGroup as being for an external window. Until then we need a workaround.

Approach #1: Using a Shared Object

One workaround is to make our store a shared object:

final class MessageStore: ObservableObject {
  static let shared = MessageStore("")
  ...
}

Then use the shared object in the App struct:

struct BannerUIApp: App {
  @StateObject var store = MessageStore.shared
  ...

We can then fix our scene delegate:

let content = ExternalView()
  .environmentObject(MessageStore.shared)
window = UIWindow(windowScene: scene)
window?.rootViewController = UIHostingController(rootView: content)
window?.isHidden = false

This seems like the quickest solution but let’s look at some alternatives.

Approach #2: Adding An App Delegate Adaptor

When Apple introduced the SwiftUI App protocol in iOS 14 they recognised that it didn’t cover all the features handled by app delegate callback methods. The UIApplicationDelegateAdaptor property wrapper lets you add a delegate type in the App declaration:

// BannerApp.swift
@main
struct BannerApp: App {
  @UIApplicationDelegateAdaptor private var appDelegate: AppDelegate
  ...
}

We could then add an App Delegate to the app and declare the store there:

// AppDelegate.swift
final class AppDelegate: NSObject, UIApplicationDelegate, ObservableObject {
  var store = MessageStore("")
}

Notes:

  • The @main attribute is still applied to the SwiftUI App so it remains the entry point for the app. See what does @main do? for details.
  • Adding ObservableObject to the AppDelegate (or SceneDelegate) class causes SwiftUI to add the delegate object to the environment so you can access it from SwiftUI views.

This seems promising. We could change our content and external view to access the app delegate from the environment:

struct ContentView: View {
  @EnvironmentObject var appDelegate: AppDelegate
  ...
}

That would then give us access to the message published property:

TextEditor(text: $appDelegate.store.message)

That works for the ContentView but crashes for the external view. The problem is that the app delegate is not added to the environment for the external window we create in the UIKit scene delegate. What caught me by surprise is that we cannot access our “app delegate” via the shared application instance:

// SceneDelegate.swift
let appDelegate = UIApplication.shared.delegate as? AppDelegate
// nil

It turns out that we aren’t adding a real app delegate but an adapter that forwards delegate calls to the real app delegate SwiftUI is using behind the scenes.

If you check the type of the shared app delegate, it’s not the same type as the app delegate adapter we added. Instead is has an internal SwiftUI type:

(lldb) po UIApplication.shared.delegate
 Optional<UIApplicationDelegate>
   some : <SwiftUI.AppDelegate: 0x60000382a7c0>

At this point, I gave up and decided to explore switching back to a full UIKit App Delegate.

Approach #3: Using The UIKit App Delegate

After removing the SwiftUI App structure from the project I need to make the app delegate the main app entry point:

// AppDelegate.swift
@main
final class AppDelegate: UIResponder, UIApplicationDelegate {
  var store = MessageStore("")
}

Since we’re no longer using a WindowGroup to create our main scene we need to add the default main and external scene configurations to our Info.plist:

Application scene manifest with default application and external scene configurations

You could use different scene delegate classes for the default and external scenes. I’m using the same scene delegate and checking the session role before creating each window:

// SceneDelegate.swift
final class SceneDelegate: UIResponder, UIWindowSceneDelegate {
  var window: UIWindow?
    
  func scene(_ scene: UIScene,
    willConnectTo session: UISceneSession,
    options connectionOptions: UIScene.ConnectionOptions) {
    guard let appDelegate = UIApplication.shared.delegate as? AppDelegate,
      let scene = scene as? UIWindowScene else {
      return
    }
    
    window = UIWindow(windowScene: scene)
    if session.role == .windowExternalDisplay {
      let content = ExternalView()
        .environmentObject(appDelegate.store)
      window?.rootViewController = UIHostingController(rootView: content)
      window?.isHidden = false
    } else {
      let content = ContentView()
        .environmentObject(appDelegate.store)
      window?.rootViewController = UIHostingController(rootView: content)
      window?.makeKeyAndVisible()
    }
  }
}

Now that we have a real app delegate I can access the store object via the delegate and pass it through the environment to the content views.

Wrapping Up

Let’s hope that Apple makes some additions to the SwiftUI App lifecycle in iOS 16. Having some way to add an external window group would save us from having to fallback to UIKit app and scene delegates.

Read More