What is the UIViewController lifecycle and when is each method called?

Walk through every important UIViewController lifecycle callback, explain its purpose, show the call order with print(#function), and clarify where to put subview setup, constraints, and observation

Answer

The core idea

UIViewController has a fixed sequence of callbacks that UIKit calls as it creates, shows, hides, and destroys a view controller's view. Understanding the order — and which callbacks repeat — is what separates a correct setup from one that leaks observers, duplicates subviews, or lays out against stale geometry.

The interview-ready sentence: UIKit calls lifecycle methods in a strict order — loadView, viewDidLoad, viewWillAppear, viewIsAppearing, viewDidAppear — and the appear/disappear pair fires every time the view enters or leaves the screen, not just once.

1. The full lifecycle with print(#function)

This snippet prints every major callback so you can see the exact call order in the console.

swift 5.2
import UIKit

class LifecycleViewController: UIViewController {

    // MARK: - View creation

    override func loadView() {
        super.loadView()
        print(#function) // "loadView()"
    }

    override func viewDidLoad() {
        super.viewDidLoad()
        print(#function) // "viewDidLoad()"
    }

    // MARK: - Appearing

    override func viewWillAppear(_ animated: Bool) {
        super.viewWillAppear(animated)
        print(#function) // "viewWillAppear(_:)"
    }

    override func viewIsAppearing(_ animated: Bool) {
        super.viewIsAppearing(animated)
        print(#function) // "viewIsAppearing(_:)"
    }

    override func viewDidAppear(_ animated: Bool) {
        super.viewDidAppear(animated)
        print(#function) // "viewDidAppear(_:)"
    }

    // MARK: - Layout

    override func viewWillLayoutSubviews() {
        super.viewWillLayoutSubviews()
        print(#function) // "viewWillLayoutSubviews()"
    }

    override func viewDidLayoutSubviews() {
        super.viewDidLayoutSubviews()
        print(#function) // "viewDidLayoutSubviews()"
    }

    // MARK: - Disappearing

    override func viewWillDisappear(_ animated: Bool) {
        super.viewWillDisappear(animated)
        print(#function) // "viewWillDisappear(_:)"
    }

    override func viewDidDisappear(_ animated: Bool) {
        super.viewDidDisappear(animated)
        print(#function) // "viewDidDisappear(_:)"
    }
}

On the first presentation the console prints:

plaintext
loadView()
viewDidLoad()
viewWillAppear(_:)
viewIsAppearing(_:)
viewWillLayoutSubviews()
viewDidLayoutSubviews()
viewDidAppear(_:)

2. What each method is for

loadView()

Creates the view hierarchy. UIKit calls this the first time self.view is accessed and no view exists yet. Override it only when you want to supply your own root UIView instead of loading one from a storyboard or nib.

viewDidLoad()

Called once after the view is loaded into memory. This is where you do one-time setup: adding subviews, creating constraints, configuring data sources, and registering notification observers that live for the controller's entire lifetime.

viewWillAppear(_:)

Called every time the view is about to become visible — on the initial presentation, on every return from a pushed controller, on every tab switch, and on every modal dismissal that reveals it. Use it for work that must refresh each time the screen appears: reloading data, syncing with a model that may have changed off-screen, or starting lightweight UI updates.

viewIsAppearing(_:) (iOS 13+)

Called after viewWillAppear but with up-to-date trait collections and geometry. Unlike viewWillAppear, the view's size, safe area insets, and traits are final here. This is the best place for layout-dependent configuration that needs accurate dimensions, such as adjusting scroll position or sizing a collection view header.

viewDidAppear(_:)

Called after the view is fully on screen and the transition animation has completed. Start work that should only happen when the user can actually see the result: beginning an animation, starting a timer, or triggering analytics.

viewWillDisappear(_:)

Called every time the view is about to be removed from the screen. Save uncommitted changes, pause ongoing media, or resign first responder here.

viewDidDisappear(_:)

Called after the view is fully off-screen. Stop expensive resources that should not run invisibly: invalidate timers, cancel network requests, or remove observers that only make sense while visible.

viewWillLayoutSubviews() / viewDidLayoutSubviews()

Called every time the view performs a layout pass — which can happen many times (rotation, keyboard, safe area changes). Avoid heavy work here; use these for fine-tuning frame-based geometry that Auto Layout cannot express.

3. Where to add subviews and constraints

The best place for addSubview and Auto Layout constraints is viewDidLoad. It runs once, the view exists, and constraints stay active for the lifetime of the controller.

swift 5.2
override func viewDidLoad() {
    super.viewDidLoad()

    view.addSubview(headerLabel)
    view.addSubview(tableView)

    NSLayoutConstraint.activate([
        headerLabel.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 16),
        headerLabel.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 16),
        headerLabel.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -16),

        tableView.topAnchor.constraint(equalTo: headerLabel.bottomAnchor, constant: 8),
        tableView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
        tableView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
        tableView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
    ])
}

If you need the view's final size to calculate something (for example, setting contentOffset on a scroll view), use viewIsAppearing(_:) or viewDidLayoutSubviews() instead — viewDidLoad fires before the layout engine has resolved geometry.

A common mistake is adding subviews in viewWillAppear. Because that method is called every time the view appears, you end up stacking duplicate subviews on each navigation pop or tab switch.

4. viewWillAppear and viewDidDisappear fire multiple times

These methods are not one-shot. Every navigation push/pop, tab switch, or modal present/dismiss cycles through them again:

swift 5.2
override func viewWillAppear(_ animated: Bool) {
    super.viewWillAppear(animated)
    tableView.reloadData() // ✅ Refreshes on every return
}

override func viewDidDisappear(_ animated: Bool) {
    super.viewDidDisappear(animated)
    locationManager.stopUpdatingLocation() // ✅ Stops when off-screen
}

A useful mental model: viewDidLoad runs once per load, the appear/disappear pair runs once per visibility transition. If the system unloads the view under memory pressure and the user returns, the entire sequence — loadView through viewDidAppear — starts over.

5. Why the super call is required — and when it is not

Apple's documentation states that you must call super for every lifecycle method so UIKit can perform its own bookkeeping — updating internal state, forwarding events to child view controllers, and managing the responder chain. Skipping super can cause silent bugs: child controllers not receiving appearance callbacks, trait changes not propagating, or the navigation bar animating incorrectly.

swift 5.2
override func viewWillAppear(_ animated: Bool) {
    // ❌ Forgetting super can break child controller forwarding
    fetchLatestData()
}

override func viewWillAppear(_ animated: Bool) {
    super.viewWillAppear(animated) // ✅ Always call super first
    fetchLatestData()
}

The one exception is loadView(). When you override loadView to assign your own root view, you must not call super.loadView(), because the default implementation loads a view from a storyboard or creates a plain UIView — both of which you are intentionally replacing:

swift 5.2
override func loadView() {
    // ⚠️ Do NOT call super.loadView() here
    self.view = MyCustomRootView()
}

If you override loadView but still want the default view (you just want to do something extra before viewDidLoad), then do call super.loadView().

Why it matters

  • Adding subviews in viewWillAppear instead of viewDidLoad creates duplicate views on every navigation return, wasting memory and causing visual artifacts.
  • Forgetting to stop timers or location updates in viewDidDisappear keeps expensive resources running while the screen is invisible, draining battery and triggering unnecessary work.
  • Skipping super in appearance methods silently breaks container-controller forwarding — child view controllers never learn they appeared, so their own lifecycle hooks never fire.
  • Reading view.bounds in viewDidLoad returns stale or zero geometry; layout-dependent calculations done there will be wrong until the first layout pass.

How to debug it

  1. Add print(#function) to every lifecycle method — the snippet in Section 1 reveals the exact call order and whether a method fires more often than expected.
  2. Set a symbolic breakpoint on -[UIViewController viewDidLoad] or the specific method you suspect is misbehaving. Xcode's stack trace shows which code path triggered it.
  3. Check child controllers — if you embed controllers via addChild/didMove(toParent:), verify they receive the same appearance sequence. Missing beginAppearanceTransition / endAppearanceTransition calls in custom containers are a common source of silent lifecycle breakage.

Interview angle

Start by listing the methods in order: loadViewviewDidLoadviewWillAppearviewIsAppearingviewDidAppear, with the symmetric disappear chain. Emphasize that viewDidLoad runs once and is the right place for subview and constraint setup, while the appear/disappear pair repeats on every visibility change. Mention the super rule — always call it except when replacing the root view in loadView. Bonus points for knowing viewIsAppearing(_:) (iOS 13+) as the first callback with accurate geometry and traits. Refer to UIViewController — Apple Developer Documentation for the full API surface.