Routing external events to a specific scene in an iPadOS app

43 views Asked by At

I have a SwiftUI and iPadOS app supporting multiple scenes and many launches. In this post, let's consider only two launch types:

  1. Normal launch (user taps on the app icon)
  2. File launch (user taps on the associated file)

I hope to include other launches like launched by quick action, launched by widget, launched by notification etc.

The App struct conformer is defined as follows:

@main
struct IOSFileAssociationMSApp: App {
    @UIApplicationDelegateAdaptor
    var appDelegate: AppDelegate
    
    var body: some Scene {
        // The default initial scene
        DefaultContentScene()
        
        // Launch file content scene
        FileContentScene()
            .handlesExternalEvents(matching: ["file launch"])
    
        // Scenes for other launch methods
    }

    init() {
        Log("IOSFileAssociationMSApp.init()")
    }
}

I have two scene delegates - DefaultContentSceneDelegate and FileContentSceneDelegate.

I'm setting one of them based on the type of launch in application(_:configurationForConnecting:options:).

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

    Log("application(_:configurationForConnecting:options:)")

    let sceneConfig: UISceneConfiguration = UISceneConfiguration(name: "mainWindow", sessionRole: connectingSceneSession.role)
        
    let urls: Set<UIOpenURLContext> = options.urlContexts
    if(urls.isEmpty) {
        Log("Loading default view...")
        sceneConfig.delegateClass = DefaultContentSceneDelegate.self
    } else {
        Log("Loading file view...")
        sceneConfig.delegateClass = FileContentSceneDelegate.self
    }
        
    return sceneConfig
}

When launched by tapping a file, the scene delegate is FileContentSceneDelegate (verified from the logs) and its lifecycle methods are getting invoked. But the UI is that of the DefaultContentScene, not the FileContentScene. It's weird. I am updating the ViewController in FileContentSceneDelegate, but the DefaultContentScene's ViewController is displayed.

I'm aware that by default, the scene defined first in the App struct is launched. But how can I ensure that when the app is launched by file, FileContentScene is launched? How to route external events to its specific scene?

Here is the rest of the code:

DefaultContentScene, DefaultContentSceneDelegate, DefaultContentView, DefaultContentViewController:

struct DefaultContentScene: Scene {
    var body: some Scene {
        WindowGroup {
            DefaultContentView()
        }
    }
    
    init() {
        Log("DefaultContentScene.init()")
    }
}

class DefaultContentSceneDelegate: UIResponder, UIWindowSceneDelegate {
    
    // Scene is constructed
    
    func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
        
        Log("DefaultContentSceneDelegate.scene(_:willConnectTo:options:)")
        
        Log("scene.session.persistentIdentifier = " + scene.session.persistentIdentifier)
        
        if (connectionOptions.urlContexts.isEmpty) {
            Log("Not launched using file!")
        } else {
            
            // Flow should not reach here.
            Log("Launched using file!")
            
            if (connectionOptions.urlContexts.count == 1) {
                let fileUrl: URL = connectionOptions.urlContexts.first!.url
                Log("fileUrl = " + fileUrl.path())
                StaticContext.fileUrl = fileUrl
            } else {
                Log("There are multiple url inputs!")
            }
        }
    }

    // Other scene delegate methods
}

// I'm using a ViewControllerRepresentable to attach a UIKit view to a SwiftUI view.
struct DefaultContentView: UIViewControllerRepresentable {
    
    init() {
        Log("DefaultContentView.init()")
    }
    
    func makeUIViewController(context: Context) -> some UIViewController {
        
        Log("DefaultContentView.makeUIViewController(_:)")
        
        let viewController: DefaultContentViewController = StaticContext.defaultContentViewController
        return viewController
    }
    
    func updateUIViewController(_ uiViewController: UIViewControllerType, context: Context) {
        
        Log("DefaultContentView.updateUIViewController(_:)")
    }
}

class DefaultContentViewController: UIViewController {
    
    override func viewDidLoad() {
        
        Log("DefaultContentViewController.viewDidLoad()")
        
        updateView()
    }
    
    func updateView() {
        
        let label: UILabel = UILabel()
        
        label.frame = CGRectMake(self.view.bounds.size.width/2,50,self.view.bounds.size.width, self.view.bounds.size.height)
        label.font = UIFont(name:"HelveticaNeue-Bold", size: 25.0)
        label.textAlignment = .center
        label.center = self.view.center
    
        label.text = StaticContext.displayMessage
        view.backgroundColor = .green
        
        // Remove previously added view.
        if(view.subviews.count != 0) {
            Log(String.init(format: "Number of subviews = %d", view.subviews.count))
            Log("Removing old views...")
            view.subviews[0].removeFromSuperview()
        }
        
        view.addSubview(label)
    }
}

Similarly, FileContentScene, FileContentSceneDelegate, FileContentView, FileContentViewController:

struct FileContentScene: Scene {
    var body: some Scene {
        WindowGroup {
            FileContentView()
        }
    }
    
    init() {
        Log("FileContentScene.init()")
    }
}

class FileContentSceneDelegate: UIResponder, UIWindowSceneDelegate {
    // Scene is constructed
    
    func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
        
        Log("FileContentSceneDelegate.scene(_:willConnectTo:options:)")
        
        Log("scene.session.persistentIdentifier = " + scene.session.persistentIdentifier)
        
        if (connectionOptions.urlContexts.isEmpty) {
            // Flow should not reach here
            Log("Not launched using file!")
        } else {
            
            Log("Launched using file!")
            
            for (index, urlContext) in connectionOptions.urlContexts.enumerated() {
                
                let url: URL = urlContext.url
                Log(String(format: "file[%d] = %@", index, url.path()))
                
                StaticContext.fileUrl = url
                StaticContext.fileContentViewController.updateView()
            }
        }
    }
    
    // Handle file launch
    func scene(_ scene: UIScene, openURLContexts URLContexts: Set<UIOpenURLContext>) {
        
        Log("FileContentSceneDelegate.scene(_:openURLContexts:)")
        
        for (index, urlContext) in URLContexts.enumerated() {
            
            let url: URL = urlContext.url
            Log(String(format: "file[%d] = %@", index, url.path()))
            
            StaticContext.fileUrl = url
            StaticContext.fileContentViewController.updateView()
        }
    }
}

struct FileContentView: UIViewControllerRepresentable {
    
    init() {
        Log("FileContentView.init()")
    }
    
    func makeUIViewController(context: Context) -> some UIViewController {
        
        Log("FileContentView.makeUIViewController(_:)")
        
        let viewController: FileContentViewController = StaticContext.fileContentViewController
        return viewController
    }
    
    func updateUIViewController(_ uiViewController: UIViewControllerType, context: Context) {
        
        Log("FileContentView.updateUIViewController(_:)")
    }
}

class FileContentViewController: UIViewController {
    
    override func viewDidLoad() {
        
        Log("viewDidLoad()")
        
        updateView()
    }
    
    func updateView() {
        
        let label: UILabel = UILabel()
        
        label.frame = CGRectMake(self.view.bounds.size.width/2,50,self.view.bounds.size.width, self.view.bounds.size.height)
        label.font = UIFont(name:"HelveticaNeue-Bold", size: 25.0)
        label.textAlignment = .center
        label.center = self.view.center
        
        if let file: URL = StaticContext.fileUrl {
            label.text = "File name = " + file.lastPathComponent
            view.backgroundColor = .cyan
        } else {
            // Flow shouldn't reach here
            label.text = "File name = nil"
            view.backgroundColor = .red
        }
        
        // Remove previously added view.
        if(view.subviews.count != 0) {
            Log(String.init(format: "Number of subviews = %d", view.subviews.count))
            Log("Removing old views...")
            view.subviews[0].removeFromSuperview()
        }
        
        view.addSubview(label)
    }
}

When I launch the app by tapping a file, I have the following logs:

[IOSFileAssociationMS]: IOSFileAssociationMSApp.init()
[IOSFileAssociationMS]: application(_:willFinishLaunchingWithOptions:)
[IOSFileAssociationMS]: RegisterForLifecycleEvents()
[IOSFileAssociationMS]: application(_:didFinishLaunchingWithOptions:)
[IOSFileAssociationMS]: application(_:configurationForConnecting:options:)
[IOSFileAssociationMS]: Loading file view...    // Perfect. The FileContentSceneDelegate is set.
[IOSFileAssociationMS]: DefaultContentScene.init() // Not sure why the DefaultContentScene is initialised?
[IOSFileAssociationMS]: FileContentScene.init()
[IOSFileAssociationMS]: DefaultContentView.init()
[IOSFileAssociationMS]: FileContentView.init()
[IOSFileAssociationMS]: FileContentSceneDelegate.scene(_:willConnectTo:options:)
[IOSFileAssociationMS]: Launched using file!
[IOSFileAssociationMS]: fileUrl = /Users/charan.karthick/Library/Developer/CoreSimulator/Devices/3BD55EE4-217F-4E06-887C-17C92BBC869E/data/Library/Mobile                   0ocuments/com~apple~CloudDocs/CSKFile.csk
[IOSFileAssociationMS]: FileContentSceneDelegate.sceneWillEnterForeground(_:)
[IOSFileAssociationMS]: Received NSNotificationName(_rawValue: UIApplicationWillEnterForegroundNotification)
[IOSFileAssociationMS]: DefaultContentView.makeUIViewController(_:) // Why? Despite the FileContentSceneDelegate invoked, DefaultContentView is used.
[IOSFileAssociationMS]: DefaultContentViewController.viewDidLoad()
[IOSFileAssociationMS]: DefaultContentView.updateUIViewController(_:)
[IOSFileAssociationMS]: FileContentSceneDelegate.sceneDidBecomeActive(_:)
[IOSFileAssociationMS]: Received NSNotificationName(_rawValue: UIApplicationDidBecomeActiveNotification)

Clearly, setting the SceneDelegate is not enough and I'm missing some other setting, but not sure what it is.

0

There are 0 answers