UIViewController Lifecycle
Which callback fires when, and what work belongs in each one.
A view controller's lifecycle splits into two groups, and keeping them straight is most of the battle.
Loading happens once. loadView creates the view, viewDidLoad runs immediately after — one time, ever, for that instance.
Appearance happens every time. viewWillAppear, viewDidAppear, viewWillDisappear, viewDidDisappear fire on each transition on and off screen. Push a detail screen and come back, and the appearance callbacks run again while viewDidLoad does not.
Layout sits in between: viewWillLayoutSubviews and viewDidLayoutSubviews can run many times, whenever bounds change — rotation, keyboard, split view.
super first on the way in. Frame-dependent work belongs in layout, not viewDidLoad.final class DetailViewController: UIViewController {
override func viewDidLoad() { super.viewDidLoad() // Once. Build the hierarchy, set constraints, configure fonts and colours. view.addSubview(titleLabel) NSLayoutConstraint.activate(constraints) }
override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) // Every appearance. Refresh anything that may have changed while away. titleLabel.text = store.currentTitle navigationController?.setNavigationBarHidden(false, animated: animated) }
override func viewDidAppear(_ animated: Bool) { super.viewDidAppear(animated) // Transition is finished: safe to start animations, log the screen view, // or become first responder. analytics.screenViewed("detail") }
override func viewDidLayoutSubviews() { super.viewDidLayoutSubviews() // Bounds are final — set anything that depends on actual size. gradientLayer.frame = header.bounds }}
Show all 29 linesShow less
final class DetailViewController: UIViewController {
override func viewDidLoad() { super.viewDidLoad() // Once. Build the hierarchy, set constraints, configure fonts and colours. view.addSubview(titleLabel) NSLayoutConstraint.activate(constraints) }
override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) // Every appearance. Refresh anything that may have changed while away. titleLabel.text = store.currentTitle navigationController?.setNavigationBarHidden(false, animated: animated) }
override func viewDidAppear(_ animated: Bool) { super.viewDidAppear(animated) // Transition is finished: safe to start animations, log the screen view, // or become first responder. analytics.screenViewed("detail") }
override func viewDidLayoutSubviews() { super.viewDidLayoutSubviews() // Bounds are final — set anything that depends on actual size. gradientLayer.frame = header.bounds }}A view controller loads once through viewDidLoad, then cycles repeatedly through viewWillAppear, viewDidAppear, viewWillDisappear and viewDidDisappear as it moves on and off screen.
Loading runs exactly once — build the hierarchy here.
- Loading runs exactly once — build the hierarchy here.
- Appearance callbacks run on every transition on screen.
- Navigating away and back re-runs the appearance pair, never viewDidLoad.
- Layout can fire many times as bounds change — rotation, keyboard, split view.
Under the hood
view is a lazy property. Touching it anywhere — including someVC.view.backgroundColor = .red from outside — forces loadView and then viewDidLoad immediately, whether or not the controller is going on screen. That's the cause of the classic bug where viewDidLoad fires far earlier than expected because something read view while configuring.
Appearance callbacks are driven by the containing controller, so a custom container that forgets addChild/didMove(toParent:) will silently never forward them, and the child's viewWillAppear never runs.
viewDidLayoutSubviews can fire many times per transition. It is not the place for expensive work or for anything that itself triggers layout — that's how you get a loop.
deinit won't run if the controller is in a retain cycle, so cleanup you put there — invalidating a timer, removing an observer — silently never happens. That's why the safer home for teardown is viewDidDisappear.
Quick check
You push a detail screen from a list, then tap back. Which callbacks run on the list controller?
- viewDidLoad, then viewWillAppear and viewDidAppear
- viewWillAppear and viewDidAppear only
- Only viewDidAppear
- None; it was never removed from the hierarchy
Reveal answer
viewWillAppear and viewDidAppear only
The list controller was never deallocated — the navigation controller kept it in its stack — so it isn't reloaded and viewDidLoad doesn't run again. Only the appearance pair fires. This is exactly why refresh logic belongs in viewWillAppear: put it in viewDidLoad and returning from the detail screen shows stale data.
Interview question
What's the difference between viewDidLoad and viewWillAppear, and how do you decide which to use?
Think first, then reveal
viewDidLoad runs exactly once per instance, right after the view hierarchy is created. viewWillAppear runs every time the view is about to go on screen — including when you come back from a screen you pushed.
So the split is one-time construction versus repeated refresh. Adding subviews, activating constraints, setting fonts and colours, wiring up targets: all viewDidLoad, because doing it again would duplicate work and can duplicate subviews. Anything that could have changed while the screen was away goes in viewWillAppear — re-reading a model, updating a badge, restoring navigation bar state, reflecting a setting the user changed two screens deep.
The bug you get from mixing them up is very recognisable: put a data refresh in viewDidLoad and everything looks right on first launch, then the user edits something on a detail screen, comes back, and the list still shows the old value.
For work that should start only once the transition is actually finished — animations, screen-view analytics, becoming first responder — I'd use viewDidAppear, since the view is genuinely on screen by then.
What an interviewer listens for
- States once-per-instance versus every-appearance clearly
- Assigns construction to viewDidLoad and refresh to viewWillAppear
- Names the stale-data bug that results from confusing them
- Knows viewDidAppear is for post-transition work
Key takeaway
viewDidLoad once, appearance callbacks every time, layout whenever bounds change. Put construction in the first, refresh in the second, and anything geometry-dependent in the third.