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

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


This, it seems to me, is the major flaw in the structure of the answers presented so far:

You would also want to chain calls to updateUserActivityState

That misses the whole point of updateUserActivityState, which is that it is called for you, automatically, for all view controllers whose userActivity is the same as the NSUserActivity returned by the scene delegate's stateRestorationActivity.

Thus, we automatically have a state-saving mechanism, and it remains only to devise a state-restoration mechanism to match. I will illustrate an entire architecture I've come up with.

NOTE: This discussion ignores multiple windows and it also ignores the original requirement of the question, that we be compatible with iOS 12 view controller-based state saving and restoration. My goal here is only to show how to do state saving and restoration in iOS 13 using NSUserActivity. However, only minor modifications are needed in order to fold this into a multiple-window app, so I think it answers the original question adequately.

Saving

Let's start with state-saving. This is entirely boilerplate. The scene delegate either creates the scene userActivity or passes the received restoration activity into it, and returns that as its own user activity:

func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {    guard let scene = (scene as? UIWindowScene) else { return }    scene.userActivity =        session.stateRestorationActivity ??        NSUserActivity(activityType: "restoration")}func stateRestorationActivity(for scene: UIScene) -> NSUserActivity? {    return scene.userActivity}

Every view controller must use its own viewDidAppear to share that user activity object. That way, its own updateUserActivityState will be called automatically when we go into the background, and it has a chance to contribute to the global pool of the user info:

override func viewDidAppear(_ animated: Bool) {    super.viewDidAppear(animated)    self.userActivity = self.view.window?.windowScene?.userActivity}// called automatically at saving time!override func updateUserActivityState(_ activity: NSUserActivity) {    super.updateUserActivityState(activity)    // gather info into `info`    activity.addUserInfoEntries(from: info)}

That's all! If every view controller does that, then every view controller that is alive at the time we go into background gets a chance to contribute to the user info of the user activity that will arrive next time we launch.

Restoration

This part is harder. The restoration info will arrive as session.stateRestorationActivity into the scene delegate. As the original question rightly asks: now what?

There's more than one way to skin this cat, and I've tried most of them and settled on this one. My rule is this:

  • Every view controller must have a restorationInfo property which is a dictionary. When any view controller is created during restoration, its creator (parent) must set that restorationInfo to the userInfo that arrived from session.stateRestorationActivity.

  • This userInfo must be copied out right at the start, because it will be wiped out from the saved activity the first time updateUserActivityState is called (that is the part that really drove me crazy working out this architecture).

The cool part is that if we do this right, the restorationInfo is set before viewDidLoad, and so the view controller can configure itself based on the info it put into the dictionary on saving.

Each view controller must also delete its own restorationInfo when it is done with it, lest it use it again during the app's lifetime. It must be used only the once, on launch.

So we must change our boilerplate:

var restorationInfo :  [AnyHashable : Any]?override func viewDidAppear(_ animated: Bool) {    super.viewDidAppear(animated)    self.userActivity = self.view.window?.windowScene?.userActivity    self.restorationInfo = nil}

So now the only problem is the chain of how the restorationInfo of each view controller is set. The chain starts with the scene delegate, which is responsible for setting this property in the root view controller:

func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {    guard let scene = (scene as? UIWindowScene) else { return }    scene.userActivity =        session.stateRestorationActivity ??        NSUserActivity(activityType: "restoration")    if let rvc = window?.rootViewController as? RootViewController {        rvc.restorationInfo = scene.userActivity?.userInfo    }}

Each view controller is then responsible not only for configuring itself in its viewDidLoad based on the restorationInfo, but also for looking to see whether it was the parent / presenter of any further view controller. If so, it must create and present / push / whatever that view controller, making sure to pass on the restorationInfo before that child view controller's viewDidLoad runs.

If every view controller does this correctly, the whole interface and state will be restored!

A bit more of an example

Presume we have just two possible view controllers: RootViewController and PresentedViewController. Either RootViewController was presenting PresentedViewController at the time we were backgrounded, or it wasn't. Either way, that information has been written into the info dictionary.

So here is what RootViewController does:

var restorationInfo : [AnyHashable:Any]?override func viewDidLoad() {    super.viewDidLoad()    // configure self, including any info from restoration info}// this is the earliest we have a window, so it's the earliest we can present// if we are restoring the editing windowvar didFirstWillLayout = falseoverride func viewWillLayoutSubviews() {    if didFirstWillLayout { return }    didFirstWillLayout = true    let key = PresentedViewController.editingRestorationKey    let info = self.restorationInfo    if let editing = info?[key] as? Bool, editing {        self.performSegue(withIdentifier: "PresentWithNoAnimation", sender: self)    }}// boilerplateoverride func viewDidAppear(_ animated: Bool) {    super.viewDidAppear(animated)    self.userActivity = self.view.window?.windowScene?.userActivity    self.restorationInfo = nil}// called automatically because we share this activity with the sceneoverride func updateUserActivityState(_ activity: NSUserActivity) {    super.updateUserActivityState(activity)    // express state as info dictionary    activity.addUserInfoEntries(from: info)}

The cool part is that the PresentedViewController does exactly the same thing!

var restorationInfo :  [AnyHashable : Any]?static let editingRestorationKey = "editing"override func viewDidLoad() {    super.viewDidLoad()    // configure self, including info from restoration info}// boilerplateoverride func viewDidAppear(_ animated: Bool) {    super.viewDidAppear(animated)    self.userActivity = self.view.window?.windowScene?.userActivity    self.restorationInfo = nil}override func updateUserActivityState(_ activity: NSUserActivity) {    super.updateUserActivityState(activity)    let key = Self.editingRestorationKey    activity.addUserInfoEntries(from: [key:true])    // and add any other state info as well}

I think you can see that at this point it's only a matter of degree. If we have more view controllers to chain during the restoration process, they all work exactly the same way.

Final notes

As I said, this is not the only way to skin the restoration cat. But there are problems of timing and of distribution of responsibilities, and I think this is the most equitable approach.

In particular, I do not hold with the idea that the scene delegate should be responsible for the whole restoration of the interface. It would need to know too much about the details of how to initialize each view controller along the line, and there are serious timing issues that are difficult to overcome in a deterministic way. My approach sort of imitates the old view controller-based restoration, making each view controller responsible for its child in the same way it would normally be.


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}


Based on more research and very helpful suggestions from the answer by Paulw11 I have come up with an approach that works for iOS 13 and iOS 12 (and earlier) with no duplication of code and using the same approach for all versions of iOS.

Note that while the original question and this answer don't use storyboards, the solution would be essentially the same. The only differences is that with storyboards, the AppDelegate and SceneDelegate wouldn't need the code to create the window and root view controller. And of course the ViewController wouldn't need code to create its views.

The basic idea is to migrate the iOS 12 code to work the same as iOS 13. This means that the old state restoration is no longer used. NSUserTask is used to save and restore state. This approach has several benefits. It lets the same code work for all iOS versions, it gets you really close to supporting handoff with virtually no additional effort, and it lets you support multiple window scenes and full state restoration using the same basic code.

Here's the updated AppDelegate.swift:

@UIApplicationMainclass AppDelegate: UIResponder, UIApplicationDelegate {    var window: UIWindow?    func application(_ application: UIApplication, willFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil) -> Bool {        print("AppDelegate willFinishLaunchingWithOptions")        if #available(iOS 13.0, *) {            // no-op - UI created in scene delegate        } else {            self.window = UIWindow(frame: UIScreen.main.bounds)            let vc = ViewController()            let nc = UINavigationController(rootViewController: vc)            self.window?.rootViewController = nc            self.window?.makeKeyAndVisible()        }        return true    }    func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {        print("AppDelegate didFinishLaunchingWithOptions")        return true    }    func application(_ application: UIApplication, viewControllerWithRestorationIdentifierPath identifierComponents: [String], coder: NSCoder) -> UIViewController? {        print("AppDelegate viewControllerWithRestorationIdentifierPath")        return nil // We don't want any UI hierarchy saved    }    func application(_ application: UIApplication, willEncodeRestorableStateWith coder: NSCoder) {        print("AppDelegate willEncodeRestorableStateWith")        if #available(iOS 13.0, *) {            // no-op        } else {            // This is the important link for iOS 12 and earlier            // If some view in your app sets a user activity on its window,            // here we give the view hierarchy a chance to update the user            // activity with whatever state info it needs to record so it can            // later be restored to restore the app to its previous state.            if let activity = window?.userActivity {                activity.userInfo = [:]                ((window?.rootViewController as? UINavigationController)?.viewControllers.first as? ViewController)?.updateUserActivityState(activity)                // Now save off the updated user activity                let wrap = NSUserActivityWrapper(activity)                coder.encode(wrap, forKey: "userActivity")            }        }    }    func application(_ application: UIApplication, didDecodeRestorableStateWith coder: NSCoder) {        print("AppDelegate didDecodeRestorableStateWith")        // If we find a stored user activity, load it and give it to the view        // hierarchy so the UI can be restored to its previous state        if let wrap = coder.decodeObject(forKey: "userActivity") as? NSUserActivityWrapper {            ((window?.rootViewController as? UINavigationController)?.viewControllers.first as? ViewController)?.restoreUserActivityState(wrap.userActivity)        }    }    func application(_ application: UIApplication, shouldSaveApplicationState coder: NSCoder) -> Bool {        print("AppDelegate shouldSaveApplicationState")        if #available(iOS 13.0, *) {            return false        } else {            // Enabled just so we can persist the NSUserActivity if there is one            return true        }    }    func application(_ application: UIApplication, shouldRestoreApplicationState coder: NSCoder) -> Bool {        print("AppDelegate shouldRestoreApplicationState")        if #available(iOS 13.0, *) {            return false        } else {            return true        }    }    // 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")    }}

Under iOS 12 and earlier, the standard state restoration process is now only used to save/restore the NSUserActivity. It's not used to persist the view hierarchy any more.

Since NSUserActivity doesn't conform to NSCoding, a wrapper class is used.

NSUserActivityWrapper.swift:

import Foundationclass NSUserActivityWrapper: NSObject, NSCoding {    private (set) var userActivity: NSUserActivity    init(_ userActivity: NSUserActivity) {        self.userActivity = userActivity    }    required init?(coder: NSCoder) {        if let activityType = coder.decodeObject(forKey: "activityType") as? String {            userActivity = NSUserActivity(activityType: activityType)            userActivity.title = coder.decodeObject(forKey: "activityTitle") as? String            userActivity.userInfo = coder.decodeObject(forKey: "activityUserInfo") as? [AnyHashable: Any]        } else {            return nil;        }    }    func encode(with coder: NSCoder) {        coder.encode(userActivity.activityType, forKey: "activityType")        coder.encode(userActivity.title, forKey: "activityTitle")        coder.encode(userActivity.userInfo, forKey: "activityUserInfo")    }}

Note that additional properties of NSUserActivity might be needed depending on your needs.

Here's the updated 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 }        window = UIWindow(windowScene: winScene)        let vc = ViewController()        let nc = UINavigationController(rootViewController: vc)        if let activity = connectionOptions.userActivities.first ?? session.stateRestorationActivity {            vc.restoreUserActivityState(activity)        }        self.window?.rootViewController = nc        window?.makeKeyAndVisible()    }    func stateRestorationActivity(for scene: UIScene) -> NSUserActivity? {        print("SceneDelegate stateRestorationActivity")        if let activity = window?.userActivity {            activity.userInfo = [:]            ((window?.rootViewController as? UINavigationController)?.viewControllers.first as? ViewController)?.updateUserActivityState(activity)            return activity        }        return nil    }}

And finally the updated ViewController.swift:

import UIKitclass ViewController: UIViewController {    var label: UILabel!    var count: Int = 0 {        didSet {            if let label = self.label {                label.text = "\(count)"            }        }    }    var timer: Timer?    override func viewDidLoad() {        print("ViewController viewDidLoad")        super.viewDidLoad()        view.backgroundColor = .green        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.userActivity?.needsSave = true        })        self.label.text = "\(count)"    }    override func viewDidAppear(_ animated: Bool) {        super.viewDidAppear(animated)        let act = NSUserActivity(activityType: "com.whatever.View")        act.title = "View"        self.view.window?.userActivity = act    }    override func viewWillDisappear(_ animated: Bool) {        super.viewWillDisappear(animated)        self.view.window?.userActivity = nil    }    override func viewDidDisappear(_ animated: Bool) {        print("ViewController viewDidDisappear")        super.viewDidDisappear(animated)        timer?.invalidate()        timer = nil    }    override func updateUserActivityState(_ activity: NSUserActivity) {        print("ViewController updateUserActivityState")        super.updateUserActivityState(activity)        activity.addUserInfoEntries(from: ["count": count])    }    override func restoreUserActivityState(_ activity: NSUserActivity) {        print("ViewController restoreUserActivityState")        super.restoreUserActivityState(activity)        count = activity.userInfo?["count"] as? Int ?? 0    }}

Note that all code related to the old state restoration has been removed. It has been replaced with the use of NSUserActivity.

In a real app, you would store all kinds of other details in the user activity needed to fully restore the app state on relaunch or to support handoff. Or store minimal data needed to launch a new window scene.

You would also want to chain calls to updateUserActivityState and restoreUserActivityState to any child views as needed in a real app.


matomo