UIViewController lifecycle under the hood

Boris Bugor
6 min readJul 14, 2023
Photo by Lauren Mancke on Unsplash

There are a lot of articles about UIViewController lifecycle. Most of them are aimed at remembering the order in which life cycle methods of UIViewController are called and at using this order for UI configuration and request network data

This article will not include another interpretation of this sequence, assuming that its reader is already familiar with the basics of the life cycle UIViewController.

My task is to show the work of the UIViewController life cycle from a different point of view, namely

  • show the source, responsible for the formation and start of the life cycle methods
  • cover more corner cases than just showing UIViewController via present / push methods;

The goals of this article are to develop a deeper understanding of how the life cycle works, which will avoid situations where life cycle methods are either called in the wrong way, or not called at all.

UIViewController

Let’s start with a small example in the playground and cover the call of the lifecycle methods with prints and initialize the instance of this UIViewController

class LCViewController: UIViewController {

init() {
super.init(nibName: nil, bundle: nil)
print(#function, type(of: self))
}

required init?(coder: NSCoder) {
super.init(coder: coder)
}

override func loadView() {
print(#function, type(of: self))
super.loadView()
}

override func viewDidLoad() {
print(#function, type(of: self))
super.viewDidLoad()
}

override func viewWillAppear(_ animated: Bool) {
print(#function, type(of: self))
super.viewWillAppear(animated)
}

override func viewDidAppear(_ animated: Bool) {
print(#function, type(of: self))
super.viewDidAppear(animated)
}
}

let vc = LCViewController()

In the console, we see that the object initialization block has been executed.

init() LCViewController

The rest of the lifecycle methods did not work, at least for the reason that the initialized object was not shown on the screen. But is this a necessary condition?

Let’s try to change the background color of the root view for the initialized object:

let vc = LCViewController()
vc.view.backgroundColor = .red

Console:

init() LCViewController
loadView() LCViewController
viewDidLoad() LCViewController

What do we see? By default, the view is initialized lazily, but after the color has been changed, the view is finally loaded (methods that signal the start and end of view loading are triggered).

Let’s move on.

We know that the lifecycle methods will work if the UIViewController is shown on the screen (for example, using the present or push methods), but is it possible to trigger the lifecycle of a UIViewController without showing it on the screen?

It is.

It is enough to initialize the UIWindow instance, and place the same UIViewController created above as the rootViewController.

let window = UIWindow()
let vc = LCViewController()
window.rootViewController = vc
window.makeKeyAndVisible()

Console:

init() LCViewController
loadView() LCViewController
viewDidLoad() LCViewController
viewWillAppear(_:) LCViewController
viewDidAppear(_:) LCViewController

In fact, this operation is enough to activate all the lifecycle methods responsible for the appearance of the UIViewController on the screen.

A similar operation is at the heart of launching the application with the installation of the starting UIViewController programmatically (without storyboard / xib) and is used under the hood in the case of storyboard / xib.

According to the documentation, UIWindow is the very source responsible for the formation and start of the UIViewController life cycle.

Let’s look at the rest of the navigation cases in UIKit:

UINavigationController

Let’s create a subclass UINavigationController:

class LCNavigationController: UINavigationController {

override init(rootViewController: UIViewController) {
super.init(rootViewController: rootViewController)
print(#function, type(of: self))
}

required init?(coder: NSCoder) {
super.init(coder: coder)
}

override func loadView() {
print(#function, type(of: self))
super.loadView()
}

override func viewDidLoad() {
print(#function, type(of: self))
super.viewDidLoad()
}

override func viewWillAppear(_ animated: Bool) {
print(#function, type(of: self))
super.viewWillAppear(animated)
}

override func viewDidAppear(_ animated: Bool) {
print(#function, type(of: self))
super.viewDidAppear(animated)
}
}

let vc = LCViewController()
let nc = LCNavigationController(rootViewController: vc)

After the usual initialization in the console, we observe:

init() LCViewController
loadView() LCNavigationController
viewDidLoad() LCNavigationController
init(rootViewController:) LCNavigationController

In the case of LCNavigationController, the methods responsible for loading the view fire before the initializer, contrary to the sequence of the UIViewController life cycle. This must be kept in mind when writing the base UINavigationController classes.

In case of setting as rootViewController:

let window = UIWindow()
let vc = LCViewController()
let nc = LCNavigationController(rootViewController: vc)
window.rootViewController = nc
window.makeKeyAndVisible()

Console:

init() LCViewController
loadView() LCNavigationController
viewDidLoad() LCNavigationController
init(rootViewController:) LCNavigationController
viewWillAppear(_:) LCNavigationController
loadView() LCViewController
viewDidLoad() LCViewController
viewWillAppear(_:) LCViewController
viewDidAppear(_:) LCNavigationController
viewDidAppear(_:) LCViewController

The results undermine the theory of a chain of direct forwarding of lifecycle events from UINavigationController to UIViewController. The UIViewController starts loading only after the UINavigationController’s viewWillAppear fires.

TabBarController

Let’s initialize UIViewController and UITabBarController:

class LCTabbarController: UITabBarController {
init() {
print(#function, type(of: self))
super.init(nibName: nil, bundle: nil)
}

required init?(coder: NSCoder) {
super.init(coder: coder)
}

override func loadView() {
print(#function, type(of: self))
super.loadView()
}

override func viewDidLoad() {
print(#function, type(of: self))
super.viewDidLoad()
}

override func viewWillAppear(_ animated: Bool) {
print(#function, type(of: self))
super.viewWillAppear(animated)
}

override func viewDidAppear(_ animated: Bool) {
print(#function, type(of: self))
super.viewDidAppear(animated)
}
}

let vc = LCViewController()
let tc = LCTabbarController()

Bottom line: UITabBarController starts loading the view immediately after initialization without additional manipulations:

init() LCViewController
init() LCTabbarController
loadView() LCTabbarController
viewDidLoad() LCTabbarController

Immediately after setting the UIViewController , the UITabBarController forces the view to be loaded by the active (by default, the first) UIViewController

let vc = LCViewController()
let secondVC = LCViewController()
let tc = LCTabbarController()
tc.setViewControllers([vc, secondVC], animated: true)

Console:

init() LCViewController
init() LCTabbarController
loadView() LCTabbarController
viewDidLoad() LCTabbarController
loadView() LCViewController
viewDidLoad() LCViewController

Immediately after setting the UITabBarController as the rootViewController of the UIWindow, the rest of the life cycle methods are triggered, which is responsible for the appearance of the UITabBarController / the first UIViewController:

let window = UIWindow()
let vc = LCViewController()
let secondVC = LCViewController()
let tc = LCTabbarController()
tc.setViewControllers([vc, secondVC], animated: true)
window.rootViewController = tc
window.makeKeyAndVisible()

Console:

init() LCViewController
init() LCViewController
init() LCTabbarController
loadView() LCTabbarController
viewDidLoad() LCTabbarController
loadView() LCViewController
viewDidLoad() LCViewController
viewWillAppear(_:) LCTabbarController
viewWillAppear(_:) LCViewController
viewDidAppear(_:) LCTabbarController
viewDidAppear(_:) LCViewController

Here we can talk about direct forwarding of life cycle events from UITabBarController to the active (by default, the first) UIViewController.

As for the other root UIViewControllers of the UITabBarController stack, setting data fetch requests to viewDidLoad will cause requests to be sent only after the user has directly navigated to the screen. You need to remember this when developing and, as an example, choose other life cycle methods to receive data or force the loading of the view of these UIViewController.

ContainerController

And the last thing worth considering within the framework of this article is the use of a container — child.

Let’s create a root container and inject another UIViewController into it

class ContainerController: UIViewController {

init() {
super.init(nibName: nil, bundle: nil)
print(#function, type(of: self))
}

required init?(coder: NSCoder) {
super.init(coder: coder)
}

override func loadView() {
print(#function, type(of: self))
super.loadView()
}

override func viewDidLoad() {
print(#function, type(of: self))
super.viewDidLoad()

let vc = LCViewController()
vc.willMove(toParent: self)
view.addSubview(vc.view)
vc.view.bounds = view.bounds
vc.view.autoresizingMask = [.flexibleHeight, .flexibleWidth]
vc.didMove(toParent: self)

}

override func viewWillAppear(_ animated: Bool) {
print(#function, type(of: self))
super.viewWillAppear(animated)
}

override func viewDidAppear(_ animated: Bool) {
print(#function, type(of: self))
super.viewDidAppear(animated)
}
}

As we have already checked, after the initialization of a regular UIViewController, only the init method fires.

let containerVC = ContainerController()

init() ContainerController

Let’s force the container to load the view:

let containerVC = ContainerController()
containerVC.view.backgroundColor = .red

Console:

init() ContainerController
loadView() ContainerController
viewDidLoad() ContainerController
init() LCViewController
loadView() LCViewController
viewDidLoad() LCViewController

Let’s place the container as rootViewController in UIWindow

let window = UIWindow()
let containerVC = ContainerController()
containerVC.view.backgroundColor = .red
window.rootViewController = containerVC
window.makeKeyAndVisible()

Console:

init() ContainerController
loadView() ContainerController
viewDidLoad() ContainerController
init() LCViewController
loadView() LCViewController
viewDidLoad() LCViewController
viewWillAppear(_:) ContainerController
viewWillAppear(_:) LCViewController
viewDidAppear(_:) LCViewController
viewDidAppear(_:) ContainerController

In general, we can talk about the similar behavior of ContainerController and UITabBarController. Forwarding lifecycle events between parent and child is direct.

Conclusion

  • The firing order of the lifecycle methods is not valid for all cases and may vary depending on the type of navigation.
  • UIWindow — the source, responsible for the formation and start of the UIViewController life cycle
  • View of UIViewController is initialized lazily, there are ways to force it to load
  • The firing order of the UINavigationController lifecycle methods can vary. Forwarding lifecycle events is not direct
  • In the case of UITabBarController, the view is loaded immediately after initialization. There is a direct redirect between the active UIViewController (the first UITabBarController in the stack) and the UITabBarController. By default, UITabBarController methods do not fire inactive UIViewController methods.
  • There is a direct forwarding of lifecycle events between an injected UIViewController and its container (on a parent-child basis).

And finally, don’t hesitate to contact me at Twitter, if you have any questions. Also, you can always buy me a coffee

--

--

Boris Bugor

Senior iOS Developer | Co-founder of VideoEditor: Reels & Stories | Founder of Flow: Diary, Calendar, Gallery | #ObjC | #Swift | #UIKit | #SwiftUI