What makes iOS hard is not the size of the frameworks. It is five decisions underneath them that everything else obeys: values are copied unless you ask for sharing, absence is written into the type, memory is freed by counting references rather than by a collector, one thread alone may touch the interface, and the interface is a description you hand over rather than an object you keep. Learn those and the frameworks stop feeling arbitrary. This guide is about them, and the instinct each one corrects.
Each chapter opens with the short version. Tap one to read the detail.
A view is a value you describe, not an object you hold
~2 min
Two instincts to unlearn: that assigning a value shares it, and that a view is a thing you keep and mutate. Structs copy; SwiftUI builds your view, reads body, throws it away.
A struct is a value type, copied on assignment and when passed to a function. A class is a reference type: assignment hands over another name for the same instance, which is why Swift gives classes an identity operator, ===, separate from equality.
struct Settings { var fontSize = 12 }
final class Session { var fontSize = 12 }
var a = Settings(); var b = a; b.fontSize = 20 // a is still 12
let x = Session(); let y = x; y.fontSize = 20 // x is 20 too — same object
Anyone arriving from a class-based language assumes the first case behaves like the second, and the mistake is quiet: you mutate your copy, the original never changes, and nothing warns you.
The second assumption is that a view is an object. A type conforming to View is a struct SwiftUI creates, reads body from, and discards, so there is no button to hold and no title to set. Because body may run any number of times, a network call or a state write inside it is a bug, not a style choice.
Modifiers follow the same logic — each returns a new view wrapping the old one, which is why order matters. .padding(8).background(.red) wraps the padded view, so the padding is red; swap them and the background wraps only the text.
Rule: to change what is on screen, find the value that describes it — never reach for the view.
State is the only input: identity decides what survives, motion included
~2 min
The trap is thinking @State is a local variable belonging to the struct. The storage is the framework's, keyed to the view's identity — which is why state survives a rebuild but vanishes when identity changes, and why animation belongs to the change.
Every value should have exactly one owner; everyone else gets a read-only copy or a binding. Mark the owner's property @State and SwiftUI manages the storage, which is why the value persists while the struct around it is rebuilt again and again. Handing that value to a child gives a snapshot it cannot write back — $value projects a Binding to the existing storage, which is what a child needs to edit. For reference-type models the view that creates an @Observable holds it in @State.
Identity is the half people miss. With no explicit id a view's identity is its position in the tree, and the arms of an if/else are different positions, so state does not travel between them. Changing .id() is documented as resetting identity, and with it the state — a feature when you mean it, a baffling reset when you do not.
Text(book.title)
.id(book.id) // a new id is a new view: old @State is gone
.task { await load(book) } // cancelled when the view goes away
.task starts asynchronous work as the view appears and SwiftUI cancels it when the view disappears or changes identity — unlike onAppear, which fires again each time.
Motion obeys the same rule, and here the misconception is imperative: there is no "animate this view" call. withAnimation sets an animation on the current transaction, so every difference caused by mutating state inside that closure animates, wherever it lands — a background color several levels up included. Narrow it with .animation(_:value:), which animates only when that value changes. Transitions catch people out for the same reason: .transition applies on insertion and removal, so the state change controlling it must itself sit inside withAnimation or nothing moves.
Rule: before debugging "my value reset" or "my animation did not run", ask what changed — state lives with identity, and motion lives with the change.
Layout is a negotiation, and a row is temporary
~2 min
The common assumption is that a parent sets its child's size and that .frame resizes a view. Neither is true: the parent proposes, the child chooses, the parent places. In a scroll view the proposal is unlimited and rows are reused.
Nothing in SwiftUI assigns a size. A container proposes and the child answers, and it learns a child's flexibility by proposing several sizes and reading the replies: zero returns the minimum, infinity the maximum, an unspecified proposal the ideal size.
That explains the modifiers people misread. .frame(width:height:) positions a view inside an invisible frame of that size — the child may answer with something else and simply be aligned within it. .fixedSize() is a counter-proposal, asking for the ideal size and letting the result overflow. A GeometryReader returns a flexible preferred size, taking whatever it is offered rather than hugging its content, which surprises everyone who drops one into a stack and watches the layout expand.
Scrolling changes the rules again: a scroll view offers unlimited space along its axis, so children must size themselves. A plain VStack builds every child immediately while LazyVStack and List create rows near the viewport — and lazy containers are documented as trading some layout correctness for speed, so start plain. Work inside a row body reruns on every recompute.
UIKit states the last assumption bluntly: a dequeued cell is not a fresh one, and it still holds the previous row's content.
override func prepareForReuse() {
super.prepareForReuse()
imageTask?.cancel() // else a late response paints a recycled row
imageTask = nil
}
Reset non-content state such as selection here; reset content where you configure the cell.
Rule: a row is a stage, not a seat — nothing it holds belongs to the item now showing.
Navigation is data
~2 min
You do not push a screen; you push a value, and navigationDestination(for:) maps that type to a view, so the route becomes state you can save and deep-link into. Two documented traps: the stack must see the destination, and dismiss acts where you declared it.
A NavigationStack presents views over a root. Give it a binding to your own array and it adds and removes items as views come and go, so the route is ordinary data — which is what makes restoring a session or opening a link possible.
The first trap is registration, and it fails silently: the documentation says not to put navigationDestination inside a lazy container such as a list, because those create children only as needed and the stack cannot see a destination that was never built.
The second trap is worse, because the code reads correctly.
struct SheetBody: View {
@Environment(\.dismiss) private var dismiss // declared INSIDE the sheet
var body: some View { Button("Done") { dismiss() } }
}
Declare dismiss in the presenting view instead, call it from that view's sheet closure, and the sheet does not close — the action belongs to the environment where it was declared, and on iPad it may close the window. A full-screen cover hides the usual way out too, so it must supply one.
Rule: if you cannot write down where the user is as a value, you cannot restore it, link to it, or test it.
One thread owns the interface, and nothing collects your garbage
~3 min
Two beliefs cost the most here: that the interface can be updated from anywhere, and that reference counting means no leaks. The interface belongs to the main thread, and Swift encodes that as @MainActor; a reference cycle never reaches zero and simply stays.
Because the system cannot serialize interface changes arriving from several threads, one thread makes them and the main run loop schedules that work. Touching the interface elsewhere is undefined rather than merely discouraged, which is why UIView and SwiftUI's View are both declared @MainActor — the annotation lets the compiler reject the mistake.
The costly follow-on is assuming Task { } moves work off that thread. It does not: it inherits the enclosing actor. Compiled and run under Swift 6.3.3, the first line below reports the main thread and mutates main-actor state with no await; the detached version reports another thread, and mutating that state from it does not compile.
@MainActor func load() {
Task { model.count += 1 } // still the main thread
Task.detached { decodeBigThing() } // genuinely elsewhere
}
Long work on that thread is a hang: the documented threshold is that under roughly 100 milliseconds is rarely noticed, while a few hundred already reads as unresponsive. Two related edges — await is a suspension point, so anything read before it may be stale after, and cancellation is cooperative, so work that never checks the flag runs on.
Memory is the second rationed resource, and the second wrong instinct. Automatic reference counting (ARC) frees an instance as soon as nothing holds a strong reference, so two objects referring to each other never reach zero. Closures count: storing one on an object and letting its body touch self builds exactly that cycle — the leak nearly everyone ships once. Run the pair below and the first prints nothing, because the deinitializer never fires.
weak becomes nil when the referent goes; unowned promises the object outlives you, so touching one after deallocation is a runtime error. Images are the other habitual waste — cost is decoded pixels, not file size, so prepareThumbnail(of:) decodes at display size on a background thread — and ignoring a low-memory warning has one documented consequence: termination.
Rule: name the actor before you optimize, and treat every stored closure as a retention decision you confirm with a deinitializer.
Data in, and where it comes to rest
~2 min
A 404 is a successful transfer — only transport failures throw, so a try that did not throw proves nothing about the server. And an optional is not a nullable variable: absence is in the type, so ! is a crash you chose in advance.
URLSession separates two failures that beginners merge. A thrown error, or a non-nil error in a completion handler, means a transport problem — the exchange did not happen. A server answering 500 is a successful transfer.
Decoding is where the second assumption bites. An optional is not a variable that happens to allow nothing; absence is part of the type, so the compiler makes you handle it. Int("12x") is Int? — not a crash and not zero. Handle it with if let, guard let or ??. Force-unwrapping with ! asserts the value is there, and when it is not the program stops with Fatal error: Unexpectedly found nil while unwrapping an Optional value (verified on Swift 6.3.3). That is not the language failing you; it is you choosing a crash over a branch.
Transport is constrained too: App Transport Security (ATS) has required connections through URLSession to use HTTPS since iOS 9, and the only way round it is an entry in the information property list naming the domain — which is why a plain-HTTP development server fails on a device.
Where data lands is a security decision, not a convenience one. The defaults system writes an unencrypted file inside your container, and the documentation says plainly not to keep personal or sensitive information there. The keychain is where that belongs, and an item's accessibility attribute decides whether it can be read while the device is locked. For structured data, SwiftData's @Model macro makes a class persistable, the container owns the store while a context accumulates unsaved changes, and @Query re-runs as the store changes.
Rule: read the status, name the absence, and choose the store by who may read it — not by which line is shortest.
The UIKit layer underneath, and the seam between them
~2 min
You will still meet UIKit — in a wrapped control, an older screen, a crash report. The recurring mistake is assuming its lifecycle methods run when you expect: loading once, layout repeatedly. Layout here is a constraint solver, not a proposal.
viewDidLoad runs when the view hierarchy loads into memory — once, not on every appearance, so per-visit work put there silently runs a single time. viewDidLayoutSubviews is the opposite, running whenever bounds change and possibly several times per appearance, which makes it where frames are trustworthy and a terrible place to work.
An outlet is declared with !: an implicitly unwrapped optional, standing permission to force-unwrap. Touching one from an initializer, before the view is loaded, gives the same runtime error as any force unwrap of nil.
Events nobody handles travel a documented path — view, superview, view controller, window, application — and a control with a nil target walks that chain until something implements the action.
Auto Layout is arithmetic: every constraint is attribute1 = multiplier × attribute2 + constant, with a relation and a priority, where 1000 means required and lower values are tried from high to low. Too few constraints leaves the layout ambiguous; conflicting required ones make it unsatisfiable.
chip.translatesAutoresizingMaskIntoConstraints = false // default is true in code
NSLayoutConstraint.activate([
chip.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor),
])
That first line is the classic lost hour: a view created in code manufactures constraints from its autoresizing mask that conflict with yours, and the console blames the ones you wrote. Set it from the parent, never on self inside a custom view.
At the seam, UIViewRepresentable has two phases: makeUIView builds once, updateUIView pushes each new state in. A struct cannot be a delegate, so delegates and target-action live on the Coordinator, and edits return through a binding.
Rule: in UIKit, ask "how often does this run?" before writing a line in a lifecycle method; almost every bug in this layer is work in the wrong one.
The settings you did not choose, and an interface that survives them
~2 min
Standard controls come labeled, so the assumption that you are done is easy to reach. What the framework cannot infer is grouping and meaning — and the grouping modifier hides children by default.
An accessible element needs three things: a name, its current value, and a trait saying what kind of control it is. SwiftUI supplies these for built-in controls, so the work is wherever you assembled something yourself.
Grouping is the subtlety, and its default is a trap: accessibilityElement(children:) defaults to .ignore, which hides the children rather than merging them, while .combine turns a row of five fragments into one element read as a sentence.
HStack { Icon(); Text(skill); Text(level) }
.accessibilityElement(children: .combine) // .ignore is the default
Then the settings, each already changed by the user. The design guidance asks that text be enlargeable by at least 200 percent, which is a layout constraint: a hard-coded height clips at the larger sizes. Controls have a default size of 44 by 44 points on iOS and a stated minimum of 28 by 28, whatever size the glyph is drawn. Color must never be the only cue — add a shape, an icon or a word. Prefer system-defined colors, which carry accessible variants following dark mode and Increase Contrast; a hard-coded value follows nothing. Reduce Motion is a promise: replace large zooms and parallax with a cross-fade. And adapt to size class, orientation and text size, never to a device name.
Rule: run every screen once with the screen reader on and the text size at maximum, on the day you build it.
Proving it works, and getting it onto a device
~2 min
Two comfortable mistakes: treating accessibility identifiers as decoration, and trusting a suite that passes only because its timeouts are generous. Shipping is a separate system, where a provisioning profile authorizes your code to run.
XCTest covers unit, performance and interface tests. Since Xcode 16 there is also Swift Testing, recommended for new unit tests while interface and performance tests stay on XCTest; a target may hold both, but never mix the two frameworks' calls in one test.
The split matters because of what each can see. A unit test runs in-process and reaches your types directly, which is why depending on a protocol rather than a concrete client or store is what makes testing possible. An interface test runs in a separate process and finds only elements the interface publishes — so identifiers are a contract you maintain deliberately, and starting state is set by launch arguments rather than by reaching into the app.
One habit prevents most flakiness: a test passing only because its timeout is generous is already flaky, it just has not failed yet.
Distribution has its own model, and the wrong assumption is that a capability is a checkbox. Apple's platforms other than macOS do not run arbitrary third-party code; execution is authorized by a provisioning profile, framed in the technote as five questions — who may sign, what they may sign, where it may run, when, and which entitlements it may claim. Turning a capability on changes what the profile must allow.
Rule: assert on a condition, never on a delay — and treat the signing identity as part of the build, not a step at the end.
Your app is a guest in a process the system can end
~2 min
The instinct to lose first is that a backgrounded app keeps running. It is suspended within seconds, and a low-memory warning is the last notice before termination. Where the user was has to survive as data.
The unit of interface is a scene: one instance of your app's window, and iPad or Mac can run several at once, each with its own lifecycle — so one may be foreground while another is suspended.
@Environment(\.scenePhase) private var scenePhase
// .onChange(of: scenePhase) { if scenePhase == .inactive { save() } }
The phases are documented precisely: .active is foreground and interactive, .inactive is foreground but should pause its work, .background is not visible. Read it from the app rather than a view and you get the aggregate, plus the sentence worth memorizing: when an app enters the background phase, expect it to terminate soon after.
So "backgrounded" does not mean "running slowly": a background app is expected to do as little as possible and is suspended shortly after. If the system needs memory and cannot get it by killing suspended apps it warns the running ones, and failing to free memory may end in termination. Anything unsaved then is gone.
Real background work goes through the Background Tasks framework, where the verb is submit rather than schedule: you register a launch handler and submit a request, and the system decides when. Notifications carry their own asymmetry — one arriving while your app is in the foreground shows nothing unless your delegate implements the presentation callback, because without one the system behaves as though you asked for silence.
Rule: assume the next launch is a cold one — if a screen cannot be rebuilt from stored data, the user will lose it.
Written by Keentune. We are not affiliated with or endorsed by the organizations whose documentation informs this guide, and any linked sources belong to their respective owners.
All exam, test, and product names and trademarks are the property of their respective owners and are used here for identification and reference only. Keentune is independent study practice — not affiliated with, authorized, or endorsed by any of these organizations.