Add Multi-screen Support to SwiftUI Apps

iOS support for external displays has been around since iPhone OS 3.2 and the very first iPad. WWDC 2011(!) has a session covering AirPlay and external displays. Enthusiasm for developing multi screen apps beyond simple mirroring did not take off until the introduction of USB-C iPads Pro. There are many excellent tutorials on how to add external display support to a UIKit app and demos showing how doing so can provide a richer and more powerful app user experience. Geoff Hackworth, the creator of Adaptivity has a comprehensive technical guide on what to consider when considering external display support. My top takeaway from this post is:

An external display is just a display; there is no user-interaction (at least, not yet!) The user will need to continue interacting with your app’s interface. You should probably avoid placing standard iOS controls on the external display that could give the misleading impression of interactivity.
External Display Support on iOS by Geoff Hackworth.

With that, here's how to extend your brand new iOS 14 SwiftUI lifecycle app to support external displays.

Start a new project

Make sure that app life cycle is set to SwiftUI App.

Make sure that app life cycle is set to SwiftUI App.

Add an ObservableObject shared between device screen and external display

Add a relevant property to be shared across screens. For this example I'm using a simple string that I will update on my device and will be visible on the external device. Also add a boolean flag that indicates whether content is showing on an external display.

//  ExternalDisplayContent.swift

class ExternalDisplayContent: ObservableObject {

    @Published var string = ""
    @Published var isShowingOnExternalDisplay = false

}

Update the object in the content view

Store an instance of the shared object created above at the app level. Pass this instance to the child view. For this example I am passing it as an environment object. Add a text field that updates the value of the shared object.

//  ContentView.swift

struct ContentView: View {

    @EnvironmentObject var externalDisplayContent: ExternalDisplayContent

    var body: some View {
        NavigationView {
            Form {
                if externalDisplayContent.isShowingOnExternalDisplay == false {
                    Section(header: Text("Output")) {
                        Text(externalDisplayContent.string)
                    }
                }
                Section(header: Text("Input")) {
                    TextField("Text", text: $externalDisplayContent.string)
                }
            }
            .navigationTitle("Screens")
        }
    }

}
//  ScreensApp.swift

@main struct ScreensApp: App {

    @ObservedObject var externalDisplayContent = ExternalDisplayContent()

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

}

Add the view to be displayed on the external display

For this example I'm keeping it simple. Make a new view that references the external display content environment object and shows its string property as Text.

//  ExternalView.swift

struct ExternalView: View {

    @EnvironmentObject var externalDisplayContent: ExternalDisplayContent

    var body: some View {
        Text(externalDisplayContent.string)
    }

}

Listen for UIScreen (dis)connect notifications

Add convenience publisher properties for UIScreen.didConnectNotification and UIScreen.didDisconnectNotification. The former notification carries the new screen just connected while the former contains the old screen that just disconnected. Subscribe to these publishers and set isShowingOnExternalDisplay to true when the screen connects and false when the screen disconnects in their action closures. import Combine so the compiler knows what AnyPublisher is.

//  ScreensApp.swift

@main struct ScreensApp: App {

    @ObservedObject var externalDisplayContent = ExternalDisplayContent()

    private var screenDidConnectPublisher: AnyPublisher<UIScreen, Never> {
        NotificationCenter.default
            .publisher(for: UIScreen.didConnectNotification)
            .compactMap { $0.object as? UIScreen }
            .receive(on: RunLoop.main)
            .eraseToAnyPublisher()
    }

    private var screenDidDisconnectPublisher: AnyPublisher<UIScreen, Never> {
        NotificationCenter.default
            .publisher(for: UIScreen.didDisconnectNotification)
            .compactMap { $0.object as? UIScreen }
            .receive(on: RunLoop.main)
            .eraseToAnyPublisher()
    }

    var body: some Scene {
        WindowGroup {
            ContentView()
                .environmentObject(externalDisplayContent)
                .onReceive(
                    screenDidConnectPublisher,
                    perform: screenDidConnect
                )
                .onReceive(
                    screenDidDisconnectPublisher,
                    perform: screenDidDisconnect
                )
        }
    }

    private func screenDidConnect(_ screen: UIScreen) {
        //  Coming soon…
        externalDisplayContent.isShowingOnExternalDisplay = true
    }

    private func screenDidDisconnect(_ screen: UIScreen) {
        //  Coming soon…
        externalDisplayContent.isShowingOnExternalDisplay = false
    }

}

Manage screen connection

Create a UIWindow with the incoming screen's bounds as its frame. Setting a window's scene directly was deprecated in iOS 13 with the introduction of the UIScene API suite. So traverse the shared UIApplication's connectedScenes to find the first UIWindowScene whose screen is the incoming screen and set it as the new window's windowScene. Try saying that five times, quickly. Create a UIHostingController with the view for the external display and set it as the window's root view controller. All newly created windows are hidden by default so unhide the one created above. Store a reference to this window otherwise it will be deallocated as soon as this function returns.

//  ScreensApp.swift

@main struct ScreensApp: App {

    @ObservedObject var externalDisplayContent = ExternalDisplayContent()
    @State var additionalWindows: [UIWindow] = []

    

    private func screenDidConnect(_ screen: UIScreen) {
        let window = UIWindow(frame: screen.bounds)

        window.windowScene = UIApplication.shared.connectedScenes
            .first { ($0 as? UIWindowScene)?.screen == screen }
            as? UIWindowScene

        let view = ExternalView()
            .environmentObject(externalDisplayContent)
        let controller = UIHostingController(rootView: view)
        window.rootViewController = controller
        window.isHidden = false
        additionalWindows.append(window)
        externalDisplayContent.isShowingOnExternalDisplay = true
    }

    

}

Manage screen disconnection

Screen disconnection is relatively very trivial. Drop the reference the window of the discarded screen. In this example, remove the window from the stored array of windows.

//  ScreensApp.swift

@main struct ScreensApp: App {

    

    private func screenDidDisconnect(_ screen: UIScreen) {
        additionalWindows.removeAll { $0.screen == screen }
        externalDisplayContent.isShowingOnExternalDisplay = false
    }

}

Final result

Run the app in the simulator. When no external display is connected, an output label with the constantly updated label should be visible above the input text field.

Run the app without external display.

Connect an external display from I/O ❯ External Displays and watch the output label disappear from the device screen and appear on the external display. As you type on your screen, the text will update on the external display.

App when external display is connected.

What the external display shows.

Close the external display window and the output string will reappear on your device.

Conclusion

Providing a multi-screen experience is easier than ever in SwiftUI with its powerful state-sharing APIs and first-class support for Combine-backed reactive programming powered by closures. This project is available on GitHub for reference and extension.

Exploring the New UISegmentedControl Initializer Available in iOS 14

The first beta SDK for iOS 14 was released on Monday, 22 June 2020 alongside the first developer beta build of the OS, bringing with it numerous new APIs and enhancements. One such enhancement is the new UIActions-based init(frame:actions:) initializer.

Before we take a look at how the new initializer works, let's go through the process of setting up a UISegmentedControl in iOS 13. A segmented control can be initialized with an array of strings that will become the segment titles in the UI.

let control = UISegmentedControl(items: ["Red", "Green", "Blue"])

Next we need to add a target to the segmented control with a selector to be executed when the control's primary action is triggered.

control.addTarget(
    self,
    action: #selector(didTapSegmentedControl),
    for: .primaryActionTriggered
)

Finally, we need to define the @objc-marked function that we passed to the selector above.

@objc private func didTapSegmentedControl(_ sender: UISegmentedControl) {
    let textColor: UIColor
    switch sender.selectedSegmentIndex {
        case 0: textColor = .systemRed
        case 1: textColor = .systemGreen
        case 2: textColor = .systemBlue
        default: textColor = .label
    }
    label.textColor = textColor
}

To the developer who has been writing UIKit code since they started development, this seems routine. However, this three step dance provides a large surface area for bugs, e.g. the manually configured binding between the control titles and the switching over the selected segment index. I could change the order of the strings in the segmented control initializer and forget to update the switch case in the selector function and this will lead to inconsistency in the UI.

The new initializer in iOS 14 SDK is based on the UIAction type introduced last year in iOS 13. An UIAction has an initializer that takes a title string and a closure to be run when the action is invoked. Here is a sample action:

let action = UIAction(title: "Title") { (action: UIAction) -> Void in
    <#Code#>
}

UISegmentedControl's new initializer takes an array of such actions and generates segments with the given title and executes the actions' closures when their corresponding segments are selected.

enum Color: String, CaseIterable {

    case red = "Red"
    case green = "Green"
    case blue = "Blue"

    var uiColor: UIColor {
        switch self {
            case .red: return .systemRed
            case .green: return .systemGreen
            case .blue: return .systemBlue
        }
    }

}

let segmentedControl = UISegmentedControl(
    frame: <#CGRect#>,
    actions: Color.allCases.map { [unowned label] color in
        UIAction(title: color.rawValue) { _ in
            label.textColor = color.uiColor
        }
    }
)

This new initializer configures everything about the segmented control in one place, the number of segments, their titles, and the code to run when a segment is tapped, making your code more organised and readable. In addition to this initializer, UISegmentedControl has new APIs to access an action at a segment , set an action at a segment, and insert an action at a segment.

In conclusion, this move away from target-actions to closure-based UIActions is one more step in the direction of the Swiftification of UIKit. A UIAction-based initializer are also available for UIControl and all of its subclasses, including your custom subclasses not predefined by UIKit.

A demo app with both the old target-action as well as the new closure-based UIAction implementations is available on GitHub.