Human above all, with pathos, weaknesses and grumpy at times. Speak for myself; think out loud. Direct, seemingly hard faced. Urged to fix things. Am fortunate.
qnoid

How to replace the `rootViewController` of the `UIWindow` in iOS

Approximately 11’ minutes reading time

This is an advanced topic. It requires extensive background knowledge and experience. Unless you feel confident with what’s being discussed here, consider and be mindful of intricacies and unknown unknowns when implementing presentation transitions, state preservation and restoration on iOS.

Errata

18 February 2019, commit “7d3cccf

  • The RootViewController had its Container View as a subview rather than being the view. Though technically is allowed, in this case it is not a requirement.
  • The RootViewController.viewDidLayoutSubviews() used to call self.transition(from:to:) which in certain layouts it leads to an unwanted animation. Instead, it now uses self.switch(source:destination:) to position its child view controller.

Table of Contents

Introduction

Have you ever needed to build an app that:

  • expects the user to go through a lengthy registration process then land on a “home screen”?
  • has an extensive tutorial that the user can go through before they start using the app?
  • goes through a series of on boarding screens, possibly keeping track of user progress?

Registration Flow

In the above example, once the user has gone through registration, you want to discard every view controller involved in the sequence as well as replacing the rootViewController with a new UIViewController that comes after registration.

Main View Controller

Notice how there is no use case where the user can navigate back to registration. There is no “Back” or “Dismiss” button in the MainViewController that can act as the root. For that reason, you should consider it best practise to discard any UIViewControllers and their respective views so as to bring overall memory usage down and establish a new baseline when a user has registered.

View Controllers’ Presentation and transitions

Let’s start with firing a Notification when the user selects “Done”.

class DoneViewController: UIViewController {

	static let NotificationDone = NSNotification.Name(rawValue: "Done")

	@IBAction func didTouchUpInsideDone(_ sender: Any) {
		NotificationCenter.default.post(name: DoneViewController.NotificationDone, object: nil)
	}
}

Since the AppDelegate holds a reference to the UIWindow and by extension the rootViewController, it can act as an observer. Upon firing, it merely sets the rootViewController property to the new one.

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

	NotificationCenter.default.addObserver(self, selector:#selector(done(notification:)),name:DoneViewController.NotificationDone,object: nil)
	
	return true
}
    
@objc func done(notification: Notification) {
	let storyboard = UIStoryboard(name: "Main", bundle: Bundle(for: type(of: self)))
	self.window?.rootViewController = storyboard.instantiateInitialViewController()
}

Let’s see how that looks.

Notice how there is no transition between the DoneViewController and the MainViewController. This plays a far more important role that it currently seems as you’ll see later. Let’s peak behind the scenes on the view hierarchy.

First Attempt View Hierarchy

Even though the code replaces the rootViewController for the window, notice how the DoneViewController is still in the view hierarchy under a UITransitionView seemingly owned by the UIWindow. Here is a close look at the memory graph.

First Attempt Memory Graph

It looks like the RegisterViewController and all of its children are still very much present. Let’s try something else now. Before replacing the rootViewController, use a presentation transition to present the view controller we are interested in.

@objc func done(notification: Notification) {
    let storyboard = UIStoryboard(name: "Main", bundle: Bundle(for: type(of: self)))
    
    guard let viewController = storyboard.instantiateInitialViewController() else {
        return
    }

    let presentedViewController = self.window?.rootViewController?.presentedViewController
    
    presentedViewController?.present(viewController, animated: true) {
        self.window?.rootViewController = viewController
    }
}

From UIViewController.present(_:animated:completion:), emphasis mine.

In a horizontally regular environment, the view controller is presented in the style specified by the modalPresentationStyle property. In a horizontally compact environment, the view controller is presented full screen by default.

Since an iPhone, in portrait, is a horizontally compact environment, “the views belonging to the presenting view controller are removed after the presentation completes.” In this case, the presentedViewController (i.e. the DoneViewController) becomes the presentingViewController to the MainViewController, hence the DoneViewController will be removed from the view hierarchy.

Second Attempt View Hierarchy

The view hierarchy indeed shows only the MainViewController present. Still, the UITransitionView should give you a clue as to what to expect in the memory graph.

Second Attempt Memory Graph

The DoneViewContoller is still very much allocated. The RegisterViewController as well as all of the UIViewController instances are also allocated (not seen in the screenshot). Finally, let’s dismiss the whole view hierarchy, as part of presenting the MainViewController, before replacing the rootViewController.

From UIViewController.dismiss(animated:completion:), emphasis mine.

If you present several view controllers in succession, thus building a stack of presented view controllers, calling this method on a view controller lower in the stack dismisses its immediate child view controller and all view controllers above that child on the stack.

@objc func done(notification: Notification) {
    let storyboard = UIStoryboard(name: "Main", bundle: Bundle(for: type(of: self)))
    
    guard let viewController = storyboard.instantiateInitialViewController() else {
        return
    }

    let presentedViewController = self.window?.rootViewController?.presentedViewController
    
    presentedViewController?.present(viewController, animated: true) {
        self.window?.rootViewController?.dismiss(animated: false) {
            self.window?.rootViewController = viewController
        }
    }
}

Here is the view hierarchy; no UITransitionView in the window.

Third Attempt View Hierarchy

And here is the memory graph.

Third Attempt Memory Graph

Well, that looks promising. Let’s see how it looks. The video on the right gives you a freeze frame.

What’s happening here? In an effort to remove every UIViewController from the view hierarchy and memory graph, the code completely ignores the presentation. The present and dismiss calls are happening independently and on separate transition contexts.

Let’s break it down.

  • The DoneViewController is presenting, modally, the MainViewController. At the end, the DoneViewController is removed from the view hierarchy and deallocated.
  • The rootViewController (i.e. RegisterViewController) dismisses the UINavigationController all the way to the just presented MainViewController, which takes us back to the initial state where the UIWindow is showing the RegisterViewController.
  • The RegisterViewController is being replaced by the MainViewController as the rootViewController.

State Preservation and Restoration

Before looking into how to best replace the rootViewController there is one more thing to consider.

Up until now, we’ve only been talking about replacing the rootViewController and its descendants as part of an “initial” launch. As long as the app isn’t terminated, upon switching to it, its view will be restored. Still, the change in the view hierarchy is not permanent. As soon as the app is terminated by iOS due to memory pressure, the next time the user launches the app, the rootViewController will revert to RegisterViewController as if it was launched for the first time.

This is where state preservation and restoration feature comes in. One thing to keep in mind is that every ancestor UIViewController, including the one whose state you want preserved and restored must have its restorationIdentifier set.

View Controllers with restoration identifiers set

The simplest way to test preservation and restoration is via Xcode. Launch the app, navigate to the view controller with a restorationIdentifier to preserve. Then:

  • Put the app into the background
  • Stop the app’s execution by pressing the stop button in Xcode
  • Launch the app

What you can’t see from the video is that shortly after I put the app in the background, I stop the execution via Xcode. Effectively the app is now considered to be terminated by iOS.

That looks good. Let’s try it on the MainViewController now.

You can briefly see the snapshot of the MainViewController that was taken just before the app moved to the background. Followed by the “Initial UI”.

Obviously the state preservation and restoration isn’t working as expected. I haven’t been able to gather enough information so I am only guessing that state preservation and restoration is coupled to the presentation and transition context. In other words, it’s not enough to simply set a new rootViewController and expect it to be restored. Whatever UIViewController to be restored possibly needs to be a descendant of the “Initial UI”. If you do know more, I would very much like to hear from you.

Container View controller

With what I know and can see so far, it appears that once the rootViewController has been set, UIKit does not provide for a way to replace it. As such, any presentation and transitions must originate from the rootViewController itself.

A Container View Controller is merely a UIViewController that introduces (or rather exposes) the concept of “child” view controllers. We came across the concept of a child view controller during presenting and dismissing view controllers.

Container view controllers are most often used to facilitate navigation and to create new user interface types based on existing content. In almost every way, a container view controller is like any other content view controller in that it manages a root view and some content.

One way to think about a container view controller is how a UITabBarController manages the presentation of the view for each UIViewController set in its viewControllers property. Every time you select a tab, the corresponding view appears into the window’s bounds and the control is passed to its respective UIViewController.

Thinking back to what we were set out to do. We want to bring into the window the view of another view controller while replacing an existing stack of view controllers. Which sounds awfully lot like how a UITabBarController behaves.

Container View Hierarchy

With the container view controller now acting as the root, the view of the RegisterViewController effectively is embedded in the view of the RootViewController. Visually, nothing has changed.

Now that a UIViewController manages the view hierarchy presented, we can replace notifications in favour of an unwind segue. Nothing to see in the DoneViewController. The unwind segue is defined in the storyboard.

class DoneViewController: UIViewController {

}

The AppDelegate has all of the code related to the notification removed. Here is the code for the RootViewController.

class RootViewController: UIViewController {

private func transition(from: UIViewController, to: UIViewController) {
    from.willMove(toParent: nil)
    self.addChild(to)
    
    self.transition(from: from, to: to, duration: self.transitionCoordinator?.transitionDuration ?? 0.4, options: [], animations: {
        
    }) { (finished) in
        from.removeFromParent()
        to.didMove(toParent: self)
    }
}

@IBAction func unwindToRootViewController(_ segue: UIStoryboardSegue) {

	let storyboard = UIStoryboard(name: "Main", bundle: Bundle(for: type(of: self)))
	
	if let registerViewController = self.children.first(where: { $0 is RegisterViewController }), let mainViewController = storyboard.instantiateInitialViewController() {
	self.transition(from: registerViewController, to: mainViewController)
	}
}

Let’s see it in action.

Note that as part of the transition, the DoneViewController is effectively dismissed. Hence the animation that “presents” the MainViewController is the same as if the call was mainViewController.dismiss(animated: true) while the DoneViewController was presented.

The respective view hierarchy and memory graph

Container View Hierarchy

Container Memory Graph

How about state preservation and restoration.

One thing to keep in mind when preserving a UIViewController that acts as the container, is that your code is responsible for saving any references to its child view controllers. Here is what it looks like.

override func encodeRestorableState(with coder: NSCoder) {
    super.encodeRestorableState(with: coder)
    
    self.children.forEach { child in
        if let restorationIdentifier = child.restorationIdentifier {
            coder.encode(child, forKey: restorationIdentifier)
        }
    }
}
    
override func decodeRestorableState(with coder: NSCoder) {
    super.decodeRestorableState(with: coder)
    
    if let mainViewController = coder.decodeObject(of: [MainViewController.self], forKey: "MainViewController") as? MainViewController {
        self.addChild(mainViewController)
    }
}

When the RootViewController is instantiated as part of the “Initial UI”, the RegisterViewController will also be present in its “children” property. Therefore as part of the layout, we need to make sure the MainViewController is the only one in the view hierarchy.

override func viewDidLayoutSubviews() {
    let registerViewController = self.children.first(where: { $0 is RegisterViewController })
    let mainViewController = self.children.first(where: { $0 is MainViewController })
    
    switch (registerViewController, mainViewController) {
    case (let registerViewController?, let mainViewController?):
        self.switch(source: registerViewController, destination: mainViewController)
    default:
        break
    }
    
    super.viewDidLayoutSubviews()
}

private func `switch`(source: UIViewController, destination: UIViewController) {
        
    source.willMove(toParent: nil)

    destination.view.frame = self.view.bounds
    self.view.addSubview(destination.view)
        
    source.view.removeFromSuperview()
    source.removeFromParent()

    destination.didMove(toParent: self)
}

This time, the MainViewController is restored as expected.

For completeness, here are the visual hierarchy and memory graph of the app after the restore process is done.

Container View Hierarchy

Container Memory Graph

Considerations

Keep in mind that the user can, at any time, kill the app. At this point, the restoration process will not happen and the rootViewController will revert back to the one in the “Initial UI”. This will also happen if your app crashes.

ISTR that removing the app from the multitasking UI specifically disables state restoration, on the grounds that the user only does this when there’s something wrong with the app and thus it’s better to start from a clean slate. - Quinn “The Eskimo!”, Apple Developer Relations, Developer Technical Support, Core OS/Hardware

You should anticipate, embrace and gracefully handle your app reverting to its “Initial UI”.

As an example, in the case of a registration, it is possible that you store some authorization token to give access to the user. On startup, you can detect that and allow the user to login with the existing credentials.

One thing to consider is that if you decide to hardcode the decision as to what rootViewController to show, you effectively limit the choices you can give to a user to recover for a “bad state”. If the user cannot merely terminate the app, they will have to re-install.

Consider giving the user the ability to fallback in stages:

  • While the user is using the app:
    • to revert to a previous state; e.g. logout
  • By removing the app from the multitasking UI:
    • to remove any existing state, either through preservation or in memory; e.g. recover from a crash
  • By un-installing:
    • to erase any hard persisted data. e.g. an authorization token so as to start over.

Conclusion

tl;dr. You should use a Container View Controller as the rootViewController and swap one child UIViewController for another.

Should you wish to preserve the rootViewController, consider the native iOS support of preserving and restoring state.

Keep in mind that state preservation and restoration goes beyond what UIViewController is present. UIKit views can also have preservable state. As an example, a UIScrollView preserves its zoomScale, contentInset, and contentOffset properties, effectively “so that the content appears scrolled to the same position as before”.

That can be useful for persisting any registration steps as the user goes through, browses through a collection of photos or reads a long text.

I would like to thank @marc, @jsorge, @tekl of Seattle Xcoders for their input and for keeping me sane through this. Even though I am remote, Seattle Xcoders has welcomed me as if I am based in Seattle.

Shoutout to Andy. It may have taken 3 years but we did it!

One more thing

This is one of the many technical challenges that I am tackling as part of making Windmill on the iPhone the best experience it can be.

I have been busy working towards Windmill 3.0 which brings publishing of your apps during development.

You can download Windmill for free.

Sample Project

github.com/qnoid/rootViewController

References