What is the view controller lifecycle, and what belongs in each method?

7.3.1 · L1 · Interview

Predict first

In what order do the lifecycle methods run when a controller first appears?

import UIKit

nonisolated(unsafe) var log: [String] = []
final class VC: UIViewController {
    let name: String
    init(_ n: String) { name = n; super.init(nibName: nil, bundle: nil) }
    required init?(coder: NSCoder) { fatalError() }
    override func loadView() { log.append("\(name).loadView"); super.loadView() }
    override func viewDidLoad() { log.append("\(name).viewDidLoad") }
    override func viewWillAppear(_ a: Bool) { log.append("\(name).viewWillAppear") }
    override func viewIsAppearing(_ a: Bool) { log.append("\(name).viewIsAppearing") }
    override func viewDidAppear(_ a: Bool) { log.append("\(name).viewDidAppear") }
    override func viewWillDisappear(_ a: Bool) { log.append("\(name).viewWillDisappear") }
    override func viewDidDisappear(_ a: Bool) { log.append("\(name).viewDidDisappear") }
}
func settle() { RunLoop.current.run(until: Date(timeIntervalSinceNow: 0.3)) }

let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 390, height: 844))
let a = VC("A")
let nav = UINavigationController(rootViewController: a)
window.rootViewController = nav
window.makeKeyAndVisible()
settle()
print(log.joined(separator: " "))
  • A.viewDidLoad A.loadView A.viewWillAppear A.viewDidAppear
  • A.loadView A.viewDidLoad A.viewWillAppear A.viewIsAppearing A.viewDidAppear
  • A.viewWillAppear A.loadView A.viewDidLoad A.viewDidAppear

Predict first

In what order do the callbacks fire when B is pushed on top of A?

import UIKit

nonisolated(unsafe) var log: [String] = []
final class VC: UIViewController {
    let name: String
    init(_ n: String) { name = n; super.init(nibName: nil, bundle: nil) }
    required init?(coder: NSCoder) { fatalError() }
    override func loadView() { log.append("\(name).loadView"); super.loadView() }
    override func viewDidLoad() { log.append("\(name).viewDidLoad") }
    override func viewWillAppear(_ a: Bool) { log.append("\(name).viewWillAppear") }
    override func viewIsAppearing(_ a: Bool) { log.append("\(name).viewIsAppearing") }
    override func viewDidAppear(_ a: Bool) { log.append("\(name).viewDidAppear") }
    override func viewWillDisappear(_ a: Bool) { log.append("\(name).viewWillDisappear") }
    override func viewDidDisappear(_ a: Bool) { log.append("\(name).viewDidDisappear") }
}
func settle() { RunLoop.current.run(until: Date(timeIntervalSinceNow: 0.3)) }

let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 390, height: 844))
let a = VC("A")
let nav = UINavigationController(rootViewController: a)
window.rootViewController = nav
window.makeKeyAndVisible()
settle()
log = []
let b = VC("B")
nav.pushViewController(b, animated: false)
settle()
print(log.joined(separator: " "))
  • B.viewDidAppear first, then A.viewDidDisappear
  • B.loadView B.viewDidLoad A.viewWillDisappear B.viewWillAppear A.viewDidDisappear B.viewIsAppearing B.viewDidAppear
  • A.viewDidDisappear before B.viewWillAppear

Predict first

And when B is popped?

import UIKit

nonisolated(unsafe) var log: [String] = []
final class VC: UIViewController {
    let name: String
    init(_ n: String) { name = n; super.init(nibName: nil, bundle: nil) }
    required init?(coder: NSCoder) { fatalError() }
    override func loadView() { log.append("\(name).loadView"); super.loadView() }
    override func viewDidLoad() { log.append("\(name).viewDidLoad") }
    override func viewWillAppear(_ a: Bool) { log.append("\(name).viewWillAppear") }
    override func viewIsAppearing(_ a: Bool) { log.append("\(name).viewIsAppearing") }
    override func viewDidAppear(_ a: Bool) { log.append("\(name).viewDidAppear") }
    override func viewWillDisappear(_ a: Bool) { log.append("\(name).viewWillDisappear") }
    override func viewDidDisappear(_ a: Bool) { log.append("\(name).viewDidDisappear") }
}
func settle() { RunLoop.current.run(until: Date(timeIntervalSinceNow: 0.3)) }

let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 390, height: 844))
let a = VC("A")
let nav = UINavigationController(rootViewController: a)
window.rootViewController = nav
window.makeKeyAndVisible()
settle()
let b = VC("B")
nav.pushViewController(b, animated: false)
settle()
log = []
nav.popViewController(animated: false)
settle()
print(log.joined(separator: " "))
  • A.viewWillAppear A.viewDidAppear B.viewWillDisappear B.viewDidDisappear
  • B.viewWillDisappear A.viewWillAppear A.viewIsAppearing B.viewDidDisappear A.viewDidAppear

Mental model. Preparing a stage: build the set once (viewDidLoad), dress it before each performance (viewWillAppear), and clear it afterwards (viewWillDisappear).

The view is created lazily the first time something asks for view: loadView creates it (override only to build the view yourself, and never call super if you do), then viewDidLoad runs once, for one-time setup that does not depend on size or visibility: create subviews, constraints, bindings. Each time the screen is about to show, viewWillAppear runs, then viewIsAppearing (iOS 17, back-deployed to iOS 13) once the view is in the hierarchy with up-to-date traits and geometry, then viewDidAppear after it is on screen. Disappearing mirrors it: viewWillDisappear then viewDidDisappear.

Use the right hook. viewDidLoad is for one-time wiring, viewWillAppear for refreshing state that may have changed while away (and starting observation), viewIsAppearing for work that needs correct size and traits before the first frame, viewDidAppear for things that should happen when visible (analytics, starting an animation), and viewWillDisappear for saving and stopping what viewWillAppear started. viewWillLayoutSubviews and viewDidLayoutSubviews run whenever the view’s layout runs, possibly many times. In a navigation push the old screen’s will and the new screen’s will both happen before either did.

The view loads first (loadView, then viewDidLoad). Then will appear, is appearing and did appear.

Both will methods run before either did method, and the new screen loads its view first.

Popping mirrors pushing: the departing screen’s will first, the returning screen’s will next, then the two did methods.

Common traps

  • Reading view.bounds in viewDidLoad. The size is not final yet. Use viewIsAppearing or layout callbacks.
  • Assuming viewDidLoad runs every time. It runs once per view creation. Refresh in viewWillAppear.

They might follow up with

  • What is new about viewIsAppearing? It runs after the first layout pass and trait update, so geometry-dependent setup no longer has to wait for viewDidAppear and cause a visible jump.
  • When is the view unloaded? Not any more: since iOS 6 the view stays loaded for the life of the controller, so there is no viewDidUnload.

Related questions

  • 7.3.2 What is view controller containment, and what is the correct way to embed one controller in another?
  • 7.4.1 How does UINavigationController manage its stack, and where do the bar items and back button come from?
  • 6.2.1 What are the app states, and which callbacks fire as you move between them?

What is view controller containment, and what is the correct way to… →