05 · iOS Fundamentals¶
This module covers the core building blocks of an iOS app: the app lifecycle, view controllers, Auto Layout, and navigation. iOS code needs a simulator or device to actually run.
Environment note for this module
This machine has only the Xcode Command Line Tools (no full Xcode, no iOS SDK/simulator), so nothing in this module can be compiled or run here. The code below is hand-reviewed for correct, current UIKit/ SwiftUI API usage — verify it in Xcode on a machine with the iOS SDK before shipping it.
The app lifecycle¶
A modern iOS app (using the SwiftUI App protocol, the default since
Xcode 11) starts from a single entry point:
Under the hood this still drives the same lifecycle events as the older
UIApplicationDelegate world — scenePhase in SwiftUI exposes them:
struct ContentView: View {
@Environment(\.scenePhase) private var scenePhase
var body: some View {
Text("Hello, Task App")
.onChange(of: scenePhase) { oldPhase, newPhase in
switch newPhase {
case .active: print("App became active")
case .inactive: print("App became inactive")
case .background: print("App entered background")
@unknown default: break
}
}
}
}
.active → .inactive → .background is the normal path when a user
backgrounds the app (say, by swiping to the home screen); the reverse path
runs on return.
UIViewController basics (UIKit)¶
Projects that predate SwiftUI, or that need UIKit-only APIs, still use
UIViewController subclasses with a well-defined set of lifecycle hooks:
import UIKit
final class ProfileViewController: UIViewController {
private let nameLabel = UILabel()
override func viewDidLoad() {
super.viewDidLoad()
// one-time setup: build the view hierarchy
view.backgroundColor = .systemBackground
nameLabel.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(nameLabel)
NSLayoutConstraint.activate([
nameLabel.centerXAnchor.constraint(equalTo: view.centerXAnchor),
nameLabel.centerYAnchor.constraint(equalTo: view.centerYAnchor)
])
}
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
// runs every time this screen is about to become visible
nameLabel.text = "Ada Lovelace"
}
}
viewDidLoad runs once, when the view hierarchy is first created;
viewWillAppear/viewDidAppear run every time the screen becomes visible
again (e.g., after popping back from a pushed screen) — a common bug is
putting per-appearance setup (like refreshing data) in viewDidLoad, where
it only runs the first time.
Auto Layout with anchors¶
The NSLayoutConstraint anchor API above is the standard programmatic Auto
Layout approach — each anchor call describes one relationship, and
NSLayoutConstraint.activate turns a batch of them on together (more
efficient than activating one at a time):
let button = UIButton(type: .system)
button.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(button)
NSLayoutConstraint.activate([
button.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 16),
button.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -16),
button.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor, constant: -24),
button.heightAnchor.constraint(equalToConstant: 50)
])
translatesAutoresizingMaskIntoConstraints = false is required on every
view you plan to constrain manually — without it, UIKit's legacy
autoresizing-mask-to-constraints translation fights your explicit
constraints, a very common first bug.
Navigation: UINavigationController and NavigationStack¶
UIKit pushes/pops view controllers on a navigation stack:
let detail = ProfileViewController()
navigationController?.pushViewController(detail, animated: true)
SwiftUI's equivalent is declarative, driven by a NavigationStack and a
value type describing the destination:
struct ContactListView: View {
let names = ["Ada", "Grace", "Alan"]
var body: some View {
NavigationStack {
List(names, id: \.self) { name in
NavigationLink(name, value: name)
}
.navigationDestination(for: String.self) { name in
Text("Profile for \(name)")
}
.navigationTitle("Contacts")
}
}
}
navigationDestination(for:) maps a value type to the view that should be
pushed when a NavigationLink carrying that type is tapped — this
decouples "what can be navigated to" from "where in the list it's
triggered from," which scales better than UIKit's segue-based navigation
for larger apps.
Swift-specific traps¶
@mainon theAppstruct is the entire entry point — there's nomain.swiftorAppDelegate.swiftto also definemain()unless you opt into the UIKit app-delegate lifecycle explicitly via@UIApplicationDelegateAdaptor.- Forgetting
translatesAutoresizingMaskIntoConstraints = falseis the single most common Auto Layout bug for programmatic UIKit layouts — constraints silently conflict with the auto-generated ones. viewDidLoadvsviewWillAppear: data that can go stale (network results, user defaults) belongs inviewWillAppearor later, notviewDidLoad, which only fires once per view controller instance.- SwiftUI's
List(names, id: \.self)requiresnames' elements to be genuinely unique — duplicate strings produce undefined row identity and visual glitches; prefer anIdentifiablemodel type with a realidonce data isn't guaranteed-unique strings.
Cheat sheet¶
| Concept | UIKit | SwiftUI |
|---|---|---|
| Entry point | AppDelegate + SceneDelegate |
@main struct MyApp: App |
| Screen | UIViewController |
View (a struct) |
| One-time setup | viewDidLoad() |
.onAppear (careful: can rerun) / init |
| Layout | NSLayoutConstraint anchors |
Stacks (VStack/HStack) + modifiers |
| Push a screen | navigationController?.pushViewController |
NavigationLink + navigationDestination |
How It Actually Works¶
SwiftUI's View.body doesn't mutate a live view tree — it produces a new
lightweight value tree every time, and the framework diffs it. A View
in SwiftUI is a struct, not a UIView; each time state changes (a
@State, @Observable property, or scenePhase change), SwiftUI calls
body again to get a fresh description of what the UI should look like,
then diffs the new tree against the previous one, node by node, using each
view type's structural identity. Where two nodes are the "same" view at the
same position, it computes the minimal set of underlying UIView/CALayer
property updates rather than tearing down and rebuilding — this is why
SwiftUI views are cheap to recreate (they're just structs) while the actual
UIKit-backed render tree underneath changes only incrementally.
Identity, not equality, drives that diff. SwiftUI infers identity from
a view's position in the tree and its type by default (Text, Toggle,
etc. at index 2 of a given parent are considered "the same view" across
updates), which is exactly why List(names, id: \.self) with duplicate
strings breaks: SwiftUI can no longer tell which row is which between
diffs, so it may reuse/reorder the wrong row's underlying state. An
Identifiable model with a genuinely unique id gives the diff algorithm
a stable key independent of array position, so insertions/removals/reorders
update the correct rows instead of ones that merely happen to sit in the
same index.
@Observable (and its predecessor @Published/ObservableObject) work
by instrumenting property access, not by watching the whole object.
@Observable's macro-generated code registers a dependency only on the
specific stored properties a view's body actually reads during that
render pass; mutating an unread property later doesn't trigger a
re-render of that view. This fine-grained tracking is why replacing a
whole array-backed TaskStore naively (recreating it) triggers far more
re-diffing than mutating a single tracked property in place.
The UIKit lifecycle underneath is still real. viewDidLoad fires
exactly once per UIViewController instance, right after its view is
loaded into memory (which itself is lazy — accessing .view for the first
time is what triggers loadView/viewDidLoad, not init); scenePhase
transitions in SwiftUI are a declarative projection of the same
UIApplicationDelegate/UISceneDelegate callbacks UIKit apps have always
received, which is why @UIApplicationDelegateAdaptor lets you drop back
into the AppDelegate world without SwiftUI and UIKit fighting each other.
Exercise¶
Sketch (don't need to run) a SwiftUI TaskListView backed by an
@Observable class TaskStore holding [Task] (where Task has id,
title, isDone). Show: a List iterating the store's tasks with a
Toggle bound to each isDone, a toolbar + button that appends a new
Task, and a NavigationStack wrapping the whole thing with the title
"Tasks". Note in a comment which lifecycle event you'd use to load
persisted tasks when the view first appears.