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 callself.transition(from:to:)
which in certain layouts it leads to an unwanted animation. Instead, it now usesself.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?
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.
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 UIViewController
s 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.
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.
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.
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.
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.
And here is the 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, theMainViewController
. At the end, theDoneViewController
is removed from the view hierarchy and deallocated. - The
rootViewController
(i.e.RegisterViewController
) dismisses theUINavigationController
all the way to the just presentedMainViewController
, which takes us back to the initial state where theUIWindow
is showing theRegisterViewController
. - The
RegisterViewController
is being replaced by theMainViewController
as therootViewController
.
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.
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.
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
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.
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
- Preserving Your App’s Visual Appearance Across Launches, App Programming Guide for iOS
- Preserving and Restoring State, View Controller Programming Guide for iOS
- Implementing a Container View Controller, View Controller Programming Guide for iOS
- UIViewController, Documentation > UIKit > View Controllers
Related
- Replacing the UIWindow’s rootViewController while using a transition, appears to be leaking
- Memory and CPU Profiling
- Strategies for Handling App State Transitions
- Technical Q&A QA1561, How do I programmatically quit my iOS application?
- Technical Note TN2298, Using Unwind Segues
- UIPresentationController, Documentation > UIKit > View Controllers