4.1 — UIKit overview & UIViewController lifecycle
Opening scenario
You inherit a 6-year-old iOS codebase. 400,000 lines, 80% UIKit, 20% recently bolted-on SwiftUI screens. A senior leaves, you’re now lead. The first bug report: “Sometimes the search bar shows the previous query when I push back to it.” You open SearchViewController. There’s viewDidLoad, viewWillAppear, viewDidAppear, viewWillDisappear, viewDidDisappear. There’s a deinit you can’t reach because of a retain cycle. There’s loadView overridden for no good reason. There’s sceneDidEnterBackground doing things viewWillDisappear should.
Knowing UIKit lifecycle cold is the difference between “I’ll dig in” and “I’m out of my depth.” Even in 2026, every major iOS app you’d want to work at — Uber, Lyft, Airbnb, Robinhood, Spotify, Notion, Instagram — has a substantial UIKit core. SwiftUI is the future for new code; UIKit is the present for production code.
| Era | What you’d write today |
|---|---|
| New feature, new app | SwiftUI |
| New feature, existing UIKit app | UIKit, or UIHostingController to embed SwiftUI |
| Maintenance / debugging | UIKit |
| Performance-critical custom UI | UIKit (often) |
| Job interview | Both, fluently |
Concept → Why → How → Code
What UIKit actually is
UIKit is Apple’s imperative UI framework, shipped since iOS 2 (2008). It’s a set of Objective-C-based classes (with Swift overlays) that handle:
- Window and view hierarchy (
UIWindow,UIView,UIViewController) - Layout (
Auto Layout,UIStackView) - Touch handling and gestures (
UIGestureRecognizer) - Navigation (
UINavigationController,UITabBarController) - Lists (
UITableView,UICollectionView) - Text input (
UITextField,UITextView) - Drawing (
Core Graphics,CALayer) - App lifecycle (
UIApplication,UIScene,UISceneDelegate)
Underneath, every SwiftUI view eventually becomes UIKit views at render time on iOS. SwiftUI is sugar; UIKit is the substance.
The app & scene lifecycle (iOS 13+)
Before iOS 13: one UIApplicationDelegate, one window, one process state.
iOS 13+ introduced scenes to support multi-window on iPad and (now) iPhone via Stage Manager. The mental model:
UIApplication
├── AppDelegate ← process-level events
└── UIScene(s) ← per-window events
└── SceneDelegate
└── UIWindow
└── rootViewController (UIViewController)
└── view (UIView)
└── child views, controllers
Events you’ll wire:
// AppDelegate.swift — process-level
class AppDelegate: UIResponder, UIApplicationDelegate {
func application(_ app: UIApplication,
didFinishLaunchingWithOptions opts: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
// Initialize crash reporting, analytics, dependency container.
// Runs once per process launch.
return true
}
func application(_ app: UIApplication, configurationForConnecting scene: UISceneSession,
options: UIScene.ConnectionOptions) -> UISceneConfiguration {
UISceneConfiguration(name: "Default", sessionRole: scene.role)
}
}
// SceneDelegate.swift — per-window
class SceneDelegate: UIResponder, UIWindowSceneDelegate {
var window: UIWindow?
func scene(_ scene: UIScene, willConnectTo session: UISceneSession,
options: UIScene.ConnectionOptions) {
guard let windowScene = scene as? UIWindowScene else { return }
let window = UIWindow(windowScene: windowScene)
window.rootViewController = RootViewController()
window.makeKeyAndVisible()
self.window = window
}
func sceneDidBecomeActive(_ scene: UIScene) { /* refresh data */ }
func sceneWillResignActive(_ scene: UIScene) { /* pause timers */ }
func sceneDidEnterBackground(_ scene: UIScene) { /* save state, schedule background work */ }
func sceneWillEnterForeground(_ scene: UIScene) { /* prepare to become active */ }
}
Rule of thumb:
- Process-level work (analytics SDK init, dependency container, third-party SDK setup):
AppDelegate - Window-level work (UI setup, refresh visible state):
SceneDelegate
UIViewController — the lifecycle you’ll be tested on
UIViewController is the workhorse. Its lifecycle in chronological order:
init → loadView → viewDidLoad → viewWillAppear → viewIsAppearing → viewDidAppear
↓
(user interacts)
↓
viewWillDisappear → viewDidDisappear → (deallocated, eventually)
Each method, what runs there:
class ProfileViewController: UIViewController {
// 1. init — pure data setup, no UI
init(user: User) {
self.user = user
super.init(nibName: nil, bundle: nil)
}
// 2. loadView — RARELY override. Default creates self.view = UIView()
// Override only if you want a custom container view as root.
override func loadView() {
view = CustomGradientView()
}
// 3. viewDidLoad — view exists but is offscreen. Run once.
// Add subviews, set constraints, configure data sources, register cells.
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .systemBackground
setupSubviews()
setupConstraints()
loadInitialData()
}
// 4. viewWillAppear — runs every time the view is about to show.
// Refresh data that might have changed elsewhere.
// Subscribe to notifications you only need while visible.
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
navigationController?.setNavigationBarHidden(false, animated: animated)
refreshIfNeeded()
}
// 5. viewIsAppearing — iOS 17+. View has layout (frames valid), but isn't on screen yet.
// Best place to update UI that depends on view size.
override func viewIsAppearing(_ animated: Bool) {
super.viewIsAppearing(animated)
updateLayoutForSize(view.bounds.size)
}
// 6. viewDidAppear — view is fully on screen.
// Kick off animations, analytics screen-view events.
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
Analytics.track(.screenView("profile"))
}
// 7. viewWillDisappear — about to leave the screen.
// Resign first responders, pause autoplay, save in-progress edits.
override func viewWillDisappear(_ animated: Bool) {
super.viewWillDisappear(animated)
view.endEditing(true)
saveDraft()
}
// 8. viewDidDisappear — fully off screen.
// Cancel network tasks, unsubscribe from notifications.
override func viewDidDisappear(_ animated: Bool) {
super.viewDidDisappear(animated)
cancellable?.cancel()
}
// 9. deinit — VC is being deallocated.
// Cleanup of things not cleaned up by ARC: removeObserver, invalidate timers.
deinit {
NotificationCenter.default.removeObserver(self)
timer?.invalidate()
}
required init?(coder: NSCoder) { fatalError("Use init(user:)") }
}
The single most common bug: doing work in viewDidLoad that should be in viewWillAppear. viewDidLoad runs once. If your VC is in a navigation stack, you push another VC, then pop back — viewDidLoad does not run again. Only viewWillAppear/viewDidAppear do. This is why the bug from the opening scenario happened: the search query was set in viewDidLoad instead of reset in viewWillAppear.
Lifecycle in containment
UIViewController containment (custom parent VCs) requires manual lifecycle plumbing:
func addChildVC(_ child: UIViewController) {
addChild(child) // 1. parent claims child
view.addSubview(child.view) // 2. add view
child.view.frame = view.bounds // 3. position
child.didMove(toParent: self) // 4. notify lifecycle complete
}
func removeChildVC(_ child: UIViewController) {
child.willMove(toParent: nil) // 1. notify lifecycle starting
child.view.removeFromSuperview() // 2. remove view
child.removeFromParent() // 3. break relationship
}
Forgetting didMove(toParent:) or willMove(toParent: nil) means the child VC won’t receive its appearance callbacks. Classic head-scratcher bug.
Storyboards vs nibs vs programmatic UI
Three ways to set up UIViewController UI:
| Approach | Best for | Gotcha |
|---|---|---|
| Storyboards | Beginner tutorials, prototypes | Merge conflicts on a team are brutal |
.xib files | Reusable component views | Modern teams have largely abandoned |
| Programmatic | Production apps at scale | More boilerplate but versioning works |
By 2026, the dominant choice at scale is programmatic UIKit (or SwiftUI). Storyboards survive in legacy apps and Apple’s templates. Almost every senior interview will assume programmatic.
Set up a programmatic VC:
class WelcomeViewController: UIViewController {
private let titleLabel = UILabel()
private let actionButton = UIButton(configuration: .filled())
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .systemBackground
configureUI()
}
private func configureUI() {
titleLabel.text = "Welcome"
titleLabel.font = .preferredFont(forTextStyle: .largeTitle)
titleLabel.adjustsFontForContentSizeCategory = true
titleLabel.translatesAutoresizingMaskIntoConstraints = false
actionButton.setTitle("Continue", for: .normal)
actionButton.addAction(UIAction { [weak self] _ in
self?.continueTapped()
}, for: .touchUpInside)
actionButton.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(titleLabel)
view.addSubview(actionButton)
NSLayoutConstraint.activate([
titleLabel.centerXAnchor.constraint(equalTo: view.centerXAnchor),
titleLabel.centerYAnchor.constraint(equalTo: view.centerYAnchor),
actionButton.topAnchor.constraint(equalTo: titleLabel.bottomAnchor, constant: 24),
actionButton.centerXAnchor.constraint(equalTo: view.centerXAnchor),
])
}
private func continueTapped() { /* push next VC */ }
}
State restoration & memory warnings
Two callbacks you’ll rarely override but should know about:
override func didReceiveMemoryWarning() {
super.didReceiveMemoryWarning()
imageCache.removeAllObjects()
}
// State restoration (iOS 13+) — encode state into NSUserActivity
override func updateUserActivityState(_ activity: NSUserActivity) {
activity.userInfo = ["lastViewedItemID": currentItemID]
}
didReceiveMemoryWarning only fires under genuine memory pressure (rare on modern devices). State restoration matters for iPad multi-window and Stage Manager.
viewIsAppearing — iOS 17’s gift
Before iOS 17, there was a frustrating gap: in viewWillAppear, layout wasn’t done yet, so view.bounds returned stale values; in viewDidAppear, you were already animating. viewIsAppearing lands in between — layout has happened, but you’re still off screen. Use it for:
- Calculating layout-dependent values before the user sees them
- Setting initial scroll positions on
UIScrollView/UICollectionView - Updating compositional layouts that depend on
view.bounds.width
If you’re targeting iOS 17+, prefer viewIsAppearing over viewWillAppear for any layout-dependent work.
In the wild
- Instagram is famously a hybrid: feeds and complex screens in UIKit with custom
UICollectionViewlayouts, newer settings and profile screens in SwiftUI. They publicly discussIGListKit(their UIKit list framework). - Airbnb maintains Epoxy — an open-source declarative UIKit framework that pre-dates SwiftUI. Used across the app for performant lists. Worth reading their architecture posts.
- Uber rewrote their rider app for the third time in 2018 (engineering post). UIKit throughout, with strict separation of view controllers and a custom RIBs architecture.
- Robinhood ships UIKit at scale; their charts are custom
CALayerdrawing for performance — SwiftUI’s Charts framework can’t keep up at 120fps with many data points. - Apple’s own apps (Mail, Calendar, Notes, Maps) are still substantially UIKit in 2026, with SwiftUI for newer surfaces.
Common misconceptions
- “UIKit is dead, just learn SwiftUI.” Wrong by any reasonable timeline. Every iOS job at a non-startup involves UIKit maintenance. Even greenfield apps interop with UIKit for things SwiftUI can’t do (e.g., custom keyboards, complex text rendering, AVPlayer’s advanced overlays).
- “
viewDidLoadruns every time the VC appears.” No. Once per VC instance lifetime. This catches juniors weekly. - “You should call
superlast in lifecycle methods.” No. Callsuperfirst inviewDidLoad/viewWillAppear/viewDidAppear; last inviewWillDisappear/viewDidDisappearif you have cleanup that depends onsuper’s state. Convention: super first unless you have a specific reason. - “Storyboards are required.” No. Programmatic UI has been Apple-supported since iOS 2.
- “
AppDelegateandSceneDelegatedo the same thing now.” They overlap, but distinct: app-level (process) vs scene-level (window). Multi-window apps especially need both.
Seasoned engineer’s take
UIKit is a 17-year-old framework with the accumulated wisdom and crust of every iOS pattern Apple ever shipped. Learning it well means learning the why of iOS UI more than the what of SwiftUI:
- The view hierarchy is a tree of
CALayers;UIViewis mostly a layer wrapper with touch handling - Layout is a two-phase process: invalidation (
setNeedsLayout) then resolution (layoutSubviews) - Everything ultimately runs on the main thread; off-main UIKit work crashes in Debug, undefined behavior in Release
- Memory leaks usually come from retain cycles between VCs and closures — always
[weak self]in long-lived closures stored on the VC
Three habits that separate good UIKit engineers from great ones:
- Know which lifecycle method to use without thinking — the difference between
viewWillAppearandviewIsAppearingis dialect, not concept - Profile in Instruments before optimizing — UIKit lets you write slow code that looks identical to fast code
- Read Apple’s UIKit sample code —
UIKitCatalog, Apple’s WWDC sessions. Every senior should have read them.
TIP: Add
print("\(type(of: self)).\(#function)")to every lifecycle method in a “scratch” VC and step through navigation in the simulator. You’ll cement the order in your head better than any blog post.
WARNING: Never call
self.viewininit. It triggersloadViewimmediately, defeating lazy view creation and frequently causing crashes if your init isn’t done setting up dependencies. Always wait forviewDidLoad.
Interview corner
Junior-level: “What’s the difference between viewDidLoad and viewWillAppear?”
viewDidLoad runs once per VC instance, right after the view is loaded into memory. It’s for one-time setup: adding subviews, registering cells, setting up data sources. viewWillAppear runs every time the view is about to be shown — every push, pop-back, modal dismiss. Use it for refreshing data that might have changed elsewhere.
Mid-level: “You push VC B from VC A, then pop back to A. Which of A’s lifecycle methods are called, in order?”
viewWillAppear → viewIsAppearing (iOS 17+) → viewDidAppear. Not viewDidLoad — A’s view is already loaded. When B was pushed, A got viewWillDisappear → viewDidDisappear.
Senior-level: “How would you architect a UIKit codebase to be testable and ready for incremental SwiftUI adoption?”
- View controllers stay thin: input handling + lifecycle, nothing else
- All business logic in plain Swift services injected via initializer (no singletons in VCs)
- View models or presenters between VC and services for testability without UIKit
- Coordinator pattern for navigation so VCs don’t know what comes next
- New screens wrapped in
UIHostingControllerfor SwiftUI, embedded via standard containment APIs - Shared design tokens and components in a Swift Package consumed by both UIKit and SwiftUI sides
- Targets split:
AppCore(no UIKit),AppUIKit,AppSwiftUI,AppRoot(composition)
Red flag in candidates: “I just use SwiftUI.” Means they’ve never maintained a real codebase. Every shop above 10 engineers has UIKit somewhere.
Lab preview
You’ll build a real UIKit app — a news reader with UITableView, URLSession, pull-to-refresh, and proper lifecycle plumbing — in Lab 4.1.