UIKit essentials · core
UIKit essentials: view controllers and the responder chain
Most shipped iOS apps are UIKit at the root, so a senior engineer maintains it and leads the migration off it. The lifecycle, containment, delegation and the responder chain — the four things interviews assume you know.
By the end you will be able to
- Name every view controller lifecycle callback, say when each fires, and place work in the right one
- Explain view controller containment and why the three-step dance has three steps
- Trace an action up the responder chain, and explain delegation and target-action
A profile screen fetches the user's balance in viewDidLoad. The user opens it, navigates to Settings, then comes back. How many times has the balance been fetched, and is that what you wanted?
Why this unit exists
SwiftUI is what you write. UIKit is what you inherit.
Most iOS apps earning money today were started before SwiftUI existed, and they are UIKit at the root with SwiftUI screens arriving one at a time. That has three consequences for someone at senior or lead level. You will maintain UIKit code you did not write. You will be the person who plans and defends the migration. And you will be interviewed by engineers who spent a decade in UIKit and reach for it by reflex — cell reuse, the lifecycle, constraint priorities, why the delegate is weak.
This unit is deliberately not a UIKit course. It is the part a SwiftUI-first engineer must be able to maintain and answer for. U5-02 already covered crossing the boundary with UIViewRepresentable and UIHostingController; this is the other side of it.
The lifecycle
Eight callbacks, and knowing which fires when is the whole skill:
| Callback | Fires | Put here |
|---|---|---|
loadView() | Once, to create the view | Nothing, unless you build the view hierarchy in code and want no nib |
viewDidLoad() | Once, after the view exists | One-time setup: subviews, constraints, delegates, styling |
viewWillAppear(_:) | Every time, before it goes on screen | Refresh data, restart what you paused, update state that may have changed elsewhere |
viewIsAppearing(_:) | Every time, after it joins the hierarchy | Work needing accurate geometry or traits — added in iOS 17 exactly for this |
viewDidAppear(_:) | Every time, once it is on screen | Start animations, begin video, log the screen view |
viewWillDisappear(_:) | Every time, before it leaves | Save edits, resign first responder |
viewDidDisappear(_:) | Every time, after it leaves | Stop timers, cancel requests, release heavy resources |
viewDidLayoutSubviews() | Many times, after each layout pass | Work that genuinely needs final frames — and nothing expensive |
The single most useful line in that table: viewDidLoad says once, the appearance pair says every, and viewDidLayoutSubviews says many.
The model below runs the sequence for a screen that is pushed, popped back to, and left again:
viewDidLoad appears once at the top and never again. viewWillAppear appears twice. That difference is the pretest's bug, printed.
The three mistakes the lifecycle punishes
Laying out by hand in viewDidLoad. The view exists there, but its size is not final — it has not been added to a window or been through a layout pass, so reading view.bounds gives you a value from the nib or storyboard rather than from this device. Code that sets a frame or draws a gradient from bounds in viewDidLoad looks right on the simulator you built it on and wrong on every other screen size. Frames are final in viewDidLayoutSubviews.
Fetching in viewWillAppear with no guard. Correct for freshness, and it fires on every appearance — including the return from a modal the user cancelled two seconds ago. Follow what that costs: the user taps a filter sheet, cancels it, and the screen refetches the entire feed; the list flickers back to a spinner; their scroll position resets; and the row they were reading disappears. Guard it with something — a timestamp, a "dirty" flag set by whatever actually changed the data, or a pull-to-refresh the user controls.
Doing real work in viewDidLayoutSubviews. It runs after every layout pass: rotation, keyboard appearing, a label's text changing, a parent resizing. Put a network call or an expensive calculation there and it runs many times per second while the keyboard animates.
Read those two side by side and the shift is visible. UIKit hands you moments and expects you to act at each one; SwiftUI takes a description and works out the moments itself. .task is viewWillAppear plus viewDidDisappear plus cancellation, collapsed into one modifier — which is why C1-01's cancellation lesson mattered.
Containment: view controllers inside view controllers
A screen is often assembled from several view controllers. UIKit calls the parent a container, and adding a child takes three steps in a fixed order:
addChild(child) // 1. tell UIKit about the relationship
view.addSubview(child.view) // 2. put the view on screen
child.didMove(toParent: self) // 3. tell the child the move finished
Skip step 1 and the child never receives its own viewWillAppear/viewDidAppear, so a child whose refresh lives there silently never refreshes. Skip step 3 and the child is not told the transition completed. Removing reverses it, and the order flips: willMove(toParent: nil), then remove the view, then removeFromParent().
You use containment more often through UIKit's own containers than by writing one:
UINavigationControllerowns a stack of view controllers.pushViewController(_:animated:)adds one,popViewController(animated:)removes it. The stack is why the pretest's screen was still in memory: popping to it does not rebuild it.UITabBarControllerowns an array of view controllers and shows one at a time. Switching tabs does not deallocate the one you left, which is why a tab that starts a timer inviewDidAppearmust stop it inviewDidDisappear.
The direct SwiftUI parallels are NavigationStack and TabView from U3-01 — with the key difference that in SwiftUI the stack is a piece of state you own, which is what D1-04's navigation-as-state section is about.
How UIKit talks back
SwiftUI passes closures and bindings downward. UIKit predates both, and uses two older mechanisms you will meet constantly.
Delegation is a protocol handed to a collaborator so it can call you back. UITableViewDelegate, UITextFieldDelegate and URLSessionDelegate are all the same shape:
Two details in that code are the interview questions.
Why AnyObject? A protocol can only be held weak if it is class-bound, because only classes are reference-counted. Writing protocol PickerDelegate: AnyObject is what makes the next line legal.
Why weak? The screen owns the picker as a property, and the picker points back at the screen as its delegate. Two strong references in a circle is the retain cycle S3-02 built: neither can ever reach zero, so both leak, and the screen keeps handling callbacks long after the user left it. Making the back reference weak breaks the circle. The rule generalises — the child's reference to its parent is the one that should be weak.
Target-action is the older mechanism, for controls:
button.addTarget(self, action: #selector(saveTapped), for: .touchUpInside)
@objc private func saveTapped() {
// …
}
#selector names a method by its Objective-C name, which is why the method needs @objc. Get the name wrong and nothing fails at compile time — it crashes at tap time with "unrecognized selector sent to instance". That is one of the few places UIKit still trades a compile-time check for a runtime one, and it is worth recognising the crash on sight.
The responder chain
Some events are not sent to a specific object. When a control's action has a nil target, or when the user picks Copy from the edit menu, UIKit sends the message to the first responder — the object currently receiving input, usually the focused text field — and if that object does not handle it, the message travels up a chain of parents until something does.
The chain runs from the first responder through its superviews, to the view controller owning them, on to the window, and finally to the application. Every one of those is a UIResponder, which is where the shared behaviour lives.
next in that model is UIResponder.next, a real property you can print while debugging. Three things it explains:
- A tap that "does nothing" is usually an action reaching the end of the chain unhandled — exactly the third line of output. Nothing crashes and nothing logs.
becomeFirstResponder()andresignFirstResponder()move the starting point. CallingresignFirstResponderon a text field is how you dismiss the keyboard, because it stops being the first responder.- A tap landing on the wrong view usually is not a chain problem at all: a superview with
isUserInteractionEnabled = false, or a subview outside its parent's bounds, never gets the touch in the first place.
A UITabBarController has a Prices tab whose view controller starts a repeating timer in viewDidAppear to refresh quotes. Users report the battery draining. What is the bug?
An engineer writes protocol CellDelegate { func tapped() } and then weak var delegate: CellDelegate?. It does not compile. Why, and what is the deeper reason?
You know SwiftUI's .task, .onAppear and @State well. Map them onto this lesson. For each of viewDidLoad, viewWillAppear, viewDidDisappear and viewDidLayoutSubviews, name the closest SwiftUI equivalent — and then say honestly where the mapping breaks down and SwiftUI has no equivalent at all. Finish by explaining why .task removes a whole class of bug that UIKit engineers have to remember to avoid by hand.
You can now:
- Name every lifecycle callback and say whether it fires once, every time, or many times
- Place a fetch in the right callback, and guard it against refiring on every appearance
- Explain containment's three steps, and why a tab or a pushed screen stays in memory
- Write a delegate protocol correctly —
AnyObjectand aweakback reference — and say what each prevents - Trace an action up the responder chain, and recognise the "nothing happened" failure
Next up: Auto Layout and lists — constraint priorities, and the cell reuse that makes a table of ten thousand rows cost three cells.