Search code examples
iosios13state-restorationuiscene

UI state restoration for a scene in iOS 13 while still supporting iOS 12. No storyboards


This is a little long but it's not trivial and it takes a lot to demonstrate this issue.

I'm trying to figure out how to update a little sample app from iOS 12 to iOS 13. This sample app doesn't use any storyboards (other than the launch screen). It's a simple app that shows one view controller with a label that is updated by a timer. It uses state restoration so the counter starts from where it left off. I want to be able to support iOS 12 and iOS 13. In iOS 13 I want to update to the new scene architecture.

Under iOS 12 the app works just fine. On a fresh install the counter starts at 0 and goes up. Put the app in the background and then restart the app and the counter continues from where it left off. The state restoration all works.

Now I'm trying to get that working under iOS 13 using a scene. The problem I'm having is figuring out the correct way to initialize the scene's window and restore the navigation controller and the main view controller to the scene.

I've been through as much of the Apple documentation as I can find related to state restoration and scenes. I've watched WWDC videos related to windows and scenes (212 - Introducing Multiple Windows on iPad, 258 - Architecting Your App for Multiple Windows). But I seem to be missing a piece that puts it all together.

When I run the app under iOS 13, all of the expected delegate methods (both AppDelegate and SceneDelegate) are being called. The state restoration is restoring the nav controller and the main view controller but I can't figure out how to set the rootViewController of the scene's window since all of the UI state restoration is in the AppDelegate.

There also seems to be something related to an NSUserTask that should be used but I can't connect the dots.

The missing pieces seem to be in the willConnectTo method of SceneDelegate. I'm sure I also need some changes in stateRestorationActivity of SceneDelegate. There may also need to be changes in the AppDelegate. I doubt anything in ViewController needs to be changed.


To replicate what I'm doing, create a new iOS project with Xcode 11 (beta 4 at the moment) using the Single View App template. Set the Deployment Target to iOS 11 or 12.

Delete the main storyboard. Remove the two references in the Info.plist to Main (one at the top level and one deep inside the Application Scene Manifest. Update the 3 swift files as follows.

AppDelegate.swift:

import UIKit

@UIApplicationMain
class AppDelegate: UIResponder, UIApplicationDelegate {
    var window: UIWindow?

    func application(_ application: UIApplication, willFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil) -> Bool {
        print("AppDelegate willFinishLaunchingWithOptions")

        // This probably shouldn't be run under iOS 13?
        self.window = UIWindow(frame: UIScreen.main.bounds)

        return true
    }

    func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
        print("AppDelegate didFinishLaunchingWithOptions")

        if #available(iOS 13.0, *) {
            // What needs to be here?
        } else {
            // If the root view controller wasn't restored, create a new one from scratch
            if (self.window?.rootViewController == nil) {
                let vc = ViewController()
                let nc = UINavigationController(rootViewController: vc)
                nc.restorationIdentifier = "RootNC"

                self.window?.rootViewController = nc
            }

            self.window?.makeKeyAndVisible()
        }

        return true
    }

    func application(_ application: UIApplication, viewControllerWithRestorationIdentifierPath identifierComponents: [String], coder: NSCoder) -> UIViewController? {
        print("AppDelegate viewControllerWithRestorationIdentifierPath")

        // If this is for the nav controller, restore it and set it as the window's root
        if identifierComponents.first == "RootNC" {
            let nc = UINavigationController()
            nc.restorationIdentifier = "RootNC"
            self.window?.rootViewController = nc

            return nc
        }

        return nil
    }

    func application(_ application: UIApplication, willEncodeRestorableStateWith coder: NSCoder) {
        print("AppDelegate willEncodeRestorableStateWith")

        // Trigger saving of the root view controller
        coder.encode(self.window?.rootViewController, forKey: "root")
    }

    func application(_ application: UIApplication, didDecodeRestorableStateWith coder: NSCoder) {
        print("AppDelegate didDecodeRestorableStateWith")
    }

    func application(_ application: UIApplication, shouldSaveApplicationState coder: NSCoder) -> Bool {
        print("AppDelegate shouldSaveApplicationState")

        return true
    }

    func application(_ application: UIApplication, shouldRestoreApplicationState coder: NSCoder) -> Bool {
        print("AppDelegate shouldRestoreApplicationState")

        return true
    }

    // The following four are not called in iOS 13
    func applicationWillEnterForeground(_ application: UIApplication) {
        print("AppDelegate applicationWillEnterForeground")
    }

    func applicationDidEnterBackground(_ application: UIApplication) {
        print("AppDelegate applicationDidEnterBackground")
    }

    func applicationDidBecomeActive(_ application: UIApplication) {
        print("AppDelegate applicationDidBecomeActive")
    }

    func applicationWillResignActive(_ application: UIApplication) {
        print("AppDelegate applicationWillResignActive")
    }

    // MARK: UISceneSession Lifecycle

    @available(iOS 13.0, *)
    func application(_ application: UIApplication, configurationForConnecting connectingSceneSession: UISceneSession, options: UIScene.ConnectionOptions) -> UISceneConfiguration {
        print("AppDelegate configurationForConnecting")

        return UISceneConfiguration(name: "Default Configuration", sessionRole: connectingSceneSession.role)
    }

    @available(iOS 13.0, *)
    func application(_ application: UIApplication, didDiscardSceneSessions sceneSessions: Set<UISceneSession>) {
        print("AppDelegate didDiscardSceneSessions")
    }
}

SceneDelegate.swift:

import UIKit

@available(iOS 13.0, *)
class SceneDelegate: UIResponder, UIWindowSceneDelegate {
    var window: UIWindow?

    func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
        print("SceneDelegate willConnectTo")

        guard let winScene = (scene as? UIWindowScene) else { return }

        // Got some of this from WWDC2109 video 258
        window = UIWindow(windowScene: winScene)
        if let activity = connectionOptions.userActivities.first ?? session.stateRestorationActivity {
            // Now what? How to connect the UI restored in the AppDelegate to this window?
        } else {
            // Create the initial UI if there is nothing to restore
            let vc = ViewController()
            let nc = UINavigationController(rootViewController: vc)
            nc.restorationIdentifier = "RootNC"

            self.window?.rootViewController = nc
            window?.makeKeyAndVisible()
        }
    }

    func stateRestorationActivity(for scene: UIScene) -> NSUserActivity? {
        print("SceneDelegate stateRestorationActivity")

        // What should be done here?
        let activity = NSUserActivity(activityType: "What?")
        activity.persistentIdentifier = "huh?"

        return activity
    }

    func scene(_ scene: UIScene, didUpdate userActivity: NSUserActivity) {
        print("SceneDelegate didUpdate")
    }

    func sceneDidDisconnect(_ scene: UIScene) {
        print("SceneDelegate sceneDidDisconnect")
    }

    func sceneDidBecomeActive(_ scene: UIScene) {
        print("SceneDelegate sceneDidBecomeActive")
    }

    func sceneWillResignActive(_ scene: UIScene) {
        print("SceneDelegate sceneWillResignActive")
    }

    func sceneWillEnterForeground(_ scene: UIScene) {
        print("SceneDelegate sceneWillEnterForeground")
    }

    func sceneDidEnterBackground(_ scene: UIScene) {
        print("SceneDelegate sceneDidEnterBackground")
    }
}

ViewController.swift:

import UIKit

class ViewController: UIViewController, UIViewControllerRestoration {
    var label: UILabel!
    var count: Int = 0
    var timer: Timer?

    static func viewController(withRestorationIdentifierPath identifierComponents: [String], coder: NSCoder) -> UIViewController? {
        print("ViewController withRestorationIdentifierPath")

        return ViewController()
    }

    override init(nibName nibNameOrNil: String? = nil, bundle nibBundleOrNil: Bundle? = nil) {
        print("ViewController init")

        super.init(nibName: nibNameOrNil, bundle: nibBundleOrNil)

        restorationIdentifier = "ViewController"
        restorationClass = ViewController.self
    }

    required init?(coder: NSCoder) {
        print("ViewController init(coder)")

        super.init(coder: coder)
    }

    override func viewDidLoad() {
        print("ViewController viewDidLoad")

        super.viewDidLoad()

        view.backgroundColor = .green // be sure this vc is visible

        label = UILabel(frame: .zero)
        label.translatesAutoresizingMaskIntoConstraints = false
        label.text = "\(count)"
        view.addSubview(label)
        NSLayoutConstraint.activate([
            label.centerXAnchor.constraint(equalTo: view.centerXAnchor),
            label.centerYAnchor.constraint(equalTo: view.centerYAnchor),
        ])
    }

    override func viewWillAppear(_ animated: Bool) {
        print("ViewController viewWillAppear")

        super.viewWillAppear(animated)

        timer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true, block: { (timer) in
            self.count += 1
            self.label.text = "\(self.count)"
        })
    }

    override func viewDidDisappear(_ animated: Bool) {
        print("ViewController viewDidDisappear")

        super.viewDidDisappear(animated)

        timer?.invalidate()
        timer = nil
    }

    override func encodeRestorableState(with coder: NSCoder) {
        print("ViewController encodeRestorableState")

        super.encodeRestorableState(with: coder)

        coder.encode(count, forKey: "count")
    }

    override func decodeRestorableState(with coder: NSCoder) {
        print("ViewController decodeRestorableState")

        super.decodeRestorableState(with: coder)

        count = coder.decodeInteger(forKey: "count")
        label.text = "\(count)"
    }
}

Run this under iOS 11 or 12 and it works just fine.

You can run this under iOS 13 and on a fresh install of the app you get the UI. But any subsequent run of the app gives a black screen because the UI restored via state restoration isn't connected to the scene's window.

What am I missing? Is this just missing a line or two of code or is my entire approach to iOS 13 scene state restoration wrong?

Keep in mind that once I get this figured out the next step will be supporting multiple windows. So the solution should work for multiple scenes, not just one.


Solution

  • To support state restoration in iOS 13 you will need to encode enough state into the NSUserActivity:

    Use this method to return an NSUserActivity object with information about your scene's data. Save enough information to be able to retrieve that data again after UIKit disconnects and then reconnects the scene. User activity objects are meant for recording what the user was doing, so you don't need to save the state of your scene's UI

    The advantage of this approach is that it can make it easier to support handoff, since you are creating the code necessary to persist and restore state via user activities.

    Unlike the previous state restoration approach where iOS recreated the view controller hierarchy for you, you are responsible for creating the view hierarchy for your scene in the scene delegate.

    If you have multiple active scenes then your delegate will be called multiple times to save the state and multiple times to restore state; Nothing special is needed.

    The changes I made to your code are:

    AppDelegate.swift

    Disable "legacy" state restoration on iOS 13 & later:

    func application(_ application: UIApplication, viewControllerWithRestorationIdentifierPath identifierComponents: [String], coder: NSCoder) -> UIViewController? {
        if #available(iOS 13, *) {
    
        } else {
            print("AppDelegate viewControllerWithRestorationIdentifierPath")
    
            // If this is for the nav controller, restore it and set it as the window's root
            if identifierComponents.first == "RootNC" {
                let nc = UINavigationController()
                nc.restorationIdentifier = "RootNC"
                self.window?.rootViewController = nc
    
                return nc
            }
        }
        return nil
    }
    
    func application(_ application: UIApplication, willEncodeRestorableStateWith coder: NSCoder) {
        print("AppDelegate willEncodeRestorableStateWith")
        if #available(iOS 13, *) {
    
        } else {
        // Trigger saving of the root view controller
            coder.encode(self.window?.rootViewController, forKey: "root")
        }
    }
    
    func application(_ application: UIApplication, didDecodeRestorableStateWith coder: NSCoder) {
        print("AppDelegate didDecodeRestorableStateWith")
    }
    
    func application(_ application: UIApplication, shouldSaveApplicationState coder: NSCoder) -> Bool {
        print("AppDelegate shouldSaveApplicationState")
        if #available(iOS 13, *) {
            return false
        } else {
            return true
        }
    }
    
    func application(_ application: UIApplication, shouldRestoreApplicationState coder: NSCoder) -> Bool {
        print("AppDelegate shouldRestoreApplicationState")
        if #available(iOS 13, *) {
            return false
        } else {
            return true
        }
    }
    

    SceneDelegate.swift

    Create a user activity when required and use it to recreate the view controller. Note that you are responsible for creating the view hierarchy in both normal and restore cases.

    func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
        print("SceneDelegate willConnectTo")
    
        guard let winScene = (scene as? UIWindowScene) else { return }
    
        // Got some of this from WWDC2109 video 258
        window = UIWindow(windowScene: winScene)
    
        let vc = ViewController()
    
        if let activity = connectionOptions.userActivities.first ?? session.stateRestorationActivity {
            vc.continueFrom(activity: activity)
        }
    
        let nc = UINavigationController(rootViewController: vc)
        nc.restorationIdentifier = "RootNC"
    
        self.window?.rootViewController = nc
        window?.makeKeyAndVisible()
    
    
    }
    
    func stateRestorationActivity(for scene: UIScene) -> NSUserActivity? {
        print("SceneDelegate stateRestorationActivity")
    
        if let nc = self.window?.rootViewController as? UINavigationController, let vc = nc.viewControllers.first as? ViewController {
            return vc.continuationActivity
        } else {
            return nil
        }
    
    }
    

    ViewController.swift

    Add support for saving and loading from an NSUserActivity.

    var continuationActivity: NSUserActivity {
        let activity = NSUserActivity(activityType: "restoration")
        activity.persistentIdentifier = UUID().uuidString
        activity.addUserInfoEntries(from: ["Count":self.count])
        return activity
    }
    
    func continueFrom(activity: NSUserActivity) {
        let count = activity.userInfo?["Count"] as? Int ?? 0
        self.count = count
    }