Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Implementing external monitor support in SwiftUI

I'm confused about implementing external monitor support via Airplay with SwiftUI.

In SceneDelegate.swift I'm using UIScreen.didConnectNotification observer and it actually detects a new screen being attached but I'm unable to assign a custom UIScene to the screen.

I found a few good examples using Swift with iOS12 and lower, but none of them work in SwiftUI, since the whole paradigm has been changed to use UIScene instead of UIScreen. Here's the list:

https://www.bignerdranch.com/blog/adding-external-display-support-to-your-ios-app-is-ridiculously-easy/

https://developer.apple.com/documentation/uikit/windows_and_screens/displaying_content_on_a_connected_screen

https://www.swiftjectivec.com/supporting-external-displays/

Apple even spoke about it last year

Perhaps something changed and now there is a new way to do this properly. Moreover, setting UIWindow.screen = screen has been deprecated in iOS13.

Has anyone already tried implementing an external screen support with SwiftUI. Any help is much appreciated.

like image 450
kernelpanic Avatar asked Sep 20 '19 18:09

kernelpanic


Video Answer


3 Answers

I modified the example from the Big Nerd Ranch blog to work as follows.

  1. Remove Main Storyboard: I removed the main storyboard from a new project. Under deployment info, I set Main interface to an empty string.

  2. Editing plist: Define your two scenes (Default and External) and their Scene Delegates in the Application Scene Manifest section of your plist.

    <key>UIApplicationSceneManifest</key>
    <dict>
        <key>UIApplicationSupportsMultipleScenes</key>
        <true/>
        <key>UISceneConfigurations</key>
        <dict>
            <key>UIWindowSceneSessionRoleApplication</key>
            <array>
                <dict>
                    <key>UISceneConfigurationName</key>
                    <string>Default Configuration</string>
                    <key>UISceneDelegateClassName</key>
                    <string>$(PRODUCT_MODULE_NAME).SceneDelegate</string>
                </dict>
            </array>
            <key>UIWindowSceneSessionRoleExternalDisplay</key>
            <array>
                <dict>
                    <key>UISceneDelegateClassName</key>
                    <string>$(PRODUCT_MODULE_NAME).ExtSceneDelegate</string>
                    <key>UISceneConfigurationName</key>
                    <string>External Configuration</string>
                </dict>
            </array>
        </dict>
    </dict>
  1. Edit View Controller to show a simple string:
class ViewController: UIViewController {

    override func viewDidLoad() {
        super.viewDidLoad()
        view.backgroundColor = .blue
        view.addSubview(screenLabel)
    }

    var screenLabel: UILabel = {
        let label = UILabel()
        label.textColor = UIColor.white
        label.font = UIFont(name: "Helvetica-Bold", size: 22)
        return label
    }()

    override func viewDidLayoutSubviews() {
        /* Set the frame when the layout is changed */
        screenLabel.frame = CGRect(x: 0,
                                y: 0,
                                width: view.frame.width - 30,
                                height: 24)
    }
}
  1. Modify scene(_:willConnectTo:options:) in SceneDelegate to display information in the main (iPad) window.
    func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
        // Use this method to optionally configure and attach the UIWindow `window` to the provided UIWindowScene `scene`.
        // If using a storyboard, the `window` property will automatically be initialized and attached to the scene.
        // This delegate does not imply the connecting scene or session are new (see `application:configurationForConnectingSceneSession` instead).
        guard let windowScene = (scene as? UIWindowScene) else { return }

        window = UIWindow(frame: windowScene.coordinateSpace.bounds)
        window?.windowScene = windowScene
        let vc = ViewController()
        vc.loadViewIfNeeded()
        vc.screenLabel.text = String(describing: window)
        window?.rootViewController = vc
        window?.makeKeyAndVisible()
        window?.isHidden = false
    }
  1. Make a scene delegate for your external screen. I made a new Swift file ExtSceneDelegate.swift that contained the same text as SceneDelegate.swift, changing the name of the class from SceneDelegate to ExtSceneDelegate.

  2. Modify application(_:configurationForConnecting:options:) in AppDelegate. Others have suggested that everything will be fine if you just comment this out. For debugging, I found it helpful to change it to:

    func application(_ application: UIApplication, configurationForConnecting connectingSceneSession: UISceneSession, options: UIScene.ConnectionOptions) -> UISceneConfiguration {

        // This is not necessary; however, I found it useful for debugging
        switch connectingSceneSession.role.rawValue {
            case "UIWindowSceneSessionRoleApplication":
                return UISceneConfiguration(name: "Default Configuration", sessionRole: connectingSceneSession.role)
            case "UIWindowSceneSessionRoleExternalDisplay":
                return UISceneConfiguration(name: "External Configuration", sessionRole: connectingSceneSession.role)
            default:
                fatalError("Unknown Configuration \(connectingSceneSession.role.rawValue)")
            }
    }
  1. Build and run the app on iOS. You should see an ugly blue screen with information about the UIWindow written. I then used screen mirroring to connect to an Apple TV. You should see a similarly ugly blue screen with different UIWindow information on the external screen.

For me, the key reference for figuring all of this out was https://onmyway133.github.io/blog/How-to-use-external-display-in-iOS/.

like image 141
Melissa Avatar answered Oct 12 '22 22:10

Melissa


Don't know about SwiftUI (I'm die hard ObjectiveC) but in iOS13 you handle application:configurationForConnectingSceneSession:options in the application delegate then look for [connectingSceneSession.role isEqualToString:UIWindowSceneSessionRoleExternalDisplay]

In there you create a new UISceneConfiguration and set its delegateClass to a UIWindowSceneDelegate derived class of your choice (the one you want to manage content on that external display.)

I reckon you can also associate UIWindowSceneSessionRoleExternalDisplay with your UIWindowSceneDelegate in the info.plist file (but I prefer coding it!)

like image 35
Scotty Avatar answered Oct 12 '22 23:10

Scotty


I was trying the same thing in my SceneDelegate, but then I realized that UISceneSession is being defined in UIAppDelegate.application(_:configurationForConnecting:options:), which is called when an external screen connects, just like UIScreen.didConnectNotification. So I added the following code to that existing method:

func application(_ application: UIApplication, configurationForConnecting connectingSceneSession: UISceneSession, options: UIScene.ConnectionOptions) -> UISceneConfiguration {
    self.handleSessionConnect(sceneSession: connectingSceneSession, options: options)
    return UISceneConfiguration(name: "Default Configuration", sessionRole: connectingSceneSession.role)
}

func handleSessionConnect(sceneSession: UISceneSession, options: UIScene.ConnectionOptions) {
    let scene = UIWindowScene(session: sceneSession, connectionOptions: options)
    let win = UIWindow(frame: scene.screen.bounds)
    win.rootViewController = UIHostingController(rootView: SecondView())
    win.windowScene = scene
    win.isHidden = false
    managedWindows.append(win)
  }

The second screen is connecting correctly. My only uncertainty is that application(_:didDiscardSceneSessions:) doesn't seem to get called, so I'm not sure how best to manage the windows as they disconnect.

** Follow-up Edit **

I realize that I can use the original UIScreen.didDisconnectNotification to listen for disconnects.

func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
    NotificationCenter.default.addObserver(forName: UIScreen.didDisconnectNotification, object: nil, queue: nil) { (notification) in
      if let screen = notification.object as? UIScreen {
        self.handleScreenDisconnect(screen)
      }
    }
    return true
  }

func handleScreenDisconnect(_ screen: UIScreen) {
    for window in managedWindows {
      if window.screen == screen {
        if let index = managedWindows.firstIndex(of: window) {
          managedWindows.remove(at: index)
        }
      }
    }
  }

But since the actual scene session disconnect method isn't being called, I'm not sure if this is incorrect or unnecessary.

like image 38
shrug Avatar answered Oct 12 '22 23:10

shrug