The Swift iOS & macOS Engineer
“In a competitive market of thousands of iOS engineers chasing the same job, this book is the unfair advantage.”
Welcome. This book takes you from zero coding experience to a production-ready, interview-ready Swift engineer capable of shipping iOS and macOS apps to the App Store — and walking into an interview at any company, from a YC seed-stage startup to Apple itself, with answers that signal seniority, not memorization.
It is unapologetically lab-based, opinionated, and modern. Swift 6, Xcode 16, SwiftUI as the default, UIKit when it matters, security and deployment treated as first-class concerns from day one.
What you will be able to do
By the end of this book you will:
- Read, write, and reason about idiomatic Swift 6 — including strict concurrency,
@Observable, and the type-system features senior engineers are expected to wield. - Ship production iOS and macOS apps built with SwiftUI (and UIKit when warranted), with proper architecture, testing, security, and CI/CD.
- Pass technical interviews at top-tier companies — Apple, Meta, Airbnb, Spotify, Uber, top YC startups — using the 3-level answer framework taught throughout this book.
- Design for the App Store like a business owner, not just a coder — pricing, monetization, subscriptions, the EU Digital Markets Act, the Reader App exception, how Netflix and Spotify actually structure payments.
- Automate everything — zero-touch deployment from
git pushto App Store, with no manual Xcode steps. - Defend your apps against real-world attacks — OWASP Mobile Top 10, certificate pinning, Keychain hardening, jailbreak detection.
- Carry yourself as a senior engineer — design conversations, code review, salary negotiation, portfolio strategy.
Who this is for
- Absolute beginners with no prior coding experience. The only prerequisite is a Mac and curiosity.
- Bootcamp grads who can write code but feel shaky on architecture, concurrency, testing, deployment, and interviews.
- Backend / web engineers transitioning to iOS who need the platform conventions and the Apple-specific deployment pipeline.
- Self-taught iOS developers who have shipped apps but want to fill the gaps that get exposed in senior-level interviews.
If you are already a senior iOS engineer at a FAANG, this book is probably not for you — except as a structured reference for mentoring or as an interview-prep refresher.
How this book is different
Most Swift books teach the syntax of the language. This book teaches the job.
- Hitchhiker’s Guide approach — every concept starts with a real-world scenario, not a syntax dump. You will not be told “an optional is a type that can be nil” — you will be shown the bug that optionals exist to prevent, and then the syntax.
- Interview DNA in every chapter — every chapter ends with the Interview Corner: 3 questions at Junior / Mid / Senior level, with model answers, what the interviewer is really testing, and the red-flag answer that signals inexperience.
- The 3-level answer system — you will internalize how to answer the same question three ways. By Phase 12 you will answer at the senior level instinctively.
- In the Wild — every concept names a real app (Duolingo, Airbnb, Netflix, Apple itself) that uses it. No generic hand-waving.
- The Seasoned Engineer’s Take — every chapter has an opinionated 3–5 sentence section on the thing only experience teaches. The kind of thing a staff engineer would tell you over coffee.
- Lab-based, never theoretical-only — ~44 hands-on labs and 6 production-grade capstone projects. Each lab has a starter Xcode scaffold, step-by-step instructions, checkpoints, troubleshooting, and an interview debrief explaining how to talk about it.
- Deployment is taught from Phase 0, not bolted on at the end. The book itself deploys via Cloudflare Pages on every commit — proof that we live what we preach.
- Capstones designed for portfolios — each of the 6 capstone projects comes with a 30-second elevator pitch, a 3-minute deep-dive answer, and a list of 10–15 interview questions it directly prepares you to answer.
The roadmap
| Phase | Title | What you build |
|---|---|---|
| 0 | Welcome | Environment ready, mdBook deployed |
| 1 | Swift Fundamentals | CLI tool, async fetcher, protocol-oriented calculator |
| 2 | Xcode Mastery | Multi-target project; debug & profile real bugs |
| 3 | Design & HIG | Figma → SwiftUI screen; accessible palette from a brief |
| 4 | iOS Fundamentals (UIKit) | News reader, custom collection layouts, secure login form |
| 5 | SwiftUI | Todo, animated dashboard, multiplatform notes, component library |
| 6 | Data Layer | SwiftData journal, CloudKit sync, production network layer |
| 7 | Apple Ecosystem | Weather+Map, widgets, StoreKit IAP, Sign in with Apple |
| 8 | Testing & Quality | TDD feature, UI testing, snapshot tests, 80% coverage |
| 9 | Security | Secure notes app, certificate pinning, OWASP audit |
| 10 | Deployment & CI/CD | Zero-touch GitHub Actions → App Store pipeline |
| 11 | Monetization & Business | Subscription paywall, automated pricing via App Store Connect API |
| 12 | Architecture & Interview Prep | 100+ interview Q&A, system design, salary negotiation |
| 13 | Capstones | 6 production-ready apps for your portfolio |
Detailed plan: see plan-swiftIosMacosEngineer.prompt.md in the repo root.
How to start
If you are new: go to The Hitchhiker’s Guide to Swift and read Phase 0 in order. It takes about an hour and ends with you having a working Mac dev environment and this very book running locally.
If you already have a Mac dev environment: skim How to use this book to understand the callout system and the Interview Corner format, then jump to whichever phase matches your level.
Let’s get you that offer.
The Hitchhiker’s Guide to Swift
Scenario. It is a Tuesday evening. You decide that this is the year you become an iOS engineer. You open your Mac. You have heard of Swift. You have heard of Xcode. You may have downloaded Xcode once and quit immediately because the interface looked like the cockpit of a spaceship. You are not alone. Almost every iOS engineer you will ever meet started exactly here.
This book is the friend who sits next to you and walks you through it.
What “Hitchhiker’s Guide” means here
Douglas Adams’ The Hitchhiker’s Guide to the Galaxy opens with two words on the cover: DON’T PANIC. The book inside is calm, practical, opinionated, and assumes you have just been thrown into a universe you did not ask to be in.
That is the contract of this book.
- Don’t panic. You don’t need a CS degree. You don’t need to have written code before. You don’t need to know what a compiler is. You will, by the end of Phase 1.
- Calm and practical. Every concept is introduced with a real-world reason it exists. Theory comes second, never first.
- Opinionated. When there are five ways to do something, this book picks one and tells you why. You can disagree later, after you have shipped your first app.
- You were thrown into this. Apple ships ~3000 pages of documentation a year. WWDC drops 100+ sessions every June. Swift Evolution proposals land monthly. This book is the path through that universe — not a transcript of it.
What this book promises
By the time you finish, you will be able to do all of the following without needing to look things up:
- Write idiomatic Swift 6, including strict concurrency.
- Build and ship a SwiftUI app to the App Store end-to-end, including code signing, TestFlight, and review submission.
- Pass a senior iOS interview at top-tier companies — including the Swift trivia, the system design round, the take-home, and the behavioral round.
- Defend an app against the OWASP Mobile Top 10.
- Talk about money — subscription strategy, the Apple 30% cut, the EU Digital Markets Act, the Reader App exception — like an engineer who has shipped a business, not just a feature.
- Carry yourself as a senior engineer in code review, architecture conversations, and offer negotiations.
This is not a “Hello World” book. It is a job book.
What this book is not
- It is not an Apple reference manual. For exhaustive API documentation, you have developer.apple.com.
- It is not a Swift language specification. For the formal grammar, you have the Swift Language Reference.
- It is not an algorithms textbook. We touch algorithms only where iOS interviews actually ask them (LRU caches, debouncing, simple
Observablefrom scratch). For deep algorithm prep, use Cracking the Coding Interview alongside this book. - It is not a design course. We teach enough design (Phase 3) for an engineer to read Figma, build accessible UIs, and not embarrass themselves in a design review.
The voice
This book is written in second person. You are the protagonist. The interviewer slides the whiteboard toward you. You debug the crash at 2am the night before launch. You negotiate the offer.
This is intentional. Engineering is not a spectator sport.
How long will this take?
Realistic ranges for someone working evenings and weekends:
| Phase | Approximate time |
|---|---|
| 0 — Welcome | 1–2 hours |
| 1 — Swift Fundamentals | 2–3 weeks |
| 2 — Xcode Mastery | 1 week |
| 3 — Design & HIG | 1 week |
| 4 — UIKit | 2 weeks |
| 5 — SwiftUI | 2–3 weeks |
| 6 — Data Layer | 2 weeks |
| 7 — Apple Ecosystem | 3 weeks |
| 8 — Testing & Quality | 1 week |
| 9 — Security | 1–2 weeks |
| 10 — Deployment & CI/CD | 1–2 weeks |
| 11 — Monetization & Business | 1 week |
| 12 — Architecture & Interview Prep | 2–4 weeks |
| 13 — Capstones | 4–8 weeks (one of six, more if you do multiple) |
Total: roughly 4–8 months of consistent evenings if you do every lab. Faster if you skip labs (don’t skip the labs).
[!TIP] Best practice. Do not skim Phase 1. Junior engineers who skip “the easy stuff” pay for it three years later when an interviewer asks about value semantics and they freeze. The fundamentals chapter is the foundation everything sits on.
Lab Preview
Phase 0 has no lab — its job is to get you set up. Phase 1’s first lab is Lab 1.1 — Playground Exploration, where you will write your first Swift code inside Xcode’s Playground feature within 10 minutes of finishing Phase 0.
Onward. Start with How to use this book.
How to use this book
Scenario. You open a Swift book. The first three chapters are 80 pages of language syntax. By page 60 you have forgotten why you started. You quit. Six months later you try again with a different book. Same result.
The structure below exists to prevent that outcome.
The structural pieces
Every phase of this book is built from the same repeatable parts. Once you recognize them, navigation becomes automatic.
1. Phase
A phase is a major topic area (Swift Fundamentals, SwiftUI, Security, etc.). There are 13 phases plus an appendix.
2. Chapter
Each phase has chapters — focused 15–45 minute reads on one topic. Every chapter has the same internal shape (see Mandatory Sections below).
3. Lab
Each phase ends with one or more labs — hands-on projects with a starter Xcode scaffold, step-by-step instructions, checkpoints, troubleshooting, and an interview debrief. The labs are not optional. A reader who skips labs cannot pass interviews.
4. Capstone
After Phase 12, you ship one (or more) capstones — production-grade apps that pull everything together. Each capstone is portfolio-ready and interview-defensible.
Mandatory sections in every chapter
Every chapter contains these sections, always in this order. Once you internalize the pattern, you can scan a chapter in 30 seconds and find exactly what you need.
| Section | Purpose |
|---|---|
| Opening Scenario | A 2–3 sentence situational hook — usually mid-interview or mid-sprint — that explains why this chapter exists. |
| Concept → Why → How → Code | The teaching rhythm. We name the concept, justify it, explain how it works, then show code. Never the other order. |
| In the Wild | A named real app (Duolingo, Airbnb, Netflix, Apple) that uses this concept in production. |
| Common Misconceptions | 2–3 bullets of the form “Junior devs often think X, but actually Y.” |
| The Seasoned Engineer’s Take | 3–5 sentences of opinionated, experience-based commentary. The kind of thing only senior engineers know. |
| Best Practice (TIP) | A concise actionable rule — quotable in a code review. |
| Gotcha (WARNING) | The specific thing that will bite you, in production or in an interview. |
| Interview Corner | 3 questions ranked Junior / Mid / Senior, with model answers, what the interviewer is really testing, and the red-flag answer. |
| Lab Preview | (If there is a lab.) A one-line bridge from theory to hands-on. |
Callout conventions
This book uses GitHub-style admonitions. They look like this:
[!NOTE] Context. A neutral piece of information or background. Skim or skip.
[!TIP] Best practice. An actionable rule. Adopt it.
[!WARNING] Gotcha. A specific failure mode. Read it twice.
[!IMPORTANT] Read before continuing. A point that, if missed, will break what follows.
[!CAUTION] Security or money implication. Read it three times.
The 3-level answer system
The single most important pattern in this book. Every interview question gets three graded answers:
- Junior. Correct but surface-level. Gets you a pass but not a “hell yes hire.”
- Mid. Correct + tradeoffs + one real-world consideration. A solid answer.
- Senior. Correct + tradeoffs + pattern awareness + “I’d also consider X” + business-impact connection. This is the answer that gets you the offer.
Worked example — “How does weak vs unowned work in Swift?”
Junior answer.
“
weakmakes the reference optional and sets it tonilwhen the object is deallocated.unownedis non-optional and crashes if the referenced object is gone. Useweakfor delegates.”
Mid answer.
Junior answer, plus: “I default to
weakbecause the crash risk fromunownedis rarely worth avoiding an optional. I useunownedonly inlazyproperty closures where I can prove the object outlives the closure.”
Senior answer.
Mid answer, plus: “The real conversation is capture-list hygiene. I have seen
[weak self]cargo-culted everywhere — including inDispatchQueue.main.async, where it is unnecessary — which erodes signal. Every[weak self]should be a documented decision: here is the specific retain cycle it prevents. In Swift 6 strict concurrency, the compiler catches some of these at compile time, which pushes me toward structured concurrency over closure-plus-capture-list for new code.”
Notice what the senior answer does:
- It includes everything the junior and mid said.
- It introduces a meta observation (cargo-culting).
- It connects to a recent platform shift (Swift 6 strict concurrency).
- It implies a preference with reasoning, not dogma.
[!TIP] Best practice. When you read every Interview Corner in this book, do not just memorize the senior answer. Memorize the shape: include the lower levels, then add (1) a meta observation, (2) a recent platform reference, and (3) a preference with reasoning.
What “lab” means here
Every lab is a real, runnable Xcode (or Swift Package) project. Every lab folder contains:
README.md— objective, prerequisites, finished state, step-by-step, checkpoints, troubleshooting, interview debrief, extension challenges.starter/— an Xcode project or Swift Package scaffolded to the point where you take over.
You build incrementally on top of starter/. The book does not provide a “solution” folder — you can compare against the next lab’s starter scaffold, which always builds on the previous lab’s completed state.
[!IMPORTANT] Read before continuing. Resist copy-pasting from the lab instructions. Type every line. Muscle memory for Xcode shortcuts and Swift syntax is what separates a reader of an iOS book from an engineer who can pass an interview.
Recommended pace
- One chapter per evening. 45 minutes.
- One lab per weekend. Most labs are 2–4 hours including reading + typing + extension challenges.
- One phase every 2 weeks for Phases 1–9, one a week for Phases 10–12, then one capstone over 4–8 weeks.
This pace gets you interview-ready in 4–8 months without burnout.
How to read out of order (advanced)
If you already know Swift fluently, you may skip Phase 1. But: still read Phase 1, Chapter 9 (Concurrency) — Swift 6 strict concurrency changes the rules even for experienced engineers.
If you already know UIKit, you may skip Phase 4. But: read Phase 4, Chapter 10 (UIKit + Combine) — interviewers love asking about the interop.
If you already know SwiftUI, you may skim Phase 5. But: read Phase 5, Chapter 4 (@Observable & Swift 6) — this is new in 2024 and most engineers have only superficially adopted it.
For everyone: do not skip Phase 9 (Security), Phase 10 (Deployment & CI/CD), or Phase 12 (Architecture & Interview Prep). These are where this book most differs from competing material — and where interviews most often reveal who actually knows their craft.
Lab Preview
Next chapter, Prerequisites, takes 5 minutes. You will confirm you have everything you need (which, almost certainly, is just a Mac).
Prerequisites — nothing but a Mac
Scenario. You are about to learn iOS development. The very first question is: do you have what you need? The answer is almost certainly yes, and this 5-minute chapter is here to confirm it.
The hard requirement
You need a Mac. That is it.
iOS, iPadOS, macOS, watchOS, tvOS, and visionOS development all require a Mac. This is not negotiable, not because Apple is gatekeeping, but because Xcode — the IDE you will use for everything — runs only on macOS. The Swift language runs on Linux and Windows; the Apple platforms toolchain does not.
Which Mac?
| Mac | Verdict |
|---|---|
| Any Apple Silicon Mac (M1, M2, M3, M4) | ✅ Ideal. Buy a refurb if budget is tight. |
| Intel Mac from 2018 or later | ✅ Works. Xcode is slower but everything functions. |
| Intel Mac from 2016–2017 | ⚠️ Works for now, but Xcode 17 will likely drop it. Plan to upgrade. |
| Intel Mac pre-2016 | ❌ Cannot run the current Xcode. |
| Hackintosh | ❌ Possible but not supported; you will hit weird signing bugs. Not worth it. |
| Cloud Mac (MacinCloud, MacStadium, AWS EC2 Mac) | ⚠️ Works but expensive. See Phase 10 — fine for CI, painful for daily learning. |
[!TIP] Best practice. If you are buying a Mac specifically to learn iOS development, a refurbished M1 MacBook Air from Apple’s refurb store is the best dollar-per-development-experience purchase in 2026. 8 GB RAM is the minimum; 16 GB is comfortable.
Disk space
Reserve at least 50 GB free. Realistic breakdown:
- Xcode itself: ~12 GB.
- iOS Simulator runtimes (you will install several): ~5–20 GB.
- Your Swift Package Manager build caches and DerivedData: 5–10 GB over time.
- This book’s labs and capstones: 2–5 GB total.
100 GB free is generous and comfortable.
Operating system
You need macOS Sonoma (14) or newer to run Xcode 16. If you are on an older macOS, run Software Update before continuing.
What you do not need
A common pre-flight panic. Let’s dispel it.
- You do not need an iPhone. The iOS Simulator runs every iPhone model and OS version on your Mac. You will only need a physical device once you start working with hardware-specific features (camera, Bluetooth, HealthKit, NFC, ARKit) — and not before Phase 7.
- You do not need an iPad, Apple Watch, Apple TV, or Vision Pro. All have simulators.
- You do not need a paid Apple Developer account yet. It costs $99/year and you only need it when you start deploying to a physical device or the App Store. That is Phase 10. You can do Phases 0–9 entirely free.
- You do not need to know any other programming language. This book teaches Swift from absolute zero.
- You do not need a CS degree. The interview-prep chapters (Phase 12) will fill the relevant gaps.
- You do not need to know Objective-C. It still exists in legacy Apple frameworks and you will see it occasionally — but you will not need to write any in this book.
What you should already know
Almost nothing. Specifically:
- Basic computer literacy. Find a file. Open a terminal. Read text on a screen. That is the floor.
- Comfort with English-language technical writing. All Apple documentation is in English.
- Willingness to type commands into a terminal. Not “expertise” — willingness. You will learn the commands as you go.
If you do not yet feel comfortable opening Terminal.app and running ls, take 20 minutes to skim the macOS Terminal basics. Then come back.
What you need emotionally
This part is honest.
- Time. Realistic minimum: 5 hours a week for 4–8 months. Less than this and you will forget what you learned between sessions.
- Tolerance for being confused. You will not understand
Optional<T>the first time you see it. You will not understand@Observablethe first time. You will not understand certificate signing the first time. Confusion is the work. Push through. - A willingness to type things you do not yet understand. Often you will type a line of code without knowing what every word means. That is fine. Understanding follows usage, not the other way around.
[!WARNING] Gotcha. The number one reason adult learners fail at programming is not aptitude. It is the expectation that things will click immediately. They will not. Plan for confusion. Confusion that resolves over a week of typing and re-reading is normal — not a sign you “don’t have the brain for it.”
Lab Preview
Next chapter, Environment setup, is where the typing starts. By the end of it you will have Xcode, Homebrew, mdBook (this book) running locally, Fastlane, and your Apple ID configured for development. About 30–45 minutes including downloads.
Environment setup
Scenario. You are 30 minutes from writing your first line of Swift. This chapter installs every tool you need for the entire book — Xcode, Homebrew, Git, mdBook (so you can read this book offline and search it instantly), Fastlane, and your Apple ID. We do it once, properly, and never have to think about it again.
Total time: 30–60 minutes, mostly waiting for downloads.
Step 1 — Install Xcode
Xcode is Apple’s IDE. It includes the Swift compiler, the iOS/macOS SDKs, the simulator, Interface Builder, the debugger, and the build system.
-
Open the App Store app on your Mac.
-
Search for Xcode. Install. (~12 GB; expect 20–60 minutes.)
-
Once installed, open Xcode at least once. Accept the license. Let it finish installing additional components.
-
Open Terminal (Cmd+Space → “Terminal”).
-
Run:
xcode-select --installThis installs the Command Line Tools (a smaller, separate package that includes
git,clang,make,swift, etc.). If it says they are already installed, you are good. -
Verify:
xcodebuild -version swift --version git --versionYou should see Xcode 16.x, Swift 6.x, and Git 2.x.
[!TIP] Best practice. Do not use App Store auto-updates for Xcode for the rest of your iOS career. Manage Xcode versions deliberately with
xcodes(we install it in Step 4). Auto-updating Xcode in the middle of a project is how teams lose a day.
[!WARNING] Gotcha. If
xcodebuild -versionfails withxcode-select: error: tool 'xcodebuild' requires Xcode, run:sudo xcode-select -s /Applications/Xcode.app/Contents/Developer
Step 2 — Install Homebrew
Homebrew is the de-facto package manager for macOS. We will use it for every non-Apple tool.
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
After installation, follow the on-screen instructions to add Homebrew to your shell PATH. On Apple Silicon Macs, that usually means running the two echo ... >> ~/.zprofile lines the installer prints.
Verify:
brew --version
Step 3 — Install the book toolchain
brew install mdbook gh
mdbook— the static site generator this book is built with. Lets you build and read this book locally with full search.gh— the GitHub CLI. Used in later phases for CI workflows and PR automation.
Verify:
mdbook --version
gh --version
Step 4 — Install xcodes (Xcode version manager)
You will install multiple Xcode versions over the course of your career. xcodes makes this painless.
brew install xcodesorg/made/xcodes
Verify:
xcodes installed
It should list at least the Xcode you installed in Step 1. We will use xcodes in depth in Phase 2.
Step 5 — Install Fastlane
Fastlane automates code signing, screenshots, App Store uploads, and TestFlight. You will need it from Phase 10 onward — but installing it now avoids a side-quest later.
brew install fastlane
Verify:
fastlane --version
[!NOTE] Context. Fastlane has two install paths: Homebrew (above) and RubyGems (
gem install fastlane). Homebrew is simpler and isolates Fastlane’s Ruby from your system Ruby. Use it unless your team standardizes on Bundler-managed Ruby.
Step 6 — Configure Git
If you have not used Git before on this Mac:
git config --global user.name "Your Name"
git config --global user.email "you@example.com"
git config --global init.defaultBranch main
git config --global pull.rebase true
Generate an SSH key for GitHub (we will use this in Phase 10’s CI setup):
ssh-keygen -t ed25519 -C "you@example.com"
# Press Enter to accept default file location and an empty passphrase, or set a passphrase.
cat ~/.ssh/id_ed25519.pub | pbcopy
The public key is now in your clipboard. Add it at github.com/settings/keys → New SSH key.
Authenticate the GitHub CLI:
gh auth login
Choose GitHub.com → SSH → use the key you just added.
Step 7 — Clone (or fork) this book
mkdir -p ~/src
cd ~/src
gh repo clone <your-username>/swift-ios-macos-engineer
# OR, if you are reading the published version, fork it first then clone your fork.
cd swift-ios-macos-engineer
Build and serve the book locally:
mdbook serve book --open
Your browser should open at http://localhost:3000 showing this book. Edits to any Markdown file in book/src/ will live-reload.
[!TIP] Best practice. Keep
mdbook serverunning in a Terminal tab while you read. When you make notes (and you will), edit the Markdown directly — your notes become permanent part of your local copy.
Step 8 — Sign in to your Apple ID in Xcode
This step is free and does not require a paid developer account. It lets you run apps on your own physical device for 7-day signed builds.
- Open Xcode.
- Xcode → Settings → Accounts → + (bottom left) → Apple ID.
- Sign in with your Apple ID. Use a personal one for learning; do not use a work one yet.
- Once added, you will see your “Personal Team” listed.
That is enough for Phases 0–9. The paid Apple Developer Program ($99/year) is required only for:
- App Store distribution
- TestFlight
- Push notifications on physical devices
- Certain entitlements (CloudKit, HealthKit, etc.) on physical devices
We will set that up at the start of Phase 10.
Step 9 — Verify the full stack
Run this one-shot sanity check:
echo "=== System ===" \
&& sw_vers \
&& echo "\n=== Xcode ===" \
&& xcodebuild -version \
&& echo "\n=== Swift ===" \
&& swift --version \
&& echo "\n=== Git ===" \
&& git --version \
&& echo "\n=== Homebrew ===" \
&& brew --version | head -1 \
&& echo "\n=== mdBook ===" \
&& mdbook --version \
&& echo "\n=== xcodes ===" \
&& xcodes --version \
&& echo "\n=== Fastlane ===" \
&& fastlane --version | head -1 \
&& echo "\n=== gh ===" \
&& gh --version | head -1 \
&& echo "\n✅ Environment ready."
If every line prints a version and the last line is ✅ Environment ready., you are done.
Troubleshooting
Xcode took 60+ minutes to download. Normal on slow connections. The App Store will resume if you close it.
xcode-select --install says “Can’t install the software because it is not currently available from the Software Update server.” Means Command Line Tools are already installed. Continue.
brew install fails with “permission denied”. Do not sudo. Instead, fix Homebrew permissions per the message — usually sudo chown -R $(whoami) $(brew --prefix)/*.
mdbook serve says “port 3000 already in use”. Another instance is running. Either find it (lsof -i :3000) and kill it, or run on a different port: mdbook serve book --port 4000 --open.
Xcode Simulator does not open. Open it manually once via Xcode → Open Developer Tool → Simulator — the first launch finalizes setup.
Lab Preview
The last chapter of Phase 0, Staying current with Apple, is a 10-minute read on how to keep your knowledge sharp once the book is done. After that, you are into Phase 1 and writing real Swift.
Staying current with Apple
Scenario. It is the second week of June. WWDC keynote drops at 10am PT. Apple announces something with
@Observable, a new SwiftUI navigation API, a deprecation, and a privacy framework you have never heard of. Twitter explodes. Your team Slack lights up. Your boss asks: “Should we adopt this?”A senior engineer has a framework for that question. A junior engineer panics. This chapter gives you the framework.
The Apple developer information diet
There is too much. You cannot read it all. The trick is curation, not consumption.
Tier 1 — must follow
Read every post. These are signal-to-noise gold.
| Source | Why |
|---|---|
| Swift Evolution | Every Swift language change is proposed here first. Read the Motivation sections — they teach you why the language is the way it is. |
| Apple Developer News | Official Apple announcements — deprecations, App Store policy changes, deadlines. Subscribe to the RSS feed. |
| WWDC (June each year) | The single most important week of the year. Watch keynote + Platforms State of the Union live. Watch 5–10 deep-dive sessions in the following weeks. |
| Hacking with Swift by Paul Hudson | Best plain-English explanations of new features. His “What’s new in Swift X” articles are the canonical reference. |
| wwdcnotes.com | Community-written notes on every WWDC session. Saves you 100+ hours each year. |
Tier 2 — skim weekly
| Source | Why |
|---|---|
| iOS Dev Weekly by Dave Verwer | Curated weekly newsletter. The single best signal-to-time-spent ratio in iOS. |
| Swift Weekly Brief | Swift language and toolchain news. Lighter than iOS Dev Weekly. |
| Swift by Sundell by John Sundell | Articles + podcast. Strong on architecture and patterns. |
| SwiftLee by Antoine van der Lee | Practical, pattern-focused articles. Excellent on concurrency and SwiftUI. |
| Donny Wals’ blog | Deep dives on concurrency, SwiftData, Core Data, and SwiftUI internals. |
| Point-Free | Paid video series. The deepest content on functional Swift, TCA, and testing. Worth the subscription if you target high-end shops. |
Tier 3 — bookmark, search when needed
| Source | When |
|---|---|
| developer.apple.com/documentation | API reference. Search via Xcode’s documentation viewer (Help → Developer Documentation) — it is faster. |
| Apple Developer Forums | Apple engineers answer here. Search before posting. |
Stack Overflow [swift] [ios] tags | Less active than it used to be, but still the answer to many tactical questions. |
| Mastodon iOS community | Where most ex-Twitter iOS folks landed. |
| Hacking with Swift forums | Beginner-friendly. Great alternative to Stack Overflow for the early phases of this book. |
In the Wild
Senior engineers at Airbnb, Spotify, Uber, and Apple itself follow the Tier 1 sources in this order: Swift Evolution > Apple Developer News > WWDC > Hacking with Swift > wwdcnotes. Whatever else fits in their week, fits.
The signal: when you join a senior iOS team, you will be expected to know — within a week of release — what Apple announced. Not to have adopted it. To know it.
Common misconceptions
- “I have to watch every WWDC session.” No. Watch the keynote, Platforms State of the Union, and the 5–10 sessions relevant to your work. The rest you read summaries of.
- “I should adopt every new API the day it ships.” No. See “The adoption framework” below.
- “Apple’s documentation is too sparse to be useful.” It improved dramatically with Xcode 13+ and now has rich tutorials and articles. The reflexive complaint about Apple docs is a decade out of date.
The adoption framework
When Apple announces a new API or pattern, ask these five questions in order. Stop adopting at the first No.
- Does it raise the minimum deployment target above what my users have?
- If yes: defer until your minimum target matches. Most apps support iOS N-1 or N-2 (one or two major versions back).
- Is the API stable, or marked beta / “to be revisited”?
- Apple sometimes ships APIs in beta in June, then significantly changes them by September.
@Observablewas a year-one win;NavigationStacktook two cycles to stabilize.
- Apple sometimes ships APIs in beta in June, then significantly changes them by September.
- Does it replace something I already use successfully?
- If the old API still works and the new one is just slightly nicer, defer. Migration is rarely free.
- Does my team have capacity to learn it?
- A new pattern means PR reviews slow down for a month. Schedule that cost.
- Will I be the one supporting it in 2 years?
- Bleeding-edge APIs you adopt today are your maintenance burden tomorrow. Be deliberate.
[!TIP] Best practice. Default behavior for a senior engineer: read every announcement on day 1; experiment in a side project within a month; adopt in production only after the API has shipped at least one further point release. This catches the cases where Apple revises the API in iOS X.1.
The Seasoned Engineer’s Take
The single hardest thing about being an iOS engineer is not Swift, not SwiftUI, not Core Data. It is Apple itself. Apple ships an enormous amount each year, deprecates aggressively, and rarely apologizes. Engineers who thrive build the meta-skill of picking what to ignore. The Tier 1 list above is short on purpose; ten years from now it will still be short, even though the specific names will rotate. The skill you are building is curation under information overload, and it is what separates a 5-year iOS engineer from a 15-year one. Build it deliberately starting today.
Interview Corner
Junior — “How do you stay up to date with iOS?”
What the interviewer is really testing. Do you have a learning habit, or did you just finish a bootcamp and call it done?
Junior answer.
“I follow Hacking with Swift and watch WWDC sessions every June. I read iOS Dev Weekly when it lands in my inbox.”
Red flag answer.
“I learn what I need when I need it.” This signals reactive, not proactive. It is true of every junior — saying it out loud is the problem.
Mid — “How do you decide whether to adopt a new Apple API in production?”
What the interviewer is really testing. Do you weigh tradeoffs, or do you chase shiny objects?
Mid answer.
“I check three things: does it require raising the minimum deployment target above where my users are? Is it the first version of the API, or has it been revised? And does it replace something that already works for me? I’ll experiment in a side project, but I don’t push to production until the API has at least one revision cycle behind it, because Apple often refines new APIs in X.1.”
Senior — “Apple announces a new framework at WWDC that overlaps with infrastructure your team already maintains. How do you handle the conversation with your team?”
What the interviewer is really testing. Can you separate technical merit from political and migration cost? Can you lead a team through a strategic decision?
Senior answer.
“First, I separate the technical evaluation from the adoption decision. The first conversation is just: what does this give us, what does it cost, in terms only of capability. I want everyone on the same factual page before we discuss whether to adopt.
Then I lay out three scenarios: do nothing, adopt incrementally for new code only, or migrate. For each I want to know the user-facing benefit, the migration cost, the testing cost, and what happens if Apple changes their mind in two years.
My default bias for first-year Apple frameworks is don’t migrate, adopt for new modules. The reason is that migration is the most expensive option and rarely visible to users, while new-module adoption gives the team hands-on experience without the all-or-nothing risk. I have seen teams burn a quarter migrating to a framework that Apple significantly revised the following year —
NavigationStackis the recent example. Wait at least one full release cycle before betting the migration on it.The thing that distinguishes this from inertia is that I do allocate time for the experimentation — usually a single engineer prototyping in a feature flag — so we are ready to migrate fast when the cost-benefit flips.“
Red flag answer.
“We should adopt it immediately to stay modern.” This signals lack of cost awareness and is almost always wrong for production teams.
Lab Preview
Phase 0 ends here. Phase 1 — Swift Fundamentals — opens with Chapter 1 (the history of Swift) so that when you write your first line of Swift in Chapter 2, you understand which Swift you are writing and why.
You now have a working dev environment, this book running locally, and a curated information diet. Onward.
1.1 — A short history of Swift, and which version you should care about
Opening scenario
It’s your first day on a new iOS team. You clone the repo, open Xcode, and the project complains: “This file requires Swift 5.5 or later.” You look at the build settings and see SWIFT_VERSION = 5.0. The CI logs mention “Swift 6 language mode is opt-in.” A colleague drops a Slack message: “FYI we’re not on strict concurrency yet, still on the 5 mode but Xcode 16.” You nod knowingly. You have no idea what they mean.
By the end of this chapter, you will.
The story so far
Swift was announced at WWDC 2014. Apple had been building Objective-C apps for 30 years (NeXT, then macOS, then iOS), and Objective-C — for all its dynamism — was showing its age: manual memory management before ARC, nil messaging hiding bugs, square-bracket syntax that scared off newcomers, no value types, no generics worth the name.
Chris Lattner (the creator of LLVM) had been working on Swift in secret since 2010. It was designed to interoperate with Objective-C (so Apple’s gigantic existing codebase didn’t have to be thrown away) while being safer, faster, and more modern.
Here’s the version timeline that actually matters in 2026:
| Version | Year | What changed (the version that defined the era) |
|---|---|---|
| Swift 1.0 | 2014 | Initial release. Nobody used it in production yet. |
| Swift 2.0 | 2015 | guard, defer, try/catch, protocol extensions. Suddenly usable. |
| Swift 3.0 | 2016 | The Great Renaming. Half the standard library changed. Every project broke. |
| Swift 4.0 | 2017 | Codable. JSON parsing stopped being painful. |
| Swift 5.0 | 2019 | ABI stability. Apps stopped shipping the Swift runtime inside them. |
| Swift 5.5 | 2021 | async/await, actors, structured concurrency. The biggest leap since 1.0. |
| Swift 5.9 | 2023 | Macros, parameter packs, if/switch expressions. |
| Swift 6.0 | 2024 | Strict concurrency by default (opt-in language mode). Data-race safety enforced at compile time. |
| Swift 6.1+ | 2025–2026 | Refinements, better C++ interop, embedded Swift maturing. |
If you’re starting today, you are writing Swift 6 in Swift 5 language mode on Xcode 16. That sentence sounds insane, so let me unpack it.
Concept → Why → How → Code
Concept: Swift version vs. language mode
A modern Swift toolchain ships with one compiler binary that understands multiple language modes. The toolchain version (e.g. Swift 6.0) tells you what features the compiler can handle. The language mode (e.g. -swift-version 5) tells the compiler which set of defaults and warnings to apply.
Why this split exists
Apple has hundreds of millions of lines of Swift code in the wild. If Swift 6 had simply forced every project into strict concurrency checking on day one, every existing app would break. Instead, Apple chose: ship the new defaults under a flag, let teams adopt incrementally.
How you read it in practice
SWIFT_VERSION = 5.0in your Xcode build settings = “use Swift 5 defaults” — your code is permissive about sendability, isolation, etc.SWIFT_VERSION = 6.0= “Swift 6 language mode” — strict concurrency errors become errors, not warnings. You opt in when you’re ready.- The actual compiler may be Swift 6.1 — the toolchain bundled with Xcode 16.
Code
Check what your machine actually has:
$ swift --version
swift-driver version: 1.115 Apple Swift version 6.1.2 (swiftlang-6.1.2.0.0 clang-1700.0.13.5)
Target: arm64-apple-macosx15.0
In a Swift package, you declare both:
// swift-tools-version:6.0
// ^ minimum tool version that can READ this manifest
import PackageDescription
let package = Package(
name: "MyLib",
swiftLanguageVersions: [.v6] // ^ language MODE to compile under
)
In Xcode, look at Build Settings → Swift Compiler – Language → Swift Language Version.
In the wild
- Apple’s own apps (Music, TV, Wallet) reportedly adopted Swift 6 language mode gradually through 2025. Even Apple doesn’t flip the switch overnight on a million-LOC codebase.
- Airbnb wrote a public retrospective in 2024 about migrating their iOS app to Swift Concurrency — they spent ~6 engineer-months just untangling
DispatchQueueand@MainActorannotations. - Open-source libraries like Alamofire, SwiftUI Introspect, and TCA (The Composable Architecture) advertise their minimum Swift version prominently in their README — because consumers need to know whether they can use the library without bumping their own toolchain.
Common misconceptions
-
“Swift 6 means I have to rewrite everything.” No. Swift 6 language mode is opt-in. Until you flip the flag in your build settings, your code compiles exactly the same as it did under Swift 5.x.
-
“Newer Swift always means faster compile times.” Often the opposite. Each new feature adds inference work. Swift 5.7+ improved compile times measurably, but the trend has been “more features, more compiler work.”
-
“Objective-C is dead.” Most of UIKit and Foundation is still Objective-C under the hood. Every Swift iOS app you ship is calling Objective-C runtime code on every line. Knowing a little Obj-C is still useful in 2026.
-
“I should use the bleeding-edge Swift version for my open-source library.” Then you exclude every team that hasn’t upgraded yet. Library authors typically support N-1 or N-2 Xcode versions.
Seasoned engineer’s take
The version-vs-language-mode split looks ugly but it’s the single most important decision Apple’s Swift team has made for ecosystem health. Compare to Python 2 → 3, where the abrupt break fragmented the community for nearly a decade. Swift’s incremental opt-in model means a 2026 codebase can have one module in Swift 6 strict concurrency, another in Swift 5 mode, and another linking to Objective-C — all in the same app, all building today. That’s the part you should internalize: Swift is designed to be migrated to, not jumped to.
When you join a team, the first three questions you should ask are:
- What Xcode version are we on?
- What
SWIFT_VERSIONis set per target? - Are we adopting strict concurrency, and if so, on which modules first?
The answers tell you 80% of what to expect about the codebase’s age, technical debt, and how cautious the team is.
TIP: Bookmark swift.org/documentation/articles/ and the Swift evolution proposals dashboard. Every change in the language is documented there before it ships.
WARNING: Never copy-paste a Swift snippet from Stack Overflow without checking the answer date. A
DispatchQueue.main.async { … }answer from 2019 is technically still valid, but in 2026 the idiomatic version isawait MainActor.run { … }or@MainActor func. Old answers compile; they just mark you as an engineer who hasn’t kept up.
Interview corner
Question (asked at almost every iOS interview): “What’s the difference between Swift 5 and Swift 6, and how would you migrate a project?”
Junior answer: “Swift 6 is the newer version. It has strict concurrency. I’d update the SWIFT_VERSION in Xcode.” → Technically correct but shallow. You’d pass a screen, probably not an onsite.
Mid-level answer: “Swift 6 introduces strict concurrency checking — the compiler now enforces data-race safety, requiring Sendable conformance on types crossing actor boundaries. The migration is opt-in via SWIFT_VERSION = 6.0 per target. I’d enable it gradually: start with the most isolated leaf modules, fix Sendable warnings under Swift 5 mode first (set -strict-concurrency=complete), then flip the language mode once the warnings are clean.” → Strong answer. Demonstrates you’ve actually done this.
Senior answer: All of the above, plus: “The real cost of the migration isn’t fixing warnings — it’s deciding the isolation architecture. Strict concurrency forces you to make explicit what was implicit: which code runs on the main actor, which models are sendable value types versus reference types pinned to an actor, where you need nonisolated(unsafe) escape hatches because of legacy frameworks. On a large codebase I’d dedicate a small team to define the isolation strategy for shared types (network layer, persistence, app-state) before enabling strict mode anywhere. Otherwise you end up sprinkling @unchecked Sendable everywhere, which gives you the warnings-clean checkbox but none of the safety. The migration is an architecture exercise, not a compiler exercise.” → That’s the answer that gets the offer.
Red-flag answer: “Swift 6 is just Swift 5 with bug fixes.” → Instant signal you haven’t touched the language in two years.
Lab preview
Lab 1.A (Playground exploration) gets you typing actual Swift in a Playground. You’ll touch every language version’s flagship feature in one file: optionals (1.0), guard (2.0), Codable (4.0), async/await (5.5), and a macro (5.9). You’ll feel the language’s history in your hands.
Next up: how to actually run Swift — Playgrounds, REPL, SPM, and the choice that trips up every beginner. → Setup, Playgrounds & SPM
1.2 — Setup, Playgrounds & SPM (where Swift code actually lives)
Opening scenario
You’re three hours into your Swift journey and you have three places to write code: Xcode Playgrounds, a Swift Package, and the swift command in your terminal. Which one is “real”? When do you reach for each? You watch a tutorial that says “open a Playground,” another that says “create a new package with swift package init,” and a third that uses Xcode’s “macOS Command Line Tool” template. You feel like everyone is gatekeeping the right answer.
There is no single right answer — but there are very right answers for each situation. By the end of this chapter you’ll know exactly which surface to use, and why.
The four places Swift lives
| Surface | Best for | Bad at |
|---|---|---|
| Playgrounds (Xcode app) | Trying a language feature, prototyping a UI snippet, exploring an API | Multi-file projects, long-running code, anything depending on a 3rd-party package |
swift REPL (terminal) | One-line sanity check (swift -e 'print(1+1)') | Anything with imports beyond Foundation |
Swift Package (Package.swift + folder) | Real libraries, CLIs, server code, sharing code across iOS/macOS/Linux | UI apps that ship to the App Store |
Xcode app project (.xcodeproj / .xcworkspace) | iOS/macOS/watchOS/tvOS apps you ship to users | Anything that needs to run on Linux/server |
For this chapter you’ll set up the first three. We’ll meet .xcodeproj in Phase 2 when you build your first SwiftUI app.
Concept → Why → How → Code
Concept: a Swift Package is just a folder + a manifest
Forget magic. A package is:
MyPackage/
├── Package.swift ← the manifest (a Swift file describing the package)
├── Sources/
│ └── MyPackage/
│ └── MyPackage.swift ← your code
└── Tests/
└── MyPackageTests/
└── MyPackageTests.swift
That’s it. No build files generated by Xcode. No .pbxproj to merge-conflict over. The manifest is the project file.
Why this matters
Before SPM (Swift Package Manager, shipped in Swift 3, matured around Swift 5.5), iOS engineers used CocoaPods or Carthage for dependency management — both of which generated giant .pbxproj files that constantly merge-conflicted. SPM moved dependency declaration into a small, plain-text, version-controlled Swift file. It’s why modern Swift codebases feel lightyears nicer to work in.
How: create a package right now
mkdir HelloSwift && cd HelloSwift
swift package init --type executable
swift run
You should see:
Building for debugging...
Build complete!
Hello, world!
You just compiled and ran a Swift program with one command. That’s the SPM promise.
Code: dissect the manifest
Open Package.swift:
// swift-tools-version: 6.0
import PackageDescription
let package = Package(
name: "HelloSwift",
targets: [
.executableTarget(name: "HelloSwift")
]
)
Five things to notice:
- The first line is a comment that the tool actually parses. It tells SPM the minimum Swift tools version required.
PackageDescriptionis a Swift module. The manifest is real Swift, executed in a sandbox by SPM at package-resolution time.targetsdefine build units. A target is “a thing that gets compiled into one binary or one library.”- Folder conventions are hard-coded. SPM looks in
Sources/<TargetName>/automatically. Don’t move files unless you tell SPM where they went viapath:. - Dependencies go in two places: at the package level (
dependencies: [.package(url: …)]) and at each target that needs them (dependencies: [.product(name: …, package: …)]).
Here’s a slightly bigger example with a dependency:
// swift-tools-version: 6.0
import PackageDescription
let package = Package(
name: "HelloSwift",
platforms: [.macOS(.v14)], // minimum OS we target
dependencies: [
.package(url: "https://github.com/apple/swift-argument-parser",
from: "1.3.0"),
],
targets: [
.executableTarget(
name: "HelloSwift",
dependencies: [
.product(name: "ArgumentParser", package: "swift-argument-parser"),
]
),
]
)
Run swift build again — SPM downloads, resolves, and links the dependency, all without Xcode opening.
Playgrounds: when to reach for one
Open Xcode → File → New → Playground → macOS → Blank.
import Foundation
let names = ["Ada", "Linus", "Grace", "Dennis"]
let upper = names.map { $0.uppercased() }
print(upper)
// → ["ADA", "LINUS", "GRACE", "DENNIS"]
The result column on the right shows you the value of every expression as you type. There’s no Run button you press repeatedly — it runs continuously as you edit. Playgrounds are the fastest feedback loop in the Apple toolchain.
When Playgrounds shine:
- “What does
.mapactually return here?” - Exploring a new SwiftUI view shape.
- Pasting in a snippet from documentation and tweaking it.
When Playgrounds frustrate:
- Anything with
importof a 3rd-party package (you can add packages to a Playground, but it’s clunky). - Code that takes more than a second to run.
- Multi-file projects.
- Anything you’ll commit to a repo.
In the wild
- Apple uses Playgrounds internally for evangelism — every SwiftUI session at WWDC ships a downloadable Playground.
- Swift Playgrounds.app (the consumer iPad app, distinct from Xcode Playgrounds) is what Apple uses to teach Swift to high-school students. It’s the same kernel underneath.
- Server-side Swift at companies like Apple itself (most of iCloud’s backend is now Swift), Kitura/Vapor users — runs as Swift Packages with
swift runin production. - The Swift compiler itself is a Swift Package. So is SwiftLint. So is Alamofire. SPM has eaten the ecosystem.
Common misconceptions
-
“You need Xcode to write Swift.” False. On macOS, Linux, and even Windows (preview), the
swifttoolchain ships separately. You can write a complete server-side Swift app in VS Code with the Swift VS Code extension and never open Xcode. -
“
swift runand the Xcode Run button do the same thing.” Subtly different. Xcode adds build configurations, codesigning steps, and platform-specific entitlements.swift runis justswift buildthen execute. For pure CLI/library code they’re equivalent; for an iOS app they’re not even comparable. -
“Playgrounds are for beginners.” Senior engineers use them constantly to verify API behavior. The first thing many of us do when learning a new framework is open a Playground and call its API to see what comes back.
-
“SPM doesn’t support resources.” It does, since Swift 5.3. You declare them in the target with
resources: [.process("Assets")].
Seasoned engineer’s take
The mental model that took me too long to develop: every Swift codebase I’ve worked on professionally is fundamentally a set of Swift Packages, plus an Xcode-shaped wrapper that turns one of them into an iOS app.
Modern iOS projects look like this:
MyAppRepo/
├── App/
│ └── MyApp.xcodeproj ← thin wrapper, mostly Info.plist + entry point
├── Packages/
│ ├── Networking/Package.swift ← URLSession code, Sendable models
│ ├── DesignSystem/Package.swift ← reusable SwiftUI components
│ └── Feature-Profile/Package.swift ← one feature module
Why? Because:
- Each package builds and tests in isolation (faster compile, faster CI).
- Each package can be opened in Xcode by itself for tight feedback loops.
- You can pull a package out and reuse it in another app or on the server.
- The “app” is just dependency-injecting features into a
WindowGroup.
Companies that have moved here in public: Spotify, Airbnb, the New York Times, Lyft, Robinhood. Once you internalize “every feature is a package,” you stop fearing dependency arrows and start designing them.
TIP: Use
swift package generate-xcodeprojis deprecated in Swift 5.7+. Don’t try to generate.xcodeprojfiles anymore — just open thePackage.swiftdirectly in Xcode (File → Openand pick the folder). Xcode 11+ has first-class SPM support.
WARNING: Putting non-trivial logic in
Package.swiftis an anti-pattern. The manifest runs in a sandbox at resolution time. Conditionals based onProcessInfo.processInfo.environmentwill work but make your package brittle and surprising to consumers. Keep manifests boring.
Interview corner
Question: “Walk me through how you’d structure a new iOS app in 2026.”
Junior answer: “I’d open Xcode, create a new iOS app project, and start coding inside it.” → Will get you a friendly nod and a follow-up: ‘and after that?’ If you don’t have an answer, you’re done.
Mid-level answer: “I’d start with an Xcode project for the app shell, then break out feature modules into Swift Packages — one for networking, one for the design system, one per feature. Each package has its own tests. The app target depends on the packages.” → Solid. Most interviewers stop here.
Senior answer: Everything above, plus: “I’d think hard about the dependency direction upfront. Feature packages should depend on abstractions (a NetworkClient protocol in a tiny NetworkingInterface package), not on concrete implementations. The app target wires the concrete URLSession-backed implementation in at composition time. That way each feature is unit-testable with a fake client, and you can swap the networking layer without touching feature code. It costs maybe a day of upfront design and pays back forever. I’d also pick the package boundaries by team boundary if the team is more than ~6 engineers — Conway’s Law applies to module graphs.” → That’s a hire.
Red-flag answer: “I’d just use CocoaPods like we did at my last job.” → Tells the interviewer you stopped learning in 2019. CocoaPods is in maintenance mode; new iOS projects in 2026 use SPM almost universally.
Lab preview
Lab 1.B (CLI with SPM) walks you through building a real command-line tool — argument parsing, file I/O, error handling — as an executable Swift Package you could publish to GitHub today.
Now that you can run Swift, let’s look at what the language actually is. → Types, variables, optionals
1.3 — Types, variables, and the optional question mark
Opening scenario
You’re reading a teammate’s code and see this:
let user: User? = await api.fetchUser(id: id)
guard let user else { return .failure(.notFound) }
let displayName = user.nickname ?? user.fullName ?? "Anonymous"
In four lines there are four optional-related operations (?, await … User?, guard let, ??). If you can’t read this fluently — like reading prose — you cannot work in modern Swift. Optionals aren’t a feature. They’re the spine of the language.
Let’s break the spine open.
Why Swift has optionals at all
In Objective-C (and C, Java, Python, Ruby, JavaScript…), any reference can be null. You don’t know whether user.email is safe to read until runtime. If you forget to check, you get a crash (NullPointerException, EXC_BAD_ACCESS) or — worse in Objective-C — a silent no-op that returns zero/nil and propagates wrong data through your app.
Tony Hoare, who invented the null reference in 1965, later called it his “billion-dollar mistake.” Swift’s design decision: the compiler refuses to let you reference something that might be nil without acknowledging it.
That acknowledgment is the optional.
Concept → Why → How → Code
Concept: T? is shorthand for Optional<T> which is an enum
public enum Optional<Wrapped> {
case none
case some(Wrapped)
}
There is no magic. User? is Optional<User>, which is either .none (the “no user” case) or .some(user) (a real user wrapped inside). Every optional operator you’ll learn (?, !, ??, if let, guard let, optional chaining) is sugar over this enum.
Why this is genius
Because the compiler can now ask, at every . access: “is this a User or an Optional<User>?” If it’s optional, you must unwrap before you can use the value. The compiler enforces what comments in other languages politely request.
How: variables, constants, and the four type-annotation rules
let pi = 3.14159 // inferred Double, immutable
var counter = 0 // inferred Int, mutable
let name: String = "Ada" // explicit type
var maybe: String? = nil // optional, currently empty
Rules of the road:
letfirst. Make every bindinglet(immutable). Switch tovaronly when you genuinely mutate.- Type inference is your friend. Don’t write types Swift can already see.
- Annotate when intent matters. Public APIs, ambiguous numeric literals (
let mass: Double = 1), or when documenting yourself. nilis only legal for optional types.let x: Int = nildoes not compile.let x: Int? = nildoes.
Code: the five ways to unwrap an optional
let nameInput: String? = readLine() // returns String?
// 1. Force unwrap (CRASHES if nil — almost always a code smell)
let force = nameInput!
// 2. Optional binding with if let (handles the value, optionally an else)
if let name = nameInput {
print("hello \(name)")
} else {
print("no name given")
}
// 3. Guard let (early-exit pattern — preferred for "must have to continue")
guard let name = nameInput else {
print("no name")
return
}
print("hello \(name)") // `name` available here as String
// 4. Nil-coalescing (default value if nil)
let final = nameInput ?? "Anonymous"
// 5. Optional chaining (call methods through the question mark)
let length = nameInput?.count // Int? — nil if nameInput is nil
let upper = nameInput?.uppercased() // String? — nil if nameInput is nil
Notice that chaining preserves optionality: nameInput?.count is Int?, not Int. The ? after a value means “if I’m nil, the whole expression is nil.”
Code: the upgrades you’ll see in modern Swift
Swift 5.7 added shorthand if-let unwrapping (no need to repeat the name):
if let nameInput { print(nameInput) } // ✅ Swift 5.7+
guard let nameInput else { return } // ✅ Swift 5.7+
Before 5.7 you had to write if let nameInput = nameInput. Now nameInput inside the braces is the unwrapped non-optional. Use the modern form.
In the wild
URLSession.shared.dataTaskand friends return(Data?, URLResponse?, Error?)— every iOS engineer has unwrapped these triplets more times than they’ve eaten breakfast.UserDefaults.standard.string(forKey: "email")returnsString?because the key might not exist. You’ll learn to coalesce these with sensible defaults.- SwiftUI’s
@State var name: String?is common when modeling “user hasn’t entered anything yet” vs “user typed empty string.” - Codable’s optional fields are the de facto way to model “field may be missing from the JSON response.”
Common misconceptions
-
“
!means ‘I know this isn’t nil.’” It actually means “trap and crash the app if I’m wrong.” It is not a documentation tool; it is a runtime weapon. Use it only at boundaries where you have a contractual guarantee (e.g.URL(string: "https://apple.com")!for a literal known-good URL). -
“Implicitly unwrapped optionals (
T!) are a clever shortcut.” They were added for Objective-C interop in 2014 and have aged poorly. In new Swift code you should almost never seevar x: Int!. Reach forT?and a real unwrap. -
“Optionals make Swift verbose.” Until you’ve debugged a production NPE in another language at 2 a.m., it can feel that way. After you have, you learn to love the noise.
-
“
??is the same as JavaScript’s??.” Mostly yes, but Swift’s??only triggers onnil, not on0or"". JS??triggers onnullandundefinedbut JS||triggers on any falsy value. Don’t conflate them. -
“I should never force-unwrap.” Slightly too strong. Test code, one-off scripts, and literally-impossible-to-fail boundaries (URL literals, hardcoded resource lookups in your own bundle) are reasonable. Production user-facing data flows? Never.
Seasoned engineer’s take
A heuristic I use during code review:
!in a feature branch → ask the author to defend it. 80% of the time they’ll convert toguard let.!in tests → fine. Test failures are loud and immediate; force-unwraps make tests more legible.!at module boundaries (Bundle.main.url(forResource: ...)!) → fine if the resource is checked-in code. The “crash” is really a build-time guarantee being asserted.as!(force-cast) → almost always wrong. Useas?and handle the failure.
A pattern worth knowing: propagate optionals up; resolve them at the edges. Inner functions return T? happily and let the caller decide the default. The UI layer (or your main) is where you decide what “no value” means for the user (empty state, placeholder, retry button). Don’t decide too early.
TIP: When learning, hover over any value in Xcode with the Option key held — Xcode’s Quick Help shows the type. Discovering that
userDefaults.string(forKey:)returnsString?(notString) the first time is a tiny eureka moment.
WARNING:
if let x = x { ... }doesn’t reassignx. It creates a new binding in the scope. The outerxis untouched, still optional. Beginners writex = x!thinking they’ve “unwrapped” the variable — they haven’t, and they’ve now introduced a crash bug.
Interview corner
Question: “What’s the difference between if let, guard let, and ??? When do you reach for each?”
Junior answer: “if let unwraps inside the if; guard let unwraps and exits if nil; ?? gives a default value.” → Correct definitions. You’d pass a screener. An onsite interviewer would push further.
Mid-level answer: “I reach for guard let when the value is required for the rest of the function — it flattens nesting and makes the happy path the linear path. I use if let when the optional is genuinely optional for the logic — a side effect like ‘log the user’s email if we have one.’ ?? is for substitution: I have a value or a sensible default, and the downstream code doesn’t care which.” → Strong. Demonstrates style judgement.
Senior answer: Everything above, plus: “I also think about what nil means semantically at each site. Sometimes nil is ‘not loaded yet’, sometimes ‘failed to load’, sometimes ‘user opted out.’ Those three deserve different types — often an enum like enum LoadState<T> { case idle, loading, loaded(T), failed(Error) } instead of a bare T?. Reaching for ?? everywhere can mask important state distinctions. Optionals are a great escape hatch; richer enums are often the right destination.” → Senior signal. Shows you think in domain types, not language primitives.
Red-flag answer: “I just use ! everywhere — it’s faster to write and you can fix the crashes later.” → Conversation ends.
Lab preview
Lab 1.A (Playground exploration) puts you in front of a Playground with deliberately broken optional code. You’ll find five force-unwraps that crash the page and rewrite each one to a safer form. Compile-error-driven learning, in the best way.
Next: how Swift glues these typed values into programs — control flow, functions, and the famously slippery closure syntax. → Control flow, functions, closures
1.4 — Control flow, functions, and the closure that ate the internet
Opening scenario
You’re reading a SwiftUI tutorial and see:
Button("Save") { try? await viewModel.save() }
.disabled(viewModel.items.allSatisfy { $0.isComplete })
.onChange(of: search) { _, new in viewModel.filter(new) }
Three different closures. Three different shapes ({ … }, { $0.isComplete }, { _, new in … }). One language. If you can’t write these from memory by the end of this chapter, every SwiftUI sample you read for the next month will look like noise.
Concept → Why → How → Code
Concept: a function is a named closure; a closure is an anonymous function
Swift makes this duality first-class. Anywhere a function is accepted, you can pass a closure literal; anywhere a closure is accepted, you can pass a function reference. They’re the same kind of value ((Args) -> Result).
Why this design
Because the language was designed in the trailing-closure era (HTML/JS-style callbacks, Ruby blocks, Rust closures). Apple’s APIs are deeply callback-oriented (UIKit delegates, completion handlers, SwiftUI view builders). Treating functions as values keeps that ergonomic.
How: functions — the boring foundation
// (1) Basic function
func greet(name: String) -> String {
"Hello, \(name)"
}
// (2) External and internal parameter names
func move(from origin: Point, to destination: Point) { … }
move(from: a, to: b) // reads like English at the call site
// (3) Omit external name with underscore
func square(_ x: Int) -> Int { x * x }
square(4) // not square(x: 4)
// (4) Default values
func log(_ msg: String, level: LogLevel = .info) { … }
// (5) Variadic parameters
func sum(_ numbers: Int...) -> Int { numbers.reduce(0, +) }
sum(1, 2, 3, 4) // 10
// (6) inout parameters (mutate the caller's value)
func double(_ x: inout Int) { x *= 2 }
var n = 3; double(&n); print(n) // 6
The call-site argument labels are Swift’s signature ergonomic choice. move(from: a, to: b) reads naturally; move(a, b) doesn’t. Embrace it.
Code: closure syntax, from longhand to shorthand
Every line below is the same closure:
// 1. Full form
let doubled1 = [1, 2, 3].map({ (x: Int) -> Int in
return x * 2
})
// 2. Type inference — drop the types
let doubled2 = [1, 2, 3].map({ x in return x * 2 })
// 3. Implicit return when the body is a single expression
let doubled3 = [1, 2, 3].map({ x in x * 2 })
// 4. Shorthand argument names ($0, $1, …)
let doubled4 = [1, 2, 3].map({ $0 * 2 })
// 5. Trailing closure syntax (when the closure is the last argument)
let doubled5 = [1, 2, 3].map { $0 * 2 }
By line 5 you have the form you’ll write 95% of the time. The other forms exist for moments when you genuinely need the clarity.
Code: multiple trailing closures (Swift 5.3+)
UIView.animate(withDuration: 0.3) {
button.alpha = 0
} completion: { _ in
button.removeFromSuperview()
}
The first closure is unnamed (the “primary” trailing closure); subsequent ones use their argument label. This is heavily used in SwiftUI:
Button {
save()
} label: {
Text("Save").bold()
}
Control flow: only the surprising bits
You already know if, while, for. Swift adds nuances:
// for-in with where clause
for n in 1...100 where n.isMultiple(of: 7) { print(n) }
// switch is exhaustive and pattern-matches richly
switch httpStatus {
case 200..<300: print("ok")
case 301, 302: print("redirect")
case let code where code >= 500: print("server bork: \(code)")
default: print("???")
}
// if-let / guard-let — see previous chapter
// switch can destructure tuples and enum associated values
switch result {
case .success(let value): process(value)
case .failure(let error as URLError): retry(after: error)
case .failure(let other): log(other)
}
// if and switch are EXPRESSIONS since Swift 5.9
let label = if status == 200 { "OK" } else { "Error" }
let pricing = switch tier {
case .free: 0
case .pro: 9
case .team(let seats): seats * 5
}
The if/switch-as-expression form is one of the modern Swift features most likely to upgrade the readability of code you write daily.
In the wild
- SwiftUI’s entire view body is closures.
var body: some View { … }is a closure under the hood (a@ViewBuilder-attributed one, which we’ll meet in Phase 4). URLSession’s completion-handler API uses closures; the modernasyncAPI replaces them but you’ll still maintain both styles in real codebases.Combine’ssink { value in … }and.map { … }chains are closures all the way down.- Test frameworks (
XCTest,Swift Testing) use trailing closures for assertions:#expect { try parser.parse(input) }.
Common misconceptions
-
“
returnis always required.” Not when the closure (or function) body is a single expression.func square(_ x: Int) -> Int { x * x }is valid Swift since 5.1. -
“
$0,$1are magic — I have to use them.” No. They’re shorthand. You can name parameters:.map { value in value * 2 }. Use named parameters when there are 2+ arguments or when the closure body is more than a single line. -
“Trailing closures only work with one closure parameter.” Multi-trailing-closure syntax has been around since Swift 5.3 (2020). Use it. Don’t paren-nest closures into oblivion.
-
“
ifandswitchare statements, not expressions.” They are both in modern Swift. Use them as expressions to flatten chained-assignment ladders. -
“
for x in 0..<array.count { array[x] … }is the idiomatic loop.” No.for item in array { … }is. Index iteration is for when you genuinely need the index (usearray.enumerated()for(index, element)pairs).
Seasoned engineer’s take
The closure-syntax progression (full form → trailing) is Swift’s most polarizing onboarding hurdle. New engineers see .map { $0.title } and feel locked out. Old engineers see .map({ (item: Item) -> String in return item.title }) and feel pity. Spend an evening writing the same closure five ways in a Playground until shorthand becomes invisible — you’ll save years of code-reading friction.
Two opinions you should form early:
- Default to
funcfor anything that needs documentation or testing in isolation; default to closures inline when the logic is incidental. A function deserves a name when calling it twice would feel right. Closures are for one-shot transformations. - Long closures are a smell. When
.map { … }exceeds ~5 lines, extract afuncand pass it by reference:.map(transform). Your future self thanks you.
Also: func is overloadable on argument labels, not just on types. move(from:to:) and move(by:) are different functions and that’s normal. Embrace the labels; they document call sites better than any comment.
TIP: When Xcode autocompletes a SwiftUI modifier and inserts
{ <#code#> }, that’s a trailing closure placeholder. Press Tab to fill it in.
WARNING: Capturing
selfin a closure that outlivesselfis the #1 cause of memory leaks in Swift apps. We’ll fix this properly in Memory Management. For now: when in doubt, write[weak self] inat the top of any closure stored as a property or passed to a long-lived callback.
Interview corner
Question: “Explain trailing-closure syntax. Why is [1,2,3].map { $0 * 2 } valid?”
Junior answer: “map takes a closure, and trailing-closure syntax lets you write the closure outside the parens. $0 is the first argument.” → Correct. They’ll push: ‘why is this useful?’
Mid-level answer: All of the above, plus: “It’s mostly a readability win — SwiftUI’s view builder would be unbearable without it. Multi-trailing-closure syntax (Swift 5.3) extended this to APIs like UIView.animate(duration:animations:completion:).” → Solid.
Senior answer: Plus: “It’s also a hint about Swift’s design priorities. The language deliberately makes the callee (the API author) work harder to produce ergonomic call sites, instead of pushing complexity onto the caller. That’s why we have argument labels, default parameters, variadics, result builders. Closures and trailing-closure syntax are part of that same design philosophy: optimize the read path, even if writing the API is a little fiddlier. When designing my own APIs I think about which arguments callers will fill in dynamically (label them clearly) versus which can have sensible defaults (give them =).” → That’s the signal of someone who’s designed real APIs, not just consumed them.
Red-flag answer: “Trailing closures are just syntactic sugar — they don’t matter.” → Tells the interviewer you don’t read SwiftUI code.
Lab preview
Lab 1.C (Protocol-oriented calculator) uses closures heavily — you’ll pass arithmetic operations as (Double, Double) -> Double values and compose them at runtime. By the time you finish, the syntax will be muscle memory.
Next: collections. Arrays, dictionaries, sets, and the higher-order functions that make Swift feel like a functional language. → Collections
1.5 — Collections, and the higher-order functions that came with them
Opening scenario
You open a code review and find this one-liner:
let names = users
.filter { $0.isActive }
.sorted { $0.createdAt > $1.createdAt }
.prefix(10)
.map(\.displayName)
Four operations, zero for loops, reads like a sentence. The first time you see it, it’s intimidating. The tenth time, it’s the only way you want to write Swift. This chapter gets you to the tenth time.
The three Swift collections you’ll use 99% of the time
| Type | What it is | When to reach for it |
|---|---|---|
Array<T> ([T]) | Ordered, indexable, allows duplicates | Default. Lists, sequences, anything ordered. |
Dictionary<K, V> ([K: V]) | Unordered key→value, keys must be Hashable | Lookups by id, configuration maps, counts. |
Set<T> | Unordered, unique, Hashable elements | Membership tests, deduplication. |
You’ll also occasionally touch Range (0..<10), ContiguousArray, OrderedDictionary (from swift-collections), but the three above carry most of daily life.
Concept → Why → How → Code
Concept: Swift collections are value types with copy-on-write
When you write let b = a for an array, Swift conceptually copies. But internally it shares the buffer until you mutate. The mutation triggers the actual copy. This is copy-on-write (COW). The upshot:
- You get value-type semantics (
bdoesn’t change whenadoes). - You don’t pay the copy cost unless you mutate.
- Passing an array to a function is cheap.
Why this matters
Other languages force you to choose: value semantics with copies (slow, safe) or reference semantics (fast, full of bugs). Swift gives you value semantics that are usually as cheap as references. You write naturally; the runtime optimizes.
How: the literal syntax
let xs: [Int] = [1, 2, 3]
let scores: [String: Int] = ["Ada": 95, "Linus": 88]
let tags: Set<String> = ["swift", "ios", "macos"]
let range = 0..<10 // half-open
let inclusive = 0...10 // closed
let empty1: [Int] = []
let empty2: [String: Int] = [:]
let empty3 = Set<String>()
Empty collection literals need a type annotation (Swift can’t infer []). Or use the explicit init.
Code: array essentials
var nums = [3, 1, 4, 1, 5, 9]
nums.append(2)
nums.insert(0, at: 0)
nums.remove(at: 2)
nums[1] = 99 // mutate by index
nums.count // 7
nums.isEmpty // false
nums.first // Int? — empty arrays return nil
nums.last // Int?
nums.contains(4) // Bool — O(n) for arrays
nums.indices // 0..<7 — for index-aware loops
Index out of bounds crashes. There is no automatic nil-return. Use nums.first, nums.last, or guard your indices.
Code: dictionary essentials
var ages = ["Ada": 36, "Linus": 54]
// Reading — subscript returns Int?
let adaAge = ages["Ada"] // Int? — nil if absent
// Reading with default
let unknown = ages["Bob", default: 0] // 0 (does not insert)
// Writing
ages["Grace"] = 87 // insert or overwrite
ages["Ada"] = nil // delete the key
ages.removeValue(forKey: "Linus") // alternative
// Iterating (order is not stable across runs)
for (name, age) in ages { print("\(name): \(age)") }
// Common: count occurrences
let words = "the the quick brown fox the lazy fox".split(separator: " ")
var counts: [Substring: Int] = [:]
for w in words { counts[w, default: 0] += 1 }
// counts == ["the": 3, "quick": 1, "brown": 1, "fox": 2, "lazy": 1]
The dict[key, default: …] subscript with += 1 is the canonical Swift counter pattern. Memorize it.
Code: set essentials
let a: Set = [1, 2, 3, 4]
let b: Set = [3, 4, 5, 6]
a.union(b) // {1, 2, 3, 4, 5, 6}
a.intersection(b) // {3, 4}
a.subtracting(b) // {1, 2}
a.isDisjoint(with: b) // false
a.contains(2) // O(1) — vs O(n) for array
When you find yourself checking array.contains(x) inside a loop, convert the array to a Set first. O(n²) → O(n).
Higher-order functions: the meat of the chapter
Every Swift collection type conforms to Sequence and Collection, which provide a rich set of methods that take closures. Master these:
let nums = [1, 2, 3, 4, 5]
// MAP — transform each element
let squared = nums.map { $0 * $0 }
// [1, 4, 9, 16, 25]
// FILTER — keep only matching elements
let even = nums.filter { $0.isMultiple(of: 2) }
// [2, 4]
// REDUCE — collapse to a single value
let total = nums.reduce(0, +) // 15
let product = nums.reduce(1, *) // 120
let csv = nums.reduce("") { $0 + "\($1)," } // "1,2,3,4,5,"
// COMPACTMAP — map + drop nils
let strings = ["1", "two", "3"]
let parsed = strings.compactMap { Int($0) }
// [1, 3]
// FLATMAP — map then flatten one level
let nested = [[1, 2], [3, 4]]
let flat = nested.flatMap { $0 }
// [1, 2, 3, 4]
// SORTED — returns a new sorted array
let mixed = [3, 1, 4, 1, 5, 9, 2, 6]
let asc = mixed.sorted() // ascending by default
let desc = mixed.sorted(by: >) // descending
let byCount = ["bb", "a", "ccc"].sorted { $0.count < $1.count }
// PREFIX / SUFFIX / DROPFIRST / DROPLAST — slicing
nums.prefix(3) // [1, 2, 3]
nums.suffix(2) // [4, 5]
nums.dropFirst() // [2, 3, 4, 5]
nums.dropLast(2) // [1, 2, 3]
// ALLSATISFY / CONTAINS / FIRST(WHERE:) — querying
nums.allSatisfy { $0 > 0 } // true
nums.contains { $0 > 4 } // true
nums.first { $0.isMultiple(of: 2) } // 2 (Int?)
// ENUMERATED — index + element pairs
for (i, n) in nums.enumerated() { print("\(i): \(n)") }
// ZIP — pairwise iteration over two sequences
for (name, age) in zip(["Ada", "Linus"], [36, 54]) {
print("\(name) is \(age)")
}
The KeyPath shorthand (Swift 5.2+) lets you replace { $0.title } with \.title:
let titles = articles.map(\.title) // instead of { $0.title }
let activeNames = users.filter(\.isActive).map(\.name)
This works wherever a (T) -> U is expected and U is a property of T.
In the wild
- JSON parsing pipelines: every
URLSession.dataTaskreturning JSON funnels through adecode → filter → map → sortchain. - SwiftUI’s
ForEach(items)iterates collections; idiomatic SwiftUI is full ofitems.filter { … }.sorted { … }to drive the view. - Core Data fetched results are converted to
[Entity]and then transformed with higher-order functions before display. - Networking layers convert
[APIPost]→[DomainPost]with.map(Post.init). This is called the mapper pattern and is everywhere in production iOS code.
Common misconceptions
-
“
mapandforloops are interchangeable; use whichever feels right.” Subtly wrong.mapreturns a new array of the same length. If you’re usingmapfor side effects (array.map { print($0) }), you’re misusing it. UseforEachor aforloop for side effects. The compiler will eventually warn you about the unused return value. -
“Higher-order functions are slow.” In Swift, the compiler aggressively inlines
map/filter/reduceclosures. The difference vs a hand-written loop is usually unmeasurable. Premature manual loops for “performance” is a 2014 attitude. -
“
Array.containsis fast.” O(n). For repeated lookups, convert to aSetonce and check membership in O(1). -
“
Dictionarypreserves insertion order.” Swift’sDictionarydoes not guarantee order. If you need ordered key-value pairs, useOrderedDictionaryfrom swift-collections. -
“
reduceis too clever for production.” Disagree, but the first parameter is the initial value, and the closure is(accumulator, element) -> accumulator. Once that clicks, it’s the most general tool in your kit.
Seasoned engineer’s take
A pipeline of higher-order functions is the declarative shape of a transformation. A for loop is the imperative shape. Both produce the same output; they have very different review and refactor costs.
- A
.filter { $0.isActive }.map(\.id)pipeline is self-documenting — a reader sees the intent (keep active users, take ids). - The equivalent
forloop with mutable accumulators requires the reader to execute the loop mentally to discover the same intent.
Use the pipeline form by default. Drop to a for loop when:
- You need early-exit (
break/return). - You’re producing multiple outputs from one pass (which would otherwise require iterating twice).
- The transformation involves more than ~3 steps; at that point break it into named functions with descriptive names — composition still beats a single megastatement, but legibility wins over chain length.
Also: be wary of flatMap on optional sequences. Modern Swift renamed the optional version to compactMap to avoid confusion. If you mean “map and drop nils”, use compactMap. If you mean “map and flatten nested arrays”, use flatMap.
TIP: The
lazymodifier (array.lazy.filter { … }.map { … }.first { … }) defers evaluation. Useful when you’re searching a huge collection and want to stop at the first match without materializing the intermediates.
WARNING:
SetandDictionaryiteration order is not guaranteed to be stable across runs (or even within a run, in theory). Never rely on the order. If your tests pass on macOS and fail in CI on Linux, this is often the cause.
Interview corner
Question: “Given an array of [User], return the top 5 active users by signup date, as [String] (their display names).”
Junior answer:
var actives: [User] = []
for u in users {
if u.isActive { actives.append(u) }
}
// then sort, then take 5, then map names…
→ Correct, verbose. They’ll ask: “can you do that in one line?”
Mid-level answer:
let result = users
.filter { $0.isActive }
.sorted { $0.createdAt > $1.createdAt }
.prefix(5)
.map { $0.displayName }
→ Strong. Pipeline is idiomatic.
Senior answer: All of the above, plus: “I’d reach for \.displayName keypath shorthand on the last map. I’d also point out this is O(n log n) because of the sort — fine for thousands, suboptimal for millions. For a very large input I’d use a min-heap of size 5 to do it in O(n log k). And if createdAt were nullable I’d handle the optional explicitly with compactMap rather than crash on a force-unwrap.”
let result = users
.filter(\.isActive)
.sorted { $0.createdAt > $1.createdAt }
.prefix(5)
.map(\.displayName)
→ Senior signal. Knows the language idioms, the complexity, and the production edge cases.
Red-flag answer: “I’d write a custom sort algorithm because the built-in one isn’t tuned for my data.” → Unless you’ve benchmarked, this is a make-work answer. Swift’s sort is Timsort-style; it’s excellent.
Lab preview
Lab 1.A (Playground exploration) includes a section where you’ll process a sample dataset (the words of Hamlet) using only higher-order functions: word counts, longest words by length, top-N alphabetized. No for loops allowed.
Next: how Swift models kinds of things — structs, classes, enums, protocols, and the religious war between them. → Structs, classes, enums, protocols
1.6 — Structs, classes, enums, protocols (the four pillars)
Opening scenario
You join a code review and find this PR:
class User {
var id: UUID
var name: String
var email: String
init(id: UUID, name: String, email: String) { /* ... */ }
}
You leave a review comment: “Should this be a struct?”
The author responds: “Why does it matter?”
How you answer that question — in your head, in a PR, in an interview — defines whether you’re a Swift programmer or a Java/Kotlin programmer typing Swift.
The taxonomy
Swift has named types in four flavors:
| Kind | Value or reference? | Inheritance? | Best for |
|---|---|---|---|
struct | Value | No | Data models, view state, anything immutable-ish |
class | Reference | Yes (single) | Identity, shared mutable state, ObjC interop |
enum | Value | No | Closed sets of cases, state machines, results |
actor | Reference (isolated) | No | Concurrency-safe mutable state (Chapter 1.9) |
Plus protocol — not a type itself but a contract a type can adopt — which is what makes Swift’s OOP feel different from Java’s or Kotlin’s.
We’ll cover all of these, then end on the question every Swift engineer has to answer: struct or class?
Concept → Why → How → Code
Structs: the default
struct Point {
var x: Double
var y: Double
func distance(to other: Point) -> Double {
let dx = x - other.x
let dy = y - other.y
return (dx*dx + dy*dy).squareRoot()
}
// mutating methods must say so explicitly
mutating func translate(by delta: Point) {
x += delta.x
y += delta.y
}
}
let a = Point(x: 0, y: 0) // memberwise init for free
var b = a // COPY, not a reference
b.x = 5
print(a.x) // 0 — a unchanged
Why value semantics matter: when you pass a Point to a function, the function gets a copy. It cannot mutate your Point behind your back. Local reasoning becomes possible.
Classes: when you need identity
class ViewModel {
var items: [Item] = []
func reload() { /* ... */ }
}
let vm1 = ViewModel()
let vm2 = vm1 // SAME instance — both point to the same object
vm2.items.append(Item())
print(vm1.items.count) // 1 — they share state
// Equality: === is reference identity, == is value equality (if Equatable)
print(vm1 === vm2) // true
You reach for class when:
- You need identity (two
Userinstances with the same name are still different users in your domain). - You need inheritance (a
UIViewControllersubclass). - You’re interoperating with Objective-C (NSObject subclass).
- You need shared mutable state with reference semantics (a cache, a coordinator).
Enums: pattern matching is the point
Swift enums are dramatically more powerful than C/Java enums. They carry associated values and support methods, computed properties, even protocols.
enum LoadState<T> {
case idle
case loading
case loaded(T)
case failed(Error)
var isFinished: Bool {
switch self {
case .loaded, .failed: true
case .idle, .loading: false
}
}
}
let state: LoadState<[Post]> = .loaded([])
switch state {
case .idle: print("waiting")
case .loading: print("...")
case .loaded(let xs): print("got \(xs.count)")
case .failed(let e): print("error: \(e)")
}
This is the single most powerful Swift feature for modeling domain state. Bad code says var isLoading: Bool, var data: [Post]?, var error: Error?. Good code says var state: LoadState<[Post]>.
Raw values (when each case has a primitive underlying value):
enum HTTPStatus: Int {
case ok = 200
case notFound = 404
case serverError = 500
}
let s = HTTPStatus(rawValue: 404) // HTTPStatus? — Optional
Protocols: contracts that types adopt
protocol Drawable {
func draw(in context: GraphicsContext)
var bounds: CGRect { get }
}
struct Circle: Drawable {
let center: CGPoint
let radius: Double
var bounds: CGRect { CGRect(x: center.x - radius, /* … */ ) }
func draw(in ctx: GraphicsContext) { /* … */ }
}
extension Array where Element == Drawable {
func drawAll(in ctx: GraphicsContext) {
forEach { $0.draw(in: ctx) }
}
}
Protocols are what types can be expected to do; structs/classes/enums are how that’s delivered. Functions can require Drawable instead of caring what kind of thing they got.
Protocol extensions: behavior with no inheritance
Java/Kotlin extract shared behavior via an abstract base class. Swift uses protocol extensions:
protocol Greetable {
var name: String { get }
}
extension Greetable {
func greet() -> String { "Hello, \(name)" } // default implementation
}
struct Person: Greetable { let name: String }
Person(name: "Ada").greet() // "Hello, Ada"
This is protocol-oriented programming — the design ethos Apple promoted hard at WWDC 2015. Compose behavior into small protocols; let concrete types adopt the protocols they need; share implementations via extensions.
In the wild
Codableis a protocol (composed ofEncodableandDecodable). Conform your model struct and free JSON encoding/decoding appears via the compiler-generated implementation.Identifiable,Hashable,Equatable— used everywhere in SwiftUI’sForEach, inSet, inDictionarykeys. The compiler can synthesize all three.Viewin SwiftUI is a protocol, not a class. Every SwiftUI view is a struct (yes, structs!) conforming toView.Sendable— the Swift 6 concurrency protocol that marks types safe to cross actor boundaries.- MVVM/MVI architectures: the M (model) is usually a struct, the VM (view model) is usually a class with
ObservableObjector@Observable, the V (view) is a struct.
Common misconceptions
-
“Classes are more ‘real’ OOP than structs.” This is Java thinking. In Swift the default is struct; classes are a specialization for when you need their unique features.
-
“Structs are slow because they copy.” COW (copy-on-write) makes struct copies cheap for the standard collections. For your own structs, copying is just member-wise — small. The compiler also optimizes returns to avoid copies.
-
“You can’t have polymorphism with structs.” False — protocols give you polymorphism without inheritance.
func render(_ shapes: [any Drawable])accepts circles, squares, paths, all heterogeneous. -
“
enumis for fixed lists likeColor { red, green, blue }.” That’s the C view. In Swift, enums with associated values are the canonical way to model “one of these N possibilities, each with different data.” -
“
protocols are just Java interfaces.” Similar, but with two key differences: (a) protocols can have default implementations (extensions), and (b) protocols can constrain associated types (we’ll see this in Generics).
Seasoned engineer’s take
Apple’s official advice is: start with a struct. Move to a class only when you have a reason. Reasons:
- You need identity — two distinct objects with identical fields are different (a
Userin your domain, a network session). - You need inheritance — typically because UIKit/AppKit forces it on you.
- You need shared mutable state with reference semantics (a cache, an in-memory store).
- You need Objective-C interop (
NSObjectsubclass for KVO, NSCoding, etc.).
For everything else — your Article, your BlogPost, your Profile, your view-state, your DTOs from the network — use struct. Value semantics + protocol conformance is the modern Swift idiom.
A heuristic I find useful: does it make sense to compare two instances with ==? If “same data = equal” is your domain rule, struct. If “different objects, even with same data” is the rule, class. (User(id: 1, name: "Ada") == User(id: 1, name: "Ada") → true makes sense, so User is a struct.)
The hard cases:
- Big structs (>200 bytes). Pass-by-value is still cheap (Swift uses register passing where possible), but if you’re holding millions of them, profile.
- Recursive types (a
Nodewithvar children: [Node]). Structs work fine for immutable trees; for mutable recursive structures, classes are often less surprising. - Long-lived state that must be unique (like a coordinator object that owns navigation). Classes with
finalkeyword.
TIP: Conform your structs to
Equatable,Hashable,Codableproactively. The Swift compiler synthesizes them for free if every stored property already conforms. It costs you nothing and unlocksSet,Dictionarykeys,ForEach, JSON I/O.
WARNING: Inheritance with classes is a slippery slope. Three levels deep and you’ll wish you’d composed protocols instead. Apple has explicitly stated the modern Swift recommendation is composition over inheritance. Use
final classby default — most classes should be unsubclassable unless they’re explicitly designed to be inherited.
Interview corner
Question: “When would you use a class instead of a struct in Swift?”
Junior answer: “When I need inheritance or when I want shared mutable state.” → Correct, but textbook. They’ll push.
Mid-level answer: “I default to struct for value-semantic models (anything that’s just data). I use class when I need (a) reference identity — like a long-lived ViewModel that the view holds a reference to, (b) inheritance — usually forced by UIKit, (c) Objective-C interop, or (d) when the type is genuinely a thing in the world rather than a value — a cache, a network session manager. With SwiftUI specifically, my models are structs, my view models are @Observable classes.” → Strong.
Senior answer: All of that, plus: “The deeper question is about identity vs. value. User is interesting because reasonable people disagree. Some teams treat User as a value (two Users with the same id are equal, immutable snapshots). Other teams treat User as having identity (the User object you’re holding is the user, mutations propagate). I’d ask: do we need to observe changes to this object in many places? Do we share mutation with state-management infrastructure (@Observable, Redux store)? If yes → class. If we’re passing snapshots around (network DTOs, view state) → struct. I’d also point out that the answer evolves: a User struct + a UserSession class is often cleaner than one giant User class doing both jobs.” → Senior signal: distinguishes data from identity.
Red-flag answer: “Classes are better because they’re faster.” → Both wrong (structs are often faster due to stack allocation and inlining) and outs you as someone who’s never profiled.
Lab preview
Lab 1.C (Protocol-oriented calculator) builds an arithmetic library where every operation is a struct conforming to a BinaryOperation protocol. You’ll see protocol-oriented design in 80 lines.
Next: Swift’s most powerful and most intimidating feature — generics. → Generics and the type system
1.7 — Generics and the Swift type system
Opening scenario
You’re reading the standard library and notice:
public struct Array<Element> : RandomAccessCollection { /* … */ }
public func + <T>(lhs: [T], rhs: [T]) -> [T]
public protocol Sequence {
associatedtype Element
associatedtype Iterator: IteratorProtocol where Iterator.Element == Element
/* … */
}
This is the language talking to itself in the abstract. Generics, associated types, where clauses, protocols-with-Self-constraints — Swift’s type system is closer to Haskell or Rust than to Java or Kotlin. You don’t have to write generic algorithms from scratch on day one. You do have to read them confidently. By the end of this chapter, you will.
Concept → Why → How → Code
Concept: a generic type is a type with type-parameters
struct Stack<Element> {
private var items: [Element] = []
mutating func push(_ x: Element) { items.append(x) }
mutating func pop() -> Element? { items.popLast() }
}
var s = Stack<Int>()
s.push(1); s.push(2)
print(s.pop() ?? -1) // 2
Stack<Int> and Stack<String> are two distinct types generated from one template. The compiler specializes generic code per concrete type — there’s no boxing, no runtime dispatch (unlike Java’s erased generics).
Why this is essential
Without generics, Array<Int> and Array<String> would either:
- Be two separate hand-coded types (DRY violation, maintenance hell), or
- Use
Anyinternally (no type safety, runtime crashes).
Generics give you one implementation that’s type-safe at every call site, with zero runtime overhead because the compiler monomorphizes.
How: generic functions
func swapTwo<T>(_ a: inout T, _ b: inout T) {
let tmp = a; a = b; b = tmp
}
var x = 1, y = 2
swapTwo(&x, &y) // T inferred as Int
var s1 = "hello", s2 = "world"
swapTwo(&s1, &s2) // T inferred as String
How: constraints
Bare T lets you assign and store but not much else. You can’t compare, hash, add — the compiler doesn’t know what T supports. Constraints tell the compiler what to assume:
func minimum<T: Comparable>(_ xs: [T]) -> T? {
guard var best = xs.first else { return nil }
for x in xs.dropFirst() where x < best { best = x }
return best
}
minimum([3, 1, 4, 1, 5]) // 1
minimum(["banana", "apple"]) // "apple"
<T: Comparable> means “T must conform to Comparable.” Now < is legal inside the function.
Multiple constraints with where:
func deduplicate<S: Sequence>(_ seq: S) -> [S.Element]
where S.Element: Hashable {
var seen = Set<S.Element>()
return seq.filter { seen.insert($0).inserted }
}
How: associated types in protocols
Generics on a protocol are spelled differently — using associatedtype:
protocol Container {
associatedtype Item
var count: Int { get }
mutating func append(_ item: Item)
subscript(i: Int) -> Item { get }
}
struct IntBag: Container {
private var xs: [Int] = []
var count: Int { xs.count }
mutating func append(_ item: Int) { xs.append(item) }
subscript(i: Int) -> Int { xs[i] }
}
// Item is INFERRED as Int from the append signature
The reason Sequence.Element exists as associatedtype Element and not protocol Sequence<Element> is historical (associated types predate primary-associated-type syntax). Both are now valid forms.
How: primary associated types (Swift 5.7+)
protocol Collection<Element>: Sequence {
associatedtype Element
/* … */
}
func process(_ items: any Collection<Int>) { … }
// ^^^^^^^^^^^^^^^ — uses the primary associated type
Before Swift 5.7, you had to write where Items.Element == Int. Now Collection<Int> is shorthand. This is one of the biggest recent quality-of-life upgrades to the type system.
How: some and any — the two erasures
This is the part where the conceptual model is most important.
// (1) Concrete type — no abstraction
func makeCircle() -> Circle { Circle(radius: 5) }
// (2) Opaque return type with `some` — "I return ONE specific concrete type
// that conforms to View, but I won't tell you which one"
func makeShape() -> some View {
Circle().fill(.red)
}
// (3) Existential type with `any` — "I return SOME type conforming to View,
// possibly different on each call; box it"
func makeShapes() -> [any View] {
[Circle(), Rectangle(), Triangle()]
}
| Form | Compiler knows the concrete type? | Runtime cost | When |
|---|---|---|---|
Circle | yes, exactly | none | concrete is fine |
some View | yes, one fixed concrete per call site | none | opaque return (SwiftUI everywhere) |
any View | no — boxed existential | one indirection per call | heterogeneous collections |
Rule of thumb: prefer some for single returns (think SwiftUI bodies); reach for any only when you genuinely need heterogeneous collections.
In the wild
- SwiftUI’s entire view system is built on
some View. Everyvar body: some View { … }returns an opaque generic-shaped view tree. CombineandAsyncSequenceuse heavy generics —Publisher<Output, Failure>is one of the more advanced uses.Result<Success, Failure>— the standard library’s generic return type for fallible operations.Codablesynthesis is generic:JSONDecoder().decode(MyType.self, from: data)works for anyDecodabletype.
Common misconceptions
-
“
someandanyare the same thing.” Profoundly different.somepreserves type identity (the compiler knows it’s all the same concrete type);anyerases it (the value is boxed, each instance might be different). Misusing them is the #1 source of “why won’t this compile?” frustration in modern SwiftUI. -
“Generics make code slower because of runtime dispatch.” Wrong for Swift specifically. The compiler specializes generic code at compile time per concrete type. There’s no boxing, no v-table dispatch (unlike
any Pwhich does box). -
“You should make every function generic to maximize reuse.” Generics have a cost: longer compile times, more complex error messages, harder onboarding. Use them when you have at least two concrete types the function should accept. Single-use “generic” code is just abstraction theater.
-
“Associated types are the same as type parameters.” Conceptually similar, syntactically different, and crucially: you can’t have a function like
func foo<C: Container>(...)where C has multiple associated types withoutwhereclauses spelling them out. -
“
anyis deprecated; you should never use it.” False.anyis correct (and required by the compiler in Swift 5.7+ for clarity) when you need a heterogeneous collection or a runtime-determined type. The cost is real but usually negligible.
Seasoned engineer’s take
Generics in Swift are like a sharp knife. You don’t need one to make a sandwich, but the moment you start cooking dinner for a family you’ll wish you had it.
Beginner mistake: never reaching for generics, copying functions for [String] and [Int]. Senior mistake: making everything generic from day one, drowning compile times in 12-second error messages.
A good progression:
- Start with concrete types. Write the function for
[User]. - When you find yourself copy-pasting that function for
[Post], then make it generic. - When the generic version starts attracting
whereclauses three lines long, ask whether you need a protocol instead. Often the right abstraction is “things that have anid” (Identifiable), not “Things that look like User and Post.”
For protocols specifically: the modern Swift trend is to use protocols when you need polymorphism, generics when you need type-parameter abstraction. Protocols compose horizontally (a type conforms to several); generics compose vertically (a function or type takes a parameter).
The single most empowering thing you can do for your Swift career: read the standard library declarations in Xcode (Cmd-click → “Show in Standard Library”). Sequence, Collection, Result, Array — the way these are written is the canonical idiom you should model your own generic code on.
TIP: Compiler errors for generics are notoriously long. If Xcode complains about a constraint, split the call into two lines (assign intermediate values to typed variables). The error will collapse from 40 lines to a clear “expected
String, gotInt.”
WARNING: Do not write
func foo(x: any Sequence<Int>)when you meanfunc foo<S: Sequence>(x: S) where S.Element == Int. Both compile; the first boxes every call, the second specializes. For hot paths the difference is measurable.
Interview corner
Question: “Explain the difference between some View and any View in SwiftUI.”
Junior answer: “some is opaque, any is existential. They both mean ‘returns a View.’” → Definitions, no insight. Pass a screen.
Mid-level answer: “some View means the function returns a single concrete type conforming to View — the type is hidden from the caller but fixed at the compiler level. any View is a box that can hold any View at runtime; different instances can have different underlying types. SwiftUI’s body uses some View because that lets the framework’s diffing algorithm see the type structure and reuse views efficiently.” → Strong.
Senior answer: Plus: “The performance distinction is real but often misunderstood. some allows full monomorphization — no boxing, all method calls statically dispatched. any requires an existential container, witness tables, dynamic dispatch on every protocol method. For SwiftUI specifically, if you wrap your body in any View you defeat SwiftUI’s whole diffing strategy because the framework can’t see the type identity of subtrees — it has to assume every update changes the type and rebuild more aggressively. That’s why you’ll see @ViewBuilder and result builders return some View everywhere. Outside SwiftUI, any is the right tool when you genuinely need heterogeneous collections, but I’d default to some and only reach for any when I can’t otherwise satisfy the type system.” → Senior signal: understands the cost and the framework consequence.
Red-flag answer: “I just put some in front of every return type because that’s what Xcode autocompletes.” → Cargo-cult code.
Lab preview
Lab 1.C (Protocol-oriented calculator) and Lab 1.D (Async fetcher) both lean on generics — the calculator builds a generic Operation<Operand> protocol, the fetcher uses URLSession with Decodable generics.
Next: when things go wrong — Swift’s distinctive error-handling model. → Error handling
1.8 — Error handling: throw, try, Result, and when to use which
Opening scenario
You inherit a screen with this code:
func loadProfile(id: String) async -> Profile? {
do {
let data = try await api.fetch(id: id)
let profile = try JSONDecoder().decode(Profile.self, from: data)
return profile
} catch {
return nil
}
}
The UI shows “Profile not found” when anything goes wrong: network down, JSON malformed, server returned a 401, the user is offline. The product manager files a bug: “users say the app lies about errors.” You agree. You also agree, after reading this chapter, that the entire catch block above is a category of bug. Let’s learn how to do better.
Concept → Why → How → Code
Concept: Swift errors are values, marked at the function signature
Three actors collaborate:
- The
Errorprotocol — any type can be an error (usually an enum). - The
throwskeyword on a function — says “this function may throw an error.” - The
trykeyword at call sites — says “I acknowledge this might throw.”
enum NetworkError: Error {
case offline
case timeout
case unauthorized
case server(status: Int)
}
func fetch(_ url: URL) throws -> Data {
// … throws NetworkError.offline / .timeout / etc.
throw NetworkError.offline
}
do {
let data = try fetch(myURL)
process(data)
} catch NetworkError.unauthorized {
showLogin()
} catch NetworkError.server(let status) where status >= 500 {
showRetry()
} catch {
showGenericError(error)
}
Why this design
Other languages: errors are exceptions (Java, Python) — unannotated, can come from anywhere, often abused for control flow. Or: errors are return values (Go, Rust) — typed, but verbose at every call site.
Swift splits the difference: errors are values, but the syntax (try/throws) keeps call sites readable. The compiler forces you to handle them — you cannot accidentally swallow an error by forgetting a catch.
How: the four error-handling tools
// 1. do / try / catch — handle locally
do {
let user = try loadUser()
show(user)
} catch {
showError(error)
}
// 2. try? — convert to optional (nil on failure)
let user: User? = try? loadUser()
// 3. try! — force "I know this won't throw" (CRASHES if wrong)
let bundleURL = try! Bundle.main.url(forResource: "config", withExtension: "json")!
// 4. throws propagation — let the caller deal with it
func handler() throws -> User {
try loadUser() // re-throws automatically
}
try? is the analog of as?: it gives you Optional<T> and loses the error detail.
Code: typed throws (Swift 6+)
Until Swift 6, every throws function could throw any Error. Now you can constrain it:
func fetch(_ url: URL) throws(NetworkError) -> Data { … }
do {
let data = try fetch(url)
} catch NetworkError.offline { … } // exhaustive — compiler enforces
Typed throws are still being adopted across the ecosystem; many APIs remain untyped. Use them in your own code when the error set is small and stable.
Code: Result — when you need an error value, not an effect
public enum Result<Success, Failure: Error> {
case success(Success)
case failure(Failure)
}
func fetchResult(_ url: URL) -> Result<Data, NetworkError> {
do {
let data = try fetch(url)
return .success(data)
} catch let e as NetworkError {
return .failure(e)
} catch { return .failure(.offline) }
}
let r = fetchResult(url)
switch r {
case .success(let data): process(data)
case .failure(let err): handle(err)
}
When do you reach for Result over throws?
throwsfor synchronous-feeling code paths — the call site reads naturally withtry.Resultfor storing or passing errors as values — caching the outcome of an async op, queueing results, returning from callbacks.Resultinterops nicely with Combine and older callback APIs:(Result<T, Error>) -> Voidcompletion handlers were standard beforeasync/await.
Code: async errors
async and throws compose naturally:
func loadUser(id: String) async throws -> User {
let data = try await api.fetch(id: id)
return try JSONDecoder().decode(User.self, from: data)
}
// Call site
Task {
do {
let user = try await loadUser(id: "ada")
await MainActor.run { self.user = user }
} catch {
await MainActor.run { self.error = error }
}
}
try await is read “try-await”: acknowledge the throw AND the suspension. Order matters in declaration (async throws) but at the call site try await is the only legal order.
In the wild
URLSession.shared.data(from: url)throws. It’s anasync throwsfunction returning(Data, URLResponse). Every network call you make in modern Swift is wrapped intry await.Codabledecoding throws.JSONDecoder().decode(...)returns the decoded value or throws aDecodingError(which is itself a rich enum —.keyNotFound,.typeMismatch, etc.).- File I/O (
String(contentsOf: url),FileManagermethods) throws. Task.checkCancellation()throwsCancellationError— the standard way to bail out of a long-running async task.
Common misconceptions
-
“
try?is the lazy programmer’s way out.” Not quite — it’s appropriate when you genuinely don’t care why an operation failed, only whether it succeeded. The bug in the opening scenario isn’t usingtry?; it’s not distinguishing a 401 from a parse error. -
“Errors should always be enums.” Most should — exhaustive switches at the catch site are valuable. But for opaque errors (libraries you can’t predict),
Erroritself is fine. For user-facing errors, conform toLocalizedErrorto provideerrorDescription. -
“
throwsis slow because of exception unwinding.” Swift’s error handling does not use exception unwinding. It’s compiled to a normal return-value path with a discriminator. Cost is comparable to returning aResult. -
“Every function should
throws.” No —throwsis part of the function’s contract. Make a functionthrowsonly when it genuinely can fail in ways the caller should handle. Afunc add(_ a: Int, _ b: Int) -> Intshould never throw. -
“
fatalErroris the same asthrow.” Profoundly not.throwis recoverable;fatalErrorterminates the process. UsefatalErroronly for “this is a programmer bug, I want a crash with a clear message” cases — typically ininit?failures that shouldn’t be possible.
Seasoned engineer’s take
The most important habit: let errors travel as far as the layer that knows how to handle them, and no further.
- Networking layer: throws
NetworkError. - Repository / domain layer: maps
NetworkErrorto domain errors (UserError.notLoggedIn,UserError.networkUnavailable). - UI layer: maps domain errors to user-visible state (“Sign in to continue”, “Check your connection”).
Don’t catch-and-swallow errors in middle layers. Don’t print(error) and continue. Don’t replace every catch with a single “Oops, something went wrong” screen — that’s the antipattern in the opening scenario. The error type is your domain language for failure, and you should use it.
A second habit: use enums with associated values for errors, so the kind of failure carries the data needed to recover from it:
enum UploadError: Error {
case quotaExceeded(currentMB: Int, limitMB: Int)
case fileTooLarge(maxBytes: Int)
case networkLost(retryAfter: Duration)
case serverRejected(reason: String)
}
The catch site has everything it needs to compose a helpful UI (“You’re 50 MB over your 200 MB quota. Upgrade?”).
TIP: Conform your error enums to
LocalizedErrorand implementerrorDescriptionto geterror.localizedDescriptionfor free, ready forText(error.localizedDescription)in SwiftUI.
WARNING: Do not rethrow errors at module boundaries without thinking. If your domain layer rethrows a
URLErrorto the UI, the UI now depends on networking concretely. Map errors at the boundary.
Interview corner
Question: “Walk me through error handling in modern Swift. When would you use throws vs Result vs returning an optional?”
Junior answer: “throws is when something can fail, Result is for async, Optional is when the value might not exist.” → Roughly true. They’ll dig deeper.
Mid-level answer: “I use throws by default for synchronous code paths where the call site benefits from try. I reach for Result when I need to store the outcome (for example caching the latest fetch state) or when integrating with callback-style APIs. I return Optional only when ‘nothing’ is a normal, non-error outcome — like a lookup that legitimately may not find a value. The distinction I make is: an error means something abnormal happened; a nil means the absence of value was expected.” → Strong. The last sentence is what interviewers want to hear.
Senior answer: Everything above, plus: “I’d also talk about error modeling. The choice of error type defines the API’s reliability contract. I’d design error enums with associated values that carry recovery data — case quotaExceeded(used: Int, limit: Int) is more useful than case quotaExceeded. I’d map errors at architecture boundaries — network errors don’t leak into the UI layer unchanged. And I’d be cautious about typed throws (Swift 6 feature): they’re great for stable error sets but lock you into the type — adding a case is a breaking change. For library code that’s published, I usually stay with untyped throws and document the error type in the docs.” → Senior signal: thinks about API design and evolution.
Red-flag answer: “I wrap every operation in do { try … } catch { print(error) }.” → That’s the bug from the opening scenario. Tells the interviewer you swallow errors silently in production.
Lab preview
Lab 1.D (Async fetcher) makes you implement a small network client with a real error type — distinguishing offline from server-rejected from decode-failure. You’ll wire each error variant to a different UI state.
Next: the chapter the whole language was redesigned around — concurrency, async/await, and actors. → Concurrency
1.9 — Concurrency: async/await, Tasks, actors, Sendable
Opening scenario
Five years ago, networking code on iOS looked like this:
URLSession.shared.dataTask(with: url) { data, response, error in
guard let data = data else {
DispatchQueue.main.async { self.handle(error: error) }
return
}
self.queue.async {
let decoded = try? JSONDecoder().decode(User.self, from: data)
DispatchQueue.main.async {
self.user = decoded
self.fetchAvatar(for: decoded) { avatar in
DispatchQueue.main.async { self.avatar = avatar }
}
}
}
}.resume()
Today it looks like this:
Task {
let user = try await api.fetchUser()
let avatar = try await api.fetchAvatar(for: user)
await MainActor.run { self.user = user; self.avatar = avatar }
}
That’s not just syntactic sugar. It’s a complete rebuild of the concurrency story: the compiler now reasons about which thread runs which code, and the type system enforces it. Welcome to the part of Swift that has been Apple’s #1 investment for half a decade.
The five concepts you need
| Concept | What it is |
|---|---|
async / await | A function that may suspend, and the call site that acknowledges the suspension. |
Task | A unit of asynchronous work — the entry point from synchronous to async code. |
Structured concurrency (async let, TaskGroup) | Spawning multiple child tasks whose lifetimes are bounded by the parent. |
actor | A reference type whose mutable state is isolated — only one task touches it at a time. |
Sendable + @MainActor | The type-system rules that prevent data races at compile time. |
Concept → Why → How → Code
async / await
func fetchUser(id: String) async throws -> User {
let (data, _) = try await URLSession.shared.data(from: url(id))
return try JSONDecoder().decode(User.self, from: data)
}
async says “this function may suspend.” await at the call site says “I’m fine with that — pause my function here, resume me when it’s ready.” The thread is free to do other work in between. The cost of a await is roughly the cost of a function call — orders of magnitude cheaper than a thread.
Task — bridging sync and async
You can’t await from synchronous code (a button handler, a viewDidLoad). To start async work, you wrap it in a Task:
// In a SwiftUI button
Button("Refresh") {
Task {
await viewModel.reload()
}
}
A Task is the entry point. Inside it, you can await freely. The closure runs on a background executor by default — unless it inherits an actor context (more in a moment).
Structured concurrency: async let and TaskGroup
Sequential await: ~2× as slow as it needs to be when calls are independent:
// SEQUENTIAL — 2 round trips
let user = try await fetchUser()
let posts = try await fetchPosts() // waits for user first
Parallel with async let:
// PARALLEL — both kick off, you await both
async let user = fetchUser()
async let posts = fetchPosts()
let (u, p) = try await (user, posts)
For dynamic numbers of tasks, use TaskGroup:
let avatars: [Avatar] = try await withThrowingTaskGroup(of: Avatar.self) { group in
for user in users {
group.addTask { try await fetchAvatar(for: user) }
}
var result: [Avatar] = []
for try await avatar in group { result.append(avatar) }
return result
}
The “structured” part is critical. All child tasks must complete (or be cancelled) before the parent returns. No orphan tasks running after the function exits. This is what makes async Swift safer than callback-pyramid code.
Actors — single-threaded mutable state
actor Cache {
private var store: [URL: Data] = [:]
func get(_ url: URL) -> Data? { store[url] }
func set(_ url: URL, data: Data) { store[url] = data }
}
let cache = Cache()
await cache.set(url, data: bytes) // every cross-actor call requires await
let value = await cache.get(url)
The actor type is a reference type (like a class) but with a runtime guarantee: only one task executes any of its methods at a time. Cross-actor calls become await calls (they may suspend if the actor is busy).
This is the right tool for shared mutable state — caches, in-memory stores, accumulators. You stop reaching for NSLock and DispatchQueue.sync.
@MainActor — the UI actor
UIKit and SwiftUI require all UI updates on the main thread. Swift now expresses this in the type system:
@MainActor
class FeedViewModel: ObservableObject {
@Published var items: [Item] = []
func reload() async {
let fresh = try? await api.fetchFeed() // hops to background
self.items = fresh ?? [] // back on main automatically
}
}
Marking the class @MainActor says “everything in here runs on main.” Calls into the class from a Task on a different actor become await calls. You can mark individual functions or properties @MainActor too.
Sendable — race prevention at compile time
struct User: Sendable { let id: String; let name: String } // ✅ all stored properties are value types
final class Logger: Sendable { let prefix: String } // ✅ immutable class
class MutableCache { var store: [String: Data] = [:] } // ❌ not Sendable — has mutable state
To pass a value across actor or task boundaries, Swift requires it to be Sendable. Value types of Sendable properties are automatically Sendable. final classes with only immutable properties can be Sendable. Mutable classes cannot be (use an actor instead).
Under Swift 6 strict concurrency, the compiler enforces all of this. It’s how data races become type errors instead of late-night production crashes.
In the wild
- SwiftUI’s
.task { … }modifier spawns a Task that’s automatically cancelled when the view disappears. That single line is structured concurrency in action. URLSession.shared.data(from:)is the modern replacement fordataTask(with:)— fully async/await.AsyncSequenceandAsyncStreammodel streams of values over time (the async analogue toSequence). Used infor await line in url.lines { … }for line-by-line file reading.- Apple’s own apps (Messages, Mail, Health) have been rewritten incrementally with actors replacing serial queues. WWDC 2024’s “Migrate to Swift 6” talk walks through their internal patterns.
Common misconceptions
-
“
asyncmeans it runs on a background thread.” Not necessarily.asyncmeans the function may suspend. Where it runs depends on the actor context. A@MainActorasync function still runs on main; it just doesn’t block. -
“
Task { … }is the same asDispatchQueue.global().async { … }.” No. ATaskinherits the actor context of its enclosing scope by default (so inside a@MainActormethod, the task runs on main). To go background explicitly, useTask.detached. The default behavior is safer but trips up GCD veterans. -
“Actors solve all my data-race problems.” They solve the intra-actor problem (one actor’s state is safe). Cross-actor data races require
Sendablediscipline, which the Swift 6 compiler enforces. Pre-Swift 6, you can still get races by passing mutable classes between actors. -
“
awaitalways suspends.” It can suspend. Often it doesn’t — if the called function returns synchronously inside its body, no suspension happens.awaitis a marker that suspension is possible, not that it’s guaranteed. -
“Async/await is just sugar for callbacks.” It’s sugar plus a structured lifecycle. Cancellation propagates, errors propagate, child tasks are joined. Callbacks have none of that.
Seasoned engineer’s take
Concurrency is the single most consequential thing to get right in a mobile app. It’s also the area where senior and junior engineers most visibly diverge. Heuristics I rely on:
- Default to
@MainActoron your view models. The cost (occasionalTask.detachedfor heavy work) is small; the benefit (no race conditions on@Publishedproperties) is enormous. - Don’t use
Task.detachedunless you mean it. Detached tasks lose the actor context, the priority inheritance, and the cancellation parent. They’re the equivalent of “fire and forget” — useful but easy to abuse. - Make your models
Sendableearly. AddingSendablelater requires touching every type. Designing for it from day one means structs everywhere, immutable references, no shared mutable globals. - Cancel things. Long-running tasks should call
try Task.checkCancellation()periodically and respectTask.isCancelled. SwiftUI’s.taskmodifier handles this for you, but explicitTask {}instances don’t. - Don’t mix GCD and async/await in new code. Pick a side. The mental model of “this work runs on actor X” doesn’t compose with “this work runs on dispatch queue Y.”
The dirty truth: migrating an old app to Swift 6 strict concurrency is painful. Apple knows this — the migration is being rolled out in waves with @preconcurrency escape hatches. But the destination is right. Apps that finish the migration are dramatically less crashy at the concurrency layer.
TIP: Use SwiftUI’s
.task(id: someID) { … }to automatically re-run async work when an identifier changes (e.g., the route param). It cancels the previous task and starts a new one — exactly what you want for navigation.
WARNING: Never call a blocking synchronous API (file read,
sleep, heavy compute) directly from an async function on@MainActor. The actor is the main thread; you’ll freeze the UI. Wrap CPU-heavy work inTask.detachedor hop to a background actor.
Interview corner
Question: “Explain what an actor is in Swift and when you’d use one.”
Junior answer: “An actor is like a class but thread-safe.” → Right idea, no detail.
Mid-level answer: “An actor is a reference type whose internal state is automatically protected — only one task can execute any of the actor’s methods at a time. Cross-actor calls become async, since they may need to wait their turn. I use actors for shared mutable state that’s accessed concurrently: caches, in-memory stores, accumulators that used to be guarded by NSLock or a serial DispatchQueue.” → Strong.
Senior answer: Plus: “I’d also talk about the cost and the trade-offs. Every cross-actor call has a suspension cost — not huge but real, especially in tight loops. So I wouldn’t make an actor for a hot inner loop; I’d make it for coarse-grained shared state. I’d also distinguish actors from @MainActor-isolated classes: the latter pins state to a specific actor (main), the former creates a new isolation domain. For UI work, @MainActor is what you want; for background mutable state, a custom actor. And I’d mention that under Swift 6’s strict concurrency the compiler enforces Sendable at actor boundaries — so designing my model types as value types up front pays off massively. Finally, if I’m writing library code, I think hard about whether actors should be part of my public surface — they force every caller to be in an async context, which can be a viral constraint.” → Senior signal: cost-aware, considers API impact.
Red-flag answer: “I just wrap everything in Task.detached so it doesn’t block the UI.” → Tells the interviewer the candidate doesn’t understand actor isolation and is going to leak unstructured tasks all over the app.
Lab preview
Lab 1.D (Async fetcher) is the concurrency capstone — build a tiny image-fetching pipeline using URLSession, an actor-based cache, and TaskGroup for parallel fetches.
Next: how Swift actually manages memory under the hood — ARC, retain cycles, weak references. → Memory management
1.10 — Memory management: ARC, retain cycles, weak/unowned
Opening scenario
Your app’s leak chart in Instruments looks like a staircase going up. Every time the user opens a detail screen, memory rises by ~3 MB and never comes back down. After ten navigations the app gets jettisoned by the OS for using too much RAM.
You crack open the view controller:
class DetailViewController: UIViewController {
var viewModel: DetailViewModel?
override func viewDidLoad() {
super.viewDidLoad()
viewModel?.onUpdate = { user in
self.userLabel.text = user.name // 🔥 retain cycle
}
}
}
By the end of this chapter you’ll spot that bug in under a second and know the three ways to fix it.
How Swift manages memory
Swift uses ARC — Automatic Reference Counting. The compiler inserts retain and release calls around every reference assignment. When the retain count drops to zero, the object is deallocated. This happens deterministically, at the moment the last reference goes away — no garbage collector, no GC pauses, no nondeterministic finalizers.
class Engine {
init() { print("Engine init") }
deinit { print("Engine deinit") }
}
func demo() {
let e1 = Engine() // count = 1, prints "Engine init"
let e2 = e1 // count = 2
_ = e2 // keep e2 alive
} // both refs out of scope → count = 0 → "Engine deinit"
Value types (structs, enums) are not reference-counted. They live on the stack or inline in their owner. ARC only matters for classes and class-based types (closures count as reference types too).
The three reference flavors
| Flavor | Increments count? | Becomes nil when target deallocated? | Use when |
|---|---|---|---|
strong (default) | Yes | N/A (it owns) | The reference owns the lifetime |
weak | No | Yes — becomes nil automatically | Reference doesn’t own; target may outlive it |
unowned | No | No — accessing after dealloc crashes | Like weak but you guarantee the target outlives this ref |
class Person {
let name: String
weak var apartment: Apartment? // doesn't keep the apartment alive
init(name: String) { self.name = name }
}
class Apartment {
let unit: String
var tenant: Person? // owns tenant
init(unit: String) { self.unit = unit }
}
weak references must be var Optional<T> — they have to be able to become nil. unowned is non-optional but unsafe if you misjudge the lifetime.
The retain cycle problem
Two objects holding strong references to each other never reach zero. ARC can’t break the cycle. The classic case is parent ↔ child with both sides strong:
class Parent { var child: Child? }
class Child { var parent: Parent? } // 🔥 strong both ways
var p: Parent? = Parent()
var c: Child? = Child()
p?.child = c
c?.parent = p
p = nil // count goes from 2 to 1 (c still references it)
c = nil // count goes from 2 to 1 (p still references c)
// Both leak forever.
Fix: make one direction weak (typically the back-reference from child to parent):
class Child { weak var parent: Parent? }
Closures: the modern source of cycles
Closures capture references. A closure stored on self that mentions self creates a cycle:
class Loader {
var onDone: (() -> Void)?
var name = "loader"
func start() {
onDone = {
print(self.name) // 🔥 closure retains self, self retains closure
}
}
}
The fix is the capture list [weak self] or [unowned self]:
func start() {
onDone = { [weak self] in
guard let self else { return }
print(self.name)
}
}
The capture list runs once, when the closure is created. [weak self] captures self as a weak reference; inside the closure you unwrap it with guard let self.
Use [unowned self] only when you’re certain self outlives the closure (typically: the closure is owned by self and runs synchronously). For async closures, network callbacks, observers — always [weak self]. The wrong choice crashes; [weak self] is the safe default.
When NOT to worry
- Pure value-type code. Structs and enums copy, no ARC.
@MainActorObservableObject view models that don’t hold completion-handler closures internally. SwiftUI’s@StateObjectand@ObservedObjectuse weak-ish semantics under the hood.async/awaitcode. No closure captures — the compiler manages task lifetimes via the structured concurrency model. This is one of the underappreciated wins ofasync/awaitover callbacks: a whole category of retain cycles simply disappears.
In the wild
- SwiftUI views are structs — no ARC at the view level. The retain-cycle worry has shifted to view models and Combine pipelines.
- Combine
sinkclosures are the most common source of cycles in modern code.cancellable.sink { [weak self] in … }is the idiom. NotificationCenter.addObserver(forName:object:queue:using:)— the block-based variant retains its observer block.[weak self]is mandatory here.- Older callback APIs (Firebase, Alamofire pre-async) all need
[weak self]discipline.
Common misconceptions
-
“Swift has garbage collection.” No. ARC is deterministic reference counting, inserted at compile time. No GC pauses, no nondeterminism.
-
“You should put
[weak self]in every closure.” Overkill. Closures that don’t escape (i.e., that run synchronously, likearray.map { … }) don’t create cycles. Capture lists are for escaping closures stored onselfor passed toasyncwork. -
“
unownedis faster thanweak.” Marginally —unownedskips the optional unwrap. Not worth the crash risk in 99% of cases. Reach forunownedonly when the relationship is structurally guaranteed. -
“
weak selfworks for value types too.” No.weakandunownedapply only to class references. Value types are copied, not referenced. -
“Instruments leaks tool catches all leaks.” It catches unreachable retained memory (classic leaks). It doesn’t catch abandoned memory — long-lived caches that grow forever. Use the Allocations instrument for the latter, and watch the steady-state baseline grow.
Seasoned engineer’s take
ARC is one of the things Swift got enormously right. Predictable destruction, no GC pauses, low overhead — for a mobile platform with tight memory budgets, it’s the right model. But it requires discipline:
- Default to value types, and the whole conversation collapses to “no ARC.”
- For classes, draw the ownership tree on a whiteboard. Who owns whom? The back-edges (child → parent, observer → subject) are always
weak. - Always
[weak self]in escaping closures, unless you have a specific reason for[unowned self]. The cost of[weak self] + guard let selfis one extra line; the cost of a retain cycle is a memory leak shipped to production. - Profile with Instruments at least once per release cycle. The Allocations + Leaks combo will flag drift you didn’t see in code review.
Specific traps I’ve seen ship to production at multiple companies:
- A
URLSessionTaskstored onselfthat capturesselfin its completion handler — fix with[weak self]. - A timer (
Timer.scheduledTimer(...)) that capturesselfin its block — fix with[weak self], and invalidate the timer indeinit. - A Combine pipeline
vm.$query.sink { self.search($0) }— fix with[weak self]. - A coordinator pattern where the coordinator strongly retains every child VC and never releases them — fix the navigation lifecycle, not the references.
TIP: Run Instruments → Allocations with the “Mark Generation” button. Mark before opening a screen, navigate forward and back, mark again, look at the diff. Anything in the diff that should be deallocated and isn’t is a leak.
WARNING: Never put
[unowned self]in an async closure that may run after self is deallocated (network callback, animation completion). It will crash.[weak self]is the only safe choice for asynchronous escapes.
Interview corner
Question: “How does memory management work in Swift, and what’s a retain cycle?”
Junior answer: “Swift uses ARC. A retain cycle is when two objects reference each other and can’t be released.” → Definitionally correct, no depth.
Mid-level answer: “Swift uses Automatic Reference Counting — the compiler inserts retain/release calls, and objects are deallocated when their reference count hits zero. A retain cycle happens when two objects (or an object and a closure) hold strong references to each other, so neither’s count can reach zero. The classic case in modern code is a closure captured on self that mentions self — fix it with [weak self] in the capture list, then guard let self else { return } inside the closure. For parent/child object graphs, the back-edge is always weak.” → Strong, real fix.
Senior answer: Plus: “I’d also talk about prevention. Value types — structs and enums — sidestep ARC entirely, so the more of my model layer I can make value-typed, the smaller my retain-cycle surface area. For class graphs, I draw the ownership tree explicitly: who owns the lifetime, who’s an observer. Back-edges and observers are weak. I’d mention that async/await has dramatically reduced retain-cycle bugs because the compiler manages task lifetimes — there’s no closure capture for me to forget [weak self] on. And in code review I’d flag any escaping closure stored on self that mentions self without a capture list. As for unowned vs weak, I default to weak because the cost of guard let self is trivial and the cost of a wrong unowned is a crash.” → Senior signal: prevention thinking, modern-tool awareness.
Red-flag answer: “I just add [unowned self] to every closure so I don’t have to deal with optionals.” → Will ship crashes.
Phase 1 wrap-up
You now have the language. You know:
- The history and where Swift sits today (1.1)
- Where Swift code lives — playgrounds, scripts, SPM, Xcode projects (1.2)
- Types, optionals, the five unwrap forms (1.3)
- Control flow, functions, closures (1.4)
- Collections and higher-order functions (1.5)
- Structs vs classes vs enums vs protocols (1.6)
- Generics,
some,any(1.7) - Error handling —
throws,try,Result(1.8) - Concurrency — async/await, Tasks, actors, Sendable (1.9)
- ARC, weak/unowned, retain cycles (1.10)
You also have four labs to prove you’ve learned it:
- 1.A — Playground exploration
- 1.B — Command-line tool with SwiftPM
- 1.C — Protocol-oriented calculator
- 1.D — Async image fetcher
When you can hold a conversation about every chapter above and you’ve shipped the four labs, you’ve cleared Phase 1. Next: Phase 2 — where Swift meets the platform: UIKit, SwiftUI, and the iOS app lifecycle.
Next: head to the labs to apply what you’ve learned. → Lab 1.A
Lab 1.A — Playground exploration
Goal: Build muscle memory with the core Swift idioms — optionals, guards, Codable, async/await — by typing and breaking code in a Swift playground, then by fixing intentionally-broken samples, and finally by composing a small word-processing pipeline.
Time budget: 60–90 minutes.
Prerequisites: Xcode 16+ installed. Read chapters 1.1, 1.2, 1.3, 1.4, and 1.5.
Part 1 — Set up
- Launch Xcode → File → New → Playground → macOS → Blank.
- Save it as
SwiftFundamentals.playgroundsomewhere you’ll find it again. - Delete the boilerplate. You should have an empty editor and a results sidebar on the right.
Part 2 — Exercises (type these, don’t paste)
2.1 Optionals and the five unwraps
let raw: String? = "42"
// (a) Force-unwrap — when do you allow yourself to do this?
let forced = raw!
// (b) if let
if let s = raw, let n = Int(s) { print("parsed", n) }
// (c) guard let — write a function `parseAge(_ s: String?) -> Int?`
// that returns nil unless s is non-nil AND parses as Int.
// (d) nil-coalesce — show 5 different default values
let display = raw ?? "—"
// (e) Optional chaining — make `raw?.count` print; then chain it with
// `?.description` so you end up with `String?`.
Write all five forms in your playground for the same raw. Notice how each changes the type of the result.
2.2 Guard and early-return
Rewrite this nested-if mess as a guard-based function with early returns:
func process(_ input: String?) -> String {
if let s = input {
if !s.isEmpty {
if let n = Int(s) {
if n > 0 {
return "got positive int \(n)"
} else {
return "non-positive"
}
} else {
return "not a number"
}
} else {
return "empty"
}
} else {
return "nil"
}
}
The rewritten version should be readable top-to-bottom with no nesting deeper than 1 level.
2.3 Codable round-trip
struct User: Codable {
let id: Int
let name: String
let createdAt: Date
}
let json = """
{ "id": 1, "name": "Ada", "createdAt": "2024-06-12T10:00:00Z" }
""".data(using: .utf8)!
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .iso8601
let user = try decoder.decode(User.self, from: json)
print(user)
Then make it fail. Change "id": 1 to "id": "one". Catch the DecodingError and print which field failed.
2.4 async/await in a playground
import Foundation
func slowGreeting(_ name: String) async -> String {
try? await Task.sleep(for: .seconds(1))
return "Hello, \(name)"
}
Task {
let s = await slowGreeting("World")
print(s)
}
You may need to enable indefinite execution in the playground (the Editor → Execute Playground menu). Note that the Task { … } runs after the surrounding synchronous code completes.
2.5 (Stretch) Macros — touch one Apple macro
@Observable // built-in Apple macro
final class Counter {
var value = 0
func inc() { value += 1 }
}
Right-click the @Observable and “Expand Macro” to see the generated code. You don’t have to write a macro in this lab, just observe how much code one annotation generated.
Part 3 — Fix the broken code
Each snippet below has at least one bug. Fix each in place and explain why it was broken in a comment.
// (a)
let count: Int = "10" // type mismatch
// (b)
var maybeName: String? = nil
print("Hello, " + maybeName) // optional in string concatenation
// (c)
func divide(_ a: Int, by b: Int) -> Int {
return a / b // crashes when b == 0
}
let r = divide(10, by: 0)
// (d)
let words = ["one", "two", "three"]
let upper = words.map { word in
print("upper: \(word)")
word.uppercased() // forgot return — closure type confusion
}
// (e)
class Counter {
var n = 0
func inc() { n += 1 }
}
let c = Counter()
c.n = 5
// Why does this work but `let n = Int(); n = 5` doesn't?
Part 4 — Word-processing pipeline (capstone)
Given the multi-line string:
let corpus = """
The quick brown fox jumps over the lazy dog.
Pack my box with five dozen liquor jugs.
How vexingly quick daft zebras jump!
"""
Write one expression (one chained sequence of higher-order calls — split, map, filter, reduce, etc.) that produces a [String: Int] of [word: count], case-insensitive, ignoring punctuation, only words of length ≥ 4.
Expected result (order doesn’t matter):
["quick": 2, "brown": 1, "jumps": 1, "over": 1, "lazy": 1, "pack": 1,
"with": 1, "five": 1, "dozen": 1, "liquor": 1, "jugs": 1, "vexingly": 1,
"daft": 1, "zebras": 1, "jump": 1]
Hints:
String.components(separatedBy: .punctuationCharacters)strips punctuation.Dictionary(grouping: by:)then.mapValues(\.count)is a clean way to count.- Or use
reduce(into:_:)with a[String: Int]accumulator.
Done when
-
You can rewrite a 5-level-nested
if letchain as guard-and-return without thinking. -
You’ve seen a
DecodingErrorand read itsuserInfo. -
You’ve run an
asyncfunction in a playground and understand whyTask { }is needed. - You can fix all five broken snippets in Part 3 in under 3 minutes.
- Your word-counter pipeline in Part 4 is a single expression that fits on three lines.
Stretch goals
- Make
User(from 2.3) conform toIdentifiableandHashable. Add it to aSet. Try to add a duplicate. - Implement a
Result<User, DecodingError>return type for your decoder wrapper. - Replace the synchronous
Counterwith anactor Counter. Notice every call site now needsawait.
Next lab: 1.B — Command-line tool with SwiftPM
Lab 1.B — Build a real CLI with Swift Package Manager
Goal: Ship a working command-line tool, built with SwiftPM, that takes flags, reads a file, does real work, returns proper exit codes, and is publishable to GitHub for anyone with Swift installed to git clone && swift run.
Time budget: 90 minutes.
Prerequisites: Ch 1.2, Ch 1.4, Ch 1.8. Comfortable in a terminal.
What you’ll build
A wordstats CLI:
$ wordstats --file README.md --min-length 4 --top 10
Top 10 words (min length 4) in README.md:
swift 42
package 31
...
Total words: 1,247 | Unique: 412
Optional flags: --json to emit JSON instead of a table; --ignore words.txt to load a stopword list.
Step 1 — Scaffold the package
mkdir wordstats && cd wordstats
swift package init --type executable --name wordstats
You should now have:
Package.swift
Sources/wordstats/wordstats.swift
Tests/wordstatsTests/wordstatsTests.swift
Verify it builds: swift build, then swift run wordstats.
Step 2 — Add swift-argument-parser
In Package.swift, add the dependency and link it:
let package = Package(
name: "wordstats",
platforms: [.macOS(.v13)],
dependencies: [
.package(url: "https://github.com/apple/swift-argument-parser",
from: "1.5.0"),
],
targets: [
.executableTarget(
name: "wordstats",
dependencies: [
.product(name: "ArgumentParser", package: "swift-argument-parser"),
]),
.testTarget(
name: "wordstatsTests",
dependencies: ["wordstats"]),
]
)
Then swift package update. Apple’s ArgumentParser is the de-facto standard for Swift CLIs (used by Apple’s own tooling, swift-format, swift-syntax, etc.).
Step 3 — The command definition
Replace Sources/wordstats/wordstats.swift with:
import ArgumentParser
import Foundation
@main
struct WordStats: ParsableCommand {
static let configuration = CommandConfiguration(
commandName: "wordstats",
abstract: "Compute word-frequency statistics for a text file."
)
@Option(name: [.short, .long], help: "Path to the input text file.")
var file: String
@Option(name: .long, help: "Minimum word length to include.")
var minLength: Int = 1
@Option(name: .long, help: "How many of the top words to display.")
var top: Int = 20
@Option(name: .long, help: "Path to a stopword list (one word per line).")
var ignore: String?
@Flag(name: .long, help: "Output as JSON instead of a table.")
var json: Bool = false
func run() throws {
let url = URL(fileURLWithPath: file)
let text = try String(contentsOf: url, encoding: .utf8)
let stop: Set<String> = try {
guard let ignore else { return [] }
let raw = try String(contentsOfFile: ignore, encoding: .utf8)
return Set(raw.lowercased().split(whereSeparator: \.isNewline).map(String.init))
}()
let stats = WordCounter.count(text: text, minLength: minLength, stopwords: stop)
if json {
try Output.json(stats: stats, top: top)
} else {
Output.table(stats: stats, top: top, file: file, minLength: minLength)
}
}
}
Step 4 — The counting engine (in its own file, for testing)
Create Sources/wordstats/WordCounter.swift:
import Foundation
struct WordStats {
let counts: [String: Int]
var totalWords: Int { counts.values.reduce(0, +) }
var uniqueWords: Int { counts.count }
}
enum WordCounter {
static func count(text: String, minLength: Int, stopwords: Set<String>) -> WordStats {
let words = text
.lowercased()
.components(separatedBy: CharacterSet.alphanumerics.inverted)
.filter { $0.count >= minLength && !stopwords.contains($0) }
var counts: [String: Int] = [:]
for w in words { counts[w, default: 0] += 1 }
return WordStats(counts: counts)
}
}
Notice: pure function, no I/O. That’s what makes it testable.
Step 5 — Output helpers
Create Sources/wordstats/Output.swift:
import Foundation
enum Output {
static func table(stats: WordStats, top: Int, file: String, minLength: Int) {
let sorted = stats.counts.sorted { $0.value > $1.value }.prefix(top)
print("Top \(top) words (min length \(minLength)) in \(file):")
for (word, n) in sorted {
print(" \(word.padding(toLength: 16, withPad: " ", startingAt: 0))\(n)")
}
print()
print("Total words: \(stats.totalWords) | Unique: \(stats.uniqueWords)")
}
static func json(stats: WordStats, top: Int) throws {
struct Payload: Encodable {
let top: [Entry]
let totalWords: Int
let uniqueWords: Int
}
struct Entry: Encodable { let word: String; let count: Int }
let entries = stats.counts.sorted { $0.value > $1.value }
.prefix(top)
.map { Entry(word: $0.key, count: $0.value) }
let payload = Payload(top: entries,
totalWords: stats.totalWords,
uniqueWords: stats.uniqueWords)
let encoder = JSONEncoder()
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
let data = try encoder.encode(payload)
print(String(decoding: data, as: UTF8.self))
}
}
Step 6 — Tests
Replace Tests/wordstatsTests/wordstatsTests.swift:
import XCTest
@testable import wordstats
final class WordCounterTests: XCTestCase {
func test_counts_are_case_insensitive() {
let s = WordCounter.count(text: "Apple apple APPLE", minLength: 1, stopwords: [])
XCTAssertEqual(s.counts["apple"], 3)
}
func test_respects_min_length() {
let s = WordCounter.count(text: "a bb ccc dddd", minLength: 3, stopwords: [])
XCTAssertEqual(s.counts.keys.sorted(), ["ccc", "dddd"])
}
func test_ignores_stopwords() {
let s = WordCounter.count(text: "the cat sat on the mat",
minLength: 1, stopwords: ["the", "on"])
XCTAssertEqual(s.counts["the"], nil)
XCTAssertEqual(s.counts["on"], nil)
XCTAssertEqual(s.counts["cat"], 1)
}
func test_strips_punctuation() {
let s = WordCounter.count(text: "Hello, world! Hello.",
minLength: 1, stopwords: [])
XCTAssertEqual(s.counts["hello"], 2)
XCTAssertEqual(s.counts["world"], 1)
}
}
Run them: swift test.
Step 7 — Use it
swift build -c release
.build/release/wordstats --file README.md --min-length 4 --top 10
.build/release/wordstats --file README.md --json --top 5 | jq .top
For convenience, you can copy the binary to a folder on your PATH:
cp .build/release/wordstats /usr/local/bin/
Step 8 — Publish (optional but recommended)
git init
git add . && git commit -m "Initial commit"
# create repo on GitHub
git remote add origin git@github.com:<you>/wordstats.git
git push -u origin main
Anyone can now do git clone … && swift run wordstats --file foo.txt.
Done when
-
swift testpasses all four tests. -
wordstats --file <some-file> --top 5prints a sorted table. -
--jsonproduces valid JSON (verify with| jq .). -
--ignore stop.txtactually drops the stopwords. - The repo is on GitHub.
Stretch goals
- Add a
--watchflag that re-runs whenever the file changes (useDispatchSource.makeFileSystemObjectSource). - Support reading from stdin when no
--fileis given (cat foo.txt | wordstats). - Add a
--bigramsflag that counts two-word phrases instead. - Package the binary as a Homebrew formula for your tap.
Real-world context
Apple’s own developer tools (swift-format, swift-syntax-test, xcrun simctl shims) are all SwiftPM CLIs using ArgumentParser. The skeleton you just built scales to those tools.
Next lab: 1.C — Protocol-oriented calculator
Lab 1.C — Protocol-oriented calculator
Goal: Build a small arithmetic library where every operation is its own struct conforming to a BinaryOperation protocol. You’ll feel — in your hands — why “protocol + struct + extension” is the modern Swift design idiom.
Time budget: 45–60 minutes.
Prerequisites: Ch 1.6, Ch 1.7, Ch 1.8.
What you’ll build
let result = try Calculator.evaluate("3 + 4 * 2")
// 11
let ops: [any BinaryOperation] = [Add(), Subtract(), Multiply(), Divide()]
for op in ops { print(op.symbol, op.apply(6, 2)) }
// + 8 / - 4 / * 12 / ÷ 3
A tiny evaluator (~80 lines), protocols all the way down.
Step 1 — The protocol
In a new SwiftPM library or a playground:
protocol BinaryOperation {
/// The symbol used in expressions: "+", "-", "*", "/".
var symbol: Character { get }
/// Operator precedence. Higher binds tighter.
var precedence: Int { get }
/// Perform the operation on two operands.
func apply(_ lhs: Double, _ rhs: Double) throws -> Double
}
The protocol describes what an operation does and exposes enough metadata for the evaluator to do its job (precedence parsing) — without the protocol knowing anything about how each operation is implemented.
Step 2 — Concrete operations
struct Add: BinaryOperation {
let symbol: Character = "+"
let precedence = 1
func apply(_ lhs: Double, _ rhs: Double) -> Double { lhs + rhs }
}
struct Subtract: BinaryOperation {
let symbol: Character = "-"
let precedence = 1
func apply(_ lhs: Double, _ rhs: Double) -> Double { lhs - rhs }
}
struct Multiply: BinaryOperation {
let symbol: Character = "*"
let precedence = 2
func apply(_ lhs: Double, _ rhs: Double) -> Double { lhs * rhs }
}
enum CalcError: Error { case divisionByZero, unknownOperator(Character), badExpression(String) }
struct Divide: BinaryOperation {
let symbol: Character = "/"
let precedence = 2
func apply(_ lhs: Double, _ rhs: Double) throws -> Double {
guard rhs != 0 else { throw CalcError.divisionByZero }
return lhs / rhs
}
}
Notice: each operation is a value type with a single responsibility. No inheritance, no superclass. To add Modulo, you write 5 lines and don’t touch anyone else’s code.
Step 3 — A registry (protocol extensions in action)
extension BinaryOperation where Self == Add { static var add: Add { Add() } }
extension BinaryOperation where Self == Subtract { static var subtract: Subtract { Subtract() } }
extension BinaryOperation where Self == Multiply { static var multiply: Multiply { Multiply() } }
extension BinaryOperation where Self == Divide { static var divide: Divide { Divide() } }
enum OperationRegistry {
static let all: [any BinaryOperation] = [Add(), Subtract(), Multiply(), Divide()]
static func lookup(_ symbol: Character) -> (any BinaryOperation)? {
all.first { $0.symbol == symbol }
}
}
The extension BinaryOperation where Self == … trick mirrors how SwiftUI’s .padding, .font, etc. are dotted onto the type — a modern protocol-oriented idiom you’ll see throughout Apple’s APIs.
Step 4 — The evaluator (shunting-yard, lite)
enum Calculator {
static func evaluate(_ expression: String) throws -> Double {
let tokens = try tokenize(expression)
let rpn = try toRPN(tokens)
return try evaluateRPN(rpn)
}
enum Token { case number(Double), op(any BinaryOperation) }
static func tokenize(_ s: String) throws -> [Token] {
var tokens: [Token] = []
var i = s.startIndex
while i < s.endIndex {
let ch = s[i]
if ch.isWhitespace { i = s.index(after: i); continue }
if ch.isNumber || ch == "." {
var j = i
while j < s.endIndex, s[j].isNumber || s[j] == "." { j = s.index(after: j) }
guard let n = Double(s[i..<j]) else {
throw CalcError.badExpression("invalid number near \(s[i..<j])")
}
tokens.append(.number(n))
i = j
} else if let op = OperationRegistry.lookup(ch) {
tokens.append(.op(op))
i = s.index(after: i)
} else {
throw CalcError.unknownOperator(ch)
}
}
return tokens
}
static func toRPN(_ tokens: [Token]) throws -> [Token] {
var output: [Token] = []
var ops: [any BinaryOperation] = []
for t in tokens {
switch t {
case .number: output.append(t)
case .op(let op):
while let top = ops.last, top.precedence >= op.precedence {
output.append(.op(ops.removeLast()))
}
ops.append(op)
}
}
while let op = ops.popLast() { output.append(.op(op)) }
return output
}
static func evaluateRPN(_ tokens: [Token]) throws -> Double {
var stack: [Double] = []
for t in tokens {
switch t {
case .number(let n): stack.append(n)
case .op(let op):
guard stack.count >= 2 else { throw CalcError.badExpression("stack underflow") }
let r = stack.removeLast(), l = stack.removeLast()
stack.append(try op.apply(l, r))
}
}
guard stack.count == 1 else { throw CalcError.badExpression("leftover values") }
return stack[0]
}
}
Step 5 — Tests
import XCTest
final class CalculatorTests: XCTestCase {
func test_basic_addition() throws {
XCTAssertEqual(try Calculator.evaluate("3 + 4"), 7)
}
func test_precedence() throws {
XCTAssertEqual(try Calculator.evaluate("3 + 4 * 2"), 11)
}
func test_division_by_zero() {
XCTAssertThrowsError(try Calculator.evaluate("10 / 0")) { err in
XCTAssertEqual(err as? CalcError, .divisionByZero)
}
}
func test_unknown_operator() {
XCTAssertThrowsError(try Calculator.evaluate("3 ^ 2")) { err in
guard case CalcError.unknownOperator("^") = err else {
XCTFail("expected unknownOperator('^'), got \(err)")
return
}
}
}
}
extension CalcError: Equatable {
public static func == (a: CalcError, b: CalcError) -> Bool {
switch (a, b) {
case (.divisionByZero, .divisionByZero): true
case (.unknownOperator(let x), .unknownOperator(let y)): x == y
case (.badExpression(let x), .badExpression(let y)): x == y
default: false
}
}
}
Step 6 — Extend it without changing anyone else’s code
Add a Modulo operation:
struct Modulo: BinaryOperation {
let symbol: Character = "%"
let precedence = 2
func apply(_ lhs: Double, _ rhs: Double) throws -> Double {
guard rhs != 0 else { throw CalcError.divisionByZero }
return lhs.truncatingRemainder(dividingBy: rhs)
}
}
// Add it to the registry — one line.
// Then: Calculator.evaluate("10 % 3") → 1.0
This is the OCP (Open-Closed Principle) win of protocol-oriented design. No existing struct, no existing extension, no existing function had to change.
Done when
- All four tests pass.
-
You added a 5th operation (
Modulo,Power, whatever) by adding one file with no edits elsewhere. -
You can explain to a colleague why each operation is a
struct, not aclass. -
You wrote at least one
extension BinaryOperation where Self == Xand saw it work.
Stretch goals
- Unary operations. Add a
UnaryOperationprotocol and aNegatestruct. The tokenizer will need to disambiguate-3 + 4from5 - 3. - Parentheses. Extend the shunting-yard with
(and). - Generic operand type. Make
BinaryOperationgeneric overOperand: Numericso you can have integer and floating-point operations side by side. You’ll discover why this requiresassociatedtype Operandinstead of a generic parameter. - Pretty-print. Add a
description: Stringrequirement toBinaryOperationand conform each op soprint(op)showsAdd(+, prec=1).
Real-world context
This pattern — protocol describes capability, struct implements one variant, registry holds them all, evaluator looks them up — is exactly how SwiftUI describes view modifiers (each .padding, .background, .frame is a ViewModifier struct), how Charts describes mark types, and how Codable decoders pick strategies. Internalize this lab and you’ve internalized half of Apple’s API design philosophy.
Next lab: 1.D — Async image fetcher
Lab 1.D — Async image fetcher with actor cache
Goal: Build a small image-fetching pipeline that demonstrates the four headline Swift concurrency features end-to-end: async/await for the network call, URLSession’s async API, an actor-based cache, and TaskGroup for parallel fetches. The result: a Fetcher type your future SwiftUI views can use to load images concurrently without races.
Time budget: 90 minutes.
Prerequisites: Ch 1.7, Ch 1.8, Ch 1.9, Ch 1.10. And Lab 1.B — you’ll re-use the SwiftPM workflow.
What you’ll build
let fetcher = Fetcher()
// One-off fetch (cached after first call)
let data = try await fetcher.image(from: url)
// Parallel batch — kicks off N concurrent requests, gathers results
let images = try await fetcher.images(from: urls)
Under the hood:
- Network calls use
URLSession.shared.data(from:). - An
actor ImageCachededuplicates concurrent fetches for the same URL (no thundering-herd). - A
TaskGroupparallelizes batch requests. - A typed
FetchErrorenum distinguishes network from decode from HTTP failures.
Step 1 — Scaffold
mkdir asyncfetcher && cd asyncfetcher
swift package init --type library --name AsyncFetcher
In Package.swift, target macOS 13 (for URLSession’s async API):
let package = Package(
name: "AsyncFetcher",
platforms: [.macOS(.v13), .iOS(.v16)],
products: [.library(name: "AsyncFetcher", targets: ["AsyncFetcher"])],
targets: [
.target(name: "AsyncFetcher"),
.testTarget(name: "AsyncFetcherTests", dependencies: ["AsyncFetcher"]),
]
)
Step 2 — Error type
Sources/AsyncFetcher/FetchError.swift:
import Foundation
public enum FetchError: Error, Equatable {
case invalidURL
case http(status: Int)
case transport(message: String)
case cancelled
}
Step 3 — The actor cache
Sources/AsyncFetcher/ImageCache.swift:
import Foundation
/// Caches data by URL AND deduplicates concurrent in-flight requests.
/// Two callers asking for the same URL at the same time share one fetch.
actor ImageCache {
private enum Entry {
case ready(Data)
case inFlight(Task<Data, Error>)
}
private var store: [URL: Entry] = [:]
/// Returns cached data if present; otherwise runs the closure (only once),
/// caches the result, and returns it. Concurrent callers share the same Task.
func data(for url: URL, fetch: @Sendable @escaping () async throws -> Data) async throws -> Data {
if let entry = store[url] {
switch entry {
case .ready(let d): return d
case .inFlight(let t): return try await t.value
}
}
let task = Task<Data, Error> { try await fetch() }
store[url] = .inFlight(task)
do {
let data = try await task.value
store[url] = .ready(data)
return data
} catch {
store[url] = nil // failure shouldn't be cached
throw error
}
}
func clear() { store.removeAll() }
}
Read this carefully — this is the lab’s most important concept. The actor’s data(for:fetch:) method does three things atomically:
- Checks the cache.
- If empty, creates ONE
Taskto do the fetch. - Returns that task’s
value— so 100 concurrent callers allawaitthe same task.
This is how you avoid “thundering herd” — 100 callers, 1 network request.
Step 4 — The fetcher
Sources/AsyncFetcher/Fetcher.swift:
import Foundation
public final class Fetcher: Sendable {
private let session: URLSession
private let cache = ImageCache()
public init(session: URLSession = .shared) {
self.session = session
}
public func image(from url: URL) async throws -> Data {
try await cache.data(for: url) { [session] in
try await Self.download(url: url, session: session)
}
}
public func images(from urls: [URL]) async throws -> [URL: Data] {
try await withThrowingTaskGroup(of: (URL, Data).self) { group in
for url in urls {
group.addTask { [self] in
let data = try await self.image(from: url)
return (url, data)
}
}
var result: [URL: Data] = [:]
for try await (url, data) in group {
result[url] = data
}
return result
}
}
private static func download(url: URL, session: URLSession) async throws -> Data {
do {
let (data, response) = try await session.data(from: url)
guard let http = response as? HTTPURLResponse else {
throw FetchError.transport(message: "non-HTTP response")
}
guard (200..<300).contains(http.statusCode) else {
throw FetchError.http(status: http.statusCode)
}
return data
} catch is CancellationError {
throw FetchError.cancelled
} catch let e as FetchError {
throw e
} catch {
throw FetchError.transport(message: error.localizedDescription)
}
}
}
Notice:
Fetcheris afinal classconforming toSendablebecause it has no mutable state (all state lives in the actor).images(from:)useswithThrowingTaskGroupfor parallelism — N URLs become N concurrent requests, bounded by the implicit task group.- Error mapping happens at the network boundary; everything below the API surface throws
FetchError.
Step 5 — Tests
Tests/AsyncFetcherTests/AsyncFetcherTests.swift:
import XCTest
@testable import AsyncFetcher
final class AsyncFetcherTests: XCTestCase {
func test_fetches_real_image() async throws {
let f = Fetcher()
let url = URL(string: "https://httpbin.org/image/png")!
let data = try await f.image(from: url)
XCTAssertGreaterThan(data.count, 0)
}
func test_404_throws_http_error() async {
let f = Fetcher()
let url = URL(string: "https://httpbin.org/status/404")!
do {
_ = try await f.image(from: url)
XCTFail("expected throw")
} catch let FetchError.http(status) {
XCTAssertEqual(status, 404)
} catch {
XCTFail("expected .http, got \(error)")
}
}
func test_parallel_fetch_returns_all() async throws {
let f = Fetcher()
let urls = [
URL(string: "https://httpbin.org/image/png")!,
URL(string: "https://httpbin.org/image/jpeg")!,
]
let results = try await f.images(from: urls)
XCTAssertEqual(results.count, 2)
}
func test_concurrent_callers_share_one_fetch() async throws {
// Two simultaneous fetches for the same URL should result in one
// network call. (Proving this rigorously requires a mock URLProtocol;
// here we just assert the data matches and there's no crash.)
let f = Fetcher()
let url = URL(string: "https://httpbin.org/image/png")!
async let a = f.image(from: url)
async let b = f.image(from: url)
let (da, db) = try await (a, b)
XCTAssertEqual(da, db)
}
}
Run them: swift test. (These tests hit the network — for CI, you’d swap in URLProtocol mocks. That’s the stretch goal.)
Step 6 — Try it from a quick driver
Add an executableTarget demo to Package.swift, or open a playground that imports the library:
import AsyncFetcher
let urls = (1...10).map { URL(string: "https://picsum.photos/200?random=\($0)")! }
let f = Fetcher()
let start = Date()
let results = try await f.images(from: urls)
let elapsed = Date().timeIntervalSince(start)
print("Fetched \(results.count) images in \(String(format: "%.2f", elapsed))s")
Sequential await would take 10× the per-image latency. The TaskGroup should make this dramatically faster.
Done when
-
swift testis green. - The demo fetches 10 images in roughly the same time as fetching 1.
- You can explain (out loud) why the cache is an actor and not a struct.
-
You can explain (out loud) why
Fetcheris afinal class: Sendableand not just a struct. -
You used
withThrowingTaskGroupcorrectly — both theaddTaskside and thefor try awaitcollection side.
Stretch goals
- Bounded concurrency. Use a custom executor or a
Semaphoreto cap concurrent in-flight requests at, say, 5. Real-world image grids do this to avoid overwhelming the server. - Cancellation. When the calling task is cancelled, the in-flight
URLSessiontask should also cancel. Verify withTask.checkCancellation(). - Disk cache layer. Add a second cache that writes to
~/Library/Caches/AsyncFetcher/so cached images survive app restarts. - Mock
URLProtocolfor tests. Replace the live HTTP calls in tests with a deterministic mock so CI doesn’t depend on httpbin. - Memory pressure. Bound the in-memory cache size (e.g., max 50 MB or 200 entries) using an LRU strategy.
Real-world context
This is roughly the architecture Apple’s own AsyncImage (in SwiftUI) and Kingfisher/Nuke (popular third-party image libraries) use internally: an actor-isolated cache, structured concurrency for batch loads, typed errors at the boundary. You haven’t built a toy — you’ve built the foundational layer of a production image pipeline.
Build out the stretch goals over a weekend and you can credibly say in an interview: “I’ve built an async image fetcher with an actor-based dedup cache and bounded parallelism. Let me sketch it.”
You’ve finished Phase 1. ← back to Memory management | next phase coming soon.
2.1 — The Xcode interface tour
Opening scenario
Your new teammate at a coffee shop asks: “Where do I look in Xcode for the thing that…” — and you cut them off with the exact ⌘-key. That’s the goal of this chapter. Xcode has roughly 40 panes, 15 inspectors, and 200 keyboard shortcuts. You won’t memorize them all. But the 5 areas of the window have a job, and if you understand the job, you can navigate Xcode the way you navigate your own kitchen.
Open Xcode now. Open any project (create a new SwiftUI app if you don’t have one — File → New → Project → iOS → App → "XcodeTour"). We’ll tour together.
The five regions
┌──────────────────────────────────────────────────────────────────────┐
│ TOOLBAR (run, scheme, device) │
├─────────────┬───────────────────────────────────┬───────────────────┤
│ │ │ │
│ NAVIGATOR │ EDITOR │ INSPECTOR │
│ (left) │ (center) │ (right) │
│ │ │ │
├─────────────┴───────────────────────────────────┴───────────────────┤
│ DEBUG AREA (bottom) │
└──────────────────────────────────────────────────────────────────────┘
| Region | Job | Show / hide |
|---|---|---|
| Toolbar | Run, choose scheme, pick destination | always visible |
| Navigator (left) | Find things in the project | ⌘0 |
| Editor (center) | Read and write code | always visible |
| Inspector (right) | Inspect / configure the selected thing | ⌥⌘0 |
| Debug area (bottom) | Console + variable inspector when running | ⌘⇧Y |
Two-pane view (hiding navigator + inspector + debug) is what you want when you’re heads-down writing code: ⌘0, ⌥⌘0, ⌘⇧Y to toggle each.
Concept → Why → How → Code (well — clicks)
The Navigator — 9 tabs that each find something different
The icons at the top of the left sidebar select which navigator. The default is “Project Navigator” (folder icon). Memorize the keyboard shortcuts; this is the single biggest productivity unlock in Xcode.
| Shortcut | Navigator | What you find here |
|---|---|---|
| ⌘1 | Project | Files, folders, the project file itself |
| ⌘2 | Source Control | Branches, commits, working changes |
| ⌘3 | Symbol | All classes/structs/functions in the project |
| ⌘4 | Find | Project-wide search (regex, replace) |
| ⌘5 | Issues | Compiler errors and warnings |
| ⌘6 | Tests | XCTest cases, run all, run individually |
| ⌘7 | Debug | Process info while running |
| ⌘8 | Breakpoints | All breakpoints, manageable |
| ⌘9 | Reports | Build, test, archive logs (gold mine for debugging build failures) |
The four you actually use daily: ⌘1 (project), ⌘4 (find), ⌘5 (issues), ⌘6 (tests). Everything else has its moment.
The Editor — three flavors
Xcode’s editor has three modes you switch between with the three buttons in the top-right of the editor area (or ⌃⌘↩, ⌃⌘⇧↩, ⌃⌘⌥↩):
- Standard — one file.
- Assistant — two files side by side. Xcode tries to be smart (“Counterparts” shows you tests for a class; “Generated Interface” shows the Obj-C bridging header).
- Versions — Compare current file against a git revision side-by-side. Great for pre-commit review.
The jump bar at the top of the editor is criminally underused. Click the path component to jump to siblings; click further right to jump to specific methods within the file. ⌃6 opens the symbol list for the current file — a poor person’s outline view.
The Inspector — context-sensitive metadata
The right sidebar’s meaning depends on what you’ve selected. With a .swift file open you’ll see:
- File Inspector (⌥⌘1) — file path, target membership (critical), text settings.
- History Inspector (⌥⌘2) — git blame for the selected file.
- Quick Help (⌥⌘3) — Apple docs for the symbol under the cursor.
With a SwiftUI canvas open or a .xib selected, additional inspectors appear (Attributes, Size, Connections). Today, in 2026, you’ll mostly use File Inspector and Quick Help.
The Debug area — three sub-panes
When you’re running, ⌘⇧Y opens it. It has three panes (toggleable via the bottom-right buttons):
- Variables (left) — local variables of the current stack frame.
- Console (right) —
print()output AND the LLDB prompt (wherepo selfworks).
The console is dual-purpose: it captures stdout from your app and accepts LLDB commands when paused. Both. Same window. This trips up newcomers — when paused, type po viewModel and hit return; it’ll print.
The Toolbar — three controls that matter
- Scheme picker — which scheme to run (we cover schemes in Ch 2.2). Click and choose; ⌃0 also opens it.
- Destination picker — Simulator / device / “Any iOS Device” (for archiving). ⌃⇧0 opens it.
- Play / Stop — ⌘R runs, ⌘. stops. ⌘B builds without running. ⌘U runs tests.
The 10 shortcuts to memorize first
Type these into your fingers — by next week you’ll never use the mouse for them again.
| Shortcut | Action |
|---|---|
| ⌘R | Run |
| ⌘B | Build |
| ⌘U | Tests |
| ⌘. | Stop |
| ⌘⇧K | Clean build folder |
| ⌘⇧O | Open Quickly (fuzzy file/symbol search — the most-used shortcut by senior devs) |
| ⌃⌘↑ | Switch to counterpart (header ↔ implementation, Swift ↔ test) |
| ⌃6 | Symbol list for current file |
| ⌘/ | Toggle line comment |
| ⌘0 | Toggle navigator |
⌘⇧O is the killer. Type “FeedV” and it finds FeedViewController, FeedView.swift, FeedViewModel. Sub-second navigation across a 500-file project.
In the wild
- Apple’s own engineers live in ⌘⇧O. Watch any WWDC session where someone demos Xcode — they barely touch the project navigator. They fuzzy-search.
- Multi-cursor editing (hold ⌃⇧, click) appears in every modern IDE; Xcode finally added it in 12.0 (2020). Use it for parallel renames where Find & Replace would be overkill.
- The minimap (Editor → Minimap, or ⌃⇧⌘M) is the same idea as VS Code — most senior devs leave it off but enable it briefly when navigating monster files.
- Code folding (⌥⌘← / →) collapses functions or types. Useful in long files; if you’re folding a lot, the file is probably too long.
Common misconceptions
-
“The Xcode interface is the IDE.” No — the interface is a thin layer over
xcodebuild, the underlying build system. Anything you can do in Xcode you can do at the command line. Senior devs run builds in CI without ever opening the app. -
“You need to wait for indexing before doing anything.” Indexing affects autocomplete and symbol search, not building. You can ⌘R while the spinner is still going. (If autocomplete is broken, that’s an indexing issue — ⌘⇧K + restart Xcode usually fixes it.)
-
“More open editor tabs = more productive.” Tabs in Xcode are surprisingly weak (no preview-on-click, no pinning conventions). Most pros work with 1–2 tabs and rely on ⌘⇧O to navigate, treating files as transient views, not workspaces.
-
“The View Debugger and Memory Graph Debugger are only for hard bugs.” They’re for every bug above trivial. We cover both in Ch 2.5. Reaching for them early is the senior move.
-
“Custom themes are a waste of time.” False productivity, false. A high-contrast theme + a comfortable font (SF Mono, JetBrains Mono, Berkeley Mono) at the right size reduces eye fatigue measurably over a 10-hour day. Xcode → Settings → Themes. Spend 10 minutes.
Seasoned engineer’s take
The interface is not where Xcode is hard. Where it’s hard:
- Indexing. Xcode rebuilds its symbol index whenever files change in non-trivial ways. On a 200k-line project this can stall autocomplete for 30 seconds. The remedy:
defaults write com.apple.dt.Xcode IDEIndexDisable 1is not the answer (you’ll lose Quick Help and rename refactoring). The answer is modularization (Swift packages, see Ch 2.2) so the indexer can work in parallel. - DerivedData. Build artifacts live in
~/Library/Developer/Xcode/DerivedData/. When weird build failures appear (“Module X not found despite being right there”), nuke the project’s DerivedData folder:rm -rf ~/Library/Developer/Xcode/DerivedData/XcodeTour-*. This single command has saved more careers thangit reset. - Project file merge conflicts.
.xcodeprojis a folder of XML; merge conflicts on it are routine and ugly. Strategies in Ch 2.2.
Your initial goal: get fast with the keyboard. Then get fluent with the build system underneath. The interface is a means; the build system is the substance.
TIP: Add
defaults write com.apple.dt.Xcode ShowBuildOperationDuration -bool YESto your shell config. You’ll see every build’s duration in the toolbar — a great accountability signal when your project starts taking 90 seconds to build clean.
WARNING: Do not edit the
.xcodeprojfile by hand unless you really know what you’re doing. It’s auto-generated and Xcode will overwrite your changes. Use the GUI or — better — generate the project withXcodeGenor migrate target structure to SwiftPM.
Interview corner
Question: “How do you navigate a large Xcode project quickly?”
Junior answer: “I use the project navigator on the left.” → Truthful, won’t survive the next question.
Mid-level answer: “Mostly ⌘⇧O (Open Quickly) for files and symbols, ⌘4 for project-wide find, and ⌃6 for the symbol list within the current file. I keep the navigators hidden most of the time and only open ⌘5 (issues) when the build fails.” → Strong.
Senior answer: All of that, plus: “On a really big codebase the navigator stops scaling — indexing slows down, symbol search returns noise. The fix is modularization: break the app into Swift packages, each focused on a domain. Xcode indexes packages independently, and you get a smaller mental working set per task. I also lean on xcodebuild from the command line for builds in CI and for diagnosing “works for me but not in CI” issues — the GUI hides build settings and the CLI surfaces them.“ → Senior signal: thinks about scale and the build system behind the GUI.
Red-flag answer: “I drag files around in the project navigator.” → Tells the interviewer you’ll be the cause of weekly .pbxproj merge conflicts.
Lab preview
Lab 2.1 (Multi-target project) gets you hands-on with the toolbar’s scheme picker and the file inspector’s target-membership checkbox — both are introduced here.
Next: the four entities every Xcode build revolves around — project, workspace, target, scheme. → Projects, workspaces, targets, schemes
2.2 — Projects, workspaces, targets, schemes
Opening scenario
You join a team. They send you a git clone URL. You open the repo and see:
MyApp.xcworkspace
MyApp.xcodeproj
MyApp/
MyAppTests/
MyAppUITests/
MyAppWidget/
Pods/
Packages/
Which file do you double-click? What’s the difference between the workspace and the project? Why are there a workspace AND a project? What’s that “scheme” dropdown in the toolbar? Why are there five schemes?
The answers feel obvious after a year of iOS work. They feel mysterious for the first three months. This chapter compresses the mystery into one read.
The four entities
Workspace (.xcworkspace) ← what you open
├── Project A (.xcodeproj)
│ ├── Target: MyApp
│ │ ├── Build settings
│ │ ├── Sources (files)
│ │ ├── Resources (assets, plists)
│ │ ├── Frameworks (linked libraries)
│ │ └── Build phases
│ ├── Target: MyAppTests
│ ├── Target: MyAppWidget
│ └── Scheme: MyApp ← "how to build/run"
│ └── Scheme: MyAppWidget
└── Project B (e.g., dependency)
└── Swift Packages (resolved)
| Entity | What it is | When you create one |
|---|---|---|
| Project | A bundle of targets sharing a folder of source | One per app |
| Workspace | A container holding multiple projects + packages | When you have >1 project, or use CocoaPods, or use local SwiftPM dependencies |
| Target | One product (app, framework, extension, test bundle) | Each thing you can build separately |
| Scheme | A recipe for building + running + testing + archiving | One per target you want runnable, plus variants (Debug, Staging, Release) |
Rule of thumb: always open the .xcworkspace if one exists
If you double-click the .xcodeproj while a .xcworkspace also exists, you’ll get build errors (“Module ‘Pods_MyApp’ not found”). The workspace is the file that knows about your dependencies; the project alone doesn’t.
Concept → Why → How → Code
Project
A project lives in a folder named MyApp.xcodeproj. Despite the .xcodeproj extension, it’s a directory:
$ ls MyApp.xcodeproj
project.pbxproj xcshareddata/
project.xcworkspace/ xcuserdata/
The project.pbxproj file is OpenStep-format ASCII (a 1990s NeXT format). It records:
- Every source file in the project and which target owns it
- Build settings (compiler flags, code-sign identities, etc.)
- Build phases (compile sources, copy resources, link frameworks)
- Targets and their dependencies
This is the file that causes 90% of merge conflicts on iOS teams. Two developers add a file at the same time → both append to the PBXFileReference section → merge conflict. Mitigations:
- Tools like
XcodeGenorTuistthat generate the project from a YAML/Swift spec checked into git. - Move source code into Swift packages (Packages are SPM files —
Package.swift— which merge cleanly). - Use
kebab-casefilenames and follow a project structure convention so additions are predictable.
Workspace
A .xcworkspace is a tiny XML file listing the projects/packages inside it:
<Workspace version="1.0">
<FileRef location="group:MyApp.xcodeproj"/>
<FileRef location="group:Pods/Pods.xcodeproj"/>
<FileRef location="group:Packages/DesignSystem"/>
</Workspace>
You need one when:
- CocoaPods generates
Pods.xcodeprojand bundles your project into a workspace. - Multiple Xcode projects depend on each other (e.g., framework project + app project).
- Local Swift packages are added via “Add Local Package” — adds the package as a peer to your project.
If you have only an app with only remote SPM dependencies, you don’t need a workspace — the .xcodeproj alone is enough.
Target
A target is a single buildable product. Common kinds:
| Target type | Produces | Example |
|---|---|---|
| iOS App | .app bundle | The main MyApp |
| App Extension | .appex bundle | Widget, Share Extension, Notification Service |
| Framework / Static Library | .framework / .a | Shared code |
| Unit Test Bundle | .xctest | MyAppTests |
| UI Test Bundle | .xctest (UI runner variant) | MyAppUITests |
| macOS App | .app bundle | A Mac Catalyst or pure AppKit version |
A real-world app commonly has 5–10 targets: app, watchOS companion, widget extension, intent extension, notification service, unit tests, UI tests, snapshot tests. Each target has its own build settings and decides which files it compiles (“target membership” — the checkbox in the File Inspector).
Scheme
If a target is “what to build,” a scheme is “how, when, and with what flags.” A scheme bundles five actions:
- Build — which targets to build (your app target almost always)
- Run — what to launch with the debugger attached, with what environment variables, arguments, executable
- Test — which test targets to run, what test plans, parallelization
- Profile — what to launch under Instruments
- Analyze — static analysis target
- Archive — what to package for App Store / Ad Hoc distribution
You’ll typically have multiple schemes per target. The common pattern:
- MyApp — Debug build, hits dev backend
- MyApp (Staging) — Release build, hits staging backend, points TestFlight uploads at a beta App Store Connect record
- MyApp (Prod) — Release build, hits prod backend, App Store distribution
The differences between these schemes are usually a combination of build configuration, environment variables, and a launch argument.
The Edit Scheme dialog (Product → Scheme → Edit Scheme, or ⌘<)
Things you’ll change here regularly:
- Run → Arguments → Environment Variables: set
OS_ACTIVITY_MODE=disableto silence noisy system logs, orMY_API_BASE=https://staging.example.comto switch backends without code changes. - Run → Diagnostics → Address Sanitizer / Thread Sanitizer / Main Thread Checker — turn these on for debug builds to catch entire classes of bugs at runtime.
- Test → Test Plans — multiple plans for unit vs integration vs perf tests.
- Run → Build Configuration — Debug for the Run action, Release for the Profile and Archive actions.
Shared vs user schemes
In the scheme list (⌃0) you see “Shared” and “User” sections. A shared scheme is checked into git (xcshareddata/xcschemes/); a user scheme is local to you (xcuserdata/<username>.xcuserdatad/). Almost always you want schemes shared — otherwise CI can’t build your app.
Open Product → Scheme → Manage Schemes and tick “Shared” for the schemes that should be in git. This single checkbox has cost teams entire afternoons.
In the wild
- Apple’s WWDC sample projects ship as bare
.xcodeprojwith no workspace — they have zero CocoaPods and only SPM dependencies. Modern, minimal. - The Wikipedia iOS app uses a workspace with ~30 frameworks structured per feature. Build times approach 4 minutes from clean.
- Tuist and XcodeGen are the two project-generation tools used at Spotify, Airbnb, Bumble, and SoundCloud to escape
.pbxprojmerge hell. They let you describe your project structure as code. xcodebuild(the command-line equivalent) takes the same project/workspace/scheme arguments —xcodebuild -workspace MyApp.xcworkspace -scheme MyApp -destination 'platform=iOS Simulator,name=iPhone 16' build. CI scripts speak this dialect.
Common misconceptions
-
“Targets and schemes are the same thing.” No. Targets describe what to build (sources + settings). Schemes describe how to drive it (which configuration, what env vars, which tests to include). One target can have many schemes.
-
“You should use one target per environment (Prod / Staging / Dev).” Almost always wrong. Targets are heavy — each has its own settings, Info.plist, asset catalog. Use one target with multiple schemes / configurations to switch environment. Only create separate targets when the binary truly differs (e.g., enterprise vs App Store edition).
-
“I don’t need a workspace for SPM-only projects.” Correct! This is a good simplification many teams haven’t adopted yet. If your dependencies are all remote SwiftPM packages, drop the workspace and open the
.xcodeprojdirectly. -
“Adding a file via Finder is fine.” No — Xcode tracks files in the project file. Drag the file into Xcode, choose target membership in the dialog. Finder-added files are invisible to the build until you add them through Xcode’s UI.
-
“Marking everything as Shared is the safe default.” Mostly — but personal experimental schemes (e.g., your one-off run config for chasing a bug) should stay user-local. Otherwise your scheme list balloons in PRs.
Seasoned engineer’s take
The mental model that unlocks Xcode: everything is a build setting. The GUI is a thin layer over hundreds of keyed settings stored in .pbxproj and resolvable from .xcconfig files. Once you internalize this, you stop “configuring Xcode” and start “writing build settings.”
For team scaling, the path is consistent across every iOS shop above 5 engineers:
- Start with a single project, single workspace, plain Xcode.
- Add a
.xcconfigper build configuration (Debug / Release / Staging). - As features grow, extract them into SwiftPM packages — a
DesignSystempackage, anNetworkingpackage, aFeature_Profilepackage. Each package builds independently, indexes independently, tests independently. - When the
.pbxprojis regenerated more often than it’s edited, adoptTuistorXcodeGenand stop editing it by hand.
The teams that don’t do this end up with 60-second incremental builds, weekly merge conflicts on project.pbxproj, and engineers waiting on the indexer.
TIP: Always tick “Shared” for new schemes, then commit
xcshareddata/. CI will thank you. Your future self chasing “why does CI not see this scheme” will thank you more.
WARNING: Never drag files into the Xcode project from Finder without selecting target membership. The file will silently fail to compile (because no target owns it) and you’ll spend 20 minutes wondering why your new view is “not in scope.”
Interview corner
Question: “What’s the difference between a target and a scheme in Xcode?”
Junior answer: “A target is what you’re building, a scheme is how you build it.” → True but vague.
Mid-level answer: “A target is a buildable product: an app, an extension, a test bundle. It has its own sources, build settings, and Info.plist. A scheme is a recipe that selects targets and configurations for the five actions — Run, Build, Test, Profile, Archive — and can include environment variables, launch arguments, and per-action build configurations. The typical setup is one target per product and multiple schemes per target for different environments (Dev, Staging, Prod).” → Strong.
Senior answer: Plus: “I’d also call out the anti-pattern of using separate targets per environment — that’s an old Objective-C habit. You end up duplicating settings, plists, asset catalogs. The right pattern is one target, multiple build configurations (Debug, Release-Staging, Release-Prod) controlled by .xcconfig files, with schemes selecting which configuration to run. The behavior differences become environment variables and a Configuration.plist swap, not source-level branching with #if everywhere. And as projects grow, modularizing into SwiftPM packages is the real escape hatch — each package becomes its own indexable, testable, parallelizable build unit.” → Senior signal: knows the anti-pattern and the scale path.
Red-flag answer: “I use separate targets for Debug and Release.” → Tells the interviewer the candidate has been copy-pasting Info.plists for two years.
Lab preview
Lab 2.1 (Multi-target project) walks you through adding a Widget Extension target, a macOS target, and a Staging scheme to a starter app — the entities described above, made concrete.
Next: where targets and schemes actually do their thinking — the build settings layer. → Build settings & configurations
2.3 — Build settings, configurations, and xcconfig files
Opening scenario
The product manager messages: “Can you point the staging build at the new API and add a ‘STAGING’ watermark in the corner?” — for the second time this month. The first time, you forked the source and added #if STAGING everywhere. The PR was 200 lines, the merge conflicts were brutal, and your tech lead said “do it the right way next time.”
This is the next time. The right way is:
struct AppEnvironment {
let apiBaseURL: URL
let watermark: String?
static let current: AppEnvironment = .fromInfoPlist()
}
…with the values populated from Info.plist, which is populated from .xcconfig files, which are selected by build configuration, which is selected by scheme. Three layers of indirection, but each is justified, and the result is that adding a new environment is a 5-minute task with no source-code changes.
The hierarchy of settings
xcodebuild command-line override
↓
Target-level setting
↓
Project-level setting
↓
.xcconfig file
↓
Default / inherited value
Higher in this list wins. The trick to staying sane: set as little as possible at the top, push defaults into .xcconfig at the bottom, and let the build settings UI act as an inspector, not a primary editor.
Concept → Why → How → Code
Build settings — what they are
Open any target → Build Settings tab. You’ll see a few hundred keyed settings: SWIFT_VERSION = 6.0, IPHONEOS_DEPLOYMENT_TARGET = 17.0, MARKETING_VERSION = 1.0.0, PRODUCT_BUNDLE_IDENTIFIER = com.example.MyApp. Each setting can be:
- A simple string / number
- A list (space-separated, sometimes quoted)
- A reference to another setting using
$(OTHER_SETTING)or${OTHER_SETTING}
The $(inherited) token is special — it means “the value coming from the level below this one.” You’ll use it constantly when appending flags without overriding:
OTHER_SWIFT_FLAGS = $(inherited) -warnings-as-errors
Build configurations — Debug, Release, and whatever else you need
A project starts with two configurations: Debug and Release. They differ in:
- Optimization (
SWIFT_OPTIMIZATION_LEVEL = -Ononevs-O) - Debug symbols (
DEBUG_INFORMATION_FORMAT = dwarfvsdwarf-with-dsym) - Whether
-DDEBUGis defined (so#if DEBUGworks)
You can add more (Project → Info → Configurations → +). A common production setup:
| Configuration | When used |
|---|---|
Debug | Day-to-day development |
Debug-Staging | Local builds pointing at staging |
Release-Staging | TestFlight builds for QA |
Release | App Store distribution |
xcconfig files — externalizing build settings
A .xcconfig file is a flat key-value file:
// Shared.xcconfig
SWIFT_VERSION = 6.0
IPHONEOS_DEPLOYMENT_TARGET = 17.0
MARKETING_VERSION = 1.0.0
PRODUCT_BUNDLE_IDENTIFIER = com.example.MyApp
// Debug.xcconfig
#include "Shared.xcconfig"
SWIFT_OPTIMIZATION_LEVEL = -Onone
API_BASE_URL = https:/$()/api.dev.example.com
// Release.xcconfig
#include "Shared.xcconfig"
SWIFT_OPTIMIZATION_LEVEL = -O
API_BASE_URL = https:/$()/api.example.com
Two quirks:
//starts a comment, even inside URLs. That’s why you’ll seehttps:/$()/api.example.com— the empty$()interpolation breaks//from being read as a comment. It’s an ancient and absurd workaround that every iOS engineer types eventually.#includeis supported (notimport) — relative paths from the including file.
To wire it up: Project → Info → Configurations → expand a configuration → set the xcconfig file for the project (or each target). Now the configuration’s defaults come from the file.
Bridging build settings into Swift code via Info.plist
Build settings are compile-time. To read them at runtime, you stage them through Info.plist and then read the bundle’s info dictionary. The Info Plist gets processed during build, so $(API_BASE_URL) in an Info.plist value gets substituted:
<!-- Info.plist -->
<key>APIBaseURL</key>
<string>$(API_BASE_URL)</string>
enum AppEnvironment {
static let apiBaseURL: URL = {
guard let raw = Bundle.main.object(forInfoDictionaryKey: "APIBaseURL") as? String,
let url = URL(string: raw) else {
fatalError("APIBaseURL missing or invalid in Info.plist")
}
return url
}()
}
Now changing API_BASE_URL in Release-Staging.xcconfig automatically routes the staging TestFlight build to the staging API. No source code change. No #if STAGING anywhere.
Compile-time flags: #if DEBUG, #if STAGING
For behavior that needs to compile in or out, use Swift Active Compilation Conditions (Build Settings → Swift Compiler — Custom Flags → Active Compilation Conditions). Add STAGING to the staging configurations:
#if STAGING
private let watermarkText: String? = "STAGING"
#else
private let watermarkText: String? = nil
#endif
These are not the same as OTHER_SWIFT_FLAGS = -D STAGING (which works too but is more verbose). Use Active Compilation Conditions for cleanliness.
Build phases — what happens, in order
Each target has a list of Build Phases (the “Build Phases” tab). The default for an iOS app:
- Target Dependencies — build these first
- Compile Sources —
.swiftand.mfiles - Link Binary with Libraries — link frameworks
- Copy Bundle Resources — assets, plists, storyboards
You can add custom build phases:
- Run Script Phase —
swiftlint,swiftformat, sentry-cli upload of dSYMs, custom code-gen.
Run Script phases run on every build by default. Add
${SRCROOT}/Path/To/inputsand${SRCROOT}/Path/To/outputsto make Xcode skip the phase when nothing’s changed. Without this, every incremental build will run your script and your build times will rot.
In the wild
- Most professional iOS projects keep one
.xcconfigper configuration in aConfig/folder at the repo root. Some even check the API URLs in plain text — they’re not secrets, they’re routing. - Apple’s CoreFoundation header is full of
OTHER_CFLAGSthat get inherited via.xcconfig. - Fastlane (the CD toolchain used at Lyft, Twitter, Snapchat) reads
MARKETING_VERSIONandCURRENT_PROJECT_VERSIONdirectly from the xcconfig to compute the next App Store version. - CocoaPods generates
.xcconfigfiles (Pods/Target Support Files/Pods-MyApp/Pods-MyApp.debug.xcconfig) — this is the actual mechanism by which CocoaPods plumbs framework paths into your project.
Common misconceptions
-
“I’ll just use
#if DEBUGfor everything.” Works for tiny apps, breaks at scale. The#ifblocks cluster in random files, you forget to update some, and the bundle ID / API URL still gets baked at compile time. xcconfig-driven configuration centralizes the difference. -
“
Info.plistis just metadata.” It’s a runtime-readable processed key/value store. Use it as the bridge between build settings and Swift code. -
“You should never edit
.pbxprojdirectly.” Mostly true — but knowing how to read it is critical for resolving merge conflicts. Open it in a text editor occasionally and learn the structure; one day you’ll thank yourself. -
“Setting
DEBUG = 1makes#if DEBUGwork.” Subtly wrong.DEBUG = 1adds the C preprocessor define; Active Compilation Conditions drive Swift’s#if. TheDebugconfiguration ships with both pre-set; that’s why it works. -
“All my Run Script phases should run every build.” No. Define inputs and outputs and Xcode will skip them when nothing’s changed. A 12-second script that runs on every keystroke compounds painfully.
Seasoned engineer’s take
The mental shift that turns a junior into a confident Xcode user: stop editing build settings in the GUI. Move them into .xcconfig. The GUI is unreviewed config drift; the .xcconfig is config-as-code that diff-reviews cleanly.
A pattern I deploy on every new project:
Config/
├── Shared.xcconfig # all configurations inherit
├── Debug.xcconfig # dev simulator
├── Debug-Staging.xcconfig # locally pointed at staging
├── Release-Staging.xcconfig# TestFlight
└── Release.xcconfig # App Store
Shared.xcconfig carries SWIFT_VERSION, deployment target, bundle ID prefix, Swift strict-concurrency settings. The per-config files carry only the differences. Pull requests that bump a deployment target show a clear single-line diff.
For secrets — API keys, OAuth client IDs — do not check them into xcconfig. They’re in source control. Use xcconfig only for non-secret routing (URLs, bundle IDs, feature flags). Real secrets belong in the Keychain at runtime, or in environment variables injected at CI build time.
TIP: Search through Apple’s open-source repos (e.g.,
swift-package-manager) for.xcconfigexamples — they’re a masterclass in setting hygiene. Look at how settings are grouped, commented, and inherited.
WARNING: Never check secrets into
.xcconfig— they’re plain-text in your repo. API keys, Firebase config, Stripe publishable keys (yes, even publishable) all leak into git history and trigger GitHub secret-scanning alerts that look bad in interviews.
Interview corner
Question: “How would you set up an iOS project to support Dev, Staging, and Prod environments?”
Junior answer: “I’d add #if DEV, #if STAGING, #if PROD and define a constant in each branch.” → Works for a coffee-shop side project. They’ll keep digging.
Mid-level answer: “I’d add three build configurations — Debug, Release-Staging, Release — backed by three .xcconfig files. The xcconfigs define API_BASE_URL, BUNDLE_ID_SUFFIX, and MARKETING_VERSION per environment. Those settings get plumbed into Info.plist using $(VAR) substitution, and Swift reads them at runtime via Bundle.main.object(forInfoDictionaryKey:). I’d then create three schemes — one per environment — each selecting the right configuration for Run, Test, Archive, and Profile actions.” → Strong, complete, what an interviewer wants.
Senior answer: Plus: “I’d also separate the bundle ID per environment (com.example.MyApp.dev, com.example.MyApp.staging, com.example.MyApp) so all three can install side-by-side on a device — invaluable during QA. I’d use Active Compilation Conditions for anything truly compile-time (like swapping in a mock network layer for Debug builds). I’d handle secrets out-of-band: not in xcconfig, but injected into the build via xcrun agvtool or fetched from a CI secret store. And I’d document the matrix — which scheme builds with which configuration and points at which backend — in a README block, because three environments × four actions = twelve combinations that a new hire will mis-remember on day three.” → Senior signal: thinks about side-by-side installs, secret hygiene, documentation.
Red-flag answer: “I’d ship a Settings.bundle toggle that lets the user pick the backend.” → Tells the interviewer the candidate is going to ship a debug UI to App Store reviewers.
Lab preview
In Lab 2.1 you’ll create the Debug / Release-Staging / Release configurations + matching xcconfig files for a real starter app and wire them into a Configuration.swift runtime accessor.
Next: the dozen Xcode shortcuts and refactor tools that separate “Xcode user” from “Xcode driver.” → Tips, tricks & shortcuts
2.4 — Tips, tricks & shortcuts
Opening scenario
You’re pair-programming with a senior engineer. They navigate to a symbol, refactor a property name across 40 files, edit five variable declarations simultaneously, and jump to the test for the current class — all in 45 seconds without touching the trackpad. You’ve been writing Swift for six months and you didn’t know Xcode did most of those things. The features have been there since Xcode 12; nobody told you.
This chapter is the “nobody told me” list. None of these are advanced. All of them are daily.
The high-leverage list
| Trick | Shortcut / How |
|---|---|
| Open Quickly (fuzzy find anything) | ⌘⇧O |
| Multi-cursor | ⌃⇧ + click, or ⌃⇧↑/↓ |
| Refactor → Rename | ⌃⌘E |
| Code Snippets | ⌃⌘L (libraries pane) |
| Jump to Definition | ⌃⌘← (back), ⌃⌘→ (forward), ⌘ + click |
| Show Quick Help | ⌥ + click on symbol |
| Move line up/down | ⌥⌘[ / ⌥⌘] |
| Duplicate line | ⌘D (not enabled by default — see below) |
| Show file in project navigator | ⌃⇧⌘J |
| Open counterpart (test ↔ class) | ⌃⌘↑ |
| Fix-it (apply suggested fix) | ⌃⌥⌘F |
| Re-indent selection | ⌃I |
| Fold / unfold all blocks | ⇧⌥⌘← / → |
| Toggle line comment | ⌘/ |
Comment block (/* */) | ⌥⌘/ |
| Jump bar (top of editor) | ⌃6 (symbol list for file) |
| Show Library (snippets, modifiers) | ⌃⌘L |
Concept → Why → How → Code
Multi-cursor editing
Hold ⌃⇧ and click to add cursors. Or use ⌃⇧↑ / ⌃⇧↓ to add a cursor on the line above/below. Useful for:
let firstName: String = ""
let lastName: String = ""
let email: String = ""
You realize String should be String?. Triple-click String on the first line, ⌃⇧↓ twice to add cursors on the next two lines (column-aligned), type ?. Done.
For non-column-aligned changes, Find & Replace in selection (⌘E to fill the search field from selection, then ⌘F → “In Selection”) often beats multi-cursor.
Refactor → Rename (⌃⌘E)
Select a symbol → ⌃⌘E → type new name. Xcode renames the declaration and all references, including:
- Method parameter labels
- The symbol in tests
- The symbol in Storyboards / XIBs (sometimes; Swift-to-IBOutlet renames are hit-or-miss)
It’s better than Find & Replace because it’s semantic — it won’t rename a String literal that happens to contain your symbol’s name.
When rename refuses to work, it’s almost always because:
- Indexing isn’t complete (wait for the progress bar).
- The symbol crosses an Obj-C boundary (rename via Find & Replace, manually update
@objcnames). - A package dependency uses the symbol (Xcode won’t rename across package boundaries).
Code Snippets — your personal autocomplete
Select code → right-click → Create Code Snippet. Give it a Completion Shortcut (e.g., weakself). Now in any file, typing weakself and hitting tab inserts:
{ [weak self] in
guard let self else { return }
<#code#>
}
Common snippets to set up on day one:
weakself→[weak self]capture withguard let selfmark→// MARK: - <#section#>unimpl→fatalError("Not yet implemented")pragma→#warning("TODO: <#description#>")
Snippets sync via iCloud across Macs if you enable it in Xcode → Settings → General → “Use iCloud for…”.
Jump to Definition + History
⌘ + click (or ⌃⌘J) jumps to the definition. ⌃⌘← jumps back through your navigation history; ⌃⌘→ jumps forward. Without those, every jump-to-definition is a “now I need to find my way home” exercise. Train your fingers on the back-arrow first; it’s the most important shortcut in the IDE.
Fix-it
When the compiler shows an error with a 🔧 wrench icon, ⌃⌥⌘F applies the suggested fix. Common cases:
- “Missing return statement” → adds the return
- “Use of unresolved identifier ‘XYZ’; did you mean ‘XYx’?” → applies the rename
- “Add missing
await” → adds it - “Conform to protocol XYZ” → stubs in protocol methods
For protocol stub-out, this single shortcut saves five minutes per conformance.
#warning and #error
Two preprocessor directives that work in Swift:
#warning("TODO: replace mock with real implementation before launch")
#error("Don't ship without an API key")
#warning shows up in ⌘5 (issues navigator) and as a yellow squiggle. #error fails the build. Combine #error with #if !DEBUG:
#if !DEBUG && DEMO_API_KEY_PRESENT
#error("Cannot ship demo API key to production")
#endif
This kind of compile-time guard catches more bugs than any unit test.
Minimap and breadcrumbs
Editor → Minimap (⌃⇧⌘M) gives a VS-Code-style overview. Hover the minimap to see method names; click to jump. Some people love it, some find it noisy; try it for a week and decide.
The breadcrumb bar (jump bar) at the top of every editor pane shows your path: project → file → method → block. Click any segment to jump to siblings at that level. Single greatest navigational tool in Xcode, and most users ignore it.
Vim mode
Xcode has a built-in Vim mode since version 13 — Editor → Vim Mode. Incomplete compared to MacVim or the vim-mode plugin in JetBrains IDEs, but enough for hjkl, dd, yy, p, :%s, visual mode. If you came to iOS from a vim background, turn it on; you’ll lose nothing and gain modal editing.
Duplicating a line
The Mac-default Xcode shortcut for duplicate line is… nothing. The fix:
- Xcode → Settings → Key Bindings
- Search “duplicate”
- Bind “Duplicate” to ⌘D (and re-bind “Use Selection for Find” to something else)
The 15 seconds you spend on this on day one saves hours over a year.
In the wild
- Apple’s WWDC presenters use snippets extensively. Watch any code-along session and notice the
tabkey being hit before complete identifiers — those are snippet shortcuts. - Multi-cursor editing is the gateway drug for VS Code converts; Xcode’s implementation is rougher around the edges but absolutely usable.
- Pair-programming via Visual Studio Code Live Share (yes, with the Swift extension) is what some senior engineers use for cross-team collaboration since Xcode lacks first-party multiplayer.
xedfrom the command line opens files in Xcode without bringing the whole IDE to front.xed -l 42 Sources/Models/User.swiftopens the file at line 42 — gold for git pre-commit hooks.
Common misconceptions
-
“I should learn all the shortcuts at once.” No. Add one per day. Permanent muscle memory takes 5–10 days of daily use; cramming 30 shortcuts in an afternoon means remembering 4 in a week.
-
“Refactor → Rename is unreliable, so I’ll use Find & Replace.” Wrong direction. Find & Replace is less reliable for symbol renames — it’ll match the symbol inside strings and comments. Rename is right 90% of the time; for the other 10% (Obj-C bridges, package boundaries) you’ll know.
-
“Snippets are for boilerplate I’ll only write once.” Wrong — snippets are for boilerplate you write every day. The 4-line
[weak self]capture, theMARKseparator, the standard test method skeleton. -
“Vim mode in Xcode is a toy.” Mostly true, but for read-mostly navigation (
/,n,N,gg,G) it’s perfectly fine and the modal mental model still pays off. -
“The minimap is essential.” It’s not. It’s a preference. Many senior engineers turn it off because vertical screen real estate matters more than a thumbnail.
Seasoned engineer’s take
Speed in Xcode comes from two reinforcing loops:
-
Keyboard fluency loop. Pick one shortcut you don’t know. Use it for a week. Add another. After 3 months, you’ll have 30 hard-wired shortcuts and you’ll have stopped thinking about navigation.
-
Snippet hygiene loop. Whenever you find yourself typing the same 3+ lines twice in a day, make a snippet. After 6 months, you’ll have a personal library of 50–80 snippets and your editing speed will visibly outpace newer hires.
Beyond that, invest in a real keyboard (mechanical, full-travel, real Function row) and a good font (SF Mono, JetBrains Mono, Berkeley Mono, Iosevka). These aren’t yak-shaving — they reduce friction in the exact action you do 8 hours a day.
The senior tell isn’t speed though. It’s the absence of friction: a senior engineer rarely reaches for the mouse, rarely looks at the menu bar, rarely scrolls to find a file. They glide.
TIP: Add a snippet with the completion shortcut
marksectionfor// MARK: - <#section name#>. You’ll type it 50 times a week. Snippet shortcut + tab key = three keystrokes to a clean section header.
WARNING: Don’t bind ⌘W to “close project” by accident in your key bindings. Default ⌘W closes the current tab; reassigning it can lose your whole window. Test new bindings in a throwaway project first.
Interview corner
Question: “Walk me through how you’d rename a property used in 50 places across an app.”
Junior answer: “I’d do a Find & Replace across the project.” → Acceptable. Won’t impress.
Mid-level answer: “I’d use Xcode’s Refactor → Rename (⌃⌘E). It’s symbol-aware, so it won’t accidentally match the name inside a string literal or a comment. It also updates references in tests and across files in the same module. After the rename I’d run the tests and a build to confirm nothing was missed — then commit.” → Strong.
Senior answer: Plus: “If the property crosses a module boundary (e.g., it’s a public API on a Swift package), I’d be aware that Refactor → Rename won’t reach into the importing module’s source. In that case I’d rename in the package, deprecate the old name with @available(*, deprecated, renamed: "newName") for a release, then remove. For Obj-C-bridged symbols I’d manually update the @objc selector. And I’d be cautious of renames inside generated code (e.g., generated by SwiftGen or by an MCP server) — those need their source updated, not the generated output.” → Senior signal: thinks about module boundaries, backward compatibility, and code generation.
Red-flag answer: “I’d git grep and sed -i the rename.” → Tells the interviewer the candidate will rename strings inside print() statements and break runtime behavior.
Lab preview
The labs in this phase (Lab 2.1, Lab 2.2, Lab 2.3) lean on these shortcuts. The first time you find yourself in Lab 2.2 hitting ⌃⌘E to rename a misnamed property, you’ll know the muscle memory has started.
Next: the most important Xcode skill no one teaches you — debugging. → Debugging deep dive
2.5 — Debugging deep dive
Opening scenario
A user-reported bug: the profile picture sometimes appears as a gray square, only after navigating away and back. The QA team can reproduce it 1-in-5 times. The PR adding profile pictures was merged two weeks ago by someone now on vacation. You have:
- ~30k lines of Swift to dig through
- A
print()statement won’t help — you need to inspect runtime state XCTestcan’t reproduce a navigation timing bug- The bug never appears in your test build
This is a debugging chapter, not a “logging” chapter. Logging is what you do after you understand the bug. Debugging is how you find it. The two senior tools for this:
- LLDB — interactive runtime inspection
- View Debugger + Memory Graph Debugger — visual inspection of the runtime tree
The five Xcode debugging tools
| Tool | Use it when |
|---|---|
| Breakpoints | Stop at a specific line; inspect locals |
LLDB console (po, p, expression) | Run code in paused process; mutate state |
| View Debugger (3D hierarchy) | “Why is this view layout wrong?” |
| Memory Graph Debugger | “Why is this object still alive / why is it nil?” |
| Thread Sanitizer / Address Sanitizer | “Why does this crash sometimes?” |
Concept → Why → How → Code
Breakpoints — beyond the click in the gutter
A click in the gutter sets a line breakpoint. Right-click the breakpoint → Edit Breakpoint… unlocks the actual power:
Condition: viewModel.userID == "abc-123"
Ignore: 0
Action: Log Message → "User loaded, isLoading = @viewModel.isLoading@"
Action: Debugger Command → po viewModel
☐ Automatically continue after evaluating actions
- Condition — only breaks when the expression evaluates true. Stop only when the bug-reproducing user ID is hit.
- Action — runs without you doing anything when the breakpoint hits. Combine “Log Message” + “Automatically continue” and you have a non-stopping breakpoint that essentially adds a
print()without recompiling. - Ignore N — skip the first N hits. Useful inside loops.
The senior move: conditional, auto-continuing breakpoints with debugger commands as “actions” turn the IDE into a tracing tool, no source-code changes needed.
Symbolic breakpoints
Breakpoint navigator (⌘8) → + → Symbolic Breakpoint. Set:
Symbol: -[UIViewController viewDidLoad]
Now you stop in every viewDidLoad of every view controller — useful when you don’t know which class is misbehaving but you know the lifecycle method involved. Also handy:
objc_exception_throw— stop on every Objective-C exception (catchesNSInvalidArgumentExceptionfrom UIKit)swift_willThrow— stop right before any Swiftthrow-[UIView setNeedsLayout]— find unexpected layout invalidations
LLDB commands every iOS engineer should know
LLDB runs in the console pane when paused. Top commands:
| Command | What it does |
|---|---|
po expr | Print object (calls description / debugDescription) |
p expr | Print value (without description) |
expression expr | Evaluate and modify state — expression viewModel.isLoading = false |
v (or frame variable) | Print all local variables of current frame |
bt (or thread backtrace) | Stack trace of the current thread |
thread list | All threads |
thread select N | Switch to thread N |
c (continue) | Resume execution (same as ⌃⌘Y) |
n (next) | Step over |
s (step) | Step into |
finish | Run to end of current function |
The killer combination:
(lldb) po viewModel
<MyApp.ProfileViewModel: 0x600003a4c800, userID: nil, isLoading: true>
(lldb) expression viewModel.userID = "test-user-id"
(lldb) expression viewModel.refresh()
(lldb) c
You just fixed runtime state without restarting the app — invaluable for reproducing edge cases or simulating server responses while paused.
Print to LLDB from SwiftUI
In SwiftUI views, print() in body is awkward. Use a let _ = print():
var body: some View {
let _ = print("Profile body rebuilt, isLoading = \(viewModel.isLoading)")
VStack { … }
}
Or — better — let _ = Self._printChanges() which logs what changed to cause the rebuild. Indispensable for diagnosing unnecessary view updates.
View Debugger (3D hierarchy)
While running, Debug → View Debugging → Capture View Hierarchy (or the icon in the debug bar that looks like three stacked squares). The editor pane explodes into a 3D exploded view of your UI:
- Rotate / pan with click+drag
- Click a view to highlight it in a hierarchy outline on the left
- Right inspector shows
UIViewproperties: frame, alpha, isHidden, view class, AutoLayout constraints - “Show Clipped Content” reveals views that were drawn off-screen
This is the tool for:
- “Why is this view zero-sized?” — captured frame is
(0, 0, 0, 0) - “Why isn’t this label visible?” —
alpha = 0orisHidden = true - “Why does this view cover that one?” — the 3D rotation makes it obvious
- “Why is my AutoLayout broken?” — inspector shows the constraints in plain English
In SwiftUI projects, the View Debugger shows the underlying UIKit hierarchy (SwiftUI compiles down to UIKit at the leaves). It’s less precise than for pure UIKit but still useful for layout questions.
Memory Graph Debugger (object retention)
While running, Debug → Debug Memory Graph (icon in the debug bar that looks like three stacked nodes). Pauses your app, takes a snapshot of every live object, and renders the retain graph:
- Left sidebar: every class with live instances, sorted by count
- Center: the retain graph for the selected object
- Right inspector: backtrace of where this object was allocated
This is the tool for memory leaks:
- A view controller that should be gone after dismissal but isn’t (look for it in the sidebar; if it’s there, click it to see what’s retaining it)
- An object retained by a closure capture (the graph shows the closure as a node, with an edge labeled the captured variable)
- A reference cycle between two objects (the graph draws the cycle as a literal cycle)
Enable in Xcode → Settings → “Show memory at top of debug navigator” so you see live allocation counts at all times.
Address / Thread / Main Thread / Undefined Behavior Sanitizers
Edit the scheme (⌘<) → Run → Diagnostics. Toggle:
- Address Sanitizer — catches buffer overflows, use-after-free. Slows the app ~2×. Catches bugs that would otherwise crash randomly.
- Thread Sanitizer — catches data races. Slows ~5–15×. Essential when adopting Swift Concurrency in a Swift 5 codebase that has nonisolated state.
- Main Thread Checker — flags UIKit calls off the main thread. Cheap. Leave on for all Debug builds.
- Malloc Stack Logging — records allocation stacks for objects shown in the Memory Graph Debugger. Turn on only when memory-debugging; it’s heavyweight.
Run with sanitizers enabled at least monthly. Race bugs they catch save weeks of “intermittent crash, no repro” tickets.
Breakpoint Sets
Power user feature: Breakpoint navigator (⌘8) → bottom-left + button → Add Breakpoint Set. Group related breakpoints (e.g., “Profile screen debugging”) and enable/disable the whole set at once. Saves the “I left 12 breakpoints on, my app is paused every 3 seconds” frustration.
In the wild
- Airbnb engineering blog has documented their use of conditional breakpoints with custom LLDB scripts for diagnosing layout issues in their hot-reload framework.
- The View Debugger was famously how the Slack iOS team chased down their “the new message badge sometimes draws under the navigation bar” bug pre-2020.
- Memory Graph Debugger + Malloc Stack Logging is how Cash App’s engineers profile transient memory spikes during navigation; the allocation backtraces point straight to the leaking closure.
- Apple’s Auto Layout team demos their own debugging in every WWDC layout session — exclusively via the View Debugger.
Common misconceptions
-
“
printis enough.” No.printrequires you to (a) know where to add it, (b) rebuild, (c) reproduce again. Conditional breakpoints with log-message actions are zero-rebuild and zero-source-change. -
“View Debugger is for visual designers.” It’s for every iOS engineer. UI bugs are 30–50% of the bug surface in a typical app and View Debugger is the first place to look.
-
“Memory leaks are rare in Swift because of ARC.” Swift’s ARC handles 95% of memory management, but the remaining 5% (closure captures, delegate cycles, Combine subscriptions held by the publisher) is where every real-world memory leak comes from. Run the Memory Graph Debugger weekly.
-
“Thread Sanitizer is too slow to run.” It’s slow, yes — but run it on your test suite in CI nightly, or in your scheme’s “test” action for the smoke-test plan. The races it catches will ship to production otherwise.
-
“LLDB is for Objective-C; Swift has Xcode’s debugger UI.” LLDB is the underlying debugger for both. The UI is a frontend. Mastering LLDB pays dividends in both languages, and is the only way to do anything non-trivial.
Seasoned engineer’s take
The split between junior and mid iOS engineers is mostly debugging skill. Anyone can write features. Mid-level engineers can isolate a bug to a 50-line block of code in under an hour without reading the codebase top to bottom. They do this by:
- Forming a hypothesis first. (“I think the view model isn’t being deallocated.”)
- Picking the right tool for the hypothesis. (“Memory Graph Debugger will tell me in 10 seconds.”)
- Confirming or rejecting in minutes. (“Confirmed — there’s a Combine subscription with
selfstrong-captured.”)
The split between mid and senior is prevention: senior engineers also:
- Leave Main Thread Checker on always
- Add Active Compilation Conditions to enable sanitizers in
Debugschemes - Write
assert()andprecondition()at API boundaries so bugs surface immediately at the failing call site, not 12 frames deeper - Use
os.Loggerwith categories so production bugs come with breadcrumbs
The toolchain is generous; most engineers use 10% of it. Decide to be the engineer who uses 80%.
TIP: Set a symbolic breakpoint on
UIViewAlertForUnsatisfiableConstraintsto stop the debugger every time AutoLayout has an unsatisfiable constraint. The console message that scrolls past at runtime becomes a pause-and-inspect moment. Single best AutoLayout debugging trick.
WARNING: Don’t ship
print()statements. They run in Release. They take time, allocate strings, and on iOS they’re written to OSLog without categories — they’re slow, noisy, and unstructured. Useos.Loggerfor anything that needs to be readable in production.
Interview corner
Question: “You’ve shipped an app and users report it crashes ‘sometimes’ on the profile screen. There’s no stack trace. How do you debug it?”
Junior answer: “I’d add print statements and ask QA to reproduce.” → Acceptable for a 1-person side project.
Mid-level answer: “First, check the crash logs in App Store Connect → Xcode Organizer → Crashes; symbolicate them. If the crash is reproducible, attach LLDB and step through. If it’s intermittent, enable Thread Sanitizer and Address Sanitizer in the Debug scheme — most ‘sometimes’ crashes are data races or use-after-free issues that sanitizers catch deterministically. If it’s a memory crash, run the Memory Graph Debugger to see what’s still alive.” → Strong.
Senior answer: Plus: “I’d also check whether the crash correlates with low-memory state — iOS sends applicationDidReceiveMemoryWarning and may terminate the app, which doesn’t generate a traditional crash report. I’d add breadcrumb logging via os.Logger with a category per feature so I have context from the minutes leading up to the crash. For ‘sometimes’ bugs that don’t crash but produce wrong behavior, I’d reach for conditional breakpoints with auto-continuing log actions — they essentially add tracing without rebuilds. And I’d structure my types defensively: preconditions at API boundaries push the failure as close to the bug source as possible, instead of crashing 12 stack frames deeper where the symptom appears.” → Senior signal: symptom vs cause, memory warnings as a class of “crash”, breadcrumb design, defensive APIs.
Red-flag answer: “I’d wrap everything in a do/catch so it doesn’t crash anymore.” → Tells the interviewer the candidate will hide bugs rather than fix them.
Lab preview
Lab 2.2 is a hands-on debugging gauntlet: a starter app with three deliberate bugs (one layout, one memory leak, one threading) that you’ll find using only Xcode’s debugging tools — no source-code reading allowed beyond the symptom.
Next: when bug-hunting graduates to perf-hunting — Instruments. → Instruments primer
2.6 — Instruments primer
Opening scenario
The app is shipping. QA reports: “The Feed scrolls smoothly on iPhone 15 Pro but stutters on iPhone 12 mini.” App Store reviews are starting to mention it. Your tech lead asks: “Can you confirm whether it’s CPU, GPU, or main-thread blocking — and pinpoint the function?”
You can answer “I think it’s the image decoder,” or you can answer with a flame graph, a CPU sample, and a 4-line function name. The second answer comes from Instruments.
Instruments is the perf-engineering tool that ships with Xcode. It’s a separate app (Xcode → Open Developer Tool → Instruments, or ⌘I to launch with the current build). It has ~30 templates, each focused on a kind of measurement. You’ll regularly use four of them.
The instruments you’ll actually use
| Template | Question it answers |
|---|---|
| Time Profiler | “Which functions are eating CPU?” |
| Allocations | “Where is memory growing?” |
| Leaks | “Which allocations are leaked (never freed)?” |
| Hangs | “Where is the main thread blocked?” |
| Animation Hitches | “Why are we dropping frames?” |
| Energy Log | “Why is the battery draining?” |
| Network Link Conditioner | (Not Instruments per se — but adjacent) “How does the app behave on a slow connection?” |
| App Launch | “Why does cold launch take 1.4 seconds?” |
| Core Data | “Which fetches are slow?” |
| File Activity | “Why is the app I/O-bound?” |
Concept → Why → How → Code
Time Profiler — the workhorse
The single most useful Instrument. Records the call stack of every thread at a fixed sampling interval (usually 1 ms) and aggregates them. The output:
- Call Tree view — Time spent in each function, hierarchically. Toggle “Invert Call Tree” to see leaf functions sorted by total time (where the work is actually done).
- Flame Graph view — visual stack-depth chart.
The workflow:
- ⌘I in Xcode → choose Time Profiler template
- Click record (red button), perform the slow action, click stop
- In the Call Tree, Hide System Libraries (sidebar) to drop noise from
libdispatch,Foundation, etc. - Sort by “Weight” descending
- The top entries are where time is going
For a typical scroll-stutter bug, you’ll see something like:
89% MyApp -[FeedCell drawRect:]
72% UIGraphicsImageRenderer.image
72% ImageDecoder.decode(_:)
65% data(contentsOf:) ← synchronous I/O on the main thread
Diagnosis in a single screen: image decode is synchronous and on the main thread. Fix: move decode to a background queue, cache decoded images.
Allocations — where does memory go
Records every allocation and deallocation. Useful views:
- All Heap & Anonymous VM — total memory over time
- Persistent Bytes — what’s still alive
- Mark Generation — record a “snapshot” before an action and after; the diff shows what was newly allocated. Excellent for finding “this screen leaks 2 MB every time I push it.”
The workflow for finding a transient memory spike:
- Start recording
- Mark generation (button: small flag icon) — call this baseline
- Perform the action (push screen, return)
- Mark generation again
- Inspect “Allocations between snapshots” — anything that should have been freed but wasn’t is your leak
Leaks — find what’s never freed
The Leaks instrument runs alongside Allocations and detects classic leaks: blocks of memory with no reference path from any root. In Swift with ARC, pure leaks are rare; cycle-based leaks are more common (and don’t always trigger Leaks — they may need the Memory Graph Debugger from Ch 2.5).
When Leaks fires, it gives you the allocation stack — the line of code that allocated the leaked object. Usually that’s enough to find the cycle.
Hangs — find what blocks the main thread
The Hangs instrument flags every span where the main thread was blocked > 250 ms. Drill in: you see the call stack at the moment of the hang. The fix is usually to move the work to a background queue.
In iOS 16+ and Swift Concurrency, hangs are also surfaced in Xcode’s Organizer → Hangs for shipped apps with hang-rate metrics from real devices.
Animation Hitches
For scroll smoothness specifically. Records when the GPU misses a frame deadline. Tells you whether the bottleneck is CPU prep, GPU draw, or main-thread blocking. The new tool for what used to be diagnosed with MetricKit + intuition.
App Launch
Launches your app with detailed timing of:
pre-main()— dynamic linking, ObjC runtime setupmain()to first frame — yourapplication(_:didFinishLaunchingWithOptions:)and initial view rendering
Goal: cold launch < 400 ms on a mid-tier device. Apple’s published guideline. App Store reviewers notice anything > 1 second.
The most common bottleneck: a startup work list — analytics SDK init, crash reporter init, push notification setup — all happening synchronously in application(_:didFinishLaunching…). Defer non-critical SDKs to after first paint.
Counters / os_signpost
To attribute time to your logical operations (not just method names), use os_signpost in your code:
import os
let signposter = OSSignposter(subsystem: "com.example.MyApp", category: "FeedLoad")
let interval = signposter.beginInterval("loadFeed", id: signposter.makeSignpostID())
await loadFeed()
signposter.endInterval("loadFeed", interval)
In Instruments → Points of Interest track, your loadFeed interval will appear as a colored band on the timeline. You can correlate it with CPU spikes, memory growth, network traffic — across all timelines simultaneously. Critical for understanding “what was happening when the frame dropped?”
Network Link Conditioner
Not part of Instruments — it’s a separate tool (download from developer.apple.com → Additional Downloads → “Additional Tools for Xcode” → install Network Link Conditioner). It throttles your Mac’s network to simulate 3G, Edge, lossy WiFi, etc. Run your app under “Edge” once a quarter — you’ll find loading states you didn’t know were absent and timeouts that are too aggressive.
On a device: Settings → Developer → Network Link Conditioner (appears after you’ve connected to Xcode with developer mode enabled).
In the wild
- Lyft’s iOS engineers measure cold launch with Instruments and
os_signpostinstrumentation, and gate releases on a launch budget (configurable per device class). - Apple’s WWDC sessions on perf (every year, multiple) demo the Time Profiler and
os_signpost. The 2023 “Analyze hangs with Instruments” session is a 25-minute crash course. - Robinhood’s iOS team uses MetricKit (shipped data from real users) + Instruments (local reproductions) as a complementary pair: MetricKit surfaces the symptom on real devices, Instruments reproduces it locally for fixing.
- NYT iOS publishes scroll-smoothness perf budgets internally and gates merges on Animation Hitches numbers.
Common misconceptions
-
“Profile in Debug builds.” No! Always profile in Release (Edit Scheme → Profile → Build Configuration: Release). Debug builds include optimization-disabled code, sanitizers, and debug symbols that make every function appear 5–10× slower. Your Time Profiler results will be lies.
-
“More cores → faster” is not a fix you can produce. Instruments shows you wall-clock time. If your work is single-threaded by design (UI updates on the main thread), buying more cores doesn’t help.
-
“Leaks instrument catches all memory leaks.” It catches the classic “no path from any root” leaks. It does not catch retain cycles where two objects retain each other but are unreachable from your code — Memory Graph Debugger catches those.
-
“Time Profiler tells you where the bug is.” It tells you where the time is. The bug may be that you’re calling that function too often, not that the function itself is slow. Always check both: “Is this function slow?” and “Why is it called 1000 times when 1 would do?”
-
“
os_signpostis for advanced users.” It’s for anyone who wants meaningful flame graphs. Add 5 signposts to your app on day one — login, feed load, image decode, push handler, scroll lifecycle. You’ll thank yourself the first time you debug a perf issue.
Seasoned engineer’s take
Always profile on the lowest-tier supported device. Your M3 MacBook running an iPhone 16 Simulator is not your audience. Your audience is the iPhone 12 mini with 4 GB of RAM, two upgrade cycles old. If the app is smooth there, it’s smooth everywhere. If you only profile on flagships, you’ll ship a stuttering app and not know it.
Set perf budgets per scenario. Cold launch < 400 ms. Feed first-frame < 300 ms. Image load on-screen → 100 ms. Tap → screen-push → 16 ms (one frame). Write them down in a PERF_BUDGETS.md. When you ship a feature, measure against the budget. You can’t manage what you don’t measure.
Wire os_signpost to your top 10 critical paths on day one. App startup, login, feed load, image cache hit/miss, push tap handling, deep link resolution, screen transitions. Then any perf investigation starts with “let me look at the existing signposts” — not “let me instrument this from scratch under pressure.”
TIP: Build an
OSSignposterhelper for measuring async functions in a single line:await signposter.withInterval("loadFeed") { await loadFeed() }. Two-line wrapper, used everywhere. Means you’ll actually add signposts instead of skipping them for “later.”
WARNING: Don’t profile in the iOS Simulator for CPU- or GPU-heavy work. The Simulator runs on your Mac’s CPU (which is much faster than any device); GPU translation is approximate. Always profile on a physical device for any perf claim you’ll act on.
Interview corner
Question: “How would you investigate a report of scroll stuttering in a feed?”
Junior answer: “I’d add print statements to the cell layout code and see what’s slow.” → Won’t pass — print doesn’t quantify.
Mid-level answer: “I’d profile in Instruments with the Time Profiler template on a physical lower-tier device, in a Release build. I’d start recording, scroll the feed for ~10 seconds, stop, and look at the call tree with system libraries hidden. The top functions by weight are the candidates. If image decoding is high, that points to synchronous decode; if drawRect: is high, that points to Core Graphics work happening per-cell; if layoutSubviews dominates, that’s AutoLayout. I’d cross-check with Animation Hitches to confirm the frames being missed correlate with the heavy work.” → Strong.
Senior answer: Plus: “I’d also instrument the scroll lifecycle with os_signpost so the timeline shows when each cell appears, which lets me correlate CPU spikes with specific cells. Beyond Instruments, I’d reach for MetricKit to confirm whether real users experience the same hitches — sometimes local repro is a Simulator-only artifact. Once I have a fix, I’d put a perf budget in CI: a script that fails the build if scroll hitches exceed a threshold on a known device baseline. Otherwise the regression will sneak back in 6 months later.” → Senior signal: production data + perf budgets in CI.
Red-flag answer: “I’d add a usleep(1000) to slow things down so it doesn’t look like it’s stuttering.” → That candidate is going to be the source of your perf regressions, not the solution.
Lab preview
Lab 2.3 gives you a starter app with a deliberate CPU hotspot and a deliberate memory leak. You’ll use Time Profiler + Allocations to find both, fix them, and confirm the fix in a second profiling run.
Next: the device that makes the bug reproduce — and the simulator that hides it. → Simulator vs device
2.7 — Simulator vs device
Opening scenario
You’re testing a new “tap to pay with Apple Pay” feature. It works perfectly in the iPhone 16 Pro Simulator. You ship to TestFlight. Beta testers report: “The pay button does nothing.” You check the Simulator again — still works. You’re confused.
The answer: Apple Pay’s NFC interaction doesn’t exist in the Simulator. The Simulator’s “successful payment” was the SDK’s mock path. On a real device, the SDK contacts the Secure Element, the Secure Element fails because there’s no Apple Pay configured, and the SDK’s real error path triggers — which your code never handled.
This kind of “works in Simulator, fails on device” bug accounts for a large fraction of post-TestFlight regressions. This chapter teaches you when to trust each.
What the Simulator is (and isn’t)
The Simulator runs your iOS app as a native macOS process with iOS frameworks loaded. It’s not a virtual machine — there’s no emulated CPU, no emulated GPU at the hardware level (it uses Apple’s Metal-on-the-Mac translation). This makes it:
- Fast — startup is near-instant; iteration is rapid
- Convenient — no cable, no provisioning
- Free — runs on any Mac
…and exactly because of these tradeoffs, it’s also:
- Unrepresentative of device perf (Mac CPU > device CPU; Mac memory ≫ device memory)
- Missing entire hardware subsystems (NFC, Bluetooth LE in limited form, true GPS, true camera, accelerometer is mocked, etc.)
- Behaviorally different in subtle ways (file system case-sensitivity, networking via the Mac’s stack, no real sandbox)
What works on Simulator vs Device
| Feature | Simulator | Device |
|---|---|---|
| UIKit / SwiftUI | ✅ Full | ✅ Full |
| Networking (URLSession) | ✅ via Mac | ✅ via cellular/WiFi |
| Core Data, SwiftData | ✅ | ✅ |
| Core Location | ⚠️ Mock locations only | ✅ Real GPS |
| Camera | ⚠️ Mock video / pick from photos | ✅ Real camera |
| Photo Library | ✅ (synthesized) | ✅ Real photos |
| Push Notifications | ✅ since Xcode 11.4 (drag .apns files) | ✅ |
| Apple Pay | ⚠️ Mock-only (no real Secure Element) | ✅ Real if configured |
| NFC (Core NFC) | ❌ Not available | ✅ |
| HealthKit | ❌ Not available (some types limited) | ✅ |
| HomeKit | ❌ Not available | ✅ |
| Bluetooth LE (CoreBluetooth) | ⚠️ Very limited | ✅ |
| Background tasks (BGTaskScheduler) | ⚠️ Triggerable via LLDB but not realistic | ✅ |
| ARKit | ❌ Not available | ✅ (A12+) |
| Metal performance | ⚠️ Translated to Mac GPU | ✅ Real |
| App Clips | ⚠️ Partial | ✅ |
| Sign in with Apple | ✅ | ✅ |
| In-App Purchase | ✅ with StoreKit configuration file | ✅ via sandbox |
| Universal Links (real) | ⚠️ Limited | ✅ |
| Memory pressure | ❌ Different from device | ✅ Real OOM kills |
The pattern: anything that touches specialized hardware (NFC, ARKit, real GPS, real camera, BLE radio, Secure Element) needs a device. Anything that touches realistic resource constraints (CPU, memory, battery) needs a device.
Concept → Why → How → Code
When to use the Simulator
- Daily development. The 99% case. Faster iteration, no cables, easy to launch multiple simulators side by side.
- UI iteration. SwiftUI Previews + Simulator covers most of it.
- Unit + UI test runs. CI runs these in the Simulator on macOS runners; it’s cheap.
- Multi-device testing of layout. Simulator → Window → Choose Device lets you tile iPhone SE, iPhone 16, iPad Pro side by side to confirm responsive layout.
When you need a device
- Anything touching the limitations table above.
- Performance work. Profile in Instruments on the lowest-tier supported device. Period.
- Background task testing. Real wake-ups, real time intervals.
- Network conditions. Real cellular signal, real lossy WiFi — supplement with Network Link Conditioner on the device.
- Battery / thermal testing. Sustained workloads on a real device reveal throttling behavior the Simulator can’t show.
- Pre-release smoke test. Always before TestFlight, every release.
Simulator features worth knowing
Simulating hardware events (Hardware menu / xcrun simctl CLI)
Device → Shake— triggersmotionEndedDevice → Rotate— orientation changesFeatures → Toggle In-Call Status Bar— test layout with the green call bar at topFeatures → Slow Animations— animations 10× slower; great for catching frame issuesFeatures → Capture Screen— saves PNG to Desktop
From the command line:
xcrun simctl list devices # list simulators
xcrun simctl boot "iPhone 16 Pro" # boot one
xcrun simctl install booted MyApp.app # install build
xcrun simctl launch booted com.example.MyApp
xcrun simctl push booted com.example.MyApp payload.apns # test push
xcrun simctl location booted set 37.7749 -122.4194 # mock location
CI scripts speak simctl natively; learning it pays off when wiring up automated tests.
Push notifications via APNS payload files
Drag any .apns file onto a Simulator window to deliver it as a push:
{
"aps": {
"alert": { "title": "Test", "body": "Hello" },
"sound": "default"
},
"Simulator Target Bundle": "com.example.MyApp"
}
Faster than real APNS for development.
StoreKit configuration files (in-app purchase testing)
File → New → File → StoreKit Configuration File. Define your products in a JSON-like editor; the Simulator (and device with StoreKit Configuration selected in the scheme) will return them from Product.products(for:) without hitting App Store Connect. Cuts IAP test iteration from minutes to seconds.
Working with physical devices
Provisioning, briefly
To run on a device you need:
- Apple Developer account (free for personal devices, $99/year for App Store)
- Code-signing identity (Xcode → Settings → Accounts → “Manage Certificates” — let Xcode auto-create)
- Provisioning profile — Xcode handles this automatically with “Automatically manage signing” in the target’s Signing & Capabilities tab
If you see “No matching provisioning profile found” you almost always need to:
- Confirm a unique bundle identifier (someone else may already use
com.example.MyApp) - Confirm the device is registered in the team (Settings → Accounts → Download Manual Profiles forces a refresh)
Trusting the developer cert on the device
First time you run a free-account build, the device shows “Untrusted Developer.” Go to Settings → General → VPN & Device Management → Developer App → trust your Apple ID. Once trusted, all builds from that account run.
Wireless debugging
Plug device into Mac once. Xcode → Window → Devices and Simulators → device → tick “Connect via network.” From then on, runs over WiFi. Slower than USB; convenient for testing while moving (location, motion).
Developer Mode (iOS 16+)
Settings → Privacy & Security → Developer Mode → toggle on, restart. Required for running any development build on iOS 16+. New device → ⌘R in Xcode → “Developer Mode required” prompt → enable, retry.
In the wild
- Apple’s WWDC sessions consistently demo Simulator features. The 2022 “What’s new in Xcode” walked through StoreKit configuration files; the 2020 session covered Simulator push.
- Spotify’s iOS team runs every PR’s CI on the Simulator (cheap, fast) but blocks merges with a separate nightly test pass on a physical device farm.
- The Lyft test infrastructure uses a custom device farm (Mac minis driving racks of iPhones) for any tests that need GPS, real maps, or real cellular conditions.
- MicroProfiler-style continuous device profiling is what fintech apps (Robinhood, Cash App) use to track perf regressions on a fleet of physical devices.
Common misconceptions
-
“If it works in the Simulator, it’ll work on the device.” Often, but not always. Hardware-touching code, perf-sensitive code, memory-pressure-sensitive code all need device confirmation.
-
“The Simulator is slower than the device.” Backward. The Simulator is faster — it runs on your Mac’s CPU. Don’t trust Simulator perf measurements.
-
“I can’t test push notifications without a developer-server setup.” You can — drag a
.apnspayload file onto the Simulator. Zero setup. -
“The Simulator file system is the same as iOS.” Mostly, but it’s case-insensitive by default (because macOS file systems usually are), while iOS is case-sensitive. A file named
Logo.pngreferenced aslogo.pngworks in the Simulator and fails on device. This is a classic and embarrassing bug. -
“Wireless debugging is unreliable.” It’s slower (build install is over WiFi) but stable once paired. The convenience of testing on-the-move outweighs the install delay for most workflows.
Seasoned engineer’s take
The seasoned approach: default to Simulator for development; mandate device for the release smoke test. Concretely:
- 95% of your day is in the Simulator. SwiftUI Previews + Simulator covers UI work, business logic, networking.
- Before pushing a PR that touches: launch behavior, perf, memory, hardware-touching code → test on device first.
- Before a TestFlight build → smoke test on at least two device classes (high-tier, low-tier; e.g., iPhone 16 Pro + iPhone 12 mini).
- Before App Store submission → full smoke test on the lowest-tier supported device. The reviewer might have one.
For teams: invest in a small device library. Five physical devices spanning two years of releases covers 95% of the install base. Anyone on the team can grab one for an afternoon’s testing.
Don’t fall for “we can’t justify a device farm” if your CI runs on Simulators only. The cost is two missed regressions per quarter; the budget is one Mac mini and three retired iPhones.
TIP: Add a
#if targetEnvironment(simulator)guard around code paths that cannot work in the Simulator (NFC, HealthKit) so they fail gracefully with a clear message instead of crashing or appearing silently broken. Saves the “is it bug or limitation” question every time.
WARNING: Memory limits are dramatically different. The iOS Simulator can use ~tens of GB before crashing; an iPhone with 4 GB of physical RAM will jettison your app at ~1.5 GB usage. Always run the Memory Graph Debugger on device for memory-sensitive features.
Interview corner
Question: “What’s the difference between testing in the Simulator and on a device?”
Junior answer: “The Simulator is a virtual iPhone; the device is real.” → True but won’t get past the first follow-up.
Mid-level answer: “The Simulator is fast and convenient for daily UI/business-logic work, but several iOS subsystems are absent or partial — Core NFC, HealthKit, HomeKit, real Core Location, real Camera, ARKit. Performance characteristics also differ significantly — the Simulator runs on the Mac’s CPU and shouldn’t be trusted for perf measurements. For perf, memory, and hardware-touching features I always test on the lowest-tier supported physical device.” → Strong.
Senior answer: Plus: “I’d also flag memory pressure: the Simulator has effectively unlimited RAM, so OOM jetsam behavior — which kills your app when iOS reclaims memory — never reproduces locally. Same for thermal throttling: a real device under sustained load downclocks; the Mac doesn’t. And background execution windows are real and tight on device (~30 seconds for a background task; the Simulator can fake this but the timing isn’t accurate). My team’s policy: any PR touching launch, memory, or hardware needs a device confirmation comment in the PR. Any release gets a Smoke Test pass on two device classes.” → Senior signal: thinks about jetsam, thermal, background windows, team policy.
Red-flag answer: “I only test on my own iPhone 16 Pro.” → They’ll ship an app that crashes on every iPhone from before 2022.
Lab preview
The labs in this phase (Lab 2.1, Lab 2.2, Lab 2.3) call out which steps require a device — most don’t, but Lab 2.3’s perf work is more meaningful on hardware.
Next: managing the Xcode versions themselves — the SDK calendar that controls the App Store. → Xcode version management & cloud Macs
2.8 — Xcode version management & cloud Macs
Opening scenario
Apple’s WWDC announcement: “Starting April 2026, all new App Store submissions must be built with Xcode 17.” Your team is currently on Xcode 16.2. You have:
- Three apps in active development on three different Xcode versions
- A CI pipeline that runs on Xcode 16.2
- One Mac mini in the office for archiving
- A consultant who only has an Intel Mac (no Apple Silicon)
- A junior dev with a personal Apple Silicon MacBook Air who needs to contribute
You also have a hard rule, learned from past experience: “Never upgrade the Xcode on your primary Mac casually.” Last year you upgraded to a new Xcode mid-sprint, hit a regression in URLSession, and lost a day rolling back.
This chapter is the survival guide for the messy reality of multi-Xcode-version development, plus the cloud-Mac options when local hardware isn’t enough.
The annual Xcode calendar
Apple’s pattern is predictable:
| When | What |
|---|---|
| June (WWDC) | Xcode N+1 beta announced |
| September | New iOS / iPadOS / macOS / watchOS / tvOS GA; Xcode N+1 ships |
| ~November | Apple announces an SDK deadline — typically “April of next year, all App Store submissions must use the latest SDK” |
| April (following year) | SDK deadline — submissions on the previous major Xcode start being rejected |
You have roughly 6 months from GA to mandatory adoption. Plan migrations accordingly.
Managing multiple Xcode versions
The xcodes CLI tool (third-party, essential)
Install via Homebrew:
brew install xcodes
Use:
xcodes list # all available versions
xcodes installed # what's on this machine
xcodes install 16.2 # download + install (asks for Apple ID)
xcodes select 16.2 # set as active for xcrun
xcodes select 15.4 # switch back
xcodes uninstall 14.3 # reclaim 15+ GB
xcodes downloads from Apple’s “More Downloads” portal, not the App Store, so it doesn’t get stuck on the App Store auto-update. Each Xcode lives in /Applications/Xcode-16.2.app (renamed by the tool).
xcode-select — the underlying mechanism
xcodes select is a wrapper over sudo xcode-select -s:
xcode-select -p # which Xcode is currently active
sudo xcode-select -s /Applications/Xcode-16.2.app
Whichever app is set as active is what xcrun, xcodebuild, and command-line swift will use. The GUI Xcode you opened might be a different version! Pay attention to this — it’s a common source of “works in Xcode, fails in CI” bugs.
The DEVELOPER_DIR environment variable
For one-off commands without changing the global setting:
DEVELOPER_DIR=/Applications/Xcode-16.2.app/Contents/Developer xcodebuild -version
CI scripts use this pattern to pin a specific Xcode for a job, regardless of what the runner’s default is.
The .xcode-version file (convention)
A plain text file at the repo root containing the version string:
16.2
Tools like xcodes and fastlane read this to enforce the project’s expected Xcode. Pair it with a script that errors out if the active Xcode doesn’t match:
EXPECTED=$(cat .xcode-version)
ACTUAL=$(xcodebuild -version | head -1 | awk '{print $2}')
if [[ "$ACTUAL" != "$EXPECTED"* ]]; then
echo "❌ Expected Xcode $EXPECTED, found $ACTUAL"
exit 1
fi
Add this to a git pre-push hook or the start of your CI script. Saves the “I built with the wrong Xcode” embarrassment.
Swift toolchains
A toolchain is a Swift compiler + standard library + tools. Xcode ships with one bundled. You can install additional toolchains (e.g., the in-development Swift main snapshot) without changing Xcode itself:
- Download from swift.org/install/macos
- Install — appears under
~/Library/Developer/Toolchains/ - Xcode → Toolchains menu → select the new one
Useful for trying upcoming Swift features (e.g., preview macros, evolving concurrency proposals) without disrupting your normal Xcode workflow.
The “never upgrade your local Mac” rule
After enough Xcode regressions, you’ll arrive at this rule yourself:
Your primary working Mac should run an Xcode version known to build, test, and archive every project you maintain. You should not upgrade it casually. Upgrades go through a dedicated test pass first.
The practical setup:
- Daily-driver Mac: stays on Xcode N (current stable). Maintain the working setup.
- Test machine (a second Mac, a VM, or a cloud Mac): install Xcode N+1 betas, test your project, document breakages, plan migration. Do not switch daily-driver until you’ve cleared the breakages list.
- CI runners: pinned per-project via
DEVELOPER_DIR. Each project bumps its CI Xcode independently when ready.
This isolation costs you a second Mac (or its equivalent). The cost of not having it: a mid-sprint upgrade regression that costs the team a day, plus rollback work. Pay the hardware cost.
Cloud Macs (when local isn’t enough)
You may need cloud Macs when:
- You don’t own a Mac at all (Linux/Windows shop with one iOS deliverable)
- You’re on Intel and need Apple Silicon (Xcode 16+ macOS hosts must be Apple Silicon for new SDKs)
- You need a fleet for CI and don’t want to manage hardware
Options (as of 2026)
| Provider | Hardware | Billing | Notes |
|---|---|---|---|
| GitHub Actions macOS | M-series (macos-14, macos-15) | per-minute, ~10× Linux | Best for CI; free quota for public repos |
| AWS EC2 Mac | Mac mini M2 dedicated | 24-hour minimum billing per dedicated host | Powerful but the 24-hour minimum is brutal for short jobs |
| MacStadium | Mac mini & Mac Pro, hourly | hourly or monthly dedicated | Long-time iOS-team standard; per-hour with no 24-hr trap |
| Hetzner Mac mini | Mac mini M-series | monthly | Lowest cost-per-month for a dedicated cloud Mac |
| Scaleway Apple Silicon | Mac mini M-series | hourly | EU-based; per-hour billing |
| MacinCloud | Various | hourly + monthly | Older infra, simpler UX, decent for occasional use |
| MacWeb / MacCloud | Various | various | Other small operators — quality varies |
The AWS EC2 Mac 24-hour billing minimum is the most cited gotcha. Quoting AWS: “You’re billed for the entire allocation duration, with a 24-hour minimum allocation.” That makes EC2 Mac unsuitable for a “run a 10-minute CI job and tear down” pattern. For that use GitHub Actions or MacStadium.
Virtualization on Apple Silicon — UTM, Parallels, Virtualization.framework
You can run macOS-in-macOS virtual machines on Apple Silicon (since macOS 12). Tools:
- UTM — free, open source, easy
- Parallels — commercial, polished
- Tart — open-source, container-style macOS VMs, popular in CI setups
- Apple’s
Virtualization.framework— what UTM/Tart use under the hood; you can also script directly
Limitations:
- macOS license: Apple limits to 2 concurrent macOS VMs per Mac host (per the macOS license agreement). Running more is a license violation, not a technical limit.
- You can run macOS guests but not iOS guests — iOS is not virtualization-licensed.
- A VM provides isolation but not a different Apple Silicon SKU — performance is bounded by the host.
For a small team, a single Mac Studio running 2 macOS VMs (one stable Xcode, one beta Xcode) covers most multi-version needs.
CI: what the modern setup looks like
Typical iOS CI today:
# .github/workflows/ci.yml
jobs:
test:
runs-on: macos-15 # GitHub-hosted Apple Silicon
steps:
- uses: actions/checkout@v4
- name: Pin Xcode
run: sudo xcode-select -s /Applications/Xcode_16.2.app
- name: Build & Test
run: xcodebuild test -workspace MyApp.xcworkspace \
-scheme MyApp \
-destination 'platform=iOS Simulator,name=iPhone 16'
For releases:
- TestFlight upload via
fastlane pilotorxcrun altool - Code signing via App Store Connect API keys (modern), not certificates pushed to runners
For perf testing on real devices: a self-hosted runner connected to a USB device. GitHub Actions supports this; the device needs Mac mini hosting.
In the wild
- Lyft, Airbnb, Slack all run multi-Xcode CI with project-level
.xcode-versionfiles andDEVELOPER_DIRpinning. Each project migrates independently. - The Swift open-source project itself uses
swift-cirunning on a fleet of physical Mac minis at Apple, with multi-toolchain matrix testing. - Many fintech & healthcare iOS teams run all CI on MacStadium dedicated Mac minis because compliance audits prefer dedicated hardware over shared GitHub runners.
- Solo developers and small consultancies often standardize on Hetzner Mac mini for the cost — a dedicated M-series mini for a fraction of the AWS price, no 24-hour billing trap.
Common misconceptions
-
“I should always be on the latest Xcode.” False. Wait until you’ve intentionally tested the upgrade — and even then, only on a non-critical machine first. Latest is often where the bugs are.
-
“
xcode-select -sis enough to switch Xcode.” It’s enough for command-line tools (xcrun,xcodebuild). The Xcode GUI app is separate — you can have multiple installed and Open from/Applications/Xcode-X.Y.app. -
“AWS EC2 Mac is cheap if I just spin up for a CI run.” No — 24-hour minimum billing per allocation. A 10-minute job costs you 24 hours of EC2 Mac time. Use GitHub Actions or MacStadium for short jobs.
-
“I need a separate Mac per Xcode version.” Not necessarily —
xcodeslets you install many on one Mac. Disk space (~15 GB per Xcode) is the constraint. Two or three Xcodes on one Mac is normal. -
“Swift toolchain updates = Xcode update.” Different things. Toolchains are independent; you can run a Swift 6.1 toolchain inside Xcode 16.2 to try language features without changing the IDE.
Seasoned engineer’s take
The Xcode version management discipline is one of those things that separates “writes iOS apps” from “ships iOS apps reliably.” The rules I follow:
- Pin the Xcode version per project with
.xcode-versionand a CI guard. - Don’t update Xcode on the main Mac without a dedicated test pass on a secondary.
- Always have a known-good archive Mac — a Mac mini in the office, on the office network, that you can plug into for archive-and-submit. Its Xcode does not change without team consensus.
- Have a plan for the SDK deadline. Each November, look at the announced April deadline. Calendar it. Don’t be the team that scrambles in March.
- For CI, prefer GitHub Actions macOS for cost and convenience. Use dedicated cloud Macs (MacStadium, Hetzner) only when audit requirements or workload patterns make GitHub Actions impractical.
TIP: Keep a
SETUP.mdin every iOS repo documenting: required Xcode version, required Ruby/CocoaPods/SwiftLint versions, the bootstrap script. New hire ramp-up time drops from 2 days to 2 hours.
WARNING: Don’t enable App Store auto-update for Xcode. It will mid-day download a 15-GB update during your lunch break, then prompt you to restart Xcode and lose unsaved state. App Store → Settings → uncheck “Automatic Updates” for Xcode. Use
xcodesinstead.
Interview corner
Question: “How does your team handle Xcode version updates?”
Junior answer: “We update when Xcode tells us to.” → Brittle. They’ll never get to senior with that.
Mid-level answer: “We pin the Xcode version per project with a .xcode-version file and a CI guard that fails the build if the wrong Xcode is active. We update intentionally, usually after testing the new version on a side branch for at least a sprint. Each engineer can have multiple Xcodes installed via the xcodes CLI and switch with xcode-select -s or xcodes select.” → Strong.
Senior answer: Plus: “We track Apple’s annual SDK deadline (announced in November for the following April) and plan the migration in February at the latest, so we have a 2-month buffer for regression cleanup. CI runs on a matrix of two Xcode versions during the migration window — current stable and target — so we catch breakages on the target before flipping the default. For dedicated hardware, we maintain an in-office Mac mini as the canonical ‘archive Mac’ that does not get its Xcode upgraded without team sign-off — that machine is what produces App Store builds, and stability matters more than newness. Locally, no engineer’s primary Mac upgrades Xcode without first testing on a secondary machine or VM.” → Senior signal: SDK calendar awareness, dedicated archive infra, upgrade discipline.
Red-flag answer: “We always run the latest Xcode on every machine, no exceptions.” → That team eats a week of lost work to every Xcode regression.
Lab preview
There’s no dedicated lab for this chapter — but in Lab 2.1 you’ll add the .xcode-version file and the CI guard described above. A 5-minute habit that pays off forever.
Next: Apple’s own answer to the cloud-Mac CI question. → Xcode Cloud intro
2.9 — Xcode Cloud intro
Opening scenario
You’re shipping a side project and need CI. Options:
- Set up GitHub Actions macOS runners — works, ~$0.08/minute after free quota
- Buy a Mac mini — $600 + maintenance + no remote-team access
- Use Xcode Cloud — Apple’s native CI/CD, free up to 25 compute hours/month, integrated with App Store Connect and TestFlight
For a small project or a one-person shop, Xcode Cloud often wins. It’s not the best choice for everything (we’ll cover when it’s not), but it’s the easiest path from “code in GitHub” to “build in TestFlight.”
This chapter is a primer — what Xcode Cloud is, its limits, when to choose it, and how it fits into the deployment story you’ll build out in later phases.
What Xcode Cloud is
Apple’s hosted CI/CD service for iOS/macOS/watchOS/tvOS/visionOS apps. Announced WWDC 2021, GA mid-2022. Built into Xcode and App Store Connect — you configure workflows entirely in Xcode (no YAML to write).
The core building blocks:
| Term | Meaning |
|---|---|
| Workflow | A set of triggers + actions (build, test, archive, deploy) |
| Start condition | When the workflow runs (PR opened, branch pushed, tag created, schedule) |
| Action | What the workflow does (build, test, analyze, archive) |
| Post-action | What happens after success/failure (notify Slack, deploy to TestFlight, publish to App Store) |
| Environment | Xcode version + macOS version pinning |
A typical “PR workflow”:
- Start condition: pull request opened against
main - Action: build + run tests on iOS 17 / iPhone 16 Simulator
- Post-action: post status to GitHub PR
A typical “release workflow”:
- Start condition: tag matching
v*.*.*pushed - Action: archive for iOS
- Post-action: upload to TestFlight (Internal Testers group)
What you don’t write
You don’t write a YAML pipeline file. You don’t manage runners. You don’t manage code-signing certificates manually — Xcode Cloud handles that via App Store Connect API. You don’t manage Xcode upgrades on the runner — Apple manages images.
For people coming from GitHub Actions or CircleCI, this is striking. It’s also the principal critique — see below.
Pricing & free tier
| Tier | Compute hours/month | Cost |
|---|---|---|
| Free | 25 | $0 (included with Apple Developer membership) |
| Paid tiers | 100 / 250 / 1000 | ~$50 / ~$100 / ~$400 (verify current pricing in App Store Connect) |
A “compute hour” is wall-clock time on the build machine. A 10-minute PR build = 0.17 compute hours. 25 hours = roughly 150 PR builds per month.
For a solo dev or small open-source project, 25 hours is more than enough. For a 5-person team merging 30 PRs/day, you’ll burn through the free tier in three days.
Concept → Why → How → Code
Setting it up (the first 10 minutes)
- In Xcode → Report Navigator (⌘9) → bottom-left “Xcode Cloud” → Create Workflow
- Authenticate with your Apple ID; pick the project and primary repository
- Connect the repo:
- App Store Connect → Apps → Your App → Xcode Cloud → Settings → Connect repository
- Authorize the GitHub/GitLab/Bitbucket integration
- Define your first workflow:
- Start condition: “Branch changes” →
main - Environment: latest Xcode + latest macOS
- Actions: Build → iOS, Test → iOS Simulator
- Post-actions: (none for now)
- Start condition: “Branch changes” →
- Save → Xcode triggers a first build immediately
That’s it. No .yml, no Procfile, no runner config. The first build will fail (always does) on a code-signing question; click through the Xcode Cloud setup wizard to grant the right App Store Connect access.
Custom scripts (ci_scripts/)
The escape hatch when you need to do something non-standard: a ci_scripts/ folder at the repo root with shell scripts that Xcode Cloud runs at well-defined hooks:
ci_scripts/
├── ci_post_clone.sh # after git clone, before build
├── ci_pre_xcodebuild.sh # right before xcodebuild
├── ci_post_xcodebuild.sh # right after xcodebuild
Example ci_post_clone.sh:
#!/usr/bin/env bash
set -e
# Install dependencies that Apple's image doesn't have
brew install swiftlint
# Run lint before build
swiftlint --strict
These hooks let you wire SwiftLint, SwiftFormat, code generation, secret injection from environment variables, etc.
Environment variables & secrets
App Store Connect → Xcode Cloud → Settings → Environment Variables. Set keys/values, optionally marked “secret” (not echoed in logs). Available in ci_scripts/ as regular env vars.
Use for: API keys for third-party services (Sentry, Firebase), backend URLs, license keys.
TestFlight integration (the killer feature)
Add a post-action: TestFlight Internal Testing group. On every successful archive triggered by a tag (e.g., v1.2.3), the build appears in TestFlight for internal testers within ~15 minutes. No fastlane, no API tokens to wrangle, no xcrun altool invocations.
This is the one thing Xcode Cloud does better than every alternative: the integration with App Store Connect is first-party and seamless. For TestFlight workflows specifically, even big iOS teams sometimes use Xcode Cloud only for that last step while running normal CI elsewhere.
When Xcode Cloud wins
- Solo developer or 2–3 person team
- Small project, < 25 compute hours/month
- TestFlight pipeline is the primary CI deliverable
- You don’t want to maintain CI infrastructure
- You want first-party Apple integration
When Xcode Cloud loses
- Team needs > 25 compute hours/month but doesn’t want the next pricing tier
- Workflow needs heavy custom logic (multi-repo builds, monorepo with non-iOS components)
- Want to integrate with existing tools that have rich GitHub Actions integrations (Slack notifications, deploy to multiple platforms in one pipeline)
- Need self-hosted runners (e.g., for on-device perf tests)
- Want to keep CI portable in case you ever migrate off Apple’s tooling
- Need very fast CI for big teams — GitHub Actions or self-hosted is usually cheaper and more flexible at scale
The pattern most mid-size iOS teams settle on: GitHub Actions for PR CI; Xcode Cloud (or fastlane) for TestFlight uploads. Best of both worlds.
How it fits the deployment story
This book has a phase dedicated to deployment (covered in detail in Phase 10). For now, the mental map:
Developer pushes commit
│
▼
PR opened ─────────────────► PR CI runs (GitHub Actions or Xcode Cloud)
│ ├─ Build
│ ├─ Test
│ └─ Status reported to PR
▼
PR merged to main
│
▼
Tag pushed (v1.2.3) ───────► Release pipeline runs (Xcode Cloud common here)
├─ Archive
├─ Upload to App Store Connect
└─ Distribute to TestFlight Internal Testers
│
▼
QA approves, promote to External Testers
│
▼
Submit for App Store Review
Xcode Cloud handles the right half (archive → TestFlight → App Store) with minimal config. The left half (PR CI) is also possible in Xcode Cloud, but other tools often serve better at scale.
In the wild
- Solo iOS apps on App Store — many use Xcode Cloud free tier exclusively. Marco Arment publicly switched Overcast to Xcode Cloud and wrote about the simplification.
- Apple’s own sample apps & frameworks dogfood Xcode Cloud in their internal CI.
- WWDC sessions (each year since 2021) showcase incremental improvements — multi-platform workflows, custom Mac sizes, more environment variables.
- Mid-size iOS shops (Calm, Headspace, Strava) often use GitHub Actions for PR CI and Xcode Cloud for the final TestFlight/App Store push — the hybrid pattern.
Common misconceptions
-
“Xcode Cloud is just an Apple-flavored Jenkins.” No — it’s deeply integrated with App Store Connect. The killer feature isn’t running builds, it’s the seamless TestFlight/App Store upload with no certificate juggling.
-
“25 hours/month is enough for any team.” For a solo developer, yes. For a 5-person team with active PR CI, no — you’ll exhaust it in days.
-
“I can’t customize Xcode Cloud builds.” You can —
ci_scripts/ci_post_clone.shand friends let you run arbitrary shell. Just less flexible than full GitHub Actions YAML. -
“Xcode Cloud requires a Mac to use.” You configure workflows in Xcode (which requires a Mac), but the builds run in Apple’s cloud. Once configured, your team’s Linux developers can trigger workflows via App Store Connect web UI.
-
“Xcode Cloud replaces fastlane.” Partially — it replaces fastlane’s upload steps cleanly. But fastlane’s snapshot, scan, match, deliver, supply (Android), and a hundred other actions still have no Xcode Cloud equivalent. Big teams use both.
Seasoned engineer’s take
For a side project, a portfolio app, or a 1–2 person startup: Xcode Cloud is the right answer. The free tier is generous, setup is 10 minutes, and the TestFlight integration is unmatched. Use it.
For a 5+ person team: use GitHub Actions for PR CI (cheaper, more flexible, better third-party integrations) and Xcode Cloud (or fastlane) for the release pipeline. Don’t try to do everything in Xcode Cloud — you’ll hit the cost cliff or the flexibility ceiling.
For enterprise / compliance-heavy environments: dedicated cloud Macs (MacStadium) or self-hosted runners. Xcode Cloud’s audit story is acceptable but limited compared to dedicated infrastructure with full log access.
The bet I’d take on Xcode Cloud’s trajectory: Apple will keep tightening the App Store Connect integration (likely adding more first-party post-actions, deeper StoreKit / TestFlight features, maybe even partial App Review automation). It will remain weaker than GitHub Actions for general-purpose CI but stronger for the App Store-specific deploy path. Plan accordingly.
TIP: Even if your team’s primary CI is GitHub Actions, set up a minimal Xcode Cloud workflow for TestFlight uploads on tag pushes. It’s 15 minutes of setup and removes an entire category of “the upload script broke again” tickets.
WARNING: Watch your compute hour usage in App Store Connect → Xcode Cloud → Usage. Going over the free tier without realizing it auto-upgrades to the next paid tier. Set a calendar reminder to check usage weekly until you know your team’s baseline.
Interview corner
Question: “How would you set up CI/CD for a new iOS project?”
Junior answer: “Use Xcode Cloud, it’s free.” → Not wrong for a small project, but doesn’t show breadth.
Mid-level answer: “It depends on the team size and complexity. For a solo project, Xcode Cloud — free tier, integrated TestFlight, minimal setup. For a team project, GitHub Actions for PR CI (build + test) and either Xcode Cloud or fastlane for the TestFlight / App Store release path. Code signing via App Store Connect API keys, not certs in repo. PRs blocked on green CI.” → Strong.
Senior answer: Plus: “I’d also think about what gets tested where: cheap fast tests (unit, lint, SwiftFormat) on every PR in GitHub Actions; expensive tests (UI tests, performance baselines) on a nightly schedule possibly on dedicated cloud Macs; smoke tests on physical devices either via a self-hosted runner or manually pre-release. For the release pipeline, I’d use tag-triggered Xcode Cloud workflows to upload to TestFlight Internal first, then a manual promotion to External after QA sign-off, then a separate manual submission to the App Store. Each environment (Dev / Staging / Prod) gets its own workflow. And I’d document the entire flow in the repo’s CONTRIBUTING.md so new hires don’t have to reverse-engineer it.” → Senior signal: test tiering, manual gates, documentation.
Red-flag answer: “We push directly to main and TestFlight auto-uploads.” → No PR review, no CI gating — the whole point of CI/CD missed.
Lab preview
There’s no dedicated lab for Xcode Cloud in this phase — it’s a multi-day setup that depends on App Store Connect access. We’ll wire up TestFlight in Phase 10 (Deployment & distribution).
Phase 2 wrap-up
You’ve now covered the full Xcode mastery stack:
- The interface (2.1)
- Projects, workspaces, targets, schemes (2.2)
- Build settings & configurations (2.3)
- Shortcuts and editor tricks (2.4)
- Debugging with LLDB, View Debugger, Memory Graph (2.5)
- Instruments for performance (2.6)
- Simulator vs device tradeoffs (2.7)
- Xcode version management & cloud Macs (2.8)
- Xcode Cloud intro (2.9)
The labs that follow let you put this into practice on a real codebase:
Next: Lab 2.1 — Multi-target project setup
Lab 2.1 — Multi-target project setup
Duration: ~90 minutes Difficulty: Intermediate Prereqs: Phase 1 complete; Xcode 16+, Apple Developer account (free tier OK)
Goal
Build a real iOS app with multiple targets (main app + widget extension + macOS Catalyst), three build configurations (Debug / Release-Staging / Release) backed by xcconfig files, and three schemes that select the right configuration. By the end, you’ll have a project where adding a fourth environment is a 10-minute task and switching between Dev / Staging / Prod backends is a scheme picker click away.
What you’ll build
App name: LabTwoOne — a tiny note-taking app
- iOS app (the main target)
- iOS Widget Extension (shows latest note on Home Screen)
- macOS Catalyst variant
- Unit test target
Three build configurations (Debug, Release-Staging, Release), each pointing at a different “backend URL” (we’ll just print it — no real backend). Three schemes wire each configuration into a runnable build.
Steps
Step 1 — Create the base project (5 min)
- Xcode → File → New → Project → iOS → App
- Product Name:
LabTwoOne - Interface: SwiftUI, Language: Swift, Storage: None
- Save to a folder of your choice
- Run ⌘R; confirm the default app launches
Step 2 — Create the xcconfig files (15 min)
- In the project navigator, right-click the project → New Group → name it
Config - Right-click
Config→ New File → iOS → Other → Configuration Settings File - Create three files (each via the same dialog):
Shared.xcconfigDebug.xcconfigReleaseStaging.xcconfigRelease.xcconfig
Contents:
Shared.xcconfig:
SWIFT_VERSION = 6.0
IPHONEOS_DEPLOYMENT_TARGET = 17.0
MARKETING_VERSION = 1.0.0
CURRENT_PROJECT_VERSION = 1
PRODUCT_BUNDLE_IDENTIFIER = com.yourname.LabTwoOne$(BUNDLE_ID_SUFFIX)
Debug.xcconfig:
#include "Shared.xcconfig"
BUNDLE_ID_SUFFIX = .dev
API_BASE_URL = https:/$()/api.dev.example.com
SWIFT_OPTIMIZATION_LEVEL = -Onone
ReleaseStaging.xcconfig:
#include "Shared.xcconfig"
BUNDLE_ID_SUFFIX = .staging
API_BASE_URL = https:/$()/api.staging.example.com
SWIFT_OPTIMIZATION_LEVEL = -O
SWIFT_ACTIVE_COMPILATION_CONDITIONS = $(inherited) STAGING
Release.xcconfig:
#include "Shared.xcconfig"
BUNDLE_ID_SUFFIX =
API_BASE_URL = https:/$()/api.example.com
SWIFT_OPTIMIZATION_LEVEL = -O
Step 3 — Add the Release-Staging configuration (5 min)
- Click the project icon at the top of the project navigator
- Select the project (not the target) in the panel
- Info tab → Configurations section
- Click
+→ Duplicate “Release” Configuration → name itRelease-Staging
You should now see three configurations: Debug, Release, Release-Staging.
Step 4 — Wire the xcconfig files to configurations (5 min)
Still in Info → Configurations, for each configuration row, expand it and set:
| Configuration | Based on Configuration File (Project level) |
|---|---|
Debug | Debug.xcconfig |
Release-Staging | ReleaseStaging.xcconfig |
Release | Release.xcconfig |
Build (⌘B). If you see “build setting BUNDLE_ID_SUFFIX is undefined” warnings, you mistyped a key. Fix and rebuild.
Step 5 — Plumb API_BASE_URL into Info.plist (10 min)
- Open the auto-generated
Infosettings (target → Info tab — Xcode 13+ stores these in target settings, not a separateInfo.plistfile) - Add a custom key:
- Key:
APIBaseURL - Type: String
- Value:
$(API_BASE_URL)
- Key:
- Create
LabTwoOne/AppEnvironment.swift:
import Foundation
enum AppEnvironment {
static let apiBaseURL: URL = {
guard let raw = Bundle.main.object(forInfoDictionaryKey: "APIBaseURL") as? String,
let url = URL(string: raw) else {
fatalError("APIBaseURL missing or invalid in Info.plist")
}
return url
}()
static var isStaging: Bool {
#if STAGING
return true
#else
return false
#endif
}
}
- Edit
ContentView.swiftto display the values:
struct ContentView: View {
var body: some View {
VStack(spacing: 12) {
Text("LabTwoOne")
.font(.title)
Text("API: \(AppEnvironment.apiBaseURL.absoluteString)")
.font(.caption)
if AppEnvironment.isStaging {
Text("⚠️ STAGING")
.font(.caption.bold())
.foregroundStyle(.orange)
}
}
.padding()
}
}
- Build (⌘B); run (⌘R). You should see the Debug API URL.
Step 6 — Create the Staging scheme (10 min)
- Product → Scheme → Manage Schemes
- Select the existing
LabTwoOnescheme → click the duplicate icon (or right-click → Duplicate) - Name the new one
LabTwoOne (Staging) - Tick “Shared” for both schemes
- With the new scheme selected, click “Edit…”
- For each of Run, Test, Profile, Analyze, Archive:
- Set “Build Configuration” →
Release-Staging
- Set “Build Configuration” →
- Close the editor
Switch the scheme picker (top-left of Xcode toolbar) to LabTwoOne (Staging) and run. You should see the staging URL and the orange ⚠️ STAGING label.
Switch back to LabTwoOne and run — back to the dev URL, no label.
✅ Checkpoint: scheme-driven environment switching is working.
Step 7 — Add a Widget Extension target (15 min)
- File → New → Target → iOS → Widget Extension
- Product Name:
LabTwoOneWidget - Include Configuration Intent: NO (keep it simple)
- Embed in Application:
LabTwoOne - Activate the new scheme when prompted
Xcode generates a starter widget. Run the LabTwoOneWidget scheme; choose a Simulator → after build, the Home Screen appears with the widget gallery available.
Now wire the widget to the same AppEnvironment:
- Click
AppEnvironment.swiftin the project navigator - File Inspector (⌥⌘1) → Target Membership → tick
LabTwoOneWidget - The widget target now has the same xcconfig-driven environment access
Add to the widget’s LabTwoOneWidgetEntryView:
Text("API: \(AppEnvironment.apiBaseURL.host() ?? "?")")
.font(.caption2)
Run the widget scheme; confirm the host appears.
Step 8 — Add a macOS Catalyst target (10 min)
- Click the project icon → select the
LabTwoOnetarget - General tab → Supported Destinations section → click
+→ choose Mac (Designed for iPad) or Mac Catalyst (choose Mac Catalyst for a deeper Mac feel) - Confirm the prompt
- The scheme’s destination picker now shows “My Mac (Mac Catalyst)”
- Run on Mac Catalyst → confirm the app launches as a native Mac window
Step 9 — Add the .xcode-version file + Xcode pin guard (10 min)
- Open Terminal in the project root
echo "16.2" > .xcode-version(use your actual Xcode version:xcodebuild -version | head -1 | awk '{print $2}')- Create
scripts/check-xcode-version.sh:
#!/usr/bin/env bash
set -e
EXPECTED=$(cat .xcode-version)
ACTUAL=$(xcodebuild -version | head -1 | awk '{print $2}')
if [[ ! "$ACTUAL" == "$EXPECTED"* ]]; then
echo "❌ Expected Xcode $EXPECTED, found $ACTUAL"
echo " Switch with: sudo xcode-select -s /Applications/Xcode-$EXPECTED.app"
exit 1
fi
echo "✅ Xcode $ACTUAL matches expected $EXPECTED"
chmod +x scripts/check-xcode-version.sh- Run it:
./scripts/check-xcode-version.sh→ should print ✅
Add the script as a Run Script Build Phase on the main target:
- Target → Build Phases →
+→ New Run Script Phase - Drag the new phase to the top (above Compile Sources)
- Script body:
"${SRCROOT}/scripts/check-xcode-version.sh" - Add
${SRCROOT}/.xcode-versionto Input Files so Xcode caches the result
Now every build verifies the Xcode version.
Step 10 — Verify everything (10 min)
- Clean build folder (⌘⇧K)
- Run
LabTwoOne(Debug) → confirm dev URL + no staging label - Run
LabTwoOne (Staging)→ confirm staging URL + orange label - Run
LabTwoOneWidget→ confirm widget shows host - Switch destination to “My Mac (Mac Catalyst)” → run → confirm Mac launch
- Run the unit tests (⌘U) for both
LabTwoOneschemes → all should pass
Commit everything to git:
git init
git add .
git commit -m "Lab 2.1 — multi-target project with xcconfig-driven environments"
Validation checklist
-
Three build configurations exist:
Debug,Release-Staging,Release - Four xcconfig files exist and are wired at the project level
-
APIBaseURLin Info.plist resolves to a different URL per configuration -
Two shared schemes:
LabTwoOne(Debug) andLabTwoOne (Staging)(Release-Staging) - Widget extension builds and shows environment data
- Mac Catalyst destination builds and runs
-
.xcode-versionfile exists; build phase script runs on each build - All targets pass unit tests
Stretch goals
- Add an
iOS Notification Service Extensiontarget (just create it, don’t implement) — wire it toAppEnvironmentso all four targets share environment. - Add a
LabTwoOneKitSwift package (local SPM) — moveAppEnvironmentand add aNotemodel into it. All targets import the package instead of duplicating files via target membership. - Add a CI workflow (
.github/workflows/ci.yml) that runs./scripts/check-xcode-version.shand thenxcodebuild teston both schemes.
What you’ve internalized
- The mental model of project → target → scheme → configuration
- How xcconfig files externalize build settings and survive merge conflicts
- The Info.plist bridge from build settings to runtime Swift code
- Multi-target target membership for sharing source files
- The Xcode version pin pattern that scales to a real team
Next: Lab 2.2 — Debug a buggy app
Lab 2.2 — Debug a buggy app
Duration: ~75 minutes Difficulty: Intermediate Prereqs: Chapter 2.5 (Debugging), a working Xcode
Goal
Find and fix three deliberate bugs in a starter app using only Xcode’s debugging tools — LLDB, breakpoints, View Debugger, and Memory Graph Debugger. No reading source code to “spot the bug” — diagnose like you would a production issue, starting from the symptom.
What you’ll debug
A SwiftUI app called BuggyFeed with:
- A list of “posts” (mocked)
- A detail view when you tap a post
- A “Like” button on each post
The three bugs
- Layout bug — On some posts, the title is invisible (cut off / zero-height).
- Memory leak — Every push of the detail view leaks the view model. The Memory Graph Debugger will show duplicates accumulating.
- Threading bug — Tapping “Like” rapidly sometimes crashes with a
Thread Sanitizerwarning, or shows the wrong like count.
Setup — create the starter app
Create a new SwiftUI app called BuggyFeed. Replace the contents of the auto-generated files with:
BuggyFeedApp.swift
import SwiftUI
@main
struct BuggyFeedApp: App {
var body: some Scene {
WindowGroup {
ContentView()
}
}
}
ContentView.swift
import SwiftUI
struct Post: Identifiable {
let id = UUID()
var title: String
var body: String
var likes: Int = 0
}
@MainActor
final class FeedViewModel: ObservableObject {
@Published var posts: [Post] = [
Post(title: "Hello world", body: "This is the first post."),
Post(title: "", body: "This post has an empty title."),
Post(title: "Another day", body: "Coffee and code."),
Post(title: "SwiftUI tips", body: "Use @StateObject for owned models."),
]
}
struct ContentView: View {
@StateObject private var feed = FeedViewModel()
var body: some View {
NavigationStack {
List($feed.posts) { $post in
NavigationLink {
PostDetailView(post: $post)
} label: {
VStack(alignment: .leading) {
Text(post.title) // BUG 1 lives here-ish
.font(.headline)
Text(post.body)
.font(.subheadline)
.foregroundStyle(.secondary)
}
}
}
.navigationTitle("Buggy Feed")
}
}
}
PostDetailView.swift
import SwiftUI
@MainActor
final class PostDetailViewModel: ObservableObject {
@Published var localLikes: Int
private var timer: Timer?
init(initialLikes: Int) {
self.localLikes = initialLikes
// BUG 2: timer captures self strongly and is never invalidated
self.timer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { _ in
// simulating "live updates"
Task { @MainActor in
_ = self // pretend we use self
}
}
}
func likeMore() {
// BUG 3: writing from multiple Tasks without isolation
Task.detached {
let new = await self.localLikes + 1
await MainActor.run { self.localLikes = new }
}
}
}
struct PostDetailView: View {
@Binding var post: Post
@StateObject private var vm: PostDetailViewModel
init(post: Binding<Post>) {
self._post = post
self._vm = StateObject(wrappedValue: PostDetailViewModel(initialLikes: post.wrappedValue.likes))
}
var body: some View {
VStack(spacing: 16) {
Text(post.title)
.font(.largeTitle)
Text(post.body)
Text("Likes: \(vm.localLikes)")
Button("Like!") {
vm.likeMore()
post.likes = vm.localLikes
}
.buttonStyle(.borderedProminent)
Spacer()
}
.padding()
}
}
Run the app. The bugs will all be reproducible.
Investigation 1 — The invisible title
Symptom
The second post in the list looks “blank” — only the body is showing. Why?
Diagnosis with View Debugger
- Run the app
- Debug menu → View Debugging → Capture View Hierarchy
- The 3D hierarchy view appears
- In the left sidebar, expand the list cells; find the second cell
- Inside, you’ll find a
Textview with frame(0, 0, x, 0)— zero height - Click the
Text→ Object Inspector (⌥⌘4) → confirmtext = ""
Fix
The bug: the data has an empty title; the view doesn’t handle that gracefully. Fix in ContentView.swift:
Text(post.title.isEmpty ? "Untitled" : post.title)
.font(.headline)
Rebuild and confirm the second post now reads “Untitled.”
✅ Bug 1 fixed. Diagnosed entirely from the rendered hierarchy, not the source.
Investigation 2 — The memory leak
Symptom
You’re not sure there’s a leak; you just heard the lab said there’s one. How do you confirm?
Diagnosis with Memory Graph Debugger
- Run the app
- Navigate into a post; navigate back
- Repeat 5 times (push detail, pop, push different post, pop, …)
- While still running, click the Debug Memory Graph button in the debug bar (icon with three connected circles)
- Xcode pauses and displays the live object graph
- In the left sidebar, search for
PostDetailViewModel - You’ll see 5 instances — but you’ve only navigated into one detail view at a time! There should be 0 (or at most 1).
- Click the most recent instance → the graph shows it’s retained by a
Timer - The timer’s
blockcapturesselfstrongly → reference cycle
Fix
In PostDetailView.swift, two changes:
init(initialLikes: Int) {
self.localLikes = initialLikes
self.timer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in
Task { @MainActor in
guard let self else { return }
_ = self
}
}
}
deinit {
timer?.invalidate()
}
Rebuild. Navigate 5 times again. Memory Graph → search PostDetailViewModel → should show 0 (or 1 if you’re currently in a detail view).
✅ Bug 2 fixed. Diagnosed entirely from the retain graph.
Investigation 3 — The threading bug
Symptom
Tap “Like” rapidly — sometimes the count is wrong, sometimes you get a Thread Sanitizer warning, sometimes the app crashes.
Diagnosis with Thread Sanitizer
- Edit scheme (⌘<) → Run → Diagnostics → tick Thread Sanitizer
- Rebuild (⌘B) and run (⌘R)
- Navigate into a post
- Tap “Like” 10 times rapidly
- Watch the console: you should see a Thread Sanitizer error like:
WARNING: ThreadSanitizer: data race
Read of size 8 at 0x... by thread T1
Previous write at 0x... by main thread
Location: BuggyFeed/PostDetailView.swift:23
- Click the line in the trace; Xcode jumps to
likeMore()
The bug: Task.detached reads self.localLikes from a background thread, but localLikes is @Published on a @MainActor class. The read happens off the main actor — undefined behavior.
Fix
Rewrite likeMore() cleanly:
func likeMore() {
localLikes += 1
}
The original “increment via a detached task” pattern was contrived — the real fix is to do mutation on the main actor where the property lives.
Rebuild with Thread Sanitizer still on. Tap “Like” 10 times rapidly. No warnings. Count is correct every time.
✅ Bug 3 fixed. Diagnosed by enabling Thread Sanitizer in the scheme.
Stretch — add a conditional breakpoint to assert no future regressions
In PostDetailView.likeMore(), set a breakpoint. Right-click → Edit Breakpoint…:
- Condition:
!Thread.isMainThread - Action: Log Message → “❌ likeMore called off main thread”
- Action: Debugger Command →
expression assert(false) - Tick “Automatically continue after evaluating actions”
This breakpoint never fires in normal use, but if someone refactors likeMore to call from a background thread, the breakpoint will crash debug builds immediately at the point of the bug. Ship-blockers caught at debug time.
Validation checklist
- All three bugs reproduce in the unfixed starter
- You used View Debugger to find Bug 1 (not source code reading)
- You used Memory Graph Debugger to find Bug 2
- You used Thread Sanitizer to find Bug 3
- All three fixes are applied; app behaves correctly
- Thread Sanitizer remains enabled with no warnings during rapid like-tapping
- Optional: conditional breakpoint added as regression guard
What you’ve internalized
- The three Xcode debugging tools every iOS engineer uses weekly: View Debugger, Memory Graph Debugger, Thread Sanitizer
- The pattern of forming a hypothesis then picking the right tool, instead of reading source
- The hidden value of conditional breakpoints as runtime assertions
- Why
@MainActormatters and why off-actor reads are not just “warnings” but real bugs
Next: Lab 2.3 — Instruments profiling
Lab 2.3 — Instruments profiling
Duration: ~90 minutes Difficulty: Intermediate Prereqs: Chapter 2.6 (Instruments), Lab 2.2 helpful
Goal
Use the Time Profiler and Allocations instruments to find and fix:
- A CPU hotspot that makes scrolling stutter
- A memory leak that grows over time as the user interacts with the app
Both bugs are deliberately introduced. The point is to practice the measure → diagnose → fix → re-measure loop, not to write performant code from scratch.
Setup — create the starter app
Create a new SwiftUI iOS app called SlowFeed. Replace the auto-generated files with:
SlowFeedApp.swift
import SwiftUI
@main
struct SlowFeedApp: App {
var body: some Scene {
WindowGroup {
ContentView()
}
}
}
ContentView.swift
import SwiftUI
import CryptoKit
@MainActor
final class FeedStore: ObservableObject {
@Published var items: [FeedItem] = (0..<500).map { FeedItem(index: $0) }
}
struct FeedItem: Identifiable, Hashable {
let id = UUID()
let index: Int
var title: String { "Item #\(index)" }
}
// Deliberately expensive avatar generation — runs on the main thread, every cell rebuild
func generateAvatar(seed: String) -> String {
var data = Data(seed.utf8)
// Bug: 50,000 rounds of SHA256 per cell. On the main thread. Every layout pass.
for _ in 0..<50_000 {
data = Data(SHA256.hash(data: data))
}
let prefix = data.prefix(2).map { String(format: "%02x", $0) }.joined()
return "🎨\(prefix)"
}
struct AvatarView: View {
let seed: String
var body: some View {
Text(generateAvatar(seed: seed))
.font(.title)
.frame(width: 44, height: 44)
.background(.quaternary, in: Circle())
}
}
// Deliberately leaky — stores closures keyed by item, never cleans up
final class LeakyCache {
static let shared = LeakyCache()
private var callbacks: [UUID: () -> Void] = [:]
func register(_ id: UUID, callback: @escaping () -> Void) {
callbacks[id] = callback
}
}
struct ContentView: View {
@StateObject private var store = FeedStore()
var body: some View {
NavigationStack {
List(store.items) { item in
HStack(spacing: 12) {
AvatarView(seed: item.id.uuidString)
VStack(alignment: .leading) {
Text(item.title).font(.headline)
Text("Tap to like").font(.caption).foregroundStyle(.secondary)
}
Spacer()
}
.onAppear {
// Bug: registers a self-capturing closure every time the cell appears
LeakyCache.shared.register(item.id) {
_ = item.title // captures item strongly forever
}
}
}
.navigationTitle("Slow Feed")
}
}
}
Run on a Simulator (Release scheme for best signal — see Step 1 below). Scroll the list. You’ll feel the stutter immediately on an Apple Silicon Mac too — the avatar generator is that expensive.
Step 1 — Configure a Release-build profile (5 min)
This is critical. Profiling in Debug gives lies.
- Product → Scheme → Edit Scheme (⌘<)
- Profile action → Build Configuration → Release
- Close
Now ⌘I will build with Release optimizations and launch Instruments — closer to real production behavior.
Step 2 — Time Profiler: find the CPU hotspot (20 min)
- Choose a physical device if you have one (more representative). Otherwise Simulator is OK for this lab.
- ⌘I → choose Time Profiler template → click “Choose”
- Instruments launches with your app
- Click the Record button (red dot, top left)
- In the app, scroll the list quickly for ~10 seconds
- Click Stop
In the recorded trace:
- Bottom-left Call Tree panel:
- Check “Invert Call Tree” (leafs at top — where the time is spent)
- Check “Hide System Libraries” (drop noise)
- Sort by “Weight” descending
- The top entry should be
generateAvatar(seed:)— likely > 80% of CPU time
Drill into the row → expand the children → you’ll see SHA256 hashing dominating.
The diagnosis
The function is called from AvatarView.body, which SwiftUI calls on the main thread every time the cell appears (and sometimes multiple times). Each call burns ~30ms on a real device. 60 fps requires < 16.6ms per frame. We’re spending 2 frames per cell just on avatar generation.
The fix
Two changes:
- Cache the result — avatars don’t change for the same seed
- Compute off-main-thread if not cached, with a placeholder while loading
// Add a global cache (or @MainActor singleton; this is for simplicity)
actor AvatarCache {
static let shared = AvatarCache()
private var cache: [String: String] = [:]
func avatar(for seed: String) async -> String {
if let cached = cache[seed] { return cached }
let generated = await Task.detached(priority: .userInitiated) {
generateAvatar(seed: seed)
}.value
cache[seed] = generated
return generated
}
}
struct AvatarView: View {
let seed: String
@State private var avatar: String = "⏳"
var body: some View {
Text(avatar)
.font(.title)
.frame(width: 44, height: 44)
.background(.quaternary, in: Circle())
.task(id: seed) {
avatar = await AvatarCache.shared.avatar(for: seed)
}
}
}
(For a real app, you’d want to drop the cell-side task(id:) for a more SwiftUI-idiomatic approach with Observable models, but for the lab this demonstrates the pattern.)
Profile again (⌘I → record → scroll → stop). The top of the inverted call tree should no longer feature generateAvatar significantly on subsequent scrolls (only on first appearance per seed).
✅ CPU hotspot fixed. Scrolling is smooth.
Step 3 — Allocations: find the leak (25 min)
- ⌘I → choose Allocations template → “Choose”
- Click Record
- In the app, scroll down through all 500 items (so every cell appears at least once)
- Scroll back to top
- Scroll down again
- Click Stop
Now in the trace:
- The top-right table shows allocations by category
- Look at “All Heap & Anonymous VM” in the bottom panel — note the trend: memory grows monotonically as you scroll
- Click the Mark Generation flag icon at the top before a scroll, then again after — you’ve recorded a “diff”
- Click the generation row in the bottom panel; the right inspector shows what was newly allocated and still alive
- You’ll see hundreds of
FeedItemand closures still alive — far more than the visible cell count
Diagnosis with the Memory Graph
For the what is retaining what? question, switch tools:
- Stop Instruments
- Back in Xcode, run the app
- Scroll all 500 items
- Click Debug Memory Graph in Xcode’s debug bar
- Search the left sidebar for
LeakyCache - Click it → the graph shows a dictionary with 500 entries, each a closure capturing a
FeedItem - The closures never get released because
LeakyCache.shared.callbacksnever removes them
The fix
Two options:
Option A (best): remove the onAppear registration entirely; we don’t actually need it.
Option B: bound the cache.
final class LeakyCache {
static let shared = LeakyCache()
private var callbacks: [UUID: () -> Void] = [:]
private let maxEntries = 50
func register(_ id: UUID, callback: @escaping () -> Void) {
callbacks[id] = callback
if callbacks.count > maxEntries {
// Evict an arbitrary old entry
if let first = callbacks.keys.first { callbacks.removeValue(forKey: first) }
}
}
}
Or remove the onAppear block entirely (the cleanest fix — the bug is that we register callbacks we never use).
Re-run Allocations. Memory should now stay bounded as you scroll.
✅ Memory leak fixed.
Step 4 — Add os_signpost instrumentation (15 min)
Add named regions to your code so future profiling sessions get rich timeline annotations:
import os
let signposter = OSSignposter(subsystem: "com.example.SlowFeed", category: "Avatars")
extension AvatarCache {
func avatar(for seed: String) async -> String {
let id = signposter.makeSignpostID()
let interval = signposter.beginInterval("avatar", id: id, "seed: \(seed.prefix(8))")
defer { signposter.endInterval("avatar", interval) }
if let cached = cache[seed] { return cached }
let generated = await Task.detached(priority: .userInitiated) {
generateAvatar(seed: seed)
}.value
cache[seed] = generated
return generated
}
}
Profile with Time Profiler again. In the timeline, click “+” at top-right → add the Points of Interest instrument. Your avatar intervals appear as a band on the timeline. Future you (or your teammate) can now see exactly when avatar generation happens, correlated with CPU spikes.
Step 5 — Verify and re-measure (15 min)
For each fix, measure before and after and write down the numbers. Sample template:
| Metric | Before | After |
|---|---|---|
Time in generateAvatar (Time Profiler) | ~85% of frame time | < 5% (cached) |
| Memory after 500 scrolls (Allocations) | grows ~2 MB | bounded |
| Frame hitches (Animation Hitches) | many | 0 |
If you can attach a physical device, run Animation Hitches as well; record before/after on the same device.
Validation checklist
- Bugs were reproducible in the starter app
-
Time Profiler trace recorded;
generateAvataridentified as the hotspot - CPU fix applied; re-measured shows hotspot gone
- Allocations trace recorded; growth confirmed
-
Memory Graph Debugger used to confirm
LeakyCacheretention - Leak fix applied; re-measured shows bounded growth
-
os_signpostinstrumentation added; visible in Points of Interest - Numbers recorded before/after each fix
Stretch goals
- Cold launch budget — Add
os_signpostfor app startup; profile with App Launch template; measure cold launch time; set a budget (< 400 ms on your test device). - Animation Hitches profile — Run the Animation Hitches template before and after the avatar fix. Confirm hitches went from many to zero.
- Energy Log — Run the Energy Log template for 60 seconds of usage; record energy impact. Identify the highest-energy subsystem.
- CI gating — Write a script that fails the build if
xctrace exportshows the avatar function exceeding a threshold. (Advanced — but this is the pattern senior teams use.)
What you’ve internalized
- The Release-build discipline for profiling
- The inverted call tree pattern for finding CPU hotspots
- Mark Generation snapshots for measuring “what should be freed but isn’t”
- The Memory Graph Debugger as the complement to Allocations for retention analysis
os_signpostas the way to add app-specific annotations to perf traces- The measure → fix → re-measure loop that defines professional perf work
Phase 2 complete
You’ve now built the Xcode-mastery skill set: navigation, project structure, build settings, debugging, profiling, device strategy, version management, and Apple’s CI option. With these skills, you can join any iOS team and be productive in the build-and-debug loop on day one.
Next: Phase 3 — Foundation & Core Frameworks (coming up).
3.1 — Apple HIG overview
Opening scenario
You ship an app to App Review. Three days later: rejected. The reviewer’s note says “Guideline 4.0 — Design. Your app’s interface does not align with iOS conventions.” No specific bug. No failing test. Just a vibe-based “this doesn’t feel like an iOS app.” Welcome to the Human Interface Guidelines — the unwritten rules that are also written, in a 1,000-page document, that you’ve never read.
The HIG is not optional. It is the law that App Review enforces, the muscle memory your users already have, and the design vocabulary every iOS designer assumes you speak. This chapter teaches you the four principles, what each one looks like in code, and the rejection-bait patterns to never ship.
| Aspect | Detail |
|---|---|
| Document | Apple Human Interface Guidelines |
| Covers | iOS, iPadOS, macOS, watchOS, tvOS, visionOS |
| Enforced by | App Review (Guideline 4.0) + user expectations |
| Updated | Annually at WWDC, mid-cycle for new platforms |
Concept → Why → How → Code
Apple’s four design principles
Apple distilled decades of Mac and iOS design into four principles. Every component, every transition, every icon is justified against these four words.
- Clarity — Text is legible. Icons are precise. Adornments are subtle. Function drives form.
- Deference — The UI helps people understand and interact with the content, but never competes with it. Translucency, blur, depth — used to show what’s underneath.
- Depth — Distinct visual layers and realistic motion convey hierarchy and aid understanding. The cards-on-cards stack, the push-and-pop nav, the modal-from-below sheet.
- Feedback — Every tap, gesture, and state change produces immediate, perceptible response. Haptics, animation, sound. If nothing happens visibly when the user taps, the user assumes the app froze.
Why this matters
The principles are not aesthetic preferences — they are cognitive load reducers. Users on iOS have been trained for 18 years to expect:
- A back chevron means “go back”
- A blue label means “this is interactive”
- A long-press shows a context menu
- A swipe-from-edge goes back
- A bottom sheet can be dragged down to dismiss
Violate any of these and the user has to learn your app before they can use it. The bounce rate spikes. Reviews complain “buggy” even when nothing crashed. App Review rejects.
Platform idioms per OS
Each Apple OS has a different “personality.” Memorize the headline patterns:
| OS | Primary nav | Hardware affordance | Pattern that’s wrong on other platforms |
|---|---|---|---|
| iOS | NavigationStack, tab bar | Touch | Sidebar (too wide for phone) |
| iPadOS | NavigationSplitView, sidebar | Touch + pencil + keyboard | Single-column tab bar (wastes width) |
| macOS | Sidebar + toolbar + menubar | Pointer + keyboard | Tab bar (use sidebar items instead) |
| watchOS | Hierarchical pages, Digital Crown | Tap + crown | Long text input (use dictation) |
| visionOS | Floating windows, ornaments | Eyes + pinch | Flat 2D-only UI (use depth) |
| tvOS | Focus engine, top-down lists | Remote (focus model) | Direct-touch UI (no touchscreen) |
A common rookie mistake: building an iPad app that’s just “iPhone but bigger.” Apple explicitly calls this out in the HIG as the #1 reason iPad apps feel cheap.
The “wrong nav for the platform” trap
// Wrong on iPad: phone-style tab bar
TabView {
Tab("Home", systemImage: "house") { HomeView() }
Tab("Search", systemImage: "magnifyingglass") { SearchView() }
}
// ↑ Wastes the iPad's screen real estate. Reviewer will flag.
// Right on iPad: NavigationSplitView
NavigationSplitView {
SidebarView()
} content: {
ListView()
} detail: {
DetailView()
}
SwiftUI’s NavigationSplitView automatically collapses to a stack on iPhone — write it once, ship to both.
Clarity in practice — text and contrast
// Wrong: hardcoded light-mode color
Text("Welcome")
.foregroundStyle(.black)
.background(.white)
// Right: semantic, adapts to dark mode and high contrast
Text("Welcome")
.foregroundStyle(.primary)
.background(Color(.systemBackground))
Use semantic colors (.primary, .secondary, Color(.label), Color(.systemBackground)) — never raw hex unless it’s a brand color. We’ll go deep on this in Chapter 3.3.
Deference — let content lead
// Wrong: heavy chrome competes with the photo
VStack {
Text("My beautiful photo")
.font(.largeTitle)
.background(.thinMaterial)
.padding()
Image("photo")
}
// Right: photo dominates, label is unobtrusive
ZStack(alignment: .bottomLeading) {
Image("photo")
.resizable()
.scaledToFill()
Text("My beautiful photo")
.font(.caption)
.foregroundStyle(.white)
.padding()
}
Photos app, Camera, Maps, TV — all let the content fill the screen. Chrome only appears on tap.
Depth — modals and transitions communicate hierarchy
// Right: sheet for "modal task" (compose, share, settings)
.sheet(isPresented: $showCompose) { ComposeView() }
// Right: full-screen cover for "immersive experience" (video, onboarding)
.fullScreenCover(isPresented: $showOnboarding) { OnboardingFlow() }
// Right: push for "next step in same task" (list → detail)
NavigationLink("Open") { DetailView() }
Don’t push a modal task; don’t sheet-present the next step in a navigation flow. Users read the transition direction as semantics.
Feedback — every action gets a response
import SwiftUI
struct LikeButton: View {
@State private var liked = false
var body: some View {
Button {
withAnimation(.spring) { liked.toggle() }
// Haptic feedback
UIImpactFeedbackGenerator(style: .light).impactOccurred()
} label: {
Image(systemName: liked ? "heart.fill" : "heart")
.symbolEffect(.bounce, value: liked)
.foregroundStyle(liked ? .red : .secondary)
}
}
}
Three layers of feedback: visual (icon change), motion (.bounce), tactile (haptic). The user feels the like.
In the wild
- Apple Photos is the textbook “deference” app — content fills the screen; chrome only on tap.
- Tweetbot (RIP) was famously over-chromed by some metrics — its loss to Twitter’s official app correlated with Apple-style minimalism winning.
- Airbnb rebuilt its iOS app around HIG principles in 2022 and reported a 13% increase in conversion — the case study is “respecting the platform pays.”
- Things 3 from Cultured Code is referenced in Apple design talks as the gold standard for following HIG on both iOS and macOS without feeling generic.
- Instagram on iPad is the canonical failure example — it’s still effectively a stretched phone app in 2025, and it has been rejected for awards and design recognition because of it.
Common misconceptions
- “HIG is just suggestions.” It is enforced under App Review Guideline 4.0. Apps using custom non-standard controls for system tasks get rejected.
- “I can use my brand’s visual identity instead of HIG.” Brand expresses through colors, typography, illustration, voice. Navigation, controls, gestures must be HIG-standard.
- “My designer didn’t mention HIG.” Then your designer is wrong. Send them the link. Designers who design iOS without reading HIG ship apps that get rejected.
- “Cross-platform consistency is more important than HIG.” No. Android users expect Material; iOS users expect HIG. Same app, two different navigation paradigms. Companies that try one universal design (looking at you, mid-2010s web-first companies) lose to platform-native competitors.
- “HIG = boring.” Wrong. Apple’s own apps (Music, Maps, Health) are HIG-conformant and visually distinct. HIG is the grammar; you bring the poetry.
Seasoned engineer’s take
The HIG isn’t a document you read once — it’s a document you reference like a dictionary. Bookmark it. Open it when arguing with a designer about whether a particular pattern is okay. Send screenshots of HIG sections in PR review when someone tries to push a custom slider that isn’t a Slider.
The fastest way to internalize HIG: spend an hour deconstructing Apple’s own apps. Open Mail. Open Notes. Open Reminders. Count the gestures. Note the transitions. Watch what happens on long-press, swipe, drag. That’s the test set you need to pass.
Also: HIG changes every WWDC. The 2025 update added a whole new section on visionOS and refined the iOS chapter for Liquid Glass. Re-read your relevant platform’s chapter every June.
TIP: When in doubt, copy Apple. If you can’t decide between two patterns, find an Apple app that does the same task and copy that.
WARNING: Custom gestures that override system gestures (swipe-from-edge, top-pull, drag-to-dismiss) are an instant rejection. Don’t fight muscle memory.
Interview corner
Junior-level: “What are Apple’s four design principles?”
Clarity, Deference, Depth, Feedback. Be ready to give one example of each from an app you’ve used.
Mid-level: “How would you adapt an iPhone-only app to iPad?”
Use NavigationSplitView instead of NavigationStack. Replace tab bars with sidebars on regular size class. Support iPad-specific input: pencil, hover, keyboard shortcuts. Test in Split View and Stage Manager. Never just stretch the iPhone UI.
Senior-level: “You disagree with a designer who wants to ship a non-HIG pattern because ‘it’s our brand.’ How do you resolve?”
Frame the cost: app review risk, user training cost (measurable as drop-off in first-session analytics), accessibility regression. Propose A/B testing a HIG-conformant variant. If the brand pattern must ship, scope it tightly (one screen, not navigation), document the rationale, and revisit after launch metrics.
Red flag in candidates: Saying “we don’t really follow HIG, our designers do whatever they want” — signals a team that ships rejection-bait apps and burns review cycles.
Lab preview
You’ll audit a pre-made app for 6 deliberate HIG and accessibility violations in Lab 3.2 — HIG & Accessibility Audit.
Next: 3.2 — Figma for developers
3.2 — Figma for developers
Opening scenario
The designer drops a Figma link in Slack. “Here’s the new feed screen, ship it by EOD Friday.” You open it. There are 47 frames. Three are labeled “final,” two are labeled “final-v2,” and one is labeled “final-FINAL-actually-this-one.” Components are nested four deep. Colors are listed as raw hex. You don’t know which frame is the source of truth, which spacing values are intentional vs accidental, what state each component represents, or how to export the icons.
This chapter teaches you to read Figma like an iOS engineer — fast, accurately, and without bothering your designer every 10 minutes.
| Aspect | Detail |
|---|---|
| Tool | Figma — free for individuals, $15/editor/month for orgs |
| Plugin you need | Figma Dev Mode (built-in, requires paid plan in 2024+) |
| iOS UI kits | Apple’s Official iOS Design Kit |
| What you produce | Pixel-accurate SwiftUI/UIKit, asset exports, design tokens |
Concept → Why → How → Code
The Figma object model
Figma’s hierarchy, from outermost to innermost:
- Team → Project → File → Page → Frame → Group / Component instance → Layer
You will mostly live in:
- Pages: tabs at the top — usually one per feature area
- Frames: artboards, each one represents a screen or screen state
- Components: reusable building blocks (button, card, avatar)
- Component instances: a placed copy of a component, possibly with overrides
A component in Figma maps to a SwiftUI View or UIKit UIView. Treat them that way.
Frames vs components vs variants
| Figma concept | Swift analog |
|---|---|
| Frame | A screen or sub-screen (a View’s body) |
| Component | A reusable view (struct ButtonStyle, struct CardView) |
| Variant | An enum-driven state (enum ButtonStyle { case primary, secondary }) |
| Instance | A call site of the component |
| Override | A parameter or .modifier at the call site |
When you see a button with variants primary / secondary / destructive, that becomes:
enum AppButtonStyle { case primary, secondary, destructive }
struct AppButton: View {
let title: String
let style: AppButtonStyle
let action: () -> Void
var body: some View {
Button(title, action: action)
.buttonStyle(.borderedProminent)
.tint(style.tint)
}
}
extension AppButtonStyle {
var tint: Color {
switch self {
case .primary: .accentColor
case .secondary: .secondary
case .destructive: .red
}
}
}
Auto Layout in Figma → HStack/VStack in SwiftUI
Figma’s Auto Layout is the design equivalent of SwiftUI’s stacks. When you select a frame with Auto Layout enabled, the right panel shows:
- Direction: vertical or horizontal →
VStackorHStack - Spacing: gap between children →
spacing:parameter - Padding: inset around children →
.padding(.horizontal, X).padding(.vertical, Y) - Alignment: top/center/bottom × leading/center/trailing →
alignment: - Sizing: hug / fill / fixed → use
.frame()or no frame; “fill” usually means.frame(maxWidth: .infinity)
// Figma: VStack, spacing 12, padding 16, fill width, hug height
VStack(alignment: .leading, spacing: 12) {
Text("Title").font(.headline)
Text("Subtitle").font(.subheadline)
}
.padding(16)
.frame(maxWidth: .infinity, alignment: .leading)
If your designer doesn’t use Auto Layout, gently insist. Without it, every screen is an exercise in eyeballing pixels. With it, the Figma file is the SwiftUI structure.
The Inspect panel (Dev Mode)
Switch Figma to Dev Mode (top right toggle). The right sidebar changes from a designer’s panel to a developer’s panel:
- Code section: shows generated SwiftUI / UIKit / CSS / Android XML for the selected layer
- Properties: width, height, fills, strokes, effects, typography
- Assets: download icons / images as SVG / PDF / PNG @1x/2x/3x
- Variables: design tokens (colors, spacing) with their semantic names
- Comments: redlines, annotations, conversation threads
The generated code is a starting point, not the final answer. It hardcodes pixel values; you should replace them with semantic tokens (Chapter 3.3) and Dynamic Type-aware fonts.
Inspecting spacing, colors, typography
Spacing: hold ⌥ (option) and hover other layers — Figma shows the pixel distance from your selection to the hovered layer. This is how you find the actual gap value, regardless of what the layer panel claims.
Color: click any fill swatch. The popover shows hex + opacity + linked variable name (e.g. color/brand/primary). Use the variable name as your token name in code.
Typography: select a text layer; the right panel shows font family, weight, size, line height, letter spacing. Map these to SwiftUI:
// Figma: SF Pro Display, Semibold, 17pt, line-height 22pt
Text("Hello")
.font(.system(size: 17, weight: .semibold))
.lineSpacing(22 - 17) // SwiftUI's lineSpacing is the *gap*, not total line-height
Better: use semantic text styles (.headline, .body, .subheadline) so Dynamic Type works. The 17pt semibold above is exactly .headline — use that.
Components from Apple’s official iOS UI kit
Apple publishes a free iOS Design Kit on Figma Community. It contains every UIKit / SwiftUI standard component (tab bars, nav bars, sheets, alerts, system colors, SF Symbols). Designers who start from this kit save you weeks of “is this a custom button or a system button?” arguments.
Push your design team to use it. The components are designed to map 1:1 to native controls.
Spec-vs-code discrepancies — the etiquette
When the Figma differs from a HIG-required behavior (e.g. designer made a 32pt tap target; HIG requires 44pt minimum), the HIG wins. Add a Figma comment quoting the HIG rule, ship the HIG-conformant version, and link your designer to the source.
When the Figma differs from technical reality (e.g. designer wants a perfect circle avatar but the image API returns variable aspect ratios), bring it up in handoff with two solutions, not just a complaint.
In the wild
- Apple’s design team ships its WWDC keynote slides from Keynote, but the design specs for first-party apps are in Figma now (as of ~2023, per public job postings).
- Airbnb’s design system (“DLS”) publishes its Figma library to all engineers; the component names match the Swift component names exactly.
- Spotify uses Figma’s variables feature for design tokens, generates JSON via the Figma API, and feeds them into a Style Dictionary build step that outputs
Color+Tokens.swiftand Android XML in the same CI job. - Lyft open-sourced its Figma-to-Swift token generator — worth reading the README to see how a real team automates handoff.
Common misconceptions
- “Figma is for designers; engineers don’t need to learn it.” Wrong. You’ll spend 30% of your handoff time in Figma. Learning the inspect panel saves hours every sprint.
- “Dev Mode auto-generated code is production-ready.” It is not. It hardcodes pixel values, uses hex colors, ignores Dynamic Type. Use it as a structural starting point.
- “Figma replaces communication with designers.” It does not. It removes the trivial questions (what’s the spacing?) so you can spend conversation budget on the real questions (what’s the empty state? what’s the error state? what’s the animation?).
- “I can just screenshot the Figma and pixel-compare.” Screenshots hide the structure. You need to inspect Auto Layout, components, and variables to build the right hierarchy.
- “All assets export from Figma at 1x; the designer should re-export 2x and 3x.” No — you export. Dev Mode → Assets → check the @1x/@2x/@3x boxes → drop into Xcode. The designer’s time is for design.
Seasoned engineer’s take
Treat Figma as read-only code. Open it with the same rigor as a Swift file. Find the source-of-truth frame (usually labeled “✅ Ready for dev” or in a specific page). Ignore the dozens of exploration frames unless your designer points to one.
Insist on a single design tokens source. If colors and spacing live in Figma variables, you can write (or use) a script that pulls them via the Figma API and outputs DesignTokens.swift automatically. Your designer changes the brand color in Figma; your CI ships a new build with the updated color. No copy-paste, no drift.
When a designer hands you a Figma without Auto Layout, components, or variables, you’re not getting design — you’re getting decoration. Push back kindly: “Can we adopt Figma Auto Layout for handoff? It makes implementation 2x faster.” This is a respectful, evidence-based request.
TIP: Install the Figma Mac app (not the browser version). The native pencil/keyboard handling is much better, and it has offline mode.
WARNING: Never trust pixel measurements from screenshots — always check the actual Figma file. Designers iterate; screenshots go stale.
Interview corner
Junior-level: “Have you used Figma? What’s Dev Mode?”
Yes — Dev Mode is the developer-facing view that exposes generated code, asset downloads, design tokens (variables), and pixel measurements. It’s how engineers extract specs without needing a designer to walk through every screen.
Mid-level: “How would you structure a handoff process between design and engineering?”
Single source-of-truth file. Designers mark frames as “ready for dev” (status field). Components and variables required — no raw hex. Engineers use Dev Mode to inspect; redlines and questions live as comments on the frame. Token names map 1:1 to code. Friday handoff meetings to walk the next sprint’s screens.
Senior-level: “Your design system is in Figma but your codebase has drifted — colors don’t match anymore. How do you fix it?”
Audit the gap (list every color in code vs Figma variables). Pick a source of truth (Figma). Use the Figma API to export variables as JSON, run through Style Dictionary or a custom script to generate DesignTokens.swift. Add to CI so token drift is caught on every PR. Migrate existing call sites incrementally, by feature. Add a SwiftLint rule banning raw hex colors.
Red flag in candidates: Saying “I don’t open Figma, I just ask the designer for the values” — signals a junior workflow that doesn’t scale.
Lab preview
You’ll implement a Figma design pixel-accurately in SwiftUI in Lab 3.1 — Figma to SwiftUI.
Next: 3.3 — Design tokens: color, typography & spacing
3.3 — Design tokens: color, typography & spacing
Opening scenario
You inherit a 4-year-old SwiftUI codebase. You search for Color(red: and find 312 results. Same brand blue, defined 50 different ways: some Color(red: 0.0, green: 0.48, blue: 1.0), some Color(hex: "#007AFF"), some Color("BrandBlue"), some UIColor.systemBlue. The designer just shipped a new brand color. You quit your job.
Design tokens are the cure. One name (Color.brandPrimary), one source, one change point. This chapter shows you how to set up a tokens layer that scales from a 1-person side project to a 500-engineer org.
| Layer | What it is | Examples |
|---|---|---|
| Primitive tokens | Raw values | blue500 = #007AFF, space-4 = 16pt |
| Semantic tokens | Intent-named, references primitives | colorBackground = blue500, spacingCardPadding = space-4 |
| Component tokens | Component-specific | buttonPrimaryBackground = colorBackground |
You always use semantic and component tokens. Primitives stay hidden inside the tokens module.
Concept → Why → How → Code
Apple’s semantic color system
Apple already gives you a complete semantic palette via UIKit / SwiftUI:
// Backgrounds — adapt to light/dark, elevated/grouped contexts
Color(.systemBackground) // Primary view background
Color(.secondarySystemBackground)
Color(.tertiarySystemBackground)
Color(.systemGroupedBackground) // For grouped lists
// Labels — text colors with built-in opacity hierarchy
Color(.label) // Primary text
Color(.secondaryLabel)
Color(.tertiaryLabel)
Color(.quaternaryLabel)
// Fills — for icon backgrounds, progress bars
Color(.systemFill)
Color(.secondarySystemFill)
// SwiftUI shortcuts (semantic, OS-aware)
.foregroundStyle(.primary)
.foregroundStyle(.secondary)
.foregroundStyle(.tertiary)
Use these. They adapt to light/dark, high contrast, and the new vibrant materials automatically. Never write Color.black for body text — write .primary.
Asset Catalog colors — the typed wrapper
For brand colors that aren’t in Apple’s palette, define them in the Asset Catalog (Assets.xcassets → New Color Set). Set Appearances: “Any, Dark” to give one value for light and one for dark. Now reference by name:
extension Color {
static let brandPrimary = Color("BrandPrimary")
static let brandSecondary = Color("BrandSecondary")
static let surfaceElevated = Color("SurfaceElevated")
}
Or even safer — use the Xcode 15+ generated symbols (set “Asset Symbols” generation to “Swift” in build settings):
// Auto-generated; you get compile-time-checked color access:
Color.brandPrimary // No string-typo runtime crash
Token layering in code
Even with Asset Catalog colors, structure them in semantic layers:
// DesignTokens/Color+Tokens.swift
extension Color {
// PRIMITIVES — never use directly outside this file
fileprivate static let _blue500 = Color("Blue500")
fileprivate static let _gray100 = Color("Gray100")
fileprivate static let _gray900 = Color("Gray900")
// SEMANTIC — use these in views
static let accent = _blue500
static let surface = _gray100
static let onSurface = _gray900
// COMPONENT — for tightly-bound use cases
static let buttonPrimaryBackground = accent
static let buttonPrimaryForeground = Color.white
static let cardBackground = surface
}
Designer changes the brand blue? Update Blue500 in Asset Catalog. Every screen updates.
Dynamic Type — typography that scales
Apple’s text styles (.body, .headline, .title2, etc.) scale with the user’s Dynamic Type setting. Always prefer them over hardcoded sizes.
// Wrong — won't scale, fails accessibility audit
Text("Hello").font(.system(size: 17))
// Right — scales with user preference
Text("Hello").font(.body)
// Right with weight override
Text("Hello").font(.body).fontWeight(.semibold)
// Right with custom font, still scaling
Text("Hello").font(.custom("Inter-Regular", size: 17, relativeTo: .body))
The full text style scale (you should memorize the names, not the sizes — sizes change with user preference):
| Style | Default size | Use for |
|---|---|---|
.largeTitle | 34 | Hero screens, onboarding |
.title | 28 | Screen titles |
.title2 | 22 | Section titles |
.title3 | 20 | Card headlines |
.headline | 17 (semibold) | List item titles, emphasized labels |
.body | 17 | Primary content |
.callout | 16 | Secondary content |
.subheadline | 15 | Subtitles |
.footnote | 13 | Captions, metadata |
.caption | 12 | Smallest readable text |
.caption2 | 11 | Microcopy (use sparingly) |
Custom fonts that respect Dynamic Type
Brand fonts (Inter, Söhne, GT America) should be wrapped in style helpers that scale:
extension Font {
static func brandBody(weight: Font.Weight = .regular) -> Font {
.custom("Inter-Regular", size: 17, relativeTo: .body)
.weight(weight)
}
static func brandHeadline() -> Font {
.custom("Inter-Semibold", size: 17, relativeTo: .headline)
}
}
// Usage
Text("Hello").font(.brandBody())
Text("Hello").font(.brandHeadline())
Note relativeTo: — this is what makes Dynamic Type work for custom fonts. Without it, your designer’s font looks great at default size and unreadable at the largest accessibility size.
Spacing — the 8pt grid
Apple’s design tradition uses an 8pt grid: every spacing value is a multiple of 8 (or sometimes 4 for fine adjustments). This produces visual rhythm and consistency.
enum Spacing {
static let xxs: CGFloat = 4
static let xs: CGFloat = 8
static let sm: CGFloat = 12
static let md: CGFloat = 16
static let lg: CGFloat = 24
static let xl: CGFloat = 32
static let xxl: CGFloat = 48
}
// Usage
VStack(spacing: Spacing.md) {
Text("Title")
Text("Body")
}
.padding(Spacing.lg)
Adopt this in code and your designer’s Figma will already align (if they’re competent, they’re also using the 8pt grid).
Generating tokens from Figma
For larger teams, manually maintaining Color+Tokens.swift and Spacing.swift is brittle. Tools that automate it:
- Style Dictionary — Amazon’s open-source token translator. Input: JSON. Output: Swift, Kotlin, CSS, anything.
- Figma Tokens / Tokens Studio plugin — exports Figma variables to JSON for Style Dictionary.
- Supernova — commercial: full Figma → multi-platform pipeline.
- Custom script — call Figma REST API → walk
variablescollection → emit Swift code.
The bare-metal flow:
Figma variables → API export → tokens.json → Style Dictionary → DesignTokens.swift → committed to repo
Run on every PR via CI, fail if tokens differ from latest Figma.
The full SwiftUI design tokens module
// DesignTokens.swift
import SwiftUI
enum DesignTokens {
enum Color {
static let surface = SwiftUI.Color("surface")
static let onSurface = SwiftUI.Color("onSurface")
static let accent = SwiftUI.Color("accent")
}
enum Spacing {
static let xs: CGFloat = 8
static let sm: CGFloat = 12
static let md: CGFloat = 16
static let lg: CGFloat = 24
}
enum Radius {
static let sm: CGFloat = 8
static let md: CGFloat = 12
static let lg: CGFloat = 16
}
enum Typography {
static let body = Font.custom("Inter-Regular", size: 17, relativeTo: .body)
static let headline = Font.custom("Inter-Semibold", size: 17, relativeTo: .headline)
}
}
// Usage
VStack(spacing: DesignTokens.Spacing.md) {
Text("Hello")
.font(DesignTokens.Typography.headline)
.foregroundStyle(DesignTokens.Color.onSurface)
}
.padding(DesignTokens.Spacing.lg)
.background(DesignTokens.Color.surface, in: RoundedRectangle(cornerRadius: DesignTokens.Radius.md))
A SwiftLint custom rule banning Color(red:, Color(hex:, Font.system(size:) enforces token usage.
In the wild
- Apple’s own apps lean almost entirely on
.primary/.secondary/.tintplus a handful of brand colors. Calculator, Stocks, Weather are all built on this minimal token set. - Airbnb’s DLS publishes a Swift package with hundreds of tokens generated from their Figma source.
- Shopify has Polaris — public design system with full token documentation; the iOS variant follows the same naming.
- Lyft open-sourced token-pipeline tooling — search GitHub for
lyft/tokens-studiostyle repos. - Linear (the project management app) uses a single semantic token system across web, iOS, macOS; it’s why the apps feel identical despite three codebases.
Common misconceptions
- “Dark mode is just inverting colors.” Wrong. Dark mode uses different values — usually a desaturated near-black background, lighter accent, and reduced contrast for non-essential text. Use Asset Catalog appearance variants, not algorithmic inversion.
- “I can use hex codes — I’ll convert them later.” “Later” never comes. Use tokens from day one.
- “Dynamic Type is for old people.” Dynamic Type is a system-wide accessibility setting that affects ~30% of iOS users (per Apple’s own talks). Fail it and your reviews drop.
- “Spacing doesn’t matter as long as it looks right.” Inconsistent spacing is the #1 reason an app feels “amateur.” The 8pt grid is cheap insurance.
- “Tokens are overkill for a small app.” For a 1-screen app, sure. For anything multi-screen, you’ll regret not having them by week three.
Seasoned engineer’s take
The first commit on any new project I start: a DesignTokens.swift file with placeholder values. Even before any screens exist. It forces the question “what’s our color palette?” immediately, and gives every subsequent screen a free pre-existing API.
Custom fonts are a liability if you don’t wrap them properly. The number of apps that ship with a custom font that doesn’t scale with Dynamic Type is huge — your app being one of the few that does will be a quiet competitive advantage in accessibility scoring.
If you’re at a company without a design system: build the tokens layer anyway, locally for your feature, and commit it. When the design system arrives in 18 months, you’ll be ahead.
TIP: Use
xcrun --sdk iphoneos --find xcassetsto verify your Asset Catalog colors parse correctly during CI — catches typos in light/dark color JSON before runtime.
WARNING: Don’t ship
.primary/.secondaryas your brand color. They are system colors that change with iOS releases. Brand colors must be defined explicitly.
Interview corner
Junior-level: “How do you handle colors in a SwiftUI app for light and dark mode?”
Asset Catalog Color Sets with “Any, Dark” appearances. Reference by typed extension on Color so they’re compile-time safe. Use .primary/.secondary for text where possible.
Mid-level: “What’s a design token? Why use one instead of hex codes?”
A semantic name for a design value, decoupled from its raw representation. Tokens let designers change values in one place, prevent inconsistency, and bridge multiple platforms (iOS + Android + web). Hex codes scatter and drift.
Senior-level: “Design a tokens system that syncs from Figma to iOS, Android, and web with one source of truth.”
Figma variables as source. Tokens Studio plugin exports to JSON in a shared repo. Style Dictionary transforms JSON to Swift (extensions on Color/Font/enums), Kotlin (object), CSS variables, in one CI run. PR check ensures token JSON matches Figma export. Versioned tokens module as a Swift Package consumed by the iOS app.
Red flag in candidates: Hardcoded hex codes in their portfolio app’s view code. Tells you they’ve never maintained a real product.
Lab preview
You’ll define a semantic palette from a brand brief in Lab 3.3 — Palette from Brief.
Next: 3.4 — SF Symbols
3.4 — SF Symbols
Opening scenario
You need a “heart” icon for a like button. The designer sends a custom SVG. You import it, scale it, recolor it for light/dark, and ship. Two weeks later, the designer wants the heart to animate when tapped. You write a custom shape interpolation. Two weeks later, the designer wants the heart at the user’s accessibility text size. You write font-scaling math. Two weeks later, iOS 19 adds a beautiful new heart-fill animation built into the system… that you can’t use, because you committed to custom SVG.
You should have used SF Symbols from day one. This chapter shows you why and how.
| Aspect | Detail |
|---|---|
| Tool | SF Symbols app — free, Mac only |
| Library | ~6,900 symbols in SF Symbols 6 (2024) |
| Format | Apple proprietary; rendered as a font |
| Custom symbols | SVG export from Figma → import to SF Symbols app |
| Animation | symbolEffect modifier in SwiftUI |
Concept → Why → How → Code
What SF Symbols actually is
SF Symbols is a typeface of icons. Each symbol is a glyph, sized and weighted to match SF Pro (Apple’s system font). When you render Image(systemName: "heart.fill"), you’re not loading an asset — you’re rendering text from a font file shipped with iOS.
Implications:
- Symbols scale with
.font()modifier exactly like text - Symbols inherit
foregroundStyleexactly like text - Symbols pair perfectly with
.body,.headline, etc. — they sit on the same baseline as adjacent text - Symbols cost zero asset weight (no PNGs in your bundle)
- Symbols update with iOS — when iOS adds a new variant, your app gets it for free
Browsing and naming
Download the SF Symbols app from Apple. The app browser shows every symbol with its canonical name (heart, heart.fill, heart.circle, heart.circle.fill, heart.text.square).
The naming convention:
[concept].[variant].[shape].[fill/lines]
Examples:
heart→ outlineheart.fill→ filledheart.circle→ outline heart inside outline circleheart.circle.fill→ filled circle backgroundxmark.bin.fill→ trash with X (compound concept)
When you can’t remember a name, open the SF Symbols app and search by keyword — “delete,” “user,” “send.”
Symbol variants
The same symbol comes in multiple style families. SwiftUI exposes these via .symbolVariant():
// Outline (default)
Image(systemName: "heart")
// Filled
Image(systemName: "heart").symbolVariant(.fill)
// or just
Image(systemName: "heart.fill")
// Slash (e.g. "muted")
Image(systemName: "speaker.slash")
// Circle
Image(systemName: "person.circle")
In a Label, variants propagate automatically — useful for tab bars where all icons should be filled when selected:
TabView {
Tab("Home", systemImage: "house") { HomeView() }
Tab("Profile", systemImage: "person") { ProfileView() }
}
.symbolVariant(.fill) // all tab icons become filled
Rendering modes — the four colorings
let icon = Image(systemName: "cloud.sun.rain.fill")
// 1. Monochrome — single tint
icon.symbolRenderingMode(.monochrome).foregroundStyle(.blue)
// 2. Hierarchical — single tint, opacity layers (drops 100/50/25%)
icon.symbolRenderingMode(.hierarchical).foregroundStyle(.blue)
// 3. Palette — multiple distinct colors
icon.symbolRenderingMode(.palette)
.foregroundStyle(.gray, .yellow, .blue) // cloud, sun, rain
// 4. Multicolor — Apple's preset multicolor
icon.symbolRenderingMode(.multicolor)
Use hierarchical as the default for system UI — it’s the most legible across light/dark. Use palette when you need brand colors on a multipart symbol. Use multicolor sparingly for visual emphasis.
Symbol effects (iOS 17+)
Animations baked into the symbol itself:
@State private var liked = false
Image(systemName: liked ? "heart.fill" : "heart")
.symbolEffect(.bounce, value: liked)
.foregroundStyle(liked ? .red : .secondary)
.onTapGesture { liked.toggle() }
The catalogue of effects (iOS 17+, expanded in 18):
| Effect | What it does |
|---|---|
.bounce | One-time scale-up bounce |
.pulse | Continuous opacity pulse |
.variableColor | Per-layer color animation (great for activity indicators) |
.scale | Scale up/down on state change |
.appear / .disappear | Animated mount/unmount |
.replace | Smooth morph from one symbol to another (iOS 17+) |
.wiggle | Subtle wiggle (iOS 18+) |
.breathe | Slow breathing animation (iOS 18+) |
.rotate | Rotation (iOS 18+) |
// Replace effect: smoothly morph between symbols
Image(systemName: liked ? "heart.fill" : "heart")
.contentTransition(.symbolEffect(.replace.byLayer))
These look professional, hit 60fps, and ship in a single line. Don’t roll your own.
Sizing and weight
Symbols inherit font sizing:
Image(systemName: "star.fill")
.font(.title2) // sets size
.fontWeight(.bold) // sets weight
// or together:
Image(systemName: "star.fill")
.imageScale(.large)
.symbolRenderingMode(.hierarchical)
imageScale (.small, .medium, .large) is a relative scale on top of the current font size. Combined with Dynamic Type, your icons scale automatically when the user bumps text size in Settings.
Custom SF Symbols
When you genuinely need a custom icon (brand mark, domain-specific glyph), draw it in Figma, export as SVG, then import into the SF Symbols app:
- Open SF Symbols app
- File → Export… any existing symbol as a template (e.g.
heart.svg) - Open the SVG in Figma; replace the path with your custom glyph (keep the canvas frame, baseline guides)
- Export back as SVG
- SF Symbols app → File → Open → your SVG → adjust by weight/scale → File → Export as
.svg(Symbol) - Drop the
.svginto Xcode’s Asset Catalog (Symbol Asset, not Image Asset) - Reference:
Image(systemName: "my.custom.symbol")— yes, even custom symbols usesystemNameif dropped in Asset Catalog as a symbol
Custom symbols inherit all the rendering modes and effects. They are first-class citizens.
Accessibility
SF Symbols are automatically labeled by VoiceOver based on their name (“heart, filled”). Override when needed:
Image(systemName: "heart.fill")
.accessibilityLabel("Add to favorites")
For decorative-only icons (e.g. inline with a labeled button), mark them:
Button {
save()
} label: {
Label("Save", systemImage: "tray.and.arrow.down")
}
// → Label handles accessibility: VoiceOver reads "Save, button"
// → The image is decorative
In the wild
- Apple Mail is SF Symbols top to bottom — sidebar icons, toolbar buttons, swipe action icons. Open it as the reference implementation.
- Apple Health mixes SF Symbols (system actions) with custom symbol assets (specific organs, vitals) — all using
Image(systemName:). - Cash App uses custom SF Symbols for its dollar-sign branding alongside system symbols in nav.
- Notion for iOS uses SF Symbols across almost every UI surface — their migration from custom PNGs to SF Symbols reportedly saved ~3 MB of app size.
Common misconceptions
- “SF Symbols are too generic — they make my app look like every other app.” That’s the point of system controls. Use SF Symbols for system-task icons (back, share, settings) and reserve custom illustration for hero/marketing surfaces.
- “SF Symbols can’t be colored.” They absolutely can — see palette and multicolor rendering modes.
- “I need to ship PDF/PNG fallbacks for older iOS.” SF Symbols ship back to iOS 13. If you’re targeting iOS 15+, you can use any symbol introduced in SF Symbols 4 (2022). Filter the SF Symbols app by deployment target.
- “Custom SF Symbols are too much work.” A one-time 30-minute setup per custom glyph. After that, free animations, free Dark Mode, free Dynamic Type. Cheaper than rolling your own image system.
- “Symbol effects are eye candy I should turn off for performance.” They’re GPU-accelerated and cheap. Use them — they’re a free quality signal.
Seasoned engineer’s take
The single biggest delta between an app that feels Apple-native and one that feels third-party is how the icons are handled. Use SF Symbols. Use the rendering modes. Use the symbol effects. The reviewer who would have given you 4 stars gives 5 because the heart bounces correctly.
When a designer hands you a custom icon for a system task (back, settings, share, send), push back: “Is there a reason we’re not using the system SF Symbol for this?” Often the answer is “I forgot SF Symbols had one” — and you save yourself an export pipeline.
Keep an eye on each year’s WWDC for new SF Symbols. Apple adds 500+ per release. Symbols you wanted three years ago may now exist.
TIP: SF Symbols app → Sample tab — lets you preview symbols with custom text size, weight, color, and rendering mode side by side. Use it before committing to a symbol.
WARNING: Don’t use SF Symbols outside Apple platforms (no Android, no web) unless you own a license — Apple’s terms restrict use. For cross-platform brand symbols, draw custom.
Interview corner
Junior-level: “What is SF Symbols?”
Apple’s icon system — a glyph font shipping with every Apple OS, accessible via Image(systemName:). Around 6,900 symbols, free, animatable, scale with Dynamic Type.
Mid-level: “What’s the difference between hierarchical and palette rendering modes?”
Hierarchical uses one tint at varying opacity to imply layers (single color brand-friendly). Palette uses multiple distinct foregroundStyles, one per layer of the symbol — useful when symbol parts represent different semantic meanings (cloud=gray, sun=yellow).
Senior-level: “Your designer wants a custom logo as an icon. Walk me through your pipeline.”
Draw in Figma at SF Symbols template grid sizes. Export as SVG. Import to SF Symbols app, align baselines and weight axes. Export as .svg symbol asset. Drop into Xcode Asset Catalog as Symbol. Use via Image(systemName:). Custom symbols inherit .symbolRenderingMode, Dynamic Type, and color modifiers — write once, ship to every accessibility setting.
Red flag in candidates: Reaching for PNG sprite sheets or custom font rendering for icons in 2025. Means they haven’t kept up with the platform.
Lab preview
You’ll consume SF Symbols extensively in Lab 3.1 — Figma to SwiftUI and audit incorrect icon usage in Lab 3.2.
Next: 3.5 — Adaptive design: Dark Mode & Dynamic Type
3.5 — Adaptive design: Dark Mode & Dynamic Type
Opening scenario
Your CEO uses Dark Mode and the largest accessibility text size. You show her the new feature in TestFlight. The button text overflows the button. The white background blinds her. The “subtle gray” caption is invisible. She closes the build, says “looks broken,” and moves on.
Two settings — Settings > Display & Brightness > Dark and Settings > Accessibility > Display & Text Size. Both ship by default on iOS. Both are toggles your designer probably did not check. Your job is to make the app look excellent in every combination — light/dark × XS through XXXL text — before it ships.
| Adaptation | What changes | API surface |
|---|---|---|
| Color appearance | Light / Dark / Increased Contrast | colorScheme, Asset Catalog appearances |
| Dynamic Type | xSmall → AX5 (12 sizes) | font(.body) + Dynamic Type-aware fonts |
| Reduced motion | Disable spring/parallax | \.accessibilityReduceMotion |
| Reduced transparency | Replace materials with solid | \.accessibilityReduceTransparency |
| Bold text | All text becomes bold | Automatic for system fonts |
Concept → Why → How → Code
Dark Mode — the basics
Detect the current appearance:
struct MyView: View {
@Environment(\.colorScheme) var colorScheme
var body: some View {
Image(colorScheme == .dark ? "logo-dark" : "logo-light")
}
}
But you almost never need to branch on colorScheme directly — use Asset Catalog colors and the system semantic colors instead:
Color(.systemBackground) // Adapts automatically
Color("Brand") // Asset Catalog: Any + Dark appearances → adapts automatically
Branch on colorScheme only when:
- You need a different image (not just tint)
- You need a different layout (rare)
- You’re computing a derived color that the Asset Catalog can’t express
Dark Mode pitfalls
// ❌ Wrong — hardcoded white on white
ZStack {
Color.white // breaks in dark mode
Text("Hello")
.foregroundStyle(.black) // breaks in dark mode
}
// ✅ Right — semantic
ZStack {
Color(.systemBackground)
Text("Hello")
.foregroundStyle(.primary)
}
Shadow opacity needs adjustment for dark mode (less effective on dark bg):
.shadow(
color: Color.black.opacity(colorScheme == .dark ? 0.4 : 0.1),
radius: 4
)
Or use Asset Catalog “Shadow Color” with appearance variants.
Forcing appearance per scene (rare, but useful)
// Force dark mode for a single view
MyView()
.preferredColorScheme(.dark)
// At the scene level (App entry)
WindowGroup {
ContentView()
.preferredColorScheme(.dark) // ignores user setting
}
Use sparingly — fighting the user’s preference annoys them. Acceptable cases: a dedicated dark-themed app (Halide camera), a video player, an immersive reader mode where the user opts in.
Dynamic Type — the scale
Apple’s Dynamic Type slider has 7 standard sizes (xS, S, M, L, default, xL, xxL, xxxL) and 5 accessibility sizes (AX1–AX5). At AX5, your .body text grows from 17pt to ~53pt. Headlines grow proportionally.
The rule: never use .font(.system(size: 17)) — it doesn’t scale. Use:
Text("Hello").font(.body) // scales
Text("Hello").font(.headline) // scales
Text("Hello").font(.title) // scales
// For custom fonts: use relativeTo:
Text("Hello").font(.custom("Inter", size: 17, relativeTo: .body))
Limit growth (when needed)
Buttons in nav bars can’t grow to AX5 size — they’d overflow. Cap them:
Text("Done")
.font(.body)
.dynamicTypeSize(...DynamicTypeSize.accessibility2)
This means “scale up to AX2, then stop.” Use this for fixed-height UI (tab bars, nav bars, toolbar buttons). Never use it for content text — capping body content breaks accessibility.
Layout strategies for large text
Three patterns to handle large text gracefully:
1. Vertical fallback at large sizes
struct AdaptiveLabel: View {
let icon: String
let title: String
@Environment(\.dynamicTypeSize) var typeSize
var body: some View {
if typeSize.isAccessibilitySize {
VStack {
Image(systemName: icon)
Text(title)
}
} else {
HStack {
Image(systemName: icon)
Text(title)
}
}
}
}
SwiftUI ships this built into Label for free — but custom layouts may need this logic.
2. ViewThatFits
ViewThatFits {
HStack { Image("icon"); Text("Long label that might overflow") }
VStack { Image("icon"); Text("Long label that might overflow") }
Image("icon") // last resort, drop the label
}
SwiftUI picks the first child that fits. Free responsive design with zero conditional code.
3. Wrap, don’t truncate
Text("A long string that should wrap rather than truncate")
.lineLimit(nil) // allow unlimited lines
.fixedSize(horizontal: false, vertical: true)
The fixedSize trick is the classic SwiftUI escape from “my text is being cut off because the parent doesn’t give it enough height.”
Testing — Environment Overrides in Xcode
Run your app in Simulator. In Xcode’s debug bar:
- Environment Overrides button (looks like a slider)
- Toggle Interface Style: Light / Dark
- Toggle Text Size: drag the slider through every value
- Toggle Increase Contrast, Bold Text, Reduce Motion
You can flip these while the app is running. Cycle through them on every PR. If anything breaks, fix before merge.
Other accessibility adaptations
// Respect reduced motion — disable parallax, swap spring for linear
@Environment(\.accessibilityReduceMotion) var reduceMotion
withAnimation(reduceMotion ? .linear(duration: 0.1) : .spring()) {
offset = newValue
}
// Respect reduced transparency — use solid instead of material
@Environment(\.accessibilityReduceTransparency) var reduceTransparency
.background(reduceTransparency ? Color(.systemBackground) : nil)
.background(reduceTransparency ? nil : .ultraThinMaterial)
Color contrast — increased contrast mode
iOS has an Increased Contrast setting (Settings → Accessibility → Display & Text Size → Increase Contrast). When on, iOS darkens text, brightens backgrounds, and amplifies separators. Asset Catalog supports a third appearance variant: Any, Dark, High Contrast:
- Open your Color in Asset Catalog
- Attributes → Appearances → “Any, Dark, High Contrast”
- You now have four color slots: Light, Dark, Light-HiContrast, Dark-HiContrast
Provide values for all four. Test by toggling Increase Contrast.
In the wild
- Apple Notes is the textbook example: every text scales, every color adapts, the toolbar gracefully wraps at AX5. Try it.
- Twitter (old) was notorious for breaking at AX3+ because the timeline cells had fixed heights. Lesson: don’t pin heights in scrollable content.
- The Wirecutter app ships with adaptive layouts that switch from 3-column to 1-column at AX2 — read NYT engineering blog for the case study.
- Apple Maps has a custom “Increase Contrast” map style that activates with the system setting — high-end adaptive design.
Common misconceptions
- “Dark mode = invert colors.” No. Dark mode usually uses a desaturated near-black, with brand colors less saturated than their light-mode counterparts.
- “Most users don’t change text size.” Wrong. Apple has cited 30%+ of users adjust text size. Older demographics: closer to 50%.
- “Accessibility text sizes are for blind users.” No — VoiceOver is for blind users. Dynamic Type is for low-vision and aging-eyes users, a much larger group.
- “I’ll add Dark Mode in v2.” You won’t. Dark Mode adoption requires touching every screen. Build it from day one.
- “
@Environment(\.colorScheme)is what I use to handle dark mode.” Only as a last resort. Asset Catalog colors and semantic system colors are the right tool 95% of the time.
Seasoned engineer’s take
The first PR test I add to any new view: a screenshot test that captures the view at light/dark × default/AX3. If something visually breaks in any combination, the test fails. (Use snapshot-testing — it’s the standard.)
Reality check: at AX5, your design will look ridiculous if it was designed for default size. That’s not your bug — that’s a designer who didn’t consider accessibility. The fix is ViewThatFits + .dynamicTypeSize(...) caps on chrome + unlimited line wrap on content. Don’t ship a design that fits only default size.
The best teams have a Friday ritual: open the app at AX5 + Dark + Increased Contrast + Reduced Motion. Use it for 15 minutes. Note what breaks. File issues. Fix on Monday.
TIP: Add a debug menu in your app to toggle these settings without leaving the app. Saves hours during development.
WARNING: Never set
.font(.system(size: ...))in shipped code. If you must use a fixed size (rare — measure twice), document why. Default rule: every.font(...)reads as a semantic style.
Interview corner
Junior-level: “How do you support Dark Mode in SwiftUI?”
Asset Catalog colors with Any/Dark appearance variants, and semantic system colors (Color(.systemBackground), .primary/.secondary foreground styles). Avoid hardcoded colors. Avoid branching on colorScheme unless asset variants can’t express what you need.
Mid-level: “What’s Dynamic Type? Walk me through how you’d support it.”
iOS user-controlled text scaling, 12 sizes including 5 accessibility sizes. Support it by always using semantic font styles (.body, .headline, etc.) and relativeTo: for custom fonts. Test with Environment Overrides at AX3+. Use ViewThatFits for layouts that need to reflow. Cap chrome text with .dynamicTypeSize(...).
Senior-level: “How would you build a CI check that prevents Dark Mode and Dynamic Type regressions?”
Snapshot tests using swift-snapshot-testing. For each major screen, capture screenshots at (light, dark) × (default, AX3) — 4 snapshots each. Run on every PR. Diffs fail the build. Reviewer must approve visual changes explicitly. Optional: pixel-perfect diff vs Figma export.
Red flag in candidates: Saying they “don’t really test dark mode, the designer signs off.” Tells you they ship broken adaptive UX.
Lab preview
You’ll fix adaptive design bugs in Lab 3.2 — HIG & Accessibility Audit, and verify your palette across modes in Lab 3.3.
Next: 3.6 — Accessibility in design
3.6 — Accessibility in design
Opening scenario
Apple’s App Store editorial team reviews your app for a feature spot. They turn on VoiceOver. Nothing reads. They turn up text size. Buttons overflow. They check tap targets — your icons are 24×24. You don’t get featured.
Then a class-action ADA lawsuit gets filed against your industry vertical. Apps without basic accessibility are getting sued in the US, the EU, and Brazil. The cost of not shipping accessibility is now larger than the cost of shipping it.
This chapter teaches the design-side accessibility rules: contrast ratios, tap target sizes, VoiceOver annotations, and the handoff between designer and engineer.
| Standard | Source | Enforced by |
|---|---|---|
| WCAG 2.1 AA | W3C | Lawsuits (ADA, EAA), Apple A11y team |
| Apple HIG accessibility | Apple | App Review, feature curation |
| 44×44pt minimum tap target | HIG | UX & lawsuit risk |
| 4.5:1 contrast (normal text) | WCAG 2.1 AA | Same |
| 3:1 contrast (large text 18pt+ or 14pt+ bold) | WCAG 2.1 AA | Same |
Concept → Why → How → Code
Contrast ratios — the math you don’t have to do
WCAG defines contrast as the ratio between foreground and background luminance. The thresholds:
| Text type | Minimum AA | Minimum AAA |
|---|---|---|
| Normal text (<18pt or <14pt bold) | 4.5:1 | 7:1 |
| Large text | 3:1 | 4.5:1 |
| UI components (icons, borders) | 3:1 | — |
Tools to check:
- Contrast by Sam Soffes — Mac app, free; menu bar utility, pick two pixels, get ratio
- Stark — Figma plugin, audits your whole file
- Accessibility Inspector — built into Xcode, runs on a live simulator
- Web: WebAIM Contrast Checker
The fast workflow: in Figma, install Stark; in Xcode dev, use Accessibility Inspector → Audit. Both catch the same problems pre-merge.
Minimum tap target — 44×44pt
Apple’s hard rule: any interactive element must have a tap target of at least 44×44pt. The visual size can be smaller (a 24×24pt icon is fine), but the tappable area must extend to 44×44.
// ❌ Visually small AND tap-area small — fails 44pt minimum
Image(systemName: "xmark")
.onTapGesture { dismiss() }
// ✅ Visual 24pt, tap area 44pt
Button { dismiss() } label: {
Image(systemName: "xmark")
.imageScale(.medium)
}
.frame(width: 44, height: 44)
// ✅ Even simpler: use .contentShape() to expand hitbox without changing layout
Image(systemName: "xmark")
.frame(width: 44, height: 44)
.contentShape(Rectangle())
.onTapGesture { dismiss() }
In Figma, designers should always show a transparent 44×44pt hit-target rectangle around small icons during dev-handoff. If they don’t, flag it in PR review with a screenshot of HIG’s accessibility section.
Touch target spacing
Two tap targets need at least 8pt of space between them (often 16pt is better). Stacked buttons that share a border violate this — users fat-finger the wrong one.
VoiceOver — what designers need to spec
VoiceOver reads the screen aloud, navigated by swipe-right/swipe-left to walk through elements. Each interactive or informational element must have:
- Label — what the element is (“Like button,” “Profile photo of Alex,” “5 unread messages”)
- Trait — its role (button, link, header, image, adjustable)
- Value (for adjustable elements) — current state (“3 of 5 stars”)
- Hint (optional) — what tapping it does (“Opens user profile”)
In SwiftUI:
Image(systemName: "heart.fill")
.accessibilityLabel("Like")
.accessibilityAddTraits(.isButton)
.accessibilityHint("Likes this post")
// For toggles/sliders
Toggle("Notifications", isOn: $enabled)
.accessibilityValue(enabled ? "On" : "Off") // SwiftUI does this automatically for Toggle
// Decorative-only (e.g. background pattern)
Image("backgroundPattern")
.accessibilityHidden(true)
The designer’s job: annotate the Figma file with VoiceOver labels for every screen. Use Figma comments or a dedicated “A11y” page with the screen + label callouts. Without these annotations, engineers guess — and often guess wrong.
Annotation layers in Figma
Best-practice Figma file structure for accessibility:
Page: "Feed Screen"
Frame: ✅ Ready for dev
[your design]
Frame: A11y annotations
[same design with red callouts: "Label: 'Profile photo of Alex'", "Trait: button"]
The annotation page is the engineer’s spec. Designers using Stark or the A11y Annotations plugin generate these in seconds.
Reading order
VoiceOver walks elements in visual order by default — left-to-right, top-to-bottom. When you have a custom layout that breaks this (e.g. a floating action button positioned mid-screen), explicitly set order:
.accessibilitySortPriority(1) // higher = read first
Or for an entire view group, override the order:
VStack {
Text("Title")
Image("hero")
Text("Body")
}
.accessibilityElement(children: .combine)
// → reads as one continuous element instead of three
Common accessibility design mistakes
- Color-only state indicators: “the red dot means unread.” A colorblind user (~8% of men, ~0.5% of women) can’t see red vs gray. Add a glyph or label.
- Placeholder-only labels: a text field with placeholder “Email” and no label disappears once the user types. Use
TextField("Email", text:)with proper label. - Tiny disabled states: a disabled button at 20% opacity may fail 3:1 contrast. WCAG actually exempts disabled controls — but if it’s the only path forward in the UX, this UX is broken.
- Auto-playing audio/video with no controls: WCAG violation; Apple actively rejects.
- Fixed-size text (covered in 3.5).
- Animations triggered without user input that can’t be disabled (covered in 3.5 via Reduce Motion).
The Accessibility Inspector
Built into Xcode. Run your app in Simulator, then in Xcode menu: Open Developer Tool → Accessibility Inspector.
Three tabs:
- Inspect: hover any pixel; see the element, its label, trait, value
- Audit: runs a checklist (contrast, tap target size, missing labels) on the entire screen — use this before every PR
- Settings: simulate VoiceOver, Increased Contrast, Reduced Transparency from your Mac
The Audit tool is the single highest-ROI accessibility tool. Run it. Fix what it flags. Re-run.
In the wild
- Apple’s own Accessibility team maintains a WWDC session library — start with “Catalog accessible apps” (WWDC23) and “Build accessible apps with SwiftUI and UIKit” (WWDC22).
- Apple Maps has a dedicated VoiceOver mode that announces turn-by-turn with cardinal directions, gates, and obstacles. Best-in-class implementation.
- Twitter was sued in 2020 for not labeling images in tweets; result was the image-description field. Lawsuit-driven accessibility is real.
- Domino’s Pizza lost a US Supreme Court case in 2019 because its app wasn’t accessible (Robles v. Domino’s Pizza). Set legal precedent that ADA covers iOS apps.
- Stark is the de facto Figma accessibility plugin — used at Microsoft, Shopify, Airbnb. Worth the $12/mo team fee.
Common misconceptions
- “Accessibility is for blind users.” No. VoiceOver users are ~1-2% of iOS. Dynamic Type users are 30%+. Reduced Motion is 5-10%. Captioning helps deaf and language learners. Accessibility = inclusive design.
- “Accessibility is the engineer’s job.” It starts with the designer. If contrast fails in Figma, no amount of engineering rescues it.
- “AAA is the gold standard; we should aim for it.” AAA is borderline impossible (7:1 contrast on body text) for branded UIs. AA is the ship target. AAA is for specific accessibility-focused apps.
- “VoiceOver labels will sound robotic — designers should care about copy.” They should. Labels are UX copy. “Profile” vs “Profile photo of Alex” — the latter is correct.
- “Compliance is enough.” Compliance avoids lawsuits. Excellence comes from designing with disabled users (not just for them). Apple’s accessibility team includes engineers who use these features daily — copy their approach.
Seasoned engineer’s take
Accessibility is the cheapest brand win in iOS. The investment is small (mostly discipline + labels + sane defaults), and the upside includes feature spots, awards, and lawsuit avoidance.
If your team doesn’t budget time for accessibility, just do it anyway as part of the work. Adding .accessibilityLabel("...") to a button takes 10 seconds. It will not show up on velocity metrics but it will show up in the Audit Inspector being green and your reviewers being happy.
The Friday ritual: open your app with VoiceOver on. Try to use the new feature without looking at the screen. If you can’t complete the task, your users can’t either. (You’ll be horrified the first time. That’s normal.)
TIP: Add
--accessibility-audit-failto your XCUITest CI step (iOS 17+ XCUIApplication hasperformAccessibilityAudit()that throws on failure). Cheap CI gate.
WARNING: Don’t ship
accessibilityLabel("")(empty) to “hide” an element — use.accessibilityHidden(true). Empty label reads as “blank, button” which is worse than no label.
Interview corner
Junior-level: “What’s the minimum tap target size on iOS?”
44×44pt per Apple HIG. Visual size can be smaller, but the tappable area must extend to 44×44 via .frame() and .contentShape().
Mid-level: “How do you make a SwiftUI screen accessible?”
Every interactive element gets .accessibilityLabel; decorative-only get .accessibilityHidden(true); groups that should read as one use .accessibilityElement(children: .combine). Test with Accessibility Inspector Audit; verify Dynamic Type up to AX3; check contrast ≥ 4.5:1 for text. Use VoiceOver in Simulator to walk through.
Senior-level: “How would you enforce accessibility standards across a 50-person iOS team?”
(1) Designers run Stark in Figma; failed designs get returned. (2) Engineers run Accessibility Inspector Audit pre-PR; CI runs XCUIApplication performAccessibilityAudit() and fails on regressions. (3) Custom SwiftLint rule banning Color(red:) and bare Image() without accessibilityLabel. (4) Quarterly accessibility audit with disabled users (paid, real users — there are agencies for this). (5) Accessibility champion role rotating between engineers. (6) Public commitment in App Store description so the brand stays accountable.
Red flag in candidates: “We’ll add accessibility once we have a designer who knows it.” Means it never ships.
Lab preview
You’ll fix 6 deliberate HIG and accessibility violations in Lab 3.2 — HIG & Accessibility Audit.
Next: 3.7 — Exporting assets from Figma
3.7 — Exporting assets from Figma
Opening scenario
Designer says: “I dropped the new icons in the Figma file, they’re ready.” You open Figma. You see 14 frames, none labeled with an export size, half of them are inside components without export presets, and the icons are at random pixel sizes (23×27, 31×31, 44×48). You ask the designer how to export. They say “just right-click and export, it’s easy.”
Three hours later you have 42 PNGs at the wrong sizes, no PDF/SVG vectors, no organization in Xcode Assets, and three blurry retina assets because the source was already too small.
This chapter is the asset export discipline that separates “ships icons in 10 minutes” from “spends a sprint fighting Figma.”
| Format | When to use | Xcode Asset Catalog |
|---|---|---|
| PDF (single-scale, preserve vectors) | Most icons, illustrations | Single PDF, “Preserve Vector Data” checked |
| SVG → SF Symbol | Custom icons matching SF Symbols style | Symbol Asset |
| PNG @1x/@2x/@3x | Raster photos, complex illustrations | Image Set with 3 slots |
| App Icon (1024×1024 PNG) | App icon only | App Icon Set |
| Asset Catalog Color | Brand colors | Color Set |
Concept → Why → How → Code
PDF vector — the default for icons
Use case: any flat illustration or icon that has clean paths (no photo content, no soft gradients with millions of colors).
Why PDF: ships once, scales to any size, supports light/dark via Asset Catalog appearances, smaller than 3 PNG slices, future-proof.
How:
- In Figma, select the icon
- Right sidebar → Export section → click
+ - Set format: PDF
- Scale: 1x (PDF is vector — 1x is correct)
- Click Export
- In Xcode: drag the PDF into Asset Catalog
- Select the asset → Attributes Inspector → Resizing: Preserve Vector Data ✅
- Set Scales: “Single Scale”
- Reference:
Image("iconName")
Image("settings-gear") // single PDF, scales perfectly
.resizable()
.frame(width: 24, height: 24)
PNG @1x/@2x/@3x — for raster
Use case: photos, screenshots, complex multi-color illustrations that don’t reduce to clean paths.
Why: Apple’s screens are 1x (very old, basically extinct), 2x (most iPhones, iPad), 3x (Pro Max). Provide all three or you ship blurry assets on the wrong device.
How:
- In Figma, select asset
- Export panel → format PNG → add three export rows: 1x, 2x, 3x
- Suffix convention:
@2x,@3x— Figma adds these automatically - Filenames:
hero.png,hero@2x.png,hero@3x.png - In Xcode: New Image Set → drop the three files into the 1x/2x/3x slots
- Reference:
Image("hero")(no suffix in code)
SVG → custom SF Symbol
For icons that follow SF Symbols visual style (line weight, stroke, baseline), prefer custom SF Symbols (covered in 3.4). They inherit Dynamic Type, rendering modes, and effects for free.
App icon — its own beast
App icon is a single 1024×1024 PNG. iOS generates all derived sizes (60×60, 76×76, 120×120, etc.) automatically since Xcode 14.
Rules:
- 1024×1024 px, no transparency, no alpha channel — must be a solid square
- sRGB color profile (don’t ship P3 unless you intend to and provide a fallback)
- No rounded corners — iOS rounds for you (rounded squircle, technically)
- No “alpha mask” — solid background pixel-to-pixel
Asset Catalog → New App Icon → drop the 1024 PNG into the iOS slot.
For Liquid Glass / iOS 26 App Icon (introduced WWDC25), you’ll also need a “Tinted” and “Dark” variant — iOS now supports up to three icon styles per app. Add them in the same App Icon Set.
Designer’s export pre-flight checklist
Send your designer this list:
- All icons live in components, not loose frames
-
Components have a unique, clear name (
icon/settings, notFrame 47) - Components have export settings configured (PDF 1x by default for icons)
-
Filename is the asset’s intended Asset Catalog name (
settings-gear) - Icons sit on a clean pixel grid (use 24×24 or 28×28 standard)
- Color is from the design system (so it adapts to dark mode via Asset Catalog)
- An “exports” page collects everything ready to ship
Following the checklist, exporting becomes one click per icon. Without it, every export becomes a negotiation.
Asset Catalog organization
Treat Asset Catalog like a folder structure. As your asset count grows, use folders (right-click → New Folder) and namespaces:
Assets.xcassets/
Colors/
Brand/
brandPrimary.colorset
brandAccent.colorset
Surface/
surface.colorset
surfaceElevated.colorset
Icons/
Tab/
home.imageset
profile.imageset
Onboarding/
welcome-hero.imageset
AppIcons/
AppIcon.appiconset
AppIcon-Beta.appiconset
Enable “Provides Namespace” on the folder → reference becomes Image("Icons/Tab/home"). Prevents name collisions.
Generated Asset Symbols (Xcode 15+)
Xcode 15 introduced typed asset symbols — never type a string-name asset key again.
- Asset Catalog → File Inspector → Asset Symbol Generation: Swift (also available: Objective-C)
- Build the project
- Now use:
// Old, string-based, runtime crash if name changes
Image("brandPrimary")
Color("brandPrimary")
// New, compile-time-checked
Image(.brandPrimary)
Color(.brandPrimary)
Rename or delete an asset → compile error at every call site. Use this everywhere — it’s free quality.
Avoiding the dreaded Image not found
The single most common Xcode crash log: Image "blah" not found in bundle. Causes:
- Asset name typo (fixed by generated symbols)
- Asset is in a separate target’s bundle (
Image("name", bundle: .module)for Swift Packages) - Asset is in a different Asset Catalog and target membership is wrong (check File Inspector → Target Membership)
- App extension can’t see app’s Asset Catalog (extensions have their own bundles — duplicate assets or use shared frameworks)
Exporting from Figma at scale — plugins
For 50+ icons, manual export is misery. Plugins:
- Figma Tokens / Tokens Studio — for colors and spacing, not images
- Iconify — pulls in established icon sets
- Figmaport — batch export with naming
- Figma REST API — write a Node script: fetch all components from a page, export PDFs in bulk, drop into Asset Catalog
For an org with 500+ assets, you build the pipeline once. Designer ships components → CI fetches → assets land in repo → typed Image(.foo) works on next build.
macOS / multi-platform asset gotcha
On macOS, app icons require many more sizes (16, 32, 128, 256, 512 — all at 1x and 2x). Set the App Icon Set to macOS App Icon Set type; Xcode shows all slots. You can also enable “Automatic Generation” (Xcode 14+) to derive macOS sizes from the 1024 PNG, but verify the result — at 16×16, automatic downscale often looks muddy.
In the wild
- Apple’s stock apps are almost entirely SF Symbols + PDF assets — minimum raster footprint.
- Discord, Slack, and other chat apps ship most stickers/emoji as PDF/SVG to save bundle size.
- Robinhood uses Figma Tokens + a custom script to push 1000+ icons to their iOS app on every design release.
- NYTimes open-sourced parts of their asset pipeline (https://github.com/NYTimes/figma-asset-pipeline — naming may differ; search “NYT Figma asset pipeline”).
Common misconceptions
- “PNG is fine for icons.” It usually isn’t. PDF is one file, smaller, scales, and supports Dark Mode via Asset Catalog appearance. Use PDF unless the content is genuinely photographic.
- “Export at 1x and Xcode scales.” Xcode does NOT auto-scale PNG. You must ship @2x and @3x for raster. Vector formats (PDF, SVG) scale; raster formats do not.
- “Asset Catalog is just folders.” It’s a build-time compilation system. Assets get compressed, optimized, and bundled into a single
.carfile. Adding 1000 assets is fine; loading them at runtime is fast. - “App icon needs transparency.” It does not, ever. Solid square only. iOS rounds.
- “I can store images in Resources/” outside Asset Catalog. You can, but you lose: appearance variants, scale-slot management, asset symbol generation, on-demand resources, and ad-targeting (App Slicing). Always use Asset Catalog.
Seasoned engineer’s take
Asset Catalog generated symbols + PDF-by-default + a Figma component library = a 5x speedup on every “add new icon” task. The first day on a new project, configure these. They pay back instantly.
The argument with designers about “PDF vs PNG” goes one of two ways: either they understand vectors and you agree on PDF, or they don’t and you need to gently explain why exporting a flat illustration as 3 PNG slices is a regression. Show them the file size delta (one PDF can be 4KB; the same illustration as 3 PNGs can be 80KB+). File size is a measurable user-facing metric — app size affects install conversion.
The most underused Asset Catalog feature: On Demand Resources. Tag heavy assets with a tag (level-2-art), download them at runtime only when needed. Cuts app download size for the long tail. Apple’s WWDC sessions on App Thinning are gold.
TIP: Use
xcrun assetutil --info Assets.car(run on your built app’s Assets.car) to audit what’s actually shipping. You’ll find dead assets and surprising size waste.
WARNING: Don’t bake brand colors into the asset itself. Export icons as black on transparent; tint at runtime via
.foregroundStyle(.brandPrimary). Otherwise you ship 5 copies of the same icon in 5 colors.
Interview corner
Junior-level: “What’s the right way to export an icon from Figma for iOS?”
PDF at 1x scale, dropped into Asset Catalog with “Preserve Vector Data” checked. Reference via generated asset symbol (Image(.iconName)).
Mid-level: “How do you handle assets for an app with light, dark, and high-contrast modes?”
Asset Catalog Color Sets with “Any, Dark, High Contrast” appearance variants. PDF/SVG icons exported as black-on-transparent and tinted at runtime so a single asset works for all three modes. Photos with mode-specific variants get an Image Set with appearance slots.
Senior-level: “You inherit a codebase with 800 unnamed PNG assets and no source Figma. How do you modernize?”
Audit: run xcrun assetutil to enumerate. Cross-reference with grep -r 'Image("' to find dead assets and orphans. Delete unused. For survivors, prioritize migration by usage frequency: top 50 → re-create as SF Symbols or PDF; remainder → keep PNG but generate Asset Symbols + set up a Figma library to re-source future variants. Track app size delta.
Red flag in candidates: Shipping icons as PNGs in Resources/ folder bypassing Asset Catalog. Means they’ve never thought about app size or appearance support.
Lab preview
You’ll export assets from a Figma file in Lab 3.1 — Figma to SwiftUI.
Next: 3.8 — Design handoff & collaboration
3.8 — Design handoff & collaboration
Opening scenario
A designer DMs you at 4:30pm Friday: “Hey, can you ship the new onboarding by Monday?” You open Figma. There are 23 frames, two pages, one branch with the “real” version, a Slack thread with 80 messages about copy revisions, three Loom videos of animation references, a PRD in Notion that’s two weeks stale, and a sign-off comment from product on the wrong frame.
You ship something Monday. The designer says “that’s not what I designed.” The PM says “that’s not what I approved.” The frames in Figma changed twice over the weekend.
Design handoff is a process problem, not a tool problem. Tools enable; process prevents disasters. This chapter is the playbook.
| Pillar | What it means |
|---|---|
| Single source of truth | One file, one branch, one status per frame |
| Status indicators | “WIP / Ready for dev / Shipped” — explicit, visible |
| Versioning | Branches for big changes, version history for everything |
| Async-first | Async comments default; sync meetings escalation only |
| Redlines & specs | Inline in Figma Dev Mode, not external docs |
Concept → Why → How → Code
The “ready for dev” status
Every Figma frame intended for engineering must be marked explicitly as ready. Figma supports this natively:
- Right-click a frame → Section settings → Status
- Choose: Draft, In Progress, Ready for Dev, Complete
- The status badge appears on the frame in Dev Mode
In Dev Mode, engineers can filter to only Ready for Dev frames. Designers can grep their own pages: “anything still in WIP that I shouldn’t have promoted?”
The rule: never implement a frame without a status, never change a Ready-for-Dev frame without coordination. Designers who change a “Ready” frame silently are the cause of half the world’s botched releases.
The branch model
Figma supports branches like git. Big design changes (multi-screen redesign, new feature flow) live in a branch:
- From main file → Branch → name it (
onboarding-v2) - Designer iterates on the branch
- Engineers can preview but don’t implement until merge
- Designer requests review → other designers comment → merge to main
- Engineers pick up implementation from main
Smaller tweaks (color adjustment, copy change) can happen on main with version naming: “v2.3 — copy revisions, see comment.”
Dev Mode workflow — the engineer’s daily
- Open Figma in Dev Mode
- Filter to “Ready for Dev” status (top filter)
- For each frame, inspect:
- Layout (Auto Layout structure → stack hierarchy)
- Tokens (variables panel → color and spacing names)
- Assets (download what you need at the right format)
- Code preview (starting point, not final)
- Ask any clarifying questions as Figma comments on the frame, not in Slack
- Implement
- Drop a Loom or screenshot reply on the comment thread once done: “Shipped in PR #1234”
Comments on the frame stay forever as design history. Slack messages die in 90 days. Future-you will thank present-you.
Redlines without redlines
Old workflow: designer drew red boxes with “8pt”, “16pt”, “20pt” annotations. Dead since Figma Dev Mode (2023). Now:
- Engineer hovers a layer → see padding/margin to siblings automatically
- Holds ⌥ → measures distance to any other layer
- Selects two layers → spacing between them shows up
Designers should not be drawing redlines anymore. If yours is, send them a Figma Dev Mode tutorial video.
Specs that aren’t visual
Some things Figma can’t show:
- States: empty, loading, error, pending, retry
- Animations: transitions between states
- Edge cases: very long text, zero items, max-int amounts
- Behavior: tap, long-press, drag, pull-to-refresh
- Copy: localization, plurals, gender forms
- Accessibility: VoiceOver labels, traits
Each of these needs explicit specification. Where:
| Spec | Where it lives |
|---|---|
| States | Additional Figma frames: “loading state,” “empty state,” “error” |
| Animations | Linked Loom or Principle / Rive file |
| Edge cases | Figma frames with deliberately extreme content |
| Behavior | Figma frame comments OR PRD section |
| Copy variants | Linked Lokalise / Localizable.strings / spreadsheet |
| A11y | Annotation page in Figma (covered in 3.6) |
If a designer hands you a single “happy path” frame and nothing else, the design is incomplete. Push back: “What’s the empty state? What if the API errors? What if the username is 30 characters?”
Design ↔ engineering PR review
Engineers review design PRs in Figma:
- Designer creates a branch
- Engineer reviews: “this Auto Layout structure would break on iPad — can we use a
LazyVGridpattern?” “This color uses a hex code, not a token — can we add it to variables?” - Designer iterates
- Merge
Designers review engineering PRs in the simulator:
- Engineer ships a TestFlight build with the new feature
- Designer opens it on device, compares to Figma frame
- Files Figma comments with screenshots: “8pt of bottom padding missing” / “wrong color token”
- Engineer fixes, ships another build
- Designer signs off
Both directions are cheap with the right tooling. The bottleneck is culture, not tools.
The handoff meeting
You don’t always need one. Async-first works for 80% of cases. When you do meet:
- Designer screen-shares the Figma file
- Walks each “Ready for Dev” frame: design intent, edge cases, states
- Engineer asks questions live, designer adds Figma comments capturing the resolutions
- 15-30 minutes per feature, weekly cadence
The meeting outputs Figma comments, not minutes in a Notion doc. Comments live where the work lives.
Tooling stack — what mature teams use
| Need | Tool |
|---|---|
| Source of truth | Figma (with branches) |
| Dev specs | Figma Dev Mode |
| Animation specs | Loom, Rive, Lottie |
| Component library | Figma library + Swift Package mirror |
| Token sync | Tokens Studio plugin + Style Dictionary |
| Design QA | TestFlight + Figma comments on screenshots |
| Long-form decisions | Notion / Linear ticket, linked from Figma frame |
| Live collaboration | FigJam for brainstorming; Figma for design |
You don’t need all of these. Pick the ones that solve your team’s bottlenecks.
Localization handoff — the often-ignored channel
Strings in Figma should reference the localization key, not just the English copy:
[onboarding.title]
Welcome to Acme
Designer maintains a strings file (often a Google Sheet or Lokalise project) where each key has translations. Engineers read keys from the spreadsheet via CI, generating Localizable.xcstrings (the modern Xcode 15+ format).
For Dynamic Type, designers should preview “DE” (German — verbose) and “JA” (Japanese — compact) variants in Figma. German strings ~30% longer than English; Japanese strings shorter. Layouts that work in English break in German.
In the wild
- Linear (the project management tool) ships its Figma library and Swift component library on the same release schedule — one PR touches both.
- Apple’s design team uses Figma extensively for app design specs (per public job postings). The handoff to Cupertino engineering teams reportedly uses Figma comments + screen recordings.
- Shopify open-sources Polaris — their design system. Worth reading their docs on contribution model: PRs to design tokens flow through a tooling pipeline.
- Notion’s Aristotle design system — case study on bidirectional design-engineering collaboration.
Common misconceptions
- “Handoff is the designer dumping a Figma link in Slack.” That’s not handoff; that’s a notification. Handoff is structured: status, specs for all states, redlines via Dev Mode, sign-off process.
- “Engineering shouldn’t push back on design.” Engineering must push back on design that can’t be reasonably implemented or that violates HIG. Designers welcome it from engineers who explain why.
- “Design QA is gating.” Treat design QA as collaborative review, not a gate. Otherwise designers become bottlenecks. Empower engineers to ship “design-approved patterns” without per-screen sign-off where possible.
- “Designers should learn to code.” Not necessarily. They should learn to spec well — which is a separate, valuable skill. Code is for engineers; specs are for designers.
- “This process is overkill for a small team.” Even 2 people benefit from explicit status labels. The cost is 30 seconds per frame; the payoff is zero “wait, was that ready?” Slack threads.
Seasoned engineer’s take
The best designer-engineer relationships I’ve seen all share one trait: they ship together. Designer ships the Figma, engineer ships the PR, both review each other’s work, both sign off. No gate, no over-the-fence handoff.
Cultural shift, not tool shift. The tools (Figma branches, Dev Mode, Tokens Studio) make it possible; the culture makes it happen. If your designer treats engineers as implementers, fix the culture first.
A short list of things to insist on, kindly but firmly:
- Status labels on every frame. Non-negotiable.
- Auto Layout — not pixel-pushing.
- Variables/tokens — not raw hex.
- All states — not just the happy path.
- Comments on frames, not DMs in Slack.
If a designer fights you on these, you’re not at a serious shop. (Or the designer is junior — coach them.)
TIP: Use Figma’s “Observe” feature when pair-debugging design issues — your cursor follows the designer’s in real time. Better than screen-share for design reviews.
WARNING: Don’t implement from screenshots. Always work from the live Figma. Screenshots go stale the moment the designer iterates.
Interview corner
Junior-level: “How do you know a design is ready for you to implement?”
The frame has a status of “Ready for Dev” set by the designer in Figma. Specs exist for all states (loading, empty, error). I have asset exports and design tokens available. If anything is unclear, I drop a comment on the frame, not in Slack.
Mid-level: “Walk me through your design handoff process.”
Designer marks frames “Ready for Dev.” I open Dev Mode, inspect Auto Layout structure, download assets, note tokens. I implement, file Figma comments for anything ambiguous. I ship a TestFlight build; designer compares to Figma and files comments. I fix; designer signs off via a comment. We don’t have sync handoff meetings — it’s all async unless something complex needs a 15-min call.
Senior-level: “You join a team where design and engineering communicate badly. What do you change first?”
(1) Diagnose: where’s the actual friction? Spec quality? Status visibility? Sign-off ambiguity? (2) Lowest-cost win: get everyone using Figma frame status. (3) Tokens pipeline so color/spacing changes don’t require coordination. (4) Pair design and engineering on a sample feature end-to-end; the experience builds empathy. (5) Quarterly retros where both sides air complaints in a structured way. Tools don’t fix culture; culture changes change tools.
Red flag in candidates: “I just implement what’s in Figma.” Means they don’t push back, don’t review specs, don’t catch edge cases. You’re hiring a code monkey, not an engineer.
Lab preview
You’ll exercise the full Figma → SwiftUI handoff flow in Lab 3.1.
Next: 3.9 — macOS design considerations
3.9 — macOS design considerations
Opening scenario
You ship your iPhone app to macOS using Mac Catalyst. It launches. It’s a single 390-pt-wide column floating in the middle of a 27“ display. No menu bar items. No keyboard shortcuts. The right-click does nothing. The window resizes but the layout just stretches awkwardly. App Store reviews: “feels like a phone app duct-taped to my Mac.” 2 stars.
macOS is not iPad scaled up, and certainly not iPhone scaled up. It is a different platform with different metaphors: pointer (not finger), keyboard-first, multi-window, menu bar, contextual right-click. This chapter covers the design patterns that make a SwiftUI app feel Mac-native rather than ported.
| Concept | iOS | macOS |
|---|---|---|
| Primary nav | Tab bar / NavigationStack | Sidebar (NavigationSplitView) |
| Action invocation | Tap (44pt target) | Click + keyboard shortcut + menu bar |
| Context actions | Long-press menu | Right-click menu (always) |
| Discoverability | On-screen | Menu bar (File, Edit, View, Window…) |
| Multitasking | Stage Manager / Split View (recent) | Multi-window from day one |
| Input | Touch | Pointer, keyboard, trackpad gestures |
| Window | Full-screen by default | Resizable, draggable, multiple instances |
Concept → Why → How → Code
The three-pane layout
The canonical Mac app uses NavigationSplitView with three columns:
NavigationSplitView {
SidebarView() // categories / inbox / sections
} content: {
ListView() // items in selected category
} detail: {
DetailView() // selected item content
}
Mail, Notes, Reminders, Finder, Messages, Music, Podcasts — all built on this template. Users expect the columns. Three-pane is the Mac equivalent of iPhone’s tab bar.
On smaller windows, the sidebar collapses into a button; on full width, all three columns show. SwiftUI handles the collapse automatically.
Toolbar — the Mac action surface
The toolbar at the top of a Mac window holds frequent actions. Use ToolbarItem:
.toolbar {
ToolbarItem(placement: .primaryAction) {
Button("New") { create() }
.keyboardShortcut("n", modifiers: .command)
}
ToolbarItem(placement: .secondaryAction) {
Button { share() } label: {
Image(systemName: "square.and.arrow.up")
}
}
ToolbarItem(placement: .navigation) {
Button { goBack() } label: {
Image(systemName: "chevron.left")
}
}
}
Toolbar items show as icon-only by default; the user can right-click → Customize Toolbar → choose icon + text or icon-only. SwiftUI handles this for free.
Menu bar — discoverability and keyboard
Every Mac app gets a menu bar with App, File, Edit, View, Window, Help by default. Add custom commands:
@main
struct MyApp: App {
var body: some Scene {
WindowGroup {
ContentView()
}
.commands {
CommandMenu("Notes") {
Button("New Note") { newNote() }
.keyboardShortcut("n", modifiers: .command)
Button("Pin") { togglePin() }
.keyboardShortcut("p", modifiers: [.command, .shift])
Divider()
Button("Export…") { showExport() }
}
// Replace stock menus
CommandGroup(replacing: .newItem) {
Button("New Note") { newNote() }
.keyboardShortcut("n")
}
}
}
}
Every command should:
- Live in the menu bar (discoverability)
- Have a keyboard shortcut (efficiency)
- Optionally appear in the toolbar (frequent use)
The user can find a command three ways. Excellent Mac apps make every action discoverable from at least the menu.
Multi-window — first-class citizen
iPhone has one window. Mac has many. Design assuming the user has 5 windows of your app open.
@main
struct MyApp: App {
var body: some Scene {
// Main document window
WindowGroup(for: Note.self) { $note in
NoteEditorView(note: $note)
}
// Settings (Cmd+,)
Settings {
SettingsView()
}
// Menu bar extra (status icon, like Dropbox)
MenuBarExtra("Quick Note", systemImage: "note.text") {
QuickNoteView()
}
}
}
WindowGroup(for:) lets users open multiple windows, each tied to a different model — like opening multiple Notes in Notes app. Use @Environment(\.openWindow) to programmatically open new windows:
struct ContentView: View {
@Environment(\.openWindow) var openWindow
var body: some View {
Button("New Window") {
openWindow(value: Note(title: "Untitled"))
}
}
}
Right-click context menus
iOS has long-press; Mac has right-click. The two should map. SwiftUI handles both with .contextMenu:
Text(item.title)
.contextMenu {
Button("Open") { open(item) }
Button("Rename") { rename(item) }
Divider()
Button("Delete", role: .destructive) { delete(item) }
}
On Mac, the menu appears at the cursor on right-click. On iPhone, the same menu appears with a long-press. Write once, ship both.
Pointer affordances
The pointer is precise (1pt resolution vs 44pt fingertip). You can ship UI that requires precise hits — but you must signal interactivity. Use .onHover to swap cursor or change visual state:
struct LinkRow: View {
@State private var hovering = false
var body: some View {
HStack {
Text("Open Settings")
Spacer()
Image(systemName: "chevron.right")
}
.padding()
.background(hovering ? Color.secondary.opacity(0.1) : Color.clear)
.onHover { hovering = $0 }
}
}
Pointer changes (resize cursor, link cursor) come automatically with system controls. For custom interactive areas, set cursor via NSCursor (requires AppKit interop) or use pointerStyle() (macOS 15+):
.pointerStyle(.link) // ← hand cursor
.pointerStyle(.text) // I-beam
.pointerStyle(.grabIdle) // open hand
Window resizing
Mac windows resize. Your layout must handle every aspect ratio between minimum and maximum. Three patterns:
// Set a minimum window size
WindowGroup {
ContentView()
.frame(minWidth: 600, minHeight: 400)
}
// Or fix the size (rarely correct — only for utility windows)
.windowResizability(.contentSize)
// Use NavigationSplitView columns with widths
NavigationSplitView(
columnVisibility: $columnVisibility,
sidebar: { Sidebar().navigationSplitViewColumnWidth(min: 180, ideal: 220) },
content: { Content().navigationSplitViewColumnWidth(min: 280) },
detail: { Detail() }
)
Resize the window during dev. Verify text doesn’t truncate, images don’t squish, lists scroll cleanly.
Mac vs iOS — the conversion mistakes
Common iOS-thinking ported wrong:
| iOS pattern | Mac equivalent |
|---|---|
| Floating action button | Toolbar primary action |
| Tab bar at bottom | Sidebar with sections |
| Bottom sheet | Sheet (top) or window |
| Pull-to-refresh | Cmd+R + menu item “View → Reload” |
| Swipe to delete | Right-click → Delete + Delete key shortcut |
| Hamburger menu | Sidebar (always visible by default) |
| Large header that collapses | Standard title (no collapse) |
| “Done” button top-right | Cmd+W to close, Cmd+S to save |
Designers who design “iOS then port to Mac” produce these antipatterns. Designers who design for the platform never do.
Toolbar customization
Users can customize their Mac apps’ toolbars (right-click → Customize Toolbar). Make this work by giving each ToolbarItem an id:
.toolbar(id: "main") {
ToolbarItem(id: "new", placement: .primaryAction) {
Button("New") { newDoc() }
}
ToolbarItem(id: "share", placement: .secondaryAction) {
ShareLink(item: url)
}
}
.toolbarRole(.editor) // Mac-style toolbar with title
Settings window
Cmd+, opens settings on Mac. SwiftUI:
Settings {
TabView {
GeneralSettingsView()
.tabItem { Label("General", systemImage: "gear") }
AdvancedSettingsView()
.tabItem { Label("Advanced", systemImage: "wrench") }
}
.frame(width: 500, height: 350)
}
Tabbed settings is the Mac convention (matches Finder, Safari, Mail). On iOS, settings is a navigation push from a list.
MenuBarExtra — the status icon
For utilities (clipboard manager, weather, timer), use MenuBarExtra:
MenuBarExtra("Pomodoro", systemImage: "timer") {
PomodoroPopover()
}
.menuBarExtraStyle(.window) // popover vs menu
Excellent for ambient apps that don’t need a main window.
Mac Catalyst vs SwiftUI Multiplatform vs separate AppKit
Three ways to ship a Mac app today:
- SwiftUI multiplatform target (recommended for new apps) — one target,
#if os(macOS)for platform-specific bits, runs natively on both - Mac Catalyst — UIKit-based iPad app, recompiled for Mac with Mac chrome. Decent for ports; never quite feels native
- Separate AppKit target — for legacy or extremely platform-specific apps (Logic Pro, Final Cut)
For 95% of new apps: SwiftUI multiplatform. Mac Catalyst only if you’re porting an existing iPad app with deep UIKit.
In the wild
- Things 3 (Cultured Code) — gold standard SwiftUI Mac app, three-pane, deep keyboard, great toolbar, multi-window
- Notion’s Mac app — was an Electron port for years; SwiftUI rewrite shipped 2023 and got better reviews immediately
- Linear’s Mac app — minimal, keyboard-first, near-perfect Mac feel despite being React under the hood (Catalyst-ish)
- Apple Music on Mac — uses three-pane sidebar and a fully customizable toolbar — reference implementation
- Slack on Mac — Electron, no MenuBarExtra, no keyboard customization — the textbook bad Mac citizen
Common misconceptions
- “Mac is dying, iPad is the future.” Mac shipped record units in 2024 and is the primary dev machine for ~80% of senior software engineers. Pro market is huge.
- “Mac Catalyst is good enough.” It works, but Catalyst apps consistently score 0.5-1 star lower in App Store reviews. Native SwiftUI is the better path.
- “Users don’t customize Mac toolbars.” Pro users absolutely do. Not enabling customization signals an amateur app.
- “Mac doesn’t need touch-friendly tap targets.” True — pointer can hit 1pt targets. But don’t go below 16-20pt for buttons; usability research shows >16pt is meaningfully easier even with pointer.
- “Menu bar is bloated, just put everything in toolbar.” Wrong. Menu bar is the index of every action; toolbar is the frequent subset. Users discover via menu bar.
Seasoned engineer’s take
Building Mac apps is the highest-leverage iOS skill in 2025. Apple Silicon, Vision Pro, and the SwiftUI maturity curve mean Mac apps are easier to build than ever, and the App Store competition is much thinner than iOS. A polished Mac app gets featured, gets press, and converts well.
The single decision that makes or breaks Mac feel: do you embrace the platform metaphors (multi-window, menu bar, right-click, keyboard) or do you fight them with iPhone idioms? Embrace = 5 stars. Fight = 2 stars.
Spend time using Apple’s own Mac apps for a week — Notes, Mail, Finder, Music, Reminders. Watch how they handle window resize, sidebar collapse, toolbar customization, keyboard navigation. That’s your spec.
TIP: Build the entire app keyboard-only first. No mouse for a day. You’ll discover every missing shortcut and every place users get stuck. Then add menu items.
WARNING: Don’t ship a Mac app without a
Help → Keyboard Shortcutsmenu item. Users expect a way to discover all shortcuts. SwiftUI lists them automatically in the Help menu’s search box — make sure each command has a.keyboardShortcut(...)so they show up.
Interview corner
Junior-level: “How is designing for Mac different from iOS?”
Pointer (precise) vs touch (44pt). Keyboard shortcuts and menu bar (every command discoverable). Multi-window vs single window. Right-click for context. Resizable windows with adaptive layouts. Sidebar instead of tab bar.
Mid-level: “How do you support multiple windows in a SwiftUI Mac app?”
WindowGroup(for: Model.self) for document-style multi-window. @Environment(\.openWindow) to programmatically open. Settings for the Cmd+, window. MenuBarExtra for status-bar utilities. Each window has its own state; shared state goes in a singleton model accessed via @Environment or Observable.
Senior-level: “You’re porting a complex iOS app to Mac. Catalyst, multiplatform SwiftUI, or AppKit — and why?”
Depends. Pure SwiftUI iOS code → SwiftUI multiplatform, one target with #if os(macOS) for platform features (toolbar, menu bar, multi-window). Heavy UIKit codebase with little engineering bandwidth → Catalyst as a stopgap (ship faster, schedule SwiftUI migration). Legacy product requiring Mac-specific features unavailable in Catalyst (deep file system access, services menu, advanced print support) → AppKit. Decision matrix: engineering cost vs target feel quality.
Red flag in candidates: Shipping a Mac app as “iOS app, but in a window.” Means they didn’t read HIG.
Lab preview
You’ll add macOS support to a multiplatform app in Phase 5’s labs — but the Mac patterns you learn here apply to every later iOS/Mac project.
Next: 3.10 — Color psychology & palette design
3.10 — Color psychology & palette design
Opening scenario
You’re a solo dev building a finance app. You pick “vibrant orange and electric purple” because they’re your favorite colors. You ship. Users on Reddit say it looks “like a toy” and “unprofessional.” Your install-to-account-link conversion is 12%. You repaint to charcoal + navy blue. Conversion jumps to 31%. Same product, same code — the color told users to trust you.
Color is not decoration. It’s a signal of category, trustworthiness, and emotional register. This chapter teaches the patterns: which palettes work for which app categories, how to compose them with the 60-30-10 rule, how to verify them with contrast tools, and how to ship them to iOS with light/dark variants.
| Color | Common associations |
|---|---|
| Blue | Trust, stability, professionalism (banks, healthcare, productivity) |
| Green | Growth, money, health (finance, fitness, nature) |
| Red | Urgency, food, passion, danger (food delivery, sports, alerts) |
| Orange | Energy, friendliness, retail (consumer, kids, coupons) |
| Purple | Creativity, luxury, beauty (cosmetics, music, kids) |
| Pink | Femininity, youth, dating (dating, gen-Z, kids) |
| Black/Gray | Premium, minimalist, fashion (luxury, photography, dev tools) |
| Yellow | Optimism, attention, warning (children’s, alerts) |
These are not universal — culture matters — but they’re the dominant Western/US/EU mappings, which is what App Review and the App Store algorithm evaluate.
Concept → Why → How → Code
Color by app category
A defensible palette starts with category research. Open the App Store, search your category, screenshot the top 20 apps. Note their primary brand color. Patterns:
- Finance / Banking: Cash App (green), Robinhood (green), Chase (blue), Bank of America (red+blue), Venmo (blue), PayPal (blue), Stripe (purple). Trust signals dominate; saturated brand color over neutral surface.
- Health / Fitness: MyFitnessPal (blue), Strava (orange), Apple Fitness (red+orange gradient), Headspace (orange + gradient). Energy and warmth; often a vibrant secondary accent.
- Entertainment / Streaming: Netflix (red), Spotify (green), HBO (purple/black), Disney+ (blue-black), Twitch (purple). Dark backgrounds + saturated brand — let content shine, frame it with confidence.
- Productivity: Notion (white+black), Linear (purple+black), Things (blue), Reminders (orange), Asana (orange+pink). Restrained, often near-monochrome with one accent.
- Social / Dating: Tinder (red/pink), Bumble (yellow), Hinge (red), Instagram (gradient pink-purple-orange). Saturated, warm, playful.
- Kids / Education: Duolingo (green), Khan Academy (green), Lego (yellow+red+blue), Toca Boca (rainbow). Primary colors, high saturation, playful.
- Travel / Maps: Airbnb (coral-red), Booking (blue+yellow), Google Maps (white+colored), Lyft (pink). Welcoming, often with one bold accent.
The exercise: when designing a new app, list 5 competitors → screenshot → find the median palette. Stay within ±20° on the color wheel from the median unless you have a defensible reason. Going too far signals “different” but often reads as “wrong category.”
The 60-30-10 rule
A balanced palette uses:
- 60% — dominant color (usually a neutral or background)
- 30% — secondary color (supporting surfaces, secondary actions)
- 10% — accent color (primary actions, brand emphasis)
In a SwiftUI app:
ZStack {
Color(.systemBackground) // 60% — most of the screen
VStack {
Card() // 30% — secondary surface
.background(Color(.secondarySystemBackground))
Button("Buy") { … } // 10% — accent (the brand color)
.buttonStyle(.borderedProminent)
}
}
Apps that violate this — 60% bright brand color — feel exhausting after 30 seconds. The eye needs rest space.
Apple’s semantic system as the 60-30
Color(.systemBackground), Color(.secondarySystemBackground), Color(.tertiarySystemBackground) already do the 60-30 for you. You just add the 10% accent:
// In your App or root view
.tint(.brandPrimary)
// Now every Button, Toggle, NavigationLink, ProgressView, etc.
// uses brandPrimary as its accent — automatically.
.tint(_:) is the single most powerful color modifier in SwiftUI. Set it once at the top, get a consistent accent everywhere. No per-component overrides.
Tools for picking palettes
When you’re not just picking a single brand color, use these:
- Coolors.co — generate 5-color palettes, lock favorites, regenerate. Free, fast, great for ideation.
- Adobe Color — color wheel with harmony rules (analogous, triadic, complementary, monochrome). Best for understanding why a palette feels balanced.
- Color Hunt — curated palettes by category. Browse the “Pastel” or “Dark” sections to spark ideas.
- Contrast — Mac app, menu bar utility, pick two pixels → instant WCAG ratio.
- Realtime Colors — paste a palette, see it applied to a sample app UI live. Best “does this work?” tool.
- uiGradients — for picking gradient pairs.
The workflow: Coolors for ideation → Adobe Color to verify harmony → Realtime Colors to preview on a UI → Contrast to verify WCAG.
The Apple semantic palette (re-stated as a checklist)
Every app needs at least these semantic colors, defined in Asset Catalog with Light/Dark variants:
// Brand
brandPrimary // your main brand color
brandSecondary // accent / link
// Surfaces (or use Apple's systemBackground family)
surfacePrimary // page background
surfaceSecondary // card / sheet background
surfaceTertiary // inset / grouped background
// Content (or use Apple's label family)
contentPrimary // body text
contentSecondary // secondary text
contentTertiary // disabled / placeholder
// Semantic state
success // green
warning // yellow / orange
error // red
info // blue
That’s 10-12 colors. Most successful apps fit in this set. More colors = more drift, more inconsistency, more decisions per screen.
Dark mode color pairs
Dark mode is not “invert” — it’s a separate palette tuned for low-light viewing. Rules of thumb:
| Light mode | Dark mode equivalent |
|---|---|
Pure white background #FFFFFF | Near-black #0A0A0A or #1C1C1E (Apple’s systemBackground) — never pure black |
Pure black text #000000 | Off-white #F2F2F7 (Apple’s label) |
| Vibrant accent at 100% saturation | Same accent at ~85% saturation (less aggressive on dark) |
| Subtle shadow | Subtle glow / lighter border |
Why not pure black in dark mode? OLED display “smearing” — pure black next to bright content causes ghosting. Apple uses #1C1C1E.
Setup in Asset Catalog:
- New Color Set → “brandPrimary”
- Attributes Inspector → Appearances: Any, Dark
- Set Any (light mode) to your brand color
- Set Dark to the desaturated/adjusted version
- Reference:
Color(.brandPrimary)or generatedColor.brandPrimary
Same color name, two values, automatic adaptation.
WCAG contrast checking
For every text-on-background combination in your palette:
- Body text on background: ≥ 4.5:1
- Large text (≥ 18pt or ≥14pt bold) on background: ≥ 3:1
- UI components (icons, borders): ≥ 3:1
If your brand color is light (yellow, light blue, pink), it likely fails 4.5:1 against white. Solutions:
- Use brand color only on large or bold text
- Use brand color only as background with white/dark text on top
- Use brand color only for icons and accents, never body text
Check every pair. The Contrast app makes this 2-second work.
App icon color strategy
Your app icon is your most-viewed color choice. Patterns:
- Solid brand color, white glyph: Spotify, Cash App, Robinhood. Simple, recognizable at every size.
- Gradient brand: Instagram, Apple Health, Apple Fitness. Eye-catching but harder to read at 60×60.
- Photographic / detailed: Bear, Threes! Art-feels but loses detail at small sizes.
- Two-color flat: Apple Notes (yellow lines on white), Apple Music (pink-red gradient). Tested at every size.
Test your icon at the actual sizes: 60×60 (iPhone home screen), 76×76 (iPad), 120×120 (retina spotlight), 1024×1024 (App Store). Apple’s Icon Composer tool renders all sizes; preview before shipping.
For iOS 26 Liquid Glass (introduced WWDC25), provide a “Tinted” icon variant (system tints your icon to user’s wallpaper) and a “Dark” variant.
Avoiding palette drift in code
Once defined, the palette must not drift. Enforce:
- SwiftLint custom rule: ban
Color(red:),Color(hex:),Color(white:). ForceColor.brandPrimarystyle. - Generated asset symbols (Xcode 15+):
Color(.brandPrimary)is compile-checked. - PR template checkbox: “If you added a color, did you add it to the design tokens module?”
- Quarterly audit:
grep -rn "Color(" --include="*.swift"— anything not from tokens gets fixed.
In the wild
- Stripe uses purple as a distinctive payment-app color (in a sea of blue) — successful differentiation within finance. Their site and docs are case studies in restrained palette use.
- Linear’s palette: near-monochrome (gray scale) plus one signature purple. 95% of the UI is neutral; the purple is reserved for active states. Studied widely in design system communities.
- Duolingo’s green: paired with a friendly typography and Duo’s voice, it signals “approachable learning.” If they’d picked navy, it would feel like an enterprise LMS. Brand color choice is product strategy.
- Cash App rebranded from green to black-and-green in 2018; conversion analytics showed the more sophisticated palette converted higher-value users.
- Twitter → X color change from blue to black caused widespread brand-recognition drop (well documented in marketing case studies). Color is brand equity.
Common misconceptions
- “My favorite color should be the brand color.” Rarely. Brand color should match category and target user, not founder preference.
- “More colors = more energy = better.” No. Restraint is a marker of professional design. Most great apps use 3-5 colors total.
- “Dark mode is just inverted light mode.” Wrong — different colors entirely, especially desaturated accents.
- “WCAG AA contrast is too restrictive for cool designs.” Inaccessible cool designs are bad designs. AA is achievable with creativity; AAA is hard but AA is non-negotiable.
- “Color psychology is pseudoscience.” The cultural mappings (red = food, blue = trust in Western markets) are empirically supported in consumer behavior research. The individual-mood claims (yellow makes you happy) are weak. Use the category data, ignore the rest.
Seasoned engineer’s take
For new apps, the order of operations on color:
- Category research — screenshot 20 competitors, find the median palette
- Pick a primary brand color — within ±30° of category median unless you have a defensible reason
- Generate complementary palette — Coolors or Adobe Color for harmony
- Verify WCAG contrast — Contrast app
- Define dark mode pairs — desaturate brand, near-black bg
- Define semantic tokens —
brandPrimary,surface,content,success/warning/error/info - Wire
.tint()at App root — every accent gets the brand color for free - Test in Realtime Colors — paste palette, preview on mock UI
- Add to Asset Catalog with light/dark + high-contrast appearances
- Lint to prevent drift
Spend a day on this once. The palette will outlive 90% of your code.
TIP: Always build a “color audit” screen in your app — a screen that shows every color token, light + dark, with WCAG ratios. Saves you from a future “is this still our brand color?” question. Keep it in a
#if DEBUGbuild configuration.
WARNING: Don’t ship colors directly from Coolors palettes without WCAG checking. Trendy palettes from Coolors often fail contrast for body text. Verify.
Interview corner
Junior-level: “How would you pick a color palette for a new app?”
Research competitors in the category. Pick a primary brand color aligned with category expectations. Use a tool like Coolors or Adobe Color to generate complementary supporting colors. Verify WCAG contrast for all text/background pairs. Define light and dark variants in Asset Catalog.
Mid-level: “What’s the 60-30-10 rule and why does it matter?”
60% dominant (usually neutral background), 30% secondary (surfaces), 10% accent (brand). It prevents palette overload, gives the eye rest space, and ensures the brand color is special (not exhausting). Apple’s semantic colors already do the 60-30; you add the 10% via .tint().
Senior-level: “Design a color system that works across iOS, watchOS, macOS, and Apple Vision Pro for a single brand.”
Define brand primary + 2-3 brand accents at the primitive token level. Define semantic tokens (surface, content, state) per platform — watchOS uses near-black backgrounds, macOS supports more saturated colors (larger surface area), visionOS prefers translucent materials. Single source in Figma variables → Tokens Studio export → Style Dictionary → generates platform-specific token files. CI ensures sync. App-level .tint() ties it all together. Verify WCAG on every platform; visionOS has unique contrast requirements with translucent backgrounds.
Red flag in candidates: Picking colors based purely on aesthetics with no category research, no contrast check, no semantic naming. Tells you they haven’t shipped a product that needed to convert.
Lab preview
You’ll derive a full palette from a one-sentence brief in Lab 3.3 — Palette from Brief.
Phase 3 chapters complete. The labs apply everything:
Lab 3.1 — Figma to SwiftUI
Duration: ~90 minutes Prereqs: Xcode 16+, Figma account (free tier works), a working iPhone simulator
Goal
Take a publicly available Figma design and ship a pixel-accurate SwiftUI implementation. You will exercise the full handoff workflow: status checking, Dev Mode inspection, token extraction, asset export, layout translation, and side-by-side visual diffing.
By the end you’ll have:
- A SwiftUI screen that matches the Figma frame within ~2pt accuracy
- Extracted design tokens (color, spacing, typography) defined in code
- Exported assets in the right formats
- A documented diff between your output and the source
Setup
- Open the iOS 17 UI Kit by Joey Banks on Figma Community (or any similar free iOS UI kit — pick one with a single “Tip Calculator” or “Login” screen).
- Duplicate the file to your drafts.
- Open it in Dev Mode (top-right toggle, or press
Shift + D). - Pick one frame — recommended: a single-screen design like a login, settings detail, or tip calculator. Avoid multi-screen flows for this lab.
- Take a full-resolution screenshot of the chosen frame (right-click → Export → PNG @2x). Save as
reference.png.
Steps
Step 1 — Audit the design (10 min)
Before writing code, inspect:
- Status: Is the frame marked “Ready for Dev”? (If not, set it.)
- Layout structure: Open the layers panel. Note the nesting (VStack/HStack equivalents).
- Colors used: Open Dev Mode → Variables panel. List every color → hex value AND token name if defined.
- Spacing: ⌥-drag between elements. Note the rhythm (likely 8, 16, 24, 32pt).
- Typography: Click each text element → note font, size, weight, line-height.
- Assets: List every image and icon. Note format needs (SF Symbol available? Custom?).
Document everything in a DESIGN_NOTES.md next to your Xcode project.
Step 2 — Create the Xcode project (5 min)
File → New → Project → iOS → App → SwiftUI → Swift
Name: FigmaToSwiftUI
Add an Asset Catalog DesignTokens.xcassets (or use the default Assets.xcassets).
Step 3 — Define design tokens (15 min)
Create DesignTokens.swift:
import SwiftUI
enum Spacing {
static let xs: CGFloat = 4
static let sm: CGFloat = 8
static let md: CGFloat = 16
static let lg: CGFloat = 24
static let xl: CGFloat = 32
}
enum AppFont {
static let title = Font.system(size: 28, weight: .bold)
static let body = Font.system(size: 17, weight: .regular)
static let caption = Font.system(size: 13, weight: .medium)
}
For each color from the Figma frame, add a Color Set to Asset Catalog:
Assets.xcassets→ right-click → New Color Set- Name it semantically (
brandPrimary,surfaceCard,textPrimary) — not by hex - Attributes Inspector → Appearances: Any, Dark
- Set Any to the Figma hex value
- Pick a sensible dark mode pair (desaturate brand colors ~15%)
Generated symbols give you Color(.brandPrimary) with compile-time checking.
Step 4 — Export assets (10 min)
For each asset in Figma:
- Icons: First check if SF Symbols 6 has it (
heart,gear,arrow.right, etc.). 90% of common UI icons are in SF Symbols. - Custom icons: Export as SVG from Figma. Convert to SF Symbol using the SF Symbols Mac app, OR drop into Asset Catalog as PDF (with “Preserve Vector Data” checked).
- Photos / illustrations: Export as PNG @1x and @3x. Drop into Asset Catalog as Image Set; Xcode auto-picks the right scale.
Export from Figma: select layer → right panel “Export” section → set format → click Export.
Step 5 — Build the layout (30 min)
Match the Figma frame structure to SwiftUI containers:
| Figma | SwiftUI |
|---|---|
| Frame with Auto Layout: vertical | VStack |
| Frame with Auto Layout: horizontal | HStack |
| Frame with Auto Layout: wrap | LazyVGrid or HStack + flexible |
| Group | ZStack or no container |
| Constraints “Hug contents” | natural sizing |
| Constraints “Fill container” | .frame(maxWidth: .infinity) |
| Padding on Auto Layout | .padding(...) |
| Gap (spacing between children) | VStack(spacing: ...) |
Example mapping for a card with title and subtitle:
VStack(alignment: .leading, spacing: Spacing.sm) {
Text("Title")
.font(AppFont.title)
.foregroundStyle(Color(.textPrimary))
Text("Subtitle here")
.font(AppFont.body)
.foregroundStyle(Color(.textSecondary))
}
.padding(Spacing.md)
.frame(maxWidth: .infinity, alignment: .leading)
.background(Color(.surfaceCard))
.clipShape(RoundedRectangle(cornerRadius: 12))
Step 6 — Side-by-side visual diff (15 min)
In Xcode:
- Run the app on iPhone 16 simulator
- Take a screenshot (
Cmd+Sin simulator) - Open both screenshots —
reference.png(Figma export) and your simulator screenshot — in Preview side by side - Use Preview’s “Tools → Adjust Size” or layer them in a Figma comparison frame
- Note every discrepancy: spacing off by 2pt, wrong font weight, missing shadow, etc.
- Fix iteratively
For pixel-precise diffing, use the Pixel-Perfect Chrome extension or import both into Figma as PNGs and overlay with 50% opacity.
Step 7 — Document the diff (5 min)
In DESIGN_NOTES.md add a “Known Discrepancies” section:
- Font rendering differs because Figma uses macOS font hinting; iOS renders differently → acceptable
- Shadow blur 12pt vs 14pt in code → fix
- Etc.
Stretch goals
- Dark mode: cover the same frame in dark mode. Run the simulator in dark mode (
Cmd+Shift+Ain simulator). Verify Asset Catalog dark variants render correctly. - Dynamic Type: enable accessibility size XXL via Settings → Accessibility → Display & Text Size → Larger Text. Does your layout reflow gracefully?
- VoiceOver: turn on VoiceOver and navigate the screen. Are labels meaningful? Add
.accessibilityLabelwhere needed. - iPad: run on iPad simulator. Does the layout adapt or look stretched? Consider
NavigationSplitView.
Acceptance criteria
- Project builds and runs on iPhone 16 simulator
-
All tokens defined in
DesignTokens.swiftand Asset Catalog (no hex literals in views) - All Figma assets exported and added to Asset Catalog
- Layout matches reference within 2pt accuracy on each axis
-
DESIGN_NOTES.mddocuments tokens, assets, and known discrepancies - No SF Symbol replaced by a custom asset (icon work done right)
Common pitfalls
- Pixel-pushing with frames: don’t
.frame(width: 327, height: 64)everything. Use Auto Layout (HStack/VStack+ padding) like Figma does. Hard-coded sizes break with Dynamic Type. - Hardcoded colors: every
Color(red:green:blue:)orColor(hex:)is a future bug. Use Asset Catalog. - Custom icons when SF Symbol exists: search SF Symbols 6 first. Saves bytes, scales perfectly, supports color variants.
- Wrong Auto Layout direction: confused about HStack vs VStack? In Figma, look at the Auto Layout property: “↓” = VStack, “→” = HStack.
What you’ve learned
You’ve executed the full design → engineering handoff loop. You can now look at any Figma frame and build it in SwiftUI without the designer hand-holding. You understand why semantic tokens beat hex literals, why SF Symbols beat custom assets, and how to verify your work visually.
Real-world version: this is exactly the loop you’ll run on every feature, every PR, for the rest of your iOS career.
Next: Lab 3.2 — HIG & Accessibility Audit
Lab 3.2 — HIG & Accessibility Audit
Duration: ~75 minutes Prereqs: Xcode 16+, Accessibility Inspector (bundled with Xcode), VoiceOver enabled on simulator
Goal
You’ll be given a starter SwiftUI app — ShoppyApp — that contains 6 deliberate HIG and accessibility violations. Your job: find every one using Apple’s tools (Accessibility Inspector, VoiceOver, Environment Overrides), then fix each one. By the end you’ll know how to audit any iOS app for accessibility correctness.
The starter app
Create a new SwiftUI app called ShoppyApp. Replace ContentView.swift with the following — do not fix anything yet, this is the source of your audit:
import SwiftUI
struct ContentView: View {
@State private var quantity: Double = 1
var body: some View {
TabView {
ProductScreen(quantity: $quantity)
.tabItem { Label("Shop", systemImage: "bag") }
CartScreen()
.tabItem { Label("Cart", systemImage: "cart") }
ProfileScreen()
.tabItem { Label("Profile", systemImage: "person") }
}
}
}
struct ProductScreen: View {
@Binding var quantity: Double
@State private var showDetails = false
var body: some View {
ZStack(alignment: .topTrailing) {
Color.white.ignoresSafeArea() // ❌ Violation #1
VStack(alignment: .leading, spacing: 16) {
Image("hero-shoe") // ❌ Violation #4 (no accessibility label)
.resizable()
.scaledToFit()
.frame(height: 240)
Text("Premium Runner")
.font(.title2)
.fontWeight(.bold)
Text("Lightweight performance shoe.")
.font(.body)
.foregroundStyle(Color.gray.opacity(0.4)) // ❌ Violation #3
Text("$159")
.font(.title)
// ❌ Violation #6: custom slider when system Slider works
CustomQuantitySlider(value: $quantity)
Button(action: { /* add */ }) {
Text("Add to Cart")
.frame(maxWidth: .infinity)
.padding()
.background(Color.blue)
.foregroundStyle(.white)
.clipShape(RoundedRectangle(cornerRadius: 12))
}
}
.padding()
// ❌ Violation #2: 24x24 close button with no hit area
Button(action: { showDetails = false }) {
Image(systemName: "xmark")
.frame(width: 24, height: 24)
}
.padding(8)
}
}
}
struct CartScreen: View {
var body: some View {
Text("Cart")
}
}
struct ProfileScreen: View {
var body: some View {
Text("Profile")
}
}
// ❌ Violation #6: hand-rolled slider with no accessibility traits
struct CustomQuantitySlider: View {
@Binding var value: Double
var body: some View {
HStack {
Text("Qty:")
Rectangle()
.fill(Color.blue)
.frame(width: CGFloat(value) * 30, height: 8)
.onTapGesture { value += 1 }
Spacer()
}
}
}
Run the app on iPad Pro 13“ simulator (this surfaces violation #5: the tab bar is wrong UI on iPad).
The 6 violations to find and fix
You should not look at this list before running the app. Try to discover each issue first using the tools below. After ~45 minutes, compare against this list and patch anything you missed.
| # | Violation | Detection method |
|---|---|---|
| 1 | Color.white background — breaks dark mode | Toggle dark mode in simulator (Cmd+Shift+A) |
| 2 | 24×24pt close button — below 44pt minimum tap target | Visual inspection; Accessibility Inspector Audit |
| 3 | Gray text at 40% opacity on white — fails WCAG contrast | Accessibility Inspector → Audit or Contrast app |
| 4 | Image with no accessibilityLabel | Turn on VoiceOver; swipe to image; hear “image” with no description |
| 5 | TabView on iPad — should be NavigationSplitView | Run on iPad simulator; visually wrong |
| 6 | Custom slider — has no accessibility traits, system Slider would work | VoiceOver doesn’t announce as slider; can’t adjust with rotor |
Tools workflow
Tool 1 — Environment Overrides (in-simulator)
In Xcode while debugging, click the small Environment Overrides toggle at the bottom of the simulator/debug toolbar. Toggle:
- Light/Dark appearance → reveals violation #1
- Text Size: AX5 → reveals layout issues with Dynamic Type
- Increased Contrast: On → tests high-contrast variants
- Reduce Motion: On → reveals heavy animations
Tool 2 — Accessibility Inspector
Open Xcode → Open Developer Tool → Accessibility Inspector. Choose your simulator as target. Click Audit (the checkmark icon at the top).
The Audit runs automated checks:
- Contrast ratios (reveals #3)
- Hit-region sizes (reveals #2)
- Missing accessibility labels (reveals #4)
- Dynamic Type breakage (reveals overflow with AX5)
For each violation in the Audit panel, you can click “Show in Simulator” to highlight the offending view.
Tool 3 — VoiceOver
On simulator: Settings → Accessibility → VoiceOver → On. Or use the keyboard shortcut from simulator menu.
Once VoiceOver is on:
- Single tap to select; the system speaks the element
- Swipe right with one finger to move to next element
- Double-tap to activate
What to listen for:
- Every interactive element has a meaningful label (not “Button” alone)
- Images convey content via label (or are correctly marked decorative)
- Custom controls announce their type (Slider, Button, Toggle) — violation #6 fails this
Tool 4 — iPad-specific testing
Run on iPad Pro 13“ simulator. Rotate to landscape (Cmd+→). Observe:
- Tab bar at bottom on a 1366pt-wide screen looks comically narrow
- iPad apps in 2025 should use
NavigationSplitViewfor 3-pane layout
The fixes
Fix #1 — Background
Color(.systemBackground).ignoresSafeArea()
Or just drop the Color.white and let the default system background show.
Fix #2 — Tap target
Button(action: { showDetails = false }) {
Image(systemName: "xmark")
.frame(width: 44, height: 44) // ← expand to 44pt
.contentShape(Rectangle()) // ← expand hit area
}
.padding(8)
Or keep the 24pt icon but expand the hit region:
Image(systemName: "xmark")
.frame(width: 24, height: 24)
.padding(10) // pads to 44pt total
.contentShape(Rectangle())
Fix #3 — Contrast
Text("Lightweight performance shoe.")
.font(.body)
.foregroundStyle(.secondary) // ← Apple's tested-contrast token
.secondary is guaranteed ≥4.5:1 by Apple in both light and dark modes.
Fix #4 — Image label
Image("hero-shoe")
.resizable()
.scaledToFit()
.frame(height: 240)
.accessibilityLabel("Premium Runner shoe, side view in white")
Or mark decorative if it adds no info:
Image("hero-shoe")
...
.accessibilityHidden(true)
Fix #5 — iPad layout
struct ContentView: View {
@State private var quantity: Double = 1
@State private var selection: AppSection? = .shop
var body: some View {
NavigationSplitView {
List(selection: $selection) {
NavigationLink(value: AppSection.shop) {
Label("Shop", systemImage: "bag")
}
NavigationLink(value: AppSection.cart) {
Label("Cart", systemImage: "cart")
}
NavigationLink(value: AppSection.profile) {
Label("Profile", systemImage: "person")
}
}
} detail: {
switch selection {
case .shop: ProductScreen(quantity: $quantity)
case .cart: CartScreen()
case .profile: ProfileScreen()
case nil: Text("Select a section")
}
}
}
}
enum AppSection: Hashable { case shop, cart, profile }
NavigationSplitView automatically collapses to a single column on iPhone and a TabView-equivalent narrow window. One layout, both platforms.
Fix #6 — Use the system Slider
VStack(alignment: .leading) {
Text("Quantity: \(Int(quantity))")
Slider(value: $quantity, in: 1...10, step: 1)
.accessibilityLabel("Quantity")
.accessibilityValue("\(Int(quantity))")
}
System Slider has full VoiceOver support, rotor adjustment, keyboard control. Free.
Verification pass
After all 6 fixes:
- Re-run Accessibility Inspector Audit — should report 0 issues
- Re-run with VoiceOver — every interactive element announces meaningfully
- Toggle dark mode — UI adapts correctly
- Toggle AX5 Dynamic Type — text grows, layout reflows without overflow
- Run on iPad Pro 13“ — sidebar+detail layout shows
- Run on iPhone 16 — same code now shows tab-bar equivalent collapse
Stretch goals
- Add Switch Control support testing (
Settings → Accessibility → Switch Controlon a real device) - Add localization — translate all visible strings to Spanish, ship with
Localizable.xcstrings. Verify Dynamic Type still works with longer strings (German is the harder test). - Add Reduce Motion branch — if you add animations, gate them on
accessibilityReduceMotion. - Run on Mac Catalyst — does the iPad layout work? What needs adjustment?
Acceptance criteria
- All 6 violations found and fixed
- Accessibility Inspector Audit reports 0 issues on all 3 screens
- App works correctly in light and dark mode
- App reflows correctly at AX5 Dynamic Type
-
App uses
NavigationSplitViewon iPad - VoiceOver navigation is meaningful end-to-end
- No accessibility-hostile custom controls (use system controls where possible)
What you’ve learned
You now own the audit playbook. Every iOS app you ship — yours, your team’s, an inherited codebase — can be put through this exact loop in under an hour. Accessibility is not “extra credit”; it’s part of “done.” This lab is the diff between an iOS engineer and an iOS engineer who ships products people actually trust.
Real numbers: 15% of users have a disability. App Store search and Editor’s Choice favor accessible apps. ADA lawsuits against inaccessible apps are real and cost six figures. This loop is the cheapest insurance you can buy.
Next: Lab 3.3 — Palette from Brief
Lab 3.3 — Palette from Brief
Duration: ~60 minutes Prereqs: Xcode 16+, Coolors.co account (free), Adobe Color (free), Contrast Mac app
The brief
Build a meditation and sleep tracking app for adults aged 30–50 who are overworked professionals trying to wind down before bed.
That’s it. One sentence. Your job: derive a complete, defensible color palette, define it in code with light/dark variants, build a 2-screen prototype, and verify every color choice with WCAG contrast checks.
This is the actual exercise you’ll do on day one of any new product. The brief is intentionally vague — real briefs always are.
Goal
By the end, you’ll have:
- A fully defined palette: 1 brand primary, 1 brand accent, surface tokens, content tokens, state tokens
- Light + dark variants in Asset Catalog
- A
DesignTokens.swiftmodule - 2 SwiftUI screens (Home / Session) using only the palette
- A documented WCAG audit showing every pair passes AA
Steps
Step 1 — Decode the brief (10 min)
Extract the constraints from the brief sentence-by-sentence:
| Brief phrase | Color implication |
|---|---|
| Meditation / sleep | Cool tones (blue, indigo, purple, deep teal) over warm |
| Adults 30–50 | Sophisticated palette, restrained; no neon |
| Overworked professionals | Premium feel; competes against Calm, Headspace |
| Winding down before bed | Dark mode is the primary mode, not the secondary |
Research the category: open the App Store, search “meditation” and “sleep.” Screenshot the top 5 apps’ icons and onboarding screens. You’ll find a dominant pattern:
- Calm: deep navy blue → indigo gradient, mountain photography
- Headspace: warm orange (outlier, intentional for “friendly”)
- Sleep Cycle: navy + light blue
- Insight Timer: purple + magenta
- Aura: dark navy + teal
Median: navy/indigo/deep blue as primary. Headspace’s orange is a deliberate differentiation but doesn’t fit “winding down” — they own “approachable” instead.
Decision: lean into the category convention. Pick a deep, cool primary.
Step 2 — Generate candidate palettes (10 min)
Go to Coolors.co. Spacebar regenerates palettes; press lock on colors you like and regenerate the rest.
Constraints to enforce:
- One deep, desaturated primary (navy / indigo / deep blue)
- One light/medium accent for highlights
- Neutral surfaces (off-white, soft gray for light; near-black for dark)
- One warm-ish accent allowed for “session complete” success states
Generate 3 candidate palettes. Save each as a Coolors URL.
Now go to Adobe Color → use “Color Wheel” → set Color Rule to “Analogous” or “Complementary” → pick a deep blue base and explore harmonies. Pick the harmony that visually feels closest to your brief.
Candidate I’ll work with for the lab template (pick your own; this is illustrative):
Primary: #2D3561 (deep indigo)
Accent: #8E9AAF (muted blue-gray)
Surface: #FAFAFA (warm off-white)
Surface 2: #F0F0F4 (slight cool tint)
Text: #1A1A2E (near-black with blue undertone)
Text muted: #6B7280 (cool gray)
Success: #7FB069 (sage green — calm, not aggressive)
Warning: #E07A5F (terracotta — soft warm)
Error: #D62828 (only for critical errors)
Notice: no saturated reds or hot yellows. The palette feels quiet.
Step 3 — Define dark mode pairs (10 min)
For sleep/meditation, dark mode is primary. Each light color needs a dark equivalent that:
- Has near-black background (#0F0F1A or similar, never pure #000)
- Desaturates accents ~15-20% (saturated colors feel harsh on dark)
- Keeps text high-contrast (off-white #F2F2F7)
LIGHT DARK
Primary: #2D3561 → #6B7BC4 (desaturated, lighter for visibility on dark)
Accent: #8E9AAF → #B8C2D6
Surface: #FAFAFA → #0F0F1A
Surface 2: #F0F0F4 → #1A1A2E
Text: #1A1A2E → #F2F2F7
Text muted: #6B7280 → #A0A8B5
Success: #7FB069 → #9CC97D
Warning: #E07A5F → #E89B85
Error: #D62828 → #FF5C5C
Step 4 — Verify WCAG contrast (5 min)
Open the Contrast app. For every text-on-surface pair, verify:
| Pair | Required ratio | Result |
|---|---|---|
| Text on Surface (light) | ≥ 4.5:1 | check it |
| Text on Surface 2 (light) | ≥ 4.5:1 | check it |
| Text muted on Surface (light) | ≥ 4.5:1 | check it (most likely to fail — adjust if needed) |
| Primary on Surface (light) | ≥ 3:1 (UI element) | check it |
| Text on Surface (dark) | ≥ 4.5:1 | check it |
| All dark mode equivalents | same thresholds | check all |
If any pair fails, darken/lighten the offender by 5% increments and re-check. Document the final hex values.
Step 5 — Define in Asset Catalog (10 min)
Create a new SwiftUI Xcode project: WindDown.
In Assets.xcassets, for each token name (brandPrimary, brandAccent, surface, surface2, textPrimary, textSecondary, success, warning, error):
- New Color Set with that name
- Attributes Inspector → Appearances: Any, Dark
- Set Any to the light hex, Dark to the dark hex
- Confirm Xcode generates
Color.brandPrimarysymbol (project settings → Build Settings → “Generate Asset Symbols” → Yes; default in Xcode 15+)
Create DesignTokens.swift:
import SwiftUI
enum Spacing {
static let xs: CGFloat = 4
static let sm: CGFloat = 8
static let md: CGFloat = 16
static let lg: CGFloat = 24
static let xl: CGFloat = 32
}
enum AppFont {
static let heroTitle = Font.system(size: 34, weight: .light, design: .serif)
static let title = Font.system(size: 22, weight: .regular)
static let body = Font.system(size: 17)
static let caption = Font.system(size: 13, weight: .medium)
}
enum Radius {
static let sm: CGFloat = 8
static let md: CGFloat = 16
static let lg: CGFloat = 24
}
Note the typography choice: serif at light weight for the hero title (Calm and Headspace both use elegant typography to signal “premium meditation”). Stick to system fonts for v1 — NewYork (SwiftUI’s .serif design) is included free.
Step 6 — Build the two screens (15 min)
Home screen
struct HomeView: View {
var body: some View {
ZStack {
Color.surface.ignoresSafeArea()
VStack(alignment: .leading, spacing: Spacing.lg) {
Text("Good evening")
.font(AppFont.heroTitle)
.foregroundStyle(Color.textPrimary)
Text("Ready to unwind?")
.font(AppFont.body)
.foregroundStyle(Color.textSecondary)
ForEach(["10 min · Sleep", "20 min · Deep Rest", "5 min · Breath"], id: \.self) { item in
HStack {
Image(systemName: "moon.stars")
.foregroundStyle(Color.brandPrimary)
Text(item)
.font(AppFont.body)
.foregroundStyle(Color.textPrimary)
Spacer()
Image(systemName: "chevron.right")
.foregroundStyle(Color.textSecondary)
}
.padding(Spacing.md)
.background(Color.surface2)
.clipShape(RoundedRectangle(cornerRadius: Radius.md))
}
Spacer()
}
.padding(Spacing.lg)
}
}
}
Session screen
struct SessionView: View {
@State private var progress = 0.6
var body: some View {
ZStack {
Color.surface.ignoresSafeArea()
VStack(spacing: Spacing.xl) {
Text("Deep Rest")
.font(AppFont.heroTitle)
.foregroundStyle(Color.textPrimary)
Circle()
.trim(from: 0, to: progress)
.stroke(Color.brandPrimary, style: StrokeStyle(lineWidth: 8, lineCap: .round))
.frame(width: 240, height: 240)
.rotationEffect(.degrees(-90))
.overlay {
Text("8:32")
.font(.system(size: 48, weight: .light))
.foregroundStyle(Color.textPrimary)
}
Button(action: { }) {
Image(systemName: "pause.fill")
.font(.title)
.foregroundStyle(.white)
.frame(width: 64, height: 64)
.background(Color.brandPrimary)
.clipShape(Circle())
}
}
.padding()
}
}
}
Apply the global accent at the App root:
@main
struct WindDownApp: App {
var body: some Scene {
WindowGroup {
ContentView()
.tint(.brandPrimary)
}
}
}
Step 7 — Test both modes
- Run on iPhone 16 simulator
- Toggle dark mode (
Cmd+Shift+A) - Both screens should feel equally “right” — different but not jarring
- Take screenshots of all 4 combinations (Home/Session × Light/Dark) for the writeup
Step 8 — Document
Create PALETTE.md in the project root with:
- The brief
- The category research (5 competitor primary colors)
- The chosen palette (hex values, light and dark)
- WCAG contrast results table
- Screenshots of both screens in both modes
- One paragraph defending each color choice
Stretch goals
- Increased Contrast variant: add a third appearance variant in Asset Catalog (
Any, Dark, High Contrast Light, High Contrast Dark) with darker text and bolder accents. Test by enabling Settings → Accessibility → Display → Increase Contrast. - Animated background gradient: add a slow-shifting linear gradient (60-second loop) using two of your palette colors. Gate on
accessibilityReduceMotion. - App icon: design a simple app icon using your palette (1024×1024). Use Figma free tier or Sketch. Provide both tinted and dark variants per iOS 26 Liquid Glass guidelines.
- Onboarding screen: design a 3-card paginated onboarding that uses your full palette. Verify every screen passes WCAG.
Acceptance criteria
- Palette defined: brand primary, brand accent, surface (1-2), text (2), state colors (3)
- All colors live in Asset Catalog with Any + Dark variants
- WCAG AA verified for every text-on-surface pair (light + dark)
-
DesignTokens.swiftdefines spacing, fonts, radii enums - Two screens built using only tokens (no hex literals in view code)
-
.tint(.brandPrimary)applied at root -
PALETTE.mddocuments brief, research, palette, contrast, screenshots - App works in light and dark mode without visual bugs
Common pitfalls
- Picking favorite colors over category-fit colors: if your meditation app uses neon green and hot pink, you’ve ignored the brief.
- Saturated brand color in 60% of the surface: brand color is 10%. Most of the screen should be neutral.
- Pure black dark mode background: causes OLED smear. Use
#0F0F1Aor Apple’ssystemBackground. - Skipping the WCAG check: trendy palettes often fail body-text contrast. Verify every pair.
- Forgetting
.tint(): without it, every Button, Toggle, Slider falls back to system blue, ignoring your brand.
What you’ve learned
You can now take a vague product brief and produce a defensible, accessible, mode-adaptive color system in under an hour. This is a senior skill — most engineers offload this to designers and can’t articulate why a palette works. You can.
The palette work you do once will outlive 90% of the code you write. Spend the hour.
Phase 3 complete. You now have the design literacy to:
- Read Apple’s HIG and apply it consistently
- Translate Figma frames to SwiftUI without losing fidelity
- Define and maintain a token-based color and type system
- Use SF Symbols with the rendering modes appropriate to each context
- Build apps that adapt to dark mode, Dynamic Type, and accessibility settings without drama
- Audit any iOS app for HIG and accessibility violations
- Design Mac apps that feel Mac-native, not iPhone-ported
- Derive a palette from a brief, verify it, and ship it
Phase 4 — Swift Language Fundamentals — comes next.
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.
Next: 4.2 — Views & view hierarchy
4.2 — Views & view hierarchy
Opening scenario
Your app’s home screen is a stack of three “cards.” On older iPhones, scrolling stutters. You open Instruments → Time Profiler → see _drawRect: consuming 40% of the main thread. You open the cards view: someone subclassed UIView and overrode draw(_:) to render a shadow with CGContext. On every scroll frame, the shadow is re-rasterized. Fix: delete draw(_:), set layer.shadowPath, scrolling jumps from 38fps to a buttery 120fps.
This chapter is about what a view actually is. UIView looks simple but hides one of the most important objects in iOS: CALayer. Once you understand the view/layer split, performance puzzles untangle themselves.
| Layer | Owns |
|---|---|
UIView | Touch handling, gesture recognizers, Auto Layout participation |
CALayer | Visual content: backgroundColor, cornerRadius, shadows, transforms, animations |
Concept → Why → How → Code
Views are layer wrappers
Every UIView has a backing CALayer. Most “visual” properties you set on UIView proxy through to the layer:
view.backgroundColor = .red // → view.layer.backgroundColor
view.layer.cornerRadius = 12 // visual
view.layer.shadowOpacity = 0.3 // visual
view.layer.borderWidth = 1 // visual
view.addGestureRecognizer(tap) // UIView-only — layers don't handle touch
view.isUserInteractionEnabled = false // UIView-only
Why the split? Layers are Core Animation primitives — fast, GPU-accelerated, animatable. Views add the iOS-specific responder chain (touch, gestures, accessibility). On Mac, NSView is the equivalent.
The view hierarchy
A tree:
UIWindow (also a UIView)
└── rootViewController.view
├── headerView
│ ├── titleLabel
│ └── avatarImageView
├── scrollView
│ └── contentView
│ ├── card1
│ ├── card2
│ └── card3
└── tabBarView
Each view has:
superview: UIView?— the parent (nil forUIWindow)subviews: [UIView]— children in z-order, last drawn on topaddSubview(_:),removeFromSuperview(),insertSubview(_:at:),bringSubviewToFront(_:)
container.addSubview(card) // appended on top
container.insertSubview(banner, at: 0)// behind everything
container.bringSubviewToFront(card) // make topmost
card.removeFromSuperview() // detach
Frames, bounds, center — coordinate systems
A frame can confuse for years until you internalize this:
| Property | Coordinate space | Meaning |
|---|---|---|
frame | Superview’s coordinates | “Where I am in my parent” |
bounds | Own coordinates | “What my drawable area looks like” (usually origin .zero) |
center | Superview’s coordinates | Shortcut for frame’s center point |
let parent = UIView(frame: CGRect(x: 0, y: 0, width: 400, height: 800))
let child = UIView(frame: CGRect(x: 20, y: 100, width: 200, height: 100))
parent.addSubview(child)
child.frame // (20, 100, 200, 100) ← in parent's space
child.bounds // (0, 0, 200, 100) ← in own space
child.center // (120, 150) ← (20+200/2, 100+100/2)
bounds.origin is non-zero in scroll views — that’s how scrolling works. The scroll view changes bounds.origin.y rather than moving each subview’s frame. Subviews are drawn relative to bounds.origin, so they appear to move.
Don’t set frame if you’re using Auto Layout. Either you use Auto Layout (translatesAutoresizingMaskIntoConstraints = false) and set constraints, or you set frames manually and don’t add constraints. Mixing causes layout conflict warnings and unpredictable behavior.
Layout lifecycle
Two phases: invalidation and resolution.
Something changes (set needs layout)
↓
Marked dirty (setNeedsLayout)
↓
Run loop tick
↓
layoutSubviews called automatically
↓
You position subviews / Auto Layout solves constraints
Methods you’ll use:
view.setNeedsLayout() // "I need a layout pass next run loop"
view.layoutIfNeeded() // "Layout right now, synchronously"
override func layoutSubviews() {
super.layoutSubviews() // Auto Layout solves here
// After super: positions are final. Adjust layer paths, etc.
backgroundLayer.frame = bounds
shadowLayer.shadowPath = UIBezierPath(roundedRect: bounds, cornerRadius: 12).cgPath
}
The triggers for layoutSubviews:
- Bounds change (rotation, window resize)
- A subview is added/removed
setNeedsLayout()was called and run loop ticks- A constraint changes
Drawing: when to override draw(_:), and why almost never
Subclassing UIView and overriding draw(_:) triggers software rasterization on every redraw. CPU-bound. Slow. Used to be the only way to do custom rendering in iOS 3.
In 2026, you almost never need it. Alternatives:
| Want to draw… | Use instead |
|---|---|
| Rounded corners | view.layer.cornerRadius = 12 |
| Shadow | view.layer.shadow* + shadowPath |
| Border | view.layer.borderColor / borderWidth |
| Gradient | CAGradientLayer as view.layer or sublayer |
| Custom shape | CAShapeLayer + UIBezierPath |
| Image processing | Core Image, Core Graphics once, cache the result |
| Complex animation | Core Animation (CABasicAnimation, CAKeyframeAnimation) |
Override draw(_:) only when you have a truly custom render that none of the above can express — a hand-drawn chart, a calligraphic signature, a Mandelbrot. Even then, render once into an UIImage and display the image; don’t redraw every frame.
CALayer essentials
You’ll use these layer classes often:
// CAShapeLayer — for arbitrary paths
let shape = CAShapeLayer()
shape.path = UIBezierPath(ovalIn: bounds).cgPath
shape.fillColor = UIColor.systemBlue.cgColor
view.layer.addSublayer(shape)
// CAGradientLayer — gradients without drawing
let gradient = CAGradientLayer()
gradient.colors = [UIColor.purple.cgColor, UIColor.blue.cgColor]
gradient.startPoint = .init(x: 0, y: 0)
gradient.endPoint = .init(x: 1, y: 1)
gradient.frame = view.bounds
view.layer.addSublayer(gradient)
// CATextLayer — fast text (rarely needed; UILabel is fine)
// CAEmitterLayer — particle systems (confetti, sparks)
// CAReplicatorLayer — automatically replicates a sublayer (loading dots)
Layer changes are GPU-composited and almost free. Combine this with implicit Core Animation: any layer property change is automatically animated, unless you wrap it in CATransaction.setDisableActions(true).
Shadows — the perf trap
// ❌ Slow: forces off-screen rendering every frame
view.layer.shadowColor = UIColor.black.cgColor
view.layer.shadowOpacity = 0.3
view.layer.shadowOffset = .init(width: 0, height: 2)
view.layer.shadowRadius = 6
// ✅ Fast: tells CA exactly what shape to shadow, no path inference needed
view.layer.shadowColor = UIColor.black.cgColor
view.layer.shadowOpacity = 0.3
view.layer.shadowOffset = .init(width: 0, height: 2)
view.layer.shadowRadius = 6
view.layer.shadowPath = UIBezierPath(roundedRect: view.bounds, cornerRadius: 12).cgPath
Set shadowPath whenever bounds settle. In layoutSubviews is the right place. This single change is responsible for more “I made it 4x faster!” PRs than any other UIKit optimization.
Corner radius — the other perf trap
view.layer.cornerRadius = 12
view.layer.masksToBounds = true // ← off-screen rendering for image clipping
With masksToBounds = true (and especially with subview content like images), the system creates an off-screen buffer to clip. Fine for static UI; expensive in scroll views.
Solutions:
- Continuous corners (
view.layer.cornerCurve = .continuous) — Apple’s iOS 13+ smoother corner shape, no extra cost - Pre-clip images — clip the
UIImagebefore assigning, no live masking - Mask layer —
CAShapeLayermask if you really need it
Hit testing
When a tap happens, UIKit walks the view hierarchy to find which view should receive the event:
override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
// Default: returns the deepest subview at point that's visible and accepts touches
// Override to expand hit area or intercept touches
super.hitTest(point, with: event)
}
A view doesn’t receive touches if:
isUserInteractionEnabled = falseisHidden = truealpha < 0.01- The touch point is outside
bounds
To expand a small button’s hit area without resizing it visually:
override func point(inside point: CGPoint, with event: UIEvent?) -> Bool {
let expanded = bounds.insetBy(dx: -12, dy: -12)
return expanded.contains(point)
}
This is the UIKit equivalent of SwiftUI’s .contentShape(Rectangle()).
Memory & view ownership
Views own their subviews via strong references. Removing from the hierarchy releases:
view.removeFromSuperview() // superview drops its strong ref
// If nothing else holds `view`, it deallocates
Common leak: holding subviews in arrays you forget to clear:
class ChartView: UIView {
private var dataPointViews: [UIView] = []
func updateData(_ points: [CGFloat]) {
// ❌ Leak: never empties array
let v = UIView()
addSubview(v)
dataPointViews.append(v)
// ✅ Fix: clear when redrawing
dataPointViews.forEach { $0.removeFromSuperview() }
dataPointViews.removeAll()
// ... add new ones
}
}
Debugging the hierarchy
In LLDB while paused:
po view.recursiveDescription()
In Xcode while running:
Debug → View Debugging → Capture View Hierarchy — opens the 3D view inspector. Indispensable for “where is that view hiding” bugs.
In the wild
- Instagram Stories uses a custom view subclass with
CAShapeLayerfor the segmented progress bar at the top — perfect example of “shape layers over draw()”. - Apple Maps route lines:
CAShapeLayerwith animatedstrokeEndfor the “drawing” effect — single property animation, no per-frame work. - iOS Control Center sliders: custom
UIViewsubclass with aCAGradientLayerbackground and gesture-driven height changes. Layout inlayoutSubviews, no drawing. - Robinhood’s stock chart:
CAShapeLayerwithUIBezierPathinterpolated through data points, 120fps even with 5000 points. The “live” line usespresentationLayerfor in-flight position queries.
Common misconceptions
- “Views and layers do the same thing.” No. Views = touch + layout participation. Layers = visual content + animation. The split is what makes iOS animation fast.
- “I need to subclass UIView for everything.” Compose with subviews and apply layer properties. Subclassing is for behavior, not visuals.
- “
setNeedsLayoutupdates immediately.” No — it schedules a layout pass for the next run loop. UselayoutIfNeeded()to force synchronous. - “Auto Layout is slow, use manual frames.” Auto Layout is plenty fast for typical UIs. Profile before assuming. The expensive code path is repeated constraint changes per frame.
- “
removeFromSuperviewimmediately deallocates the view.” Only if nothing else retains it. Arrays, closures, observers can keep it alive.
Seasoned engineer’s take
The view hierarchy is the most important data structure in your iOS app. Treat it like one:
- Flatten what you can. Each subview is a small but real cost. A row with 12 nested containers vs 3 is measurably slower.
- Use
UIStackViewfor layout grouping instead of manually nesting UIViews with constraints. Less code, same perf, easier to debug. hiddenoverremovedfor views you’ll toggle frequently. Add/remove costs constraints work; togglingisHiddenis cheap.- Reuse aggressively in lists.
UITableViewandUICollectionViewhandle this for you; for one-offs (a “load more” button), reuse the same view across appearances. - Don’t fight Auto Layout. It will win. If a constraint produces an unsatisfiable warning, fix the constraint; never silence the warning.
TIP: Run your app in Xcode’s view debugger after every nontrivial feature. You’ll catch zombie views, overlapping constraints, and unnecessarily deep hierarchies you didn’t know you had.
WARNING:
view.layer.cornerRadius = 12withoutmasksToBounds = truedoes nothing visible if the view has a background color set on the layer but content (e.g., aUIImageView) added as a subview. The cornerRadius only masks the layer’s own drawing, not subviews. UsecornerRadius + masksToBounds, accept the perf cost, or use aCAShapeLayermask.
Interview corner
Junior-level: “What’s the difference between frame and bounds?”
frame is the view’s rectangle in its superview’s coordinate space — where it sits in its parent. bounds is in the view’s own coordinate space — usually origin .zero and the same size as the frame. Scroll views change bounds.origin to scroll their content.
Mid-level: “You see a scroll view that stutters when scrolling. What do you check first?”
Profile with Instruments (Time Profiler, Core Animation). Common culprits in order of likelihood: shadows without shadowPath, off-screen rendering from masksToBounds + cornerRadius on cell images, blending non-opaque views (Color Blended Layers debug option), too many subviews per cell, overriding draw(_:). Fix the worst offender, re-profile.
Senior-level: “Design a custom view that draws a real-time stock chart at 120 Hz with 10,000 data points.”
CAShapeLayer with a precomputed UIBezierPath. Don’t override draw(_:). For real-time updates: keep a circular buffer of points, rebuild the path on a background queue, marshal back to main, assign to shapeLayer.path. Use CATransaction.setDisableActions(true) to avoid implicit animation between frames. For 10k points, simplify the path with Douglas-Peucker before rendering; humans can’t see sub-pixel detail anyway. Test on a ProMotion device with Instruments.
Red flag in candidates: Overriding draw(_:) for shadows, rounded corners, gradients, or borders. Means they don’t know CALayer.
Lab preview
You’ll build a card stack with shadows, rounded corners, and gestures in Lab 4.1. The shadow setup is exactly the perf-aware pattern from this chapter.
Next: 4.3 — Auto Layout & constraints
4.3 — Auto Layout & constraints
Opening scenario
A junior PR lands on your desk. A single screen, 340 lines of constraint code, four nested UIStackViews, six priority = 999 constraints to silence warnings, a if traitCollection.horizontalSizeClass == .compact block that no longer matches reality, and one // FIXME: Auto Layout is broken here comment from 2021. The screen looks fine on iPhone 15. It explodes on iPad in landscape with the keyboard up.
Auto Layout is not the enemy. Auto Layout misused is the enemy. This chapter is the playbook for using it without ending up in the world of 340-line constraint hell.
| Tool | When to reach for it |
|---|---|
NSLayoutAnchor | Default. Modern, type-safe, readable. |
UIStackView | Whenever you’d write 4+ constraints for sibling alignment. |
NSLayoutConstraint.activate([...]) | Batch activation; faster than per-constraint isActive = true. |
| Visual Format Language | Almost never anymore. Legacy code only. |
translatesAutoresizingMaskIntoConstraints = false | On every view you add programmatically. Forget once, layout breaks silently. |
Concept → Why → How → Code
What Auto Layout actually does
Auto Layout is a constraint solver. You declare relationships:
cardA.leadingAnchor == container.leadingAnchor + 16cardA.widthAnchor == container.widthAnchor / 2 - 16cardA.topAnchor == container.safeAreaLayoutGuide.topAnchor + 12
The engine (Cassowary algorithm) solves the system and assigns each view a frame. Per frame of animation, per rotation, per Dynamic Type change — solved fresh.
The cost: solving is non-trivial. For 50 views with reasonable constraints, ~1ms on modern hardware. For 500 nested views with conflicting priorities, several frames. Profile if your scroll stutters.
NSLayoutAnchor — your only constraint API in 2026
The modern way. Type-safe (you can’t constrain leadingAnchor to topAnchor — won’t compile):
let card = UIView()
card.translatesAutoresizingMaskIntoConstraints = false // ← forget this and you'll hate yourself
view.addSubview(card)
NSLayoutConstraint.activate([
card.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor, constant: 16),
card.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor, constant: -16),
card.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 24),
card.heightAnchor.constraint(equalToConstant: 120),
])
The anchors:
- Edge:
leadingAnchor,trailingAnchor,topAnchor,bottomAnchor,leftAnchor,rightAnchor - Center:
centerXAnchor,centerYAnchor - Dimension:
widthAnchor,heightAnchor - Baseline (text views):
firstBaselineAnchor,lastBaselineAnchor
Always use leading/trailing, not left/right. Leading/trailing flip for RTL languages (Arabic, Hebrew) automatically; left/right do not.
UIStackView — the single highest-leverage view
90% of layouts you’d write 4 constraints for, you can write 1 stack view for:
let stack = UIStackView(arrangedSubviews: [titleLabel, subtitleLabel, actionButton])
stack.axis = .vertical
stack.spacing = 12
stack.alignment = .leading // .leading, .center, .trailing, .fill
stack.distribution = .fill // .fill, .fillEqually, .fillProportionally, .equalSpacing, .equalCentering
stack.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(stack)
NSLayoutConstraint.activate([
stack.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 16),
stack.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -16),
stack.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 24),
])
That’s 3 constraints for 3 stacked subviews. Without stack view: 9+ constraints.
Stack view properties to know:
axis:.verticalor.horizontalspacing: gap between arranged subviewsalignment: cross-axis alignment of arranged subviewsdistribution: how arranged subviews share the main axissetCustomSpacing(_:after:): per-pair spacing override (iOS 11+)isLayoutMarginsRelativeArrangement: respectslayoutMargins
Nest stack views for grids:
let row = UIStackView(arrangedSubviews: [cellA, cellB, cellC])
row.axis = .horizontal
row.distribution = .fillEqually
row.spacing = 8
let grid = UIStackView(arrangedSubviews: [row, anotherRow])
grid.axis = .vertical
grid.spacing = 8
For dense grids prefer UICollectionView. For UIs with 2-4 sections of stacked content, nested stacks are clean.
Priorities & content hugging / compression
Every constraint has a priority (1-1000, default 1000 = required). When constraints conflict, the lower priority loses.
let optional = label.widthAnchor.constraint(equalToConstant: 200)
optional.priority = .defaultLow // 250
optional.isActive = true
Two implicit priorities every view has:
- Content hugging priority: “how strongly do I resist being stretched larger than my intrinsic size?”
- Content compression resistance priority: “how strongly do I resist being squeezed smaller than my intrinsic size?”
Example: two labels side by side, one long, one short. Without tuning, Auto Layout doesn’t know which to truncate.
// "Always show me fully; truncate the other one"
titleLabel.setContentCompressionResistancePriority(.required, for: .horizontal)
detailLabel.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
Common pattern with a label + chevron in a row:
// Title takes whatever space is left after the chevron
titleLabel.setContentHuggingPriority(.defaultLow, for: .horizontal)
chevronImageView.setContentHuggingPriority(.required, for: .horizontal)
Intrinsic content size
Some views know their natural size:
UILabel: size of its text in its fontUIImageView: image dimensionsUIButton: title + image + insetsUISwitch,UITextField: fixed system sizes
Custom views override:
override var intrinsicContentSize: CGSize {
CGSize(width: 200, height: 44)
}
// Call when intrinsic size changes
invalidateIntrinsicContentSize()
For views with intrinsic size, you don’t need width/height constraints; Auto Layout uses the intrinsic size. That’s why stack views of labels “just work.”
Safe area, layout margins, readable content
Three guides you’ll reference:
view.safeAreaLayoutGuide // avoid notch, home indicator, status bar
view.layoutMarginsGuide // configurable insets (system default 8-20pt)
view.readableContentGuide // width-capped guide for readable text on iPad
For typical screens, constrain to safeAreaLayoutGuide. For text-heavy screens (article reader), constrain to readableContentGuide so text doesn’t span 1024pt on iPad.
articleLabel.leadingAnchor.constraint(equalTo: view.readableContentGuide.leadingAnchor),
articleLabel.trailingAnchor.constraint(equalTo: view.readableContentGuide.trailingAnchor),
iPad in landscape, readable content guide caps at ~672pt with auto margins.
Size classes & trait collections
Two size classes (compact, regular) for each axis. Combinations:
| Class | Devices |
|---|---|
| Compact width, Regular height | iPhone portrait |
| Regular width, Regular height | iPad full screen, iPhone Plus landscape |
| Compact width, Compact height | iPhone landscape |
| Regular width, Compact height | iPad in split view (sometimes), iPhone Pro Max landscape |
Adapt layout in traitCollectionDidChange:
override func traitCollectionDidChange(_ previous: UITraitCollection?) {
super.traitCollectionDidChange(previous)
if traitCollection.horizontalSizeClass == .regular {
stack.axis = .horizontal
} else {
stack.axis = .vertical
}
}
In iOS 17+ prefer the trait change registration API:
registerForTraitChanges([UITraitHorizontalSizeClass.self]) { (self: ContentVC, _) in
self.updateLayoutForSizeClass()
}
Animating constraints
You can animate constraint changes, not frame changes (with Auto Layout):
heightConstraint.constant = 200 // change the constraint, not the frame
UIView.animate(withDuration: 0.3) {
self.view.layoutIfNeeded() // forces layout pass *inside* animation block
}
The pattern:
- Update constraint constants
- Call
layoutIfNeeded()inside anUIView.animateblock on the root of the affected hierarchy - Auto Layout resolves new positions; animation interpolates between old and new frames
Debugging Auto Layout
The console will yell at you with “Unable to satisfy constraints”:
2026-05-18 14:32:11.044 MyApp[1234:5678] [LayoutConstraints] Unable to simultaneously satisfy constraints.
Probably at least one of the constraints in the following list is one you don't want.
...
Will attempt to recover by breaking constraint
<NSLayoutConstraint:0x... UIView.height == 100>
Read the list carefully — usually two constraints disagree (a fixed height of 100 plus content too tall for 100). Fix:
- Remove one of the conflicting constraints, or
- Lower the priority of the optional one, or
- Use
>=instead of==for flexible bounds
In Xcode, set the Symbolic Breakpoint UIViewAlertForUnsatisfiableConstraints to break exactly when the issue happens, with full stack trace.
For runtime debugging:
po view.constraintsAffectingLayout(for: .horizontal)
po view.hasAmbiguousLayout
po view.exerciseAmbiguityInLayout() // animates between possible layouts
Performance rules
Auto Layout is fast for typical screens, slow for pathological cases:
- Avoid deep nesting (10+ levels). Each level is a solver step.
- Activate constraints in batches with
NSLayoutConstraint.activate([...]); faster than per-constraintisActive = true. - Don’t deactivate and reactivate the same constraints per frame. Cache constraint references; toggle
isActive. - Pre-size
UIStackViewwithsetContentCompressionResistancePriorityto avoid ambiguous fallbacks. UICollectionViewCompositionalLayoutuses Auto Layout under the hood; profile with Instruments’ “Hangs” tool if you see scroll jank.
Cells & self-sizing
UITableViewCell and UICollectionViewCell can self-size via Auto Layout:
tableView.rowHeight = UITableView.automaticDimension
tableView.estimatedRowHeight = 80 // hint for scrollbar accuracy
// In cell:
override func awakeFromNib() {
super.awakeFromNib()
contentView.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
contentView.leadingAnchor.constraint(equalTo: leadingAnchor),
contentView.trailingAnchor.constraint(equalTo: trailingAnchor),
contentView.topAnchor.constraint(equalTo: topAnchor),
contentView.bottomAnchor.constraint(equalTo: bottomAnchor),
])
// Cell internals: constrain subviews to contentView.
// CRITICAL: subviews must form a constraint chain from top to bottom
// so the cell can compute its own height.
}
Common bug: chain breaks (a subview is constrained to the top but not the bottom), so the cell collapses to estimatedRowHeight and stays there. Always verify your subview constraints form a top-to-bottom and leading-to-trailing chain.
When to abandon Auto Layout
Two cases:
- Custom layout passes for highly dynamic UIs: A magazine layout with flowing text, image breakouts, dynamic line breaks. Use
UICollectionViewCompositionalLayout(still Auto Layout-aware) or manuallayoutSubviews. - Performance-critical animations: A 120fps chart cursor that follows pan gestures. Use
CATransform3Dor directframemanipulation in a view that doesn’t participate in Auto Layout (settranslatesAutoresizingMaskIntoConstraints = true, no constraints).
For 99% of UI, Auto Layout is the right tool.
In the wild
- Apple’s UIKit Catalog sample ships dozens of
UIStackViewexamples; the canonical reference. - Airbnb’s Epoxy uses
UIStackViewinternally; their declarative views compile down to nested stacks plus constraints. - Twitter (now X) famously rewrote their feed with
UIStackViews in 2017 and shaved 40% off layout time vs hand-rolled constraints (per their engineering blog). - iOS Mail’s message list uses self-sizing
UITableViewCellwith stack views — long subjects expand row height naturally.
Common misconceptions
- “Auto Layout is slow.” Misused Auto Layout is slow. Used correctly, plenty fast for most apps.
- “
UIStackViewis just sugar.” It’s a realUIViewsubclass that manages its own constraints. Costs the same as nested stack-of-views with no view of your own. - “Set
translatesAutoresizingMaskIntoConstraints = falsealways.” Only on views you constrain. Views you frame manually keep ittrue. Cells’contentViewistrueby default and should remain so unless you specifically need its constraints. - “Priority 999 vs 1000 doesn’t matter.” It matters a lot. 999 is “I’d really like this but I’ll yield”; 1000 is “I will crash before yielding.” The difference avoids most constraint-conflict warnings.
- “Constraints set in
viewDidLoadare enough.” Constraints between views in different VCs (e.g., child VC’s view to parent’s view) must be set after containment is established and beforeviewWillLayoutSubviews. Get the lifecycle wrong, layout breaks.
Seasoned engineer’s take
Auto Layout mastery is mostly knowing when to reach for UIStackView vs raw constraints. The rule I use:
- Layout has visible “flow” (top to bottom or left to right with predictable spacing) →
UIStackView - Layout has overlapping elements, precise asymmetric positioning, or per-view animation → raw
NSLayoutAnchor - Layout is a grid or list of repeating items →
UICollectionViewwith compositional layout
You should be able to look at any iPhone screen and sketch its hierarchy and stack-view structure in 60 seconds. That’s the bar.
Three habits:
- Always activate constraints in batches.
NSLayoutConstraint.activate([...]), never one-at-a-time. - Name constraints you’ll animate. Stuff them in instance properties so you can mutate
.constantlater instead of removing/recreating. - Test in the simulator at every size class. iPhone 16, iPhone 16 Pro Max landscape, iPad Air, iPad Pro 13“ split view, Mac Catalyst window resize. Each surfaces different bugs.
TIP: When debugging “why isn’t this label showing up?”, check four things in this order: (1) was it added to a superview? (2) is
translatesAutoresizingMaskIntoConstraints = false? (3) does it have constraints in both X and Y axes? (4) is the color the same as the background?
WARNING: Animating with
view.layoutIfNeeded()outside the right context (e.g., on a subview rather than the root) may animate nothing — or animate too much. Always call it on the common ancestor of the views whose layout changes.
Interview corner
Junior-level: “How do you pin a view to the safe area of its superview?”
NSLayoutConstraint.activate([
v.leadingAnchor.constraint(equalTo: superview.safeAreaLayoutGuide.leadingAnchor),
v.trailingAnchor.constraint(equalTo: superview.safeAreaLayoutGuide.trailingAnchor),
v.topAnchor.constraint(equalTo: superview.safeAreaLayoutGuide.topAnchor),
v.bottomAnchor.constraint(equalTo: superview.safeAreaLayoutGuide.bottomAnchor),
])
And don’t forget v.translatesAutoresizingMaskIntoConstraints = false.
Mid-level: “What’s the difference between content hugging and compression resistance?”
Content hugging: how strongly a view resists growing past its intrinsic size. Content compression resistance: how strongly it resists shrinking below its intrinsic size. Tune them when two views share space and only one should yield (e.g., a label next to a chevron).
Senior-level: “Design a chat bubble row that auto-sizes to text, has a max-width, and aligns left or right based on sender.”
Cell with a horizontal UIStackView containing a bubble view. Bubble view contains a UILabel with numberOfLines = 0, preferredMaxLayoutWidth set in layoutSubviews (or use compositional layout’s widthDimension). Max width constraint on the bubble at high priority (.required - 1), leading or trailing alignment via toggling stack view’s alignment or by inserting UIView() spacers. Self-sizing rows via tableView.rowHeight = .automaticDimension. For iMessage-style elasticity, swap to compositional layout with estimated heights.
Red flag in candidates: Setting frame manually inside a view that already has constraints. Means they don’t understand the contract.
Lab preview
Auto Layout shows up in Lab 4.1 (list with self-sizing cells), Lab 4.2 (compositional layout), and Lab 4.3 (form layout with stack views).
Next: 4.4 — Navigation
4.4 — Navigation
Opening scenario
PM walks over: “We need a deep link from a push notification straight into Settings → Privacy → Block List, with the user already filtered to a specific contact.” Your nav stack is a UITabBarController with 5 tabs, each wrapped in a UINavigationController. The Settings tab has 4 levels of pushViewController already. You’re going to construct that path programmatically, possibly while the app is launching from a cold start, possibly while it’s resuming from background, and the user should be able to hit back and end up at the right place at every level.
This is navigation engineering — not “I added a push.” This chapter covers the controller types, how they nest, and how to wrangle them for production-grade flows.
| Controller | Mental model |
|---|---|
UINavigationController | Stack: push and pop, back button automatic |
UITabBarController | Set of peers: switch via bottom tabs |
UISplitViewController | Master/detail (iPad, Mac) — sidebar + content |
UIPageViewController | Horizontally swipeable pages (onboarding) |
present(_:animated:) | Modal: covers current screen, dismiss via swipe-down or button |
Concept → Why → How → Code
UINavigationController — push and pop
The most common container. Holds a stack of view controllers; users push deeper and pop back.
let root = ListViewController()
let nav = UINavigationController(rootViewController: root)
window.rootViewController = nav
// Push
nav.pushViewController(DetailViewController(item: item), animated: true)
// Pop one
nav.popViewController(animated: true)
// Pop to root
nav.popToRootViewController(animated: true)
// Pop to specific VC
nav.popToViewController(someVC, animated: true)
// Replace entire stack
nav.setViewControllers([root, level1, level2], animated: true)
The navigation bar at the top:
- Auto-shows back button (when stack count > 1)
- Title comes from each VC’s
navigationItem.title(ortitle) - Bar buttons via
navigationItem.leftBarButtonItem/rightBarButtonItem - Hide bar per VC with
navigationController?.setNavigationBarHidden(true, animated: animated)inviewWillAppear
override func viewDidLoad() {
super.viewDidLoad()
navigationItem.title = "Profile"
navigationItem.rightBarButtonItem = UIBarButtonItem(
systemItem: .edit,
primaryAction: UIAction { [weak self] _ in self?.startEditing() }
)
}
UITabBarController — top-level peers
For “modes” of your app — feed, search, profile, etc. Each tab is typically its own UINavigationController so each has its own push stack.
let feed = UINavigationController(rootViewController: FeedViewController())
feed.tabBarItem = UITabBarItem(title: "Feed", image: UIImage(systemName: "house"), tag: 0)
let search = UINavigationController(rootViewController: SearchViewController())
search.tabBarItem = UITabBarItem(title: "Search", image: UIImage(systemName: "magnifyingglass"), tag: 1)
let profile = UINavigationController(rootViewController: ProfileViewController())
profile.tabBarItem = UITabBarItem(title: "Profile", image: UIImage(systemName: "person"), tag: 2)
let tabs = UITabBarController()
tabs.viewControllers = [feed, search, profile]
window.rootViewController = tabs
Programmatic switching:
tabBarController?.selectedIndex = 2
iOS 18+ added UITabBarController rich tab APIs with section grouping; for new code consider UITabBarController.tabs with UITab objects. Apple’s Health app uses this style.
UISplitViewController — iPad and Mac primary
The canonical iPad layout: sidebar + content. On iPhone, it collapses to a navigation stack automatically.
let split = UISplitViewController(style: .doubleColumn)
split.setViewController(SidebarVC(), for: .primary)
split.setViewController(UINavigationController(rootViewController: DetailVC()), for: .secondary)
split.preferredDisplayMode = .oneBesideSecondary
split.preferredSplitBehavior = .tile
For a 3-pane layout (Mail-style): UISplitViewController(style: .tripleColumn). Apple’s Files, Mail, Notes use this.
When the sidebar item changes, swap the detail:
final class SidebarVC: UITableViewController {
override func tableView(_ tv: UITableView, didSelectRowAt indexPath: IndexPath) {
let detail = makeDetailVC(for: indexPath)
splitViewController?.setViewController(
UINavigationController(rootViewController: detail),
for: .secondary
)
}
}
Modal presentation
present(_:animated:) covers the current view with a new one:
let editor = EditorViewController()
editor.modalPresentationStyle = .pageSheet // default in iOS 13+, sheet with grabber
editor.sheetPresentationController?.detents = [.medium(), .large()]
editor.sheetPresentationController?.prefersGrabberVisible = true
present(editor, animated: true)
Presentation styles:
| Style | Use |
|---|---|
.automatic (default) | iOS picks; usually .pageSheet |
.pageSheet | Card sheet, swipe-down dismiss, detents for height |
.formSheet | Smaller card, centered on iPad |
.fullScreen | Covers entire screen, no swipe-dismiss |
.overFullScreen | Like fullScreen but presenting VC stays in hierarchy |
.popover | iPad only; anchored arrow popover |
iOS 15+ sheet detents (.medium(), .large(), custom) give you Apple-Maps-style draggable sheets:
sheet.detents = [
.custom { ctx in ctx.maximumDetentValue * 0.3 },
.medium(),
.large()
]
sheet.largestUndimmedDetentIdentifier = .medium
sheet.prefersScrollingExpandsWhenScrolledToEdge = false
Dismiss:
dismiss(animated: true)
// Or from the presenting VC:
presentedViewController?.dismiss(animated: true)
Programmatic navigation patterns
For anything beyond trivial apps, do not have view controllers call navigationController?.pushViewController directly. Use the Coordinator pattern:
protocol Coordinator: AnyObject {
func start()
}
final class AppCoordinator: Coordinator {
private let window: UIWindow
private var children: [Coordinator] = []
init(window: UIWindow) { self.window = window }
func start() {
let nav = UINavigationController()
let main = MainCoordinator(navigation: nav)
children.append(main)
main.start()
window.rootViewController = nav
window.makeKeyAndVisible()
}
}
final class MainCoordinator: Coordinator {
private let navigation: UINavigationController
init(navigation: UINavigationController) { self.navigation = navigation }
func start() {
let list = ListViewController()
list.onSelect = { [weak self] item in self?.showDetail(item) }
navigation.setViewControllers([list], animated: false)
}
private func showDetail(_ item: Item) {
let detail = DetailViewController(item: item)
detail.onEdit = { [weak self] in self?.showEditor(for: item) }
navigation.pushViewController(detail, animated: true)
}
private func showEditor(for item: Item) {
let editor = EditorViewController(item: item)
editor.onDone = { [weak self] _ in self?.navigation.dismiss(animated: true) }
let editorNav = UINavigationController(rootViewController: editor)
navigation.present(editorNav, animated: true)
}
}
Benefits:
- VCs don’t know what comes next — only what they emit (closures)
- Coordinators own navigation logic, are unit-testable
- Deep linking becomes “navigate the coordinator tree”
- Swapping flows (A/B test a new onboarding) means swapping coordinators
Deep linking
The deep-link problem: given a URL like myapp://settings/privacy/block?contact=42, navigate the user there cold-start or warm.
// SceneDelegate.swift
func scene(_ scene: UIScene, openURLContexts contexts: Set<UIOpenURLContext>) {
guard let url = contexts.first?.url else { return }
coordinator.handle(url: url)
}
// AppCoordinator
func handle(url: URL) {
let path = url.pathComponents.dropFirst()
switch path.first {
case "settings":
switchToTab(.settings)
settingsCoordinator?.handlePath(Array(path.dropFirst()), query: url.queryItems)
case "feed":
switchToTab(.feed)
feedCoordinator?.handlePath(Array(path.dropFirst()), query: url.queryItems)
default:
return
}
}
Universal Links work the same — Apple’s APIs (NSUserActivity) deliver the URL through scene(_:continue:).
Cold-start: the URL arrives in scene(_:willConnectTo:options:) via options.urlContexts. Cache it, complete UI setup, then apply.
func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options: UIScene.ConnectionOptions) {
// ...setup window, coordinator...
if let url = options.urlContexts.first?.url {
coordinator.handle(url: url)
}
}
Back gestures & interactive pop
By default, UINavigationController supports swipe-from-left-edge to pop. Easy to break by setting a custom back button:
// ❌ Breaks the swipe gesture
navigationItem.leftBarButtonItem = UIBarButtonItem(title: "Back", style: .plain, target: ...)
// ✅ Preserves swipe; just customizes the button visible
navigationItem.backBarButtonItem = UIBarButtonItem(title: "Items", style: .plain, target: nil, action: nil)
The backBarButtonItem is set on the previous VC and applies to its push children. Subtle but important.
If your VC overrides navigationItem.leftBarButtonItem, the interactive pop gesture is disabled. Re-enable explicitly:
navigationController?.interactivePopGestureRecognizer?.delegate = self
extension MyVC: UIGestureRecognizerDelegate {
func gestureRecognizerShouldBegin(_ g: UIGestureRecognizer) -> Bool { true }
}
Transition coordinators
For custom push/pop animations:
let transition = CATransition()
transition.duration = 0.4
transition.type = .moveIn
transition.subtype = .fromRight
navigationController?.view.layer.add(transition, forKey: nil)
navigationController?.pushViewController(detail, animated: false)
For full custom transitions, conform to UIViewControllerAnimatedTransitioning and set yourself as the navigation controller’s delegate. Rarely needed; default push/pop is what users expect.
UIPageViewController — onboarding & swipeable pages
For 3-5 page horizontal swipe flows:
let pager = UIPageViewController(transitionStyle: .scroll, navigationOrientation: .horizontal)
pager.dataSource = self // returns pages before/after current
pager.delegate = self // tracks current page index
pager.setViewControllers([page0], direction: .forward, animated: false)
For longer paged content, prefer UICollectionView with horizontal paging-enabled scrolling — less ceremony, better performance.
Memory: who retains whom
Navigation hierarchies are easy to leak:
window → tabBarController → [navController1, navController2]
navController1 → [vc1, vc2]
vc2 → closure → [weak self] ✅
vc2 → closure → self ❌ retain cycle if closure stored on vc2
Coordinators introduce another retention point. If coordinator holds children and children hold a back-reference, you’ve made a cycle. Children should weak the parent or use IDs.
When a presented modal is dismissed, the presented VC and any objects it owns deallocate — unless something else holds them. A common leak: dismissing a modal whose VC has a delegate set to itself indirectly, or holds a strong reference to a long-lived service that holds it back.
Validate with the Memory Graph Debugger: pause the app, click the graph icon. Expand MyApp > Coordinator. Anything you expected to be deallocated still there is a leak.
In the wild
- Spotify iOS uses a tab bar (Home, Search, Library) with each tab as its own navigation controller. Now Playing is a sheet detent over the entire app.
- Instagram is tabbed with a feed/search/reels/shopping/profile pattern. Story camera presents
.overFullScreen. DMs push from the tab — not present — preserving back navigation. - Apple Maps uses split view on iPad (sidebar + map), tab-like card detents on iPhone. Search results are a detented sheet.
- Lyft uses a single navigation controller with deep stacks; ride flow is
.fullScreenmodal so back gestures don’t accidentally exit a paid ride. - Mail.app is
UISplitViewController(.tripleColumn)on iPad — mailboxes / messages / message. On iPhone it collapses to a nav stack automatically.
Common misconceptions
- “Just push from VC A to VC B directly.” Fine for prototypes; production codebases use coordinators or routers because flow logic must be testable and swappable.
- “Modal
.fullScreenis the same as a push.” It’s not — modal isn’t in a navigation stack; no back button, no swipe-back gesture, no shared nav bar. Pick deliberately. - “
presentworks from anywhere.” Only from the topmost presented VC. Presenting from a backgrounded VC silently does nothing or logs a warning. - “
UINavigationControlleralways shows a navigation bar.” It does by default, but you can hide it per VC. The container is the stack; the bar is decoration. - “Deep links need a special framework.” No. Standard URL handling in
SceneDelegateplus a coordinator that knows the routes is enough. Frameworks like XCoordinator help with complex apps.
Seasoned engineer’s take
Navigation is the single most under-architected area in junior iOS code. Every senior interview probes it because senior engineers know:
- Flow logic belongs outside view controllers
- Deep linking is a routing problem, not a presentation problem
- iPad and Mac split views must be supported, not afterthoughts
- The user always wants to be able to back out gracefully
A pragmatic recipe for new projects:
AppCoordinatorowned bySceneDelegate- Per-feature coordinators created lazily as user enters the feature
- View controllers expose closures (
onSelect,onDone) rather than navigation calls - Deep linking goes through
AppCoordinator.handle(url:)which dispatches to feature coordinators - Modal vs push decided by user mental model: “is this a brief task they’ll finish or cancel” (modal) vs “is this part of an exploration journey” (push)
TIP: Run your app’s deep links from Terminal with
xcrun simctl openurl booted "myapp://settings/privacy". Saves you from typing into Notes and tapping every time.
WARNING: Presenting a modal from a VC that’s inside another modal is a stack: dismissing only dismisses the top. Track your presentation depth or your users will be stuck two modals deep wondering where the back button is.
Interview corner
Junior-level: “When do you use a push vs a modal?”
Push for navigation through a hierarchy of related content (list → detail → sub-detail). Modal for a self-contained task the user will finish or cancel (compose a tweet, edit a setting, sign up). Modals interrupt; pushes continue.
Mid-level: “Describe the coordinator pattern and why you’d use it.”
A coordinator owns navigation between view controllers. VCs emit events (closures or delegates) saying “the user wants to go to the detail screen with this item”; the coordinator decides what comes next. Benefits: VCs are reusable across flows, navigation logic is testable in isolation, deep linking maps onto coordinator method calls, A/B testing a new flow is swapping a coordinator.
Senior-level: “Design deep linking for an app with 5 tabs, each with a 4-level navigation stack, that needs to support cold-start, warm-start, and Universal Links.”
URL scheme: myapp://<tab>/<level1>/<level2>?<query>. SceneDelegate captures URL in willConnectTo: (cold) or openURLContexts (warm) or continueUserActivity: (Universal). AppCoordinator.handle(url:) parses path, switches tab, calls into the tab’s coordinator with the rest of the path. Each level checks if it can construct that level’s VC with the given parameters; missing data triggers a fetch with a loading state. Edge cases: app is in onboarding flow (queue the deep link, replay after onboarding completes), user isn’t authenticated (queue, replay after auth). Tests: snapshot the resulting navigation stack for each known URL.
Red flag in candidates: “I’d just push from each VC directly.” Means they’ve never debugged a 6-level deep nav stack with back-button bugs.
Lab preview
Navigation patterns thread through every UIKit lab. Lab 4.1 uses a navigation controller with detail push; Lab 4.3 uses modal presentation for the signup flow.
Next: 4.5 — UITableView & UICollectionView
4.5 — UITableView & UICollectionView
Opening scenario
The app you joined has a 600-line UITableViewController with a 90-line cellForRowAt, three if/else arms inside it, three prepareForReuse quirks, an Array re-sorted on every reloadData(), and intermittent “Cell at index path X doesn’t exist” crashes when filtering. Plus: the next ticket says “use the same UI but as a grid on iPad.”
In 2026 you do not write any of that. You use diffable data sources and compositional layouts — Apple’s modern APIs from iOS 13+ that solve the entire category of “I changed the data and the table state is inconsistent” bugs by design, and UITableView / UICollectionView are nearly interchangeable.
| Need | API |
|---|---|
| Scrollable list of rows | UITableView (or UICollectionView with list layout) |
| Grid, mosaic, magazine, custom layouts | UICollectionView with compositional layout |
| Reordering, deletes, animated updates | Diffable data source (universal) |
| Many sections with different layouts | Compositional layout sections |
Concept → Why → How → Code
Why UITableView exists when UICollectionView does more
Historical reasons. UITableView shipped in iOS 2; UICollectionView in iOS 6. By 2026:
UITableViewis still simpler for vertical row lists with self-sizingUICollectionViewwithUICollectionLayoutListConfigurationmatches table view feature-for-feature- New code can pick either; teams often default to
UICollectionViewfor consistency
For learning, you must know both. Production code: pick one and stay consistent within the codebase.
Diffable data sources — stop fighting state
Old world (don’t write this):
// ❌ ancient pattern
private var items: [Item] = []
func tableView(_ tv: UITableView, numberOfRowsInSection section: Int) -> Int { items.count }
func tableView(_ tv: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = tv.dequeueReusableCell(withIdentifier: "Cell", for: indexPath) as! ItemCell
cell.configure(items[indexPath.row])
return cell
}
func refresh(newItems: [Item]) {
items = newItems
tableView.reloadData() // throws away scroll position, breaks animations, racy
}
Modern world (write this):
import UIKit
enum Section: Hashable { case main }
final class ItemListVC: UIViewController {
private var tableView: UITableView!
private var dataSource: UITableViewDiffableDataSource<Section, Item.ID>!
private var items: [Item.ID: Item] = [:]
override func viewDidLoad() {
super.viewDidLoad()
setupTableView()
configureDataSource()
Task { await loadInitial() }
}
private func setupTableView() {
tableView = UITableView(frame: view.bounds, style: .plain)
tableView.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(tableView)
NSLayoutConstraint.activate([
tableView.topAnchor.constraint(equalTo: view.topAnchor),
tableView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
tableView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
tableView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
])
tableView.register(ItemCell.self, forCellReuseIdentifier: "Cell")
tableView.rowHeight = UITableView.automaticDimension
}
private func configureDataSource() {
dataSource = UITableViewDiffableDataSource<Section, Item.ID>(tableView: tableView) {
[weak self] tv, indexPath, id in
let cell = tv.dequeueReusableCell(withIdentifier: "Cell", for: indexPath) as! ItemCell
if let item = self?.items[id] { cell.configure(item) }
return cell
}
}
private func apply(_ newItems: [Item], animated: Bool = true) {
items = Dictionary(uniqueKeysWithValues: newItems.map { ($0.id, $0) })
var snap = NSDiffableDataSourceSnapshot<Section, Item.ID>()
snap.appendSections([.main])
snap.appendItems(newItems.map(\.id))
dataSource.apply(snap, animatingDifferences: animated)
}
}
What you get for free:
- Inserts, deletes, moves between snapshots are diffed → correct animations automatically
- No “index out of bounds” race conditions between data update and reload
- Sections are first-class — append/insert/reorder
Use the item’s ID (Hashable) as the diffable identifier, not the full model. Otherwise changing any property forces a “delete then insert” instead of a reload.
For “the item’s data changed but it’s the same row” — call snap.reloadItems([id]) (animated diff between old & new contents) or snap.reconfigureItems([id]) (calls cell config without scrap/reuse — iOS 15+, preferred).
Cell registration — modern API
let registration = UICollectionView.CellRegistration<UICollectionViewListCell, Item.ID> {
[weak self] cell, indexPath, id in
guard let item = self?.items[id] else { return }
var config = cell.defaultContentConfiguration()
config.text = item.title
config.secondaryText = item.subtitle
config.image = UIImage(systemName: item.iconName)
cell.contentConfiguration = config
cell.accessories = [.disclosureIndicator()]
}
dataSource = UICollectionViewDiffableDataSource<Section, Item.ID>(collectionView: collectionView) {
cv, indexPath, id in
cv.dequeueConfiguredReusableCell(using: registration, for: indexPath, item: id)
}
No register(_:forCellWithReuseIdentifier:), no as! casts. Type-safe end to end.
For UITableView, the equivalent is UITableView.CellRegistration (iOS 17+). Same API shape.
Compositional layout — one layout for all the shapes
UICollectionViewCompositionalLayout is the way to build complex layouts in 2026. The model:
Section
├── Group (defines layout of items)
│ └── Item (defines size of a single cell)
└── Supplementary items (headers, footers, badges)
Vertical list:
let layout = UICollectionViewCompositionalLayout { sectionIndex, env in
let item = NSCollectionLayoutItem(layoutSize: .init(
widthDimension: .fractionalWidth(1.0),
heightDimension: .estimated(60)
))
let group = NSCollectionLayoutGroup.vertical(
layoutSize: .init(
widthDimension: .fractionalWidth(1.0),
heightDimension: .estimated(60)
),
subitems: [item]
)
let section = NSCollectionLayoutSection(group: group)
return section
}
Two-column grid:
let item = NSCollectionLayoutItem(layoutSize: .init(
widthDimension: .fractionalWidth(0.5),
heightDimension: .fractionalHeight(1.0)
))
let group = NSCollectionLayoutGroup.horizontal(
layoutSize: .init(
widthDimension: .fractionalWidth(1.0),
heightDimension: .absolute(120)
),
subitems: [item]
)
group.interItemSpacing = .fixed(8)
let section = NSCollectionLayoutSection(group: group)
section.interGroupSpacing = 8
section.contentInsets = .init(top: 16, leading: 16, bottom: 16, trailing: 16)
Horizontally scrolling carousel within a vertical list section:
section.orthogonalScrollingBehavior = .continuous // .paging, .continuousGroupLeadingBoundary, etc.
Apple Music’s UI: a single vertical scroll with multiple horizontal carousels — built entirely with compositional layout’s orthogonalScrollingBehavior. No nested collection views, no scroll delegation hacks.
For list-style sections that you’d previously do with UITableView:
let listConfig = UICollectionLayoutListConfiguration(appearance: .insetGrouped)
let listSection = NSCollectionLayoutSection.list(using: listConfig, layoutEnvironment: env)
Then your list section uses UICollectionViewListCell with its built-in swipe actions, accessories, etc.
Self-sizing cells
For variable-height cells (text-driven UI):
// UITableView
tableView.rowHeight = UITableView.automaticDimension
tableView.estimatedRowHeight = 80
For compositional layout, use .estimated(60) instead of .absolute(60). The system measures the actual size after Auto Layout solves and updates the layout.
Inside cells, the constraint chain from contentView top → middle subviews → contentView bottom must be complete. If it’s broken, cells collapse to the estimated height.
Swipe actions
UICollectionViewListCell (or UITableViewCell in iOS 11+):
listConfig.trailingSwipeActionsConfigurationProvider = { [weak self] indexPath in
guard let id = self?.dataSource.itemIdentifier(for: indexPath) else { return nil }
return UISwipeActionsConfiguration(actions: [
UIContextualAction(style: .destructive, title: "Delete") { _, _, completion in
self?.delete(id: id)
completion(true)
}
])
}
listConfig.leadingSwipeActionsConfigurationProvider = { [weak self] indexPath in
UISwipeActionsConfiguration(actions: [
UIContextualAction(style: .normal, title: "Star") { _, _, completion in
// mark item starred
completion(true)
}
])
}
Apple’s full-swipe behavior is handled automatically when the first action’s style = .destructive.
Performance — what to watch
UITableView / UICollectionView are highly optimized but still trip on:
- Heavy cells: lots of subviews, shadows without
shadowPath, blended layers. Profile with Core Animation instrument. - Synchronous image loading in
cellForRowAt. Always async; useURLCacheor a library (Kingfisher, Nuke, SDWebImage). reloadData()instead of snapshot diffs — kills animations, breaks scroll, can crash if data updates during a scroll.prepareForReusedoing too much — reset stateful properties only, not visual setup.- Cell heights that aren’t cached — provide accurate
estimatedRowHeight. Wildly wrong estimates make scroll position jump.
Section snapshots — for outline/expandable UIs
For nested/expandable hierarchies (Files-style outline view):
var sectionSnap = NSDiffableDataSourceSectionSnapshot<Item.ID>()
let parent = rootItem.id
sectionSnap.append([parent])
sectionSnap.append(rootItem.children.map(\.id), to: parent)
sectionSnap.expand([parent])
dataSource.apply(sectionSnap, to: .main, animatingDifferences: true)
Combined with UICollectionViewListCell.accessories = [.outlineDisclosure()] you get expand/collapse for free.
Drag & drop, reordering
dataSource.reorderingHandlers.canReorderItem = { _ in true }
dataSource.reorderingHandlers.didReorder = { [weak self] transaction in
self?.applyReorder(transaction)
}
collectionView.dragInteractionEnabled = true
The system handles the gesture, animation, and snapshot diffing. You only react to the final transaction.
Headers, footers, decoration
let header = NSCollectionLayoutBoundarySupplementaryItem(
layoutSize: .init(widthDimension: .fractionalWidth(1.0), heightDimension: .estimated(40)),
elementKind: UICollectionView.elementKindSectionHeader,
alignment: .top
)
header.pinToVisibleBounds = true // sticky header
section.boundarySupplementaryItems = [header]
let headerRegistration = UICollectionView.SupplementaryRegistration<TitleHeaderView>(
elementKind: UICollectionView.elementKindSectionHeader
) { header, kind, indexPath in
header.titleLabel.text = "Section \(indexPath.section)"
}
dataSource.supplementaryViewProvider = { cv, kind, indexPath in
cv.dequeueConfiguredReusableSupplementary(using: headerRegistration, for: indexPath)
}
In the wild
- Apple’s WWDC sample code — Search for “Modern Collection Views” (Apple’s official compositional-layout sample). Canonical patterns for every shape.
- App Store iOS app: every screen — Today, Apps, Games — is one compositional
UICollectionViewper tab with many section types. - Instagram feed:
UICollectionViewwith diffable data source; each post is a section with multiple item types (header, image, actions, caption). Used to beIGListKit; in 2026 modern compositional layout. - Apple Notes sidebar: list configuration with outline section snapshots for the folder hierarchy.
- Robinhood watchlist: custom compositional layout with sticky headers and continuous orthogonal scrolling carousels for the “top movers” rows.
Common misconceptions
- “Use
reloadData()ifperformBatchUpdatesis confusing.” Modern code uses neither; diffable data sources handle everything via snapshots. - “
UICollectionViewis overkill for a simple list.” With list configuration it’s the same code as a table, with better APIs going forward. - “Compositional layout is hard.” It’s verbose at first; learn item → group → section once and the rest composes. Far simpler than the old
flowLayoutsubclassing. - “I need a 3rd-party library for diffing.” Apple’s diffable data source is excellent; you only need a library for unusual cases (e.g., custom transitions).
- “Estimated sizes are exact.” They’re hints. The system measures actual cells; wildly wrong estimates affect scroll bar accuracy and initial scroll position.
Seasoned engineer’s take
Modern UIKit lists are easy once you commit to diffable + compositional. The old patterns (reloadData, hand-coded performBatchUpdates, flow layouts) generated a class of bugs that simply doesn’t exist with the new APIs. The flip side: senior interviewers will probe whether you know the modern stack — answering with the old patterns dates your knowledge to 2017.
Habits:
- Always identify items by
HashableID (UUID,String), not by value. Lets the diffing engine track moves correctly. - Use
reconfigureItemsoverreloadItemswhen only content (not identity) changes. iOS 15+, much cheaper. - Build cells with content configurations, not custom subclasses, when possible.
UIListContentConfigurationis Apple’s tested, performant, accessible default. - Profile with
os_signpostany time you suspect collection view perf issues. Apple’s Instruments has built-in Collection View instruments. - Test snapshots with multiple update orderings (insert + reload + delete in same snapshot). The diff engine is robust but your understanding might not be.
TIP: When converting old code, start by replacing the data source. Diffable + your existing layout works fine — you don’t need to migrate to compositional layout in the same PR. Incremental modernization beats big-bang rewrites.
WARNING: Don’t capture
selfstrongly in cell registration or supplementary registration closures. They’re long-lived (the registration object lives as long as the collection view). Always[weak self].
Interview corner
Junior-level: “How does cell reuse work?”
The collection/table view maintains a pool of cells off-screen. When a cell scrolls off, it’s added back to the pool. When a new row needs a cell, the pool’s reused. cellForRowAt gets a recycled cell — you must reset all stateful properties (image, text, selection) before configuring with the new data, otherwise old content bleeds through.
Mid-level: “What’s diffable data source and why is it better than reloadData()?”
You provide snapshots (immutable section/item lists by identifier). The data source diffs the new snapshot against the current state and applies inserts/deletes/moves with the right animations. Eliminates index-out-of-bounds bugs from racy mutations, gives correct animations free, makes sections first-class.
Senior-level: “Design a feed that mixes ad cards, story carousels, and post cells in one scroll with smooth 120fps performance.”
UICollectionView with compositional layout. Multiple section types: ad section (single full-width item with .estimated height), story section (horizontal orthogonal scrolling, items as .absolute(80) circles), post section (vertical list of estimated-height items). Diffable data source with an enum item type (case ad(AdID), story(StoryID), post(PostID)) so updates animate correctly when types interleave. Image loading via Nuke with prefetching tied to UICollectionViewDataSourcePrefetching. Cells preallocate views, avoid shadows without shadowPath, opaque backgrounds for blending. Profile on a low-end target (iPhone SE 3) with Time Profiler + Core Animation instruments to verify 120fps.
Red flag in candidates: Writing cellForRowAt with if/else to pick cell type, casting to a class with as!. The modern pattern is enum item identifiers + cell registrations per type.
Lab preview
Lab 4.1 builds a real diffable + table-style list. Lab 4.2 builds a 3-section compositional layout (banner, carousel, grid).
Next: 4.6 — User input
4.6 — User input: touches, gestures, text
Opening scenario
A designer drops a Figma file: a swipeable card stack like Tinder, with a long-press to peek at full detail, a double-tap to like, and pinch-to-zoom on the image. The “save card” form below has 6 text fields, autocomplete on city, formatted phone number input, and the keyboard must not cover the active field.
You don’t write touch tracking from scratch. You compose gesture recognizers and lean on UITextField / UITextView / UIKeyboardLayoutGuide. This chapter is the toolbox.
| Need | Tool |
|---|---|
| Tap, double-tap | UITapGestureRecognizer |
| Drag a view | UIPanGestureRecognizer |
| Pinch to zoom | UIPinchGestureRecognizer |
| Long press / context menu | UILongPressGestureRecognizer, UIContextMenuInteraction |
| Swipe in a cardinal direction | UISwipeGestureRecognizer |
| Text input | UITextField, UITextView |
| Keyboard avoidance | UIKeyboardLayoutGuide (iOS 15+) |
Concept → Why → How → Code
Hit testing recap
When a touch lands, UIKit walks the view tree from the root, calling point(inside:with:) on each subview. The deepest view that returns true becomes the touch target. From 4.2 you remember: views with isHidden, isUserInteractionEnabled = false, or alpha < 0.01 are skipped.
You almost never override touchesBegan/Moved/Ended directly. You attach a gesture recognizer.
Tap recognizers
let tap = UITapGestureRecognizer(target: self, action: #selector(handleTap))
tap.numberOfTapsRequired = 1
view.addGestureRecognizer(tap)
@objc private func handleTap(_ gr: UITapGestureRecognizer) {
let location = gr.location(in: view)
print("tapped at \(location)")
}
Modern (closure-based) — UIAction doesn’t fit gestures directly, but you can wrap:
private let tap = UITapGestureRecognizer()
tap.addTarget(self, action: #selector(handleTap))
Or use a small wrapper that holds a closure as an @objc target. Many teams have ClosureGestureRecognizer helpers; pick one or stay with @objc.
For double-tap that doesn’t compete with single-tap, set requirement:
let single = UITapGestureRecognizer(target: self, action: #selector(handleSingle))
let double = UITapGestureRecognizer(target: self, action: #selector(handleDouble))
double.numberOfTapsRequired = 2
single.require(toFail: double) // single waits to confirm double didn't happen
view.addGestureRecognizer(single)
view.addGestureRecognizer(double)
Adds a small delay to single tap (~300ms) — only use this when you actually need both.
Pan — drag with state machine
Pan recognizer reports a state machine: began → changed (many) → ended/cancelled. Always switch on it:
let pan = UIPanGestureRecognizer(target: self, action: #selector(handlePan))
card.addGestureRecognizer(pan)
@objc private func handlePan(_ gr: UIPanGestureRecognizer) {
let translation = gr.translation(in: view)
switch gr.state {
case .began:
startCenter = card.center
case .changed:
card.center = CGPoint(
x: startCenter.x + translation.x,
y: startCenter.y + translation.y
)
case .ended, .cancelled:
let velocity = gr.velocity(in: view)
if abs(velocity.x) > 1000 || abs(card.center.x - startCenter.x) > 100 {
// commit swipe
animateOffScreen(direction: velocity.x > 0 ? .right : .left)
} else {
// snap back
UIView.animate(withDuration: 0.3) { self.card.center = self.startCenter }
}
default: break
}
}
Key API choices:
translation(in:)— total movement since.beganvelocity(in:)— current velocity in points/sec, useful for fling detectiongr.setTranslation(.zero, in: view)— reset baseline mid-gesture (rare)
Pinch & rotation
let pinch = UIPinchGestureRecognizer(target: self, action: #selector(handlePinch))
imageView.addGestureRecognizer(pinch)
@objc private func handlePinch(_ gr: UIPinchGestureRecognizer) {
if gr.state == .began || gr.state == .changed {
imageView.transform = imageView.transform.scaledBy(x: gr.scale, y: gr.scale)
gr.scale = 1.0 // reset to delta, not absolute
}
}
For pinch-to-zoom in a scroll view, prefer UIScrollView with minimumZoomScale / maximumZoomScale and viewForZooming(in:). Free hardware-accelerated zoom, momentum, bounce.
Long press & context menus
Old way: UILongPressGestureRecognizer. New way (iOS 13+): UIContextMenuInteraction. Adds the system long-press → preview → menu UI matching iOS conventions:
let interaction = UIContextMenuInteraction(delegate: self)
card.addInteraction(interaction)
extension CardVC: UIContextMenuInteractionDelegate {
func contextMenuInteraction(
_ interaction: UIContextMenuInteraction,
configurationForMenuAtLocation location: CGPoint
) -> UIContextMenuConfiguration? {
UIContextMenuConfiguration(identifier: nil, previewProvider: nil) { _ in
UIMenu(children: [
UIAction(title: "Share", image: UIImage(systemName: "square.and.arrow.up")) { _ in
self.share()
},
UIAction(title: "Delete", image: UIImage(systemName: "trash"), attributes: .destructive) { _ in
self.delete()
},
])
}
}
}
UICollectionView and UITableView have built-in delegate methods (contextMenuConfigurationForItemAt) — use those for cell context menus.
Gesture conflicts & delegates
Multiple recognizers on the same view can conflict. UIGestureRecognizerDelegate resolves:
extension MyVC: UIGestureRecognizerDelegate {
func gestureRecognizer(
_ a: UIGestureRecognizer,
shouldRecognizeSimultaneouslyWith b: UIGestureRecognizer
) -> Bool {
true // allow both pinch & rotation simultaneously
}
}
Common need: pan inside a scroll view (without canceling scroll). Set:
panGR.delegate = self
// allow pan and scroll to fire together
For when one gesture should defer to another (e.g., your custom swipe shouldn’t activate until the system back-swipe fails):
mySwipe.require(toFail: navigationController!.interactivePopGestureRecognizer!)
Text input — UITextField vs UITextView
UITextField | UITextView | |
|---|---|---|
| Lines | Single | Multiple |
| Delegate | UITextFieldDelegate | UITextViewDelegate |
| Return key | Closes / submits | Inserts newline |
| Placeholder | Built-in .placeholder | Manual workaround |
| Common use | Form inputs, search | Comments, descriptions, long form text |
let email = UITextField()
email.placeholder = "Email"
email.keyboardType = .emailAddress
email.autocapitalizationType = .none
email.autocorrectionType = .no
email.textContentType = .emailAddress // enables AutoFill
email.returnKeyType = .next
email.delegate = self
extension SignupVC: UITextFieldDelegate {
func textFieldShouldReturn(_ tf: UITextField) -> Bool {
if tf == emailField { passwordField.becomeFirstResponder() }
else { submit() }
return true
}
func textField(_ tf: UITextField,
shouldChangeCharactersIn range: NSRange,
replacementString string: String) -> Bool {
// input validation, formatting (e.g., phone number masking)
true
}
}
Critical: set textContentType correctly (.emailAddress, .password, .oneTimeCode, .streetAddressLine1). This unlocks AutoFill, password manager integration, SMS code suggestions. Users hate forms that don’t autofill.
Keyboard avoidance — UIKeyboardLayoutGuide (iOS 15+)
The old pattern: subscribe to UIResponder.keyboardWillShowNotification, parse the frame, compute inset, animate bottomConstraint.constant. Ten lines, easy to break.
The new pattern: one constraint.
NSLayoutConstraint.activate([
submitButton.bottomAnchor.constraint(
equalTo: view.keyboardLayoutGuide.topAnchor,
constant: -16
)
])
The button now floats above the keyboard automatically, with the right animation curve and duration when the keyboard appears/disappears. Replaces the entire notification-subscription pattern.
For scroll-view-based forms:
scrollView.keyboardDismissMode = .interactive // user can swipe down to dismiss
scrollView.contentInsetAdjustmentBehavior = .always
For more complex needs (e.g., scrolling the active field into view), keep manual notification handling — but only for fields. Layout uses keyboardLayoutGuide.
Dismissing the keyboard
// Programmatic
view.endEditing(true)
// On tap outside
let tap = UITapGestureRecognizer(target: view, action: #selector(UIView.endEditing(_:)))
tap.cancelsTouchesInView = false
view.addGestureRecognizer(tap)
The cancelsTouchesInView = false is critical — without it, taps on buttons get swallowed.
Custom input views & accessory views
Replace the keyboard with a custom picker:
let picker = UIPickerView()
picker.dataSource = self
picker.delegate = self
field.inputView = picker
// Toolbar above keyboard
let toolbar = UIToolbar()
toolbar.sizeToFit()
toolbar.items = [
UIBarButtonItem(systemItem: .flexibleSpace),
UIBarButtonItem(systemItem: .done, primaryAction: UIAction { [weak self] _ in
self?.view.endEditing(true)
})
]
field.inputAccessoryView = toolbar
Search bars & search controllers
let search = UISearchController(searchResultsController: nil)
search.searchResultsUpdater = self
search.obscuresBackgroundDuringPresentation = false
search.searchBar.placeholder = "Search items"
navigationItem.searchController = search
navigationItem.hidesSearchBarWhenScrolling = false
extension MyVC: UISearchResultsUpdating {
func updateSearchResults(for searchController: UISearchController) {
let query = searchController.searchBar.text ?? ""
filter(query: query)
}
}
For real search, debounce the query (you don’t want to hit the network on every keystroke). Combine debounce or a simple Task + cancellation:
private var searchTask: Task<Void, Never>?
func updateSearchResults(for sc: UISearchController) {
searchTask?.cancel()
let query = sc.searchBar.text ?? ""
searchTask = Task { [weak self] in
try? await Task.sleep(for: .milliseconds(300))
guard !Task.isCancelled else { return }
await self?.runSearch(query)
}
}
Accessibility for input
accessibilityLabel— what VoiceOver readsaccessibilityHint— extra context (“Double tap to edit”)accessibilityTraits—.button,.searchField, etc.- For gesture-only UI, provide a tappable alternative (long-press shortcut won’t help VoiceOver users)
- Test with Voice Control (“Show numbers” / “Tap 4”) — your buttons must have accessible names
In the wild
- Tinder swipe deck:
UIPanGestureRecognizerwith velocity-based decision; cards stacked in aUICollectionViewwith custom layout. The “rewind” feature is a stack of past-card snapshots. - iMessage:
UITextViewwith intrinsic-size growth, attachedinputAccessoryViewis the entire compose bar (Camera, App drawer, send). - Apple Camera: gesture-heavy app — pinch zooms, double-tap flips camera, drag adjusts exposure. All recognizers, all configured to fire simultaneously via the delegate.
- Apple Maps: pinch + rotate + pan all simultaneous; long press drops a pin. Custom interactions on top of
MKMapView’s built-in recognizers. - Robinhood chart cursor: long-press to show value, drag to scrub.
UILongPressGestureRecognizermorphs into a pan on.began.
Common misconceptions
- “Override
touchesBegan/Moved/Endedfor custom interactions.” Almost never. Compose recognizers; they’re battle-tested and integrate with system behaviors. - “
shouldRecognizeSimultaneouslyWithdefaults totrue.” It defaults to false. Two recognizers on the same view will exclude each other unless you say otherwise. - “
textContentTypeis optional decoration.” It controls AutoFill, SMS code suggestions, and password manager integration. Critical for UX. - “
UIKeyboardLayoutGuideis iOS 16+.” It’s iOS 15+. Use it. The old notification dance is legacy. - “Disable autocorrect on every field.” Only on email, password, username, codes, URLs. Leave it on for actual text fields (names, addresses, comments) — users expect it.
Seasoned engineer’s take
Input UX is where apps feel polished or cheap. Lessons over years:
- Match system conventions: long-press = context menu, pull-down = refresh, swipe-left = delete. Don’t reinvent them.
- Form input deserves design care: AutoFill, smart keyboards,
returnKeyTypechains, formatted input (phone, currency, card number). Saves users seconds per field across millions of sessions. - Always handle gesture cancellation:
.cancelledstate happens (interruption from system alert, low memory, etc.). Restore visual state cleanly. - Test with a slow finger: many drag gestures only work right when fast. Real users include grandparents.
TIP: For one-handed reachability, place primary CTAs near the bottom (within thumb reach on a 6.7“ phone).
keyboardLayoutGuide-anchored bottom buttons are a UX win.
WARNING:
inputAccessoryViewset on aUIViewController(via the override) is separate frominputAccessoryViewset on aUITextField/UITextView. Pick one model; mixing them produces double accessory bars.
Interview corner
Junior-level: “How do you dismiss the keyboard when the user taps outside a text field?”
Add a UITapGestureRecognizer to the view that calls view.endEditing(true). Set cancelsTouchesInView = false so it doesn’t eat taps on buttons.
Mid-level: “How would you implement a swipeable card stack like Tinder?”
Stack of UIViews in z-order. Top card has a UIPanGestureRecognizer; track translation(in:) to drag and velocity(in:) to decide a fling commit. On .changed, also rotate slightly by translation.x / 1000 for a natural feel. On .ended, decide commit vs snap-back based on distance + velocity. Animate off-screen and reveal the next card. Use a custom UICollectionView layout if you want to manage many cards efficiently.
Senior-level: “Design the keyboard-handling for a chat app with an inputAccessoryView that contains a growing text view, attach button, and send button.”
Use inputAccessoryView at the UIViewController level (override inputAccessoryView and return your bar). Bar is a UIView subclass that returns intrinsicContentSize based on the UITextView’s content size, capped at ~5 lines. becomeFirstResponder returns true on the VC. For when the keyboard isn’t visible, the bar floats at the bottom anchored to view.keyboardLayoutGuide.topAnchor so it tracks keyboard up and down. The text view’s content size is observed via textViewDidChange; the bar’s height changes invalidate the intrinsic size, which animates. Send button is disabled when text is empty; on send, clear the text view and call becomeFirstResponder again to keep keyboard up. Test on rotation, on iPad floating keyboard, and on hardware keyboard (where inputAccessoryView becomes a small bar above no keyboard).
Red flag in candidates: Subscribing to keyboardWillShowNotification to adjust a constraint when keyboardLayoutGuide solves it in one line. Indicates outdated knowledge.
Lab preview
Lab 4.3 builds a form with validation, keyboard handling, and Keychain storage of the auth token.
Next: 4.7 — Data persistence
4.7 — Data persistence
Opening scenario
Three tickets land the same week:
- “User settings reset after the app updates. Use UserDefaults better.”
- “Cache 500 articles offline so the app works on the subway.”
- “Encrypt the user’s auth token. Audit failed last quarter.”
Three different storage problems, three different APIs:
| Problem | Tool |
|---|---|
| Key-value preferences | UserDefaults |
| Files (images, JSON dumps, exports) | FileManager + sandbox directories |
| Secrets (tokens, keys) | Keychain (Security framework) |
| Structured data, queries, relationships | Core Data or SwiftData (iOS 17+) |
| Sync across devices | CloudKit (Apple) or app-specific backend |
Choose deliberately. Misusing them (token in UserDefaults, settings in Keychain, blobs in Core Data) is a classic anti-pattern.
Concept → Why → How → Code
UserDefaults — key-value preferences
For small, non-sensitive, user-visible preferences: theme, last-selected tab, “don’t show this tip again.”
let defaults = UserDefaults.standard
defaults.set(true, forKey: "didCompleteOnboarding")
defaults.set("dark", forKey: "theme")
defaults.set(Date(), forKey: "lastFetchedAt")
let onboarded = defaults.bool(forKey: "didCompleteOnboarding")
let theme = defaults.string(forKey: "theme") ?? "system"
Constraints:
- Not encrypted. Anyone with file-system access (jailbroken device, backup) can read it.
- Synced via iCloud Backup by default — fine for preferences, never for secrets.
- Loaded into memory at app launch; large values (>4KB) slow startup. Don’t dump arrays of model objects here.
- Typed wrappers help: define a
Settingsstruct with computed properties backed byUserDefaults, or use@AppStorageif you’re mixing SwiftUI in.
For app extensions (Today widget, share sheet) you need an App Group and UserDefaults(suiteName: "group.com.your.app") to share.
File system — FileManager and the sandbox
iOS apps live in a sandbox. Standard directories:
| Path | Use | Backed up | Cleared by OS |
|---|---|---|---|
Documents/ | User-generated content visible in Files.app | Yes | No |
Library/Application Support/ | App-managed persistent data, not user-visible | Yes | No |
Library/Caches/ | Re-downloadable cache | No | Yes, when device low on space |
tmp/ | Truly temporary files | No | Yes, between launches |
let appSupport = try FileManager.default.url(
for: .applicationSupportDirectory,
in: .userDomainMask,
appropriateFor: nil,
create: true
)
let articleCacheURL = appSupport.appendingPathComponent("articles.json")
let data = try JSONEncoder().encode(articles)
try data.write(to: articleCacheURL, options: .atomic)
let loaded = try JSONDecoder().decode([Article].self, from: Data(contentsOf: articleCacheURL))
Hygiene:
- Write with
.atomic(write to temp, rename) to avoid corrupt half-written files - Exclude large caches from iCloud backup:
URL.setResourceValues(URLResourceValues())withisExcludedFromBackup = true - Don’t store user secrets here — sandbox is not encryption
Keychain — secrets only
The Keychain is the encrypted, OS-managed secret store. Backed by Secure Enclave on devices with one; survives app uninstall (intentional — for sticky session tokens) unless you opt out.
Raw Security framework API is C-style and painful. Pattern:
import Security
enum KeychainError: Error { case unhandled(OSStatus), notFound, badData }
enum Keychain {
static func save(_ data: Data, service: String, account: String) throws {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: account,
kSecValueData as String: data,
kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
]
SecItemDelete(query as CFDictionary) // remove existing
let status = SecItemAdd(query as CFDictionary, nil)
guard status == errSecSuccess else { throw KeychainError.unhandled(status) }
}
static func read(service: String, account: String) throws -> Data {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: account,
kSecReturnData as String: true,
kSecMatchLimit as String: kSecMatchLimitOne
]
var result: AnyObject?
let status = SecItemCopyMatching(query as CFDictionary, &result)
guard status != errSecItemNotFound else { throw KeychainError.notFound }
guard status == errSecSuccess, let data = result as? Data else {
throw KeychainError.unhandled(status)
}
return data
}
static func delete(service: String, account: String) throws {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: account
]
let status = SecItemDelete(query as CFDictionary)
guard status == errSecSuccess || status == errSecItemNotFound else {
throw KeychainError.unhandled(status)
}
}
}
// Usage
let token = "secret-token-value"
try Keychain.save(Data(token.utf8), service: "com.myapp.auth", account: "accessToken")
let stored = try String(data: Keychain.read(service: "com.myapp.auth", account: "accessToken"), encoding: .utf8)
kSecAttrAccessible controls when the secret is decryptable:
AfterFirstUnlock— works after first unlock until reboot (use for background-needed secrets)WhenUnlocked— only while device is unlocked (most user secrets)WhenPasscodeSetThisDeviceOnly— won’t migrate to a new device via iCloud restore (good for device-bound credentials)
For Face/Touch ID-gated secrets, add SecAccessControl:
let access = SecAccessControlCreateWithFlags(
nil,
kSecAttrAccessibleWhenUnlockedThisDeviceOnly,
.biometryCurrentSet,
nil
)
// Add kSecAttrAccessControl to the query
For OAuth: persist refresh token in Keychain, never in UserDefaults or files.
Core Data — the mature option
Core Data (iOS 3+) is Apple’s object graph and persistence framework. Powerful, mature, has every feature you’d want — and has a learning curve. The pieces:
- Persistent Store (SQLite under the hood, almost always)
- Managed Object Model (
.xcdatamodeldfile in Xcode) NSManagedObjectContext(your scratchpad)NSPersistentContainer(sets it all up)
Setup:
import CoreData
final class Persistence {
static let shared = Persistence()
let container: NSPersistentContainer
init() {
container = NSPersistentContainer(name: "Model")
container.loadPersistentStores { _, error in
if let error { fatalError("Core Data failed to load: \(error)") }
}
container.viewContext.automaticallyMergesChangesFromParent = true
}
}
Read & write on viewContext (main thread) or newBackgroundContext() for heavy work:
let article = Article(context: container.viewContext)
article.id = UUID()
article.title = "Hello"
article.body = "World"
article.createdAt = Date()
try container.viewContext.save()
// Fetch
let req = Article.fetchRequest()
req.predicate = NSPredicate(format: "title CONTAINS[c] %@", "hello")
req.sortDescriptors = [NSSortDescriptor(key: "createdAt", ascending: false)]
req.fetchLimit = 50
let results = try container.viewContext.fetch(req)
Background work:
container.performBackgroundTask { ctx in
// bulk import 10k articles
for raw in payload {
let a = Article(context: ctx)
a.title = raw.title
// ...
}
try? ctx.save()
}
Threading rule: each NSManagedObjectContext is bound to its queue. Don’t pass managed objects between threads — pass NSManagedObjectIDs and re-fetch.
For UIKit: NSFetchedResultsController integrates with UITableView/UICollectionView diffable data source, animating inserts/deletes as the store changes.
let frc = NSFetchedResultsController(
fetchRequest: req,
managedObjectContext: container.viewContext,
sectionNameKeyPath: nil,
cacheName: nil
)
frc.delegate = self
try frc.performFetch()
extension MyVC: NSFetchedResultsControllerDelegate {
func controller(_ controller: NSFetchedResultsController<NSFetchRequestResult>,
didChangeContentWith snapshot: NSDiffableDataSourceSnapshotReference) {
dataSource.apply(snapshot as NSDiffableDataSourceSnapshot<String, NSManagedObjectID>,
animatingDifferences: true)
}
}
SwiftData — the new option (iOS 17+)
SwiftData is Apple’s 2023 successor wrapper over Core Data. Model with Swift macros, no .xcdatamodeld file:
import SwiftData
@Model
final class Article {
var id: UUID
var title: String
var body: String
var createdAt: Date
init(title: String, body: String) {
self.id = UUID()
self.title = title
self.body = body
self.createdAt = .now
}
}
// Setup
let container = try ModelContainer(for: Article.self)
let context = container.mainContext
// Insert & save
let a = Article(title: "Hello", body: "World")
context.insert(a)
try context.save()
// Fetch with FetchDescriptor + #Predicate
let descriptor = FetchDescriptor<Article>(
predicate: #Predicate { $0.title.contains("Hello") },
sortBy: [SortDescriptor(\.createdAt, order: .reverse)]
)
let results = try context.fetch(descriptor)
SwiftData is great in pure-SwiftUI apps. In UIKit it’s usable but Core Data + NSFetchedResultsController is still more battle-tested for complex apps. As of 2026, choose:
- New SwiftUI-heavy app → SwiftData
- UIKit-heavy or migrating from existing Core Data → Core Data
- Mixed → either; SwiftData wraps Core Data underneath, can interop
Sync to other devices
For multi-device persistence:
- CloudKit + Core Data (
NSPersistentCloudKitContainer) — one flag flips your Core Data store into iCloud-syncing. Apple manages conflict resolution. - CloudKit + SwiftData — same, native in 2024+
- Your own backend — full control, full responsibility (auth, conflict resolution, offline sync). Apps like Notion, Bear use this.
container = NSPersistentCloudKitContainer(name: "Model")
// Configure store description with iCloud container identifier
let desc = container.persistentStoreDescriptions.first!
desc.setOption(true as NSNumber, forKey: NSPersistentHistoryTrackingKey)
desc.setOption(true as NSNumber, forKey: NSPersistentStoreRemoteChangeNotificationPostOptionKey)
Encryption at rest
iOS encrypts the entire device when a passcode is set (Data Protection). Files marked with NSFileProtectionComplete are only decryptable when device is unlocked. Set on app files:
let attrs: [FileAttributeKey: Any] = [.protectionKey: FileProtectionType.complete]
try FileManager.default.setAttributes(attrs, ofItemAtPath: url.path)
For ultra-sensitive data (medical records, financial PII), layer your own encryption (CryptoKit) on top:
import CryptoKit
let key = SymmetricKey(size: .bits256)
let sealed = try ChaChaPoly.seal(plaintext, using: key)
try sealed.combined.write(to: url)
let opened = try ChaChaPoly.SealedBox(combined: Data(contentsOf: url))
let decrypted = try ChaChaPoly.open(opened, using: key)
Store the SymmetricKey in the Keychain (SecKeyCreateRandomKey or Data(key.withUnsafeBytes(...))).
Migrations
Core Data: model versioning. Add a new model version, mark it current, choose mapping (lightweight if you only added/removed/renamed columns; heavyweight if you transformed data).
SwiftData: schema migration via SchemaMigrationPlan and VersionedSchema.
UserDefaults: versioning via a "schemaVersion" key; on app launch, compare and run migration code if needed.
let current = 3
let stored = UserDefaults.standard.integer(forKey: "schemaVersion")
if stored < current {
runMigrations(from: stored, to: current)
UserDefaults.standard.set(current, forKey: "schemaVersion")
}
Test migrations explicitly. Create an app build at the old schema, install, populate data, then upgrade to new build. Verify nothing’s lost. This is how production data-loss bugs ship.
In the wild
- Signal uses SQLite (via SQLCipher) directly for messages — encrypted database. Keychain holds the encryption key.
- Notion iOS uses Core Data for offline cache of pages; sync via their own backend, not CloudKit.
- Apple Notes is Core Data + CloudKit (
NSPersistentCloudKitContainer). Locked notes encrypted with user-derived keys stored in Keychain. - 1Password uses Keychain for the master vault unlock secret, custom encrypted SQLite for the vault. Defense in depth.
- Spotify caches downloaded songs in
Library/Application Support/, marked excluded from backup, with custom DRM.
Common misconceptions
- “UserDefaults is fine for the auth token.” No. It’s plaintext, backed up to iCloud, readable on a jailbroken device. Always Keychain for secrets.
- “Core Data is just SQLite.” It’s an object graph + persistence framework backed by SQLite. The graph (faulting, relationships, validation) is most of what you’re paying for.
- “
NSManagedObjectis thread-safe.” No — strictly bound to its context’s queue. Cross-thread access crashes. - “SwiftData replaces Core Data.” SwiftData wraps Core Data. Core Data is still the deeper API; SwiftData is sugar.
- “My users have storage; size doesn’t matter.” Wrong. Users with full storage uninstall apps with large footprints; Apple shows your app size at install time. Caches must be evictable.
Seasoned engineer’s take
Data persistence bugs are the worst kind: silent, slow to manifest, and corrupt user trust. Three rules:
- Pick the right tool per data type. Don’t unify everything into Core Data or everything into JSON files; each has the right cases.
- Schema versioning from day 1. The first time you ship, write down “schema v1.” When you add a field in v2, write the migration. Test it. Otherwise some user upgrades from v1 to v4 and loses everything.
- Backup hygiene matters. Mark caches
isExcludedFromBackup. Don’t bloat iCloud backups with regenerable data. Apple will throttle apps that do this.
TIP: For tokens, encrypt the user’s actual data — not just the access token. If your backend supports it, request short-lived access tokens + a refresh token; rotate the access token every hour. Loss of the access token then becomes recoverable.
WARNING:
try Keychain.save(...)failing witherrSecDuplicateItemis the common one. AlwaysSecItemDelete(or useSecItemUpdate) before adding. Easy to miss in a hurried first version, leads to “I logged in but the token is still the old one” bugs.
Interview corner
Junior-level: “Where do you store an auth token?”
Keychain, with kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly so background tasks can read it but it doesn’t migrate to a new device automatically.
Mid-level: “What’s the difference between Core Data’s viewContext and a background context?”
viewContext is the main-thread context — use for UI-driven fetches and small writes. Background contexts (newBackgroundContext() or performBackgroundTask) run on a private queue for bulk work (large imports, exports). Save in the background, set viewContext.automaticallyMergesChangesFromParent = true to propagate. Never pass NSManagedObject between contexts — pass NSManagedObjectID and re-fetch.
Senior-level: “Design persistence for a note-taking app: 10k notes per user, full-text search, sync across devices, offline-first.”
Storage: Core Data (or SwiftData) with NSPersistentCloudKitContainer for cross-device sync. Note entity has id, title, body, createdAt, updatedAt, deletedAt (soft delete for sync), version (for conflict detection). Full-text search via SQLite FTS5 — add via NSPersistentStoreDescription’s setOption for FTS, or maintain a separate index table updated on save. UI uses NSFetchedResultsController for list, batched fetches with fetchLimit for performance. Conflict resolution policy: NSMergeByPropertyObjectTrumpMergePolicy for simple cases; custom resolver for body conflicts (could surface conflict UI like Notes does). Offline-first: writes always succeed locally; sync queue retries when online. Keychain holds CloudKit user record-ID for re-auth after reinstall. Test: bulk-create 10k notes, measure fetch time; simulate sync conflict by editing same note on two devices offline then bringing both online.
Red flag in candidates: Storing access tokens in UserDefaults. Indicates they’ve never had a security audit.
Lab preview
Lab 4.3 walks the Keychain pattern end-to-end with a real signup form.
Next: 4.8 — Networking
4.8 — Networking
Opening scenario
Backend gives you a REST API. Your app needs to:
- Fetch the user’s feed (paginated, with auth header)
- POST a new comment
- Upload a 5MB image with progress
- Stream a download in the background while the user uses the app
- Retry on flaky connectivity
- Cache the feed for offline viewing
- Cancel in-flight requests when the user navigates away
You don’t write any of that on top of raw BSDSockets. You use URLSession with async/await — Apple’s modern networking layer. This chapter is the working knowledge required for production iOS networking.
| Need | Tool |
|---|---|
| One-off GET/POST | URLSession.shared.data(for:) |
| Custom timeouts, cellular policy, auth handling | URLSession(configuration:) + delegate |
| Large download with progress | URLSession.downloadTask |
| Background download/upload | URLSessionConfiguration.background(...) |
| WebSocket | URLSessionWebSocketTask |
| Reactive streams | Combine or AsyncSequence |
| GraphQL | Apollo or custom on top of URLSession |
Concept → Why → How → Code
URLSession — the foundation
import Foundation
let url = URL(string: "https://api.example.com/feed")!
let (data, response) = try await URLSession.shared.data(from: url)
guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode) else {
throw NetworkError.badResponse
}
let feed = try JSONDecoder().decode(Feed.self, from: data)
Three task types:
- Data tasks — memory-buffered request/response (most common)
- Upload tasks — POST/PUT a body from
Data,URL, orInputStream - Download tasks — write response to a file on disk (large payloads, background)
A real networking client
In production you wrap URLSession in a thin service that handles auth, encoding, error mapping:
struct APIRequest<Response: Decodable> {
let path: String
let method: HTTPMethod
let body: Encodable?
let queryItems: [URLQueryItem]
}
enum HTTPMethod: String { case GET, POST, PUT, DELETE, PATCH }
enum NetworkError: Error {
case invalidURL
case http(Int, Data)
case decoding(Error)
case transport(Error)
case unauthorized
}
final class APIClient {
private let session: URLSession
private let baseURL: URL
private let tokenProvider: () async -> String?
init(baseURL: URL,
session: URLSession = .shared,
tokenProvider: @escaping () async -> String?) {
self.baseURL = baseURL
self.session = session
self.tokenProvider = tokenProvider
}
func send<R: Decodable>(_ request: APIRequest<R>) async throws -> R {
var components = URLComponents(url: baseURL.appendingPathComponent(request.path),
resolvingAgainstBaseURL: false)!
if !request.queryItems.isEmpty { components.queryItems = request.queryItems }
guard let url = components.url else { throw NetworkError.invalidURL }
var urlRequest = URLRequest(url: url)
urlRequest.httpMethod = request.method.rawValue
urlRequest.setValue("application/json", forHTTPHeaderField: "Content-Type")
urlRequest.setValue("application/json", forHTTPHeaderField: "Accept")
if let token = await tokenProvider() {
urlRequest.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
}
if let body = request.body {
urlRequest.httpBody = try JSONEncoder().encode(AnyEncodable(body))
}
let (data, response): (Data, URLResponse)
do {
(data, response) = try await session.data(for: urlRequest)
} catch {
throw NetworkError.transport(error)
}
let http = response as! HTTPURLResponse
switch http.statusCode {
case 200..<300:
do { return try JSONDecoder().decode(R.self, from: data) }
catch { throw NetworkError.decoding(error) }
case 401:
throw NetworkError.unauthorized
default:
throw NetworkError.http(http.statusCode, data)
}
}
}
// AnyEncodable helper for Encodable existential
struct AnyEncodable: Encodable {
let value: Encodable
init(_ v: Encodable) { self.value = v }
func encode(to encoder: Encoder) throws { try value.encode(to: encoder) }
}
Usage:
let req = APIRequest<Feed>(path: "/feed", method: .GET, body: nil, queryItems: [])
let feed = try await api.send(req)
Benefits: typed request/response, central auth/headers, central error handling, easy to mock for tests.
Cancellation
In Swift Concurrency, cancellation propagates through Task:
let task = Task {
let feed = try await api.send(feedRequest)
await MainActor.run { self.show(feed) }
}
// User navigates away
task.cancel()
URLSession honors cancellation: when the Task is cancelled, the underlying URLSessionDataTask is cancelled, and the await throws CancellationError or URLError(.cancelled).
In a UIViewController, cancel ongoing work in viewDidDisappear or when initiating new work:
private var loadTask: Task<Void, Never>?
private func reload() {
loadTask?.cancel()
loadTask = Task { [weak self] in
guard let self else { return }
do {
let feed = try await api.send(feedRequest)
try Task.checkCancellation()
self.show(feed)
} catch is CancellationError {
return
} catch {
self.showError(error)
}
}
}
Upload with progress
let url = URL(string: "https://api.example.com/photo")!
var req = URLRequest(url: url)
req.httpMethod = "POST"
req.setValue("image/jpeg", forHTTPHeaderField: "Content-Type")
let (asyncBytes, response) = try await URLSession.shared.upload(for: req, from: jpegData, delegate: self)
// To observe progress, attach a URLSessionTaskDelegate:
class ProgressDelegate: NSObject, URLSessionTaskDelegate {
func urlSession(_ session: URLSession,
task: URLSessionTask,
didSendBodyData bytesSent: Int64,
totalBytesSent: Int64,
totalBytesExpectedToSend: Int64) {
let progress = Double(totalBytesSent) / Double(totalBytesExpectedToSend)
// post to UI
}
}
The iOS 15+ upload(for:from:delegate:) ties the per-call delegate. For richer per-task progress, use URLSessionUploadTask directly with task.progress observable via KVO or task.progress.fractionCompleted.
Background sessions
For uploads/downloads that must continue when your app suspends:
let config = URLSessionConfiguration.background(withIdentifier: "com.myapp.upload")
config.isDiscretionary = false // true lets iOS schedule based on power/wifi
config.sessionSendsLaunchEvents = true
let bgSession = URLSession(configuration: config, delegate: self, delegateQueue: nil)
let task = bgSession.uploadTask(with: req, fromFile: fileURL)
task.resume()
When the upload completes (even if the app was killed), iOS launches your app in the background and calls application(_:handleEventsForBackgroundURLSession:completionHandler:). Implement the delegate to finalize.
Constraints:
- Only file-based (
uploadTask(with:fromFile:)) — noDatapayloads - One session per identifier; recreate on app launch with the same identifier to reattach
- Test on a real device; simulator skips some background behavior
Caching
URLSession honors HTTP caching headers (Cache-Control, ETag, Last-Modified) via URLCache:
let config = URLSessionConfiguration.default
config.urlCache = URLCache(memoryCapacity: 10 * 1024 * 1024,
diskCapacity: 100 * 1024 * 1024,
directory: nil)
config.requestCachePolicy = .useProtocolCachePolicy
let session = URLSession(configuration: config)
For app-level cache (parsed objects, image bitmaps), use NSCache:
let cache = NSCache<NSString, UIImage>()
cache.countLimit = 200
cache.totalCostLimit = 50 * 1024 * 1024 // ~50MB
For images specifically use a library (Nuke, Kingfisher, SDWebImage) — they handle decoding off the main thread, downsampling for cell size, prefetching, and disk cache.
Retry & exponential backoff
func sendWithRetry<R: Decodable>(_ request: APIRequest<R>, attempts: Int = 3) async throws -> R {
var lastError: Error?
for attempt in 0..<attempts {
do {
return try await send(request)
} catch NetworkError.http(let code, _) where (500...599).contains(code) {
lastError = NetworkError.http(code, Data())
} catch NetworkError.transport {
lastError = NetworkError.transport(URLError(.networkConnectionLost))
}
// exponential backoff with jitter
let delay = pow(2.0, Double(attempt)) * 0.5 + Double.random(in: 0..<0.5)
try await Task.sleep(for: .seconds(delay))
}
throw lastError ?? NetworkError.transport(URLError(.unknown))
}
Don’t retry on 4xx (client errors won’t fix themselves on retry). Do retry on 5xx, transient transport errors, timeouts.
Auth: token refresh
Common pattern: access token expires; refresh once, retry the original request. Race condition: 5 in-flight requests all 401 simultaneously and all try to refresh.
actor TokenStore {
private var accessToken: String?
private var refreshTask: Task<String, Error>?
func currentToken() async throws -> String {
if let accessToken { return accessToken }
return try await refresh()
}
func refresh() async throws -> String {
if let existing = refreshTask { return try await existing.value }
let task = Task<String, Error> {
let newToken = try await performRefresh()
self.accessToken = newToken
self.refreshTask = nil
return newToken
}
self.refreshTask = task
return try await task.value
}
func invalidate() { accessToken = nil }
}
actor serializes access. Concurrent callers see the same in-flight refresh task and await its result.
Pagination
Cursor-based (preferred over page numbers):
struct FeedPage: Decodable {
let items: [Article]
let nextCursor: String?
}
func loadNext() async {
let req = APIRequest<FeedPage>(
path: "/feed",
method: .GET,
body: nil,
queryItems: nextCursor.map { [URLQueryItem(name: "cursor", value: $0)] } ?? []
)
let page = try await api.send(req)
articles.append(contentsOf: page.items)
nextCursor = page.nextCursor
}
In UICollectionView, trigger loadNext from prefetchItemsAt when the user nears the bottom of loaded content.
WebSockets
let task = URLSession.shared.webSocketTask(with: URL(string: "wss://api.example.com/live")!)
task.resume()
Task {
while true {
let message = try await task.receive()
switch message {
case .string(let text): handle(text)
case .data(let data): handle(data)
@unknown default: break
}
}
}
// Send
try await task.send(.string("hello"))
// Close
task.cancel(with: .goingAway, reason: nil)
For long-lived connections, implement ping/pong heartbeats (task.sendPing(pongReceiveHandler:)) and reconnection with backoff.
Network monitoring
import Network
let monitor = NWPathMonitor()
monitor.pathUpdateHandler = { path in
if path.status == .satisfied {
print("Online")
if path.usesInterfaceType(.cellular) { print("Cellular") }
} else {
print("Offline")
}
}
monitor.start(queue: .global())
Use to gate “Retry” buttons, show offline banners, defer non-urgent uploads to Wi-Fi.
Security: ATS, pinning
App Transport Security (ATS) requires HTTPS with modern TLS. Exceptions go in Info.plist:
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsArbitraryLoads</key>
<false/> <!-- never set true in production -->
<key>NSExceptionDomains</key>
<dict>
<key>legacy.example.com</key>
<dict>
<key>NSExceptionAllowsInsecureHTTPLoads</key>
<true/>
</dict>
</dict>
</dict>
For high-security apps (banking, healthcare), pin certificates via URLSessionDelegate.urlSession(_:didReceive:completionHandler:). Validate the server’s certificate chain against bundled pinned hashes. Reject otherwise. Prevents MITM with rogue CAs.
Testing
Don’t hit the network in tests. Inject a mock session:
final class MockURLProtocol: URLProtocol {
static var requestHandler: ((URLRequest) throws -> (HTTPURLResponse, Data))?
override class func canInit(with request: URLRequest) -> Bool { true }
override class func canonicalRequest(for request: URLRequest) -> URLRequest { request }
override func startLoading() {
guard let handler = MockURLProtocol.requestHandler else { return }
do {
let (response, data) = try handler(request)
client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)
client?.urlProtocol(self, didLoad: data)
client?.urlProtocolDidFinishLoading(self)
} catch {
client?.urlProtocol(self, didFailWithError: error)
}
}
override func stopLoading() {}
}
// In test
let config = URLSessionConfiguration.ephemeral
config.protocolClasses = [MockURLProtocol.self]
let session = URLSession(configuration: config)
let api = APIClient(baseURL: URL(string: "https://test")!, session: session, tokenProvider: { "test" })
MockURLProtocol.requestHandler = { req in
let response = HTTPURLResponse(url: req.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!
let data = #" {"items":[]} "#.data(using: .utf8)!
return (response, data)
}
In the wild
- Slack iOS uses
URLSessionfor REST + WebSockets for real-time. Background session for file uploads. - Lyft custom client on top of
URLSessionwith circuit-breaker pattern (after N failures, stop hitting endpoint for a window). - Apollo iOS (used by Airbnb, Robinhood) wraps
URLSessionfor GraphQL with response caching and normalized cache (each entity stored once, queries reference it). - Nuke and Kingfisher are the standard image-loading libraries, both on top of
URLSessionwith custom in-memory and disk caching. - Apple News uses background URL sessions to pre-fetch articles overnight on Wi-Fi.
Common misconceptions
- “Use
AlamofirebecauseURLSessionis too low-level.” In 2026, with async/await,URLSessionis less code than Alamofire for typical use. Reach for libraries when you have a real need (request adapting, advanced retry, GraphQL). - “Set
requestCachePolicy = .reloadIgnoringCacheDatato be safe.” That defeats HTTP caching. Default.useProtocolCachePolicyis correct most of the time. - “
URLSession.sharedis fine for everything.” Fine for one-off GETs. For auth, custom config, background, or testing — instantiate your own. - “Retry every error with exponential backoff.” Don’t retry 4xx (they won’t change), don’t retry POST with non-idempotent body (you’ll double-submit). Retry GET, idempotent PUT, and transient 5xx/timeouts.
- “async/await means I don’t need delegates.” Wrong. Per-task delegates (
URLSession.data(for:delegate:)) are how you observe progress, handle auth challenges, and customize per-request behavior.
Seasoned engineer’s take
Networking code is where bugs hide because the network is non-deterministic. Habits:
- Centralize. One
APIClient(or generated client from OpenAPI/GraphQL schema). Don’t sprinkleURLSession.shared.dataacross view controllers. - Type the responses end-to-end.
Decodablemodels,Resultorthrowspropagation, no[String: Any]JSON dictionaries floating around. - Profile real networks. Use Xcode’s Network Link Conditioner (“3G”, “Edge”, “100% Loss”) regularly. Your loading states and timeouts are wrong if you only test on Wi-Fi.
- Treat errors as first-class UI. Every network call has a loading, success, empty, and error state. Sketch all four for every screen.
- Log responsibly. Don’t log auth tokens or PII. Use OSLog with privacy markers (
"\(token, privacy: .private)").
TIP: When debugging “why is this request failing in production but not in the simulator,” check (1) certificate pinning if you have it, (2) network reachability vs DNS issues, (3) clock skew (some servers reject requests with timestamps off by >5min), (4) proxy / VPN configurations on the user’s device.
WARNING: Using
URLSession.sharedwith a background-session identifier is a programming error and will crash. Background sessions must be created withURLSession(configuration:delegate:delegateQueue:).
Interview corner
Junior-level: “How do you fetch JSON from an endpoint?”
let (data, _) = try await URLSession.shared.data(from: url)
let result = try JSONDecoder().decode(Model.self, from: data)
Wrap in do/catch, handle network errors and decoding errors separately, present the right UI.
Mid-level: “How would you handle an expired auth token mid-request?”
API client checks for 401 in response. Calls a TokenStore actor to refresh; multiple concurrent requests share one refresh Task to avoid stampede. Once refreshed, retry the original request once. If refresh also 401s, log out user.
Senior-level: “Design an offline-first feed: fetch from network, cache locally, show cache instantly, refresh in background, handle conflicts.”
FeedRepository exposes an AsyncStream<[Article]> for observers. On subscription, emits cache immediately (from Core Data or disk). Kicks off network fetch in background. On success, merges into cache (last-write-wins per article ID with updatedAt comparison) and emits new state. Network failures keep cache. Pagination via cursor; “load more” appends. Pull-to-refresh re-fetches first page. WebSocket subscription pushes deltas; on delta, update cache, emit. Conflict UI for edits made offline that conflict with server changes — Notes-style “keep local”/“keep server” prompt. Tested with XCTestExpectation + mock URLSession; race condition tests with TaskGroup.
Red flag in candidates: Using completion handlers in new code in 2026. Async/await is the default for networking; completion-handler patterns belong in legacy contexts only.
Lab preview
Lab 4.1 builds a real news reader: URLSession async/await, Codable, error states, pull-to-refresh.
Next: 4.9 — Background tasks
4.9 — Background execution
Opening scenario
User taps your podcast app’s “download episode” button, then locks the phone and shoves it in a pocket. Twenty minutes later, on the bus, they pull out the phone, open the app — and the episode is downloaded, the next-up queue refreshed, listening history synced.
iOS is aggressive about suspending apps. The OS prefers your app uses zero CPU, zero radio, zero battery when not foregrounded. Background execution is a system of explicit, narrow permissions: each one says “you may do this specific thing for this much time.”
| Need | API |
|---|---|
| Finish a task user just kicked off (~30s) | UIApplication.beginBackgroundTask |
| Periodic refresh (“update content overnight”) | BGAppRefreshTask |
| Heavy work on power + Wi-Fi (“re-index database”) | BGProcessingTask |
| Download / upload that survives app suspension | Background URLSession |
| Audio playing while screen off | Audio background mode |
| Location updates in background | Location background mode + entitlement |
| Server-pushed updates | Silent push notifications |
Concept → Why → How → Code
App lifecycle recap
In iOS, your process states (from UIScene.activationState / app delegate):
- Not running — never launched, or terminated by user/OS
- Inactive — foreground but not receiving events (e.g., user pulled down Control Center)
- Active — foreground and receiving events
- Background — runs briefly after going to background; will be suspended
- Suspended — frozen in memory; OS may kill it any time
Background tasks let you do work in state 4 before becoming suspended, or get launched into state 4 for periodic work.
beginBackgroundTask — finish what you started
When the user backgrounds the app mid-operation (uploading a comment, saving a draft), you get ~30 seconds to finish:
let taskID = UIApplication.shared.beginBackgroundTask(withName: "SubmitComment") {
// Expiration handler — called when time runs out
cleanUp()
UIApplication.shared.endBackgroundTask(taskID)
}
Task {
defer { UIApplication.shared.endBackgroundTask(taskID) }
do {
try await api.send(commentRequest)
} catch {
log(error)
}
}
Rules:
- Always call
endBackgroundTask— even on error, even in the expiration handler. Failing to do so eventually crashes the app for hogging background time. - Pair with
beginBackgroundTask1:1. You can have multiple concurrent task IDs. - Don’t expect more than ~30 seconds. Earlier iOS versions gave 3 minutes; modern iOS is stingier.
Use case: user submits a form, hits home before the request finishes. Without beginBackgroundTask, the app suspends instantly and the request fails.
BGTaskScheduler — periodic background work
For “every now and then, refresh content” or “occasionally re-process data,” use BackgroundTasks framework (iOS 13+).
Register identifiers in Info.plist:
<key>BGTaskSchedulerPermittedIdentifiers</key>
<array>
<string>com.myapp.refresh</string>
<string>com.myapp.cleanup</string>
</array>
Register handlers at launch (must happen before application(_:didFinishLaunchingWithOptions:) returns):
import BackgroundTasks
BGTaskScheduler.shared.register(forTaskWithIdentifier: "com.myapp.refresh", using: nil) { task in
handleRefresh(task: task as! BGAppRefreshTask)
}
BGTaskScheduler.shared.register(forTaskWithIdentifier: "com.myapp.cleanup", using: nil) { task in
handleCleanup(task: task as! BGProcessingTask)
}
Schedule work when the app goes to background:
func scheduleAppRefresh() {
let request = BGAppRefreshTaskRequest(identifier: "com.myapp.refresh")
request.earliestBeginDate = Date(timeIntervalSinceNow: 60 * 60) // earliest 1 hour from now
do {
try BGTaskScheduler.shared.submit(request)
} catch {
print("Could not schedule app refresh: \(error)")
}
}
iOS decides when (based on usage patterns, power, network). You get called when it picks your moment.
Handler — finish quickly (~30s for refresh, longer for processing) and must call setTaskCompleted:
func handleRefresh(task: BGAppRefreshTask) {
scheduleAppRefresh() // chain the next one
task.expirationHandler = {
// OS reclaiming time; cancel ongoing work
currentTask?.cancel()
}
currentTask = Task {
do {
try await refreshContent()
task.setTaskCompleted(success: true)
} catch {
task.setTaskCompleted(success: false)
}
}
}
BGProcessingTask is for heavier work that needs power and/or network — re-indexing local DB, downloading large updates. You can require requiresNetworkConnectivity = true and requiresExternalPower = true so the OS only triggers when plugged in on Wi-Fi.
Test the handler manually — iOS won’t run it on demand for you. From LLDB while paused:
e -l objc -- (void)[[BGTaskScheduler sharedScheduler] _simulateLaunchForTaskWithIdentifier:@"com.myapp.refresh"]
(This is a private API — debug only.)
Background URLSession
Covered in 4.8 but recapped: URLSessionConfiguration.background(withIdentifier:) keeps downloads/uploads running after suspension. OS launches your app in background on completion via application(_:handleEventsForBackgroundURLSession:completionHandler:). Implement the delegate to save the completionHandler, finalize, then invoke it so iOS knows you’re done.
Silent push notifications
For server-pushed background refresh (e.g., new message arrived, update local cache before user opens app):
- Enable “Remote Notifications” capability + “Background Modes → Remote notifications”
- Server sends APNs payload with
"content-available": 1:
{
"aps": { "content-available": 1 },
"messageId": "abc123"
}
- iOS wakes your app and calls:
func application(_ app: UIApplication,
didReceiveRemoteNotification userInfo: [AnyHashable: Any]) async -> UIBackgroundFetchResult {
do {
try await syncNewMessages(triggeredBy: userInfo)
return .newData
} catch {
return .failed
}
}
Return one of .newData / .noData / .failed so iOS calibrates how often to wake you.
Caveats:
- iOS may throttle silent pushes (rate-limit, defer). Not guaranteed delivery for waking up the app.
- User can disable “Background App Refresh” in Settings — your silent pushes won’t wake the app.
- Don’t use silent push for time-critical actions; use a regular alerting push.
Audio in background
For podcast / music apps, enable Audio background mode:
<key>UIBackgroundModes</key>
<array>
<string>audio</string>
</array>
Configure the audio session for playback:
import AVFoundation
try AVAudioSession.sharedInstance().setCategory(.playback, mode: .spokenAudio, options: [])
try AVAudioSession.sharedInstance().setActive(true)
Now AVPlayer.play() continues after lock screen. Combine with MPNowPlayingInfoCenter to show track info on lock screen and Control Center:
import MediaPlayer
MPNowPlayingInfoCenter.default().nowPlayingInfo = [
MPMediaItemPropertyTitle: episode.title,
MPMediaItemPropertyArtist: episode.showName,
MPMediaItemPropertyPlaybackDuration: episode.duration,
MPNowPlayingInfoPropertyElapsedPlaybackTime: player.currentTime().seconds
]
let cmd = MPRemoteCommandCenter.shared()
cmd.playCommand.addTarget { _ in player.play(); return .success }
cmd.pauseCommand.addTarget { _ in player.pause(); return .success }
Location in background
Two modes:
- Significant location changes (battery-friendly, ~500m precision) — works fully suspended
- Standard continuous updates (precise but battery-hungry) — requires
Alwaysauthorization + entitlement
let manager = CLLocationManager()
manager.delegate = self
manager.requestAlwaysAuthorization()
manager.allowsBackgroundLocationUpdates = true
manager.pausesLocationUpdatesAutomatically = true
manager.startMonitoringSignificantLocationChanges()
For Uber-style ride-tracking, enable showsBackgroundLocationIndicator = true (blue bar on top while tracking — required for Always-authorized continuous updates).
Combining modes
Many apps combine: audio + remote notifications + background fetch. Each requires its own UIBackgroundModes entry and matching capabilities. Apple reviews these; lying about why you need them is a fast App Store rejection.
Energy & responsibility
Background execution costs battery. iOS measures this and exposes it to the user (Settings → Battery → app usage). Apps with bad reputation get throttled — silent pushes ignored, BGAppRefresh rarely scheduled.
Best practices:
- Do the minimum work each background invocation
- Coalesce: if you need to sync 5 things, do them in one background pass, not five
- Respect
Low Power Mode(ProcessInfo.processInfo.isLowPowerModeEnabledistrue) — skip non-essential refreshes - Use Wi-Fi when available (
config.allowsCellularAccess = falseon opportunistic syncs)
Debugging
- Console.app on macOS with the device connected shows OSLog messages and system “ran your background task” entries
- Xcode → Debug → Simulate Background Fetch triggers the legacy fetch API (deprecated, but somewhat useful)
- Xcode → Debug → Simulate Push Notification with a JSON file triggers silent pushes in the simulator
- Settings → Developer → Background Task on iOS device gives you manual triggers
- OSLog with
subsystemandcategory— filter system logs to your app’s background activity
In the wild
- Spotify uses background audio + background URL sessions for downloaded podcasts/songs.
- Strava uses background location (significant changes + standard with paused updates between activities).
- Pocket prefetches articles using
BGAppRefreshTaskovernight on Wi-Fi. - Slack uses silent pushes to pre-fetch new messages; your app shows them instantly on next open.
- Apple Photos does heavy ML re-indexing (faces, objects) via
BGProcessingTaskwithrequiresExternalPower = true— runs at night while charging.
Common misconceptions
- “
beginBackgroundTaskgives me unlimited time.” No — ~30 seconds max. Expiration handler fires; you must clean up. - “
BGAppRefreshTaskruns on a schedule I set.” You request an earliest time. iOS decides actual scheduling based on usage patterns, power, network. Could be 1 hour from now, could be 12 hours. - “Silent push is reliable for delivery.” It’s best-effort. APNs may coalesce, defer, or drop them — especially if your app’s battery reputation is bad.
- “I can use background modes to run arbitrary code.” No — each background mode unlocks one specific capability. Apple reviews and rejects misuse.
- “Background fetch is the same as
BGAppRefresh.”UIApplication.backgroundFetchis deprecated. UseBGTaskSchedulergoing forward.
Seasoned engineer’s take
Background execution is where principled architecture pays off. Lessons:
- Cheap, idempotent work is best. Background invocations may be interrupted, repeated, or skipped. Your sync logic must handle that gracefully (cursor + idempotent merge).
- Always log what you do in background. OSLog with privacy-correct markers. When users report “the app didn’t refresh,” you need traces.
- Respect the system. Apple measures battery impact and throttles abusers. Earn your background time by being lean and useful.
- Defer to push when you can. Silent push is more reliable than
BGAppRefreshfor “something changed on the server.” SaveBGAppRefreshfor housekeeping. - Test on real devices, in real conditions. Simulator doesn’t reflect iOS’s actual scheduling. Use TestFlight + telemetry to confirm your background tasks actually run.
TIP: When chaining
BGAppRefreshTask, always schedule the next one first, before doing the work. If your work crashes, at least the system still knows you want to be called again.
WARNING: Modifying SwiftUI views or
UIKitUI from a background task handler will crash. UI updates must come from the main actor.await MainActor.run { ... }or@MainActorannotate the relevant code.
Interview corner
Junior-level: “What’s the difference between viewWillDisappear and applicationDidEnterBackground?”
viewWillDisappear is per-VC (the view is being removed from screen — could be a push, modal dismiss, tab switch). applicationDidEnterBackground (now sceneDidEnterBackground with scene API) is app-level (the user backgrounded the app). Different cleanup work belongs in each — release expensive per-screen resources in the former, save state and snapshot in the latter.
Mid-level: “How would you implement ‘sync user’s data periodically when the app isn’t open’?”
Register a BGAppRefreshTaskRequest with identifier com.app.sync, earliestBeginDate 1 hour out. Handler: schedule next, kick off async sync, on completion call task.setTaskCompleted(success:). Expiration handler cancels the sync Task. Combined with silent pushes for time-sensitive updates. Test by triggering manually via private LLDB call in debug builds and via TestFlight in production.
Senior-level: “Design a podcast app that downloads episodes overnight on Wi-Fi while charging, plays them with lock-screen controls, and handles network changes gracefully.”
Downloads: BGProcessingTaskRequest with requiresExternalPower = true and requiresNetworkConnectivity = true. Handler uses a background URLSession to download episode files. Persistence: track download state per episode in Core Data; URLSessionDelegate updates progress + final URL. App-foreground rebinds the background session to continue progress UI. Playback: AVAudioSession .playback category; MPNowPlayingInfoCenter for lock-screen UI; MPRemoteCommandCenter for play/pause/skip. Audio interruption (call, alarm) handled via AVAudioSession.interruptionNotification. Connectivity changes via NWPathMonitor; if user switches from Wi-Fi to cellular mid-download, pause if “Wi-Fi only downloads” preference set. Energy: skip pre-fetching in Low Power Mode. Telemetry: log background task start/end with episode IDs, processing time, failures — surface in dashboards.
Red flag in candidates: Trying to keep the app “alive” via abuse of background audio (silent audio loop) or location (no real location use case). Apple rejects these and users uninstall battery-drainers.
Lab preview
Background work doesn’t feature in Phase 4 labs directly (UIKit fundamentals focus); covered with real implementation in Phase 6 (SwiftUI + Combine) and Phase 11 (production app).
Next: 4.10 — UIKit + Combine
4.10 — UIKit + Combine
Opening scenario
You inherited a UISearchController-driven product search VC. The current code:
- Fires a network request on every keystroke (300 RPM at peak)
- Sometimes shows stale results (request for “ipad” finishes after “iphone”)
- Maintains 4 boolean flags (
isLoading,hasError,lastQueryEmpty,didCancel) and a 60-lineif/elseto derive what to render - No tests because the logic is tangled in delegate methods
You rewrite it with Combine — Apple’s reactive framework. The state becomes a pipeline: text input → debounce → de-duplicate → switch-to-latest network request → map to view state → bind to UI. 80 lines, deterministic, testable.
Combine is no longer Apple’s future — that’s AsyncSequence / Swift Concurrency. But Combine remains the strongest tool for declarative reactive pipelines in UIKit codebases, and you’ll encounter it in every senior interview and most established apps.
| Use case | Combine fits |
|---|---|
| Search debounce + switchToLatest | ✅ Native |
| Form validation across N fields | ✅ Native |
| View-model state pipelines | ✅ Native |
| One-off async fetch | ❌ Use async/await |
| Iterating over a stream of values | ⚠️ Use AsyncSequence for new code |
Concept → Why → How → Code
Vocabulary
Publisher— emits a stream ofOutputvalues (or finishes with an error)Subscriber— receives values; the contract is “give me one at a time, demand more when I’m ready”Operator— a publisher transformed from another publisher (.map,.filter,.debounce)Cancellable— token you keep alive to keep the subscription running; deinit cancels
import Combine
let publisher = ["a", "b", "c"].publisher
let cancellable = publisher
.map { $0.uppercased() }
.sink { print($0) } // A, B, C
@Published — the workhorse for state
A property that publishes its changes:
final class FeedViewModel {
@Published var query: String = ""
@Published private(set) var state: ViewState = .idle
enum ViewState {
case idle, loading, results([Article]), error(String)
}
}
$query is the publisher; query is the value. You can .sink on $query to observe changes:
let vm = FeedViewModel()
let c = vm.$query.sink { print("query is now \($0)") }
vm.query = "hello" // prints "query is now hello"
A real search pipeline
import Combine
import Foundation
final class SearchViewModel {
@Published var query: String = ""
@Published private(set) var state: State = .idle
enum State { case idle, loading, results([Product]), error(String) }
private let api: APIClient
private var cancellables: Set<AnyCancellable> = []
init(api: APIClient) {
self.api = api
bind()
}
private func bind() {
$query
.debounce(for: .milliseconds(300), scheduler: DispatchQueue.main)
.removeDuplicates()
.map { [api] query -> AnyPublisher<State, Never> in
guard !query.isEmpty else {
return Just(.idle).eraseToAnyPublisher()
}
return api.searchPublisher(query: query)
.map { State.results($0) }
.catch { error in Just(State.error(error.localizedDescription)) }
.prepend(.loading)
.eraseToAnyPublisher()
}
.switchToLatest()
.receive(on: DispatchQueue.main)
.assign(to: &$state)
}
}
What this does:
$query— observe query changes.debounce— wait 300ms of silence before forwarding (don’t hit network every keystroke).removeDuplicates— same query as last time? skip.map— for each query, build a publisher that emits.loadingthen.resultsor.error.switchToLatest— when a new query arrives, cancel the previous pipeline (stops stale “ipad” results from clobbering “iphone”).receive(on: .main)— switch back to main thread for UI.assign(to: &$state)— publish into our@Published var state
The view controller observes state and renders:
final class SearchVC: UIViewController {
let vm: SearchViewModel
var cancellables: Set<AnyCancellable> = []
func bind() {
vm.$state
.sink { [weak self] state in self?.render(state) }
.store(in: &cancellables)
searchBar.publisher(for: \.text)
.compactMap { $0 }
.assign(to: \.query, on: vm)
.store(in: &cancellables)
}
}
(Note: UISearchBar doesn’t natively expose a Combine publisher for text; add a small extension via delegate or KVO.)
Building publishers from UIKit
UIKit isn’t Combine-native, but you can bridge:
extension UIControl {
struct EventPublisher<Control: UIControl>: Publisher {
typealias Output = Control
typealias Failure = Never
let control: Control
let event: UIControl.Event
func receive<S: Subscriber>(subscriber: S) where S.Input == Output, S.Failure == Failure {
let subscription = EventSubscription(subscriber: subscriber, control: control, event: event)
subscriber.receive(subscription: subscription)
}
}
final class EventSubscription<S: Subscriber, Control: UIControl>: Subscription
where S.Input == Control {
private var subscriber: S?
private weak var control: Control?
init(subscriber: S, control: Control, event: UIControl.Event) {
self.subscriber = subscriber
self.control = control
control.addTarget(self, action: #selector(handle), for: event)
}
func request(_ demand: Subscribers.Demand) {}
func cancel() { subscriber = nil; control = nil }
@objc private func handle() {
guard let control else { return }
_ = subscriber?.receive(control)
}
}
func publisher(for event: UIControl.Event) -> EventPublisher<Self> {
EventPublisher(control: self, event: event)
}
}
// Usage
button.publisher(for: .touchUpInside)
.sink { _ in print("tapped") }
.store(in: &cancellables)
For UITextField:
extension UITextField {
var textPublisher: AnyPublisher<String, Never> {
publisher(for: .editingChanged)
.map { $0.text ?? "" }
.eraseToAnyPublisher()
}
}
Then form validation:
Publishers.CombineLatest(emailField.textPublisher, passwordField.textPublisher)
.map { email, password in isValidEmail(email) && password.count >= 8 }
.assign(to: \.isEnabled, on: submitButton)
.store(in: &cancellables)
Common operators you’ll actually use
.map,.compactMap,.tryMap— transform.filter— drop values.removeDuplicates— dedupe consecutive equal values.debounce(for:scheduler:)— emit only after silence.throttle(for:scheduler:latest:)— at most one per interval.combineLatest,.zip— combine multiple publishers.merge— interleave outputs.flatMap,.switchToLatest— chain into new publishers.handleEvents(receiveOutput:receiveCompletion:)— side effects (logging).assign(to:on:)— bind to a property.sink(receiveValue:)— terminal subscriber
Memory: cancellables & retain cycles
AnyCancellable cancels its subscription on deinit. The pattern:
var cancellables: Set<AnyCancellable> = []
somePublisher
.sink { value in /* ... */ }
.store(in: &cancellables)
When the VC deinits, the set deinits, cancellables cancel, subscriptions tear down. Don’t keep the cancellable in a local variable — it’ll deinit immediately and cancel before any value arrives.
Retain cycles in .sink closures: capture [weak self]:
publisher
.sink { [weak self] value in
self?.update(value)
}
.store(in: &cancellables)
If you don’t, the closure retains self, self retains the Cancellable set, the set retains the subscription, the subscription retains the closure → cycle.
Error handling
Publishers have a Failure type. Operators that can throw produce typed errors:
URLSession.shared.dataTaskPublisher(for: url) // Failure == URLError
.map(\.data)
.decode(type: Feed.self, decoder: JSONDecoder()) // Failure == Error (broader)
.receive(on: DispatchQueue.main)
.sink(
receiveCompletion: { completion in
if case .failure(let error) = completion { print(error) }
},
receiveValue: { feed in /* ... */ }
)
Once a publisher errors, it’s done — no more values. To recover and continue:
.catch { error in Just([]).setFailureType(to: Error.self) }
Or .replaceError(with:) to swap any error for a fallback value.
@Published vs CurrentValueSubject vs PassthroughSubject
@Published var x | CurrentValueSubject<X, E> | PassthroughSubject<X, E> | |
|---|---|---|---|
| Stores current value | Yes | Yes | No (transient events) |
| New subscriber receives latest | Yes | Yes | No |
| Has typed failure | No (Never) | Yes | Yes |
| Direct property syntax | Yes | No | No |
| Send manually | Set the property | .send(_:) | .send(_:) |
Rule of thumb: @Published for view-model state; CurrentValueSubject if you need typed failure; PassthroughSubject for events that don’t have a “current” value (taps, notifications).
Combine vs async/await in 2026
Apple introduced AsyncSequence and async/await partly to replace Combine for many use cases:
- One-off requests →
async/await(URLSession.data(for:)) - Streams of values →
AsyncSequencefor new code, but Combine still widely used in UIKit codebases - Complex reactive pipelines (debounce, combineLatest, switchToLatest) → Combine still wins; AsyncSequence operators are limited
- UIKit property bindings (
@Published→ text field, button enabled) → Combine
In practice, codebases written 2019-2022 are Combine-heavy. 2024+ projects mix: async/await for sequential async, Combine for reactive UI. SwiftUI uses Combine under the hood (ObservableObject, @Published).
Bridging:
// Combine → async/await
let value = try await publisher.values.first(where: { _ in true })
// async/await → Combine
let publisher = Future<Value, Error> { promise in
Task {
do {
let v = try await fetchValue()
promise(.success(v))
} catch {
promise(.failure(error))
}
}
}
Testing Combine pipelines
Combine pipelines are pure functions of their inputs (given a sequence of inputs at times, produce a sequence of outputs at times). That makes them deterministic and testable:
func test_emptyQueryProducesIdle() {
let api = MockAPIClient()
let vm = SearchViewModel(api: api)
let expectation = XCTestExpectation()
vm.$state
.dropFirst() // skip initial value
.first()
.sink { state in
if case .idle = state { expectation.fulfill() }
}
.store(in: &cancellables)
vm.query = ""
wait(for: [expectation], timeout: 1)
}
For .debounce, inject a TestScheduler (third-party libraries like CombineSchedulers from Point-Free, or roll your own) so tests don’t actually wait 300ms.
In the wild
- Apple’s own SwiftUI is built on Combine.
ObservableObject’sobjectWillChangeis aPassthroughSubject. - Robinhood iOS has many Combine pipelines for ticker streams: WebSocket → decode → throttle to 1Hz per ticker → de-dupe → bind to view.
- Airbnb’s MvRx pattern (their internal architecture) uses Combine for view model state derivation.
- Lyft uses Combine extensively for form validation and search debouncing.
- Mozilla’s iOS Focus browser uses Combine for the URL bar suggestion pipeline (debounce, history search, sync).
Common misconceptions
- “Combine is dead because Apple promotes async/await.” No — Combine is still actively used, supported, and the best tool for reactive (vs sequential) async. Apple ships SwiftUI on Combine internals.
- “
@Publishedworks onletproperties.” No, onlyvar. The publisher fires on the property’sdidSet. - “
.sinkwithout.store(in:)works.” It works until the returnedAnyCancellableis deallocated — usually on the next line. Always store. - “
combineLatestwaits for all publishers to emit.” Yes — and emits no value until each has emitted at least once. If one publisher never emits, the combined never emits. - “Threading is handled automatically.” No. Publishers emit on whatever queue they were created on. Use
.receive(on: DispatchQueue.main)before UI updates.
Seasoned engineer’s take
Combine is declarative async state management. Once you wire it correctly, the bug class of “state is in 4 places, hard to keep in sync, race conditions when network is flaky” mostly disappears.
Rules I follow:
- State lives in
@Publishedproperties on view models. Views observe, render. One-way data flow. - Side effects via
.handleEvents— log, trigger analytics, never mutate state outside the pipeline. - Use
.switchToLatestover.flatMapfor user-driven async (search, filter changes) — cancels stale work automatically. receive(on: .main)once, at the end — let upstream operators do work on background queues.- Tests pass synthetic schedulers, not wall-clock waits.
TestSchedulerlets you advance time and assert what the pipeline emits.
TIP: When debugging “why isn’t my pipeline emitting?”, add
.print("debug")at multiple points. It logs every event (subscribed, value, completion, cancelled). Disposable but invaluable.
WARNING:
assign(to:on:)(one argument: target, key path, object) strongly retains the target object. Useassign(to: &$state)(@Publishedform, no retention) or[weak self]+.sink { self?.x = $0 }.
Interview corner
Junior-level: “What’s the difference between flatMap and switchToLatest?”
flatMap keeps every inner publisher alive — values from old ones can still arrive. switchToLatest (applied to a publisher of publishers) cancels the previous inner publisher when a new outer value arrives. Use switchToLatest for “only care about the latest request” patterns like search.
Mid-level: “How would you implement form validation across 3 fields, where the submit button enables only when all are valid?”
Publishers.CombineLatest3(
emailField.textPublisher.map(isValidEmail),
passwordField.textPublisher.map { $0.count >= 8 },
confirmField.textPublisher
)
.map { emailValid, passwordValid, confirm in
emailValid && passwordValid && confirm == passwordField.text
}
.assign(to: \.isEnabled, on: submitButton)
.store(in: &cancellables)
Each field’s editing-changed event flows through. CombineLatest3 emits a tuple whenever any of the three emits. The transform decides whether all conditions hold.
Senior-level: “Design a real-time ticker streaming UI: 100 stocks, server pushes updates over WebSocket up to 50/sec, UI throttles to 1 update per stock per second, batches by 100ms, sorts the list, applies a diffable snapshot.”
WebSocket → PassthroughSubject<TickerUpdate, Never>. Group by ticker ID with a dictionary [String: CurrentValueSubject<TickerUpdate, Never>]; each per-ticker subject is .throttle(for: 1, scheduler: DispatchQueue.global(), latest: true). Merge all throttled streams, then .collect(.byTime(scheduler, 0.1)) to batch by 100ms, then .map { batch in apply(batch) -> sortedSnapshot }, then .receive(on: .main), then .sink { snapshot in dataSource.apply(snapshot) }. Tests with TestScheduler: feed synthetic events, advance virtual time, assert snapshots. Profile with Instruments to confirm we don’t allocate excessively per update.
Red flag in candidates: Treating every async task as a Future — Future is for one-shot work, runs immediately even without subscribers (eager), retains its closure forever. For repeatable work, use AnyPublisher from a PassthroughSubject or wrap a function in Deferred { Future { ... } }.
Lab preview
Combine threads through Phase 6 (SwiftUI + Combine) extensively. In Phase 4 labs, you can extend Lab 4.1 with a Combine pipeline for the search bar as a stretch goal.
Phase 4 chapters complete. Continue with Lab 4.1 — News reader.
Lab 4.1 — News reader
Goal: Build a UIKit news reader app that fetches headlines from a public API, displays them in a list with self-sizing cells, supports pull-to-refresh, shows loading/error/empty states, and pushes a detail view on tap.
Time: ~90 minutes Phase prerequisites: Chapters 4.1 – 4.5, 4.8
What you’ll build
A two-screen UIKit app:
- List screen —
UITableView(orUICollectionViewwith list layout) showing article headlines, source, and timestamp. Pull-to-refresh, error banner with retry, loading shimmer. - Detail screen — Pushed when a row is tapped. Shows full article info with a “Open in Safari” button.
Stack: UIKit, programmatic Auto Layout, diffable data source, URLSession async/await, Codable.
Setup
- New Xcode project: App template, name
NewsReader, language Swift, interface Storyboard, lifecycle UIKit App Delegate. - Delete
Main.storyboard. In project settings → Targets → Info → “Main storyboard file base name” — delete the value. In Info → Application Scene Manifest, delete “Storyboard Name” entries. - Set up
SceneDelegate.scene(_:willConnectTo:options:)to make the window programmatically.
Step 1 — Configure the window
// SceneDelegate.swift
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 = UINavigationController(rootViewController: ArticleListVC())
window.makeKeyAndVisible()
self.window = window
}
Step 2 — Pick an API
Two free options (no signup required):
- Hacker News Firebase API:
https://hacker-news.firebaseio.com/v0/topstories.jsonthen per-itemhttps://hacker-news.firebaseio.com/v0/item/<id>.json - Spaceflight News API:
https://api.spaceflightnewsapi.net/v4/articles/?limit=30— returns a single JSON page with all needed fields
Use Spaceflight News API — simpler, single request, includes title/summary/url/imageUrl/publishedAt.
Step 3 — Model
// Models/Article.swift
struct ArticleResponse: Decodable {
let results: [Article]
}
struct Article: Decodable, Hashable, Identifiable {
let id: Int
let title: String
let url: String
let imageUrl: String?
let newsSite: String
let summary: String
let publishedAt: Date
enum CodingKeys: String, CodingKey {
case id, title, url
case imageUrl = "image_url"
case newsSite = "news_site"
case summary
case publishedAt = "published_at"
}
}
Step 4 — Networking
// Networking/NewsAPI.swift
enum NewsAPIError: Error {
case badURL, badResponse, decoding(Error), transport(Error)
}
final class NewsAPI {
private let session: URLSession
init(session: URLSession = .shared) { self.session = session }
func fetchArticles() async throws -> [Article] {
guard let url = URL(string: "https://api.spaceflightnewsapi.net/v4/articles/?limit=30") else {
throw NewsAPIError.badURL
}
do {
let (data, response) = try await session.data(from: url)
guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode) else {
throw NewsAPIError.badResponse
}
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .iso8601
do {
let envelope = try decoder.decode(ArticleResponse.self, from: data)
return envelope.results
} catch {
throw NewsAPIError.decoding(error)
}
} catch let error as NewsAPIError {
throw error
} catch {
throw NewsAPIError.transport(error)
}
}
}
Step 5 — View state
// ViewModels/ArticleListState.swift
enum ArticleListState {
case idle
case loading
case loaded([Article])
case empty
case error(String)
}
Step 6 — Cell
// Views/ArticleCell.swift
import UIKit
final class ArticleCell: UITableViewCell {
static let reuseID = "ArticleCell"
private let titleLabel: UILabel = {
let l = UILabel()
l.font = .preferredFont(forTextStyle: .headline)
l.numberOfLines = 0
l.adjustsFontForContentSizeCategory = true
l.translatesAutoresizingMaskIntoConstraints = false
return l
}()
private let sourceLabel: UILabel = {
let l = UILabel()
l.font = .preferredFont(forTextStyle: .caption1)
l.textColor = .secondaryLabel
l.adjustsFontForContentSizeCategory = true
l.translatesAutoresizingMaskIntoConstraints = false
return l
}()
private let summaryLabel: UILabel = {
let l = UILabel()
l.font = .preferredFont(forTextStyle: .subheadline)
l.textColor = .label
l.numberOfLines = 3
l.adjustsFontForContentSizeCategory = true
l.translatesAutoresizingMaskIntoConstraints = false
return l
}()
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
super.init(style: style, reuseIdentifier: reuseIdentifier)
let stack = UIStackView(arrangedSubviews: [titleLabel, summaryLabel, sourceLabel])
stack.axis = .vertical
stack.spacing = 6
stack.translatesAutoresizingMaskIntoConstraints = false
contentView.addSubview(stack)
NSLayoutConstraint.activate([
stack.leadingAnchor.constraint(equalTo: contentView.layoutMarginsGuide.leadingAnchor),
stack.trailingAnchor.constraint(equalTo: contentView.layoutMarginsGuide.trailingAnchor),
stack.topAnchor.constraint(equalTo: contentView.layoutMarginsGuide.topAnchor),
stack.bottomAnchor.constraint(equalTo: contentView.layoutMarginsGuide.bottomAnchor),
])
accessoryType = .disclosureIndicator
}
required init?(coder: NSCoder) { fatalError() }
func configure(with article: Article) {
titleLabel.text = article.title
summaryLabel.text = article.summary
let formatter = RelativeDateTimeFormatter()
formatter.unitsStyle = .short
let when = formatter.localizedString(for: article.publishedAt, relativeTo: .now)
sourceLabel.text = "\(article.newsSite) · \(when)"
}
}
Step 7 — List view controller
// VCs/ArticleListVC.swift
import UIKit
final class ArticleListVC: UIViewController {
private enum Section { case main }
private let api = NewsAPI()
private var tableView: UITableView!
private var dataSource: UITableViewDiffableDataSource<Section, Article>!
private var loadTask: Task<Void, Never>?
private let refreshControl = UIRefreshControl()
private let statusLabel: UILabel = {
let l = UILabel()
l.textAlignment = .center
l.numberOfLines = 0
l.font = .preferredFont(forTextStyle: .body)
l.textColor = .secondaryLabel
l.translatesAutoresizingMaskIntoConstraints = false
return l
}()
override func viewDidLoad() {
super.viewDidLoad()
title = "Spaceflight"
view.backgroundColor = .systemBackground
setupTableView()
setupStatusLabel()
configureDataSource()
Task { await load(showsSpinner: true) }
}
private func setupTableView() {
tableView = UITableView(frame: view.bounds, style: .plain)
tableView.translatesAutoresizingMaskIntoConstraints = false
tableView.register(ArticleCell.self, forCellReuseIdentifier: ArticleCell.reuseID)
tableView.rowHeight = UITableView.automaticDimension
tableView.estimatedRowHeight = 100
tableView.delegate = self
tableView.refreshControl = refreshControl
refreshControl.addTarget(self, action: #selector(pulledToRefresh), for: .valueChanged)
view.addSubview(tableView)
NSLayoutConstraint.activate([
tableView.topAnchor.constraint(equalTo: view.topAnchor),
tableView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
tableView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
tableView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
])
}
private func setupStatusLabel() {
view.addSubview(statusLabel)
NSLayoutConstraint.activate([
statusLabel.centerXAnchor.constraint(equalTo: view.centerXAnchor),
statusLabel.centerYAnchor.constraint(equalTo: view.centerYAnchor),
statusLabel.leadingAnchor.constraint(equalTo: view.layoutMarginsGuide.leadingAnchor),
statusLabel.trailingAnchor.constraint(equalTo: view.layoutMarginsGuide.trailingAnchor),
])
statusLabel.isHidden = true
}
private func configureDataSource() {
dataSource = UITableViewDiffableDataSource<Section, Article>(tableView: tableView) { tv, indexPath, article in
let cell = tv.dequeueReusableCell(withIdentifier: ArticleCell.reuseID, for: indexPath) as! ArticleCell
cell.configure(with: article)
return cell
}
}
@objc private func pulledToRefresh() {
Task { await load(showsSpinner: false) }
}
private func load(showsSpinner: Bool) async {
loadTask?.cancel()
if showsSpinner { showStatus("Loading…") }
loadTask = Task { [weak self] in
guard let self else { return }
do {
let articles = try await api.fetchArticles()
try Task.checkCancellation()
await MainActor.run {
self.refreshControl.endRefreshing()
if articles.isEmpty {
self.showStatus("No articles right now.")
} else {
self.hideStatus()
var snap = NSDiffableDataSourceSnapshot<Section, Article>()
snap.appendSections([.main])
snap.appendItems(articles)
self.dataSource.apply(snap, animatingDifferences: true)
}
}
} catch is CancellationError {
return
} catch {
await MainActor.run {
self.refreshControl.endRefreshing()
self.showStatus("Couldn't load articles.\n\(error.localizedDescription)\n\nPull to retry.")
}
}
}
await loadTask?.value
}
private func showStatus(_ message: String) {
statusLabel.text = message
statusLabel.isHidden = false
tableView.isHidden = true
}
private func hideStatus() {
statusLabel.isHidden = true
tableView.isHidden = false
}
}
extension ArticleListVC: UITableViewDelegate {
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
tableView.deselectRow(at: indexPath, animated: true)
guard let article = dataSource.itemIdentifier(for: indexPath) else { return }
navigationController?.pushViewController(ArticleDetailVC(article: article), animated: true)
}
}
Step 8 — Detail view controller
// VCs/ArticleDetailVC.swift
import UIKit
import SafariServices
final class ArticleDetailVC: UIViewController {
private let article: Article
init(article: Article) {
self.article = article
super.init(nibName: nil, bundle: nil)
}
required init?(coder: NSCoder) { fatalError() }
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .systemBackground
title = article.newsSite
let titleLabel = UILabel()
titleLabel.text = article.title
titleLabel.font = .preferredFont(forTextStyle: .title1)
titleLabel.numberOfLines = 0
let summaryLabel = UILabel()
summaryLabel.text = article.summary
summaryLabel.font = .preferredFont(forTextStyle: .body)
summaryLabel.numberOfLines = 0
let openButton = UIButton(configuration: .filled(), primaryAction: UIAction(title: "Open in Safari") { [weak self] _ in
guard let self, let url = URL(string: article.url) else { return }
present(SFSafariViewController(url: url), animated: true)
})
let stack = UIStackView(arrangedSubviews: [titleLabel, summaryLabel, openButton])
stack.axis = .vertical
stack.spacing = 16
stack.alignment = .leading
stack.translatesAutoresizingMaskIntoConstraints = false
let scroll = UIScrollView()
scroll.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(scroll)
scroll.addSubview(stack)
NSLayoutConstraint.activate([
scroll.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor),
scroll.leadingAnchor.constraint(equalTo: view.leadingAnchor),
scroll.trailingAnchor.constraint(equalTo: view.trailingAnchor),
scroll.bottomAnchor.constraint(equalTo: view.bottomAnchor),
stack.topAnchor.constraint(equalTo: scroll.contentLayoutGuide.topAnchor, constant: 16),
stack.bottomAnchor.constraint(equalTo: scroll.contentLayoutGuide.bottomAnchor, constant: -16),
stack.leadingAnchor.constraint(equalTo: scroll.contentLayoutGuide.leadingAnchor, constant: 16),
stack.trailingAnchor.constraint(equalTo: scroll.contentLayoutGuide.trailingAnchor, constant: -16),
stack.widthAnchor.constraint(equalTo: scroll.frameLayoutGuide.widthAnchor, constant: -32),
])
}
}
Step 9 — Run
Build and run. You should see:
- “Loading…” briefly
- A list of articles
- Pull down to refresh — spinner appears, list updates
- Tap a row — pushes the detail screen
- Tap “Open in Safari” — modal Safari view appears
Stretch goals
- Search bar with debounced filtering of loaded articles (use Combine’s
.debounceper chapter 4.10). - Image loading — async-load
imageUrlinto a cellUIImageViewwith caching (URLCache). Add a fixed-width image on the leading side of the cell content. - Compositional layout — convert from
UITableViewtoUICollectionViewwith.list(using:). - Section by date — group by today/yesterday/last week using a custom
Sectionenum. - Offline cache — on successful fetch, save articles to
Library/Application Support/articles.json. On launch, load and display while fetching fresh. Per chapter 4.7. - Unit tests — inject a mock
URLSessionviaURLProtocol(per chapter 4.8) and write tests forNewsAPI.fetchArticles()success, decoding error, network error.
Notes & troubleshooting
- “App Transport Security blocked the request”: Spaceflight News API is HTTPS, so no issue. If you swap APIs, ensure HTTPS or add an
Info.plistexception (don’t ship that). - Dates parse oddly: API returns ISO 8601 with milliseconds. If the default
.iso8601strategy fails, use a customDateFormatterwith"yyyy-MM-dd'T'HH:mm:ss.SSSZ". - Pull-to-refresh doesn’t appear: Make sure
refreshControlis set on thetableViewbefore the view loads, and the table is a direct subview of the VC (not nested in a scroll view). - Cells truncate: Check
numberOfLines = 0on the labels androwHeight = UITableView.automaticDimensionon the table.
Next: Lab 4.2 — Custom collection layout
Lab 4.2 — Custom collection layout
Goal: Build a multi-section feed screen with UICollectionViewCompositionalLayout: a horizontal hero carousel, a 2-column featured grid, and a full-width list. Use a diffable data source with multiple item types and supplementary section headers.
Time: ~120 minutes Phase prerequisites: Chapters 4.2, 4.3, 4.5
What you’ll build
A single-screen “Discover” feed that looks like Apple’s App Store today tab:
- Section 1 — Hero carousel: full-width-ish cards, scroll horizontally, snap to page
- Section 2 — Featured grid: 2 columns, square cards
- Section 3 — Recent list: full-width row cells with image + title + subtitle
Each section has a header (supplementary view). All powered by one UICollectionView with one diffable data source.
Setup
- New Xcode project: App template,
DiscoverFeed, Swift, UIKit, programmatic (delete Main.storyboard). - Configure
SceneDelegateto makeDiscoverVCthe root inside aUINavigationController.
Step 1 — Model the data
// Models/FeedItem.swift
import UIKit
enum FeedSection: Int, CaseIterable, Hashable {
case hero, featured, recent
var title: String {
switch self {
case .hero: return "Featured stories"
case .featured: return "You might like"
case .recent: return "Latest"
}
}
}
struct FeedItem: Hashable {
let id: UUID
let title: String
let subtitle: String
let color: UIColor
let section: FeedSection
}
enum FeedFixtures {
static func make() -> [FeedItem] {
let colors: [UIColor] = [.systemRed, .systemBlue, .systemGreen, .systemOrange, .systemPurple, .systemTeal, .systemPink, .systemIndigo]
var items: [FeedItem] = []
for i in 0..<5 {
items.append(FeedItem(id: UUID(), title: "Hero \(i + 1)", subtitle: "Editor's pick", color: colors[i % colors.count], section: .hero))
}
for i in 0..<8 {
items.append(FeedItem(id: UUID(), title: "Featured \(i + 1)", subtitle: "Trending now", color: colors[(i + 2) % colors.count], section: .featured))
}
for i in 0..<20 {
items.append(FeedItem(id: UUID(), title: "Article \(i + 1)", subtitle: "5 min read · Today", color: colors[(i + 4) % colors.count], section: .recent))
}
return items
}
}
Step 2 — Cells
// Views/HeroCell.swift
import UIKit
final class HeroCell: UICollectionViewCell {
static let reuseID = "HeroCell"
private let titleLabel = UILabel()
private let subtitleLabel = UILabel()
private let container = UIView()
override init(frame: CGRect) {
super.init(frame: frame)
container.layer.cornerRadius = 16
container.layer.cornerCurve = .continuous
container.translatesAutoresizingMaskIntoConstraints = false
contentView.addSubview(container)
titleLabel.font = .preferredFont(forTextStyle: .title2)
titleLabel.textColor = .white
titleLabel.numberOfLines = 2
titleLabel.translatesAutoresizingMaskIntoConstraints = false
subtitleLabel.font = .preferredFont(forTextStyle: .caption1)
subtitleLabel.textColor = .white.withAlphaComponent(0.85)
subtitleLabel.translatesAutoresizingMaskIntoConstraints = false
container.addSubview(titleLabel)
container.addSubview(subtitleLabel)
NSLayoutConstraint.activate([
container.topAnchor.constraint(equalTo: contentView.topAnchor),
container.bottomAnchor.constraint(equalTo: contentView.bottomAnchor),
container.leadingAnchor.constraint(equalTo: contentView.leadingAnchor),
container.trailingAnchor.constraint(equalTo: contentView.trailingAnchor),
subtitleLabel.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: 16),
subtitleLabel.trailingAnchor.constraint(equalTo: container.trailingAnchor, constant: -16),
subtitleLabel.bottomAnchor.constraint(equalTo: container.bottomAnchor, constant: -16),
titleLabel.leadingAnchor.constraint(equalTo: subtitleLabel.leadingAnchor),
titleLabel.trailingAnchor.constraint(equalTo: subtitleLabel.trailingAnchor),
titleLabel.bottomAnchor.constraint(equalTo: subtitleLabel.topAnchor, constant: -4),
])
}
required init?(coder: NSCoder) { fatalError() }
func configure(with item: FeedItem) {
titleLabel.text = item.title
subtitleLabel.text = item.subtitle
container.backgroundColor = item.color
}
}
// Views/FeaturedCell.swift
final class FeaturedCell: UICollectionViewCell {
static let reuseID = "FeaturedCell"
private let titleLabel = UILabel()
private let container = UIView()
override init(frame: CGRect) {
super.init(frame: frame)
container.layer.cornerRadius = 12
container.translatesAutoresizingMaskIntoConstraints = false
contentView.addSubview(container)
titleLabel.font = .preferredFont(forTextStyle: .headline)
titleLabel.textColor = .white
titleLabel.numberOfLines = 0
titleLabel.translatesAutoresizingMaskIntoConstraints = false
container.addSubview(titleLabel)
NSLayoutConstraint.activate([
container.topAnchor.constraint(equalTo: contentView.topAnchor),
container.bottomAnchor.constraint(equalTo: contentView.bottomAnchor),
container.leadingAnchor.constraint(equalTo: contentView.leadingAnchor),
container.trailingAnchor.constraint(equalTo: contentView.trailingAnchor),
titleLabel.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: 12),
titleLabel.trailingAnchor.constraint(equalTo: container.trailingAnchor, constant: -12),
titleLabel.bottomAnchor.constraint(equalTo: container.bottomAnchor, constant: -12),
])
}
required init?(coder: NSCoder) { fatalError() }
func configure(with item: FeedItem) {
titleLabel.text = item.title
container.backgroundColor = item.color
}
}
// Views/RecentCell.swift — use the iOS 14 list content config
final class RecentCell: UICollectionViewListCell {
static let reuseID = "RecentCell"
func configure(with item: FeedItem) {
var config = defaultContentConfiguration()
config.text = item.title
config.secondaryText = item.subtitle
config.image = UIImage(systemName: "doc.text")
config.imageProperties.tintColor = item.color
contentConfiguration = config
accessories = [.disclosureIndicator()]
}
}
Step 3 — Section header
// Views/SectionHeader.swift
final class SectionHeader: UICollectionReusableView {
static let reuseID = "SectionHeader"
static let elementKind = "section-header"
private let label: UILabel = {
let l = UILabel()
l.font = .preferredFont(forTextStyle: .title3).bold()
l.adjustsFontForContentSizeCategory = true
l.translatesAutoresizingMaskIntoConstraints = false
return l
}()
override init(frame: CGRect) {
super.init(frame: frame)
addSubview(label)
NSLayoutConstraint.activate([
label.leadingAnchor.constraint(equalTo: layoutMarginsGuide.leadingAnchor),
label.trailingAnchor.constraint(equalTo: layoutMarginsGuide.trailingAnchor),
label.topAnchor.constraint(equalTo: topAnchor, constant: 8),
label.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -4),
])
}
required init?(coder: NSCoder) { fatalError() }
func configure(title: String) { label.text = title }
}
extension UIFont {
func bold() -> UIFont {
guard let desc = fontDescriptor.withSymbolicTraits(.traitBold) else { return self }
return UIFont(descriptor: desc, size: 0)
}
}
Step 4 — Layout
The heart of the lab. Build a UICollectionViewCompositionalLayout with a per-section provider:
// VCs/DiscoverVC+Layout.swift
extension DiscoverVC {
func makeLayout() -> UICollectionViewLayout {
UICollectionViewCompositionalLayout { sectionIndex, environment in
guard let section = FeedSection(rawValue: sectionIndex) else { return nil }
switch section {
case .hero: return self.makeHeroSection()
case .featured: return self.makeFeaturedSection(env: environment)
case .recent: return self.makeRecentSection(env: environment)
}
}
}
private func makeHeroSection() -> NSCollectionLayoutSection {
let item = NSCollectionLayoutItem(
layoutSize: .init(widthDimension: .fractionalWidth(1), heightDimension: .fractionalHeight(1))
)
let group = NSCollectionLayoutGroup.horizontal(
layoutSize: .init(widthDimension: .fractionalWidth(0.85), heightDimension: .absolute(220)),
subitems: [item]
)
let section = NSCollectionLayoutSection(group: group)
section.orthogonalScrollingBehavior = .groupPagingCentered
section.interGroupSpacing = 12
section.contentInsets = .init(top: 0, leading: 16, bottom: 16, trailing: 16)
section.boundarySupplementaryItems = [makeHeader()]
return section
}
private func makeFeaturedSection(env: NSCollectionLayoutEnvironment) -> NSCollectionLayoutSection {
let item = NSCollectionLayoutItem(
layoutSize: .init(widthDimension: .fractionalWidth(0.5), heightDimension: .fractionalHeight(1))
)
item.contentInsets = .init(top: 4, leading: 4, bottom: 4, trailing: 4)
let group = NSCollectionLayoutGroup.horizontal(
layoutSize: .init(widthDimension: .fractionalWidth(1), heightDimension: .absolute(140)),
subitems: [item]
)
let section = NSCollectionLayoutSection(group: group)
section.contentInsets = .init(top: 0, leading: 12, bottom: 16, trailing: 12)
section.boundarySupplementaryItems = [makeHeader()]
return section
}
private func makeRecentSection(env: NSCollectionLayoutEnvironment) -> NSCollectionLayoutSection {
var config = UICollectionLayoutListConfiguration(appearance: .plain)
config.headerMode = .supplementary
return NSCollectionLayoutSection.list(using: config, layoutEnvironment: env)
}
private func makeHeader() -> NSCollectionLayoutBoundarySupplementaryItem {
let header = NSCollectionLayoutBoundarySupplementaryItem(
layoutSize: .init(widthDimension: .fractionalWidth(1), heightDimension: .estimated(44)),
elementKind: SectionHeader.elementKind,
alignment: .top
)
header.pinToVisibleBounds = false
return header
}
}
Step 5 — View controller
// VCs/DiscoverVC.swift
import UIKit
final class DiscoverVC: UIViewController {
private var collectionView: UICollectionView!
private var dataSource: UICollectionViewDiffableDataSource<FeedSection, FeedItem>!
override func viewDidLoad() {
super.viewDidLoad()
title = "Discover"
view.backgroundColor = .systemBackground
collectionView = UICollectionView(frame: view.bounds, collectionViewLayout: makeLayout())
collectionView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
collectionView.backgroundColor = .systemBackground
collectionView.delegate = self
view.addSubview(collectionView)
registerViews()
configureDataSource()
applyInitialSnapshot()
}
private func registerViews() {
collectionView.register(HeroCell.self, forCellWithReuseIdentifier: HeroCell.reuseID)
collectionView.register(FeaturedCell.self, forCellWithReuseIdentifier: FeaturedCell.reuseID)
collectionView.register(RecentCell.self, forCellWithReuseIdentifier: RecentCell.reuseID)
collectionView.register(
SectionHeader.self,
forSupplementaryViewOfKind: SectionHeader.elementKind,
withReuseIdentifier: SectionHeader.reuseID
)
}
private func configureDataSource() {
dataSource = UICollectionViewDiffableDataSource<FeedSection, FeedItem>(collectionView: collectionView) { cv, indexPath, item in
switch item.section {
case .hero:
let cell = cv.dequeueReusableCell(withReuseIdentifier: HeroCell.reuseID, for: indexPath) as! HeroCell
cell.configure(with: item)
return cell
case .featured:
let cell = cv.dequeueReusableCell(withReuseIdentifier: FeaturedCell.reuseID, for: indexPath) as! FeaturedCell
cell.configure(with: item)
return cell
case .recent:
let cell = cv.dequeueReusableCell(withReuseIdentifier: RecentCell.reuseID, for: indexPath) as! RecentCell
cell.configure(with: item)
return cell
}
}
dataSource.supplementaryViewProvider = { cv, kind, indexPath in
guard kind == SectionHeader.elementKind,
let section = FeedSection(rawValue: indexPath.section) else { return nil }
let header = cv.dequeueReusableSupplementaryView(
ofKind: kind,
withReuseIdentifier: SectionHeader.reuseID,
for: indexPath
) as! SectionHeader
header.configure(title: section.title)
return header
}
}
private func applyInitialSnapshot() {
let all = FeedFixtures.make()
var snap = NSDiffableDataSourceSnapshot<FeedSection, FeedItem>()
for section in FeedSection.allCases {
snap.appendSections([section])
snap.appendItems(all.filter { $0.section == section }, toSection: section)
}
dataSource.apply(snap, animatingDifferences: false)
}
}
extension DiscoverVC: UICollectionViewDelegate {
func collectionView(_ cv: UICollectionView, didSelectItemAt indexPath: IndexPath) {
cv.deselectItem(at: indexPath, animated: true)
guard let item = dataSource.itemIdentifier(for: indexPath) else { return }
let detail = UIViewController()
detail.title = item.title
detail.view.backgroundColor = item.color
navigationController?.pushViewController(detail, animated: true)
}
}
Step 6 — Run
You should see:
- Section header “Featured stories” and a horizontally-scrolling card carousel that snaps
- Section header “You might like” with a 2-column grid
- Section header “Latest” with a full-width list
- Tapping any item pushes a placeholder detail VC
Rotate the device — the layout adapts because every dimension is fractional / estimated.
Stretch goals
- Different hero card sizes by index — use
NSCollectionLayoutGroup.customto mix tall and short cards. - Pull-to-refresh that animates new items in via
dataSource.apply(_:animatingDifferences: true). - Swipe actions on the recent list — use
UICollectionLayoutListConfiguration’strailingSwipeActionsConfigurationProvider. - Adaptive layout — for compact width, make the featured section 1 column. Use the
NSCollectionLayoutEnvironment.container.effectiveContentSize.widthcheck. - Reorderable featured section — set
dataSource.reorderingHandlersand a long-press gesture (chapter 4.6). - Animated cell highlight on selection — override
isHighlightedinFeaturedCelland scalecontainer.transform.
Notes & troubleshooting
- Cells overlap headers: ensure
boundarySupplementaryItemsis on the section, not the layout config (the list-section helper handles this viaheaderMode = .supplementary). - List section ignores your header layout: list sections use
UICollectionLayoutListConfiguration’s own header. UseheaderMode = .supplementarythen provide the view via the supplementary provider. - Orthogonal scrolling stutters: this is usually because cells are doing heavy work in
cellForItemAt(image decode on main thread). Move work off main; pre-decode images. - Snapshot animation looks weird:
Hashableconformance must be stable.FeedItem.idis aUUID— re-creating items will give new IDs and confuse the diff. Generate fixtures once and store.
Next: Lab 4.3 — Form with Keychain
Lab 4.3 — Form with Keychain
Goal: Build a login form that validates email/password fields live, handles keyboard avoidance with keyboardLayoutGuide, simulates an auth API call, and persists the returned auth token in the Keychain. On relaunch, the app reads the token and skips the login screen.
Time: ~90 minutes Phase prerequisites: Chapters 4.3, 4.6, 4.7
What you’ll build
Two screens:
- LoginVC — email field, password field (secure), “Sign in” button (disabled until valid), live validation messages, loading spinner, error banner. Keyboard never covers the active field.
- HomeVC — placeholder “Welcome <email>” + “Sign out” button. Pushed automatically when a valid token exists; presented after successful sign-in.
The token is stored in Keychain via the Security framework (no third-party deps). Sign-out deletes the token.
Setup
- New Xcode project:
KeychainForm, UIKit, Swift, no Storyboard. - Configure
SceneDelegateto install a root VC chosen by token presence.
Step 1 — Keychain helper
Reuse the pattern from chapter 4.7:
// Keychain.swift
import Foundation
import Security
enum KeychainError: Error {
case status(OSStatus)
case dataConversion
}
enum Keychain {
private static let service = "dev.10x.KeychainForm"
static func set(_ string: String, for account: String) throws {
guard let data = string.data(using: .utf8) else { throw KeychainError.dataConversion }
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: account,
]
let attributes: [String: Any] = [
kSecValueData as String: data,
kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly,
]
let updateStatus = SecItemUpdate(query as CFDictionary, attributes as CFDictionary)
if updateStatus == errSecItemNotFound {
var addQuery = query
addQuery.merge(attributes) { _, new in new }
let addStatus = SecItemAdd(addQuery as CFDictionary, nil)
guard addStatus == errSecSuccess else { throw KeychainError.status(addStatus) }
} else if updateStatus != errSecSuccess {
throw KeychainError.status(updateStatus)
}
}
static func get(_ account: String) throws -> String? {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: account,
kSecReturnData as String: true,
kSecMatchLimit as String: kSecMatchLimitOne,
]
var item: CFTypeRef?
let status = SecItemCopyMatching(query as CFDictionary, &item)
if status == errSecItemNotFound { return nil }
guard status == errSecSuccess, let data = item as? Data, let s = String(data: data, encoding: .utf8) else {
throw KeychainError.status(status)
}
return s
}
static func delete(_ account: String) throws {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: account,
]
let status = SecItemDelete(query as CFDictionary)
guard status == errSecSuccess || status == errSecItemNotFound else {
throw KeychainError.status(status)
}
}
}
enum KeychainKeys {
static let token = "auth-token"
static let email = "auth-email"
}
Step 2 — Validators
// Validation.swift
import Foundation
enum FieldValidation {
case valid
case invalid(String)
var isValid: Bool { if case .valid = self { return true } else { return false } }
var message: String? { if case .invalid(let m) = self { return m } else { return nil } }
}
enum Validators {
static func email(_ raw: String) -> FieldValidation {
if raw.isEmpty { return .invalid("Email is required.") }
// Simple, good-enough regex; for production use NSDataDetector + RFC 5322
let pattern = #"^[A-Z0-9a-z._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}$"#
if raw.range(of: pattern, options: .regularExpression) == nil {
return .invalid("Enter a valid email address.")
}
return .valid
}
static func password(_ raw: String) -> FieldValidation {
if raw.count < 8 { return .invalid("Password must be at least 8 characters.") }
if raw.rangeOfCharacter(from: .decimalDigits) == nil { return .invalid("Include at least one number.") }
return .valid
}
}
Step 3 — Simulated auth service
// AuthService.swift
import Foundation
enum AuthError: Error, LocalizedError {
case invalidCredentials
case network
var errorDescription: String? {
switch self {
case .invalidCredentials: return "Wrong email or password."
case .network: return "Couldn't reach the server. Try again."
}
}
}
final class AuthService {
func signIn(email: String, password: String) async throws -> String {
try await Task.sleep(nanoseconds: 800_000_000) // simulate latency
if email == "demo@10x.dev" && password == "password1" {
return UUID().uuidString
}
throw AuthError.invalidCredentials
}
}
Step 4 — LoginVC
// LoginVC.swift
import UIKit
final class LoginVC: UIViewController {
private let scrollView = UIScrollView()
private let contentStack = UIStackView()
private let titleLabel = UILabel()
private let emailField = UITextField()
private let emailError = UILabel()
private let passwordField = UITextField()
private let passwordError = UILabel()
private let signInButton = UIButton(configuration: .filled())
private let bannerLabel = UILabel()
private let spinner = UIActivityIndicatorView(style: .medium)
private let auth = AuthService()
private var signInTask: Task<Void, Never>?
var onSignedIn: ((_ email: String, _ token: String) -> Void)?
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .systemBackground
setupViews()
setupConstraints()
wireActions()
updateValidation()
}
private func setupViews() {
scrollView.translatesAutoresizingMaskIntoConstraints = false
scrollView.alwaysBounceVertical = true
view.addSubview(scrollView)
scrollView.addSubview(contentStack)
contentStack.axis = .vertical
contentStack.spacing = 12
contentStack.translatesAutoresizingMaskIntoConstraints = false
titleLabel.text = "Sign in"
titleLabel.font = .preferredFont(forTextStyle: .largeTitle).bold()
titleLabel.adjustsFontForContentSizeCategory = true
emailField.placeholder = "Email"
emailField.borderStyle = .roundedRect
emailField.keyboardType = .emailAddress
emailField.textContentType = .emailAddress
emailField.autocapitalizationType = .none
emailField.autocorrectionType = .no
emailField.returnKeyType = .next
emailError.font = .preferredFont(forTextStyle: .caption1)
emailError.textColor = .systemRed
emailError.numberOfLines = 0
passwordField.placeholder = "Password"
passwordField.borderStyle = .roundedRect
passwordField.isSecureTextEntry = true
passwordField.textContentType = .password
passwordField.returnKeyType = .go
passwordError.font = .preferredFont(forTextStyle: .caption1)
passwordError.textColor = .systemRed
passwordError.numberOfLines = 0
var buttonConfig = UIButton.Configuration.filled()
buttonConfig.title = "Sign in"
signInButton.configuration = buttonConfig
bannerLabel.font = .preferredFont(forTextStyle: .footnote)
bannerLabel.textColor = .systemRed
bannerLabel.numberOfLines = 0
bannerLabel.isHidden = true
spinner.hidesWhenStopped = true
let hint = UILabel()
hint.text = "Use demo@10x.dev / password1"
hint.font = .preferredFont(forTextStyle: .footnote)
hint.textColor = .secondaryLabel
[titleLabel, emailField, emailError, passwordField, passwordError, signInButton, bannerLabel, spinner, hint].forEach {
contentStack.addArrangedSubview($0)
}
}
private func setupConstraints() {
let frame = scrollView.frameLayoutGuide
let content = scrollView.contentLayoutGuide
NSLayoutConstraint.activate([
scrollView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor),
scrollView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
scrollView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
scrollView.bottomAnchor.constraint(equalTo: view.keyboardLayoutGuide.topAnchor),
contentStack.topAnchor.constraint(equalTo: content.topAnchor, constant: 24),
contentStack.bottomAnchor.constraint(equalTo: content.bottomAnchor, constant: -24),
contentStack.leadingAnchor.constraint(equalTo: content.leadingAnchor, constant: 20),
contentStack.trailingAnchor.constraint(equalTo: content.trailingAnchor, constant: -20),
contentStack.widthAnchor.constraint(equalTo: frame.widthAnchor, constant: -40),
])
}
private func wireActions() {
emailField.addTarget(self, action: #selector(textChanged), for: .editingChanged)
passwordField.addTarget(self, action: #selector(textChanged), for: .editingChanged)
emailField.addTarget(self, action: #selector(emailReturn), for: .editingDidEndOnExit)
passwordField.addTarget(self, action: #selector(submit), for: .editingDidEndOnExit)
signInButton.addAction(UIAction { [weak self] _ in self?.submit() }, for: .touchUpInside)
}
@objc private func textChanged() { updateValidation() }
@objc private func emailReturn() { passwordField.becomeFirstResponder() }
private func updateValidation() {
let emailResult = Validators.email(emailField.text ?? "")
let passwordResult = Validators.password(passwordField.text ?? "")
emailError.text = emailField.hasText ? emailResult.message : nil
emailError.isHidden = (emailError.text ?? "").isEmpty
passwordError.text = passwordField.hasText ? passwordResult.message : nil
passwordError.isHidden = (passwordError.text ?? "").isEmpty
signInButton.isEnabled = emailResult.isValid && passwordResult.isValid
bannerLabel.isHidden = true
}
@objc private func submit() {
view.endEditing(true)
guard signInButton.isEnabled else { return }
let email = emailField.text ?? ""
let password = passwordField.text ?? ""
bannerLabel.isHidden = true
spinner.startAnimating()
signInButton.isEnabled = false
signInTask?.cancel()
signInTask = Task { [weak self] in
guard let self else { return }
do {
let token = try await auth.signIn(email: email, password: password)
try Task.checkCancellation()
try Keychain.set(token, for: KeychainKeys.token)
try Keychain.set(email, for: KeychainKeys.email)
await MainActor.run {
self.spinner.stopAnimating()
self.onSignedIn?(email, token)
}
} catch is CancellationError {
return
} catch {
await MainActor.run {
self.spinner.stopAnimating()
self.signInButton.isEnabled = true
self.bannerLabel.text = error.localizedDescription
self.bannerLabel.isHidden = false
}
}
}
}
}
Step 5 — HomeVC
// HomeVC.swift
import UIKit
final class HomeVC: UIViewController {
private let email: String
var onSignedOut: (() -> Void)?
init(email: String) {
self.email = email
super.init(nibName: nil, bundle: nil)
}
required init?(coder: NSCoder) { fatalError() }
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .systemBackground
title = "Home"
let greeting = UILabel()
greeting.text = "Welcome, \(email)"
greeting.font = .preferredFont(forTextStyle: .title2)
greeting.numberOfLines = 0
let signOut = UIButton(configuration: .borderedProminent(), primaryAction: UIAction(title: "Sign out") { [weak self] _ in
try? Keychain.delete(KeychainKeys.token)
try? Keychain.delete(KeychainKeys.email)
self?.onSignedOut?()
})
let stack = UIStackView(arrangedSubviews: [greeting, signOut])
stack.axis = .vertical
stack.spacing = 20
stack.alignment = .leading
stack.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(stack)
NSLayoutConstraint.activate([
stack.leadingAnchor.constraint(equalTo: view.layoutMarginsGuide.leadingAnchor),
stack.trailingAnchor.constraint(equalTo: view.layoutMarginsGuide.trailingAnchor),
stack.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 20),
])
}
}
Step 6 — Wire it up in SceneDelegate
// SceneDelegate.swift
import UIKit
final 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 = makeRoot()
window.makeKeyAndVisible()
self.window = window
}
private func makeRoot() -> UIViewController {
if let token = try? Keychain.get(KeychainKeys.token), token != nil,
let email = try? Keychain.get(KeychainKeys.email), let email {
return UINavigationController(rootViewController: makeHome(email: email))
}
return UINavigationController(rootViewController: makeLogin())
}
private func makeLogin() -> LoginVC {
let login = LoginVC()
login.onSignedIn = { [weak self] email, _ in
self?.window?.rootViewController = UINavigationController(rootViewController: self!.makeHome(email: email))
}
return login
}
private func makeHome(email: String) -> HomeVC {
let home = HomeVC(email: email)
home.onSignedOut = { [weak self] in
self?.window?.rootViewController = UINavigationController(rootViewController: self!.makeLogin())
}
return home
}
}
Step 7 — Run
- Launch — Login screen.
- Type “junk” in email — inline error appears.
- Type “demo@10x.dev” and “password1” — button enables.
- Tap “Sign in” — spinner, then push to Home.
- Force-quit the app, relaunch — opens directly to Home (token in Keychain).
- Sign out — back to Login. Relaunch — Login again.
- Tap password field — keyboard appears, the scroll view’s bottom is pinned to
keyboardLayoutGuide.topAnchorso the field stays visible.
Stretch goals
- Biometric unlock: after first successful sign-in, store the token with
kSecAttrAccessControlrequiring.biometryCurrentSet. On launch, attempt to read; the system will prompt Face ID / Touch ID. - Password strength meter that updates a
UIProgressViewas the user types. - Show/hide password button — a
UIButtonset aspasswordField.rightViewtogglingisSecureTextEntry. - Form submit on Cmd+Return (iPad keyboard) via
UIKeyCommandon the VC. - Combine version — bind both fields’ text into a
CombineLatestpipeline that drivessignInButton.isEnabled(per chapter 4.10). - Snapshot test the validation states (loading, error, valid) using a third-party snapshot testing library.
Notes & troubleshooting
- Keychain returns
errSecMissingEntitlementon simulator: in Xcode 11+ this requires the Keychain Sharing capability or running with a development team. The simplest fix: assign a real team in Signing & Capabilities, even for simulator runs. UIScrollViewdoesn’t scroll up when keyboard appears: ensure the scroll view’sbottomAnchoris constrained toview.keyboardLayoutGuide.topAnchor, not the safe area or the view’s bottom.- Token shows up in iCloud Keychain on another device: that’s
kSecAttrSynchronizable, which we did NOT set. The accessibility flagkSecAttrAccessibleAfterFirstUnlockThisDeviceOnlyensures the value never syncs. - Form fields recreate every keystroke: don’t rebuild the view hierarchy on
textChanged— only update labels andisEnabled. - Storing the password itself: don’t. Only store the token returned by the server. The password should leave the device only over HTTPS and never be persisted client-side.
Phase 4 labs complete.
5.1 — Philosophy & UIKit comparison
Opening scenario
You’re starting a greenfield app at a 40-person startup in 2026. Your team lead asks: “SwiftUI or UIKit?” Three answers will get you laughed out of the room:
- “SwiftUI, it’s the future” — you’re picking the future, not what ships in 6 months.
- “UIKit, SwiftUI isn’t ready” — that argument expired around iOS 16.
- “It depends” without naming the dependencies — what does it depend on?
The right answer in 2026: SwiftUI by default, drop into UIKit for specific, named gaps — heavy custom collection views, mature third-party SDKs that ship UIView subclasses, or features that hit known SwiftUI limitations (custom keyboard handling, complex text editors, AVKit corner cases). Most production apps are mixed: SwiftUI hosting UIKit, UIKit hosting SwiftUI, sometimes in the same screen.
This chapter sets the mental model. The next 12 chapters teach you how SwiftUI actually works under the hood, so you can pick the right tool without superstition.
| Question | UIKit | SwiftUI |
|---|---|---|
| Minimum deployment target | iOS 2.0 | iOS 13 (practical: iOS 16+ in 2026) |
| Programming paradigm | Imperative, object-oriented | Declarative, value-typed |
| Layout primitive | NSLayoutConstraint, Auto Layout | Modifiers, Layout protocol |
| State to view | You wire it manually | Framework re-renders on change |
| Multiplatform | iOS only (Catalyst for macOS) | iOS, macOS, watchOS, tvOS, visionOS |
| Custom drawing | UIView.draw(_:), Core Animation | Canvas, Shape, Path |
| Animation | Block-based, CABasicAnimation | withAnimation { }, implicit |
| Maturity in 2026 | 18 years, battle-tested | 7 years, production-ready for most apps |
Concept → Why → How → Code
Imperative vs declarative — the actual difference
Imperative (UIKit): You give the framework a sequence of instructions that do things — create a view, set its frame, add it as a subview, update its text when state changes. The framework executes; you are the choreographer.
// UIKit — imperative
class CounterVC: UIViewController {
var count = 0
let label = UILabel()
let button = UIButton(type: .system)
override func viewDidLoad() {
super.viewDidLoad()
label.text = "Count: \(count)"
button.setTitle("Increment", for: .normal)
button.addAction(UIAction { [weak self] _ in
guard let self else { return }
self.count += 1
self.label.text = "Count: \(self.count)" // YOU update the view
}, for: .touchUpInside)
// ...add to hierarchy, constraints...
}
}
You wrote label.text = "Count: \(self.count)" twice — once at setup, once in the action. Forget the second one, and the label stays at 0 while count ticks up. The bug class “UI out of sync with state” is the canonical UIKit defect.
Declarative (SwiftUI): You describe what the UI looks like as a function of state. The framework figures out what to render and what to re-render when state changes.
// SwiftUI — declarative
struct CounterView: View {
@State private var count = 0
var body: some View {
VStack {
Text("Count: \(count)")
Button("Increment") { count += 1 }
}
}
}
Text("Count: \(count)") is recomputed every time count changes. The framework owns the “when does the label get updated” question. You can’t write the bug above — there’s no place to put it.
The rendering model in one paragraph
When a @State/@Observable value mutates, SwiftUI marks the views that read it as invalid. On the next render pass, it calls body on each invalid view, which produces a new value-typed view graph. SwiftUI diffs the new graph against the previous one and only updates the underlying platform views (UIView/NSView) that actually changed. Your body returns a description — SwiftUI owns the rendering. Re-reading body is cheap; that’s why it’s safe to call thousands of times per second.
This is the same model as React, Flutter, Jetpack Compose. Apple just adapted it to Swift’s value types and Swift’s strong type system.
Where SwiftUI is great
- Forms, settings, lists, navigation — declarative shines when the UI is a function of data
- Animations —
withAnimation { state.x = 100 }is one line; the UIKit equivalent is 5-15 lines - Multiplatform — one codebase across iOS/iPadOS/macOS/watchOS/visionOS
- Previews —
#Preview { }renders without launching the simulator (iterative speed gain measurable in hours/week) - Testability of view logic — your “ViewModel” is plain Swift, no
UIViewControllermocking - Accessibility defaults — VoiceOver, Dynamic Type, dark mode work out of the box
Where UIKit is still necessary
- Complex
UICollectionViewlayouts that needprefetchDataSource,UICollectionViewDelegateFlowLayoutwith hand-tuned cell heights, or interactive transitions - Custom text input —
UITextField’s/UITextView’s delegate methods give finer control than SwiftUI’sTextField. Apple’s own Notes app usesUITextView. - Third-party SDKs that ship UIKit views — Mapbox, Stripe checkout, video player SDKs
- Mature performance-critical screens — feeds with thousands of cells, video walls (Instagram Reels)
- Specific gaps that vary by year — keyboard layout in chat apps, pull-to-dismiss sheets with non-trivial behavior,
UIPageViewControllerparity - Custom drag-and-drop with complex previews — possible in SwiftUI, but UIKit’s API surface is mature
The 2026 production reality
Most apps you’ll work on are mixed:
- New screens in SwiftUI, legacy screens stay UIKit
- SwiftUI screen with one stubborn subview wrapped via
UIViewRepresentable - UIKit
UIViewControllerhosting a SwiftUI subview viaUIHostingController - Modular architecture where each feature module picks its own framework
Examples:
- Apple Notes (iOS 17+): SwiftUI shell, UIKit
UITextViewfor the editor - Instagram: still mostly UIKit; SwiftUI for newer settings and onboarding flows
- Airbnb: UIKit + their custom Epoxy framework; experimenting with SwiftUI for non-critical flows
- Apple’s own Settings, Wallet, Reminders, Stocks: SwiftUI
When to pick what (decision tree)
Greenfield app, target iOS 16+:
- Default to SwiftUI. Drop into UIKit for the screens where you hit a concrete blocker.
Existing UIKit app:
- New screens: SwiftUI hosted via
UIHostingController. Reusable UIKit components stay UIKit until rewrite makes business sense.
Multiplatform (iOS + macOS):
- SwiftUI. Mac Catalyst is a maintenance pit; AppKit alone won’t share code.
Targeting iOS 15 or below:
- SwiftUI is doable, but many modern APIs (
NavigationStack,@Observable,Layout) require iOS 16+/iOS 17+. Evaluate per-feature.
watchOS or visionOS:
- SwiftUI. Both platforms are SwiftUI-first.
Swift 6 + SwiftUI in 2026
SwiftUI in 2026 ships with:
@Observablemacro (iOS 17+) — replacesObservableObject/@Publishedfor new code- Strict concurrency enabled —
View.bodyis@MainActor-isolated NavigationStackmature,NavigationViewdeprecatedSwiftDatafor persistence (Phase 6)#Previewmacro replacesPreviewProvider- Custom
Layoutprotocol for bespoke layouts MeshGradient,PhaseAnimator,KeyframeAnimator, scroll position APIs
If a tutorial uses NavigationView, ObservableObject, @Published, or PreviewProvider, it’s pre-2024 and there’s a more modern API. Read it for concepts, not boilerplate.
One concrete migration example
UIKit settings screen with one toggle, ~80 lines:
class SettingsVC: UIViewController, UITableViewDataSource, UITableViewDelegate {
var notificationsEnabled = UserDefaults.standard.bool(forKey: "notifications")
let tableView = UITableView(frame: .zero, style: .insetGrouped)
// viewDidLoad, register cell, dataSource, delegate, indexPath dispatching...
// cellForRowAt: build cell, add UISwitch as accessoryView, addTarget for valueChanged
// @objc func toggleChanged: write UserDefaults, no view update needed (switch owns state)
}
SwiftUI equivalent:
struct SettingsView: View {
@AppStorage("notifications") var notificationsEnabled = false
var body: some View {
Form {
Toggle("Enable notifications", isOn: $notificationsEnabled)
}
}
}
5 lines. Same behavior. @AppStorage handles UserDefaults read/write and view updates. This delta multiplied across every settings/form/list screen in an app is why teams are migrating.
In the wild
- Apple’s own: SwiftUI is now the default for new Apple apps. Translate, Journal, Freeform are SwiftUI-heavy. Even the iOS Control Center editor in iOS 18 is SwiftUI.
- Robinhood: started a partial SwiftUI migration in 2022; account/settings screens shipped first.
- Lyft: uses SwiftUI for internal employee tooling apps and new onboarding flows; main rider/driver experience stays UIKit.
- Stripe SDK: ships both UIKit and SwiftUI APIs because their customers use both.
- Apollo (RIP): was SwiftUI-first in 2022 — one of the early “is SwiftUI production-ready?” proof points.
Common misconceptions
- “SwiftUI is for prototypes, not production.” That argument was valid in 2019–2021. In 2026, Apple ships their own multi-million-user apps in SwiftUI. The framework has bugs (every framework does), but they’re tractable.
- “SwiftUI is slower than UIKit.” Per-view, no. Some rendering paths are faster (less Auto Layout passes). Misuses (
AnyVieweverywhere, computing huge collections inbody) cause perceived slowness — that’s a misuse pattern, fixable. - “You have to rewrite the whole app to use SwiftUI.” Wrong.
UIHostingControllerandUIViewRepresentablelet you mix at any granularity — screen, view, even modifier. - “SwiftUI doesn’t give you fine-grained control.” It gives you less control over the render loop, by design. For everything else (custom layout, drawing, animations), there’s an escape hatch (
Layoutprotocol,Canvas,UIViewRepresentable). - “
@StateObjectand@ObservedObjectare the same thing.” They are not.@StateObjectowns the instance (created once, survives view re-creation).@ObservedObjectobserves an instance owned elsewhere. Mix them up and you get state that disappears on every parent re-render — a real bug class.
Seasoned engineer’s take
I default to SwiftUI for any new screen in 2026. The leverage is real: the same code that takes me 80 UIKit lines takes 20 SwiftUI lines, and the SwiftUI version is testable without UIWindow instantiation. Where I push back on SwiftUI:
- Lists with 10K+ items and complex cells —
UICollectionViewwith compositional layout still wins on memory and scroll performance for the heaviest cases. - Rich text editors — SwiftUI’s
TextEditorimproved a lot butUITextView+NSAttributedStringis still the path for custom typography. - When my team has zero SwiftUI experience and we ship in 4 weeks — learning curve cost matters; use what people know.
When I review a SwiftUI codebase, the bug classes I look for are: stale closures capturing initial state, AnyView erasure killing diffing, @StateObject initialized from a parent prop (anti-pattern), and body doing work (network calls, mutating state outside onAppear/task).
TIP: When you’re learning SwiftUI, write the same screen twice — once in UIKit, once in SwiftUI. Side-by-side gives you intuition for what each framework optimizes for. After ~5 of these, you stop debating and start picking.
WARNING: “We’ll migrate to SwiftUI over the next year” is a project-killer if there’s no concrete per-screen plan. Migration without a deadline is renaming things. Pick the screens, pick the order, pick the kill-switch, ship.
Interview corner
Junior-level: “What’s the difference between declarative and imperative UI?”
Imperative: you tell the framework how to update the UI step by step (label.text = "new value"). Declarative: you describe what the UI is as a function of state (Text(value)), and the framework figures out when to re-render. SwiftUI is declarative; UIKit is imperative.
Mid-level: “You’re starting a new iOS app targeting iOS 17+, 4-person team, 6-month timeline. SwiftUI or UIKit, and why?”
Default to SwiftUI. Two of four engineers can ramp on it fast; iteration speed (previews, less boilerplate) gives back days. Identify likely escape-hatch screens upfront: any chat UI, media-heavy feeds, anything with mature third-party UIKit SDKs (Stripe SDK, video player, advanced map). Set a convention: those screens use UIViewControllerRepresentable. Acknowledge SwiftUI’s costs: smaller talent pool with deep experience, occasional framework bugs (have workarounds in mind). Six months at four engineers is enough to ship a production SwiftUI app.
Senior-level: “Walk me through how SwiftUI’s diffing actually works. What’s the cost model? When does it fall down?”
SwiftUI builds an immutable value-typed view graph from body. Each view has an identity derived from its position in the graph plus any explicit id modifier. On state change, SwiftUI re-invokes body on the affected branches, produces a new graph, walks it in parallel with the old graph, and computes a minimal diff. Identical view structs (same type at the same position) get their underlying UIView/NSView reused with new properties applied; structurally different views are torn down and rebuilt.
Cost model: body invocation should be O(constant) per view. Diffing is O(graph size). Where it falls down: erasing types to AnyView (defeats the static type-based fast path; SwiftUI falls back to dynamic diffing), reading state in deep ancestors (invalidates large subtrees), building giant view bodies inline (compiler timeout), creating objects in body (allocating per invalidation, plus closures capturing state on every render). Fixes: prefer typed some View, push state ownership down to leaves, extract subviews, use EquatableView for expensive subtrees with custom equality.
Red flag in candidates: Claiming you’ve “shipped SwiftUI in production” but can’t articulate the difference between @StateObject and @ObservedObject, or doesn’t know what body is called on. Indicates copy-paste fluency without mental model.
Lab preview
Phase 5 labs build up from a SwiftData todo app (Lab 5.1) through animated dashboards (Lab 5.2), a true multiplatform app (Lab 5.3), and end with a reusable component library shipped as a Swift Package (Lab 5.4). By the end you’ll have made the SwiftUI-vs-UIKit decision dozens of times under realistic constraints.
Next: Views, modifiers & the rendering model
5.2 — Views, modifiers & the rendering model
Opening scenario
A junior on your team opens a PR. Their body is 400 lines, the screen flickers when the user scrolls, and Instruments shows body being called ~60 times per second on a list row. They ask: “Is SwiftUI just slow?”
No. SwiftUI is fast — the problem is that the developer doesn’t have a mental model of what body is, when it runs, how view identity is computed, or what AnyView does to the diffing algorithm. This chapter is that mental model. Without it, you write SwiftUI that compiles, runs, and silently destroys your scroll performance.
| Concept | What it is |
|---|---|
View | A value-typed description of UI, not a UI element |
body | A computed property called by SwiftUI whenever inputs change |
| View identity | How SwiftUI decides “same view, update” vs “new view, replace” |
| Modifier | A function returning a new view that wraps the receiver |
some View | Opaque type — single concrete type known at compile time |
AnyView | Type-erased wrapper — defeats compile-time view diffing |
Concept → Why → How → Code
A view is not what you think
In UIKit, UILabel is a thing — a CALayer-backed object that occupies pixels. In SwiftUI, Text("hi") is a value that describes a label. The actual rendering object lives behind the scenes, owned by SwiftUI.
let view: Text = Text("hi")
print(MemoryLayout<Text>.size) // small, stack-allocated
print(type(of: view.body)) // Text — body returns itself for leaf views
Views are structs. Cheap to create. Cheap to throw away. SwiftUI throws away and recreates view structs constantly — that’s not the work; the work is the diff against the previous structure.
body is a pure function (treat it that way)
struct Greeting: View {
let name: String
var body: some View {
Text("Hello, \(name)")
}
}
body should be a pure function of the view’s stored properties + observed state. SwiftUI calls it any time it suspects something changed; calling it must be cheap and side-effect-free.
What does “cheap” mean in practice? On the order of microseconds for typical views. If your body does network calls, mutates state outside of onAppear/task, accesses singletons that mutate, or allocates large objects, you’ll see:
- Stale state showing in the UI
- Crashes from publishing changes during view updates (“Publishing changes from within view updates is not allowed”)
- Scroll jank
- Recursive
bodyinvocations
// ❌ side effect in body
var body: some View {
Task { await viewModel.refresh() } // runs on every render
return Text(viewModel.title)
}
// ✅ side effect in lifecycle modifier
var body: some View {
Text(viewModel.title)
.task { await viewModel.refresh() } // runs once on appear
}
View identity — the most important concept in SwiftUI
SwiftUI tracks views by identity. When state changes, SwiftUI walks the new view tree and the old tree in parallel:
- Same identity at the same position → it’s the same view; reuse the underlying rendering object, update properties
- Different identity → tear down old, build new (loses state, runs
onAppear)
Two kinds of identity:
- Structural identity — derived from the view’s type and position in the view graph.
if condition { TextA() } else { TextB() }— these are different identities. Evenif condition { Text("a") } else { Text("a") }are different. - Explicit identity — via the
.id(_:)modifier. Forces a new identity when the value changes.
struct Demo: View {
@State private var flag = false
@State private var resetCount = 0
var body: some View {
VStack {
// Structural: same Text type at same position regardless of flag
Text(flag ? "Off" : "On")
// Different structural identity per branch:
if flag {
Text("A") // identity #1
} else {
Text("B") // identity #2 — different from #1
}
// Explicit identity changes whenever resetCount changes:
CounterView()
.id(resetCount)
}
}
}
Why this matters: any state (@State, @StateObject, scroll position, focus, animation in flight) is tied to identity. Change identity → state resets. Forget this and you’ll get bugs like “the form clears itself every time the parent re-renders”.
View modifiers — chaining and wrapping
Text("hi")
.font(.title)
.foregroundStyle(.blue)
.padding()
.background(.yellow)
Each modifier returns a new view that wraps the receiver. The chain is read left-to-right, applied outside-in. The order matters — padding().background() puts the background outside the padding; background().padding() puts padding around the background.
A modifier is just a method that returns some View:
extension View {
func bordered() -> some View {
self
.padding(8)
.background(RoundedRectangle(cornerRadius: 8).strokeBorder(.gray))
}
}
Custom modifiers via ViewModifier:
struct CardStyle: ViewModifier {
func body(content: Content) -> some View {
content
.padding()
.background(.thinMaterial, in: RoundedRectangle(cornerRadius: 12))
.shadow(radius: 4, y: 2)
}
}
extension View {
func cardStyle() -> some View { modifier(CardStyle()) }
}
Text("Hello").cardStyle()
Use a ViewModifier when the wrapping logic is non-trivial (multiple modifiers, state, environment access). Use an extension method for simple chains.
some View vs AnyView
// some View — opaque return type; one concrete type per function
var body: some View {
Text("hi") // type: Text
}
// some View with branches works if both branches share a common type via ViewBuilder
var body: some View {
if condition {
Text("a")
} else {
Text("b") // _ConditionalContent<Text, Text> via @ViewBuilder
}
}
// AnyView — type erasure; cost is real
var body: some View {
if condition {
AnyView(Text("a"))
} else {
AnyView(Image(systemName: "star"))
}
}
some View keeps the static type. SwiftUI can compute structural identity and diff at compile time — fast path.
AnyView boxes the view into a heap allocation with a type tag. SwiftUI loses static visibility, falls back to dynamic diffing, often invalidates subtrees unnecessarily. Avoid AnyView unless you genuinely need heterogeneous arrays (and even then, prefer enums + @ViewBuilder or ForEach with a sum type).
The right fix for heterogeneous content:
enum Card { case text(String), image(Image) }
struct CardView: View {
let card: Card
var body: some View {
switch card {
case .text(let s): Text(s)
case .image(let img): img
}
}
}
ForEach(cards) { CardView(card: $0) } // no AnyView needed
@ViewBuilder — the magic behind the trailing closure
VStack { Text("a"); Text("b") } looks like a closure with two statements. It is — annotated with @ViewBuilder. The result builder collects each statement into a tuple view (TupleView), supports if/else/switch, and produces a single some View.
You can use it on your own functions:
@ViewBuilder
func header(showsSubtitle: Bool) -> some View {
Text("Title").font(.title)
if showsSubtitle {
Text("Subtitle").font(.subheadline)
}
}
Limit: max ~10 statements per builder before the compiler complains (Group { } to break up). Each statement becomes a Tuple slot.
EquatableView — manual diffing for performance
By default SwiftUI re-invokes body whenever any input it reads might have changed. For expensive views, you can opt in to custom equality:
struct PriceChart: View, Equatable {
let ticks: [PricePoint]
static func == (lhs: Self, rhs: Self) -> Bool {
lhs.ticks.count == rhs.ticks.count &&
lhs.ticks.last?.value == rhs.ticks.last?.value
}
var body: some View {
Canvas { /* expensive drawing */ }
}
}
// Usage
PriceChart(ticks: ticks).equatable()
SwiftUI calls your ==; if true, it skips re-rendering. Use sparingly — bad equality functions cause stale UI.
The render pipeline in one diagram
┌─────────────────┐ mutation ┌──────────────────┐
│ @State / │ ───────────────▶ │ invalidate │
│ @Observable │ │ reading views │
└─────────────────┘ └──────────┬───────┘
│
▼
┌──────────────────┐
│ call `body` │
│ on invalidated │
│ views │
└──────────┬───────┘
│
▼
┌──────────────────┐
│ diff new graph │
│ vs old graph │
│ by identity │
└──────────┬───────┘
│
▼
┌──────────────────┐
│ apply minimal │
│ changes to │
│ underlying │
│ UIView/NSView │
└──────────────────┘
That cycle happens at up to display refresh rate (60/120 Hz). Your job: keep body cheap, stable identity, no side effects.
In the wild
- Apple’s Music app SwiftUI rewrite famously had performance issues at launch — root cause was excessive view invalidation and
AnyViewuse deep in lists. Subsequent updates pushed state ownership down to leaf views. - Robinhood charts use
Canvas(Phase 7) wrapped inEquatableViewfor tick streams at 30+ Hz updates. - Apple’s Stocks app uses
Equatableon chart subviews; you can see in profiler that they avoid re-rendering the chart axes when only the price line changes. - Airbnb’s experiments with SwiftUI flagged
AnyViewand large@ViewBuilderblocks as the top two perf issues in their internal report.
Common misconceptions
- “
bodyis the view.” No.bodyreturns a description of the view. SwiftUI owns the rendering objects. - “Calling
body60 times per second is bad.” It’s bad only ifbodyis expensive. SwiftUI is designed assumingbodyis microseconds-cheap. - “Modifier order doesn’t matter, it’s all the same view.” Order matters significantly.
.frame(width: 100).background(.red)paints a 100-wide red area;.background(.red).frame(width: 100)paints the background at intrinsic size, then constrains the layout. Different visual result. - “
some Viewis just a fancyAny.” No.some Viewis a single, concrete, compile-time-known type. The compiler infers the exact type (e.g.,ModifiedContent<Text, _PaddingLayout>). It’s the opposite ofAny. - “I should give every view an
.id().” No — that forces identity changes and resets state. Only use.id()when you explicitly want a state reset (e.g., changing the user shown in a profile screen).
Seasoned engineer’s take
The SwiftUI performance bugs I’ve shipped (or caught in review) almost all fall into:
AnyViewin a loop — kills the type-based fast path- State read at the wrong scope — putting
@StateObjectin the root view that owns the whole feature, so any leaf mutation re-runs the rootbody - Heavy work in
body— date formatters, image decoding, network calls - Unstable identity —
ForEach(items, id: \.self)on items that aren’tHashable-stable, causing constant tear-down/rebuild @StateObjectinitialized with parent values — the@autoclosureruns only once, so the view holds stale data
When I review SwiftUI PRs I scan for those five patterns first, before reading any logic.
TIP: Drop
let _ = Self._printChanges()in anybodyto log what triggered the re-render. The output names the property that changed. Removing this is one of the highest-leverage performance debug techniques you have.
WARNING: Don’t allocate inside
body.DateFormatter(),JSONDecoder(),NumberFormatter()— instantiate once (static let,@State, or pass in) and reuse. Re-creating these per render is a measurable cost in lists.
Interview corner
Junior-level: “What’s the difference between padding().background() and background().padding()?”
The first applies padding to the view, then puts a background behind the padded view (background extends through the padded area). The second puts a background behind the view at its intrinsic size, then adds padding around the backgrounded view (background does not extend through the padding). Visual result: in the first, the background fills the padded area; in the second, the padding is outside the background.
Mid-level: “What is view identity in SwiftUI and why does it matter? Give an example bug caused by misuse.”
Identity is how SwiftUI decides “same view, update its properties” vs “new view, tear down and rebuild.” Identity comes from structural position + explicit .id(_:). State (@State, @StateObject, scroll position, focus, animation) is tied to identity — change identity, lose state.
Bug example: a profile screen with ProfileView(user: user).id(user.id) resets every time the user changes — correct. But if you accidentally do .id(UUID()) thinking it forces an update, you generate a new identity every render, so state never persists and onAppear runs forever.
Senior-level: “You have a 100-row LazyVStack with a complex chart in each row. Scrolling is janky. Walk through your debug + fix process.”
First, profile with Instruments (Time Profiler + SwiftUI template). Confirm body is the hot path and identify the row view.
Diagnose:
- Check for
AnyView— replace with concrete type or@ViewBuilder+Group. - Look for objects allocated in
body(formatters, decoders) — hoist tostatic letor@State. - Check for state read at the wrong scope — is the entire list re-rendering on each scroll position update?
- Add
Self._printChanges()to the row body — what property is changing per scroll? - If the chart legitimately needs to re-render only when its data changes, wrap it in
Equatableconformance and.equatable().
Fixes (in priority order):
- Make the chart a
View, Equatablewith==comparing only the data points. - Cache decoded data in the view model (pass plain values down, not full models).
- Use
.drawingGroup()on the chart to offload to Metal (acceptable trade — caches as bitmap). - If still bad, drop the chart into
UIViewRepresentableand use a UIKit chart implementation.
Red flag in candidates: Reaching for AnyView as the default solution to “I have heterogeneous children” without considering enum + @ViewBuilder.
Lab preview
Lab 5.4 is where you’ll write your first reusable ViewModifiers and ButtonStyles — that lab exercises both modifier composition and some View discipline.
Next: State management
5.3 — State management
Opening scenario
A SwiftUI app has a checkout flow: cart screen → shipping → payment → confirmation. State is scattered:
- The cart total is computed in the cart screen and recomputed in the confirmation screen
- The shipping address is stored in
@Statein the shipping screen and disappears when the user navigates away - The payment screen reads the cart from
UserDefaultsfor some reason - The total amount is wrong on the confirmation screen because the discount applied at payment never propagates back
This is a state ownership bug. SwiftUI gives you five tools — @State, @Binding, @StateObject, @ObservedObject, @EnvironmentObject — for five different ownership situations. Misusing them turns straightforward features into “why is this empty/stale/duplicated?” bugs.
This chapter teaches the taxonomy. Next chapter (@Observable) modernizes the syntax for Swift 6.
| Property wrapper | Owned by | Use when |
|---|---|---|
@State | The view itself | Private, view-local state (toggles, text input, scroll position) |
@Binding | A parent view | A child needs read/write access to parent’s state |
@StateObject | The view itself (single instance) | The view creates and owns a reference-type observable |
@ObservedObject | An ancestor or external | The view receives a reference-type observable |
@EnvironmentObject | Anywhere in the hierarchy | Many views need access without prop-drilling |
Concept → Why → How → Code
@State — view-private value-typed state
struct ToggleDemo: View {
@State private var isOn = false
var body: some View {
Toggle("Notifications", isOn: $isOn)
}
}
- SwiftUI stores
isOnoutside the struct (because the struct is recreated constantly) - The view reads it; mutations trigger re-render
$isOnproduces aBinding<Bool>— a two-way handle to the storage- Always
private—@Stateis for this view only. If a parent needs it, lift it up. - Works for
Int,String,Bool, structs, arrays, enums — any value type
When SwiftUI initializes the view, it sees @State for the first time and allocates persistent storage. On subsequent recreations of the view struct (which happen constantly), SwiftUI reuses the same storage.
@Binding — pass-through to someone else’s state
struct ParentView: View {
@State private var text = ""
var body: some View {
VStack {
ChildField(text: $text)
Text("You typed: \(text)")
}
}
}
struct ChildField: View {
@Binding var text: String
var body: some View {
TextField("Name", text: $text)
}
}
ChildFielddoesn’t own the state; it has a two-way binding into the parent’s@State$text(parent) unwraps@StatetoBinding<String>; passes to@Binding(child)- Inside the child,
$textextracts the sameBinding<String>for further pass-through - Mutating
textin the child writes through to the parent’s storage; parent re-renders too
Use @Binding when a child needs to mutate parent-owned state. It’s the SwiftUI equivalent of inout for views.
@StateObject — view owns a reference-type observable
When state outgrows a single property and needs methods, computed properties, or coordination with services, you reach for a class:
@MainActor
final class SearchModel: ObservableObject {
@Published var query = ""
@Published private(set) var results: [Item] = []
func search() async { /* ... */ }
}
struct SearchView: View {
@StateObject private var model = SearchModel()
var body: some View {
VStack {
TextField("Search", text: $model.query)
List(model.results) { item in Text(item.title) }
}
.task { await model.search() }
}
}
@StateObjectruns the initializer once for the view’s lifetime- Survives view struct recreation
- Re-renders when any
@Publishedproperty changes (orobjectWillChange.send()is called) - Only initialize with a fresh instance —
@StateObject var x = ParentDependency.sharedworks but is usually a smell
The most common bug: initializing @StateObject from a parent-provided value:
struct UserScreen: View {
let userID: String
@StateObject var model: UserModel // ❌ STALE
init(userID: String) {
self.userID = userID
_model = StateObject(wrappedValue: UserModel(userID: userID))
}
}
StateObject(wrappedValue:) takes an @autoclosure — but SwiftUI only evaluates it the first time the view is initialized. If userID changes later, the model still holds the old value. The fix: use @ObservedObject with parent-owned storage, or use .id(userID) to force a new identity (which recreates the StateObject).
@ObservedObject — view observes a reference-type observable owned elsewhere
struct CartItemRow: View {
@ObservedObject var cart: Cart // injected, owned by parent
let item: CartItem
var body: some View {
HStack {
Text(item.name)
Spacer()
Button("Remove") { cart.remove(item) }
}
}
}
- View does not own the object’s lifecycle
- View re-renders when the observed object’s
@Publishedproperties change - Lifetime is the parent’s problem
- Don’t use
@ObservedObjectfor a view-owned model — every parent re-render creates a new instance
Rule of thumb: if you write @ObservedObject var x = SomeModel() you almost certainly meant @StateObject.
@EnvironmentObject — implicit dependency injection
For shared services or top-level state, prop-drilling through 5 views is tedious:
@MainActor
final class AuthService: ObservableObject {
@Published var currentUser: User?
}
// Inject at the root
@main
struct MyApp: App {
@StateObject private var auth = AuthService()
var body: some Scene {
WindowGroup {
ContentView()
.environmentObject(auth)
}
}
}
// Read anywhere in the subtree
struct ProfileView: View {
@EnvironmentObject var auth: AuthService
var body: some View {
if let user = auth.currentUser {
Text("Hi, \(user.name)")
}
}
}
- Looks up the object by type in the environment
- If absent at runtime, crashes — important to test the injection
- Subtree-scoped: an
.environmentObject(_:)modifier propagates downward only - Prefer over deep prop-drilling, but don’t over-globalize (auth, theme, feature flags — sure; “the current cart” — maybe just pass it)
@Environment — typed environment values (different beast)
Confusingly named, but different:
struct MyView: View {
@Environment(\.colorScheme) var colorScheme
@Environment(\.dismiss) var dismiss
@Environment(\.scenePhase) var scenePhase
@Environment(\.locale) var locale
var body: some View {
Button("Close") { dismiss() }
}
}
@Environment(\.keyPath) reads values from EnvironmentValues (system-provided like colorScheme, dismiss, scenePhase; or your own via EnvironmentKey). Not for arbitrary objects — for that use @EnvironmentObject (or @Environment(ObservableType.self) in iOS 17+ @Observable world; next chapter).
Putting it together — a real example
@MainActor
final class CheckoutFlow: ObservableObject {
@Published var cart: Cart = Cart()
@Published var shippingAddress: Address?
@Published var paymentMethod: PaymentMethod?
@Published var discount: Discount?
var total: Decimal { cart.subtotal - (discount?.amount ?? 0) }
}
@main
struct ShopApp: App {
@StateObject private var checkout = CheckoutFlow()
var body: some Scene {
WindowGroup {
NavigationStack { CartScreen() }
.environmentObject(checkout)
}
}
}
struct CartScreen: View {
@EnvironmentObject var checkout: CheckoutFlow
var body: some View {
VStack {
ForEach(checkout.cart.items) { item in Text(item.name) }
NavigationLink("Continue", value: "shipping")
}
.navigationDestination(for: String.self) { _ in ShippingScreen() }
}
}
struct ShippingScreen: View {
@EnvironmentObject var checkout: CheckoutFlow
@State private var streetField = "" // local — just for input
var body: some View {
Form {
TextField("Street", text: $streetField)
Button("Save") {
checkout.shippingAddress = Address(street: streetField)
}
NavigationLink("Pay", value: "payment")
}
.navigationDestination(for: String.self) { _ in PaymentScreen() }
}
}
// PaymentScreen + ConfirmationScreen all read from `checkout`
// Total is always consistent — single source of truth
Notice the discipline:
- One owner (
@StateObjectat the app root) - Shared via environment to all flow screens
- Local input state stays
@State - No
@ObservedObjectanywhere — there’s nothing to observe that the view created itself - Mutations happen explicitly via methods or direct property writes; the published properties propagate everywhere
State lifecycle — what lives, what dies
| State | Lives across | Dies on |
|---|---|---|
@State | View identity unchanged | Identity change, or view leaves hierarchy |
@StateObject | View identity unchanged | Identity change, or view leaves hierarchy |
@ObservedObject | The object’s own lifetime | Object deallocated externally |
@EnvironmentObject | Lifetime of whoever called .environmentObject(_:) | Container scene/view dies |
If a screen loses scroll position or animation state on a parent re-render, check view identity (chapter 5.2) and that no parent re-renders are forcing new identity.
Threading
- All view updates happen on the main thread
@MainActorannotate your observable classes (Swift 6 enforces this)- Mutating
@Publishedproperties off main → warning (“Publishing changes from background threads is not allowed”) - Use
await MainActor.run { ... }orTask { @MainActor in ... }to hop
@MainActor
final class Model: ObservableObject {
@Published var items: [Item] = []
func load() {
Task {
let fetched = try await api.fetch() // off main
// back on main: @MainActor isolates the class
self.items = fetched
}
}
}
In the wild
- Apple’s Reminders app uses a single root observable (the data store) injected via environment; per-screen
@Statefor ephemeral input. - Apollo (RIP) famously had a deep
@EnvironmentObjectgraph — auth, theme, settings, network status — visible in its WWDC talk. - Airbnb’s SwiftUI internal apps standardize on “one observable per feature, injected via environment, view-local
@Statefor inputs only.” - A Slack-shaped chat app typically has: a
MessageStoreenv object, aConversationenv object (per-conversation), and@Statefor the compose field.
Common misconceptions
- “
@StateObjectand@ObservedObjectare interchangeable.” Critical bug source.@ObservedObjectdoes not manage lifetime; if you write@ObservedObject var x = Model()you create a new model every render and lose state. - “
@EnvironmentObjectis global state.” No — it’s scoped to the subtree below the.environmentObject(_:)modifier. Two different subtrees can have two different objects of the same type. - “
@Stateworks for any property type.” Only for value types. For reference types, use@StateObject. Using@Statefor a class instance silently breaks observation. - “I should put
objectWillChange.send()in every setter.” No —@Publisheddoes this for you. Manual sends are for cases where the publishing is conditional or batched. - “
@Bindingand@Stateproduce the same thing when you use$.” Both produceBinding<T>, but the source differs.@State’s binding writes to view-owned storage;@Binding’s binding writes wherever the original@Statelives.
Seasoned engineer’s take
State management is where SwiftUI codebases go bad fast. The team-level rules I enforce:
- One source of truth per piece of state. If it lives in two places, they will diverge.
- State ownership matches scope. Local to a view →
@State. Cross-feature → environment. Cross-app → root. - No
@StateObjectinitialized from props. If you need that, you have a design problem (use.id()or pass plain data). @ObservedObjectrequires explicit comment explaining what owns the lifetime.- Don’t reach for
@EnvironmentObjectto avoid passing 2 params. Use it for genuinely cross-cutting concerns. @AppStorageis for genuine user preferences only — not for state you’d be sad to lose (use Keychain or your data store).
When a SwiftUI codebase shows “the cart goes empty randomly” or “the form clears itself” bugs, 90% of the time it’s @StateObject vs @ObservedObject confusion or unstable view identity.
TIP: When debugging “my view doesn’t update”, check: (1) is the property actually
@Published? (2) is the object reference the same one I’m mutating? (3) isbodyreading the property — if you only read it inside a closure, SwiftUI doesn’t subscribe.
WARNING: Don’t store reference-type objects in
@State. SwiftUI uses identity (==) for value types; for classes, it’ll observe the reference not the content. Use@StateObject.
Interview corner
Junior-level: “When do you use @State vs @Binding?”
@State when the view owns the value (private, view-local). @Binding when a child view needs to read and write a parent’s state — the binding is a handle to someone else’s storage. @State declares storage; @Binding references it via $value.
Mid-level: “Explain @StateObject vs @ObservedObject. Walk through a bug each one would cause if misused.”
@StateObject owns the lifetime — the instance is created on first view init and persists across the view struct being recreated. @ObservedObject does not own lifetime — the instance is provided externally and observed for changes.
Bug from misuse:
- Using
@ObservedObject var model = Model()on a view: every parent re-render creates a newModel, losing all state. Symptom: fields clear themselves, lists go empty. - Using
@StateObject var model = Model(userID: userID)whereuserIDis a parent-provided prop: theModelis initialized once with the originaluserIDand never updates. Symptom: changing users in the parent doesn’t update the child’s data.
Senior-level: “Design the state architecture for a 50-screen e-commerce app. What lives where, and why?”
Layered:
- App root:
@StateObjectforAuthService,CartService,ThemeService,FeatureFlagService. Injected as.environmentObject(_:). These are cross-cutting and must be single instances. - Feature roots (Checkout, Profile, Browse, Search): each owns a feature-scoped
@StateObject(e.g.,CheckoutFlow). Injected via environment for that subtree only. - Screens:
@EnvironmentObjectto read shared state;@Statefor local input (text fields, toggles, animation triggers). - Components (small, reusable views):
@Bindingfor parent state; no environment dependence — improves reusability and previewability. - Persistence: separate
Storelayer (SwiftData/Core Data). Observable services subscribe; views never touch persistence directly.
Plus: every observable is @MainActor. Network and data work happens in Task { let result = try await ... } then assigns to @Published on main. Migrating to @Observable (next chapter) when minimum target is iOS 17+.
Red flag in candidates: Defaulting @EnvironmentObject for everything (“it’s simpler”). Indicates no taste for explicit dependencies. Production apps with 20+ env objects become untestable and hard to reason about.
Lab preview
Lab 5.1 exercises @State, @Binding, @StateObject together in a SwiftData-backed todo app — a controlled environment to build muscle memory before reading the next chapter on @Observable.
Next: @Observable & Swift 6
5.4 — @Observable & Swift 6
Opening scenario
You open a SwiftUI codebase from 2022. Every view model looks like:
@MainActor
final class FeedViewModel: ObservableObject {
@Published var items: [Post] = []
@Published var isLoading = false
@Published var error: String?
@Published var query: String = ""
}
Every property is @Published. Every view that reads even one of these properties re-renders when any of them changes — because ObservableObject notifies on the whole object, and the view subscribes to the whole object. Searching causes the loading indicator subtree to re-render. Loading causes the search bar to re-render. Type a character — three subtrees re-render. It works, but it’s wasteful.
Then iOS 17 / Xcode 15 shipped the @Observable macro. Same view model, less ceremony, per-property observation so views only re-render when properties they actually read change:
@MainActor
@Observable
final class FeedViewModel {
var items: [Post] = []
var isLoading = false
var error: String?
var query: String = ""
}
That’s the new model. This chapter is how it works under the hood, how to migrate, and what changes in Swift 6’s strict concurrency world.
| Comparison | ObservableObject (pre-2023) | @Observable (iOS 17+) |
|---|---|---|
| Conformance | class: ObservableObject | @Observable class (macro) |
| Property annotation | @Published var x | plain var x |
| View wrapper | @StateObject / @ObservedObject / @EnvironmentObject | @State / @Bindable / @Environment |
| Granularity | Object-level (whole object invalidates) | Property-level (only readers of changed props re-render) |
| Threading | Uses Combine | Uses observation framework, no Combine |
| Concurrency | Manual @MainActor | Same, but more uniform with Swift 6 |
Concept → Why → How → Code
What @Observable does
The @Observable macro (declared in the Observation module, shipped in Foundation) expands at compile time into:
- Conformance to the
Observableprotocol - An internal observation registrar that tracks which properties were read by which observers
- Property accessors that record access on read and notify on write
You write:
@Observable
final class Counter {
var count = 0
}
The compiler generates (roughly):
final class Counter: Observable {
private let _$observationRegistrar = ObservationRegistrar()
private var _count = 0
var count: Int {
get {
_$observationRegistrar.access(self, keyPath: \.count)
return _count
}
set {
_$observationRegistrar.withMutation(of: self, keyPath: \.count) {
_count = newValue
}
}
}
}
SwiftUI’s withObservationTracking { } integration calls body, recording every observed property read during that invocation. On the next mutation of those exact properties, only views that read them get invalidated.
Per-property re-rendering — the actual win
@Observable
final class Profile {
var name = ""
var bio = ""
var avatarURL: URL?
}
struct NameView: View {
let profile: Profile
var body: some View {
Text(profile.name) // only re-renders when `name` changes
}
}
struct BioView: View {
let profile: Profile
var body: some View {
Text(profile.bio) // only re-renders when `bio` changes
}
}
Even though both views share the same Profile instance, mutating profile.bio invalidates BioView and not NameView. With ObservableObject + @Published, both views would re-render.
In a real app, this means search bars don’t blink when network requests finish, animation states don’t reset when unrelated properties change, and large lists don’t re-diff on every minor mutation.
The new property wrappers
struct CounterScreen: View {
@State private var counter = Counter() // owns the instance
var body: some View {
VStack {
Text("\(counter.count)")
Button("Increment") { counter.count += 1 }
ChildView(counter: counter) // plain pass — no wrapper needed
}
}
}
struct ChildView: View {
let counter: Counter // just a reference; observation auto-set up
var body: some View {
Text("Child sees \(counter.count)")
}
}
Key changes from the old world:
@Statereplaces@StateObjectfor owning an@Observableinstance. Yes,@Statenow works with reference types (only when they’re@Observable).- Children take the instance as a plain
letproperty. No@ObservedObjectneeded — observation registration happens automatically when the view reads a property. @Bindablereplaces@Bindingfor@Observableinstances when you need two-way bindings:
struct ProfileForm: View {
@Bindable var profile: Profile
var body: some View {
TextField("Name", text: $profile.name)
TextField("Bio", text: $profile.bio)
}
}
@Bindable projects bindings to individual properties via $ — same syntax as @State, but on a reference-type observable.
@Environment for @Observable instances
@main
struct MyApp: App {
@State private var auth = AuthService() // not @StateObject — @State
var body: some Scene {
WindowGroup {
ContentView()
.environment(auth) // not .environmentObject(_:)
}
}
}
struct ProfileView: View {
@Environment(AuthService.self) private var auth // type-based lookup
var body: some View {
Text(auth.currentUser?.name ?? "Signed out")
}
}
.environment(_:)(not.environmentObject(_:))@Environment(MyType.self)(the type, not a key path)- Like
@EnvironmentObject, crashes at runtime if missing
For optional environment (no crash), use @Environment(MyType.self) private var x: MyType?.
Migration cheat sheet
Before (ObservableObject) | After (@Observable) |
|---|---|
class X: ObservableObject | @Observable class X |
@Published var p | var p |
@StateObject private var x = X() | @State private var x = X() |
@ObservedObject var x: X | let x: X (just a property) |
@EnvironmentObject var x: X | @Environment(X.self) private var x |
.environmentObject(x) | .environment(x) |
@Binding var name: String from observable | @Bindable var model: X then $model.name |
You can migrate one file at a time — @Observable and ObservableObject coexist in the same app. Only the views observing each model need to know its style.
What @Observable is not
- Not Combine. It uses the
Observationframework, a separate, lighter mechanism. There’s noobjectWillChangepublisher. - Not a property wrapper. It’s a macro that generates observation. No
@Published, no_$storage you should touch. - Not value-type. Still requires a class. Structs use
@State. - Not magic. It tracks property reads/writes; it does not track computed property dependencies. If a computed property reads stored properties internally, observation works through it. If it reads external state (singletons, globals), it doesn’t.
Working with Swift 6 concurrency
Swift 6 enables strict concurrency checking. SwiftUI’s View.body is @MainActor-isolated. Combine this with @Observable:
@MainActor
@Observable
final class FeedModel {
var items: [Post] = []
var isLoading = false
func load() async {
isLoading = true
defer { isLoading = false }
do {
// .task hops off main, then we hop back
let fetched = try await api.fetch()
self.items = fetched
} catch {
// handle
}
}
}
- The class is
@MainActor— all property access is checked on main load()is implicitly@MainActor(because the class is)- Inside,
awaitmay suspend; the suspension point hops off main if the awaited function is on a different actor - After resume, you’re back on main, so
self.items = fetchedis safe
The compiler will catch you if you try to mutate items from a non-main context. This is a feature, not friction — it eliminates a category of “UI updates from background threads” bugs.
For non-UI observables (e.g., a background sync service), you can omit @MainActor and use other actors:
actor SyncManager {
// ...
}
@Observable
final class SyncStatus {
var pendingCount = 0
var lastSync: Date?
}
If SyncStatus is read from SwiftUI views (@MainActor), mutate it on @MainActor. If it’s only used internally, don’t pin to @MainActor.
Performance characteristics
@Observable is generally faster than ObservableObject:
- No Combine pipeline allocation per property
- Per-property observation reduces re-render scope
- No
objectWillChange.send()call cost - Macro-generated code is dead simple — direct property access with registrar hooks
Apple’s own measurements (WWDC 2023 “Discover Observation in SwiftUI”) show 1.5-3× scroll perf improvements in lists where rows previously observed shared ObservableObjects.
Edge cases & gotchas
-
Computed properties that depend on stored ones — observation works transparently.
var fullName: String { "\(first) \(last)" }— readers offullNameare notified whenfirstorlastchanges. -
Mutating arrays/dictionaries in place — observation tracks property set, not internal mutation. If your property is
var items: [Item] = [], thenitems.append(x)triggers notification (because Swift treats the array assignment as a write toitems). If your property is a@Observable class List, then mutatinglist.add(x)triggers notification onList’s properties, not on the parent. -
@Observable+ protocols —@Observableis a macro, not a protocol you can constrain to. To pass observables polymorphically, use the underlyingObservableprotocol:
func observe(_ thing: any Observable) { /* ... */ }
-
Subclassing — works, but subclass should also be
@Observableif it adds observable properties. -
Properties you don’t want observed — mark with
@ObservationIgnored:
@Observable
final class Model {
var displayedValue = ""
@ObservationIgnored
var lastFetchedAt: Date? // mutations don't notify
}
Use for caches, instrumentation, things that aren’t UI-visible.
In the wild
- Apple’s own apps built/updated for iOS 17+ use
@Observableexclusively for new model code. Translate, Journal, Sandbox. - The Apple Sample Code repository — every new SwiftUI sample since 2023 uses
@Observable. - Point-Free’s TCA (Composable Architecture) released a
@Observable-friendly variant in 2024 — their@ObservableStatemacro is conceptually similar. - Soroush Khanlou’s open-source apps migrated their
ObservableObjectview models to@Observableand reported measurable scroll perf wins in chat list views.
Common misconceptions
- “
@Observablereplaces everything fromObservableObject.” Not quite —@Observablerequires iOS 17+. If you support iOS 16 or earlier, you still needObservableObject. Many production apps run both. - “
@Bindableis the same as@Binding.” No.@Bindingis for value-typed@Statepassed from a parent.@Bindableis for@Observablereference-typed instances to project bindings to their properties. Different mechanism. - “
@Observablemakes my class thread-safe.” No. It tracks observation, not concurrency. Use@MainActor(for UI-bound) or actors (for shared mutable state). - “
@Stateis now for everything.”@Stateworks for value types (as before) and for@Observableinstances. It does not work for plain reference types — they still need to be@Observablefor observation to work. - “I have to migrate everything to
@Observableimmediately.” No. They interoperate. Migrate file by file as you touch each.
Seasoned engineer’s take
When I greenfield a SwiftUI app in 2026 with iOS 17+ minimum, I use @Observable exclusively. There’s almost no reason to reach for ObservableObject in new code. The main reasons I keep ObservableObject around:
- iOS 16 support — drops
@Observableoff the table - Combine integration — if I’m already using Combine pipelines that feed
@Published(rare in 2026 —AsyncSequencecovers most cases) - A monolithic legacy view model that touches 200 things — wait until the next major refactor
For migration: I do it lazily — when I touch a view model for an unrelated reason, I migrate it as part of that PR. Trying to mass-migrate is a project that gets abandoned.
The @Observable thing I most often see misused: people put @Observable on a class but then read it from a non-SwiftUI context expecting Combine semantics. There is no $property Combine publisher; observation is SwiftUI-scoped. For non-SwiftUI reactive needs, use AsyncSequence or Observation.withObservationTracking { } directly.
TIP: When migrating, search the codebase for
@Publishedand@StateObject— those are your migration targets.@ObservedObjectand@EnvironmentObjectget replaced by plain property access and@Environment(Type.self)respectively.
WARNING:
@Observablerequires the class to be a class. Marking a struct with@Observableis a compile error. Some folks try to make their value-typed models@Observable; they need@Stateinstead, which works fine for structs.
Interview corner
Junior-level: “What does the @Observable macro do?”
It’s a Swift 5.9+ macro that conforms a class to the Observable protocol and wraps each stored property in an accessor that tracks reads and writes. SwiftUI observes those property accesses to figure out which views need to re-render when a property changes — at per-property granularity, rather than the whole-object invalidation of ObservableObject.
Mid-level: “Why migrate from ObservableObject to @Observable? What’s the practical difference?”
ObservableObject with @Published causes any view subscribing to the object to re-render when any @Published property changes. @Observable tracks which views read which properties, and only invalidates the views that actually read the changed property. In practice, lists and forms with many fields gain noticeable scroll/edit perf. Migration is mostly mechanical: drop @Published, change the class to @Observable, change view wrappers (@StateObject → @State, @ObservedObject → plain prop, @EnvironmentObject → @Environment(Type.self), @Binding from observable → @Bindable).
Senior-level: “How does SwiftUI know which properties a view reads, given that @Observable doesn’t use Combine?”
SwiftUI invokes body inside a call to withObservationTracking { ... } onChange: { ... } (Observation framework). The withObservationTracking block records, via the property accessors generated by the @Observable macro, every observable property access (calls to registrar.access(self, keyPath:)). When the block completes, SwiftUI has a set of (instance, keyPath) pairs that this body invocation depends on. On the next mutation of any of those pairs (caught by registrar.withMutation(...)), SwiftUI invalidates just the views whose recorded set included that pair, scheduling them for re-render. The result is per-property fine-grained invalidation without Combine subscriptions.
Red flag in candidates: Saying “@Observable is just syntax sugar over ObservableObject.” Indicates they haven’t actually used it. The mechanisms are entirely different and the perf characteristics differ.
Lab preview
Every Phase 5 lab uses @Observable for view models. Lab 5.1 is the first hands-on with the new property wrappers, including @Bindable in the edit screen.
Next: Navigation
5.5 — Navigation
Opening scenario
You inherited a SwiftUI app from 2022. The codebase is full of NavigationView, NavigationLink(isActive:), and Booleans named isProfilePresented, isSettingsPresented, isShippingPresented — one per destination. Deep linking is a switch statement inside .onOpenURL that toggles seven flags in sequence with DispatchQueue.main.asyncAfter delays to “make sure navigation completes.” When two pushes happen close together, the second silently fails. The QA log lists 14 navigation bugs.
Apple deprecated NavigationView and the isActive: pattern for exactly this reason. iOS 16 introduced NavigationStack and NavigationSplitView — value-driven navigation. Your routes become data; you push a value, SwiftUI looks up the destination, navigation works deterministically. Deep linking becomes “set the navigation path to [.profile, .settings]” — atomic, testable, no flags.
| API | Era | Use for |
|---|---|---|
NavigationView | iOS 13–15 | Deprecated. Don’t write new code with it. |
NavigationLink(isActive:) | iOS 13–15 | Deprecated. Bug-prone. |
NavigationStack | iOS 16+ | Single-column push/pop navigation (iPhone, iPad portrait) |
NavigationSplitView | iOS 16+ | Multi-column sidebar/list/detail (iPad, macOS, large iPhones in landscape) |
navigationDestination(for:) | iOS 16+ | Map a value type to a destination view |
NavigationPath | iOS 16+ | Type-erased path for deep linking |
.sheet/.fullScreenCover | iOS 13+ | Modal presentation (not navigation, conceptually) |
Concept → Why → How → Code
The pre-iOS-16 problem
// OLD — don't do this
struct ContentView: View {
@State private var isProfileActive = false
@State private var isSettingsActive = false
var body: some View {
NavigationView {
VStack {
NavigationLink("Profile", isActive: $isProfileActive) {
ProfileView()
}
NavigationLink("Settings", isActive: $isSettingsActive) {
SettingsView()
}
}
}
}
}
Problems:
- One flag per destination — N flags for N destinations
- No central “where am I in the navigation stack?”
- Two pushes in quick succession race
- Deep linking is a chain of
Bool.toggle()s with manual delays - Hard to test (“what should be on screen?” answer requires inspecting many flags)
NavigationStack — value-driven push/pop
struct ContentView: View {
var body: some View {
NavigationStack {
List {
NavigationLink("Profile", value: Route.profile)
NavigationLink("Settings", value: Route.settings)
}
.navigationDestination(for: Route.self) { route in
switch route {
case .profile: ProfileView()
case .settings: SettingsView()
}
}
}
}
}
enum Route: Hashable {
case profile, settings
}
NavigationLink(_, value:)pushes a value onto the stack.navigationDestination(for: T.self) { value in ... }declares how to render aT- The stack is a list of values; push appends, pop removes the last
- Multiple navigation destinations per type are supported (declare separately by type)
Programmatic navigation with NavigationPath
For deep linking and explicit control:
struct AppRoot: View {
@State private var path = NavigationPath()
var body: some View {
NavigationStack(path: $path) {
HomeView()
.navigationDestination(for: Route.self) { route in
destination(for: route)
}
}
.onOpenURL { url in
// Deep link: /profile/settings → push profile then settings
path.append(Route.profile)
path.append(Route.settings)
}
}
}
NavigationPath is a type-erased container — it can hold any Hashable and Codable values. You can mix value types in one stack:
path.append(Route.profile)
path.append(Item(id: "x", title: "Document")) // a different type!
Both need separate .navigationDestination(for:) modifiers — one for Route, one for Item. SwiftUI dispatches by value type.
Typed path for better APIs
If your navigation is homogeneous, use [Route] directly:
struct AppRoot: View {
@State private var path: [Route] = []
var body: some View {
NavigationStack(path: $path) {
HomeView()
.navigationDestination(for: Route.self) { route in
destination(for: route)
}
}
}
}
// Push: path.append(.profile)
// Pop: path.removeLast()
// Pop all: path.removeAll()
// Pop to specific: path = [.home, .profile]
Programmatic operations are array operations. Testable.
NavigationSplitView — multi-column
For iPad and macOS, you want a sidebar + content + detail layout:
struct ContentView: View {
@State private var selectedFolder: Folder?
@State private var selectedNote: Note?
var body: some View {
NavigationSplitView {
// Sidebar
List(folders, selection: $selectedFolder) { folder in
Text(folder.name).tag(folder)
}
} content: {
// Middle column: notes in the selected folder
if let folder = selectedFolder {
List(folder.notes, selection: $selectedNote) { note in
Text(note.title).tag(note)
}
} else {
Text("Select a folder")
}
} detail: {
// Detail column
if let note = selectedNote {
NoteEditor(note: note)
} else {
Text("Select a note")
}
}
}
}
NavigationSplitView adapts:
- Mac / iPad landscape: three columns visible
- iPad portrait: sidebar collapses to overlay
- iPhone: collapses to a
NavigationStack-equivalent
Three flavors: 2-column (sidebar | detail) or 3-column (sidebar | content | detail).
columnVisibility parameter controls which columns show by default.
Combining NavigationSplitView + NavigationStack
In the detail column, you can have its own push/pop stack:
NavigationSplitView {
Sidebar(selection: $selection)
} detail: {
NavigationStack(path: $detailPath) {
DetailRoot(selection: selection)
.navigationDestination(for: Route.self) { ... }
}
}
Each navigation context (split sidebar, split detail, sheet) can have its own NavigationStack with its own path binding. Pushes in the detail stack don’t affect the sidebar.
Modals: .sheet, .fullScreenCover, .popover
Modals are not navigation — they present a view on top of the current context. Same value-driven pattern works:
struct InboxView: View {
@State private var composing: Draft?
var body: some View {
List(messages) { msg in
Text(msg.subject)
}
.toolbar {
Button("Compose") {
composing = Draft()
}
}
.sheet(item: $composing) { draft in
ComposeView(draft: draft)
}
}
}
sheet(item:)shows when the item is non-nil; dismisses when set to nil- Works with any
Identifiablevalue - Versus
sheet(isPresented:)(Boolean) — preferitem:for passing context
.fullScreenCover(item:) covers the whole screen with no swipe-to-dismiss (use sparingly — iOS users expect swipe-down).
Dismiss from a child view
struct ComposeView: View {
@Environment(\.dismiss) private var dismiss
var body: some View {
Button("Cancel") { dismiss() }
}
}
@Environment(\.dismiss) works for sheets, full-screen covers, and pushes — dismisses whatever current presentation context the view is in.
Centralized router pattern
For non-trivial apps, centralize navigation in an @Observable router:
@Observable
@MainActor
final class AppRouter {
var homePath: [HomeRoute] = []
var profilePath: [ProfileRoute] = []
var presentedSheet: Sheet?
enum HomeRoute: Hashable {
case product(Product.ID)
case category(Category)
}
enum ProfileRoute: Hashable {
case settings, editProfile, helpCenter
}
enum Sheet: Identifiable {
case auth, debug
var id: String { String(describing: self) }
}
func openProduct(_ id: Product.ID) {
homePath = [.product(id)]
}
func handleDeepLink(_ url: URL) {
// Parse URL → set appropriate path
}
}
@main
struct App: App {
@State private var router = AppRouter()
var body: some Scene {
WindowGroup {
RootView()
.environment(router)
.onOpenURL { router.handleDeepLink($0) }
}
}
}
Benefits:
- Single place to inspect “where is the user?” — useful for analytics, restoration
- Deep linking is a method call, no race conditions
- Tab switching + navigation reset becomes one atomic operation
- Testable: assert router state after action
Deep linking — the right way
// URL: myapp://product/42
func handleDeepLink(_ url: URL) {
guard url.scheme == "myapp" else { return }
let parts = url.pathComponents.filter { $0 != "/" }
switch parts.first {
case "product":
if let idStr = parts[safe: 1], let id = Product.ID(idStr) {
selectedTab = .home
homePath = [.product(id)]
}
case "settings":
selectedTab = .profile
profilePath = [.settings]
default:
return
}
}
Atomic — set the tab and path in the same run loop turn. No flags, no delays.
For universal links (HTTPS-based, App-bound), same pattern via onContinueUserActivity:
.onContinueUserActivity(NSUserActivityTypeBrowsingWeb) { activity in
if let url = activity.webpageURL { handleDeepLink(url) }
}
Toolbar items
NavigationStack {
ProfileView()
.navigationTitle("Profile")
.navigationBarTitleDisplayMode(.inline) // or .large / .automatic
.toolbar {
ToolbarItem(placement: .topBarLeading) {
Button("Cancel") { dismiss() }
}
ToolbarItem(placement: .topBarTrailing) {
Button("Save") { save() }
}
}
}
Placements: .topBarLeading, .topBarTrailing, .principal (centered), .bottomBar, .keyboard (above keyboard), .navigationBarLeading/Trailing (legacy). Use semantic placements; SwiftUI adapts per platform.
What about tabs?
Tabs are orthogonal to navigation — each tab can host its own NavigationStack:
TabView(selection: $selectedTab) {
NavigationStack(path: $homePath) { HomeView() }
.tabItem { Label("Home", systemImage: "house") }
.tag(Tab.home)
NavigationStack(path: $profilePath) { ProfileView() }
.tabItem { Label("Profile", systemImage: "person") }
.tag(Tab.profile)
}
iOS 18 introduced TabView with Tab API (Tab("Home", systemImage: "house") { ... }) — cleaner syntax, supports floating tab bar on iPad. Use the new API on iOS 18+.
In the wild
- Apple Notes (iOS 16+) uses
NavigationSplitViewextensively; the same codebase adapts iPhone (collapsed stack) and iPad (3 columns). - Apollo (RIP) centralized its router in an observable; deep linking from notifications was a single method call.
- Stripe Dashboard uses a typed
Routeenum per tab and stores paths in their flow coordinator. - Apple Reminders uses
NavigationSplitViewwith custom column visibility per orientation.
Common misconceptions
- “
NavigationViewstill works, don’t bother migrating.” It’s deprecated and gets less attention each release. New APIs (.navigationDestination,NavigationPath) don’t work insideNavigationView. Migrate when you touch the file. - “
NavigationLinkis the only way to push.” Programmatic push (path.append(...)) is fully supported and necessary for deep linking, post-action navigation, and tests. - “
NavigationPathand[Route]are different.” They serve the same goal;[Route]gives you compile-time type safety,NavigationPathallows mixed types. Use the typed array unless you need heterogeneity. - “Sheets are part of navigation.” Modals are presented on top of a navigation context. They have their own dismiss semantics. Don’t push views via sheets; use a
NavigationStackinside the sheet if you need internal navigation. - “Deep linking needs
DispatchQueue.main.asyncAfter.” With value-driven navigation, deep links are atomic — set the path and tab in one synchronous block.
Seasoned engineer’s take
Centralize your navigation in a router object as soon as your app has more than ~10 destinations or any deep linking. The benefits are huge: analytics (router.didChangePath), state restoration (encode the path), tests (assert path after action), and the bug class of “two flags set, race, ambiguous state” disappears.
Keep modals separate from push navigation in your router. Sheets/full-screen covers are presentation events, not destinations on a stack. A common smell: routers with path mixing sheet routes and push routes. Split them.
For multiplatform (iPhone + iPad + macOS), use NavigationSplitView and let SwiftUI adapt. Don’t try to detect platform and switch between NavigationStack and NavigationSplitView; that path has subtle bugs.
TIP: Make your
RouteenumCodable(in addition toHashable). Then you can persistpathto disk (or restore from notification payload) trivially: encode/decode as JSON. State restoration becomes free.
WARNING: Don’t use
NavigationLink { ... } label: { ... }(closure-based) in deep navigation. It eagerly initializes the destination — wasteful and reads state for views the user may never see. UseNavigationLink(_, value:)+.navigationDestination(for:)for lazy initialization.
Interview corner
Junior-level: “What replaced NavigationView and why?”
NavigationStack (for single-column) and NavigationSplitView (for multi-column). NavigationView was deprecated because it had subtle issues with programmatic navigation, mixed iPhone/iPad behavior, and the NavigationLink(isActive:) pattern was bug-prone. The new APIs introduced value-driven navigation: push values, register destinations by type, control the stack as data.
Mid-level: “How would you implement deep linking from a push notification in a SwiftUI app?”
Centralize navigation state in an @Observable router with one path per tab and a current tab selection. On notification tap, parse the payload, then set the router’s tab and path atomically (router.selectedTab = .messages; router.messagesPath = [.conversation(id), .messageDetail(messageID)]). SwiftUI re-renders, the stack reconstructs, the user lands on the right view. No flags, no async delays. Make routes Codable so the path round-trips through the notification payload.
Senior-level: “You have a tab-based app with 4 tabs, each its own NavigationStack. The user is deep in Profile → Settings → Privacy. They tap a push notification that should take them to Messages → Conversation 42 → Message 17. What’s the implementation, and what edge cases do you handle?”
Centralized router with per-tab paths:
@Observable @MainActor
final class Router {
var selectedTab: Tab = .home
var paths: [Tab: [Route]] = [:]
}
On notification tap, parse payload → call router.openConversation(id: 42, focusMessage: 17):
func openConversation(id: ConversationID, focusMessage: MessageID?) {
selectedTab = .messages
var path: [Route] = [.conversation(id)]
if let mid = focusMessage { path.append(.message(mid)) }
paths[.messages] = path
}
Edge cases:
- App is killed:
application(_:didFinishLaunching...)checkslaunchOptions[.remoteNotification]; defer the navigation until SwiftUI hierarchy is up (.onAppearon root, or.taskwith a small delay only if needed). - App is backgrounded:
onChange(of: scenePhase)handle pending deep links queued while inactive. - Modal presented: dismiss modals first, then navigate (router can dismiss via
presentedSheet = nil). - Route doesn’t exist (e.g., conversation deleted): navigate to fallback (the conversations list), show a toast.
- User is in onboarding: queue the deep link, replay after onboarding completes.
Test by writing unit tests that call router methods and assert the resulting state — no view hierarchy needed.
Red flag in candidates: Using DispatchQueue.main.asyncAfter to “make sure navigation completed” before deep-linking. Indicates fighting the framework rather than using value-driven navigation properly.
Lab preview
Lab 5.1 uses NavigationStack with a typed path; Lab 5.3 uses NavigationSplitView for the iPad/macOS split UI.
Next: Lists, forms & grids
5.6 — Lists, forms & grids
Opening scenario
A new SwiftUI engineer ships a feed screen. It’s a ScrollView { VStack { ForEach(items) { ... } } }. Works fine — until production data hits 5,000 items. The screen takes 8 seconds to appear, scrolls choppy, and memory spikes to 600MB. The fix is one keyword: LazyVStack. Or better: List.
SwiftUI’s collection containers each pick a tradeoff. Pick wrong and you ship perf bugs. Pick right and the framework handles diffing, recycling, and accessibility for you.
| Container | Lazy? | Use for |
|---|---|---|
List | Yes | Standard scrolling lists (uses platform list view) |
Form | Yes | Grouped settings/input forms |
LazyVStack / LazyHStack | Yes | Custom-styled lists inside ScrollView |
VStack / HStack | No | Small fixed sets of views (< ~50) |
LazyVGrid / LazyHGrid | Yes | Grid layouts (Instagram-style photo grid) |
Grid | No | Aligned cells, no scrolling (calculator UI) |
Table | Yes | Multi-column tables (macOS/iPadOS only) |
Concept → Why → How → Code
List — the workhorse
List(items) { item in
HStack {
AsyncImage(url: item.imageURL)
.frame(width: 50, height: 50)
VStack(alignment: .leading) {
Text(item.title).font(.headline)
Text(item.subtitle).font(.subheadline).foregroundStyle(.secondary)
}
}
}
Under the hood on iOS, List wraps UICollectionView (was UITableView pre-iOS 16). Cells are recycled. The default styling is platform-appropriate.
List styles
List(items) { ... }
.listStyle(.plain) // no insets, edge-to-edge
.listStyle(.insetGrouped) // iOS Settings look
.listStyle(.grouped) // legacy grouped
.listStyle(.sidebar) // macOS/iPad sidebar with disclosure groups
Each style has subtle differences in spacing, separators, background. .sidebar enables collapsible disclosure groups and matches platform conventions.
Sections
List {
Section("Today") {
ForEach(todayItems) { ItemRow(item: $0) }
}
Section("Yesterday") {
ForEach(yesterdayItems) { ItemRow(item: $0) }
}
Section {
ForEach(olderItems) { ItemRow(item: $0) }
} header: {
Text("Older")
} footer: {
Text("Older than 7 days").font(.caption)
}
}
Sections enable headers, footers, and grouping. With .insetGrouped style, sections render as rounded card groups.
Swipe actions
List(items) { item in
Text(item.title)
.swipeActions(edge: .trailing) {
Button("Delete", role: .destructive) {
delete(item)
}
Button("Archive") {
archive(item)
}
.tint(.orange)
}
.swipeActions(edge: .leading) {
Button("Flag") { flag(item) }.tint(.yellow)
}
}
edge: .trailing(right swipe) — destructive actions go here per HIGedge: .leading(left swipe) — neutral/positive actions- First action shown is invoked on full swipe
role: .destructivecolors red and confirms full-swipe destruction
onDelete / onMove / EditMode
List {
ForEach(items) { ItemRow(item: $0) }
.onDelete { offsets in items.remove(atOffsets: offsets) }
.onMove { source, dest in items.move(fromOffsets: source, toOffset: dest) }
}
.toolbar { EditButton() }
Provides classic iOS edit-mode reorder and delete. Less common now than .swipeActions for delete, but .onMove + EditButton remains the standard for reorderable lists.
Selection
@State private var selection: Set<Item.ID> = []
List(items, selection: $selection) { item in
Text(item.title)
}
.toolbar { EditButton() }
- Single selection:
@State var selection: Item.ID? - Multi-selection:
@State var selection: Set<Item.ID>+ edit mode - On macOS, selection works without edit mode (click to select)
Pull-to-refresh
List(items) { ... }
.refreshable {
await loadLatest() // async closure
}
refreshable provides system pull-to-refresh. The async closure suspends until refresh completes; the spinner displays during that time.
Searchable
List(filteredItems) { ... }
.searchable(text: $query, prompt: "Search items")
.searchScopes($scope) {
Text("All").tag(Scope.all)
Text("Active").tag(Scope.active)
}
var filteredItems: [Item] {
query.isEmpty ? items : items.filter { $0.title.localizedCaseInsensitiveContains(query) }
}
System-styled search field, integrates with navigation bar. .searchScopes adds segmented filter chips below.
Form — grouped input UI
struct SettingsView: View {
@AppStorage("notifications") private var notifications = true
@AppStorage("frequency") private var frequency = "daily"
@State private var email = ""
var body: some View {
Form {
Section("Account") {
TextField("Email", text: $email)
SecureField("Password", text: $password)
}
Section("Notifications") {
Toggle("Enabled", isOn: $notifications)
Picker("Frequency", selection: $frequency) {
Text("Daily").tag("daily")
Text("Weekly").tag("weekly")
}
}
Section {
Button("Sign out", role: .destructive) { signOut() }
}
}
}
}
Form is List with adaptive styling: iOS Settings-style on iOS, indented labels on macOS. Use for any settings/input UI. Don’t reach for Form for content lists — use List.
LazyVStack / LazyHStack — custom lists in ScrollView
When List styling doesn’t fit:
ScrollView {
LazyVStack(spacing: 12, pinnedViews: [.sectionHeaders]) {
Section {
ForEach(items) { item in
CustomCard(item: item)
}
} header: {
HStack { Text("Today").font(.title2); Spacer() }
.background(.regularMaterial)
}
}
}
- Lazy: views off-screen are not instantiated
pinnedViews: [.sectionHeaders]for sticky headers- More layout flexibility than
List(custom backgrounds, full-width cells, etc.) - Lose: built-in swipe actions, selection, separators, accessibility traits
Use LazyVStack when:
- You need a custom card-style design that doesn’t fit list cell conventions
- You need pinned section headers
- You need a non-list-shaped scroll (e.g., heterogeneous content above a feed)
Use List when you can — you get more for free.
Grids
let columns = [
GridItem(.adaptive(minimum: 100), spacing: 8)
]
ScrollView {
LazyVGrid(columns: columns, spacing: 8) {
ForEach(photos) { photo in
AsyncImage(url: photo.thumbnailURL) { image in
image.resizable().aspectRatio(1, contentMode: .fill)
} placeholder: {
Color.gray.opacity(0.2)
}
.frame(height: 100)
.clipped()
}
}
.padding(8)
}
GridItem types:
.fixed(width)— fixed-width column.flexible(minimum:, maximum:)— fills available space, bounded.adaptive(minimum:, maximum:)— fits as many columns as possible at min width
Adaptive grids are the Instagram pattern — 3 columns on iPhone, 5 on iPad, more on Mac.
Grid (non-lazy, aligned)
Grid(horizontalSpacing: 16, verticalSpacing: 8) {
GridRow {
Text("Name").gridColumnAlignment(.trailing)
TextField("Name", text: $name)
}
GridRow {
Text("Email").gridColumnAlignment(.trailing)
TextField("Email", text: $email)
}
GridRow {
Color.clear
.gridCellUnsizedAxes([.horizontal, .vertical])
Button("Save") { save() }
}
}
Grid (iOS 16+) is a non-scrolling, non-lazy layout container with column alignment — like CSS Grid. Use for forms with aligned labels, calculator-style layouts, dashboards.
Table (macOS, iPadOS)
Multi-column tables with sortable headers:
struct OrdersTable: View {
@State private var orders: [Order] = []
@State private var sortOrder: [KeyPathComparator<Order>] = []
var body: some View {
Table(orders, sortOrder: $sortOrder) {
TableColumn("ID", value: \.id.uuidString)
TableColumn("Customer", value: \.customer)
TableColumn("Amount", value: \.amount) { order in
Text(order.amount, format: .currency(code: "USD"))
}
}
.onChange(of: sortOrder) { _, new in
orders.sort(using: new)
}
}
}
iPadOS 16+ supports Table. iOS (iPhone) collapses Table to a list. Most use Table for productivity apps; consumer apps stick to List.
Performance gotchas
ForEachwithout stable IDs triggers full re-renders. UseIdentifiableorid: \.someStable.AsyncImagewithout.id(url)can flicker on reuse. Apply.id()to force fresh state when URL changes.- Computing derived data in
body— heavy filters/sorts invar bodyrun every render. Hoist to@Observablemodel. - Reading large model objects in cell — even with
@Observableper-property tracking, if a cell readsmodel.everythingyou re-render on any change. Pass only the data the cell needs. - Heterogeneous cells in
LazyVStack— varying row heights cause more work. Acceptable; just don’t expectList-level perf for tens of thousands of mixed rows.
Diffing — identity matters
struct Item: Identifiable {
let id: UUID
var title: String
}
List(items) { item in
Text(item.title)
}
When items changes, SwiftUI diffs old vs new by id and animates inserts/removes. If you use id: \.title and two items share a title, you get visual glitches. Always use a truly unique, stable identity.
Async data loading pattern
struct FeedView: View {
@State private var model = FeedModel()
var body: some View {
List(model.items) { item in ItemRow(item: item) }
.overlay {
if model.isLoading && model.items.isEmpty {
ProgressView()
}
}
.refreshable { await model.refresh() }
.task { await model.loadInitial() }
}
}
.task { ... }runs when view appears, cancels on disappear (good!).refreshablefor pull-to-refresh- Overlay for empty-state spinner
In the wild
- Apple Mail uses
List+.swipeActions+.searchable— exactly the pattern in this chapter. - Instagram is
LazyVGridwith.adaptivefor the profile grid; the feed itself isLazyVStackfor custom card design. - Apple Settings is the canonical
Formexample — sections, toggles, pickers, disclosure rows. - Apple’s Reminders app uses
Listwith custom row content, including the inline-edit text fields. - Notion’s iPad app uses
Tablefor database views with sortable columns.
Common misconceptions
- “
ListandLazyVStackare interchangeable.” They’re not.Listgives you swipe actions, selection, separators, edit mode, accessibility.LazyVStackgives you custom styling freedom. Pick based on what you need. - “
VStackis fine for any list.” No —VStackinstantiates every child upfront. With 5,000 items, it’s catastrophic. UseListorLazyVStack. - “
Formis for any input.”Formadds platform-specific styling. Use it for settings-style input. For a one-offTextFieldin a custom flow,VStackis fine. - “You can’t customize
Listappearance.” You can —.listRowBackground,.listRowSeparator(.hidden),.listRowInsets(),.scrollContentBackground(.hidden)(combined with.background(...)for a custom backdrop). - “
AsyncImageis good enough for image grids.” It’s fine for thumbnails but lacks caching beyond URL session. For real photo grids, use a caching library (SDWebImage, Nuke, Kingfisher) wrapped in aUIViewRepresentable, or roll your own cache.
Seasoned engineer’s take
List first. Only reach for LazyVStack when you have a concrete reason. The amount of accessibility and platform behavior you give up by hand-rolling list UI is enormous and most teams underestimate it.
For forms: Form is criminally underused. Engineers reach for custom VStack layouts when Form would have produced a more native-looking, more accessible, more localizable result with less code.
For grids: LazyVGrid with adaptive columns is the default. If you need fixed columns and complex per-cell sizing, you might be reaching for a custom layout — consider Layout protocol (iOS 16+) rather than nested stacks.
Watch out for accidentally non-lazy lists. ScrollView { ForEach(...) { ... } } (no LazyVStack) silently becomes eager. Always wrap with LazyVStack or use List.
TIP: When debugging list perf, add
let _ = Self._printChanges()to your row view’s body. You’ll see every re-render and why. Then optimize.
WARNING:
Listcell reuse means@Stateinside a cell can leak between rows if your IDs are unstable. Always useIdentifiablewith truly unique IDs.
Interview corner
Junior-level: “What’s the difference between List and ScrollView { VStack { ForEach { ... } } }?”
List is a lazy container backed by the platform’s native list view; only visible cells are instantiated, and you get cell recycling, swipe actions, selection, and edit mode for free. ScrollView + VStack instantiates all children upfront — slow for large datasets. The lazy version is ScrollView { LazyVStack { ForEach { ... } } } which only instantiates visible children.
Mid-level: “You have a 10,000-item feed with custom card styling, pull-to-refresh, and per-card swipe actions. What container do you use?”
List with .listStyle(.plain), .listRowSeparator(.hidden), .listRowBackground(Color.clear), custom card view in the row. This keeps native swipe actions, accessibility, and lazy loading. If the card styling absolutely cannot work as a list row (e.g., overlapping cards or pinned headers in the middle of scrolling), then LazyVStack in a ScrollView with manual swipe-gesture implementation, but you give up a lot. Start with List; switch only with evidence.
Senior-level: “Walk me through optimizing a list that’s scrolling at 40fps.”
- Profile with Instruments (Time Profiler, SwiftUI template, Hangs).
- Check whether the list is actually lazy. Confirm
ListorLazyVStack; rule out an accidentalVStack. - Use
Self._printChanges()in row view; identify rows re-rendering on every scroll. Common cause: row reads parent state that changes per-scroll (e.g., scroll offset). - Check identity stability — non-stable IDs cause full diff churn.
- Check whether row computes expensive properties in
body(formatting dates, parsing strings). Hoist to data layer. - Check
AsyncImageusage — if rows show images that load synchronously or compute thumbnails inline, replace with a caching solution. - Check
@Observablemodel granularity — if rows read a giant model and any property change re-renders, split into per-row models or pass only needed data. - If using nested
LazyVStacks — flatten or useLazyVGrid. - Consider
drawingGroup()for complex composited rows (renders to offscreen layer). - Last resort: drop to a
UICollectionViewwrapped inUIViewRepresentablefor absolute control.
Red flag in candidates: Reaching for LazyVStack instead of List without naming a specific reason. Or building custom swipe gestures when .swipeActions exists.
Lab preview
Lab 5.1 uses List with .swipeActions (complete and delete) and Form for the add/edit screen. Lab 5.3 uses List in a sidebar and a custom detail view.
Next: Animations & transitions
5.7 — Animations & transitions
Opening scenario
A designer drops a Lottie file in Slack and asks “can we just match this?” The animation: a cart icon scales up, the count badge slides in from the top-right with a bouncy spring, the underlying button shifts color, and the previous count crossfades out. In UIKit, you’d spend a day with UIView.animate(withDuration:delay:options:animations:) and CABasicAnimation, and the result wouldn’t quite match.
In SwiftUI, this is ~30 lines. The framework’s animation system is declarative — you describe what state means visually; SwiftUI interpolates between states when state changes. You don’t manage animation curves manually for each property; you change a value, wrap it in withAnimation, and SwiftUI handles the rest.
| Concept | Use for |
|---|---|
Implicit animation (.animation(_:value:)) | Animate a specific value’s changes |
Explicit animation (withAnimation { }) | Animate a state mutation block |
Transition (.transition(_:)) | Animate insertion/removal |
matchedGeometryEffect | Animate elements moving between layouts |
PhaseAnimator | Multi-phase scripted animations |
KeyframeAnimator | Complex keyframe-based animations |
Custom AnimatableData | Animate non-standard properties |
Concept → Why → How → Code
Implicit animations
struct LikeButton: View {
@State private var isLiked = false
var body: some View {
Image(systemName: isLiked ? "heart.fill" : "heart")
.foregroundStyle(isLiked ? .red : .gray)
.scaleEffect(isLiked ? 1.2 : 1.0)
.animation(.spring(response: 0.3, dampingFraction: 0.6), value: isLiked)
.onTapGesture { isLiked.toggle() }
}
}
.animation(_:value:)says “whenvaluechanges, animate dependent properties”- The
value:parameter is critical (the deprecated 1-arg.animation(_:)animates everything indiscriminately) - Animation applies to the modifiers above it in the chain
Explicit animations
Button("Toggle") {
withAnimation(.spring) {
isExpanded.toggle()
}
}
withAnimation { } wraps the state mutation. Every observable change in the closure is animated with the given curve. This is the most common pattern in real codebases — you control when animations happen at the source, not by sprinkling .animation modifiers across views.
Animation curves
.animation(.linear, value: x)
.animation(.easeIn, value: x)
.animation(.easeOut, value: x)
.animation(.easeInOut(duration: 0.5), value: x)
.animation(.spring, value: x) // default spring
.animation(.spring(duration: 0.4, bounce: 0.3), value: x)
.animation(.bouncy, value: x) // playful spring
.animation(.smooth, value: x) // snappy spring
.animation(.snappy, value: x) // fast spring
.animation(.interpolatingSpring(stiffness: 100, damping: 10), value: x)
iOS 17+ animation presets (.spring, .bouncy, .smooth, .snappy) cover 95% of cases and are physically tuned.
Modifiers:
.animation(.spring.delay(0.2), value: x)
.animation(.spring.speed(2.0), value: x)
.animation(.spring.repeatCount(3, autoreverses: true), value: x)
.animation(.spring.repeatForever(), value: x)
Transitions
Transitions animate insertion and removal:
struct Card: View {
@State private var isShowing = false
var body: some View {
VStack {
Button("Toggle") { withAnimation { isShowing.toggle() } }
if isShowing {
Text("Hello!")
.padding()
.background(.regularMaterial)
.transition(.scale.combined(with: .opacity))
}
}
}
}
Built-in transitions:
.identity(no animation).opacity(fade in/out).scale(grow/shrink, optional anchor).move(edge: .leading)(slide in/out).slide(slide from leading).push(from: .trailing)(system push)
Combine: .scale.combined(with: .opacity)
Asymmetric (different in vs out):
.transition(.asymmetric(
insertion: .move(edge: .leading).combined(with: .opacity),
removal: .scale(scale: 0.8).combined(with: .opacity)
))
matchedGeometryEffect — element morphing
The “magic move” effect: a thumbnail in a grid expands into a full-screen view, smoothly animating its position and size.
struct Gallery: View {
@Namespace private var ns
@State private var selectedID: Photo.ID?
var body: some View {
ZStack {
if let id = selectedID, let photo = photos.first(where: { $0.id == id }) {
AsyncImage(url: photo.fullURL)
.matchedGeometryEffect(id: photo.id, in: ns)
.onTapGesture {
withAnimation(.spring) { selectedID = nil }
}
} else {
LazyVGrid(columns: gridColumns) {
ForEach(photos) { photo in
AsyncImage(url: photo.thumbnailURL)
.frame(height: 100)
.clipped()
.matchedGeometryEffect(id: photo.id, in: ns)
.onTapGesture {
withAnimation(.spring) { selectedID = photo.id }
}
}
}
}
}
}
}
@Namespace— a shared identifier scope for matched elementsmatchedGeometryEffect(id:in:)on source AND destination view- When state changes, SwiftUI interpolates position/size between the two views with the same
id - The “two” views need not exist simultaneously — one disappears, the other appears, SwiftUI animates the morph
This is the same primitive Apple uses for Photos app’s tap-to-expand, App Library card opens, etc.
Animatable properties — what can be interpolated
SwiftUI animates between values of types conforming to VectorArithmetic:
Double,CGFloat,IntCGPoint,CGSize,CGRectColor,Angle- Composites (
AnimatablePair,AnimatableVector)
For custom properties, conform to Animatable:
struct Wave: Shape {
var phase: Double
var animatableData: Double {
get { phase }
set { phase = newValue }
}
func path(in rect: CGRect) -> Path {
// ...
}
}
// Then:
Wave(phase: animating ? 2 * .pi : 0)
.animation(.linear(duration: 2).repeatForever(autoreverses: false), value: animating)
For two animatable properties:
struct ProgressArc: Shape {
var start: Double
var end: Double
var animatableData: AnimatablePair<Double, Double> {
get { AnimatablePair(start, end) }
set { start = newValue.first; end = newValue.second }
}
// ...
}
PhaseAnimator (iOS 17+) — multi-phase scripted animations
Cycle through phases, each with its own visual state:
enum WelcomePhase: CaseIterable {
case start, expand, settle
}
struct WelcomeBanner: View {
var body: some View {
PhaseAnimator(WelcomePhase.allCases, trigger: shouldAnimate) { phase in
Text("Welcome")
.font(.largeTitle)
.scaleEffect(phase == .start ? 0.5 : (phase == .expand ? 1.3 : 1.0))
.opacity(phase == .start ? 0 : 1)
} animation: { phase in
switch phase {
case .start: .easeOut(duration: 0)
case .expand: .spring(duration: 0.4)
case .settle: .spring(duration: 0.3)
}
}
}
}
- SwiftUI cycles through
phasesautomatically - For each phase, you define the visual state and the transition curve
- Re-triggered when
trigger:value changes
Useful for: launch screens, success animations, loading indicators, attention pulses.
KeyframeAnimator (iOS 17+) — complex keyframe sequences
When you need different properties animating on different schedules:
struct BouncyMessage: View {
var body: some View {
Image(systemName: "heart.fill")
.keyframeAnimator(initialValue: AnimationValues(), trigger: tapCount) { content, value in
content
.scaleEffect(value.scale)
.rotationEffect(value.rotation)
.offset(y: value.verticalOffset)
} keyframes: { _ in
KeyframeTrack(\.scale) {
CubicKeyframe(1.3, duration: 0.2)
SpringKeyframe(1.0, duration: 0.5)
}
KeyframeTrack(\.rotation) {
CubicKeyframe(.degrees(-10), duration: 0.15)
CubicKeyframe(.degrees(10), duration: 0.15)
SpringKeyframe(.degrees(0), duration: 0.4)
}
KeyframeTrack(\.verticalOffset) {
LinearKeyframe(-20, duration: 0.2)
SpringKeyframe(0, duration: 0.5)
}
}
}
}
struct AnimationValues {
var scale = 1.0
var rotation = Angle.zero
var verticalOffset = 0.0
}
- One animator value (
AnimationValues) holds all animated properties - Each
KeyframeTrackanimates one property along a sequence of keyframes CubicKeyframe,SpringKeyframe,LinearKeyframe,MoveKeyframe- Powerful for complex micro-interactions: notification arrivals, achievement unlocks, success states
Gesture-driven animations
struct Drag: View {
@State private var offset: CGSize = .zero
var body: some View {
Circle()
.fill(.blue)
.frame(width: 80, height: 80)
.offset(offset)
.gesture(
DragGesture()
.onChanged { offset = $0.translation }
.onEnded { _ in
withAnimation(.spring) { offset = .zero }
}
)
}
}
Direct gesture tracking (no animation) for the drag itself, then spring back on release. Common pattern for cards, sheets, swipe interactions.
Animation in lists (insertion/deletion)
List {
ForEach(items) { item in
Row(item: item)
.transition(.slide.combined(with: .opacity))
}
.onDelete { offsets in
withAnimation { items.remove(atOffsets: offsets) }
}
}
List animates inserts/removes automatically when wrapped in withAnimation. Custom transitions via .transition on ForEach children.
Reduce Motion accessibility
@Environment(\.accessibilityReduceMotion) var reduceMotion
var body: some View {
Image(systemName: "star.fill")
.scaleEffect(isPulsing ? 1.2 : 1.0)
.animation(reduceMotion ? nil : .spring.repeatForever(), value: isPulsing)
}
Always check accessibilityReduceMotion for long, repeating, or parallax animations. Respect it.
In the wild
- Apple Photos uses
matchedGeometryEffect(or its UIKit equivalent) for the tap-to-zoom transition. - Robinhood uses keyframe animations for the success state when an order fills — number scales, color flashes, haptic fires.
- Instagram Stories uses gesture-driven progressive spring animations for the swipe-down-to-dismiss gesture.
- Lyft uses
PhaseAnimator(or similar pre-iOS 17 hacks) for the driver-arriving sequence — pulse, scale, slide. - Airbnb uses subtle spring animations on every primary interaction; their internal design system enforces a small set of spring presets.
Common misconceptions
- “Use
withAnimationeverywhere.” Overusing it animates state that shouldn’t visually transition (e.g., loading state replacing content). Be intentional. - “
.animation(_:)(1-arg) is deprecated for no reason.” It’s deprecated because it animated everything changing, often unintentionally. Use the value-bound.animation(_:value:). - “Springs are slower than ease curves.” Modern springs (iOS 17 presets) feel faster than ease curves because they decelerate naturally. Designers prefer them for direct-manipulation UI.
- “
matchedGeometryEffectonly works for moving views.” It also works for morphing (different sizes/shapes). The two views can be completely different — only id and namespace match. - “Custom
Animatableis rare.” It’s surprisingly common for custom shapes, charts, and progress indicators. Worth knowing the protocol.
Seasoned engineer’s take
Define your animation vocabulary once and reuse it. A typical app has:
.spring(duration: 0.35, bounce: 0.2)for primary interactions (taps, navigation).smoothor.easeOut(duration: 0.25)for content fades- A single “success” keyframe animator for confirmation states
Reduce Motionoverrides
Then every screen looks consistent. Without this, animations drift — one engineer uses .spring, another .easeInOut(duration: 0.3), a third hand-tunes for “feel” — and the app feels disjointed.
For complex sequences (multi-step success animations, onboarding), reach for PhaseAnimator or KeyframeAnimator. They’re more readable than chained DispatchQueue.main.asyncAfter(deadline:) with withAnimation.
Avoid implicit .animation(_:value:) for animations triggered by user gestures — explicit withAnimation at the gesture’s end is cleaner. Implicit animations are for data-driven changes (state updated from network, model mutation).
TIP: When debugging animations, slow time globally: enable “Slow Animations” in iOS Simulator (Debug menu) or “Slow Animations” in the Simulator app’s Window menu. You’ll see what’s actually happening.
WARNING:
animation(.repeatForever())does not stop when the view leaves the screen — it continues consuming CPU. Pair with a state that disables the animation when not needed, or use.task { try? await Task.sleep(...) }for time-bounded effects.
Interview corner
Junior-level: “What’s the difference between implicit and explicit animations?”
Implicit: .animation(_:value:) modifier — SwiftUI animates property changes triggered by changes to the bound value. Explicit: withAnimation { state.x = newValue } — SwiftUI animates any observable changes inside the closure. Explicit is more controlled (you choose when); implicit is more declarative (the view describes when it animates).
Mid-level: “Implement a smooth thumbnail-to-fullscreen transition for a photo gallery.”
@Namespace + matchedGeometryEffect. Both the thumbnail in the grid and the fullscreen view declare the same matchedGeometryEffect(id: photo.id, in: namespace). Wrap the state change that toggles between them in withAnimation(.spring). SwiftUI interpolates position and size between the two declared geometries. The two views can use entirely different child content; only the matched geometry animates.
Senior-level: “Design the animation system for a fintech app — what’s reusable, what’s per-screen, and how do you enforce consistency?”
Reusable layer:
- A
Motionnamespace with named animations:.appPrimary(spring, 0.35s, bounce 0.2),.appFade(easeOut, 0.2s),.appBouncy(bouncy preset),.appAttention(custom keyframe sequence for success). Engineers reference these by name, never construct ad-hoc. - A
Transitionsnamespace with named transitions:.appCard(asymmetric scale+opacity),.appSheet,.appBadge. - A
MotionTokensstruct in the design system package. - Custom
ViewModifiers for “successFlash”, “errorShake”, “loadingPulse” — reusable visual feedback. - A
MatchedGeometryhelper that pairs source/destination with consistent namespacing.
Enforcement:
- Lint rule: ban literal
.animation(.spring(...))outside theMotionnamespace. - Code review checklist: any animation requires named motion token or design review.
- Audit screen for
Reduce Motioncompliance before ship.
Per-screen:
- Onboarding:
PhaseAnimatorsequences, longer durations OK. - Trade execution success:
KeyframeAnimatorcelebrating fill with scale/color/haptic. - List item enter/exit: standard transitions, fast (200ms max — long list animations are jarring).
Red flag in candidates: Hand-tuned .animation(.easeInOut(duration: 0.347)) everywhere. Indicates no system thinking.
Lab preview
Lab 5.2 combines Canvas, PhaseAnimator, matchedGeometryEffect, and KeyframeAnimator to build a chart dashboard with bar entry animation, value-change keyframes, and tap-to-expand detail cards.
Next: Custom views & ViewModifiers
5.8 — Custom views & ViewModifiers
Opening scenario
Your app has 47 screens. The “primary action” button appears on 38 of them. Today it’s defined ad-hoc on each screen — some are Button { ... } .foregroundStyle(.white) .frame(maxWidth: .infinity) .padding() .background(.blue) .clipShape(...), others use slightly different paddings or corner radii. Design ships a new brand: rounded corners 8 → 12, padding 12 → 14, color blue → indigo. You have to find and update 38 places. Some you’ll miss. Some QA flags.
This is what ButtonStyle, ViewModifier, and reusable components fix. SwiftUI’s composition story is excellent: you can build a small palette of primitives once, and screens become declarative compositions of those primitives. When the brand changes, you change the primitive.
| Tool | Use for |
|---|---|
Custom View | Reusable UI components (cards, headers, badges) |
ViewModifier | Reusable groups of modifiers (card styling, headers) |
ButtonStyle / PrimitiveButtonStyle | Customizing every Button in a subtree |
LabelStyle, MenuStyle, etc. | Customizing other system controls |
TextFieldStyle | Custom text-input styling |
EnvironmentKey | Custom environment values for theming |
#Preview macro | Preview variants in Xcode |
Concept → Why → How → Code
Custom View — your first abstraction
struct PrimaryButton: View {
let title: String
let action: () -> Void
var body: some View {
Button(action: action) {
Text(title)
.font(.headline)
.foregroundStyle(.white)
.frame(maxWidth: .infinity)
.padding()
.background(Color.accentColor, in: .rect(cornerRadius: 12))
}
}
}
// Usage
PrimaryButton(title: "Continue") { goNext() }
Pros: simple, type-safe, no surprises.
Cons: every variation needs a new view or initializer parameters. Doesn’t compose well with other modifiers (you can’t say PrimaryButton(...).destructive).
Use for: composite components that are conceptually one thing (cards, headers, badges, empty states).
ButtonStyle — restyle every button
struct PrimaryButtonStyle: ButtonStyle {
func makeBody(configuration: Configuration) -> some View {
configuration.label
.font(.headline)
.foregroundStyle(.white)
.frame(maxWidth: .infinity)
.padding()
.background(Color.accentColor, in: .rect(cornerRadius: 12))
.opacity(configuration.isPressed ? 0.7 : 1.0)
.scaleEffect(configuration.isPressed ? 0.97 : 1.0)
.animation(.spring(duration: 0.2), value: configuration.isPressed)
}
}
extension ButtonStyle where Self == PrimaryButtonStyle {
static var primary: PrimaryButtonStyle { PrimaryButtonStyle() }
}
// Usage
Button("Continue") { goNext() }
.buttonStyle(.primary)
ButtonStyle is the right choice for buttons because:
- Preserves the semantics of
Button(accessibility, action, focus) - Gives you
configuration.isPressedfor free - Composes with other view modifiers (
.disabled,.tint,.controlSize) - Cascades — apply once at a container, and all child buttons restyle:
VStack {
Button("Save") { ... }
Button("Cancel") { ... }
}
.buttonStyle(.primary) // applies to both
PrimitiveButtonStyle lets you change the trigger gesture (long-press, double-tap). Rarely needed.
LabelStyle, MenuStyle, ToggleStyle, etc.
Same pattern for other controls:
struct BadgeLabelStyle: LabelStyle {
func makeBody(configuration: Configuration) -> some View {
HStack(spacing: 4) {
configuration.icon
.foregroundStyle(.tint)
configuration.title
.font(.caption)
}
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(.tint.opacity(0.15), in: .capsule)
}
}
// Usage
Label("3 unread", systemImage: "bell")
.labelStyle(BadgeLabelStyle())
.tint(.orange)
Similar protocols: ToggleStyle, PickerStyle, MenuStyle, ProgressViewStyle, GaugeStyle, DatePickerStyle, NavigationSplitViewStyle. All follow make(...) -> some View with a Configuration.
TextFieldStyle — custom text inputs
struct RoundedTextFieldStyle: TextFieldStyle {
func _body(configuration: TextField<Self._Label>) -> some View {
configuration
.padding(12)
.background(Color(uiColor: .secondarySystemBackground))
.clipShape(.rect(cornerRadius: 8))
}
}
extension TextFieldStyle where Self == RoundedTextFieldStyle {
static var rounded: RoundedTextFieldStyle { RoundedTextFieldStyle() }
}
// Usage
TextField("Email", text: $email)
.textFieldStyle(.rounded)
(TextFieldStyle uses an underscored protocol member by historical accident — it works.)
ViewModifier — reusable modifier chains
When a sequence of modifiers should be reused but it’s not a button/control:
struct CardModifier: ViewModifier {
var padding: CGFloat = 16
var cornerRadius: CGFloat = 12
func body(content: Content) -> some View {
content
.padding(padding)
.background(.background)
.clipShape(.rect(cornerRadius: cornerRadius))
.shadow(color: .black.opacity(0.1), radius: 8, y: 2)
}
}
extension View {
func card(padding: CGFloat = 16, cornerRadius: CGFloat = 12) -> some View {
modifier(CardModifier(padding: padding, cornerRadius: cornerRadius))
}
}
// Usage
VStack {
Text("Hello")
Text("World")
}
.card()
ViewModifieris astructwith abody(content:)returningsome View- Provide an
extension Viewhelper for ergonomic call sites - Same reusability win as a function, but participates in SwiftUI’s diffing
Environment-based theming
For values that propagate through the view tree (theme, currency, locale):
struct AppTheme {
var primaryColor: Color = .indigo
var cornerRadius: CGFloat = 12
var titleFont: Font = .system(.title, design: .rounded, weight: .bold)
}
private struct AppThemeKey: EnvironmentKey {
static let defaultValue = AppTheme()
}
extension EnvironmentValues {
var appTheme: AppTheme {
get { self[AppThemeKey.self] }
set { self[AppThemeKey.self] = newValue }
}
}
// Inject
ContentView()
.environment(\.appTheme, AppTheme(primaryColor: .pink, cornerRadius: 16, titleFont: .largeTitle))
// Read
struct StyledTitle: View {
@Environment(\.appTheme) var theme
let text: String
var body: some View {
Text(text)
.font(theme.titleFont)
.foregroundStyle(theme.primaryColor)
}
}
Combined with ViewModifiers, this gives you a full theming system: components read the environment theme; design system swaps the value at the top to rebrand.
Modern Swift (5.10+) has @Entry macro shortcut:
extension EnvironmentValues {
@Entry var appTheme: AppTheme = AppTheme()
}
One line — no key struct, no extension scaffolding.
Composition pattern — slot-based components
Components that take child views:
struct Card<Header: View, Content: View>: View {
@ViewBuilder let header: () -> Header
@ViewBuilder let content: () -> Content
var body: some View {
VStack(alignment: .leading, spacing: 8) {
header()
.font(.headline)
content()
}
.card()
}
}
// Usage
Card {
Text("Today's stats")
} content: {
Text("12 active users")
Text("3 conversions")
}
@ViewBuilder enables the trailing-closure DSL (multiple statements, conditionals). Critical for ergonomic component APIs.
Group and EquatableView
Group lets you apply modifiers to multiple views without a layout container:
Group {
Text("First")
Text("Second")
Text("Third")
}
.font(.headline)
.foregroundStyle(.blue)
EquatableView short-circuits re-renders when wrapped value’s == returns true:
struct ExpensiveChart: View, Equatable {
let data: [Double]
var body: some View { ... }
}
// Usage
EquatableView(content: ExpensiveChart(data: data))
If data == previousData, SwiftUI skips body. Use for expensive views that often receive equal data.
#Preview macro (iOS 17+)
#Preview("Default") {
PrimaryButton(title: "Continue") {}
}
#Preview("Disabled", traits: .sizeThatFitsLayout) {
PrimaryButton(title: "Continue") {}
.disabled(true)
}
#Preview("Dark", traits: .sizeThatFitsLayout) {
PrimaryButton(title: "Continue") {}
.preferredColorScheme(.dark)
}
Replaces the old PreviewProvider boilerplate. Multiple previews per file. Named. Supports traits (size, color scheme, locale).
For interactive previews:
#Preview("Interactive") {
@Previewable @State var text = ""
return TextField("Type", text: $text)
.textFieldStyle(.rounded)
.padding()
}
@Previewable (iOS 18+) lets you declare state directly in a preview block.
Component library — packaging for reuse
For a design system, ship as a Swift Package:
DesignSystem/
├── Package.swift
└── Sources/DesignSystem/
├── Buttons/
│ ├── PrimaryButtonStyle.swift
│ └── SecondaryButtonStyle.swift
├── TextFields/
│ └── RoundedTextFieldStyle.swift
├── Modifiers/
│ └── CardModifier.swift
├── Components/
│ ├── Card.swift
│ ├── Badge.swift
│ └── EmptyState.swift
└── Theme/
└── AppTheme.swift
Apps import DesignSystem. Updates ship as version bumps. Multiple apps share. (Lab 5.4 builds exactly this.)
Accessibility in custom components
Custom components must explicitly forward or set accessibility:
struct Badge: View {
let count: Int
var body: some View {
Text("\(count)")
.font(.caption.weight(.bold))
.padding(.horizontal, 6)
.padding(.vertical, 2)
.background(.red, in: .capsule)
.foregroundStyle(.white)
.accessibilityLabel("\(count) unread")
}
}
For composite components, decide:
- Should the children be discoverable separately?
- Or should the component be one accessibility element?
.accessibilityElement(children: .combine) // one element, combined labels
// or
.accessibilityElement(children: .ignore) // one element, custom label
Covered in depth in chapter 5.13.
In the wild
- Airbnb’s Epoxy (their iOS UI framework, partially open-sourced) is conceptually a design-system-as-code: components, styles, layouts as composable primitives.
- Apple’s SwiftUI sample code uses
ButtonStyleextensively for consistent app-wide buttons (see WWDC sample projects). - Stripe’s iOS SDK ships a design-system Swift Package; custom
ButtonStyle,TextFieldStyle, and reusable card components are exported. - Mozilla Firefox iOS (open source) has a
ComponentLibrarySPM module with their button/input/card styles. - Apollo’s RIP had a small private design system for the Reddit client —
RedditButton,RedditTextField,RedditCard.
Common misconceptions
- “Custom
ViewandViewModifierare interchangeable.” Not quite. A customViewis its own entity (you compose with it). AViewModifieris applied to existing content. UseViewwhen the thing is something; useViewModifierwhen it adds something. - “
ButtonStyleis just styling.” It’s also interaction state (configuration.isPressed) and accessibility. Recreating buttons withonTapGestureloses both. - “You can’t share styles across apps.” Swift Packages make it trivial. Most teams ship a design-system package.
- “Theming requires a giant
EnvironmentObject.” A simplestructwith anEnvironmentKeyis enough. Avoid making theme a class unless you need to mutate it at runtime (dark mode swap is handled by the system). - “
#Previewis just for new code.” Migrating oldPreviewProviderto#Previewis mostly mechanical and removes boilerplate; do it as you touch files.
Seasoned engineer’s take
The hierarchy I use:
ButtonStyle/LabelStyle/TextFieldStylefor every input and control. Never style controls inline.ViewModifierfor reusable visual treatments (cards, headers, badges) that aren’t controls.- Custom
Viewfor genuinely reusable composite components (empty state, error view, loading state). Group+Viewextension for one-off compositions inside a feature.
When I see a screen with 5+ modifiers applied to a button, I extract a ButtonStyle. When I see the same combination of (padding, background, corner radius) twice, I extract a ViewModifier. When I see a screen that’s 80% existing components and 20% new content, the architecture is healthy.
Avoid the “10-parameter init” trap. If a component grows past ~5 parameters, split it. Either decompose into smaller components, or pass @ViewBuilder closures for the variable parts.
TIP: Inside a
ButtonStyle, theconfiguration.labelis the original button’s label — preserve it. Don’t replace it withText(...); you’d lose the call-site flexibility.
WARNING: Don’t put
@Statein aViewModifierunless you really mean it. It’ll be re-instantiated per application. For stateful modifiers (e.g., shake-on-error), it works but is subtle.
Interview corner
Junior-level: “When would you create a ViewModifier vs a custom View?”
ViewModifier when you have a reusable set of modifiers to apply to existing content (e.g., “card” styling — padding, background, shadow). Custom View when you have a reusable component with its own identity and content (e.g., a Badge view with text inside). Rule of thumb: if it modifies content, it’s a modifier; if it is content, it’s a view.
Mid-level: “Walk through implementing a design-system primary button. Why use ButtonStyle over a custom View?”
struct PrimaryButtonStyle: ButtonStyle {
func makeBody(configuration: Configuration) -> some View {
configuration.label
.font(.headline).foregroundStyle(.white)
.frame(maxWidth: .infinity).padding()
.background(Color.accentColor, in: .rect(cornerRadius: 12))
.opacity(configuration.isPressed ? 0.7 : 1.0)
}
}
extension ButtonStyle where Self == PrimaryButtonStyle {
static var primary: PrimaryButtonStyle { .init() }
}
ButtonStyle preserves the semantics of Button (accessibility, focus, action), provides isPressed for free, composes with .disabled / .controlSize, and cascades via .buttonStyle(.primary) on a container to all child buttons. A custom View wrapper loses all of that — you’d reinvent press states with gestures and lose Button’s accessibility traits.
Senior-level: “Design a design-system package architecture for an app that supports 3 brand variants (dark/light/holiday).”
Package layout:
DesignSystem/Tokens/—Colors.swift,Spacing.swift,Typography.swift— static design tokens per brand.DesignSystem/Theme/—AppThemestruct with the tokens,EnvironmentKey,View.theme(_:)modifier.DesignSystem/Styles/—ButtonStyles,TextFieldStyles, etc., that read tokens from@Environment(\.appTheme).DesignSystem/Components/—Card,EmptyState,LoadingView, etc., reading theme.DesignSystem/Brands/—DarkBrand.swift,LightBrand.swift,HolidayBrand.swift— staticAppThemeinstances.
App init:
RootView()
.environment(\.appTheme, Brand.current)
Brand.current is determined at launch from settings/A-B test.
Everything below the root reads from environment. Switching brand requires no view changes. Holiday brand can swap colors, corner radii, even iconography by overriding the theme struct’s properties.
For runtime brand switching (e.g., user toggles a “holiday mode” preference), make brand a @State at the root and animate the change.
Red flag in candidates: Reaching for inheritance (“BaseButton subclass”) to handle button variants. Indicates an OOP-first mindset that doesn’t fit SwiftUI’s composition-first model.
Lab preview
Lab 5.4 builds a complete design-system Swift Package with PrimaryButtonStyle, RoundedTextFieldStyle, CardModifier, Badge, and EmptyState — each with #Preview blocks demonstrating variants.
Next: SwiftUI ↔ UIKit interop
5.9 — SwiftUI ↔ UIKit interop
Opening scenario
You’re building a SwiftUI map screen. SwiftUI’s Map view (iOS 17+) covers most cases — but you need to drop custom annotation views, handle camera animation programmatically, and read the underlying gesture recognizer to detect long-press-and-drag. SwiftUI’s Map doesn’t expose those hooks. Time to wrap MKMapView in a UIViewRepresentable.
Or: you have a legacy UIKit app and your team wants to start writing new screens in SwiftUI. Each new SwiftUI screen needs to push from existing UINavigationControllers. Time for UIHostingController.
Interop goes both ways. In 2026, almost every shipping iOS app is a mixed codebase. Knowing how to bridge cleanly — and where the pitfalls are — is non-negotiable.
| Direction | Use |
|---|---|
UIKit UIView → SwiftUI | UIViewRepresentable |
UIKit UIViewController → SwiftUI | UIViewControllerRepresentable |
| SwiftUI → UIKit (as a UIView) | UIHostingConfiguration (cells), wrap UIHostingController.view |
| SwiftUI → UIKit (as a VC) | UIHostingController |
AppKit NSView → SwiftUI | NSViewRepresentable (covered in chapter 5.11) |
Concept → Why → How → Code
UIViewRepresentable — wrap a UIKit view
The minimal protocol:
struct WebView: UIViewRepresentable {
let url: URL
func makeUIView(context: Context) -> WKWebView {
WKWebView()
}
func updateUIView(_ uiView: WKWebView, context: Context) {
uiView.load(URLRequest(url: url))
}
}
// Usage
WebView(url: URL(string: "https://example.com")!)
.frame(height: 400)
makeUIView(context:)is called once to create the viewupdateUIView(_:context:)is called whenever SwiftUI re-evaluates with new statecontextprovides access to coordinator and environment
The tricky part is updateUIView: you must reconcile the existing view to match the current SwiftUI state. Idempotent, cheap, and handles all properties.
Coordinator — UIKit delegate callbacks
UIKit delegates need an object. SwiftUI views are structs. The bridge:
struct MapView: UIViewRepresentable {
@Binding var region: MKCoordinateRegion
func makeCoordinator() -> Coordinator {
Coordinator(self)
}
func makeUIView(context: Context) -> MKMapView {
let map = MKMapView()
map.delegate = context.coordinator
map.setRegion(region, animated: false)
return map
}
func updateUIView(_ map: MKMapView, context: Context) {
// Only update if changed externally to avoid feedback loops
if !context.coordinator.isUserDriven, map.region != region {
map.setRegion(region, animated: true)
}
}
final class Coordinator: NSObject, MKMapViewDelegate {
var parent: MapView
var isUserDriven = false
init(_ parent: MapView) {
self.parent = parent
}
func mapView(_ mapView: MKMapView, regionDidChangeAnimated animated: Bool) {
isUserDriven = true
parent.region = mapView.region
DispatchQueue.main.async { self.isUserDriven = false }
}
}
}
Coordinator holds the delegate. The parent struct is passed by value (latest copy) so the coordinator always has the current bindings.
The feedback loop problem
When SwiftUI state changes → updateUIView runs → sets UIKit state → UIKit delegate fires → updates SwiftUI state → updateUIView runs again → loop.
Solutions:
- Compare before applying:
if uiView.value != newValue { uiView.value = newValue } - Flag user-driven changes as above (
isUserDriven) - Coalesce on next runloop with
DispatchQueue.main.async
Every wrapper needs to think about this. Bugs caused by feedback loops manifest as jitter, infinite re-renders, or “the view fights back”.
UIViewControllerRepresentable — wrap a UIViewController
Same shape, but for VCs:
struct ImagePicker: UIViewControllerRepresentable {
@Binding var image: UIImage?
@Environment(\.dismiss) var dismiss
func makeCoordinator() -> Coordinator {
Coordinator(self)
}
func makeUIViewController(context: Context) -> UIImagePickerController {
let picker = UIImagePickerController()
picker.delegate = context.coordinator
picker.sourceType = .photoLibrary
return picker
}
func updateUIViewController(_ vc: UIImagePickerController, context: Context) {
// typically nothing
}
final class Coordinator: NSObject, UIImagePickerControllerDelegate, UINavigationControllerDelegate {
let parent: ImagePicker
init(_ parent: ImagePicker) { self.parent = parent }
func imagePickerController(_ picker: UIImagePickerController,
didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey : Any]) {
if let img = info[.originalImage] as? UIImage {
parent.image = img
}
parent.dismiss()
}
func imagePickerControllerDidCancel(_ picker: UIImagePickerController) {
parent.dismiss()
}
}
}
// Usage
.sheet(isPresented: $showPicker) {
ImagePicker(image: $selectedImage)
}
Useful for VCs SwiftUI hasn’t natively replaced: MFMailComposeViewController, custom camera UIs, PKAddPaymentPassViewController, etc.
Passing changes both ways — Bindings
The pattern: SwiftUI state → wrapper struct → updateUIView propagates to UIKit. UIKit changes → coordinator delegate → mutates the binding → SwiftUI re-renders → updateUIView (debounced via the isUserDriven flag).
Avoid two-way bindings that update on every frame (e.g., scroll position) without throttling — you’ll cause re-render storms.
Sizing
By default, UIKit views report their intrinsicContentSize. SwiftUI uses that for layout. If the wrapped view doesn’t have one (a UIScrollView, a MKMapView), wrap with .frame(...):
WebView(url: url).frame(height: 400)
MapView(region: $region).frame(height: 300)
For self-sizing in lists, set the intrinsic size explicitly in the UIKit view, or override sizeThatFits(_:) in a UIView subclass.
UIHostingController — embed SwiftUI in UIKit
let host = UIHostingController(rootView: ProfileView(user: user))
navigationController?.pushViewController(host, animated: true)
UIHostingControllerIS aUIViewControllerhosting a SwiftUI hierarchy- Push, present, embed in tab bars, child of other VCs
- Pass observable state via environment as usual:
let host = UIHostingController(
rootView: ProfileView().environment(authService)
)
For inline embedding (SwiftUI view inside a UIKit view):
class MyVC: UIViewController {
override func viewDidLoad() {
super.viewDidLoad()
let host = UIHostingController(rootView: HeaderView())
addChild(host)
host.view.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(host.view)
NSLayoutConstraint.activate([
host.view.topAnchor.constraint(equalTo: view.topAnchor),
host.view.leadingAnchor.constraint(equalTo: view.leadingAnchor),
host.view.trailingAnchor.constraint(equalTo: view.trailingAnchor),
])
host.didMove(toParent: self)
}
}
The full UIKit child-VC dance — add, constrain, didMove.
UIHostingConfiguration — SwiftUI in cells (iOS 16+)
class FeedVC: UIViewController, UICollectionViewDataSource {
var collectionView: UICollectionView!
func collectionView(_ cv: UICollectionView,
cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
let cell = cv.dequeueReusableCell(withReuseIdentifier: "cell", for: indexPath)
let item = items[indexPath.item]
cell.contentConfiguration = UIHostingConfiguration {
FeedCardView(item: item)
}
return cell
}
}
UIHostingConfiguration was Apple’s response to “we want SwiftUI cells but UICollectionView is faster than LazyVGrid”. Native interop. No coordinator. Reuse handled correctly. The right answer when you have a UIKit list with SwiftUI rows.
Navigation across the boundary
SwiftUI pushes via NavigationStack. UIKit pushes via UINavigationController.pushViewController. When a SwiftUI view is embedded in a UIKit nav controller (or vice versa), you can use either:
// Inside SwiftUI hosted in UIKit nav:
struct HostedView: View {
@Environment(\.uikitNavigationController) var nav // custom env key
var body: some View {
Button("Push UIKit") {
nav?.pushViewController(SomeUIKitVC(), animated: true)
}
}
}
// Inject the nav controller via env in the hosting controller setup
A common pattern: each push-able screen is a UIHostingController containing a SwiftUI view. The SwiftUI view requests navigation via a closure or callback that the host responds to by pushing.
Sharing state across the boundary
@Observable instances work across boundaries — pass them via environment:
// UIKit side
let auth = AuthService()
let host = UIHostingController(rootView: ProfileView().environment(auth))
// And the UIKit VC can hold the same `auth` reference, mutate it, and SwiftUI views update.
For old ObservableObject, same idea with .environmentObject(auth).
For one-way data flow (push state from UIKit to SwiftUI), pass via the rootView’s properties and update the rootView:
host.rootView = ProfileView(user: newUser)
This re-evaluates the root with new props.
When NOT to interop
- Don’t wrap simple UIKit primitives that SwiftUI already has.
UILabel→ useText.UIButton→ useButton. - Don’t wrap UIKit views for “performance” without evidence. SwiftUI’s
Text,List,LazyVStackare already fast. - Don’t push UIKit into a SwiftUI screen to avoid learning SwiftUI patterns. Tech debt.
Interop is a tool for specific gaps:
- UIKit-only APIs (PassKit, ReplayKit, AVKit, MapKit’s full surface, custom camera UIs)
- Specialized 3rd-party UIKit libraries with no SwiftUI equivalent
- Performance-critical custom drawing (sometimes
CALayerwork) - Gradual migration from UIKit codebases
Threading
UIViewRepresentablemethods run on main thread (it’s@MainActor-ish)updateUIViewmay be called many times; keep it cheap and idempotent- Don’t dispatch UIKit mutations to background; you’ll crash
@MainActor and Swift 6
In Swift 6 strict concurrency, UIView and UIViewController subclasses are @MainActor-isolated. The UIViewRepresentable methods are also main-isolated. Things mostly Just Work, but be careful:
- Coordinator methods called from UIKit delegates are on main (since UIKit is main-actor)
- If you spawn a
Task { ... }in a delegate method that updates SwiftUI bindings, mark it@MainActoror be sure the binding mutation happens on main
In the wild
- Robinhood wraps a charting library (originally OpenGL-based) in
UIViewRepresentablefor SwiftUI screens; the new candles render in SwiftUI but the chart canvas remains UIKit. - Apollo mixed SwiftUI heavily but kept the comments thread as a
UICollectionViewfor performance reasons, embedded viaUIHostingConfiguration. - Uber has SwiftUI driver-side screens that embed
MKMapViewviaUIViewRepresentablefor full camera/annotation control. - Apple Wallet’s “Add to Wallet” flow uses
PKAddPassesViewController(UIKit) presented from SwiftUI viaUIViewControllerRepresentable. - Most production apps in 2026 have a
Bridging/folder with 5–20 representable wrappers for things SwiftUI doesn’t cover yet.
Common misconceptions
- “Wrapping UIKit always means losing SwiftUI animations.” Not necessarily —
UIViewanimations can be coordinated with SwiftUI state viaupdateUIViewandUIView.animate. But it’s manual. - “
updateUIViewis called once.” It’s called many times — on every state change observed by the wrapping SwiftUI view. Must be idempotent and cheap. - “Coordinator is for state.” It’s primarily for delegates (the UIKit object holding callbacks). It can hold state, but that state is per-coordinator instance and rebuilt across some scenarios.
- “
UIHostingControlleris heavy.” Not particularly. Embedding a SwiftUI view as a single cell is fine. Embedding 1,000 hosting controllers as cells is slow — useUIHostingConfigurationinstead. - “
@Bindingto a UIKit-driven value is enough.” Without debounce/coalesce logic, you’ll create feedback loops. Always think about who writes to the binding and when.
Seasoned engineer’s take
Treat representables as a bounded interface. Each one has:
- A clear, narrow purpose (wrap this one UIKit thing)
- A coordinator handling delegate callbacks
- Explicit feedback-loop prevention
- Documented sizing assumptions (does it need
.frame(...)?) - A
#Previewshowing it in isolation
Keep these in a dedicated Bridging/ folder. Treat them like third-party code: review carefully, add tests for the bridge behavior, and isolate from app logic.
For new code, start in SwiftUI. Drop to UIKit only when you hit a specific gap. Resist the urge to “just use the UIKit version because it’s more flexible” — you trade flexibility for the entire SwiftUI ecosystem (animations, accessibility, layout, multi-platform).
For old codebases, embed SwiftUI feature-by-feature in UIHostingController. Each new screen is SwiftUI; integration is via well-defined boundaries (push, pop, environment-shared state). Over time, the SwiftUI portion grows.
TIP: When debugging “the UIKit view isn’t updating”, check that
updateUIViewactually runs (
WARNING: Never capture
selfstrongly from a closure stored on a UIKit delegate inside a Representable’s Coordinator. Standard memory-leak pattern. Useweakor pass values explicitly.
Interview corner
Junior-level: “How do you embed a UILabel in a SwiftUI view?”
Trick question — use Text, not UILabel. SwiftUI has a native equivalent. Wrapping basic UIKit primitives is wasted effort. UIViewRepresentable is for things SwiftUI doesn’t cover (MapKit, custom drawing, third-party UIKit widgets).
Mid-level: “Walk through building a UIViewRepresentable wrapper for MKMapView with two-way region binding.”
Implement makeUIView to create and configure MKMapView; set delegate to context.coordinator. Implement updateUIView to apply state from the SwiftUI side — guarded against feedback loops (skip if change came from the user via the coordinator). Implement makeCoordinator returning a class that conforms to MKMapViewDelegate. In mapView(_:regionDidChangeAnimated:), mark isUserDriven = true, update parent.region (the binding), and reset the flag on next runloop. Without that guard, the SwiftUI side writes the region back to the map, triggering another delegate call, ad infinitum.
Senior-level: “Your app is 80% UIKit, and the team wants to start writing new features in SwiftUI. Outline the migration architecture, the boundary conventions, and how you handle shared state.”
Boundary architecture:
- Each new SwiftUI screen wrapped in
UIHostingController - Existing
UINavigationControllers push hosting controllers seamlessly (pushViewController(host, animated: true)) - Existing tab-bar controller adds SwiftUI tabs by wrapping them in hosting controllers
- For existing screens that need partial SwiftUI (e.g., a SwiftUI banner inside a UIKit list), use
UIHostingConfigurationfor cells,UIHostingControlleras a child VC for sections
Shared state:
- Migrate to an
@Observable(orObservableObject) layer for cross-feature state — auth, user, feature flags - UIKit screens hold a reference and observe via
withObservationTracking { ... }(iOS 17+) or Combine (older) and update UI manually - SwiftUI screens consume via
@Environment(Type.self)injected at hosting controller creation
Navigation:
- New screens use SwiftUI
NavigationStackonly within their own SwiftUI subgraphs - Cross-screen navigation goes through the existing UIKit nav controller (predictable, testable)
- Per-screen, the hosting controller receives a callback closure for “navigate to X”; the closure pushes the next hosting controller
Conventions:
- All bridging code in a
Bridging/module, reviewed carefully - Each
Representablehas a#Preview - Each
UIHostingControllersetup has a factory function (Screens.makeProfile()) so the construction is testable - Migration tracked in a doc — N screens UIKit, M screens SwiftUI, target % per quarter
Pitfalls handled:
- Navigation bar visibility differs between UIKit and SwiftUI — set
navigationBarHiddenper-screen, document the convention - iOS 16+
NavigationStackkeyboard-avoidance differs from UIKit — test both paths - Sheets presented from UIKit show fine in a SwiftUI hosting child but inherit the UIKit presentation style; specify modally
Red flag in candidates: Saying “we should rewrite everything in SwiftUI before adding features.” Indicates poor judgment for incremental migration.
Lab preview
The Phase 5 labs are pure SwiftUI, but Lab 5.3 (Multiplatform Notes) optionally uses NSViewRepresentable for macOS-specific behaviors (chapter 5.11 covers AppKit interop in depth).
Next: Universal & multiplatform apps
5.10 — Universal & multiplatform apps
Opening scenario
Your iOS app is doing well. The product team wants a Mac version. Options on the table:
- Mac Catalyst — flip a checkbox, ship iPad-on-Mac. Fast, but the result feels foreign on macOS.
- Separate AppKit Mac target — full native fidelity, but a separate codebase to maintain.
- SwiftUI multiplatform — single target, single codebase, runs on iPhone, iPad, and Mac with platform-appropriate adaptations.
In 2026, option 3 is the default for new apps and the right answer for most existing iOS-only apps adding Mac support. SwiftUI’s platform abstractions (Scene, WindowGroup, NavigationSplitView, toolbar placements) generate native-feeling UI on each platform from the same view code.
This chapter is how to actually do it — the scene hierarchy, the conditionals, the universal primitives, and when to drop down to per-platform code.
| Approach | Codebase | Mac fidelity | Best for |
|---|---|---|---|
| Mac Catalyst | iOS, with flag | Medium (iPad-like) | Quick port of existing iPad apps |
| Separate AppKit target | Two | Native | Mac-first or Mac-heavy use cases |
| SwiftUI multiplatform | One | Native (with #if adaptations) | New apps, modern iOS apps adding Mac |
| SwiftUI on Catalyst | One | iPad-like | Rare today; SwiftUI multiplatform is better |
Concept → Why → How → Code
Choosing the approach (decision tree)
- Are you starting fresh and want iOS + Mac (+ maybe iPad)? → SwiftUI multiplatform.
- Mac is a primary platform with desktop-class needs (windows, menu commands, sidebar inspectors)? → SwiftUI multiplatform, lean into AppKit interop where needed.
- You have a large, mature iOS codebase and need a quick Mac port? → Mac Catalyst. Set “Optimize for Mac” in target settings.
- You have a pure Mac product (Final Cut Pro-class)? → Native AppKit or SwiftUI with heavy AppKit interop.
- You support iPhone but Mac is “nice to have”? → SwiftUI multiplatform; minimal Mac-specific tuning.
The App and Scene model
In SwiftUI, the entry point is the App protocol — universal across platforms:
@main
struct NotesApp: App {
@State private var store = NoteStore()
var body: some Scene {
WindowGroup {
ContentView()
.environment(store)
}
}
}
Scene is the unit of UI; App.body returns one or more scenes. Multiplatform-specific scenes:
WindowGroup— multi-window on Mac and iPad; single-window on iPhoneWindow(macOS, iPadOS 16+) — single-instance windowSettings(macOS) — adds the standard “Settings…” menu item and paneMenuBarExtra(macOS) — menu bar status item (covered in chapter 5.11)DocumentGroup— for document-based appsUtilityWindow(macOS 13+) — auxiliary window styles
Multi-window on Mac & iPad
@main
struct NotesApp: App {
var body: some Scene {
WindowGroup("Notes", id: "main") {
NotesView()
}
WindowGroup("Note", id: "note", for: Note.ID.self) { $noteID in
NoteWindow(noteID: noteID)
}
#if os(macOS)
Settings {
SettingsView()
}
#endif
}
}
// Open a note in a new window
struct NotesView: View {
@Environment(\.openWindow) private var openWindow
var body: some View {
List(notes) { note in
Button(note.title) {
openWindow(id: "note", value: note.id)
}
}
}
}
WindowGroup(for:)allows per-window value binding — open one window per note@Environment(\.openWindow)action to open by id and value@Environment(\.dismissWindow)to close
On iPhone, “open new window” is silently a no-op or replaces content (iPhone doesn’t have multi-window). On iPad and Mac, you get genuine new windows.
Universal primitives that adapt
SwiftUI’s high-value primitives behave platform-appropriately:
| Primitive | iPhone | iPad | Mac |
|---|---|---|---|
NavigationStack | Push/pop | Push/pop | Push/pop |
NavigationSplitView | Stack (collapsed) | Sidebar+detail | Sidebar+detail (native split) |
List | UITableView-style | UITableView/sidebar | NSTableView-style |
Form | Settings-style grouped | Settings-style | Mac-style with right-aligned labels |
Toolbar | Navigation bar | Navigation bar | Window toolbar |
Sheet | Modal sheet | Sheet or formsheet | Modal sheet (resizable) |
Menu | Pull-down menu | Pull-down menu | Native menu |
ContextMenu | Long-press menu | Right-click/long-press | Right-click menu |
KeyboardShortcut | Hardware kbd | Hardware kbd | Menu equivalent |
You write NavigationSplitView { sidebar } detail: { detail } and SwiftUI adapts: iPhone shows the stack; iPad shows the split; Mac shows the resizable split. Same code.
Platform conditionals — when you need them
Compile-time:
#if os(iOS)
.navigationBarTitleDisplayMode(.large)
#elseif os(macOS)
.frame(minWidth: 400, minHeight: 300)
#endif
#if targetEnvironment(macCatalyst)
.toolbarRole(.editor)
#endif
Runtime (rare, prefer compile-time):
if ProcessInfo.processInfo.isMacCatalystApp {
// ...
}
Common conditional needs:
- Window sizing (Mac wants min frame)
- Toolbar placement (
.bottomBaris iPhone-only) - Hover effects (
.onHovermostly Mac/iPad) - Mac-specific commands menus
- iOS-specific haptics (
.sensoryFeedback) - iPhone-only navigation bar styles
Commands — Mac menu bar
Mac apps live in the menu bar. SwiftUI’s Commands:
@main
struct NotesApp: App {
@State private var store = NoteStore()
var body: some Scene {
WindowGroup {
ContentView()
.environment(store)
}
#if os(macOS)
.commands {
CommandGroup(replacing: .newItem) {
Button("New Note") { store.createNew() }
.keyboardShortcut("n", modifiers: .command)
}
CommandMenu("Note") {
Button("Toggle Favorite") { store.toggleFavorite() }
.keyboardShortcut("f", modifiers: [.command, .shift])
Divider()
Button("Export…") { store.exportSelected() }
.keyboardShortcut("e", modifiers: .command)
}
}
#endif
}
}
CommandGroup(replacing:)overrides system menus (File → New Item, etc.)CommandGroup(after:)/before:add to existing system menusCommandMenu("…")adds a top-level menu- Buttons in commands become menu items;
keyboardShortcutmakes them invokable
On iPad, hardware keyboard users get the same shortcuts via the discoverability hint (Cmd-hold). On iPhone, commands are ignored (no menu bar).
focusedSceneValue — what menus act on
Commands need to know what they’re acting on (which document? which selection?). The pattern:
extension FocusedValues {
@Entry var selectedNoteAction: (() -> Void)?
}
// In a view that has focus:
ContentView()
.focusedSceneValue(\.selectedNoteAction) {
toggleFavorite()
}
// In commands:
.commands {
CommandMenu("Note") {
FocusedValueButton("Toggle Favorite", \.selectedNoteAction)
}
}
focusedSceneValue publishes values from focused views; Commands reads them. The action is enabled only when the focused view publishes it.
Settings scene (macOS)
#if os(macOS)
Settings {
TabView {
GeneralSettings()
.tabItem { Label("General", systemImage: "gear") }
AppearanceSettings()
.tabItem { Label("Appearance", systemImage: "paintbrush") }
}
.frame(width: 400, height: 300)
}
#endif
Settings adds “Settings…” to the app menu (⌘,). Standard Mac convention; users expect it.
Sharing model & business logic
The model layer is fully platform-independent — no UIKit or AppKit imports. View models, services, persistence (SwiftData/Core Data), networking: all shared across platforms.
// Shared
@MainActor @Observable
final class NoteStore {
var notes: [Note] = []
func createNew() { ... }
}
// View layer reuses the store on every platform
ContentView().environment(store) // works on iOS, iPadOS, macOS
If your model layer references UIImage, abstract to a cross-platform image type (or use CGImage / Image(_:from:)).
File organization
Common patterns:
Single-target, conditional includes:
NotesApp/
├── Sources/
│ ├── App.swift
│ ├── Views/
│ │ ├── ContentView.swift
│ │ ├── NoteRow.swift
│ │ └── Mac/
│ │ └── InspectorView.swift // #if os(macOS) at top
│ ├── Models/
│ └── Services/
Per-platform folders, conditional compilation:
NotesApp/
├── Shared/ // shared sources
├── iOS/ // iOS-only sources
└── Mac/ // Mac-only sources
For very platform-different UI (e.g., a Mac sidebar inspector vs iPhone modal), use separate view files with #if os(macOS) at the top.
Sizing & windows
WindowGroup {
ContentView()
}
.windowResizability(.contentSize) // sized to content, user can't resize
.defaultSize(width: 800, height: 600)
.defaultPosition(.center)
.commands {
SidebarCommands() // adds "Toggle Sidebar" menu item
ToolbarCommands() // adds "Customize Toolbar…"
}
SidebarCommands() and ToolbarCommands() add system-standard menu items for free.
Catalyst vs SwiftUI multiplatform
If your codebase is currently iOS and you’re considering paths:
Catalyst:
- Pros: minimal effort, ship Mac version in days
- Cons: feels like iPad-on-Mac (oversized controls, modal sheets), limited Mac integration, weird scrollbar behavior
- Mitigations: “Optimize for Mac” flag (Xcode 13+) helps, but still not native-feeling
SwiftUI multiplatform:
- Pros: native Mac feel, easier to add commands and proper windowing
- Cons: must use SwiftUI on iOS (or extract UIKit into UIViewRepresentables for the Mac path)
- Effort: requires migrating iOS UIKit screens to SwiftUI (or accept the rewrite as part of multiplatform push)
If your iOS app is SwiftUI: multiplatform is straightforward. If your iOS app is UIKit: Catalyst is faster; SwiftUI multiplatform is a larger investment but pays off long-term.
Mac Catalyst tips (if you go that route)
- Enable “Optimize for Mac” in target settings → controls scale natively
- Use
#if targetEnvironment(macCatalyst)for Mac-specific code paths - Hide iPad-only UI elements (page sheets that don’t make sense as Mac modals)
- Add native macOS menus via
UIMenuBuilder(UIKit’s Mac menu API) - Test resize behavior; iPad UIs often break at very wide aspect ratios
@Environment(\.openWindow) and friends
Mac/iPad multi-window actions:
@Environment(\.openWindow) var openWindow
@Environment(\.dismissWindow) var dismissWindow
@Environment(\.openURL) var openURL
Button("Open") {
openWindow(id: "note", value: noteID)
}
On iPhone, these are no-ops or behave as best they can.
In the wild
- Apple’s Reminders, Notes, Mail apps are SwiftUI multiplatform — single codebase, native feel on iPhone, iPad, Mac.
- Things 3 (Cultured Code) was AppKit-only for years; their newer features ship as SwiftUI multiplatform.
- Craft uses SwiftUI multiplatform with heavy AppKit interop on Mac for advanced text editing.
- Bear (note-taking app) is currently Mac Catalyst; the new version is rumored to migrate to SwiftUI multiplatform.
- Apollo for Reddit (RIP) was SwiftUI on iOS; never shipped Mac.
- Apple’s Sample Code “Backyard Birds” is the canonical SwiftUI multiplatform example (iOS + iPadOS + macOS + watchOS + tvOS from one target).
Common misconceptions
- “SwiftUI on Mac is just SwiftUI on iPhone in a window.” No —
Toolbar,Menu,Settings,NavigationSplitView, multi-window, and AppKit interop give SwiftUI access to Mac-specific affordances. Done well, it’s genuinely native. - “Mac Catalyst is dead.” Not at all — for porting iPad-heavy apps, it’s the fastest path. Apple still ships updates to Catalyst.
- “SwiftUI multiplatform means one identical UI on every device.” It means one codebase that adapts. Sidebars on Mac, stacks on iPhone, same view code with
NavigationSplitView. - “You can’t mix SwiftUI and AppKit.” You can —
NSViewRepresentableandNSHostingControllerare the AppKit equivalents of UIKit’s. Chapter 5.11. - “Multi-window is hard.” With
WindowGroup(for:)and@Environment(\.openWindow), it’s a few lines.
Seasoned engineer’s take
For new apps in 2026 with a desktop ambition: SwiftUI multiplatform from day one. The leverage is enormous — every feature ships on every platform automatically.
For existing iOS apps adding Mac: evaluate honestly. If your iOS code is UIKit and you don’t have appetite to migrate, ship Catalyst with “Optimize for Mac” and iterate. If you can migrate to SwiftUI gradually, do that and reap multi-platform benefits.
Don’t reach for cross-platform third-party frameworks (Flutter, React Native) just for Mac support. SwiftUI multiplatform is the native answer with better integration, performance, and long-term support.
The biggest mistake I see: shipping a Catalyst app that looks like iPad-on-Mac and calling it done. Mac users notice immediately — wrong scrollbars, oversized controls, no menu bar, no keyboard shortcuts. Either invest in proper Mac integration (commands, focused values, native window styles) or use SwiftUI multiplatform from the start.
TIP: Test on every platform from day one. Set up CI to build for iOS, iPadOS, and macOS on every PR. Catching “this scene only works on iOS” early saves agony.
WARNING:
frame(...)behaves differently on Mac (window starts at that size unless.windowResizability(.contentSize)) vs iOS (frame within parent). Test both.
Interview corner
Junior-level: “Mac Catalyst vs SwiftUI multiplatform — when do you use each?”
Catalyst when you have an existing iPad-heavy UIKit app and want the fastest path to Mac. SwiftUI multiplatform when you’re starting fresh or your codebase is already SwiftUI — produces a more native-feeling Mac experience because SwiftUI’s primitives (Toolbar, Commands, NavigationSplitView, Settings) adapt to platform conventions rather than forcing iPad UI onto Mac.
Mid-level: “How would you structure a SwiftUI multiplatform notes app supporting iPhone, iPad, and Mac?”
Single target with @main App containing platform-appropriate scenes: WindowGroup for the main UI; WindowGroup(for: Note.ID.self) for per-note detached windows on iPad/Mac; Settings scene on Mac. The main view is NavigationSplitView with a sidebar (folders), content (notes list), detail (editor) — adapts: iPhone collapses to stack, iPad/Mac shows split. Shared @Observable NoteStore injected via .environment. Platform-specific code via #if os(macOS) blocks for: window sizing (.frame(minWidth:minHeight:)), commands menu, hover effects on Mac. iOS-only blocks for haptics. Model and service layers are pure Swift, no platform imports.
Senior-level: “A user opens a note in a new window on Mac, edits it, then quits the app. Expected behavior on relaunch?”
The system should restore the open windows. SwiftUI handles this when:
- The window’s
WindowGroup(for: Note.ID.self)uses aCodablevalue type for the binding — SwiftUI persists the window-value associations - The model layer can hydrate the note by ID (so when the window reconstructs with the saved ID, it can render content)
- App-level state (selected folder, sidebar visibility) is saved via
@SceneStorage
Implementation:
WindowGroup("Note", id: "note", for: Note.ID.self) { $noteID in
if let id = noteID, let note = store.note(for: id) {
NoteEditor(note: note)
}
}
@SceneStorage for per-window UI state (selected text range, scroll position). @AppStorage for global preferences (sidebar default width).
Edge cases:
- Note deleted while window persisted → show “Note no longer exists” placeholder
- Notes opened but store still loading → show loading state, hydrate when ready
- iCloud sync conflict on relaunch → present conflict resolution UI
Red flag in candidates: Saying “just use Catalyst” without considering the tradeoffs, or saying “rewrite everything in SwiftUI” without acknowledging the cost.
Lab preview
Lab 5.3 (Multiplatform Notes) builds a single-target iOS + macOS notes app with NavigationSplitView, shared @Observable store, platform-conditional toolbars, and Settings scene on Mac.
Next: SwiftUI macOS advanced
5.11 — SwiftUI macOS advanced
Opening scenario
Your SwiftUI multiplatform notes app works fine on Mac, but Mac users complain:
- “There’s no menu bar icon to quick-create a note”
- “Inspector pane doesn’t toggle with the standard ⌥⌘I shortcut”
- “I want a floating window with all my favorites”
- “The toolbar items don’t show labels in ‘Icon and Text’ mode”
- “Why can’t I right-click a note for actions?”
- “Where’s the dock menu?”
Mac users have higher expectations than iPhone users for UI conventions. The Mac has a 40-year history of standards: menu bar items, keyboard shortcuts for everything, customizable toolbars, dock menus, status items, services. SwiftUI provides primitives for most of this; for the rest, drop into AppKit interop.
| Affordance | API |
|---|---|
| Menu bar status item | MenuBarExtra scene |
| Dock menu | App.commands { ... } or NSApp.dockMenu |
| Floating/auxiliary window | UtilityWindow, custom window controller |
| Inspector pane | .inspector(isPresented:) modifier (iOS 17+/macOS 14+) |
| Keyboard shortcuts | .keyboardShortcut(_:modifiers:) |
| Right-click menu | .contextMenu { ... } |
| Toolbar with customization | Toolbar + ToolbarItem(customizationID:) |
| Native NSView | NSViewRepresentable |
| URL handling | .handlesExternalEvents(...), .onOpenURL |
Concept → Why → How → Code
MenuBarExtra — status item
@main
struct QuickNotesApp: App {
@State private var store = NoteStore()
var body: some Scene {
WindowGroup { ContentView().environment(store) }
MenuBarExtra("Quick Notes", systemImage: "note.text") {
QuickNotesMenu(store: store)
}
.menuBarExtraStyle(.window) // or .menu
}
}
struct QuickNotesMenu: View {
let store: NoteStore
var body: some View {
VStack(alignment: .leading) {
ForEach(store.favorites) { note in
Button(note.title) { open(note) }
}
Divider()
Button("New Note") { store.createNew() }
.keyboardShortcut("n", modifiers: [.command, .shift])
Divider()
Button("Quit") { NSApplication.shared.terminate(nil) }
.keyboardShortcut("q", modifiers: .command)
}
.padding()
.frame(width: 240)
}
}
MenuBarExtrais its ownScene.menuBarExtraStyle(.menu): traditional dropdown menu of items.menuBarExtraStyle(.window): opens a custom view (like Apple’s Control Center popups)- Works alongside
WindowGroup— both coexist
For menu-bar-only apps (no Dock icon), add LSUIElement = true in Info.plist; ship just the MenuBarExtra scene.
Toolbar on macOS
NavigationStack {
NoteEditor(note: $note)
.navigationTitle(note.title)
.toolbar(id: "editor") {
ToolbarItem(id: "bold", placement: .primaryAction) {
Button(action: toggleBold) {
Label("Bold", systemImage: "bold")
}
}
ToolbarItem(id: "italic", placement: .primaryAction) {
Button(action: toggleItalic) {
Label("Italic", systemImage: "italic")
}
}
ToolbarItem(id: "spacer", placement: .primaryAction) {
Spacer()
}
ToolbarItem(id: "share", placement: .primaryAction) {
ShareLink(item: note.text)
}
}
.toolbarTitleDisplayMode(.inline)
}
.toolbar(id:)enables user customization (drag-and-drop reorder, show/hide)ToolbarItem(id:placement:)— id makes them customizableplacement: .primaryActionputs in the window toolbar (Mac)Label("Name", systemImage: "icon")— Mac users can show both, icon-only, or text-only via toolbar customizationToolbarItemGroupfor related groups
.inspector(isPresented:) — right-side pane
struct ContentView: View {
@State private var showInspector = true
@State private var selectedNote: Note?
var body: some View {
NavigationSplitView {
Sidebar(selection: $selectedNote)
} detail: {
if let note = selectedNote {
NoteEditor(note: note)
.inspector(isPresented: $showInspector) {
InspectorPane(note: note)
.inspectorColumnWidth(min: 220, ideal: 280, max: 400)
.toolbar {
Button {
showInspector.toggle()
} label: {
Label("Toggle Inspector", systemImage: "sidebar.right")
}
.keyboardShortcut("i", modifiers: [.command, .option])
}
}
}
}
}
}
.inspector (iOS 17+ / macOS 14+) provides the standard right-side inspector. Resizable on Mac, modal-like on iPad portrait.
Window and UtilityWindow
@main
struct AppName: App {
var body: some Scene {
WindowGroup { MainView() }
Window("About", id: "about") {
AboutView()
.frame(width: 360, height: 220)
}
.windowResizability(.contentSize)
.windowStyle(.hiddenTitleBar)
UtilityWindow("Calculator", id: "calc") {
CalculatorView()
}
.keyboardShortcut("c", modifiers: [.command, .option])
}
}
Window: single-instance window (callingopenWindow(id:)again brings it forward)UtilityWindow: floats above main windows, smaller title bar (palette-style).windowStyle(.hiddenTitleBar): no title bar, custom background.windowResizability(.contentSize): locked to content size
Window styling
WindowGroup { MainView() }
.windowStyle(.titleBar) // default
.windowStyle(.hiddenTitleBar)
.windowStyle(.plain)
.windowToolbarStyle(.unified) // toolbar merged with title bar
.windowToolbarStyle(.unifiedCompact)
.windowToolbarStyle(.expanded)
.windowToolbarStyle(.unified) is the modern look — title and toolbar in one row.
Dock menu
@main
struct App: App {
var body: some Scene {
WindowGroup { ContentView() }
.commands {
CommandGroup(replacing: .appInfo) {
Button("About App") { showAbout = true }
}
}
}
}
For a true dock menu (right-click the dock icon), use NSApplicationDelegate:
class AppDelegate: NSObject, NSApplicationDelegate {
func applicationDockMenu(_ sender: NSApplication) -> NSMenu? {
let menu = NSMenu()
menu.addItem(withTitle: "New Note", action: #selector(newNote), keyEquivalent: "n")
return menu
}
}
@main
struct App: App {
@NSApplicationDelegateAdaptor(AppDelegate.self) var delegate
var body: some Scene { /* ... */ }
}
@NSApplicationDelegateAdaptor adopts an NSApplicationDelegate into a SwiftUI app — for the corners SwiftUI doesn’t cover.
NSViewRepresentable — wrap AppKit views
Same pattern as UIViewRepresentable:
struct ColorPickerWell: NSViewRepresentable {
@Binding var color: Color
func makeNSView(context: Context) -> NSColorWell {
let well = NSColorWell()
well.target = context.coordinator
well.action = #selector(Coordinator.colorChanged(_:))
return well
}
func updateNSView(_ nsView: NSColorWell, context: Context) {
nsView.color = NSColor(color)
}
func makeCoordinator() -> Coordinator {
Coordinator(self)
}
final class Coordinator: NSObject {
var parent: ColorPickerWell
init(_ parent: ColorPickerWell) { self.parent = parent }
@objc func colorChanged(_ sender: NSColorWell) {
parent.color = Color(sender.color)
}
}
}
Use for: NSTextView (full-featured rich text editing), NSTableView (when Table doesn’t fit), MTKView (Metal rendering), WKWebView (or use the SwiftUI WebView in newer SDKs), 3rd-party AppKit controls.
NSHostingController — SwiftUI inside AppKit
let host = NSHostingController(rootView: ContentView().environment(store))
window.contentViewController = host
For mixed AppKit apps (existing Mac codebase adding SwiftUI features).
Right-click context menus
List(notes) { note in
Text(note.title)
.contextMenu {
Button("Open") { open(note) }
Button("Open in New Window") { openWindow(id: "note", value: note.id) }
Divider()
Button("Toggle Favorite") { note.isFavorite.toggle() }
Divider()
Button("Delete", role: .destructive) { delete(note) }
}
}
.contextMenu works on iOS (long press) and Mac (right-click) with the same code. Use it everywhere — Mac users expect right-click on anything.
For dynamic content + preview:
.contextMenu {
Button("Open") { ... }
Button("Share") { ... }
} preview: {
NotePreview(note: note) // iOS shows; Mac ignores preview
}
Keyboard shortcuts
Button("Save") { save() }
.keyboardShortcut("s", modifiers: .command)
Button("Refresh") { refresh() }
.keyboardShortcut(.return, modifiers: [.command, .shift])
Button("Escape") { dismiss() }
.keyboardShortcut(.escape)
In Commands, shortcuts appear in menus. Without Commands, shortcuts still work when the view is in the responder chain.
KeyboardShortcut.standardEdit patterns
Apple-conventional shortcuts:
| Shortcut | Action |
|---|---|
⌘N | New |
⌘O | Open |
⌘S | Save |
⌘W | Close window |
⌘Q | Quit |
⌘, | Settings |
⌘Z | Undo |
⇧⌘Z | Redo |
⌘X/C/V | Cut/Copy/Paste |
⌘F | Find |
⌘P | |
⌘+/⌘- | Zoom |
⌥⌘S | Save as / duplicate |
⌥⌘1/2/3 | View modes |
⌥⌘I | Show inspector |
Match these. Mac users have them in muscle memory.
focusedSceneValue and @FocusedValue
The mechanism for “what’s the focused window/view, and what actions does it offer?”:
extension FocusedValues {
@Entry var selectedNote: Note?
@Entry var noteActions: NoteActions?
}
struct NoteActions {
var toggleFavorite: () -> Void
var rename: () -> Void
}
// In a view:
NoteEditor(note: note)
.focusedSceneValue(\.selectedNote, note)
.focusedSceneValue(\.noteActions, NoteActions(
toggleFavorite: { note.isFavorite.toggle() },
rename: { startRenaming() }
))
// In commands:
.commands {
CommandMenu("Note") {
Button("Toggle Favorite") {
actions?.toggleFavorite()
}
.keyboardShortcut("f", modifiers: [.command, .shift])
.disabled(actions == nil)
}
}
struct NoteCommands: Commands {
@FocusedValue(\.noteActions) var actions: NoteActions?
var body: some Commands {
CommandMenu("Note") { /* as above */ }
}
}
The menu items enable when a view publishes the action. The pattern that makes Mac menus feel native.
MenuBarExtra + LSUIElement for accessory apps
For utilities that live in the menu bar (no Dock icon, no main window):
- Info.plist:
LSUIElement = YES - App scene contains only
MenuBarExtra - Pure status item
@main
struct AccessoryApp: App {
var body: some Scene {
MenuBarExtra("Status", systemImage: "wifi") {
StatusView()
}
.menuBarExtraStyle(.window)
}
}
openURL and deep links
ContentView()
.onOpenURL { url in
handleDeepLink(url)
}
.handlesExternalEvents(matching: ["myapp"])
Mac: register URL schemes in Info.plist (CFBundleURLTypes). Same as iOS.
For File handling (drag-drop, double-click in Finder):
DocumentGroup(viewing: NoteDocument.self) { config in
NoteEditor(document: config.document)
}
Drag and drop
List(notes) { note in
Text(note.title)
.draggable(note)
}
.dropDestination(for: Note.self) { items, _ in
items.forEach { store.add($0) }
return true
}
Transferable protocol (iOS 16+/macOS 13+) — define how your type encodes for drag-drop:
extension Note: Transferable {
static var transferRepresentation: some TransferRepresentation {
CodableRepresentation(contentType: .text)
ProxyRepresentation(exporting: \.text)
}
}
Same code works for share sheets, paste, drag-drop, between apps.
In the wild
- Things 3 uses
MenuBarExtrafor the quick-entry popup that’s a key product feature. - 1Password 8 uses SwiftUI for the menu bar extra; macOS app is SwiftUI with AppKit interop for the secure text fields.
- Linear’s Mac app uses SwiftUI multiplatform; the Mac version adds
Settings,Commands,MenuBarExtra. - Craft uses extensive
NSViewRepresentablefor their rich text editor (NSTextView). - Bear’s new Mac version uses SwiftUI for chrome, AppKit
NSTextViewfor the editor.
Common misconceptions
- “Mac SwiftUI is just iOS SwiftUI with extra modifiers.” No — Mac-specific primitives (
MenuBarExtra,Window,Settings,UtilityWindow,FocusedValue) and conventions (toolbar customization, menu commands, keyboard shortcuts) are first-class. - “
NSApplicationDelegateisn’t needed with SwiftUI.” Often not, but for dock menus, custom URL handling beyondonOpenURL, accessibility hooks, services menu items, you’ll add one via@NSApplicationDelegateAdaptor. - “
MenuBarExtrais only for accessory apps.” It works for any app that wants a quick-access menu bar item. Mainstream apps (1Password, Notion) have one. - “Inspector is iPad-only.”
.inspector(isPresented:)(iOS 17+) is Mac-and-iPad. Standard right-side pane convention. - “Toolbar customization is automatic.” Only when you use
ToolbarItem(id:)and.toolbar(id:). Without IDs, items are fixed.
Seasoned engineer’s take
Mac users are conservative — they want apps to behave like Mac apps. The investment is real but pays off: dock menu (5 minutes), Settings scene (10 minutes), proper Commands with focusedSceneValue (an hour), MenuBarExtra (an hour), toolbar customization (15 minutes per toolbar). Each addition makes the app feel more native; the cumulative effect is “this app respects me as a Mac user.”
For long-form text editing (notes, docs, articles), TextEditor is not good enough. Plan to wrap NSTextView. SwiftUI’s Text doesn’t support rich text input either. Apple knows; new APIs may come, but in 2026 NSViewRepresentable is still the answer for rich text.
For data-dense UIs (tables with sortable columns, multi-row selection, drag-reorder), Table covers most cases. Drop to NSTableView when you need column-level cell types, advanced selection behaviors, or virtualized columns.
focusedSceneValue is the trick that makes commands feel right. Without it, your menu items are always enabled (or always disabled), and the wrong window’s action might fire. Spend the time to wire it.
TIP: Test your Mac app with the keyboard only (no mouse). If you can’t navigate every screen and trigger every action, Mac users won’t be able to either. This is also the fastest accessibility test.
WARNING: Don’t ship Mac apps without testing on multiple window sizes, including very narrow (320pt wide) and very wide (2000pt+). SwiftUI layouts that work at one size sometimes break at extremes.
Interview corner
Junior-level: “What’s MenuBarExtra for?”
A SwiftUI Scene (macOS 13+) for adding an item to the system menu bar at the top-right of the screen. Two styles: .menu (dropdown of menu items) and .window (opens a custom SwiftUI view as a popover). Combined with LSUIElement = YES in Info.plist, you can build a menu-bar-only app with no Dock icon.
Mid-level: “How do you enable/disable menu items in Mac SwiftUI based on what view is focused?”
Use FocusedValues and focusedSceneValue. Define a custom FocusedValues entry (using @Entry macro) — e.g., noteActions: NoteActions?. In the focused view, publish the value via .focusedSceneValue(\.noteActions, NoteActions(...)). In your Commands, read it with @FocusedValue(\.noteActions). Disable the menu Button via .disabled(actions == nil). As focus changes between views/windows, the published value changes, and SwiftUI re-evaluates menu state.
Senior-level: “Architect a SwiftUI Mac app that needs: main editor window, menu bar quick-access, inspector pane, keyboard-driven workflow, and integrates with rich text via NSTextView.”
Scene hierarchy:
WindowGroup— main editor window withNavigationSplitView { sidebar } detail: { editor }Window("Settings", id: "settings")— settings (orSettingsscene if standard)MenuBarExtra— quick actions: new note, search, recent
Editor:
NoteEditorusesNSViewRepresentablewrappingNSTextViewfor rich text- Inspector pane via
.inspector(isPresented:)— toggleable with ⌥⌘I - Toolbar with
.toolbar(id:)for user customization
Keyboard:
- All major actions in
CommandswithkeyboardShortcut focusedSceneValuepublishes editor actions (bold, italic, list, link) from the focused editor view- Commands disable when no editor focused
Menu structure:
- Standard menus (File: New, Open, Save, Close; Edit: Undo, Cut/Copy/Paste, Find)
- Custom Note menu (Toggle Favorite, Pin, Move to…)
- Custom Format menu (Bold, Italic, Heading 1/2/3, List)
- View menu with sidebar/inspector toggles (
SidebarCommands())
NSTextView bridging:
- Coordinator handles
NSTextViewDelegatecallbacks - Two-way binding for text content with feedback-loop guard
- Attribute manipulation (bold/italic) via coordinator methods, exposed as actions in
FocusedValues - Find panel integration via
NSTextFinder
App lifecycle:
@NSApplicationDelegateAdaptorfor dock menu, services menu items, URL handling
State:
@Observable NoteStoreinjected via.environmentto all scenes- Per-window state via
@SceneStorage - Preferences via
@AppStorage
Red flag in candidates: Reaching for NSWindow and AppKit-first design when SwiftUI scenes would do. Or, conversely, refusing to drop to NSViewRepresentable for tasks that genuinely require AppKit (rich text editing).
Lab preview
Lab 5.3 (Multiplatform Notes) optionally includes Mac-specific touches: Settings scene, Commands menu, toolbar customization. The lab is a controlled environment to practice the conventions in this chapter.
Next: Environment, PreferenceKey & GeometryReader
5.12 — Environment, PreferenceKey & GeometryReader
Opening scenario
Three problems that look unrelated until they aren’t:
- Data flowing down: every screen needs the user’s locale + theme + auth state. Passing them through every initializer is hell.
- Data flowing up: a tab bar at the bottom of the screen needs to know which tab is selected by a deeply nested child view, and animate an indicator to its frame.
- Layout that depends on geometry: a custom chart needs to position labels at calculated points; a card needs to know its own width to choose between layouts.
SwiftUI’s answer for each:
- Down:
Environment— implicit context that flows from parent to all descendants. - Up:
PreferenceKey— children publish values, ancestors collect them. - Geometry:
GeometryReader,coordinateSpace,alignmentGuide,onGeometryChange.
These three primitives unlock most of “this is hard to do” in SwiftUI. Use them, but don’t reach for GeometryReader first — it’s the most-abused tool in the kit.
| Need | Tool |
|---|---|
| Parent → all descendants | Environment (@Entry, EnvironmentValues) |
| Child → ancestor (single or aggregated values) | PreferenceKey |
| Read view’s size/position | onGeometryChange(for:of:action:) |
| Calculate layout from container size | GeometryReader (sparingly) |
| Align views across a stack | alignmentGuide |
| Coordinate frames across the hierarchy | coordinateSpace(name:) + GeometryProxy.frame(in:) |
Concept → Why → How → Code
Environment — implicit downward data flow
We covered the basics in chapters 5.3 and 5.4. The full picture:
Built-in environment values:
@Environment(\.colorScheme) var scheme // .light / .dark
@Environment(\.horizontalSizeClass) var hSize // .compact / .regular
@Environment(\.dynamicTypeSize) var dyn
@Environment(\.locale) var locale
@Environment(\.timeZone) var tz
@Environment(\.calendar) var cal
@Environment(\.layoutDirection) var dir // .leftToRight / .rightToLeft
@Environment(\.scenePhase) var phase // .active / .inactive / .background
@Environment(\.isEnabled) var enabled
@Environment(\.editMode) var editMode
@Environment(\.dismiss) var dismiss // action
@Environment(\.openURL) var openURL // action
@Environment(\.openWindow) var openWindow // action (Mac/iPad)
@Environment(\.refresh) var refresh // action (in refreshable scope)
@Environment(\.modelContext) var ctx // SwiftData
@Environment(MyObservable.self) var store // Observable type
Custom environment values (Swift 6 @Entry macro):
extension EnvironmentValues {
@Entry var theme: Theme = .default
@Entry var analytics: Analytics = .noop
}
// Inject
ContentView()
.environment(\.theme, currentTheme)
.environment(\.analytics, AppAnalytics())
// Read
@Environment(\.theme) var theme
Before @Entry (iOS 17 and earlier), you wrote a verbose EnvironmentKey conformance. @Entry collapses it to one line.
When to use Environment vs preferences vs explicit parameters
Environment: values used by many descendants, often cross-cutting (theme, locale, analytics, services, current user).
Explicit parameters: values used by one specific child, especially business data. Pass note: Note to NoteEditor — don’t put it in environment.
Preferences: values flowing up from children to ancestors.
A common abuse: putting domain models in environment (“the current selected note”). Use explicit binding or routing for that; environment for cross-cutting concerns.
PreferenceKey — child → ancestor
A PreferenceKey defines a type-keyed value that children write and ancestors read:
struct TabFrameKey: PreferenceKey {
static var defaultValue: [Int: CGRect] = [:]
static func reduce(value: inout [Int: CGRect], nextValue: () -> [Int: CGRect]) {
value.merge(nextValue(), uniquingKeysWith: { _, new in new })
}
}
// Child publishes
TabButton(index: 0)
.background {
GeometryReader { proxy in
Color.clear
.preference(
key: TabFrameKey.self,
value: [0: proxy.frame(in: .named("tabbar"))]
)
}
}
// Ancestor reads
HStack { ... }
.coordinateSpace(name: "tabbar")
.onPreferenceChange(TabFrameKey.self) { frames in
self.tabFrames = frames
}
Use cases:
- Tab indicator that animates to selected tab’s frame
- Synchronized heights across columns (matching tallest)
- Scroll position aggregation
- Custom badge/popover anchor points
- Title published from inner views (navigationTitle uses this internally)
reduce — combining multiple children’s values
If multiple subviews write the same key, reduce merges them. For dictionary keys, merge by id. For single values, use min/max/sum:
struct MaxHeightKey: PreferenceKey {
static var defaultValue: CGFloat = 0
static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
value = max(value, nextValue())
}
}
GeometryReader — read container size
GeometryReader { proxy in
let width = proxy.size.width
HStack(spacing: 0) {
Rectangle().frame(width: width * 0.3)
Rectangle().frame(width: width * 0.7)
}
}
GeometryReader’s catch: it claims all available space in both dimensions (greedy), which breaks intrinsic sizing. You wrap content in a GeometryReader and suddenly the parent thinks it wants the whole screen.
Patterns that work:
GeometryReaderinside.background { ... }or.overlay { ... }— these don’t affect the host view’s sizeGeometryReaderfilling a known-size container (full-screen views, fixed-frame containers)
Patterns that break:
GeometryReaderas the root of a reusable component — it greedily expandsGeometryReaderinside aListrow — wreaks havoc
onGeometryChange(for:of:action:) — modern replacement
iOS 17.1+/macOS 14.1+: prefer onGeometryChange over GeometryReader for many cases:
@State private var width: CGFloat = 0
ContentView()
.onGeometryChange(for: CGFloat.self) { proxy in
proxy.size.width
} action: { newWidth in
self.width = newWidth
}
No layout-greediness; callback fires when value changes. Use this whenever you only need geometry as data to drive state, not as direct layout.
coordinateSpace(name:) and frame(in:)
Coordinate spaces let you measure positions/sizes in the frame of an ancestor:
ScrollView {
LazyVStack {
ForEach(items) { item in
ItemRow(item: item)
.background {
GeometryReader { proxy in
Color.clear
.preference(
key: ItemFrameKey.self,
value: proxy.frame(in: .named("scroll"))
)
}
}
}
}
}
.coordinateSpace(name: "scroll")
.onPreferenceChange(ItemFrameKey.self) { frame in
// y is relative to ScrollView's content origin
}
Common coordinate spaces:
.local— view’s own.global— screen.named("…")— custom
Modern (iOS 17+): .coordinateSpace(.named("scroll")) and proxy.frame(in: .named("scroll")). Earlier: same API with string "scroll".
alignmentGuide — custom alignment
HStack(alignment: .myAlignment) {
Text("Label")
.alignmentGuide(.myAlignment) { d in d[VerticalAlignment.center] }
Image(systemName: "star")
.alignmentGuide(.myAlignment) { d in d[.bottom] }
}
extension VerticalAlignment {
private struct MyAlignment: AlignmentID {
static func defaultValue(in d: ViewDimensions) -> CGFloat { d[.center] }
}
static let myAlignment = VerticalAlignment(MyAlignment.self)
}
Use when you need precise alignment that built-in .top/.center/.bottom/.firstTextBaseline/.lastTextBaseline don’t cover (e.g., align checkmark of a checkbox with first line of a multi-line label).
matchedGeometryEffect — synchronized geometry (recap from 5.7)
Cross-references geometry between two views with the same id+namespace. Internally uses preferences and the rendering pipeline; you don’t need to manage preferences manually.
Layout protocol — custom layouts (iOS 16+)
For when stacks don’t fit and you need full control:
struct EqualWidthHStack: Layout {
func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
guard !subviews.isEmpty else { return .zero }
let maxWidth = subviews.map { $0.sizeThatFits(.unspecified).width }.max() ?? 0
let totalWidth = maxWidth * CGFloat(subviews.count)
let maxHeight = subviews.map { $0.sizeThatFits(.unspecified).height }.max() ?? 0
return CGSize(width: totalWidth, height: maxHeight)
}
func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {
let width = bounds.width / CGFloat(subviews.count)
for (index, subview) in subviews.enumerated() {
let x = bounds.minX + CGFloat(index) * width + width / 2
subview.place(at: CGPoint(x: x, y: bounds.midY), anchor: .center, proposal: .init(width: width, height: bounds.height))
}
}
}
// Use
EqualWidthHStack {
Button("Yes") { }
Button("No") { }
Button("Maybe") { }
}
Layout protocol is the answer for custom containers (flow layouts, radial menus, masonry grids). Animatable via AnimatableData.
Worked example: tab indicator that follows selected tab
struct TabBar: View {
@Binding var selection: Int
@State private var frames: [Int: CGRect] = [:]
@Namespace private var ns
var body: some View {
HStack(spacing: 0) {
ForEach(0..<3) { idx in
Button(action: { selection = idx }) {
Text(tab(for: idx).title)
.padding()
}
.background {
GeometryReader { proxy in
Color.clear
.preference(key: TabFrameKey.self, value: [idx: proxy.frame(in: .named("tabbar"))])
}
}
}
}
.coordinateSpace(name: "tabbar")
.onPreferenceChange(TabFrameKey.self) { frames = $0 }
.overlay(alignment: .bottomLeading) {
if let frame = frames[selection] {
Rectangle()
.frame(width: frame.width, height: 2)
.offset(x: frame.minX)
.animation(.spring, value: selection)
}
}
}
}
The indicator reads each tab’s frame via preference, then renders an underline at the selected tab’s position. Animates because selection change drives .animation.
In the wild
- Apple’s
navigationTitleusesPreferenceKeyinternally — the title set inside the destination flows up to the container. - TabView indicator in iOS 17+ uses preference-based geometry for the underline.
- Apple’s Charts framework uses extensive
PreferenceKeyto position axis labels, gridlines, and annotations relative to the chart area. - Pointer-style hover effects in Mac SwiftUI use
onGeometryChangeto track hover bounds. - Custom date pickers with calendar grids use
Layoutprotocol for week/month arrangements.
Common misconceptions
- “
GeometryReaderis the answer to all sizing problems.” No — it’s greedy and breaks intrinsic sizes. PreferonGeometryChangefor size-as-data needs. - “
PreferenceKeyis obscure.” It’s how half of SwiftUI’s internals work (navigationTitle,toolbar,tab,searchable). Worth understanding. - “Environment is for any shared data.” No — environment for cross-cutting concerns, explicit params for view-specific data. Domain models often don’t belong in environment.
- “
alignmentGuideis for spacing.” No — it’s for defining a custom alignment line that children align to. Usepadding/spacingfor spacing. - “
Layoutprotocol is too complex; just nest stacks.” Sometimes — but for non-orthogonal layouts (flow, radial, masonry),Layoutis cleaner and more performant than 5 levels of conditional stacks.
Seasoned engineer’s take
Environment is leverage — design your app to inject services/state from the top once. Test by injecting mock environments. The dependency injection story in SwiftUI is Environment; embrace it.
PreferenceKey reads as scary the first time; after 5 uses, it’s another tool. Common pattern: a child needs to publish “I have computed this value” to an ancestor. Examples: dynamic content height (auto-sizing sheets), custom anchor points, sync layouts.
GeometryReader is overused. Reach for it last. Almost always, the better answer is: a smarter layout (use Layout protocol), onGeometryChange (for state-driven needs), or a PreferenceKey (for sibling/ancestor coordination). I’ve inherited codebases where every other view starts with GeometryReader { proxy in ... } and the apps are unusably slow and unmaintainable.
alignmentGuide is niche but powerful — when you need it, nothing else works.
Layout protocol is severely underused. Most teams keep building nested HStack/VStack/ZStack pyramids when a 30-line Layout would be cleaner, more performant, and more flexible.
TIP: When debugging preferences, set a
.onPreferenceChangewith aprint(value)to see what flows up. Often the issue is reduce being wrong or the child not firing at the expected time.
WARNING:
GeometryReaderreturns aproxy.sizethat’s the proposal SwiftUI passed it. If parent geometry is wrong (e.g., from a misuse upstream),GeometryReaderpropagates the wrong value. Read sizes viaonGeometryChangeand verify them.
Interview corner
Junior-level: “What’s the difference between Environment and @State?”
@State is private to a view (and its body’s reads). @Environment reads values injected by an ancestor — implicit dependency injection from any height of the view tree. Both trigger re-render on change. @State for “data only this view owns”; @Environment for “cross-cutting context provided by parents”.
Mid-level: “Walk me through implementing a tab bar where a colored indicator slides to the selected tab.”
Wrap the tab bar in HStack with .coordinateSpace(name: "tabbar"). Each tab button publishes its frame (in the tabbar coordinate space) via a PreferenceKey whose value is [TabID: CGRect] (reduce by merging dicts). The container reads the aggregated preference in .onPreferenceChange and stores it in @State frames: [TabID: CGRect]. An .overlay(alignment: .bottomLeading) renders an indicator at frames[selection]?.minX with width frames[selection]?.width. Wrap the indicator in .animation(.spring, value: selection) for smooth movement.
Senior-level: “A team’s app uses GeometryReader everywhere and is slow + janky. Plan to fix it.”
- Identify
GeometryReaderusages: greedy frame impact, hot-path uses (insideList/ForEachrows), nested usages. - Categorize:
- State-driven needs (“react to size changes”) → replace with
onGeometryChange(for:of:action:)(iOS 17.1+). No greediness, fires only on change. - Layout-driven needs (“place children based on container”) → replace with
Layoutprotocol (custom container) or built-in containers (Grid,GridRow,ViewThatFits,ZStackwith anchored alignment). - Sibling coordination (“child A wants to know B’s size”) →
PreferenceKeyfrom each child, aggregated by ancestor. - Cross-hierarchy positioning (“anchor a popover to a deep child”) →
anchorPreference+overlayPreferenceValue(the anchor-based preference APIs). - Genuine geometry calculations (e.g., charts) → keep
GeometryReaderbut isolate inside.backgroundor.overlayto avoid greediness.
- State-driven needs (“react to size changes”) → replace with
- Replace
GeometryReader { proxy in proxy.size.width * 0.3 }patterns with proper layout (HStackwithframe(maxWidth:)proportional sizing usinglayoutPriorityorLayoutprotocol). - Audit
Listrows: anyGeometryReaderinside cells should be removed — measure outside or useonGeometryChange. - Benchmark: Instruments → SwiftUI template → look at “View body” calls. After refactor, body counts should drop dramatically.
Red flag in candidates: Saying “GeometryReader is fine, just use it everywhere.” Or never having heard of PreferenceKey.
Lab preview
Lab 5.2 uses PreferenceKey for chart axis labels, and onGeometryChange for the dashboard layout. Lab 5.4 uses Environment for theme injection in the component library.
Next: Accessibility
5.13 — Accessibility
Opening scenario
Apple’s App Store review team has rejected your update. Reason: “VoiceOver users cannot complete a checkout — the ‘Buy’ button is not announced, and the price stepper is unreachable.”
You’ve never tested with VoiceOver. You’ve never used Dynamic Type. You assume “Accessibility” means screen-reader-for-blind-people and your app doesn’t really need it because most users aren’t blind.
You’re wrong on every count:
- ~20% of users have some accessibility need: low vision, motor impairments, hearing loss, cognitive differences.
- Dynamic Type is used by ~30% of iOS users (Apple’s internal data).
- VoiceOver, Switch Control, Voice Control, AssistiveTouch are gateways for many of these users.
- App Store review actively rejects updates with broken accessibility.
- Lawsuits under ADA (US), EAA (EU 2025+) are real and growing.
SwiftUI gets accessibility mostly right by default — but only if you don’t actively break it (custom controls, decorative views without proper labels, layouts that don’t reflow with Dynamic Type). And the “mostly” isn’t enough; you have to add semantic info for screen readers.
This chapter is the playbook: testing, fixing, designing for accessibility from the start.
| Accessibility area | SwiftUI support |
|---|---|
| Screen reader (VoiceOver) | accessibilityLabel, accessibilityHint, accessibilityValue, accessibilityAction |
| Dynamic Type | Automatic for Text, Label; @ScaledMetric for custom dimensions |
| Reduce Motion | @Environment(\.accessibilityReduceMotion) |
| Reduce Transparency | @Environment(\.accessibilityReduceTransparency) |
| Differentiate Without Color | @Environment(\.accessibilityDifferentiateWithoutColor) |
| Bold Text | Automatic for system fonts |
| Increase Contrast | @Environment(\.colorSchemeContrast) |
| Switch Control / Voice Control | Inherited from VoiceOver labels |
| Focus order | accessibilityElement(children:), accessibilitySortPriority |
| Rotor | accessibilityRotor |
Concept → Why → How → Code
VoiceOver and the accessibility tree
When VoiceOver is on, iOS/macOS reads the accessibility tree — a separate hierarchy from the rendering hierarchy. Each accessible element has:
- Label — what it is (“Buy Button”)
- Value — current state (“Selected”, “$29.99”, “Slider: 50%”)
- Hint — what happens on activation (“Double-tap to purchase”)
- Traits — semantic role (button, header, image, link, adjustable, selected)
SwiftUI auto-generates these for standard controls (Button, Toggle, TextField, etc.) from your labels. Custom controls need explicit annotation.
accessibilityLabel, accessibilityHint, accessibilityValue
// Icon-only button — bad
Button(action: favorite) {
Image(systemName: "star.fill")
}
// VoiceOver reads "star fill" (the SF Symbol name) — useless
// Fixed
Button(action: favorite) {
Image(systemName: "star.fill")
}
.accessibilityLabel("Add to favorites")
.accessibilityHint("Saves this item to your favorites list")
Label (the SwiftUI view, not the modifier) does this for free:
Button(action: favorite) {
Label("Add to favorites", systemImage: "star.fill")
.labelStyle(.iconOnly) // visually only icon
}
// VoiceOver still hears "Add to favorites"
Prefer Label + .labelStyle(.iconOnly) over bare Image + accessibilityLabel.
accessibilityElement(children:)
Default: each subview is a separate accessibility element. Sometimes you want to combine them:
HStack {
Image(systemName: "person.fill")
VStack(alignment: .leading) {
Text("Sara")
Text("Online").font(.caption).foregroundStyle(.green)
}
}
.accessibilityElement(children: .combine)
.accessibilityLabel("Sara, online")
Options:
.ignore— children not announced; only this view’s explicit label.combine— children’s labels concatenated.contain— children separate but grouped (good for nested grouping)
Traits
Text("Section Header")
.font(.title2)
.accessibilityAddTraits(.isHeader)
Image("decorative-divider")
.accessibilityHidden(true) // not in tree
Image("hero-photo")
.accessibilityLabel("Sunset over Golden Gate Bridge")
.accessibilityRemoveTraits(.isImage)
.accessibilityAddTraits(.isImage) // ensure trait
Common traits:
.isButton,.isHeader,.isImage,.isLink,.isSearchField,.isSelected,.isModal,.isSummaryElement,.updatesFrequently.isStaticText,.allowsDirectInteraction,.causesPageTurn
Headers (.isHeader) let VoiceOver users navigate by heading (rotor → headings → swipe). Critical for long screens.
accessibilityHidden(_:)
For decorative views that shouldn’t be in the tree:
Image("subtle-pattern")
.accessibilityHidden(true)
// Or hide entire decorative subtrees
DecorativeBackground()
.accessibilityHidden(true)
accessibilityAction
Custom actions that VoiceOver surfaces:
NoteCard(note: note)
.accessibilityAction(named: "Delete") {
delete(note)
}
.accessibilityAction(named: "Toggle favorite") {
note.isFavorite.toggle()
}
.accessibilityAction(.magicTap) {
// invoked by 2-finger double tap with VoiceOver
playPause()
}
VoiceOver users hear “Actions available” and can browse via rotor. Far better than requiring complex gestures.
For swipe-to-delete in a List, the swipe action is exposed automatically as an accessibility action.
Adjustable values
For sliders, steppers, custom adjustable controls:
struct StarRating: View {
@Binding var rating: Int
var body: some View {
HStack {
ForEach(1...5, id: \.self) { star in
Image(systemName: star <= rating ? "star.fill" : "star")
}
}
.accessibilityElement(children: .ignore)
.accessibilityLabel("Rating")
.accessibilityValue("\(rating) of 5 stars")
.accessibilityAdjustableAction { direction in
switch direction {
case .increment: rating = min(5, rating + 1)
case .decrement: rating = max(0, rating - 1)
@unknown default: break
}
}
}
}
VoiceOver users swipe up/down to adjust — the standard gesture for sliders.
Dynamic Type
Text("Title").font(.title) // scales with Dynamic Type
Text("Body").font(.body)
Text("Caption").font(.caption)
// Custom font that scales:
Text("Custom").font(.system(size: 17, weight: .semibold, design: .rounded))
// Use:
Text("Custom").font(.system(.body, design: .rounded))
// Or with explicit text style mapping:
Text("Custom").font(.custom("Helvetica", size: 17, relativeTo: .body))
Test at extreme sizes: Settings → Accessibility → Display & Text Size → Larger Text → drag to max (AX5). Or in code:
ContentView()
.dynamicTypeSize(.accessibility5)
Common breakages:
- Text truncates in narrow containers → use
.lineLimit(nil)and.minimumScaleFactor(0.8)selectively, or reflow - Icons too small relative to giant text → use
@ScaledMetricfor sizes - Buttons overlap with surrounding content → use
ViewThatFitsto switch layouts - Toolbar items get clipped → switch to overflow menu
@ScaledMetric:
@ScaledMetric(relativeTo: .body) var iconSize: CGFloat = 24
Image(systemName: "star")
.resizable()
.frame(width: iconSize, height: iconSize)
Scales the value relative to the user’s Dynamic Type setting.
ViewThatFits for adaptive layouts
ViewThatFits(in: .horizontal) {
HStack {
Image(systemName: "star")
Text("Add to favorites")
}
Image(systemName: "star") // icon only fallback
}
When Dynamic Type makes the labeled version too wide, the icon-only fallback shows. Always pair with accessibilityLabel on the icon so VoiceOver still gets the text.
Reduce Motion
@Environment(\.accessibilityReduceMotion) var reduceMotion
withAnimation(reduceMotion ? .none : .spring(duration: 0.4)) {
isExpanded.toggle()
}
Or use SwiftUI’s automatic respect (covered in 5.7) — much animation infrastructure respects this automatically, but you should double-check for custom transitions.
Reduce Transparency, Differentiate Without Color, Increase Contrast
@Environment(\.accessibilityReduceTransparency) var reduceTransparency
@Environment(\.accessibilityDifferentiateWithoutColor) var diffColor
@Environment(\.colorSchemeContrast) var contrast
// Reduce Transparency: swap blur backgrounds for opaque
background(reduceTransparency ? .gray.opacity(0.95) : .ultraThinMaterial)
// Differentiate Without Color: add icon/pattern alongside color
HStack {
Circle().fill(.red)
if diffColor {
Image(systemName: "exclamationmark.triangle.fill")
}
Text("Error")
}
// Increase Contrast: switch to higher-contrast colors
foregroundStyle(contrast == .increased ? .black : .secondary)
accessibilityRotor — custom rotor entries
Rotor is VoiceOver’s “type-of-thing browser” (links, headers, form controls). You can publish custom rotors:
ScrollView {
LazyVStack {
ForEach(messages) { message in
MessageRow(message: message)
.id(message.id)
}
}
}
.accessibilityRotor("Unread Messages") {
ForEach(messages.filter(\.isUnread)) { message in
AccessibilityRotorEntry(message.preview, id: message.id)
}
}
VoiceOver users open the rotor, see “Unread Messages”, and can jump between them. Common for inboxes, search results, error fields.
.accessibilityFocused — programmatic focus
@AccessibilityFocusState private var focusedField: Field?
enum Field { case email, password }
TextField("Email", text: $email)
.accessibilityFocused($focusedField, equals: .email)
Button("Submit") {
if email.isEmpty {
focusedField = .email // VoiceOver jumps and announces
}
}
Critical for forms — after a validation error, focus the offending field so VoiceOver users know what to fix.
accessibilityRepresentation
Replace what VoiceOver “sees” with a different view:
ColorCircle(color: .red)
.accessibilityRepresentation {
Text("Red")
}
Apple’s recommendation: design the represented view as if it were the actual control, then VoiceOver/Switch Control get the right semantics for free.
Custom controls — full picture
A custom slider built from gestures + shapes:
struct CustomSlider: View {
@Binding var value: Double
let range: ClosedRange<Double>
var body: some View {
// ... gesture and rendering code ...
track
.gesture(dragGesture)
.accessibilityElement(children: .ignore)
.accessibilityLabel("Volume")
.accessibilityValue("\(Int(value * 100))%")
.accessibilityAdjustableAction { direction in
switch direction {
case .increment: value = min(range.upperBound, value + 0.1)
case .decrement: value = max(range.lowerBound, value - 0.1)
@unknown default: break
}
}
}
}
Without these, VoiceOver users cannot use the control. With them, it’s identical to native Slider from their POV.
Testing
Accessibility Inspector (Mac app, free, ships with Xcode):
- Launch from Xcode → Open Developer Tool → Accessibility Inspector
- Point at Simulator or device
- Audit tab → checks labels, contrast, hit-target size
- Inspection tab → see the accessibility tree as VoiceOver would
VoiceOver in Simulator:
- ⌘5 (toggle VoiceOver) — uses macOS VoiceOver to read the Simulator
- Better: physical device for realistic experience
VoiceOver gestures (device):
- Single tap: select & announce
- Double tap: activate
- Swipe right/left: next/previous element
- Two-finger swipe up: read all
- Magic tap (2-finger double tap): primary action
- Rotor: two-finger rotate
Dynamic Type:
- Settings → Accessibility → Display & Text Size → Larger Text
- Drag the slider; test your app at every size
Switch Control:
- Settings → Accessibility → Switch Control → enable, use external switch or screen taps
- Verifies focus order and reachability
Voice Control:
- “Show numbers” / “Show names” — overlay numbers/names on tappable elements
- Tap-target labels rely on your accessibility labels
Smell tests in code review
Image(systemName: "...")inside aButtonwithout anaccessibilityLabelor wrappingLabel→ reject- Custom controls without
accessibilityAdjustableActionoraccessibilityAction→ reject - Fixed
font(.system(size: 14))for body text → reject (use text styles) - Frames in
ptfor icons that don’t scale → suggest@ScaledMetric - Animation without considering
reduceMotion→ review - Colored badges/status without icon/text differentiation → review
In the wild
- Apple’s apps are best-in-class for accessibility — Reminders, Notes, Mail are fully usable with VoiceOver only.
- Stripe’s apps have excellent form accessibility — every field has labels, errors are announced.
- Twitter (RIP) and Instagram were criticized historically for poor accessibility; iOS-native rebuilds improved this.
- Banking apps are heavily scrutinized — government regulations + the user base requires accessibility.
- Apple’s “Built for All” annual blog series showcases apps with excellent accessibility.
Common misconceptions
- “My app doesn’t need accessibility; most users aren’t disabled.” ~20% of users have some accessibility need. Dynamic Type users alone are ~30%. Plus: legal requirements, App Store reviews, and “designing for accessibility” generally produces better UI for everyone.
- “SwiftUI handles accessibility for me.” Mostly, but custom controls, icon-only buttons, decorative views, and Dynamic Type-breaking layouts are your responsibility.
- “
accessibilityLabelis enough.” Often you need label + value + hint + traits + actions. A button with state (toggle) needs all four. - “Just turn on VoiceOver once at the end of the project.” Bake it in from the start. Retrofitting accessibility into a finished app costs 5-10x more.
- “Dynamic Type breaks our designs; we’ll cap the font size.” Apps that cap Dynamic Type below AX1 are flagged in App Store review and frustrate users. Design layouts that adapt.
Seasoned engineer’s take
Accessibility is not a checkbox — it’s a discipline. Teams that get it right have:
- Accessibility audits in CI — Accessibility Inspector audits, or scripted XCUITest with
accessibilityActivate()checks. - A team member assigned as the accessibility champion — reviews every PR for accessibility regressions.
- VoiceOver testing in every sprint — at least one feature touched with VoiceOver before ship.
- Dynamic Type at AX5 included in design reviews — if it breaks, redesign.
- Default-on accessibility traits — instead of forgetting, code is structured so labels are required (e.g., custom view types that require an
accessibilityLabelinitializer parameter).
The argument “we’ll add accessibility later” is the same as “we’ll add tests later” — it never happens, and the eventual cost is far higher than building it in.
The good news: SwiftUI makes 80% of accessibility automatic if you use standard controls (Button, Label, Toggle, Slider, TextField, Form, List, NavigationStack). The other 20% (custom controls, icon-only UI, custom gestures, Dynamic Type-aware layouts) needs deliberate work.
Hire and listen to disabled users. The best accessibility insights come from people who use these technologies daily.
TIP: Add “Accessibility QA” as a step in your release checklist. At minimum: full VoiceOver pass through the primary flow, Dynamic Type AX5 visual check, Reduce Motion check on animation-heavy screens.
WARNING: Don’t use
accessibilityHidden(true)to hide UI you’re too lazy to label. If it’s visible, users with assistive tech expect to be able to interact with it.
Interview corner
Junior-level: “How do you make an icon-only button accessible?”
Either wrap the icon in a Label with .labelStyle(.iconOnly) (preferred — single source of truth for the name), or apply .accessibilityLabel("…") to the button. The Label approach is better because the accessibility text comes from the same string you’d use for the visual label, reducing drift.
Mid-level: “A custom slider built from a Capsule, a Circle, and a DragGesture is not usable with VoiceOver. How do you fix it?”
- Mark the whole control as a single accessibility element with
.accessibilityElement(children: .ignore)so the individual shapes don’t pollute the tree. - Add
.accessibilityLabel("Volume")(or whatever it represents). - Add
.accessibilityValue("\(Int(value * 100))%")so VoiceOver announces the current state. - Add
.accessibilityAdjustableAction { direction in ... }to handle VoiceOver’s swipe-up/swipe-down increments. Increment/decrement by a sensible step. - Optionally:
.accessibilityAddTraits(.isSlider)(thoughaccessibilityAdjustableActionimplies it).
Now VoiceOver users hear “Volume, 50%, slider, swipe up to increment” and can adjust.
Senior-level: “Design an accessibility strategy for a 200-screen app that has had accessibility ignored for 3 years.”
- Audit & prioritize: Run Accessibility Inspector audit on every screen — produces a backlog. Categorize: (a) blockers (control unreachable, no label), (b) usability (poor labels, missing actions), (c) polish (better announcements, custom rotors). Triage by user-facing impact (login screen first, settings last).
- Establish baseline rules: Lint for
Image(systemName:)withoutaccessibilityLabelorLabel. CI fails PRs that introduce regressions. - Tackle highest-impact screens first: Authentication, primary flows, payment. Get them VoiceOver-clean. Each gets a dedicated VoiceOver QA pass.
- Refactor reusable components first:
PrimaryButton, custom form fields, custom navigation — fixing once propagates to all uses. - Add Dynamic Type tests: Snapshot tests at sizes Body, Large, XL, AX5. Visual diff reveals layout breakage.
- Onboard team: Lunch-and-learn sessions, accessibility champion appointed, design-review checklist updated.
- Hire accessibility consultants: External audit at the end to catch what the team misses. Apple’s Accessibility Consultancy team provides feedback for high-profile apps.
- Continuous integration: UI tests with VoiceOver activated assertions (
XCTAccessibilityAPI), Accessibility Inspector audits in CI. - User research with disabled users: Recruit through accessibility advocacy organizations. Watch them use the app. Insights you can’t get otherwise.
- Track metrics: Accessibility bug count over time, AX5 layout compliance percentage, “VoiceOver score” per release.
Red flag in candidates: Treating accessibility as a “nice to have” or “specialized feature”. Or saying “we’ll only support default Dynamic Type sizes”.
Lab preview
The labs in this phase implicitly require accessibility — when you ship the Todo app, Animated Dashboard, Multiplatform Notes, or Component Library, run them with VoiceOver and Dynamic Type AX3 to verify they’re usable. Component library especially: every published component should have built-in accessibility (label parameter required, sensible defaults).
Phase 5 chapters complete. Continue with Lab 5.1 — Todo app.
Lab 5.1 — Todo app
Goal
Build a minimal SwiftData-backed todo app: list of todos, add / edit / delete, mark complete, persistence across launches. Single iPhone + iPad target. Modern Swift 6 patterns: @Observable, NavigationStack, @Model, @Query, swipe actions, Form.
By the end you’ll be comfortable wiring SwiftData + SwiftUI for a basic CRUD app — the bread-and-butter app shape you’ll see in 80% of iOS jobs.
Time
90–120 minutes.
Prereqs
- Xcode 16+
- Comfort with Swift 6,
@Observable,NavigationStack(chapter 5.5),@Model(chapter 7 forward look, but minimal knowledge here)
Setup
- Xcode → New Project → iOS App
- Interface: SwiftUI, Storage: SwiftData
- Name:
TodoApp, organization identifier whatever - Delete the boilerplate
Item.swiftand the sample views inContentView.swift
Build
1. Model
Todo.swift:
import Foundation
import SwiftData
@Model
final class Todo {
var title: String
var notes: String
var isCompleted: Bool
var createdAt: Date
var dueDate: Date?
init(
title: String,
notes: String = "",
isCompleted: Bool = false,
dueDate: Date? = nil
) {
self.title = title
self.notes = notes
self.isCompleted = isCompleted
self.createdAt = .now
self.dueDate = dueDate
}
}
2. App entry — SwiftData container
TodoAppApp.swift:
import SwiftUI
import SwiftData
@main
struct TodoAppApp: App {
var body: some Scene {
WindowGroup {
TodoListView()
}
.modelContainer(for: Todo.self)
}
}
.modelContainer(for:) creates the SwiftData container, injects \.modelContext into the environment.
3. List view with @Query
TodoListView.swift:
import SwiftUI
import SwiftData
struct TodoListView: View {
@Environment(\.modelContext) private var context
@Query(sort: \Todo.createdAt, order: .reverse) private var todos: [Todo]
@State private var showingAdd = false
@State private var editing: Todo?
var body: some View {
NavigationStack {
Group {
if todos.isEmpty {
ContentUnavailableView(
"No todos",
systemImage: "checklist",
description: Text("Tap + to add one")
)
} else {
List {
ForEach(todos) { todo in
TodoRow(todo: todo)
.contentShape(Rectangle())
.onTapGesture { editing = todo }
.swipeActions(edge: .leading) {
Button {
todo.isCompleted.toggle()
} label: {
Label(
todo.isCompleted ? "Unmark" : "Complete",
systemImage: todo.isCompleted ? "circle" : "checkmark.circle.fill"
)
}
.tint(.green)
}
.swipeActions(edge: .trailing) {
Button(role: .destructive) {
context.delete(todo)
} label: {
Label("Delete", systemImage: "trash")
}
}
}
}
}
}
.navigationTitle("Todos")
.toolbar {
ToolbarItem(placement: .primaryAction) {
Button {
showingAdd = true
} label: {
Label("Add", systemImage: "plus")
}
}
}
.sheet(isPresented: $showingAdd) {
NavigationStack {
TodoEditor(todo: nil)
}
}
.sheet(item: $editing) { todo in
NavigationStack {
TodoEditor(todo: todo)
}
}
}
}
}
Notes:
@Queryis reactive — when the data changes, the view re-renders.swipeActions(edge:)on both sides — leading for complete, trailing for delete.ContentUnavailableView(iOS 17+) is the standard empty-state component.sheet(item:)for editing existing todo (binding-based, dismisses onediting = nil).
4. Row
TodoRow.swift:
import SwiftUI
import SwiftData
struct TodoRow: View {
@Bindable var todo: Todo
var body: some View {
HStack(spacing: 12) {
Button {
todo.isCompleted.toggle()
} label: {
Image(systemName: todo.isCompleted ? "checkmark.circle.fill" : "circle")
.font(.title2)
.foregroundStyle(todo.isCompleted ? .green : .secondary)
}
.buttonStyle(.plain)
.accessibilityLabel(todo.isCompleted ? "Mark incomplete" : "Mark complete")
VStack(alignment: .leading, spacing: 2) {
Text(todo.title)
.strikethrough(todo.isCompleted)
.foregroundStyle(todo.isCompleted ? .secondary : .primary)
if let due = todo.dueDate {
Text(due, style: .date)
.font(.caption)
.foregroundStyle(due < .now && !todo.isCompleted ? .red : .secondary)
}
}
}
.padding(.vertical, 4)
}
}
@Bindable enables two-way bindings on the @Model instance. Toggling isCompleted persists automatically — SwiftData detects the property mutation.
5. Editor
TodoEditor.swift:
import SwiftUI
import SwiftData
struct TodoEditor: View {
@Environment(\.modelContext) private var context
@Environment(\.dismiss) private var dismiss
let todo: Todo? // nil = new
@State private var title = ""
@State private var notes = ""
@State private var hasDueDate = false
@State private var dueDate = Date.now
var body: some View {
Form {
Section("Details") {
TextField("Title", text: $title)
TextField("Notes", text: $notes, axis: .vertical)
.lineLimit(3...10)
}
Section("Due date") {
Toggle("Has due date", isOn: $hasDueDate.animation())
if hasDueDate {
DatePicker(
"Due",
selection: $dueDate,
displayedComponents: [.date, .hourAndMinute]
)
}
}
}
.navigationTitle(todo == nil ? "New Todo" : "Edit Todo")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") { dismiss() }
}
ToolbarItem(placement: .confirmationAction) {
Button(todo == nil ? "Add" : "Save") {
save()
dismiss()
}
.disabled(title.trimmingCharacters(in: .whitespaces).isEmpty)
}
}
.onAppear {
if let todo {
title = todo.title
notes = todo.notes
hasDueDate = todo.dueDate != nil
dueDate = todo.dueDate ?? .now
}
}
}
private func save() {
if let todo {
todo.title = title
todo.notes = notes
todo.dueDate = hasDueDate ? dueDate : nil
} else {
let new = Todo(
title: title,
notes: notes,
dueDate: hasDueDate ? dueDate : nil
)
context.insert(new)
}
}
}
6. Run & verify
- Add 3 todos
- Mark one complete
- Edit one (change title, add a due date)
- Delete one with swipe
- Kill app, relaunch — data persists
Stretch goals
- Filters/segments: Add
Picker(.segmented)at top: All / Active / Completed. Use a@Query(filter:)or local filtering. - Categories: Add
@Model Categorywith@RelationshipfromTodotoCategory. Add a category picker in the editor. - Search: Add
.searchable(text:)onTodoListView, filter the list. - Pull to refresh:
.refreshable { try? await Task.sleep(for: .seconds(1)) }(no-op, but shows the pattern). - Notifications: Schedule a local notification when a todo has a due date in the future.
- iPad split view: Wrap in
NavigationSplitView— list on left, editor on right. - Reorder: Add
.onMovefor manual ordering, storeorder: Intin model. - Watch app: Add a watchOS target that reads the same SwiftData (via App Group + CloudKit sync).
Notes & troubleshooting
@Bindablerequires the type to be an@Observableor@Model. Won’t compile on plain classes.@Queryre-runs whenever data changes. Don’t filter in the view body if you can express it in@Query(filter: #Predicate { ... })for performance.- SwiftData with iCloud: add
.modelContainer(for: Todo.self, isAutosaveEnabled: true)and configure CloudKit container in entitlements. - Editing pattern: passing the
Tododirectly and using@Bindablelets edits commit live as the user types. If you prefer “Cancel” to discard changes, use the local@State+save()pattern as shown (changes only persist on Save). - Sheet binding gotcha:
editing = todotriggers thesheet(item:). Settingediting = nildismisses. Tapping outside or the cancel button also nils it viadismiss()— works becausesheet(item:)is bound to$editing.
Where to next
Lab 5.2 (Animated dashboard) explores Canvas, PhaseAnimator, matchedGeometryEffect — the animation-heavy side of SwiftUI.
Next: Lab 5.2 — Animated dashboard
Lab 5.2 — Animated dashboard
Goal
Build a dashboard with animated metric cards, a Canvas-drawn bar chart, a PhaseAnimator entrance animation, and matchedGeometryEffect for tap-to-expand transitions. Practice the animation primitives from chapter 5.7.
By the end you’ll have a portfolio-grade animated UI showcase — the kind of work that shows up in design-conference SwiftUI talks.
Time
90–120 minutes.
Prereqs
- Xcode 16+, iOS 17+
- Chapter 5.7 (animations & transitions)
Setup
- New iOS App, SwiftUI, no SwiftData needed.
- Name:
DashboardLab.
Build
1. Data
Metric.swift:
import Foundation
struct Metric: Identifiable, Hashable {
let id = UUID()
let title: String
let value: Double
let unit: String
let trend: Double // % change
let sparkline: [Double]
}
extension Metric {
static let sample: [Metric] = [
Metric(title: "Revenue", value: 124_320, unit: "$",
trend: 12.4, sparkline: [10, 12, 14, 11, 15, 18, 20]),
Metric(title: "Users", value: 8_421, unit: "",
trend: -2.1, sparkline: [40, 38, 36, 35, 33, 34, 36]),
Metric(title: "Sessions", value: 32_115, unit: "",
trend: 5.8, sparkline: [100, 105, 110, 108, 115, 120, 125]),
Metric(title: "Conversion", value: 3.42, unit: "%",
trend: 0.4, sparkline: [3.2, 3.3, 3.25, 3.4, 3.35, 3.45, 3.42]),
]
}
2. Entry animation with PhaseAnimator
DashboardView.swift:
import SwiftUI
struct DashboardView: View {
@State private var animateIn = false
@State private var expanded: Metric?
@Namespace private var ns
private let metrics = Metric.sample
var body: some View {
ZStack {
ScrollView {
LazyVGrid(columns: [.init(.adaptive(minimum: 160), spacing: 16)], spacing: 16) {
ForEach(Array(metrics.enumerated()), id: \.element.id) { idx, metric in
if expanded?.id != metric.id {
MetricCard(metric: metric)
.matchedGeometryEffect(id: metric.id, in: ns)
.onTapGesture {
withAnimation(.spring(duration: 0.4, bounce: 0.25)) {
expanded = metric
}
}
.phaseAnimator([0, 1], trigger: animateIn) { content, phase in
content
.opacity(phase)
.scaleEffect(phase == 0 ? 0.85 : 1)
.offset(y: phase == 0 ? 20 : 0)
} animation: { _ in
.spring(duration: 0.5, bounce: 0.25).delay(Double(idx) * 0.06)
}
} else {
Color.clear
.frame(height: 1)
}
}
}
.padding()
}
.navigationTitle("Dashboard")
if let expanded {
ExpandedCard(metric: expanded, namespace: ns) {
withAnimation(.spring(duration: 0.4, bounce: 0.2)) {
self.expanded = nil
}
}
.matchedGeometryEffect(id: expanded.id, in: ns)
.padding()
.transition(.opacity)
}
}
.onAppear { animateIn = true }
}
}
The trick:
- Each card uses
matchedGeometryEffect(id:in:)with its metric id. - When tapped,
expanded = metric; we set the placeholderColor.clearin the grid position and render theExpandedCard(alsomatchedGeometryEffect’d to the same id) — SwiftUI animates the geometry transition. PhaseAnimatorruns the entry animation on each card with a staggered delay.
3. Metric card
MetricCard.swift:
import SwiftUI
struct MetricCard: View {
let metric: Metric
var body: some View {
VStack(alignment: .leading, spacing: 8) {
HStack {
Text(metric.title)
.font(.caption)
.foregroundStyle(.secondary)
Spacer()
TrendBadge(trend: metric.trend)
}
Text(formatted)
.font(.title2.bold())
.contentTransition(.numericText())
SparklineView(values: metric.sparkline)
.frame(height: 40)
.foregroundStyle(metric.trend >= 0 ? .green : .red)
}
.padding()
.background(.regularMaterial, in: .rect(cornerRadius: 16))
}
private var formatted: String {
if metric.unit == "$" {
"$\(Int(metric.value).formatted())"
} else if metric.unit == "%" {
String(format: "%.2f%%", metric.value)
} else {
"\(Int(metric.value).formatted())"
}
}
}
struct TrendBadge: View {
let trend: Double
var body: some View {
Label("\(trend, specifier: "%+.1f")%", systemImage: trend >= 0 ? "arrow.up.right" : "arrow.down.right")
.font(.caption2.weight(.semibold))
.foregroundStyle(trend >= 0 ? .green : .red)
.padding(.horizontal, 6).padding(.vertical, 3)
.background((trend >= 0 ? Color.green : .red).opacity(0.15), in: .capsule)
}
}
4. Canvas sparkline
SparklineView.swift:
import SwiftUI
struct SparklineView: View {
let values: [Double]
var body: some View {
Canvas { context, size in
guard values.count > 1 else { return }
let minV = values.min() ?? 0
let maxV = values.max() ?? 1
let range = max(maxV - minV, 0.0001)
var path = Path()
for (idx, value) in values.enumerated() {
let x = CGFloat(idx) / CGFloat(values.count - 1) * size.width
let y = size.height - CGFloat((value - minV) / range) * size.height
if idx == 0 {
path.move(to: CGPoint(x: x, y: y))
} else {
path.addLine(to: CGPoint(x: x, y: y))
}
}
context.stroke(path, with: .foreground, lineWidth: 2)
// Fill below the line
var fillPath = path
fillPath.addLine(to: CGPoint(x: size.width, y: size.height))
fillPath.addLine(to: CGPoint(x: 0, y: size.height))
fillPath.closeSubpath()
context.fill(fillPath, with: .foreground.opacity(0.2))
}
}
}
Canvas is the imperative drawing API — fast, ideal for charts that don’t need individual hit-testing.
5. Expanded card
ExpandedCard.swift:
import SwiftUI
struct ExpandedCard: View {
let metric: Metric
let namespace: Namespace.ID
let onDismiss: () -> Void
@State private var animatedValue: Double = 0
var body: some View {
VStack(alignment: .leading, spacing: 20) {
HStack {
Text(metric.title).font(.headline)
Spacer()
Button {
onDismiss()
} label: {
Image(systemName: "xmark.circle.fill")
.font(.title2)
.foregroundStyle(.secondary)
}
}
Text("\(animatedValue, specifier: "%.0f")")
.font(.system(size: 56, weight: .bold, design: .rounded))
.contentTransition(.numericText())
.keyframeAnimator(initialValue: 0.0, trigger: metric.id) { content, value in
content.scaleEffect(value)
} keyframes: { _ in
KeyframeTrack {
SpringKeyframe(1.0, duration: 0.4, spring: .bouncy)
}
}
BarChart(values: metric.sparkline)
.frame(height: 180)
TrendBadge(trend: metric.trend)
}
.padding(24)
.frame(maxWidth: .infinity)
.background(.regularMaterial, in: .rect(cornerRadius: 24))
.shadow(radius: 20)
.onAppear {
withAnimation(.spring(duration: 0.6, bounce: 0.3)) {
animatedValue = metric.value
}
}
}
}
6. Animated bar chart
BarChart.swift:
import SwiftUI
struct BarChart: View {
let values: [Double]
@State private var progress: Double = 0
var body: some View {
Canvas { context, size in
guard !values.isEmpty else { return }
let maxV = values.max() ?? 1
let barWidth = size.width / CGFloat(values.count) * 0.7
let spacing = size.width / CGFloat(values.count) * 0.3
for (idx, value) in values.enumerated() {
let height = CGFloat(value / maxV) * size.height * progress
let x = CGFloat(idx) * (barWidth + spacing) + spacing / 2
let y = size.height - height
let rect = CGRect(x: x, y: y, width: barWidth, height: height)
context.fill(
Path(roundedRect: rect, cornerRadius: 4),
with: .linearGradient(
Gradient(colors: [.blue, .purple]),
startPoint: CGPoint(x: x, y: y),
endPoint: CGPoint(x: x, y: size.height)
)
)
}
}
.onAppear {
withAnimation(.smooth(duration: 0.8)) { progress = 1 }
}
}
}
7. Wire and run
AnimatedDashboardApp.swift:
@main
struct AnimatedDashboardApp: App {
var body: some Scene {
WindowGroup {
NavigationStack {
DashboardView()
}
}
}
}
Run, observe:
- Cards stagger-fly in on launch
- Tap a card → it expands smoothly to a detail view in the same screen position
- Bar chart animates from 0 to full height
- Value counts up
- Tap X → it collapses back
Stretch goals
- Pull to refresh + value updates:
.refreshablerandomly perturbs metric values;contentTransition(.numericText())animates the digit changes. - Real chart with Swift Charts: Replace
BarChartwithChart { BarMark(...) }. - Gesture-driven expansion: Long press to start expanding, drag to commit/cancel.
- Time-range picker in the expanded view (1D/1W/1M/1Y) with morphing chart.
- Color theming via
@Environmentinjection — try a “playful” vs “professional” theme. - Mesh gradient backgrounds (iOS 18+)
MeshGradient(width:height:points:colors:)for the expanded card background.
Notes & troubleshooting
matchedGeometryEffectrequires the sameidandnamespacein both source and destination. A spelling mismatch silently breaks the animation.- The collapsed-card position must “exist” in the layout when the expanded card returns. Using
Color.clear.frame(height: 1)is hacky; a cleaner approach is to keepMetricCardrendered with.opacity(0)and disable hit-testing while expanded. PhaseAnimatorruns once per phase change. SettinganimateIn = trueon appear triggers from0to1. If you want repeating, usePhaseAnimator(phases: ...).Canvasdoesn’t redraw automatically. If you changevalues, mark them as@Stateor pass via@Bindableso SwiftUI invalidates.keyframeAnimator(trigger:)runs once per trigger change. Use a value that changes when you want to re-trigger.- iOS 17 minimum for
PhaseAnimator,KeyframeAnimator,contentTransition. Drop deployment target below = no go.
Where to next
Lab 5.3 (Multiplatform notes) builds a real cross-device app — iPad sidebar, Mac toolbar/commands, shared @Observable store.
Next: Lab 5.3 — Multiplatform notes
Lab 5.3 — Multiplatform notes
Goal
Build a single-target Notes app that runs natively on iPhone, iPad, and Mac with one codebase. Practice NavigationSplitView, @Observable store sharing, platform-specific Commands and Settings scene on Mac, WindowGroup(for:) multi-window on iPad/Mac.
By the end you’ll have done a real multiplatform SwiftUI project — the most common modern SwiftUI app shape.
Time
120–180 minutes.
Prereqs
- Xcode 16+
- Chapters 5.10 (multiplatform) and 5.11 (Mac advanced)
Setup
- Xcode → New Project → Multiplatform App (iOS + macOS in one target)
- Name:
MultiNotes - Interface: SwiftUI, Language: Swift, no SwiftData (we’ll use a simple in-memory +
Codablestore for simplicity; SwiftData would also work)
Build
1. Model
Note.swift:
import Foundation
struct Note: Identifiable, Hashable, Codable {
let id: UUID
var title: String
var body: String
var folder: String
var modified: Date
init(id: UUID = UUID(), title: String, body: String = "", folder: String = "Inbox", modified: Date = .now) {
self.id = id
self.title = title
self.body = body
self.folder = folder
self.modified = modified
}
}
2. Store
NoteStore.swift:
import Foundation
import Observation
@MainActor
@Observable
final class NoteStore {
var notes: [Note] = []
var folders: [String] = ["Inbox", "Work", "Personal", "Archive"]
private let url: URL = {
let dir = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
return dir.appendingPathComponent("notes.json")
}()
init() {
load()
if notes.isEmpty {
notes = [
Note(title: "Welcome", body: "This is a multiplatform note.", folder: "Inbox"),
Note(title: "Shopping list", body: "Milk, eggs, bread", folder: "Personal"),
]
}
}
func notes(in folder: String) -> [Note] {
notes.filter { $0.folder == folder }
.sorted { $0.modified > $1.modified }
}
func add(_ folder: String) {
let new = Note(title: "Untitled", folder: folder)
notes.append(new)
save()
}
func update(_ note: Note) {
if let idx = notes.firstIndex(where: { $0.id == note.id }) {
var updated = note
updated.modified = .now
notes[idx] = updated
save()
}
}
func delete(_ id: Note.ID) {
notes.removeAll { $0.id == id }
save()
}
private func load() {
guard let data = try? Data(contentsOf: url),
let decoded = try? JSONDecoder().decode([Note].self, from: data) else { return }
notes = decoded
}
private func save() {
try? JSONEncoder().encode(notes).write(to: url)
}
}
3. App entry
MultiNotesApp.swift:
import SwiftUI
@main
struct MultiNotesApp: App {
@State private var store = NoteStore()
var body: some Scene {
WindowGroup {
ContentView()
.environment(store)
}
#if os(macOS)
.commands {
CommandGroup(replacing: .newItem) {
Button("New Note") {
store.add("Inbox")
}
.keyboardShortcut("n", modifiers: .command)
}
SidebarCommands()
}
Settings {
SettingsView()
.frame(width: 360, height: 200)
}
#endif
WindowGroup("Note", id: "note", for: Note.ID.self) { $noteID in
if let id = noteID,
let note = store.notes.first(where: { $0.id == id }) {
DetachedNoteWindow(note: note)
.environment(store)
}
}
}
}
4. Content view — NavigationSplitView
ContentView.swift:
import SwiftUI
struct ContentView: View {
@Environment(NoteStore.self) private var store
@State private var selectedFolder: String? = "Inbox"
@State private var selectedNoteID: Note.ID?
var body: some View {
NavigationSplitView {
// Sidebar
List(store.folders, id: \.self, selection: $selectedFolder) { folder in
Label(folder, systemImage: icon(for: folder))
.tag(folder)
}
.navigationTitle("Folders")
#if os(macOS)
.frame(minWidth: 160)
#endif
} content: {
// Note list
if let folder = selectedFolder {
NoteList(folder: folder, selection: $selectedNoteID)
} else {
ContentUnavailableView("No folder", systemImage: "folder")
}
} detail: {
// Editor
if let id = selectedNoteID, let note = store.notes.first(where: { $0.id == id }) {
NoteEditor(note: note)
} else {
ContentUnavailableView("No note selected", systemImage: "note.text")
}
}
}
private func icon(for folder: String) -> String {
switch folder {
case "Inbox": return "tray"
case "Work": return "briefcase"
case "Personal": return "person"
case "Archive": return "archivebox"
default: return "folder"
}
}
}
NavigationSplitView adapts:
- iPhone: stack (Folders → NoteList → NoteEditor)
- iPad: 3-column on landscape, 2-column on portrait
- Mac: 3-column with native split bars
5. Note list
NoteList.swift:
import SwiftUI
struct NoteList: View {
@Environment(NoteStore.self) private var store
@Environment(\.openWindow) private var openWindow
let folder: String
@Binding var selection: Note.ID?
var notes: [Note] { store.notes(in: folder) }
var body: some View {
List(selection: $selection) {
ForEach(notes) { note in
NoteRow(note: note)
.tag(note.id)
.contextMenu {
Button("Open in New Window") {
openWindow(id: "note", value: note.id)
}
Button("Delete", role: .destructive) {
store.delete(note.id)
}
}
}
.onDelete { idx in
idx.forEach { store.delete(notes[$0].id) }
}
}
.navigationTitle(folder)
.toolbar {
ToolbarItem(placement: .primaryAction) {
Button {
store.add(folder)
} label: {
Label("New Note", systemImage: "square.and.pencil")
}
}
}
#if os(macOS)
.frame(minWidth: 220)
#endif
}
}
struct NoteRow: View {
let note: Note
var body: some View {
VStack(alignment: .leading, spacing: 4) {
Text(note.title.isEmpty ? "Untitled" : note.title)
.font(.headline)
.lineLimit(1)
Text(note.body)
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(2)
Text(note.modified, style: .date)
.font(.caption2)
.foregroundStyle(.tertiary)
}
.padding(.vertical, 2)
}
}
6. Editor
NoteEditor.swift:
import SwiftUI
struct NoteEditor: View {
@Environment(NoteStore.self) private var store
let note: Note
@State private var title = ""
@State private var body = ""
var body: some View {
VStack(alignment: .leading, spacing: 0) {
TextField("Title", text: $title)
.textFieldStyle(.plain)
.font(.title.bold())
.padding()
Divider()
TextEditor(text: $body)
.padding(.horizontal)
}
.navigationTitle(title.isEmpty ? "Untitled" : title)
.onAppear {
title = note.title
body = note.body
}
.onChange(of: note.id) {
title = note.title
body = note.body
}
.onChange(of: title) { commitDebounced() }
.onChange(of: body) { commitDebounced() }
#if os(macOS)
.frame(minWidth: 400, minHeight: 300)
#endif
}
@State private var commitTask: Task<Void, Never>?
private func commitDebounced() {
commitTask?.cancel()
commitTask = Task {
try? await Task.sleep(for: .milliseconds(400))
guard !Task.isCancelled else { return }
var updated = note
updated.title = title
updated.body = body
store.update(updated)
}
}
}
7. Detached window (Mac/iPad)
DetachedNoteWindow.swift:
import SwiftUI
struct DetachedNoteWindow: View {
let note: Note
var body: some View {
NoteEditor(note: note)
#if os(macOS)
.frame(minWidth: 500, minHeight: 400)
#endif
}
}
8. Settings (Mac)
SettingsView.swift:
import SwiftUI
#if os(macOS)
struct SettingsView: View {
@AppStorage("editor.font.size") private var fontSize: Double = 14
@AppStorage("editor.theme") private var theme: String = "Light"
var body: some View {
TabView {
Form {
Slider(value: $fontSize, in: 10...32, step: 1) {
Text("Editor font size: \(Int(fontSize))")
}
Picker("Theme", selection: $theme) {
Text("Light").tag("Light")
Text("Dark").tag("Dark")
Text("System").tag("System")
}
}
.padding()
.tabItem { Label("General", systemImage: "gear") }
}
}
}
#endif
9. Run on each platform
- iOS Simulator (iPhone 17 / iPad Pro)
- Mac (just hit Run with macOS destination)
- Verify:
- Folders → notes → editor flow on each
- Add note button works
- Delete works (swipe on iOS, context menu on Mac)
- Edit a note, switch away and back: changes persisted
- Mac: ⌘N creates note, ⌘, opens Settings, “Open in New Window” right-click works
- Kill app, relaunch — notes persist
Stretch goals
- Search:
.searchable(text:)on the note list, filter live. - Tags: Add
tags: Set<String>toNote; chip UI in the editor. - iCloud sync: Use
NSUbiquitousKeyValueStorefor small data, or migrate to SwiftData + CloudKit for real sync. - Mac: rich text editor: Replace
TextEditorwithNSTextViewviaNSViewRepresentablefor full rich text + spell check. - iPad keyboard shortcuts: Add
.keyboardShortcuton toolbar buttons so external-keyboard iPad users get the same UX. - Inspector pane on Mac/iPad: Add
.inspector(isPresented:)with metadata (created date, word count, tags). - Quick Look on Mac: Make
NoteTransferableso dragging a note row out exports a.txtfile. MenuBarExtraon Mac: Recent notes shortcut in menu bar.
Notes & troubleshooting
@Environment(NoteStore.self)requiresNoteStoreto be@Observableand injected via.environment(store). Forgetting either crashes at runtime with “Missing Observable object of type NoteStore”.TextEditoron macOS usesNSTextViewunder the hood, but doesn’t expose rich text. For real rich text, wrapNSTextViewyourself.- Multi-window with
WindowGroup(for:)requires the binding value (Note.ID = UUID) to beCodable + Hashable.UUIDis both. The window then restores on relaunch. @AppStorageis shared across the entire app — fine for settings, not for per-window state. Use@SceneStoragefor per-window state (selected note, scroll position).- Editor debouncing: The simple Task-based debounce works; for production, consider Combine
debounceor an actor-based debouncer. - Mac min frame: Without
.frame(minWidth:minHeight:), the window can shrink to 0 in some configurations. Always set sane minimums on Mac.
Where to next
Lab 5.4 (Component library) packages reusable SwiftUI components as a Swift package — the design-system pattern used by Robinhood, Lyft, Airbnb’s Epoxy.
Next: Lab 5.4 — Component library
Lab 5.4 — Component library
Goal
Build a small SwiftUI component library as a Swift Package: a PrimaryButtonStyle, RoundedTextFieldStyle, CardModifier, Badge, and EmptyState views. Each with #Preview blocks, accessibility built in, and a README with usage examples.
By the end you’ll have practiced the design-system pattern that every iOS team at scale uses.
Time
90–120 minutes.
Prereqs
- Xcode 16+
- Chapter 5.8 (custom views & view modifiers)
Setup
- Xcode → New → Package…
- Name:
DesignKit - iOS 17, macOS 14 deployment targets in
Package.swift
// swift-tools-version: 5.10
import PackageDescription
let package = Package(
name: "DesignKit",
platforms: [.iOS(.v17), .macOS(.v14)],
products: [
.library(name: "DesignKit", targets: ["DesignKit"]),
],
targets: [
.target(name: "DesignKit"),
.testTarget(name: "DesignKitTests", dependencies: ["DesignKit"]),
]
)
Build
1. Theme via EnvironmentValues
Sources/DesignKit/Theme.swift:
import SwiftUI
public struct Theme: Sendable {
public var primary: Color
public var background: Color
public var cardBackground: Color
public var cornerRadius: CGFloat
public var spacingUnit: CGFloat
public init(
primary: Color = .accentColor,
background: Color = Color(.systemBackground),
cardBackground: Color = Color(.secondarySystemBackground),
cornerRadius: CGFloat = 12,
spacingUnit: CGFloat = 8
) {
self.primary = primary
self.background = background
self.cardBackground = cardBackground
self.cornerRadius = cornerRadius
self.spacingUnit = spacingUnit
}
public static let `default` = Theme()
}
extension EnvironmentValues {
@Entry public var theme: Theme = .default
}
extension View {
public func theme(_ theme: Theme) -> some View {
environment(\.theme, theme)
}
}
Note: Color(.systemBackground) is iOS-only. For cross-platform you’d use Color(uiColor:) / Color(nsColor:) conditional. Kept simple here — assume iOS.
2. PrimaryButtonStyle
Sources/DesignKit/PrimaryButtonStyle.swift:
import SwiftUI
public struct PrimaryButtonStyle: ButtonStyle {
@Environment(\.theme) private var theme
@Environment(\.isEnabled) private var isEnabled
public init() {}
public func makeBody(configuration: Configuration) -> some View {
configuration.label
.font(.body.weight(.semibold))
.foregroundStyle(isEnabled ? Color.white : Color.white.opacity(0.6))
.padding(.vertical, theme.spacingUnit * 1.5)
.padding(.horizontal, theme.spacingUnit * 2)
.frame(maxWidth: .infinity)
.background(
RoundedRectangle(cornerRadius: theme.cornerRadius)
.fill(isEnabled ? theme.primary : theme.primary.opacity(0.4))
)
.scaleEffect(configuration.isPressed ? 0.98 : 1)
.animation(.spring(duration: 0.2), value: configuration.isPressed)
}
}
extension ButtonStyle where Self == PrimaryButtonStyle {
public static var primary: PrimaryButtonStyle { .init() }
}
#Preview {
VStack(spacing: 16) {
Button("Primary") { }.buttonStyle(.primary)
Button("Disabled") { }.buttonStyle(.primary).disabled(true)
}
.padding()
}
3. RoundedTextFieldStyle
Sources/DesignKit/RoundedTextFieldStyle.swift:
import SwiftUI
public struct RoundedTextFieldStyle: TextFieldStyle {
@Environment(\.theme) private var theme
public init() {}
public func _body(configuration: TextField<Self._Label>) -> some View {
configuration
.padding(.vertical, theme.spacingUnit * 1.25)
.padding(.horizontal, theme.spacingUnit * 1.5)
.background(
RoundedRectangle(cornerRadius: theme.cornerRadius)
.fill(theme.cardBackground)
)
.overlay(
RoundedRectangle(cornerRadius: theme.cornerRadius)
.stroke(Color.secondary.opacity(0.2), lineWidth: 1)
)
}
}
extension TextFieldStyle where Self == RoundedTextFieldStyle {
public static var rounded: RoundedTextFieldStyle { .init() }
}
#Preview {
@Previewable @State var text = ""
return VStack {
TextField("Email", text: $text).textFieldStyle(.rounded)
}
.padding()
}
4. CardModifier
Sources/DesignKit/CardModifier.swift:
import SwiftUI
public struct CardModifier: ViewModifier {
@Environment(\.theme) private var theme
public init() {}
public func body(content: Content) -> some View {
content
.padding(theme.spacingUnit * 2)
.background(
RoundedRectangle(cornerRadius: theme.cornerRadius)
.fill(theme.cardBackground)
)
.shadow(color: .black.opacity(0.06), radius: 8, x: 0, y: 2)
}
}
extension View {
public func card() -> some View {
modifier(CardModifier())
}
}
#Preview {
VStack(alignment: .leading) {
Text("Card title").font(.headline)
Text("Card body with some longer text").font(.body).foregroundStyle(.secondary)
}
.card()
.padding()
}
5. Badge
Sources/DesignKit/Badge.swift:
import SwiftUI
public struct Badge: View {
public enum Style {
case info, success, warning, error
}
let text: String
let style: Style
public init(_ text: String, style: Style = .info) {
self.text = text
self.style = style
}
public var body: some View {
Text(text)
.font(.caption2.weight(.semibold))
.foregroundStyle(foreground)
.padding(.horizontal, 8)
.padding(.vertical, 3)
.background(background, in: .capsule)
.accessibilityLabel("\(styleLabel): \(text)")
}
private var foreground: Color {
switch style {
case .info: .blue
case .success: .green
case .warning: .orange
case .error: .red
}
}
private var background: Color { foreground.opacity(0.15) }
private var styleLabel: String {
switch style {
case .info: "Info"
case .success: "Success"
case .warning: "Warning"
case .error: "Error"
}
}
}
#Preview {
HStack {
Badge("New", style: .info)
Badge("Live", style: .success)
Badge("Beta", style: .warning)
Badge("Failed", style: .error)
}
.padding()
}
6. EmptyState
Sources/DesignKit/EmptyState.swift:
import SwiftUI
public struct EmptyState<Action: View>: View {
let title: String
let message: String?
let systemImage: String
let action: Action
public init(
_ title: String,
message: String? = nil,
systemImage: String,
@ViewBuilder action: () -> Action = { EmptyView() }
) {
self.title = title
self.message = message
self.systemImage = systemImage
self.action = action()
}
public var body: some View {
VStack(spacing: 16) {
Image(systemName: systemImage)
.font(.system(size: 56))
.foregroundStyle(.secondary)
Text(title)
.font(.title3.weight(.semibold))
if let message {
Text(message)
.font(.body)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
}
action
.padding(.top, 8)
}
.padding(32)
.frame(maxWidth: 320)
.accessibilityElement(children: .combine)
}
}
#Preview("With action") {
EmptyState(
"No notes yet",
message: "Tap below to create your first note.",
systemImage: "note.text"
) {
Button("Create note") { }.buttonStyle(.primary)
}
}
#Preview("Without action") {
EmptyState(
"All caught up!",
systemImage: "checkmark.circle"
)
}
7. Showcase view (for development)
Sources/DesignKit/Showcase.swift:
import SwiftUI
public struct Showcase: View {
@State private var text = ""
public init() {}
public var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 32) {
Group {
Text("Buttons").font(.title2.bold())
Button("Primary action") { }.buttonStyle(.primary)
Button("Disabled") { }.buttonStyle(.primary).disabled(true)
}
Group {
Text("Text fields").font(.title2.bold())
TextField("Email", text: $text).textFieldStyle(.rounded)
TextField("Password", text: $text).textFieldStyle(.rounded)
}
Group {
Text("Cards").font(.title2.bold())
VStack(alignment: .leading) {
Text("A card").font(.headline)
Text("With some content.").foregroundStyle(.secondary)
}
.card()
}
Group {
Text("Badges").font(.title2.bold())
HStack {
Badge("New", style: .info)
Badge("Live", style: .success)
Badge("Beta", style: .warning)
Badge("Failed", style: .error)
}
}
Group {
Text("Empty state").font(.title2.bold())
EmptyState(
"Nothing here",
message: "Add something to get started.",
systemImage: "tray"
) {
Button("Add") { }.buttonStyle(.primary)
}
.frame(maxWidth: .infinity)
}
}
.padding()
}
}
}
#Preview {
Showcase()
}
8. README
README.md in the package root:
# DesignKit
A small SwiftUI component library demonstrating the style/modifier/view patterns.
## Install
In your `Package.swift`:
```swift
.package(url: "https://example.com/DesignKit.git", from: "1.0.0")
```
## Usage
```swift
import DesignKit
VStack {
TextField("Email", text: $email).textFieldStyle(.rounded)
Button("Sign in") { signIn() }.buttonStyle(.primary)
HStack {
Badge("New", style: .info)
Badge("Beta", style: .warning)
}
VStack {
Text("Welcome").font(.headline)
Text("Get started by creating an account.")
}
.card()
}
.theme(.default)
```
## Customization
Inject a custom theme via `.theme(_:)`:
```swift
ContentView()
.theme(Theme(primary: .purple, cornerRadius: 4))
```
## Showcase
Run `Showcase()` in a preview or test app to see all components.
## Components
- `PrimaryButtonStyle` — primary CTA button. `Button(...).buttonStyle(.primary)`
- `RoundedTextFieldStyle` — bordered rounded text field. `TextField(...).textFieldStyle(.rounded)`
- `CardModifier` — apply via `.card()`
- `Badge` — small pill-shaped label with semantic style
- `EmptyState` — illustrated empty-state with optional action
9. Test app
Create a quick iOS app DesignKitDemo, depend on DesignKit as a local package, set ContentView to:
import SwiftUI
import DesignKit
struct ContentView: View {
var body: some View {
Showcase()
}
}
Run on Simulator. All components render.
Stretch goals
- Dark mode polish: Verify each component in dark mode; tweak
Themeto define light/dark variants. - Dynamic Type sweep: Run at AX5; fix overflows in
EmptyStateandCard. - Snapshot tests: Add
SnapshotTestinglibrary; snapshot each#Previewto PNGs in CI. - More components:
Avatar,ListSection,LoadingButton(with spinner state),Toast,SegmentedPicker. - Animations: Add
withAnimationon state changes; document animation behavior. - Cross-platform: Add
macOS 14support; conditional colors forsystemBackgroundon Mac. - Documentation: Add DocC comments to every public API; build a DocC archive (
xcodebuild docbuild). - Versioning + release: Tag
1.0.0in git, set up CI to test on push.
Notes & troubleshooting
#Previewinside a package works in Xcode 16. Click “Resume” in the canvas if it doesn’t load.@Previewable(iOS 18 SDK) lets you put@Statedirectly in#Previewblocks instead of wrapping in a helper view.buttonStyle(.primary)static accessor: only works when you extendButtonStylewithwhere Self == PrimaryButtonStyle. Without that extension, you’d write.buttonStyle(PrimaryButtonStyle())— verbose.EnvironmentKey(the old way) vs@Entry(Swift 6):@Entryis much less ceremony. Both work;@Entryis the future.Color(.systemBackground)requires UIKit imports under the hood; cross-platform packages use#if canImport(UIKit)/#if canImport(AppKit)conditionals.- Public API surface: every type, init, and method you want consumers to use must be
public. Forget one and the package compiles but consumers get “not accessible” errors.
Where to next
Phase 5 done — you now have a working Todo app, animated dashboard, multiplatform notes, and design-system package. Phase 6 covers Concurrency & Swift 6 — the deeper story of async/await, structured concurrency, actors, sendable, Swift 6 strict concurrency.
Phase 5 complete. Return to Summary or continue to Phase 6 once it ships.
6.1 — Core Data
Opening scenario
It’s Monday. Your PM walks over: “The journal app needs offline. Users complain that everything disappears when they lose signal on the subway.” You nod. You’ve been here before. The decision tree starts spinning: UserDefaults (no — relational data), files (no — querying), SQLite directly (no — you’d cry), Realm (third-party, abandoned-ish since MongoDB bought them), SwiftData (new, lots of gotchas pre-iOS 17.4), Core Data (15 years old, battle-tested, still under the hood of half the apps on your phone).
You pick Core Data. Not because it’s elegant. Because Apple Notes uses it, Mail uses it, Photos uses it, and when your app has 50,000 records and CloudKit sync and a migration from v1 to v7 schema — Core Data has already solved that, painfully, for a decade.
| Context | What it usually means |
|---|---|
| Reads “stack setup” | Knows NSPersistentContainer replaced the manual stack in iOS 10 |
| Reads “background context” | Has been bitten by viewContext deadlocks in production |
| Reads “lightweight migration” | Has shipped a v2 model and watched 1% of users crash on launch |
| Reads “fetched results controller” | Has a UIKit background, or maintains a legacy app |
| Reads “Core Data is dead, use SwiftData” | Hasn’t shipped anything with SwiftData to >10k users yet |
Concept → Why → How → Code
Concept
Core Data is not a database. It’s an object graph and persistence framework. It happens to use SQLite by default, but that’s an implementation detail. The mental model: you describe entities and relationships in a .xcdatamodeld schema, Core Data manages a graph of NSManagedObject instances in memory inside a NSManagedObjectContext, and you call save() to flush changes to the persistent store.
Why
You want this when you have:
- Relational data (a journal has many entries, each entry has many tags)
- Querying (
NSPredicate, sort descriptors, faulting for memory) - Offline-first behavior
- Sync (Core Data + CloudKit is the only first-party offline-sync solution Apple ships)
- Migrations that survive multiple app versions
You don’t want this for: a list of recent searches, user preferences, a download cache, anything you’d be happy losing. UserDefaults and Codable to disk are fine for that.
How
The modern stack (iOS 10+) is three lines:
import CoreData
final class PersistenceController {
static let shared = PersistenceController()
let container: NSPersistentContainer
init(inMemory: Bool = false) {
container = NSPersistentContainer(name: "Journal")
if inMemory {
container.persistentStoreDescriptions.first!.url = URL(fileURLWithPath: "/dev/null")
}
container.loadPersistentStores { description, error in
if let error {
fatalError("Core Data failed to load: \(error)")
}
}
container.viewContext.automaticallyMergesChangesFromParent = true
container.viewContext.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy
}
}
The viewContext is the main-queue context — use it for UI. For anything that touches more than a handful of rows, use a background context:
func importEntries(_ payload: [EntryDTO]) async throws {
try await container.performBackgroundTask { context in
context.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy
for dto in payload {
let entry = Entry(context: context)
entry.id = dto.id
entry.title = dto.title
entry.body = dto.body
entry.createdAt = dto.createdAt
}
try context.save()
}
}
Code — the full CRUD loop
Define Entry in Journal.xcdatamodeld with attributes id: UUID, title: String, body: String, createdAt: Date, and let Xcode codegen the NSManagedObject subclass (codegen = Class Definition).
Create:
@MainActor
func addEntry(title: String, body: String) throws {
let context = PersistenceController.shared.container.viewContext
let entry = Entry(context: context)
entry.id = UUID()
entry.title = title
entry.body = body
entry.createdAt = .now
try context.save()
}
Read (in SwiftUI):
struct EntryListView: View {
@FetchRequest(
sortDescriptors: [SortDescriptor(\.createdAt, order: .reverse)],
animation: .default
)
private var entries: FetchedResults<Entry>
var body: some View {
List(entries) { entry in
VStack(alignment: .leading) {
Text(entry.title ?? "Untitled").font(.headline)
Text(entry.createdAt ?? .now, style: .date)
.font(.caption)
.foregroundStyle(.secondary)
}
}
}
}
Update: mutate the NSManagedObject properties and save(). Core Data tracks the change automatically.
Delete:
func delete(_ entry: Entry) throws {
let context = entry.managedObjectContext ?? PersistenceController.shared.container.viewContext
context.delete(entry)
try context.save()
}
NSFetchedResultsController — still relevant in 2026
In SwiftUI you’ll mostly use @FetchRequest. In UIKit (or anywhere you need controlled diffing into a UITableView/UICollectionView), NSFetchedResultsController is the workhorse:
let request: NSFetchRequest<Entry> = Entry.fetchRequest()
request.sortDescriptors = [NSSortDescriptor(keyPath: \Entry.createdAt, ascending: false)]
let frc = NSFetchedResultsController(
fetchRequest: request,
managedObjectContext: PersistenceController.shared.container.viewContext,
sectionNameKeyPath: nil,
cacheName: nil
)
frc.delegate = self
try frc.performFetch()
The delegate methods (controller(_:didChange:at:for:newIndexPath:)) hand you precise diffs you feed into a UITableViewDiffableDataSource. This is how Apple Mail’s inbox list updates without flicker when 50 messages arrive at once.
Migrations — the part everyone gets wrong
Lightweight migration (Apple infers the mapping): add a non-required attribute, add a new entity, rename via the “Renaming ID” model field. Enable it by setting both options:
let description = container.persistentStoreDescriptions.first!
description.shouldMigrateStoreAutomatically = true
description.shouldInferMappingModelAutomatically = true
Heavyweight migration (you write a NSMappingModel): required when you split one entity into two, merge attributes with logic, or transform data. You ship a .xcmappingmodel file and optionally an NSEntityMigrationPolicy subclass.
The rule that will save your job: every shipped schema version stays in the project forever. Name them Journal.xcdatamodel (v1), Journal 2.xcdatamodel (v2), etc. The current version is set in the .xcdatamodeld inspector. Migrating from v3 → v7? Core Data needs v3, v4, v5, v6, v7 all present to chain the migrations.
In the wild
- Apple Notes runs on Core Data + CloudKit. The schema has dozens of versions accumulated since 2012. They use heavyweight migrations for major iCloud schema bumps.
- Things 3 (Cultured Code) uses Core Data with a custom sync layer (not CloudKit — they shipped before CloudKit was viable). Their migrations are bulletproof; the app has been continuously installable since 2017.
- Day One Journal moved off Core Data to Realm, then announced (2023) a partial move back to Core Data via Swift Data for the sync layer. The takeaway: third-party storage frameworks always look better in the demo, worse in year five.
- Bear uses Core Data + CloudKit for sync. Their public postmortem of a 2022 sync bug is one of the best free Core Data lessons on the internet.
Common misconceptions
- “Core Data is a database.” No. It’s an object graph that can persist to SQLite (default), XML, binary, or in-memory. Treat it as an in-memory graph that flushes on
save(). - “
viewContextis thread-safe.” It is safe only on the main queue. Touch it from a background thread and you get nondeterministic crashes, often months after the change ships. - “
save()on the background context updatesviewContextautomatically.” Only ifautomaticallyMergesChangesFromParent = trueis set onviewContextand the contexts share the same persistent coordinator. The default isfalse. - “Faulting is a bug.” Faulting is Core Data not loading row data until you access it. It’s the entire reason Core Data scales to 100k records on a phone. Don’t fight it; iterate over the keys you need.
- “You don’t need migrations if no users have the old schema.” TestFlight users do have the old schema. App Store reviewers do update from previous binaries. Skipping a migration costs you a one-star review and possibly a rejection.
Seasoned engineer’s take
Core Data has the worst API surface in Apple’s catalog. It is also, by a wide margin, the most reliable persistence framework on the platform. Every team I’ve watched migrate from Core Data to something “simpler” — Realm, GRDB, raw SQLite via SQLite.swift, even SwiftData in 2024 — has either come back, or has built something that ships fewer features per quarter while burning more engineer-hours on data layer bugs.
The investment is real: spend a week understanding contexts, faulting, and migrations before you ship v1. After that week, Core Data fades into the background of your project and stops being a source of bugs. Skip that week and you’ll spend the next two years writing increasingly clever workarounds for things Core Data already does, which is exactly what most Realm-to-CoreData postmortems describe.
For new apps in 2026: I still recommend Core Data over SwiftData for any project that ships before iOS 17.4 is the floor and needs CloudKit sync, because SwiftData’s sync story remains noticeably rougher than NSPersistentCloudKitContainer. If you’re iOS 17.4+ only and the data model is simple, SwiftData is fine and the ergonomics are dramatically better.
TIP: Wrap every
try context.save()in ado/catchthat logs theNSErroruserInfo dictionary, not justerror.localizedDescription. Core Data validation errors are buried inNSDetailedErrorsKeyand you cannot debug them without that data.
WARNING: Never store image or video blobs in a Core Data attribute marked “Allows External Storage” without setting a size threshold. The “external” files are managed by Core Data and orphaned files don’t get cleaned up if your migration fails. Store the file path; keep the bytes on disk.
Interview corner
Junior: “How do you read and write data with Core Data?”
Set up an
NSPersistentContainerwith the model name. Usecontainer.viewContextfor the main queue. Create anNSManagedObjectsubclass, set its properties, callsave(). Read with anNSFetchRequestor, in SwiftUI,@FetchRequest.
Mid: “Walk me through threading in Core Data.”
Every
NSManagedObjectis bound to the context that created it.viewContextis main-queue-only. For heavy work usecontainer.performBackgroundTaskor anewBackgroundContext(). Never pass managed objects between contexts — passNSManagedObjectIDand re-fetch on the destination context. SetautomaticallyMergesChangesFromParentand a merge policy onviewContextso background saves flow to the UI.
Senior: “Design a migration from a schema where Entry.tags: String (comma-separated) becomes Entry.tags: [Tag] (many-to-many).”
Lightweight won’t handle this — the data needs reshaping. Create model v2 with the new
Tagentity and the relationship. Add a.xcmappingmodelfrom v1 → v2. SubclassNSEntityMigrationPolicyand overridecreateDestinationInstances(forSource:in:manager:)to split the comma-separated string, dedupe tags across entries (use a manager userInfo dictionary as a[String: Tag]cache to avoid duplicates), and wire the relationship. Ship v1 and v2 model files in the bundle so users on any prior version can chain through. Test the migration on a copy of a real production store before release.
Red flag: “We don’t use migrations — we just delete the old store on schema change.”
Tells the interviewer you have never shipped to a real user base and don’t understand that this destroys all user data on update. Instant downgrade in level discussion.
Lab preview
Lab 6.1 — Journal App with SwiftData is the modern counterpart to this chapter; building the same app on Core Data is left as a stretch goal in that lab so you can compare the two APIs side-by-side.
Next: SwiftData
6.2 — SwiftData
Opening scenario
You’re at a hackathon. 48 hours. The idea: a habit tracker with daily check-ins, streaks, and a chart of the last 30 days. You’re alone. You will not be writing an NSPersistentContainer boilerplate file. You open Xcode, hit ⌘N, type Habit, slap @Model on it, drop a .modelContainer(for: Habit.self) on your WindowGroup, and you’re persisting to disk before the third coffee.
That’s the SwiftData pitch. It’s Core Data, with three decades of “we should have done it this way” applied.
| Context | What it usually means |
|---|---|
Reads “@Model macro” | Knows SwiftData generates a Core Data entity under the hood at compile time |
Reads “@Query” | Has built a SwiftUI list off SwiftData and seen the auto-refresh magic |
Reads “#Predicate” | Has hit the “this Swift expression can’t be translated” wall |
| Reads “ModelActor” | Has tried background work in SwiftData and felt the rough edges |
| Reads “schema migrations” | Has shipped a SwiftData app to production users |
Concept → Why → How → Code
Concept
SwiftData is a Swift-native wrapper around Core Data, introduced at WWDC 2023 (iOS 17). The schema is your code: classes annotated with @Model are the entities. Property wrappers replace key path strings. #Predicate macros translate Swift expressions into Core Data predicates at compile time. ModelContainer, ModelContext, ModelConfiguration map roughly to NSPersistentContainer, NSManagedObjectContext, NSPersistentStoreDescription.
Why
- Less boilerplate. A
@Modelclass is one annotation, no.xcdatamodeldfile, no codegen step, noNSManagedObjectsubclass. - Type-safe predicates.
#Predicate<Habit> { $0.streak > 5 }is checked at compile time.NSPredicatestrings were not. - SwiftUI-native.
@Queryre-renders your view when the underlying data changes, with sort and filter inline. No@FetchRequestsyntax weirdness. - CloudKit toggle. A single config flag enables iCloud sync (with caveats — see Chapter 6.5).
You don’t want SwiftData when: your minimum deployment is below iOS 17, you have a complex existing Core Data schema (interop is possible but painful), you need fine-grained migration control today, or you need cross-platform with a non-Apple system (Realm or GRDB are still the answer there).
How
A SwiftData app looks like this end to end:
import SwiftUI
import SwiftData
@Model
final class Habit {
@Attribute(.unique) var id: UUID
var name: String
var createdAt: Date
var streak: Int
@Relationship(deleteRule: .cascade, inverse: \CheckIn.habit)
var checkIns: [CheckIn] = []
init(name: String) {
self.id = UUID()
self.name = name
self.createdAt = .now
self.streak = 0
}
}
@Model
final class CheckIn {
var date: Date
var habit: Habit?
init(date: Date = .now, habit: Habit) {
self.date = date
self.habit = habit
}
}
@main
struct HabitsApp: App {
var body: some Scene {
WindowGroup {
HabitListView()
}
.modelContainer(for: [Habit.self, CheckIn.self])
}
}
That’s it. The .modelContainer modifier creates the container, registers the schema, opens an SQLite store at the default location, and injects a ModelContext into the environment.
Code — list, create, update, delete
struct HabitListView: View {
@Environment(\.modelContext) private var context
@Query(sort: \Habit.createdAt, order: .reverse) private var habits: [Habit]
@State private var newHabitName = ""
var body: some View {
NavigationStack {
List {
ForEach(habits) { habit in
NavigationLink(value: habit) {
HabitRow(habit: habit)
}
}
.onDelete(perform: delete)
}
.navigationTitle("Habits")
.navigationDestination(for: Habit.self) { HabitDetailView(habit: $0) }
.safeAreaInset(edge: .bottom) {
HStack {
TextField("New habit", text: $newHabitName)
.textFieldStyle(.roundedBorder)
Button("Add", action: add)
.disabled(newHabitName.trimmingCharacters(in: .whitespaces).isEmpty)
}
.padding()
.background(.bar)
}
}
}
private func add() {
let habit = Habit(name: newHabitName)
context.insert(habit)
newHabitName = ""
}
private func delete(at offsets: IndexSet) {
for index in offsets {
context.delete(habits[index])
}
}
}
Three things to notice:
- No
try context.save()after every mutation. SwiftData autosaves the main-contextModelContexton a debounced timer (and on backgrounding). Calltry context.save()explicitly only when you need a guarantee before reading. @Queryupdates the view. The list animates when you insert or delete; no diffing code.@Bindable habit(used inHabitDetailViewbelow) gives you two-way bindings into model properties:
struct HabitDetailView: View {
@Bindable var habit: Habit
var body: some View {
Form {
TextField("Name", text: $habit.name)
Stepper("Streak: \(habit.streak)", value: $habit.streak, in: 0...365)
}
}
}
Predicates and complex queries
@Query(
filter: #Predicate<Habit> { $0.streak >= 7 },
sort: \Habit.streak,
order: .reverse
) private var streakHeroes: [Habit]
For dynamic filters, build the descriptor manually:
struct HabitSearchView: View {
@Environment(\.modelContext) private var context
@State private var searchText = ""
@State private var results: [Habit] = []
var body: some View {
List(results) { Text($0.name) }
.searchable(text: $searchText)
.onChange(of: searchText) { _, query in
let predicate = #Predicate<Habit> { habit in
habit.name.localizedStandardContains(query)
}
let descriptor = FetchDescriptor<Habit>(
predicate: predicate,
sortBy: [SortDescriptor(\.name)]
)
results = (try? context.fetch(descriptor)) ?? []
}
}
}
#Predicate is a macro. It can only translate a subset of Swift to SQL. String methods (contains, hasPrefix), comparison operators, basic logical operators, optional unwrapping, and collection membership all work. Calling your own functions does not.
ModelConfiguration & multiple stores
let appConfig = ModelConfiguration("AppData", schema: Schema([Habit.self, CheckIn.self]))
let analyticsConfig = ModelConfiguration("Analytics", schema: Schema([Event.self]), isStoredInMemoryOnly: true)
let container = try ModelContainer(for: Schema([Habit.self, CheckIn.self, Event.self]),
configurations: appConfig, analyticsConfig)
Use a second store (in-memory) for previews, tests, or ephemeral data you don’t want polluting iCloud.
Background work with @ModelActor
The main-context approach works for UI mutations. For batch imports use @ModelActor:
@ModelActor
actor HabitImporter {
func importHabits(_ payload: [HabitDTO]) throws {
for dto in payload {
let habit = Habit(name: dto.name)
habit.streak = dto.streak
modelContext.insert(habit)
}
try modelContext.save()
}
}
// usage
let importer = HabitImporter(modelContainer: container)
try await importer.importHabits(payload)
@ModelActor synthesizes the actor with its own ModelContext bound to the actor’s executor. Object identifiers (PersistentIdentifier) cross actor boundaries; the objects themselves do not.
Schema migrations
SwiftData uses versioned schemas — Swift enums conforming to VersionedSchema:
enum SchemaV1: VersionedSchema {
static var versionIdentifier = Schema.Version(1, 0, 0)
static var models: [any PersistentModel.Type] { [Habit.self] }
@Model final class Habit { /* original */ }
}
enum SchemaV2: VersionedSchema {
static var versionIdentifier = Schema.Version(2, 0, 0)
static var models: [any PersistentModel.Type] { [Habit.self] }
@Model final class Habit { /* new shape: added `category` */ }
}
enum HabitMigrationPlan: SchemaMigrationPlan {
static var schemas: [any VersionedSchema.Type] { [SchemaV1.self, SchemaV2.self] }
static var stages: [MigrationStage] {
[.lightweight(fromVersion: SchemaV1.self, toVersion: SchemaV2.self)]
}
}
let container = try ModelContainer(for: SchemaV2.Habit.self,
migrationPlan: HabitMigrationPlan.self)
For data-rewriting migrations use .custom(...) with willMigrate/didMigrate closures.
In the wild
- Apple’s WWDC sample apps (Backyard Birds, Trip Planner) are pure SwiftData. They’re the most accurate model of “Apple thinks this is how you should write it.”
- Day One Journal announced SwiftData adoption for new features in 2024 while keeping Core Data for legacy sync paths.
- Several breakout indie apps from 2024–2025 (Cubby, Bento, Athlytic v3) ship pure SwiftData. The pattern: small team, fresh codebase, iOS 17+ floor.
- Hard-NO list: anyone still supporting iOS 16 (a chunk of enterprise apps), anyone with a Core Data schema older than two years (the migration story isn’t worth it), anyone whose data layer is the product (Notion-style note apps need control SwiftData doesn’t yet expose).
Common misconceptions
- “SwiftData is not Core Data.” It is Core Data with a Swift façade. Look at the persistent store — it’s still SQLite, with table names derived from your
@Modelclass names. You can open the store with the Core Data debugging tools. - “
@Queryis free.” Each@Querytriggers a fetch on every view invalidation that changes its parameters. Don’t put a@Queryfiltered by@Stateinside a tight loop of view rebuilds; build aFetchDescriptormanually instead. - “
#Predicatecan do anythingNSPredicatecould.” It can’t. NoSUBQUERY, no aggregate functions beyond a small set, no custom function calls. Complex predicates either get rewritten or fall back toNSPredicate(format:). - “Autosave means I never call
save().” Autosave runs when the app enters background and on a debounce. If youfetchimmediately afterinsertfrom a different context, the row may not exist yet.try context.save()synchronizes. - “SwiftData supports CloudKit out of the box and it Just Works.” It supports CloudKit. It does not Just Work. See Chapter 6.5 for the list of constraints (all attributes optional or with defaults, no unique constraints, no
denydelete rule, public databases not supported).
Seasoned engineer’s take
I shipped my first SwiftData app in late 2023 expecting to like it. I liked the API. I did not like the bugs. Through iOS 17.0–17.3 there were enough crash reports filed against SwiftData symbols to make me roll my own Core Data layer for a paid app. By iOS 17.4 the worst sharp edges were filed off and by iOS 18 SwiftData crossed into “I’d recommend this for a greenfield app” territory for me.
In May 2026, with iOS 19 around the corner, my heuristic is:
- Greenfield app, iOS 17.4+ floor, simple schema, no public CloudKit data: SwiftData. The velocity gain is real.
- Greenfield app, complex schema, CloudKit shared databases, or supporting iOS 16: Core Data.
- Existing Core Data app: stay on Core Data. Interop adds work and removes very little.
The thing nobody tells you: SwiftData migrations are less powerful than Core Data migrations today, not more. The MigrationStage API is cleaner, but features like multi-step heavyweight migrations with mapping models don’t have a direct equivalent. Plan your schema carefully early.
TIP: In SwiftUI previews, use
.modelContainer(for: Habit.self, inMemory: true)and seed sample data with aPreviewableModelContext. Saves you from having “PreviewData.swift” leak into the App Store build.
WARNING: Do not mark a
@Modelclassfinalunless you control all callers.@Modelsynthesizes initializers via macros that interact with class inheritance; some macros work, some don’t, and the diagnostics are catastrophic. Honestly, in 99% of cases you should make itfinal(above example does), but know the macros are why it matters.
Interview corner
Junior: “How do you persist a model in SwiftData?”
Annotate the class with
@Model, attach a.modelContainer(for:)to yourScene, and use@Queryto read ormodelContext.insertto write. SwiftData autosaves.
Mid: “How does @Query interact with SwiftUI’s view lifecycle?”
@Queryis a property wrapper that holds aFetchDescriptorand registers an observer on theModelContext. When the context publishes a change notification matching the predicate, the wrapped value updates and SwiftUI invalidates the view. The query re-runs against the store; results are cached per-descriptor between fetches.
Senior: “Walk me through migrating a v1 schema where Habit.tags: String (comma-separated) becomes v2 with a Tag model and many-to-many relationship.”
Two
VersionedSchemaenums, V1 and V2. Define V2’sHabitwith the relationship and theTagmodel. Build aSchemaMigrationPlanwith a.customMigrationStagebetween them. In thewillMigrateclosure, fetch V1 habits, parse the tag string, dedupe, instantiateTagmodels in the destination context, and wire the relationship. IndidMigrate, drop the oldtags: Stringattribute if it isn’t already gone via the schema. Ship a test that loads a fixture SQLite store from V1 and migrates it through, asserting the V2 invariants.
Red flag: “Whenever we change the schema we just delete the local store on launch.”
Same red flag as the Core Data chapter, with a different framework name. Production data loss for any user who updates.
Lab preview
Lab 6.1 — Journal App with SwiftData builds a full CRUD journal app with relationships, a #Predicate-driven search, and an optional CloudKit sync toggle you’ll wire up after reading Chapter 6.5.
Next: CloudKit
6.3 — CloudKit
Opening scenario
A user emails you: “I bought your app on my iPhone in January. Got an iPad last week. My data isn’t there. Refund.”
You have three options:
- Build a backend (Postgres + auth + a sync protocol + scaling + GDPR + 24/7 on-call).
- Use Firebase (free until you’re successful, then $$$, plus Google sees every byte).
- Ship CloudKit and let Apple’s iCloud account on the device do all of it for free, with end-to-end encryption on private data, no signup screen, no password.
For a consumer app on the Apple platform, the answer is almost always #3 — until you need cross-platform, server-side computation, or a feature CloudKit doesn’t support (full-text search across users, complex analytics, multi-region). Then you add a backend alongside CloudKit, not instead of it.
| Context | What it usually means |
|---|---|
Reads “CKContainer” | Has wired up CloudKit at least once |
| Reads “private / public / shared database” | Understands the privacy model |
Reads “CKSubscription” | Has set up push-driven sync |
| Reads “CKRecord vs NSManagedObject” | Knows the difference between raw CloudKit and Core Data + CloudKit |
| Reads “schema is auto-promoted in dev” | Has been bitten by the production schema being empty |
Concept → Why → How → Code
Concept
CloudKit is Apple’s cloud database, identity, and push service. Every container has three databases:
- Public — shared by all users; readable by anyone, writable by the record creator (or anyone you grant permissions). Storage counts against your app’s quota, not the user’s.
- Private — per-user data; encrypted; storage counts against that user’s iCloud quota. The user pays. You don’t see the data.
- Shared — records the user has explicitly shared with other iCloud accounts (think collaborative documents).
Records (CKRecord) are key-value bags with typed fields: strings, numbers, dates, bytes, references to other records, asset URLs (large blobs stored separately and lazy-loaded), and location.
Why
- Free for users with iCloud. No signup screen. Identity comes from the iCloud account on the device.
- Free for you up to generous limits (10 GB asset storage / 100 MB database / 2 GB/day data transfer per user — and the user-private storage is on the user’s iCloud plan, not yours).
- Push out of the box.
CKSubscriptiontriggers an APNs notification to other devices when a record changes — no APNs server work on your side. - End-to-end encryption on private data when the user has Advanced Data Protection enabled.
How — initial setup
- Enable the CloudKit capability in your target’s Signing & Capabilities tab.
- Xcode creates a default container named
iCloud.com.yourcompany.YourApp. Open it in the CloudKit Dashboard (CloudKit Consolebutton in the capability pane, or icloud.developer.apple.com/dashboard). - In the dashboard, you’ll see Development and Production environments. Records and indexes you create in code show up automatically in Development; you must explicitly Deploy Schema to Production before App Store builds can read them.
Code — write a record
import CloudKit
final class JournalCloudStore {
private let container = CKContainer(identifier: "iCloud.com.example.Journal")
private var database: CKDatabase { container.privateCloudDatabase }
func save(title: String, body: String) async throws -> CKRecord {
let record = CKRecord(recordType: "Entry")
record["title"] = title as CKRecordValue
record["body"] = body as CKRecordValue
record["createdAt"] = Date() as CKRecordValue
return try await database.save(record)
}
}
After running this once, refresh the CloudKit Dashboard → Schema. You’ll see the Entry record type with the three fields. This auto-promotion of schema from code is Development-only behavior; in Production you set the schema deliberately and deploy.
Code — query
func fetchRecentEntries(limit: Int = 50) async throws -> [CKRecord] {
let predicate = NSPredicate(value: true)
let query = CKQuery(recordType: "Entry", predicate: predicate)
query.sortDescriptors = [NSSortDescriptor(key: "createdAt", ascending: false)]
var results: [CKRecord] = []
let (matchedResults, _) = try await database.records(matching: query, resultsLimit: limit)
for (_, result) in matchedResults {
if let record = try? result.get() { results.append(record) }
}
return results
}
CKQuery uses NSPredicate strings. The available operators are limited (no joins, no LIKE with arbitrary regex, sortable fields must be in a queryable index in production).
Code — subscriptions for real-time updates
func subscribeToEntries() async throws {
let subscription = CKQuerySubscription(
recordType: "Entry",
predicate: NSPredicate(value: true),
subscriptionID: "all-entries",
options: [.firesOnRecordCreation, .firesOnRecordUpdate, .firesOnRecordDeletion]
)
let info = CKSubscription.NotificationInfo()
info.shouldSendContentAvailable = true // silent push to wake the app
info.alertBody = "" // empty = no banner
subscription.notificationInfo = info
_ = try await database.save(subscription)
}
Then in your UNUserNotificationCenter delegate (or AppDelegate’s didReceiveRemoteNotification), fetch the changed records via CKDatabase.fetchDatabaseChanges and fetchZoneChanges. This is the foundation of an offline-first sync engine.
The change token pattern
Production sync is delta-based. CloudKit returns a CKServerChangeToken after every change fetch; you persist it; the next fetch passes it back and you get only what changed since that token. The pattern:
func sync() async throws {
let savedToken = loadSavedChangeToken()
let operation = CKFetchRecordZoneChangesOperation(
recordZoneIDs: [defaultZone.zoneID],
configurationsByRecordZoneID: [
defaultZone.zoneID: CKFetchRecordZoneChangesOperation.ZoneConfiguration(previousServerChangeToken: savedToken)
]
)
operation.recordWasChangedBlock = { id, result in /* upsert locally */ }
operation.recordWithIDWasDeletedBlock = { id, _ in /* delete locally */ }
operation.recordZoneChangeTokensUpdatedBlock = { _, token, _ in
if let token { self.saveChangeToken(token) }
}
operation.qualityOfService = .userInitiated
database.add(operation)
}
Custom zones (CKRecordZone) are required for fetching deltas in the private database. The default zone does not support fetchRecordZoneChanges — a gotcha that has cost more than one engineer a weekend.
Asset uploads
let image = UIImage(named: "cover")!
let url = FileManager.default.temporaryDirectory.appendingPathComponent("\(UUID()).jpg")
try image.jpegData(compressionQuality: 0.8)!.write(to: url)
let record = CKRecord(recordType: "Entry")
record["cover"] = CKAsset(fileURL: url)
_ = try await database.save(record)
CloudKit stores the asset separately and the CKAsset.fileURL on retrieval is a local cached URL — read once, cache the data, and don’t assume the URL stays valid across launches.
In the wild
- Apple Notes, Reminders, Photos, Calendar, Mail VIPs, Safari bookmarks/history/tabs — all CloudKit private database, mostly via
NSPersistentCloudKitContainer. - News, Maps Guides, Fitness sharing — CloudKit public database.
- iA Writer, Ulysses, Day One — CloudKit private database for document sync.
- Working Copy uses CloudKit shared database for collaborative repos.
Notably not CloudKit: anything with a Web client (Things 3 stayed off CloudKit for years for this reason; Notes still has no real Web client because of it), anything needing server-side search across all users (CloudKit indexes are per-user-private or fully-public; you can’t run cross-user queries on private data, and that’s a feature).
Common misconceptions
- “CloudKit is a backend.” It is a distributed key-value store with subscriptions and identity. It is not a place to run server code. You cannot run a
WHERE userId IN (?, ?, ?)query on private data. For business logic you still need a server, or you push all logic to the client. - “
CKQueryis like SQL.” It isNSPredicate-based with limited operators, can’t join across record types, requires queryable indexes in production, and has a default result limit of 100. - “Subscriptions deliver data.” They deliver a push notification that something changed. You still have to fetch the changes via
CKFetchRecordZoneChangesOperation. The notification carries a small payload, not the new record. - “Public database is free unlimited storage.” It counts against your app’s CloudKit quota (which scales with your active user count). Going viral with public records can become expensive; rate-limit your writes.
- “Development and Production share data.” They are separate environments, separate databases, separate schemas. A record you wrote in Development is invisible to a TestFlight build pointing at Production. Schema must be explicitly deployed via the dashboard before Production code can use a new record type or field.
Seasoned engineer’s take
CloudKit is the best-kept secret in Apple’s developer toolkit. It costs $0, requires no auth screen, and the privacy story is unbeatable — when a user complains that an app “isn’t syncing properly,” check if iCloud is signed in before checking your code, because nine times out of ten that’s the answer.
The cost of CloudKit is control. You can’t see your users’ private data — not for support, not for debugging, not ever. You can’t run aggregations across users. You can’t migrate schemas with custom logic running on a server. You can’t query Friend A’s data from Friend B’s device unless A has shared it via CKShare. When these constraints become limits, you bolt on a small backend for the things CloudKit can’t do, and keep CloudKit for what it does well (user-owned data sync).
For a side project, ship CloudKit on day one. The “no signup, just open the app and it syncs to all your devices” experience is genuinely magical, and it costs you almost nothing in code. For a venture-backed cross-platform startup, evaluate carefully — you’ll likely need both CloudKit (iOS sync) and a real backend (Web, Android, business logic), and the duplication is real.
TIP: When iterating on schema in Development, the auto-promotion happens only on the first write of a new record type or field. If you change a field’s type, you must reset the Development environment in the dashboard. There is no “alter table” — fields are forever once promoted to Production.
WARNING:
accountStatuscan be.noAccount,.restricted,.couldNotDetermine, or.available. Always check before any CloudKit call and handle gracefully — millions of users (kids, parental control accounts, Mac Minis without iCloud sign-in) have no usable iCloud account, and crashing or erroring onaccountStatus != .availableis one of the most common one-star reviews for CloudKit apps.
Interview corner
Junior: “What’s the difference between public, private, and shared databases?”
Public is one shared database for all users of the app; storage counts against the app’s quota. Private is per-user, encrypted, stored in their iCloud, counting against their quota. Shared contains records the user has accepted invitations to from other users via
CKShare.
Mid: “Walk me through delta sync with CKFetchRecordZoneChangesOperation.”
Use custom record zones in the private database. Persist the
CKServerChangeTokenreturned after each fetch. Pass it back next time to receive only changes since that token. Set up a silent-pushCKQuerySubscriptionso the device gets woken when a change happens. Apply changes locally inside a transaction so partial fetches don’t leave inconsistent state.
Senior: “Design the offline-first sync for a collaborative notes app where two devices edit the same note while offline, then both come online.”
Two layers: a local store of truth (Core Data or SwiftData), and a CloudKit mirror via custom zones with change tokens. Each note carries a
modifiedAtand a vector or simple last-writer-wins resolution. On reconnect, fetch zone changes; if local has unsynced edits to a record that arrived modified, present a conflict UI or auto-merge per-field (CRDT for text bodies if budget allows). UseCKModifyRecordsOperationwithsavePolicy = .ifServerRecordUnchangedto detect server-side concurrent edits and re-resolve. For real collaborative editing (Google-Docs-style), CloudKit isn’t enough — you’d add a WebSocket layer for live cursor and OT/CRDT ops, keeping CloudKit for the durable snapshot.
Red flag: “We poll CloudKit every 30 seconds for updates.”
Tells the interviewer you’ve never read the CloudKit docs. Polling wastes battery, hits rate limits, and is exactly what subscriptions exist to prevent.
Lab preview
Lab 6.2 — CloudKit Sync App builds a recipe-sharing app with both private (your saved recipes) and public (community recipes) databases, plus CKSubscription-driven real-time updates.
Next: Core Data + CloudKit
6.4 — Core Data + CloudKit
Opening scenario
You’ve shipped a Core Data app. Users love it. They ask, every week, “why doesn’t this sync to my iPad?” You read about CloudKit (Chapter 6.3). You weigh writing a custom sync engine — change tokens, conflict resolution, asset uploads, the works — against switching a single line in your persistence stack.
NSPersistentContainer → NSPersistentCloudKitContainer. That’s the line. Apple wrote the sync engine.
It’s not free. There are constraints on the schema, a developer-mode iCloud step, and a few sharp edges around shared databases. But: the cost is a week of careful work, and you get the same sync engine Apple Notes uses.
| Context | What it usually means |
|---|---|
Reads “NSPersistentCloudKitContainer” | Has at least read the docs |
| Reads “optional or default for every attribute” | Has tried to enable CloudKit on an existing schema |
| Reads “no unique constraints with CloudKit” | Has been bitten by it in production |
| Reads “history tracking” | Knows the magic that makes the sync engine work |
Reads “CKShare” | Has built collaborative features |
Concept → Why → How → Code
Concept
NSPersistentCloudKitContainer is a NSPersistentContainer subclass. It mirrors your Core Data store to an iCloud private (and optionally shared) database, using CloudKit’s APIs invisibly. The same viewContext you’ve always used now triggers iCloud syncs on save(). Changes from other devices appear via the normal NSManagedObjectContextDidSave notifications.
Under the hood:
NSPersistentHistoryTrackingrecords every change you make.- A background “mirror” process turns Core Data changes into
CKModifyRecordsOperationcalls. - Subscriptions and silent push pull remote changes back.
- Conflicts are resolved with last-writer-wins by default (configurable via your merge policy).
Why
You want this when:
- Your app already uses Core Data and adding sync would otherwise mean a custom backend.
- You want end-to-end encryption on user data (CloudKit private database respects Advanced Data Protection).
- You want iCloud sharing (collaborative documents, shared lists) without building your own access-control system.
You don’t want this when:
- You need a Web client (CloudKit has no public Web API beyond
CKWebAuthToken-based JSON, which is limited). - You need cross-platform with Android.
- You need server-side aggregation, search, or business logic.
How — the upgrade
Start from your existing Core Data stack. Three changes:
- Container class:
NSPersistentContainer→NSPersistentCloudKitContainer. - Store description: mark the persistent store as CloudKit-backed.
- Schema constraints: every attribute must be optional or have a default value, all relationships must be optional or to-many, and you cannot use unique constraints or
denydelete rules.
import CoreData
final class PersistenceController {
static let shared = PersistenceController()
let container: NSPersistentCloudKitContainer
init() {
container = NSPersistentCloudKitContainer(name: "Journal")
guard let description = container.persistentStoreDescriptions.first else {
fatalError("Missing persistent store description")
}
// 1. CloudKit container identifier
description.cloudKitContainerOptions =
NSPersistentCloudKitContainerOptions(containerIdentifier: "iCloud.com.example.Journal")
// 2. History tracking + remote change notifications
description.setOption(true as NSNumber, forKey: NSPersistentHistoryTrackingKey)
description.setOption(true as NSNumber, forKey: NSPersistentStoreRemoteChangeNotificationPostOptionKey)
container.loadPersistentStores { _, error in
if let error { fatalError("Core Data load: \(error)") }
}
container.viewContext.automaticallyMergesChangesFromParent = true
container.viewContext.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy
}
}
The history tracking + remote change options are mandatory; CloudKit sync will silently not work without them.
Initial schema deploy
In Development environment, Core Data + CloudKit auto-promotes your Core Data schema into CloudKit’s record types and fields. Run the app once with a signed-in iCloud account on the simulator (or device) and CloudKit Dashboard will show the schema appearing.
When you ship to TestFlight or App Store, the build will run against the Production environment. The Production schema is empty until you go to CloudKit Dashboard → Schema → Deploy Schema to Production. Forget this step and your App Store users will see nothing sync.
Listening for remote changes
The container posts .NSPersistentStoreRemoteChange notifications when iCloud pushes arrive. Most of the time automaticallyMergesChangesFromParent = true is enough and SwiftUI / FetchedResultsController re-renders automatically. For custom UI:
NotificationCenter.default.publisher(for: .NSPersistentStoreRemoteChange)
.sink { [weak self] _ in
Task { @MainActor in
self?.refreshUI()
}
}
.store(in: &cancellables)
Shared records (collaboration)
Add a second store description for the shared database:
let sharedDescription = NSPersistentStoreDescription(
url: container.persistentStoreDescriptions.first!.url!
.deletingLastPathComponent()
.appendingPathComponent("Journal-Shared.sqlite")
)
sharedDescription.configuration = "Shared"
sharedDescription.cloudKitContainerOptions = {
let options = NSPersistentCloudKitContainerOptions(containerIdentifier: "iCloud.com.example.Journal")
options.databaseScope = .shared
return options
}()
sharedDescription.setOption(true as NSNumber, forKey: NSPersistentHistoryTrackingKey)
sharedDescription.setOption(true as NSNumber, forKey: NSPersistentStoreRemoteChangeNotificationPostOptionKey)
container.persistentStoreDescriptions.append(sharedDescription)
Then in your .xcdatamodeld, define two configurations: “Default” for entities living in private, “Shared” for entities living in shared. Use container.share(_:to:) to share an NSManagedObject graph, and accept incoming shares via UIApplicationDelegate.application(_:userDidAcceptCloudKitShareWith:).
In the wild
- Apple Reminders, Apple Notes use Core Data + CloudKit (with private extensions Apple doesn’t expose).
- NetNewsWire uses Core Data + CloudKit for cross-device feed/read state sync.
- Bear (open beta of v2) uses Core Data + CloudKit for the new sync engine, replacing their legacy CloudKit-direct implementation.
- Things 3 does not use Core Data + CloudKit — they have a custom sync server. The reason is historical (they shipped before
NSPersistentCloudKitContainerwas good enough), and switching now would risk their reliability story.
Common misconceptions
- “It just works after I change the class name.” It works for new schemas where every attribute is optional or defaulted. Existing schemas usually need migration to relax constraints (required → optional with default). Test on a copy of a real production store before shipping.
- “CloudKit and Core Data share IDs.” CloudKit assigns its own
CKRecord.IDto each mirrored object. The mapping is internal. Don’t expose CloudKit record IDs as keys in your app. - “Schema changes deploy automatically.” Only in Development. Production schema deployment is a manual click in the dashboard and is one-way (you can’t remove a field or record type from production).
- “I can use it with the public database.” No.
NSPersistentCloudKitContainersupports private and shared databases only. Public requires raw CloudKit. - “Conflicts are handled automatically.” They are resolved automatically (last-writer-wins by default), but the resolution may not match user expectations. For semantic conflicts (two devices renamed the same record), you need a merge policy or app-level UX.
Seasoned engineer’s take
When NSPersistentCloudKitContainer works, it’s the best deal in mobile development: weeks of sync engineering for a one-line change. When it doesn’t work, the failure modes are subtle (silent merge errors, missing records that “should be there”, history-tracking corruption that requires a re-sync), and debugging requires reading Apple’s CoreData/CloudKit logs in Console.app on a tethered device.
The framework has matured enormously since iOS 13. In 2026 it’s the default for new sync-requiring apps on the Apple platform. The biggest practical issue I still hit: the initial sync of an existing store with thousands of records on a new device is slow (minutes, not seconds), and users perceive the app as “missing data.” Add a clear “Syncing from iCloud…” status UI by observing eventChangedNotification and showing progress.
For schemas you can shape from scratch, plan for the constraints up front. For schemas you’re retrofitting, do a migration to v2 that relaxes the constraints, ships and bakes for one release, and then enable CloudKit in v3. Trying to do both at once will turn one of your weekends into all of them.
TIP: Subscribe to
NSPersistentCloudKitContainer.eventChangedNotificationto surface sync state (.setup,.import,.export) and errors to your UI. Users tolerate slow sync if they can see it’s happening; they uninstall if they can’t.
WARNING: Never delete and recreate the SQLite store on launch as a “reset CloudKit” hack. Deleting the local store does not delete the iCloud records, and on next launch the sync re-imports them — but the history-tracking state is lost and you can end up with duplicates. To reset, use the CloudKit Dashboard to delete the user’s zone, then delete the local store, then sign in again.
Interview corner
Junior: “What’s the easiest way to add iCloud sync to a Core Data app?”
Change
NSPersistentContainertoNSPersistentCloudKitContainer, set the CloudKit container identifier on the store description, enable history tracking and remote change notifications. Make sure every attribute is optional or has a default.
Mid: “Why does the schema require all attributes to be optional or defaulted?”
CloudKit records are schemaless on the wire — fields can be missing. When a record arrives from another device that was created against an earlier schema (or by an older app version), the new attribute won’t be present. Core Data must fault that record into your
NSManagedObjectand needs either anilvalue (optional) or a default to fill in. Required-no-default would crash on insert.
Senior: “Walk me through troubleshooting a user report of ‘changes I made on iPhone don’t appear on iPad’.”
First, verify both devices are signed into the same iCloud account and have iCloud Drive on. Next, look at sync state via
eventChangedNotification— is import/export running, is there a recurring.exporterror? Check CloudKit Dashboard for the user’s record zone size and recent operations. Common causes: (1) schema not deployed to production, (2) the iPad still on an older app version with a schema mismatch, (3) silent merge failure because of a constraint added later — fix by versioned schema migration on next release, (4) iCloud account in.restrictedor quota-full state. Have the user pull-to-refresh which callstry await container.fetchSharedAccount()and a manualloadPersistentStoresif needed. If still missing, walk throughos_logfiltered tosubsystem:com.apple.coredata.cloudkiton a tethered device — the system logs every sync attempt with the failure reason.
Red flag: “We don’t use the CloudKit dashboard — we just code, build, ship.”
Tells the interviewer you’ve never deployed a schema change to production. Every new field is invisible to App Store users until clicked through the dashboard. This is one of the most common reasons “the feature works for the dev team but not for shipped users.”
Lab preview
Lab 6.1 — Journal App with SwiftData ships with a stretch goal to migrate the same schema to Core Data + CloudKit and compare the developer experience side by side.
Next: SwiftData + CloudKit
6.5 — SwiftData + CloudKit
Opening scenario
You loved SwiftData (Chapter 6.2). You loved CloudKit (Chapter 6.3). You assume putting them together is one modifier. Mostly, it is. There’s an asterisk the size of a phone book.
.modelContainer(for: Habit.self, isUndoEnabled: true)
Add a CloudKit container to your entitlements, and SwiftData mirrors to iCloud. Two minutes from clean app to “syncs to all my devices, encrypted, free.” The catch: SwiftData + CloudKit inherits all the schema constraints of NSPersistentCloudKitContainer (every attribute optional or defaulted, no unique constraints, no .deny delete rule, no public database), and adds a few of its own.
| Context | What it usually means |
|---|---|
| Reads “CloudKit-compatible schema” | Has hit “cannot enable CloudKit” errors at runtime |
Reads “ModelConfiguration with cloudKitDatabase” | Has wired up sync from scratch |
| Reads “schema versioning + CloudKit” | Has migrated a synced schema in production |
Reads “no @Attribute(.unique) with CloudKit” | Has been bitten by it |
| Reads “no shared CloudKit yet (in SwiftData)” | Has tried to ship collaboration |
Concept → Why → How → Code
Concept
SwiftData uses NSPersistentCloudKitContainer under the hood when CloudKit is enabled. Your @Model classes are translated to Core Data entities at compile time; CloudKit mirrors them as it would any Core Data store. You get the same constraints, the same dashboard, the same end-to-end encryption — through a much terser API.
Why
- Minimum boilerplate: a single
ModelConfigurationwith acloudKitDatabaseargument. - Type-safe queries via
@Queryand#Predicatecontinue to work over synced data. - Same iCloud, same encryption, same cost ($0) as Core Data + CloudKit.
You don’t want it when:
- You need the shared CloudKit database (collaboration). At time of writing (iOS 19 beta / May 2026), SwiftData supports private database sync only; shared and public are Core Data only.
- You need to drop down to
CKRecordfor any custom sync logic.
How — enable CloudKit on a SwiftData app
- Add the iCloud capability to your target → check CloudKit → add a container
iCloud.com.example.YourApp. - Add the Background Modes capability → check Remote notifications (needed for silent pushes).
- Update your
@Modelclasses so every property is optional or has a default, all relationships are to-many or optional, and you remove any@Attribute(.unique)declarations.
@Model
final class Habit {
var id: UUID = UUID() // default
var name: String = "" // default
var createdAt: Date = Date() // default
var streak: Int = 0 // default
@Relationship(deleteRule: .cascade, inverse: \CheckIn.habit)
var checkIns: [CheckIn]? = [] // optional + default
init(name: String) {
self.name = name
}
}
Notice @Attribute(.unique) is gone from id. CloudKit-backed SwiftData stores cannot enforce uniqueness at the database layer — you enforce it at the application layer.
- Configure the container with a CloudKit database:
@main
struct HabitsApp: App {
let container: ModelContainer
init() {
let schema = Schema([Habit.self, CheckIn.self])
let config = ModelConfiguration(
schema: schema,
isStoredInMemoryOnly: false,
cloudKitDatabase: .private("iCloud.com.example.Habits")
)
do {
container = try ModelContainer(for: schema, configurations: config)
} catch {
fatalError("ModelContainer load: \(error)")
}
}
var body: some Scene {
WindowGroup { HabitListView() }
.modelContainer(container)
}
}
Build, run on a device or simulator signed into iCloud, write a record, open CloudKit Dashboard. The schema appears in Development. Deploy to Production before TestFlight.
Code — observe sync
SwiftData re-publishes NSPersistentStoreRemoteChange notifications. @Query views update automatically. For custom UI (status indicators, error banners):
@MainActor
final class SyncMonitor: ObservableObject {
@Published private(set) var lastSync: Date?
@Published private(set) var lastError: Error?
private var cancellables = Set<AnyCancellable>()
init() {
NotificationCenter.default
.publisher(for: NSPersistentCloudKitContainer.eventChangedNotification)
.sink { [weak self] note in
guard let event = note.userInfo?[NSPersistentCloudKitContainer.eventNotificationUserInfoKey]
as? NSPersistentCloudKitContainer.Event else { return }
if event.endDate != nil {
self?.lastSync = event.endDate
self?.lastError = event.error
}
}
.store(in: &cancellables)
}
}
Migrations on a synced store
The pattern from Chapter 6.2 (VersionedSchema, SchemaMigrationPlan) applies — with one critical addition: every schema version must remain CloudKit-compatible. Adding a required (non-optional, no default) attribute in SchemaV2 will break sync silently on devices still running V1.
Rule: when adding a new attribute that must exist for new records, give it a default value, and treat the absence on old records as “this record predates the feature.” Never delete a CloudKit-deployed field; it stays forever in the production schema.
In the wild
- Apple’s WWDC 2024 sample apps (Backyard Birds) ship SwiftData + CloudKit private sync.
- A growing wave of 2025–2026 indie apps (focus timers, journals, habit trackers) ship the combo for the velocity.
- Day One Journal uses SwiftData (selectively) for some entities + a custom backend for the collaboration features SwiftData + CloudKit can’t yet support.
- Apps that need collaboration today (Notion-like, Things-like) still use Core Data + CloudKit because of the missing shared-database support.
Common misconceptions
- “SwiftData CloudKit works with any
@Model.” It works with@Models that meet the CloudKit constraints. Adding@Attribute(.unique)or a non-optional non-defaulted property will raise a runtime error when you try to load the container. - “
@Modelcollections can be required.” No. Relationships participating in CloudKit sync must be optional or to-many. The “to-one required” case has to be re-modeled. - “Conflict resolution is handled.” It uses the default Core Data merge policy. For domain-specific conflicts, you still need application-level logic.
- “Shared CloudKit works in SwiftData.” As of iOS 19 beta (May 2026) it does not. Apple has said it’s planned. Plan as if it isn’t.
- “
isStoredInMemoryOnly: trueplus a CloudKit config gives you preview sync.” No — in-memory stores cannot sync. For previews, omit the CloudKit argument entirely.
Seasoned engineer’s take
SwiftData + CloudKit is the most-improved Apple framework of the last two years. The iOS 17 launch was rough — silent sync stalls, edge-case crashes on schema mismatch, undocumented errors from the bridging layer. By iOS 18.3 and continuing into iOS 19, it’s stable enough that I default to it for new private-data apps with simple schemas.
The unspoken cost: when something goes wrong, you have two abstraction layers between you and the wire. The os_log filter is subsystem:com.apple.coredata (because SwiftData uses Core Data under the hood). Bug reports that say “SwiftData doesn’t work” are usually CloudKit issues you can read about in the Core Data + CloudKit knowledge base.
For an interview: be specific. “We chose SwiftData + CloudKit because the schema is simple, every attribute can be optional or defaulted, and we don’t need shared collaboration. We mitigated the lack of unique-attribute enforcement at the application layer with a UUID-keyed lookup before insert.” That sentence lands you in senior consideration.
TIP: Keep a Development iCloud account distinct from your personal account. Sync gets confused when you run debug builds, TestFlight builds, and the App Store version against the same account simultaneously. A dedicated
dev@yourcompany.comApple ID for the simulator is worth its weight.
WARNING:
cloudKitDatabase: .privaterequires that the user be signed into iCloud at app launch. If they aren’t, the container fails to load. Always wrapModelContainerconstruction in ado/tryand fall back to a local-onlyModelConfigurationfor users without iCloud, otherwise your app will not launch for them.
Interview corner
Junior: “How do you sync a SwiftData app to iCloud?”
Enable the iCloud capability with the CloudKit container, enable Remote Notifications, ensure every
@Modelattribute is optional or has a default, and construct yourModelConfigurationwithcloudKitDatabase: .private("iCloud.com.example.MyApp").
Mid: “What schema constraints does CloudKit impose on SwiftData models?”
Every attribute must be optional or have a default. All relationships must be optional or to-many. No
@Attribute(.unique). No.denydelete rule. No public database support — private and (soon) shared only. Schema deploys auto in Development but must be manually promoted to Production via the dashboard.
Senior: “Design a fallback strategy for users who aren’t signed into iCloud.”
Detect iCloud account status at launch via
CKContainer.default().accountStatus. If.available, construct the syncedModelConfiguration. Otherwise, construct a local-onlyModelConfigurationwith the same schema and nocloudKitDatabase. Show a soft prompt encouraging iCloud sign-in for cross-device sync, but don’t block usage. When the user later signs into iCloud, listen forCKAccountChangedand offer to migrate local data to the synced store by reading from one and writing to the other inside a singleModelActor. Test this path explicitly — it’s the path that gets a one-star review when missed.
Red flag: “If iCloud isn’t signed in we just show ‘iCloud required’ and quit.”
Tells the interviewer the candidate considers a meaningful slice of the user base disposable. Apple Review will also reject the app for poor handling of a normal account state.
Lab preview
Lab 6.1 — Journal App with SwiftData includes a toggleable CloudKit sync section so you can wire the modifier and watch records cross between simulator + device in real time.
Next: Networking Advanced
6.6 — Networking Advanced
Opening scenario
Your app shipped. The crash rate is fine. The one-star reviews aren’t about crashes — they’re about flakiness. “Login fails on my commute.” “Image never loads on hotel WiFi.” “App says I’m offline when I’m clearly not.” Welcome to the second life of every iOS network layer: when the happy path works but the real world doesn’t.
Real networking is intercepting requests for auth, retrying with backoff, refreshing expired tokens before the user sees a 401, uploading multipart with progress, downloading 100MB videos that can pause and resume, and surviving a Wi-Fi-to-cellular transition mid-request. URLSession does all of this. You just have to know how.
| Context | What it usually means |
|---|---|
| Reads “interceptor” | Has worked on a network layer with auth refresh |
| Reads “exponential backoff” | Has been bitten by hammering a failing API |
| Reads “401 → refresh → retry” | Has built or maintained an auth-aware client |
| Reads “multipart/form-data” | Has uploaded an image to a backend |
| Reads “background URLSession” | Has shipped large downloads/uploads that survive backgrounding |
Concept → Why → How → Code
Concept
The right mental model: URLSession is a transport. Above it lives an APIClient you own — a thin layer that owns base URL, headers, encoding, decoding, auth, retry policy, and error mapping. Below URLSession lives the system: TLS, DNS, NSURLConnection internals, the network reachability you don’t touch directly.
Why
URLSession calls scattered through view models is the source of half the bugs in any iOS codebase past 10k LOC. Centralizing in an APIClient gives you:
- One place to add auth, one place to revoke
- One place to test, one place to mock
- One place to enforce timeouts, retry policy, logging
- One place to introduce certificate pinning (Chapter 9.3) without touching call sites
How — the production APIClient skeleton
import Foundation
public protocol Endpoint {
var path: String { get }
var method: HTTPMethod { get }
var headers: [String: String] { get }
var query: [URLQueryItem] { get }
var body: Data? { get }
}
public enum HTTPMethod: String {
case get, post, put, patch, delete
}
public actor APIClient {
private let baseURL: URL
private let session: URLSession
private let decoder: JSONDecoder
private var tokenProvider: TokenProvider
public init(baseURL: URL, session: URLSession = .shared, tokenProvider: TokenProvider) {
self.baseURL = baseURL
self.session = session
self.decoder = JSONDecoder()
self.decoder.dateDecodingStrategy = .iso8601
self.tokenProvider = tokenProvider
}
public func send<T: Decodable>(_ endpoint: Endpoint, as type: T.Type) async throws -> T {
try await sendWithRetry(endpoint, attempt: 0, as: type)
}
}
Note the actor: requests are serialized through the actor so the token refresh logic doesn’t race when ten concurrent calls all see a 401 at the same time. Real-world auth bugs are almost always concurrency bugs.
Interceptors via request building
Build the URLRequest inside the client so you control the full pipeline:
extension APIClient {
private func buildRequest(_ endpoint: Endpoint, token: String?) -> URLRequest {
var components = URLComponents(url: baseURL.appendingPathComponent(endpoint.path),
resolvingAgainstBaseURL: false)!
if !endpoint.query.isEmpty { components.queryItems = endpoint.query }
var request = URLRequest(url: components.url!)
request.httpMethod = endpoint.method.rawValue.uppercased()
request.httpBody = endpoint.body
request.timeoutInterval = 30
// Default headers
request.setValue("application/json", forHTTPHeaderField: "Accept")
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.setValue(userAgent(), forHTTPHeaderField: "User-Agent")
// Auth interceptor
if let token { request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") }
// Endpoint-specific
endpoint.headers.forEach { request.setValue($0.value, forHTTPHeaderField: $0.key) }
return request
}
}
Auth refresh + retry on 401
public protocol TokenProvider: Sendable {
func currentAccessToken() async -> String?
func refreshTokens() async throws -> String // returns the new access token
}
extension APIClient {
private func sendWithRetry<T: Decodable>(_ endpoint: Endpoint, attempt: Int, as type: T.Type) async throws -> T {
let token = await tokenProvider.currentAccessToken()
let request = buildRequest(endpoint, token: token)
let (data, response) = try await session.data(for: request)
guard let http = response as? HTTPURLResponse else { throw APIError.invalidResponse }
switch http.statusCode {
case 200...299:
return try decoder.decode(T.self, from: data)
case 401 where attempt == 0:
_ = try await tokenProvider.refreshTokens()
return try await sendWithRetry(endpoint, attempt: 1, as: type)
case 429, 500...599 where attempt < 3:
try await Task.sleep(nanoseconds: backoff(attempt: attempt))
return try await sendWithRetry(endpoint, attempt: attempt + 1, as: type)
default:
throw APIError.server(status: http.statusCode, body: data)
}
}
private func backoff(attempt: Int) -> UInt64 {
// 0.5s, 1s, 2s with ±20% jitter
let base = pow(2.0, Double(attempt)) * 0.5
let jitter = base * Double.random(in: -0.2...0.2)
return UInt64((base + jitter) * 1_000_000_000)
}
}
public enum APIError: Error {
case invalidResponse
case server(status: Int, body: Data)
}
The actor isolation gives you one critical guarantee: when ten concurrent requests get 401, only the first one performs the refresh; the others wait for the actor to be free, then re-read currentAccessToken() and get the fresh one.
Multipart upload
public struct MultipartFormData {
public struct Part {
public let name: String
public let filename: String?
public let mimeType: String?
public let data: Data
}
public let parts: [Part]
public let boundary = "Boundary-\(UUID().uuidString)"
public func encode() -> Data {
var body = Data()
for part in parts {
body.append("--\(boundary)\r\n")
var disposition = "Content-Disposition: form-data; name=\"\(part.name)\""
if let filename = part.filename { disposition += "; filename=\"\(filename)\"" }
body.append("\(disposition)\r\n")
if let mime = part.mimeType { body.append("Content-Type: \(mime)\r\n") }
body.append("\r\n")
body.append(part.data)
body.append("\r\n")
}
body.append("--\(boundary)--\r\n")
return body
}
}
private extension Data {
mutating func append(_ string: String) { append(string.data(using: .utf8)!) }
}
Set Content-Type: multipart/form-data; boundary=\(boundary) on the request and use URLSession.upload(for:from:) for progress tracking via the session delegate.
Download progress + resumable
final class DownloadController: NSObject, URLSessionDownloadDelegate {
private lazy var session = URLSession(configuration: .default, delegate: self, delegateQueue: nil)
private var continuations: [Int: CheckedContinuation<URL, Error>] = [:]
private var progressHandlers: [Int: (Double) -> Void] = [:]
func download(from url: URL, onProgress: @escaping (Double) -> Void) async throws -> URL {
let task = session.downloadTask(with: url)
return try await withCheckedThrowingContinuation { continuation in
continuations[task.taskIdentifier] = continuation
progressHandlers[task.taskIdentifier] = onProgress
task.resume()
}
}
func urlSession(_ s: URLSession, downloadTask: URLSessionDownloadTask,
didWriteData _: Int64, totalBytesWritten written: Int64, totalBytesExpectedToWrite total: Int64) {
guard total > 0 else { return }
progressHandlers[downloadTask.taskIdentifier]?(Double(written) / Double(total))
}
func urlSession(_ s: URLSession, downloadTask: URLSessionDownloadTask, didFinishDownloadingTo location: URL) {
// Move file out of the temp location before delegate returns
let target = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
try? FileManager.default.moveItem(at: location, to: target)
continuations.removeValue(forKey: downloadTask.taskIdentifier)?.resume(returning: target)
}
func urlSession(_ s: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) {
if let error { continuations.removeValue(forKey: task.taskIdentifier)?.resume(throwing: error) }
}
}
For background downloads that survive app suspension, use URLSessionConfiguration.background(withIdentifier:) and implement urlSessionDidFinishEvents(forBackgroundURLSession:) in your AppDelegate. Background sessions are how Apple Music downloads a 500MB album while your app is closed.
Timeouts & cancellation
The default 60s timeout is too long for interactive UI. Set per-request timeoutInterval = 15 for foreground API calls, longer for uploads. Cancel in-flight requests when the user navigates away:
struct ProfileView: View {
@State private var profile: Profile?
var body: some View {
VStack { /* … */ }
.task { @MainActor in
profile = try? await api.send(ProfileEndpoint(), as: Profile.self)
}
// .task auto-cancels when the view disappears
}
}
In the wild
- Alamofire and Moya are the venerable Swift networking libraries. Both are still useful, but for new projects the ergonomics of
async/awaitoverURLSessionare good enough that adding a dependency is rarely worth it. - Apollo iOS wraps
URLSessionfor GraphQL — same patterns, codegen-driven typed responses. - Uber, Lyft, Instagram publish iOS engineering posts confirming they all run custom
URLSessionwrappers similar to the skeleton above, plus pinning and metrics. Nobody pulls in Alamofire at that scale. - The native iOS networking layer used by Safari, Mail, Messages is
URLSession(or its lower-level cousinNSURLConnectionhistorically). Apple eats their own dog food.
Common misconceptions
- “Reachability tells me if the network works.” It tells you the interface state; it doesn’t tell you the server is reachable. Use it for offline UX hints, never as a gate before a request.
- “
URLSession.sharedis fine for everything.” It’s a singleton with default configuration — no custom timeouts, no custom delegates, no background mode. Build your own configured sessions for production code. - “Retrying on every error is good.” No. Retry on 5xx, 429, and network transport errors (
URLError.notConnectedToInternet,.timedOut). Don’t retry on 4xx (you’ll just hit the same wall) or on.cancelled(the user wanted to stop). - “
async/awaitcancellation propagates automatically.” It propagates throughTask. If you use a continuation to bridge to a delegate-based API (like download tasks), you must calltask.cancel()when the parentTaskis cancelled —withTaskCancellationHandleris your friend. - “Multipart is hard; use a library.” Multipart is 30 lines of code (above). Libraries hide bugs in encoding edge cases; writing it yourself once teaches you what’s actually going over the wire.
Seasoned engineer’s take
The network layer is the single highest-leverage piece of infrastructure in a mobile app. Done well, your app feels fast, recovers from spotty connections, and never asks the user to log in again after a token expires silently. Done poorly, your app is the one users delete on the train.
My recommendation: build the APIClient actor I sketched above, use it everywhere, and resist every temptation to call URLSession.shared.data(for:) directly from anywhere outside it. The first time you need to add Bearer token refresh, you’ll thank past-you for the discipline. The second time you need to add metrics, ditto. The third time you need to swap to a different transport (mocks for tests, certificate-pinned in production), you’ll do it in 20 lines.
The single piece of advice that takes most engineers years to internalize: errors are not edge cases; they are 30% of the runtime of your app on flaky cellular networks. Build error UI before you build happy-path UI. Test offline. Test “WiFi connected but no internet behind the captive portal.” Test “request started on WiFi, completed on cellular.” Most one-star reviews are bugs that would have been caught by testing one of those three.
TIP: Add a debug-only
URLSessiondelegate that logs every request and response with curl-equivalent format and timing. You’ll save days of “why does this work on staging but not production” investigations.
WARNING: Do not put
Authorizationheaders, OAuth tokens, or anything secret into the URL query string. URL query strings are logged by intermediaries, captured in crash reports, and stored in browser history. Always send sensitive values in headers or the body.
Interview corner
Junior: “How do you make a network request in Swift?”
URLSession.shared.data(for: request)returns(Data, URLResponse)as an async throws call. Cast the response toHTTPURLResponse, check the status code, decode the body withJSONDecoder. Wrap in an actor or class so the call sites stay clean.
Mid: “Design an auth interceptor that refreshes tokens on 401.”
Centralize requests through an actor-isolated
APIClient. On 401 from the first attempt, await the token-provider’srefreshTokens()(the actor isolation serializes concurrent refresh attempts), then retry once. Don’t retry indefinitely — one attempt, then surface the auth failure to the UI which logs the user out. Refresh tokens themselves are stored in Keychain (Chapter 9.2), neverUserDefaults.
Senior: “Design the network layer for a video app that supports large downloads, foreground streaming, offline caching, and token-authenticated APIs.”
Two
URLSessioninstances. One default-config foreground session for JSON APIs, wrapped by theAPIClientactor with auth + retry. One background session for large video downloads (URLSessionConfiguration.background) with a delegate that survives app relaunch viahandleEventsForBackgroundURLSession. Video streaming usesAVAssetResourceLoaderfor HLS, separate fromURLSession. Cache layer:URLCachefor small JSON, customFileManager-backed cache for videos with LRU eviction and a size cap. Auth flow: tokens in Keychain, refresh through the actor pattern, withnotifyon 401 events surfaced asos_logfor metrics. Add request timing viaURLSessionTaskMetricsand ship a P50/P95/error-rate per-endpoint dashboard.
Red flag: “We have a class Networking { static func get(_ url: String, completion: ...) } and every view controller calls it.”
Tells the interviewer the codebase has no centralized control over networking. There’s no path to add auth refresh, retry, pinning, or metrics without rewriting every call site. Refactor is a 6-month project.
Lab preview
Lab 6.3 — Production Network Layer implements the APIClient actor with auth refresh, retry/backoff, pagination, and a full unit-test suite using a mock URLProtocol.
Next: Combine
6.7 — Combine
Opening scenario
You inherit a five-year-old codebase. The LoginViewModel is 400 lines and reads like a logic puzzle: .combineLatest, .flatMap, .debounce, .removeDuplicates, .receive(on:), ending in .sink { [weak self] in self?.update($0) }.store(in: &cancellables). You ask the lead, “should we migrate this to async/await?” The lead says, “yes — but slowly, and you still need to know Combine, because the migration is going to take three years and meanwhile UIKit + Combine is half our code.”
Combine is not dead. It’s settled. In 2026, new iOS code rarely starts in Combine; new code is async/await and @Observable. Legacy code and UIKit reactive bridges are full of it. SwiftUI’s @Published is Combine under the hood. Form validation pipelines, search debouncing, and real-time data streams are still cleaner in Combine than in async/await today.
| Context | What it usually means |
|---|---|
| Reads “publisher / subscriber” | Has the reactive-streams mental model |
Reads “@Published” | Has used Combine through SwiftUI |
Reads “AnyCancellable” | Knows memory management is manual |
| Reads “backpressure” | Comes from RxSwift or Reactive Streams |
| Reads “Combine vs async/await” | Has migrated a codebase between the two |
Concept → Why → How → Code
Concept
Combine is Apple’s reactive streams framework, introduced iOS 13 (2019). Three types:
Publisher— emits a stream of values, optional failure, then optional completion.Subscriber— receives values and demand.Subscription— connects them and is the disposal handle.
Most consumers use AnyCancellable (a subscription wrapped for ARC-based disposal) and operators (map, flatMap, combineLatest, debounce, throttle, catch, share) to build pipelines. The same conceptual model as RxSwift, ReactiveSwift, or any Reactive Streams library.
Why (still, in 2026)
- SwiftUI ↔ Combine bridge is built in.
@Publishedproperties onObservableObjectproduce a publisher that SwiftUI auto-subscribes to. - UIKit reactive form patterns (text field validation, button-enabled state from multiple inputs) are still cleaner in Combine.
- Event buses (NotificationCenter wrappers, Realm/Core Data change publishers, Firebase listeners) speak Combine natively or have trivial bridges.
- The codebase you’ve been hired to maintain is full of it.
When not Combine in new code: one-shot async work (use async/await), simple “fetch a thing once” patterns (use URLSession.data), state machines (use @Observable).
How — the building blocks
import Combine
let just = Just(42) // emits 42, completes
let array = [1, 2, 3].publisher // emits each, completes
let timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect()
let subject = PassthroughSubject<String, Never>() // imperative push
let current = CurrentValueSubject<Int, Never>(0) // BehaviorSubject equivalent
Subscribe with sink (most common) or assign(to:on:):
var cancellables = Set<AnyCancellable>()
subject
.filter { $0.count > 2 }
.map { $0.uppercased() }
.sink { value in print(value) }
.store(in: &cancellables)
store(in:) retains the subscription; when the Set is deallocated, all subscriptions cancel. Forget this and your subscription is GC’d on the next line.
@Published + ObservableObject
final class SearchViewModel: ObservableObject {
@Published var query = ""
@Published private(set) var results: [SearchResult] = []
@Published private(set) var isLoading = false
private var cancellables = Set<AnyCancellable>()
private let api: SearchAPI
init(api: SearchAPI) {
self.api = api
$query
.debounce(for: .milliseconds(300), scheduler: RunLoop.main)
.removeDuplicates()
.filter { $0.count >= 2 }
.handleEvents(receiveOutput: { [weak self] _ in self?.isLoading = true })
.flatMap { [api] q in
api.search(query: q)
.catch { _ in Just([]) }
.eraseToAnyPublisher()
}
.receive(on: DispatchQueue.main)
.sink { [weak self] results in
self?.results = results
self?.isLoading = false
}
.store(in: &cancellables)
}
}
The classic “type-ahead search with debouncing” pipeline. Three years from now this might be a 60-line async sequence with Task-cancellation, but as one declarative chain it’s hard to beat.
Combining multiple inputs
final class SignupViewModel: ObservableObject {
@Published var email = ""
@Published var password = ""
@Published var confirmPassword = ""
@Published private(set) var isFormValid = false
private var cancellables = Set<AnyCancellable>()
init() {
Publishers.CombineLatest3($email, $password, $confirmPassword)
.map { email, password, confirm in
email.contains("@") && password.count >= 8 && password == confirm
}
.assign(to: &$isFormValid) // self-retaining assign-to-property
}
}
assign(to: &$property) (the inout/key-path variant) is the modern, leak-safe way to feed a publisher into a @Published. No cancellables bookkeeping.
Bridging to async/await
let result = try await urlPublisher
.values
.first(where: { _ in true })
Or simpler, for a single-value publisher:
let value = try await publisher.async() // hand-rolled extension
extension Publisher where Failure == Error {
func async() async throws -> Output {
try await withCheckedThrowingContinuation { continuation in
var cancellable: AnyCancellable?
cancellable = self.sink { completion in
if case .failure(let error) = completion {
continuation.resume(throwing: error)
}
cancellable?.cancel()
} receiveValue: { value in
continuation.resume(returning: value)
cancellable?.cancel()
}
}
}
}
This is how you migrate gradually: leave the Combine pipeline at the source, await its first value from your async code.
Memory management
The two patterns that prevent every Combine leak I’ve seen in code review:
// 1. Always [weak self] in sink closures
.sink { [weak self] value in self?.handle(value) }
// 2. Always .store(in: &cancellables) OR .assign(to: &$published)
Set<AnyCancellable> released → all subscriptions cancelled. If you store a cancellable in a let constant outside a set, it stays alive forever (memory leak); if you don’t store it at all, the publisher cancels immediately.
In the wild
- Most iOS 13–16 era SwiftUI apps use
ObservableObject+@Published. That’s Combine underneath. The migration to@Observable(Chapter 5.4) drops Combine from the SwiftUI surface but the publishers remain available. - Banking and trading apps (Robinhood, Coinbase, Square) lean heavily on Combine for real-time price streams in UIKit.
- Airbnb’s iOS architecture talk (2023) detailed their use of Combine as the spine of their MVVM layer; their 2025 follow-up describes a gradual migration to
@Observablebut says “Combine for streams, Observation for state” remains the heuristic. - Firebase Apple SDK ships Combine publishers as a first-class API. Same for Realm, GRDB, and most modern persistence libraries.
Common misconceptions
- “Combine is dead.” Marked-as-legacy in mindshare, fully maintained by Apple. SwiftUI’s
@Publishedis Combine. New first-party iOS frameworks still ship Combine extensions. Don’t write new business logic in it, but expect to read it for years. - “
sinkretains the publisher.” It doesn’t — the returnedAnyCancellableis what holds the subscription. Don’t store it and the pipeline dies. - “Schedulers don’t matter.” They matter enormously.
.receive(on: DispatchQueue.main)is required before UI mutations. Forgetting it gives you “purple warnings” or random crashes depending on the OS version. - “
PassthroughSubjectis the same asCurrentValueSubject.” They differ:PassthroughSubjectdoesn’t store a current value (new subscribers don’t get the last emission);CurrentValueSubjectdoes. Use the right one or you’ll spend a debugging session on “the view sometimes shows the old data.” - “
async/awaitreplaces all of Combine.” It replaces single-value async work and finite sequences. It does not (yet) replace multi-publisher composition, declarative timing operators (debounce,throttle), or hot streams of events.AsyncSequenceis closing the gap but not there yet.
Seasoned engineer’s take
In 2026 my heuristic is “streams: Combine; one-shots: async/await; state: @Observable.” A SwiftUI form’s validation pipeline is Combine. A func fetchProfile() async throws -> Profile is async/await. The view model’s @Observable state is neither — it’s Observation.
Don’t migrate working Combine code for fashion. The “modernize to async/await” project nobody is paid to ship is the project that breaks in production. Migrate when you touch a file for another reason and the migration is small. Migrate when a Combine pipeline has accumulated more than six operators and is hard to debug. Otherwise: leave it, document it, write tests around it.
The skill that separates senior from staff: knowing when not to be reactive. Half the Combine pipelines I’ve reviewed could be three lines of imperative code in an async function. Reactive composition is a tool, not a moral position. Reach for it when the synchronization is the hard part (multiple async inputs, debouncing, switching latest); reach past it when the work is a linear sequence.
TIP:
.print("label")is the best Combine debugging tool nobody uses. Drop it anywhere in a pipeline to log subscriptions, demands, values, completions, and cancellations to the console. Find the broken operator in 30 seconds.
WARNING:
.flatMapon a publisher of fast-changing values can pile up unfinished inner subscriptions. Use.switchToLatest()(or theflatMap(maxPublishers: .max(1))variant) for “cancel the previous in-flight request when a new value arrives” — the canonical pattern for type-ahead search.
Interview corner
Junior: “What is @Published?”
A property wrapper that wraps a value and exposes a Combine publisher (
$propertyName) emitting whenever the value changes. Used onObservableObjectclasses so SwiftUI views can subscribe to changes via@ObservedObject/@StateObject.
Mid: “Debounce vs throttle — when do you use each?”
debouncewaits a quiet period after the latest emission and then emits the last value; perfect for type-ahead search where you only want to query after the user stops typing.throttleemits the first (or last) value within a window and ignores the rest; perfect for scroll position events where you want at most one update every N milliseconds regardless of how fast events arrive.
Senior: “Walk me through migrating a Combine-heavy SearchViewModel to @Observable + async/await.”
Replace
ObservableObject+@Publishedwith@Observableand ordinary stored properties. Replace theflatMapAPI call with anasyncmethod on the model that starts aTaskcancelled on each new query. Keepdebouncesemantics by usingAsyncStreamor by tracking the query andTask.sleep(for: .milliseconds(300))withTask.checkCancellation()before the API call. The result is more code but linearly readable. Migrate incrementally: keep the form-validationcombineLatestpipeline in Combine (it’s clean), migrate only the network-fetching side. Ship behind a feature flag, A/B for one release.
Red flag: “We use Combine because it’s the modern way.”
Tells the interviewer the candidate adopts tech for trend reasons. The modern way in 2026 for new state is
@Observable; Combine remains valid but for specific reasons.
Lab preview
Lab 6.3 — Production Network Layer builds the network client end-to-end with async/await; the stretch goal adds Combine publisher wrappers for callers that haven’t migrated yet.
Next: Caching Strategies
6.8 — Caching Strategies
Opening scenario
The product manager says: “The feed has to feel instant. Like Instagram instant. Not ‘spinner-for-a-second’ instant — no spinner ever.” You answer: “Then we need to cache.” They say: “Sure, cache everything.” You take a breath. You know “cache everything” is exactly how apps end up holding 4GB of stale data the user pays to back up to iCloud. Caching is not “save bytes.” Caching is deciding what to save, where, for how long, and how to invalidate when the truth changes.
| Context | What it usually means |
|---|---|
| Reads “memory vs disk cache” | Knows the two-tier pattern |
Reads “NSCache” | Has used it for image caches |
Reads “URLCache” | Has tweaked HTTP response caching |
| Reads “cache-aside” | Has built a custom cache layer |
| Reads “TTL / invalidation” | Has been bitten by stale data |
Concept → Why → How → Code
Concept
Three caches you’ll usually layer:
- Memory (
NSCache<NSString, NSObject>) — fast, capped, auto-purged on memory pressure. Holds decoded objects (UIImage, parsed JSON model). - Disk (
FileManager) — slower (milliseconds), persistent across launches, capped by your code. Holds raw bytes (encoded images, JSON, response data). - HTTP (
URLCache) — built intoURLSession. RespectsCache-Control,ETag,If-Modified-Sinceheaders from the server. Free, server-controlled.
The pattern: check memory → check disk → fetch from network → write to disk → decode → write to memory → return.
Why
- Latency: RAM read is ~100ns, disk read is ~1ms, network round trip is 50–500ms. Three orders of magnitude per tier.
- Battery: Network requests are 10–100× more expensive than memory access in energy.
- Offline: Disk cache turns a “no network, show error” into “no network, show what we have.”
- Cost: Bandwidth costs (server and user) drop dramatically when assets are cached.
How — NSCache for memory
final class ImageMemoryCache {
private let cache = NSCache<NSString, UIImage>()
init() {
cache.countLimit = 200 // max 200 images
cache.totalCostLimit = 100 * 1024 * 1024 // 100MB
}
func image(for key: String) -> UIImage? {
cache.object(forKey: key as NSString)
}
func set(_ image: UIImage, for key: String) {
let cost = Int(image.size.width * image.size.height * image.scale * image.scale * 4)
cache.setObject(image, forKey: key as NSString, cost: cost)
}
}
NSCache automatically evicts on memory warnings — you don’t need to handle UIApplication.didReceiveMemoryWarningNotification yourself. Provide cost so eviction is byte-aware, not just count-aware.
URLCache for HTTP-level caching
let config = URLSessionConfiguration.default
config.urlCache = URLCache(
memoryCapacity: 50 * 1024 * 1024, // 50MB RAM
diskCapacity: 500 * 1024 * 1024, // 500MB disk
directory: nil // default location
)
config.requestCachePolicy = .useProtocolCachePolicy // honor server cache headers
let session = URLSession(configuration: config)
If the server sends Cache-Control: max-age=3600, URLSession will serve that response from the cache for the next hour without a network call. If you don’t control the server, override per-request:
var request = URLRequest(url: url)
request.cachePolicy = .returnCacheDataElseLoad // try cache, fall back to network
The most useful overrides:
.useProtocolCachePolicy(default) — honor server..returnCacheDataElseLoad— offline-friendly..returnCacheDataDontLoad— strict offline mode..reloadIgnoringLocalCacheData— force fresh.
A two-tier custom cache
For things URLCache doesn’t handle (decoded images, custom binary blobs, post-processing results):
actor TwoTierCache<Key: Hashable & Sendable, Value: Sendable> {
private let memory = NSCache<NSString, AnyObject>()
private let directory: URL
private let encode: @Sendable (Value) throws -> Data
private let decode: @Sendable (Data) throws -> Value
init(name: String,
encode: @escaping @Sendable (Value) throws -> Data,
decode: @escaping @Sendable (Data) throws -> Value) throws {
let caches = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)[0]
directory = caches.appendingPathComponent(name, isDirectory: true)
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
self.encode = encode
self.decode = decode
memory.countLimit = 500
}
func value(for key: Key) async -> Value? {
let nsKey = String(describing: key.hashValue) as NSString
if let cached = memory.object(forKey: nsKey) as? CacheBox<Value> {
return cached.value
}
let url = directory.appendingPathComponent("\(key.hashValue)")
guard let data = try? Data(contentsOf: url),
let value = try? decode(data) else { return nil }
memory.setObject(CacheBox(value: value), forKey: nsKey)
return value
}
func set(_ value: Value, for key: Key) async {
let nsKey = String(describing: key.hashValue) as NSString
memory.setObject(CacheBox(value: value), forKey: nsKey)
let url = directory.appendingPathComponent("\(key.hashValue)")
if let data = try? encode(value) {
try? data.write(to: url)
}
}
func clear() {
memory.removeAllObjects()
try? FileManager.default.removeItem(at: directory)
try? FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
}
}
private final class CacheBox<T> {
let value: T
init(value: T) { self.value = value }
}
NSCache doesn’t accept Swift value types directly; wrap them in a CacheBox reference type.
TTL and invalidation
The two hardest problems in computer science: naming, off-by-one errors, and cache invalidation. Three strategies:
- TTL (time-to-live): stamp each cached item with an expiration. On read, check
Date.now < expiration; if not, treat as miss.
struct CachedItem<T: Codable>: Codable {
let value: T
let expiresAt: Date
}
func fetch(key: String) async throws -> Profile {
if let cached = try? await cache.value(for: key),
cached.expiresAt > .now {
return cached.value
}
let fresh = try await api.fetchProfile(key)
let item = CachedItem(value: fresh, expiresAt: .now.addingTimeInterval(300))
await cache.set(item, for: key)
return fresh
}
-
Server-driven (
ETag/If-None-Match): server returns304 Not Modifiedand yourURLCacheserves the cached body. Free if your server supports it. -
Push invalidation: subscribe to a server event (WebSocket, CloudKit subscription, push notification) that says “key X is stale, drop your cache.” The most accurate, the most complex.
Size caps & eviction
Set a max disk size and a periodic cleanup:
func enforceDiskLimit(maxBytes: Int) {
let files = (try? FileManager.default.contentsOfDirectory(
at: directory, includingPropertiesForKeys: [.contentAccessDateKey, .fileSizeKey]
)) ?? []
let sized = files.compactMap { url -> (URL, Date, Int)? in
let values = try? url.resourceValues(forKeys: [.contentAccessDateKey, .fileSizeKey])
guard let access = values?.contentAccessDate, let size = values?.fileSize else { return nil }
return (url, access, size)
}
let total = sized.reduce(0) { $0 + $1.2 }
guard total > maxBytes else { return }
let sortedLRU = sized.sorted { $0.1 < $1.1 } // oldest first
var freed = 0
for (url, _, size) in sortedLRU {
try? FileManager.default.removeItem(at: url)
freed += size
if total - freed <= maxBytes { break }
}
}
LRU eviction by last-access date. Run this on a background queue every app launch or every 24 hours.
Don’t cache anywhere
- Sensitive data (auth tokens, personal info, anything covered by privacy reviews) — Keychain only (Chapter 9.2).
- Anything
Data Protectionwould protect — caches survive backup, sometimes survive uninstall, and may leak across user account contexts on macOS. Tag the cache directory withURLResourceValues.isExcludedFromBackup = trueif it shouldn’t sync to iCloud Backup.
var values = URLResourceValues()
values.isExcludedFromBackup = true
try directory.setResourceValues(values)
In the wild
- SDWebImage and Kingfisher are the de facto third-party image caches for iOS. Both follow the two-tier pattern with NSCache + disk; both have ~25k stars.
- Apple’s
ImageRenderer/AsyncImagedoes not aggressively cache. For production image-heavy UIs (feeds, grids), most teams roll their own or use Kingfisher. - Instagram publishes engineering posts about their image cache (Texture, predictive prefetch, decoded-image cache on a background thread). The strategy: warm the cache before the cell appears.
- YouTube iOS caches video segments on disk for offline playback and aggressively prefetches ahead of the current playhead.
Common misconceptions
- “
UIImage(named:)is cached.” Asset catalog images are cached aggressively. Loose images viaUIImage(contentsOfFile:)are not — every load reads disk and decodes. UseUIImage(named:)or your ownNSCache. - “
URLCacheworks automatically.” It works if (a) the server sends correct cache headers, (b) the request method is GET, (c) the response isn’t too big for the cache, and (d) the request policy is set to use it. Many APIs fail (a) and you get zero caching by default. - “More cache is always better.” A 4GB disk cache means the user wonders why your app is 4GB in Settings → iPhone Storage. They delete the app. Cap aggressively (50–500MB for most apps); evict by LRU.
- “Caching makes things faster.” Caching makes the cache hit path faster. The cache miss path can be slower if you have a complex check-memory-then-disk-then-network ladder. Profile both paths.
- “
NSCacheis just a dictionary.” It’s a thread-safe dictionary with auto-eviction on memory pressure. The cost-based eviction means it actually understands “an image is bigger than an integer.”
Seasoned engineer’s take
Caching is the most rewarding investment in app performance you can make, and the most dangerous. The reward: feeds load instantly, offline works without a custom code path, network bills drop. The danger: stale data, user reports of “I added a comment and don’t see it,” and the eternal “logged out but my data is still here” privacy bug.
My rules:
- Always cache reads, never cache writes. A POST that creates a record shouldn’t be cached — it should hit the server, get a real ID, and then the result lands in your cache.
- Always have an “invalidate everything” path. On logout, on account switch, on schema migration: nuke every cache. The user will not forgive seeing the previous user’s data.
- Cache decoded objects in memory, raw bytes on disk. Decoding (JPEG, PDF, parsed JSON) is often as expensive as the network. Keep the post-decode result in
NSCache. - TTL by data sensitivity. Static reference data (country list): 30 days. User profile: 5 minutes. Anything financial: 0 seconds — always fetch fresh.
- Tell the user when you’re showing cached data. A discreet “Updated 2 minutes ago” subtitle prevents support tickets.
TIP: Add a debug menu option to clear all caches. You’ll use it every day in development, and you can also expose it (with a confirm) for users who report “app is acting weird” — clearing caches fixes more bugs than most rollbacks.
WARNING: Never store the result of
.encrypted(for: user)(or any decrypted PII) in a disk cache without re-encrypting. Disk caches survive iCloud Backup by default, can survive uninstall (Mac sandbox containers persist longer than expected), and may be readable by other apps on a jailbroken device. Decrypt for use, never for storage.
Interview corner
Junior: “What’s the difference between NSCache and Dictionary?”
NSCacheis thread-safe, automatically evicts entries under memory pressure, supports cost-based eviction (bytes per entry), and doesn’t hold strong references that prevent objects from being purged. ADictionaryis none of those.
Mid: “Design an image-loading layer for a feed of 1,000 items.”
Three tiers. (1) Memory cache (
NSCache<NSURL, UIImage>, costed by decoded byte size, ~100MB cap). (2) Disk cache for encoded bytes (~500MB cap, LRU eviction). (3) Network fetch with cancellation when the cell scrolls off. Decode on a background queue (Task.detached), set the image on main. Cancel in-flight requests on cell reuse. Prefetch a screen ahead based on scroll direction. Verify with Instruments — memory should plateau, not climb.
Senior: “Walk me through a cache invalidation strategy for a CRM app where a sales rep edits a customer record on Device A; Device B should see the change without manual refresh.”
Per-record TTL is too coarse — sales reps see stale data while the TTL window holds. Push invalidation is the answer. Backend pushes a silent APNs notification with the changed record’s ID. App receives, evicts that key from memory and disk caches, and on next access either fetches fresh or, better, includes the new payload in the push and writes it straight to the cache. Combine with optimistic updates: when Device A writes locally, write to its own cache immediately, send to server async, reconcile on response. Mark records “edited locally, awaiting server” so UI can show a sync indicator until the server confirms.
Red flag: “We don’t really cache — the app is supposed to always show fresh data.”
Tells the interviewer the candidate hasn’t considered that “always fetch” means “slow always” and “broken on the train.” Even the freshest-required apps cache rendered cells, fonts, decoded images, asset bundles — caching is an architecture concern, not an optional feature.
Lab preview
The chapter material is woven into Lab 6.3 — Production Network Layer, which wires a memory + disk cache behind the APIClient so the GET endpoints stay fast under repeat access.
Next: Lab 6.1 — Journal App with SwiftData
Lab 6.1 — Journal App with SwiftData
Goal
Build a single-screen-into-detail journal app on SwiftData with relationships (Entries ↔ Tags), search via #Predicate, and an optional toggle to enable CloudKit private-database sync. By the end you’ll have hands-on the entire SwiftData persistence surface from Chapter 6.2 plus a working CloudKit configuration.
Time
~90 minutes. Stretch goals push this to 3 hours.
Prerequisites
- Xcode 16+ with iOS 18+ simulator (iOS 17.4 minimum if you must)
- Apple Developer account (free tier is fine for simulator; paid required for device + CloudKit)
- Read Chapters 6.1, 6.2, 6.3, 6.4, 6.5
Setup
- New project: Xcode → File → New → Project → iOS App. Product name
Journal. Interface: SwiftUI. Storage: None (we’ll add SwiftData manually so you see every line). - Bundle ID:
com.yourname.Journal. The CloudKit container will follow this name. - Delete the generated
Item.swiftand any SwiftData boilerplateAppfile’s.modelContainer(for: Item.self).
Build
Step 1 — define the schema
Create Models.swift:
import Foundation
import SwiftData
@Model
final class Entry {
var id: UUID = UUID()
var title: String = ""
var body: String = ""
var createdAt: Date = Date()
var mood: Int = 3 // 1..5
@Relationship(deleteRule: .nullify, inverse: \Tag.entries)
var tags: [Tag]? = []
init(title: String, body: String, mood: Int = 3) {
self.title = title
self.body = body
self.mood = mood
}
}
@Model
final class Tag {
var id: UUID = UUID()
var name: String = ""
@Relationship var entries: [Entry]? = []
init(name: String) {
self.name = name
}
}
Note: every attribute optional or defaulted, no @Attribute(.unique). That’s the CloudKit-ready shape from Chapter 6.5.
Step 2 — wire the container
Edit JournalApp.swift:
import SwiftUI
import SwiftData
@main
struct JournalApp: App {
@AppStorage("cloudSyncEnabled") private var cloudSyncEnabled = false
let container: ModelContainer
init() {
let schema = Schema([Entry.self, Tag.self])
// First-launch decision: local-only by default; user opts into iCloud in Settings.
let cloudEnabled = UserDefaults.standard.bool(forKey: "cloudSyncEnabled")
let config = ModelConfiguration(
schema: schema,
isStoredInMemoryOnly: false,
cloudKitDatabase: cloudEnabled ? .private("iCloud.com.yourname.Journal") : .none
)
do {
container = try ModelContainer(for: schema, configurations: config)
} catch {
fatalError("Failed to create ModelContainer: \(error)")
}
}
var body: some Scene {
WindowGroup {
ContentView()
}
.modelContainer(container)
}
}
(If you toggle the setting later, prompt the user to relaunch — switching containers at runtime is not supported.)
Step 3 — the list view
ContentView.swift:
import SwiftUI
import SwiftData
struct ContentView: View {
@Environment(\.modelContext) private var context
@Query(sort: \Entry.createdAt, order: .reverse) private var entries: [Entry]
@State private var searchText = ""
@State private var showingNew = false
var body: some View {
NavigationStack {
List {
ForEach(filteredEntries) { entry in
NavigationLink(value: entry) {
EntryRow(entry: entry)
}
}
.onDelete(perform: delete)
}
.navigationTitle("Journal")
.navigationDestination(for: Entry.self) { EntryDetailView(entry: $0) }
.searchable(text: $searchText, prompt: "Search title or body")
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button("New", systemImage: "plus") { showingNew = true }
}
}
.sheet(isPresented: $showingNew) { NewEntrySheet() }
}
}
private var filteredEntries: [Entry] {
guard !searchText.isEmpty else { return entries }
let q = searchText
return entries.filter { entry in
entry.title.localizedStandardContains(q) ||
entry.body.localizedStandardContains(q)
}
}
private func delete(at offsets: IndexSet) {
for index in offsets { context.delete(filteredEntries[index]) }
}
}
struct EntryRow: View {
let entry: Entry
var body: some View {
VStack(alignment: .leading, spacing: 4) {
HStack {
Text(entry.title.isEmpty ? "Untitled" : entry.title).font(.headline)
Spacer()
Text(String(repeating: "★", count: entry.mood))
.font(.caption)
.foregroundStyle(.orange)
}
Text(entry.createdAt, style: .date)
.font(.caption).foregroundStyle(.secondary)
}
}
}
Step 4 — #Predicate-driven search (advanced)
Replace the in-memory filter with a true predicate fetch:
struct ContentView: View {
@Environment(\.modelContext) private var context
@State private var searchText = ""
@State private var entries: [Entry] = []
@State private var showingNew = false
var body: some View {
NavigationStack {
List { /* same as before, using $entries */ }
.searchable(text: $searchText, prompt: "Search…")
.task(id: searchText) { await refresh() }
}
}
private func refresh() async {
let q = searchText
let predicate: Predicate<Entry>? = q.isEmpty ? nil : #Predicate {
$0.title.localizedStandardContains(q) || $0.body.localizedStandardContains(q)
}
var descriptor = FetchDescriptor<Entry>(predicate: predicate,
sortBy: [SortDescriptor(\.createdAt, order: .reverse)])
descriptor.fetchLimit = 200
entries = (try? context.fetch(descriptor)) ?? []
}
}
Notice the task(id:) modifier — fetch re-runs every time searchText changes.
Step 5 — the detail view with @Bindable
struct EntryDetailView: View {
@Bindable var entry: Entry
@Environment(\.modelContext) private var context
@Query(sort: \Tag.name) private var allTags: [Tag]
@State private var newTag = ""
var body: some View {
Form {
Section("Entry") {
TextField("Title", text: $entry.title)
TextEditor(text: $entry.body).frame(minHeight: 120)
Stepper("Mood: \(entry.mood)", value: $entry.mood, in: 1...5)
}
Section("Tags") {
ForEach(allTags) { tag in
Toggle(tag.name, isOn: binding(for: tag))
}
HStack {
TextField("New tag", text: $newTag)
Button("Add") { addTag() }.disabled(newTag.trimmingCharacters(in: .whitespaces).isEmpty)
}
}
}
.navigationTitle(entry.title.isEmpty ? "Untitled" : entry.title)
.navigationBarTitleDisplayMode(.inline)
}
private func binding(for tag: Tag) -> Binding<Bool> {
Binding {
entry.tags?.contains(where: { $0.id == tag.id }) ?? false
} set: { isOn in
if isOn {
if entry.tags == nil { entry.tags = [] }
if !(entry.tags?.contains(where: { $0.id == tag.id }) ?? false) {
entry.tags?.append(tag)
}
} else {
entry.tags?.removeAll(where: { $0.id == tag.id })
}
}
}
private func addTag() {
let trimmed = newTag.trimmingCharacters(in: .whitespaces)
guard !trimmed.isEmpty else { return }
// Application-layer uniqueness (CloudKit can't enforce it).
if !allTags.contains(where: { $0.name.caseInsensitiveCompare(trimmed) == .orderedSame }) {
let tag = Tag(name: trimmed)
context.insert(tag)
}
newTag = ""
}
}
Step 6 — new-entry sheet
struct NewEntrySheet: View {
@Environment(\.modelContext) private var context
@Environment(\.dismiss) private var dismiss
@State private var title = ""
@State private var body = ""
@State private var mood = 3
var body: some View {
NavigationStack {
Form {
TextField("Title", text: $title)
TextEditor(text: $body).frame(minHeight: 120)
Stepper("Mood: \(mood)", value: $mood, in: 1...5)
}
.navigationTitle("New Entry")
.toolbar {
ToolbarItem(placement: .topBarLeading) { Button("Cancel") { dismiss() } }
ToolbarItem(placement: .topBarTrailing) {
Button("Save") { save() }.disabled(title.isEmpty && body.isEmpty)
}
}
}
}
private func save() {
let entry = Entry(title: title, body: body, mood: mood)
context.insert(entry)
dismiss()
}
}
Step 7 — Settings (enable CloudKit)
struct SettingsView: View {
@AppStorage("cloudSyncEnabled") private var cloudSyncEnabled = false
var body: some View {
Form {
Toggle("Sync via iCloud", isOn: $cloudSyncEnabled)
.onChange(of: cloudSyncEnabled) { _, _ in
// Inform the user a relaunch is required.
}
Text("Changes take effect after the next app launch.")
.font(.caption).foregroundStyle(.secondary)
}
}
}
Add a settings tab or a gear-icon button to surface this view.
Step 8 — enable CloudKit (when ready)
- Project → Signing & Capabilities → + Capability → iCloud → check CloudKit → add container
iCloud.com.yourname.Journal. -
- Capability → Background Modes → check Remote notifications.
- Run on a device or simulator signed into iCloud. Toggle the setting on. Relaunch.
- Make an entry. Open the CloudKit Dashboard → your container → Schema → you should see
CD_Entry,CD_Tagrecord types appear.
Stretch
- Stretch 1 — Core Data side-by-side: create a sibling target
JournalLegacyusingNSPersistentCloudKitContainerwith the equivalent schema. Compare lines of code, build time, and iCloud sync behavior. - Stretch 2 — Schema v2: add an
attachments: [Attachment]relationship as a versioned schema migration (SchemaV1,SchemaV2,SchemaMigrationPlan). Verify a v1 store migrates cleanly. - Stretch 3 — Background import: write a
@ModelActor-based importer that takes a JSON file (sample fixture provided in your code) and inserts 1,000 entries off the main thread. - Stretch 4 — Sync status UI: subscribe to
NSPersistentCloudKitContainer.eventChangedNotification, show a sync indicator in the toolbar with last-sync timestamp.
Notes & gotchas
- If
ModelContainerinitialization fails with a CloudKit-related error, check that every attribute is optional/defaulted and that no@Attribute(.unique)is present. The error message is rarely specific. - Don’t share the same iCloud account between your dev and personal devices while iterating — schema confusion across builds is real.
- The first sync of an empty new device can take minutes. Show a loading state, not an empty list.
- Production schema deploys are one-way; don’t deploy until you’re confident the schema is final for the next release.
Next: Lab 6.2 — CloudKit Sync App
Lab 6.2 — CloudKit Sync App
Goal
Build a recipe-sharing app that uses two CloudKit databases simultaneously: the private database holds your personal recipes; the public database hosts community recipes anyone can browse. Wire CKSubscription silent pushes so changes from other devices appear in real time without polling. By the end you’ll have shipped a non-trivial CloudKit-direct app and feel comfortable with the raw CKContainer/CKDatabase/CKRecord APIs that Chapter 6.3 introduced.
Time
~3 hours. Stretch goals push this to a full day.
Prerequisites
- Xcode 16+
- Paid Apple Developer account (required to use CloudKit on device; free tier is simulator-only)
- An iCloud-signed-in simulator or device
- Read Chapter 6.3
Setup
- New iOS app project. Name
CookCloud. SwiftUI. No SwiftData/Core Data. - Project → Signing & Capabilities → + Capability → iCloud → check CloudKit. Add container
iCloud.com.yourname.CookCloud. -
- Capability → Background Modes → check Remote notifications.
-
- Capability → Push Notifications.
- Make sure your simulator/device is signed into iCloud (Settings → Sign in).
Build
Step 1 — model types & DTO
Recipe.swift:
import CloudKit
struct Recipe: Identifiable, Hashable {
let id: CKRecord.ID
var title: String
var ingredients: String
var instructions: String
var modifiedAt: Date
init(record: CKRecord) {
self.id = record.recordID
self.title = record["title"] as? String ?? ""
self.ingredients = record["ingredients"] as? String ?? ""
self.instructions = record["instructions"] as? String ?? ""
self.modifiedAt = record.modificationDate ?? .now
}
func toRecord(in zoneID: CKRecordZone.ID) -> CKRecord {
let record = CKRecord(recordType: "Recipe", recordID: id)
apply(to: record)
return record
}
func apply(to record: CKRecord) {
record["title"] = title as CKRecordValue
record["ingredients"] = ingredients as CKRecordValue
record["instructions"] = instructions as CKRecordValue
}
}
Step 2 — repository actor
CloudKitRepository.swift:
import CloudKit
import Foundation
actor CloudKitRepository {
enum Scope { case privateDB, publicDB }
private let container: CKContainer
private let zoneID = CKRecordZone.ID(zoneName: "Recipes", ownerName: CKCurrentUserDefaultName)
private var didCreateZone = false
init() {
self.container = CKContainer(identifier: "iCloud.com.yourname.CookCloud")
}
private func database(for scope: Scope) -> CKDatabase {
switch scope {
case .privateDB: return container.privateCloudDatabase
case .publicDB: return container.publicCloudDatabase
}
}
func ensurePrivateZone() async throws {
guard !didCreateZone else { return }
let zone = CKRecordZone(zoneID: zoneID)
_ = try await container.privateCloudDatabase.save(zone)
didCreateZone = true
}
func save(_ recipe: Recipe, in scope: Scope) async throws -> Recipe {
if scope == .privateDB { try await ensurePrivateZone() }
let recordID = CKRecord.ID(recordName: recipe.id.recordName,
zoneID: scope == .privateDB ? zoneID : .default)
let existing = try? await database(for: scope).record(for: recordID)
let record = existing ?? CKRecord(recordType: "Recipe", recordID: recordID)
recipe.apply(to: record)
let saved = try await database(for: scope).save(record)
return Recipe(record: saved)
}
func fetchAll(in scope: Scope) async throws -> [Recipe] {
let predicate = NSPredicate(value: true)
let query = CKQuery(recordType: "Recipe", predicate: predicate)
query.sortDescriptors = [NSSortDescriptor(key: "modificationDate", ascending: false)]
let (matches, _) = try await database(for: scope).records(matching: query,
inZoneWith: scope == .privateDB ? zoneID : nil,
resultsLimit: 200)
return matches.compactMap { try? Recipe(record: $0.1.get()) }
}
func delete(_ recipe: Recipe, in scope: Scope) async throws {
_ = try await database(for: scope).deleteRecord(withID: recipe.id)
}
// MARK: subscriptions
func subscribeToChanges() async throws {
try await subscribe(to: .privateDB, subscriptionID: "private-recipes")
try await subscribe(to: .publicDB, subscriptionID: "public-recipes")
}
private func subscribe(to scope: Scope, subscriptionID: String) async throws {
let subscription = CKQuerySubscription(
recordType: "Recipe",
predicate: NSPredicate(value: true),
subscriptionID: subscriptionID,
options: [.firesOnRecordCreation, .firesOnRecordUpdate, .firesOnRecordDeletion]
)
let info = CKSubscription.NotificationInfo()
info.shouldSendContentAvailable = true
subscription.notificationInfo = info
do {
_ = try await database(for: scope).save(subscription)
} catch let error as CKError where error.code == .serverRejectedRequest {
// already exists - that's fine
}
}
}
Step 3 — @Observable store
import Observation
@Observable
@MainActor
final class RecipeStore {
private let repo = CloudKitRepository()
var privateRecipes: [Recipe] = []
var publicRecipes: [Recipe] = []
var error: String?
func bootstrap() async {
do {
try await repo.subscribeToChanges()
await refresh()
} catch {
self.error = "Bootstrap: \(error.localizedDescription)"
}
}
func refresh() async {
async let privates = try? await repo.fetchAll(in: .privateDB) ?? []
async let publics = try? await repo.fetchAll(in: .publicDB) ?? []
privateRecipes = await privates ?? []
publicRecipes = await publics ?? []
}
func save(_ recipe: Recipe, public isPublic: Bool) async {
do {
let saved = try await repo.save(recipe, in: isPublic ? .publicDB : .privateDB)
if isPublic { upsert(saved, into: &publicRecipes) }
else { upsert(saved, into: &privateRecipes) }
} catch {
self.error = error.localizedDescription
}
}
private func upsert(_ recipe: Recipe, into list: inout [Recipe]) {
if let i = list.firstIndex(where: { $0.id == recipe.id }) {
list[i] = recipe
} else {
list.insert(recipe, at: 0)
}
}
func delete(_ recipe: Recipe, public isPublic: Bool) async {
do {
try await repo.delete(recipe, in: isPublic ? .publicDB : .privateDB)
if isPublic { publicRecipes.removeAll { $0.id == recipe.id } }
else { privateRecipes.removeAll { $0.id == recipe.id } }
} catch {
self.error = error.localizedDescription
}
}
}
Step 4 — handle silent pushes (AppDelegate)
import UIKit
import CloudKit
final class AppDelegate: NSObject, UIApplicationDelegate {
static var refreshHandler: (() async -> Void)?
func application(_ application: UIApplication,
didFinishLaunchingWithOptions opts: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
application.registerForRemoteNotifications()
return true
}
func application(_ application: UIApplication,
didReceiveRemoteNotification userInfo: [AnyHashable: Any],
fetchCompletionHandler completion: @escaping (UIBackgroundFetchResult) -> Void) {
guard CKNotification(fromRemoteNotificationDictionary: userInfo) != nil else {
completion(.noData); return
}
Task {
await Self.refreshHandler?()
completion(.newData)
}
}
}
@main
struct CookCloudApp: App {
@UIApplicationDelegateAdaptor(AppDelegate.self) private var delegate
@State private var store = RecipeStore()
var body: some Scene {
WindowGroup {
RootView()
.environment(store)
.task {
await store.bootstrap()
AppDelegate.refreshHandler = { @MainActor in await store.refresh() }
}
}
}
}
Step 5 — UI
struct RootView: View {
@Environment(RecipeStore.self) private var store
var body: some View {
TabView {
RecipeListView(scopeIsPublic: false)
.tabItem { Label("My Recipes", systemImage: "person.crop.circle") }
RecipeListView(scopeIsPublic: true)
.tabItem { Label("Community", systemImage: "globe") }
}
}
}
struct RecipeListView: View {
let scopeIsPublic: Bool
@Environment(RecipeStore.self) private var store
@State private var showingNew = false
private var recipes: [Recipe] {
scopeIsPublic ? store.publicRecipes : store.privateRecipes
}
var body: some View {
NavigationStack {
List {
ForEach(recipes) { recipe in
NavigationLink(value: recipe) {
VStack(alignment: .leading) {
Text(recipe.title).font(.headline)
Text(recipe.modifiedAt, style: .relative)
.font(.caption).foregroundStyle(.secondary)
}
}
}
.onDelete { offsets in
Task {
for i in offsets { await store.delete(recipes[i], public: scopeIsPublic) }
}
}
}
.navigationTitle(scopeIsPublic ? "Community" : "My Recipes")
.toolbar {
Button("New", systemImage: "plus") { showingNew = true }
}
.sheet(isPresented: $showingNew) {
RecipeEditorSheet(scopeIsPublic: scopeIsPublic)
}
.navigationDestination(for: Recipe.self) { RecipeDetailView(recipe: $0) }
.refreshable { await store.refresh() }
}
}
}
struct RecipeEditorSheet: View {
let scopeIsPublic: Bool
@Environment(RecipeStore.self) private var store
@Environment(\.dismiss) private var dismiss
@State private var title = ""
@State private var ingredients = ""
@State private var instructions = ""
var body: some View {
NavigationStack {
Form {
TextField("Title", text: $title)
Section("Ingredients") { TextEditor(text: $ingredients).frame(minHeight: 120) }
Section("Instructions") { TextEditor(text: $instructions).frame(minHeight: 120) }
}
.navigationTitle("New Recipe")
.toolbar {
ToolbarItem(placement: .topBarLeading) { Button("Cancel") { dismiss() } }
ToolbarItem(placement: .topBarTrailing) {
Button("Save") {
let recipe = Recipe(
record: {
let r = CKRecord(recordType: "Recipe")
r["title"] = title as CKRecordValue
r["ingredients"] = ingredients as CKRecordValue
r["instructions"] = instructions as CKRecordValue
return r
}())
Task { await store.save(recipe, public: scopeIsPublic); dismiss() }
}.disabled(title.isEmpty)
}
}
}
}
}
struct RecipeDetailView: View {
let recipe: Recipe
var body: some View {
Form {
Section("Ingredients") { Text(recipe.ingredients) }
Section("Instructions") { Text(recipe.instructions) }
}
.navigationTitle(recipe.title)
}
}
Step 6 — verify
- Run on a simulator signed into iCloud. Create a recipe. Confirm it appears.
- Open the CloudKit Dashboard → your container → Records → choose the appropriate database. You should see the record.
- Run a second simulator (different scheme target or different model) signed into the same iCloud account for the private DB test, or any iCloud account for the public DB test. Confirm changes propagate (give the silent push a few seconds).
- Delete a recipe on Device A. Confirm it disappears on Device B.
Stretch
- Stretch 1 — delta sync: switch from full
fetchAlltoCKFetchRecordZoneChangesOperationwith persistedCKServerChangeToken. Massive speedup once the dataset grows. - Stretch 2 — assets: add a photo to each recipe via
CKAsset. Store local cache of fetched assets; don’t re-download on every refresh. - Stretch 3 — sharing: enable
CKSharefor collaborative editing of a private recipe with another iCloud user. Implement theapplication(_:userDidAcceptCloudKitShareWith:)flow. - Stretch 4 — account changes: handle
CKAccountChangednotification by clearing caches and re-bootstrapping when the user signs out/in.
Notes & gotchas
- Public database queries need indexes in Production. Sort and filter fields must be marked queryable in the dashboard. Development environment auto-indexes; Production does not.
- Silent pushes don’t deliver to apps that were force-quit. The user must launch the app at least once after install for push registration to take effect.
- The default zone in the private database doesn’t support
fetchRecordZoneChanges— you must use a custom zone (we created “Recipes”). The public database uses the default zone and a different sync strategy (last-modified queries). - Schema must be deployed to Production before App Store builds can use new record types or fields. Test on TestFlight with Production environment, not Development.
- Container identifier must match the bundle ID convention Apple expects:
iCloud.+ your bundle ID. Use a different one and you’ll burn an afternoon.
Next: Lab 6.3 — Production Network Layer
Lab 6.3 — Production Network Layer
Goal
Build a production-grade APIClient actor with: typed Endpoints, automatic 401 token refresh + retry, exponential backoff with jitter on transient failures, cursor-based pagination, and a two-tier (memory + disk) response cache. Then prove it works with a complete unit test suite backed by URLProtocol mocks — no real network required. This is the lab that takes you from “I’ve shipped network code” to “I’ve shipped network code I’d defend in a senior interview.”
Time
~3 hours minimum, easily 6 with the stretch goals.
Prerequisites
- Xcode 16+ (Swift 6, strict concurrency)
- Read Chapter 6.6, Chapter 6.7, Chapter 6.8
- A test API: use
https://reqres.inor your own. Examples below assumehttps://api.example.com.
Setup
- Create a Swift Package (File → New → Package) named
NetKit. We’re building a library, not an app, so we can unit-test cleanly. Package.swift:
// swift-tools-version: 6.0
import PackageDescription
let package = Package(
name: "NetKit",
platforms: [.iOS(.v17), .macOS(.v14)],
products: [.library(name: "NetKit", targets: ["NetKit"])],
targets: [
.target(name: "NetKit"),
.testTarget(name: "NetKitTests", dependencies: ["NetKit"])
]
)
- Enable strict concurrency in target settings:
swiftSettings: [.enableExperimentalFeature("StrictConcurrency")].
Build
Step 1 — Endpoint protocol
Endpoint.swift:
import Foundation
public protocol Endpoint: Sendable {
associatedtype Response: Decodable & Sendable
var method: HTTPMethod { get }
var path: String { get }
var query: [URLQueryItem] { get }
var body: Data? { get }
var requiresAuth: Bool { get }
}
public enum HTTPMethod: String, Sendable {
case GET, POST, PUT, PATCH, DELETE
}
public extension Endpoint {
var query: [URLQueryItem] { [] }
var body: Data? { nil }
var requiresAuth: Bool { true }
}
Step 2 — errors
APIError.swift:
public enum APIError: Error, Equatable {
case invalidURL
case transport(message: String)
case http(status: Int, body: Data?)
case decoding(message: String)
case unauthorized
case rateLimited(retryAfter: TimeInterval?)
case cancelled
}
Step 3 — token store
TokenStore.swift:
public actor TokenStore {
private var accessToken: String?
private var refreshToken: String?
public init(access: String? = nil, refresh: String? = nil) {
self.accessToken = access
self.refreshToken = refresh
}
public func current() -> String? { accessToken }
public func refresh() -> String? { refreshToken }
public func update(access: String?, refresh: String?) {
accessToken = access
refreshToken = refresh
}
public func clear() {
accessToken = nil
refreshToken = nil
}
}
Step 4 — the client actor
APIClient.swift:
import Foundation
public actor APIClient {
public struct Config: Sendable {
public var baseURL: URL
public var maxRetries: Int = 3
public var initialBackoff: TimeInterval = 0.4
public init(baseURL: URL) { self.baseURL = baseURL }
}
private let config: Config
private let session: URLSession
private let tokens: TokenStore
private let refresher: (@Sendable (String) async throws -> (access: String, refresh: String))?
private var inflightRefresh: Task<String, Error>?
public init(config: Config,
tokens: TokenStore,
session: URLSession = .shared,
refresher: (@Sendable (String) async throws -> (access: String, refresh: String))? = nil) {
self.config = config
self.session = session
self.tokens = tokens
self.refresher = refresher
}
public func send<E: Endpoint>(_ endpoint: E) async throws -> E.Response {
let data = try await perform(endpoint, isRetry: false, attempt: 0)
do {
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .iso8601
return try decoder.decode(E.Response.self, from: data)
} catch {
throw APIError.decoding(message: String(describing: error))
}
}
// MARK: - core
private func perform<E: Endpoint>(_ endpoint: E, isRetry: Bool, attempt: Int) async throws -> Data {
let request = try await buildRequest(endpoint)
do {
let (data, response) = try await session.data(for: request)
return try await handle(data: data, response: response, endpoint: endpoint, isRetry: isRetry, attempt: attempt)
} catch let urlError as URLError where urlError.code == .cancelled {
throw APIError.cancelled
} catch let urlError as URLError {
if attempt < config.maxRetries, urlError.shouldRetry {
try await backoff(attempt: attempt)
return try await perform(endpoint, isRetry: isRetry, attempt: attempt + 1)
}
throw APIError.transport(message: urlError.localizedDescription)
}
}
private func handle<E: Endpoint>(data: Data, response: URLResponse, endpoint: E,
isRetry: Bool, attempt: Int) async throws -> Data {
guard let http = response as? HTTPURLResponse else {
throw APIError.transport(message: "Non-HTTP response")
}
switch http.statusCode {
case 200..<300:
return data
case 401 where endpoint.requiresAuth && !isRetry:
try await refreshTokens()
return try await perform(endpoint, isRetry: true, attempt: attempt)
case 401:
await tokens.clear()
throw APIError.unauthorized
case 429:
let retryAfter = http.value(forHTTPHeaderField: "Retry-After").flatMap(TimeInterval.init)
if attempt < config.maxRetries {
try await Task.sleep(for: .seconds(retryAfter ?? backoffSeconds(attempt: attempt)))
return try await perform(endpoint, isRetry: isRetry, attempt: attempt + 1)
}
throw APIError.rateLimited(retryAfter: retryAfter)
case 500..<600 where attempt < config.maxRetries:
try await backoff(attempt: attempt)
return try await perform(endpoint, isRetry: isRetry, attempt: attempt + 1)
default:
throw APIError.http(status: http.statusCode, body: data)
}
}
private func buildRequest<E: Endpoint>(_ endpoint: E) async throws -> URLRequest {
var components = URLComponents(url: config.baseURL.appendingPathComponent(endpoint.path),
resolvingAgainstBaseURL: false)
if !endpoint.query.isEmpty { components?.queryItems = endpoint.query }
guard let url = components?.url else { throw APIError.invalidURL }
var request = URLRequest(url: url)
request.httpMethod = endpoint.method.rawValue
request.httpBody = endpoint.body
if endpoint.body != nil {
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
}
if endpoint.requiresAuth, let token = await tokens.current() {
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
}
return request
}
// MARK: - refresh coalescing
private func refreshTokens() async throws {
if let inflight = inflightRefresh {
_ = try await inflight.value
return
}
let task = Task<String, Error> {
guard let refreshToken = await tokens.refresh(),
let refresher else { throw APIError.unauthorized }
let pair = try await refresher(refreshToken)
await tokens.update(access: pair.access, refresh: pair.refresh)
return pair.access
}
inflightRefresh = task
defer { inflightRefresh = nil }
_ = try await task.value
}
// MARK: - backoff
private func backoff(attempt: Int) async throws {
try await Task.sleep(for: .seconds(backoffSeconds(attempt: attempt)))
}
private func backoffSeconds(attempt: Int) -> TimeInterval {
let exp = pow(2.0, Double(attempt))
let jitter = Double.random(in: 0...0.5)
return config.initialBackoff * exp + jitter
}
}
private extension URLError {
var shouldRetry: Bool {
switch code {
case .timedOut, .networkConnectionLost, .notConnectedToInternet,
.dnsLookupFailed, .cannotConnectToHost: return true
default: return false
}
}
}
Step 5 — cursor pagination helper
public struct Page<Item: Decodable & Sendable>: Decodable, Sendable {
public let items: [Item]
public let nextCursor: String?
}
public extension APIClient {
func paginate<E: Endpoint>(_ build: @Sendable (String?) -> E) -> AsyncThrowingStream<E.Response, Error>
where E.Response: PageProtocol {
AsyncThrowingStream { continuation in
let task = Task {
var cursor: String? = nil
repeat {
do {
let page = try await send(build(cursor))
continuation.yield(page)
cursor = page.nextCursor
} catch {
continuation.finish(throwing: error); return
}
} while cursor != nil
continuation.finish()
}
continuation.onTermination = { _ in task.cancel() }
}
}
}
public protocol PageProtocol: Sendable {
associatedtype Item
var items: [Item] { get }
var nextCursor: String? { get }
}
extension Page: PageProtocol {}
Step 6 — define endpoints
Endpoints/Users.swift:
public struct User: Decodable, Sendable, Hashable {
public let id: Int
public let email: String
public let firstName: String
public let lastName: String
enum CodingKeys: String, CodingKey {
case id, email
case firstName = "first_name"
case lastName = "last_name"
}
}
public struct ListUsers: Endpoint {
public typealias Response = Page<User>
public var method: HTTPMethod = .GET
public var path = "/api/users"
public var query: [URLQueryItem]
public var requiresAuth = false
public init(cursor: String? = nil, pageSize: Int = 25) {
var q = [URLQueryItem(name: "per_page", value: String(pageSize))]
if let cursor { q.append(URLQueryItem(name: "page", value: cursor)) }
self.query = q
}
}
Step 7 — URLProtocol mock
Tests/NetKitTests/MockURLProtocol.swift:
import Foundation
final class MockURLProtocol: URLProtocol, @unchecked Sendable {
nonisolated(unsafe) static var responder: ((URLRequest) throws -> (HTTPURLResponse, Data))?
override class func canInit(with request: URLRequest) -> Bool { true }
override class func canonicalRequest(for request: URLRequest) -> URLRequest { request }
override func startLoading() {
guard let responder = MockURLProtocol.responder else {
client?.urlProtocol(self, didFailWithError: URLError(.unknown)); return
}
do {
let (response, data) = try responder(request)
client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)
client?.urlProtocol(self, didLoad: data)
client?.urlProtocolDidFinishLoading(self)
} catch {
client?.urlProtocol(self, didFailWithError: error)
}
}
override func stopLoading() {}
}
func mockedSession() -> URLSession {
let config = URLSessionConfiguration.ephemeral
config.protocolClasses = [MockURLProtocol.self]
return URLSession(configuration: config)
}
Step 8 — tests
Tests/NetKitTests/APIClientTests.swift:
import XCTest
@testable import NetKit
final class APIClientTests: XCTestCase {
func client(refresher: (@Sendable (String) async throws -> (access: String, refresh: String))? = nil,
tokens: TokenStore = TokenStore(access: "a", refresh: "r")) -> APIClient {
let config = APIClient.Config(baseURL: URL(string: "https://api.example.com")!)
return APIClient(config: config, tokens: tokens, session: mockedSession(), refresher: refresher)
}
func testHappyPath_DecodesUsers() async throws {
let payload = """
{"items":[{"id":1,"email":"a@b.com","first_name":"A","last_name":"B"}],"nextCursor":null}
""".data(using: .utf8)!
MockURLProtocol.responder = { req in
let r = HTTPURLResponse(url: req.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!
return (r, payload)
}
let page = try await client().send(ListUsers())
XCTAssertEqual(page.items.first?.email, "a@b.com")
}
func test401_TriggersRefresh_ThenSucceeds() async throws {
actor Counter { var n = 0; func incr() -> Int { n += 1; return n } }
let counter = Counter()
MockURLProtocol.responder = { req in
let n = await counter.incr() // can't await here — see note below
_ = n
let auth = req.value(forHTTPHeaderField: "Authorization") ?? ""
if auth.contains("oldtoken") {
let r = HTTPURLResponse(url: req.url!, statusCode: 401, httpVersion: nil, headerFields: nil)!
return (r, Data())
} else {
let r = HTTPURLResponse(url: req.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!
return (r, "{\"items\":[],\"nextCursor\":null}".data(using: .utf8)!)
}
}
let tokens = TokenStore(access: "oldtoken", refresh: "rtok")
let api = client(refresher: { _ in ("newtoken", "rtok2") }, tokens: tokens)
let page = try await api.send(ListUsers())
XCTAssertEqual(page.items.count, 0)
let current = await tokens.current()
XCTAssertEqual(current, "newtoken")
}
func test5xx_RetriesWithBackoff() async throws {
actor Hits { var count = 0; func bump() -> Int { count += 1; return count } }
let hits = Hits()
MockURLProtocol.responder = { req in
// Synchronous; bump via a static counter for the test
return responseFor(req)
}
// (helper omitted for brevity — use a NSLock-protected static int)
_ = hits
}
}
Note: MockURLProtocol.responder is synchronous; for actor-backed counters use an NSLock-wrapped static Int. Refactor to taste.
Run the test suite (⌘U). All tests should pass without ever hitting the network.
Stretch
- Stretch 1 — wire the cache: add an actor
ResponseCache(use the two-tier pattern from Chapter 6.8) that the client consults forGETendpoints with a per-endpoint TTL. Add acacheTTLproperty to theEndpointprotocol with a default of zero (no cache). - Stretch 2 — Combine bridge: add
func publisher<E: Endpoint>(_ endpoint: E) -> AnyPublisher<E.Response, APIError>so consumers who prefer Combine can subscribe. - Stretch 3 — request signing: add support for HMAC-signed requests where a
Signeractor produces anX-Signatureheader from method + path + body. - Stretch 4 — multipart upload: build an
UploadEndpointvariant that sendsmultipart/form-dataand exposes upload progress via anAsyncStream<Progress>. - Stretch 5 — adopt in an app: wire
APIClientinto a SwiftUI app that browses a public API (e.g., GitHub repos), showing pagination + retry behavior in the simulator’s Network Link Conditioner under “Edge” and “Lossy” profiles.
Notes & gotchas
- Don’t
URLSession.sharedin production code if you also use it elsewhere — you’ll fight cookies, cache, and configuration. Make a dedicated session per network boundary. - The token refresh coalescing is the trickiest piece. If 10 requests fire and all see 401, you want one refresh and nine waiters, not ten refreshes. The
inflightRefreshTask<String, Error>?pattern above is the simplest correct shape; verify with a test that fires concurrent requests. Task.sleephonors cancellation. If the parent task is cancelled mid-backoff, the wait wakes up and re-throws. CatchCancellationErrorupstream if you want to swallow it.URLSessionretries some transport errors automatically (waiting for connectivity, etc.). Your retry layer should focus on the layer above transport — HTTP-level failures.- For real device testing, set up Network Link Conditioner (Settings → Developer) to simulate 3G, Edge, and Lossy Wifi. Your backoff and retry behavior is only as good as how you’ve tested it.
Next: Lab 7.1 placeholder (Phase 7 forthcoming)
7.1 — Push Notifications (APNs)
Opening scenario
The product team says: “We need to ping users when a friend messages them.” Sounds simple — until you realize there are three lifecycles (foreground, background, killed), four payload categories (alert, sound, badge, content-available), two delivery priorities, three extensions you might want (Service, Content, Notification), a per-app opt-in, a per-user revocation, and a server certificate or auth token that will expire on a holiday. Push is one of the densest topics in iOS — and most engineers learn it twice: once incorrectly, once correctly.
| Context | What it usually means |
|---|---|
| Reads “registers for remote notifications” | Has wired the basics |
| Reads “APNs token” | Knows there’s a device-side token |
| Reads “silent push” | Has shipped background refresh notifications |
Reads “UNNotificationServiceExtension” | Has decrypted/customized payloads |
| Reads “Time-Sensitive interruption level” | Has dealt with Focus and the modern delivery model |
Concept → Why → How → Code
Concept
Apple Push Notification service (APNs) is a TLS-based message bus between Apple’s servers and every Apple device. Your server sends a JSON payload addressed to a device token; APNs delivers it (or doesn’t — APNs is “best effort”); the OS displays/processes it according to the payload and the user’s notification settings.
Three actors:
- Provider (your server). Has an APNs auth key (
.p8) or certificate, the bundle ID, the device token, and the payload. - APNs (Apple). Routes by device token, throttles silent pushes, holds messages briefly when offline.
- Device (your app). Registers, receives the token, persists it (forward to your server), receives notifications.
Why
Without push: the only way your app processes external events is polling — which destroys battery and is laggy. With push: instant delivery, Focus-aware presentation, the ability to wake the app for ≤30s background work, the ability to update Live Activities and widgets via push.
How — registering and receiving the token
import UIKit
import UserNotifications
final class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCenterDelegate {
func application(_ app: UIApplication,
didFinishLaunchingWithOptions opts: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
UNUserNotificationCenter.current().delegate = self
Task { await requestAuthorization() }
return true
}
@MainActor
func requestAuthorization() async {
let center = UNUserNotificationCenter.current()
do {
let granted = try await center.requestAuthorization(
options: [.alert, .badge, .sound, .provisional]
)
guard granted else { return }
UIApplication.shared.registerForRemoteNotifications()
} catch {
print("Authorization error: \(error)")
}
}
func application(_ app: UIApplication,
didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
let token = deviceToken.map { String(format: "%02x", $0) }.joined()
Task { await TokenUploader.upload(token) }
}
func application(_ app: UIApplication,
didFailToRegisterForRemoteNotificationsWithError error: Error) {
print("APNs registration failed: \(error)")
}
}
.provisional is the modern trick: notifications arrive quietly to the notification center without ever interrupting the user, so you can deliver value before asking for full permission. After a few notifications, the user can tap “Keep” to upgrade to interruptive.
Foreground presentation
By default iOS suppresses the banner if your app is foregrounded. Override:
func userNotificationCenter(_ center: UNUserNotificationCenter,
willPresent notification: UNNotification) async
-> UNNotificationPresentationOptions {
return [.banner, .list, .sound, .badge]
}
Tap handling
func userNotificationCenter(_ center: UNUserNotificationCenter,
didReceive response: UNNotificationResponse) async {
let userInfo = response.notification.request.content.userInfo
if let convoID = userInfo["conversationId"] as? String {
await AppRouter.shared.openConversation(id: convoID)
}
}
Silent / background pushes
To wake your app for background work, the payload must include "content-available": 1 and the request must be sent with apns-priority: 5 (background) and apns-push-type: background:
{
"aps": {
"content-available": 1
},
"syncCursor": "abc-123"
}
In code:
func application(_ application: UIApplication,
didReceiveRemoteNotification userInfo: [AnyHashable: Any]) async
-> UIBackgroundFetchResult {
let cursor = userInfo["syncCursor"] as? String
await SyncEngine.shared.pull(from: cursor)
return .newData
}
APNs rate-limits silent pushes aggressively — typically 2–3 per hour per app when the device is on Low Power Mode. Don’t design a feature whose UX assumes silent push always arrives.
Rich notifications with UNNotificationServiceExtension
When you add a Notification Service Extension target, you get a 30-second window after delivery to modify the payload — decrypt end-to-end-encrypted text, download a thumbnail, fetch the message body, etc.
import UserNotifications
final class NotificationService: UNNotificationServiceExtension {
var contentHandler: ((UNNotificationContent) -> Void)?
var bestAttempt: UNMutableNotificationContent?
override func didReceive(_ request: UNNotificationRequest,
withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void) {
self.contentHandler = contentHandler
bestAttempt = (request.content.mutableCopy() as? UNMutableNotificationContent)
guard let bestAttempt else { return }
if let urlString = request.content.userInfo["image-url"] as? String,
let url = URL(string: urlString) {
Task {
if let attachment = await downloadAttachment(url) {
bestAttempt.attachments = [attachment]
}
contentHandler(bestAttempt)
}
} else {
contentHandler(bestAttempt)
}
}
override func serviceExtensionTimeWillExpire() {
if let contentHandler, let bestAttempt {
contentHandler(bestAttempt)
}
}
private func downloadAttachment(_ url: URL) async -> UNNotificationAttachment? {
guard let (data, _) = try? await URLSession.shared.data(from: url) else { return nil }
let tmp = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString + ".jpg")
try? data.write(to: tmp)
return try? UNNotificationAttachment(identifier: "image", url: tmp)
}
}
For the OS to invoke your extension, the payload must include "mutable-content": 1.
Interruption levels (iOS 15+)
{
"aps": {
"alert": { "title": "Garage door left open" },
"sound": "default",
"interruption-level": "time-sensitive"
}
}
Levels:
passive— silent landing in Notification Center.active(default) — normal.time-sensitive— pierces Focus modes if the user has allowed it.critical— pierces Do Not Disturb and Silent switch; requires special entitlement approved by Apple.
Provider-side: APNs auth key (modern)
Use a .p8 auth key, not certificates. One key works across all environments and your apps in the team:
# JWT (ES256) signed with your .p8 key
curl -v --http2 \
--header "authorization: bearer $JWT" \
--header "apns-topic: com.yourname.App" \
--header "apns-push-type: alert" \
--header "apns-priority: 10" \
--data '{"aps":{"alert":"Hello"}}' \
https://api.push.apple.com/3/device/$DEVICE_TOKEN
For sandbox (debug builds): api.sandbox.push.apple.com. Production builds (App Store, TestFlight) hit api.push.apple.com.
In the wild
- WhatsApp / Signal / iMessage — payload contains only
mutable-content: 1and an encrypted blob; the Service Extension decrypts and rewrites the alert before display. The OS never sees plaintext. - Slack — uses Notification Content Extensions for inline replies and previewing attachments.
- Uber Eats — Live Activities driven by APNs push; the same token plumbing, different
apns-push-type: liveactivity. - Tesla — silent pushes wake the app to refresh vehicle state when the user opens the widget.
- Apple Wallet — Wallet passes have their own push channel (
pkpasspush) using the same APNs infrastructure.
Common misconceptions
- “The token is permanent.” It changes after reinstall, after restoring from backup to a new device, after iOS major upgrades sometimes. Re-upload on every launch (cheap if the server idempotently dedupes).
- “Silent push is guaranteed.” It is budgeted. The OS may delay or drop them; Low Power Mode disables them entirely. Design for “eventually” not “instantly.”
- “
requestAuthorizationis a one-time call.” The user can revoke at any time. CallUNUserNotificationCenter.current().getNotificationSettings()on launch and reconcile. - “Production and sandbox tokens are interchangeable.” They aren’t. A token minted with an Xcode debug build is sandbox-only; sending it to the production APNs gateway returns 400 Bad Request.
- “I’ll just put the chat message in the alert body.” Compliance and privacy reviewers will not love this. End-to-end-encrypted apps must use the Service Extension pattern.
Seasoned engineer’s take
Push is half client, half server, and the server half is where 80% of the bugs live. The thing nobody tells you: invest in observability before you ship the feature. Log every push attempt with a correlation ID, the topic, the priority, the response from APNs (200 OK, 410 Gone, 429 TooManyRequests), and store it in your existing telemetry. When a user reports “I didn’t get the notification,” you need to answer “we sent it, APNs returned 200, your device is unreachable on Wi-Fi” — not “huh, weird.”
Two more things from a decade of pushes:
- Treat 410 Gone as “remove this token, don’t ever send again.” Devices that uninstall the app keep their tokens marked until you call again — and APNs charges your reputation when you spam.
- Build a push playground screen in your debug build that lets you fire any local payload to test rich content, custom sounds, interruption levels, and Live Activities without round-tripping to your server.
TIP: Use
apns-collapse-idto dedupe a stream of pushes for the same logical event (e.g., “new email” — only show the latest count). Saves your users from notification spam during sync storms.
WARNING: Never embed user data in the alert body sent through APNs in a region that requires data residency. APNs servers may transit through US infrastructure. The Service Extension pattern (encrypted blob, decrypt on device) is the only compliant approach for GDPR-strict apps.
Interview corner
Junior: “How does an iOS app receive a push notification?”
Request user permission via
UNUserNotificationCenter. If granted, callregisterForRemoteNotifications(). iOS returns a device token indidRegisterForRemoteNotificationsWithDeviceToken. Send that token to your server. Your server makes an authenticated HTTP/2 request to APNs with the token and a JSON payload. APNs delivers it to the device, which presents the notification according to the app’s foreground/background state and user settings.
Mid: “Walk me through implementing E2E-encrypted message notifications that show the decrypted message preview.”
Server sends a payload with
mutable-content: 1, the encrypted message as a custom field, and a generic placeholder title. Add aUNNotificationServiceExtensiontarget. IndidReceive, fetch the local decryption key (from Keychain shared via App Group), decrypt the message, setbestAttempt.titleandbody, callcontentHandler. ImplementserviceExtensionTimeWillExpireto flush a fallback. The OS never sees the plaintext; the network never carries it.
Senior: “Design the push notification reliability layer for a chat app at 100M MAU scale.”
Server side: sharded queue keyed by token; per-token rate limiter; HTTP/2 multiplexed connections to APNs (one connection handles thousands of requests); separate priority lanes for high-priority alerts vs background syncs; mandatory correlation IDs in payload for end-to-end tracing. Client side: every received notification reports a delivery receipt via a lightweight HTTP ping (or the next foreground sync) so server-side analytics can compute a real delivery rate. Token lifecycle: every cold launch re-uploads token; server treats 410 as a hard delete; APNs feedback periodically reconciles. Observability: per-token, per-day delivery rate dashboard, alert on regional drops (often Apple-side issues). Fallback: critical messages also push via SMS through a separate provider if no delivery receipt within 60s.
Red flag: “We send the full message in the payload because rich notifications need it.”
Demonstrates the candidate doesn’t know about
UNNotificationServiceExtensionand doesn’t think about privacy. Bonus red flag if they say “but the channel is encrypted” — APNs is encrypted in transit, but Apple’s servers see plaintext bodies. The Service Extension exists precisely for this.
Lab preview
Lab 7.2 — Widget extension wires APNs notifications into a Live Activity. You’ll set up the auth key, the curl command, and observe the activity update from a Terminal-driven push.
Next: 7.2 — WidgetKit
7.2 — WidgetKit
Opening scenario
The designer hands you a beautiful Lock Screen widget mock and says: “Just like the Apple Weather one.” You smile politely. Then you discover widgets aren’t tiny views — they’re a separate process, sandboxed from your app, that the OS snapshots into an image and shows. They can’t run code on their own. They can’t network freely. They get woken on a schedule the OS controls. Welcome to WidgetKit, the framework where you trade interactivity for residency on the user’s most precious screen real estate.
| Context | What it usually means |
|---|---|
| Reads “TimelineProvider” | Has shipped a basic widget |
| Reads “widget families” | Knows the size matrix |
| Reads “AppIntent configuration” | Has done iOS 17+ customization |
| Reads “Live Activity” | Has shipped one for Dynamic Island |
| Reads “interactive widget” | Knows what iOS 17 unlocked (and didn’t) |
Concept → Why → How → Code
Concept
A widget is a separate extension target that defines a SwiftUI view, a provider that produces timeline entries (data + dates), and a configuration. iOS:
- Asks your provider for a timeline (a list of “show this at this time” entries).
- Renders each entry to a static image at the entry’s date.
- Optionally re-asks for a new timeline later.
Widgets do not run continuously. The view body re-renders only when a timeline entry’s date triggers. There is no scrolling, no gestures (other than tap-to-deeplink, and from iOS 17 limited Button/Toggle via AppIntent).
Why
Widgets are the highest-value real estate Apple gives a third-party developer outside the app icon. Apple’s own widgets dominate the default Home Screen; a high-quality widget is a strong retention lever. From iOS 17 they’re also the foundation for StandBy and the Watch face complications.
How — a minimal widget
Add target: File → New → Target → Widget Extension. The template gives you a struct conforming to Widget.
import WidgetKit
import SwiftUI
struct StreakProvider: TimelineProvider {
typealias Entry = StreakEntry
func placeholder(in context: Context) -> StreakEntry {
StreakEntry(date: .now, days: 0)
}
func getSnapshot(in context: Context, completion: @escaping (StreakEntry) -> Void) {
completion(StreakEntry(date: .now, days: 42))
}
func getTimeline(in context: Context, completion: @escaping (Timeline<StreakEntry>) -> Void) {
let entries = (0..<6).map { offset in
let date = Calendar.current.date(byAdding: .hour, value: offset, to: .now)!
return StreakEntry(date: date, days: StreakStore.shared.currentStreak())
}
let timeline = Timeline(entries: entries, policy: .after(.now.addingTimeInterval(60 * 60 * 6)))
completion(timeline)
}
}
struct StreakEntry: TimelineEntry {
let date: Date
let days: Int
}
struct StreakWidgetView: View {
let entry: StreakEntry
var body: some View {
VStack {
Text("Streak").font(.caption).foregroundStyle(.secondary)
Text("\(entry.days)").font(.system(size: 48, weight: .bold, design: .rounded))
Text("days").font(.caption2)
}
.containerBackground(.fill.tertiary, for: .widget)
}
}
struct StreakWidget: Widget {
var body: some WidgetConfiguration {
StaticConfiguration(kind: "StreakWidget", provider: StreakProvider()) { entry in
StreakWidgetView(entry: entry)
}
.configurationDisplayName("Habit Streak")
.description("Shows your current habit streak.")
.supportedFamilies([.systemSmall, .systemMedium, .accessoryCircular, .accessoryRectangular])
}
}
@main
struct StreakBundle: WidgetBundle {
var body: some Widget { StreakWidget() }
}
Widget families
| Family | Use cases |
|---|---|
.systemSmall | Single stat, single action |
.systemMedium | Header + list, side-by-side |
.systemLarge | Mini feed, multiple stats |
.systemExtraLarge | iPad only |
.accessoryCircular | Lock Screen circle (iOS 16+) |
.accessoryRectangular | Lock Screen rectangle |
.accessoryInline | Text-only above the clock |
Shared data via App Group
The widget runs in a separate process. To share data with the host app, enable an App Group capability on both targets, then read/write via UserDefaults(suiteName:), a shared file, or a shared SwiftData store.
extension UserDefaults {
static let shared = UserDefaults(suiteName: "group.com.yourname.app")!
}
// In the app, after data changes:
UserDefaults.shared.set(currentStreak, forKey: "streak")
WidgetCenter.shared.reloadTimelines(ofKind: "StreakWidget")
WidgetCenter.reloadTimelines is how you tell the system: my data changed, please re-ask my provider.
AppIntentConfiguration (iOS 17+)
User-customizable widgets use an AppIntent for parameters:
import AppIntents
struct PickHabit: AppIntent, WidgetConfigurationIntent {
static var title: LocalizedStringResource = "Choose Habit"
@Parameter(title: "Habit") var habit: HabitEntity?
func perform() async throws -> some IntentResult { .result() }
}
struct ConfigurableStreakWidget: Widget {
var body: some WidgetConfiguration {
AppIntentConfiguration(kind: "ConfigStreak",
intent: PickHabit.self,
provider: ConfigurableStreakProvider()) { entry in
ConfigurableStreakView(entry: entry)
}
.supportedFamilies([.systemSmall])
}
}
HabitEntity conforms to AppEntity (covered in 7.9).
Interactive widgets (iOS 17+)
Button and Toggle in widget views can invoke an AppIntent — that’s the only interaction model.
struct LogToday: AppIntent {
static var title: LocalizedStringResource = "Log Habit"
@Parameter var habitID: String
func perform() async throws -> some IntentResult {
await HabitStore.shared.log(id: habitID, on: .now)
return .result()
}
}
struct InteractiveStreakView: View {
let entry: StreakEntry
var body: some View {
VStack(spacing: 8) {
Text("\(entry.days)").font(.title.bold())
Button(intent: LogToday(habitID: entry.habitID)) {
Label("Log today", systemImage: "checkmark.circle.fill")
}
}
.containerBackground(.fill.tertiary, for: .widget)
}
}
No gestures, no scrolling, no text fields — just buttons and toggles invoking intents.
Live Activities
Live Activities are widgets with a continuous “now” entry shown on the Lock Screen and the Dynamic Island.
import ActivityKit
struct DeliveryAttributes: ActivityAttributes {
public struct ContentState: Codable, Hashable {
var minutesAway: Int
var status: String
}
var orderNumber: String
}
// Starting from the host app:
let attributes = DeliveryAttributes(orderNumber: "A123")
let initialState = DeliveryAttributes.ContentState(minutesAway: 25, status: "Preparing")
let content = ActivityContent(state: initialState, staleDate: .now.addingTimeInterval(60 * 60))
let activity = try Activity.request(attributes: attributes,
content: content,
pushType: .token)
let pushToken = await activity.pushTokenUpdates.first(where: { _ in true })
Update via APNs with apns-push-type: liveactivity from your server, using the per-activity push token. The widget extension contains a LiveActivityConfiguration declaring small/medium/expanded layouts.
In the wild
- Apple Weather, Calendar, Reminders — the bar most teams compare against.
- Carrot Weather — power-user widget customization, every parameter exposed via
AppIntent. - Things 3 — clean Lock Screen complication showing today’s count; tap deep-links into the project.
- Uber Eats, DoorDash, Lyft — Live Activities for order/ride status.
- Spotify, Music — Live Activities (Now Playing) on the Dynamic Island.
Common misconceptions
- “My widget can use a Timer to update every second.” It cannot. Updates happen at timeline entry dates the system honors loosely; high frequency drains the widget budget and the OS will throttle you.
- “I’ll fetch from the network in
getTimeline.” You can, but you have a tight budget (a few seconds) and the system runs the provider sparingly. Prefer pre-computed data via App Group, refreshed by background tasks in the host app. - “Widgets can show animations.” Only the system-provided transition between entries. SwiftUI animations don’t run; what you draw is captured as a snapshot.
- “
reloadTimelinesupdates the widget immediately.” It schedules a reload. The OS may delay actual rendering. - “Interactive widgets are mini-apps now.” No.
ButtonandToggleinvoke anAppIntentand the widget re-snapshots. There’s still no input field, no scrolling, no live data.
Seasoned engineer’s take
Widgets are 80% data plumbing and 20% UI. The UI is constrained enough that designers get there quickly; the hard part is keeping the shared-data store fresh without burning battery or hitting the network on a schedule the OS hates. My usual architecture:
- Host app owns truth. Background refresh / push wakes the app, writes the latest payload to the App Group store, calls
WidgetCenter.reloadTimelines. - Widget reads, never writes. Provider reads from the App Group, returns a 4–8 hour timeline of pre-computed entries.
- Static data first, dynamic second. A widget that shows yesterday’s data is still useful; a widget that shows a spinner is broken.
Live Activities deserve their own paragraph: the engineering cost is real (server push infra, foreground/background state, stale-date semantics), the reward is enormous when shipped well. Don’t ship one unless you can commit to keeping the data fresh for the full lifecycle.
TIP: When debugging a widget, edit scheme → Run → Executable → choose your widget extension and pick “Ask on Launch.” This attaches the debugger to the widget process directly, with print statements you can read.
WARNING: Never put authentication tokens in the App Group store unencrypted. Other extensions in the same group (Share, Notification Service) can read them. Use Keychain with
kSecAttrAccessGroupinstead.
Interview corner
Junior: “What is a widget and how is it different from an app screen?”
A widget is an extension that exposes a small SwiftUI view to the Home/Lock Screen. The system renders it as a snapshot; it doesn’t run continuously. It receives data via a
TimelineProviderthat supplies dated entries, and updates only at those dates or when the host app callsWidgetCenter.reloadTimelines. Limited interaction in iOS 17+ viaAppIntent-bound buttons and toggles.
Mid: “Design a stock-price widget that updates as fresh as possible without draining battery.”
Provider returns a timeline of, say, 30 entries one minute apart. Hosting app runs
BGAppRefreshTaskto pull a recent price snapshot every 15 minutes; on update it writes to the App Group and callsreloadTimelines. For market-hours-only refresh, schedule the background task with policy. Outside market hours, return a single entry withTimeline(.after(marketOpenDate)). For paid-tier users with push entitlement, additionally subscribe to push-driven refreshes that wake the app and reload.
Senior: “Design Live Activities for a multi-leg flight tracker — boarding, takeoff, mid-flight, landing — across 8 hours.”
ActivityAttributes.ContentStatecarries the current leg, gate, ETA. Server pushes via APNs withapns-push-type: liveactivityper leg event;staleDateis set to the next expected event +30 min so the Lock Screen can dim if data goes silent. Dynamic Island compact, expanded, and minimal variants all show different fidelities of the same state — never lie about freshness; letstaleDatedo its job. End the activity from the host or from the final push (ActivityUIDismissalPolicy.after(.now + 5*60)). Cap concurrent activities (Apple’s limit) — if multiple flights, end the oldest. Watch for token rotation: pushTokenUpdates is a stream, must be persisted to your backend each time.
Red flag: “We refresh the widget every minute using a Timer.”
Demonstrates the candidate fundamentally misunderstands the widget process model. There is no continuous runtime; there is no Timer that fires while the widget is on-screen. The OS controls update cadence. This single sentence is enough to fail a widget-focused interview.
Lab preview
Lab 7.2 — Widget extension walks you through adding a WidgetKit extension to an existing app, wiring App Group data sharing, and implementing a Live Activity end-to-end with push token registration.
Next: 7.3 — WeatherKit
7.3 — WeatherKit
Opening scenario
The product team says: “Add the weather to the trip planner. Just like Dark Sky used to do.” You used to ping Dark Sky’s REST API for $0.0001 per call. Apple bought Dark Sky in 2020, deprecated the public API in March 2023, and rolled the data into WeatherKit — a Swift-native framework with 500,000 free calls per month per developer team, then $49.99/mo per million. Same forecast data, different ergonomics: type-safe, async/await, and bundled with proper Apple privacy controls.
| Context | What it usually means |
|---|---|
| Reads “WeatherService” | Knows the entry point |
| Reads “current/hourly/daily/alerts” | Has worked with the data shapes |
| Reads “WeatherKit entitlement” | Has wired the capability |
| Reads “attribution” | Knows Apple requires the badge |
| Reads “REST endpoint” | Has used the server-side WeatherKit too |
Concept → Why → How → Code
Concept
WeatherKit is a Swift-only async API over Apple’s weather data (formerly Dark Sky’s). One call returns a Weather object holding sub-objects: currentWeather, minuteForecast, hourlyForecast, dailyForecast, weatherAlerts, and availability. Request only the slices you need to reduce battery and quota usage.
Why
- Native Swift, no DTO boilerplate.
- Free tier generous enough for most apps.
- Privacy-respecting — no third-party data sharing.
- Server-side REST also available with the same auth key for cross-platform backends.
How — entitlement & first call
- Apple Developer portal → Identifiers → your App ID → enable WeatherKit.
- Project → Signing & Capabilities → + Capability → WeatherKit.
Info.plist→ no key required (no permission prompt — uses CLLocation permission you already requested).
import WeatherKit
import CoreLocation
let service = WeatherService.shared
let nyc = CLLocation(latitude: 40.7128, longitude: -74.0060)
let weather = try await service.weather(for: nyc)
print(weather.currentWeather.temperature.formatted(.measurement(width: .abbreviated)))
print(weather.dailyForecast.first?.condition.description ?? "")
weather.currentWeather.temperature is a Measurement<UnitTemperature> — formatting respects locale & units.
Requesting a subset
Save quota and latency:
let (current, hourly) = try await service.weather(
for: nyc,
including: .current, .hourly
)
The variadic including: returns a tuple matching the order of the keypaths. Each slice is independently typed:
.current→CurrentWeather.minute→Forecast<MinuteWeather>?(next hour; nil outside US).hourly→Forecast<HourWeather>.daily→Forecast<DayWeather>.alerts→[WeatherAlert]?.availability→WeatherAvailability
Wiring with CoreLocation
import Observation
@Observable
@MainActor
final class WeatherViewModel {
var summary: String = "—"
var error: String?
func refresh(for location: CLLocation) async {
do {
let weather = try await WeatherService.shared.weather(for: location)
let temp = weather.currentWeather.temperature
let condition = weather.currentWeather.condition.description
summary = "\(temp.formatted(.measurement(width: .abbreviated))) • \(condition)"
} catch {
self.error = error.localizedDescription
}
}
}
Use CLLocationManager (Chapter 7.4) to obtain the user’s CLLocation before calling.
Attribution (required)
Apple requires you to display attribution near the weather data:
let attribution = try await WeatherService.shared.attribution
AsyncImage(url: attribution.combinedMarkLightURL) { img in
img.resizable().scaledToFit()
} placeholder: { Color.clear }
.frame(height: 12)
Link("Other data sources", destination: attribution.legalPageURL)
.font(.caption2)
Skip this and Apple may revoke your entitlement. Apple checks during App Review.
Caching strategy
actor WeatherCache {
private var cache: [String: (Weather, Date)] = [:]
private let ttl: TimeInterval = 600 // 10 minutes
func weather(at location: CLLocation) async throws -> Weather {
let key = "\(Int(location.coordinate.latitude * 100)),\(Int(location.coordinate.longitude * 100))"
if let (cached, date) = cache[key], Date.now.timeIntervalSince(date) < ttl {
return cached
}
let fresh = try await WeatherService.shared.weather(for: location)
cache[key] = (fresh, .now)
return fresh
}
}
Round the coordinates to ~1km buckets so a slowly-walking user doesn’t burn quota.
Server-side REST
For backends (and non-Apple clients) Apple exposes WeatherKit as a REST API. Auth uses the same .p8 key flow as APNs: sign a JWT with your WeatherKitTeam key, send it as a Bearer token, hit https://weatherkit.apple.com/api/v1/weather/{lang}/{lat}/{lon}.
In the wild
- Apple Weather itself uses WeatherKit + the deprecated Dark Sky models.
- Carrot Weather, CARROT, Weather Strip — all migrated from Dark Sky to WeatherKit during the 2022–2023 transition.
- Strava uses WeatherKit on iOS to show conditions during a run.
- Hopper (travel) integrates WeatherKit for destination forecasts.
- Apple Watch uses the same framework via watchOS.
Common misconceptions
- “WeatherKit is a system app.” It’s a framework; you must add the capability and your app’s entitlement bundles a per-team auth key.
- “WeatherKit needs its own location permission.” It uses whatever
CLLocationyou pass in. You still needNSLocationWhenInUseUsageDescriptionfrom CoreLocation to get the location. - “500K calls is unlimited.” It’s per developer team, not per app. A studio with 10 apps shares the budget. Above the cap you’re billed (or throttled if no payment method).
- “
weatherAlertsworks everywhere.” Coverage is country-dependent (US/Canada/EU/UK/JP/AU/NZ). Always checkweather.availability.alertAvailability. - “WeatherKit can deliver via push when conditions change.” No. It’s pull-only. Build your own background-refresh-and-notify pipeline.
Seasoned engineer’s take
The most underrated thing about WeatherKit isn’t the data; it’s the types. After years of decoding JSON shapes like {"icon":"partly-cloudy-day","temperature":72.4} and writing your own enum WeatherIcon, getting weather.currentWeather.symbolName (an SF Symbol name) and weather.currentWeather.condition.description (localized) is a quiet joy. Lean into that — pass Measurement<UnitTemperature> and Date down to your views, don’t pre-convert. The formatters localize for you.
Two practical things:
- Bucket your locations before calling. Users who move 5m don’t need a new forecast. We’ve seen apps burn through 500K quota in a week by calling on every
CLLocationManagerupdate. - Cache aggressively (10–30 min) and prefer subset requests. A widget that needs
currentonly should not fetch hourly+daily+alerts.
TIP: Use
weather.currentWeather.symbolNameas theImage(systemName:)value — Apple maintains the mapping so conditions like “mostly cloudy at night” stay aligned with their visual symbol.
WARNING: WeatherKit’s
availabilityobject isn’t decorative. In several countries minute-by-minute precipitation isnil; in others, alerts return empty arrays despite active storms. Always checkavailabilitybefore promising features to your designer.
Interview corner
Junior: “How do you get the current temperature for a user’s location?”
Request
CLLocationWhenInUseUsageDescriptionand use CLLocationManager to get aCLLocation. Add the WeatherKit capability, then callWeatherService.shared.weather(for: location).currentWeather.temperature. Display attribution per Apple’s requirement.
Mid: “How do you minimize API quota use across an app that powers a widget, a Live Activity, and the main app?”
Centralize in a single shared actor backed by a coordinate-bucketed cache (~1km, 10-min TTL). The widget and main app share via an App Group — widget reads pre-computed data the host app populated. The Live Activity pulls minute-cast or hourly only as needed and updates via APNs from your own backend that uses the REST WeatherKit endpoint (also caches). Request only the slices you need with the variadic
including:API.
Senior: “Design a notification system that pings 5 million users when severe weather is forecast for their location.”
Don’t call WeatherKit per user every hour — that’s 120M calls/day, well above quota and absurdly wasteful. Build a server-side pipeline: cluster users by geohash (~5km), call the REST WeatherKit endpoint per cluster on a schedule, materialize alerts into a queue, fan out APNs pushes to users in affected clusters. Critical alerts use the
criticalinterruption level (with Apple-granted entitlement). On-device, the app subscribes via APNs and verifies alert relevance using last-known coords before showing. Audit: log per-cluster call counts, ensure quota isn’t exceeded. Cache: serve repeated reads of the same cluster from a Redis-style cache with a 10-min TTL.
Red flag: “We poll WeatherKit every 5 minutes from the app’s foreground.”
Burns battery, burns quota, and isn’t even fresh — the underlying data updates less often than that. Polling is almost never the right pattern for weather; either cache + on-demand, or push-driven from your backend.
Lab preview
Lab 7.1 — Weather + Map app builds a SwiftUI screen overlaying current conditions, hourly forecast, and active alerts on a MapKit canvas with location-following annotations.
Next: 7.4 — MapKit & CoreLocation
7.4 — MapKit & CoreLocation
Opening scenario
The PM wants a “Find My”-style screen: live user location, a few annotations, the ability to draw a route. You start with Map { ... } in SwiftUI and hit a wall the moment the brief grows: clustering 10,000 pins, drawing a polyline, switching to a custom tile source, getting reverse-geocoded street addresses for an annotation. Welcome to MapKit + CoreLocation — twin frameworks deep enough to fill a year, accessible enough that the basics fit in a single chapter.
| Context | What it usually means |
|---|---|
| Reads “Map in SwiftUI” | Has built simple location views |
| Reads “MKMapView” | Has used UIKit MapKit for advanced features |
| Reads “geocoding” | Has converted between coordinates and addresses |
| Reads “CLLocationManager” | Has wrangled the permission dance |
| Reads “region monitoring / CLVisit” | Has built location-aware background features |
Concept → Why → How → Code
Concept
- CoreLocation owns the device’s location: GPS, Wi-Fi/cell triangulation, region monitoring, visit detection, motion-based activity.
- MapKit displays maps and annotations. SwiftUI’s
Mapis a thin layer overMKMapView; for complex needs, drop back to UIKitMKMapViewviaUIViewRepresentable.
Why
Native MapKit is free, integrates with Apple Maps for navigation handoffs, respects user privacy, and offers richer features (3D buildings, Look Around, Maps Server API for search) than most third-party SDKs without leaking user behavior.
How — CoreLocation permission
Add to Info.plist:
NSLocationWhenInUseUsageDescription— required for foreground access.NSLocationAlwaysAndWhenInUseUsageDescription— required if you ever want background.NSLocationTemporaryUsageDescriptionDictionary— for precise-location prompts in iOS 14+.
import CoreLocation
@Observable
@MainActor
final class LocationManager: NSObject, CLLocationManagerDelegate {
private let manager = CLLocationManager()
var lastLocation: CLLocation?
var authorization: CLAuthorizationStatus = .notDetermined
override init() {
super.init()
manager.delegate = self
manager.desiredAccuracy = kCLLocationAccuracyHundredMeters
authorization = manager.authorizationStatus
}
func requestWhenInUse() { manager.requestWhenInUseAuthorization() }
func startUpdates() { manager.startUpdatingLocation() }
nonisolated func locationManagerDidChangeAuthorization(_ mgr: CLLocationManager) {
Task { @MainActor in
self.authorization = mgr.authorizationStatus
if self.authorization == .authorizedWhenInUse {
self.startUpdates()
}
}
}
nonisolated func locationManager(_ mgr: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
guard let location = locations.last else { return }
Task { @MainActor in self.lastLocation = location }
}
nonisolated func locationManager(_ mgr: CLLocationManager, didFailWithError error: Error) {
// Most failures are transient; log and ignore.
}
}
Precise vs reduced accuracy (iOS 14+)
If the user grants only “Approximate Location,” lastLocation is fuzzed to ~1km. For one-off precise reads:
manager.requestTemporaryFullAccuracyAuthorization(withPurposeKey: "PreciseForRouteSearch")
PreciseForRouteSearch must exist as a key in NSLocationTemporaryUsageDescriptionDictionary.
SwiftUI Map (iOS 17+)
import MapKit
import SwiftUI
struct StoresMap: View {
@State private var position: MapCameraPosition = .automatic
let stores: [Store]
let user: CLLocation?
var body: some View {
Map(position: $position) {
UserAnnotation()
ForEach(stores) { store in
Marker(store.name, systemImage: "cup.and.saucer", coordinate: store.coordinate)
.tint(.brown)
}
if let user, let nearest = stores.min(by: { $0.distance(to: user) < $1.distance(to: user) }) {
MapPolyline(coordinates: [user.coordinate, nearest.coordinate])
.stroke(.blue, lineWidth: 4)
}
}
.mapStyle(.standard(elevation: .realistic))
.mapControls {
MapUserLocationButton()
MapCompass()
MapPitchToggle()
}
.safeAreaInset(edge: .bottom) {
StoreListSheet(stores: stores)
}
}
}
MapContentBuilder (the closure DSL) supports Marker, Annotation (custom view), MapCircle, MapPolyline, MapPolygon.
Custom annotation view
Annotation("HQ", coordinate: CLLocationCoordinate2D(latitude: 37.33, longitude: -122.03)) {
VStack(spacing: 0) {
Image(systemName: "building.2.fill")
.padding(8)
.background(.tint.opacity(0.2), in: .circle)
Image(systemName: "arrowtriangle.down.fill")
.offset(y: -4)
}
}
Forward & reverse geocoding
let geocoder = CLGeocoder()
// Address → coordinate
let placemarks = try await geocoder.geocodeAddressString("1 Infinite Loop, Cupertino")
let coordinate = placemarks.first?.location?.coordinate
// Coordinate → address
let placemark = try await geocoder.reverseGeocodeLocation(location).first
let street = placemark?.thoroughfare ?? "—"
CLGeocoder is rate-limited by Apple (~one request per few seconds). For high-volume server-side geocoding, use Apple MapKit Server API or Google/Mapbox.
Region monitoring (geofencing)
let region = CLCircularRegion(
center: CLLocationCoordinate2D(latitude: 40.78, longitude: -73.97),
radius: 100,
identifier: "central-park-entrance"
)
region.notifyOnEntry = true
region.notifyOnExit = true
manager.startMonitoring(for: region)
// Delegate
func locationManager(_ mgr: CLLocationManager, didEnterRegion region: CLRegion) {
// Fired even if app is killed (subject to OS budget)
}
iOS caps at 20 monitored regions per app. For more, swap them in/out as the user moves.
Significant location changes (battery-friendly background)
manager.startMonitoringSignificantLocationChanges()
Wakes the app every ~500m of movement. Far cheaper than continuous updates; perfect for crash-resilient location loggers.
CLVisit
manager.startMonitoringVisits()
func locationManager(_ mgr: CLLocationManager, didVisit visit: CLVisit) {
// Triggered when the user arrives/departs from a "significant" place
}
The OS does the inference: dwell time + radius. Lifelogging apps love this.
In the wild
- Apple Maps, Find My, Reminders (location-based) — all use MapKit + CoreLocation.
- Yelp, OpenTable — MapKit for restaurant pins, often layered with custom annotations.
- Strava, Nike Run Club — CoreLocation continuous high-accuracy in foreground, polyline rendering on MapKit.
- Citymapper — uses MapKit basemap with overlay routing computed server-side.
- Find My Friends — region monitoring + significant change for stalker-free location sharing.
Common misconceptions
- “
requestAlwaysAuthorizationworks immediately.” Apple now shows the “Always” prompt only after the user has been on “When In Use” for a while. Plan a two-step UX. - “Background updates work as long as my background mode is on.” iOS will kill your app eventually if it sits in the background only for location. Use significant-change or region monitoring for long-running needs.
- “
CLGeocodercan handle bulk addresses.” It’s rate-limited and intended for occasional UI use. Bulk geocoding belongs on a server. - “MapKit needs an API key.” It doesn’t on iOS. The server-side MapKit JS and MapKit Server API require a JWT, but the iOS framework is free with your Apple Developer Program membership.
- “
Map { }in SwiftUI can do everythingMKMapViewdoes.” SwiftUI’s map gets closer each year but still lacks fine-grained gestures, custom tile overlays, and some legacy delegate hooks. Drop toUIViewRepresentablefor those.
Seasoned engineer’s take
Location is the place teams overspend battery and underspend privacy review. The two best instincts:
- Always ask the question: “What’s the minimum accuracy and frequency that satisfies the feature?” A delivery app’s “ETA updating” feature does not need 1-meter precision every second; reduced accuracy + significant location changes are usually enough.
- The user is right when they grant “Approximate.” Don’t keep nagging for “Precise.” Build the feature to be useful with approximate; offer a one-tap temporary-precise upgrade for the moments that demand it (e.g., turning navigation).
On MapKit specifically: prefer Marker for known SF Symbols, drop to Annotation for custom UI, and don’t try to render 10K annotations in SwiftUI — MKMapView with MKClusterAnnotation still wins at scale.
TIP: Set
manager.pausesLocationUpdatesAutomatically = trueandmanager.activityType = .fitness(or your app’s activity) to let iOS smartly pause updates when the user is stationary. Apps that override these defaults blindly are why battery icons turn yellow.
WARNING: Never log raw CLLocation values to a third-party analytics service. Always bucket to ~1km or coarser. Apple’s privacy reviewers and your DPO will both care.
Interview corner
Junior: “How do you ask for location permission and read the current location?”
Add the
NSLocationWhenInUseUsageDescriptionInfo.plist key, create aCLLocationManager, set its delegate, callrequestWhenInUseAuthorization(), and start updates afterlocationManagerDidChangeAuthorizationconfirms authorized. Readlocations.lastindidUpdateLocations.
Mid: “How would you implement a low-battery geofencing feature for 50 stores in a city?”
iOS caps
startMonitoring(for:)at 20 regions per app. Compute the 20 closest stores to the user’s current significant-change region, monitor those. WhendidExitRegionfires, re-rank and update the monitored set. UsestartMonitoringSignificantLocationChangesinstead of continuous updates so the app wakes every ~500m, not every second.
Senior: “Design the location pipeline for a ride-sharing driver app where the server needs the driver’s position every 5 seconds with sub-10-meter accuracy.”
Foreground, high-accuracy CLLocationManager updates with
desiredAccuracy = kCLLocationAccuracyBest,distanceFilter = kCLDistanceFilterNone. Background: enable Location background mode, useallowsBackgroundLocationUpdates = true. Wrap updates in an actor that batches and sends every 5s rather than every update; gzip the payload. Heartbeat with a server keep-alive over WebSocket; on disconnect, queue updates locally for 60s then drop oldest. Battery hedge: whenCLActivityManagerreports stationary for >2min, drop to significant-change to save power; on motion resume, return to high-accuracy. Privacy: rotate the analytics ID per shift; obfuscate the precise lat/lng of pickup/dropoff in any non-active-trip context.
Red flag: “We just keep continuous location updates running in the background to be safe.”
Battery, App Review rejection, and a privacy violation in one sentence. Demonstrates the candidate doesn’t understand iOS’s background execution budget or the user-facing battery and privacy implications.
Lab preview
Lab 7.1 — Weather + Map app wires this chapter together: user location + MapKit annotations + WeatherKit overlay. You’ll see exactly how CLLocationManager, Map, and WeatherService cooperate in a real screen.
Next: 7.5 — HealthKit
7.5 — HealthKit
Opening scenario
The fitness team says: “Pull the user’s heart rate, today’s steps, and last night’s sleep — and let them log a workout.” HealthKit makes that possible, but the data model is wider than the entire rest of iOS. Every metric has a type, a unit, a source, an authorization status (granular per type, per direction), a quantity vs category vs correlation vs workout shape, and — crucially — a privacy model where Apple actively hides whether the user denied reads from you, to prevent inference attacks. Welcome to HealthKit, the most privacy-paranoid framework in the SDK.
| Context | What it usually means |
|---|---|
| Reads “HKHealthStore” | Has done basic reads |
| Reads “quantity / category / workout” | Knows the type taxonomy |
| Reads “HKObserverQuery / background delivery” | Has shipped background syncing |
| Reads “HKWorkoutSession” | Has built a watchOS workout app |
| Reads “deny-state privacy model” | Understands the read-status quirk |
Concept → Why → How → Code
Concept
HKHealthStore is the single gateway to the Health database — a system-managed SQLite store on the device that’s encrypted, syncs end-to-end through iCloud to other devices, and is never shared with Apple servers. Your app requests read/write permission per type; the user can grant a subset or none.
Three data shapes:
- Quantity samples: numeric measurements with units (steps, heart rate, body mass).
- Category samples: enum-like states with optional duration (sleep analysis, menstruation, headache severity).
- Workouts: a special
HKWorkoutwith type, duration, energy, distance, and child samples.
Why
- Single source of truth the user already trusts.
- Cross-device sync for free via iCloud.
- Permission UX the user already understands from the Health app.
- Watch integration: HealthKit data flows seamlessly between iPhone and Apple Watch.
How — entitlement & setup
- Project → Signing & Capabilities → + Capability → HealthKit.
- Tick Clinical Health Records only if you read FHIR records (extra App Review).
Info.plist:NSHealthShareUsageDescription— what reads will be used for.NSHealthUpdateUsageDescription— what writes will be used for.
import HealthKit
actor HealthService {
let store = HKHealthStore()
func requestAuthorization() async throws {
guard HKHealthStore.isHealthDataAvailable() else {
throw HKError(.errorHealthDataUnavailable)
}
let read: Set = [
HKQuantityType(.heartRate),
HKQuantityType(.stepCount),
HKQuantityType(.activeEnergyBurned),
HKCategoryType(.sleepAnalysis),
HKObjectType.workoutType()
]
let write: Set = [
HKQuantityType(.bodyMass),
HKObjectType.workoutType()
]
try await store.requestAuthorization(toShare: write, read: read)
}
}
Reading quantities
extension HealthService {
func todaySteps() async throws -> Double {
let type = HKQuantityType(.stepCount)
let cal = Calendar.current
let start = cal.startOfDay(for: .now)
let predicate = HKQuery.predicateForSamples(withStart: start, end: .now)
return try await withCheckedThrowingContinuation { cont in
let query = HKStatisticsQuery(
quantityType: type,
quantitySamplePredicate: predicate,
options: .cumulativeSum
) { _, stats, error in
if let error { cont.resume(throwing: error); return }
let sum = stats?.sumQuantity()?.doubleValue(for: .count()) ?? 0
cont.resume(returning: sum)
}
store.execute(query)
}
}
}
Reading category samples (sleep)
func sleepLastNight() async throws -> TimeInterval {
let type = HKCategoryType(.sleepAnalysis)
let cal = Calendar.current
let now = Date.now
let start = cal.date(byAdding: .hour, value: -16, to: now)!
let predicate = HKQuery.predicateForSamples(withStart: start, end: now)
return try await withCheckedThrowingContinuation { cont in
let query = HKSampleQuery(sampleType: type, predicate: predicate,
limit: HKObjectQueryNoLimit, sortDescriptors: nil) { _, samples, error in
if let error { cont.resume(throwing: error); return }
let asleep = (samples as? [HKCategorySample] ?? [])
.filter { $0.value == HKCategoryValueSleepAnalysis.asleepCore.rawValue ||
$0.value == HKCategoryValueSleepAnalysis.asleepDeep.rawValue ||
$0.value == HKCategoryValueSleepAnalysis.asleepREM.rawValue }
.reduce(0.0) { $0 + $1.endDate.timeIntervalSince($1.startDate) }
cont.resume(returning: asleep)
}
store.execute(query)
}
}
Writing a workout
func saveWalkout(start: Date, end: Date, distanceMeters: Double, kcal: Double) async throws {
let workout = HKWorkout(
activityType: .walking,
start: start,
end: end,
duration: end.timeIntervalSince(start),
totalEnergyBurned: HKQuantity(unit: .kilocalorie(), doubleValue: kcal),
totalDistance: HKQuantity(unit: .meter(), doubleValue: distanceMeters),
metadata: nil
)
try await store.save(workout)
}
Background delivery
For “wake my app when new heart-rate data lands” semantics, combine HKObserverQuery with enableBackgroundDelivery:
func startWatchingHeartRate() async throws {
let type = HKQuantityType(.heartRate)
try await store.enableBackgroundDelivery(for: type, frequency: .immediate)
let observer = HKObserverQuery(sampleType: type, predicate: nil) { _, completion, error in
if let error { completion(); return }
Task {
// Re-query to get the new samples and process
completion()
}
}
store.execute(observer)
}
Even with .immediate, the OS coalesces; expect bursts every few minutes, not the millisecond a sample lands.
The deny-state quirk
When the user denies read access, HealthKit does not tell you. Queries simply return empty. This is intentional — if your app could see “denied” vs “no data,” it could infer the user has the metric but is hiding it. Workaround pattern: present the permission sheet, then always show data with a graceful “No data yet” state. Don’t try to detect a denial; provide a “Re-check permissions” button that re-runs requestAuthorization.
Statistics collections
For chart data (e.g., 7 days of step totals):
let type = HKQuantityType(.stepCount)
let cal = Calendar.current
let end = cal.startOfDay(for: .now)
let start = cal.date(byAdding: .day, value: -6, to: end)!
let interval = DateComponents(day: 1)
let query = HKStatisticsCollectionQuery(
quantityType: type,
quantitySamplePredicate: HKQuery.predicateForSamples(withStart: start, end: end),
options: .cumulativeSum,
anchorDate: end,
intervalComponents: interval
)
query.initialResultsHandler = { _, stats, _ in
stats?.enumerateStatistics(from: start, to: end) { stat, _ in
let day = stat.startDate
let count = stat.sumQuantity()?.doubleValue(for: .count()) ?? 0
// collect
}
}
store.execute(query)
In the wild
- Apple Fitness / Health — the reference apps.
- Strava, Nike Run Club, Peloton — write workouts, read heart rate during sessions.
- AutoSleep, Sleep Cycle — read/write
sleepAnalysiscategory samples. - MyFitnessPal — writes
dietaryEnergyConsumed, readsbodyMass. - Apollo Neuro, Calm — write
mindfulSessioncategory samples for meditation tracking.
Common misconceptions
- “
requestAuthorizationreturns whether the user granted each type.” It returns the user-prompt-completed signal for writes. For reads, you cannot know what was granted — see the deny-state quirk above. - “HealthKit syncs to iCloud Drive.” It syncs to the user’s iCloud Health backup (separate, end-to-end encrypted), not iCloud Drive. Your app cannot access another device’s Health database except through the same user’s HealthKit.
- “HealthKit works on iPad.” Limited support landed in iPadOS 17. Older iPads cannot host HealthKit; sample apps should guard with
HKHealthStore.isHealthDataAvailable(). - “I can show a hospital’s records via HealthKit.” Only via the Clinical Records (FHIR) API, which requires an additional entitlement and App Review explanation.
- “Background delivery wakes me instantly.” It wakes you when the OS feels like it. Don’t design UX assuming sub-second latency from sensor read to app notification.
Seasoned engineer’s take
HealthKit is the framework where you most need to read the user agreement before writing code. Apple is very specific: you may not sell HealthKit-derived data, use it for advertising, store it on your servers without explicit consent, or copy it to non-Apple cloud backups. App Review checks the privacy policy URL on every release; vague language gets you rejected. Plan the privacy story before the API call.
Engineering-wise:
- Treat HKHealthStore as I/O-bound and async-only. Wrap legacy callback queries in
withCheckedThrowingContinuationonce; never touch the callbacks again. - Cache aggressively. Health database queries can take 100ms+; for the same window users open repeatedly, cache the rolled-up number.
- Always handle the empty case. Because deny looks like no data, every chart needs a graceful “Allow Health access in Settings →” path.
TIP: Use
HKStatisticsCollectionQuerywith.cumulativeSumand a daily interval anchor to compute weekly/monthly summaries server-style on-device. It’s an order of magnitude faster thanHKSampleQuery+ map-reducing in Swift.
WARNING: Never write to types your app doesn’t own conceptually. Writing fake heart-rate samples to “make charts look populated” pollutes the user’s permanent health record across all apps. App Review rejects this on sight.
Interview corner
Junior: “How do you get today’s step count?”
Request read authorization for
HKQuantityType(.stepCount), runHKStatisticsQuerywith a predicate fromstartOfDayto now, options.cumulativeSum. Readstats.sumQuantity()?.doubleValue(for: .count()).
Mid: “How do you get notified when new heart-rate samples arrive while the app is in the background?”
Enable background delivery via
enableBackgroundDelivery(for:type, frequency: .immediate). Register anHKObserverQuerywith completion handler; the OS wakes the app and calls it when new samples land. In the handler, run a follow-upHKSampleQueryfrom the last seen anchor to get the new data, persist, and call the completion. Background delivery requires the HealthKit background mode entitlement.
Senior: “Design a sleep coach app that reads sleep stages, writes mindfulness session correlations, and respects the user’s permission denials gracefully.”
Two phases. (1) Permission UX: show a single sheet listing what you read (sleep) and write (mindful sessions) with one-sentence justifications; on dismiss, you cannot know what was granted, so render a “Connect Health” state if today’s query returns empty. Provide a “Re-check” button that re-runs
requestAuthorization(cheap if already granted, prompts if not). (2) Data layer: actor-wrapped HKHealthStore, anchored queries viaHKAnchoredObjectQueryfor incremental sync, statistics-collection queries for chart data. On wakeup from observer, batch-process new samples and write a correlatedmindfulSessionif the sleep window matches a logged meditation. Persist the anchor per type in Keychain (not UserDefaults — survives migrations). Privacy: never POST raw samples to a server without an explicit “Sync to coach” opt-in; if synced, only aggregate scores, not raw timestamps.
Red flag: “We check if authorizationStatus(for:) returns .sharingDenied for reads, and if so, show an error.”
That call returns the write status. For reads, Apple intentionally returns
.notDeterminedeven after a denial. Demonstrates the candidate hasn’t read Apple’s privacy documentation and will ship a broken UX that “works in dev” because the dev granted everything.
Lab preview
Health is foundational background reading for the broader ecosystem; the Lab 7.2 — Widget extension optionally includes a stretch goal that surfaces step count from HealthKit on the widget via App Group caching.
Next: 7.6 — StoreKit 2
7.6 — StoreKit 2
Opening scenario
The CEO says: “Add a Pro tier with a monthly and annual subscription, plus a one-time ‘remove ads’ purchase, plus 100-coin and 500-coin consumables. Oh, and Family Sharing, restore-purchases, free trials, and a ‘Cancel anytime’ link.” Five years ago this required SKPaymentQueue, SKPaymentTransactionObserver, server-side receipt validation, base64-encoded payloads, and a 600-line file you copied from an Apple sample project. StoreKit 2 (iOS 15+) replaces all of it with async/await, JSON Web Signature transactions verified on-device, and a single coherent API.
| Context | What it usually means |
|---|---|
| Reads “Product.products(for:)” | Has done basic fetches |
| Reads “Transaction.currentEntitlements” | Knows the receipt-less model |
| Reads “subscription status” | Has built tier-aware UI |
| Reads “App Store Server API” | Has a backend that knows about transactions |
| Reads “App Store Server Notifications V2” | Has handled webhook lifecycle events |
Concept → Why → How → Code
Concept
StoreKit 2 is built on Swift concurrency. Four types you’ll touch:
Product— a SKU you defined in App Store Connect, fetched async by ID.Transaction— a purchase event with a cryptographic signature; verify on-device.Product.SubscriptionInfo.Status— current subscription state (subscribed, in grace, expired, in billing retry).AppStore.sync()— refresh the device’s transaction cache from Apple (rarely needed; iOS does this automatically).
There is no more receipt blob. Instead, each Transaction is a JWS payload your code verifies (Apple’s public key is embedded), and the API exposes the current set of valid entitlements at any moment.
Why
- Async-native — no delegates, no queues, no observer threading bugs.
- On-device verification — no server required to grant entitlements for basic apps.
- App Store Server API + V2 notifications — for server-aware apps (fraud, refunds, cross-platform unlock), the server layer is also JSON-based.
- One library, all platforms — iOS, macOS, watchOS, tvOS, visionOS, Catalyst.
How — products & purchase
import StoreKit
enum ProductID: String, CaseIterable {
case removeAds = "com.example.app.removeAds"
case coins100 = "com.example.app.coins100"
case coins500 = "com.example.app.coins500"
case proMonthly = "com.example.app.pro.monthly"
case proYearly = "com.example.app.pro.yearly"
}
@Observable
@MainActor
final class Store {
var products: [Product] = []
var ownedProductIDs: Set<String> = []
var subscriptionStatus: Product.SubscriptionInfo.Status?
private var updateListener: Task<Void, Never>?
init() {
updateListener = listenForTransactions()
Task { await loadProducts(); await refreshEntitlements() }
}
deinit { updateListener?.cancel() }
func loadProducts() async {
do {
products = try await Product.products(for: ProductID.allCases.map(\.rawValue))
} catch {
print("Failed to load products: \(error)")
}
}
func purchase(_ product: Product) async throws -> Transaction? {
let result = try await product.purchase()
switch result {
case .success(let verification):
let transaction = try verify(verification)
await refreshEntitlements()
await transaction.finish()
return transaction
case .userCancelled, .pending: return nil
@unknown default: return nil
}
}
private func verify<T>(_ verification: VerificationResult<T>) throws -> T {
switch verification {
case .unverified(_, let error): throw error
case .verified(let value): return value
}
}
}
Entitlements — the modern “what is the user allowed to do”
extension Store {
func refreshEntitlements() async {
var owned: Set<String> = []
for await result in Transaction.currentEntitlements {
guard case let .verified(transaction) = result else { continue }
owned.insert(transaction.productID)
}
ownedProductIDs = owned
// Subscription status (any group)
if let sub = products.first(where: { $0.subscription != nil })?.subscription {
let statuses = try? await sub.status
subscriptionStatus = statuses?.first
}
}
}
Transaction.currentEntitlements is the source of truth. Don’t store “user is pro” in UserDefaults and trust it; recompute from entitlements on launch and every transaction event.
Listen for store-driven transactions
extension Store {
func listenForTransactions() -> Task<Void, Never> {
Task.detached(priority: .background) {
for await result in Transaction.updates {
guard case let .verified(transaction) = result else { continue }
await self.refreshEntitlements()
await transaction.finish()
}
}
}
}
This catches: family-sharing grants, parental purchase approvals, server-side promotional offers, refunds, billing retries — events that didn’t originate from a product.purchase() call.
Subscription status
if let status = subscriptionStatus {
switch status.state {
case .subscribed:
// Active
case .expired:
// Past due — show paywall again
case .inGracePeriod:
// Payment failed but user still has access; nudge to update payment method
case .inBillingRetryPeriod:
// Payment failing; show recovery banner
case .revoked:
// Apple refunded the user; revoke access
default: break
}
}
status.renewalInfo (also a VerificationResult) tells you whether auto-renew is on, the next renewal product, whether the user is in a promotional offer, etc.
Restore purchases
In StoreKit 2 there’s no special “restore” call — Transaction.currentEntitlements already reflects what the user owns across devices via their Apple ID. Provide a button anyway because users expect it; it can just call await store.refreshEntitlements().
If you suspect the local cache is stale (e.g., user switched Apple IDs):
try await AppStore.sync()
This triggers a sign-in prompt; reserve for “Restore Purchases” button taps.
Promotional offers & introductory offers
Defined in App Store Connect; surfaced via:
if let subscription = product.subscription {
if let intro = subscription.introductoryOffer {
Text("Free trial: \(intro.period.formatted())")
}
for offer in subscription.promotionalOffers {
// Signed server-side, attached at purchase time
let signedOffer = try await server.signPromotionalOffer(productID: product.id,
offerID: offer.id)
try await product.purchase(options: [.promotionalOffer(offerID: offer.id, signature: signedOffer)])
}
}
Promotional offers require server-side signing with your .p8 key; the API surface here just attaches the signed offer to the purchase call.
Server-side: App Store Server API & Notifications V2
For fraud detection, cross-platform entitlement, server-side unlock:
- App Store Server API — REST. Given a transaction ID or original transaction ID, fetch all transactions, subscription history, refund history.
- App Store Server Notifications V2 — Apple POSTs JWS-signed JSON to your webhook for
SUBSCRIBED,DID_RENEW,DID_FAIL_TO_RENEW,EXPIRED,REFUND,CONSUMPTION_REQUEST, etc. Verify the JWS chain, update your DB, optionally push the user.
Testing
- StoreKit Configuration File (Xcode → File → New → File → StoreKit Configuration) — local mock products. Runs in the simulator, no Apple Developer account needed for the basics.
- Sandbox testers in App Store Connect → Users & Access → Sandbox.
- Subscriptions in sandbox renew accelerated — 1 month becomes 5 minutes; 1 year becomes 1 hour. Plan your test sessions accordingly.
In the wild
- Bear, Things 3, Day One — pure StoreKit 2 with one-time unlock or subscription tiers.
- Spotify (Reader app), Netflix — do not use StoreKit for subscription signup (reader app exemption — sign up on web, log in in app).
- Calm, Headspace — full StoreKit 2 subscriptions with intro offers, promotional offers, win-back campaigns.
- Duolingo Super — StoreKit with aggressive yearly conversion paywall.
- Procreate — single non-consumable IAP, the simplest StoreKit story.
Common misconceptions
- “I need to validate receipts on my server.” Not for entitlement gating.
Transaction.currentEntitlementsis signed and verified on-device. Server validation matters for fraud detection, cross-platform unlock, and revenue attribution — not for “is this user pro right now.” - “
product.purchase()returns the transaction immediately.” It returns aPurchaseResult..successcarries aVerificationResult<Transaction>you must verify. Always also handle.userCancelledand.pending(Ask to Buy). - “Subscriptions auto-renew without my app running.” They do, but you don’t see the new transaction until the user opens the app or you fetch it via the App Store Server API. Build server-side awareness for billing-cycle-driven UX.
- “
AppStore.sync()should run on every launch.” It triggers a sign-in prompt. Reserve for explicit Restore Purchases taps. - “I can use StoreKit for tipping.” Tips that grant no entitlement use a special Tip Jar category of non-consumables. Selling tangible goods or external services with IAP is forbidden — use Stripe/PayPal.
Seasoned engineer’s take
StoreKit 2 is good. Annoyingly good. After a decade of receipt-validation hell, having a typed Swift API that just says “here are the user’s current entitlements” feels like cheating. Lean in:
- Build a single
Storeactor/observable and inject it everywhere. Don’t sprinkle StoreKit calls across view models. One owner, one source of truth. - Always re-derive entitlements; never cache “is pro” as a boolean. A refund or family-sharing change must be visible within seconds of the next app launch.
- Test the paywall on a real device with sandbox. Simulator + StoreKit Config catches the happy path; real sandbox catches the “Ask to Buy”, “billing retry”, and “tax dialog” flows.
For subscriptions specifically, the unlock UX is more important than the purchase UX. When the user’s payment fails and they enter billing retry, your app should show a calm “Your subscription is about to expire — update payment method” banner, not silently revoke access. This costs 50 lines of UI and saves thousands in churn.
TIP: If your app sells across iOS, macOS, and a web tier, use RevenueCat (covered in Chapter 11.5). It wraps StoreKit 2 + Google Play + Stripe with a single entitlement abstraction and removes the cross-platform “is this user entitled?” headache.
WARNING: Never extend access beyond what
Transaction.currentEntitlementsreports. “But the user paid us yesterday” doesn’t survive an App Review audit; if Apple’s source of truth says expired, your app must reflect expired. Server-side overrides for legitimate edge cases must be auditable.
Interview corner
Junior: “How do you sell a non-consumable IAP and know the user owns it?”
Define the product in App Store Connect. Load it with
Product.products(for: ["productID"]). On user tap, callawait product.purchase(). Verify the result, finish the transaction. On every launch, iterateTransaction.currentEntitlementsand check if your product ID is present.
Mid: “How do you handle a subscription that lapses while the app is in the background?”
Subscribe to
Transaction.updatesfrom app launch in a long-livedTask. When Apple delivers a renewal event (or expiry, or refund) the iterator yields aVerificationResult<Transaction>. Verify, re-fetchTransaction.currentEntitlements, finish the transaction, update UI. For server-side awareness (e.g., to send win-back email), wire App Store Server Notifications V2 to a webhook that updates your DB and triggers the campaign.
Senior: “Design a cross-platform subscription system: iOS, web (Stripe), Android. User pays once on any platform, gets access everywhere.”
Single source of truth is your server’s “entitlements” table keyed by user ID. iOS: StoreKit 2 transaction events flow to your backend via App Store Server Notifications V2 — server creates/updates the entitlement. Stripe webhook does the same for web. Google Play Developer API + Real-Time Developer Notifications for Android. iOS client fetches entitlements from your server on launch (in addition to
Transaction.currentEntitlementsfor offline grace), and treats server-granted entitlements as primary. Apple compliance: do not advertise web/Android purchases inside the iOS app (anti-steering) — but the unlock works once the user signs in. Sign in with Apple is convenient as the cross-platform identity layer. For UX during signup, optionally use RevenueCat to wrap all three stores behind one identifier.
Red flag: “We cache isPro = true in UserDefaults after purchase and trust it forever.”
Two failures: it doesn’t refresh after refund/family-sharing changes, and
UserDefaultsisn’t tamper-proof — a jailbroken device can flip the bit. Server-validated entitlement orTransaction.currentEntitlementschecked at every relevant boundary is the standard.
Lab preview
Lab 7.3 — StoreKit 2 IAP builds a complete paywall: non-consumable + monthly/yearly subscription, intro offer, restore-purchases button, sandbox-tested across the full lifecycle.
Next: 7.7 — ARKit basics
7.7 — ARKit basics
Opening scenario
A retail client wants an “place this couch in your living room” feature. You open Xcode, see ARKit, RealityKit, SceneKit, Reality Composer Pro, USDZ, QuickLook — and wonder which one you’re supposed to use. The answer: in 2026, RealityKit on top of ARKit for new code, with QuickLook + USDZ for any “view this product in AR” feature that doesn’t need custom interaction. SceneKit still ships but is in maintenance; SpriteKit is unrelated (2D). This chapter is the just-enough-to-be-dangerous tour.
| Context | What it usually means |
|---|---|
| Reads “ARView / RealityView” | Has placed objects in AR |
| Reads “plane detection” | Knows the world-tracking basics |
| Reads “anchors / entities” | Has structured a scene |
| Reads “occlusion / people occlusion” | Has worked on realism |
| Reads “image / object tracking” | Has built marker-based AR |
Concept → Why → How → Code
Concept
- ARKit — the sensor + tracking layer. Owns the camera feed, world tracking, plane/image/object/face/body detection, LiDAR depth, ARCoachingOverlay.
- RealityKit — the rendering + simulation layer. PBR materials, physics, audio, ECS-style
Entitymodel. - RealityView (SwiftUI, iOS 18+) — modern SwiftUI host. Older code uses
ARView(UIKit). - Reality Composer Pro — visual scene editor; outputs
.realityand USDZ bundles. - QuickLook + USDZ — drop a USDZ file into a SwiftUI view (
ARQuickLookView) and Apple handles AR-place, scale, rotate, share. Zero custom code.
Why
ARKit is the only way to use the device’s full sensor fusion (camera, gyro, IMU, LiDAR) for AR. RealityKit gives you a modern, Swift-native scene graph with PBR rendering that visually matches Apple’s reference apps without authoring an OpenGL/Metal renderer.
How — capabilities & Info.plist
NSCameraUsageDescription = "AR features require the camera to overlay 3D content on your view."
ARKit availability check:
import ARKit
guard ARWorldTrackingConfiguration.isSupported else {
// Older device — fall back to 2D or QuickLook USDZ
return
}
Simplest AR — QuickLook with a USDZ
import QuickLook
import SwiftUI
struct ARQuickLookView: UIViewControllerRepresentable {
let url: URL // path to a .usdz file
func makeUIViewController(context: Context) -> QLPreviewController {
let controller = QLPreviewController()
controller.dataSource = context.coordinator
return controller
}
func updateUIViewController(_ controller: QLPreviewController, context: Context) {}
func makeCoordinator() -> Coordinator { Coordinator(url: url) }
final class Coordinator: NSObject, QLPreviewControllerDataSource {
let url: URL
init(url: URL) { self.url = url }
func numberOfPreviewItems(in c: QLPreviewController) -> Int { 1 }
func previewController(_ c: QLPreviewController, previewItemAt index: Int) -> QLPreviewItem {
url as QLPreviewItem
}
}
}
Tap the AR button, the user gets free plane detection, scale, rotate, occlusion, share. 90% of e-commerce “view in your room” features should stop here.
Custom AR with RealityView (iOS 18+)
import RealityKit
import ARKit
import SwiftUI
struct PlaceCubeView: View {
var body: some View {
RealityView { content in
// One-time setup
let arConfig = ARWorldTrackingConfiguration()
arConfig.planeDetection = [.horizontal]
arConfig.environmentTexturing = .automatic
content.camera = .spatialTracking
// Anchor will attach to first detected horizontal plane
let anchor = AnchorEntity(plane: .horizontal, classification: .floor, minimumBounds: [0.2, 0.2])
let mesh = MeshResource.generateBox(size: 0.1)
let material = SimpleMaterial(color: .systemTeal, isMetallic: false)
let model = ModelEntity(mesh: mesh, materials: [material])
anchor.addChild(model)
content.add(anchor)
}
.ignoresSafeArea()
}
}
Older path — ARView (UIKit, still common in 2026 codebases)
import ARKit
import RealityKit
final class ARSceneController: UIViewController {
private let arView = ARView(frame: .zero)
override func viewDidLoad() {
super.viewDidLoad()
view.addSubview(arView)
arView.frame = view.bounds
arView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
let config = ARWorldTrackingConfiguration()
config.planeDetection = [.horizontal, .vertical]
config.frameSemantics = [.personSegmentationWithDepth] // people occlusion (A12+)
arView.session.run(config)
let coaching = ARCoachingOverlayView()
coaching.session = arView.session
coaching.goal = .horizontalPlane
coaching.frame = view.bounds
view.addSubview(coaching)
let tap = UITapGestureRecognizer(target: self, action: #selector(handleTap(_:)))
arView.addGestureRecognizer(tap)
}
@objc func handleTap(_ tap: UITapGestureRecognizer) {
let point = tap.location(in: arView)
guard let result = arView.raycast(from: point, allowing: .estimatedPlane, alignment: .horizontal).first else { return }
let anchor = AnchorEntity(world: result.worldTransform)
let box = ModelEntity(mesh: .generateBox(size: 0.1),
materials: [SimpleMaterial(color: .systemPink, isMetallic: false)])
box.generateCollisionShapes(recursive: true)
anchor.addChild(box)
arView.scene.addAnchor(anchor)
arView.installGestures([.translation, .rotation, .scale], for: box)
}
}
Image tracking — marker-based AR
let config = ARImageTrackingConfiguration()
guard let images = ARReferenceImage.referenceImages(inGroupNamed: "ARImages", bundle: nil) else { return }
config.trackingImages = images
config.maximumNumberOfTrackedImages = 4
arView.session.run(config)
Reference images live in Assets.xcassets → AR Resource Group, tagged with their physical size (the tracker needs the real-world width).
Object tracking & body / face tracking
ARObjectScanningConfiguration+ARReferenceObject— scan a 3D object once, then track in subsequent sessions.ARFaceTrackingConfiguration— front camera, requires A12+ Bionic. Powers Memoji, FaceTime filters.ARBodyTrackingConfiguration— full skeleton tracking, A12+.
LiDAR niceties (Pro iPhones, iPads)
config.sceneReconstruction = .meshWithClassification
config.frameSemantics.insert(.sceneDepth)
Gives you a real-time mesh of the room with surface classification (wall/floor/ceiling/window). Use for occlusion of virtual objects behind real geometry and for physics interactions with real walls.
visionOS — same APIs, different scale
On visionOS, RealityKit is the primary UI framework, not a special-case AR layer. Most of the scene-graph code transfers; the input model (gaze + pinch) and the spatial UX patterns are different. Covered in Chapter 12.
In the wild
- IKEA Place, Wayfair View in Room — QuickLook USDZ + custom Reality Composer scenes.
- Apple Maps Look Around — not strictly AR but uses related camera+geo fusion.
- Snapchat / Instagram AR filters — they ship their own engine on top of
ARFaceTrackingConfiguration. - Measure (Apple) — pure ARKit + raycast + simple geometry.
- Pokémon Go — runs both ARCore on Android and ARKit on iOS, falls back to gyro-only.
Common misconceptions
- “AR drains the battery so much I shouldn’t ship it.” Modern devices handle 15-30 minutes of AR fine. Be mindful of idle AR — don’t keep
ARSessionrunning on a screen the user isn’t interacting with. - “SceneKit is the modern choice.” SceneKit is in maintenance. RealityKit is where Apple invests; new features (Object Capture, hover effects, visionOS) land in RealityKit.
- “I need a Mac with USDZ tools to make a model.” Reality Composer Pro (free with Xcode) and even SwiftUI’s
Model3Dview handle USDZ. For complex models, Blender → USDZ via the official exporter works. - “Plane detection works in any lighting.” It needs visual features. Plain white walls, glossy floors, and low-light all degrade tracking. Use
ARCoachingOverlayViewto guide the user. - “ARKit can save the user’s AR session to share with another user.” It can — via
ARWorldMapserialization — but only between the same user’s devices on iOS. Cross-user shared AR isMultipeerConnectivity+ sharing collaboration data.
Seasoned engineer’s take
The hardest part of shipping AR is the UX before the AR session starts. Users don’t know they need to wave the phone around in good light; they tap the AR button, see a black screen for 3 seconds, and bounce. Standard mitigations:
ARCoachingOverlayView— Apple’s official “move your phone” UI. Always present it.- Pre-AR onboarding — a single still-frame explainer: “Point at the floor and move your phone slowly.”
- Fallback to 3D-only mode when tracking fails (Reality Composer scene viewed without the camera background).
Engineering: keep the ARKit code in a dedicated controller, don’t sprinkle anchors across view models. ARSession is a long-lived stateful object; restarting it costs ~500ms of re-tracking, which feels like a freeze.
For most apps that “want AR,” the answer is QuickLook + USDZ. Custom RealityKit work is justified when you need interactive behavior (drag-to-resize beyond QuickLook’s, multi-object scenes, physics) or specialized tracking (images, objects, body).
TIP: Cache USDZ files in
Application Support, not in the bundle, when products are downloaded dynamically. Bundle USDZs inflate IPA size dramatically and trigger over-the-air install warnings at 200MB.
WARNING: Never store raw
ARFramecapturedImage data to disk without a strong privacy reason and user consent. The camera frame contains the user’s living room.
Interview corner
Junior: “What’s the simplest way to let a user place a 3D product in their room?”
Ship a USDZ file and present it with
QLPreviewController. The system handles AR-place, scale, rotate, occlusion, and the AR button. No custom AR code needed.
Mid: “How would you let the user tap to place a model and then drag, rotate, scale it?”
Run an
ARWorldTrackingConfigurationwith horizontal-plane detection. On tap,arView.raycast(from:point, allowing: .estimatedPlane, alignment: .horizontal)returns a world transform; create anAnchorEntity(world:)and aModelEntity. CallgenerateCollisionShapeson the entity, thenarView.installGestures([.translation, .rotation, .scale], for: entity). UseARCoachingOverlayViewto guide the user through plane detection.
Senior: “Design an AR retail app that ships hundreds of products, with reliable performance on a 5-year-old iPhone XS.”
Two-tier model. (1) Lightweight catalog: thumbnails + USDZ files hosted on a CDN, downloaded on demand into Application Support, capped to ~50MB cache with LRU eviction. (2) AR mode: QuickLook for default “place in room”; custom RealityView only for SKUs that need configurator behavior (color swap, modular assembly). Tracking config: horizontal-plane only on older devices; horizontal+vertical+person-occlusion on A12+. Reserve LiDAR-only features for Pro devices. Optimize USDZ assets: <5MB per model, materials baked, normal maps preferred over high-poly geometry. Show
ARCoachingOverlayViewalways; on tracking failure, fall back to a 360° spin viewer (no camera background) so the user still sees the product. Telemetry: log “AR placed”, “AR dismissed without place”, “tracking lost” to identify catalog SKUs with broken assets.
Red flag: “We use SceneKit because it’s been around longer and is more stable.”
In 2026, Apple is investing in RealityKit, not SceneKit. New AR features (people occlusion, scene reconstruction, hover effects, visionOS) land in RealityKit first. SceneKit still works but is a dead-end choice for greenfield code.
Lab preview
ARKit doesn’t have a dedicated lab in Phase 7 (the 4 labs prioritize the more common patterns), but the Lab 7.1 — Weather + Map app stretch goal includes “place a 3D weather icon in AR at the location’s coordinate” — a 50-line addition that exercises this chapter end-to-end.
Next: 7.8 — CoreML & Create ML
7.8 — CoreML & Create ML
Opening scenario
The product team wants three things: “classify this photo as cat/dog/other,” “tell me the sentiment of this review,” and “detect when the user finishes a yoga pose on Vision Pro.” All three are on-device ML problems. You don’t need a PhD, a GPU cluster, or a cloud bill. You need Create ML (Apple’s no-code/low-code trainer, ships with Xcode) and CoreML (the on-device runtime). The full pipeline — gather data → train → drop the .mlmodel into Xcode → call from Swift — takes a couple of afternoons.
| Context | What it usually means |
|---|---|
| Reads “MLModel / VNCoreMLRequest” | Has done basic image inference |
| Reads “Create ML” | Has trained a custom model |
| Reads “model quantization / Neural Engine” | Has optimized for size and speed |
| Reads “MLModelConfiguration” | Has tuned compute units |
| Reads “ModelCollection / on-device personalization” | Has shipped updateable models |
Concept → Why → How → Code
Concept
- CoreML — the runtime. Loads
.mlmodelc(compiled.mlmodel), runs inference on CPU/GPU/Neural Engine. Auto-generated Swift class per model. - Vision — the vision framework. Wraps image-related CoreML use into pre-built request types (object detection, classification, face detection, text recognition).
- Natural Language — sentiment, language ID, tokenization, named entity recognition. Many built-in models; you can swap your own.
- Create ML — Apple’s training app (standalone Mac app + Xcode integration). UI for image classifier, object detector, text classifier, tabular regressor/classifier, sound classifier, action classifier, hand pose classifier.
- CoreML Tools (
coremltoolsPython package) — converts TensorFlow/PyTorch/ONNX/scikit-learn models to CoreML format.
Why
- On-device — no network, no inference cost, no privacy leak.
- Neural Engine — Apple’s dedicated ML accelerator. Inference times measured in single-digit milliseconds for typical image classifiers.
- No backend — your inference is free at scale.
- Offline — works on planes and in tunnels.
How — image classification with a Create ML model
- Open Create ML.app (Xcode → Open Developer Tool → Create ML).
- New Document → Image Classification.
- Drop training folder structured as
cat/,dog/,other/with ~50+ images each. - Drop validation folder with the same structure (~20% of training count).
- Click Train. Wait minutes.
- Export the
.mlmodel. - Drag into Xcode. Xcode generates a Swift class (e.g.
PetClassifier).
import CoreML
import Vision
import UIKit
actor PetClassifierService {
private let model: VNCoreMLModel
init() throws {
let config = MLModelConfiguration()
config.computeUnits = .all // CPU + GPU + Neural Engine; iOS picks best
let core = try PetClassifier(configuration: config).model
self.model = try VNCoreMLModel(for: core)
}
func classify(_ image: UIImage) async throws -> [VNClassificationObservation] {
guard let cgImage = image.cgImage else { return [] }
let request = VNCoreMLRequest(model: model)
request.imageCropAndScaleOption = .centerCrop
let handler = VNImageRequestHandler(cgImage: cgImage, orientation: .up)
try handler.perform([request])
return (request.results as? [VNClassificationObservation]) ?? []
}
}
// Usage
let results = try await service.classify(uiImage)
if let top = results.first {
print("\(top.identifier) — \(String(format: "%.2f", top.confidence))")
}
Vision built-in: text recognition, face detection, body pose
You don’t always need a custom model. Vision ships with:
VNRecognizeTextRequest— OCR. Multiple languages, fast, accurate.VNDetectFaceRectanglesRequest/VNDetectFaceLandmarksRequest— faces and landmarks.VNDetectHumanBodyPoseRequest— joint positions for body pose.VNDetectAnimalBodyPoseRequest— dogs and cats.VNDetectBarcodesRequest— QR + many barcode formats.VNGenerateOpticalFlowRequest— frame-to-frame motion.
let request = VNRecognizeTextRequest()
request.recognitionLevel = .accurate
request.recognitionLanguages = ["en-US", "ja-JP"]
let handler = VNImageRequestHandler(cgImage: cgImage)
try handler.perform([request])
let lines = (request.results ?? []).compactMap { $0.topCandidates(1).first?.string }
NaturalLanguage — sentiment & language ID
import NaturalLanguage
let tagger = NLTagger(tagSchemes: [.sentimentScore])
tagger.string = "I love this app, but the latest update broke my widget."
let (sentiment, _) = tagger.tag(at: tagger.string!.startIndex,
unit: .paragraph, scheme: .sentimentScore)
// sentiment.rawValue is "-0.4" — slightly negative
For language ID:
let recognizer = NLLanguageRecognizer()
recognizer.processString("これは日本語のテキストです。")
let lang = recognizer.dominantLanguage // .japanese
MLModelConfiguration — tuning
let config = MLModelConfiguration()
config.computeUnits = .cpuAndNeuralEngine // skip GPU to save battery on intensive inference
config.allowLowPrecisionAccumulationOnGPU = true // faster, occasionally less accurate
config.preferredMetalDevice = MTLCreateSystemDefaultDevice()
.all(default) — let CoreML pick. Usually right..cpuAndNeuralEngine— force Neural Engine path, lowest power..cpuOnly— for debugging, or for ANE-incompatible ops.
Quantization & model size
Models ship at FP32 by default. Most apps can quantize to FP16 or even INT8 with negligible accuracy loss and 2-4× smaller download. Done in coremltools or in Create ML’s export options.
A 50MB image classifier becomes 12MB after FP16 → INT8 — fits in a bundle without bloating IPA.
Updateable models (on-device personalization)
CoreML supports .mlmodel files marked updatable: you can call MLUpdateTask on-device to fine-tune with the user’s own data without ever leaving the device. The Photos app uses this to learn faces; Mail uses it for spam classification heuristics.
Setup is involved (must be designed updatable in the original model spec). Not the first feature to ship, but worth knowing exists.
Converting from PyTorch / TensorFlow
# coremltools (run on a Mac with Python)
import coremltools as ct
import torch
torch_model.eval()
example = torch.rand(1, 3, 224, 224)
traced = torch.jit.trace(torch_model, example)
mlmodel = ct.convert(
traced,
inputs=[ct.ImageType(shape=example.shape, scale=1/255.0)],
classifier_config=ct.ClassifierConfig(labels=["cat", "dog", "other"]),
convert_to="mlprogram", # modern format
minimum_deployment_target=ct.target.iOS17,
)
mlmodel.save("PetClassifier.mlmodel")
mlprogram is the modern CoreML format (replacing older .mlmodel NeuralNetwork spec) — better ANE compatibility, smaller, faster.
Foundation Models (iOS 18+)
In 2024 Apple introduced on-device foundation models (~3B parameter language model) accessible via the new Apple Intelligence APIs. Not strictly CoreML — a higher-level framework that wraps them. Covered briefly in Chapter 13.
In the wild
- Apple Photos — face recognition, object classification, scene detection, OCR — all on-device CoreML.
- Apple Mail — sender categorization, “important” flagging, spam scoring.
- Visual Look Up —
VNGenerateImageFeaturePrintRequest+ a curated landmark database. - Pixelmator Pro, Photoshop on iPad — denoise, upscale, object selection use CoreML.
- Shazam Kit — built on a CoreML audio fingerprint model.
- Be My Eyes — accessibility app that pairs OCR + a vision LLM for blind users.
Common misconceptions
- “CoreML is slow because it’s a phone.” A typical image classifier runs in 5-20ms on the Neural Engine of an A14 or newer. Often faster than a server round-trip.
- “I need TensorFlow expertise to use Create ML.” Image, sound, action, tabular, and text classifiers can all be trained in Create ML with no code — drop folders, click train.
- “My model is huge so I’ll just download it.” Apple’s Background Assets framework lets you ship a small bundle and download large ML payloads on first launch. Use it for models >50MB.
- “CoreML can run any PyTorch model.” Most ops convert; some (custom CUDA kernels, certain dynamic shapes) don’t. Run
ct.convertearly to validate. - “All CoreML models run on the Neural Engine.” Only certain op patterns are ANE-compatible. Use Xcode’s Instruments → Core ML template to verify which units handle your model.
Seasoned engineer’s take
The team writing the model and the team shipping the app must talk constantly. Three lessons from production:
- Define the inference contract before training. Input shape, normalization, output labels, expected latency budget. Models that drop in with no docs become “magic box that returns numbers.”
- Build a fallback path. Models occasionally output garbage (low confidence). Always check
topCandidates.first.confidence > thresholdand degrade gracefully — “We couldn’t identify this image.” - Profile on the oldest supported device. A model that runs in 8ms on an iPhone 16 Pro might run in 40ms on an SE 3 — fine, but if it runs in 400ms on an XS, you have a problem.
For the Apple-flavored ML workflow, here’s the order of operations that always pays off:
- Can a Vision built-in request do it? (OCR, face, body pose, animal pose, barcode.) If yes, use that.
- Can NaturalLanguage do it? (sentiment, language ID, NER.) If yes, use that.
- Can a Create ML built-in template do it? (image / text / sound / action classifier.) If yes, train in Create ML.
- Only then drop to custom PyTorch/TF +
coremltools.
TIP: Xcode 16 has a Preview tab for
.mlmodelfiles. Drag test images in, see live predictions. Catches bad labels and broken preprocessing before you write Swift.
WARNING: Don’t ship a model trained on a dataset you don’t have rights to. App Review doesn’t check, but lawsuits do. Document model provenance in your repo.
Interview corner
Junior: “How do you classify a photo as cat/dog/other?”
Train an image classifier in Create ML by dropping labeled folders. Export the
.mlmodel, drop into Xcode. Xcode generates a class. Wrap it inVNCoreMLModel, runVNCoreMLRequestviaVNImageRequestHandler, read the topVNClassificationObservation.
Mid: “How would you ship a 200MB ML model without bloating the IPA?”
Use Apple’s Background Assets framework. Ship a stub model or no model at all; on first launch, fetch the full model from your CDN (or App Store-hosted asset pack), persist to
Application Support, load withMLModel(contentsOf:). Quantize first — most 200MB models quantize to 50-80MB with FP16 and 25-40MB with INT8 at small accuracy cost. SetcomputeUnitsto.cpuAndNeuralEngineto force the most battery-efficient path.
Senior: “Design an on-device personalization system: a recipe app learns the user’s cuisine preferences without sending data to a server.”
Ship an updatable CoreML model with
isUpdatable = trueflags on the final layers. When the user rates or saves a recipe, build training samples (recipe feature vector + user score) and accumulate to disk. Periodically (battery-permitting, charging-and-idle-only), runMLUpdateTaskto fine-tune the model with the accumulated samples. Persist the updated model toApplication Support. Cap stored training data (e.g., last 500 events) to bound disk and training time. Recommendations come from running inference on candidate recipes; the per-user model gives personalized scores. Privacy: never POST samples to a server. For backups, optionally allow opt-in iCloud sync of just the user’s model file (encrypted). For new-user cold start, ship a generic base model and graceful “We’re learning your preferences” UI for the first dozen interactions.
Red flag: “We just hit OpenAI’s API on every photo upload to classify it.”
Five wins for on-device CoreML: zero inference cost, zero latency variance, works offline, no privacy issues, no rate limits. A trained Create ML classifier nails this in a weekend with zero ongoing cost. Reaching for an LLM API for a 3-class image classifier is overengineering and budget waste.
Lab preview
CoreML doesn’t have a dedicated Phase 7 lab; the Lab 7.2 — Widget extension stretch goal includes “classify the most recent photo with a Create ML model and show its label on the widget” — a tight end-to-end demonstrating model load + Vision request + App Group caching.
Next: 7.9 — AppIntents & Shortcuts
7.9 — AppIntents & Shortcuts
Opening scenario
Your app has a “Start workout” button. A power user wants to say “Hey Siri, start my morning run” without opening the app. The PM wants the same action available from a Lock Screen widget, the Action Button on iPhone 15 Pro, the Shortcuts app, Spotlight search, the Apple Watch, and — as of iOS 18 — Apple Intelligence. AppIntents is the framework that unifies all of these surfaces. Write the action once; it appears everywhere.
| Context | What it usually means |
|---|---|
| Reads “AppIntent” | Has defined a basic intent |
| Reads “AppShortcutsProvider” | Has registered Siri shortcuts |
| Reads “AppEntity” | Has modeled custom domain objects |
| Reads “IntentParameter / dynamic options” | Has built parameterized intents |
| Reads “AppIntentVocabulary” | Has tuned Siri recognition |
Concept → Why → How → Code
Concept
AppIntents replaces the old INIntent / SiriKit / Intents Extension / Intents UI Extension stack with a single, Swift-native, code-only framework. Three core protocols:
AppIntent— an action your app exposes. Has parameters, performs work, returns a result.AppEntity— a domain object your app understands (Workout, Recipe, Habit). Can be passed as a parameter or returned.AppShortcutsProvider— registersAppShortcutdefinitions with Siri at install time (no user setup required).
The result: one Swift file defines a feature that’s invokable from Siri, the Shortcuts app, Lock Screen widgets (Buttons with intent:), Action Button, Spotlight, Apple Watch’s Smart Stack, the Action menu on Vision Pro, and Apple Intelligence’s tool-use surface.
Why
- Free surface coverage — write once, appear in 8+ system places.
- No extension target — runs in-process or out-of-process based on the platform; you don’t manage that.
- Type-safe — parameters are Swift enums, structs, entities. No NSDictionary plumbing.
- Apple Intelligence integration — iOS 18+ uses your AppIntent metadata as tool definitions for the on-device LLM.
How — a basic AppIntent
import AppIntents
struct StartWorkoutIntent: AppIntent {
static var title: LocalizedStringResource = "Start Workout"
static var description = IntentDescription("Begin a workout in MyFitnessApp.")
@Parameter(title: "Activity")
var activity: ActivityType
static var parameterSummary: some ParameterSummary {
Summary("Start a \(\.$activity) workout")
}
@MainActor
func perform() async throws -> some IntentResult & ProvidesDialog {
try await WorkoutService.shared.start(activity: activity)
return .result(dialog: "Started your \(activity.localizedName).")
}
}
enum ActivityType: String, AppEnum {
case running, cycling, yoga, strength
static var typeDisplayRepresentation: TypeDisplayRepresentation = "Activity"
static var caseDisplayRepresentations: [ActivityType: DisplayRepresentation] = [
.running: "Run",
.cycling: "Cycle",
.yoga: "Yoga",
.strength: "Strength"
]
var localizedName: String {
Self.caseDisplayRepresentations[self]?.title.key ?? rawValue
}
}
That single struct now appears in:
- The Shortcuts app under your app.
- Siri (after user has used it once, or always with an
AppShortcutsProvider— see below). - Lock Screen widgets via
Button(intent: StartWorkoutIntent(activity: .running)) { ... }.
AppShortcutsProvider — zero-setup Siri
struct MyAppShortcuts: AppShortcutsProvider {
static var appShortcuts: [AppShortcut] {
AppShortcut(
intent: StartWorkoutIntent(activity: .running),
phrases: [
"Start a run in \(.applicationName)",
"Begin running with \(.applicationName)",
"\(.applicationName) start run"
],
shortTitle: "Start Run",
systemImageName: "figure.run"
)
AppShortcut(
intent: ViewTodayHabitsIntent(),
phrases: ["Show my habits in \(.applicationName)"],
shortTitle: "Today's Habits",
systemImageName: "checklist"
)
}
}
AppShortcutsProvider is auto-registered at install time — the user never has to “enable” the shortcut. They just say “Start a run in MyFitnessApp” and it works. Limit: 10 AppShortcuts per app.
AppEntity — exposing domain objects
struct HabitEntity: AppEntity {
let id: UUID
var name: String
static var typeDisplayRepresentation: TypeDisplayRepresentation = "Habit"
var displayRepresentation: DisplayRepresentation { DisplayRepresentation(title: "\(name)") }
static var defaultQuery = HabitQuery()
}
struct HabitQuery: EntityQuery {
func entities(for identifiers: [HabitEntity.ID]) async throws -> [HabitEntity] {
await HabitStore.shared.habits.filter { identifiers.contains($0.id) }
}
func suggestedEntities() async throws -> [HabitEntity] {
await HabitStore.shared.habits
}
}
Now an intent can take a HabitEntity parameter and Siri/Shortcuts will offer a list picker:
struct CompleteHabitIntent: AppIntent {
static var title: LocalizedStringResource = "Complete Habit"
@Parameter(title: "Habit") var habit: HabitEntity
func perform() async throws -> some IntentResult {
await HabitStore.shared.complete(id: habit.id)
return .result()
}
}
Dynamic options
@Parameter(
title: "Workout",
optionsProvider: WorkoutOptionsProvider()
)
var workout: String
struct WorkoutOptionsProvider: DynamicOptionsProvider {
func results() async throws -> [String] {
try await WorkoutCatalog.shared.availableNames()
}
}
Returning results — ReturnsValue, OpensIntent, ProvidesDialog, ShowsSnippetView
func perform() async throws -> some IntentResult & ReturnsValue<Int> & ProvidesDialog {
let count = await HabitStore.shared.todayCount
return .result(value: count, dialog: "You've completed \(count) habits today.")
}
For Spotlight/widget snippets:
func perform() async throws -> some IntentResult & ShowsSnippetView {
.result(view: HabitSummaryView())
}
Where intents run
- In-process when the host UI needs to update (Lock Screen Button tap, widget Button tap).
- Out-of-process (background) for Siri/Shortcuts execution. You don’t pick — set
static var openAppWhenRun = trueif your intent absolutely requires app foreground. ForegroundContinuableIntent— for intents that might need to open the app: start in background, escalate if needed.
Spotlight donation
CSSearchableItem integration with AppEntity is automatic in iOS 17+: registered AppEntities appear in Spotlight, and tapping one runs your default intent. No NSUserActivity boilerplate.
iOS 18 — Apple Intelligence tool use
When the user invokes Apple Intelligence and asks “What habits did I complete today?”, the system can use your CompleteHabitIntent and a GetHabitsTodayIntent as tools, parameterized by the conversation context. Your AppIntents become LLM-callable functions. No additional code beyond well-named intents with clear description strings.
This is why naming and descriptions matter enormously now. description: "Returns the user's habits completed today, with completion timestamps" is better than description: "Habits intent".
In the wild
- Shortcuts (built-in) — the canonical AppIntents host.
- Bear, Things 3, Drafts — extensive intent coverage for “Create note,” “Add task,” “Open inbox.”
- Carrot Weather — donated intents for “Show forecast for [location]” feeding Siri and Lock Screen.
- Toolbox for HomeKit, Home+ 6 —
AppEntity-based intents for “Turn on [room].” - Apple’s own apps (Reminders, Calendar, Notes, Mail) — all migrated from
INIntenttoAppIntent.
Common misconceptions
- “AppShortcuts need user setup.” They don’t. Define them in
AppShortcutsProviderand Siri recognizes the phrases at install time. - “AppIntents are only for Siri.” They power Shortcuts, widgets, Action Button, Spotlight, Apple Intelligence, Smart Stack, and more. Siri is one of many surfaces.
- “I can put any complex UI in an intent result.” Snippet views must be lightweight SwiftUI — no heavy interaction, no scroll views with full app state. Treat them as widgets.
- “AppEntity is just a struct that conforms to a protocol.” It also needs an
EntityQueryfor ID-based lookup and ideallysuggestedEntitiesfor picker UX. Without those, the entity isn’t useful in dynamic intents. - “AppIntents replace
NSUserActivityfor handoff.” They do for the action surface, butNSUserActivitystill exists for Handoff between devices, Continuity, and SiriKit donations on older OSes.
Seasoned engineer’s take
AppIntents is the highest-leverage code in your app. Three intents, well-named with clear descriptions, can light up your app on every Apple surface: home screen widget, Lock Screen, Watch face, Spotlight, Action Button, Apple Intelligence. The cost is one Swift file per intent. The competition is people who haven’t shipped intents at all and whose features only exist when the app is open.
Two practices that pay off:
- Make your
descriptionstrings narrative. They are read by the on-device LLM as tool descriptions. “Returns the user’s three most recently completed habits with their completion times” is parsed and matched far better than “Get habits.” - Avoid optional parameters when you can. Siri’s dialog UX for “What workout?” is much smoother than “Workout? (optional) Activity? (optional).” Provide good defaults and require only what’s necessary.
For migrations: if you’re on legacy INIntent extensions, the AppIntents migration is one of the highest-ROI refactors available. You delete entire extension targets, lose ~1000 lines of plist+Swift+ObjC plumbing, and gain Apple Intelligence support for free.
TIP: Use
IntentDonationManagerto donate intents the user has performed even if they weren’t invoked via Siri. The OS uses these to rank your AppShortcuts in Spotlight and the Suggestions widget.
WARNING: Don’t let an AppIntent perform a destructive action without confirmation. Provide
parameterSummarywith clear language, and for “Delete X” intents, setopenAppWhenRun = true(or useConfirmationflow) so the user sees the consequence.
Interview corner
Junior: “How do you add a ‘Start workout’ Siri shortcut to your app?”
Define an
AppIntentwith aperform()method. Register it in anAppShortcutsProviderwith one or more invocation phrases. Build and run; Siri recognizes the phrase at install time, no user setup needed.
Mid: “How would you let the user say ‘Complete my habit’ and have Siri ask which one?”
Define a
HabitEntity(withEntityQuerythat returns the user’s habits). DefineCompleteHabitIntentwith@Parameter var habit: HabitEntity. Siri sees the entity has a query, runssuggestedEntities(), and presents a picker dialog. Once selected,perform()runs against the chosen habit’s ID. Donate completed intents viaIntentDonationManagerso frequent habits appear higher in the picker.
Senior: “Design the AppIntent layer for a recipe app that exposes search, view, save, and cook actions to Siri, widgets, Lock Screen, and Apple Intelligence.”
Four
AppIntents:SearchRecipesIntent(query: String) -> RecipeEntity[],ShowRecipeIntent(recipe: RecipeEntity)withopenAppWhenRun = true,SaveRecipeIntent(recipe: RecipeEntity),StartCookingIntent(recipe: RecipeEntity)(background, posts aUNNotificationwith a “Next step” action). OneRecipeEntitywith anEntityQuerybacked by SwiftData. Register the top three inAppShortcutsProviderwith natural phrases (“Search recipes for (parameter), Show me (parameter), Start cooking (parameter)”). Richdescriptionstrings on every intent so Apple Intelligence can invoke them as tools: “Searches the user’s saved recipes by ingredient or dish name and returns up to five matches.” Lock Screen widget usesButton(intent: SearchRecipesIntent(query: "dinner")) { ... }. Spotlight surfaces individualRecipeEntityinstances automatically; tapping one runsShowRecipeIntentas the default. Test on iOS 18 device with Apple Intelligence enabled to verify tool invocation in chat.
Red flag: “We still ship an Intents Extension and an Intents UI Extension for our SiriKit shortcuts.”
In 2026 this is legacy code unless you specifically need pre-iOS 16 support. Migrating to AppIntents deletes two extension targets, removes the Intents framework code, and gives you the full modern surface (widgets, Action Button, Apple Intelligence) for free.
Lab preview
Lab 7.2 — Widget extension builds a parameterized AppIntent and surfaces it via an interactive widget button — the cleanest possible demonstration of “write the intent once, run it from a widget tap.”
Next: 7.10 — watchOS & WatchKit
7.10 — watchOS & WatchKit
Opening scenario
The PM says: “Let’s add a Watch app. Show today’s habits, let the user complete one with a tap, and update the iPhone immediately.” You open Xcode → File → New → Target → Watch App. The template appears. Then come the questions: SwiftUI or WatchKit Storyboard? Independent or paired? Companion data flow — WatchConnectivity, App Groups, CloudKit, or a server? Complications, Smart Stack, double-tap, Always-On display? Welcome to watchOS, where the constraints are real (battery, screen, CPU, RAM) but the integration points are deep (HealthKit, ActivityKit, complications, Siri).
| Context | What it usually means |
|---|---|
| Reads “SwiftUI on watchOS” | Has built basic watch UIs |
| Reads “WatchConnectivity” | Has done iPhone ↔ Watch data sync |
| Reads “complication / Smart Stack” | Has shipped a watch face complication |
| Reads “independent watchOS app” | Has built standalone apps without an iPhone counterpart |
| Reads “HKWorkoutSession” | Has built a real-time workout app |
Concept → Why → How → Code
Concept
watchOS in 2026 is SwiftUI-first, independent-app-first. The old WatchKit Storyboard approach still exists but is legacy. Modern watchOS apps are:
- Independent — install/run without requiring an iPhone (still pair via Apple ID for personalization).
- SwiftUI-based — same
View,@State,@Observablemodel as iOS. - Complication-eligible — surface as WidgetKit widgets (yes, WidgetKit unified across iOS, macOS, watchOS).
- HealthKit + ActivityKit native — most premium watch apps are workout or activity apps.
Why
- At-a-glance feature — features on the watch get checked 10x more than the same feature on the phone.
- Workout / health credibility — Apple Watch is the dominant health wearable; HealthKit + watch is the gold-standard pairing.
- Notification first-class display — Watch notifications can have custom interactive UI via WidgetKit + AppIntents.
How — project layout
Xcode → File → New → Target → Watch App (or Watch App for iOS App if you want a paired pair).
Modern watchOS apps no longer have a separate WatchKit Extension target; the Watch App target contains both the UI and the runtime code in one bundle.
Hello, watch
import SwiftUI
@main
struct MyWatchApp: App {
var body: some Scene {
WindowGroup {
TodayView()
}
}
}
struct TodayView: View {
@State private var habits: [Habit] = []
var body: some View {
NavigationStack {
List(habits) { habit in
Button {
Task { await complete(habit) }
} label: {
Label(habit.name, systemImage: habit.completed ? "checkmark.circle.fill" : "circle")
}
}
.navigationTitle("Today")
.task { habits = await HabitStore.shared.todayHabits }
}
}
func complete(_ habit: Habit) async {
await HabitStore.shared.complete(id: habit.id)
habits = await HabitStore.shared.todayHabits
}
}
WatchConnectivity — iPhone ↔ Watch sync
For paired apps, WatchConnectivity (WCSession) is the legacy direct-pipe. Still works.
import WatchConnectivity
@MainActor
final class ConnectivityService: NSObject, WCSessionDelegate, ObservableObject {
static let shared = ConnectivityService()
private let session = WCSession.default
func activate() {
guard WCSession.isSupported() else { return }
session.delegate = self
session.activate()
}
func sendHabits(_ habits: [Habit]) {
guard session.activationState == .activated else { return }
let data = (try? JSONEncoder().encode(habits)) ?? Data()
// applicationContext: dictionary, latest-wins, persisted across launches
try? session.updateApplicationContext(["habits": data])
}
// Receive
nonisolated func session(_ session: WCSession,
didReceiveApplicationContext applicationContext: [String : Any]) {
if let data = applicationContext["habits"] as? Data,
let habits = try? JSONDecoder().decode([Habit].self, from: data) {
Task { @MainActor in HabitStore.shared.set(habits) }
}
}
nonisolated func session(_ session: WCSession, activationDidCompleteWith state: WCSessionActivationState, error: Error?) {}
#if os(iOS)
nonisolated func sessionDidBecomeInactive(_ session: WCSession) {}
nonisolated func sessionDidDeactivate(_ session: WCSession) { session.activate() }
#endif
}
WCSession provides several APIs with different semantics:
updateApplicationContext— single dictionary, latest-wins, persists. Use for “current state.”sendMessage(replyHandler:)— fire-and-await reply (counterpart must be reachable). Use for live request/response.transferUserInfo— queued delivery, guaranteed even if counterpart asleep. Use for events.transferFile— for binary blobs.
In 2026, CloudKit + SwiftData is preferred over WatchConnectivity for new apps. Both sides read/write the same iCloud container; no special connectivity dance. WatchConnectivity remains useful for low-latency interaction (e.g., the watch needs to tell the phone “user tapped this now”).
Complications & Smart Stack — via WidgetKit
watchOS 9+ unified complications with WidgetKit. A single Widget declared with .supportedFamilies([.accessoryCircular, .accessoryRectangular, .accessoryInline, .accessoryCorner]) powers watch face complications, the Smart Stack, and the iOS Lock Screen.
struct HabitWidget: Widget {
var body: some WidgetConfiguration {
StaticConfiguration(kind: "Habit", provider: HabitProvider()) { entry in
HabitWidgetView(entry: entry)
}
.configurationDisplayName("Habit Tracker")
.description("Today's progress at a glance.")
.supportedFamilies([
.accessoryCircular, .accessoryRectangular, .accessoryInline, .accessoryCorner,
.systemSmall, .systemMedium // also works on iOS Lock Screen + Home Screen
])
}
}
struct HabitWidgetView: View {
@Environment(\.widgetFamily) var family
let entry: HabitEntry
var body: some View {
switch family {
case .accessoryCircular:
Gauge(value: entry.progress) { Image(systemName: "checkmark.circle") }
.gaugeStyle(.accessoryCircularCapacity)
case .accessoryRectangular:
VStack(alignment: .leading) {
Text("Today: \(entry.completed)/\(entry.total)")
ProgressView(value: entry.progress).tint(.accentColor)
}
case .accessoryInline:
Text("\(entry.completed)/\(entry.total) habits")
default:
HabitProgressCard(entry: entry)
}
}
}
Always-On display
The watch screen stays dimly on between active interactions. SwiftUI handles most of it; for fine control:
@Environment(\.isLuminanceReduced) var isLuminanceReduced
var body: some View {
Text("Steps: \(steps)")
.foregroundStyle(isLuminanceReduced ? .secondary : .primary)
}
Workout sessions (real-time HealthKit on Watch)
The Apple Watch is the only place an app can run continuously with the screen-off, high-frequency sensor access. For workout-style apps:
import HealthKit
final class WorkoutSessionController: NSObject, HKWorkoutSessionDelegate, HKLiveWorkoutBuilderDelegate {
let store = HKHealthStore()
var session: HKWorkoutSession?
var builder: HKLiveWorkoutBuilder?
func start() throws {
let config = HKWorkoutConfiguration()
config.activityType = .running
config.locationType = .outdoor
let session = try HKWorkoutSession(healthStore: store, configuration: config)
let builder = session.associatedWorkoutBuilder()
builder.dataSource = HKLiveWorkoutDataSource(healthStore: store, workoutConfiguration: config)
session.delegate = self
builder.delegate = self
self.session = session
self.builder = builder
session.startActivity(with: .now)
builder.beginCollection(withStart: .now) { _, _ in }
}
func end() async throws {
session?.end()
try await builder?.endCollection(at: .now)
try await builder?.finishWorkout()
}
// Delegate stubs ...
func workoutSession(_ workoutSession: HKWorkoutSession, didChangeTo: HKWorkoutSessionState, from: HKWorkoutSessionState, date: Date) {}
func workoutSession(_ workoutSession: HKWorkoutSession, didFailWithError error: Error) {}
func workoutBuilder(_ workoutBuilder: HKLiveWorkoutBuilder, didCollectDataOf types: Set<HKSampleType>) {}
func workoutBuilderDidCollectEvent(_ workoutBuilder: HKLiveWorkoutBuilder) {}
}
While an HKWorkoutSession is active, your app keeps running in the background with sensor access (heart rate, GPS), even with the screen off. This is the magic that powers Strava, Nike Run Club, AutoSleep, etc.
Double-tap (Apple Watch Series 9+)
Set a primary widget action. The system invokes the primary Button of your currently-foreground widget when the user double-taps. No custom API — just design widgets where the primary button is the right default action.
Siri & AppIntents on Watch
Same AppIntent you defined for iOS works on watchOS. Add the watch target to the intent’s deployment scope and it appears in Watch’s Siri + Smart Stack automatically.
In the wild
- Strava, Nike Run Club, WHOOP —
HKWorkoutSessionwith real-time heart rate / GPS / power output. - Streaks, Habitify, Things 3 — independent watch apps with rich complications.
- Carrot Weather — multi-family widget supporting every complication slot.
- Just Press Record — independent watch app recording audio with watch-side editing.
- Spotify, Apple Music — watch playback with offline sync.
Common misconceptions
- “watchOS uses UIKit.” Modern watchOS uses SwiftUI exclusively. UIKit is unavailable. The legacy WatchKit (Storyboard-based) is deprecated.
- “My iOS app’s data is automatically available on the watch.” No. You must either share via WatchConnectivity, App Group + SwiftData (if both targets are in the same App Group), or CloudKit.
- “Complications use a special framework.” Since watchOS 9, complications are WidgetKit widgets. Same
Widget, differentsupportedFamilies. - “I can run a long background task on the watch.” Only during an active
HKWorkoutSessionorWKExtendedRuntimeSession(limited budget, specific activity types). Otherwise watchOS suspends the app aggressively. - “Watch apps need iPhone for installation.” Independent watch apps can be installed directly via the Watch’s App Store (paired-app-store-search). Most users still install via iPhone for convenience.
Seasoned engineer’s take
The temptation when writing a Watch app is to port the iPhone UI. Don’t. The Watch is a glance + tap device. The right architecture for almost every watch feature is:
- Open watch app → see today’s most important number (steps, habit progress, next event).
- One tap → an action (complete, start, dismiss).
- Hand off complexity to the iPhone (Handoff handle gets you to the right iPhone screen).
For data sync: in 2026, CloudKit + SwiftData with the same container shared by iOS and watchOS targets is the cleanest. The watch reads/writes; CloudKit fan-outs to the iPhone (and Mac, and iPad) automatically. WatchConnectivity is the live-channel for cases where CloudKit’s latency (seconds) is too slow.
For workouts: HKWorkoutSession is the single most powerful watchOS API. It’s the only way to get long-running, screen-off, sensor-active background execution. If your app benefits from continuous heart rate or GPS, this is the path.
TIP: Add a
Button(intent:)to your most prominent widget — that becomes the double-tap target on Series 9 and later. Users love that “raise wrist → double-tap → it’s done” loop.
WARNING: Battery. The watch has a tiny battery. A bad NSTimer-driven UI in
.frontmoststate can drain 20% per hour. Profile with Instruments → Time Profiler against the watch simulator and a real device.
Interview corner
Junior: “How do you show a list and let the user tap to complete a habit on Apple Watch?”
Create a Watch App target. In SwiftUI, use a
NavigationStackcontaining aListof habits, each rendered as aButtonwhose action toggles the completed state. Same SwiftUI you’d write for iOS, just laid out for the smaller canvas.
Mid: “How do you sync data between an iPhone app and its Watch app?”
In 2026, prefer CloudKit + SwiftData sharing the same container — both targets read/write, sync handled by iCloud. For low-latency live interaction (e.g., the watch needs to push a one-off command to the phone), use
WCSession:sendMessage(replyHandler:)if both are reachable,transferUserInfofor queued delivery. For “what’s the current state” useupdateApplicationContext.
Senior: “Design a running app that records GPS + heart rate for an hour, survives the screen turning off, and posts the summary to a server when the workout ends.”
Foundation is
HKWorkoutSessionwithHKWorkoutConfiguration(activityType: .running, locationType: .outdoor). Pair withHKLiveWorkoutBuilderto collect heart rate samples andCLLocationManager(high-accuracy,allowsBackgroundLocationUpdates = true) for GPS. The active workout session keeps the app running in background with sensor access — screen-off, wrist-down, all sustained. Persist samples + route to disk (Core Data or SwiftData) every 30s as a crash safety net. OnendWorkout, build aHKWorkoutwith route data, save to HealthKit, and POST the summary to server with retry. Battery hedge: drop GPS tokCLLocationAccuracyHundredMeterswhen user is stationary for >2min. UI: live metrics on watch (pace, HR, distance) usingTimelineView(.periodic); full route + splits handoff to iPhone viaNSUserActivityso the user can review on the bigger screen.
Red flag: “We use a 1-second timer on the watch to poll the phone for updates.”
Watch battery and CPU budget cannot sustain a 1-second polling loop. Use
WCSessionpush semantics, or model the data on the watch as the source of truth and sync via CloudKit on natural state changes.
Lab preview
watchOS doesn’t have a dedicated lab in Phase 7; the Lab 7.2 — Widget extension stretch goal includes “add .accessoryCircular and .accessoryRectangular families so the widget appears on watch complications” — the cleanest way to ship a watch surface without committing to a full Watch App target.
Next: 7.11 — HomeKit & Matter
7.11 — HomeKit & Matter
Opening scenario
A client makes smart bulbs and wants iOS users to control them from a native app — not the generic Home app, their branded app. They also want to support Matter so the same bulbs work with Google Home and Alexa. You open Xcode, find HomeKit and Matter frameworks, and have to figure out which is for which. The answer: HomeKit (the framework) is your read/write API into the user’s HomeKit database — the user-managed set of accessories. Matter is the cross-vendor protocol over Thread / Wi-Fi that newer accessories speak. Apple’s MatterSupport framework bridges them: a Matter accessory commissioned through your app shows up in HomeKit and in any other Matter ecosystem the user invites.
| Context | What it usually means |
|---|---|
| Reads “HMHomeManager” | Has read/written HomeKit data |
| Reads “HMService / HMCharacteristic” | Knows the device model |
| Reads “Matter / Thread border router” | Understands the cross-ecosystem protocol |
| Reads “MatterSupport extension” | Has commissioned a Matter accessory |
| Reads “HMHomeManagerAuthorizationStatus” | Has shipped a HomeKit-enabled app |
Concept → Why → How → Code
Concept
- HomeKit — Apple’s user-controlled smart-home database. Users add accessories via the Home app (or third-party apps with the HomeKit entitlement); your app, with permission, can read and control them.
- Matter — the cross-vendor IP-based protocol. Built on top of Thread (low-power mesh) or Wi-Fi. Accessories speaking Matter are vendor-agnostic.
- HomePod / Apple TV as border routers — extend Thread mesh.
- MatterSupport.framework — lets your app run commissioning UI (the “scan QR / pair this device” flow) for a Matter accessory, after which the accessory is shared across HomeKit, Google Home, Alexa, SmartThings.
Why
- Native control — your branded experience without web pages or vendor-specific cloud round-trips.
- Cross-platform device sales — Matter means the same hardware works in every ecosystem; the app you ship for setup is your differentiator.
- Local-first — most HomeKit/Matter operations are LAN-local; works without cloud.
How — capability & authorization
- Apple Developer account → request HomeKit entitlement (Apple-approved; not auto-granted).
- Xcode → Signing & Capabilities → + Capability → HomeKit.
Info.plist:NSHomeKitUsageDescription= “We need access to your home to control your bulbs.”
import HomeKit
@MainActor
final class HomeService: NSObject, HMHomeManagerDelegate, ObservableObject {
private let manager = HMHomeManager()
@Published var primaryHome: HMHome?
@Published var bulbs: [HMAccessory] = []
override init() {
super.init()
manager.delegate = self
}
func homeManagerDidUpdateHomes(_ manager: HMHomeManager) {
primaryHome = manager.primaryHome
bulbs = manager.primaryHome?.accessories.filter {
$0.services.contains { $0.serviceType == HMServiceTypeLightbulb }
} ?? []
}
func homeManager(_ manager: HMHomeManager, didUpdateAuthorizationStatus status: HMHomeManagerAuthorizationStatus) {
// .authorized, .restricted, .determined ...
}
}
Reading / writing a characteristic
func togglePower(_ accessory: HMAccessory) async throws {
guard let bulbService = accessory.services.first(where: { $0.serviceType == HMServiceTypeLightbulb }),
let power = bulbService.characteristics.first(where: { $0.characteristicType == HMCharacteristicTypePowerState })
else { return }
let current = (power.value as? Bool) ?? false
try await power.writeValue(!current)
}
func setBrightness(_ accessory: HMAccessory, percent: Int) async throws {
guard let bulbService = accessory.services.first(where: { $0.serviceType == HMServiceTypeLightbulb }),
let brightness = bulbService.characteristics.first(where: { $0.characteristicType == HMCharacteristicTypeBrightness })
else { return }
try await brightness.writeValue(percent)
}
Subscribing to changes
brightness.enableNotification(true) { error in /* handle */ }
// Receive via HMAccessoryDelegate
extension HomeService: HMAccessoryDelegate {
nonisolated func accessory(_ accessory: HMAccessory,
service: HMService,
didUpdateValueFor characteristic: HMCharacteristic) {
// characteristic.value just changed (from physical button, automation, or other app)
}
}
Automations
let trigger = HMEventTrigger(
name: "Sunset porch lights",
events: [HMSignificantTimeEvent(significantEvent: .sunset, offset: nil)],
predicate: nil
)
let action = HMCharacteristicWriteAction(characteristic: bulb.powerCharacteristic, targetValue: NSNumber(value: true))
let actionSet = HMActionSet(name: "Turn on porch")
home.addActionSet(actionSet) { _, error in
actionSet.addAction(action) { _ in
trigger.updateActionSets([actionSet]) { _ in
home.addTrigger(trigger) { _, _ in }
}
}
}
(Yes, the callback-tower is rough. Most teams write an async wrapper.)
Matter commissioning
For Matter accessories, you don’t add them to HomeKit directly. You call MatterSupport to launch the system commissioning sheet, which discovers the accessory (over Bluetooth + Wi-Fi/Thread), gets the user to confirm, and onboards it across all ecosystems the user has enabled.
import MatterSupport
@MainActor
final class Commissioner {
func startSetup() async throws {
let topology = MatterAddDeviceRequest.Topology(ecosystemName: "MyBulbBrand", homes: [])
let request = MatterAddDeviceRequest(topology: topology)
try await request.perform() // Presents the system sheet
}
}
You also ship a Matter Extension target (MatterSupport extension) that handles the cryptographic onboarding payload on Apple’s behalf and reports back the new device’s identifier.
After commissioning, the accessory appears in HomeKit; your app interacts with it via HMAccessory like any other accessory.
Thread
Thread is a low-power mesh networking standard. Battery-powered Matter accessories (door locks, sensors) almost always use Thread, not Wi-Fi. Thread requires a border router: HomePod mini, HomePod 2, Apple TV 4K (3rd gen), or compatible non-Apple hubs.
Your app doesn’t directly drive Thread; you commission via Matter and the OS handles the network selection.
Cloud relays & remote access
When the user is away from home, HomeKit routes commands via iCloud + a home hub (HomePod / Apple TV / iPad). Your app code doesn’t change — the same HMCharacteristic.writeValue works whether local or remote. The user needs at least one home hub set up.
In the wild
- Apple Home app — the canonical HomeKit/Matter UI.
- Eve for HomeKit, Controller for HomeKit, Home+ 6 — third-party HomeKit-only superapps with advanced automation building.
- Aqara Home, Tapo, Nanoleaf — vendor apps using MatterSupport for cross-ecosystem setup.
- Lutron Caseta — original HomeKit accessory ecosystem; powerful bridged automation.
- HomeKit Secure Video (Logitech Circle, Aqara Hub G3) — privacy-preserving camera storage on iCloud.
Common misconceptions
- “Anyone can add the HomeKit entitlement.” Apple manually approves HomeKit entitlement requests; first-time apps are sometimes denied or require a brand justification.
- “Matter replaces HomeKit.” Matter is a network protocol; HomeKit is Apple’s local DB + APIs. Apple’s adoption of Matter means HomeKit also speaks Matter; the user-facing data model is unchanged.
- “I can read accessories without user permission.” No —
HMHomeManagerreturns nothing until the user grants permission via the system prompt that fires onmanager.delegateassignment. - “HomeKit lets me control my neighbor’s lights if I’m on the same Wi-Fi.” No — HomeKit accessories pair cryptographically to a specific HomeKit home. Even on the same LAN, another user can’t control them without an invite.
- “My app needs to keep a TCP connection open to receive changes.” Use
enableNotification(true)on a characteristic; HomeKit pushes updates to your delegate when the value changes.
Seasoned engineer’s take
HomeKit’s API surface looks dated — block-based, NS-prefixed types, no async sugar in many places. But the underlying model is exactly right: the user owns their home, the user grants access per-app, and apps speak through Apple’s privacy-preserving local-first transport. Two practical notes:
- Wrap the legacy API in actors. Build a
HomeServiceactor that exposes async methods. Keep all the block-based callbacks and delegate forwarding inside; the rest of your app never sees them. - Don’t rebuild Home.app. Users have invested in their Home setup. Your branded app’s value is the vertical experience — better visualizations for your specific accessory class, custom automation building for your devices, deeper accessory-state UI. Generic “control my lights” is what Home is for.
For Matter specifically: the hard part is the first-time setup story. If your accessory is Matter-only, MatterSupport gives you a polished system sheet. If it’s BLE + custom cloud (the old way), you’re rolling your own setup UI from scratch and asking users to install your app, sign up, and follow a 12-step wizard. The conversion delta is enormous. Ship Matter for new hardware.
TIP: Test your HomeKit code with the HomeKit Accessory Simulator (available on developer.apple.com). It mocks accessories so you can develop without buying hardware.
WARNING: Never cache the user’s HomeKit topology to a server you control. The Home graph (which devices, where, who has access) is some of the most sensitive smart-home data; App Review will scrutinize any backend that hoards it.
Interview corner
Junior: “How do you turn on a HomeKit bulb from a native iOS app?”
Add HomeKit entitlement +
NSHomeKitUsageDescription. CreateHMHomeManager, set yourself as delegate, wait forhomeManagerDidUpdateHomes. Find the user’s primary home → find the lightbulb accessory → find itsHMServiceTypeLightbulbservice → find itsHMCharacteristicTypePowerStatecharacteristic → callwriteValue(true).
Mid: “How would you commission a brand-new Matter accessory through your branded app?”
Add a MatterSupport extension target that handles the cryptographic onboarding payload. In the main app, when the user taps “Add device,” construct a
MatterAddDeviceRequestwith your ecosystem name and call.perform()— the system presents the standard Matter pairing sheet. Once commissioning completes, the accessory is in HomeKit and your app interacts with it viaHMAccessory. Apple shares the same accessory across other Matter ecosystems the user has connected (Google Home, Alexa).
Senior: “Design a third-party Home automation app that supports HomeKit and Matter, schedules complex automations, and integrates Apple Intelligence for natural-language scene creation.”
Wrap HomeKit in an async-friendly
HomeServiceactor (push the block-based delegate noise into a single bottleneck). Model the accessory tree as your own SwiftData entities mirroringHMAccessory/HMService/HMCharacteristic, refreshed on everyHMHomeManagerDelegatecallback — gives you SwiftUI-friendly observation. Automation builder UI lets users compose triggers (time, location, characteristic-changed) and action sets; persist asHMEventTrigger+HMActionSet. For natural-language (“turn off all upstairs lights at sunset”), expose your accessories asAppEntityand defineAppIntentactions (TurnOffAccessoryIntent, RunSceneIntent, ScheduleEventIntent). Apple Intelligence (iOS 18+) will invoke your intents as tools, parameterized from the user’s prompt. For Matter commissioning, ship a MatterSupport extension. For remote access, rely on the user’s home hub — no custom cloud needed unless you have features beyond HomeKit’s scope (e.g., analytics dashboards). Privacy: store no accessory state on your servers; if you must, encrypt with a per-user key the user controls.
Red flag: “We’re skipping HomeKit and shipping our own Bluetooth-direct app because HomeKit is too complicated.”
That choice forfeits remote access, Siri, automation, watch widgets, the Home app, cross-ecosystem (Matter) compatibility, and the user’s mental model. The pain of HomeKit’s older API is real but tiny next to those tradeoffs. Use HomeKit (or HomeKit + Matter) for any smart-home accessory in 2026.
Lab preview
HomeKit doesn’t have a dedicated lab in Phase 7 (it requires entitlement approval and physical or simulator accessories), but the Lab 7.2 — Widget extension stretch goal mentions exposing a “Turn off all lights” AppIntent — a clean way to surface HomeKit control via the unified intent system without building a full Home app.
Next: 7.12 — Sign in with Apple
7.12 — Sign in with Apple
Opening scenario
Your app’s signup screen currently has Google, Facebook, email/password — and you just got an App Review rejection: App Store Review Guideline 4.8 (“apps that use a third-party login service must also offer Sign in with Apple as an equivalent option”). The fix is one button and one delegate, plus a server change to accept Apple’s identity token. SIWA also delivers something Google/Facebook can’t: email obscuring (the user gets a xxx@privaterelay.appleid.com proxy address that forwards to their real inbox), no tracking, no email scraping — making it the highest-conversion auth choice for privacy-conscious users.
| Context | What it usually means |
|---|---|
| Reads “ASAuthorizationController” | Has shipped the button |
| Reads “identity token / authorization code” | Knows the token model |
| Reads “credential state” | Has handled sign-out / Apple ID changes |
| Reads “server-side verification” | Has a backend that validates Apple JWTs |
| Reads “App Transfer” | Has migrated apps with active SIWA users |
Concept → Why → How → Code
Concept
Sign in with Apple is an OAuth-flavored identity protocol with three concrete deliverables for your app:
user— a stable opaque identifier per (Apple ID, app team).identityToken— a signed JWT containing the user identifier and (on first sign-in) the email. Your server verifies this against Apple’s public keys.authorizationCode— a one-time code your server can exchange with Apple’s token endpoint for a refresh token (for long-lived server-side sessions).
You only get the user’s full name and email on the first sign-in. After that, Apple stops sending those fields. Your server must persist them on first contact.
Why
- Guideline 4.8 compliance — if you use any third-party auth, SIWA is required.
- Privacy story — the email-relay feature (“Hide My Email”) is genuinely useful and trusted.
- Conversion — Apple-ID users tap one button and they’re in. No email confirmation flow, no password.
- Cross-platform — works on iOS, macOS, watchOS, tvOS natively; on web/Android via the Sign in with Apple JS SDK with the same identity.
How — the button
import AuthenticationServices
import SwiftUI
struct SIWAButton: View {
let onComplete: (ASAuthorizationAppleIDCredential) -> Void
let onError: (Error) -> Void
var body: some View {
SignInWithAppleButton(.signIn) { request in
request.requestedScopes = [.fullName, .email]
} onCompletion: { result in
switch result {
case .success(let auth):
guard let cred = auth.credential as? ASAuthorizationAppleIDCredential else { return }
onComplete(cred)
case .failure(let error):
onError(error)
}
}
.signInWithAppleButtonStyle(.black)
.frame(height: 48)
.cornerRadius(8)
}
}
Handle the credential
struct LoginView: View {
@State private var session: UserSession?
var body: some View {
SIWAButton(
onComplete: handle(credential:),
onError: { print("SIWA error: \($0)") }
)
}
func handle(credential: ASAuthorizationAppleIDCredential) {
// First sign-in only:
let firstName = credential.fullName?.givenName
let lastName = credential.fullName?.familyName
let email = credential.email
// Always present:
let userID = credential.user // Stable ID
let identityToken = credential.identityToken // JWT bytes
let authorizationCode = credential.authorizationCode // One-time code
Task {
// POST identityToken to your server for verification + session creation
session = try? await AuthAPI.shared.signIn(
identityToken: identityToken,
authorizationCode: authorizationCode,
firstSignInName: PersonName(firstName: firstName, lastName: lastName),
firstSignInEmail: email
)
}
}
}
Server-side verification
Your server must:
- Decode the JWT (header + payload).
- Verify the signature against Apple’s public keys (rotated; cache and refetch from
https://appleid.apple.com/auth/keys). - Verify the
issishttps://appleid.apple.com. - Verify the
audmatches your bundle identifier. - Verify
expis in the future. - Use
sub(the stable user ID) to look up or create your account.
Optionally exchange the authorizationCode for a refresh token:
POST https://appleid.apple.com/auth/token
client_id=your.bundle.id
client_secret=<JWT_signed_with_p8>
code=<authorizationCode>
grant_type=authorization_code
The client_secret is itself a JWT you sign with your Apple .p8 (Sign in with Apple key downloaded from Developer Portal). The refresh token lets your server validate the user is still active over time without re-prompting.
Credential state on app launch
When the app launches, check whether the user is still signed in (they may have revoked credentials in iOS Settings → Apple ID → Sign-In & Security → “Apps Using Apple ID”):
@MainActor
func checkSignInStatus() async {
guard let storedUserID = UserDefaults.standard.string(forKey: "siwa.userID") else { return }
let provider = ASAuthorizationAppleIDProvider()
let state = try? await provider.credentialState(forUserID: storedUserID)
switch state {
case .authorized: // Still good
break
case .revoked, .notFound:
// User signed out via system settings — clear local session, return to login
await session.signOut()
case .transferred:
// App was transferred between teams (rare) — re-auth required
break
default: break
}
}
Listen for revocation in real-time
let center = NotificationCenter.default
center.addObserver(forName: ASAuthorizationAppleIDProvider.credentialRevokedNotification,
object: nil, queue: .main) { _ in
Task { await sessionStore.signOut() }
}
Re-authentication (“Sign In with existing Apple ID”)
If you want a silent check on a screen (e.g., paywall), use a quiet variant:
let request = ASAuthorizationAppleIDProvider().createRequest()
request.requestedScopes = [.fullName, .email]
let controller = ASAuthorizationController(authorizationRequests: [request])
controller.performRequests(options: .preferImmediatelyAvailableCredentials)
With .preferImmediatelyAvailableCredentials, the system uses an existing token if available without showing UI.
Web / Android — Sign in with Apple JS
Add the Sign in with Apple JS button to your web app. Configure a Services ID in the Apple Developer Portal (separate from your bundle ID, with web domains + redirect URLs). The JS button posts the same identity token to your server, where the same verification code path applies. Result: web/Android users land on the same sub as the iOS user.
App Transfer caveat
If you ever transfer your app between Apple Developer teams, all existing SIWA users will get .transferred credential state on next check. You must perform a one-time team transfer migration: download the Apple team-transfer token, POST to Apple’s migration endpoint, and Apple gives you the mapping from old sub to new sub. Don’t transfer your app without budgeting for this work.
In the wild
- Spotify, Airbnb, Twitter / X — all support SIWA alongside other providers.
- Substack, Medium — popular SIWA users in the publishing space.
- WhatsApp, Telegram — don’t use SIWA (phone-number based).
- Dropbox, Notion — full SIWA + email relay.
- Apple’s own services (App Store, Apple Music, Apple TV+) — naturally.
Common misconceptions
- “You get the email every sign-in.” No — only the first. Persist it on first contact or you’ll lose it.
- “The
useridentifier is global.” No — it’s stable per (Apple ID, app’s team). Two different apps from the same Apple ID get the sameuseronly if they’re under the same Developer Team. - “You don’t need a server for SIWA.” You can technically use it for local-only auth (just store the
user), but for any account that survives app deletion or syncs across devices, server-side verification is required. - “
credential.emailis the user’s real email.” It may be thexxx@privaterelay.appleid.comproxy if the user chose “Hide My Email.” Treat both the same way; never assume you can correlate to a “real” address. - “SIWA is required for all apps.” Only required if you use another third-party auth (Google, Facebook, etc.). Pure email/password is OK, as is no auth at all.
Seasoned engineer’s take
Sign in with Apple is one of those rare APIs where Apple did the integration work right: the button is system-styled, the privacy story is clean, the server-side primitives are standard JWT. The mistakes teams make are universally on the persistence and lifecycle side, not the API side:
- Persist the name and email on first sign-in or never see them again. Build the server endpoint to accept them and store on the user row.
- Check
credentialStateon app launch. If the user revoked you in iOS Settings, you must sign them out client-side. Apps that don’t do this present a broken “I’m logged in but every API call returns 401” state. - Subscribe to
credentialRevokedNotification. For immediate sign-out when revocation happens mid-session. - Don’t email the
xxx@privaterelay.appleid.comaddress from a domain you haven’t registered with Apple Mail Service. Apple requires you to register your sending domain via the developer portal or relay emails will bounce.
For greenfield apps: make SIWA the default auth choice. Conversion data across multiple apps consistently shows SIWA outperforming email/password and Google for users on iOS. Combine it with passkeys (Chapter 9.6) for users without an Apple ID, and you have a no-password app.
TIP: During development, you can revoke your own dev app from iOS Settings → Apple ID → Sign-In & Security → Apps Using Apple ID. Useful for testing the
.revokedcredential state path without making 50 test accounts.
WARNING: If you store the
identityTokenon disk in plain text, you’ve stored a signed assertion that anyone with the file can use to impersonate the user to your server (until expiry, typically ~10 min). Either don’t store it, or store it in Keychain.
Interview corner
Junior: “How do you add a Sign in with Apple button to a SwiftUI screen?”
Add the
AuthenticationServicesimport. UseSignInWithAppleButton(.signIn)withrequestedScopes = [.fullName, .email]in the request closure. In the completion closure, downcastauth.credentialtoASAuthorizationAppleIDCredentialand readuser,identityToken,authorizationCode, optionallyfullName/
Mid: “How do you handle the user revoking your app’s Apple ID access from iOS Settings?”
Two layers. (1) On every app foreground / launch, call
ASAuthorizationAppleIDProvider().credentialState(forUserID: storedUserID). If it returns.revokedor.notFound, sign the user out locally. (2) Subscribe toASAuthorizationAppleIDProvider.credentialRevokedNotificationfor real-time revocation. Both layers point to the samesignOut()function that clears Keychain + local DB and returns the user to the login screen.
Senior: “Design the server-side validation flow for a SIWA-enabled app, including refresh tokens and a Sign in with Apple JS web counterpart.”
Single
/auth/appleendpoint acceptsidentityTokenand (first sign-in)authorizationCode. Server: (1) Parse JWT header to getkid; fetch Apple’s JWKS fromappleid.apple.com/auth/keys(cache 24h, refresh on cache miss). Verify signature with the matching key. (2) Verifyiss,aud(must match bundle ID for native, Services ID for web), andexp. (3) Usesubas the stable user identifier. Upsert user; on insert, persist any provided email/name. (4) IfauthorizationCodepresent, sign a client-secret JWT with your.p8key (5–6-month expiry on the secret JWT), POST toappleid.apple.com/auth/tokento exchange for a refresh token. Store refresh token encrypted at rest. (5) Issue your own server session (JWT or session cookie). For periodic re-validation, hit/auth/tokenwith the stored refresh token to confirm the Apple side is still valid; if it returns an error, revoke the user’s server session. Web counterpart: Configure a Services ID with allowed domains + redirect URLs; the Sign in with Apple JS button posts the same identity token. Same/auth/appleendpoint, same verification code path — only difference is theaudclaim being the Services ID instead of bundle ID, which the server validates against an allow-list.
Red flag: “We just trust credential.email and use it as the primary key for the user account.”
Three problems: (1) On second sign-in the email is nil. (2) The email may be a relay address (changes if the user re-creates the relay). (3) The user might use SIWA on iOS and email/password elsewhere — multiple “primary keys” for the same person. Always key on
sub(theuseridentifier), treat email as a profile field.
Lab preview
Lab 7.4 — Sign in with Apple auth flow builds the complete pipeline: button → token capture → mock server verification → Keychain session → credential-state re-check → revocation handling. Pair with Chapter 9 (Security) for the production version with real JWT validation and refresh tokens.
Next: Lab 7.1 — Weather + Map app
Lab 7.1 — Weather + Map app
Goal: Build a SwiftUI app that shows the user’s current location on a map, drops annotations for nearby points of interest, and overlays current weather conditions from WeatherKit.
Time: 90–120 minutes.
Prereqs:
- Xcode 16+, iOS 18+ deployment target.
- A paid Apple Developer Program account (WeatherKit requires entitlement).
- Real device for first run (Simulator works for most paths but location coaching is awkward).
Setup
- New Xcode project → App → SwiftUI → name
WeatherMapLab. - Project → Signing & Capabilities → + Capability → WeatherKit.
- Apple Developer portal → Certificates, Identifiers & Profiles → your App ID → enable WeatherKit. Allow ~30 min for propagation if first time.
Info.plistkeys:NSLocationWhenInUseUsageDescription= “We show your local weather on the map.”NSLocationTemporaryUsageDescriptionDictionary={ "PreciseForWeather" : "Precise location gives more accurate forecasts." }
Build
LocationManager
Create LocationManager.swift:
import CoreLocation
import Observation
@Observable
@MainActor
final class LocationManager: NSObject, CLLocationManagerDelegate {
private let manager = CLLocationManager()
var lastLocation: CLLocation?
var authorization: CLAuthorizationStatus = .notDetermined
override init() {
super.init()
manager.delegate = self
manager.desiredAccuracy = kCLLocationAccuracyHundredMeters
authorization = manager.authorizationStatus
}
func requestWhenInUse() { manager.requestWhenInUseAuthorization() }
func startUpdates() { manager.startUpdatingLocation() }
nonisolated func locationManagerDidChangeAuthorization(_ m: CLLocationManager) {
Task { @MainActor in
authorization = m.authorizationStatus
if authorization == .authorizedWhenInUse { startUpdates() }
}
}
nonisolated func locationManager(_ m: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
guard let location = locations.last else { return }
Task { @MainActor in lastLocation = location }
}
nonisolated func locationManager(_ m: CLLocationManager, didFailWithError error: Error) {}
}
WeatherService wrapper
Create WeatherCache.swift:
import WeatherKit
import CoreLocation
actor WeatherCache {
static let shared = WeatherCache()
private let service = WeatherService.shared
private var cache: [String: (Weather, Date)] = [:]
private let ttl: TimeInterval = 15 * 60
func currentWeather(at location: CLLocation) async throws -> CurrentWeather {
let key = bucketKey(for: location)
if let (cached, ts) = cache[key], Date().timeIntervalSince(ts) < ttl {
return cached.currentWeather
}
let weather = try await service.weather(for: location)
cache[key] = (weather, .now)
return weather.currentWeather
}
private func bucketKey(for location: CLLocation) -> String {
let lat = (location.coordinate.latitude * 100).rounded() / 100
let lon = (location.coordinate.longitude * 100).rounded() / 100
return "\(lat),\(lon)"
}
}
Mock POIs
Create POIs.swift:
import CoreLocation
import MapKit
struct POI: Identifiable, Hashable {
let id = UUID()
let name: String
let coordinate: CLLocationCoordinate2D
let systemImage: String
}
extension POI {
static func near(_ location: CLLocation) -> [POI] {
let c = location.coordinate
let offset = 0.005
return [
POI(name: "Coffee", coordinate: .init(latitude: c.latitude + offset, longitude: c.longitude),
systemImage: "cup.and.saucer.fill"),
POI(name: "Park", coordinate: .init(latitude: c.latitude, longitude: c.longitude + offset),
systemImage: "tree.fill"),
POI(name: "Pharmacy", coordinate: .init(latitude: c.latitude - offset, longitude: c.longitude + offset),
systemImage: "cross.case.fill"),
]
}
}
Main view
Replace ContentView.swift:
import SwiftUI
import MapKit
import WeatherKit
struct ContentView: View {
@State private var location = LocationManager()
@State private var cameraPosition: MapCameraPosition = .automatic
@State private var currentWeather: CurrentWeather?
@State private var pois: [POI] = []
@State private var weatherError: String?
var body: some View {
ZStack(alignment: .top) {
Map(position: $cameraPosition) {
UserAnnotation()
ForEach(pois) { poi in
Annotation(poi.name, coordinate: poi.coordinate) {
Image(systemName: poi.systemImage)
.padding(8)
.background(.thinMaterial, in: .circle)
}
}
}
.mapStyle(.standard)
.mapControls {
MapUserLocationButton()
MapCompass()
}
.ignoresSafeArea()
.task {
location.requestWhenInUse()
}
.onChange(of: location.lastLocation) { _, newValue in
guard let newValue else { return }
pois = POI.near(newValue)
cameraPosition = .region(.init(
center: newValue.coordinate,
latitudinalMeters: 1500, longitudinalMeters: 1500
))
Task {
do {
currentWeather = try await WeatherCache.shared.currentWeather(at: newValue)
} catch {
weatherError = error.localizedDescription
}
}
}
if let weather = currentWeather {
WeatherBadge(weather: weather)
.padding()
} else if let err = weatherError {
Text("Weather: \(err)")
.padding(8)
.background(.regularMaterial, in: .capsule)
.padding()
}
}
.safeAreaInset(edge: .bottom) {
HStack {
Link("Weather", destination: WeatherAttribution.legalPageURL)
.font(.caption2)
Spacer()
Link("Apple Weather", destination: URL(string: "https://weatherkit.apple.com/legal-attribution.html")!)
.font(.caption2)
}
.padding(.horizontal)
}
}
}
struct WeatherBadge: View {
let weather: CurrentWeather
var body: some View {
HStack(spacing: 12) {
Image(systemName: weather.symbolName)
.font(.title2)
VStack(alignment: .leading, spacing: 2) {
Text(weather.temperature.formatted())
.font(.headline)
Text(weather.condition.description)
.font(.caption)
.foregroundStyle(.secondary)
}
}
.padding(.horizontal, 14)
.padding(.vertical, 10)
.background(.regularMaterial, in: .capsule)
}
}
#Preview { ContentView() }
Run
Build to a real device. Tap Allow While Using App at the prompt. Within seconds you should see your map centered on your location, three annotated POIs around you, and a weather capsule with the current condition + temperature.
Stretch
- Hourly forecast strip at the bottom: fetch
weather.hourlyForecastand render withScrollView(.horizontal)showing the next 12 hours. - Weather-based POI filtering: if
weather.conditionis.rain, hide outdoor-only POIs. - Map style toggle: a
Pickerswitching between.standard,.imagery,.hybrid. - Severe weather alerts: fetch
.alertsfrom WeatherKit and present a red banner when present. - AR placement: tapping a POI presents a
RealityView(Chapter 7.7) with a 3D weather icon hovering at the user’s eye height.
Notes
- WeatherKit’s first call on a fresh device often takes 2–4 seconds. Build a loading state.
- WeatherKit’s free tier is 500K calls/month per app — the bucketed cache above keeps you well under that for typical traffic.
- The attribution links are mandatory by WeatherKit’s terms. Missing attribution gets your app rejected (and could lose your WeatherKit entitlement).
- If you hit “Forecast unavailable for this location,” WeatherKit lacks coverage there (very rare, mostly Arctic / Antarctic).
Next: Lab 7.2 — Widget extension
Lab 7.2 — Widget extension
Goal: Add a WidgetKit extension to an existing app. Render a Home Screen + Lock Screen + (optional) Watch complication widget showing a “Habits completed today” count. Include an interactive Button(intent:) that bumps the count, and an optional Live Activity for a “habit streak in progress” timer.
Time: 90–150 minutes.
Prereqs: Xcode 16+, iOS 18+. Real device recommended for Lock Screen + Live Activity testing.
Setup
- Open or create a SwiftUI app called
HabitWidgetLab. Bundle ID:com.example.HabitWidgetLab. - File → New → Target → Widget Extension.
- Name:
HabitWidget. - Uncheck “Include Configuration App Intent” (we’ll add a custom one manually).
- Check “Include Live Activity”.
- Name:
- Add both targets (app + widget) to a shared App Group:
- Project → Signing & Capabilities → + Capability → App Groups.
- Group ID:
group.com.example.HabitWidgetLab. - Repeat for the widget target.
- Add App Groups capability to the Live Activity target if present (Xcode usually nests it inside the widget target).
Build
Shared model (in the main app target, also added to widget target)
Shared/HabitStore.swift:
import Foundation
import WidgetKit
struct HabitState: Codable {
var completedToday: Int
var totalToday: Int
var lastUpdate: Date
}
enum HabitStore {
static let groupID = "group.com.example.HabitWidgetLab"
private static let key = "habitState"
static var defaults: UserDefaults {
UserDefaults(suiteName: groupID)!
}
static func read() -> HabitState {
guard let data = defaults.data(forKey: key),
let state = try? JSONDecoder().decode(HabitState.self, from: data)
else { return HabitState(completedToday: 0, totalToday: 5, lastUpdate: .now) }
return state
}
static func write(_ state: HabitState) {
guard let data = try? JSONEncoder().encode(state) else { return }
defaults.set(data, forKey: key)
WidgetCenter.shared.reloadAllTimelines()
}
static func completeOne() {
var state = read()
if state.completedToday < state.totalToday {
state.completedToday += 1
state.lastUpdate = .now
}
write(state)
}
static func resetToday() {
write(HabitState(completedToday: 0, totalToday: 5, lastUpdate: .now))
}
}
Add this file to both the app target and the widget extension target (File Inspector → Target Membership).
App Intent (shared)
Shared/CompleteHabitIntent.swift:
import AppIntents
import WidgetKit
struct CompleteHabitIntent: AppIntent {
static var title: LocalizedStringResource = "Complete a Habit"
static var description = IntentDescription("Marks one habit as completed today.")
@MainActor
func perform() async throws -> some IntentResult {
HabitStore.completeOne()
return .result()
}
}
struct ResetHabitsIntent: AppIntent {
static var title: LocalizedStringResource = "Reset Today"
@MainActor
func perform() async throws -> some IntentResult {
HabitStore.resetToday()
return .result()
}
}
Add to both targets.
Widget code
Replace the generated HabitWidget.swift:
import WidgetKit
import SwiftUI
import AppIntents
struct HabitEntry: TimelineEntry {
let date: Date
let state: HabitState
}
struct HabitProvider: TimelineProvider {
func placeholder(in context: Context) -> HabitEntry {
HabitEntry(date: .now, state: HabitState(completedToday: 2, totalToday: 5, lastUpdate: .now))
}
func getSnapshot(in context: Context, completion: @escaping (HabitEntry) -> Void) {
completion(HabitEntry(date: .now, state: HabitStore.read()))
}
func getTimeline(in context: Context, completion: @escaping (Timeline<HabitEntry>) -> Void) {
let entry = HabitEntry(date: .now, state: HabitStore.read())
completion(Timeline(entries: [entry], policy: .never))
}
}
struct HabitWidgetView: View {
@Environment(\.widgetFamily) var family
let entry: HabitEntry
var body: some View {
switch family {
case .accessoryCircular:
Gauge(value: progress) {
Image(systemName: "checkmark.circle")
} currentValueLabel: {
Text("\(entry.state.completedToday)")
}
.gaugeStyle(.accessoryCircularCapacity)
case .accessoryRectangular:
VStack(alignment: .leading) {
Text("Today's Habits")
.font(.caption)
Text("\(entry.state.completedToday)/\(entry.state.totalToday)")
.font(.title2.bold())
}
case .accessoryInline:
Text("Habits: \(entry.state.completedToday)/\(entry.state.totalToday)")
default:
VStack(spacing: 8) {
Text("Today")
.font(.caption)
.foregroundStyle(.secondary)
Text("\(entry.state.completedToday) / \(entry.state.totalToday)")
.font(.title.bold())
HStack {
Button(intent: CompleteHabitIntent()) {
Image(systemName: "plus")
}
.buttonStyle(.borderedProminent)
Button(intent: ResetHabitsIntent()) {
Image(systemName: "arrow.counterclockwise")
}
.buttonStyle(.bordered)
}
}
.padding()
}
}
private var progress: Double {
guard entry.state.totalToday > 0 else { return 0 }
return Double(entry.state.completedToday) / Double(entry.state.totalToday)
}
}
struct HabitWidget: Widget {
let kind = "HabitWidget"
var body: some WidgetConfiguration {
StaticConfiguration(kind: kind, provider: HabitProvider()) { entry in
HabitWidgetView(entry: entry)
.containerBackground(.fill.tertiary, for: .widget)
}
.configurationDisplayName("Habit Tracker")
.description("See and bump today's progress.")
.supportedFamilies([
.systemSmall, .systemMedium,
.accessoryCircular, .accessoryRectangular, .accessoryInline,
])
}
}
Live Activity (the “streak in progress” timer)
HabitStreakAttributes.swift (in widget target, also Live Activity target if separate):
import ActivityKit
import SwiftUI
import WidgetKit
struct HabitStreakAttributes: ActivityAttributes {
public struct ContentState: Codable, Hashable {
var startedAt: Date
var goalMinutes: Int
}
var habitName: String
}
HabitStreakActivityWidget.swift:
import ActivityKit
import WidgetKit
import SwiftUI
struct HabitStreakActivityWidget: Widget {
var body: some WidgetConfiguration {
ActivityConfiguration(for: HabitStreakAttributes.self) { context in
// Lock Screen / Notification Center presentation
VStack(alignment: .leading) {
Text(context.attributes.habitName).font(.headline)
Text(timerInterval: context.state.startedAt...context.state.startedAt.addingTimeInterval(Double(context.state.goalMinutes * 60)))
.font(.title2.monospacedDigit())
}
.padding()
.activityBackgroundTint(.indigo.opacity(0.2))
} dynamicIsland: { context in
DynamicIsland {
DynamicIslandExpandedRegion(.leading) {
Image(systemName: "checkmark.circle.fill")
}
DynamicIslandExpandedRegion(.trailing) {
Text(timerInterval: context.state.startedAt...context.state.startedAt.addingTimeInterval(Double(context.state.goalMinutes * 60)))
.monospacedDigit()
}
DynamicIslandExpandedRegion(.bottom) {
Text(context.attributes.habitName)
}
} compactLeading: {
Image(systemName: "checkmark.circle")
} compactTrailing: {
Text(timerInterval: context.state.startedAt...context.state.startedAt.addingTimeInterval(Double(context.state.goalMinutes * 60)))
.monospacedDigit()
.frame(maxWidth: 50)
} minimal: {
Image(systemName: "checkmark.circle")
}
}
}
}
Widget bundle
HabitWidgetBundle.swift:
import WidgetKit
import SwiftUI
@main
struct HabitWidgetBundle: WidgetBundle {
var body: some Widget {
HabitWidget()
HabitStreakActivityWidget()
}
}
Start the Live Activity from the app
Add a button to your app’s ContentView:
import ActivityKit
func startStreak() async {
let attrs = HabitStreakAttributes(habitName: "Morning meditation")
let state = HabitStreakAttributes.ContentState(startedAt: .now, goalMinutes: 10)
do {
let activity = try Activity.request(
attributes: attrs,
content: ActivityContent(state: state, staleDate: nil),
pushType: nil
)
print("Activity started: \(activity.id)")
} catch {
print("Failed: \(error)")
}
}
Info.plist of the app target:
NSSupportsLiveActivities= YES.
Build & test
- Run the app, tap “Start streak” → Live Activity appears on Lock Screen / Dynamic Island.
- Long-press Home Screen → + → search “Habit Tracker” → add small or medium widget. Tap
+/resetbuttons; the count updates without launching the app. - Long-press Lock Screen → Customize → add the accessory widget.
Stretch
- Configurable widget — convert to
AppIntentConfigurationletting the user pick a target habit count. - Watch complication — add
accessoryCornerfamily; build with a watchOS target sharing the same widget code. - CoreML hook — classify the most recent photo and surface its label on the widget (see Chapter 7.8).
- HealthKit step count — show today’s step total from HealthKit on the widget by reading from the same app group cache.
- Push-driven Live Activity — switch
pushType: .tokenand POST updates from a server (see Chapter 7.1 for APNs).
Notes
- Interactive widget Buttons require iOS 17+. On older OSes the buttons appear but tapping just opens the app.
- App Group is the single most error-prone step. If your widget shows stale data, double-check both targets share the same
group.*identifier and that you wrote to the sameUserDefaults(suiteName:). WidgetCenter.shared.reloadAllTimelines()after data writes is mandatory; widgets don’t poll.- Live Activities are budgeted by iOS — about 8 hours max active duration, fewer than 10 active across the system at once.
Next: Lab 7.3 — StoreKit 2 IAP
Lab 7.3 — StoreKit 2 IAP
Goal: Build a complete IAP paywall in SwiftUI: a non-consumable “Remove Ads,” monthly and yearly Pro subscriptions, an intro free trial on yearly, a restore-purchases button, and listening to Transaction.updates. Test everything against a local StoreKit Configuration file with no Apple Developer account required for the basics.
Time: 90–150 minutes.
Prereqs: Xcode 16+, iOS 18+ deployment target.
Setup
- New Xcode project → App → SwiftUI → name
StoreKitIAPLab. - File → New → File → App → StoreKit Configuration File → name
Products.storekit→ check “Sync with App Store Connect: No” (local-only). - In
Products.storekit, add three products:- Non-Consumable — ID
com.example.iaplab.removeAds, display “Remove Ads”, price $4.99. - Auto-Renewable Subscription — create subscription group
Pro:- ID
com.example.iaplab.pro.monthly, “Pro Monthly”, $9.99 / month. - ID
com.example.iaplab.pro.yearly, “Pro Yearly”, $79.99 / year. Add an Introductory Offer: free trial, 1 week, pay-as-you-go.
- ID
- Non-Consumable — ID
- Edit the scheme: Run → Options → StoreKit Configuration → select
Products.storekit.
Build
Store actor
Store.swift:
import StoreKit
import Observation
enum ProductID: String, CaseIterable {
case removeAds = "com.example.iaplab.removeAds"
case proMonthly = "com.example.iaplab.pro.monthly"
case proYearly = "com.example.iaplab.pro.yearly"
}
enum StoreError: Error { case unverified, productNotFound }
@Observable
@MainActor
final class Store {
var products: [Product] = []
var ownedProductIDs: Set<String> = []
var subscriptionState: Product.SubscriptionInfo.RenewalState?
var isLoading = false
var lastError: String?
private var updateListener: Task<Void, Never>?
init() {
updateListener = listenForTransactions()
Task {
await loadProducts()
await refreshEntitlements()
}
}
deinit { updateListener?.cancel() }
func loadProducts() async {
isLoading = true; defer { isLoading = false }
do {
let ids = ProductID.allCases.map(\.rawValue)
products = try await Product.products(for: ids).sorted { $0.price < $1.price }
} catch {
lastError = "Load failed: \(error.localizedDescription)"
}
}
func purchase(_ product: Product) async {
do {
let result = try await product.purchase()
switch result {
case .success(let verification):
let transaction = try verify(verification)
await refreshEntitlements()
await transaction.finish()
case .userCancelled, .pending: break
@unknown default: break
}
} catch {
lastError = "Purchase failed: \(error.localizedDescription)"
}
}
func restorePurchases() async {
do {
try await AppStore.sync()
await refreshEntitlements()
} catch {
lastError = "Restore failed: \(error.localizedDescription)"
}
}
func refreshEntitlements() async {
var owned: Set<String> = []
for await result in Transaction.currentEntitlements {
if case let .verified(transaction) = result {
owned.insert(transaction.productID)
}
}
ownedProductIDs = owned
// Subscription state
if let proGroup = products.first(where: { $0.subscription != nil })?.subscription {
let statuses = (try? await proGroup.status) ?? []
subscriptionState = statuses.first?.state
}
}
private func verify<T>(_ result: VerificationResult<T>) throws -> T {
switch result {
case .unverified: throw StoreError.unverified
case .verified(let value): return value
}
}
private func listenForTransactions() -> Task<Void, Never> {
Task.detached(priority: .background) { [weak self] in
for await result in Transaction.updates {
guard let self else { return }
guard case let .verified(transaction) = result else { continue }
await self.refreshEntitlements()
await transaction.finish()
}
}
}
}
Paywall UI
PaywallView.swift:
import SwiftUI
import StoreKit
struct PaywallView: View {
@Environment(Store.self) private var store
var body: some View {
NavigationStack {
VStack(spacing: 24) {
header
if store.isLoading && store.products.isEmpty {
ProgressView()
} else {
productList
}
Spacer()
Button("Restore Purchases") {
Task { await store.restorePurchases() }
}
.font(.callout)
if let error = store.lastError {
Text(error)
.font(.caption)
.foregroundStyle(.red)
.multilineTextAlignment(.center)
}
}
.padding()
.navigationTitle("Upgrade")
}
}
private var header: some View {
VStack(spacing: 8) {
Image(systemName: "sparkles")
.font(.system(size: 56))
.foregroundStyle(.tint)
Text("Unlock everything")
.font(.title.bold())
Text("Remove ads forever, or go Pro for premium features.")
.multilineTextAlignment(.center)
.foregroundStyle(.secondary)
}
}
private var productList: some View {
VStack(spacing: 12) {
ForEach(store.products) { product in
ProductRow(product: product, owned: store.ownedProductIDs.contains(product.id))
.environment(store)
}
}
}
}
struct ProductRow: View {
@Environment(Store.self) private var store
let product: Product
let owned: Bool
var body: some View {
Button {
guard !owned else { return }
Task { await store.purchase(product) }
} label: {
HStack {
VStack(alignment: .leading, spacing: 2) {
Text(product.displayName).font(.headline)
if let offer = product.subscription?.introductoryOffer {
Text("Free for \(offer.period.formatted())")
.font(.caption)
.foregroundStyle(.green)
} else if let sub = product.subscription {
Text("Renews every \(sub.subscriptionPeriod.formatted())")
.font(.caption)
.foregroundStyle(.secondary)
}
}
Spacer()
if owned {
Label("Owned", systemImage: "checkmark.seal.fill")
.foregroundStyle(.green)
} else {
Text(product.displayPrice)
.font(.headline)
.padding(.horizontal, 12).padding(.vertical, 6)
.background(.tint, in: .capsule)
.foregroundStyle(.white)
}
}
.padding()
.background(.thinMaterial, in: .rect(cornerRadius: 12))
}
.buttonStyle(.plain)
}
}
extension SubscriptionPeriod {
func formatted() -> String {
let n = value
switch unit {
case .day: return n == 1 ? "1 day" : "\(n) days"
case .week: return n == 1 ? "week" : "\(n) weeks"
case .month: return n == 1 ? "month" : "\(n) months"
case .year: return n == 1 ? "year" : "\(n) years"
@unknown default: return "\(n)"
}
}
}
App entry
StoreKitIAPLabApp.swift:
import SwiftUI
@main
struct StoreKitIAPLabApp: App {
@State private var store = Store()
var body: some Scene {
WindowGroup {
ContentView()
.environment(store)
}
}
}
struct ContentView: View {
@Environment(Store.self) var store
@State private var showPaywall = false
var body: some View {
VStack(spacing: 16) {
Text("Demo App")
.font(.largeTitle)
if store.ownedProductIDs.contains(ProductID.removeAds.rawValue) {
Label("No ads ✨", systemImage: "checkmark.circle.fill")
.foregroundStyle(.green)
} else {
Text("(banner ad placeholder)")
.padding()
.background(.yellow.opacity(0.3), in: .rect(cornerRadius: 8))
}
if isPro {
Label("Pro subscriber", systemImage: "star.fill")
.foregroundStyle(.yellow)
}
Button("Upgrade…") { showPaywall = true }
.buttonStyle(.borderedProminent)
}
.sheet(isPresented: $showPaywall) {
PaywallView()
}
}
private var isPro: Bool {
store.ownedProductIDs.contains(ProductID.proMonthly.rawValue) ||
store.ownedProductIDs.contains(ProductID.proYearly.rawValue)
}
}
Test
Run on the simulator. Tap Upgrade → tap any product → the StoreKit testing flow appears (no real money, no Apple ID). After purchase, the row should switch to “Owned” and the ad placeholder/Pro badge updates.
Test the lifecycle:
- Manage Transactions → Debug menu in Xcode (with the storekit file selected, you get Editor → “Manage StoreKit Transactions”). From here you can refund, expire, ask-to-buy-approve, and renew.
- Refund a subscription → re-open the app → the badge should disappear (caught by
Transaction.updates). - Manually expire the subscription → next entitlement refresh shows it gone.
Stretch
- Subscription status banner: when
subscriptionState == .inGracePeriodor.inBillingRetryPeriod, show a yellow “Update your payment method” banner with a deep link to Settings. - Promotional offers: add a winback offer in
Products.storekitand surface it conditionally. - Server-side validation stub: write a tiny Swift Vapor app that accepts the JWS transaction and verifies it against Apple’s public key (covered in Chapter 9.7). For the lab, mock it locally.
- Family Sharing badge: detect via
transaction.ownershipType == .familySharedand show a “Shared by Family” tag. - Sandbox testing: configure a real Sandbox tester in App Store Connect, sign into the simulator/device with that account, switch the scheme back to “Use Sandbox” → exercise the same flows end-to-end.
Notes
- Subscriptions in the local StoreKit file renew aggressively fast (configurable in Manage Transactions). Don’t be alarmed if “1 month” passes in 5 seconds.
Transaction.currentEntitlementsis the single source of truth. Do not persist “isPro” anywhere else.- For real shipping, you must declare every IAP in App Store Connect with screenshots and metadata; the local
.storekitfile is dev-only. AppStore.sync()triggers an Apple ID sign-in prompt — only call from explicit “Restore Purchases” taps, never from app launch.
Next: Lab 7.4 — Sign in with Apple auth flow
Lab 7.4 — Sign in with Apple auth flow
Goal: Build the complete client-side SIWA pipeline: button → credential capture → Keychain-persisted session → credential-state re-check on launch → revocation handling. Server-side validation is mocked (a 30-line local “server” that performs JWT structural validation only; production validation requires fetching Apple’s JWKS — out of scope for a lab).
Time: 60–90 minutes.
Prereqs: Xcode 16+, real iOS 18+ device (Simulator’s SIWA flow is fragile; use a device). Apple ID signed into the device.
Setup
- New Xcode project → App → SwiftUI → name
SIWALab. Bundle IDcom.example.siwalab. - Project → Signing & Capabilities → + Capability → Sign in with Apple.
- No additional
Info.plistkeys required for SIWA itself.
Build
Keychain helper
Keychain.swift:
import Foundation
import Security
enum Keychain {
static func save(_ data: Data, for key: String) {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: key
]
SecItemDelete(query as CFDictionary)
let attrs = query.merging([kSecValueData as String: data]) { $1 }
SecItemAdd(attrs as CFDictionary, nil)
}
static func load(key: String) -> Data? {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: key,
kSecReturnData as String: true,
kSecMatchLimit as String: kSecMatchLimitOne
]
var item: AnyObject?
let status = SecItemCopyMatching(query as CFDictionary, &item)
return status == errSecSuccess ? item as? Data : nil
}
static func delete(key: String) {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: key
]
SecItemDelete(query as CFDictionary)
}
}
Session model
UserSession.swift:
import Foundation
struct UserSession: Codable, Equatable {
var userID: String
var firstName: String?
var lastName: String?
var email: String?
var identityTokenSummary: String // truncated JWT for display only
var signedInAt: Date
var displayName: String {
[firstName, lastName].compactMap { $0 }.joined(separator: " ").nilIfEmpty
?? email
?? "Apple User"
}
}
private extension String {
var nilIfEmpty: String? { isEmpty ? nil : self }
}
Mock “server” — JWT structural validation
MockAuthAPI.swift:
import Foundation
enum MockAuthAPI {
/// In production this lives on your server. It would:
/// 1) Fetch Apple's JWKS from https://appleid.apple.com/auth/keys
/// 2) Verify the JWT signature against the key matching the `kid` header
/// 3) Verify iss == "https://appleid.apple.com", aud == your bundle id, exp > now
/// 4) Return your own session token tied to `sub`
///
/// This mock only checks structural integrity and returns the decoded payload.
static func signIn(identityToken: Data, authorizationCode: Data) throws -> [String: Any] {
let jwt = String(decoding: identityToken, as: UTF8.self)
let parts = jwt.split(separator: ".")
guard parts.count == 3 else { throw AuthError.malformedToken }
let payloadSegment = String(parts[1])
guard let payloadData = base64URLDecode(payloadSegment),
let json = try JSONSerialization.jsonObject(with: payloadData) as? [String: Any]
else { throw AuthError.malformedToken }
// Spot-check claims (no signature verification in mock)
guard let iss = json["iss"] as? String, iss == "https://appleid.apple.com" else {
throw AuthError.invalidIssuer
}
guard let exp = json["exp"] as? TimeInterval, Date(timeIntervalSince1970: exp) > .now else {
throw AuthError.tokenExpired
}
return json
}
private static func base64URLDecode(_ s: String) -> Data? {
var str = s.replacingOccurrences(of: "-", with: "+")
.replacingOccurrences(of: "_", with: "/")
let pad = 4 - str.count % 4
if pad != 4 { str.append(String(repeating: "=", count: pad)) }
return Data(base64Encoded: str)
}
}
enum AuthError: LocalizedError {
case malformedToken, invalidIssuer, tokenExpired
var errorDescription: String? {
switch self {
case .malformedToken: "Token is not a valid JWT."
case .invalidIssuer: "Token issuer is not Apple."
case .tokenExpired: "Token expired."
}
}
}
Auth manager
AuthManager.swift:
import AuthenticationServices
import Observation
@Observable
@MainActor
final class AuthManager {
var session: UserSession?
var lastError: String?
private let sessionKey = "siwa.session"
private var revocationObserver: NSObjectProtocol?
init() {
loadSession()
revocationObserver = NotificationCenter.default.addObserver(
forName: ASAuthorizationAppleIDProvider.credentialRevokedNotification,
object: nil, queue: .main
) { [weak self] _ in
Task { @MainActor [weak self] in self?.signOut() }
}
}
deinit {
if let obs = revocationObserver { NotificationCenter.default.removeObserver(obs) }
}
func handle(credential: ASAuthorizationAppleIDCredential) {
do {
guard let identityToken = credential.identityToken,
let authCode = credential.authorizationCode
else { throw AuthError.malformedToken }
let payload = try MockAuthAPI.signIn(identityToken: identityToken, authorizationCode: authCode)
let userID = (payload["sub"] as? String) ?? credential.user
// Email + name are only present on first sign-in
let firstName = credential.fullName?.givenName
let lastName = credential.fullName?.familyName
let email = credential.email ?? (payload["email"] as? String)
// Merge with existing (don't overwrite a previously-saved name with nil)
var newSession = session ?? UserSession(
userID: userID,
firstName: nil, lastName: nil, email: nil,
identityTokenSummary: "", signedInAt: .now
)
newSession.userID = userID
newSession.firstName = firstName ?? newSession.firstName
newSession.lastName = lastName ?? newSession.lastName
newSession.email = email ?? newSession.email
newSession.identityTokenSummary = String(String(decoding: identityToken, as: UTF8.self).prefix(40)) + "…"
newSession.signedInAt = .now
saveSession(newSession)
} catch {
lastError = error.localizedDescription
}
}
func checkCredentialState() async {
guard let session else { return }
let provider = ASAuthorizationAppleIDProvider()
let state = try? await provider.credentialState(forUserID: session.userID)
switch state {
case .authorized: break
case .revoked, .notFound, .transferred:
signOut()
default: break
}
}
func signOut() {
Keychain.delete(key: sessionKey)
session = nil
}
private func saveSession(_ s: UserSession) {
session = s
if let data = try? JSONEncoder().encode(s) {
Keychain.save(data, for: sessionKey)
}
}
private func loadSession() {
guard let data = Keychain.load(key: sessionKey),
let s = try? JSONDecoder().decode(UserSession.self, from: data)
else { return }
session = s
}
}
UI
ContentView.swift:
import SwiftUI
import AuthenticationServices
struct ContentView: View {
@State private var auth = AuthManager()
var body: some View {
Group {
if let session = auth.session {
signedInView(session)
} else {
signedOutView
}
}
.padding()
.task { await auth.checkCredentialState() }
}
private var signedOutView: some View {
VStack(spacing: 24) {
Image(systemName: "lock.shield.fill")
.font(.system(size: 64))
.foregroundStyle(.tint)
Text("Sign in to continue")
.font(.title2)
SignInWithAppleButton(.signIn) { req in
req.requestedScopes = [.fullName, .email]
} onCompletion: { result in
switch result {
case .success(let authResult):
if let cred = authResult.credential as? ASAuthorizationAppleIDCredential {
auth.handle(credential: cred)
}
case .failure(let error):
auth.lastError = error.localizedDescription
}
}
.signInWithAppleButtonStyle(.black)
.frame(height: 48)
if let err = auth.lastError {
Text(err).font(.caption).foregroundStyle(.red)
}
}
}
private func signedInView(_ session: UserSession) -> some View {
VStack(spacing: 16) {
Image(systemName: "person.circle.fill")
.font(.system(size: 64))
.foregroundStyle(.tint)
Text(session.displayName).font(.title)
if let email = session.email {
Text(email).font(.callout).foregroundStyle(.secondary)
}
VStack(alignment: .leading, spacing: 4) {
Label("User ID", systemImage: "key").font(.caption.bold())
Text(session.userID).font(.caption2.monospaced()).lineLimit(2)
Label("Token", systemImage: "doc.text").font(.caption.bold()).padding(.top, 8)
Text(session.identityTokenSummary).font(.caption2.monospaced())
}
.padding()
.background(.thinMaterial, in: .rect(cornerRadius: 12))
Button("Sign out", role: .destructive) { auth.signOut() }
.buttonStyle(.bordered)
}
}
}
App entry
import SwiftUI
@main
struct SIWALabApp: App {
var body: some Scene {
WindowGroup { ContentView() }
}
}
Test
Run on a real device with your Apple ID signed in.
- First sign-in: tap the SIWA button → choose Share My Email or Hide My Email → confirm with Face ID. You should land on the signed-in screen with your name and (relay) email.
- Force-quit & relaunch: the signed-in state persists (Keychain).
- Revoke via Settings: iOS Settings → Apple ID → Sign-In & Security → Apps Using Apple ID → find your app → Stop Using. Re-foreground the app → after
checkCredentialState, you should be signed out automatically. (You can also test by deleting the app on the device; second install gets.notFoundand the revocation observer / state check signs out cleanly.) - Second sign-in (same user): tap SIWA again. Notice
credential.emailis nil this time — Apple only sends it once. Your code merges, so the previously-saved email is retained.
Stretch
- Real server verification: replace
MockAuthAPIwith a Vapor or Express endpoint that fetches Apple’s JWKS, verifies the signature, exchanges theauthorizationCodefor a refresh token. Cache JWKS for 24h. - Background credential check: add a Background App Refresh task (Chapter 6.3 covered this) that calls
checkCredentialStateperiodically. - Passkey fallback: present a passkey-based sign-in alongside SIWA for users without an Apple ID (Chapter 9.6 covers passkeys).
- Sign in with Apple JS: stand up a tiny web page that uses the JS SDK and hits the same mock endpoint — verifies cross-platform identity continuity on the same
sub. - Account deletion: in your real backend, when the user deletes their account, call Apple’s
/auth/revoketo revoke the refresh token (required by App Store Guideline 5.1.1(v)).
Notes
- Simulator’s SIWA UX is flaky. Use a real device.
- The mock validation does not verify the JWT signature. Never ship this to production. Always validate on a server.
- App Store Guideline 5.1.1(v): apps that let users create accounts must also let them delete the account in-app. For SIWA users, you should call Apple’s revocation endpoint at the same time.
- App Transfer caveat: if you ever transfer your app to another team, plan a one-time team-transfer migration (Apple gives you the old-
sub→ new-submapping); existing users get.transferredstate otherwise.
Phase 7 complete. Phase 8 (Testing & Quality) covers unit, snapshot, UI, and CI testing patterns.
8.1 — Testing Philosophy
Opening scenario
Your team just shipped a release that broke the checkout flow on iPad. The bug? A ViewModel returned the wrong currency formatter when the locale was Swiss German. There were 412 unit tests. None of them exercised non-English locales. Coverage was 87%. The CTO wants to know how this happened “with all that testing.”
Coverage isn’t correctness. Tests measure what you decided was worth measuring. Most iOS teams test the wrong layer.
Context taxonomy
| Concept | Context | Why it matters | Common confusion |
|---|---|---|---|
| Unit test | Single function/type, no I/O | Fastest signal; runs in millis | “I tested my ViewController” → that’s integration |
| Integration test | 2+ types together, real boundaries (DB, network) | Catches wiring bugs | Slow, flaky, expensive — keep few |
| UI test | Whole app, simulated taps | Catches “doesn’t launch” regressions | Brittle, slowest tier, run in CI smoke |
| Snapshot test | Rendered view → image diff | Catches visual regressions cheaply | Wildly different across Xcode versions |
| Test pyramid | Many unit, fewer integration, fewest UI | Inverted = slow + flaky suite | “Testing trophy” is a competing model |
Concept → Why → How → Code
Concept: tests exist on a spectrum from “pure logic in a function” to “the whole app on a real device.” Each level costs more and breaks for more reasons. Build a portfolio of tests biased toward the cheap, fast end.
Why: a 30-second test suite gets run before every commit. A 30-minute suite gets run by CI only — and disabled when it goes red on a flaky day. Speed and reliability are correctness multipliers.
How: identify the layers in your app (Models, ViewModels, Services, Views) and write the bulk of tests where logic lives (ViewModels and Services). Don’t test SwiftUI body — Apple already tested that. Don’t test thin pass-through functions — they have no logic to verify.
Code — anatomy of a layered test strategy:
// ✅ Unit test — pure logic, no I/O, fast
func test_cartTotal_includesDiscount() {
let cart = Cart(items: [Item(price: 100)], discountPct: 10)
XCTAssertEqual(cart.total, 90)
}
// ✅ Integration test — ViewModel + mocked Service
func test_loadProducts_populatesItems() async throws {
let vm = ProductListViewModel(api: MockAPI(stub: [Product(id: "1")]))
await vm.load()
XCTAssertEqual(vm.items.count, 1)
}
// ⚠️ UI test — keep few, gate on critical user paths only
func test_userCanCompleteCheckout() {
let app = XCUIApplication(); app.launch()
app.buttons["Buy"].tap()
XCTAssertTrue(app.staticTexts["Order confirmed"].waitForExistence(timeout: 5))
}
What to test, what to skip
Test:
- ViewModels (state transitions, business logic)
- Services (parsing, validation, retry logic)
- Pure functions (formatters, calculators)
- Critical user flows (checkout, sign-in) — one UI test each
Skip:
- SwiftUI
body(Apple’s job) - Trivial getters/setters
- Generated code
descriptionoverrides nobody reads- Single-line wrappers around UIKit
In the wild
- Apple’s WWDC sample code has shockingly little test coverage — sample code is documentation, not production.
- Airbnb publicly stated they killed most of their UI tests because the maintenance cost exceeded the value.
- Square runs 5,000+ unit tests in under 90 seconds on their iOS codebase.
- Spotify maintains a strict pyramid: ~10k unit, ~500 integration, ~50 UI.
Common misconceptions
- “100% coverage = no bugs.” False. Coverage means “this line executed during a test”; it says nothing about whether you asserted the right thing.
- “More tests are always better.” A flaky test is worse than no test — it teaches your team to ignore red builds.
- “You should write tests for every PR.” Test what changed; refactoring untouched code to make it testable inflates PR diffs and reviewer fatigue.
- “UI tests replace QA.” They catch regressions in known paths only. Exploratory testing finds the bugs UI tests can’t.
- “Testing slows you down.” Untested code makes you afraid to refactor, which slows you down far more over a year.
Seasoned engineer’s take
The number that matters isn’t coverage — it’s how often you ship a change with confidence in under an hour. Teams obsessed with 90%+ coverage usually have brittle, mocked-to-oblivion tests that lock the implementation in place. Teams with 60% coverage on the right layers ship faster and break less. Aim for ViewModels and Services at near-100%; let Views drift toward 0%.
[!TIP] Run your test suite right now and time it. If it’s over 60 seconds, find your slowest 5 tests — they’re almost certainly doing real I/O that should be mocked.
[!WARNING] Tests that use real network calls, real timers, real file I/O, or
Task.sleepare not unit tests. They’re integration tests pretending to be unit tests, and they will eventually flake on CI.
Interview corner
Junior — “What’s the difference between a unit test and an integration test?” A unit test exercises one type in isolation with no I/O — typically a function or method on a struct. An integration test exercises multiple types together, possibly hitting a database, network, or filesystem. Unit tests are fast and deterministic; integration tests are slower and verify wiring.
Mid — “Your app has 90% coverage but you still ship bugs. What’s wrong?”
Coverage measures execution, not assertion strength. The team might be writing tests that exercise code paths without asserting the correct outcomes, or they might be testing trivial code (getters, generated code) while missing edge cases in business logic. Coverage on ViewModels and Services matters; coverage on View.body doesn’t.
Senior — “Design a testing strategy for a new payments feature.”
Pyramid first. ViewModels for the cart, the payment method picker, and the receipt — 100% unit coverage including locale and currency edge cases. Service layer (PaymentAPI, ReceiptValidator) — unit tested with mocked URL sessions, plus 2–3 integration tests against a sandbox endpoint behind a feature flag. One end-to-end XCUITest for the happy path. Snapshot tests for the receipt view at 3 dynamic type sizes. CI gate: full suite must run in under 5 minutes; UI tests separated into a nightly job. I’d also consider running the integration tier against a recorded HTTP fixture to keep PR builds offline.
Red flag — “I write integration tests for everything because they catch more bugs.” This person hasn’t felt the pain of a 40-minute CI build that flakes 30% of the time.
Lab preview
Lab 8.1 (TDD Feature) drives this home: you’ll write tests before the implementation for a NetworkClient, and feel firsthand how testability shapes design.
Next: XCTest Unit Testing
8.2 — XCTest Unit Testing
Opening scenario
You inherit a codebase with 800 tests written in five different styles: some use setUp, some use setUpWithError, some pre-create everything in init, some use synchronous expectations, some use async. Your new tests need to fit in. Which patterns are current Swift 6 + Xcode 16 best practice, and which are legacy noise?
XCTest has evolved across nine years. This chapter shows what to write in 2026.
Context taxonomy
| Concept | Context | Why it matters | Common confusion |
|---|---|---|---|
XCTestCase subclass | One file per type under test | Discoverability | One huge test class for everything |
setUp vs setUpWithError | Per-test fixture | throws lets you try in setup | Putting fixture creation in init (works but unconventional) |
XCTAssertEqual family | Value comparison | Clear diffs on failure | Using XCTAssertTrue(a == b) — loses diff |
async throws tests | Default for async code | First-class concurrency support | XCTestExpectation callbacks for async (legacy) |
XCTestExpectation | Notification/callback-driven | Still needed for non-async APIs | Using it for async/await — overkill |
XCTUnwrap | Optional that must exist | Auto-fails with line number | Force unwrap with ! — crashes the suite |
Concept → Why → How → Code
Concept: XCTestCase is an NSObject subclass; every method named test... becomes a test. Xcode discovers them via Objective-C runtime. Swift Testing (the new framework introduced in 2024) is replacing XCTest for new projects, but XCTest remains the production standard in 2026.
Why: XCTest is what every existing iOS codebase uses, ships with Xcode, runs on simulator + device + Mac + Linux (via swift-corelibs-xctest), and integrates with Xcode’s UI for one-click debugging into a failing assertion.
How: subclass XCTestCase, declare your subject under test (SUT) as a property, set it up in setUpWithError, tear it down in tearDownWithError, assert with XCTAssert* variants that produce diffs.
Code — canonical structure:
import XCTest
@testable import MyApp
final class CartTests: XCTestCase {
private var sut: Cart!
override func setUpWithError() throws {
try super.setUpWithError()
sut = Cart(items: [], discountPct: 0)
}
override func tearDownWithError() throws {
sut = nil
try super.tearDownWithError()
}
func test_total_emptyCart_returnsZero() {
XCTAssertEqual(sut.total, 0)
}
func test_total_withItems_sumsCorrectly() {
sut.add(Item(price: 10))
sut.add(Item(price: 15))
XCTAssertEqual(sut.total, 25)
}
}
Async tests (the only way you should write them in 2026)
func test_loadUser_returnsUser() async throws {
let service = UserService(api: MockAPI(stub: User(id: "1")))
let user = try await service.loadUser(id: "1")
XCTAssertEqual(user.id, "1")
}
No XCTestExpectation. No wait(for:). Just async throws. If you see expectation-based async tests in code review for new code, request changes.
When you DO still need XCTestExpectation
For APIs that take callbacks and have no async equivalent:
func test_notification_isPosted() {
let expectation = expectation(forNotification: .userDidLogin, object: nil)
AuthManager.shared.simulateLogin()
wait(for: [expectation], timeout: 1.0)
}
Assertion variants — pick the right one
XCTAssertEqual(actual, expected) // Diff on failure
XCTAssertNotEqual(actual, expected)
XCTAssertTrue(condition) // No diff, just true/false
XCTAssertNil(optional)
XCTAssertNotNil(optional)
XCTAssertThrowsError(try operation()) // Expects throw
XCTAssertNoThrow(try operation()) // Expects success
XCTAssertEqual(a, b, accuracy: 0.001) // Floating-point compare
let unwrapped = try XCTUnwrap(optional) // Optional → unwrapped or fail
XCTSkip — conditional skipping
func test_someThingThatNeedsM1() throws {
try XCTSkipIf(ProcessInfo.processInfo.machineHardwareName != "arm64",
"Requires Apple Silicon")
// ... rest of test
}
In the wild
- swift-corelibs-xctest — Apple’s open-source XCTest implementation for Linux server-side Swift; same API.
- swift-testing (the new framework) — uses
@Testmacro and#expect()macro. Apple introduced it at WWDC 2024 and ships it alongside XCTest. Most teams haven’t migrated yet — XCTest is still the safer choice for shipping today. - Quick/Nimble — once-popular BDD wrappers (
describe,it,expect). Mostly legacy now; new projects rarely adopt them.
Common misconceptions
- “
setUpruns once per class.” No — it runs before each test method. UsesetUp(class:)or static fixtures for once-per-class. - “
XCTAssertTrue(a == b)is the same asXCTAssertEqual(a, b).” Wrong — the equality version shows the actual and expected values in the failure message. The boolean version just says “false.” - “
asynctests automatically run on the main actor.” No — they run on the global executor unless your test class is@MainActor. If you’re testing UIKit/SwiftUI code, mark the class or test@MainActor. - “Force-unwrap is fine in tests.” A crash in
setUpkills the whole test class. UseXCTUnwrapso other tests still report. - “Order of test execution matters.” XCTest runs tests in alphabetical order by default, but never rely on this. Each test must be independent — that’s the contract.
Seasoned engineer’s take
Async/await is the single biggest improvement XCTest has seen. Convert callback-based tests to async throws whenever you touch them — your suite gets simpler and your error messages get better. Don’t migrate to swift-testing yet on shipping projects; the tooling (CI reporters, code coverage, third-party integrations) is still catching up. Wait until Xcode 17.
[!TIP] Add
XCTContext.runActivity(named:)blocks inside long tests to make the Xcode test report readable. Each activity becomes a collapsible section with its own timing.
[!WARNING] Never store state in static or singleton properties touched by tests. Each test runs in a fresh instance of your test class, but global state survives — leading to tests that pass alone and fail in suites.
Interview corner
Junior — “How do you set up state before each test?”
Override setUpWithError() and initialize properties there. tearDownWithError() runs after each test for cleanup. Both methods run once per test... method, not once per class.
Mid — “How do you test async code with XCTest?”
Declare the test as async throws and use await directly. Use XCTestExpectation only for callback-based APIs that don’t have an async variant. For testing race conditions, use @MainActor annotations to control thread context.
Senior — “Walk me through diagnosing a flaky test.”
First, run it 50 times in isolation with xcodebuild test -only-testing and -test-iterations 50. If it’s deterministic alone but flaky in the suite, the issue is shared state — singletons, file system, UserDefaults, or test ordering dependencies. If it flakes in isolation, look for real timers (DispatchQueue.main.asyncAfter, Task.sleep), real I/O, or async work that completes outside the awaited path. Replace real clocks with injected Clock protocols. Replace URLSession with a URLProtocol mock. If the test asserts on UI work, ensure the test is @MainActor. I’d also consider whether the test is asserting on observable behavior vs implementation detail — implementation-detail tests flake every time the implementation changes.
Red flag — “I just add XCTSkip to flaky tests.” That’s not fixing the test; it’s hiding the bug.
Lab preview
Lab 8.1 walks you through writing tests first in the XCTest style for a real NetworkClient, including async APIs and XCTUnwrap patterns.
Next: TDD in Swift
8.3 — TDD in Swift
Opening scenario
You join a team that swears by TDD. Your first ticket is a PriceCalculator that supports tax, discounts, and bulk pricing. Your tech lead says “write the failing test first.” You stare at the cursor. You don’t know what API the calculator should have yet. How do you write a test for code that doesn’t exist?
That hesitation is the point of TDD. It forces you to design the interface before the implementation, from the consumer’s perspective.
Context taxonomy
| Concept | Context | Why it matters | Common confusion |
|---|---|---|---|
| Red | Write a failing test | Forces interface design first | Skipping → write tests after, lose the design pressure |
| Green | Minimum code to pass | Resist the urge to over-implement | Writing the whole feature at once |
| Refactor | Clean up with green tests | Safety net for cleanup | Refactoring while still red |
| Triangulation | Add tests to force generalization | Avoids hardcoded return 42 | Writing the general code without the second test |
| Test list | Brainstorm test cases up front | Stay focused, avoid yak-shaving | Trying to write all tests at once |
Concept → Why → How → Code
Concept: Test-Driven Development is a discipline, not a religion. Write a failing test, write the smallest code to make it pass, refactor with the test as a safety net. Repeat in cycles of seconds to minutes.
Why: TDD pressures you to write small, testable units. Tightly-coupled designs are physically painful to test, so TDD organically pushes you toward dependency injection, single-responsibility, and clean boundaries. The tests themselves are a side effect — the real product is better architecture.
How:
- Brainstorm a short test list (5–10 cases).
- Pick the simplest case. Write the test. Run it. See it fail.
- Write the minimum code (even
return 0) to make it pass. - Add the next test that forces you to generalize.
- Refactor with confidence.
Code — TDD walkthrough of a PriceCalculator:
Red 1 — simplest case
func test_total_emptyCart_returnsZero() {
let calc = PriceCalculator()
XCTAssertEqual(calc.total(items: []), 0)
}
This won’t compile — PriceCalculator doesn’t exist. That’s red.
Green 1 — minimum to compile + pass
struct PriceCalculator {
func total(items: [Int]) -> Int { 0 }
}
Hard-coding 0 is correct. Don’t generalize until a second test forces you to.
Red 2 — force generalization
func test_total_singleItem_returnsItemPrice() {
let calc = PriceCalculator()
XCTAssertEqual(calc.total(items: [100]), 100)
}
Green 2
struct PriceCalculator {
func total(items: [Int]) -> Int { items.reduce(0, +) }
}
Red 3 — add discount behavior
func test_total_withDiscount_appliesPercentage() {
let calc = PriceCalculator()
XCTAssertEqual(calc.total(items: [100], discountPct: 10), 90)
}
Green 3
struct PriceCalculator {
func total(items: [Int], discountPct: Int = 0) -> Int {
let raw = items.reduce(0, +)
return raw - (raw * discountPct / 100)
}
}
Refactor
Three tests pass. Now make the API nicer:
struct PriceCalculator {
var discountPct: Int = 0
func total(items: [Int]) -> Int {
let raw = items.reduce(0, +)
return raw - (raw * discountPct / 100)
}
}
Update the tests to use the new shape, run them, all green. Move to the next test on the list.
TDD for ViewModels (the iOS sweet spot)
@MainActor
final class LoginViewModelTests: XCTestCase {
func test_initialState_buttonDisabled() {
let vm = LoginViewModel(api: MockAuthAPI())
XCTAssertFalse(vm.canSubmit)
}
func test_validEmailAndPassword_enablesButton() {
let vm = LoginViewModel(api: MockAuthAPI())
vm.email = "a@b.com"
vm.password = "longpass1"
XCTAssertTrue(vm.canSubmit)
}
func test_login_success_setsAuthenticated() async {
let vm = LoginViewModel(api: MockAuthAPI(stub: .success))
vm.email = "a@b.com"; vm.password = "longpass1"
await vm.submit()
XCTAssertTrue(vm.isAuthenticated)
}
}
Notice how testing forced the ViewModel to accept its API as a dependency (init(api:)), which is exactly the right design even without TDD.
In the wild
- Kent Beck invented TDD, wrote “Test-Driven Development by Example” (2003) — required reading.
- Uncle Bob’s “Three Rules of TDD” — controversial purist take: never write a line of production code without a failing test. Most working engineers treat this as aspirational, not literal.
- GitHub Copilot has made the green step trivially fast — but you still need to write the failing test yourself. Don’t let the AI design your API for you.
Common misconceptions
- “TDD slows you down.” First week, yes. After the muscle memory forms, total time-to-shippable is faster because you spend less time debugging.
- “TDD doesn’t work for UI code.” Correct — don’t TDD your
Viewbodies. TDD theViewModel/Reducerinstead, which is where the logic lives. - “You need to TDD every line.” No. TDD the logic. Skip TDD for thin glue, framework calls, and obvious mappings.
- “Tests written first are higher quality.” They’re higher quality at driving design. The assertion strength is the same as tests written after — what matters is whether you’re testing observable behavior.
- “TDD = 100% coverage.” TDD gives you ~90% on the code you wrote with it. The remaining 10% is glue, error paths you didn’t drive, and integration points.
Seasoned engineer’s take
TDD is most valuable when you’re uncertain about the API. For familiar code (one more endpoint added to your network layer), tests-after is fine and faster. For unfamiliar problems (a brand new domain object, a tricky algorithm), TDD pays for itself in three commits because the design pressure stops you from coding into a corner.
[!TIP] Keep the cycle small. If your red phase takes more than 90 seconds, the test you wrote is too big — split it into smaller cases.
[!WARNING] Don’t refactor while the bar is red. Refactoring requires green tests as a safety net; if you’re red, you’re flying blind on two fronts.
Interview corner
Junior — “What does red-green-refactor mean?” The TDD cycle. Red: write a test that fails. Green: write the minimum code to make it pass. Refactor: clean up with the passing test protecting you. Repeat in short cycles, usually under 10 minutes.
Mid — “Show me TDD on email validation.”
Start with test_empty_isInvalid returning false. Write func isValid(_ s: String) -> Bool { false }. Add test_simpleAt_isValid for "a@b" returning true. Implement s.contains("@"). Add edge cases (no domain, multiple @, whitespace) one at a time, generalizing the function each step. Done in 5 tests, 5 minutes.
Senior — “When does TDD hurt productivity?” TDD struggles when the API surface is dictated by an external system you don’t understand yet — fighting Core Animation, integrating an opaque third-party SDK, or exploring a new framework. In those cases, write a spike (untested prototype), throw it away, then TDD the cleaned-up version once you know the shape. TDD also pays poorly for thin orchestration code where there’s no logic to drive — pure pass-throughs to other services don’t benefit from test-first. I’d also consider that TDD doesn’t replace exploratory testing or property-based testing; those find different bugs.
Red flag — “I don’t write tests because TDD is too slow.” This person hasn’t tried it long enough to feel the payoff.
Lab preview
Lab 8.1 puts you through a full TDD cycle on a NetworkClient. You’ll write the tests with no implementation, watch them all fail, then implement piece by piece.
Next: Mocking & Dependency Injection
8.4 — Mocking & Dependency Injection
Opening scenario
Your UserProfileViewModel calls URLSession.shared directly to fetch a profile, reads UserDefaults.standard to get a feature flag, and calls Date() for the cache timestamp. You want to test it. You can’t — it talks to three singletons. The test would hit the network, depend on whatever was in UserDefaults from the last run, and fail at midnight when the date rolls over.
The fix isn’t mocking magic. It’s making those dependencies parameters.
Context taxonomy
| Concept | Context | Why it matters | Common confusion |
|---|---|---|---|
| Dependency Injection (DI) | Pass collaborators in via init | Lets tests substitute fakes | “I use DI” but everything is a singleton (X.shared inside types) |
| Protocol | Abstract dependency interface | Mockable seam | One protocol per type — over-engineered |
| Manual mock | Hand-written test double | Total control, zero deps | Tedious for large protocols |
| Mock library | Mockable, Cuckoo, Mockingbird | Generates mocks via macros | Macro tooling can be brittle across Xcode versions |
@testable import | Access internal symbols from tests | Test internals without making them public | Confused with import (only public symbols) |
URLProtocol mock | Intercept URLSession calls | Works with the real URLSession | Reinventing URLSession from scratch |
Concept → Why → How → Code
Concept: dependency injection means a type accepts its dependencies through its initializer (or function parameters), instead of constructing or fetching them itself. Combined with protocols, this lets tests inject fakes that record calls and return canned data.
Why: without DI, every test becomes an integration test against whatever globals happen to exist. With DI, tests are fast, deterministic, and exercise exactly the unit under test.
How: define a protocol for each external boundary (network, persistence, clock, analytics). Inject the protocol into your type. Provide a real implementation for production and a fake for tests.
Code — before and after:
Untestable version
@MainActor
final class UserProfileViewModel {
var profile: Profile?
var error: String?
func load(id: String) async {
do {
let url = URL(string: "https://api.example.com/users/\(id)")!
let (data, _) = try await URLSession.shared.data(from: url) // 👎 singleton
self.profile = try JSONDecoder().decode(Profile.self, from: data)
} catch {
self.error = error.localizedDescription
}
}
}
Injectable version
protocol UserAPI {
func loadUser(id: String) async throws -> Profile
}
@MainActor
final class UserProfileViewModel {
private let api: UserAPI
var profile: Profile?
var error: String?
init(api: UserAPI) { self.api = api }
func load(id: String) async {
do { profile = try await api.loadUser(id: id) }
catch { self.error = error.localizedDescription }
}
}
Manual mock
final class MockUserAPI: UserAPI {
var stubResult: Result<Profile, Error> = .failure(URLError(.notConnectedToInternet))
var receivedIDs: [String] = []
func loadUser(id: String) async throws -> Profile {
receivedIDs.append(id)
return try stubResult.get()
}
}
Tests
@MainActor
final class UserProfileViewModelTests: XCTestCase {
func test_load_success_setsProfile() async {
let mock = MockUserAPI()
mock.stubResult = .success(Profile(id: "1", name: "Ada"))
let sut = UserProfileViewModel(api: mock)
await sut.load(id: "1")
XCTAssertEqual(sut.profile?.name, "Ada")
XCTAssertEqual(mock.receivedIDs, ["1"])
}
func test_load_failure_setsErrorMessage() async {
let mock = MockUserAPI()
mock.stubResult = .failure(URLError(.timedOut))
let sut = UserProfileViewModel(api: mock)
await sut.load(id: "1")
XCTAssertNotNil(sut.error)
XCTAssertNil(sut.profile)
}
}
URLProtocol — the most underused mock
When you want to test the actual URLSession integration (header construction, query encoding) without a real network:
final class MockURLProtocol: URLProtocol {
static var stub: (Data, HTTPURLResponse)?
override class func canInit(with request: URLRequest) -> Bool { true }
override class func canonicalRequest(for request: URLRequest) -> URLRequest { request }
override func startLoading() {
guard let (data, response) = Self.stub else { return }
client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)
client?.urlProtocol(self, didLoad: data)
client?.urlProtocolDidFinishLoading(self)
}
override func stopLoading() {}
}
// In test:
let config = URLSessionConfiguration.ephemeral
config.protocolClasses = [MockURLProtocol.self]
let session = URLSession(configuration: config)
MockURLProtocol.stub = (jsonData, HTTPURLResponse(...))
This is how every serious iOS project mocks the network — no third-party libraries needed.
Clocks and dates
Hard-coded Date() calls are non-deterministic. Inject a clock:
protocol Clock { func now() -> Date }
struct SystemClock: Clock { func now() -> Date { Date() } }
struct FakeClock: Clock {
var fixed: Date
func now() -> Date { fixed }
}
In Swift 5.7+ you can also use the standard library’s Clock protocol for time-based work (ContinuousClock, SuspendingClock).
@testable import
@testable import MyApp
Exposes internal symbols (the default access level) to the test target. Don’t make symbols public just for tests — that’s leaking abstraction.
In the wild
- Mockable (Sahin) — Swift macro-based mock generator; clean and modern but tied to macro tooling stability.
- Cuckoo — long-standing manual mock generator; mature but verbose.
- Mockingbird — once-popular, less active now.
- swift-dependencies (Point-Free) — a structured way to register and inject dependencies app-wide; pairs with TCA but works standalone.
- Apple’s swift-async-algorithms — provides
AsyncStreamfor testing time-based code.
Common misconceptions
- “DI requires a container framework.” No. Constructor injection (passing dependencies as init parameters) is DI. You don’t need Resolver, Swinject, or Needle.
- “Protocols make code slower.” Negligible. Swift can devirtualize protocol calls when the conforming type is known.
- “Mocks should record every call.” They should record what you assert on. Over-recording leaks implementation detail into tests.
- “
@testable importis a security risk.” It’s a compile-time directive only; production code can’t@testable importanything. - “You must mock every dependency.” Mock only at boundaries — network, disk, clock, analytics, hardware. Don’t mock pure value types; instantiate the real thing.
Seasoned engineer’s take
A type with five injected dependencies is screaming at you that it has too many responsibilities. The pain of building a mock for it in tests is the same pain you’d feel reading it in production six months from now. Refactor the type, don’t add yet another protocol. Constructor injection plus protocols is 95% of what teams need; reach for a DI container only when you’re wiring an app with 50+ services.
[!TIP] Default test mocks to failing behavior (throw, return invalid data, crash). Each test then opts into success by setting the stub. This catches missing setup quickly — you can’t accidentally pass with a default value that happens to be benign.
[!WARNING]
URLSession.sharedis a singleton with cookies, caches, and an HTTP/2 connection pool that persists between tests. Always create a freshURLSession(configuration:)per test, or your tests will see ghost data.
Interview corner
Junior — “Why use protocols for testing?” A protocol lets a type depend on an interface instead of a concrete class. In tests, you swap in a mock that conforms to the same protocol. In production, you use the real implementation. The type doesn’t know or care which is which.
Mid — “Walk me through making URLSession mockable without a third-party library.”
Define a protocol HTTPClient with the methods your code uses (func data(for: URLRequest) async throws -> (Data, URLResponse)). Make URLSession conform via an extension. Inject HTTPClient into your service. For tests, either implement a manual mock or use URLProtocol-based interception, which gives you a real URLSession configured with a custom protocolClasses that returns canned responses.
Senior — “How do you decide what to mock and what to use the real implementation for?”
Three rules. One: mock things that cross trust boundaries — network, disk, clock, hardware, analytics. Two: don’t mock pure value types; the real Profile struct is faster than MockProfile. Three: don’t mock the type under test or anything reachable only from it — that’s testing the mock, not the code. For complex object graphs, prefer “stub” (canned return values) over “mock” (verification of calls), because verification couples the test to implementation. I’d also consider Andrew Trick’s distinction between fakes (working in-memory implementations like InMemoryDatabase) and mocks — fakes scale better as the suite grows.
Red flag — “I make everything public so tests can see it.” This person doesn’t know about @testable import.
Lab preview
Lab 8.1 puts mocking front and center: you’ll define a Networking protocol, write a MockNetworking, and inject it into a NetworkClient under test.
Next: UI Testing with XCUITest
8.5 — UI Testing with XCUITest
Opening scenario
The CEO’s demo broke at the worst moment: she tapped “Sign In,” and the app crashed. Your unit tests are all green. The crash was a SwiftUI state bug only triggered when the keyboard dismissed while a sheet was animating. No unit test could have caught it. This is the niche where XCUITest earns its keep — and where it costs the most maintenance.
UI tests are slow, brittle, and indispensable for a small set of paths. Pick them carefully.
Context taxonomy
| Concept | Context | Why it matters | Common confusion |
|---|---|---|---|
XCUIApplication | Launches the app | Each test runs a full app lifecycle | Re-using across tests — state leaks |
XCUIElement | Query handle to a UI element | Lazy; resolves at access | Treating it like a snapshot |
XCUIElementQuery | Filters across the element tree | Composable | One mega-query — slow + brittle |
| Accessibility identifier | Stable test handle | Doesn’t change with localization | Using visible text — breaks on i18n |
waitForExistence(timeout:) | Wait for async UI | Required for animations/network | Polling with Thread.sleep — flaky + slow |
XCUIApplication.launchArguments | Pass test config to app | Disable animations, seed state | Hard-coding test state in app code |
Concept → Why → How → Code
Concept: XCUITest drives the simulator (or device) at the accessibility layer. It taps, swipes, and types like a user would, then asserts on visible elements.
Why: smoke tests for critical paths (sign-in, checkout, signup) catch the bugs that unit tests can’t — broken nav, missing entitlements, dead deep links, race conditions across the whole stack.
How: launch the app per test, query elements by accessibility identifier, perform gestures, assert on results with explicit waits.
Code — a complete sign-in UI test:
import XCTest
final class SignInUITests: XCTestCase {
var app: XCUIApplication!
override func setUpWithError() throws {
try super.setUpWithError()
continueAfterFailure = false
app = XCUIApplication()
app.launchArguments = ["-UITestMode", "1"] // app reads this to seed test state
app.launch()
}
func test_validCredentials_navigatesToHome() {
let emailField = app.textFields["signIn.email"]
XCTAssertTrue(emailField.waitForExistence(timeout: 2))
emailField.tap()
emailField.typeText("test@example.com")
let passwordField = app.secureTextFields["signIn.password"]
passwordField.tap()
passwordField.typeText("correct-horse-battery-staple")
app.buttons["signIn.submit"].tap()
let homeTitle = app.staticTexts["home.title"]
XCTAssertTrue(homeTitle.waitForExistence(timeout: 5))
}
func test_emptyForm_buttonDisabled() {
let submit = app.buttons["signIn.submit"]
XCTAssertTrue(submit.waitForExistence(timeout: 2))
XCTAssertFalse(submit.isEnabled)
}
}
Accessibility identifiers
In your SwiftUI views:
TextField("Email", text: $email)
.accessibilityIdentifier("signIn.email")
Button("Sign In") { ... }
.accessibilityIdentifier("signIn.submit")
In UIKit:
emailField.accessibilityIdentifier = "signIn.email"
Identifiers don’t change with localization. They don’t change when designers tweak copy. They are the single most important XCUITest hygiene practice.
Element queries
app.buttons["Submit"] // by label or identifier
app.buttons.matching(identifier: "submit") // explicit by identifier
app.cells.element(boundBy: 0) // first cell
app.staticTexts.containing(.staticText, identifier: "Welcome").element
app.scrollViews.firstMatch // shorthand for first match
Waiting (always explicit, never sleep)
let title = app.staticTexts["home.title"]
XCTAssertTrue(title.waitForExistence(timeout: 5)) // wait for appear
// Wait for disappear
let predicate = NSPredicate(format: "exists == false")
expectation(for: predicate, evaluatedWith: title)
waitForExpectations(timeout: 5)
// Wait for an arbitrary property
let predicate = NSPredicate(format: "isEnabled == true")
expectation(for: predicate, evaluatedWith: app.buttons["Submit"])
waitForExpectations(timeout: 5)
Launch arguments — talk to your app
Tests pass flags; the app reads them at startup and configures itself:
// In test
app.launchArguments = ["-UITestMode", "1", "-SeedUser", "premium"]
// In AppDelegate or App
if CommandLine.arguments.contains("-UITestMode") {
UIView.setAnimationsEnabled(false)
AppEnvironment.current = .uiTest
}
Common flags to wire in:
- Disable animations (otherwise tests are slow + flaky)
- Stub the network layer with canned fixtures
- Skip onboarding
- Sign in a test user automatically
CI-safe patterns
continueAfterFailure = false— stop on first failure; the rest of the test is noise.- Disable animations —
UIView.setAnimationsEnabled(false)for UIKit;Transaction.disablesAnimations = truefor SwiftUI via launch arg. - Network stubbing — never hit real network in UI tests. Use
URLProtocolor a local stubs server. - Screenshots on failure —
add(XCTAttachment(screenshot: app.screenshot()))intearDownWithError. - Retry policy —
xcodebuild ... -test-iterations 2 -retry-tests-on-failureif a test flakes once, retry; if it flakes twice, fail.
Record vs hand-write
Xcode’s record-and-playback (the red record button in the test source) generates code. It’s useful for discovering element queries on a new screen. Always rewrite the recording by hand — recorded code uses fragile queries like app.buttons.element(boundBy: 3) instead of stable identifiers.
In the wild
- Lyft wrote internally about killing 80% of their UI tests after they realized maintenance cost outweighed value — kept only ~20 smoke tests on critical flows.
- Airbnb maintains “Mock Mode” — a build configuration that runs the app entirely against canned data for UI test runs.
- Apple’s own apps (Calendar, Mail) use XCUITest heavily for app-wide accessibility audits.
Common misconceptions
- “More UI tests = more confidence.” Past 10–20 critical flows, more UI tests destroy your CI throughput.
- “UI tests can replace QA.” They catch regression in known paths only. Humans find novel bugs UI tests can’t.
- “
Thread.sleep(forTimeInterval:)is fine for waits.” It’s the #1 cause of flake. Always usewaitForExistenceorexpectation(for:). - “Recorded tests are good enough.” They’re a starting point. The recorded queries are unstable; always rewrite with accessibility identifiers.
- “UI tests can run on the unit test target.” No. UI tests need a separate target — they run in a different process from your app.
Seasoned engineer’s take
A UI test suite that takes longer than 10 minutes will be skipped. A UI test that flakes more than once a week will be XCTSkip-ed by some tired engineer and never re-enabled. Budget aggressively: 15–25 UI tests, run in parallel on simulator clones, finishing under 8 minutes. Save them for the paths that matter (auth, checkout, the top-of-funnel) and let unit tests handle everything else.
[!TIP] Parallelize UI tests with
xcodebuild ... -parallel-testing-enabled YES -parallel-testing-worker-count 4. Each worker uses a cloned simulator. Speeds up runs by 3–4×.
[!WARNING] Don’t
accessibilityIdentifieryour way into a single global namespace. Prefix by screen (signIn.email,home.cart.button). A flat namespace becomes unmanageable around 50 elements.
Interview corner
Junior — “How do you find a button in a UI test?”
Use app.buttons["identifier"] where identifier is the accessibility identifier set on the SwiftUI view or UIKit control. Avoid finding by visible text — it breaks when you localize.
Mid — “Your UI test is flaky. What’s your first step?”
Look for Thread.sleep, hard-coded timeouts that are too short, animations not disabled, or assertions made before a network call completes. Replace sleep with waitForExistence, set a generous timeout (5s for animations, 10s for network), disable animations via launch argument, and stub the network layer so timing is deterministic.
Senior — “Design a UI test strategy for an app with 50+ screens.”
Identify the critical flows — sign-in, signup, checkout, content creation, settings change — five to ten paths max. Write one happy-path UI test per flow plus a small set of error-state tests where the error is high-impact. Stub the network via URLProtocol injection driven by a launch argument so all tests run offline against canned fixtures. Keep accessibility identifiers prefixed by screen. Run the full UI suite on every PR with parallelization; allow one retry per test. Move long-tail UI tests to a nightly job that doesn’t block PRs. Track flake rate per test in a dashboard; auto-quarantine any test that flakes more than 5% over a 7-day window. I’d also consider snapshot tests as a cheaper alternative for visual-only assertions — UI tests should be reserved for interaction correctness.
Red flag — “I write a UI test for every story.” That’s a CI suite that takes 90 minutes by year two.
Lab preview
Lab 8.2 walks you through writing XCUITests for a pre-built login screen, including accessibility identifier setup, network stubbing, and CI-safe patterns.
Next: Snapshot Testing
8.6 — Snapshot Testing
Opening scenario
A designer messages you on Slack: “the cart button is 2 points too low on iPhone SE.” You scroll through the PR diff — nothing changed in CartView. Two days of git-blaming later, you discover someone modified a shared ButtonStyle extension that subtly bumped padding on small screens. There’s no unit test that could have caught this. There is, however, a snapshot test.
Snapshot tests record what a view looks like, then fail when the rendered image differs by even one pixel.
Context taxonomy
| Concept | Context | Why it matters | Common confusion |
|---|---|---|---|
| Reference snapshot | PNG committed to repo | The “expected” output | Re-recording every failure → loses the signal |
| Snapshot diff | Per-pixel comparison | Catches visual regressions | Anti-aliasing/font rendering can produce false positives |
record: true mode | Records new snapshots instead of asserting | Use to capture initial baselines | Forgetting to flip back to assert mode |
| Device/scale/locale matrix | Snap on multiple configurations | Catches platform-specific issues | Exploding test count — pick critical configs only |
swift-snapshot-testing | Point-Free’s library | De facto standard for iOS | Building it yourself — don’t |
Concept → Why → How → Code
Concept: render a view to an image, compare against a previously-recorded reference. Fail with a diff image showing what changed.
Why: visual regressions are invisible to logic tests but immediately obvious to users. Snapshot tests run in seconds, dozens at a time, with no real device required. They catch the entire class of “I changed a shared style and broke five unrelated screens” bug.
How: install swift-snapshot-testing, write one assertion per view × configuration, commit the generated PNGs alongside your code.
Setup
Package.swift:
.testTarget(
name: "AppTests",
dependencies: [
"App",
.product(name: "SnapshotTesting", package: "swift-snapshot-testing"),
]
)
First test
import SnapshotTesting
import SwiftUI
import XCTest
@testable import App
final class CartViewSnapshotTests: XCTestCase {
func test_emptyCart() {
let view = CartView(items: [])
assertSnapshot(of: view, as: .image(layout: .device(config: .iPhone13)))
}
func test_threeItems() {
let view = CartView(items: [
.stub(name: "Coffee", price: 4),
.stub(name: "Bagel", price: 3),
.stub(name: "OJ", price: 5),
])
assertSnapshot(of: view, as: .image(layout: .device(config: .iPhone13)))
}
}
First run — record mode
isRecording = true // global; or pass `record: true` per-assertion
Run tests. They all “fail” but actually write the reference PNGs to a __Snapshots__/ directory next to your test file. Commit those PNGs. Flip back:
isRecording = false
Now subsequent runs compare against the committed images.
Device/configuration matrix
func test_cart_iPhoneSE() {
assertSnapshot(of: CartView(items: stubItems),
as: .image(layout: .device(config: .iPhoneSe)))
}
func test_cart_iPhone13() {
assertSnapshot(of: CartView(items: stubItems),
as: .image(layout: .device(config: .iPhone13)))
}
func test_cart_iPadPro() {
assertSnapshot(of: CartView(items: stubItems),
as: .image(layout: .device(config: .iPadPro12_9)))
}
Or parameterize:
for config in [ViewImageConfig.iPhoneSe, .iPhone13, .iPadPro12_9] {
assertSnapshot(of: view, as: .image(layout: .device(config: config)),
named: "\(config)")
}
Dynamic Type and Dark Mode
assertSnapshot(
of: view.environment(\.sizeCategory, .accessibilityExtraExtraLarge),
as: .image(layout: .device(config: .iPhone13))
)
assertSnapshot(
of: view.preferredColorScheme(.dark),
as: .image(layout: .device(config: .iPhone13)),
named: "dark"
)
CI gotchas
Snapshot tests are environment-sensitive:
- Xcode version — font rendering changes between Xcode versions. CI must use the same Xcode version as developers.
- Simulator runtime — iOS 17 vs iOS 18 simulator can render the same view differently.
- Apple Silicon vs Intel — different float math in some Metal paths. Pin runners to one arch.
Solutions:
- Pin Xcode version in CI (
/Applications/Xcode_16.0.app) - Pin simulator destination (
-destination "platform=iOS Simulator,name=iPhone 15,OS=18.0") - Use the same arch as developers’ machines
When snapshots differ harmlessly (one anti-aliased pixel), set a precision tolerance:
assertSnapshot(of: view, as: .image(precision: 0.99)) // accept 99% match
What NOT to snapshot
- Time-based content — anything with
Date()orrelativeFormatter. Either inject a fixed clock or hide the timestamp from the snapshot. - Animations mid-flight — snapshots capture a moment. If your view is animating at capture time, it’ll flake.
- Random IDs — UUID-driven content needs deterministic seeding.
- System UI — keyboard, status bar, share sheet are not snapshotted reliably.
Storage size
Snapshot PNGs live in Git. A medium app accumulates 200–500 MB of PNGs over a year. Mitigations:
- Use Git LFS for
__Snapshots__/directories - Snapshot at smaller scale (
scale: 2instead of3) for compact files - Prune unused snapshots quarterly (the library can detect orphans)
In the wild
- swift-snapshot-testing (Point-Free) — the canonical library. 5k+ stars.
- iOSSnapshotTestCase (Facebook, formerly FBSnapshotTestCase) — older, predates Swift Package Manager era; still in maintenance.
- iosched (Google) — uses snapshot tests for their conference app’s collection of views.
- Square’s Workflow — relies heavily on snapshots to test their architecture’s screen state.
Common misconceptions
- “Snapshots replace UI tests.” They verify appearance, not interaction. You still need XCUITest for taps.
- “Pixel-perfect comparisons are always best.” False positives waste hours. Use
precisionthresholds for non-critical regions. - “Re-record when it fails.” Stop. A failing snapshot is either a real regression or a flaky environment. Investigate before re-recording.
- “Snapshots make tests faster.” A snapshot test running through SwiftUI’s render pipeline is slower than an XCTest assertion. Fast vs slow is relative to UI tests, not unit tests.
- “Once recorded, snapshots are stable forever.” False — Xcode/iOS updates can break them. Plan for snapshot churn every major Xcode release.
Seasoned engineer’s take
Snapshot tests pay off most for design systems (button styles, card components, list cells) where many screens depend on a shared piece of UI. They pay off least for screens that change weekly — you’ll spend more time re-recording than debugging. Pick the stable, reusable parts of your UI and snapshot those. Skip ephemeral product screens.
[!TIP] Use
as: .recursiveDescription(not just.image) to also snapshot the SwiftUI view hierarchy as text. Text diffs are easier to read in PR reviews than image diffs.
[!WARNING] Never auto-update snapshots in CI. If a snapshot test fails, a human must inspect the diff and decide whether it’s a regression or an intended change.
Interview corner
Junior — “What is snapshot testing?” Render a view to an image, save it, then assert later renders match. If the appearance changes, the test fails. Used to catch visual regressions you can’t catch with logic tests.
Mid — “Your snapshot tests flake in CI but pass locally. Why?” Almost always environment. Xcode version, simulator runtime, or simulator architecture differs between dev machines and CI. Pin all three. If they still flake, check for non-deterministic content like timestamps, random IDs, or unfinished animations.
Senior — “When would you NOT add snapshot tests?” Three cases. One: rapidly evolving product screens where re-recording costs exceed catch-rate. Two: views with heavy dynamic content (charts, maps, video) where pixel comparison is meaningless. Three: views with intentional randomness or system UI. I’d also consider that snapshot tests work best at the component level — shared design system pieces, cells, modals — and worse at screen level, because screens combine state in too many ways to snapshot every combination cleanly.
Red flag — “I snapshot every screen on every device size.” That’s 500+ snapshots churning weekly, with everyone re-recording on every PR.
Lab preview
Lab 8.3 has a snapshot testing portion: you’ll add swift-snapshot-testing, capture baselines for three components, and tune a precision threshold.
Next: Performance Testing
8.7 — Performance Testing
Opening scenario
A user reports that scrolling the news feed “feels janky on my iPhone 12.” Your team’s iPhone 15s see 120fps; nobody noticed. Three weeks later your App Store rating drops half a star because the iPhone 12 is the median device, and 30% of your users are on it. A performance regression test would have caught this on commit.
Performance tests in XCTest measure execution time, memory, CPU, and frame rate — and fail the build when a metric drifts past a baseline.
Context taxonomy
| Concept | Context | Why it matters | Common confusion |
|---|---|---|---|
measure {} | Wraps code to be timed | Runs the block 10× by default | One measurement → meaningless statistics |
XCTMetric | What to measure | Time, memory, CPU, storage | Defaulting to time only |
XCTClockMetric | Wall-clock time | Total elapsed time | Differs from XCTCPUMetric (CPU work) |
XCTMemoryMetric | Peak/persistent memory | Catches leaks + bloat | Confused with Instruments leak detection |
| Baseline | The expected metric value | Committed to repo, enforced in CI | Re-baselining every regression — kills the signal |
| Instruments | Apple’s profiler | Deeper investigation tool | Confused with XCTest performance (they’re complementary) |
Concept → Why → How → Code
Concept: XCTest performance tests run a code block multiple times, record metrics, and compare against a stored baseline. If the new run exceeds the baseline by your tolerance (default 10%), the test fails.
Why: performance regressions are silent until users complain. A regression gate in CI keeps your hot paths fast forever — you can’t accidentally drop frame rate, allocate 10× more memory, or 2× a critical function without the test screaming.
How: write a test that exercises the hot path, wrap it in measure(metrics:options:), record a baseline on a known-good build, commit the baseline, configure CI to fail on regression.
Code — a complete performance test:
import XCTest
@testable import App
final class FeedPerformanceTests: XCTestCase {
let largeFeed = (0..<10_000).map { Post.stub(id: $0) }
func test_renderFeed_performance() {
let options = XCTMeasureOptions()
options.iterationCount = 5
measure(
metrics: [XCTClockMetric(), XCTMemoryMetric(), XCTCPUMetric()],
options: options
) {
let processed = FeedProcessor.prepare(largeFeed)
XCTAssertEqual(processed.count, 10_000)
}
}
}
After first run, click the gray diamond in the gutter → “Set Baseline.” Commit. Subsequent runs compare against it.
The metric catalog
| Metric | Measures |
|---|---|
XCTClockMetric | Wall-clock time (default since iOS 14) |
XCTCPUMetric | CPU instructions retired + cycles |
XCTMemoryMetric | Peak physical memory, peak heap |
XCTStorageMetric | Bytes written to disk |
XCTApplicationLaunchMetric | Cold launch time (UI test target only) |
XCTOSSignpostMetric | Custom signpost spans |
Pass multiple metrics in the array — one run, multiple gauges.
Custom signposts — measure the right span
Use os_signpost to demarcate the work you actually care about:
import os
let log = OSLog(subsystem: "com.example.app", category: "feed")
func processBatch() {
let id = OSSignpostID(log: log)
os_signpost(.begin, log: log, name: "processBatch", signpostID: id)
// ... work ...
os_signpost(.end, log: log, name: "processBatch", signpostID: id)
}
// In test:
func test_processBatch_performance() {
measure(metrics: [XCTOSSignpostMetric(subsystem: "com.example.app",
category: "feed",
name: "processBatch")]) {
processBatch()
}
}
Launch time (UI test target)
final class LaunchTests: XCTestCase {
func test_launch_performance() {
measure(metrics: [XCTApplicationLaunchMetric()]) {
XCUIApplication().launch()
}
}
}
Cold launch is the metric Apple highlights in Xcode Organizer — it’s one of the very few that the App Store surfaces to users implicitly via “first impression.”
Baselines and CI
- Baselines are stored per-device per-OS. iPhone 12 simulator and iPhone 15 simulator have separate baselines.
- Don’t set baselines on the slowest device in your dev team; set them on what CI runs.
- Default tolerance: 10% above baseline = failure. Tune in the gutter UI.
- In CI:
xcodebuild test ... -resultBundlePath→ parse the.xcresultfor performance regressions.
Instruments vs XCTest performance
| Use Instruments when… | Use XCTest performance when… |
|---|---|
| Debugging a specific slow path | Preventing future regressions |
| Investigating allocations + leaks | Asserting “this stays under X ms” |
| Profiling a real device session | Running in CI on every PR |
| Building a flame graph | Failing the build on drift |
They’re complementary. Instruments tells you why; XCTest tells you whether.
In the wild
- Xcode Organizer → Metrics — surfaces real-user performance (launch, hang, disk usage, energy) from the App Store opt-in metrics; not the same as XCTest performance, but the data goal is the same.
- MetricKit — opt-in framework that delivers
MXMetricPayloadreports daily; for production telemetry, not CI tests. - Square’s
Pollux— internal perf regression dashboard built on XCTest metrics + custom signposts.
Common misconceptions
- “Performance tests need real devices.” Simulator is fine for regression detection — you’re measuring deltas, not absolutes. Real devices are for absolute measurements before launch.
- “
measure {}runs the code once.” It runs 5–10 times by default and reports min/avg/std-dev. - “Re-baseline whenever the test fails.” That destroys the regression signal. Investigate first; only re-baseline when the change is intentional.
- “
XCTMemoryMetriccatches leaks.” It catches peak memory deltas, not leaks specifically. Use Instruments → Leaks for that. - “Performance tests should run on every PR.” They should — but only on a consistent runner. Putting them on a varied pool gives noisy baselines.
Seasoned engineer’s take
The two performance tests that matter most for nearly every app: cold launch and the largest list-rendering path. Get those two locked down with baselines and a CI gate, and you’ve caught 80% of the regressions users will notice. Everything beyond that is nice-to-have. Don’t build a massive performance test suite up front — let production telemetry (MetricKit, App Store metrics) tell you what’s actually slow before you over-instrument.
[!TIP] Run performance tests with Release configuration, not Debug. Debug has assertions, no inlining, no whole-module optimization — measurements there are meaningless.
[!WARNING] Performance tests on macOS runners with thermal throttling (CI fleet under heavy load) produce flaky baselines. Pin tests to a dedicated runner or use a noisy-neighbor-tolerant tolerance (15–20%).
Interview corner
Junior — “How do you measure performance in XCTest?”
Use measure {} inside a test method. It runs the block multiple times and records metrics. Pass an array of XCTMetric to capture time, memory, CPU. Set a baseline through Xcode’s gutter UI; future runs compare against it.
Mid — “Your CI started flaking on a perf test. What’s the diagnosis?”
Check whether CI is running on a shared/variable runner — thermal throttling and noisy neighbors inflate measurements unpredictably. Check the baseline was set on the same runner type. Check the test isn’t doing real I/O (network, disk) inside measure. If all three are clean, investigate whether a recent change actually regressed the code path being measured.
Senior — “Design a perf regression strategy for a list-heavy app.” Three layers. One: XCTest perf tests on the hot paths — cell configuration, image decoding, scroll-triggered prefetch — with baselines per CI device, gated on every PR. Two: custom OS signposts wrapping each subsystem, so Instruments traces in development map cleanly to the same boundaries the tests measure. Three: MetricKit + App Store metrics for real-user telemetry, with a weekly dashboard for cold launch time, hang rate, and scroll responsiveness. The XCTest layer catches the regressions you wrote; the telemetry layer catches the ones the device fleet exposes that you didn’t predict. I’d also consider running the perf tests under Instruments’ “Time Profiler” template in nightly to capture flame graphs alongside the pass/fail signal.
Red flag — “I just look at Xcode’s runtime info when I’m coding.” That’s not a regression strategy; that’s spot-checking.
Lab preview
Lab 8.3 includes a perf test section: write a measure test for an image-heavy collection view, set a baseline, intentionally regress the code, and watch the test fail.
Next: Code Coverage & SwiftLint
8.8 — Code Coverage & SwiftLint
Opening scenario
Two engineers join your team in the same week. One opens a PR with 600 lines of new code and 0 tests; CI is happy because there’s no coverage gate. The other opens a PR with 200 lines fixing a real bug; CI fails because SwiftLint flags a 5-line function as “too long.” The first PR ships; the second waits. Your tooling is rewarding the wrong behavior.
Coverage and lint are powerful — when configured to actually serve the team, and ruthless when they’re not.
Context taxonomy
| Concept | Context | Why it matters | Common confusion |
|---|---|---|---|
| Code coverage | % of lines executed by tests | Identifies untested code | Treating it as a quality metric (it isn’t) |
| Branch coverage | % of if/switch branches | Stronger signal than line coverage | Most tools report line only |
xccov | Apple’s coverage CLI | Parse .xcresult for coverage data | Reading the Xcode UI manually |
| SwiftLint | Style + simple bug linter | Catches dozens of issues pre-review | Adding too many rules → review-cycle paralysis |
--strict | SwiftLint flag turning warnings into errors | Failures gate CI | Without it, warnings ignored forever |
| Custom rules | Project-specific regex linters | Encode your team’s conventions | Over-customization → rules nobody understands |
Concept → Why → How → Code
Concept: code coverage is the percentage of executable lines that any test exercises. SwiftLint statically analyzes source and flags violations of style rules and common bug patterns.
Why: coverage shows you where tests don’t reach. Lint catches a class of obvious issues (force unwraps, dead code, fragile patterns) before code review starts, freeing reviewers for the things linters can’t see.
How: enable coverage in the test scheme, parse xccov output in CI, gate PRs on a minimum threshold for changed lines only. Install SwiftLint via Homebrew or SPM plugin, configure .swiftlint.yml, run with --strict in CI.
Enabling coverage in Xcode
Edit Scheme → Test → Options → Code Coverage: Gather coverage for: all targets.
Run tests. In Xcode → Report Navigator → latest test run → Coverage tab. Drill into per-file, per-function coverage.
Coverage from the command line
xcodebuild test \
-scheme MyApp \
-destination 'platform=iOS Simulator,name=iPhone 15' \
-enableCodeCoverage YES \
-resultBundlePath build/result.xcresult
xcrun xccov view --report --json build/result.xcresult > coverage.json
Parse coverage.json to extract per-file coverage and feed into a CI gate.
Code coverage on changed lines only
The trick that makes coverage gates work in practice:
# Get the lines changed in this PR
git diff --unified=0 origin/main...HEAD | parse-diff > changed-lines.json
# Intersect with xccov output
jq --slurpfile changed changed-lines.json \
'.targets[] | .files[] | select(...)' coverage.json
Tools like Codecov, Coveralls, and SonarCloud do this for you and post a PR comment with “+12 lines added, 9 covered (75%).” Gate the PR on a threshold for changed lines, not whole-project coverage. This makes the metric actionable without forcing test backfill on legacy code.
SwiftLint setup
Install:
brew install swiftlint
Or via SPM plugin (Xcode 14+):
.target(
name: "App",
plugins: [.plugin(name: "SwiftLintBuildToolPlugin", package: "SwiftLintPlugins")]
)
.swiftlint.yml at repo root:
included:
- App
- AppTests
excluded:
- Pods
- .build
- DerivedData
disabled_rules:
- todo # we use TODOs intentionally
- line_length # let formatter handle this
opt_in_rules:
- empty_count
- explicit_init
- first_where
- force_unwrapping # 🔥 the killer rule
- implicitly_unwrapped_optional
- last_where
- operator_usage_whitespace
- sorted_imports
- unused_import
analyzer_rules:
- unused_declaration
- unused_import
force_cast: error
force_try: error
force_unwrapping: error
type_body_length:
warning: 300
error: 500
function_body_length:
warning: 50
error: 100
cyclomatic_complexity:
warning: 10
error: 20
custom_rules:
no_print_in_production:
regex: '^\s*print\('
message: 'Use os_log instead of print() in production code'
severity: warning
excluded: '.*Tests\.swift'
Running in CI
swiftlint --strict --reporter github-actions-logging
--strict promotes every warning to error → red CI on any violation. github-actions-logging formats output so violations show up as inline PR annotations.
// swiftlint:disable escape hatches
// swiftlint:disable:next force_unwrapping
let value = optional!
// swiftlint:disable force_cast
let result = thing as! SpecificType
// swiftlint:enable force_cast
Use sparingly. Every disable is a tiny crack; review them in PRs.
What coverage gates should look like
| Project state | Recommended gate |
|---|---|
| Greenfield | 80% on changed lines, 70% project-wide |
| Legacy w/ low coverage | 70% on changed lines, no project minimum |
| Pure infrastructure (CLI, libraries) | 90% on changed lines |
| UI-heavy app | 60% on changed lines (Views are unrealistic to cover) |
Never gate on absolute project coverage going up — engineers will write meaningless tests to pad the number.
In the wild
- SwiftLint (Realm) — de facto standard. 18k+ stars.
- SwiftFormat (Nick Lockwood) — code formatter, not a linter, but commonly paired.
- Periphery — dead code analyzer. Finds unused classes/methods/imports.
- Slather — coverage reporter that pre-dates
xccov; many CIs still use it. - Codecov / Coveralls — SaaS that ingests
xccovoutput, posts PR comments with coverage diffs.
Common misconceptions
- “Higher coverage = fewer bugs.” Loose correlation, no causation. A project at 90% coverage with shallow assertions can be buggier than 60% with strong ones.
- “100% is the goal.” It’s a useless goal — the last 10% is usually generated code, unreachable branches, and trivial accessors.
- “SwiftLint catches bugs.” It catches patterns associated with bugs. The killer rules (force_unwrap, force_cast, force_try) catch real crashes. Most style rules catch style only.
- “More lint rules = better code.” More rules = more PR cycles arguing about style. Pick the rules that prevent real damage; let the rest go.
- “Coverage is meaningless if you don’t have branch coverage.” Line coverage at 80% is still a better signal than no coverage at all.
Seasoned engineer’s take
Coverage and lint are guardrails, not quality measures. Use them to prevent specific failure modes — force unwraps in shipped code, totally untested business logic — not to assert quality. The teams that obsess over 90% coverage typically have brittle, mock-heavy tests. The teams with the right gates (force-unwrap forbidden, changed-line coverage ≥ 70%, function complexity ≤ 15) ship faster with fewer bugs.
[!TIP] Add
swiftlint --fixas a pre-commit hook (vialefthookorpre-commit). Auto-fixes whitespace and trivial issues before they hit CI.
[!WARNING] Don’t enable all SwiftLint opt-in rules at once on a legacy codebase. You’ll create thousands of warnings, your team will mass-disable, and the linter loses all credibility. Enable rules one at a time, fix violations, then merge.
Interview corner
Junior — “What’s code coverage?” The percentage of your source lines (or branches) executed by your test suite. A line at 100% coverage was hit by at least one test. Coverage measures execution, not correctness — a covered line can still be wrong if the test asserts the wrong outcome.
Mid — “How do you stop coverage from being gamed?” Gate on changed lines only, not project totals. Engineers can’t pad the metric with trivial tests because the gate only checks code in this PR. Pair with code review for assertion quality — coverage tells you tests exist; review tells you they test the right thing.
Senior — “Roll out SwiftLint to a 500k-LOC legacy codebase. Plan?”
Phase one: install with default config + --strict disabled. Generate the violation baseline. Phase two: enable only the killer rules (force_unwrap, force_cast, force_try) as errors; fix the violations across the codebase in dedicated PRs. Phase three: enable five high-value style rules as warnings; let teams fix opportunistically. Phase four: flip warnings to errors via --strict in CI; communicate the cutover date a week ahead. Phase five: introduce one new rule per quarter via the same workflow. Never enable a flood of rules at once — you’ll either drown the team or get them all disabled. I’d also consider Periphery in parallel for dead code; it tends to surface a startling amount in legacy projects.
Red flag — “We require 100% coverage on every PR.” That’s not a quality bar; that’s a recipe for tests-of-tests and resentment.
Lab preview
Lab 8.3 closes the phase with coverage and lint gates: you’ll configure .swiftlint.yml, set up a CI workflow that runs tests with coverage, and post the coverage report to your PR.
Phase 8 complete. Phase 9 (Security & Secure Coding) covers Keychain, biometrics, TLS pinning, OWASP Mobile Top 10, and pentest tooling.
Lab 8.1 — TDD Feature: NetworkClient
Goal: build a generic NetworkClient from zero using strict test-first discipline. By the end you’ll have a typed, async, retry-capable client with ~95% coverage and a clean injectable design.
Time: 90–150 minutes.
Prereqs: Xcode 16+, Swift 6.
Setup
- New iOS App → SwiftUI → name
NetworkClientLab. - Add a unit test target if not present (
File → New → Target → Unit Testing Bundle). - Create empty files:
App/NetworkClient.swift,App/HTTPClient.swift,Tests/NetworkClientTests.swift. - In tests file:
@testable import NetworkClientLab.
The test list (write these on paper before coding)
1. GET request returns decoded JSON
2. 404 throws .notFound
3. 500 throws .server with status code
4. Network error throws .transport
5. Decoding error throws .decoding
6. POST sends body
7. POST sets Content-Type: application/json
8. Custom headers are merged with defaults
9. Authorization header attached via TokenProvider
10. Retries 3 times on transient failures
11. Doesn't retry on 4xx
You’ll TDD the first 6. The rest are stretch.
Build (RED-GREEN-REFACTOR cycles)
Cycle 1 — RED
Tests/NetworkClientTests.swift:
import XCTest
@testable import NetworkClientLab
final class NetworkClientTests: XCTestCase {
func test_get_returnsDecodedJSON() async throws {
let mock = MockHTTPClient()
mock.stub = (
data: #"{"id":"1","name":"Ada"}"#.data(using: .utf8)!,
response: HTTPURLResponse(url: URL(string: "https://x")!,
statusCode: 200, httpVersion: nil, headerFields: nil)!
)
let sut = NetworkClient(http: mock)
let user: User = try await sut.get("/users/1")
XCTAssertEqual(user.id, "1")
XCTAssertEqual(user.name, "Ada")
}
}
struct User: Codable, Equatable { let id: String; let name: String }
Compile fails — nothing exists. Good.
Cycle 1 — GREEN
App/HTTPClient.swift:
import Foundation
protocol HTTPClient {
func send(_ request: URLRequest) async throws -> (Data, URLResponse)
}
extension URLSession: HTTPClient {
func send(_ request: URLRequest) async throws -> (Data, URLResponse) {
try await data(for: request)
}
}
App/NetworkClient.swift:
import Foundation
enum NetworkError: Error, Equatable {
case notFound
case server(status: Int)
case transport(message: String)
case decoding(message: String)
}
struct NetworkClient {
let baseURL = URL(string: "https://api.example.com")!
let http: HTTPClient
let decoder = JSONDecoder()
func get<T: Decodable>(_ path: String) async throws -> T {
var req = URLRequest(url: baseURL.appendingPathComponent(path))
req.httpMethod = "GET"
let (data, _) = try await http.send(req)
return try decoder.decode(T.self, from: data)
}
}
Tests/MockHTTPClient.swift:
@testable import NetworkClientLab
import Foundation
final class MockHTTPClient: HTTPClient {
var stub: (data: Data, response: HTTPURLResponse)?
var error: Error?
var receivedRequests: [URLRequest] = []
func send(_ request: URLRequest) async throws -> (Data, URLResponse) {
receivedRequests.append(request)
if let error { throw error }
guard let stub else { fatalError("Stub not set") }
return (stub.data, stub.response)
}
}
Run. GREEN.
Cycle 2 — RED
Add to NetworkClientTests:
func test_404_throwsNotFound() async {
let mock = MockHTTPClient()
mock.stub = (data: Data(), response: response(404))
let sut = NetworkClient(http: mock)
do {
let _: User = try await sut.get("/users/missing")
XCTFail("Expected throw")
} catch let error as NetworkError {
XCTAssertEqual(error, .notFound)
} catch {
XCTFail("Wrong error type: \(error)")
}
}
private func response(_ status: Int) -> HTTPURLResponse {
HTTPURLResponse(url: URL(string: "https://x")!,
statusCode: status, httpVersion: nil, headerFields: nil)!
}
Run. RED.
Cycle 2 — GREEN
Update NetworkClient.get:
func get<T: Decodable>(_ path: String) async throws -> T {
var req = URLRequest(url: baseURL.appendingPathComponent(path))
req.httpMethod = "GET"
let (data, response) = try await http.send(req)
guard let http = response as? HTTPURLResponse else {
throw NetworkError.transport(message: "Non-HTTP response")
}
switch http.statusCode {
case 200...299: break
case 404: throw NetworkError.notFound
case 400...499, 500...599: throw NetworkError.server(status: http.statusCode)
default: break
}
do { return try decoder.decode(T.self, from: data) }
catch { throw NetworkError.decoding(message: error.localizedDescription) }
}
GREEN.
Cycle 3 — RED → 500 status
func test_500_throwsServer() async {
let mock = MockHTTPClient()
mock.stub = (Data(), response(500))
let sut = NetworkClient(http: mock)
do {
let _: User = try await sut.get("/users/1")
XCTFail("Expected throw")
} catch let error as NetworkError {
XCTAssertEqual(error, .server(status: 500))
} catch { XCTFail() }
}
Already GREEN from previous cycle. Refactor opportunity: extract validate(_:) into a separate function.
Cycle 4 — RED → transport error
func test_transportError_wraps() async {
let mock = MockHTTPClient()
mock.error = URLError(.notConnectedToInternet)
let sut = NetworkClient(http: mock)
do {
let _: User = try await sut.get("/users/1")
XCTFail()
} catch let error as NetworkError {
if case .transport = error { return }
XCTFail("Wrong case")
} catch { XCTFail() }
}
GREEN: wrap the call.
func get<T: Decodable>(_ path: String) async throws -> T {
var req = URLRequest(url: baseURL.appendingPathComponent(path))
req.httpMethod = "GET"
let result: (Data, URLResponse)
do { result = try await http.send(req) }
catch { throw NetworkError.transport(message: error.localizedDescription) }
let (data, response) = result
try validate(response)
do { return try decoder.decode(T.self, from: data) }
catch { throw NetworkError.decoding(message: error.localizedDescription) }
}
private func validate(_ response: URLResponse) throws {
guard let http = response as? HTTPURLResponse else {
throw NetworkError.transport(message: "Non-HTTP response")
}
switch http.statusCode {
case 200...299: return
case 404: throw NetworkError.notFound
case 400...499, 500...599: throw NetworkError.server(status: http.statusCode)
default: return
}
}
Cycle 5 — RED → decoding error
func test_badJSON_throwsDecoding() async {
let mock = MockHTTPClient()
mock.stub = (Data("not json".utf8), response(200))
let sut = NetworkClient(http: mock)
do {
let _: User = try await sut.get("/users/1")
XCTFail()
} catch let error as NetworkError {
if case .decoding = error { return }
XCTFail()
} catch { XCTFail() }
}
GREEN from existing implementation.
Cycle 6 — RED → POST with body
func test_post_sendsJSONBody() async throws {
let mock = MockHTTPClient()
mock.stub = (#"{"id":"42","name":"X"}"#.data(using: .utf8)!, response(201))
let sut = NetworkClient(http: mock)
let body = User(id: "", name: "Posted")
let result: User = try await sut.post("/users", body: body)
XCTAssertEqual(result.id, "42")
let req = try XCTUnwrap(mock.receivedRequests.first)
XCTAssertEqual(req.httpMethod, "POST")
XCTAssertEqual(req.value(forHTTPHeaderField: "Content-Type"), "application/json")
let sentBody = try JSONDecoder().decode(User.self, from: req.httpBody ?? Data())
XCTAssertEqual(sentBody.name, "Posted")
}
GREEN — add to NetworkClient:
let encoder = JSONEncoder()
func post<Body: Encodable, T: Decodable>(_ path: String, body: Body) async throws -> T {
var req = URLRequest(url: baseURL.appendingPathComponent(path))
req.httpMethod = "POST"
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
req.httpBody = try encoder.encode(body)
let result: (Data, URLResponse)
do { result = try await http.send(req) }
catch { throw NetworkError.transport(message: error.localizedDescription) }
let (data, response) = result
try validate(response)
do { return try decoder.decode(T.self, from: data) }
catch { throw NetworkError.decoding(message: error.localizedDescription) }
}
Refactor — merge GET/POST
struct NetworkClient {
let baseURL = URL(string: "https://api.example.com")!
let http: HTTPClient
private let encoder = JSONEncoder()
private let decoder = JSONDecoder()
func get<T: Decodable>(_ path: String) async throws -> T {
try await perform(path: path, method: "GET", body: Optional<String>.none)
}
func post<Body: Encodable, T: Decodable>(_ path: String, body: Body) async throws -> T {
try await perform(path: path, method: "POST", body: body)
}
private func perform<Body: Encodable, T: Decodable>(
path: String, method: String, body: Body?
) async throws -> T {
var req = URLRequest(url: baseURL.appendingPathComponent(path))
req.httpMethod = method
if let body {
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
req.httpBody = try encoder.encode(body)
}
let result: (Data, URLResponse)
do { result = try await http.send(req) }
catch { throw NetworkError.transport(message: error.localizedDescription) }
try validate(result.1)
do { return try decoder.decode(T.self, from: result.0) }
catch { throw NetworkError.decoding(message: error.localizedDescription) }
}
private func validate(_ response: URLResponse) throws { /* as before */ }
}
Tests all GREEN. Coverage should be ≥ 90%.
Stretch
- Custom headers — accept
headers: [String: String]per call; merge with defaults. TokenProviderprotocol — inject; auto-attachAuthorization: Bearer ….- Retry with exponential backoff — TDD this: test retries 3 times on
.transport, doesn’t retry on.notFound. - Cancellation — verify
Task.cancel()propagates; assert with a custom slow-mock. - Real-world — swap
MockHTTPClientfor the realURLSessionagainsthttps://jsonplaceholder.typicode.comand run the same tests.
Notes
- Each cycle should be under 5 minutes. If a RED is hard to write, your test list is too big — break the case down.
- When you refactor (merge GET/POST), tests stay GREEN throughout. That’s the safety net TDD gives you.
- Resist the urge to write the whole
NetworkClientat once. The pain of going slow now pays off in cleaner design and fewer bugs.
Next: Lab 8.2 — UI Testing Login Flow
Lab 8.2 — UI Testing Login Flow
Goal: write a full XCUITest suite for a SwiftUI login screen. Cover happy path, validation errors, and server errors. Wire the app for testability via launch arguments and a stubbed network layer.
Time: 60–90 minutes.
Prereqs: Xcode 16+, completed Lab 8.1 (or comfort with URLProtocol mocking).
Setup
- New iOS App → SwiftUI → name
LoginUITestLab. - Add a UI Testing Bundle target:
File → New → Target → UI Testing Bundle. - Create the login screen described below.
The login screen (production code)
LoginView.swift:
import SwiftUI
@MainActor
@Observable
final class LoginViewModel {
var email = ""
var password = ""
var isLoading = false
var error: String?
var isAuthenticated = false
private let api: AuthAPI
init(api: AuthAPI = LiveAuthAPI()) { self.api = api }
var canSubmit: Bool {
!isLoading && email.contains("@") && password.count >= 8
}
func submit() async {
isLoading = true; defer { isLoading = false }
error = nil
do {
try await api.signIn(email: email, password: password)
isAuthenticated = true
} catch let err as AuthError {
error = err.message
} catch {
error = "Unexpected error"
}
}
}
struct LoginView: View {
@State private var vm = LoginViewModel()
var body: some View {
NavigationStack {
Form {
Section {
TextField("Email", text: $vm.email)
.textContentType(.emailAddress)
.keyboardType(.emailAddress)
.autocapitalization(.none)
.accessibilityIdentifier("signIn.email")
SecureField("Password", text: $vm.password)
.textContentType(.password)
.accessibilityIdentifier("signIn.password")
}
if let error = vm.error {
Section {
Text(error)
.foregroundStyle(.red)
.accessibilityIdentifier("signIn.error")
}
}
Button {
Task { await vm.submit() }
} label: {
if vm.isLoading {
ProgressView()
} else {
Text("Sign In")
}
}
.disabled(!vm.canSubmit)
.accessibilityIdentifier("signIn.submit")
}
.navigationTitle("Sign In")
.navigationDestination(isPresented: $vm.isAuthenticated) {
Text("Welcome")
.accessibilityIdentifier("home.title")
}
}
}
}
AuthAPI.swift:
import Foundation
protocol AuthAPI: Sendable {
func signIn(email: String, password: String) async throws
}
struct AuthError: Error { let message: String }
struct LiveAuthAPI: AuthAPI {
func signIn(email: String, password: String) async throws {
let url = URL(string: "https://api.example.com/auth/signin")!
var req = URLRequest(url: url)
req.httpMethod = "POST"
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
req.httpBody = try JSONEncoder().encode(["email": email, "password": password])
let (_, resp) = try await URLSession.shared.data(for: req)
guard let http = resp as? HTTPURLResponse, http.statusCode == 200 else {
throw AuthError(message: "Invalid credentials")
}
}
}
Wire the app for test mode
LoginUITestLabApp.swift:
import SwiftUI
@main
struct LoginUITestLabApp: App {
init() {
if CommandLine.arguments.contains("-UITestMode") {
UIView.setAnimationsEnabled(false)
URLProtocol.registerClass(StubURLProtocol.self)
StubURLProtocol.configure(from: CommandLine.arguments)
}
}
var body: some Scene {
WindowGroup { LoginView() }
}
}
StubURLProtocol.swift (in the app target so it can be exercised at runtime):
import Foundation
final class StubURLProtocol: URLProtocol {
nonisolated(unsafe) static var stubStatus: Int = 200
nonisolated(unsafe) static var stubBody: Data = Data()
static func configure(from args: [String]) {
if let idx = args.firstIndex(of: "-StubAuthStatus"),
idx + 1 < args.count, let s = Int(args[idx + 1]) {
stubStatus = s
}
}
override class func canInit(with request: URLRequest) -> Bool {
request.url?.host == "api.example.com"
}
override class func canonicalRequest(for request: URLRequest) -> URLRequest { request }
override func startLoading() {
let response = HTTPURLResponse(url: request.url!,
statusCode: Self.stubStatus,
httpVersion: nil, headerFields: nil)!
client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)
client?.urlProtocol(self, didLoad: Self.stubBody)
client?.urlProtocolDidFinishLoading(self)
}
override func stopLoading() {}
}
Write the UI tests
LoginUITestLabUITests/LoginUITests.swift:
import XCTest
final class LoginUITests: XCTestCase {
var app: XCUIApplication!
override func setUpWithError() throws {
try super.setUpWithError()
continueAfterFailure = false
app = XCUIApplication()
}
private func launch(status: Int = 200) {
app.launchArguments = ["-UITestMode", "-StubAuthStatus", String(status)]
app.launch()
}
func test_emptyForm_submitDisabled() {
launch()
let submit = app.buttons["signIn.submit"]
XCTAssertTrue(submit.waitForExistence(timeout: 2))
XCTAssertFalse(submit.isEnabled)
}
func test_invalidEmail_keepsSubmitDisabled() {
launch()
let email = app.textFields["signIn.email"]
email.tap(); email.typeText("not-an-email")
let pw = app.secureTextFields["signIn.password"]
pw.tap(); pw.typeText("longenough")
XCTAssertFalse(app.buttons["signIn.submit"].isEnabled)
}
func test_validCredentials_navigatesHome() {
launch(status: 200)
signIn(email: "ada@example.com", password: "longenough")
let home = app.staticTexts["home.title"]
XCTAssertTrue(home.waitForExistence(timeout: 3))
}
func test_invalidCredentials_showsError() {
launch(status: 401)
signIn(email: "ada@example.com", password: "longenough")
let error = app.staticTexts["signIn.error"]
XCTAssertTrue(error.waitForExistence(timeout: 3))
}
private func signIn(email: String, password: String) {
let emailField = app.textFields["signIn.email"]
XCTAssertTrue(emailField.waitForExistence(timeout: 2))
emailField.tap(); emailField.typeText(email)
let pwField = app.secureTextFields["signIn.password"]
pwField.tap(); pwField.typeText(password)
app.buttons["signIn.submit"].tap()
}
}
Run
- Select the UI test scheme.
- Cmd+U.
- Watch the simulator dance through four tests in under 30 seconds (animations off).
- If a test fails, screenshots are auto-attached to the test report — open the Report Navigator.
Stretch
- Screenshot on failure — add an
override func tearDownWithErrorthat attachesapp.screenshot()whenever a test fails. - Parallelize — enable parallel testing in the scheme; verify tests still pass with 4 cloned simulators.
- Localization smoke — set
app.launchArguments += ["-AppleLanguages", "(de)"]and verify the accessibility identifiers still find elements (they should — identifiers are not localized). - Network failure — add a
StubAuthErrormode that triggersURLError(.timedOut)and assert the error UI. - Dark mode — pass
-AppleInterfaceStyle Darkand re-run; assert no crashes and that the error text is still readable.
Notes
- Test mode is opt-in via
-UITestMode. Production builds and developer-launched debug builds skip the stub setup entirely. StubURLProtocolonly interceptsapi.example.com. Image CDNs, analytics, etc. still talk to the real internet — for full hermetic tests, broadencanInit.SecureFieldshows assecureTextFields(nottextFields) in the accessibility tree. Easy to miss.app.terminate()between tests is not needed — XCTest launches a fresh app per test by default.
Next: Lab 8.3 — Full Test Suite
Lab 8.3 — Full Test Suite
Goal: take a small data module (Notes) from zero tests to a production-grade suite: unit, snapshot, performance, plus SwiftLint and coverage gating in CI. Hit ≥ 80% coverage on changed code with meaningful assertions.
Time: 120–180 minutes.
Prereqs: Labs 8.1 + 8.2 completed (or equivalent comfort with XCTest, mocking, and CI).
Setup
- New iOS App → SwiftUI →
NotesTestSuiteLab. - Add dependencies via
File → Add Package Dependencies:https://github.com/pointfreeco/swift-snapshot-testing(version 1.17+)
- Add SwiftLint via SPM plugin: package
https://github.com/SimplyDanny/SwiftLintPlugins. AttachSwiftLintBuildToolPluginto the app target.
The module under test
Notes/Note.swift:
import Foundation
struct Note: Identifiable, Codable, Equatable, Sendable {
let id: UUID
var title: String
var body: String
var createdAt: Date
var tags: Set<String>
}
extension Note {
var preview: String {
body.split(separator: "\n").first.map(String.init) ?? ""
}
}
Notes/NoteStore.swift:
import Foundation
protocol Clock: Sendable { func now() -> Date }
struct SystemClock: Clock { func now() -> Date { Date() } }
protocol NotePersistence: Sendable {
func load() throws -> [Note]
func save(_ notes: [Note]) throws
}
@MainActor
@Observable
final class NoteStore {
private(set) var notes: [Note] = []
private let persistence: NotePersistence
private let clock: Clock
init(persistence: NotePersistence, clock: Clock = SystemClock()) {
self.persistence = persistence
self.clock = clock
}
func loadNotes() throws { notes = try persistence.load() }
@discardableResult
func create(title: String, body: String, tags: Set<String> = []) throws -> Note {
let trimmed = title.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { throw NoteError.emptyTitle }
let note = Note(id: UUID(), title: trimmed, body: body, createdAt: clock.now(), tags: tags)
notes.append(note)
try persistence.save(notes)
return note
}
func delete(id: UUID) throws {
notes.removeAll { $0.id == id }
try persistence.save(notes)
}
func search(_ query: String) -> [Note] {
let q = query.lowercased()
guard !q.isEmpty else { return notes }
return notes.filter { note in
note.title.lowercased().contains(q) ||
note.body.lowercased().contains(q) ||
note.tags.contains { $0.lowercased().contains(q) }
}
}
}
enum NoteError: Error, Equatable { case emptyTitle }
Notes/NoteRow.swift:
import SwiftUI
struct NoteRow: View {
let note: Note
var body: some View {
VStack(alignment: .leading, spacing: 4) {
Text(note.title)
.font(.headline)
.lineLimit(1)
Text(note.preview)
.font(.subheadline)
.foregroundStyle(.secondary)
.lineLimit(2)
if !note.tags.isEmpty {
HStack(spacing: 4) {
ForEach(Array(note.tags.sorted()), id: \.self) { tag in
Text(tag)
.font(.caption2)
.padding(.horizontal, 6).padding(.vertical, 2)
.background(.tint.opacity(0.2), in: .capsule)
}
}
}
}
.padding(.vertical, 4)
}
}
Unit tests
NoteStoreTests.swift:
import XCTest
@testable import NotesTestSuiteLab
@MainActor
final class NoteStoreTests: XCTestCase {
private var persistence: InMemoryPersistence!
private var clock: FixedClock!
private var sut: NoteStore!
override func setUpWithError() throws {
try super.setUpWithError()
persistence = InMemoryPersistence()
clock = FixedClock(date: Date(timeIntervalSince1970: 1_700_000_000))
sut = NoteStore(persistence: persistence, clock: clock)
}
func test_create_appendsNote() throws {
try sut.create(title: "Hello", body: "World")
XCTAssertEqual(sut.notes.count, 1)
XCTAssertEqual(sut.notes[0].title, "Hello")
}
func test_create_trimsTitle() throws {
let note = try sut.create(title: " spaced ", body: "")
XCTAssertEqual(note.title, "spaced")
}
func test_create_emptyTitle_throws() {
XCTAssertThrowsError(try sut.create(title: " ", body: "x")) { error in
XCTAssertEqual(error as? NoteError, .emptyTitle)
}
XCTAssertTrue(sut.notes.isEmpty)
}
func test_create_usesInjectedClock() throws {
let note = try sut.create(title: "T", body: "")
XCTAssertEqual(note.createdAt, clock.fixed)
}
func test_create_persists() throws {
try sut.create(title: "T", body: "")
XCTAssertEqual(persistence.saved.last?.count, 1)
}
func test_loadNotes_restoresFromPersistence() throws {
persistence.stored = [Note(id: UUID(), title: "X", body: "",
createdAt: clock.now(), tags: [])]
try sut.loadNotes()
XCTAssertEqual(sut.notes.count, 1)
}
func test_delete_removesNote() throws {
let n = try sut.create(title: "T", body: "")
try sut.delete(id: n.id)
XCTAssertTrue(sut.notes.isEmpty)
}
func test_search_emptyQuery_returnsAll() throws {
try sut.create(title: "A", body: "")
try sut.create(title: "B", body: "")
XCTAssertEqual(sut.search("").count, 2)
}
func test_search_caseInsensitive_matchesTitle() throws {
try sut.create(title: "Swift Tips", body: "")
try sut.create(title: "Kotlin", body: "")
XCTAssertEqual(sut.search("SWIFT").count, 1)
}
func test_search_matchesTags() throws {
try sut.create(title: "T", body: "", tags: ["work"])
try sut.create(title: "U", body: "", tags: ["personal"])
XCTAssertEqual(sut.search("work").count, 1)
}
func test_preview_extractsFirstLine() {
let note = Note(id: UUID(), title: "T", body: "first\nsecond",
createdAt: .now, tags: [])
XCTAssertEqual(note.preview, "first")
}
}
// MARK: - Test doubles
final class InMemoryPersistence: NotePersistence {
var stored: [Note] = []
var saved: [[Note]] = []
func load() throws -> [Note] { stored }
func save(_ notes: [Note]) throws { saved.append(notes); stored = notes }
}
struct FixedClock: Clock {
let fixed: Date
init(date: Date) { self.fixed = date }
func now() -> Date { fixed }
}
Run. All GREEN. Open the Coverage tab — NoteStore should be ≥ 95%.
Snapshot tests
NoteRowSnapshotTests.swift:
import XCTest
import SnapshotTesting
import SwiftUI
@testable import NotesTestSuiteLab
final class NoteRowSnapshotTests: XCTestCase {
// Flip to true once, run, commit __Snapshots__/ folder, flip back to false.
let isRecordingMode = false
override func invokeTest() {
withSnapshotTesting(record: isRecordingMode ? .all : .never) {
super.invokeTest()
}
}
private func host<V: View>(_ view: V, width: CGFloat = 375) -> some View {
view.frame(width: width).background(.background)
}
func test_row_simple() {
let note = Note(id: UUID(), title: "Meeting notes",
body: "Discuss roadmap\nNext steps", createdAt: .now, tags: [])
assertSnapshot(of: host(NoteRow(note: note)), as: .image(layout: .sizeThatFits))
}
func test_row_withTags() {
let note = Note(id: UUID(), title: "Tagged",
body: "Body", createdAt: .now,
tags: ["work", "urgent", "q4"])
assertSnapshot(of: host(NoteRow(note: note)), as: .image(layout: .sizeThatFits))
}
func test_row_longTitle_truncates() {
let note = Note(id: UUID(),
title: "This is a very long title that should truncate after one line because we set lineLimit(1)",
body: "x", createdAt: .now, tags: [])
assertSnapshot(of: host(NoteRow(note: note)), as: .image(layout: .sizeThatFits))
}
}
Flip isRecordingMode = true, run once, commit __Snapshots__/, flip back.
Performance test
NoteStorePerformanceTests.swift:
import XCTest
@testable import NotesTestSuiteLab
@MainActor
final class NoteStorePerformanceTests: XCTestCase {
func test_search_10kNotes_performance() throws {
let persistence = InMemoryPersistence()
let store = NoteStore(persistence: persistence)
for i in 0..<10_000 {
_ = try store.create(title: "Note \(i)", body: "body \(i)", tags: ["tag\(i % 50)"])
}
measure(metrics: [XCTClockMetric()]) {
_ = store.search("tag5")
}
}
}
Set the baseline once you have a representative number. CI gates regressions over 10%.
SwiftLint config
.swiftlint.yml:
included:
- NotesTestSuiteLab
- NotesTestSuiteLabTests
opt_in_rules:
- force_unwrapping
- empty_count
- first_where
- sorted_imports
disabled_rules:
- todo
force_unwrapping: error
force_cast: error
force_try: error
Build the app. SwiftLint runs as a build phase via the plugin.
CI pipeline
.github/workflows/test.yml:
name: Test
on: [pull_request]
jobs:
test:
runs-on: macos-15
steps:
- uses: actions/checkout@v4
- uses: maxim-lobanov/setup-xcode@v1
with:
xcode-version: '16.0'
- name: Run tests with coverage
run: |
xcodebuild test \
-scheme NotesTestSuiteLab \
-destination 'platform=iOS Simulator,name=iPhone 15,OS=18.0' \
-enableCodeCoverage YES \
-resultBundlePath build/result.xcresult \
| xcpretty
- name: Generate coverage report
run: |
xcrun xccov view --report --json build/result.xcresult > coverage.json
- name: Coverage gate
run: |
COVERAGE=$(jq '.targets[] | select(.name=="NotesTestSuiteLab.app") | .lineCoverage' coverage.json)
THRESHOLD=0.80
echo "Coverage: $COVERAGE (gate: $THRESHOLD)"
awk -v c="$COVERAGE" -v t="$THRESHOLD" 'BEGIN { exit (c < t) }'
- name: SwiftLint
run: |
brew install swiftlint
swiftlint --strict --reporter github-actions-logging
Commit. Open a PR. Watch CI: tests run, coverage extracted, gate enforced at 80%, lint runs strict.
Stretch
- Branch coverage — switch
-enableCodeCoverage YESanalysis to usexcrun xccov view --report --has-branch-coverageand report branch coverage in CI. - Codecov integration — sign up at codecov.io, add the token as a GitHub secret, replace the inline gate with
codecov/codecov-action@v4and configure status checks on changed-lines-only. - Mutation testing — install
muter; run it onNoteStore.swiftand improve the suite until the mutation score is ≥ 70%. - Parallel UI tests — add a UI test target with the login pattern from Lab 8.2; configure parallel execution with 4 simulator clones.
- PR coverage comment — write a tiny script that diffs
coverage.jsonbetweenmainand your branch, posts the delta as a PR comment.
Notes
withSnapshotTesting(record:)is the modern API inswift-snapshot-testing1.16+. Older guides reference a globalisRecording— both work.- The performance test will fail the first time (no baseline). Right-click the gray diamond → “Set Baseline” once you’re satisfied.
- SwiftLint’s SPM plugin requires Xcode to grant the plugin permission on first build — accept the prompt.
- CI runtime should be under 4 minutes for the full suite. If it’s longer, profile and split into matrix jobs.
Phase 8 complete. Phase 9 (Security & Secure Coding) starts with OWASP Mobile Top 10, Keychain, biometrics, and TLS pinning.
9.1 — OWASP Mobile Top 10
Opening scenario
You’re four weeks from launching a fintech iOS app. The bank’s security team sends a 22-page audit. Half the findings reference “OWASP Mobile Top 10 M3” or “M4” with no further explanation. They want fixes before the security signoff meeting on Friday. You need the map between the abstract OWASP categories and the concrete iOS APIs that close each gap.
Context — what OWASP Mobile Top 10 is
OWASP (Open Web Application Security Project) publishes a periodic ranked list of the ten most impactful mobile security risks. The current generation (2024 refresh):
| # | Category | iOS-flavored hit list |
|---|---|---|
| M1 | Improper Credential Usage | Hard-coded API keys, tokens in UserDefaults |
| M2 | Inadequate Supply Chain Security | Unvetted SPM/CocoaPods deps, malicious typosquats |
| M3 | Insecure Authentication/Authorization | Auth on client only, missing rate-limit, no Passkeys |
| M4 | Insufficient Input/Output Validation | NSPredicate format injection, web view XSS |
| M5 | Insecure Communication | Plain HTTP, no TLS pinning, wildcard ATS exceptions |
| M6 | Inadequate Privacy Controls | Missing PrivacyInfo, over-broad permission strings |
| M7 | Insufficient Binary Protections | No symbol strip, no jailbreak detection on high-value apps |
| M8 | Security Misconfiguration | Debug logs in release, .plist with secrets, exposed URL schemes |
| M9 | Insecure Data Storage | UserDefaults for tokens, unencrypted SQLite, Keychain with weak accessibility |
| M10 | Insufficient Cryptography | CommonCrypto with ECB, hand-rolled key derivation, fixed IVs |
For each category, there’s a specific Apple API or pattern that addresses it. Memorize the mapping — it’s the language security teams speak.
The mapping
M1 → Keychain + OAuth2/PKCE + ASWebAuthenticationSession
M2 → Package.resolved review, swift-package-manager checksums, dependabot, SBOM
M3 → ASAuthorizationController (Passkeys), LAContext (biometrics), server-side authz
M4 → Codable strict decoding, NSPredicate with %@ placeholders, WKWebView with strict config
M5 → URLSession + TLS 1.3 default + URLSessionDelegate pinning
M6 → PrivacyInfo.xcprivacy, App Tracking Transparency, minimum permissions
M7 → STRIP_SWIFT_SYMBOLS, IOSSecuritySuite, RASP libraries
M8 → xcconfig per env, no os_log without privacy specifier, Info.plist audit
M9 → Keychain (kSecAttrAccessibleWhenUnlocked), Data Protection class, SQLCipher
M10 → CryptoKit only (AES-GCM, ChaChaPoly, Curve25519)
Walking through a real audit
When a security report cites “M9 violation — login token persisted in UserDefaults”:
- Acknowledge specifically: “Confirmed: AuthManager.swift line 47 writes the JWT to UserDefaults.”
- Identify the right primitive: Keychain with
kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly. - Migrate safely: write to Keychain, fallback-read from UserDefaults for one release, then delete the UserDefaults entry on next launch.
- Add a test that fails if the token ever lands in UserDefaults again.
The senior signal isn’t fixing the bug — it’s the disciplined migration that doesn’t strand existing users.
In the wild
When the 2023 LastPass breach post-mortem traced back to a developer’s home-stored secrets, the entire mobile community re-audited. Apps that had M1 (credentials in source) discovered them in old PR history; M2 (supply chain) audits surfaced packages last updated in 2019 by single maintainers. The category names became standing agenda items in iOS security reviews.
Common misconceptions
- “OWASP Mobile Top 10 = OWASP Top 10.” Different project, different list. Web Top 10 (SQLi, XSS) overlaps with M4 but the mobile list emphasizes binary protections and storage that the web list ignores.
- “If we pass App Store Review, we’re secure.” Review checks ATS, permission strings, basic crash safety. It doesn’t audit Keychain accessibility flags or pinning configuration.
- “M7 (binary protections) doesn’t matter for normal apps.” True for low-value apps. False for banking, healthcare, IP-sensitive code. Know your threat model.
- “OWASP categories are rare edge cases.” M9 (insecure storage) and M5 (insecure comms) appear in roughly half of all iOS audits — these are the default failure modes.
- “Hand-rolled crypto is fine if we’re careful.” M10. Always use CryptoKit. Never write your own AES-GCM, never reuse an IV, never invent a KDF.
Seasoned engineer’s take
The Top 10 isn’t a list of paranoid worries — it’s a checklist that, if followed, eliminates 80 % of the bugs that lead to breach reports. Treat it as the iOS equivalent of the runtime errors a compiler catches: cheap to prevent, expensive to discover post-shipping. The pattern of senior security engineers is to internalize the M1–M10 mapping so completely that they can scan a PR diff and instantly think “M9 violation here” without consulting docs.
TIP: Pin a
security-checklist.mdto your repo’s root that lists each M-number with the project-specific control. Reviewers can tag PR comments[M9]or[M5]and link directly.
WARNING: Don’t dismiss findings as “theoretical.” The M-categories rank by impact-weighted frequency in real breaches, not by interestingness. M9 is #9 in the name but #2 in actual breach data.
Interview corner
Junior: “What’s the OWASP Mobile Top 10?” A ranked list of the ten most common security risks in mobile apps, refreshed periodically. For iOS each category maps to specific Apple APIs — e.g., M9 (insecure storage) maps to Keychain, M5 (insecure comms) maps to TLS pinning.
Mid: “How would you address an M9 finding in a legacy app storing tokens in UserDefaults?”
Migrate the read/write to Keychain with kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly. Add a one-release transition that reads from Keychain first and falls back to UserDefaults so existing users don’t sign out; on first successful Keychain read, delete the UserDefaults copy. Add a test that asserts the token key never appears in UserDefaults.standard.dictionaryRepresentation().
Senior: “How do you operationalize OWASP Mobile Top 10 on a 12-engineer iOS team?”
Three layers. First, a pinned security-checklist.md in the repo mapping each M-category to project-specific controls and review tags. Second, automated gates — SwiftLint security rules, semgrep for known bad patterns, dependabot for M2, and a CI check that fails if UserDefaults is written from AuthManager or any file matching *Auth*. Third, a quarterly threat-modeling exercise where we walk through the M-list against new features shipped that quarter and update controls. The point is making the abstract OWASP language concrete in the codebase so it shapes day-to-day PR review, not just annual audits.
Red-flag answer: “We rely on Apple’s sandboxing.” Sandbox blocks some classes of attack but does nothing for M9, M5, M3, M10. Sandbox alone is not a security strategy.
Lab preview
Lab 9.3 hands you an app with 8 deliberate OWASP violations across the M-categories. You’ll identify each one by category and fix it with the right primitive.
Next: 9.2 — Secure Data Storage
9.2 — Secure Data Storage
Opening scenario
A pentester opens your app’s container on a jailbroken iPhone and finds UserDefaults.plist containing a raw OAuth refresh token. With that token they can impersonate the user from anywhere for the next 30 days. The bug took 4 lines of code to introduce; preventing it takes the same 4 lines pointed at a different API. This chapter is the map of where to put what.
Context — the iOS storage hierarchy
| Mechanism | Encrypted at rest? | Survives reinstall? | Backed up to iCloud? | Use for |
|---|---|---|---|---|
UserDefaults | At-rest via Data Protection (when locked) | No | Yes | Non-sensitive preferences |
FileManager (Documents) | Yes (Data Protection) | No | Yes | User-generated content |
FileManager (Library/Caches) | Yes | No | No | Re-downloadable cache |
FileManager (tmp) | Yes | No | No | Throwaway |
| Core Data / SwiftData (default) | Yes (Data Protection) | No | Configurable | Structured app data |
| Core Data + SQLCipher | Yes + app-level key | No | Configurable | Highly sensitive structured data |
| Keychain (default) | Yes + hardware-bound | Yes | Configurable | Tokens, passwords, small secrets |
| Secure Enclave | N/A (keys never leave SE) | Yes | No | Private keys for signing |
The single most-violated rule: secrets go in Keychain, not UserDefaults. UserDefaults is Data-Protected when the device is locked, but readable freely when unlocked — including by file-system inspection on jailbroken devices. Keychain entries are tied to the device hardware and accessible only to your app (or your app group / team).
Keychain deep dive
The Keychain API is C-flavored CFDictionary-based. Wrap it once, never expose the raw API:
import Security
import Foundation
enum KeychainError: Error { case unhandled(OSStatus) }
struct Keychain {
static func set(_ data: Data, for key: String) throws {
let q: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: key,
]
SecItemDelete(q as CFDictionary)
var add = q
add[kSecValueData as String] = data
add[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
let status = SecItemAdd(add as CFDictionary, nil)
guard status == errSecSuccess else { throw KeychainError.unhandled(status) }
}
static func get(_ key: String) -> Data? {
let q: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: key,
kSecReturnData as String: true,
kSecMatchLimit as String: kSecMatchLimitOne,
]
var result: AnyObject?
guard SecItemCopyMatching(q as CFDictionary, &result) == errSecSuccess else { return nil }
return result as? Data
}
static func delete(_ key: String) {
let q: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: key,
]
SecItemDelete(q as CFDictionary)
}
}
Accessibility classes (the most important parameter)
| Constant | When readable | iCloud Keychain syncs |
|---|---|---|
kSecAttrAccessibleWhenUnlocked | Device unlocked | Yes if app opts in |
kSecAttrAccessibleWhenUnlockedThisDeviceOnly | Device unlocked | No |
kSecAttrAccessibleAfterFirstUnlock | After first unlock since boot | Yes if app opts in |
kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly | After first unlock since boot | No |
kSecAttrAccessibleWhenPasscodeSetThisDeviceOnly | Only if user has a passcode set; device unlocked | No |
Default to AfterFirstUnlockThisDeviceOnly for background-friendly secrets (BGAppRefresh needs them, but you don’t want them on a restored backup on a different device). Bump to WhenUnlockedThisDeviceOnly for high-value items that should never be readable from background. Use WhenPasscodeSetThisDeviceOnly for items that fundamentally require a passcoded device.
Keychain access groups + extensions
Sharing between an app and its extension (widget, share extension) requires a keychain access group:
let q: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: key,
kSecAttrAccessGroup as String: "TEAMID.com.acme.shared",
]
The access group must be declared in both targets’ entitlements. Without it, your widget cannot read the token your app wrote.
Data Protection classes (file-level)
File-system files have their own protection class:
let url = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
.appendingPathComponent("vault.dat")
try data.write(to: url, options: [.completeFileProtection])
| Option | When readable |
|---|---|
.noFileProtection | Always (anti-pattern; only for cache that must work pre-first-unlock) |
.completeUntilFirstUserAuthentication | After first unlock since boot |
.completeUnlessOpen | Locked except while file is open |
.complete | Only while device unlocked |
Default for new files is .completeUntilFirstUserAuthentication. Upgrade to .complete for high-sensitivity files; understand it’ll block background access while the screen is locked.
What never to put in UserDefaults
- Auth tokens (access, refresh, session)
- Passwords or password hashes
- API keys with non-revocable scope
- PII (email, phone, address, government IDs)
- Encryption keys
- Webhook secrets
Pattern of replacement: each UserDefaults.standard.set(token, forKey:) becomes try Keychain.set(tokenData, for: "auth.token"). Add a lint rule that flags UserDefaults writes from any file matching *Auth* or *Secret*.
Encrypted Core Data / SwiftData
The default SwiftData / Core Data store is Data-Protected but not app-key encrypted — anyone with file-system access (jailbreak, forensic tools) and the device passcode can read it. For genuinely sensitive structured data:
- SQLCipher — drop-in SQLite replacement with AES-256 encryption. Use via GRDB.swift. Key derivation through
CryptoKitHKDF, key stored in Keychain. - Manual envelope encryption — encrypt sensitive fields at the model layer before persistence, decrypt on read. More work, but Core Data introspection remains useful.
Don’t roll your own. SQLCipher is battle-tested and the GRDB wrapper is clean.
Secure Enclave for private keys
For asymmetric cryptography (signing, key exchange), the Secure Enclave is a separate co-processor that generates and holds keys the main CPU never sees:
import CryptoKit
let attributes: [String: Any] = [
kSecAttrKeyType as String: kSecAttrKeyTypeECSECPrimeRandom,
kSecAttrKeySizeInBits as String: 256,
kSecAttrTokenID as String: kSecAttrTokenIDSecureEnclave,
kSecPrivateKeyAttrs as String: [
kSecAttrIsPermanent as String: true,
kSecAttrApplicationTag as String: "com.acme.signing".data(using: .utf8)!,
],
]
var error: Unmanaged<CFError>?
let key = SecKeyCreateRandomKey(attributes as CFDictionary, &error)
// Sign with key; private key never leaves the SE.
Only ECC P-256 keys are supported in the SE. Use for: signing user-facing tokens, Passkeys, app-attestation receipts, anything where loss of the private key would be catastrophic.
In the wild
1Password’s iOS app uses Keychain for the master vault key with kSecAttrAccessibleWhenPasscodeSetThisDeviceOnly, plus SQLCipher for the local vault database keyed by a derived key. Restoring a backup to a new device intentionally cannot unlock the vault — the user must re-authenticate with the master password.
Common misconceptions
- “UserDefaults is encrypted enough.” Only when locked, and trivially readable on jailbreak. Never for secrets.
- “Keychain is automatically shared with extensions.” No — explicit
kSecAttrAccessGroup+ entitlement required. - “Items in Keychain survive app uninstall.” They do on iOS by default (changed historically). Delete deliberately on logout/reset.
- “Secure Enclave can store arbitrary data.” No — only ECC P-256 keys. For other secrets, store the key handle in SE and encrypted blob in Keychain or file.
- “
.completeData Protection is always best.” It breaks background tasks running while the device is locked (BGAppRefresh, location updates). Match the class to the workload.
Seasoned engineer’s take
Storage decisions are quietly the most consequential security decisions in an iOS app. They’re invisible in code review unless someone is looking; they show up in audit reports years later. Build a thin SecureStorage abstraction with three methods (storeSecret, readSecret, deleteSecret) and a single internal implementation that picks the right primitive per data sensitivity tag. Force every callsite through it; ban direct UserDefaults/FileManager writes for anything tagged sensitive.
TIP: For brand-new projects, never call
UserDefaultsfor anything but UI preferences. Even a launch counter belongs in a wrapper that you can later swap. The discipline pays for itself the first time someone tries to stash a token there.
WARNING:
kSecAttrAccessibleAlwaysexists but is deprecated/discouraged. If you see it in a legacy codebase, treat it as a bug — it lets the secret be read while the device is locked, defeating the whole point.
Interview corner
Junior: “Where would you store an auth token?”
Keychain, with accessibility AfterFirstUnlockThisDeviceOnly for typical background-friendly tokens, or WhenUnlockedThisDeviceOnly for high-value tokens that shouldn’t be read while the screen is locked. Never UserDefaults.
Mid: “Why prefer *ThisDeviceOnly accessibility classes?”
They opt out of iCloud Keychain sync. If a user restores a backup to a different device, the secret doesn’t follow — which is usually what you want for tokens, since you’d rather force re-authentication than risk cross-device token replay.
Senior: “Design the storage layer for a banking app — what goes where?”
I’d build a SecureStorage facade with sensitivity tags: public, private, secret, top-secret. Public lives in UserDefaults. Private lives in Documents with .completeUntilFirstUserAuthentication Data Protection. Secret — auth tokens, API keys — Keychain with WhenUnlockedThisDeviceOnly. Top-secret — payment authorization keys — Secure Enclave for the private key plus SQLCipher for the encrypted local database, with the SQLCipher key derived via HKDF from a Keychain-stored seed gated by biometrics. Backups: explicitly exclude the SQLCipher store from iCloud via URLResourceKey.isExcludedFromBackupKey so a restored device doesn’t ship an encrypted-but-extractable database. Then I’d ban direct UserDefaults/FileManager access for anything but UI prefs via SwiftLint rule, forcing every persistence path through the facade. The facade is testable, swappable, and audit-friendly.
Red-flag answer: “We encrypt it ourselves with AES.” Hand-rolled storage encryption is M10. Use CryptoKit or SQLCipher; never invent.
Lab preview
Lab 9.1 (Secure Notes App) starts with an intentionally insecure version storing notes plaintext in UserDefaults. You’ll migrate to Keychain-keyed SQLCipher with biometric unlock.
Next: 9.3 — Network Security & TLS Pinning
9.3 — Network Security & TLS Pinning
Opening scenario
You launch the app on a corporate Wi-Fi that’s running TLS interception (“for compliance”). Your API requests appear in mitmproxy in plaintext. The attacker — or the curious IT admin — can read every request, response, and auth token in flight. Your code did nothing wrong; TLS just trusted the wrong root. Pinning is the fix.
Context — what TLS gives you (and doesn’t)
Default URLSession requests use TLS 1.3 (or 1.2 with FS ciphers) and validate against the system trust store. That defends against passive eavesdropping on the open internet. It does not defend against:
- A user-installed root certificate (Charles, mitmproxy, corporate MDM)
- A compromised public CA issuing a fraudulent cert
- An attacker on the local network with a malicious DNS + freshly-issued LE cert
- State-level adversaries with CA influence
Certificate pinning locks your app to a specific certificate (leaf), certificate chain (intermediate or root), or public key, refusing any TLS connection whose chain doesn’t include the expected pin.
Pinning strategies
| Strategy | What you pin | Pros | Cons |
|---|---|---|---|
| Leaf cert pinning | The server’s specific TLS cert | Strongest | Breaks on every cert renewal (~90 days for LE, 1 yr for paid) |
| Intermediate / CA pinning | One level up | Survives leaf renewal | Breaks if you change CA |
| Public-key pinning (SPKI) | Hash of the public key (in leaf or intermediate) | Survives cert renewal as long as key reused | Requires planning the rotation in advance |
Most teams pin the SPKI hash of the leaf or intermediate, with one or two backup pins for emergency rotation. The backup pin corresponds to a key already generated and stored offline, ready to swap in.
Implementing pinning with URLSessionDelegate
import CryptoKit
import Foundation
final class PinnedDelegate: NSObject, URLSessionDelegate {
// Base64(SHA-256(SubjectPublicKeyInfo DER))
private let pins: Set<String> = [
"AAAA…primary",
"BBBB…backup",
]
func urlSession(
_ session: URLSession,
didReceive challenge: URLAuthenticationChallenge,
completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void
) {
guard
challenge.protectionSpace.authenticationMethod == NSURLAuthenticationMethodServerTrust,
let trust = challenge.protectionSpace.serverTrust
else {
completionHandler(.performDefaultHandling, nil)
return
}
// First let the system validate the chain normally.
var error: CFError?
guard SecTrustEvaluateWithError(trust, &error) else {
completionHandler(.cancelAuthenticationChallenge, nil)
return
}
// Then walk the chain looking for any cert whose SPKI matches a pin.
let count = SecTrustGetCertificateCount(trust)
for i in 0..<count {
guard let cert = SecTrustGetCertificateAtIndex(trust, i),
let publicKey = SecCertificateCopyKey(cert),
let publicKeyData = SecKeyCopyExternalRepresentation(publicKey, nil) as Data?
else { continue }
let hash = SHA256.hash(data: publicKeyData)
let pin = Data(hash).base64EncodedString()
if pins.contains(pin) {
completionHandler(.useCredential, URLCredential(trust: trust))
return
}
}
completionHandler(.cancelAuthenticationChallenge, nil)
}
}
let session = URLSession(
configuration: .default,
delegate: PinnedDelegate(),
delegateQueue: nil
)
Two subtleties:
- Always run
SecTrustEvaluateWithErrorfirst. Pinning augments trust validation, not replaces it. Skipping the system check bypasses revocation, expiry, hostname validation. - Pin the SPKI, not the full cert bytes. SPKI hashes survive certificate renewals when the same key is reused.
For real apps, use TrustKit (production-tested wrapper with reporting, fallback, multiple pins per host). Don’t ship the raw delegate above without extensive testing.
Computing pins for your server
# Get the leaf cert from your server
openssl s_client -servername api.acme.com -connect api.acme.com:443 < /dev/null \
| openssl x509 -outform DER \
| openssl dgst -sha256 -binary \
| openssl enc -base64
For SPKI hash (recommended):
openssl s_client -servername api.acme.com -connect api.acme.com:443 < /dev/null \
| openssl x509 -pubkey -noout \
| openssl pkey -pubin -outform DER \
| openssl dgst -sha256 -binary \
| openssl enc -base64
Pin the primary key AND a backup key you’ve pre-generated. Store the backup private key offline (HSM or sealed envelope); rotate when needed without a forced app update.
Rotation strategy — the part teams forget
Pinning will break the day a cert silently rotates. Avoid the outage:
- Pin the primary key + at least one backup key.
- Ship a remote config flag (
pinning.enabled) so you can disable from server in catastrophe. - Monitor failed pinning attempts (count + sample) via analytics, separated from generic network failures.
- Roll the cert to use the backup key, then mint a new backup key, then ship an app update with the new pin set. Never rotate to a key you haven’t pre-pinned.
TLS misconfigurations to look for
- Allowed ATS exceptions —
NSAllowsArbitraryLoads = truefor any production domain is a red flag. Document every exception. - Self-signed dev certs leaking into release — a
URLSessiondelegate that returns.useCredentialunconditionally during dev must be#if DEBUG-gated, and ideally guarded by build configuration not branch. - Outdated TLS versions — ATS requires TLS 1.2 with FS; the
NSExceptionMinimumTLSVersionkey can downgrade. Audit every exception. - Plain
http://anywhere — should fail ATS unless explicitly excepted.
In the wild
The Signal iOS app pins to the Signal Foundation’s intermediate CA. The Reddit iOS app uses leaf pinning with TrustKit. WhatsApp pins SPKI hashes via the same library. The pattern across high-stakes consumer apps is consistent: SPKI hashes + two pins + remote kill-switch.
Common misconceptions
- “TLS is enough.” It defends against passive observers, not against user-trusted roots or compromised CAs. Pinning is the closer.
- “Pinning the leaf is best because it’s most specific.” Most specific = most fragile. The cert rotates and your app breaks. Pin SPKI of leaf OR an intermediate, with a backup.
- “Pinning prevents jailbroken-device interception.” Doesn’t help against an attacker who repackages your IPA with the pinning code removed. Combine with jailbreak detection and code integrity checks for that threat model.
- “Pinning solves all MITM.” Pinning fails open if
URLSessionDelegateis bypassed (e.g.,WKWebViewrequests, third-party SDKs that have their own networking). Audit every networking surface. - “We can pin once and forget.” Pinning is an operational commitment. Plan the rotation, monitor failures, ship a remote kill-switch.
Seasoned engineer’s take
Pinning is a discipline, not a feature. The decision to pin commits the team to a quarterly rotation drill and a monitoring pipeline. Apps that pin without that operational muscle ship outages every 12 months when their cert silently rolls. Before adding pinning, decide who owns the rotation runbook and how you’ll be alerted when 90 % of users start failing. Without that owner, pinning is a footgun.
TIP: Add a synthetic pinning-failure metric to your dashboards (separate from general network errors) so a misconfigured rotation surfaces in minutes, not customer complaints.
WARNING: Never ship pinning without a server-side kill switch. The day you need to rotate to an unplanned cert, your only options without the switch are “force-update every user” or “leave them with a broken app.”
Interview corner
Junior: “What’s certificate pinning?” Restricting your app to trust only specific certificates or public keys instead of every cert chained to a system-trusted CA. Defends against malicious CAs and user-installed proxy roots.
Mid: “How do you avoid an outage when the pinned cert rotates?” Pin the SubjectPublicKeyInfo hash of the leaf or intermediate, not the leaf cert bytes — SPKI survives cert renewal as long as the same key pair is reused. Always pin a primary plus at least one backup whose private key is generated and stored offline. Ship a remote kill switch, and monitor pinning-specific failures.
Senior: “Walk me through deploying pinning for the first time on a 5M-user app.” First, instrument: ship a release that measures what the SPKI pins would be on every request, reporting back via analytics but never enforcing. Confirm the distribution looks like what you expect — one primary pin, no surprises from CDN edge variations. Second, ship enforcement gated by a remote flag, defaulted off; flip it to 1 % of users, then 10 %, then 50 %, monitoring the pinning-failure metric distinct from generic network errors. Third, write the rotation runbook before enabling fully: backup pin location, rotation steps, kill-switch URL, on-call rotation. Only at 100 % rollout with a runbook in place do I consider pinning shipped. The technology takes a day; the operational discipline takes a quarter.
Red-flag answer: “We just pin and forget.” Reveals lack of operational thinking; this is how teams ship 6-month outages.
Lab preview
Lab 9.2 walks you through adding TrustKit-based pinning to a sample API client, then attempting a mitmproxy intercept and verifying it’s blocked.
Next: 9.4 — Authentication, Biometrics & Secure Enclave
9.4 — Authentication, Biometrics & Secure Enclave
Opening scenario
The PM asks: “Can we add Face ID to unlock the app?” Sounds trivial. But the answer to what Face ID actually authenticates, what gets unlocked, and what happens when Face ID fails shapes the entire auth architecture. Get it wrong and a screenshot-style attacker walks past your “secured” app; get it right and even a stolen unlocked phone can’t decrypt the vault.
Context — three different auth concerns
| Concern | What it answers | iOS API |
|---|---|---|
| Authentication to your server | “Is this user really alice@acme.com?” | OAuth/PKCE + token, Passkeys |
| Local re-confirmation | “Is this still alice holding her phone?” | LAContext (biometrics) |
| Cryptographic identity | “Sign this challenge with a key only this device holds” | Secure Enclave + ASAuthorization |
Each requires different APIs and threat models. Conflating them is the most common architectural mistake.
LocalAuthentication for re-confirmation
import LocalAuthentication
func reconfirm() async throws {
let context = LAContext()
var error: NSError?
guard context.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: &error) else {
throw error ?? AuthError.biometricsUnavailable
}
let ok = try await context.evaluatePolicy(
.deviceOwnerAuthenticationWithBiometrics,
localizedReason: "Confirm to unlock your vault"
)
guard ok else { throw AuthError.userCancelled }
}
Two policies:
.deviceOwnerAuthenticationWithBiometrics— biometrics only; no passcode fallback..deviceOwnerAuthentication— biometrics first, falls back to device passcode after failures.
Use WithBiometrics for genuine biometric reconfirmation; use the broader policy when “verify the device owner is present, somehow” is acceptable.
Critically: LAContext.evaluatePolicy returning true does not give you anything cryptographic. It’s a UI-level confirmation. The attacker who jailbreaks the device can hook the function to always return true. Gate something cryptographic on the auth.
Cryptographic gating via the Keychain
To make biometrics actually decrypt something, store the key with SecAccessControl:
import Security
func storeBiometricGatedSecret(_ data: Data) throws {
let access = SecAccessControlCreateWithFlags(
nil,
kSecAttrAccessibleWhenPasscodeSetThisDeviceOnly,
.biometryCurrentSet,
nil
)!
let q: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: "vault.key",
kSecValueData as String: data,
kSecAttrAccessControl as String: access,
]
SecItemDelete(q as CFDictionary)
let status = SecItemAdd(q as CFDictionary, nil)
guard status == errSecSuccess else { throw KeychainError.unhandled(status) }
}
SecAccessControl flags:
| Flag | Effect |
|---|---|
.userPresence | Biometric or passcode |
.biometryAny | Any enrolled biometric (Face ID or Touch ID) |
.biometryCurrentSet | Only currently-enrolled fingerprints/faces; invalidated if user enrolls a new biometric |
.devicePasscode | Device passcode only |
.privateKeyUsage | Enables use with Secure Enclave keys |
Pair with .biometryCurrentSet for high-security flows — if anyone adds a new fingerprint, the secret becomes inaccessible until re-enrolled by the user. This blocks “let a friend add their finger and unlock my vault” attacks.
Secure Enclave for key-pair operations
For asymmetric crypto (signing API calls, deriving session keys), generate the key inside the SE so the private half never reaches main memory:
import CryptoKit
import Security
let access = SecAccessControlCreateWithFlags(
nil,
kSecAttrAccessibleWhenPasscodeSetThisDeviceOnly,
[.privateKeyUsage, .biometryCurrentSet],
nil
)!
let attributes: [String: Any] = [
kSecAttrKeyType as String: kSecAttrKeyTypeECSECPrimeRandom,
kSecAttrKeySizeInBits as String: 256,
kSecAttrTokenID as String: kSecAttrTokenIDSecureEnclave,
kSecPrivateKeyAttrs as String: [
kSecAttrIsPermanent as String: true,
kSecAttrApplicationTag as String: "com.acme.signing".data(using: .utf8)!,
kSecAttrAccessControl as String: access,
],
]
var error: Unmanaged<CFError>?
guard let privateKey = SecKeyCreateRandomKey(attributes as CFDictionary, &error) else { throw error!.takeRetainedValue() }
let publicKey = SecKeyCopyPublicKey(privateKey)!
To sign: SecKeyCreateSignature(privateKey, .ecdsaSignatureMessageX962SHA256, data, &error). Each invocation triggers the biometric prompt (since .biometryCurrentSet is set on the key’s access control). Even if an attacker has the device unlocked, signing requires a fresh face.
Passkeys (FIDO2 / WebAuthn)
Passkeys are the modern replacement for passwords. Apple’s implementation uses Secure Enclave-backed ECC keys synced via iCloud Keychain. The server stores only the public key; the device stores the private key; biometric unlock signs the authentication challenge.
import AuthenticationServices
final class PasskeySignIn: NSObject, ASAuthorizationControllerDelegate,
ASAuthorizationControllerPresentationContextProviding {
func signIn(relyingPartyID: String, challenge: Data) {
let provider = ASAuthorizationPlatformPublicKeyCredentialProvider(relyingPartyIdentifier: relyingPartyID)
let request = provider.createCredentialAssertionRequest(challenge: challenge)
let controller = ASAuthorizationController(authorizationRequests: [request])
controller.delegate = self
controller.presentationContextProvider = self
controller.performRequests()
}
// delegate methods…
}
Server side, you implement WebAuthn relying-party logic. Use a vetted library (e.g., webauthn-rs, py_webauthn, or platform-specific equivalents); don’t roll your own challenge verification.
Adoption pattern: offer Passkeys alongside OAuth and email-link login. Don’t make it the only option until your support team is ready for “how do I sync my passkey to a non-Apple device” tickets.
OAuth 2.0 with PKCE via ASWebAuthenticationSession
For third-party identity providers (Google, GitHub, your own SSO), ASWebAuthenticationSession is the right primitive — it shares cookies with Safari for SSO and supports the secure return URL handshake:
import AuthenticationServices
func login() async throws -> URL {
let codeVerifier = generateRandomString(64)
let codeChallenge = sha256Base64URL(codeVerifier)
let url = URL(string: "https://idp.acme.com/oauth/authorize?client_id=…&code_challenge=\(codeChallenge)&code_challenge_method=S256&redirect_uri=acme://callback")!
return try await withCheckedThrowingContinuation { cont in
let session = ASWebAuthenticationSession(url: url, callbackURLScheme: "acme") { url, error in
if let url { cont.resume(returning: url) }
else { cont.resume(throwing: error ?? AuthError.unknown) }
}
session.presentationContextProvider = self
session.prefersEphemeralWebBrowserSession = false // true to disable SSO cookies
session.start()
}
}
Exchange the returned code for tokens server-side (PKCE ensures the original verifier matches). Store the refresh token in Keychain with biometric gating; rotate access tokens via short expiry.
JWT storage
Refresh tokens: Keychain, WhenUnlockedThisDeviceOnly, ideally biometric-gated.
Access tokens: short-lived (5-15 min), can live in memory only and re-fetch on app cold start. If you must persist, Keychain with the same accessibility.
Never put tokens in UserDefaults, plain files, or query strings. Send via Authorization: Bearer header, not URL.
In the wild
Apple’s own Wallet app uses Secure Enclave keys for every payment authorization, with .biometryCurrentSet so re-enrolling Face ID invalidates the payment credential and forces re-add. 1Password’s vault key derivation uses biometric-gated Keychain plus a server-side challenge. Bank of America’s app uses Passkeys for primary login since 2024, with OAuth/PKCE as fallback.
Common misconceptions
- “Face ID success = authenticated.” No —
LAContext.evaluatePolicyreturning true is UI-level. Real security comes from gating a cryptographic operation on the biometric. - “Secure Enclave can store any secret.” Only ECC P-256 keys. For symmetric secrets, store the key handle in SE and use it to derive symmetric keys.
- “Passkeys replace OAuth.” They replace the password step. You still need a server with user accounts; Passkeys just handle the authentication primitive.
- “
SFAuthenticationSession/SFSafariViewControlleris fine for OAuth.” Both are deprecated for auth flows. UseASWebAuthenticationSession— it’s the only API that handles the redirect-back-to-app handoff securely. - “Biometric-gated Keychain items survive Face ID re-enrollment.” Only if you use
.biometryAny. With.biometryCurrentSet, re-enrollment invalidates the item — usually what you want for high-value secrets.
Seasoned engineer’s take
Auth architecture is layered cake: server-side identity (OAuth/Passkeys), token storage (Keychain), re-confirmation gates (LAContext + access controls), and cryptographic ops (Secure Enclave). Each layer addresses a different threat. Cargo-culting one layer without the others — “we added Face ID!” — creates a security theater that auditors and attackers see through. Design the whole stack on day one even if you ship MVP with only the first layer.
TIP: For high-value mutations (transfers, key rotation, bulk delete), always require a fresh
evaluatePolicyon a biometric-gated key — don’t trust that “the user unlocked the app 20 minutes ago” still applies.
WARNING: Don’t ship biometric auth without a passcode fallback path documented in your support flow. Users get new phones, get cataract surgery, wear COVID masks indefinitely. The fallback is a feature, not a bug.
Interview corner
Junior: “How do you add Face ID to an app?”
LAContext.evaluatePolicy(.deviceOwnerAuthenticationWithBiometrics). But that’s just a UI confirmation — for real security, gate a Keychain item or Secure Enclave key on biometrics via SecAccessControl.
Mid: “What’s the difference between LAContext and a biometric-gated Keychain item?”
LAContext.evaluatePolicy is a UI prompt that returns a Bool; an attacker hooking the function can bypass it. A biometric-gated Keychain item only releases its data after a successful biometric, with the gate enforced by the OS, not your app’s code. Always pair the UX with a cryptographic gate.
Senior: “Design end-to-end auth for a new fintech iOS app.”
Server-side: OAuth 2.0 with PKCE via ASWebAuthenticationSession, with Passkeys as the preferred path for new signups. Tokens: refresh token in Keychain with WhenPasscodeSetThisDeviceOnly + .biometryCurrentSet, access tokens short-lived in memory. App-unlock: LAContext biometric prompt that, on success, unlocks a Secure Enclave key used to decrypt the local SQLCipher database. High-value mutations — wire transfers, payment-method changes — re-prompt for biometrics gating a Secure Enclave signing operation that produces a signed transaction the server verifies before executing. Fallback path: if biometrics fail or unavailable, force a fresh OAuth login with the IDP; never relax the cryptographic gate. Monitoring: log biometric failures (anonymized) so we can spot patterns of stolen-device attempts.
Red-flag answer: “We use Face ID for login.” Reveals conflation of UI confirmation with cryptographic auth, and missing server-side story.
Lab preview
Lab 9.1 (Secure Notes App) requires you to wire LAContext to unlock a Keychain-stored database key, with proper fallback when biometrics aren’t available.
Next: 9.5 — Jailbreak & Tampering Detection
9.5 — Jailbreak & Tampering Detection
Opening scenario
A pentest report flags: “app continues normal operation when run on jailbroken device under Frida instrumentation; user accounts can be enumerated via dumped strings.” Leadership wants jailbreak detection added. Easy enough — but the deeper question is what your app does when detection fires and how often you check. Get it wrong and you’ll lock out legitimate users with rooted developer phones; get it right and you graceful-degrade the security-critical surfaces while keeping the rest usable.
Context — what jailbreaking actually does
Jailbreaking bypasses iOS code-signing enforcement, letting users install arbitrary binaries and inject code into other apps. The relevant capabilities for an attacker:
- Filesystem access outside the sandbox (read your app’s container, read other apps’ data)
- Code injection via Frida, Cycript, objection, Substitute — hook any Swift/ObjC method
- TLS interception with user-installed root certs (also possible without jailbreak)
- Mach-O modification — repackage your IPA with security checks removed
Apple’s Secure Enclave and Keychain hardware bindings still work on jailbroken devices. What breaks is the assumption that your code runs exactly as you wrote it.
Detection heuristics (none are bulletproof)
import Foundation
import UIKit
enum JailbreakDetector {
static func isLikelyJailbroken() -> Bool {
#if targetEnvironment(simulator)
return false
#else
return checkSuspiciousFiles()
|| checkSuspiciousURLSchemes()
|| checkWriteOutsideSandbox()
|| checkForkSucceeds()
#endif
}
private static let suspiciousPaths = [
"/Applications/Cydia.app", "/Applications/Sileo.app", "/Applications/Zebra.app",
"/Library/MobileSubstrate/MobileSubstrate.dylib",
"/usr/sbin/sshd", "/etc/apt", "/private/var/lib/apt",
"/private/var/tmp/cydia.log",
]
private static func checkSuspiciousFiles() -> Bool {
suspiciousPaths.contains { FileManager.default.fileExists(atPath: $0) }
}
private static func checkSuspiciousURLSchemes() -> Bool {
["cydia://", "sileo://", "zbra://"].contains {
UIApplication.shared.canOpenURL(URL(string: $0)!)
}
}
private static func checkWriteOutsideSandbox() -> Bool {
let path = "/private/jailbreak-canary-\(UUID().uuidString).txt"
do {
try "x".write(toFile: path, atomically: true, encoding: .utf8)
try? FileManager.default.removeItem(atPath: path)
return true
} catch {
return false
}
}
private static func checkForkSucceeds() -> Bool {
// On non-jailbroken iOS, fork() is blocked. On jailbroken systems it usually succeeds.
let forkPtr = dlsym(dlopen(nil, RTLD_NOW), "fork")
guard let fork = unsafeBitCast(forkPtr, to: (@convention(c) () -> Int32).self) as (@convention(c) () -> Int32)? else { return false }
let pid = fork()
if pid >= 0 { return true }
return false
}
}
Each check is independently bypassable. Use multiple, combine into a score, and never rely on any single check.
The bypass arms race
- Liberty Lite, Shadow — system extensions that lie to apps about jailbreak state, hiding the obvious paths
- objection patchapp — strips known jailbreak detection from your binary
- Frida hooks — replace
JailbreakDetector.isLikelyJailbrokento return false at runtime
You can never win this race by adding more checks. You can raise the cost of bypass enough that casual attackers move on.
IOSSecuritySuite
The community-standard library bundles 20+ detection heuristics, code-integrity checks, debugger detection, and Frida-detection:
import IOSSecuritySuite
let result = IOSSecuritySuite.amIJailbrokenWithFailMessage()
if result.jailbroken {
log.warning("jailbreak suspected: \(result.failMessage)")
}
if IOSSecuritySuite.amIDebugged() { /* … */ }
if IOSSecuritySuite.amIRunInEmulator() { /* … */ }
if IOSSecuritySuite.amIReverseEngineered() { /* … */ }
Use it as a baseline, augment with app-specific checks, accept it’ll be bypassed by motivated attackers.
RASP — Runtime Application Self-Protection
Commercial products (Promon, Appdome, Guardsquare DexGuard for Android-equivalent) wrap your binary with anti-tamper, anti-debug, code obfuscation, and SSL pinning bypass detection at the LLVM IR level. Cost: $5k–$50k/yr per app. Use for:
- Financial apps with significant TVL exposure
- IP-sensitive apps (premium video DRM, gaming with anti-cheat)
- Apps mandated by compliance (PCI-DSS Level 1, certain banking regulations)
For most apps, IOSSecuritySuite + good server-side validation is sufficient and free.
Graceful degradation — what to do on detection
The wrong response to detection: hard refuse and crash. This locks out:
- Security researchers
- Developers running dev builds on test devices
- Users with rooted phones who aren’t attacking you
The right response: degrade specific surfaces based on risk:
| Risk level | Response on jailbreak detection |
|---|---|
| Low (read-only browsing) | Allow, log telemetry |
| Medium (write actions) | Allow with warning banner |
| High (financial transactions) | Require step-up auth + lower limits |
| Critical (admin actions) | Refuse with clear message |
Communicate to the user: “We detected this device may be at elevated risk. Some features require additional verification.” Don’t lie, don’t pretend, don’t crash.
Server-side is the real defense
Every jailbreak detection check is bypassable in the client. The only durable protection is server-side:
- App Attest (
DCAppAttestService) — Apple-signed attestation that a request came from an unmodified copy of your app on a real device. Use for high-value endpoints. - Server-enforced limits — rate limits, anomaly detection, transaction caps. The server doesn’t trust the client.
- Cryptographic ratchet — require client signatures from Secure Enclave keys for sensitive ops. Even on a jailbroken device, the SE key is still hardware-bound.
import DeviceCheck
let service = DCAppAttestService.shared
guard service.isSupported else { return }
let keyId = try await service.generateKey()
let challenge = try await fetchChallengeFromServer()
let attestation = try await service.attestKey(keyId, clientDataHash: SHA256.hash(data: challenge).data)
// send attestation + keyId to server; server verifies with Apple's anchor
Subsequent requests use service.generateAssertion(keyId, clientDataHash:) to prove they came from the attested app. Server validates and refuses requests without valid assertion. This is the production-grade answer to “is this really my app?”
In the wild
WhatsApp uses App Attest + assertion on every message-send request. Banking apps (Chase, Revolut) layer IOSSecuritySuite + App Attest + risk-scoring server-side. Snapchat famously broke its client-side jailbreak detection a half-dozen times in the early 2010s before pivoting to server-side rate-limiting + anomaly detection as the durable answer.
Common misconceptions
- “If we detect jailbreak we crash.” Worst possible response — false positives lock out legit users, attackers bypass anyway. Always degrade gracefully.
- “Adding more checks makes us safer.” Past a point, adding checks just bloats the binary without raising bypass cost meaningfully. Three good checks + server validation > thirty client checks.
- “Detection libraries are bulletproof.” None are. Treat all detection as informational, never load-bearing.
- “Jailbreak detection prevents the attack.” It detects one signal of one threat model. An unjailbroken device can still be MITM’d, repackaged, or compromised by a malicious profile.
- “App Attest is overkill.” App Attest is free, low-effort, and the only durable client-attestation primitive. Use it on any non-trivial backend.
Seasoned engineer’s take
Client-side defenses are speed bumps; server-side is the wall. Spend your security budget on the server (App Attest validation, anomaly detection, transaction monitoring, signed-payload requirements) and use client checks as input signals to that server-side risk engine. Apps that pour effort into ever-more-elaborate client checks while shipping a permissive backend are doing security theater.
TIP: Send jailbreak-detection results to the server as a signal, not a gate. The server can combine the signal with IP reputation, transaction velocity, and account history to make smarter decisions than the client ever could in isolation.
WARNING: Don’t display “your device is jailbroken!” warnings in the UI. They’re easy to bypass with hooks and they alienate legitimate dev/test users. Move the response to server-side risk scoring that’s invisible to the attacker.
Interview corner
Junior: “How do you detect jailbreak?” Check for Cydia/Sileo files, jailbreak URL schemes, write-outside-sandbox tests, fork() availability. Use the community library IOSSecuritySuite as a baseline.
Mid: “What do you do when detection fires?” Never crash. Degrade gracefully based on risk: low-risk features stay enabled, high-risk features require step-up auth or lower limits. Report the signal to the server so server-side risk scoring can combine it with other signals.
Senior: “Design the threat model and response for a fintech app on jailbroken devices.” The threat model has three actors: (1) curious users with rooted personal devices, no malicious intent; (2) targeted attackers exploiting jailbreak to attack one specific user’s account; (3) automated attackers using jailbroken farms to abuse the platform. Response per actor: (1) allow normal operation, log the signal; (2) server-side anomaly detection plus App Attest assertions on every value-bearing request, with assertions failing on repackaged binaries; (3) server-side rate limits, App Attest required for new account flows, IP+device-fingerprint correlation. Client-side I’d use IOSSecuritySuite for one signal and App Attest as the cryptographic anchor — App Attest is the durable one because the assertion key lives in Secure Enclave and can’t be hooked. The server is the real wall: any client-only defense gets bypassed within a quarter by a motivated attacker.
Red-flag answer: “Detect and exit.” Reveals limited understanding of false positives and bypass economics.
Lab preview
Lab 9.3 hands you an app that crashes on jailbreak detection. Your task is to replace it with graceful degradation + server-side reporting + App Attest on the highest-value endpoint.
Next: 9.6 — Code Obfuscation & Reverse Engineering
9.6 — Code Obfuscation & Reverse Engineering
Opening scenario
Curious about your competitor’s app? Drop their IPA into Hopper, run class-dump, grep for “ApiKey” and “secret”. On most iOS apps you’ll find readable Swift class names, ObjC selectors, and embedded plaintext strings within 30 seconds. The same applies to your app. This chapter is about what to obfuscate, what not to, and why server-side validation is the real protection.
Context — the Mach-O binary
iOS binaries are Mach-O format with multiple sections of interest to a reverse engineer:
| Section | Contents |
|---|---|
__TEXT.__text | Compiled machine code (encrypted at rest, decrypted on launch) |
__TEXT.__cstring | C/Swift string literals |
__TEXT.__objc_methname | ObjC selector names |
__DATA.__objc_classlist | ObjC class metadata (class-dump uses this) |
| Symbol table | Swift mangled symbol names |
Apple’s FairPlay DRM encrypts __TEXT.__text for App Store binaries — but the encryption is bypassed by any jailbroken device running dumpdecrypted to obtain the unencrypted binary for analysis. Treat your shipped binary as readable to any motivated attacker.
What reverse engineers actually use
| Tool | What it does |
|---|---|
otool | Mach-O inspector (sections, load commands) |
class-dump | Recover ObjC class headers from binary |
Hopper / Ginzu | Disassembler with pseudo-C output |
IDA Pro | Industrial-grade disassembler + decompiler |
Frida | Dynamic instrumentation: hook ObjC selectors and Swift functions at runtime |
objection | Frida toolkit pre-loaded with iOS patches (bypass pinning, dump Keychain) |
dumpdecrypted | Strip FairPlay encryption to get analyzable binary |
The cheap analysis: 30 seconds of strings against the binary. The expensive analysis: hours of Hopper + Frida. The protection budget should scale to the value being protected.
Strip Swift symbols in release
By default, Swift binaries ship with mangled-but-recoverable symbol names: _$s7MyApp10AuthManagerC5login…. Demangler tools turn these into MyApp.AuthManager.login(…) instantly.
In your release build settings, set:
STRIP_SWIFT_SYMBOLS = YES
STRIP_STYLE = all
DEPLOYMENT_POSTPROCESSING = YES
DEAD_CODE_STRIPPING = YES
This is built into Xcode but often disabled when teams enable debug symbols for production crash reporting. Solution: strip symbols from the shipped binary, but upload a dSYM to your crash reporter (Sentry, Firebase Crashlytics, Bugsnag) to symbolicate post-hoc.
String obfuscation
Plaintext strings are the first thing an attacker greps for. Two patterns:
Compile-time XOR:
enum ObfuscatedString {
// "https://api.acme.com" XORed with key 0x5A
private static let bytes: [UInt8] = [0x32, 0x32, 0x29, 0x29, 0x39, 0x29, 0x05, 0x05, 0x3b, 0x37, 0x29, 0x29, 0x05, 0x39, 0x37, 0x35, 0x39, 0x05, 0x37, 0x3d]
static var apiBase: String {
String(bytes: bytes.map { $0 ^ 0x5A }, encoding: .utf8)!
}
}
Build-script generated:
# build-phase script: read secrets.json, emit Swift file with XOR-obfuscated literals
swift run obfuscate-strings secrets.json Sources/Generated/Strings.swift
Obfuscation is not encryption — a determined attacker recovers the strings in minutes with Frida or static analysis. The point is raising the bar above strings-grep.
What never to put in the binary
- Server API keys with broad scope (anything from Twilio, Stripe live keys, AWS root credentials)
- Webhook signing secrets
- Encryption keys for at-rest data — derive them from device-specific material instead
- Database credentials
- User PII or test accounts
All of these belong in your server. The client requests scoped, short-lived credentials from an authenticated endpoint. If a secret must live on-device — e.g., a third-party SDK that requires a fixed API key client-side — minimize blast radius via key restrictions (referrer/IP/bundle-ID allowlists configured at the provider).
Anti-Frida and anti-debug
import Darwin
func isDebuggerAttached() -> Bool {
var info = kinfo_proc()
var size = MemoryLayout<kinfo_proc>.stride
var mib: [Int32] = [CTL_KERN, KERN_PROC, KERN_PROC_PID, getpid()]
sysctl(&mib, UInt32(mib.count), &info, &size, nil, 0)
return (info.kp_proc.p_flag & P_TRACED) != 0
}
Frida injects a dylib into your process. Detect it:
import MachO
func isFridaInjected() -> Bool {
let count = _dyld_image_count()
for i in 0..<count {
if let name = _dyld_get_image_name(i) {
let s = String(cString: name)
if s.contains("frida") || s.contains("Substrate") || s.contains("substitute") {
return true
}
}
}
return false
}
Both checks are bypassable but raise cost. Combine with server-side App Attest for actual protection (see 9.5).
Bitcode and IR
Bitcode submission is deprecated as of Xcode 14 — Apple no longer accepts new bitcode-enabled apps. This removes one historical reverse-engineering vector (Apple-side recompilation). Set ENABLE_BITCODE = NO in current projects; ignore old advice that recommends enabling it.
Why server-side is the real protection
Every client-side obfuscation can be reversed. The only durable defenses are server-side:
- Authenticate every request with a short-lived token tied to a Secure-Enclave-signed challenge
- Rate-limit per-account and per-IP
- Refuse requests without a valid App Attest assertion
- Monitor for behavioral anomalies (sudden geo shift, unusual API mix)
- Use scoped, short-lived credentials issued from your backend after auth — never embed long-lived secrets in the client
The pattern: the binary contains code, not authority. Authority lives on the server, gated by authentication and attestation.
In the wild
Telegram’s iOS client uses heavy code obfuscation (Obfuscator-LLVM) for the cryptographic core because its threat model includes adversarial state-level actors. Netflix uses commercial RASP (Guardsquare iXGuard) to protect its DRM client. Most consumer apps — Twitter, Reddit, Spotify — ship with default symbol stripping plus minimal string obfuscation, and put their security budget into server-side defenses.
Common misconceptions
- “Obfuscation is security.” Obfuscation is a speed bump, not a wall. Plan for full reverse engineering.
- “If we strip symbols, no one can decompile us.” Hopper and IDA reconstruct function signatures from the ABI conventions; strip just removes the friendly names.
- “We need a commercial obfuscator.” Only if your threat model includes targeted reverse engineering and the cost of compromise justifies $10k+/year.
- “Encrypted strings = secure strings.” If the decryption key lives in the same binary, an attacker recovers it. The same logic applies to encrypted assets, encrypted code, encrypted anything-shipped-with-the-app.
- “FairPlay DRM protects our binary.” FairPlay is bypassed in minutes on a jailbroken device. Treat your shipped binary as fully readable.
Seasoned engineer’s take
Spend 20 % of your security budget on the client (symbol stripping, basic string obfuscation, jailbreak signal collection) and 80 % on the server (App Attest, rate limits, anomaly detection, short-lived credentials, monitoring). Teams that invert this ratio end up with elaborately obfuscated clients shipping the same exploitable backends.
TIP: Add a CI check that runs
strings $BINARY | grep -iE "(api_key|password|secret|token)"against the release binary and fails the build on any hit. Cheap, catches the most-common leak.
WARNING: Don’t obfuscate so heavily that your own crash logs become unreadable. Always keep a clean
dSYMupload pipeline and verify symbolication works end-to-end before shipping.
Interview corner
Junior: “How do you protect against reverse engineering?” Strip Swift symbols, basic string obfuscation for sensitive constants, never embed real secrets. Server-side validation is the real defense.
Mid: “What’s the difference between obfuscation and encryption for in-binary secrets?”
Encryption requires a key; if the key is in the same binary, an attacker recovers both. Obfuscation just makes static strings-grep less productive. Neither is real protection — both are speed bumps. Real secrets belong on the server, fetched at runtime via authenticated requests.
Senior: “Walk me through the reverse-engineering threat model for a fintech app and what you’d do about it.”
The attacker downloads the IPA, decrypts FairPlay with dumpdecrypted, runs class-dump and Hopper to map the auth and signing flow. They use Frida to hook Keychain.get and URLSession.dataTask to extract tokens and observe API contracts. They then build a headless tool to abuse the API. Defense: client-side, strip Swift symbols and obfuscate any hardcoded URLs/keys for friction; never embed long-lived secrets — issue short-lived scoped credentials post-auth. The durable defense is server-side: every value-bearing request requires a Secure-Enclave-signed assertion plus a valid App Attest token, and the server validates Apple’s anchor on the attestation. Anomaly detection catches the headless tool by behavior (response timing, request mix, geo). I’d skip a $20k commercial obfuscator unless we had a specific high-stakes threat — at most fintechs, that budget delivers more security spent on backend monitoring.
Red-flag answer: “We obfuscate everything.” Reveals theater-over-substance thinking; no engineer with real security experience would say this.
Lab preview
No dedicated lab for this chapter — Lab 9.3 (Security Audit) includes finding embedded secrets in a sample binary using strings and a basic Hopper pass.
Next: 9.7 — Secure Coding Practices in Swift
9.7 — Secure Coding Practices in Swift
Opening scenario
Code review finds: let predicate = NSPredicate(format: "name == '\(userInput)'"). Looks harmless. But the user types '; truepredicate; -- and your search returns every record in the database. NSPredicate injection. Swift is a safer language than C, but unsafe patterns still creep in — Any, format strings, integer overflow, sensitive memory not zeroed. This chapter is the iOS-specific secure-coding checklist.
Context — what Swift gives you for free
Swift the language closes several entire classes of bugs by default:
| Bug class | Swift’s mitigation |
|---|---|
| Buffer overflow | Array, String are bounds-checked |
| Use-after-free | ARC + value types |
| Null deref | Optionals + unwrap discipline |
| Type confusion | Strong static typing, no implicit casts |
| Data race (under Swift 6 strict concurrency) | Sendable + actor isolation |
But escape hatches exist: unsafeBitCast, withUnsafePointer, Any, format strings, C interop. Each is a place where the safety net dissolves.
Input validation
Validate at trust boundaries — anywhere data enters from outside your code:
struct Username: RawRepresentable, Codable {
let rawValue: String
init?(rawValue: String) {
let allowed = CharacterSet.alphanumerics.union(.init(charactersIn: "._-"))
guard rawValue.count >= 3, rawValue.count <= 30,
rawValue.unicodeScalars.allSatisfy(allowed.contains)
else { return nil }
self.rawValue = rawValue
}
}
Push validation into types. Once a Username exists, callers know it’s safe — they can’t accidentally pass an unvalidated string. This is “make illegal states unrepresentable” applied to security boundaries.
Codable with strict decoding
JSON deserialization is a security boundary. Use strict types — never [String: Any]:
struct User: Codable {
let id: UUID
let email: String
let role: Role
enum Role: String, Codable { case admin, member, guest }
}
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .iso8601
decoder.keyDecodingStrategy = .convertFromSnakeCase
let user = try decoder.decode(User.self, from: data)
Role as an enum means a server-side value of "superadmin" causes a decode failure, not a silent privilege escalation. Avoid decodeIfPresent for security-critical fields — explicit failure is safer than silent defaulting.
NSPredicate injection
NSPredicate(format:) accepts format strings. Direct interpolation is injectable:
// ❌ Injection
let p = NSPredicate(format: "name == '\(userInput)'")
// ✅ Parameterized
let p = NSPredicate(format: "name == %@", userInput)
%@ substitutes the value as a literal, not parsed format. Same rule for NSExpression. Treat format: like printf — never concatenate untrusted input into the format.
The same applies to:
NSExpression(format:)NSRegularExpression(pattern:)with user-supplied patterns (ReDoS risk)- Core Data
NSFetchRequest.predicateconstructed from strings
Integer overflow
Default Swift arithmetic traps on overflow (crashes the app). This is safer than C’s silent wrap, but a crash is still a DoS. For arithmetic on untrusted inputs:
// Use overflow-aware operators when overflow is acceptable
let result = a &+ b // wraps; never traps
// Or explicit overflow check
let (result, overflow) = a.addingReportingOverflow(b)
guard !overflow else { throw ArithmeticError.overflow }
// Or use Int with bounds known to be safe
&+, &-, &* wrap silently — only use when wrap is the desired semantics (hashing, checksums). For business logic, prefer addingReportingOverflow and handle the error path.
URLs, web views, and deeplinks
// ❌ Trusts userInput to be a URL
UIApplication.shared.open(URL(string: userInput)!, options: [:])
// ✅ Validate scheme + host allowlist
guard let url = URL(string: userInput),
let scheme = url.scheme?.lowercased(),
["https"].contains(scheme),
let host = url.host,
["acme.com", "www.acme.com"].contains(host)
else { return }
UIApplication.shared.open(url)
For deeplinks into your own app, validate the path components before acting:
func handle(deeplink: URL) {
guard deeplink.scheme == "acme",
let components = URLComponents(url: deeplink, resolvingAgainstBaseURL: false),
let path = components.path.split(separator: "/").first
else { return }
switch path {
case "open": openItem(id: components.queryItems?.first { $0.name == "id" }?.value)
case "share": showShareSheet()
default: break // never trust unknown paths
}
}
WKWebView deserves its own audit:
javaScriptEnabled = falseif you don’t need itWKContentRuleListStoreto block third-party domainsallowsContentJavaScriptper-frame on iOS 14+- Never load arbitrary user-supplied HTML; always source from your own trusted backend
Logging — os_log privacy
Default os_log redacts dynamic values in release builds, replacing with <private>. Don’t override unless you mean it:
import os.log
private let log = Logger(subsystem: "com.acme.app", category: "auth")
// ✅ Default behavior — emails redacted in release logs
log.info("user signed in: \(email)")
// ❌ Forces public, leaks email to logs
log.info("user signed in: \(email, privacy: .public)")
// ✅ Explicit public for non-sensitive data
log.info("user count: \(count, privacy: .public)")
The same applies to crash reporters: filter PII before sending. Sentry, Crashlytics, and others have hooks for scrubbing breadcrumbs.
Zeroing sensitive memory
Swift Strings and Data are heap-allocated; deallocating them leaves bytes in memory pages until overwritten. For high-sensitivity data (master passwords, raw key material):
import Foundation
extension Data {
mutating func zeroize() {
withUnsafeMutableBytes { ptr in
guard let base = ptr.baseAddress, ptr.count > 0 else { return }
memset_s(base, ptr.count, 0, ptr.count)
}
}
}
func deriveKey(from password: String) throws -> SymmetricKey {
var passwordData = Data(password.utf8)
defer { passwordData.zeroize() }
return try deriveKeyFromData(passwordData)
}
memset_s is preferred over memset because the compiler isn’t allowed to optimize it away. Note: this only helps against memory-dump attackers; it doesn’t help against jailbreak / runtime hooking.
Other Swift-specific footguns
Anyas parameter type — defeats type checking. Use generics or protocol composition.unsafeBitCast— bypasses type safety entirely. Audit every callsite; usually replaceable with proper conversion.- C interop (
UnsafePointer,withUnsafeBytes) — restore C-level UB risk. Wrap once at the boundary, never spread. - Force-unwrap in user-facing flows — converts a logic bug into a crash.
guard letand graceful handling. openaccess for non-final classes shipped in libraries — allows subclassing and method override by callers; can be used to break security invariants. Usepublic finalby default.@objc dynamic— adds ObjC-runtime method dispatch, which is hookable by Frida. Audit usage in security-critical code paths.
SwiftLint security rules
Enable these in .swiftlint.yml:
opt_in_rules:
- force_unwrapping
- force_cast
- explicit_init
- implicitly_unwrapped_optional
- private_outlet
- prohibited_super_call
custom_rules:
no_userdefaults_secrets:
name: "No secrets in UserDefaults"
regex: 'UserDefaults\.standard\.set\(.*(?:token|password|secret|key)'
severity: error
no_nspredicate_format_interpolation:
name: "Don't interpolate into NSPredicate format"
regex: 'NSPredicate\(format:\s*"[^"]*\\\('
severity: error
These catch the most common regressions during PR review, before the security team’s quarterly scan.
In the wild
The Signal iOS codebase enforces zeroization on every key-handling code path and ships with extensive SwiftLint rules. Apple’s own first-party apps use strict Codable types throughout — leaked source from past macOS releases shows almost no [String: Any] deserialization in security-critical surfaces.
Common misconceptions
- “Swift is memory-safe, so secure-by-default.” Memory safety closes one class of bugs. Injection, validation, privacy, key handling are all still your responsibility.
- “
force_unwrapis just a style rule.” It’s a security rule — force-unwraps convert exploitable conditions into crashes that DoS the app. - “
os_logredacts everything in production.” Only dynamic values, only on certain devices, and only if you don’t override with.public. Audit every log call in security-critical files. - “Zeroing memory in Swift is impossible because of immutable strings.” Use
Datafor sensitive material from the start; never convert toString.Data.zeroize()works as shown. - “
@objc dynamicis just for Objective-C compatibility.” It’s also the vector for Frida hooks — security-critical methods should not be@objc dynamic.
Seasoned engineer’s take
Most security bugs in modern Swift apps are not exotic memory-corruption — they’re boring validation gaps, accidental Any, force-unwraps on untrusted data, format-string injection. Build a habit of treating every trust boundary (network, file, deeplink, user input, third-party SDK callback) as adversarial: validate at the boundary, type the validated value, then trust internally. The pattern is “validate once, parse into a strong type, propagate the type.” It scales — once you can spot trust-boundary code by smell, you’ll catch security bugs in PR review automatically.
TIP: Treat
[String: Any]as a code smell in any decoder. Every deserialization should land in a typed struct withCodableand enum-typed fields for closed-set values.
WARNING: Don’t disable SwiftLint security rules to silence noise — fix the underlying violations. Disabled rules grow until they’re worthless; enforced rules catch real bugs.
Interview corner
Junior: “How do you prevent NSPredicate injection?”
Use %@ placeholders instead of interpolating user input into the format string. Same pattern as parameterized SQL queries.
Mid: “Walk me through input validation in Swift.”
Validate at the trust boundary, then push the validated value into a strong type. Use RawRepresentable + failable init for the validation logic, so callers downstream know they’re holding a verified value without re-validating. For JSON, use strict Codable types with enum fields for closed sets, and prefer hard failures over decodeIfPresent defaults.
Senior: “What’s your secure-coding checklist for Swift code review?”
At trust boundaries: validate and type. In data layer: no [String: Any], all enums for closed sets, hard-fail decode for security-critical fields. NSPredicate, NSExpression, NSRegularExpression: parameterized only. Arithmetic on untrusted data: overflow-aware operators. URLs and deeplinks: scheme + host allowlists, no naive open(URL(string:)!). Logging: default os_log redaction, audit every .public annotation. Sensitive memory: Data only, memset_s on dealloc. Force-unwraps: banned in any path reachable from untrusted input. @objc dynamic: not in security-critical methods. WKWebView: javaScriptEnabled = false unless required, content rule lists, no user-supplied HTML. SwiftLint rules enforce the boring half automatically so PR review focuses on architecture. Each item maps to a real CVE class.
Red-flag answer: “Swift handles all that automatically.” Reveals overconfidence in language features.
Lab preview
Lab 9.3 includes a Swift file with 12 deliberate secure-coding violations — NSPredicate injection, missing input validation, leaked logs, sensitive memory not zeroed. You’ll identify and fix each one.
Next: 9.8 — App Transport Security
9.8 — App Transport Security
Opening scenario
Your app makes a request to http://legacy-analytics.acme-internal.com and it silently fails on iOS 17 with no error in the console — just an empty response. Or it works in development on the simulator but fails on devices. App Transport Security (ATS) is doing its job: blocking plaintext traffic. The cure isn’t disabling ATS globally; it’s understanding the exception system and using it surgically.
Context — what ATS enforces
Since iOS 9, ATS has been on by default for any networking through NSURLSession, NSURLConnection, or CFNetwork. The defaults require:
| Requirement | Value |
|---|---|
| Scheme | https:// only |
| TLS version | ≥ 1.2 |
| Cipher suite | Forward-secret (ECDHE, DHE) |
| Certificate | RSA ≥ 2048-bit or ECC ≥ 256-bit |
| Hash | SHA-256 or stronger |
Plain HTTP is blocked. TLS 1.0/1.1 is blocked. Self-signed certs are blocked. Most legacy on-prem APIs you might integrate with violate at least one rule.
The Info.plist key
ATS is configured via the NSAppTransportSecurity dictionary:
<key>NSAppTransportSecurity</key>
<dict>
<key>NSExceptionDomains</key>
<dict>
<key>legacy-analytics.acme-internal.com</key>
<dict>
<key>NSExceptionAllowsInsecureHTTPLoads</key>
<true/>
<key>NSIncludesSubdomains</key>
<false/>
</dict>
</dict>
</dict>
Domain-scoped exceptions are surgical and explicit. The build tooling can audit them; reviewers can see them in diffs.
The escape hatches (and when they’re acceptable)
| Key | Effect | Acceptable use |
|---|---|---|
NSAllowsArbitraryLoads | Disables ATS globally | Almost never. Red flag in audits. |
NSAllowsArbitraryLoadsInWebContent | Disables ATS only for WKWebView | Apps that legitimately render arbitrary user-supplied web content (browsers, RSS readers) |
NSAllowsArbitraryLoadsForMedia | Disables ATS for AVFoundation media loads | Apps streaming legacy HTTP video sources |
NSAllowsLocalNetworking | Allows plaintext to .local, IP literals, unqualified hostnames | Apps controlling IoT devices, smart speakers, printers |
The pattern: domain exceptions are fine when justified and documented. Global NSAllowsArbitraryLoads requires App Store Review justification and often gets the app rejected without a written reason in the review notes.
Per-domain exception keys
<key>api.legacy-vendor.com</key>
<dict>
<key>NSExceptionMinimumTLSVersion</key>
<string>TLSv1.1</string>
<key>NSExceptionRequiresForwardSecrecy</key>
<false/>
<key>NSExceptionAllowsInsecureHTTPLoads</key>
<true/>
<key>NSIncludesSubdomains</key>
<true/>
<key>NSRequiresCertificateTransparency</key>
<false/>
</dict>
Each key relaxes a specific requirement. Be minimal — if the vendor supports TLS 1.2 with FS, only set those keys, don’t blanket allow HTTP.
App Store Review and ATS
Apple’s review notes when an app contains NSAllowsArbitraryLoads:
“Your app contains the NSAllowsArbitraryLoads key in your Info.plist. Please remove this key or provide reasonable justification for its use.”
Acceptable justifications:
- The app is a general-purpose web browser
- The app integrates with a specific legacy enterprise system documented in a screenshot
- The app processes user-supplied URLs (e.g., RSS reader, link previewer)
Unacceptable:
- “We need to call a third-party tracking API that doesn’t support HTTPS”
- “Development convenience”
- “We didn’t have time to fix our backend”
Apps with unjustified ATS exceptions get rejected, and the rejection cycle adds days to releases.
Local development workaround
In dev, a common need is hitting http://localhost:8080 for a local server. Use a configuration that’s clearly dev-only:
<!-- Info.Debug.plist -->
<key>NSAppTransportSecurity</key>
<dict>
<key>NSExceptionDomains</key>
<dict>
<key>localhost</key>
<dict>
<key>NSExceptionAllowsInsecureHTTPLoads</key>
<true/>
</dict>
</dict>
</dict>
Wire via build configs so the dev Info.plist is only used for Debug builds. Release Info.plist has no exceptions. This is auditable in CI: assert that the release-config-compiled Info.plist contains no ATS exceptions other than the documented production set.
ATS and pinning
ATS validates that the server’s cert chain meets the cipher and version requirements. It does not pin — chain validation succeeds for any system-trusted CA. Pinning (Chapter 9.3) is layered on top via URLSessionDelegate. The two work together:
- ATS: ensures the connection properties are strong
- Pinning: ensures the server identity is what you expect
Disabling ATS for a domain doesn’t disable your pinning — they’re independent. Conversely, having ATS doesn’t mean you’re pinned. Belt and suspenders.
What’s new since iOS 14+
NSRequiresCertificateTransparencywas added — by defaultfalse, but settruefor sensitive endpoints to require CT log proofs- TLS 1.3 is the negotiated default when both endpoints support it; ATS minimum remains 1.2
NSAllowsArbitraryLoadsInWebContentwas scoped more strictly — now requires a specific App Store Review attestation
Audit script for CI
# fail the build if Info.plist contains forbidden ATS keys (release config only)
INFO_PLIST="$BUILT_PRODUCTS_DIR/$INFOPLIST_PATH"
FORBIDDEN=$(/usr/libexec/PlistBuddy -c "Print :NSAppTransportSecurity:NSAllowsArbitraryLoads" "$INFO_PLIST" 2>/dev/null)
if [ "$FORBIDDEN" = "true" ]; then
echo "error: NSAllowsArbitraryLoads is set in release build"
exit 1
fi
Wire into a Run Script build phase active only for the Release configuration.
In the wild
Most banking and healthcare apps have zero ATS exceptions in production — their backends are fully TLS-modern. Consumer apps that integrate with third-party legacy ad networks accumulate exceptions over time; the Twitter/X iOS app’s Info.plist has historically included a few documented domain exceptions for ad partners. Browsers (Brave, DuckDuckGo) explicitly use NSAllowsArbitraryLoadsInWebContent with App Store Review approval.
Common misconceptions
- “ATS = certificate pinning.” Different things. ATS validates connection properties; pinning validates server identity. Use both.
- “
NSAllowsArbitraryLoadsis fine if we ‘know what we’re doing’.” App Store Review disagrees. Even when accepted, it’s an audit red flag forever. - “ATS doesn’t apply to third-party SDKs.” It applies to all CFNetwork-based requests, including from SDKs you ship. Audit dependencies’ network behavior; some old SDKs make HTTP requests that silently fail under ATS.
- “Setting
NSExceptionMinimumTLSVersionlower is just a slight relaxation.” TLS 1.0 has known attacks (BEAST, POODLE). Any downgrade is a real security regression. - “
NSAllowsLocalNetworkingis for development.” No — it’s for production apps controlling devices on the local network. For dev, use a Debug-config Info.plist with alocalhostexception.
Seasoned engineer’s take
ATS is the most successful security default in modern iOS. It single-handedly forced the entire mobile ecosystem to TLS 1.2+. Treat exceptions as commitments — every entry requires a comment in the Info.plist (yes, XML comments work) explaining why this domain, this key, until when. Quarterly, walk the exception list and ask “can we remove this yet?” Most can, eventually. Apps without exception hygiene accumulate cruft that auditors point at five years later.
TIP: For brand-new projects, target zero ATS exceptions on day one. Modern backends, even legacy vendors, almost always support TLS 1.2 with FS — you just have to ask the vendor’s support team to enable it.
WARNING: Don’t add
NSAllowsArbitraryLoads“temporarily” for a sprint. Temporary becomes permanent. Add domain-specific exceptions with a comment noting the planned removal date, then track them.
Interview corner
Junior: “What is App Transport Security?”
iOS networking enforcement that requires HTTPS with TLS 1.2+, strong ciphers, and forward secrecy by default. Exceptions are configured via NSAppTransportSecurity in Info.plist.
Mid: “When would you use NSAllowsArbitraryLoads?”
Almost never. The only legitimate uses are general-purpose browsers and apps that process user-supplied URLs. Anything else should use scoped NSExceptionDomains. Global NSAllowsArbitraryLoads triggers App Store Review pushback and is a perpetual audit finding.
Senior: “Walk me through ATS strategy for a fintech app with one legacy partner integration.”
Production Info.plist has exactly one domain exception, scoped to api.partner-legacy.example.com, with NSIncludesSubdomains = false and the minimum specific relaxation (probably NSExceptionRequiresForwardSecrecy = false if their cipher suite is dated). Comment in the plist references the JIRA ticket tracking the partner’s TLS modernization with a target date. CI build script asserts no other ATS keys in the release Info.plist. Dev Info.plist (Debug-only build config) adds localhost and any staging hostnames. Quarterly review walks the exception list and pings partner contacts on aged exceptions. Pinning is layered separately on the auth/transaction endpoints — ATS handles connection properties, pinning handles identity. The whole policy fits in a one-pager that ships with the repo for new-engineer onboarding.
Red-flag answer: “We turned ATS off because it was annoying.” Reveals a culture of bypassing safety defaults; expect bigger gaps elsewhere.
Lab preview
Lab 9.1 includes auditing the starter app’s Info.plist for ATS misconfigurations and adding a CI script that fails the build on NSAllowsArbitraryLoads.
Next: 9.9 — Privacy, Permissions & Data Minimization
9.9 — Privacy, Permissions & Data Minimization
Opening scenario
App Store Review rejects your submission with: “Your app uses one or more APIs that access user data without including the required Privacy Manifest entries.” Or: “Your NSLocationAlwaysAndWhenInUseUsageDescription string does not adequately describe why your app needs the user’s location.” Privacy enforcement on iOS has moved from polite suggestion to hard gate. This chapter is the playbook for shipping a compliant app — and for being a good steward of user data, which is mostly the same thing.
Context — the iOS privacy stack
| Layer | What it does | Required since |
|---|---|---|
| Usage description strings | Human-readable reason at the OS-level prompt | iOS 6+ |
| App Tracking Transparency (ATT) | Explicit consent for cross-app tracking | iOS 14.5 |
| Privacy Nutrition Labels | Disclosed in App Store listing | Dec 2020 |
| App Privacy Report | User-facing diagnostic showing app data access | iOS 15.2 |
| Required Reason API declarations | Justify use of specific APIs (e.g., file timestamps) | Xcode 15 (May 2024) |
PrivacyInfo.xcprivacy | Manifest of tracked domains + reason APIs | Xcode 15 (May 2024) |
Missing or inaccurate entries lead to App Store Review rejections. Apple has tightened enforcement annually; 2024–2025 reviews catch what passed in 2022.
Usage description strings (Info.plist)
Every permission-gated API requires a corresponding Info.plist string:
| Permission | Key |
|---|---|
| Camera | NSCameraUsageDescription |
| Microphone | NSMicrophoneUsageDescription |
| Photo Library (read) | NSPhotoLibraryUsageDescription |
| Photo Library (add only) | NSPhotoLibraryAddUsageDescription |
| Location (always + in-use) | NSLocationAlwaysAndWhenInUseUsageDescription |
| Location (when-in-use) | NSLocationWhenInUseUsageDescription |
| Contacts | NSContactsUsageDescription |
| Calendar | NSCalendarsUsageDescription |
| Reminders | NSRemindersUsageDescription |
| Bluetooth | NSBluetoothAlwaysUsageDescription |
| Local Network | NSLocalNetworkUsageDescription |
| Tracking (ATT) | NSUserTrackingUsageDescription |
| Face ID | NSFaceIDUsageDescription |
| Speech Recognition | NSSpeechRecognitionUsageDescription |
Quality matters. Vague strings like “We need this for app functionality” get rejected. Strong strings explain the concrete user-facing benefit: “Camera access lets you scan business cards and add contacts automatically.” Apple’s review explicitly judges this.
App Tracking Transparency
If your app uses the IDFA (Identifier for Advertisers) or shares identifiers with third parties for tracking across apps, you must prompt:
import AppTrackingTransparency
import AdSupport
func requestTracking() async {
guard ATTrackingManager.trackingAuthorizationStatus == .notDetermined else { return }
let status = await ATTrackingManager.requestTrackingAuthorization()
switch status {
case .authorized: /* use ASIdentifierManager.shared().advertisingIdentifier */ break
default: /* no tracking */ break
}
}
The prompt is non-customizable. The string above the prompt comes from NSUserTrackingUsageDescription. Do not ask in a pre-prompt that tries to coerce; Apple has rejected apps for doing so. Honor the user’s choice.
ATT consent rates hover around 20-30 % — design your business model assuming most users decline.
PrivacyInfo.xcprivacy (Xcode 15+)
The Privacy Manifest is a .xcprivacy file in your app and in every third-party SDK you bundle. As of May 2024, App Store Review requires it for any app or SDK on Apple’s Commonly Used Reason APIs list.
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>NSPrivacyTracking</key>
<false/>
<key>NSPrivacyTrackingDomains</key>
<array/>
<key>NSPrivacyCollectedDataTypes</key>
<array>
<dict>
<key>NSPrivacyCollectedDataType</key>
<string>NSPrivacyCollectedDataTypeEmailAddress</string>
<key>NSPrivacyCollectedDataTypeLinked</key>
<true/>
<key>NSPrivacyCollectedDataTypeTracking</key>
<false/>
<key>NSPrivacyCollectedDataTypePurposes</key>
<array>
<string>NSPrivacyCollectedDataTypePurposeAppFunctionality</string>
</array>
</dict>
</array>
<key>NSPrivacyAccessedAPITypes</key>
<array>
<dict>
<key>NSPrivacyAccessedAPIType</key>
<string>NSPrivacyAccessedAPICategoryUserDefaults</string>
<key>NSPrivacyAccessedAPITypeReasons</key>
<array>
<string>CA92.1</string> <!-- Access info for current user only -->
</array>
</dict>
</array>
</dict>
</plist>
Required Reason API categories (current list, may grow):
- File timestamp APIs (
creationDate,modificationDate) - System boot time
- Disk space
- Active keyboard
UserDefaults
Each requires a numeric reason code documented in Apple’s list. Pick the most specific reason that matches your use; don’t pick a permissive one to “cover yourself.”
For tracking, list every domain involved in cross-app/cross-site tracking under NSPrivacyTrackingDomains. iOS 17+ silently blocks DNS resolution for unlisted tracking domains when ATT consent is denied. Missing entries = silent broken functionality.
Privacy Nutrition Labels
In App Store Connect, declare what data your app collects and how it’s used. Categories:
- Identifiers — user ID, device ID
- Usage Data — interactions, ad clicks
- Diagnostics — crash data, performance data
- Location — coarse, precise
- Contact Info — name, email, phone, address
- User Content — messages, photos
- Financial Info — payment info, credit info
- Health & Fitness
- Sensitive Info
- Browsing History
- Search History
- Purchases
- Contacts
- Other Data
For each: is it linked to user identity? Used for tracking? What’s the purpose (analytics, product personalization, app functionality, third-party advertising)?
The labels appear in the App Store listing. Inaccurate labels are a violation of the Developer Program License Agreement and can trigger app removal. Audit yearly and after every SDK addition.
App Privacy Report (iOS 15.2+)
Users can enable “Record App Activity” in Settings → Privacy → App Privacy Report. The OS logs:
- Permissions accessed (camera, mic, location, contacts, etc.) with timestamp
- Network domains contacted
- Domains contacted by app in the last 7 days
Users see your app’s data access in plain language. If your nutrition label says “we don’t collect location” but the privacy report shows daily location pings, users notice — and complain publicly. The report is a forcing function for honesty.
Data minimization principles
The lasting protection is collecting less data in the first place:
- Don’t ask if you don’t need. Every permission prompt has friction; every unused permission is liability.
- Coarse over precise. Location: ask for “while using” not “always”; precision: reduced (city-level) not full unless you need it.
- Local over server. Process on-device when possible (Core ML, on-device speech, local search) instead of sending data up.
- Ephemeral over persistent. Keep what you need for the session; don’t archive what you don’t need to recall.
- Hash or tokenize identifiers. If you only need to know “same user across requests,” send a hash of the user ID, not the ID itself.
- Delete on demand. Implement actual data deletion (not just account deactivation) within 30 days of request — GDPR and CCPA require it; the App Store requires an in-app delete-account flow.
GDPR & CCPA touchpoints
Compliance is mostly back-end policy, but client-side concerns:
- Consent flow for non-essential data collection in the EU/UK
- Privacy policy linked from Settings and the App Store listing
- Data export — give users a download of their data (often a server endpoint surfaced in-app)
- Account deletion — Apple requires this in-app since iOS 16 / June 2022
- Children’s data (COPPA, GDPR-K) — different rules under 13/16; if applicable, age-gate
A senior engineer doesn’t need to write the privacy policy but must know what features the lawyers’ requirements translate to in code.
In the wild
Apple’s first-party apps (Notes, Maps, Photos) demonstrate the gold standard — minimal data collection, all on-device processing where possible, clear and specific usage strings. DuckDuckGo and Firefox Focus market themselves on the same principles, with extensive NSPrivacyTrackingDomains blocklists and zero linked-identifier data types in their nutrition labels.
Common misconceptions
- “Privacy is just legal stuff.” It’s also a hard App Store Review gate and a public reputation issue. Engineers own the implementation.
- “We don’t need a Privacy Manifest because we’re a simple app.” If you use
UserDefaultsor file timestamps, you need one. That’s almost every app. - “Vague usage strings are safer because they don’t commit to anything.” Apple Review rejects vague strings. Be specific or be rejected.
- “ATT prompt is optional.” Required if you use IDFA or share any tracking identifier. Missing prompt + tracking = removal.
- “Account deletion via email is fine.” Apple requires in-app deletion since iOS 16. Email-only is a rejection.
Seasoned engineer’s take
Privacy is increasingly a technical discipline, not just a policy one. The Privacy Manifest, ATT prompts, App Privacy Report, and Required Reason APIs are all implemented in code. Treat them as architecture concerns from day one of a new project — retrofitting compliance into a shipped app costs weeks and risks app removal. The good news: every privacy-respecting design choice (data minimization, on-device processing, ephemeral storage) also reduces breach blast radius and infrastructure cost. Privacy and engineering quality converge.
TIP: Add
PrivacyInfo.xcprivacyto your starter template before writing any feature code. Each new dependency or API addition triggers a manifest review. Cheap when habitual; expensive as a pre-submission scramble.
WARNING: Don’t lie on nutrition labels to look better. The App Privacy Report makes lies user-visible; the discrepancy is a guaranteed PR story.
Interview corner
Junior: “What’s NSCameraUsageDescription?”
The Info.plist string shown to the user when your app first requests camera access. Required to use the camera; must be specific and user-meaningful or App Store Review rejects.
Mid: “What’s the Privacy Manifest and what does it contain?”
A PrivacyInfo.xcprivacy file required for apps and SDKs since Xcode 15. Declares tracked domains, collected data types and purposes, and required-reason API usage (UserDefaults, file timestamps, etc.). Missing or inaccurate manifests cause App Store Review rejections.
Senior: “Design a privacy strategy for a new health-tracking app.”
Start from data minimization: process everything on-device with Core ML and the Health framework, sync only aggregates upward (and only if the user opts in). All permission strings written by the product team with concrete user-facing reasons. Manifest declares the minimum data types (probably Health & Fitness and Diagnostics) with Linked = false where possible. No ATT prompt because we don’t track. No third-party analytics SDKs in the initial release — own the metrics via first-party logging with strict PII filtering. Privacy policy reviewed by counsel and surfaced in Settings + onboarding. In-app account deletion flow that actually deletes server data within 30 days (GDPR/CCPA). Quarterly review walks the App Privacy Report on internal devices to verify our claims match reality. The architecture saves on backend costs, simplifies HIPAA conversations if we ever go US-clinical, and avoids the entire class of “you said you didn’t collect X but you do” disasters.
Red-flag answer: “Legal will handle that.” Reveals offloading of engineering responsibility.
Lab preview
Lab 9.1 includes adding a complete PrivacyInfo.xcprivacy to the starter notes app, with accurate data type declarations and Required Reason API entries.
Next: 9.10 — Security Auditing & Pentest for iOS
9.10 — Security Auditing & Pentest for iOS
Opening scenario
Before launch, the CTO asks: “Are we secure?” The honest answer requires structured assessment, not a gut feeling. This chapter is the workflow — what to scan with, what tools to install, what a pentester actually does to your IPA, and how to triage and disclose findings. By the end you can do an internal audit confidently before the external pentester arrives, so the report is smaller and the fixes are cheaper.
Context — the audit pyramid
┌──────────────┐
│ External │ $$$$
│ Pentest │
└──────────────┘
┌──────────────────────┐
│ Internal Pentest │ $$$
│ (red-team your app) │
└──────────────────────┘
┌────────────────────────────┐
│ Dynamic analysis (DAST) │ $$
│ Frida, mitmproxy, objection│
└────────────────────────────┘
┌──────────────────────────────────┐
│ Static analysis (SAST) │ $
│ SwiftLint security, semgrep, MobSF│
└──────────────────────────────────┘
┌────────────────────────────────────────────┐
│ Build-time checks + code review │ ¢
│ Every PR; baseline │
└────────────────────────────────────────────┘
The bottom layers are cheap and continuous; the top layers are expensive and periodic. Shift findings down as much as possible — the fewer surprises in the external pentest, the better.
Static analysis tools
SwiftLint security rules
Already covered in 9.7 — enable force_unwrapping, force_cast, and custom rules for UserDefaults-secrets and NSPredicate format interpolation. Run in CI on every PR.
semgrep
brew install semgrep
semgrep --config "p/swift" --config "p/security-audit" .
The semgrep p/swift ruleset includes ~50 iOS-specific patterns (hardcoded API keys, weak crypto, unsafe network configs). False positive rate is moderate; baseline first, then enforce in CI.
MobSF (Mobile Security Framework)
Open-source IPA static + dynamic analyzer. Upload your .ipa or .zip of source, get a report covering:
- Hardcoded secrets (entropy + regex scan)
- Insecure permissions in entitlements
- ATS misconfigurations
- Insecure URLs and schemes
- Suspicious third-party libraries
- Class-dump output of ObjC interfaces
git clone https://github.com/MobSF/Mobile-Security-Framework-MobSF.git
cd Mobile-Security-Framework-MobSF
./setup.sh
./run.sh 127.0.0.1:8000
# upload IPA via web UI
MobSF is the iOS equivalent of “running OWASP ZAP on a web app.” Run before every release.
Trivy / Snyk for SPM dependencies
trivy fs --scanners vuln,secret,misconfig .
Scans Package.resolved against the GHSA database for known CVEs in your transitive dependencies. Integrate into CI; fail builds on high-severity findings.
Dynamic analysis tools
mitmproxy
brew install mitmproxy
mitmproxy --listen-port 8080
# On simulator: System Settings → Network → Wi-Fi → Configure Proxy → 127.0.0.1:8080
# On device: install mitmproxy CA via http://mitm.it after configuring proxy
Reveals every request your app makes. Use to:
- Verify pinning blocks intercept (pinning should refuse the mitmproxy CA)
- Audit headers — are auth tokens correctly attached only to authorized hosts?
- Spot unintended endpoints (analytics SDK calling its own server with PII)
- Replay requests to test server-side authorization
Frida + objection
pip install frida-tools objection
objection --gadget "com.acme.app" explore
In the objection REPL on a jailbroken device or repackaged IPA:
ios keychain dump
ios sslpinning disable
ios cookies get
ios nsuserdefaults get
memory list modules
ios hooking list classes
ios hooking watch class_method "AuthManager.login"
This is what a pentester does on day one. Run these yourself first; if pinning is bypassable in 30 seconds, fix it before the report lands.
IDA Pro / Hopper / Ghidra
Reverse-engineering disassemblers. Walk the binary’s auth path, look for hardcoded strings, identify the cryptographic primitives in use. Skip unless you have time to learn the tool; rely on MobSF + class-dump for the 80 % case.
A typical internal pentest workflow
- Recon — install IPA on jailbroken test device. Run
stringsandclass-dump. Inspect Info.plist and entitlements. Check for embedded secrets. - Storage audit — dump Keychain, UserDefaults, Documents, Library. Look for tokens, PII, encryption keys.
- Network audit — proxy through mitmproxy with the device’s CA trust subverted. Check pinning, headers, request payloads, plaintext fields.
- Runtime audit — Frida-hook critical methods (
AuthManager.login,Keychain.get,URLSession.dataTask). Verify business logic enforcement isn’t bypassable. - Server audit — replay captured requests with modified parameters. Test for IDOR, missing authz, parameter tampering.
- Privacy audit — match nutrition labels to actual network behavior. Check Privacy Manifest accuracy.
- Documentation — every finding gets severity (Critical/High/Medium/Low/Informational), OWASP M-category, repro steps, fix recommendation, and code reference.
A focused internal audit takes 1-3 days for a mid-sized app. Repeat quarterly + before major releases.
Severity rubric
| Severity | Criteria | Example |
|---|---|---|
| Critical | Remote exploitable, immediate user/account compromise | Hardcoded admin API key |
| High | Account takeover with attacker proximity or interaction | TLS not pinned + token in plaintext |
| Medium | Information disclosure, weak crypto, missing defense-in-depth | UserDefaults storing email |
| Low | Hardening recommendation, no direct impact | Missing jailbreak detection |
| Informational | Best practice violation | Forgotten debug log |
Don’t inflate severity — bigwig reports lose credibility. Don’t deflate either — Critical findings need executive visibility.
Responsible disclosure
When you find a vulnerability in someone else’s iOS app or SDK:
- Document with proof-of-concept, environment, version, expected vs actual behavior.
- Contact via the vendor’s security@ email or security.txt; allow at least 90 days for remediation before public disclosure.
- Coordinate the disclosure timeline; vendors appreciate this, attackers don’t get this courtesy.
- Apple Security Bounty — vulnerabilities in iOS, macOS, watchOS, or Apple platforms qualify for Apple’s bounty program. Reports go via security@apple.com with PGP-encrypted payloads. Bounties range $5k to $1M+ depending on severity and demo quality.
When someone reports a vulnerability in your app:
- Acknowledge within 24 hours.
- Triage with severity assessment within 72 hours.
- Communicate timeline back to reporter.
- Patch with regression test.
- Disclose publicly after fix is in users’ hands (App Store rollout completion).
- Credit the reporter if they want it.
A public security.txt (RFC 9116) at https://acme.com/.well-known/security.txt listing contact and policy is table stakes.
Pre-release security checklist
□ STRIP_SWIFT_SYMBOLS = YES in release config
□ No NSAllowsArbitraryLoads in release Info.plist
□ All ATS exceptions documented and justified
□ PrivacyInfo.xcprivacy present and accurate
□ All required usage description strings (Info.plist)
□ Privacy nutrition labels updated for current release
□ TLS pinning active with primary + backup pins
□ Pinning kill-switch verified working
□ Keychain accessibility audited (no Always, no plain Whenever)
□ No tokens or PII in UserDefaults (lint-enforced)
□ Biometric-gated keys for high-value operations
□ App Attest enabled on value-bearing endpoints
□ Jailbreak detection signals reporting to server
□ SwiftLint security rules passing
□ semgrep security ruleset passing
□ MobSF report reviewed; all High+ findings resolved
□ Trivy/Snyk passing on Package.resolved
□ Account deletion flow tested in-app
□ Crash reporter PII filters verified
□ Internal pentest within last quarter
□ Security review sign-off from designated owner
Pin this in the repo. Required check before pressing the App Store submit button.
In the wild
The Signal team publishes regular security reviews — third-party audits by NCC Group and others, reports made public. Lyft, Robinhood, Coinbase, and most fintechs use a combination of in-house security teams plus annual external pentests by firms like NCC Group, Trail of Bits, or Cure53. The pattern across mature security programs: continuous SAST in CI, quarterly internal pentests, annual external pentests, plus a public bug bounty for ongoing crowdsourced testing.
Common misconceptions
- “External pentest = secure.” A pentest is a snapshot, not a guarantee. Continuous internal review + a strong baseline is what produces durable security.
- “Tools find everything.” Static + dynamic analysis catch the common cases. Architectural flaws (broken auth flow, missing authz) require human review.
- “We don’t need a bug bounty until we’re big.” Even a coordinated-disclosure email is enough. The cost is responsiveness, the benefit is finding issues before attackers do.
- “Annual pentest is fine.” Annual pentest + zero internal review means findings get rediscovered every year. Build internal capability.
- “Apple’s review checks security.” App Store Review catches policy and a small subset of security issues (ATS misconfigs, missing privacy strings). It does not audit your auth architecture or key management.
Seasoned engineer’s take
Security is a process, not a product. The most secure iOS apps aren’t the ones with the most defensive code — they’re the ones with the most disciplined review cycles. Bake static analysis into every PR so the baseline never regresses. Run an internal pentest every quarter so external pentests find architectural improvements, not blocking critical bugs. Maintain a security.txt and respond fast to reporters. Build the muscle of writing post-fix retrospectives so each finding teaches the team and reduces the next finding’s likelihood.
TIP: After every external pentest, hold a retrospective. Categorize each finding: what static check would have caught this? What internal pentest checklist item missed it? Update the checklist. Compound learning beats stronger one-time defenses.
WARNING: Don’t ship security fixes silently. Public release notes for security-affecting versions (“This release contains security improvements; please update”) help users prioritize. Quiet patches make users complacent about updates and erode the next time you need them to act fast.
Interview corner
Junior: “How would you audit an iOS app for security issues?” Run static analysis (SwiftLint security rules, semgrep, MobSF), then dynamic analysis through mitmproxy and Frida on a jailbroken test device. Check the Info.plist, Privacy Manifest, and entitlements. Map findings to OWASP Mobile Top 10.
Mid: “What would you do with a Critical finding two days before launch?” First, validate: reproduce the finding, confirm severity, assess blast radius. Second, communicate: alert the security owner and product leadership with timeline impact. Third, decide: fix-and-ship (if fix is contained and testable), kill-switch (if remote-configurable), or delay (if neither). Document the decision and reasoning. After ship, retrospective with a focus on how the finding survived to two days before launch — what review caught it, what review missed it, what we change next time.
Senior: “Design the security audit program for a Series B fintech with 40 engineers and one security person.” Three concentric loops. Innermost: every PR triggers SwiftLint + semgrep + Trivy in CI; broken security rules block merge. Security person reviews PRs touching auth, payments, key management — flagged by codeowners on those directories. Middle loop: monthly internal pentest by the security person on one new release per month, rotating focus areas (auth one month, networking next, storage next). Findings go into a tracked backlog with SLAs by severity. Outermost: annual external pentest by NCC Group or Trail of Bits, scoped to highest-value flows; pre-pentest, run an internal mock-pentest to surface and fix easy findings so external time is spent on architecture. Public security.txt, coordinated-disclosure policy, response SLAs published. After year one, add a managed bug bounty via HackerOne or Bugcrowd. The 40-engineer team’s part: secure-coding training onboarding, security-checklist signoff on every release, blameless retrospectives on findings. The system scales with engineering headcount without scaling the security team linearly.
Red-flag answer: “We’ll get a pentest before launch.” Reveals point-in-time thinking instead of process thinking.
Lab preview
Lab 9.3 (Security Audit) walks you through a complete internal pentest workflow: static analysis with MobSF, dynamic analysis with mitmproxy and objection, finding 8 deliberate vulnerabilities, categorizing by OWASP M-number, and producing a remediation plan with prioritization.
This concludes the Phase 9 chapters. Continue to the labs to put it into practice:
Then continue to Phase 10 — Deployment & CI/CD.
Lab 9.1 — Secure Notes App
Goal: Take an intentionally insecure notes app and migrate it to use Keychain-stored encryption keys, biometric unlock, encrypted persistence, and a complete Privacy Manifest.
Time: ~3 hours
Prerequisites:
- Phase 9 chapters 9.2 (Secure Data Storage), 9.4 (Authentication & Biometrics), 9.7 (Secure Coding), 9.9 (Privacy)
- A real iOS device with Face ID or Touch ID (simulator works for most but not biometric flow)
- Xcode 16+
Setup
- Clone the starter:
git clone https://github.com/bl9/swift-engineer-labs.git
cd swift-engineer-labs/09-security/secure-notes-app/starter
open SecureNotesApp.xcodeproj
- Inspect the starter — deliberately insecure:
// AppState.swift (starter — DO NOT SHIP)
@Observable final class AppState {
var notes: [Note] = []
private let storeKey = "notes.store"
init() {
if let data = UserDefaults.standard.data(forKey: storeKey),
let decoded = try? JSONDecoder().decode([Note].self, from: data) {
self.notes = decoded
}
}
func save() {
let data = try! JSONEncoder().encode(notes)
UserDefaults.standard.set(data, forKey: storeKey)
}
}
Note the issues: plaintext in UserDefaults, force-try, no auth gate, no Privacy Manifest.
Tasks
Task 1 — Move persistence out of UserDefaults
Replace UserDefaults persistence with file-based storage under Application Support, using .complete Data Protection:
final class NotesStore {
private let url: URL = {
let support = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
try? FileManager.default.createDirectory(at: support, withIntermediateDirectories: true)
return support.appendingPathComponent("notes.encrypted")
}()
func load(key: SymmetricKey) throws -> [Note] {
guard FileManager.default.fileExists(atPath: url.path) else { return [] }
let blob = try Data(contentsOf: url)
let box = try AES.GCM.SealedBox(combined: blob)
let plain = try AES.GCM.open(box, using: key)
return try JSONDecoder().decode([Note].self, from: plain)
}
func save(_ notes: [Note], key: SymmetricKey) throws {
let plain = try JSONEncoder().encode(notes)
let box = try AES.GCM.seal(plain, using: key)
try box.combined!.write(to: url, options: [.completeFileProtection])
}
}
Task 2 — Generate and store the encryption key in Keychain
On first launch, generate a 256-bit symmetric key and store it in Keychain with biometric gating:
enum KeyVault {
static let account = "com.acme.securenotes.dataKey"
static func loadOrCreate() throws -> SymmetricKey {
if let data = try read() { return SymmetricKey(data: data) }
let key = SymmetricKey(size: .bits256)
try store(key.withUnsafeBytes { Data($0) })
return key
}
private static func store(_ data: Data) throws {
let access = SecAccessControlCreateWithFlags(
nil,
kSecAttrAccessibleWhenPasscodeSetThisDeviceOnly,
.biometryCurrentSet,
nil
)!
let q: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: account,
kSecValueData as String: data,
kSecAttrAccessControl as String: access,
]
SecItemDelete(q as CFDictionary)
let status = SecItemAdd(q as CFDictionary, nil)
guard status == errSecSuccess else { throw KeychainError.unhandled(status) }
}
private static func read() throws -> Data? {
let q: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: account,
kSecReturnData as String: true,
kSecMatchLimit as String: kSecMatchLimitOne,
kSecUseOperationPrompt as String: "Unlock your notes",
]
var result: AnyObject?
let status = SecItemCopyMatching(q as CFDictionary, &result)
if status == errSecItemNotFound { return nil }
guard status == errSecSuccess else { throw KeychainError.unhandled(status) }
return result as? Data
}
}
Task 3 — Wire the biometric unlock flow
On app launch, show a locked screen. User taps “Unlock with Face ID” → KeyVault.loadOrCreate triggers the biometric prompt → notes decrypt and display.
Implement an AppLockState @Observable that drives the UI between .locked, .unlocking, .unlocked(NotesStore), and .failed(error).
Handle the passcode-fallback path explicitly: if LAError.biometryNotAvailable or .biometryNotEnrolled, fall back to .deviceOwnerAuthentication (passcode).
Task 4 — Add the Privacy Manifest
Create PrivacyInfo.xcprivacy at the project root with:
NSPrivacyTracking = falseNSPrivacyCollectedDataTypes: User Content (notes), Linked = false, Tracking = false, Purpose = App FunctionalityNSPrivacyAccessedAPITypes:NSPrivacyAccessedAPICategoryUserDefaultswith reasonCA92.1,NSPrivacyAccessedAPICategoryFileTimestampwith reasonC617.1
Task 5 — ATS audit
Open Info.plist and verify no NSAppTransportSecurity exceptions. Add a Run Script build phase that fails the build if any are present in Release config.
Task 6 — Add the usage description string
NSFaceIDUsageDescription = “Face ID protects your notes so only you can read them.”
Build & verify
- Run on a real device. First launch should generate the key and prompt for biometric setup (depending on flow).
- Add a note, close the app fully, reopen → should require Face ID to unlock.
- With the device locked (long-press power, then unlock screen), verify that the file at
Application Support/notes.encryptedcannot be read by a tethered session — the.completeFileProtectionflag should block it. - Use the iOS Files app or a backup inspector to verify nothing about the notes leaks via UserDefaults or any unencrypted file.
- Run
stringsagainst the build product.app/SecureNotesApp— should NOT find any of your test note content.
Stretch goals
- Re-lock after timeout — after 30 seconds in background, re-require biometric unlock.
- Decoy mode — secondary fingerprint enrollment unlocks a different notes database (the “decoy”). Hint: use distinct Keychain accounts gated on different
SecAccessControlflags. - Export-encrypted — implement an “Export Notes” feature that produces an age-encrypted file the user can share, with the key derived from a passphrase.
- SQLCipher — replace the AES.GCM blob with a SQLCipher-encrypted SQLite database via GRDB.
Notes
- The
.biometryCurrentSetflag means re-enrolling Face ID invalidates the key and loses the notes. Acceptable for a security-first app; document clearly to users in onboarding. - The Keychain item survives app uninstall by default. To clean up on uninstall, you’d need to clear on first launch detection — but it’s an explicit choice and most secure-notes apps keep data across reinstalls intentionally.
- Don’t store decrypted notes in UserDefaults caching or anywhere outside the in-memory
AppState.
Next: Lab 9.2 — Certificate Pinning
Lab 9.2 — Certificate Pinning
Goal: Add TrustKit-style SPKI pinning to a sample API client, then verify with mitmproxy that the pinning blocks man-in-the-middle interception.
Time: ~2 hours
Prerequisites:
- Phase 9 chapter 9.3 (Network Security & TLS Pinning)
mitmproxyinstalled (brew install mitmproxy)openssl(preinstalled on macOS)- An iOS device or simulator on the same Wi-Fi as your Mac
Setup
- Clone the starter:
git clone https://github.com/bl9/swift-engineer-labs.git
cd swift-engineer-labs/09-security/certificate-pinning/starter
open APIPinningLab.xcodeproj
- The starter app calls
https://httpbin.org/getvia a plainURLSession. No pinning, no delegate.
Tasks
Task 1 — Extract the SPKI hash for httpbin.org
openssl s_client -servername httpbin.org -connect httpbin.org:443 < /dev/null 2>/dev/null \
| openssl x509 -pubkey -noout \
| openssl pkey -pubin -outform DER \
| openssl dgst -sha256 -binary \
| openssl enc -base64
Note the result — that’s your primary pin. Also extract pins for at least one intermediate or root in the chain as backup, so cert renewal doesn’t break you:
openssl s_client -servername httpbin.org -showcerts -connect httpbin.org:443 < /dev/null 2>/dev/null \
| awk '/BEGIN CERT/,/END CERT/' \
| csplit -s -f cert- - '/BEGIN CERT/' '{*}'
# For each cert-NN, extract the SPKI hash
for f in cert-*; do
openssl x509 -in "$f" -pubkey -noout \
| openssl pkey -pubin -outform DER \
| openssl dgst -sha256 -binary \
| openssl enc -base64
done
Task 2 — Implement the pinning delegate
import CryptoKit
final class PinnedDelegate: NSObject, URLSessionDelegate {
let pins: Set<String>
init(pins: Set<String>) { self.pins = pins }
func urlSession(
_ session: URLSession,
didReceive challenge: URLAuthenticationChallenge,
completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void
) {
guard
challenge.protectionSpace.authenticationMethod == NSURLAuthenticationMethodServerTrust,
let trust = challenge.protectionSpace.serverTrust
else {
completionHandler(.performDefaultHandling, nil)
return
}
var error: CFError?
guard SecTrustEvaluateWithError(trust, &error) else {
completionHandler(.cancelAuthenticationChallenge, nil)
return
}
let count = SecTrustGetCertificateCount(trust)
for i in 0..<count {
guard let cert = SecTrustGetCertificateAtIndex(trust, i),
let publicKey = SecCertificateCopyKey(cert),
let keyData = SecKeyCopyExternalRepresentation(publicKey, nil) as Data?
else { continue }
let hash = Data(SHA256.hash(data: keyData)).base64EncodedString()
if pins.contains(hash) {
completionHandler(.useCredential, URLCredential(trust: trust))
return
}
}
completionHandler(.cancelAuthenticationChallenge, nil)
}
}
Task 3 — Wire the session
let session = URLSession(
configuration: .default,
delegate: PinnedDelegate(pins: ["PRIMARY...", "BACKUP..."]),
delegateQueue: nil
)
Run the app; the request should succeed. Network tab in Xcode should show 200 OK from httpbin.
Task 4 — Verify with mitmproxy
# Terminal 1
mitmproxy --listen-port 8080
On your device:
- Settings → Wi-Fi → tap (i) → Configure Proxy → Manual → Server: your Mac’s LAN IP, Port: 8080
- Visit
http://mitm.itin Safari, follow instructions to install the mitmproxy CA profile, then Settings → General → About → Certificate Trust Settings → enable for mitmproxy
Now re-run your app. Without pinning the request would appear in mitmproxy’s flow list. With pinning correctly implemented, the request should fail — URLError with cancelled or secureConnectionFailed. Verify both:
- Temporarily disable pinning → request appears in mitmproxy
- Re-enable pinning → request fails before reaching mitmproxy (you’ll see a TLS handshake failure in mitmproxy’s event log, not in the flow list)
Task 5 — Add pinning failure metrics
Add an analytics callback that reports pinning-failure events distinct from other network errors. Differentiate by checking the URLError code and the certificate-validation context. Verify your local logger captures pinning-specific failures during the mitmproxy test.
Task 6 — Remote kill-switch
Add a PinningConfig struct fetched from a remote endpoint (mock it with a hardcoded true for the lab). When enforce = false, the delegate should call .performDefaultHandling and let the system trust prevail. Verify the switch works by toggling the value, restarting, and confirming mitmproxy can now intercept.
Stretch goals
- TrustKit integration — replace the hand-rolled delegate with TrustKit’s pinning policy. Compare LOC and feature set.
- Multi-host pinning — pin different hosts to different pin sets. The hand-rolled delegate currently doesn’t dispatch by host; add that.
- Synthetic monitoring — write a unit test that uses URLProtocol stubbing to feed in a server trust object built from a fake cert, and verifies the delegate correctly rejects it.
- App Attest — additionally require an App Attest assertion on each request as a second wall.
Notes
- After the lab, remove the proxy settings from your device. Leaving mitmproxy trusted is itself an M5 violation.
- httpbin.org occasionally rotates certs. If the lab stops working in a few months, refresh your pin extraction.
- The hand-rolled delegate above is for learning; production apps should use TrustKit for the additional features (per-host config, reporting, backup pin support out of the box).
Next: Lab 9.3 — Security Audit
Lab 9.3 — Security Audit
Goal: Perform a complete internal security audit on a deliberately vulnerable iOS app — find 8 OWASP Mobile Top 10 violations, categorize them by M-number, and produce a prioritized remediation plan.
Time: ~4 hours
Prerequisites:
- All of Phase 9
mitmproxy,objection,MobSF(Docker is easiest),semgrep- A jailbroken test device OR the iOS simulator (lab works mostly on simulator; jailbreak-specific findings are documented but not directly exploitable)
Setup
git clone https://github.com/bl9/swift-engineer-labs.git
cd swift-engineer-labs/09-security/security-audit/starter
open VulnerableBankApp.xcodeproj
The starter is a fake “banking” app — login, balance, transfers. It contains 8 deliberate OWASP violations. Your job is to find them all by category, fix each with the correct primitive, and write the audit report.
Tasks
Task 1 — Static analysis
Run static analysis tools and triage findings:
# semgrep
semgrep --config "p/swift" --config "p/security-audit" VulnerableBankApp/
# Note any high-confidence findings.
# strings scan
strings build/Release-iphonesimulator/VulnerableBankApp.app/VulnerableBankApp \
| grep -iE "(api_key|password|secret|token|sk_live|http://)"
# MobSF (Docker)
docker run -it --rm -p 8000:8000 opensecurity/mobile-security-framework-mobsf:latest
# Upload the .ipa via http://localhost:8000
Record every finding in audit-report.md with file:line references.
Task 2 — Dynamic analysis
# mitmproxy on port 8080, install CA on simulator
mitmproxy --listen-port 8080
In simulator: System Settings → Network → set HTTP Proxy to 127.0.0.1:8080; install the mitmproxy CA per Lab 9.2.
Run the app, log in, view balance, attempt a transfer. Inspect every request in mitmproxy.
Then run objection (requires Frida-injectable build — provided Debug-Frida config in the starter):
objection --gadget "VulnerableBankApp" explore
# Inside REPL:
ios keychain dump
ios nsuserdefaults get
ios cookies get
memory list modules
Task 3 — Find the 8 violations
Without spoiling: each violation maps to a specific M-category. They cover roughly: credential storage, authentication, network communication, input validation, privacy, cryptography, binary protections, security misconfiguration. Use the OWASP Mobile Top 10 mapping from 9.1 as your scoring rubric.
For each finding, document:
- File / line reference
- M-category and brief description
- Severity (Critical / High / Medium / Low) per the rubric in 9.10
- Reproduction steps (concrete: “log in, observe X in mitmproxy”)
- Recommended fix with the correct iOS primitive
- Estimated effort (S/M/L)
Task 4 — Apply the fixes
In the fixed/ directory, implement each remediation:
- Move secrets from
UserDefaultsto Keychain with appropriate accessibility - Replace plain HTTP with HTTPS + pinning
- Sanitize NSPredicate format strings with
%@placeholders - Remove hardcoded API keys; replace with a server-issued short-lived token
- Add the missing
PrivacyInfo.xcprivacyand accurate nutrition-label declarations - Replace ECB-mode CommonCrypto with
AES.GCMfrom CryptoKit - Strip Swift symbols in release; add the missing build-script audit
- Fix the
os_log%{public}@token leak - Upgrade weak Keychain accessibility (
AccessibleAlways→AccessibleAfterFirstUnlockThisDeviceOnlyat minimum)
After each fix, re-run the relevant static/dynamic check to verify the finding is closed.
Task 5 — Write the audit report
Produce audit-report.md with:
- Executive summary — total findings by severity, key risk areas, headline conclusion
- Findings — one entry per violation with all fields above
- Remediation plan — prioritized by severity, with owners and estimated dates
- Verification matrix — table mapping each finding to the tool/test that confirmed the fix
- Residual risk — what’s still exposed even after all fixes (e.g., reverse engineering, server-side attacks)
This is what real security reports look like. Practice the format; you’ll see it again.
Task 6 — CI gates to prevent regressions
For each fix, add a CI check that fails the build if the regression recurs:
- SwiftLint custom rule for
UserDefaultssecret writes - semgrep ruleset committed and enforced
- Build script asserting no
NSAllowsArbitraryLoadsin release Info.plist - Build script asserting no
STRIP_SWIFT_SYMBOLS = NOin release xcconfig - Strings-based scan that fails on hardcoded
sk_live_orBearerliterals
Demonstrate each check failing on a deliberate regression, then passing once reverted.
Stretch goals
- App Attest integration — wire the transfer endpoint to require an attestation assertion. Use a mock server.
- Threat model document — beyond the findings, write a one-pager threat model: actors, motivations, attack surfaces, mitigations. The kind of document a security review asks for.
- Coordinated-disclosure simulation — pretend you found these in a third-party app. Draft the disclosure email with timeline.
- Comparison study — run MobSF against a real shipping app (your own past projects, or a non-production sample). Note what shipping apps actually look like under the same scanner.
Notes
- Don’t run mitmproxy or trust user-installed CAs on your personal device after the lab. Reset the CA trust in Settings.
- The lab uses fake data and a mock server. Don’t point any of these techniques at real banking apps without explicit authorization — that’s a CFAA violation in the US (and equivalents elsewhere).
- The “8 violations” count is intentional. If you find more, congratulations — note them in your report as bonus findings.
This concludes Phase 9. Continue to Phase 10 — Deployment & CI/CD.
10.1 — Apple Developer Program
Opening scenario
You finish a polished MVP on Friday night, hit Archive, and try to upload to App Store Connect. Xcode tells you: “No accounts with App Store Connect access.” You sign up for an Apple Developer account, pay the $99, wait. Monday morning, still pending — because you used a Gmail account that doesn’t match the legal entity on your tax forms. By Thursday, support has bounced you twice. Your launch date is gone.
The Apple Developer Program isn’t a credit card transaction. It’s an identity verification, a legal contract, and a permission system rolled into one. Get this layer wrong and nothing else matters — your code never reaches users.
Context taxonomy
| Account type | Cost | Who it’s for | DUNS required | Time to approve | Caveats |
|---|---|---|---|---|---|
| Individual | $99/year | Solo developer, publishing under your name | No | Hours to days | Your real name appears on App Store |
| Organization | $99/year | LLC, Inc, GmbH, Ltd | Yes (free, ~5 biz days) | 1–2 weeks | Legal entity name appears on store |
| Enterprise | $299/year | In-house distribution only, 100+ employees | Yes | 2–4 weeks | Cannot publish to App Store |
| Apple Developer (free) | $0 | Build to your device, no distribution | No | Instant | Provisioning expires after 7 days |
| Education / non-profit | $0 or waived | Accredited schools, qualifying non-profits | Sometimes | 1–3 weeks | Apply via Apple’s program page |
Concept → Why → How → Code
Concept. The program gives you three things: an App Store Connect identity, code signing certificates, and entitlements to use Apple’s restricted APIs (Push, HealthKit, CarPlay, etc.).
Why. Apple gates the platform because every signed binary is traceable to a real person or entity. This is what makes “scammed by an iOS app” rare compared to other ecosystems.
How. You’ll work with five primitives in your career:
- Team ID — 10-char string like
ABCDE12345. Identifies your account everywhere. - Certificate — public/private keypair issued by Apple. Two main types: Apple Development, Apple Distribution.
- App ID (Identifier) — bundle ID like
com.acme.notes, registered in your account with associated capabilities. - Provisioning Profile — binds (certificate × app ID × devices × entitlements). The thing Xcode actually embeds in your
.app. - Role — Account Holder, Admin, App Manager, Developer, Marketing, Customer Support, Finance. Each has a precise permission scope.
# Inspect a downloaded provisioning profile (decode the PKCS#7 envelope)
security cms -D -i ~/Downloads/MyApp_AdHoc.mobileprovision | plutil -p -
# List signing identities installed in your keychain
security find-identity -v -p codesigning
The provisioning profile dump shows you the App ID, the certificate fingerprints, expiry date, and (for Ad Hoc/Development) the UDIDs allowed to run it. When code signing breaks, this is what you read first.
In the wild
- Stripe runs an Organization account under “Stripe, Inc.” with the Account Holder being a tightly-restricted role no engineer has direct access to. Engineers get App Manager.
- Indie devs (Marco Arment, Underscore Apps, Cultured Code) use Individual accounts to keep the legal name on the store page, which matters for personal branding.
- Large studios (Riot Games, Niantic) buy Enterprise accounts for internal QA distribution alongside their App Store Organization account — two separate Team IDs.
- Startups that haven’t formed an LLC yet publish under Individual accounts and migrate to Organization later via Apple’s account transfer process (slow, paperwork-heavy, requires Apple’s manual review).
Common misconceptions
- “Enterprise lets me put apps on the App Store.” No. Enterprise is private distribution only. Listing to App Store gets your account terminated.
- “I can switch from Individual to Organization later for free.” You can, but it’s not seamless — Apple manually re-verifies and your Team ID changes, which breaks every existing keychain-shared app group and Sign in with Apple linkage.
- “DUNS takes weeks.” It takes ~5 business days if you request it via Apple’s free lookup tool. Going to D&B directly costs money.
- “Account Holder = root.” Account Holder controls billing and termination. Admin actually controls most day-to-day. You want Admin, not Account Holder, on automation accounts.
- “$99 covers everything.” It covers App Store distribution. It does not cover Mac notarization throttling, expedited reviews, or storage above the App Store’s limits.
Seasoned engineer’s take
The Apple Developer Program is administrivia, but it’s the kind of administrivia that destroys launches. Three rules to live by:
TIP. Set the Account Holder to a role-based email (
apple-account@yourcompany.com) backed by a mailing list, not a person’s inbox. People quit. Mailing lists don’t.
WARNING. The DUNS number is legal entity-specific. If you re-incorporate, change states, or pivot the LLC, you need a new DUNS and your Apple account must be transferred — Apple is the slowest party in this dance. Plan months ahead.
The other thing nobody tells you: the App Store agreement (the “Paid Apps Agreement”) has banking and tax forms that take longer than the developer account itself. Until those are signed and your bank account is verified, you can’t get paid. Start that paperwork on day one.
Interview corner
Junior — “What does $99/year get you?” The ability to distribute apps to the App Store and TestFlight, code signing certificates, access to beta OSes, technical support incidents, and entitlements to restricted APIs. The free tier lets you run apps on your own devices but only with 7-day provisioning.
Mid — “Walk me through registering a new app from scratch.” Create a bundle ID in Identifiers (matching what you’ll use in Xcode), enable any capabilities (Push, App Groups, etc.), generate or use existing certificates, create a provisioning profile that binds them, download it, and configure Xcode signing to use it — or let Xcode-managed signing do all of that for you.
Senior — “Your company is moving from solo founder to 4-person team plus 2 contractors. What does your Apple Developer setup look like?” Move the Account Holder to a role-based email controlled by a small board. Add admins (founders), App Managers (full-time engineers), and Developers (contractors — they can run on test devices but can’t ship). All Account Holder actions go through a 2FA-enforced account with a hardware key. Set up an App Store Connect API key for CI so nobody’s personal credentials are in pipelines.
Red flag — “I just use the Account Holder login on the CI server because it’s simpler.” That’s a credentials-on-disk anti-pattern, and if you leave the company the Account Holder cannot be reassigned without contacting Apple support.
Lab preview
In Lab 10.1 you’ll build a Fastlane pipeline that uses an App Store Connect API key (not a username/password) so this entire chapter’s lessons about roles, account safety, and automation come together in working code.
Next: 10.2 — Code Signing Deep Dive
10.2 — Code Signing Deep Dive
Opening scenario
It’s 11pm. Your release branch builds locally. CI fails:
error: No profiles for 'com.acme.notes' were found:
Xcode couldn't find any iOS App Store provisioning profiles matching 'com.acme.notes'.
You regenerate profiles in the Apple Developer portal. Still fails. You delete derived data, restart Xcode, restart your Mac. The build that worked an hour ago no longer works and you don’t know why.
Code signing is the single biggest source of “works on my machine” frustration in iOS. Understanding the actual cryptographic machinery — not just clicking checkboxes — is what separates engineers who can ship from those who can’t.
Context taxonomy
| Artifact | Purpose | Lives where | Expiry |
|---|---|---|---|
| Apple Development certificate | Sign development builds for your devices | Keychain (private), Apple portal (public) | 1 year |
| Apple Distribution certificate | Sign Ad Hoc, App Store, Enterprise builds | Keychain + portal | 1 year |
| Apple Push Notification key (p8) | Auth APNs requests | Anywhere; download once | Never expires (revocable) |
| Development profile | Bundle ID × cert × device list (≤ 100) | ~/Library/MobileDevice/Provisioning Profiles/ | 1 year |
| Ad Hoc profile | Off-store distribution to listed devices | Same dir | 1 year |
| App Store profile | Build that App Store accepts | Same dir | 1 year |
| Enterprise profile | In-house distribution, any device | Same dir | 1 year |
| Entitlements | XML capabilities map embedded in binary | .entitlements in Xcode project | n/a |
Concept → Why → How → Code
Concept. Code signing answers three questions:
- Who produced this binary? (Certificate)
- What is it allowed to do? (Entitlements)
- Where is it allowed to run? (Provisioning profile constraints)
Why. iOS refuses to launch a binary unless every byte hashes correctly against the embedded signature, the signing certificate chains to Apple’s root, and a matching valid provisioning profile is installed (or, for App Store, the profile was present at app submission time).
How — the flow at build time.
Source code
↓ compile
Mach-O binary + resources
↓ codesign --sign "Apple Distribution: Acme Inc."
Signed .app bundle (contains _CodeSignature/CodeResources hash map)
↓ embed PROVISIONING_PROFILE
.app ready for IPA packaging
↓ xcodebuild -exportArchive
.ipa (zip of Payload/MyApp.app + metadata)
Inspect a built .app.
# What's the signing identity?
codesign -dv --verbose=4 build/MyApp.app 2>&1 | head -20
# What entitlements does it claim?
codesign -d --entitlements :- build/MyApp.app
# What provisioning profile is embedded?
security cms -D -i build/MyApp.app/embedded.mobileprovision | plutil -p -
# Verify the signature is intact
codesign --verify --deep --strict --verbose=2 build/MyApp.app
# Re-sign with a different identity (useful for white-label apps)
codesign --force --sign "Apple Distribution: Other Co." \
--entitlements ./resigned.entitlements \
build/MyApp.app
The five most common errors and their exact fixes.
| Error | Root cause | Fix |
|---|---|---|
No profiles for 'X' were found | No profile in portal matches bundle ID + cert + team | Regenerate profile; if using automatic signing, toggle “Automatically manage signing” off and on |
Provisioning profile doesn't include signing certificate | Profile generated before a new cert was issued | Regenerate profile in portal — selecting the new cert |
code object is not signed at all | Bundled framework/extension was not re-signed | Add a Run Script phase calling codesign -fs "$EXPANDED_CODE_SIGN_IDENTITY" Frameworks/*.framework |
Entitlement com.apple.developer.X not supported | Capability not enabled on App ID | Enable it in Identifiers → App IDs → Edit |
errSecInternalComponent (CI only) | Keychain locked or not in search list | Run security unlock-keychain and security set-key-partition-list -S apple-tool:,apple: |
In the wild
- Apple’s own apps (Pages, Numbers) are signed with
Apple Distribution: Apple Inc.— confirm viacodesign -dvon a downloaded.app. - WhatsApp uses a
Group ID(group.net.whatsapp.WhatsApp.shared) entitlement to share a keychain item between the main app and the share extension — both must be signed with the same team. - Apps that lazy-download frameworks (early Facebook SDK) used to break code signing on iOS 9+ because the on-disk binary no longer matched the signature manifest. Apple’s
On-Demand ResourcesAPI exists partly to fix that pattern. - Re-signing for enterprise distribution is how MDM platforms like Jamf Pro deliver internal-only versions of consumer apps.
Common misconceptions
- “Automatic signing always works.” It works when (a) your account is correctly configured, (b) your bundle ID matches what’s registered, and (c) you have the right role. Otherwise, it silently fails or generates the wrong profile.
- “The provisioning profile is the certificate.” No. The profile references one or more certificates; it’s a separate file with its own expiry.
- “Deleting profiles in
~/Library/MobileDevice/...is dangerous.” It’s safe and often necessary. Xcode re-downloads what it needs. - “Entitlements live in Info.plist.” No — entitlements live in a separate
.entitlementsfile. Info.plist has unrelated metadata. - “Once I sign an IPA, it works on every device of that type.” Only if it’s an App Store build. Ad Hoc/Dev builds are restricted to the device UDIDs in the profile.
Seasoned engineer’s take
There are two religions in iOS code signing: automatic (Xcode manages everything) and manual (you control every step, usually via Fastlane match). Pick one per project — never mix.
TIP. For team projects, use
fastlane matchwith a private Git repo. Every engineer runsfastlane match developmentonce and code signing is solved forever. Onboarding goes from a 2-hour ritual to a single command.
WARNING. Never commit a
.mobileprovisionor.p12to source control without encryption. If you must check certs into Git, usematch(which encrypts everything with a passphrase) orgit-crypt— both are battle-tested.
When debugging code signing, work outside-in: first verify the IPA itself (codesign -dv), then the embedded frameworks, then the entitlements, then the profile. 80% of the time it’s a stale profile or a framework that wasn’t re-signed by an old Xcode script.
Interview corner
Junior — “What’s the difference between a development and a distribution certificate?” A development cert lets you sign builds for testing on your own registered devices. A distribution cert lets you sign builds that can go to the App Store, TestFlight, Ad Hoc lists, or enterprise distribution.
Mid — “You get a ‘no provisioning profile found’ error in CI but builds work locally. Where do you look?” Three places: the App Store Connect API key permissions, the keychain on the CI runner (it must be unlocked and the certificate imported), and the profile download path — automatic signing rarely works on headless runners, so move to manual + fastlane match.
Senior — “Walk me through what happens cryptographically when iOS launches an App Store-signed binary.” iOS checks the binary’s signature against the embedded _CodeSignature/CodeResources hash list, verifies the signing certificate chains to Apple’s root, validates that the embedded provisioning profile (or App Store-issued certificate) authorizes the bundle ID and entitlements, and confirms the binary hasn’t been tampered with at rest. Any mismatch and dyld refuses to load it — the user sees a generic “cannot be opened” error.
Red flag — “I always commit the certificates and profiles to the repo so CI just works.” That leaks your distribution identity to anyone with repo access; if it walks, attackers can sign malware as your company until you revoke and re-issue.
Lab preview
Lab 10.1 has you set up fastlane match against a private certs repo, then build and ship to TestFlight with one command — putting every concept here into practice.
Next: 10.3 — App Store Connect
10.3 — App Store Connect
Opening scenario
Your app finally builds, signs, and uploads. You navigate to App Store Connect to submit for review and discover you need: a privacy nutrition label, screenshots in five required sizes, a 4000-character description in two languages, an age rating from a 30-question questionnaire, export compliance documentation, the URL of a working support page, and a marketing icon at exactly 1024×1024 with zero alpha. Each missing field is a separate “Submit” button blocker. You ship two days late.
App Store Connect is where your engineering work meets Apple’s distribution machinery. Knowing what it demands before you build saves you from “we built the app, now we have to invent the screenshots” panic.
Context taxonomy
| Section | What it controls | Required at submission | Editable after release |
|---|---|---|---|
| App Information | Name, primary language, bundle ID, category, content rights | Yes | Some fields locked |
| Pricing & Availability | Price tier, regions, pre-order, custom B2B | Yes | Yes (phased) |
| App Privacy | Data collection nutrition labels | Yes (since iOS 14.5) | Yes (re-review for changes) |
| Version (1.0, 1.1, …) | Build, what’s new, screenshots, description, keywords | Yes | Until “Ready for Sale” |
| TestFlight | Beta groups, builds, tester invites | No (for App Store) | Always |
| App Store Connect Users & Access | Team roles, App Store Connect API keys | n/a | Always |
| App Analytics | Impressions, downloads, sessions, crashes | n/a | Always |
| Sales and Trends | Revenue, units, refund data | n/a | Always |
Concept → Why → How → Code
Concept. App Store Connect is two things at once: the product catalog (one record per app) and the operational dashboard (analytics, finance, users).
Why. Splitting the dev portal (signing) from App Store Connect (distribution) lets non-engineering roles (marketing, finance) operate without code signing access.
How — create an app record.
- App Store Connect → My Apps → “+”.
- Platforms: iOS (or macOS, tvOS, visionOS — same record can hold multiple).
- Name — appears under the icon on devices. 30 chars max. Cannot be edited during a review.
- Primary Language — locks your default localization.
- Bundle ID — picks from registered IDs in the dev portal; cannot be changed after a build is uploaded.
- SKU — internal identifier you choose. Used in reports.
- User Access — Full Access vs Limited Access (granular per-app).
Screenshots — required sizes (2026).
| Device class | Required size | Notes |
|---|---|---|
| iPhone 6.9“ (iPhone 16 Pro Max) | 1290 × 2796 | Apple uses these for the marketing page first |
| iPhone 6.5“ (iPhone XS Max generation) | 1284 × 2778 | Optional if 6.9“ provided |
| iPhone 5.5“ | 1242 × 2208 | Still required for some older app categories |
| iPad Pro 13“ (M4) | 2064 × 2752 | Required for iPad apps |
| iPad Pro 12.9“ (3rd gen) | 2048 × 2732 | Often shared across iPad classes |
| App Preview video | 30 seconds max, .mov/.mp4 | Optional but high-converting |
Up to 10 screenshots per device class, up to 3 app preview videos.
App Privacy — the nutrition label. You must declare:
- What data you collect (Contact Info, Health, Location, Browsing History, Identifiers, Purchases, Usage Data, Diagnostics, Other).
- For each: linked to user identity vs not, used for tracking vs not, purpose (Analytics, Functionality, Personalization, Ads, etc.).
Misrepresentation here is the #2 cause of post-launch removal (after IAP violations). Treat it like a legal document.
Automating with the App Store Connect API.
# Generate a JWT (App Store Connect API uses ES256)
KEY_ID="ABC1234567"
ISSUER_ID="69a6de70-XXXX-XXXX-XXXX-XXXXXXXXXXXX"
PRIVATE_KEY=$(cat AuthKey_$KEY_ID.p8)
# Use a helper like asc-jwt or write your own with openssl + base64
TOKEN=$(asc-jwt --key-id $KEY_ID --issuer-id $ISSUER_ID --key "$PRIVATE_KEY")
# List apps in your account
curl -H "Authorization: Bearer $TOKEN" \
"https://api.appstoreconnect.apple.com/v1/apps?limit=10" | jq '.data[].attributes.name'
# Get the current version info for an app
curl -H "Authorization: Bearer $TOKEN" \
"https://api.appstoreconnect.apple.com/v1/apps/$APP_ID/appStoreVersions?limit=5"
Every field in the App Store Connect UI maps to an API endpoint. Anything you do twice should be scripted.
In the wild
- Spotify localizes to 30+ languages with separate screenshots per locale, all uploaded via Fastlane
deliverfrom their CI. - Duolingo uses phased release (gradual rollout over 7 days) on every version so a regression hits 1% of users on day one, not 100%.
- Notion uses the
Promote Appsfeature to cross-promote their Mac/Web offerings on their App Store iOS listing. - Many indie devs use Sandvox/MyAppPreview/RocketSim for screenshot mockups, then upload via Fastlane
snapshot+frameitfor repeatable, localized screenshots.
Common misconceptions
- “I can change the bundle ID later.” No. Once a build is uploaded, the bundle ID is permanent for that app record. To change it you ship as a new app.
- “App name and Subtitle are keyword-searchable equally.” Both are searchable, but the Subtitle + Keywords field (100 chars hidden from users) carry the most ASO weight. The marketing Description carries very little.
- “Screenshots show actual app content.” They can be marketing-styled — adding text overlays and lifestyle imagery is allowed. The screenshot doesn’t need to be a raw simulator capture.
- “Once submitted, I have to wait for review to update screenshots.” You can update screenshots, description, keywords, and metadata without a new binary review via “Submit for Review with metadata-only changes.”
- “Phased release is for major updates only.” Use it on every release. It’s free insurance.
Seasoned engineer’s take
Treat App Store Connect like a database your marketing team and your CI write to concurrently. Anything humans do twice should be automated; anything automation does should be auditable.
TIP. Build a “metadata as code” repo: a
fastlane/metadata/folder containingdescription.txt,keywords.txt,name.txt, etc. per locale.fastlane deliversyncs them on every release. You get version control on your store listing — invaluable for compliance audits.
WARNING. The App Store Connect API has aggressive rate limits and silently throttles. Build retry-with-backoff into any script that touches it; treat 429s as expected.
The hidden game-changer: App Store Connect users with the Marketing role can edit screenshots and copy without ever seeing your source code. Use it to keep your engineers out of the metadata business.
Interview corner
Junior — “What goes into a basic App Store submission?” A build (signed, uploaded via Xcode/Transporter/Fastlane), screenshots in required sizes, description, keywords, category, age rating, privacy nutrition label, support URL, and a 1024 icon. Then submit and wait for review.
Mid — “How would you support 10 locales for screenshots and description?” Use Fastlane deliver with a metadata/ folder per locale, generate screenshots via snapshot (Xcode UI tests across simulator/locale combinations) plus frameit for device frames. Wire it into CI so each release pushes both binary and updated metadata.
Senior — “How would you implement automatic pricing changes — e.g., a 25% discount in Brazil for one week?” Use the App Store Connect API: read current price schedule via GET /appPriceSchedules, write a new schedule with a sale tier scoped to the BRL territory, then schedule another schedule to revert at end-of-sale. Wrap in a CI job with a dry-run flag; alert on 4xx/5xx; never trust the API to be eventually consistent — verify with a follow-up GET.
Red flag — “We just have one engineer who knows how to upload to App Store Connect.” That’s a bus factor of 1 for shipping. The fix is automation + role distribution, not promoting that engineer.
Lab preview
Lab 10.3 wires metadata-as-code and binary upload into a single GitHub Actions pipeline that promotes a tagged commit all the way from green tests to App Store-submitted in under 15 minutes.
Next: 10.4 — TestFlight
10.4 — TestFlight
Opening scenario
You ship a TestFlight build on a Friday for your weekend beta group. Saturday morning, a tester messages: “App won’t open — says ‘this beta has expired’.” You check: the build was uploaded 91 days ago. TestFlight builds expire after 90, no warning, no grace period. Your beta cohort, 200 strong, is now locked out until you upload a new build, wait for processing, and re-distribute.
TestFlight is the fastest way to get pre-release builds in real hands — but it has constraints, expiry rules, and review steps you have to plan around.
Context taxonomy
| Distribution mode | Tester limit | Approval needed | Build expiry | Use case |
|---|---|---|---|---|
| Internal Testing | 100 users (App Store Connect members) | None — instant | 90 days | Dogfooding inside your company |
| External Testing | 10,000 users | First build of each version reviewed (~24h) | 90 days | Public/customer betas |
| Public Link | Same external pool, no email needed | Yes, first build per version | 90 days | Marketing-driven beta signups |
| Ad Hoc | 100 devices/year per device type | None | 1 year (cert/profile validity) | Off-store distribution for QA, contractors |
Concept → Why → How → Code
Concept. TestFlight is an Apple-hosted, throttled distribution channel layered onto App Store Connect. Same upload pipeline, lighter review, time-boxed.
Why. Apple wants beta apps tested by real users on real devices but doesn’t want indefinite “off-store” distribution that bypasses normal review. The 90-day expiry forces a beta cadence.
How — the lifecycle.
1. Build & upload (Xcode / Fastlane pilot / Transporter)
↓
2. Apple processes build (5–30 minutes); status changes from
"Processing" → "Ready to Submit" (internal) or
"Waiting for Beta Review" (external)
↓
3. Add Beta App Description, Email, Feedback URL (per version, external only)
↓
4. Submit for Beta Review (external only) — first build per version
↓
5. Apple Beta Review (~24h) → Approved
↓
6. Distribute to groups (Internal / External)
↓
7. Users install via TestFlight app, submit feedback
↓
8. ~Day 80 — TestFlight emails users that build expires soon
↓
9. Day 90 — Build expires. Upload a new one or lose your testers.
Upload a build with Fastlane pilot.
# Fastfile
lane :beta do
build_app(
scheme: "Acme",
export_method: "app-store"
)
upload_to_testflight(
api_key_path: "fastlane/AuthKey_AAAA1111BB.json",
skip_waiting_for_build_processing: false,
distribute_external: true,
groups: ["Beta Cohort A"],
changelog: File.read("CHANGELOG.md"),
beta_app_review_info: {
contact_email: "beta@acme.com",
contact_first_name: "Pat",
contact_last_name: "Reviewer",
contact_phone: "+1 555 0100",
demo_account_name: "demo@acme.com",
demo_account_password: ENV["DEMO_PASSWORD"],
notes: "Use demo account; tap 'Trips' to see core flow."
}
)
end
Manage testers via the App Store Connect API.
# Invite an external tester to a group
curl -X POST \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
https://api.appstoreconnect.apple.com/v1/betaTesters \
-d '{
"data": {
"type": "betaTesters",
"attributes": {
"email": "user@example.com",
"firstName": "User",
"lastName": "Name"
},
"relationships": {
"betaGroups": {
"data": [{"type": "betaGroups", "id": "'$GROUP_ID'"}]
}
}
}
}'
itmstransporter (Transporter CLI) — legacy but useful as a fallback when Xcode/Fastlane can’t upload.
xcrun iTMSTransporter -m upload \
-assetFile build/Acme.ipa \
-apiKey $API_KEY_ID \
-apiIssuer $ISSUER_ID
In the wild
- Marco Arment ships Overcast betas to a tight internal group of ~15 power users every couple of weeks — relies on TestFlight’s feedback screenshot annotations.
- Slack uses Internal Testing for engineers and External Testing for customer-success-selected enterprise customers (under 10k cap, no public link).
- Linear runs three external beta groups: “Alpha” (~50), “Beta” (~500), “Beta Wide” (~5000) — each gets a build a day apart so regressions surface in alpha before reaching wide.
- Crypto exchanges and other high-risk apps often skip TestFlight entirely and use Ad Hoc + MDM because TestFlight feedback is visible to Apple’s reviewers.
Common misconceptions
- “Internal testers don’t need beta review.” True for internal groups, but you still need to upload a build that compiles — Apple’s processing pipeline catches malformed binaries even for internal distribution.
- “Public Link bypasses beta review.” No — public link distribution still requires beta review on the first build of each version.
- “TestFlight expiry can be extended.” No. 90 days, hard cap. Plan release cadence around it.
- “Beta testers see the App Store listing.” They see a TestFlight listing with the Beta App Description — not the App Store description. Don’t confuse the two.
- “All 10k tester slots are free.” Yes. The cap is the only constraint. No per-tester charge.
Seasoned engineer’s take
TestFlight is your most underrated production tool. Use it for:
- Phased canary: ship to internal first (5–10 power users), then alpha (~50), then external wide. Bake for 24–48h at each stage.
- Crash regression detection: TestFlight crashes are visible in App Store Connect alongside symbolicated stack traces, often hours before real users would surface them.
- Feature flagging: ship a build with flags off, enable them for a beta group via remote config, measure, then enable for App Store.
TIP. Always include a meaningful changelog in
upload_to_testflight. Without it, testers won’t know what to test — and TestFlight feedback quality plummets. Treat changelogs as a contract: “if I test this, you tested this.”
WARNING. Beta App Review notes are read by humans. Vague notes (“test the app”) cause rejections. Include a demo account, a tour script, and a phone number you actually answer.
The strategic move: when a build is ~70 days old, set up CI to push a maintenance build automatically (v1.2.3-beta-refresh) just to reset the expiry clock for long-running QA cohorts.
Interview corner
Junior — “What’s the difference between TestFlight Internal and External testing?” Internal: up to 100 App Store Connect users, no review, instant distribution. External: up to 10,000 users by email or public link, requires beta review on the first build of each version, ~24h turnaround.
Mid — “A beta build expired with active testers — how do you recover and prevent it next time?” Recover by uploading a new build and re-distributing — same CFBundleShortVersionString, incremented build number. Prevent by setting a CI alarm at day 70 to auto-push a refresh build, and by tagging long-running QA cohorts so you know which versions to keep alive.
Senior — “Design a TestFlight strategy for a 1M-DAU consumer app shipping weekly.” Three external groups: Alpha (internal employees + power users, 200), Wide Beta (5,000 opt-ins via public link), QA (100 contractors on Ad Hoc as fallback). CI pushes Monday → Alpha, Wednesday → Wide if no crashes, Friday → App Store submission. Each step gates on TestFlight crash-free-session-rate ≥ 99.5% measured from the previous wave. All builds tagged in Git with the TestFlight cohort.
Red flag — “We just push every commit to TestFlight.” You’ll hit Apple’s processing throttles, drown testers in updates, and burn beta reviewer goodwill (Apple notices when you submit 30 builds a week).
Lab preview
Lab 10.1 builds a Fastlane lane that uploads to TestFlight with a baked-in changelog, demo account, and group routing — the production pattern in 30 lines.
Next: 10.5 — Fastlane
10.5 — Fastlane
Opening scenario
A senior engineer leaves. She was the only one who knew the “release ritual”: 14 manual steps, two passwords from a shared note, an Xcode menu sequence that takes 40 minutes if nothing goes wrong. The next release is in three days. The new lead opens a terminal, types fastlane release, watches a script run for 12 minutes, and ships to App Store Connect — because everything she did was already encoded in a Fastfile that lived in the repo.
Fastlane is the duct tape that holds iOS distribution together. It wraps Apple’s CLI tools in a Ruby DSL so the entire release pipeline becomes version-controlled, reviewable, and reproducible.
Context taxonomy
| Tool | Wraps | What it does |
|---|---|---|
match | git, openssl, security | Sync code signing certs/profiles across the team via an encrypted private Git repo |
gym (build_app) | xcodebuild | Compile + archive + export .ipa |
scan | xcodebuild test | Run tests with prettier output and JUnit XML |
pilot (upload_to_testflight) | App Store Connect API | Upload + distribute TestFlight builds |
deliver (upload_to_app_store) | App Store Connect API | Upload metadata, screenshots, submit for review |
snapshot (capture_screenshots) | xcodebuild UI tests | Auto-generate localized screenshots |
frameit | ImageMagick | Wrap screenshots in device frames with captions |
pem | Apple portal | Generate APNs certificates |
sigh | Apple portal | Resign IPAs / manage profiles outside of match |
Concept → Why → How → Code
Concept. A Fastfile is Ruby. It declares lanes (named workflows) that compose Apple’s CLI tools through Fastlane’s actions (Ruby wrappers).
Why. Apple’s CLIs are powerful but inconsistent — xcodebuild, iTMSTransporter, altool, notarytool, xcrun, security, codesign all have different syntax. Fastlane normalizes them and adds smart defaults.
How — install.
# Recommended: install as a gem (Ruby 3.0+)
gem install fastlane -NV
# Or via bundler in the repo
echo 'source "https://rubygems.org"' > Gemfile
echo 'gem "fastlane"' >> Gemfile
bundle install
# Initialize in your project
cd ios && fastlane init
A complete annotated Fastfile.
# fastlane/Fastfile
# Sets minimum fastlane version expected by this file
fastlane_version "2.220.0"
default_platform :ios
# Shared config — read from .env (committed: .env.default, not committed: .env.secret)
APP_IDENTIFIER = "com.acme.notes"
SCHEME = "Acme"
WORKSPACE = "Acme.xcworkspace"
API_KEY_PATH = "fastlane/AuthKey_AAAA1111BB.json"
platform :ios do
# ── Setup ────────────────────────────────────────────────
before_all do
setup_ci if is_ci # creates a temp keychain on CI runners
ensure_git_status_clean unless is_ci
end
# ── Cert / profile sync ───────────────────────────────────
desc "Sync dev certs/profiles via match"
lane :certs do
match(type: "development", app_identifier: APP_IDENTIFIER, readonly: is_ci)
match(type: "appstore", app_identifier: APP_IDENTIFIER, readonly: is_ci)
end
# ── Test ──────────────────────────────────────────────────
desc "Run unit + UI tests"
lane :test do
scan(
workspace: WORKSPACE,
scheme: SCHEME,
device: "iPhone 16 Pro",
clean: true,
code_coverage: true
)
end
# ── TestFlight beta ───────────────────────────────────────
desc "Build and upload to TestFlight"
lane :beta do
certs
increment_build_number(xcodeproj: "Acme.xcodeproj")
build_app(
workspace: WORKSPACE,
scheme: SCHEME,
export_method: "app-store",
export_options: {
provisioningProfiles: {
APP_IDENTIFIER => "match AppStore #{APP_IDENTIFIER}"
}
}
)
upload_to_testflight(
api_key_path: API_KEY_PATH,
distribute_external: true,
groups: ["Beta Wide"],
changelog: File.read("../CHANGELOG.md"),
skip_waiting_for_build_processing: false
)
commit_version_bump(message: "chore: bump build [skip ci]")
push_to_git_remote
slack(message: "📦 Beta #{lane_context[SharedValues::BUILD_NUMBER]} live on TestFlight")
end
# ── App Store release ─────────────────────────────────────
desc "Build and submit to App Store for review"
lane :release do
test
certs
increment_version_number(bump_type: "patch")
build_app(workspace: WORKSPACE, scheme: SCHEME, export_method: "app-store")
upload_to_app_store(
api_key_path: API_KEY_PATH,
force: true, # skip metadata-changed prompt
submit_for_review: true,
automatic_release: true,
phased_release: true,
submission_information: {
add_id_info_uses_idfa: false,
export_compliance_uses_encryption: false
}
)
add_git_tag(tag: "v#{lane_context[SharedValues::VERSION_NUMBER]}")
push_to_git_remote(tags: true)
slack(message: "🚀 v#{lane_context[SharedValues::VERSION_NUMBER]} submitted to App Store")
end
# ── Screenshots ───────────────────────────────────────────
desc "Generate localized screenshots"
lane :screenshots do
capture_screenshots(scheme: "AcmeUITests", devices: ["iPhone 16 Pro Max", "iPad Pro (13-inch)"])
frame_screenshots(white: true)
end
# ── Error handling ────────────────────────────────────────
error do |lane, exception|
slack(message: "❌ #{lane} failed: #{exception.message}", success: false)
end
end
Run any lane with fastlane beta or bundle exec fastlane release.
In the wild
- Most VC-backed iOS startups use Fastlane somewhere in their pipeline — it’s the default for a reason.
- Shopify open-sourced significant Fastlane plugins for their checkout SDK and uses match for hundreds of internal apps.
- MGM Resorts and major hotel apps rely on Fastlane to ship 6+ regional white-labels from one codebase by parametrizing lanes per brand.
- Many studios skip Xcode Cloud and stay on GitHub Actions + Fastlane because Fastlane gives them control over Slack notifications, error retries, and custom hooks Xcode Cloud doesn’t support.
Common misconceptions
- “Fastlane is dead because of Xcode Cloud.” Fastlane is more popular than ever — Xcode Cloud handles some pipelines but Fastlane still glues things Xcode Cloud doesn’t (Slack, Jira, multi-tenant white-labels, custom signing flows).
- “You need to know Ruby.” You need to know enough to read the DSL. The Fastfile rarely uses advanced Ruby.
- “match means committing certs to Git.” match commits encrypted certs to a private repo. Without the passphrase, the files are useless.
- “Lanes are just bash.” Lanes get retries, error hooks, shared lane_context, and structured output — none of which bash gives you cleanly.
- “Fastlane is slow.” Most slowness comes from
xcodebuildand Apple’s processing pipeline. Fastlane itself adds milliseconds.
Seasoned engineer’s take
A good Fastfile is boring. The novel parts of your business should not live in your release pipeline.
TIP. Keep
Fastfileunder 200 lines. If a lane grows beyond ~30 lines, extract it into afastlane/Pluginfileaction or a Ruby module underfastlane/actions/. Big Fastfiles become unmaintainable just like big shell scripts.
WARNING.
matchis convenient but it stores credentials in the keychain. On CI runners, the temp keychain created bysetup_cimust be cleaned up — if it’s not, secrets leak to the next build. Use ephemeral runners (GitHub Actions, Xcode Cloud) where this isn’t an issue.
The single most valuable Fastlane habit: every CI build that ships to TestFlight or App Store should git tag itself so you can trace any submitted binary back to its commit in 5 seconds. The lane above does that for release; add it to beta if your team allows tag explosion.
Interview corner
Junior — “What does Fastlane give you that Xcode doesn’t?” Reproducibility. Every Xcode menu click becomes a line in a Fastfile, version-controlled, peer-reviewable, runnable in CI. Plus prettier output and a giant ecosystem of community actions.
Mid — “You inherit a project with no CI. Write me the minimum Fastfile to ship to TestFlight.” Three lanes: certs (match readonly), test (scan), beta (build_app + upload_to_testflight). Add a before_all for setup_ci and an error hook for Slack. ~40 lines.
Senior — “How do you handle a 6-target white-label app where each target has its own bundle ID, certs, App Store Connect record, but shares 95% of the codebase?” One Fastfile, lanes parameterized by brand (read from ENV or a YAML config), separate match namespaces per bundle ID, a script that loops the build lane for each brand on release. Each brand’s metadata lives in fastlane/metadata/<brand>/. CI matrix builds them in parallel; failures are isolated per brand.
Red flag — “We have shell scripts that call xcodebuild directly because Fastlane is too magical.” That works for one engineer. It doesn’t scale, doesn’t normalize across Apple’s CLI inconsistencies, and reinvents Fastlane badly.
Lab preview
Lab 10.1 is exactly the beta lane above — built from scratch against a sample app, with a private match repo and an App Store Connect API key, ending with a real TestFlight upload.
Next: 10.6 — Xcode Cloud Full Walkthrough
10.6 — Xcode Cloud Full Walkthrough
Opening scenario
You’re a solo dev. You’ve spent two weekends fighting GitHub Actions YAML, certificate provisioning, and xcodebuild flag arcana just to get tests running on a hosted Mac. You watch a friend with an Apple Developer subscription open Xcode 16, click “Create Workflow”, point it at his GitHub repo, and have green CI running in 90 seconds. He pays $0 extra because his usage is under 25 hours/month.
Xcode Cloud is Apple’s first-party CI for iOS/macOS. It’s not always the right answer — but when it is, nothing else competes on integration depth.
Context taxonomy
| Layer | What you configure | Where |
|---|---|---|
| Workflow | Trigger + actions + post-actions | Xcode Report Navigator → Cloud, or App Store Connect |
| Trigger | Branch, PR, tag, scheduled | Workflow config |
| Environment | macOS image, Xcode version, env vars, secrets | Workflow → Environment |
| Action | Test, Build, Analyze, Archive | Workflow → Actions |
| Post-action | TestFlight distribution, notification, webhook | Workflow → Post-Actions |
| Pricing | Free 25 compute hours/month, ~$0.05/hr after | App Store Connect → Users and Access → Xcode Cloud |
Concept → Why → How → Code
Concept. Xcode Cloud is a managed CI service tightly coupled to Xcode and App Store Connect. Workflows live in App Store Connect; the build environment is Apple-managed; signing is automatic if you let it be.
Why. Apple owns the entire toolchain — they can pre-cache Xcode versions, simulator runtimes, and signing identities far better than third-party hosted runners. The setup tax goes from hours to minutes.
How — first-time setup.
- Open your project in Xcode 16+.
- Reports navigator (⌘9) → “Cloud” tab → “Get Started”.
- Connect to App Store Connect (if not already).
- Connect source repository — Apple OAuth flow with GitHub/GitLab/Bitbucket. Choose repository-scoped access, never org-wide.
- Apple analyzes your project and proposes a default workflow:
- Trigger: any branch change
- Action: Build (release config)
- Post-action: none
- Edit before saving — most defaults are too eager.
A realistic 4-workflow setup.
| Workflow name | Trigger | Actions | Post-actions |
|---|---|---|---|
PR Tests | PR to main | Test (iPhone 16, iOS 18 simulator) | GitHub status check |
Main CI | Push to main | Test + Archive | TestFlight: Internal Testing |
Release Candidate | Tag v*.*.* | Test + Archive | TestFlight: External “Beta Wide” + Slack |
Nightly | Scheduled, 02:00 UTC | Test + Analyze | Slack on failure only |
Environment variables and secrets.
# Public (visible in logs):
DEFAULT_REGION = US
SLACK_WEBHOOK_PUBLIC = (not actually public — see below)
# Secret (masked in logs, encrypted at rest):
SLACK_WEBHOOK = https://hooks.slack.com/...
ASC_API_KEY_ID = AAAA1111BB
ASC_API_ISSUER_ID = 69a6de70-...
Secrets are managed in App Store Connect → Xcode Cloud → Workflow → Environment → Secrets. They appear as $SLACK_WEBHOOK etc. in CI scripts.
Custom scripts. Xcode Cloud runs three optional shell scripts you place in ci_scripts/ at the repo root:
# ci_scripts/ci_post_clone.sh — after checkout, before build
#!/bin/sh
set -e
echo "Installing dependencies"
brew install swiftlint
# ci_scripts/ci_pre_xcodebuild.sh — just before xcodebuild runs
#!/bin/sh
agvtool new-version -all "$CI_BUILD_NUMBER"
# ci_scripts/ci_post_xcodebuild.sh — after xcodebuild succeeds
#!/bin/sh
if [ "$CI_WORKFLOW" = "Release Candidate" ]; then
curl -X POST -H 'Content-Type: application/json' \
--data "{\"text\":\"✅ Build $CI_BUILD_NUMBER ready\"}" \
"$SLACK_WEBHOOK"
fi
These scripts execute on the runner. They have access to all env vars (CI_* are auto-populated by Xcode Cloud).
Automatic code signing. Xcode Cloud manages its own signing identity — you don’t import a .p12 or use match. App Store Connect provisions a “Xcode Cloud” certificate scoped to the connected workflow. To use it, set the project’s Signing & Capabilities → Team and check “Automatically manage signing.” Locally you can still use your own cert.
In the wild
- Apple’s own sample apps (e.g., the WWDC SwiftUI Trips demo) use Xcode Cloud workflows, visible in the public-facing sample repos.
- Indie shops with one app love Xcode Cloud — Marco Arment (Overcast), Mike Rundle’s app — because the setup overhead vanishes.
- The Browser Company publicly mentioned moving Arc on iOS to Xcode Cloud for the speed of TestFlight integration.
- Larger orgs rarely fully replace GitHub Actions with Xcode Cloud — they use Xcode Cloud for archive/sign/upload and GitHub Actions for the broader release orchestration (changelogs, Jira updates, web companion deployment).
Common misconceptions
- “Xcode Cloud is free.” It’s free up to 25 compute hours/month per organization. A typical 5-engineer team blows through that in a week.
- “Xcode Cloud requires zero YAML.” Mostly — but workflow config is JSON-backed and not easily diffable in PRs. Some prefer GitHub Actions specifically because the config is text-in-repo.
- “You can use any Xcode version.” You can pick from Apple-supported versions — typically current GA, current beta, and ~3 prior majors. No custom Xcode versions.
- “Xcode Cloud manages signing — I never need a certificate again.” True for the App Store distribution path. You still need certs for local development and any non-Xcode-Cloud release path.
- “Workflows live in the repo.” They live in App Store Connect, not in the repo. Backing them up means exporting JSON from the App Store Connect API.
Seasoned engineer’s take
Xcode Cloud is the best CI for two situations:
- Solo or small team shipping a single app, wanting zero infra overhead.
- Large team’s archive/sign/upload step where you want first-party reliability for the most failure-prone part of the pipeline.
It’s the wrong choice when:
- You need cross-platform CI (Android, web, backend) and want one runner pool.
- You need budget visibility — at scale, GitHub Actions on
macos-15is often cheaper. - You want config in the repo for PR-reviewable changes.
TIP. Start with the default workflow Apple proposes, run it once, then turn off triggers you don’t want (it defaults to “every branch change” which fills your queue with stale branch builds). Pin the Xcode version explicitly — “latest” will change under you when Apple releases a new GA.
WARNING. The 25-hour free tier counts all workflows across the org. A misconfigured nightly that rebuilds for 90 minutes daily eats the budget by day 17.
Interview corner
Junior — “Where do you configure an Xcode Cloud workflow?” In Xcode’s Report Navigator → Cloud, or directly in App Store Connect → Xcode Cloud. The config lives in App Store Connect, not the repo.
Mid — “How do you customize the build without touching workflow JSON?” Put scripts in ci_scripts/ci_post_clone.sh, ci_pre_xcodebuild.sh, ci_post_xcodebuild.sh. They run automatically and inherit all CI_* environment variables Xcode Cloud injects.
Senior — “Cost-compare Xcode Cloud vs GitHub Actions macOS for a team of 8 doing 10 builds/day, 15 min each.” 8 × 10 × 15 = 1200 minutes/day = 600 hours/month. Xcode Cloud: 25 free + 575 × $0.05 ≈ $29/mo. GitHub Actions macos-15 standard: 600 × $0.08/min × 60 ≈ $2880/mo. Xcode Cloud is ~100× cheaper at this volume. But Xcode Cloud charges per hour, GHA per minute — short builds favor GHA, long archives favor Xcode Cloud.
Red flag — “We use Xcode Cloud and rebuild every commit on every branch.” That’s a fast way to burn $300/month for nothing. Filter to main + PR + tags.
Lab preview
Lab 10.2 walks you through creating two workflows (PR Tests + Main CI) end-to-end on a real project, including secrets, a Slack post-action, and a custom ci_post_clone.sh.
Next: 10.7 — GitHub Actions iOS Full Walkthrough
10.7 — GitHub Actions iOS Full Walkthrough
Opening scenario
You join a team that runs CI on Bitrise. The bill is $700/month for two iOS apps and three Android apps. Your boss asks if you can cut it. You move iOS to GitHub Actions on macos-15 runners, keep Android on the free ubuntu-latest, and the next month’s bill is $180. You also gain PR-reviewable YAML, native integration with everything in the GitHub ecosystem, and the same runners as the open-source projects you depend on.
GitHub Actions on macOS is the default professional choice when you need a CI that does more than just iOS.
Context taxonomy
| Item | Value (2026) |
|---|---|
| Default macOS runner | macos-15 (Apple Silicon, Sequoia, Xcode 16.x) |
| Larger macOS runner | macos-15-xlarge (M2, 12 cores, ~3× faster) |
| Free macOS minutes | 200/mo on personal Free, 0 for orgs — macos multiplier 10× |
| Standard macOS pricing | $0.08/min standard, $0.32/min xlarge |
| Concurrency limit | 5 concurrent macOS jobs (Free), 50 (Team), 180 (Enterprise) |
| Job timeout default | 6 hours |
| Cache storage | 10 GB per repo (LRU) |
Concept → Why → How → Code
Concept. A .github/workflows/*.yml file declares jobs that run on hosted runners. Each job is a fresh VM; you script every step (dependencies, build, test, sign, upload).
Why. Total control + tight GitHub integration + the same runner spec as millions of OSS projects (huge knowledge base).
How — a complete annotated deploy workflow.
# .github/workflows/deploy.yml
name: iOS Deploy
on:
push:
branches: [main]
tags: ['v*.*.*']
pull_request:
branches: [main]
workflow_dispatch: # manual "Run workflow" button
# Cancel superseded runs on the same ref
concurrency:
group: ios-${{ github.ref }}
cancel-in-progress: true
env:
XCODE_VERSION: "16.0"
SCHEME: "Acme"
WORKSPACE: "Acme.xcworkspace"
jobs:
test:
name: Tests
runs-on: macos-15
steps:
- uses: actions/checkout@v4
- name: Select Xcode
run: sudo xcode-select -switch /Applications/Xcode_${{ env.XCODE_VERSION }}.app
- name: Cache SPM
uses: actions/cache@v4
with:
path: |
~/Library/Developer/Xcode/DerivedData/**/SourcePackages
~/.swiftpm
key: spm-${{ runner.os }}-${{ env.XCODE_VERSION }}-${{ hashFiles('**/Package.resolved') }}
restore-keys: |
spm-${{ runner.os }}-${{ env.XCODE_VERSION }}-
- name: Resolve packages
run: xcodebuild -resolvePackageDependencies -workspace ${{ env.WORKSPACE }} -scheme ${{ env.SCHEME }}
- name: Run tests
run: |
set -o pipefail
xcodebuild test \
-workspace ${{ env.WORKSPACE }} \
-scheme ${{ env.SCHEME }} \
-destination 'platform=iOS Simulator,name=iPhone 16 Pro,OS=18.0' \
-enableCodeCoverage YES \
-resultBundlePath build/result.xcresult \
| xcpretty --report junit --output build/test-results.xml
- name: Upload test results
if: always()
uses: actions/upload-artifact@v4
with:
name: test-results
path: |
build/test-results.xml
build/result.xcresult
archive:
name: Archive & TestFlight
needs: test
if: github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v')
runs-on: macos-15
steps:
- uses: actions/checkout@v4
- name: Select Xcode
run: sudo xcode-select -switch /Applications/Xcode_${{ env.XCODE_VERSION }}.app
- name: Install fastlane
run: |
gem install fastlane -NV --no-document
- name: Decode App Store Connect API key
env:
ASC_KEY_BASE64: ${{ secrets.ASC_KEY_BASE64 }}
run: |
mkdir -p fastlane
echo "$ASC_KEY_BASE64" | base64 --decode > fastlane/AuthKey.p8
- name: Sync certs via match
env:
MATCH_PASSWORD: ${{ secrets.MATCH_PASSWORD }}
MATCH_GIT_BASIC_AUTHORIZATION: ${{ secrets.MATCH_GIT_TOKEN_B64 }}
run: fastlane certs
- name: Build & upload to TestFlight
env:
ASC_KEY_ID: ${{ secrets.ASC_KEY_ID }}
ASC_ISSUER_ID: ${{ secrets.ASC_ISSUER_ID }}
run: fastlane beta
- name: Notify Slack
if: always()
env:
SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK }}
run: |
STATUS=${{ job.status }}
EMOJI=$([ "$STATUS" = "success" ] && echo "✅" || echo "❌")
curl -X POST -H 'Content-Type: application/json' \
--data "{\"text\":\"$EMOJI iOS deploy $STATUS — ${{ github.sha }}\"}" \
"$SLACK_WEBHOOK"
Key patterns in this workflow.
- Concurrency group + cancel-in-progress: stops wasting minutes when you push three commits in a row.
- Pinned Xcode version:
macos-15ships multiple Xcode versions side-by-side;xcode-selectmakes the choice explicit. - SPM cache: cuts resolution from ~2 min to ~10 sec on hot cache.
- xcpretty + JUnit: makes test results render in GitHub UI and PR checks.
- Jobs split: tests run on every PR; archive runs only on main and tags. Saves 80% of macOS minutes.
- Base64-encoded API key as secret: a
.p8file goes in as one secret, decoded at runtime.
Manually trigger a workflow from the CLI:
gh workflow run "iOS Deploy" --ref main -f environment=production
In the wild
- Pretty much every OSS Swift package on GitHub uses GitHub Actions for CI — it’s the path of least resistance.
- The Composable Architecture by Point-Free runs a complex matrix workflow across Xcode versions and platforms.
- Cash App’s iOS team publicly uses GitHub Actions + Fastlane + match for their main release pipeline.
- Microsoft (Outlook iOS) moved much of their iOS CI to GitHub Actions after the Microsoft acquisition.
Common misconceptions
- “macOS minutes are 10× UNIX minutes.” Correct — but only for billed minutes. Free-tier minute allotments are also multiplied (200 macOS-equivalent free minutes on a personal Free plan = 20 actual macOS minutes).
- “You can run iOS builds on Linux.” No. iOS toolchain requires macOS (Xcode is macOS-only). Some build steps (test result parsing, fastlane metadata) can run on Linux to save money.
- “Caching is free.” Cache use is free; cache storage counts against a 10 GB per-repo limit. Old entries are evicted LRU; warm caches occasionally rebuild.
- “
macos-latestis fine.” It moves whenever GitHub bumps the default — your build will break the day after a major Xcode release. Always pin. - “GitHub Actions can sign builds without certificates.” No. You import certs at runtime via match or via base64-decoded
.p12.
Seasoned engineer’s take
The pattern that wins on GitHub Actions for iOS is split jobs, pinned versions, aggressive caching, and matrix where it helps.
TIP. Run
xcodebuild -showsdksandxcodebuild -showdestinationsin a one-off workflow to confirm what’s pre-installed onmacos-15. Apple bumps simulator runtimes silently; what worked last month may have moved.
WARNING. Never put a
.p12,.p8, or.mobileprovisionin a workflow log viaecho. Evenset -xcan leak. Useadd-maskor rely on GitHub’s auto-redaction of secrets — and nevercatdecoded files.
The non-obvious cost saver: use a free ubuntu-latest job to do metadata sync (fastlane deliver --skip_binary_upload) and test result analysis. Only spin up macos-15 for the actual xcodebuild steps.
Interview corner
Junior — “What does runs-on: macos-15 give you?” A fresh macOS Sequoia VM with the current Xcode pre-installed, simulator runtimes, common tools (git, brew, fastlane installable). Each job gets a clean VM.
Mid — “How do you handle code signing in a GitHub Actions iOS workflow?” Two approaches: (1) fastlane match against an encrypted private certs repo, with MATCH_PASSWORD and a Git access token as secrets; (2) base64-encode the .p12 and .mobileprovision, store as secrets, decode at runtime, and import into a temp keychain via security import.
Senior — “Design a cost-optimized CI for a 20-engineer iOS team.” Tests on PR via macos-15 only when iOS code changes (paths: filter). Linting and metadata validation on ubuntu-latest. Archive only on main push and tag. Use concurrency groups to cancel stale runs. Cache SPM, DerivedData (carefully). Move screenshots to a nightly schedule. Set monthly budget alerts in GitHub billing. Expect ~3–4k macOS minutes/month for that team size, ~$300/mo.
Red flag — “We run the full build matrix on every PR including UI screenshots across 8 devices.” You’ll spend $5k/month doing nothing useful — PR builds need a smoke subset, not the full nightly matrix.
Lab preview
Lab 10.3 builds exactly this workflow file end-to-end, including the matching Fastfile from chapter 5, plus a tag-triggered release lane that promotes to App Store.
Next: 10.8 — CI Secrets, Certs & Code Signing
10.8 — CI Secrets, Certs & Code Signing
Opening scenario
Your CI works on Monday. On Tuesday a contractor opens a PR from a fork. GitHub Actions runs your workflow — including the step that imports the .p12 into the temp keychain — and the log helpfully prints “imported 1 identity.” For 12 minutes, that contractor’s PR has effectively shipped malware-signing capability under your team’s name. Nobody catches it because secrets aren’t exposed to fork PRs by default — but your match Git token was exposed because you set it via env: at the workflow level instead of the job level.
Secret hygiene on CI is one of those topics where one mistake compromises your entire app’s signing trust. This chapter is the playbook.
Context taxonomy
| Approach | What you store | Pros | Cons |
|---|---|---|---|
Option A — fastlane match | Encrypted certs in private Git repo | Team-wide, zero-touch onboarding | Need passphrase + Git token as secrets |
| Option B — Xcode Cloud managed signing | Nothing — Apple manages | Zero secret management | Locked to Xcode Cloud |
| Option C — base64-encoded cert as secret | .p12 and .mobileprovision base64 → GitHub Secret | No external repo | Manual rotation, harder to share across projects |
| App Store Connect API key | .p8 private key | Replaces Apple ID/password for uploads | Must be carefully scoped |
Concept → Why → How → Code
Concept. Three secrets matter for an iOS release pipeline:
- Code signing identity (private key + certificate, usually a
.p12). - Provisioning profile (
.mobileprovision). - App Store Connect API key (
.p8+ Key ID + Issuer ID) — used to upload, manage metadata, fetch TestFlight info.
Why. Apple removed username/password support for altool and xcrun notarytool in 2024. The App Store Connect API key is now the only sustainable upload credential.
Option A — fastlane match
Setup the certs repo (one-time, on a trusted dev machine).
# 1. Create a private GitHub repo: github.com/acme/ios-certs (empty, no README)
# 2. From your iOS project:
cd ios
fastlane match init
# storage_mode: git
# git_url: git@github.com:acme/ios-certs.git
# type: development (we'll generate appstore separately)
# 3. Generate certs (writes encrypted artifacts to the repo)
fastlane match development --app_identifier com.acme.notes
fastlane match appstore --app_identifier com.acme.notes
# 4. Set a strong passphrase when prompted; record it in 1Password as MATCH_PASSWORD
Use in CI (GitHub Actions).
- name: Generate temp Git token for match
id: match-token
uses: actions/github-script@v7
with:
script: |
const token = await core.getIDToken();
// ...or use a fine-grained PAT scoped to read:repo on certs repo only
- name: Sync signing
env:
MATCH_PASSWORD: ${{ secrets.MATCH_PASSWORD }}
MATCH_GIT_BASIC_AUTHORIZATION: ${{ secrets.MATCH_GIT_TOKEN_B64 }}
run: fastlane match appstore --readonly
MATCH_GIT_TOKEN_B64 is echo -n "x-access-token:$PAT" | base64 where $PAT is a fine-grained PAT scoped to Contents:Read on just the certs repo. Never use a classic token with org-wide scope.
Option B — Xcode Cloud managed signing
Nothing to do. App Store Connect provisions an Xcode Cloud signing identity scoped to the workflow. You only manage workflow-level secrets (Slack webhooks, env vars).
Option C — base64-encoded certs as GitHub secrets
# Convert .p12 to base64 on your machine
base64 -i Certificates.p12 -o Certificates.p12.b64
# Copy contents into GitHub repo → Settings → Secrets → CERT_P12_BASE64
# Provisioning profile
base64 -i AppStore.mobileprovision -o AppStore.mobileprovision.b64
# → MOBILE_PROVISION_BASE64
Workflow step that imports them safely.
- name: Import signing identity
env:
CERT_P12_BASE64: ${{ secrets.CERT_P12_BASE64 }}
CERT_P12_PASSWORD: ${{ secrets.CERT_P12_PASSWORD }}
MOBILE_PROVISION_BASE64: ${{ secrets.MOBILE_PROVISION_BASE64 }}
run: |
set -euo pipefail
KEYCHAIN_PATH="$RUNNER_TEMP/build.keychain"
KEYCHAIN_PASSWORD=$(openssl rand -base64 24)
security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"
security set-keychain-settings -lut 21600 "$KEYCHAIN_PATH"
security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"
CERT_FILE="$RUNNER_TEMP/cert.p12"
echo "$CERT_P12_BASE64" | base64 --decode > "$CERT_FILE"
security import "$CERT_FILE" -k "$KEYCHAIN_PATH" -P "$CERT_P12_PASSWORD" \
-T /usr/bin/codesign -T /usr/bin/security
security set-key-partition-list -S apple-tool:,apple: -s -k "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"
security list-keychains -d user -s "$KEYCHAIN_PATH" $(security list-keychains -d user | tr -d '"')
PROFILE_DIR="$HOME/Library/MobileDevice/Provisioning Profiles"
mkdir -p "$PROFILE_DIR"
echo "$MOBILE_PROVISION_BASE64" | base64 --decode > "$PROFILE_DIR/AppStore.mobileprovision"
# Cleanup file copy of cert
rm "$CERT_FILE"
Cleanup step (always run).
- name: Cleanup keychain
if: always()
run: security delete-keychain "$RUNNER_TEMP/build.keychain" || true
App Store Connect API key setup
- App Store Connect → Users and Access → Keys → “+ Generate API Key”.
- Name: “CI Upload”. Access: App Manager (not Admin).
- Download the
.p8immediately — it can only be downloaded once. - Record the Key ID (shown in the table) and Issuer ID (above the table).
- Base64 the
.p8and add to secrets:ASC_KEY_BASE64=base64 -i AuthKey_AAAA1111BB.p8ASC_KEY_ID=AAAA1111BBASC_ISSUER_ID=69a6de70-...
Use in Fastlane.
# Fastfile
def asc_api_key
app_store_connect_api_key(
key_id: ENV["ASC_KEY_ID"],
issuer_id: ENV["ASC_ISSUER_ID"],
key_content: ENV["ASC_KEY_BASE64"], # base64-encoded .p8
is_key_content_base64: true,
in_house: false
)
end
lane :beta do
build_app(scheme: "Acme", export_method: "app-store")
upload_to_testflight(api_key: asc_api_key)
end
In the wild
- Most teams use Option A (
match) because onboarding a new engineer isfastlane match developmentand done. - Single-app teams sometimes prefer Option C — fewer moving parts, no certs repo to maintain.
- Apple’s own developer relations push Option B (Xcode Cloud) for the simplicity.
- Security-paranoid teams (banking, healthcare) often add a hardware security module (HSM) step where the actual
.p12private key never leaves a YubiKey or AWS CloudHSM — signing happens via a remote signing service.
Common misconceptions
- “GitHub Secrets are encrypted, so it’s fine.” They are — at rest. Once decrypted into a step’s env, they can leak via
echo, log forwarding, third-party actions, or any tool that prints env vars. - “
fastlane matchstores plaintext certs.” It encrypts everything withMATCH_PASSWORDusing AES-256 before committing. Without the password, the repo is opaque. - “App Store Connect API keys can do anything.” They’re scoped — App Manager keys can’t manage Users & Access; Developer-scoped keys can’t upload. Pick the minimum role.
- “Forks of my repo can read my secrets.” Default: no. Secrets are not exposed to workflows triggered by
pull_requestfrom forks. But they are exposed onpull_request_target— which is exactly why that trigger is dangerous. - “Rotating certs is rare.” They expire annually. Build a calendar reminder + a rotation runbook now.
Seasoned engineer’s take
Three rules that prevent 95% of CI signing disasters:
TIP. Use Option A (
match) for any team of 2+ engineers shipping a single iOS app. The cost of setup (1 hour) repays itself the first time a new engineer joins or a cert expires.
WARNING. Never set sensitive env vars at the workflow level when only one job needs them. Job-scoped secrets are visible only to that job; workflow-scoped secrets are visible to every step in every job, including any third-party action.
The rotation runbook is non-negotiable: certs expire annually; the App Store Connect API keys should be rotated every 6–12 months; MATCH_PASSWORD should be rotated when anyone with access leaves the team. Document the rotation procedure in your repo’s RUNBOOK.md and dry-run it once a year.
Interview corner
Junior — “Where do code signing certificates live on a GitHub Actions runner?” The runner has no certs by default. You import them at runtime from a GitHub Secret (base64-encoded .p12) into a temporary keychain, then delete the keychain in a cleanup step.
Mid — “Walk me through fastlane match.” match keeps your team’s certs and profiles in a private Git repo, encrypted with MATCH_PASSWORD. On CI, you provide MATCH_PASSWORD and a read-only Git token. fastlane match appstore --readonly checks out the repo, decrypts, and installs into a temp keychain. Onboarding is fastlane match development for new devs.
Senior — “You discover a former engineer leaked the certs repo URL but not the passphrase. What’s your response?” (1) Rotate MATCH_PASSWORD — fastlane match change_password. (2) Re-issue all distribution certs (Apple portal — revokes old ones). (3) Regenerate all provisioning profiles. (4) Force-push the certs repo to invalidate any clones. (5) Rotate the GitHub PAT and the App Store Connect API key as a precaution. (6) Audit recent CI runs for unauthorized triggers.
Red flag — “We share the App Store Connect API key via Slack and everyone has it on their laptops.” That’s a credentials sprawl that scales the blast radius linearly with team size. The key should live only in CI secrets and a single 1Password vault.
Lab preview
Lab 10.3 sets up Option A (match) plus an App Store Connect API key, runs through the full encrypt → CI → decrypt → sign → upload cycle.
Next: 10.9 — Cloud Mac Environments & Cost
10.9 — Cloud Mac Environments & Cost
Opening scenario
Your team’s lead engineer has been complaining about her 2019 Intel MacBook Pro for two years. The CFO finally asks, “How much for new Macs?” You respond: “What if we never upgrade local Macs again?” — you spec a $1500 M4 Mac mini for every engineer (basic productivity), do all heavy lifting in the cloud, and skip the $4000+ M4 Max upgrade cycle entirely. Total infra cost: $200/mo for a Hetzner Mac that builds your app 3× faster than her laptop ever did.
The 2026 cost of cloud Macs has flipped the math on iOS infrastructure. Knowing the options pays for itself in one fiscal quarter.
Context taxonomy
| Provider | 2026 pricing | Best for | Caveats |
|---|---|---|---|
| Xcode Cloud | 25 hrs free, ~$0.05/hr after | Solo devs, small teams, archive-only step | Per-hour billing, Apple-managed |
GitHub Actions macos-15 | $0.08/min standard, $0.32/min xlarge | Multi-platform teams already on GitHub | macOS = 10× multiplier on free minutes |
GitHub Actions macos-15-xlarge | $0.32/min (~3× faster than standard) | Long archive steps where total $ < standard | Apple Silicon M2, 12 cores |
| CircleCI macOS Resource Class | m2pro.medium ~$0.10/min, m4pro.large ~$0.15/min | Teams already on CircleCI | Tightly integrated with their orb ecosystem |
| Bitrise | ~$60–$200/concurrency seat/mo | Plug-and-play iOS focus | Per-seat, not per-minute |
AWS EC2 Mac (mac2.metal, mac2-m2.metal) | $1.08/hr — 24-hour minimum | High-volume, bring-your-own-orchestration | The 24h minimum is brutal for sporadic jobs |
| MacStadium | Dedicated M2 Pro from ~$99/mo | Always-on private build/sign servers | Manual provisioning, monthly commit |
| Hetzner Mac | M2 Pro ~€79/mo, M4 ~€129/mo (limited availability) | Best $/perf in 2026, EU-hosted | Stock-limited; signup waitlist often |
| MacinCloud | from $24.99/mo (shared) up to ~$249/mo (dedicated M2) | Remote desktop access for occasional builds | Performance varies; not great for CI |
| Anka / Tart (orchestrators) | Free (OSS) on your own Mac hardware | Running ephemeral VMs on M-series Macs | You own and host the Mac hardware |
Concept → Why → How → Code
Concept. Cloud Mac environments fall into three buckets:
- Per-minute hosted CI (Xcode Cloud, GitHub Actions, CircleCI, Bitrise) — ephemeral, pay-per-use, fastest setup.
- Dedicated cloud Mac (MacStadium, Hetzner, AWS EC2 Mac) — your machine, full SSH, monthly cost.
- Self-hosted on Mac mini (Anka, Tart, GitHub self-hosted runner) — capex over opex.
Why. Apple Silicon transformed economics: the per-second compute on an M2 Pro is now cheap enough that what used to need a colocation rack runs on a single $1300 Mac mini in a closet. Per-minute pricing reflects this — but the 10× macOS multiplier on GitHub Actions still bites at scale.
Decision framework by team size
| Team / volume | Recommendation |
|---|---|
| Solo dev, < 1 release/week | Xcode Cloud (likely free tier) |
| 2–5 engineers, weekly releases | GitHub Actions on macos-15 + Fastlane |
| 5–20 engineers, daily releases | GitHub Actions + dedicated Hetzner/MacStadium for nightly + heavy snapshot runs |
| 20+ engineers, multi-app | Self-host Mac mini fleet with Tart/Anka, optionally augment with macos-15-xlarge for burst |
| Burst-heavy CI (200+ builds/day) | EC2 Mac mac2.metal only if you can keep machines hot 24h+; otherwise GHA + GHA xlarge |
Cost worked example: 10-engineer team, ~80 builds/day, 15 min average
80 builds/day × 30 days × 15 min = 36,000 min/mo
GitHub Actions standard macos-15:
36,000 × $0.08 = $2,880/mo
GitHub Actions xlarge (3× faster → 5 min builds):
36,000 / 3 × $0.32 = $3,840/mo ← faster but more expensive
Xcode Cloud (hourly):
36,000 / 60 = 600 hr/mo
25 free + 575 × $0.05 = $28.75/mo ← if you can move 100% there
Self-hosted on 4× Mac mini M2 Pro:
Capex: $5,200 one-time (amortize 36 mo = $145/mo)
Power+internet+colocation: ~$60/mo
Maintenance time: ~4 hr/mo (~$200 at $50/hr)
Total: ~$405/mo ← if you can run the ops
Self-hosting with Tart (OSS VM orchestrator for Apple Silicon)
# On a Mac mini M2 Pro
brew install cirruslabs/cli/tart
# Pull a pre-baked macOS Sequoia + Xcode 16 image
tart pull ghcr.io/cirruslabs/macos-sequoia-xcode:latest
# Clone & run an ephemeral VM
tart clone macos-sequoia-xcode:latest ci-vm
tart run ci-vm --no-graphics &
# Register as a GitHub Actions self-hosted runner inside the VM
tart ip ci-vm # → 192.168.64.x
ssh admin@192.168.64.x # password: admin
# Inside VM:
./config.sh --url https://github.com/acme/ios --token ABC...
./run.sh
Each VM is ephemeral; on job completion, the orchestrator destroys it and clones a fresh one. This is how Cirrus Labs runs FreeBSD/macOS CI for thousands of OSS projects.
Hetzner Mac quick spin-up
- Sign in at hetzner.com → Cloud → Mac mini section.
- Choose M2 Pro 16/512 — ~€79/mo, hourly available.
- Wait ~15 min for provisioning, receive SSH credentials.
- Install Xcode (~30 min via
xcodes install 16.0). - Register as self-hosted runner or use directly via SSH for nightly archives.
In the wild
- Cash App runs a hybrid: GitHub Actions for PR builds, dedicated MacStadium fleet for releases.
- Telegram historically used a small farm of M1/M2 Mac minis for iOS CI to avoid per-minute charges.
- Linear publicly mentioned moving heavy iOS CI off GitHub Actions to a self-hosted fleet to cut costs ~70%.
- OSS projects (Vapor, swift-snapshot-testing) lean on Cirrus Labs / GitHub Actions free tiers because their volume fits.
Common misconceptions
- “Self-hosting is always cheaper.” It’s cheaper at scale. For sporadic builds, ephemeral hosted CI is cheaper and easier.
- “AWS EC2 Mac is the AWS price you expect.” No — the 24-hour minimum charge makes it 30× more expensive than hourly hosted Macs for short jobs.
- “
macos-15-xlargeis always worth the 4× cost for 3× speed.” Only when wall-clock matters more than dollars (release-day deploys, blocking PR checks). For nightly jobs, standard is cheaper. - “Cloud Macs are slower than my MacBook Pro.” M2 Pro / M4 cloud instances often beat a laptop on sustained loads because they don’t thermal-throttle.
- “You can run an iOS build in a Linux container.” No. Xcode is macOS-only; Apple Silicon virtualization on macOS hosts is the only legitimate path.
Seasoned engineer’s take
The “never upgrade your local Mac again” strategy works in 2026 because:
- Apple Silicon cloud Macs are powerful enough that your laptop never has to compile a clean archive.
- Remote build via xcodebuild over SSH is a 30-second setup with a wrapper script.
- Xcode Cloud + GitHub Actions cover 95% of CI needs without owning hardware.
TIP. Even if you keep buying new MacBooks for your team, route all archives, snapshot generation, and TestFlight uploads through CI. Engineer laptops should compile incremental, ship nothing. This alone deletes the most painful class of “works on my machine” release bugs.
WARNING. EC2 Mac’s 24-hour minimum charge is a footgun. If your CI provisions one for a 10-minute job, you’ve spent $25.92 on that build. Use Anka/Tart on dedicated hardware or a per-minute provider instead.
The strategic move: lock down the matrix early. Pick one per-minute hosted CI (likely GitHub Actions) and one fallback (Xcode Cloud), and forbid PRs that introduce a third. Each provider you support is a recurring tax in tooling, secrets, runbooks, and onboarding.
Interview corner
Junior — “What CI options exist for iOS builds?” Apple’s Xcode Cloud, GitHub Actions on macos-15, CircleCI macOS, Bitrise, GitLab CI macOS runners, plus dedicated cloud Macs (MacStadium, Hetzner, AWS EC2 Mac) and self-hosted on owned Mac hardware.
Mid — “Your CI bill is $4k/mo on GitHub Actions macOS. How do you cut it in half?” Audit minute usage; move PR tests to a lighter device matrix; move nightly snapshot runs to a dedicated self-hosted Hetzner Mac; cache aggressively; cancel stale runs via concurrency groups; split jobs so only macOS-needed steps run on macOS.
Senior — “Design CI infrastructure for a 30-engineer iOS+macOS team that ships 5 apps.” Two layers: (1) hot path on GitHub Actions for PR builds + small archives, (2) a self-hosted Tart cluster on 6× Mac mini M2 Pro for nightly snapshots, big archives, and on-call hot-fixes. App Store Connect API keys per-app, match repos per-app, RBAC restricting who can trigger production lanes. Monitoring via Datadog on runner queue depth and cost-per-build. Quarterly review of minute usage vs self-hosted utilization to rebalance.
Red flag — “We provision a fresh EC2 Mac for every CI build.” That’s $25.92/build minimum on the 24-hour cycle. Either move to a per-minute provider or run a long-lived Anka/Tart cluster on the EC2 Mac.
Lab preview
Lab 10.4 closes the loop: a fully zero-touch pipeline running on GitHub Actions standard macos-15, cost-optimized via cache + concurrency + smart job splits.
Next: 10.10 — Zero-Touch Automated Deployment
10.10 — Zero-Touch Automated Deployment
Opening scenario
You’re on vacation. Your phone lights up: a customer found a critical typo in the checkout flow. Your colleague pushes a one-character fix, opens a PR, your bot approves it (lint passes, tests green), the merge to main triggers CI which: bumps the patch version, archives, signs, uploads to App Store Connect, fills in metadata, submits for review, and pings Slack. You glance at your watch, see “✅ v2.4.7 submitted to App Store”, and go back to the beach.
This is what zero-touch deployment looks like in 2026. Every Xcode menu click that ships a binary is a place where the wrong person, wrong day, wrong action breaks production. Removing them is engineering work.
Context taxonomy
| Stage | Manual version | Automated version | Tool |
|---|---|---|---|
| Version bump | Edit MARKETING_VERSION in Xcode | agvtool new-marketing-version 1.2.3 | agvtool / PlistBuddy |
| Build number bump | Edit CURRENT_PROJECT_VERSION | agvtool new-version -all 42 | agvtool |
| Archive | Xcode → Product → Archive | xcodebuild archive | xcodebuild |
| Export IPA | Organizer → Distribute App | xcodebuild -exportArchive | xcodebuild |
| Upload | Organizer → Upload | xcrun altool (legacy) / xcrun notarytool, Transporter, pilot | notarytool / Transporter / Fastlane |
| Metadata update | App Store Connect UI | App Store Connect REST API | curl / fastlane deliver |
| Submit for review | App Store Connect → Submit | REST API POST /appStoreVersionSubmissions | REST API / Fastlane |
| Release | App Store Connect → Release | API or automatic_release: true | Fastlane |
| Notify | Slack message manually | Webhook in CI | curl |
Concept → Why → How → Code
Concept. Every artifact between commit and “Live in App Store” is producible by a CLI tool. Tie them together in a pipeline.
Why. Every manual step adds latency, requires a person, and introduces variance. Zero-touch deployment turns shipping from a weekly ritual into a commodity.
The full CLI toolchain
# Version manipulation
agvtool new-marketing-version 2.5.0
agvtool new-version -all 142
# Or via PlistBuddy for project formats that resist agvtool
/usr/libexec/PlistBuddy -c "Set :CFBundleShortVersionString 2.5.0" Acme/Info.plist
# Archive
xcodebuild archive \
-workspace Acme.xcworkspace \
-scheme Acme \
-configuration Release \
-archivePath build/Acme.xcarchive \
-allowProvisioningUpdates
# Export IPA
cat > build/ExportOptions.plist <<'EOF'
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0"><dict>
<key>method</key><string>app-store</string>
<key>uploadBitcode</key><false/>
<key>uploadSymbols</key><true/>
<key>signingStyle</key><string>manual</string>
<key>provisioningProfiles</key><dict>
<key>com.acme.notes</key><string>match AppStore com.acme.notes</string>
</dict></dict></plist>
EOF
xcodebuild -exportArchive \
-archivePath build/Acme.xcarchive \
-exportPath build/ \
-exportOptionsPlist build/ExportOptions.plist
# Notarize a Mac app
xcrun notarytool submit build/AcmeMac.dmg \
--key fastlane/AuthKey.p8 \
--key-id "$ASC_KEY_ID" \
--issuer "$ASC_ISSUER_ID" \
--wait
# Upload an iOS IPA via Transporter
xcrun iTMSTransporter -m upload \
-assetFile build/Acme.ipa \
-apiKey "$ASC_KEY_ID" \
-apiIssuer "$ASC_ISSUER_ID"
App Store Connect REST API — change pricing without UI
# Generate ASC JWT (10-min expiry, ES256)
TOKEN=$(asc-jwt --key-id "$ASC_KEY_ID" --issuer-id "$ASC_ISSUER_ID" --key "$(cat AuthKey.p8)")
APP_ID=1234567890
# Read current US price point for tier 8
curl -s -H "Authorization: Bearer $TOKEN" \
"https://api.appstoreconnect.apple.com/v1/appPricePoints?filter[priceTier]=8&filter[territory]=USA" \
| jq '.data[0].attributes.customerPrice'
# Schedule a new price tier effective immediately, USD territory
curl -X POST \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
https://api.appstoreconnect.apple.com/v2/appPriceSchedules \
-d @<(cat <<EOF
{
"data": {
"type": "appPriceSchedules",
"relationships": {
"app": { "data": { "type": "apps", "id": "$APP_ID" } },
"manualPrices": { "data": [{ "type": "appPrices", "id": "manual-price-id" }] },
"baseTerritory":{ "data": { "type": "territories", "id": "USA" } }
}
},
"included": [
{
"type": "appPrices",
"id": "manual-price-id",
"attributes": { "startDate": null },
"relationships": {
"appPricePoint": { "data": { "type": "appPricePoints", "id": "<tier-point-id>" } },
"territory": { "data": { "type": "territories", "id": "USA" } }
}
}
]
}
EOF
)
Complete zero-touch Fastfile
# fastlane/Fastfile — git push tag v*.*.* → App Store submission
default_platform :ios
platform :ios do
desc "Tag-triggered release: v*.*.* → App Store"
lane :tag_release do
# 1. Validate we're on a tag
UI.user_error!("Not on a tag commit") unless ENV["GITHUB_REF"]&.start_with?("refs/tags/v")
version = ENV["GITHUB_REF"].sub("refs/tags/v", "")
# 2. Set marketing version from tag
increment_version_number(version_number: version)
# 3. Build number = CI run number for uniqueness + traceability
increment_build_number(build_number: ENV["GITHUB_RUN_NUMBER"])
# 4. Sign + archive
setup_ci
match(type: "appstore", readonly: true)
build_app(
scheme: "Acme",
export_method: "app-store",
output_directory: "build",
output_name: "Acme.ipa"
)
# 5. Upload + submit + release automatically
upload_to_app_store(
api_key_path: "fastlane/AuthKey.json",
ipa: "build/Acme.ipa",
skip_screenshots: true, # metadata only
skip_metadata: false,
force: true,
submit_for_review: true,
automatic_release: true,
phased_release: true,
release_notes: read_release_notes(version),
submission_information: {
add_id_info_uses_idfa: false,
export_compliance_uses_encryption: false,
export_compliance_encryption_updated: false
}
)
# 6. Notify
slack(message: "🚀 v#{version} (build #{ENV['GITHUB_RUN_NUMBER']}) submitted to App Store")
end
def read_release_notes(version)
notes = {}
Dir.glob("metadata/*/release_notes.txt").each do |path|
locale = path.split("/")[-2]
notes[locale] = File.read(path)
end
notes
end
end
GitHub Actions workflow that triggers it
# .github/workflows/release.yml
name: Release on Tag
on:
push:
tags: ['v*.*.*']
jobs:
release:
runs-on: macos-15
timeout-minutes: 60
steps:
- uses: actions/checkout@v4
- run: sudo xcode-select -switch /Applications/Xcode_16.0.app
- run: gem install fastlane -NV --no-document
- name: Decode API key
env: { ASC_KEY_BASE64: ${{ secrets.ASC_KEY_BASE64 }} }
run: |
mkdir -p fastlane
echo "$ASC_KEY_BASE64" | base64 --decode > fastlane/AuthKey.p8
- name: Release
env:
ASC_KEY_ID: ${{ secrets.ASC_KEY_ID }}
ASC_ISSUER_ID: ${{ secrets.ASC_ISSUER_ID }}
MATCH_PASSWORD: ${{ secrets.MATCH_PASSWORD }}
MATCH_GIT_BASIC_AUTHORIZATION: ${{ secrets.MATCH_GIT_TOKEN_B64 }}
SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK }}
run: fastlane tag_release
The release ritual (now a ritual of one)
git tag v2.5.0 -m "Checkout bug fix"
git push origin v2.5.0
# ... 8 minutes later, Slack notification fires; App Store review starts ...
In the wild
- Shopify runs near-zero-touch releases for their consumer apps — a tag is enough to trigger the full pipeline through to App Store submission.
- Apple’s own teams (per WWDC talks) push code to internal CI that handles signing, archive, and TestFlight without engineer interaction.
- Many crypto exchanges run zero-touch to TestFlight only, with the final “Submit for Review” gated behind a 2-of-3 multisig approval to comply with audit requirements.
- Open-source apps like Mastodon’s iOS client publish releases entirely via tag pushes, no manual App Store Connect interaction.
Common misconceptions
- “Zero-touch means risky.” It’s the opposite — every manual step is a source of human error. Automation enforces consistency, audit logs every action, and is rollback-able.
- “App Store review can be automated.” Submission can, but Apple’s review takes 1–48 hours of human (and ML) effort that no API affects.
- “
automatic_release: truereleases the moment review passes.” Yes — be intentional. For high-risk releases, setfalseand gate manual release with a Slack approval bot. - “You still need to log into App Store Connect for screenshots.” No —
fastlane deliver(nowupload_to_app_store) syncs screenshots, descriptions, keywords, age rating, everything. - “Phased release prevents review issues.” Phased release controls post-approval rollout. It doesn’t affect review timelines.
Seasoned engineer’s take
The goal isn’t to remove humans — it’s to put them at the right decision points.
| What humans should still decide | What machines should always do |
|---|---|
| Should we ship this version? | Bump version, build, sign, archive |
| What’s in the release notes? | Format, localize, upload notes |
| Approve cert/passphrase rotations | Apply the rotation |
| Authorize the actual “Release to users” toggle (sometimes) | Submit for review, upload metadata |
TIP. Build the pipeline incrementally. Start by automating just
xcodebuild archive. Then add upload. Then metadata. Then submission. Each layer should be reliable for two weeks before adding the next.
WARNING. A zero-touch pipeline that ships every
mainpush will eventually ship a regression. Always have a 24h soak in TestFlight before App Store submission — maketag_releasethe only path to production, not everymainmerge.
The transformative effect: shipping becomes uneventful. When release is a 15-second action, you ship more often, and small frequent releases have smaller blast radius than big quarterly ones. The first-order improvement is engineer time; the second-order improvement is product velocity.
Interview corner
Junior — “What’s the minimum CLI you need to ship an app?” xcodebuild archive, xcodebuild -exportArchive, then either xcrun iTMSTransporter or xcrun altool (deprecated) to upload. Plus an App Store Connect API key for auth.
Mid — “Walk me through automating a release from a Git tag.” Tag triggers CI; CI checks out, installs Fastlane, decodes API key, syncs certs via match, sets version from tag, builds + signs + archives, uploads via upload_to_app_store, submits for review, fires Slack notification. About 30 lines of Fastfile plus a small workflow YAML.
Senior — “What guardrails would you add to a zero-touch pipeline shipping to App Store on every main merge?” Mandatory 24h TestFlight bake before App Store submission (separate pipeline triggered by tag, not main); coverage + lint gates that block merge; required reviewers on main; runbook for emergency rollback (App Store Connect: “Remove from sale”); on-call rotation; metrics dashboard tracking submission → approval time; alerting on consecutive review rejections.
Red flag — “We have zero-touch but only one engineer knows the secrets.” That’s a bus factor of 1. Secrets should live in a shared password manager + CI; the runbook should let any senior engineer rotate and recover.
Lab preview
Lab 10.4 is exactly the workflow above — tag-triggered, end-to-end, with realistic guardrails — built from scratch on a sample repo.
Next: 10.11 — App Store Review Strategy
10.11 — App Store Review Strategy
Opening scenario
Submission #1: rejected. Guideline 4.0 Design — “your app appears to be a web view.” Submission #2 (after major refactor): rejected. Guideline 5.1.1 Privacy — “your privacy policy URL returns a 404.” Submission #3: rejected. Guideline 3.1.1 In-App Purchase — “subscriptions must use StoreKit, not Stripe.” Each cycle costs 1–2 days. You watch your competitor (who knew the rules) ship a similar app in two weeks while you’re on submission #4.
App Store review isn’t random. There’s a knowable rulebook, a predictable pattern of rejection reasons, and a reliable communication protocol. Knowing them means most submissions go through on attempt one.
Context taxonomy
| Review topic | Cause | Likelihood | Severity |
|---|---|---|---|
| 4.0 Design — minimum functionality | Thin app, web view wrapper | Very high | Hard reject |
| 5.1.1 Privacy — data collection | Missing/inaccurate App Privacy disclosure | High | Hard reject |
| 3.1.1 In-App Purchase | Using third-party payment for digital goods | High | Hard reject + entitlement loss |
| 2.5.1 Software Requirements | Private APIs, undocumented frameworks | Medium | Hard reject |
| 4.2 Minimum Functionality | “Just a calculator” rejection | Medium | Hard reject |
| 5.1.2 Sign in with Apple required | If you offer Google/Facebook sign-in | High | Hard reject |
| 5.3.1 Gambling/contests | Sweepstakes/loot box not registered | Medium | Hard reject |
| Metadata rejection | Description references other platforms, beta language | Very common | Soft — fix and resubmit |
| 2.1 App Completeness | Crash on first launch, broken core flow | Medium | Hard reject |
| 5.6 Developer Code of Conduct | Spam, low-quality, copy of another app | Low (but fatal) | Account termination |
Concept → Why → How → Code
Concept. Apple’s App Review Guidelines are public, frequently updated, and the reviewers’ literal scoring rubric.
Why. Apple wants a high-trust catalog. Their reviewers (~500 humans + ML pre-screening) apply the guidelines literally. Arguing principle loses; meeting the rule wins.
How — the top 10 rejection reasons in 2026 + exact preventions.
| # | Rejection | Prevention |
|---|---|---|
| 1 | Guideline 4.0 — “appears to be a web view” | Use native UI for core flows. If using WKWebView, justify it explicitly (“our editor is a CodeMirror instance we contribute back to OSS”) in the review notes |
| 2 | Guideline 5.1.1 — privacy disclosure inaccurate | Run through your code with a checklist: every SDK that hits the network, every analytics call. Map each to App Privacy nutrition label sections |
| 3 | Guideline 3.1.1 — IAP bypass | Digital content / subscriptions = StoreKit. Physical goods or real-world services = Stripe is OK. Reader apps (Netflix, Kindle) can show no signup at all |
| 4 | Guideline 2.5.1 — private API usage | Run nm on your binary against Apple’s published symbol list; any _OBJC_CLASS_$_UIKeyboard... private use will be caught |
| 5 | Guideline 5.1.2 — missing Sign in with Apple | If you offer Google/Facebook, you must also offer Sign in with Apple. Equal prominence |
| 6 | Guideline 4.2 — minimum functionality | Add depth: a single-purpose calc/clock is rejected. The phrase “we’re a utility for X” doesn’t help |
| 7 | Crashes on launch | Run full smoke tests on iPhone SE + iPhone 16 Pro + iPad mini before submitting |
| 8 | Metadata mentions Android/web | Strip beta language, version references to competitors, “currently in beta” wording |
| 9 | Demo account broken | Test the demo account the morning of submission, not the morning you created the account |
| 10 | Guideline 4.7 — HTML5 mini apps | If you bundle third-party “mini apps”, they must comply with all the rules too (looking at you, super-app pattern) |
Expedite request
App Store Connect → My Apps → [App] → App Information → Contact Information →
"Request Expedited Review"
Allowed reasons (per Apple):
• Critical bug fix affecting users (most common)
• Time-sensitive event (Olympics, election, product launch)
Apple's response: ~24h to either grant or deny. If granted, review happens in
~4–8h instead of 24–48.
Don't abuse this. Apple tracks per-account expedite frequency.
Response templates
Template 1 — Disputing a metadata rejection (we believe reviewer misread the app).
Hi App Review,
Thank you for the feedback. We'd like to clarify how the app works:
1. The Subscribe button shown in your screenshot opens our StoreKit-powered
paywall (see attached video at https://acme.com/review-2026-05/paywall.mp4).
2. The link below ("Manage on web") is a **management** link for users to
cancel/refund — not a purchase link. Per guideline 3.1.3(b), this is
permitted for multi-platform services.
Could you please re-review? Happy to provide additional details or set up a
call if helpful.
Best,
[Name]
[App Name] team
Template 2 — Acknowledging a fix and resubmitting.
Hi App Review,
Thanks for the detailed reasoning. We've addressed the issue:
• Build 47 (now uploaded) removes the third-party share-extension that
was using `_UIBackdropView`. We replaced it with `UIVisualEffectView`
for the same visual effect.
A test demo: tap "Share to Story" — observe no private API usage.
Tested on iOS 18.0, iPhone 15 Pro and iPhone SE (3rd gen).
Re-submitting for review.
Best,
[Name]
Template 3 — Appeal to App Review Board.
(Use the "Submit an Appeal" form, not Resolution Center)
Reference: Submission ID 1234567890
Original rejection: Guideline 4.3 — Spam
We believe this rejection is in error because:
1. Our app is not a duplicate. While the category (notes) is crowded, our
approach is distinctive: end-to-end encrypted, real-time CRDT sync,
markdown-first.
2. We have not submitted multiple similar apps from this account or
related accounts.
3. We've shipped 47 versions over 3 years; the App Store Connect history
shows substantive feature additions.
We respectfully request the App Review Board to re-evaluate.
Sincerely,
[Founder Name]
Account ID: 12345678
Pre-submission checklist
□ Privacy nutrition label matches code (audit every SDK)
□ Privacy policy URL returns 200, content accurate, signed today
□ Demo account credentials tested THIS MORNING
□ Review notes: explicit tour ("tap Trips, then '+'")
□ Crash-free first-launch on iPhone SE (3rd gen) and iPhone 16 Pro
□ All third-party logins paired with Sign in with Apple if applicable
□ Screenshots are current (no beta watermarks)
□ Description has no mention of Android, web, or "soon"
□ Marketing icon 1024×1024 with zero alpha
□ Build is signed with Distribution cert, not Development
□ Export compliance: ITSAppUsesNonExemptEncryption set correctly
□ Age rating matches actual content
□ Localized release notes for every supported language
□ Beta/staging URLs replaced with production URLs in code
In the wild
- Hey by Basecamp (2020) famously fought App Store review over the IAP requirement — they eventually settled by offering a free tier. Public dispute, rare strategy.
- Epic Games (Fortnite, 2020–2024) built an entire antitrust case after deliberate IAP-bypass rejection. Got partial relief in 2024 US court ruling allowing external purchase links.
- Wordle (after NYT acquisition) had ASO-driven naming disputes — App Store reviewers caught the “official” claim and required disclaimer text.
- Notion quietly handles app review with extreme metadata polish + an enterprise-grade demo account. Rejection rate is near zero.
Common misconceptions
- “App review is random.” It’s pattern-matched against the guidelines. Same submission, same outcome 95% of the time.
- “You can argue your way out of any rejection.” You can argue out of metadata rejections and reviewer misunderstandings. You cannot argue out of policy violations (IAP, private API).
- “Expedited review skips the queue forever.” It only applies to the current submission. Future submissions go back to normal queue.
- “Appeals take months.” First response from App Review Board is usually 5–10 business days. Often shorter.
- “Apple bans you for one rejection.” Account termination requires repeated, intentional violations or severe single violations (fraud, malware).
Seasoned engineer’s take
App review is a customer of your release pipeline. Treat it like a partner integration.
TIP. Maintain a “review notes” document in your repo (
docs/review-notes.md) that you copy-paste into the App Store Connect notes field every submission. Include: demo account, 3-step tour, justification for any unusual design (web views, custom share extensions, etc.). Update it when you ship new features.
WARNING. Never argue tone with a reviewer. Stay professional even if the rejection feels wrong. The reviewer is one person; the next one might be more lenient. Burned bridges are forever.
The strategic insight: rejections cluster around new features. A submission that adds nothing new (bug-fix release) rarely gets rejected. A submission that adds payments, account flows, or share extensions has ~30% rejection probability on first pass. Plan release cadence accordingly — don’t bundle big risky features with hard launch deadlines.
Interview corner
Junior — “What’s the most common App Store rejection reason?” In 2026, the top three are: Privacy nutrition label inaccuracy, IAP bypass for digital goods, and metadata issues (description, demo account broken).
Mid — “Your app was rejected for using a private API. Walk me through diagnosis and fix.” Re-read the rejection — Apple usually names the symbol. Run nm -gU build/Acme.app/Acme | grep _UI (or specific framework) to confirm. Identify whether it’s first-party code or a vendored SDK. Replace with a public equivalent. Test. Resubmit with explanation in review notes.
Senior — “How do you reduce time-to-approval for a 50-engineer team shipping weekly?” (1) Pre-submission checklist enforced in CI (Privacy plist validation, demo account uptime check, metadata lint). (2) Review notes generated from a template per release. (3) Phased release on every submission so partial regressions are contained. (4) Cultivate one named contact at App Review via professional follow-ups; over time, App Review history is part of your account reputation.
Red flag — “We resubmit immediately when rejected without reading the reason.” You’ll just get the same rejection. Apple notes repeated similar submissions and slows down review.
Lab preview
Phase 10 ends with Lab 10.4 — a zero-touch pipeline that includes a pre-submission validation step verifying privacy plist, demo account, and review-notes presence before the lane is even allowed to upload.
Next: Lab 10.1 — Fastlane Pipeline
Lab 10.1 — Fastlane Pipeline
Goal: build a match + gym + pilot pipeline that uploads a fresh build to TestFlight with one command. By the end you’ll have a real, reusable Fastfile.
Time: 60–120 minutes (mostly Apple account setup if first time).
Prereqs: Paid Apple Developer account, a private GitHub repo for certs, an App Store Connect API key.
Setup
- Open or create a SwiftUI iOS app:
FastlaneLabwith bundle IDcom.yourname.fastlanelab(use your reverse-domain). - In App Store Connect, create the app record (My Apps → +) before automation can talk to it.
- Install fastlane:
When prompted: “Manual setup” (option 4) — we’ll write the Fastfile ourselves.gem install fastlane -NV --no-document cd FastlaneLab fastlane init - Create a private empty GitHub repo:
yourname/fastlane-lab-certs. Generate a fine-grained PAT scoped only to that repo (Contents: Read & Write). - App Store Connect → Users and Access → Keys → “+ Generate”. Role: App Manager. Download the
.p8. Note Key ID + Issuer ID.
Build (cert sync first, then upload lane)
Step 1 — Configure match
Create fastlane/Matchfile:
git_url("https://github.com/yourname/fastlane-lab-certs")
storage_mode("git")
type("development")
app_identifier(["com.yourname.fastlanelab"])
username("you@example.com") # only used for Apple Developer Portal API
team_id("ABCDE12345") # your Apple team ID
Run once locally to seed the repo:
# Pick a strong passphrase; record in 1Password as MATCH_PASSWORD
fastlane match development
fastlane match appstore
Watch the certs repo populate with encrypted artifacts.
Step 2 — Configure the App Store Connect API key in fastlane
mkdir -p fastlane
mv ~/Downloads/AuthKey_AAAA1111BB.p8 fastlane/AuthKey.p8
# Build a JSON wrapper fastlane expects
cat > fastlane/AuthKey.json <<EOF
{
"key_id": "AAAA1111BB",
"issuer_id":"69a6de70-XXXX-XXXX-XXXX-XXXXXXXXXXXX",
"key": "$(awk '{printf "%s\\n", $0}' fastlane/AuthKey.p8)",
"duration": 1200,
"in_house": false
}
EOF
# Make sure these are .gitignored
echo "fastlane/AuthKey.p8" >> .gitignore
echo "fastlane/AuthKey.json" >> .gitignore
Step 3 — Write the Fastfile
fastlane/Fastfile:
fastlane_version "2.220.0"
default_platform :ios
APP_ID = "com.yourname.fastlanelab"
SCHEME = "FastlaneLab"
PROJECT = "FastlaneLab.xcodeproj"
API_KEY = "fastlane/AuthKey.json"
platform :ios do
before_all do
setup_ci if is_ci
end
desc "Sync development certs/profiles"
lane :dev_certs do
match(type: "development", app_identifier: APP_ID, readonly: is_ci)
end
desc "Sync App Store certs/profiles"
lane :appstore_certs do
match(type: "appstore", app_identifier: APP_ID, readonly: is_ci)
end
desc "Build and upload to TestFlight"
lane :beta do
appstore_certs
increment_build_number(xcodeproj: PROJECT)
build_app(
project: PROJECT,
scheme: SCHEME,
export_method: "app-store",
export_options: {
signingStyle: "manual",
provisioningProfiles: { APP_ID => "match AppStore #{APP_ID}" }
},
output_directory: "build",
output_name: "FastlaneLab.ipa"
)
upload_to_testflight(
api_key_path: API_KEY,
ipa: "build/FastlaneLab.ipa",
skip_waiting_for_build_processing: false,
distribute_external: false, # internal only for first run
changelog: "Initial TestFlight build via Fastlane lab"
)
UI.success("Beta #{lane_context[SharedValues::BUILD_NUMBER]} live on TestFlight!")
end
error do |lane, exception|
UI.error("Lane #{lane} failed: #{exception.message}")
end
end
Step 4 — Run it
bundle exec fastlane beta # or: fastlane beta
Watch the output:
[12:34:01] ✓ match: 1 profile installed
[12:34:11] ✓ build_app: building...
[12:36:42] ✓ build_app: archived
[12:36:50] ✓ build_app: exported FastlaneLab.ipa
[12:36:55] ↑ upload_to_testflight: uploading...
[12:39:21] ✓ upload_to_testflight: build 2 visible in TestFlight
[12:39:21] 🎉 Beta 2 live on TestFlight!
Confirm: open App Store Connect → TestFlight; your build should be in Processing → Ready to Test.
Stretch
- External group — change
distribute_external: true, addgroups: ["Beta Wide"], submit for beta review automatically. - Changelog from Git — replace the static
changelog:string withchangelog: changelog_from_git_commits(commits_count: 10). - Slack hook — install
fastlane add_plugin slack, append aslack(message: ...)call after upload, store the webhook in.env.secret. - Git auto-bump commit — add
commit_version_bump(message: "chore: build [skip ci]")andpush_to_git_remote. - CI runner — port this Fastfile to the GitHub Actions workflow from chapter 7. Same lane, just running on
macos-15.
Notes
- If
matchcomplains about “Could not create another Distribution certificate” — you’ve hit the 2-cert-per-account limit. Revoke unused ones in the portal. increment_build_numberwrites to the.xcodeproj. Commit the change or setcommit_version_bumpto do it automatically.- The first TestFlight upload always takes longer than later ones — Apple builds your symbols index. Patience.
- If TestFlight processing fails, the email from Apple usually explains why. Common: missing
ITSAppUsesNonExemptEncryptionin Info.plist (set tofalsefor most apps).
Next: Lab 10.2 — Xcode Cloud Workflow
Lab 10.2 — Xcode Cloud Workflow
Goal: stand up two Xcode Cloud workflows — one for PR tests, one for TestFlight from main — entirely without touching CI YAML.
Time: 30–60 minutes.
Prereqs: Paid Apple Developer subscription, a GitHub/GitLab/Bitbucket repo with an iOS project, Xcode 16+.
Setup
- Open an existing iOS project (or create
XCloudLabfrom a SwiftUI App template). - Push the project to a GitHub repository.
- In App Store Connect, create the app record matching the bundle ID.
- In Xcode:
View → Navigators → Reports(⌘9). Click the Cloud tab.
Build
Workflow 1 — PR Tests
- In the Cloud tab, click Get Started (or Create Workflow).
- Connect to App Store Connect (sign in if prompted).
- Connect source control:
- Choose GitHub, authenticate, install Xcode Cloud’s GitHub App scoped to this repo only.
- Apple proposes a default workflow. Edit it:
- Name:
PR Tests - Description: “Run unit + UI tests on every pull request to main”
- Repository: your repo
- Branch: leave blank
- Start Conditions: remove the auto-added “Branch Changes” → add Pull Request Changes → Source branches: any; Target branch:
main - Environment:
- macOS Version: latest (or pin “macOS 15”)
- Xcode Version: pin a specific version like “Xcode 16.0”
- Clean Build: off (faster incremental)
- Actions:
- Remove “Build” if present
- Add Test
- Destination: iOS Simulator → iPhone 16 Pro (Latest OS)
- Scheme: your app’s main scheme
- Test Plan: your default test plan
- Post-Actions: leave empty for now
- Name:
- Save. Trigger a test run by opening a PR or pushing a commit on a branch and opening a PR.
Workflow 2 — Main CI → TestFlight
- In the Cloud tab, + → Create Workflow.
- Configure:
- Name:
Main CI - Start Conditions: Branch Changes →
mainonly - Environment: same Xcode version as Workflow 1 (consistency matters)
- Actions:
- Test (same simulator destination)
- Archive: iOS, Deployment Preparation set to “TestFlight (Internal Testing Only)”
- Post-Actions:
- TestFlight Internal Testing: select your internal group(s)
- Optionally: Notify → Slack webhook (URL in Environment → Secrets)
- Name:
- Save.
Add a custom script
In your repo, create ci_scripts/ci_post_clone.sh:
#!/bin/sh
set -e
echo "📦 Xcode Cloud post-clone"
echo "Workflow: $CI_WORKFLOW"
echo "Build: $CI_BUILD_NUMBER"
echo "Branch: $CI_BRANCH"
echo "Commit: $CI_COMMIT"
# Install SwiftLint via brew (Xcode Cloud has brew pre-installed)
which swiftlint || brew install swiftlint
Make it executable, commit, push:
chmod +x ci_scripts/ci_post_clone.sh
git add ci_scripts/ci_post_clone.sh
git commit -m "ci: add post-clone script"
git push
This script runs on every workflow before xcodebuild. Confirm by viewing the build log under the Cloud tab.
Add a secret + use it in ci_post_xcodebuild.sh
- App Store Connect → Xcode Cloud → your workflow (
Main CI) → Environment → Secrets → AddSLACK_WEBHOOK = https://hooks.slack.com/services/... - Create
ci_scripts/ci_post_xcodebuild.sh:
#!/bin/sh
set -e
if [ "$CI_WORKFLOW" = "Main CI" ] && [ "$CI_XCODEBUILD_EXIT_CODE" = "0" ]; then
curl -X POST -H 'Content-Type: application/json' \
--data "{\"text\":\"✅ Main CI build $CI_BUILD_NUMBER passed — TestFlight upload in progress\"}" \
"$SLACK_WEBHOOK"
fi
chmod +x ci_scripts/ci_post_xcodebuild.sh
git add ci_scripts/ci_post_xcodebuild.sh
git commit -m "ci: add slack post-build notification"
git push
Verify
- Open a PR →
PR Testsworkflow starts in Xcode Cloud → green check appears as a GitHub status check. - Merge to
main→Main CIruns → archive uploaded to TestFlight → Slack message arrives.
Stretch
- Workflow 3 — Release Candidate: Trigger on tag
v*.*.*. Action: Archive. Post-action: TestFlight External “Beta Wide”. - Conditional skip: add
if [ "$CI_PULL_REQUEST_TARGET_BRANCH" != "main" ]; then exit 0; fiin aci_pre_xcodebuild.shto no-op for non-main PRs. - Parallel test plan: split unit and UI tests into two test plans; add two Test actions running in parallel.
- Cost watch: in App Store Connect → Xcode Cloud → Usage, observe minutes consumed; tune workflows to stay under 25h/mo.
- Migrate one job from GitHub Actions: pick the noisiest macOS job in your current GitHub Actions setup and replicate it in Xcode Cloud. Compare wall clock + $.
Notes
- Xcode Cloud workflows live in App Store Connect, not in your repo. They’re not PR-reviewable as files. If you want diff-able config, you’d need to script the App Store Connect API to write workflow definitions — only worth it at scale.
CI_*env vars are auto-populated; full list: Apple Xcode Cloud Environment Variables.- Xcode Cloud’s free tier (25 compute hours/mo) is per Apple Developer organization, not per app. A four-app team easily blows through it.
- If a workflow seems stuck “Queueing”, check Apple’s Xcode Cloud system status — Apple occasionally throttles.
Next: Lab 10.3 — Full Release Pipeline
Lab 10.3 — Full Release Pipeline
Goal: stitch GitHub Actions + Fastlane into one pipeline that runs tests on every PR, ships to TestFlight on every main push, and submits to App Store on every v*.*.* tag.
Time: 120–180 minutes.
Prereqs: Lab 10.1 complete (working Fastfile + match repo), a GitHub repo, App Store Connect API key, paid Apple Developer subscription.
Setup
- Push your
FastlaneLabrepo from Lab 10.1 to GitHub. - Convert the API key to base64 for GitHub secrets:
base64 -i fastlane/AuthKey.p8 -o /tmp/asc_key.b64 pbcopy < /tmp/asc_key.b64 - Generate a fine-grained PAT scoped to your certs repo with Contents: Read.
- Build the basic auth token for match:
echo -n "x-access-token:$YOUR_PAT" | base64
Build (3 GitHub secrets, 3 Fastlane lanes, 1 workflow)
Step 1 — Add GitHub secrets
Repo → Settings → Secrets and variables → Actions → New secret:
| Name | Value |
|---|---|
ASC_KEY_BASE64 | contents of /tmp/asc_key.b64 |
ASC_KEY_ID | e.g. AAAA1111BB |
ASC_ISSUER_ID | e.g. 69a6de70-... |
MATCH_PASSWORD | the passphrase you set in Lab 10.1 |
MATCH_GIT_TOKEN_B64 | the base64 you just produced |
SLACK_WEBHOOK | a Slack incoming webhook URL (or use webhook.site for testing) |
Step 2 — Extend the Fastfile
Replace fastlane/Fastfile with:
fastlane_version "2.220.0"
default_platform :ios
APP_ID = "com.yourname.fastlanelab"
SCHEME = "FastlaneLab"
PROJECT = "FastlaneLab.xcodeproj"
def asc_api_key
app_store_connect_api_key(
key_id: ENV["ASC_KEY_ID"],
issuer_id: ENV["ASC_ISSUER_ID"],
key_content: ENV["ASC_KEY_BASE64"],
is_key_content_base64: true,
in_house: false
)
end
platform :ios do
before_all do
setup_ci if is_ci
end
desc "Run all tests"
lane :test do
run_tests(
project: PROJECT,
scheme: SCHEME,
devices: ["iPhone 16 Pro"],
clean: true
)
end
desc "Sync App Store certs"
lane :certs do
match(type: "appstore", app_identifier: APP_ID, readonly: is_ci)
end
desc "Build and upload to TestFlight (internal)"
lane :beta do
certs
increment_build_number(
xcodeproj: PROJECT,
build_number: ENV["GITHUB_RUN_NUMBER"] || latest_testflight_build_number(api_key: asc_api_key, app_identifier: APP_ID) + 1
)
build_app(
project: PROJECT,
scheme: SCHEME,
export_method: "app-store",
export_options: {
signingStyle: "manual",
provisioningProfiles: { APP_ID => "match AppStore #{APP_ID}" }
},
output_directory: "build",
output_name: "FastlaneLab.ipa"
)
upload_to_testflight(
api_key: asc_api_key,
ipa: "build/FastlaneLab.ipa",
skip_waiting_for_build_processing: true,
distribute_external: false
)
notify("📦 Beta #{lane_context[SharedValues::BUILD_NUMBER]} uploaded to TestFlight")
end
desc "Tag-triggered App Store release"
lane :release do
UI.user_error!("Not on a tag") unless ENV["GITHUB_REF"]&.start_with?("refs/tags/v")
version = ENV["GITHUB_REF"].sub("refs/tags/v", "")
test
certs
increment_version_number(version_number: version)
increment_build_number(build_number: ENV["GITHUB_RUN_NUMBER"])
build_app(
project: PROJECT,
scheme: SCHEME,
export_method: "app-store",
export_options: {
signingStyle: "manual",
provisioningProfiles: { APP_ID => "match AppStore #{APP_ID}" }
},
output_directory: "build",
output_name: "FastlaneLab.ipa"
)
upload_to_app_store(
api_key: asc_api_key,
ipa: "build/FastlaneLab.ipa",
skip_screenshots: true,
skip_metadata: false,
force: true,
submit_for_review: true,
automatic_release: true,
phased_release: true,
submission_information: {
add_id_info_uses_idfa: false,
export_compliance_uses_encryption: false
}
)
notify("🚀 v#{version} submitted to App Store")
end
def notify(text)
return unless ENV["SLACK_WEBHOOK"]
sh "curl -s -X POST -H 'Content-Type: application/json' --data '{\"text\":\"#{text}\"}' \"$SLACK_WEBHOOK\""
end
error do |lane, exception|
notify("❌ Lane #{lane} failed: #{exception.message}")
end
end
Step 3 — Write the GitHub Actions workflow
.github/workflows/ios.yml:
name: iOS
on:
push:
branches: [main]
tags: ['v*.*.*']
pull_request:
branches: [main]
workflow_dispatch:
concurrency:
group: ios-${{ github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
env:
XCODE_VERSION: "16.0"
jobs:
test:
runs-on: macos-15
steps:
- uses: actions/checkout@v4
- run: sudo xcode-select -s /Applications/Xcode_${{ env.XCODE_VERSION }}.app
- uses: actions/cache@v4
with:
path: |
~/Library/Developer/Xcode/DerivedData/**/SourcePackages
~/.swiftpm
key: spm-${{ runner.os }}-${{ env.XCODE_VERSION }}-${{ hashFiles('**/Package.resolved') }}
- run: gem install fastlane -NV --no-document
- run: fastlane test
beta:
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
needs: test
runs-on: macos-15
steps:
- uses: actions/checkout@v4
- run: sudo xcode-select -s /Applications/Xcode_${{ env.XCODE_VERSION }}.app
- run: gem install fastlane -NV --no-document
- run: bundle install || true
- name: Run beta lane
env:
ASC_KEY_ID: ${{ secrets.ASC_KEY_ID }}
ASC_ISSUER_ID: ${{ secrets.ASC_ISSUER_ID }}
ASC_KEY_BASE64: ${{ secrets.ASC_KEY_BASE64 }}
MATCH_PASSWORD: ${{ secrets.MATCH_PASSWORD }}
MATCH_GIT_BASIC_AUTHORIZATION: ${{ secrets.MATCH_GIT_TOKEN_B64 }}
SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK }}
run: fastlane beta
release:
if: startsWith(github.ref, 'refs/tags/v')
needs: test
runs-on: macos-15
steps:
- uses: actions/checkout@v4
- run: sudo xcode-select -s /Applications/Xcode_${{ env.XCODE_VERSION }}.app
- run: gem install fastlane -NV --no-document
- name: Run release lane
env:
ASC_KEY_ID: ${{ secrets.ASC_KEY_ID }}
ASC_ISSUER_ID: ${{ secrets.ASC_ISSUER_ID }}
ASC_KEY_BASE64: ${{ secrets.ASC_KEY_BASE64 }}
MATCH_PASSWORD: ${{ secrets.MATCH_PASSWORD }}
MATCH_GIT_BASIC_AUTHORIZATION: ${{ secrets.MATCH_GIT_TOKEN_B64 }}
SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK }}
run: fastlane release
Step 4 — Test it
# 1. PR test
git checkout -b test/pr
echo "// touch" >> FastlaneLab/ContentView.swift
git commit -am "test PR pipeline"
git push -u origin test/pr
gh pr create --title "Test PR" --body "Pipeline verification"
# → GitHub Actions runs `test` job only
# 2. Beta
gh pr merge --merge
# → `test` + `beta` jobs run; check Slack + TestFlight
# 3. Release
git tag v1.0.1 -m "First automated release"
git push origin v1.0.1
# → `test` + `release` jobs run; check App Store Connect for submission
Stretch
- Phased release with metadata — set up
fastlane/metadata/en-US/release_notes.txt, etc., removeskip_metadata: true, watch metadata sync alongside the binary. - Reject “main” merges if test fails — set branch protection in GitHub:
mainrequirestestjob to pass before merge. - Approval gate before release — wrap the
releasejob in a GitHub Environment with required reviewers; the workflow pauses until an admin approves. - Auto-generate changelog — replace
release_notes.txtcontent withgit log --pretty=format:'- %s' $(git describe --tags --abbrev=0 HEAD^)..HEAD. - Cost-cut PR runs — add a
paths:filter so thetestjob only runs when*.swiftfiles change.
Notes
- The first
releaselane on a brand new app will fail because App Store Connect requires manual setup of pricing + age rating. Configure those one time in the UI, then automation takes over. - If
matchcomplains about “cert not in keychain” on CI,setup_ciwasn’t called. Confirmbefore_allruns. latest_testflight_build_numbermakes builds idempotent — even if GitHub re-runs a job, the build number stays unique without collisions.- For real apps, add a
notarizestep for Mac apps and avalidate_appstep beforeupload_to_app_storeto catch issues earlier.
Next: Lab 10.4 — Zero-Touch Pipeline
Lab 10.4 — Zero-Touch Pipeline
Goal: a fully automated pipeline where git tag v1.2.3 && git push --tags is the only human action between “code committed” and “App Store submission”. Plus guardrails: pre-submission validation, soak period, approval gate.
Time: 120–240 minutes.
Prereqs: Lab 10.3 complete and working.
Setup
Continue from Lab 10.3. We’ll add:
- A pre-submission validator
- A 24-hour TestFlight soak before App Store submission
- A required-reviewer approval gate
- An emergency rollback runbook
- Symbolicated crash report uploading
Build
Step 1 — Pre-submission validator
scripts/validate-release.sh:
#!/bin/sh
set -euo pipefail
echo "🔍 Pre-submission validation"
# 1. Privacy plist exists and is non-empty
PRIVACY_FILE="FastlaneLab/PrivacyInfo.xcprivacy"
if [ ! -s "$PRIVACY_FILE" ]; then
echo "❌ Missing or empty $PRIVACY_FILE"
exit 1
fi
echo "✓ Privacy manifest present"
# 2. Required Info.plist keys
INFO="FastlaneLab/Info.plist"
for key in CFBundleShortVersionString CFBundleVersion ITSAppUsesNonExemptEncryption; do
if ! /usr/libexec/PlistBuddy -c "Print :$key" "$INFO" >/dev/null 2>&1; then
echo "❌ Missing Info.plist key: $key"
exit 1
fi
done
echo "✓ Required Info.plist keys present"
# 3. Demo account uptime check
DEMO_LOGIN_URL="${DEMO_LOGIN_URL:-https://api.acme.com/health}"
if [ "$(curl -s -o /dev/null -w '%{http_code}' "$DEMO_LOGIN_URL")" != "200" ]; then
echo "❌ Demo API unhealthy: $DEMO_LOGIN_URL"
exit 1
fi
echo "✓ Demo API healthy"
# 4. Release notes exist for primary locale
NOTES="fastlane/metadata/en-US/release_notes.txt"
if [ ! -s "$NOTES" ]; then
echo "❌ Missing $NOTES"
exit 1
fi
echo "✓ Release notes present"
# 5. Tag matches semver
TAG="${GITHUB_REF#refs/tags/}"
if ! echo "$TAG" | grep -Eq '^v[0-9]+\.[0-9]+\.[0-9]+$'; then
echo "❌ Tag must match v<MAJOR>.<MINOR>.<PATCH>, got: $TAG"
exit 1
fi
echo "✓ Tag $TAG is valid semver"
echo "✅ All pre-submission checks passed"
chmod +x scripts/validate-release.sh
Step 2 — Three-stage pipeline
Replace .github/workflows/ios.yml:
name: iOS Zero-Touch
on:
push:
branches: [main]
tags: ['v*.*.*']
pull_request:
branches: [main]
concurrency:
group: ios-${{ github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
env:
XCODE_VERSION: "16.0"
jobs:
# ─── 1. Tests on every PR + main push ────────────────────────
test:
runs-on: macos-15
steps:
- uses: actions/checkout@v4
- run: sudo xcode-select -s /Applications/Xcode_${{ env.XCODE_VERSION }}.app
- uses: actions/cache@v4
with:
path: ~/Library/Developer/Xcode/DerivedData/**/SourcePackages
key: spm-${{ runner.os }}-${{ env.XCODE_VERSION }}-${{ hashFiles('**/Package.resolved') }}
- run: gem install fastlane -NV --no-document
- run: fastlane test
# ─── 2. Beta: any push to main goes to TestFlight Internal ───
beta:
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
needs: test
runs-on: macos-15
steps:
- uses: actions/checkout@v4
- run: sudo xcode-select -s /Applications/Xcode_${{ env.XCODE_VERSION }}.app
- run: gem install fastlane -NV --no-document
- env: &secrets
ASC_KEY_ID: ${{ secrets.ASC_KEY_ID }}
ASC_ISSUER_ID: ${{ secrets.ASC_ISSUER_ID }}
ASC_KEY_BASE64: ${{ secrets.ASC_KEY_BASE64 }}
MATCH_PASSWORD: ${{ secrets.MATCH_PASSWORD }}
MATCH_GIT_BASIC_AUTHORIZATION: ${{ secrets.MATCH_GIT_TOKEN_B64 }}
SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK }}
run: fastlane beta
# ─── 3. Validate: tag pushed but nothing ships yet ───────────
validate:
if: startsWith(github.ref, 'refs/tags/v')
runs-on: ubuntu-latest # cheap Linux for validation
steps:
- uses: actions/checkout@v4
- env:
DEMO_LOGIN_URL: ${{ secrets.DEMO_LOGIN_URL }}
run: ./scripts/validate-release.sh
# ─── 4. Approval gate: human required for App Store push ─────
approve:
if: startsWith(github.ref, 'refs/tags/v')
needs: [test, validate]
runs-on: ubuntu-latest
environment:
name: production
url: https://appstoreconnect.apple.com
steps:
- run: echo "Approved by ${{ github.actor }} — proceeding to App Store"
# ─── 5. Release to App Store ─────────────────────────────────
release:
if: startsWith(github.ref, 'refs/tags/v')
needs: approve
runs-on: macos-15
steps:
- uses: actions/checkout@v4
- run: sudo xcode-select -s /Applications/Xcode_${{ env.XCODE_VERSION }}.app
- run: gem install fastlane -NV --no-document
- env: *secrets
run: fastlane release
# Upload dSYMs for symbolication after release
- name: Upload dSYMs to Sentry
if: env.SENTRY_AUTH_TOKEN != ''
env:
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
SENTRY_ORG: acme
SENTRY_PROJECT: ios
run: |
curl -sL https://sentry.io/get-cli/ | bash
sentry-cli debug-files upload --include-sources build/
Step 3 — Configure the production environment
GitHub repo → Settings → Environments → New environment → name: production.
- Required reviewers: add yourself + a designated approver
- Wait timer: optionally 1440 (24 hours) for the soak
This means: pushing a v*.*.* tag now triggers test → validate, then pauses awaiting human approval before release runs. Click “Review deployments” in the Actions UI, approve, and submission proceeds.
Step 4 — Emergency rollback runbook
docs/runbook-rollback.md:
# Emergency rollback
## Symptom: a released version has a critical bug
### Option A — Halt new downloads (within minutes)
1. App Store Connect → My Apps → Acme → Pricing and Availability
2. Set "Availability" to "Remove from sale" → save
3. Existing users still have the app; no new downloads possible
### Option B — Revert to a previous version (within hours)
1. App Store Connect → Acme → App Store → Versions
2. Click the previous "Ready for Sale" version
3. Click "..." → "Re-submit for review"
4. Apple typically processes re-submits within 4–8h
5. Once approved, set to "Manually release" if you want to control the rollout
### Option C — Hot-fix (within ~24h)
1. Branch from the tag: `git checkout v<bad> && git checkout -b hotfix/v<bad>-1`
2. Cherry-pick the fix or write a one-liner
3. Tag `v<bad>-1` and push
4. Pipeline runs; approve in `production` env
5. Expedite request in App Store Connect with reason "critical bug affecting users"
### Communication template (Slack)
> 🚨 Production incident: <one-line description>
> Affected versions: v<x.y.z>
> Action: <Option A/B/C above>
> Owner: @<handle>
> ETA: <time>
Commit it. Practice the rollback at least once per quarter in a dry run.
Step 5 — Test the full flow
# 1. Merge to main → triggers test + beta automatically (no human action)
git checkout main && git pull
git push origin main
# 2. Wait for TestFlight Internal build to appear (~10 min)
# QA validates manually for ~24h
# 3. Tag the release after QA approval
git tag v1.0.2 -m "QA-approved release"
git push origin v1.0.2
# 4. Pipeline: test → validate (~3 min)
# Pipeline pauses at approve job
# You receive Slack notification "Approval needed"
# 5. Visit GitHub → Actions → Review deployments → Approve
# 6. release job runs → App Store submission triggered
# Slack confirms "🚀 v1.0.2 submitted to App Store"
Stretch
- Crash-free-session-rate gate — query Sentry’s API for the last 24h of TestFlight builds; fail the pipeline if rate < 99.5%.
- Auto-generated screenshots — wire
capture_screenshots+frame_screenshotsinto a nightly job; PR-bot opens a metadata PR if screenshots change. - Branch-protection lockdown — require the
testjob + 1 reviewer onmain; block force-pushes to release tags. - Beta diff in Slack — after
betasucceeds, post the git log diff from the previous TestFlight build into Slack so QA knows what changed. - Multi-region phased release — first release to NZ only (24h), then to EU (24h), then worldwide. Encode the phasing in App Store Connect API calls.
Notes
- The
environment: productiongate is the single most important guardrail. It transforms “tag = release” into “tag + human nod = release” without removing automation. - For solo dev shops, you can self-approve — the audit log still records the action.
- TestFlight Internal can hold many builds simultaneously; older ones still expire at 90 days. Add a cleanup script if needed.
- For real production apps add: Datadog/Honeycomb pipeline monitoring, Sentry dSYM upload (shown), App Store Connect API rate-limit handling, and Slack ack for failures.
Phase 10 complete. Phase 11 (Monetization & Business Strategy) explores StoreKit business patterns, subscription design, App Store pricing automation, and how companies like Spotify and Netflix navigate Apple’s payment rules.
11.1 — Monetization Models Overview
Opening scenario
You ship a meticulously crafted productivity app at $4.99 paid upfront. Week one: 200 downloads. Month one: 240 downloads. Six months in: 263 downloads total, $700 gross, $490 after Apple’s cut. Across the App Store on the same day you shipped, three free-with-IAP competitors each crossed $50k MRR. You quietly switch to free + subscription, fix nothing else, and three months later you’re at $8k MRR with 30,000 downloads.
The model is the product decision. Pick wrong and the best code in the world earns rent money.
Context taxonomy
| Model | Example apps | Conversion rate | ARPU benchmark (2026) | Best for |
|---|---|---|---|---|
| Paid upfront | Procreate, Things 3, Bear (legacy) | 100% of downloaders | $5–$50 one-time | Niche pro tools with loyal audience |
| Freemium (free + unlocks) | Notability, PDF Expert | 2–5% pay | $10–$80 one-time | Tools with clear “pro” feature line |
| Free + subscription | Calm, Duolingo, Notion | 1–4% trial → 30–60% trial→paid | $40–$120/year ARPU | Habit-forming, content-refreshed apps |
| Free + IAP (consumables) | Candy Crush, Clash of Clans | 1–3% pay; whales 80% of revenue | $30–$200/year ARPU (avg buyer $500+) | Games, especially F2P loops |
| Free + ads | TikTok, free game apps | 100% see ads | $0.50–$5 ARPU/user/year via ads | Mass-market, session-heavy apps |
| Hybrid (subscription + IAP + ads) | Spotify, YouTube, Dropbox | varies | $50–$150 ARPU/year | Mature apps with multiple user segments |
| B2B / Enterprise | Slack, Asana, Zoom | per-seat | $5–$50/seat/month | Work-context tools, sold top-down |
| Reader (no in-app payment) | Netflix, Spotify, Kindle | 0% in-app | $100+/year on web | Subscription content from desktop-first brands |
Concept → Why → How → Code
Concept. Monetization is a portfolio of structural choices: who pays, when, how often, and on what mental model. Each choice has measurable downstream effects on retention, virality, marketing CAC, and App Store algorithmic placement.
Why. App Store algorithms favor revenue per impression. A free app that converts at 2% to $60/year subscriptions out-ranks a $4.99 paid app on virtually every search query. Apple’s surface preference for subscriptions (Today tab features, App Store editorial picks) compounds this.
Decision framework by app type
Is the app valuable to use repeatedly over weeks/months?
├── No (single-use, reference, one-off utility)
│ └── Paid upfront or Freemium one-time IAP
│ Examples: Carrot Weather, Things 3, Halide
│
└── Yes (habit, social, content-refreshing)
│
├── Does new value arrive over time (content, sync, AI inference)?
│ └── Subscription
│ Examples: Bear (notes sync), Halide Mark II (active dev)
│
├── Is there a clear functional split (basic vs power)?
│ └── Freemium with one-time Pro unlock OR subscription
│ Examples: Notability, PDF Expert
│
├── Is the loop engagement-based with appetite for "more"?
│ └── Free + consumable IAP (games territory)
│ Examples: Royal Match, Genshin Impact
│
└── Is the audience mass-market and tolerant of ads?
└── Free + ads (+ optional remove-ads subscription)
Examples: Duolingo (ads or Super), most casual games
ARPU benchmarks by category (2026, public data + industry reports)
| Category | Median ARPU/year | Top quartile | Notes |
|---|---|---|---|
| Productivity (paid) | $12 | $60 | One-time skews low |
| Productivity (subscription) | $45 | $180 | Notion enterprise > $1000 |
| Health & fitness | $35 | $140 | Calm, Headspace anchor the top |
| Photo & video | $25 | $120 | Halide, Darkroom subscription model |
| Education | $40 | $200 | Duolingo Super ~$70 ARPU |
| Games (casual) | $5 | $80 | Whale-driven distribution |
| Games (hardcore) | $30 | $500+ | Genshin players average $300+ |
| News & media | $20 | $80 | NYT digital ~$200 |
| Finance | $15 | $90 | Crypto apps fee-based vs ARPU |
| Utilities | $3 | $25 | Hard to monetize broadly |
Hybrid model patterns
Spotify — Free with ads, $11.99/mo Premium subscription, Family Plan, Duo Plan. The ads tier is conversion fuel; the subscription tiers segment willingness to pay.
Dropbox — Free 2GB, $12/mo Plus, $20/mo Family, $20–24/seat/mo Business. Same product, four pricing surfaces for four buying contexts.
Duolingo — Free with ads (most users), Super Duolingo $7/mo (ad removal + hearts), Duolingo Max $30/mo (GPT-4 powered explanations). Three tiers map to three motivation levels.
In the wild
- Procreate is famously paid upfront ($12.99) and prints money — but it’s the rare exception: a creative tool with a loyal pro audience that grew via word of mouth in iPad communities. Try to replicate the model in a saturated category and you’ll match the opening scenario.
- Pokemon Go earns >$1B/year on a free + IAP model. Less than 5% of players pay; the top 1% drives most revenue.
- NYT moved from “10 free articles/month” metered paywall to a subscription-first model; revenue per subscriber doubled within 18 months.
- Apollo for Reddit (RIP) made $500k/year as a paid app + IAP for premium features. When Reddit killed third-party API access, the whole revenue base evaporated overnight — a cautionary tale about model dependency on third-party APIs.
Common misconceptions
- “Premium apps are dead.” They’re alive but tightly niched (pro tools, indie games with brand). For consumer utilities, free + IAP/subscription dominate.
- “Subscriptions are always best.” Only when ongoing value arrives. A subscription on a one-shot tool gets churned in month one, tanks your ratings, and burns App Store algorithmic favor.
- “Ads are easy money.” At small scale, ad ARPU is laughable ($0.50–$5/user/year). You need millions of MAU before ads pay rent.
- “Family Sharing kills subscription revenue.” Family Sharing on subscriptions is opt-in by you (the developer) — turn it off if it doesn’t fit the model. Apple Music chose to opt in because it accelerates network growth.
- “Going from paid to free is reversible.” It’s a one-way door. Going free destroys your established price anchor; converting back to paid usually halves install volume permanently.
Seasoned engineer’s take
The single highest-leverage choice in your product life is the monetization model. Re-pick it whenever evidence demands.
TIP. Before committing, study the App Store top-grossing list for your category. Note the model of the top 20. If none use your chosen model, you’re probably about to learn an expensive lesson.
WARNING. Don’t bolt subscriptions onto a paid app post-launch without grandfathering existing paid users into Pro forever. The 1-star review brigade for “I already paid!” can sink your rating in 48 hours.
The most under-appreciated reality: Apple’s App Store algorithm explicitly favors revenue per impression. Two equivalent apps; the one earning $0.30 per download (via subscription) will out-rank the one earning $0.15 (via paid upfront). The monetization model isn’t just about money — it’s about distribution.
Interview corner
Junior — “Name three iOS monetization models.” Paid upfront, freemium with IAP, and subscription. Plus ad-supported and B2B for completeness.
Mid — “Why are most successful 2026 apps free?” App Store search rank favors revenue per impression; free apps have ~30× more downloads which compounds via referral, ratings, and editorial. They convert a small percentage at high ARPU (subscription), out-earning paid models at scale.
Senior — “You launch a $4.99 paid productivity app, it earns nothing. How do you diagnose and recover?” Audit category top 20 — likely confirms subscription dominance. Migrate to free + subscription with generous free trial; grandfather all existing paid users into Pro permanently; refresh App Store screenshots emphasizing free; ASO keywords around “free” and category leaders. Expect 5–20× download bump in two weeks, conversion data after one full subscription cycle.
Red flag — “We picked our monetization model because it’s what we use on web.” Mobile economics are different. App Store discoverability, Apple’s cut, and impulse-buy psychology don’t match the web. Decide bottom-up from your category’s data.
Lab preview
Lab 11.1 builds a 3-tier subscription paywall with free trial — the dominant 2026 pattern — using RevenueCat to abstract StoreKit.
Next: 11.2 — App Pricing Strategy
11.2 — App Pricing Strategy
Opening scenario
You launch your subscription at $9.99/month, the round-number default. A year of data: 1.8% trial→paid conversion, $12k MRR. A founder friend looks at your dashboard and asks one question: “Why $9.99?” You shrug. She suggests: drop monthly to $7.99 (a more impulse-friendly number), add an annual tier at $49/year (50% discount anchored against monthly), and price an annual + family tier at $79/year. You ship the change in a single App Store Connect session. Three months later: 4.1% conversion, $34k MRR, identical product. The pricing was leaving 60% of revenue on the table.
Price is a product feature you ship deliberately, not a number you pick at random.
Context taxonomy
| Lever | Mechanism | Typical effect |
|---|---|---|
| Price point | Apple’s ~900 price tiers per territory | Direct revenue × volume tradeoff |
| Annual vs monthly | Discount + commitment | Annual converts higher, retains 2–4× longer |
| Free trial length | 3/7/14/30 days | Longer trials → higher conversion but more reverse-engineered abuse |
| Introductory offer | First-period discount, free or reduced | Boosts conversion 20–60% |
| Promotional offer | Returning lapsed subscribers, win-back | Recovers 10–30% of churned |
| Regional pricing | PPP-adjusted per territory | Critical for global growth |
| Currency anchoring | $9.99 vs $10 vs $12 | Psychology matters more in low price ranges |
| Tier structure | Free / Basic / Pro / Team | Anchors perceived value of mid tier |
Concept → Why → How → Code
Concept. Pricing has four interacting dimensions: point, cadence, tier structure, and geography. Optimize each with data, not vibes.
Why. A 30% price increase plus a 10% conversion drop is a 17% revenue gain. A new annual tier alongside monthly typically doubles ARPU because the people who would pay yearly were paying monthly suboptimally. Geographic mispricing — charging US prices in India — leaves entire markets economically unreachable.
Apple’s price tier system
Apple maintains ~900 price points per territory, exposed via the App Store Connect API as appPricePoints. They map roughly:
US Tier 1 = $0.99 (low impulse)
US Tier 5 = $4.99 (legacy mobile sweet spot)
US Tier 10 = $9.99 (subscription monthly default)
US Tier 50 = $49.99 (annual subscription default)
US Tier 80 = $79.99 (pro annual)
US Tier 100 = $99.99 (lifetime / family)
US Tier 999 = $999.99 (pro/enterprise one-time)
You don’t set $7.43 — you pick the nearest tier, and Apple converts to local currency via their territory mapping (which is not a live FX rate; it updates ~monthly).
Psychological pricing in 2026 mobile context
| Price | Psychological frame | Use when |
|---|---|---|
| $0.99 | Impulse, “less than a coffee” | Consumable IAP, low-stakes tip jar |
| $2.99 | Coffee equivalent, low barrier | Premium one-time unlock |
| $4.99 | Lunch-money, casual | Mid-tier IAP or low-end monthly sub |
| $7.99 | Below $10 anchor, “single-digit” | Monthly subscription standard |
| $9.99 | Round but not threatening | Default if you don’t optimize |
| $14.99 | Above $10, “deliberate” | Pro tier, signals quality |
| $49 / $59 / $79 (annual) | Anchored vs 12× monthly | Annual conversion play |
| $99/year | Round, premium | Lifetime equivalent psychology |
| $199–499 | Pro / business | Enterprise-leaning consumer pro |
The annual vs monthly multiplier
Industry rule of thumb in 2026:
Annual price = (monthly × 12) × 0.5 ← strong discount, anchors perceived savings
= (monthly × 12) × 0.7 ← moderate discount, balances cash flow
Examples:
$9.99/mo → $9.99 × 12 × 0.5 = $59.94 → list as $59.99/yr (50% off)
$7.99/mo → $7.99 × 12 × 0.5 = $47.94 → list as $49.99/yr
$14.99/mo → $14.99 × 12 × 0.6 = $107.93 → list as $99.99/yr (anchored "under $100")
Annual conversion rate is typically 20–40% of subscribers. They retain 2–4× longer than monthly. Annual moves cash flow forward, reduces churn measurement noise, and is the single most reliable LTV booster.
Regional pricing (PPP-adjusted)
Apple lets you override per-territory price. Pricing US tier 10 ($9.99) globally means:
| Territory | Same tier | PPP-adjusted | Effect |
|---|---|---|---|
| US | $9.99 | $9.99 | Baseline |
| Germany | €9.99 | €9.99 | Roughly OK (parity) |
| Brazil | R$54 | R$24 (tier 3) | Huge volume gain |
| India | ₹830 | ₹299 (tier 3) | 10× volume potential |
| Indonesia | Rp 159k | Rp 49k | Market accessible |
| Turkey | ₺320 | ₺99 | Market accessible (FX volatile) |
Use App Store Connect’s “Equalize Prices” tool to opt out of automatic FX adjustments (set a price point in each territory manually), then map each territory to a tier that respects local purchasing power.
Free trial mechanics
Trial length | Convert rate | Abuse risk | Best for
3 days | ~35–60% | Low | Habit apps (Duolingo, Calm)
7 days | ~25–40% | Medium | Productivity, content apps
14 days | ~15–30% | High | Pro tools, dev tools
30 days | ~10–20% | Very high | Enterprise, B2B
Shorter trials force decisions faster — counterintuitively, often converting higher. Why? Long trials let users churn into “I’ll decide later” purgatory; short trials force a “yes or no now” moment.
Apple gates one free trial per app per family group per StoreKit account (since iOS 14). Sybil attacks via burner Apple IDs are detectable via App Store Server API’s Transaction.appAccountToken — gate trial eligibility server-side for high-risk products.
Tier structure: the 3-tier dance
Free | Basic | Pro | Team
↑ ↑ ↑ ↑
acquisition conversion fuel primary revenue B2B expansion
Notion: Free / Plus ($10/seat) / Business ($18) / Enterprise ($custom)
1Password: Free / Personal ($3.99) / Families ($6.99) / Business ($9.95/seat)
Bear: Free / Pro ($14.99/yr or $2.99/mo) / [no business tier]
Notice anchoring: the Pro tier exists not just to be sold but to make Basic look reasonable. Without “Business at $18”, “Plus at $10” feels expensive. With it, $10 feels like the smart-buyer choice.
In the wild
- Spotify Premium is $11.99/mo in the US. Family plan $19.99 (6 accounts). Duo $14.99 (2 accounts). The Duo plan exists because data showed couples were the highest-churn family configuration; pricing it specifically retained them.
- Disney+ launched at $6.99/mo, raised twice to $13.99/mo within 4 years. Churn stayed within expectations because content kept pace.
- Adobe Creative Cloud is the textbook annual-discount play: $54.99/mo individual, $59.99 prepaid annual paid monthly, $659.88/yr prepaid annual paid upfront. The structure pushes annual commits while letting users self-select cadence.
- Carrot Weather charges $5/yr Premium Club + an $8.99 IAP for full premium features — a deliberately weird hybrid that works because the audience is loyal.
Common misconceptions
- “$9.99 is the safe default.” It’s the unoptimized default. Half your users would pay $14.99 and half would convert better at $7.99. Test.
- “Lower prices always mean more revenue.” Only in elastic categories. For pro tools the price ↑ raises revenue and signals quality.
- “Free trials always boost conversion.” They often boost trials but also attract triallers who never convert. Measure trial→paid conversion, not trial volume.
- “Apple doesn’t allow A/B price testing.” Since 2023, App Store Connect supports Custom Product Pages and Price A/B Testing via StoreKit configuration. Use it.
- “Regional pricing is too much work.” It’s a 10-minute initial setup that 5×s your install volume in PPP markets. Highest leverage hour you’ll spend.
Seasoned engineer’s take
TIP. Re-price every 6–12 months based on data. Markets shift, your value props mature, and competitors move. Static pricing is decaying pricing.
WARNING. Apple notifies subscribers of any price increase and requires explicit opt-in to stay subscribed (per 2023 policy). Plan for 5–15% involuntary churn on increases. Run them rarely and communicate value beforehand.
The mental model that beats the rest: price is a hypothesis, not a decision. You ship a price point the same way you ship a feature — instrumented, observed, iterated.
Interview corner
Junior — “How does Apple’s price tier system work?” Apple defines ~900 numeric price points per territory. You pick a tier in App Store Connect; Apple converts to local currency. The tier is a key into a per-territory price table.
Mid — “You’re launching a subscription. How do you pick the price?” Survey the top 10 competitors in your category. Land slightly below the mode for new entrants, at the mode for established. Always offer an annual tier discounted ~50% off monthly. Plan a 6-month re-evaluation.
Senior — “Walk me through a regional pricing strategy for a global launch.” Default to US prices then deliberately adjust ~20 high-volume territories: India, Brazil, Mexico, Indonesia, Turkey, Vietnam, Argentina, Egypt down 50–80%; Switzerland, Norway, Australia ±10%; EU at currency parity. Re-evaluate quarterly as FX moves. Use App Store Connect API to script bulk repricing — too tedious to maintain by hand.
Red flag — “We launched at $14.99/mo with no annual option.” You’re handing money back. Annual converts 20–40% of subs and 2–4×s retention. Day one priority.
Lab preview
Lab 11.1 puts pricing strategy into code — three tiers, annual+monthly toggles, free trial — wired through RevenueCat.
Next: 11.3 — Automated Pricing via App Store Connect API
11.3 — Automated Pricing via App Store Connect API
Opening scenario
Your marketing director Slacks you on a Sunday evening: “Black Friday is in 14 hours. Drop all our annual tiers 40% from midnight Friday to midnight Monday, restore exactly after.” Pre-automation, this is an emergency Monday-morning meeting and a 90-click marathon in App Store Connect with non-zero chance of typos in the date pickers. Post-automation: you run ./scripts/run-sale.sh black-friday-2026, get a Slack confirmation in 30 seconds, sleep peacefully, and the price reverts itself on Tuesday morning while you’re still asleep.
The App Store Connect REST API turns pricing from a manual calendar event into infrastructure.
Context taxonomy
| API endpoint | Purpose | When you use it |
|---|---|---|
GET /v1/appPricePoints | List available price tiers per territory | Discovery — find the tier ID for “$4.99 in USA” |
GET /v1/apps/{id}/appPriceSchedules | Read current/scheduled pricing | Audit, backup before changes |
POST /v2/appPriceSchedules | Create new price schedule (immediate or scheduled) | Apply a sale, schedule a launch price |
GET /v1/subscriptionPricePoints | List subscription tier price points | Discovery for subscriptions |
GET /v1/subscriptions/{id}/prices | Current subscription pricing | Audit subscriptions |
POST /v1/subscriptionPrices | Set new subscription price (with optional preservation for existing subs) | Subscription repricing |
POST /v1/promotionalOffers | Win-back / loyalty offers | Retention automation |
POST /v1/territoryAvailabilities | Add/remove territories | Geographic expansion |
Concept → Why → How → Code
Concept. The App Store Connect API is a full REST surface over pricing operations. Authenticated via ES256 JWT signed with your .p8 private key.
Why. Time-zone-correct sales windows, multi-territory promotions, A/B price experiments, and audit trails are all manual operations in the UI that scale linearly with apps + territories. Automation flattens the cost to one-time scripting work.
Authentication: ASC JWT
# scripts/asc_jwt.py
import jwt # PyJWT
import time
import sys
from pathlib import Path
KEY_ID = "AAAA1111BB"
ISSUER_ID = "69a6de70-XXXX-XXXX-XXXX-XXXXXXXXXXXX"
KEY_PATH = Path("AuthKey_AAAA1111BB.p8")
def make_token() -> str:
private_key = KEY_PATH.read_text()
headers = {"alg": "ES256", "kid": KEY_ID, "typ": "JWT"}
payload = {
"iss": ISSUER_ID,
"iat": int(time.time()),
"exp": int(time.time()) + 1200, # 20 min max
"aud": "appstoreconnect-v1",
}
return jwt.encode(payload, private_key, algorithm="ES256", headers=headers)
if __name__ == "__main__":
print(make_token())
TOKEN=$(python3 scripts/asc_jwt.py)
curl -H "Authorization: Bearer $TOKEN" https://api.appstoreconnect.apple.com/v1/apps
Discovering price points
# Find all USA price points for tier 8 (≈ $7.99)
TOKEN=$(python3 scripts/asc_jwt.py)
curl -s -H "Authorization: Bearer $TOKEN" \
"https://api.appstoreconnect.apple.com/v1/appPricePoints?filter[priceTier]=8&filter[territory]=USA" \
| jq '.data[0] | {id, customerPrice: .attributes.customerPrice, proceeds: .attributes.proceeds}'
# Example output:
# {
# "id": "eyJzIjoxNDQ3MDQ1MzcxLCJ0IjoiVVNBIiwicCI6IjgifQ", ← opaque tier point ID
# "customerPrice": "7.99",
# "proceeds": "5.59"
# }
The id is the opaque price-point identifier you pass to scheduling endpoints.
One-shot sale: schedule a price drop today, revert in 7 days
# scripts/run_sale.py
import os
import sys
import requests
from datetime import datetime, timedelta, timezone
from asc_jwt import make_token
APP_ID = "1234567890"
NORMAL_TIER = "10" # $9.99
SALE_TIER = "5" # $4.99
SALE_DAYS = 7
def price_point_id(token: str, tier: str, territory: str = "USA") -> str:
r = requests.get(
"https://api.appstoreconnect.apple.com/v1/appPricePoints",
headers={"Authorization": f"Bearer {token}"},
params={"filter[priceTier]": tier, "filter[territory]": territory},
)
r.raise_for_status()
return r.json()["data"][0]["id"]
def schedule_sale(token: str):
sale_point_id = price_point_id(token, SALE_TIER)
normal_point_id = price_point_id(token, NORMAL_TIER)
now = datetime.now(timezone.utc).replace(microsecond=0)
end_date = (now + timedelta(days=SALE_DAYS)).isoformat()
payload = {
"data": {
"type": "appPriceSchedules",
"relationships": {
"app": {"data": {"type": "apps", "id": APP_ID}},
"baseTerritory":{"data": {"type": "territories", "id": "USA"}},
"manualPrices": {"data": [
{"type": "appPrices", "id": "sale-price"},
{"type": "appPrices", "id": "restore-price"},
]},
},
},
"included": [
{
"type": "appPrices",
"id": "sale-price",
"attributes": {"startDate": None}, # null = immediate
"relationships": {
"appPricePoint": {"data": {"type": "appPricePoints", "id": sale_point_id}},
"territory": {"data": {"type": "territories", "id": "USA"}},
},
},
{
"type": "appPrices",
"id": "restore-price",
"attributes": {"startDate": end_date},
"relationships": {
"appPricePoint": {"data": {"type": "appPricePoints", "id": normal_point_id}},
"territory": {"data": {"type": "territories", "id": "USA"}},
},
},
],
}
r = requests.post(
"https://api.appstoreconnect.apple.com/v2/appPriceSchedules",
headers={
"Authorization": f"Bearer {token}",
"Content-Type": "application/json",
},
json=payload,
)
r.raise_for_status()
return r.json()
if __name__ == "__main__":
token = make_token()
result = schedule_sale(token)
print(f"Sale scheduled until {result['included'][1]['attributes']['startDate']}")
The schedule fires at Apple’s processing pace — usually within minutes. Restore is automatic on the date you specified.
Subscription repricing (preserving existing subscribers)
TOKEN=$(python3 scripts/asc_jwt.py)
SUB_ID=12345678 # subscription product ID
NEW_PRICE_POINT_ID=eyJzI... # discovered via /v1/subscriptionPricePoints
# Reduce price for new subs, preserve current subscribers at old price
curl -X POST \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
"https://api.appstoreconnect.apple.com/v1/subscriptionPrices" \
-d @- <<EOF
{
"data": {
"type": "subscriptionPrices",
"attributes": {
"startDate": null,
"preserveCurrentPrice": true
},
"relationships": {
"subscription": { "data": { "type": "subscriptions", "id": "$SUB_ID" } },
"subscriptionPricePoint":{ "data": { "type": "subscriptionPricePoints","id": "$NEW_PRICE_POINT_ID" } },
"territory": { "data": { "type": "territories", "id": "USA" } }
}
}
}
EOF
preserveCurrentPrice: true is critical for downward price moves — without it, existing subs benefit from the lower price (good for users, fine for retention, but reduces revenue from your highest LTV cohort). Set carefully.
For upward price moves, Apple enforces a 30-day notification + opt-in window. The API call schedules the change; Apple handles the user notification automatically.
Fastlane wrapper
If your team already uses Fastlane:
# fastlane/Fastfile
desc "Black Friday sale"
lane :black_friday do
api_key = app_store_connect_api_key(
key_id: ENV["ASC_KEY_ID"],
issuer_id: ENV["ASC_ISSUER_ID"],
key_content: ENV["ASC_KEY_BASE64"],
is_key_content_base64: true,
)
# Fastlane `deliver` action handles price tier setting,
# but for time-bounded sales the REST API directly is more flexible.
sh "python3 scripts/run_sale.py"
slack(message: "🛍️ Black Friday sale live — 50% off until Tuesday")
end
Scheduling via GitHub Actions cron
# .github/workflows/sale.yml
name: Scheduled Sale
on:
schedule:
- cron: '0 7 24 11 *' # Black Friday 2026 (Friday Nov 27, 07:00 UTC)
workflow_dispatch:
jobs:
start-sale:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with: { python-version: '3.12' }
- run: pip install pyjwt requests
- env:
ASC_KEY_BASE64: ${{ secrets.ASC_KEY_BASE64 }}
run: echo "$ASC_KEY_BASE64" | base64 -d > AuthKey_AAAA1111BB.p8
- run: python3 scripts/run_sale.py
In the wild
- 1Password automates pricing across 50+ territories via the API — adjusting tiers quarterly as currencies move.
- Bear, Day One, Drafts all run synchronized Black Friday / Christmas sales via scripted price schedules — same script template, different app IDs.
- Calm and Headspace use the API for win-back promotional offers triggered by lapsed subscriber webhooks (App Store Server Notifications V2).
- App Store Connect’s own UI literally calls these endpoints — your script and Apple’s UI are peers. You see the exact same scheduled changes you’d see in the web UI.
Common misconceptions
- “Pricing changes apply instantly.” They apply once Apple’s pipeline processes the request — usually 5–30 minutes. Schedule a buffer.
- “You can A/B test pricing arbitrarily.” No. App Store Connect has dedicated Custom Product Pages and Price A/B Tests for controlled experiments. Raw price flipping is not an A/B test — it’s just sequential pricing.
- “Free trial length is part of price tier.” No — it’s a separate introductory offer attribute. Different API endpoint (
/v1/subscriptionIntroductoryOffers). - “The API is rate-limited so heavily it’s unusable.” It’s ~50 requests/min, plenty for pricing operations. Bulk territory changes do require batching.
- “Apple notifies users for every price change.” Only for upward subscription changes. Downward and one-time price changes happen silently to existing users.
Seasoned engineer’s take
TIP. Wrap every pricing change script in a dry-run mode that prints what would happen without making the API call. Run dry-run in code review, real mode only after approval.
WARNING. Test sale scripts against a sandbox app first. App Store Connect has no “undo” for a botched price schedule — you have to schedule a corrective change. Tooling errors visible to users are PR incidents.
The deeper insight: pricing automation isn’t just convenience — it’s how you build pricing as a product surface. With CI behind it, pricing experiments become PRs, audit trails become git logs, and incidents are bisectable. Pricing becomes infrastructure rather than a Sunday-evening fire drill.
Interview corner
Junior — “What does the App Store Connect API let you automate?” Pricing, metadata, screenshots, build uploads, TestFlight management, beta testers, sales reports, and subscription/IAP configuration — basically everything in the App Store Connect web UI.
Mid — “How would you script a one-week sale on an in-app purchase?” Two appPrices entries in a single appPriceSchedules payload: a sale-tier price with null startDate (immediate), and the original-tier price with startDate set to one week out. Run from CI on a cron.
Senior — “Design a pricing automation system for a 4-app product line across 30 territories.” Central config: YAML mapping app→territory→base tier with seasonal overrides. CLI tool reads config, diffs against current ASC state, produces a plan. Apply step requires --confirm flag. CI job runs daily in dry-run, emits a Slack alert if drift detected. Sales scheduled via PRs to the config repo; merging triggers the apply job. Audit log = git log.
Red flag — “Our designer manually changes prices in App Store Connect for promos.” That’s a high-risk pattern. Automate, add CI guardrails, and limit manual access to App Store Connect to a small admin group.
Lab preview
Lab 11.2 is exactly this — a Python script that reads current pricing, applies a 7-day sale, and restores afterward, all via the ASC REST API.
Next: 11.4 — StoreKit 2 Business Patterns
11.4 — StoreKit 2 Business Patterns
Opening scenario
A user emails: “I paid for Pro last year. The app forgot. Help.” You dig in: the receipt validation worked at purchase, the bool got cached in UserDefaults, then a fresh install nuked it. No receipt re-check. No source of truth other than a local cache. You apologize and grant a courtesy refund. Then you find 47 similar tickets in the queue from the past year. Your real revenue is ~5% higher than your dashboard suggests, and your trust is leaking.
StoreKit 2’s receipt-less, await-native API was designed to make these bugs structurally impossible — if you adopt it correctly.
Context taxonomy
| Concept | StoreKit 1 (legacy) | StoreKit 2 (iOS 15+) |
|---|---|---|
| Receipt | Single binary blob, parsed via OpenSSL or server validation | Per-transaction JWS, verified locally with Transaction.verificationResult |
| Source of truth | Receipt file at Bundle.main.appStoreReceiptURL | Async streams: Transaction.currentEntitlements, Transaction.updates, Transaction.unfinished |
| Transaction finishing | SKPaymentQueue.default().finishTransaction() | await transaction.finish() |
| Renewal events | StoreKit 1 didn’t surface them client-side; required server-side notifications | Transaction.updates async sequence |
| Server validation | Hit verifyReceipt endpoint (now deprecated) | App Store Server API (REST, JWT) |
| Server notifications | V1, single-shot, often dropped | V2, signed JWS, retry, idempotent |
Concept → Why → How → Code
Concept. StoreKit 2 replaces the legacy “single receipt blob” model with a stream of cryptographically signed transactions. The client treats Apple’s transaction store as the source of truth; the server augments with fraud detection, cross-device entitlement, and refund handling via the App Store Server API + Server Notifications V2 webhooks.
Why. Local caches drift, get nuked, and contradict Apple. The StoreKit 2 model bakes “Apple is the source of truth” into the API surface: you can’t accidentally cache an entitlement when the canonical way to read it is await Transaction.currentEntitlements.
Pattern 1 — entitlement check (the only correct way)
// EntitlementService.swift
import StoreKit
actor EntitlementService {
static let shared = EntitlementService()
private(set) var isPro: Bool = false
private var updatesTask: Task<Void, Never>?
func start() {
// 1. Process anything missed while app was closed
Task { await refresh() }
// 2. Subscribe to ongoing updates (renewals, refunds, family share changes)
updatesTask = Task.detached { [weak self] in
for await update in Transaction.updates {
await self?.handle(update)
}
}
}
func refresh() async {
var hasPro = false
for await result in Transaction.currentEntitlements {
guard case .verified(let txn) = result else { continue }
if txn.productID.hasPrefix("com.acme.pro") && txn.revocationDate == nil {
hasPro = true
}
}
isPro = hasPro
}
private func handle(_ result: VerificationResult<Transaction>) async {
guard case .verified(let txn) = result else { return }
await refresh()
await txn.finish()
}
}
Why this is the only correct pattern:
Transaction.currentEntitlementsis the live set of active entitlements. There is no cache to invalidate.Transaction.updatesfires on renewals, refunds, expirations, family share grants — all the events that legacy receipts forced you to poll for.- Both are async sequences; SwiftUI/UIKit observe them via
Taskand re-render naturally.
Pattern 2 — purchase flow
// PaywallViewModel.swift
import StoreKit
@Observable
final class PaywallViewModel {
var products: [Product] = []
var purchasing = false
var lastError: String?
func loadProducts() async {
do {
products = try await Product.products(for: [
"com.acme.pro.monthly",
"com.acme.pro.annual",
"com.acme.pro.lifetime",
])
} catch {
lastError = error.localizedDescription
}
}
func purchase(_ product: Product) async {
purchasing = true; defer { purchasing = false }
do {
let result = try await product.purchase()
switch result {
case .success(let verification):
if case .verified(let txn) = verification {
await EntitlementService.shared.refresh()
await txn.finish()
}
case .userCancelled:
break
case .pending:
// Ask-to-Buy (parental approval), SCA — wait for Transaction.updates
lastError = "Awaiting approval"
@unknown default:
break
}
} catch {
lastError = error.localizedDescription
}
}
}
The .pending case is the production bug that bites every team: a child making a purchase under Ask-to-Buy returns .pending immediately, and the actual purchase result arrives minutes-to-hours later via Transaction.updates. Your UI must handle it.
Pattern 3 — server validation (fraud + audit)
For high-value subscriptions or fraud-sensitive flows, validate transactions server-side via the App Store Server API.
# JWT for App Store Server API (same key as ASC API)
TOKEN=$(python3 scripts/asc_jwt.py)
# Fetch a transaction by its id
curl -H "Authorization: Bearer $TOKEN" \
"https://api.storekit.itunes.apple.com/inApps/v1/transactions/$TRANSACTION_ID" \
| jq '.signedTransactionInfo'
The response is a JWS you verify server-side with Apple’s published public keys. Once verified, you trust the bundled JSON: productId, purchaseDate, expiresDate, appAccountToken, etc.
Pattern 4 — Server Notifications V2 (webhooks)
App Store Server Notifications V2 push lifecycle events to your server: SUBSCRIBED, DID_RENEW, DID_FAIL_TO_RENEW, EXPIRED, REFUND, REVOKE, GRACE_PERIOD_EXPIRED, OFFER_REDEEMED. Each notification is a signed JWS.
# Webhook handler (FastAPI-style)
from fastapi import FastAPI, Request, HTTPException
import jwt
from apple_jwks import get_apple_signing_key # your helper
app = FastAPI()
@app.post("/storekit/notifications")
async def notify(req: Request):
body = await req.json()
signed = body["signedPayload"]
# 1. Verify signature against Apple's published JWKs
key = get_apple_signing_key(signed)
payload = jwt.decode(signed, key, algorithms=["ES256"])
# 2. Unwrap inner signed payloads
notif_type = payload["notificationType"]
subtype = payload.get("subtype")
txn_info = jwt.decode(payload["data"]["signedTransactionInfo"], key, algorithms=["ES256"])
renewal_info = jwt.decode(payload["data"]["signedRenewalInfo"], key, algorithms=["ES256"])
# 3. Update your DB: subscription state machine transitions
await update_subscription(
user_token=txn_info["appAccountToken"],
event=notif_type,
subtype=subtype,
expires=txn_info["expiresDate"],
auto_renew=renewal_info["autoRenewStatus"],
)
# 4. ALWAYS return 200 — Apple retries on non-200, can cause duplicates
return {"ok": True}
Critical: idempotency. Apple retries notifications. Key your DB writes on txn_info["transactionId"] and dedupe.
Subscription state machine
┌──────────────┐
OFFER_REDEEMED ──────►│ │
SUBSCRIBED ────────►│ ACTIVE │
│ │
└──┬───────┬───┘
│ │
DID_FAIL_TO_RENEW DID_RENEW
│ │
▼ └─────► stays ACTIVE
┌─────────────────┐
│ BILLING_RETRY │
│ (grace period) │
└────┬────────┬───┘
RECOVERED │ │ GRACE_PERIOD_EXPIRED
│ │
▼ ▼
ACTIVE EXPIRED
│
│ user resubscribes
▼
ACTIVE
REFUND/REVOKE from any state ──► REVOKED
Code your entitlement check to consult this state machine, not just expiresDate > now(). A user in BILLING_RETRY should still have entitlement; a REVOKED user should lose it instantly even if their expiresDate is in the future.
In the wild
- Apollo for Reddit used StoreKit 2 from launch, citing radical simplification vs StoreKit 1’s receipt blob model.
- Bear Notes validates subscriptions via App Store Server API for cross-device entitlement (iOS, iPad, macOS).
- Things 3 ships a one-time IAP per platform — no subscription — but uses StoreKit 2’s transaction stream to handle reinstalls and family sharing without bug reports.
- Linear (web-first) uses webhooks only for its iOS in-app trial-to-paid conversions — webhook reconciles against their main billing system in Stripe.
Common misconceptions
- “I can cache
isProinUserDefaultsfor performance.” You can, but reload fromTransaction.currentEntitlementson every cold start. The cache is a render hint, not a source of truth. - “App Store Server Notifications V2 is at-most-once.” It’s at-least-once. Dedupe by transaction ID.
- “
Transaction.updatesonly fires for new purchases.” It fires for every lifecycle event: renewals, family share grants, refunds, revocations. Treat it as a stream of state-change events. - “
appAccountTokenis set automatically.” No — you opt in by settingpurchase(options:)Purchase.Option.appAccountToken(token)at purchase time. Without it, you can’t cross-link Apple transactions to your own users. - “You don’t need a server for StoreKit 2.” True for single-device apps. False for any app with login, cross-device entitlement, or fraud-sensitive billing.
Seasoned engineer’s take
The single most important business pattern: server is source of truth for entitlements, client is source of truth for purchase intent. The client tells your server “user just purchased X”; the server independently verifies via App Store Server API; the server tells the client “you have access to Y”. The client never grants itself access.
TIP. Always set
appAccountTokento your own user ID at purchase time. It survives reinstalls, sandboxes the transaction to a user, and is the only reliable join key between Apple’s transaction world and your user database.
WARNING. Refund notifications (
REFUND,REFUND_DECLINED) can arrive months after the original purchase. Your subscription state machine must handle late-arriving revocations. Apps that don’t, leak revenue and confuse users.
The mindset shift StoreKit 2 demands: stop thinking about “do I have a valid receipt?” and start thinking “what does Apple currently say about this user’s entitlements?” Same answer, very different reliability.
Interview corner
Junior — “How do you check if a user has a paid subscription in StoreKit 2?” Iterate Transaction.currentEntitlements, filter for verified transactions matching your product ID with no revocationDate. Done.
Mid — “Why subscribe to Transaction.updates?” It’s how renewals, refunds, family share grants, and ask-to-buy approvals reach you. Without it, you miss state changes between launches.
Senior — “Design a server-side subscription system for a cross-platform app (iOS + web).” Web payments via Stripe; iOS payments via StoreKit 2; both write to a unified subscriptions table keyed by user ID with provider-specific transaction IDs. iOS purchases set appAccountToken = user_id. Server validates each iOS purchase via App Store Server API at purchase time and on every Server Notification V2. Entitlement reads always go through the server’s /me/entitlements endpoint, never trust client-cached values for paywall decisions. Reconcile job runs daily to catch missed webhooks. Refunds, revocations, and dispute resolution are server-driven.
Red flag — “We just cache isPro in UserDefaults after purchase.” Cold installs lose it, refunds don’t update it, family sharing doesn’t trigger it. Symptom: customer-service backlog about “lost” subscriptions.
Lab preview
Lab 11.1 implements EntitlementService and the paywall view model exactly as outlined here, wired through RevenueCat (which abstracts the StoreKit boilerplate while exposing the same model).
Next: 11.5 — Subscriptions: Design & Retention
11.5 — Subscriptions: Design & Retention
Opening scenario
Your subscription has a 7-day free trial, $9.99/mo, no annual option. Conversion is 22% trial→paid; churn is 18% month-one, 10% month-two, 7% steady-state. A year in, you launch annual at $59 (50% off monthly) and a “Family” tier at $99. You add an introductory offer (3-day trial → $4.99 first month → $9.99 rolling) on monthly. You add a win-back offer (50% off first 3 months) emailed to lapsed subscribers. Six months later: conversion 34%, month-one churn 9%, ARPU/customer doubled. Same product. The retention design was the product.
Context taxonomy
| Mechanism | Apple feature | Effect |
|---|---|---|
| Free trial | introductoryOffer.paymentMode = .freeTrial | Conversion boost; abuse risk |
| Pay-as-you-go intro | introductoryOffer.paymentMode = .payAsYouGo | Lower price for N periods |
| Pay-upfront intro | introductoryOffer.paymentMode = .payUpFront | Single discounted period |
| Promotional offer | Product.PromotionalOffer (signed, server-issued) | Win-back, loyalty, retention |
| Billing grace period | App Store Connect → Subscription → Grace Period | Lets billing-retry subs keep access 6–16 days |
| Family Sharing | Per-subscription toggle in ASC | Whole-family access on one purchase |
| Subscription Offer Codes | Server-generated redeem codes | Marketing, partnership, churn recovery |
manageSubscriptionsSheet() | iOS 15+ SwiftUI modifier | In-app subscription management — cancellation prevention surface |
| Refund request sheet | iOS 15+ refundRequestSheet(for:) | In-app refund request |
Concept → Why → How → Code
Concept. A subscription is a recurring relationship; retention is the product. Apple provides specific mechanisms — intro offers, promotional offers, grace periods, family sharing, and in-app management surfaces — each of which moves a specific KPI.
Why. Acquisition CAC is paid once; retention compounds. Doubling month-12 retention quadruples LTV. The features that drive retention aren’t user-visible UX — they’re billing surface design.
Tier design — the canonical 3-tier shape
Free ← acquisition top of funnel
Basic ← conversion sweet spot ($)
Pro / Family ← ARPU maximizer ($$)
[Team] ← B2B expansion lane ($$$)
Anchoring matters: people pick the middle tier. Without a high tier, your middle tier looks expensive; with one, it looks reasonable. Notion’s “Business at $18” makes “Plus at $10” feel like a steal.
Introductory offers — three payment modes
import StoreKit
extension Product.SubscriptionOffer {
var displayDescription: String {
switch paymentMode {
case .freeTrial:
return "Free for \(period.value) \(period.unit)"
case .payAsYouGo:
return "\(displayPrice) for \(periodCount) \(period.unit)s"
case .payUpFront:
return "\(displayPrice) for \(period.value) \(period.unit)"
@unknown default:
return ""
}
}
}
// Reading the intro offer attached to a product:
if let intro = product.subscription?.introductoryOffer {
print("Intro offer: \(intro.displayDescription)")
}
Strategy by mode:
| Mode | Use when | Risk |
|---|---|---|
.freeTrial (3/7/14 days) | Habit-forming apps where you need short-term proof of value | Trial abusers, accidental rebill resentment |
.payAsYouGo ($0.99 first 3 months) | Low-friction skin in the game | “Bait-and-switch” complaints if huge jump |
.payUpFront ($9 for first 6 months) | Annual products with high LTV | Higher initial commitment, fewer triallers |
Promotional offers — server-signed win-back
Promotional offers can’t be redeemed organically — your server issues a signed offer to specific user IDs, typically as a win-back for lapsed subscribers.
// Apply a promotional offer at purchase time
let offerSignature: Product.PurchaseOption.PromotionalOffer = .promotionalOffer(
offerID: "win_back_50_off_3mo",
keyID: "L256SYR32L",
nonce: UUID(),
signature: Data(signatureFromServer),
timestamp: Int(Date().timeIntervalSince1970)
)
let result = try await product.purchase(options: [
.appAccountToken(currentUserID),
offerSignature
])
Server-side, you sign offers with your subscription key from App Store Connect:
# Signing a promotional offer (Python)
import hmac, hashlib, base64, uuid, time
def sign_offer(app_bundle_id: str, key_id: str, product_id: str,
offer_id: str, app_account_token: str, key_pem: bytes) -> dict:
nonce = str(uuid.uuid4())
timestamp = str(int(time.time() * 1000))
payload = "\u2063".join([
app_bundle_id, key_id, product_id, offer_id,
app_account_token, nonce, timestamp,
])
# In real code: ECDSA P-256 with subscription key
signature = ecdsa_sign(key_pem, payload.encode())
return {
"offerID": offer_id,
"keyID": key_id,
"nonce": nonce,
"signature": base64.b64encode(signature).decode(),
"timestamp": timestamp,
}
Win-back lifecycle:
- Server-side daily job queries lapsed subscribers (
EXPIREDevent ≥ 24h ago, no resubscribe). - Sends an email/push with a deep link
acme://winback?offer=win_back_50_off_3mo. - Client opens paywall pre-loaded with the offer applied.
- Conversion typically 15–30% — orders of magnitude better than cold acquisition.
Family Sharing toggle
In ASC: each subscription product has a “Family Sharing” toggle. ON means one purchase grants entitlement to all family group members. OFF means each member subscribes individually.
Calm — ON (network value: more meditators = better suggestions)
Apple Music — ON (loss-leader; Apple sells the Family Plan for that reason)
1Password — OFF (separate "Families" tier $7/mo vs Personal $4/mo)
Notion — OFF (B2B; each seat must subscribe)
When ON, family members appear in Transaction.currentEntitlements with ownershipType == .familyShared. Treat them identically for entitlement but track separately for analytics (their churn correlates with the purchaser’s, not their own engagement).
Billing grace period
Configure in ASC under each subscription group. Choices: Off, 6 days, 16 days (varies by subscription period). Effect:
Day 0: renewal fails (expired card)
Without grace: entitlement revoked, user sees paywall immediately
With 16-day grace: entitlement keeps; user is in BILLING_RETRY; banner says "Update payment"
Day 0–15: Apple retries; user fixes card; renewal succeeds → seamless
Day 16: if still failed → entitlement revoked, EXPIRED event
Always enable. Costs you nothing; saves 5–15% of involuntary churn from failed payments.
manageSubscriptionsSheet — your cancellation prevention surface
import SwiftUI
import StoreKit
struct ProfileView: View {
@State private var showManage = false
var body: some View {
Button("Manage subscription") { showManage = true }
.manageSubscriptionsSheet(isPresented: $showManage)
}
}
This sheet is Apple’s UI for cancellation, plan changes, and downgrades. You don’t get to customize it. But you can intercept the moment a user enters it — log the analytic event, show a “Before you go…” retention offer in your own UI before the sheet opens:
Button("Manage subscription") {
Task {
if await shouldShowRetentionOffer() {
// Show your custom offer view first
retentionFlow.start()
} else {
showManage = true
}
}
}
Refund request sheet
.refundRequestSheet(for: transaction.id, isPresented: $showRefund) { result in
// result: .success(.success), .success(.userCancelled), .failure(error)
}
Why surface this proactively? Self-service refund > customer-support ticket. Lower friction = better reviews even when users are unhappy. And users who self-refund vs charge-back have meaningfully better re-subscription rates later.
RevenueCat for cross-platform subscriptions
RevenueCat is the de facto wrapper around StoreKit + Google Play Billing + web (Stripe). It gives you:
- Single SDK for iOS, Android, web, with unified entitlement keys
- Webhook normalization — one webhook format instead of Apple V2 + Google RTDN + Stripe events
- A/B testing via Experiments / Offerings — ship different paywalls to user cohorts without an app update
- Dashboard for cohort retention, MRR, churn, conversion funnels
- Free up to $2.5k MTR; 1% of MTR above (so cheap at small scale, meaningful at $1M ARR+)
import RevenueCat
// In App init:
Purchases.configure(withAPIKey: "appl_xxxx")
Purchases.shared.logIn(currentUserID) { customerInfo, _, _ in
isPro = customerInfo?.entitlements["pro"]?.isActive == true
}
// Purchase:
let offerings = try await Purchases.shared.offerings()
let pkg = offerings.current?.availablePackages.first { $0.identifier == "$rc_annual" }!
let result = try await Purchases.shared.purchase(package: pkg)
isPro = result.customerInfo.entitlements["pro"]?.isActive == true
RevenueCat’s Offerings map to your App Store Connect products. The remote-config layer lets you reshuffle paywall presentations (different products, different ordering, different copy) without an app update — a major superpower for paywall experimentation.
In the wild
- Duolingo Super uses 14-day free trial + aggressive promotional offers for lapsed users (50% off, signed via their backend). Their churn analysis is publicly documented in earnings calls.
- Calm runs A/B paywalls via RevenueCat — different intro offers per cohort, measured weekly.
- Headspace added a “skip the trial” $5.99/month entry-level tier to combat trial abuse; conversion rose 18%.
- Apple TV+ uses Apple’s promotional offer system aggressively — “3 months free with iPhone purchase” is a signed Apple promotional offer.
Common misconceptions
- “Free trials always boost LTV.” They boost trials. They sometimes hurt LTV (trial abusers, post-trial sticker shock). Measure trial→paid conversion and month-3 retention separately.
- “Family Sharing kills revenue.” It can grow it if your retention loops include other family members (Music, Calm). For B2B-leaning products, keep it off.
- “Promotional offers go through Apple’s UI.” They’re applied silently at purchase time when the client passes a valid server-issued signature. The user sees the discounted price; no special UI.
- “Annual subscriptions reduce monthly recurring revenue.” They reduce reported MRR but increase LTV. Move to ARR for reporting.
- “RevenueCat is overkill for small apps.” At < $2.5k MTR it’s free. The dashboard alone justifies it before you ship anything more.
Seasoned engineer’s take
TIP. Always run a 16-day billing grace period. It’s free involuntary-churn prevention. The 5–15% retention bump compounds.
WARNING. Don’t ship promotional offers without server-side rate limiting. A leaked offer ID can be applied unlimited times if your server signs every request. Tie offers to user IDs server-side and reject duplicates.
The non-obvious lesson: subscription design is iterative experimentation, not one-shot config. Ship a paywall, watch conversion + retention for 60 days, change one variable, watch again. RevenueCat or App Store Connect Custom Product Pages give you the substrate; the discipline of running the experiments is what compounds.
Interview corner
Junior — “What’s the difference between a free trial and an introductory offer?” A free trial is one type of introductory offer (paymentMode = .freeTrial). The other two modes are pay-as-you-go (lower price for N periods) and pay-up-front (one discounted lump-sum period).
Mid — “How would you reduce involuntary churn?” Enable billing grace period (16 days). Add an in-app “Update payment method” banner when BILLING_RETRY arrives via Server Notifications. Send a series of emails on day 1, 7, 14 with deep link to update payment.
Senior — “Design a subscription retention system for an app at $2M ARR.” Webhooks land in your server; subscription state machine tracks every user. Three retention surfaces: (1) in-app banner during grace period, (2) push + email sequence on DID_FAIL_TO_RENEW, (3) win-back promotional offers signed server-side for users 7-30 days post-EXPIRED. RevenueCat or equivalent for dashboard/A-B testing of paywalls. Quarterly review of promo-offer cost vs incremental LTV. Refund request sheet exposed prominently to prefer self-service over chargebacks.
Red flag — “We don’t track lifecycle events because our backend has no users.” The moment you ship subscriptions you need a server. Even a tiny one. Without webhooks, you can’t reliably know if a user is still subscribed.
Lab preview
Lab 11.1 wires a 3-tier paywall with introductory free trial, monthly/annual toggle, and restore-purchases, using RevenueCat to abstract StoreKit.
Next: 11.6 — External Payments, Stripe & EU DMA
11.6 — External Payments, Stripe & EU DMA
Opening scenario
You’re shipping a B2B SaaS app with a $50/seat/month price. Your CFO sees the StoreKit invoice for the first month: $5,000 in revenue, $1,500 to Apple. “That’s our entire AWS bill,” she says. “Can’t we just use Stripe?” The answer is the most nuanced question in App Store policy. The rules differ for digital vs physical, consumer vs reader, US vs EU, and the right answer for your app might be “yes,” “no,” or “partial — and Stripe only for the upgrade webhook.”
This chapter is the decision table that saves your margins legally.
Context taxonomy
| Scenario | Required payment processor (2026) | Apple cut |
|---|---|---|
| Digital goods/subscription consumed in-app | StoreKit (worldwide default) | 15–30% |
| Same, but in EU (DMA) | StoreKit OR alternative processor | 17% Apple + 0.5€ Core Tech Fee/install/yr |
| Same, US | StoreKit OR external link to web checkout (post-2024 court ruling) | 27% even if external |
| Physical goods (shipped to door) | Stripe / your own processor — Apple has NO claim | 0% |
| Real-world services (rideshare, food delivery, hotel booking) | Stripe / your own processor | 0% |
| B2B Custom Apps (Apple Business Manager) | Apple invoicing OR direct contract | 0% on Custom Apps |
| Reader apps (Netflix, Spotify, Kindle) — no in-app signup | External web payment | 0% (but can’t sign up in-app) |
| Cross-platform service with web-first signup | External web payment | 0% (but no upsell flows in-app) |
| Consumable game items | StoreKit only | 15–30% |
| Cryptocurrency, NFTs | StoreKit for minting fees, external wallets for trading | Murky; mostly StoreKit |
Concept → Why → How → Code
Concept. Apple’s IAP rules apply only to digital goods/services consumed within the app. The boundary moves: court rulings, the EU Digital Markets Act, and category-specific exceptions (reader apps) carve out territory where you can use Stripe or your own processor.
Why. Apple’s cut is 15–30%. Stripe’s is ~2.9% + $0.30. For high-margin or high-ticket products, that’s a 5–10× difference. The legal architecture of who-pays-whom is the most important financial decision your app makes.
Apple’s actual cut in 2026 (post court + DMA)
Worldwide default
Year 1 of subscription 30%
Year 2+ of subscription 15%
One-time IAP 30%
Small Business Program 15% (< $1M/year)
US-only (post Epic Games v. Apple 2024)
External purchase link allowed 27% (Apple still claims commission)
EU-only (post DMA 2024)
Alternative app stores allowed 0% to Apple (paid to other store)
Alternative in-app payment 17% (year 1) / 10% (year 2+) Apple
Core Technology Fee €0.50 per install/year over 1M
Reader apps (Netflix, Spotify, Kindle, Audible patterns)
Can link out to web 0% to Apple if no in-app purchase
Cannot have signup in-app ← the key constraint
Decision table
| Your scenario | Use | Rationale |
|---|---|---|
| Indie productivity sub, $5/mo, < $1M ARR | StoreKit | Small Business Program = 15%; friction-free buy |
| B2B SaaS sub, $50/seat, $5M ARR | Stripe via web signup, app is “reader” pattern | Save $750k/year vs StoreKit 30% |
| Casual game with consumable coins | StoreKit | No legal alternative; gem stores are IAP-only |
| Shopify-style commerce app | Stripe / your own processor | Physical goods; Apple has no claim |
| Uber-style on-demand service | Stripe | Real-world service; Apple has no claim |
| Music streaming, EU launch | Alternative payment + 17% Apple OR StoreKit | DMA compliance window; weigh dev cost vs savings |
| News subscription, US | StoreKit + optional external link to lower web price | Court ruling allows the link; can A/B prices |
| Enterprise B2B Custom App | Apple Business Manager direct invoice | 0% Apple commission on Custom Apps |
| Crypto exchange app | StoreKit for fees, external wallets for trading | Spot trading isn’t IAP; Apple permits |
The Reader App pattern (Netflix model)
A “reader app” provides access to content sold elsewhere. Rules:
- ✅ Show content (videos, books, news) to logged-in users
- ❌ No signup or pricing inside the app
- ❌ No “subscribe now” buttons
- ✅ Login form is OK
- ✅ “Manage subscription” link to your website is OK
- ✅ External Link Entitlement (apply to Apple) → one in-app link to your website’s signup page
Implementation: an “empty-state” view for non-subscribers with a Login button and tasteful copy “Sign up at netflix.com” — no price, no Subscribe button.
// Acceptable reader-app empty state
struct EmptyAuthView: View {
var body: some View {
VStack(spacing: 16) {
Image(systemName: "tv")
.font(.system(size: 80))
Text("Welcome to Acme Stream")
.font(.title.bold())
Text("Sign in to watch")
.foregroundStyle(.secondary)
Button("Sign In") { /* show login */ }
.buttonStyle(.borderedProminent)
// External link entitlement (separately requested from Apple):
Link("Don't have an account? Visit acme.com",
destination: URL(string: "https://acme.com/signup")!)
.font(.caption)
}
}
}
Stripe integration for physical goods
// Buying a physical t-shirt — Stripe is fine, Apple takes 0%
import StripePaymentSheet
@MainActor
final class CheckoutViewModel: ObservableObject {
@Published var paymentSheet: PaymentSheet?
func prepareCheckout(amount: Int, currency: String) async throws {
// 1. Your backend creates a Stripe PaymentIntent
let resp = try await api.post("/checkout/intent", body: ["amount": amount, "currency": currency])
let clientSecret = resp["client_secret"] as! String
// 2. Configure Stripe SDK
var config = PaymentSheet.Configuration()
config.merchantDisplayName = "Acme Store"
config.applePay = .init(merchantId: "merchant.com.acme", merchantCountryCode: "US")
paymentSheet = PaymentSheet(paymentIntentClientSecret: clientSecret, configuration: config)
}
}
Apple Review accepts this because the user is buying a physical item. The Apple Pay surface is fine — Apple Pay ≠ IAP; Apple Pay processes payments for any kind of goods.
External Purchase Link Entitlement (US, post-2024 ruling)
For digital goods sold to US users, you can add a single in-app link to your website’s checkout:
- Request the
com.apple.developer.storekit.external-purchase-linkentitlement. - Add the System Disclosure Sheet (Apple-required UI) explaining the user is leaving the app.
- Apple still claims 27% commission on resulting purchases.
Math vs StoreKit:
StoreKit subscription $9.99 → Apple takes $3.00 (30%) → you net $6.99
External link, web checkout → Apple claims $2.70 (27%) → you pay
Stripe takes $0.59 (~6%)
You net $6.70 minus reporting overhead
Savings: marginal. Worth it only if you offer a lower web price ($7.99 instead of $9.99)
→ user pays less, you net the same, Apple gets less. Spotify uses this pattern in EU.
EU Digital Markets Act (DMA) — what’s actually available in 2026
Since March 2024, EU users can:
- Install apps from alternative app stores (AltStore PAL, Setapp, Epic Games Store on iOS)
- Pay via alternative payment processors inside iOS apps
- Use NFC chip via alternative wallet providers
Developer choices in EU:
- Stay on App Store + StoreKit: same 15/30% as before (with Small Business Program).
- Stay on App Store + alternative payment: 17% Apple commission year 1, 10% year 2+ on transaction value, plus €0.50 Core Technology Fee per first install per year (after 1M installs/year).
- List in alternative app stores: 0% Apple commission on transactions, but still pay €0.50 CTF if > 1M installs/year.
Math for a 5M install/year free app at $1 ARPU
App Store + StoreKit $1.00 × 30% = $0.30 Apple cut, no CTF
→ $0.70 net per install
→ $3.5M net on 5M installs
Alt payment $1.00 × 17% = $0.17 Apple
+ €0.50 × 4M = $2M CTF (first 1M is free)
→ $0.83 - $0.40/install (CTF averaged) = $0.43
→ $2.15M net ← worse at this scale
Alt app store (no App Store) 0% Apple commission, $0.40/install CTF
→ $0.60 net → $3M net
The CTF is the catch. For high-volume free apps, DMA “freedom” can cost more than the App Store.
Apple Pay vs StoreKit IAP — they are different
A constant source of confusion: Apple Pay is a wallet/payment-credential service. StoreKit IAP is Apple’s billing system for digital goods. Apple Pay is fine for any purchase type; it’s not the IAP system.
✅ Buying a Lyft ride via Apple Pay → No Apple commission
✅ Buying a Domino's pizza via Apple Pay → No Apple commission
✅ Subscribing to NYT digital via StoreKit → 30% Apple commission
❌ Subscribing to NYT digital via Apple Pay → REJECTED by App Review (must use StoreKit)
The test isn’t how you charge; it’s what you’re charging for.
In the wild
- Spotify in EU uses alternative in-app payment for new subscribers, passing the ~13% savings to users as lower prices.
- Netflix is the canonical reader-app pattern: no in-app signup, login-only.
- Amazon Kindle is the same reader pattern — you can read books bought on amazon.com but can’t buy them in the app.
- Patreon got an exception for the creator-economy use case after fighting Apple publicly in 2023; creators can be paid via external processors.
- Epic Games (Fortnite) runs its own iOS store in EU (post-DMA) — bypassing both App Store distribution and Apple’s commission entirely.
- Telegram Premium uses StoreKit on iOS but linked to a Telegram-internal subscription so users can also pay on web — bridging the systems.
Common misconceptions
- “DMA means no more Apple cut in EU.” False. Alternative payment still pays Apple 17% + Core Technology Fee. The CTF can dwarf the savings.
- “I can use Stripe for any subscription in my app.” False for digital goods. True for physical goods, real-world services, and B2B Custom Apps. Always.
- “The US ruling means I can stop using StoreKit.” False. You can add an external link, but Apple still claims 27% on external purchases. You’ll need reporting infrastructure.
- “Apple Pay = StoreKit IAP.” No. Different APIs, different policies, different commission structures.
- “Reader apps can charge in-app.” Only if they don’t have a signup flow — and even then Apple is very picky. Stick to the Netflix model exactly.
Seasoned engineer’s take
TIP. Before architecting payments, write down: what exactly am I selling, where is it consumed, who is the user (consumer/business), which territory. The decision table flows from those four. Skip the analysis and you’ll re-architect later under regulatory pressure.
WARNING. Apple’s policies change yearly. The 2024 US ruling, 2024 DMA, 2025 Patreon exception, and 2026 reader-app updates are all post-2020 changes. Subscribe to appstoreupdates.apple.com and revisit your strategy annually.
The right mental model: payment architecture is a regulatory + business + technical decision, not just technical. For most consumer apps the answer is “StoreKit, take the cut, ship fast.” For high-margin B2B and physical-goods apps, not using Stripe is leaving money on the table. Know which one you are.
Interview corner
Junior — “What’s the Apple Tax?” Apple’s commission on IAP and subscriptions: 30% for year-one subs and one-time IAPs, 15% for subs after year one or for Small Business Program participants (< $1M/year).
Mid — “When is it legal to use Stripe instead of StoreKit on iOS?” For physical goods (shipped items), real-world services (rides, food, hotels), B2B Custom Apps via Apple Business Manager, and reader-app login flows (no in-app signup). For digital goods consumed in-app: StoreKit only in worldwide default; US allows external links (27% Apple); EU allows alternative payment (17% + CTF) under DMA.
Senior — “You’re CTO of a B2B SaaS app at $10M ARR going iOS-first. Design the payment architecture.” Web-first signup (Stripe). The iOS app is a reader-pattern client — login-only, no pricing. Apply for the External Link Entitlement to add one “Visit acme.com for plans” link inside the app. Save ~$3M/year in Apple cut. Bonus: in EU, evaluate alternative app store distribution for users with strong corporate IT controls who use AltStore PAL or similar.
Red flag — “We charge subscriptions via Stripe inside our iOS app.” You’ll be rejected by App Review and likely lose your developer account on second infraction. Either switch to StoreKit or pivot to the reader-app pattern.
Lab preview
The labs cover RevenueCat-based StoreKit and ASC pricing automation. The Stripe/reader-app patterns are architectural decisions, not implementation labs — but the patterns shown here are immediately usable in any production codebase.
Next: 11.7 — How Major Apps Handle Payments
11.7 — How Major Apps Handle Payments
Opening scenario
You’re staring at your own paywall, trying to decide: do I add an annual tier? Do I link out? Do I show prices in-app? You spend an hour googling “best iOS subscription strategy” and read 30 contradictory blog posts. Then you open Netflix, Spotify, Amazon Kindle, Duolingo, Calm, and Groupon side-by-side and observe what they actually ship. In 20 minutes you understand the entire field. The opening lecture they don’t give you in any monetization course: the field is your competitive intelligence.
This chapter is that 20 minutes, distilled.
Context taxonomy
| Pattern | Used by | Why | Apple cut |
|---|---|---|---|
| Reader app (no in-app signup) | Netflix, Amazon Kindle, Audible | Subscription set up on web; iOS is consumption only | 0% |
| Reader app + External Link Entitlement | Spotify (EU), some news apps | Adds one “subscribe at our website” link | 0% on external, but Apple still claims 27% in US |
| StoreKit-only | Calm, Headspace, Duolingo Super | Friction-free in-app conversion; mass-market | 15–30% |
| StoreKit + external upsell tier | YouTube Premium (lower price on web) | Hybrid — App Store for impulse, web for power users | 15–30% on StoreKit purchases only |
| Stripe (physical/services) | Uber, Lyft, Amazon Shopping, Groupon | Apple has no claim on physical/real-world | 0% |
| Enterprise direct contract | Salesforce, Workday, Slack Business | B2B sold via direct sales | 0% (Apple Business Manager) |
| Alternative payment processor (EU DMA) | Spotify (EU), Epic Games Store (EU) | EU regulation allows; pass savings to users | 17% Apple + CTF |
| Family Sharing as growth lever | Apple Music, Calm | Bundle anchors expansion within household | 15–30% |
| Promotional offers as retention | Duolingo, NYT, Disney+ | Win back lapsed subscribers at discounted intro | 15–30% on the discounted amount |
Concept → Why → How → Code
Concept. Every major app has architected payments around a specific business reality: their CAC, their LTV, their global distribution, their legal exposure. By reverse-engineering them you absorb decades of A/B-tested strategy.
Why. No one will publish their playbook. The apps in your App Store are the playbook. Reading them is faster and more accurate than any guide.
Case study 1 — Netflix (the reader-app gold standard)
What you see in the iOS app:
- Open → login screen, no signup option
- Successful login → content browser
- “Manage account” tab → opens netflix.com in Safari
What this earns Netflix:
- 0% Apple commission on subscriptions
- Estimated annual savings: ~$800M+ at current scale (200M+ subscribers, ~$15/mo each)
- Tradeoff: ~5–10% lower iOS new-subscriber conversion vs in-app signup. They accept the tradeoff because LTV math at their scale dwarfs CAC math.
How they made it work:
- Massive cross-platform brand → people search “Netflix” in the App Store after signing up on web
- Strong UX of the login flow (Apple ID sign-in, magic-link options)
- Explicit Apple agreement (one of the original reader-app exception holders)
When to copy: you have a strong brand, web-first signup flow, and your math says LTV justifies the conversion loss.
Case study 2 — Spotify (EU DMA pioneer + US external links)
EU version (post-DMA 2024):
- Free tier with ads available globally
- In-app upgrade flow uses Spotify’s own payment system
- Prices ~10% lower than App Store equivalent (savings passed to user)
- Apple commission: 17% + Core Technology Fee
- Net to Spotify: still ~5–10% better than StoreKit’s 15–30%
US version (post-2024 court ruling):
- External purchase link added to in-app upgrade flow
- Web checkout offers $9.99/mo vs $14.99/mo on iOS direct
- Apple still claims 27% on external purchases — Spotify disputes this is collectible in practice
Pre-2024 (the famous “Spotify can’t tell you the price” era):
- iOS app intentionally hid pricing
- Cryptic “go to our website to subscribe” with no link
- Notoriously bad UX, but cheap
When to copy: you have meaningful pricing power, large EU/US user base, and the engineering bandwidth for alternative payment infrastructure.
Case study 3 — Duolingo (StoreKit-only mass-market with aggressive intro)
What they ship:
- Free tier with ads (the acquisition engine)
- Super Duolingo $6.99/mo or $59.99/yr (14-day free trial)
- Duolingo Max $29.99/mo or $167.99/yr (AI-powered features)
- Family Plan $9.99/mo (up to 6 users)
- All via StoreKit; no external links
Retention tactics observed:
- Aggressive 14-day free trial with friction-free conversion
- “Streak” mechanic that creates psychological switching cost
- Win-back offers (50% off first 3 months) sent to lapsed subs
- Promotional offers issued during major holidays
- Family Plan as natural expansion vector
When to copy: mass-market consumer app, casual price points (< $10/mo), strong daily-use loop.
Case study 4 — Calm (subscription + A/B paywall optimization)
What they ship:
- 7-day free trial → $69.99/yr (charged annually)
- Family Plan $99.99/yr
- A/B testing of paywall presentation via RevenueCat-style infrastructure
- Promotional offers for lapsed users
What’s not obvious from the surface:
- The “1-year for $69.99” is psychologically anchored against the implied “$13.99/month” — they don’t even sell monthly prominently because annual converts much better
- Free trial → annual auto-conversion produces 6× the LTV of monthly conversions
- They’ve reportedly tested 200+ paywall variants
When to copy: high-engagement habit app where annual makes psychological sense.
Case study 5 — Amazon Kindle (the cleanest reader app)
What you see:
- Open → empty library if not logged in
- Login screen
- Logged in → book library
- No “Buy this book” button anywhere
- No price displayed on any book
- Tap a sample → read sample, but no purchase flow
The trick: Amazon assumes you already bought the book on amazon.com. The iOS Kindle app is only a reader. Buying happens on web or in the Amazon shopping app.
This is the platonic ideal of reader-app architecture. Implementation is so strict that even tapping “buy this book” doesn’t exist in the iOS UI.
When to copy: you have a parent ecosystem (web, other apps) where purchase naturally happens.
Case study 6 — Groupon, Uber, Lyft (physical / real-world)
These never used StoreKit because they’re selling real-world stuff. Stripe, Adyen, or proprietary processors handle payment. Apple Pay surfaces are used for UX convenience (fingerprint/Face ID confirmation) — but the payment processor is the merchant’s, not Apple’s.
// Lyft pseudocode — Apple Pay as auth, Stripe as processor
PKPaymentAuthorizationViewController(paymentRequest: req) { result, completion in
Task {
// Send payment token to Lyft backend
let chargeResult = try await lyftAPI.charge(
stripeToken: result.token,
amount: rideAmount
)
completion(chargeResult.success ? .success : .failure)
}
}
Apple commission: 0%.
Case study 7 — Enterprise SaaS abstraction pattern
Slack, Zoom, Salesforce, Workday: the iOS app is a thin client of a web-managed service.
- Buying seats happens via your admin’s web console or direct sales contract
- iOS app: login-only, no plan management
- Apple Business Manager handles enterprise distribution + invoicing if applicable
- Apple commission: 0%
This is the same pattern as reader apps, applied to B2B.
Calm vs Headspace — same category, different choices
Both: meditation, subscription, 7-day free trial, ~$70/yr
↓
Calm Headspace
- StoreKit-only globally - StoreKit + extensive web upsell
- A/B paywall heavy - More conservative paywall variants
- Family Plan in-app - Family Plan via website only
- Higher iOS ARPU - Lower iOS ARPU but better margin on web conversions
Same product space, two valid architectures. Both companies have public revenue data; neither is obviously winning. The architecture flows from organizational structure (Calm is iOS-strong, Headspace is web-strong) more than user behavior.
In the wild
- Apple TV+ uses StoreKit but bundles via the Apple One subscription — Apple absorbs the bundling math internally.
- Disney+ initially used StoreKit only; added a “lower price on web” path after 2024 ruling for new US subscribers.
- YouTube Premium charges $13.99/mo on iOS but $11.99/mo on web (Apple-tax-pass-through). They link to web from the iOS app.
- Tinder/Match Group sued Apple in EU (2023), forced policy concessions, now uses both StoreKit and external payments depending on region.
- Telegram Premium charges $4.99/mo via StoreKit but offers the same subscription cheaper on their website (~$2 if paid via crypto).
Common misconceptions
- “Reader apps lose conversion to in-app signup.” They do — 5–15% typically. They make it back in commission savings, often 10×.
- “You have to pick StoreKit OR external.” Many large apps use both: StoreKit for impulse iOS buyers, external links for power users seeking a deal.
- “Apple punishes apps that link externally.” They have process requirements (System Disclosure Sheet, entitlement application) but they don’t algorithmically punish.
- “Alternative payment processors are the future of EU.” Math says only for very high-volume apps. Many EU developers stayed on StoreKit because CTF + 17% > 30% on small scales.
- “Reader apps just give up in-app revenue.” They give up in-app conversion. They still earn revenue from the same users — just collected elsewhere.
Seasoned engineer’s take
TIP. Audit the top 20 apps in your category. Note for each: payment model, has free trial, monthly vs annual, family plan, in-app vs external upsell, A/B paywall (you can spot RevenueCat by the variation between cold installs). Within an hour you’ll have the field’s playbook.
WARNING. Copying surface UX without copying the underlying math is dangerous. Spotify can run a complex external-payment flow because they spend $30M+ on backend infra. You probably can’t justify that at $100k MRR.
The most underrated lesson: payment architecture should be revisited yearly. The 2024 court ruling changed US options; the 2024 DMA changed EU options; the 2025 Patreon exception changed creator-economy options. What was correct in 2023 might be leaving money on the table in 2026.
Interview corner
Junior — “How does Netflix handle payment on iOS?” Reader app pattern — login only, no in-app signup or pricing. Subscription happens at netflix.com. Saves Apple’s 30% commission.
Mid — “You’re building a B2B SaaS app. How do you handle payments?” Web-first signup with Stripe. iOS app is reader-pattern: login-only, no plan management. Apply for the External Link Entitlement to surface one “Visit acme.com for plans” link in-app. Apple’s commission: 0% on web purchases.
Senior — “Design payment architecture for a global app launching simultaneously on iOS US, iOS EU, Android, and web.” Web payments via Stripe with PaymentIntents. iOS US: StoreKit primary + External Link Entitlement to web checkout (offers $X discount). iOS EU: evaluate alternative payment based on volume — at scale, switch to alternative; below 1M installs/yr, stay StoreKit. Android: Google Play Billing primary + external links permitted post-2024. Web: Stripe. All providers feed a unified subscription state machine keyed by user ID; entitlement queries go to your server, never trust client.
Red flag — “We made architectural decisions based on a 2021 blog post.” Re-evaluate. The post-2024 regulatory landscape changed everything.
Lab preview
Lab 11.1 implements a StoreKit + RevenueCat paywall. Lab 11.2 covers automated pricing — the operational layer of any of these strategies.
Next: 11.8 — Enterprise & B2B Distribution
11.8 — Enterprise & B2B Distribution
Opening scenario
A Fortune 500 customer wants 5,000 seats of your app, with custom features (their logo, their SSO provider, their MDM compliance settings), deployed to corporate-managed devices, and they want to pay you via PO and invoice — not via consumer App Store IAP. “Just publish to the App Store,” you say. Their CIO laughs. “Our devices are MDM-locked. Public App Store is disabled. We need this through Apple Business Manager or it can’t ship.” Welcome to enterprise iOS distribution — a totally separate planet from consumer App Store, with its own tooling, contracts, and economics.
Context taxonomy
| Distribution channel | Audience | Apple commission | Distribution mechanism |
|---|---|---|---|
| Public App Store | Anyone | 15–30% on IAP | App Store search/browse |
| Custom App via Apple Business Manager | Specific orgs only | 0% (direct invoice) | Private link, MDM, or ABM portal |
| Ad Hoc | ≤ 100 specific devices/yr | N/A | Direct IPA install via UDID provisioning |
| TestFlight | ≤ 10,000 testers, 90-day expiry | N/A | TestFlight invite link or public link |
| Apple Developer Enterprise Program | In-house employees only | N/A | Direct IPA install via enterprise cert |
| Unlisted App Distribution | Specific URL recipients | 15–30% on IAP | App Store hidden listing |
| Alternative App Stores (EU) | Anyone (EU) | 0% Apple commission, €0.50 CTF | AltStore PAL, Setapp, etc. |
| MDM (Jamf, Intune, Mosyle, Kandji) | Org-managed devices | N/A; orgs license MDM | Push apps to managed devices |
Concept → Why → How → Code
Concept. Enterprise distribution is a parallel App Store with its own contracts (Apple Business Manager direct), its own commission model (often 0%), and its own deployment surfaces (MDM push instead of App Store browse). The architecture serves IT-controlled environments where employees can’t install consumer apps.
Why. Enterprise buyers want: predictable pricing (per-seat per-month invoiced in arrears), MDM-managed deployment (no employee choice), custom features (logos, SSO, compliance settings), and zero consumer App Store dependencies. The consumer App Store delivers none of this.
Channel by channel
Apple Business Manager (ABM) — the canonical enterprise channel.
Workflow:
1. You enroll in ABM (apple.com/business)
2. Your customer enrolls in ABM
3. You publish a Custom App to ABM, scoped to that customer's DUNS number
4. Customer's IT admin sees the app in ABM portal, pushes to managed devices via MDM
5. Customer pays you via direct invoice (PO, NET-30, whatever you agree)
6. Apple takes 0% commission on Custom Apps
Custom App requirements:
- Same binary, packaging, and review as regular App Store
- Reviewed by App Review, but listed privately
- One Custom App can be scoped to multiple customers (each gets a different config bundled)
- IAP works but defeats the purpose; use config to unlock features
Volume Purchase Program (VPP) — legacy term, now folded into ABM. Same workflow as ABM Custom Apps, but for buying licenses of public App Store apps in bulk.
Custom Apps — variant where you ship a customized binary to a specific customer with their branding, SSO, etc.
// Configurable via managed app config (MDM pushes this)
struct ManagedConfig {
static let shared = ManagedConfig()
var ssoProvider: String? {
UserDefaults.standard.dictionary(forKey: "com.apple.configuration.managed")?["sso_provider"] as? String
}
var allowedFeatures: [String]? {
UserDefaults.standard.dictionary(forKey: "com.apple.configuration.managed")?["features"] as? [String]
}
var customLogoURL: URL? {
(UserDefaults.standard.dictionary(forKey: "com.apple.configuration.managed")?["logo_url"] as? String)
.flatMap(URL.init)
}
}
// In your app's start:
if let sso = ManagedConfig.shared.ssoProvider {
auth.configureSSO(provider: sso) // Customer-specific SSO endpoint
}
This com.apple.configuration.managed key is set by MDM at install time. Your app just reads it. No build customization needed per customer.
Ad Hoc — 100 specific devices per year via UDID provisioning. Useful for beta-testing on physical devices outside TestFlight, demos for prospects, or unusual deployment scenarios. Not a real enterprise distribution channel — just a developer-account-included mechanism.
TestFlight — 90-day test cycle, up to 10,000 testers. Often abused as a stealth distribution channel for limited audiences. Apple is increasingly strict about TestFlight-only “production” use — apps that stay in TestFlight indefinitely with no path to App Store get reviewed and rejected.
Apple Developer Enterprise Program (ADEP) — $299/year, allows in-house distribution to employees of your company only. Strict requirements:
- Must have 100+ employees (Apple verifies)
- Apps cannot be distributed to non-employees (revocation on violation)
- Many startups bought this license to bypass App Store; Apple aggressively revokes
- Use case: internal tools at large enterprises (Goldman Sachs internal trading apps, etc.)
Unlisted App Distribution — public App Store app with hidden listing. Distribution via direct App Store link only. Useful for niche B2B apps that don’t want to show up in App Store search.
Alternative app stores (EU only, post-DMA) — AltStore PAL, Setapp Mobile, Epic Games Store iOS, others. 0% Apple commission, €0.50 CTF per install over 1M/yr. Useful for European-targeted enterprise tools that want to bypass App Store entirely.
MDM tools — the real distribution layer
Enterprises manage iOS devices via MDM (Mobile Device Management). The market:
| MDM | Strengths | Pricing (typical) |
|---|---|---|
| Jamf Pro | Most Apple-ecosystem-aware; default for Apple-heavy orgs | $4–8/device/mo |
| Microsoft Intune | Best for orgs already on Microsoft 365 | bundled with M365 E3+ |
| Mosyle | Education-focused; competitive pricing | $1–4/device/mo |
| Kandji | Newer; strong UX | $4–7/device/mo |
| Hexnode, ManageEngine | Mid-market, multi-platform | $1–3/device/mo |
You don’t need to integrate with each MDM individually. You ship a normal app + a Managed App Configuration specification (an XML schema describing what config keys your app understands). MDMs read your spec and let admins fill in values.
Per-seat per-month invoicing pattern
# scripts/monthly_invoicing.py — typical B2B billing job
import requests
from datetime import datetime, timedelta
def generate_invoices_for_month(year: int, month: int):
for customer in active_customers():
seats = count_active_seats(customer.id, year, month)
prorate = compute_proration(customer.id, year, month)
amount = seats.peak * customer.price_per_seat * prorate
invoice_num = next_invoice_number()
invoice = {
"customer": customer.legal_name,
"billing_addr": customer.billing_address,
"po_number": customer.current_po,
"items": [{
"desc": f"Acme Pro — {customer.name} — {year}-{month:02d}",
"qty": seats.peak,
"unit": customer.price_per_seat,
"total": amount,
}],
"total": amount,
"due_date":(datetime.now() + timedelta(days=customer.payment_terms_days)).date(),
}
send_invoice(customer.billing_email, invoice)
record_in_books(invoice)
Per-seat per-month is the standard B2B billing rhythm. Pricing is usually $5–$50/seat/month depending on app sophistication. Annual prepayment (5–15% discount) is common at $10k+ contract values.
Fastlane enterprise distribution lane
# Fastfile
desc "Build & upload Custom App to specific customer ABM"
lane :ship_to_customer do |options|
customer = options[:customer] # e.g., "acme-corp"
# Build with customer-specific config
build_app(
scheme: "AcmeApp",
configuration: "Release-#{customer}",
export_method: "app-store",
export_options: {
"iCloudContainerEnvironment": "Production",
}
)
# Upload to App Store Connect; mark as Custom App for specific customer
upload_to_app_store(
force: true,
skip_metadata: true,
skip_screenshots: true,
api_key: app_store_connect_api_key,
precheck_include_in_app_purchases: false,
)
# Notify customer's IT admin
slack(message: "Custom App build for #{customer} uploaded to ABM")
end
Decision flow: which channel?
Audience size?
├── < 100 devices, internal beta?
│ └── Ad Hoc or TestFlight
│
├── Public consumer release?
│ └── App Store (Standard)
│
├── Specific corporate customer(s), not on public App Store?
│ ├── Customer wants their own branded version?
│ │ └── Custom App via ABM
│ └── Generic app, just need to invoice instead of IAP?
│ └── Volume purchase via ABM + standard App Store app
│
├── In-house employees of a large company?
│ └── Apple Developer Enterprise Program
│ (only if you actually have 100+ employees)
│
└── EU customers, want zero Apple involvement?
└── Alternative app store (AltStore PAL, etc.)
In the wild
- Salesforce Mobile uses Custom Apps via ABM for many F500 deployments — same binary, MDM-pushed managed config for each customer’s Salesforce org URL.
- Cisco WebEx distributes via standard App Store for consumers and Custom Apps for enterprise customers needing custom SSO/MDM integration.
- Goldman Sachs has internal trading apps on Apple Developer Enterprise Program — never available to the public.
- Jamf themselves run an ABM-deployed agent app for managed devices.
- Tesla ships their service-tech tool internally via ADEP; their consumer app via App Store.
Common misconceptions
- “Apple Developer Enterprise Program lets you skip App Store.” Only for your own employees. Distributing to non-employees gets the cert revoked, killing every installed app in seconds.
- “Custom Apps are a different binary.” They’re the same binary by default; per-customer behavior comes from managed app config keys. You ship one app, infinite Custom App targets.
- “You need TestFlight for enterprise beta.” Ad Hoc (100 devices) works for small deployments; TestFlight for larger ones. ABM Custom Apps for production.
- “MDM means the app needs special MDM SDK integration.” No. MDM controls device-level policies and pushes Managed App Config. Your app reads
com.apple.configuration.managedfrom UserDefaults. That’s the full integration. - “Custom Apps via ABM means Apple still takes 15–30%.” No. ABM Custom Apps with direct invoicing have 0% Apple commission.
Seasoned engineer’s take
TIP. For B2B SaaS, default to: standard App Store distribution + per-seat invoicing via your billing platform (Stripe, Chargebee). Switch to Custom Apps only when a customer explicitly requires it. Most customers don’t.
WARNING. Apple Developer Enterprise Program ($299/yr) is extremely tempting as a workaround for various App Store frictions. Every workaround story ends with Apple revoking the certificate, killing every installed app simultaneously, and losing the developer license. Don’t.
The mental model that matters: enterprise iOS distribution is a separate sales motion from consumer App Store. It requires sales contracts, MSAs, security questionnaires, MDM integration testing, and an account management function — none of which the App Store automates for you. Plan headcount accordingly.
Interview corner
Junior — “What’s Apple Business Manager?” Apple’s portal for organizations to manage Apple devices and apps in bulk. Includes Custom Apps (private B2B distribution), Volume Purchase (bulk consumer app licenses), and device enrollment.
Mid — “How do you ship a private app to just one customer?” Custom App via Apple Business Manager. Scope the app to the customer’s DUNS number; it appears only in their ABM portal; their MDM pushes to managed devices. 0% Apple commission.
Senior — “Design B2B distribution for a SaaS app needing to serve 100+ enterprise customers each with their own SSO, branding, and compliance requirements.” Single binary with Managed App Config (XML schema published with the app). Each customer’s MDM pushes their config — SSO provider URL, branding asset URLs, feature flags, compliance toggles. Per-customer Custom App not needed unless legal/contractual requirement forces it. Billing via Stripe per-seat, monthly invoiced in arrears. Customer success team owns the deployment relationship; engineering ships one app.
Red flag — “We’re using Apple Developer Enterprise Program to distribute to customers.” That’s a TOS violation. Apple will eventually revoke the cert and kill every installed instance.
Lab preview
The labs in this phase focus on consumer monetization (paywall, pricing automation). Enterprise distribution is contract + ops territory; the patterns here are immediately applicable in any team selling B2B.
Next: 11.9 — Ad Monetization & SKAdNetwork
11.9 — Ad Monetization & SKAdNetwork
Opening scenario
You ship a free utility app — flashlight, calculator, currency converter — pick your noun. After two weeks you have 50,000 users and zero revenue. You drop in AdMob, take a week to integrate, and watch the dashboard: $0.42 the first day. Twenty users hit the ATT prompt and 18 said “Ask App Not to Track.” Your eCPM is $0.30 instead of the $5 you saw in last year’s blog post. You don’t have an ad strategy — you have an ad widget. The actual ad business is a stack: networks, mediation, attribution, ATT consent, SKAdNetwork postbacks, fraud detection, eCPM optimization. This chapter is the field guide.
Context taxonomy
| Layer | Examples | Purpose |
|---|---|---|
| Ad networks | AdMob (Google), Meta Audience Network, Unity Ads, ironSource, Vungle | Sources of demand |
| Mediation | AdMob Mediation, Max (AppLovin), LevelPlay (ironSource), TradPlus | Pick the highest-bidding network per impression |
| MMP (Mobile Measurement Partner) | Adjust, Appsflyer, Branch, Kochava, Singular | Attribute installs to ad campaigns |
| ATT (App Tracking Transparency) | ATTrackingManager.requestTrackingAuthorization() | User permission to use IDFA cross-app |
| SKAdNetwork | Apple framework, version 4 in 2026 | Privacy-preserving install attribution without IDFA |
| AdServices | Apple framework | Apple Search Ads attribution |
| Ad formats | Banner, Interstitial, Rewarded video, Native, App Open | Different RPM tiers |
| Privacy frameworks | App Privacy Report, Privacy Manifest required 2024+ | Disclosure of tracking |
Concept → Why → How → Code
Concept. Ad monetization is a multi-layer stack: networks bring demand, mediation routes impressions to highest bidders, attribution closes the loop from campaign spend to installs and post-install events, and Apple’s privacy frameworks (ATT, SKAdNetwork, Privacy Manifest) gate everything.
Why. Ad ARPU is small per user ($0.50–$5/yr) but scales to massive numbers (millions of MAU). Apps with hundreds of millions of users — TikTok, Snapchat, ad-supported games — earn most of their revenue from ads. For most indie apps, ads at small scale are pocket change; at 1M+ MAU, ads become a viable business.
Ad network landscape (2026 eCPM benchmarks)
| Network | Best for | iOS eCPM (US, post-ATT) | Notes |
|---|---|---|---|
| Google AdMob | Default integration, broad demand | $3–8 banner, $15–40 rewarded | Lowest ops burden |
| Meta Audience Network | Casual games, social | $2–7 banner, $10–30 rewarded | Recovered post-ATT through SKAd |
| Unity Ads | Games (Unity-shipped) | $5–15 interstitial, $20–50 rewarded | Strong rewarded video |
| ironSource (Unity) | Games mediation + own demand | similar | Often part of LevelPlay mediation |
| AppLovin / Max | Mediation default 2026 | $4–10 interstitial avg | Most respected mediation; Max audited |
| Vungle (Liftoff) | Mid-tier video, casino | $8–20 video | Niche but strong |
| TikTok Ads (Pangle) | High demand 2026 | $3–8 banner, $15–40 rewarded | Fast-growing demand |
eCPM: effective revenue per 1,000 impressions. Rewarded video > Interstitial > Native > Banner > App Open in revenue, by roughly that order.
Ad format strategy
Banner ← always-on, low intrusion, $0.30–$3 eCPM
Interstitial ← full-screen between game levels, $3–10 eCPM (don't show > 1/min)
Rewarded video ← user opts in for in-game reward, $10–50 eCPM, BEST format
Native ← matches app UI, $1–5 eCPM, hard to integrate cleanly
App Open ← shown on cold launch, $1–4 eCPM, polarizing UX
The dominant 2026 pattern for free apps with revenue: rewarded video + interstitial mediation. Banner-only is a 2018 strategy and earns coffee money.
App Tracking Transparency (ATT) — the consent gate
// AppDelegate.swift or main App init
import AppTrackingTransparency
import AdSupport
func requestTrackingPermission() async {
guard ATTrackingManager.trackingAuthorizationStatus == .notDetermined else { return }
// Wait briefly for app to settle (Apple recommends not asking on first launch)
try? await Task.sleep(nanoseconds: 1_500_000_000)
let status = await ATTrackingManager.requestTrackingAuthorization()
switch status {
case .authorized:
let idfa = ASIdentifierManager.shared().advertisingIdentifier
// IDFA available for cross-app tracking
case .denied, .restricted, .notDetermined:
// IDFA returns 00000000-0000-0000-0000-000000000000
break
@unknown default: break
}
}
Info.plist requirement:
<key>NSUserTrackingUsageDescription</key>
<string>We use this to show you more relevant ads and improve our app.</string>
ATT opt-in rates in 2026:
- Top-tier apps with great pre-prompt explanation: 30–45%
- Average: 20–30%
- Poor pre-prompts or no explanation: 10–15%
A “pre-prompt” is your own custom UI shown before triggering the system prompt — explaining why and what the user gains by opting in. Big lift on opt-in rate.
SKAdNetwork — privacy-preserving attribution
When ATT is denied (most users), advertisers can’t track installs via IDFA. SKAdNetwork is Apple’s replacement: cryptographically signed postbacks that tell the ad network “an install happened” without revealing the user’s identity.
Flow:
1. User taps ad in App A (publisher)
2. App A calls SKAdNetwork.startImpression(...)
3. User taps ad → App B (advertised app) installs
4. App B calls SKAdNetwork.updatePostbackConversionValue(...) per user activity
5. 24h+ random delay → Apple sends signed postback to App B's ad network
6. Postback includes: ad campaign ID, ad network ID, conversion value (0-63),
but NOT user ID
// App B (the advertised app) — on first launch
import StoreKit
SKAdNetwork.registerAppForAdNetworkAttribution() // SKAdNetwork 1.0 legacy
// Or modern:
do {
try await SKAdNetwork.updatePostbackConversionValue(0)
} catch { /* handle */ }
// As user completes onboarding / purchase, increase conversion value
do {
try await SKAdNetwork.updatePostbackConversionValue(15,
coarseValue: .high,
lockWindow: false)
} catch { /* handle */ }
Conversion value is a 6-bit number (0–63) you encode meaning into. Typical encoding:
- 0: install only
- 1–10: completed onboarding
- 11–30: in-app purchase, dollar bins
- 31–63: high-value events (subscription, repeat purchase)
Your ad network defines the encoding; you implement it; their dashboard decodes it.
AdServices — Apple Search Ads attribution
import AdServices
if let token = try? AAAttribution.attributionToken() {
// Send to your server
let url = URL(string: "https://api-adservices.apple.com/api/v1/")!
var req = URLRequest(url: url)
req.httpMethod = "POST"
req.httpBody = token.data(using: .utf8)
req.setValue("text/plain", forHTTPHeaderField: "Content-Type")
let (data, _) = try await URLSession.shared.data(for: req)
// data contains: campaignId, adgroupId, keywordId, etc.
}
This is the only deterministic install attribution still allowed for Apple Search Ads. Other ad networks use SKAdNetwork.
Privacy Manifest (required since 2024)
Apple requires PrivacyInfo.xcprivacy declaring:
- Data types collected
- Tracking domains
- Required Reason APIs used (e.g.,
UserDefaults,SystemBootTime)
<dict>
<key>NSPrivacyTracking</key>
<true/>
<key>NSPrivacyTrackingDomains</key>
<array>
<string>ads.example-network.com</string>
<string>track.appsflyer.com</string>
</array>
<key>NSPrivacyCollectedDataTypes</key>
<array>
<dict>
<key>NSPrivacyCollectedDataType</key>
<string>NSPrivacyCollectedDataTypeDeviceID</string>
<key>NSPrivacyCollectedDataTypeLinked</key>
<true/>
<key>NSPrivacyCollectedDataTypeTracking</key>
<true/>
<key>NSPrivacyCollectedDataTypePurposes</key>
<array>
<string>NSPrivacyCollectedDataTypePurposeAdvertising</string>
</array>
</dict>
</array>
</dict>
Apple rejects builds without correct Privacy Manifests as of 2024.
Mediation example with AdMob
import GoogleMobileAds
// Initialize
@main
struct MyApp: App {
init() {
MobileAds.shared.start()
}
}
// Rewarded video
final class RewardedAdLoader {
private var rewardedAd: RewardedAd?
func load() async throws {
rewardedAd = try await RewardedAd.load(
with: "ca-app-pub-XXX/YYY",
request: Request()
)
}
@MainActor
func show(from vc: UIViewController) async throws -> Bool {
guard let ad = rewardedAd else { return false }
return await withCheckedContinuation { cont in
ad.present(from: vc) {
let reward = ad.adReward
cont.resume(returning: true)
Task { try? await self.load() } // Pre-load next
}
}
}
}
AdMob’s mediation feature lets you bid out impressions to AppLovin, Meta, Unity, etc. — auctioning each impression in real-time. eCPM uplift of 10–30% typical.
When ads destroy UX (anti-pattern checklist)
❌ Interstitial on every screen transition (Apple Review flags this) ❌ App Open ad shown on every cold launch (frustrating; bad reviews) ❌ Banner that overlaps content (Apple Review rejects) ❌ Misleading “X” button that opens ad instead of closes ❌ Auto-play video with sound on ❌ More than ~3% of session time spent on ads ❌ Ad in onboarding flow (kills retention) ❌ Cannot close ad without watching full 30 seconds
The line: ads should feel like the occasional cost of using a free product, not the product itself. Apps that cross the line get 1-star reviews and tanked retention.
In the wild
- TikTok earns ~$15B/yr from ads. Almost zero in-app purchases relative to ad revenue.
- Duolingo’s free tier shows interstitials every ~3 lessons; rewarded video for hearts. ~30% of revenue from ads, 70% from Super.
- Royal Match (King) — runs almost 100% on interstitials and rewarded video; $2B+ annual revenue.
- Snapchat — ads, AR filter purchases, Snap+ subscription. Ads dominant.
- Reddit official app — runs banner + native ads. Eyeballed eCPM higher than expected because of strong audience targeting.
Common misconceptions
- “Ad ARPU is high.” Median ad ARPU is $1–3/user/year. You need huge MAU before ads pay rent.
- “ATT killed mobile ads.” It killed deterministic cross-app tracking. SKAdNetwork attribution still works at lower fidelity. Ad spend shifted, didn’t vanish.
- “Banner ads are the default.” They’re the lowest-revenue format. Rewarded video is 20–100× more lucrative per impression. Default to rewarded video where it fits.
- “You can skip ATT prompt if you’re not tracking.” You don’t need to ask permission if you don’t use IDFA. But not asking means SKAdNetwork-only attribution — works but more limited.
- “Mediation is too complex for indies.” AdMob Mediation is a couple of config screens. Doubles eCPM. Skip it at your peril.
Seasoned engineer’s take
TIP. Always pre-prompt before showing the ATT system dialog. Explain what the user gets by allowing tracking (more relevant ads, fewer irrelevant ones). Opt-in rates can double.
WARNING. App Store Review polices ad density aggressively. More than ~3 interstitials per minute, App Open + immediate interstitial, or anything that makes the app feel like an ad delivery vehicle gets rejected.
The right way to think about ads: ads are a layer on top of a great free experience, not a substitute for one. The most lucrative ad-funded apps (TikTok, Duolingo, Royal Match) are also the most loved. Bad UX + ads = bad reviews and zero retention; great UX + ads = sustainable business at scale.
Interview corner
Junior — “What is ATT?” App Tracking Transparency — Apple’s permission framework requiring user consent before an app can use IDFA for cross-app tracking. Introduced iOS 14.5.
Mid — “How do ads still get attributed after ATT?” SKAdNetwork — Apple’s cryptographically-signed postback system that tells ad networks when installs happen without revealing user identity. Conversion value (0–63) encodes app-defined success events.
Senior — “Design ad monetization for a free utility app at 5M MAU.” Default rewarded video for premium features; light interstitial after natural pause points (post-result screens). AdMob with Mediation to AppLovin Max, Meta, Unity for bidding. ATT pre-prompt explaining relevance benefit; opt-in target 30%. SKAdNetwork conversion values encoded for in-app conversions. Privacy Manifest declared. Annual eCPM optimization review: rotate mediation waterfall based on actual fill + eCPM data per network.
Red flag — “We ask for ATT permission immediately on first launch with no explanation.” Opt-in rate will be <15%. Add a pre-prompt screen explaining the benefit; conversion can double.
Lab preview
The labs focus on subscriptions, which are the dominant 2026 monetization. Ad integration is well-supported by SDK setup wizards from AdMob/Max — not a unique learning surface, but the strategic patterns from this chapter apply directly.
Next: 11.10 — Analytics, RevenueCat & Growth
11.10 — Analytics, RevenueCat & Growth
Opening scenario
You ask your CEO at standup: “What’s our trial→paid conversion rate?” Silence. Open App Store Connect: aggregate weekly downloads and revenue. Open RevenueCat (if installed): cohort retention, MRR, trial conversion. Open Mixpanel: in-app event funnels. Open Appsflyer: install attribution. Each tool answers part of the question; the engineering effort of stitching them together is what separates “we have data” from “we have answers.” This chapter is the analytics stack that turns shipping into a feedback loop.
Context taxonomy
| Tool | Layer | What it answers |
|---|---|---|
| App Store Connect Analytics | App Store funnel | Impressions → product views → downloads → IAP revenue |
| RevenueCat / Adapty | Subscription lifecycle | MRR, ARR, churn, cohort LTV, paywall A/B |
| Mixpanel / Amplitude | In-app behavior | Event funnels, retention curves, segment analysis |
| Appsflyer / Adjust / Branch / Kochava | Install attribution (MMP) | Which campaign drove which install |
| Apple Search Ads (via AdServices) | Apple’s ad network attribution | Search Ads campaign ROI |
| Sentry / Firebase Crashlytics | Crash & error tracking | Stability metrics — leading indicator of churn |
| Statsig / LaunchDarkly / Optimizely | Feature flags + experiments | Causal impact of feature changes |
| Posthog | Self-hosted product analytics | Open-source alternative to Mixpanel |
| Google Analytics for Firebase | Cross-platform behavior | Default Firebase stack analytics |
| ASO tools — App Annie/data.ai, Sensor Tower, MobileAction | Competitive intelligence | Category ranks, keyword positions, competitor downloads |
Concept → Why → How → Code
Concept. Analytics for paid apps is a stack: acquisition (MMP + Apple Search Ads + SKAdNetwork), in-app behavior (Mixpanel/Amplitude/Posthog), revenue (RevenueCat/Adapty + App Store Connect), and stability (Sentry/Crashlytics). Each layer answers different questions; you wire them via a single user identifier (your own userID) that flows through every system.
Why. Without the stack, you ship blind. With it, every release is measured against a baseline; every A/B test produces a verdict; every churn cohort can be reverse-engineered to root cause. Compound that over a year and you out-iterate competitors who guess.
Layer 1 — App Store Connect Analytics
Built-in. Read via App Store Connect web UI or REST API.
# Sales report via App Store Connect API
TOKEN=$(python3 scripts/asc_jwt.py)
DATE=2026-11-22
curl -H "Authorization: Bearer $TOKEN" \
-H "Accept: application/a-gzip" \
"https://api.appstoreconnect.apple.com/v1/salesReports?\
filter[frequency]=DAILY&\
filter[reportType]=SALES&\
filter[reportSubType]=SUMMARY&\
filter[vendorNumber]=12345678&\
filter[reportDate]=$DATE&\
filter[version]=1_0" \
--output sales-$DATE.tsv.gz
The funnel App Store Connect exposes:
Impressions ← App Store search/browse appearances
↓ ~3% (ASO-dependent)
Product Page Views ← User tapped your listing
↓ ~30% (screenshots, video, description quality)
Downloads (App Units)
↓ varies wildly
Sessions ← Active usage
↓ retention curves
IAP Revenue ← After Apple cut
The conversion from Impressions → Product Page Views is the highest-leverage metric most teams ignore. Improving ASO (icon, screenshots, video, keywords) moves this number 2–10×.
Layer 2 — RevenueCat for subscription analytics
import RevenueCat
@main
struct MyApp: App {
init() {
Purchases.logLevel = .info
Purchases.configure(withAPIKey: "appl_xxxx", appUserID: currentUserID)
}
var body: some Scene { WindowGroup { ContentView() } }
}
// Read entitlements anywhere
extension Purchases {
var isPro: Bool {
get async {
let info = try? await customerInfo()
return info?.entitlements["pro"]?.isActive == true
}
}
}
// Tag custom subscriber attributes for cohort slicing
Purchases.shared.attribution.setMixpanelDistinctID("user_123")
Purchases.shared.attribution.setAdjustID("adjust_id_xyz")
Purchases.shared.attribution.setCampaign("brand_search_2026")
RevenueCat dashboard gives you out-of-the-box:
- MRR / ARR
- Trial start, trial conversion, churn
- Cohort retention curves (e.g., “users who started a trial in Jan 2026: 34% still subscribed at month 6”)
- LTV by paywall variant, country, attribution source
- Webhook events for downstream systems
A typical RevenueCat→Mixpanel wiring:
# Webhook receiver — RevenueCat → Mixpanel
@app.post("/webhooks/revenuecat")
async def revenuecat_webhook(req: Request):
body = await req.json()
evt = body["event"]
user_id = evt["app_user_id"]
mp_event = {
"INITIAL_PURCHASE": "subscription_started",
"RENEWAL": "subscription_renewed",
"CANCELLATION": "subscription_cancelled",
"BILLING_ISSUE": "billing_issue",
"SUBSCRIPTION_PAUSED": "subscription_paused",
"UNCANCELLATION": "subscription_uncancelled",
"NON_RENEWING_PURCHASE": "one_time_purchase",
}.get(evt["type"])
if mp_event:
mixpanel.track(
distinct_id=user_id,
event_name=mp_event,
properties={
"product_id": evt["product_id"],
"price": evt["price"],
"currency": evt["currency"],
"is_trial": evt["period_type"] == "TRIAL",
"store": evt["store"],
}
)
return {"ok": True}
Layer 3 — Mixpanel / Amplitude in-app events
import Mixpanel
// In App init
Mixpanel.initialize(token: "your_token", trackAutomaticEvents: false)
Mixpanel.mainInstance().identify(distinctId: currentUserID)
// Track custom events
Mixpanel.mainInstance().track(event: "Lesson Completed", properties: [
"lesson_id": lesson.id,
"duration_seconds": Int(duration),
"completion_score": score,
])
// User profile properties for cohort building
Mixpanel.mainInstance().people.set(properties: [
"$name": user.name,
"subscription": user.tier,
"signup_date": user.signupDate,
])
Build funnels in the Mixpanel UI:
App Open
→ Lesson Started (70% drop)
→ Lesson Completed (40% drop)
→ Paywall Shown (5% drop)
→ Trial Started (35% drop)
→ Paid (3 months later)
Each step’s drop-off is a hypothesis to test.
Layer 4 — MMP attribution (Appsflyer / Adjust)
import AppsFlyerLib
// AppDelegate
AppsFlyerLib.shared().appsFlyerDevKey = "your_dev_key"
AppsFlyerLib.shared().appleAppID = "1234567890"
AppsFlyerLib.shared().customerUserID = currentUserID
func application(_ app: UIApplication, didFinishLaunchingWithOptions: ...) -> Bool {
AppsFlyerLib.shared().start()
return true
}
// Track purchase events (sends to MMP for ROAS attribution)
AppsFlyerLib.shared().logEvent(
AFEventPurchase,
withValues: [
AFEventParamPrice: 49.99,
AFEventParamCurrency: "USD",
AFEventParamContentId: "annual_subscription",
]
)
MMP’s job: tell you which Facebook/TikTok/Google campaign drove each install, and which installs eventually generated revenue. ROAS (Return on Ad Spend) reports tell you which campaigns to scale.
Layer 5 — ASO (App Store Optimization)
Tools: Sensor Tower, App Annie / data.ai, MobileAction, Asodesk.
Workflow:
- Identify 30–50 candidate keywords with relevant search volume in your category
- Find your current rank per keyword (lower = better; top 10 typically required for meaningful traffic)
- Update your app’s title, subtitle, and keyword field (Apple gives you 100 char hidden keyword field)
- Iterate screenshots/video → measure conversion lift in App Store Connect Analytics
- Repeat quarterly
Example:
Before:
Title: "Acme Notes" (no keyword juice)
Subtitle: "A great notes app"
Keyword field: "notes,writing,memo"
After:
Title: "Acme Notes — Markdown Editor"
Subtitle: "Sync notes, organize ideas, beautifully"
Keyword field: "markdown,obsidian,bear,journal,zettelkasten,outline,..."
Result: rank for "markdown editor" goes from #47 to #11; impressions × 8.
Growth experimentation loop
1. Hypothesize ← e.g., "longer trial → higher trial-to-paid conversion"
2. Design ← 7-day vs 14-day trial, randomized via paywall_variant
3. Instrument ← Mixpanel events with variant tag
4. Ship ← RevenueCat Offerings / Custom Product Pages to expose variants
5. Wait ← need 2× max(trial period) for clean data
6. Analyze ← cohort LTV per variant in RevenueCat
7. Decide ← winner becomes default; loser sunsetted
8. Repeat
A typical pattern: one growth experiment per 2 weeks. Over a year that’s 24 tested hypotheses; even at 30% win rate, ~7 wins compounding multiplicatively can double overall LTV.
In the wild
- Duolingo publicly attributes its growth to constant A/B testing — their growth team runs ~50 experiments/quarter.
- Spotify runs hundreds of paywall A/Bs per quarter via internal tooling that resembles RevenueCat Offerings on steroids.
- Headspace publicly cited RevenueCat as the tool that “unlocked their experimentation velocity.”
- Calm runs continuous paywall A/Bs — observed shipping ~3 different paywalls in a single week across cold-install cohorts.
- Pokemon Go (Niantic) uses Adjust as MMP, Mixpanel for in-app, RevenueCat-like internal tooling.
Common misconceptions
- “App Store Connect Analytics is enough.” It’s enough to know revenue and downloads. It can’t tell you why users churned, what features they used, or which paywall version converted best.
- “Mixpanel and Amplitude are basically the same.” Mixpanel is more event-funnel-oriented; Amplitude leans behavioral cohorting. Both work; pick one and commit.
- “You need every tool from day one.” Day-one stack: Crashlytics + RevenueCat + a basic in-app event tool. Add MMP when paid acquisition begins. Add ASO tools when you’re optimizing organic.
- “RevenueCat is just a paywall SDK.” It’s also a webhook router, an A/B test platform, an analytics dashboard, and a cross-platform abstraction. Worth using even if you don’t need the paywall SDK.
- “Privacy laws killed analytics.” They killed cross-app tracking without consent. First-party in-app behavior tracking (your own events) is unrestricted as long as you disclose in your privacy policy and Privacy Manifest.
Seasoned engineer’s take
TIP. Wire one user identifier (your internal
userID) into every analytics tool from day one. Cross-system join later is impossible if events are tagged with different IDs.
WARNING. Many ad networks and MMPs offer “free for early stage.” That free tier is data-rate-limited; production volumes hit caps and your data goes silent without warning. Read the contract.
The mindset that separates teams that compound from teams that ship features: data is a product surface, not a side-channel. Every release should produce measurable behavioral output; every metric should map to a decision; every decision should be reviewed against the metric’s movement. That feedback loop is the growth function.
Interview corner
Junior — “What’s RevenueCat?” A subscription analytics + paywall SDK that abstracts StoreKit (iOS) and Google Play Billing (Android) into a single API, with built-in webhooks, cohort analysis, and A/B testing infrastructure.
Mid — “How would you measure if a paywall change improved conversion?” RevenueCat Offerings or Custom Product Pages to expose variants A and B to cold-install cohorts. Tag each user’s variant in your analytics tool. Wait ≥ 2× trial period for clean data. Compare trial→paid conversion + LTV at 30/60/90 days between cohorts.
Senior — “Design an analytics stack for a $1M ARR subscription app planning to grow to $10M.” Crashlytics for stability (leading churn indicator). RevenueCat for subscription truth + paywall A/B. Mixpanel or Amplitude for in-app event funnels. Appsflyer for paid acquisition attribution. App Store Connect API ingested daily for organic metrics. Sentry for non-fatal errors. All systems tagged with a single internal userID. Weekly experimentation cadence with PRD → instrumentation → ship → measure → decide loop. Dashboard in Metabase/Looker pulling from a unified BigQuery/Snowflake warehouse fed by RevenueCat exports + Mixpanel exports.
Red flag — “We rely entirely on App Store Connect Analytics.” You’re flying blind on retention, behavior, and attribution. Add at minimum RevenueCat + an in-app event tool before your next pricing decision.
Lab preview
Lab 11.1 ships a real RevenueCat-integrated paywall — the first piece of the analytics stack. Lab 11.2 ships pricing automation — the operational backbone for any data-driven pricing decisions.
Next: Lab 11.1 — Subscription Paywall
Lab 11.1 — Subscription Paywall with RevenueCat
Goal
Ship a production-ready 3-tier subscription paywall — monthly, annual (with discount badge), lifetime — with free trial, restore purchases, and entitlement-gated content. Powered by RevenueCat, backed by StoreKit 2.
Time
90–120 minutes
Prereqs
- Xcode 16+
- Free Apple Developer account (paid not required for sandbox testing)
- Free RevenueCat account (app.revenuecat.com)
- An app already configured in App Store Connect (you can use any existing app’s bundle ID)
Setup
Step 1 — App Store Connect: create the subscription group
- Open App Store Connect → Apps → your app → Monetization → Subscriptions.
- Create Subscription Group: name it
Pro. Subscription groups bundle related tiers; users can only have one active subscription per group. - Add Subscription:
| Reference name | Product ID | Duration | Price |
|---|---|---|---|
| Pro Monthly | com.acme.pro.monthly | 1 month | $7.99 |
| Pro Annual | com.acme.pro.annual | 1 year | $49.99 |
- For Pro Monthly: add Introductory Offer → Free Trial → 7 days, eligibility: New Customers.
- Submit each subscription. Status will be “Ready to Submit” — that’s enough for sandbox testing.
Step 2 — App Store Connect: create the lifetime IAP
- In-App Purchases → Create IAP → Non-Consumable.
| Reference | Product ID | Price |
|---|---|---|
| Pro Lifetime | com.acme.pro.lifetime | $99.99 |
Step 3 — Create sandbox tester
App Store Connect → Users and Access → Sandbox Testers → Add. Use a unique email (Apple won’t take your real one). Note the password.
Step 4 — RevenueCat configuration
- Sign up at app.revenuecat.com.
- Create a New App → iOS. Paste your bundle ID.
- App Store Connect API Key: in RevenueCat → Project Settings → upload your
.p8key, Key ID, and Issuer ID. - Products: RevenueCat auto-discovers from App Store Connect. Confirm
com.acme.pro.monthly,com.acme.pro.annual,com.acme.pro.lifetimeappear. - Entitlements: create one entitlement called
pro. Attach all three products to it. - Offerings: create one called
default. Add three Packages:$rc_monthly→com.acme.pro.monthly$rc_annual→com.acme.pro.annual$rc_lifetime→com.acme.pro.lifetime
- Mark the
defaultoffering as Current. - API Keys: copy the public iOS SDK key (starts with
appl_).
Step 5 — Xcode project
# In your project root
xcrun swift package init --type executable # or use an existing project
Open Package.swift (or your Xcode project’s package dependencies) and add RevenueCat:
.package(url: "https://github.com/RevenueCat/purchases-ios.git", from: "5.0.0"),
In target:
.product(name: "RevenueCat", package: "purchases-ios"),
In your scheme: Edit Scheme → Run → Options → set StoreKit Configuration to your .storekit file (optional, useful for offline iteration).
Build
File: App.swift
import SwiftUI
import RevenueCat
@main
struct AcmeApp: App {
@State private var entitlement = EntitlementStore()
init() {
Purchases.logLevel = .info
Purchases.configure(withAPIKey: "appl_XXXXXXXXXXXXXXXX")
}
var body: some Scene {
WindowGroup {
ContentView()
.environment(entitlement)
.task {
await entitlement.refresh()
}
}
}
}
File: EntitlementStore.swift
import Foundation
import RevenueCat
import Observation
@Observable
final class EntitlementStore {
var isPro = false
var customerInfo: CustomerInfo?
func refresh() async {
do {
customerInfo = try await Purchases.shared.customerInfo()
isPro = customerInfo?.entitlements["pro"]?.isActive == true
} catch {
print("Entitlement refresh failed: \(error)")
}
}
}
File: PaywallViewModel.swift
import Foundation
import RevenueCat
import Observation
@Observable
@MainActor
final class PaywallViewModel {
enum Cadence { case monthly, annual, lifetime }
var offering: Offering?
var selectedCadence: Cadence = .annual
var purchasing = false
var error: String?
func load() async {
do {
offering = try await Purchases.shared.offerings().current
} catch {
self.error = error.localizedDescription
}
}
func selectedPackage() -> Package? {
guard let offering else { return nil }
switch selectedCadence {
case .monthly: return offering.monthly
case .annual: return offering.annual
case .lifetime: return offering.lifetime
}
}
func purchase() async -> Bool {
guard let pkg = selectedPackage() else { return false }
purchasing = true; defer { purchasing = false }
do {
let result = try await Purchases.shared.purchase(package: pkg)
return !result.userCancelled && result.customerInfo.entitlements["pro"]?.isActive == true
} catch {
self.error = error.localizedDescription
return false
}
}
func restore() async -> Bool {
do {
let info = try await Purchases.shared.restorePurchases()
return info.entitlements["pro"]?.isActive == true
} catch {
self.error = error.localizedDescription
return false
}
}
}
File: PaywallView.swift
import SwiftUI
import RevenueCat
struct PaywallView: View {
@State private var vm = PaywallViewModel()
@Environment(EntitlementStore.self) private var entitlement
@Environment(\.dismiss) private var dismiss
var body: some View {
ScrollView {
VStack(spacing: 24) {
header
if let offering = vm.offering {
tierSelector(offering: offering)
purchaseButton
restoreButton
legalFooter
} else if vm.error != nil {
Text(vm.error ?? "Couldn't load offerings")
.foregroundStyle(.red)
} else {
ProgressView()
}
}
.padding()
}
.task { await vm.load() }
}
private var header: some View {
VStack(spacing: 8) {
Image(systemName: "sparkles")
.font(.system(size: 56))
.foregroundStyle(.tint)
Text("Acme Pro")
.font(.largeTitle.bold())
Text("Unlimited notes, sync across devices, dark themes, priority support.")
.multilineTextAlignment(.center)
.foregroundStyle(.secondary)
}
}
@ViewBuilder
private func tierSelector(offering: Offering) -> some View {
VStack(spacing: 12) {
if let monthly = offering.monthly {
tierRow(title: "Monthly",
price: monthly.localizedPriceString + "/mo",
badge: monthly.storeProduct.introductoryDiscount?.paymentMode == .freeTrial ? "7-day free trial" : nil,
selected: vm.selectedCadence == .monthly,
tap: { vm.selectedCadence = .monthly })
}
if let annual = offering.annual, let monthly = offering.monthly {
let savings = computeSavings(annual: annual, monthly: monthly)
tierRow(title: "Annual",
price: annual.localizedPriceString + "/yr",
badge: "Save \(savings)%",
selected: vm.selectedCadence == .annual,
tap: { vm.selectedCadence = .annual })
}
if let lifetime = offering.lifetime {
tierRow(title: "Lifetime",
price: lifetime.localizedPriceString,
badge: "One-time",
selected: vm.selectedCadence == .lifetime,
tap: { vm.selectedCadence = .lifetime })
}
}
}
private func tierRow(title: String, price: String, badge: String?, selected: Bool, tap: @escaping () -> Void) -> some View {
Button(action: tap) {
HStack {
VStack(alignment: .leading) {
Text(title).font(.headline)
if let badge { Text(badge).font(.caption).foregroundStyle(.tint) }
}
Spacer()
Text(price).font(.body.bold())
Image(systemName: selected ? "largecircle.fill.circle" : "circle")
.foregroundStyle(selected ? .tint : .secondary)
}
.padding()
.background(RoundedRectangle(cornerRadius: 12).strokeBorder(selected ? .tint : .secondary.opacity(0.3), lineWidth: 2))
}
.buttonStyle(.plain)
}
private var purchaseButton: some View {
Button {
Task {
if await vm.purchase() {
await entitlement.refresh()
if entitlement.isPro { dismiss() }
}
}
} label: {
HStack {
if vm.purchasing { ProgressView() } else { Text("Continue").bold() }
}
.frame(maxWidth: .infinity)
.padding()
.background(Color.accentColor)
.foregroundStyle(.white)
.clipShape(RoundedRectangle(cornerRadius: 12))
}
.disabled(vm.purchasing || vm.selectedPackage() == nil)
}
private var restoreButton: some View {
Button("Restore purchases") {
Task {
if await vm.restore() {
await entitlement.refresh()
if entitlement.isPro { dismiss() }
}
}
}
.font(.footnote)
.foregroundStyle(.secondary)
}
private var legalFooter: some View {
VStack(spacing: 4) {
Text("Auto-renews. Cancel anytime in Settings.")
HStack {
Link("Terms", destination: URL(string: "https://acme.com/terms")!)
Text("·")
Link("Privacy", destination: URL(string: "https://acme.com/privacy")!)
}
}
.font(.caption2)
.foregroundStyle(.secondary)
}
private func computeSavings(annual: Package, monthly: Package) -> Int {
let annualCost = NSDecimalNumber(decimal: annual.storeProduct.price).doubleValue
let monthlyAsAnnual = NSDecimalNumber(decimal: monthly.storeProduct.price).doubleValue * 12
let savings = (1 - (annualCost / monthlyAsAnnual)) * 100
return Int(savings.rounded())
}
}
File: ContentView.swift
import SwiftUI
struct ContentView: View {
@Environment(EntitlementStore.self) private var entitlement
@State private var showPaywall = false
var body: some View {
NavigationStack {
VStack(spacing: 20) {
if entitlement.isPro {
Label("Pro unlocked", systemImage: "checkmark.seal.fill")
.font(.title.bold())
.foregroundStyle(.green)
Text("All features available.")
} else {
Text("Free tier")
.font(.title.bold())
Button("Upgrade to Pro") { showPaywall = true }
.buttonStyle(.borderedProminent)
}
}
.padding()
.sheet(isPresented: $showPaywall) {
PaywallView()
}
}
}
}
Running with sandbox
- Build and run on a physical device (Simulator works partially but sandbox purchase flow is more reliable on device).
- Settings → App Store → Sandbox Account → sign in with your sandbox tester.
- Open your app → tap Upgrade → pick a tier → Continue → enter sandbox password.
- Watch the console: RevenueCat logs every step.
- Tap Restore Purchases to verify entitlement re-hydrates on reinstall.
Stretch
- A/B test paywalls via RevenueCat Experiments: create a second Offering with different prices/cadence, run an experiment, observe variant in vm.offering.
- Add a “Manage subscription” button that opens
.manageSubscriptionsSheet(isPresented:)for active subscribers. - Wire RevenueCat webhooks to a tiny FastAPI endpoint that logs events to a sqlite DB — instant subscription analytics.
- Add ATT pre-prompt explaining attribution benefit before triggering the system prompt — see chapter 11.9.
- Test a refund flow: subscribe in sandbox, then in RevenueCat dashboard issue a test refund; confirm webhook fires and entitlement is revoked within seconds.
Notes
- Sandbox subscriptions accelerate: a “monthly” subscription renews every 5 minutes in sandbox. Plan testing accordingly.
- RevenueCat free tier covers $2.5k MTR; you’ll easily build and ship under that limit.
- Always test restore-purchases on a fresh install — many production bugs only surface there.
- Always set
Purchases.shared.attribution.setAttributes(...)with your internal user ID after login, so cross-system join works.
Next: Lab 11.2 — Automated Pricing Script
Lab 11.2 — Automated Pricing Script (App Store Connect API)
Goal
Build a Python CLI that authenticates with the App Store Connect REST API, reads your app’s current pricing, schedules a sale at a lower tier, and auto-restores after N days. Wire it into a GitHub Actions cron so Black Friday runs itself.
Time
60–90 minutes
Prereqs
- Python 3.11+
- App Store Connect account with Admin role
- An existing app published or in TestFlight
.p8private key downloaded from App Store Connect → Users and Access → Keys
Setup
Step 1 — Generate App Store Connect API key
- App Store Connect → Users and Access → Integrations → App Store Connect API → Generate API Key.
- Name:
pricing-automation. Access: Admin (Developer role can’t manage pricing). - Download the
.p8file (one-time download — back it up). - Note the Key ID (e.g.,
AAAA1111BB) and Issuer ID (top of the page, UUID format).
Step 2 — Find your app’s vendor number and app ID
# Vendor number: App Store Connect → Payments and Financial Reports → top of page
VENDOR_NUMBER=12345678
# App ID: App Store Connect → My Apps → your app → App Information → Apple ID
APP_ID=1234567890
Step 3 — Python project
mkdir asc-pricing && cd asc-pricing
python3 -m venv .venv && source .venv/bin/activate
pip install pyjwt cryptography requests typer rich
mkdir scripts
mv ~/Downloads/AuthKey_AAAA1111BB.p8 ./AuthKey_AAAA1111BB.p8
echo "AuthKey_*.p8" >> .gitignore
Step 4 — Environment
cat > .env <<'EOF'
ASC_KEY_ID=AAAA1111BB
ASC_ISSUER_ID=69a6de70-XXXX-XXXX-XXXX-XXXXXXXXXXXX
ASC_KEY_PATH=./AuthKey_AAAA1111BB.p8
APP_ID=1234567890
EOF
echo ".env" >> .gitignore
Build
File: scripts/asc.py
"""Shared App Store Connect helpers — JWT, requests, price-point lookup."""
import os
import time
import jwt # pyjwt
import requests
from pathlib import Path
from dotenv import load_dotenv
load_dotenv()
KEY_ID = os.environ["ASC_KEY_ID"]
ISSUER_ID = os.environ["ASC_ISSUER_ID"]
KEY_PATH = Path(os.environ["ASC_KEY_PATH"])
APP_ID = os.environ["APP_ID"]
BASE = "https://api.appstoreconnect.apple.com"
def make_token() -> str:
private_key = KEY_PATH.read_text()
headers = {"alg": "ES256", "kid": KEY_ID, "typ": "JWT"}
payload = {
"iss": ISSUER_ID,
"iat": int(time.time()),
"exp": int(time.time()) + 1200,
"aud": "appstoreconnect-v1",
}
return jwt.encode(payload, private_key, algorithm="ES256", headers=headers)
def session() -> requests.Session:
s = requests.Session()
s.headers.update({
"Authorization": f"Bearer {make_token()}",
"Content-Type": "application/json",
})
return s
def find_price_point(s: requests.Session, app_id: str, tier: str, territory: str = "USA") -> dict:
"""Find a specific price point for an app."""
r = s.get(
f"{BASE}/v1/apps/{app_id}/appPricePoints",
params={
"filter[priceTier]": tier,
"filter[territory]": territory,
"limit": 1,
}
)
r.raise_for_status()
data = r.json().get("data", [])
if not data:
raise RuntimeError(f"No price point found for tier {tier} in {territory}")
return data[0]
File: scripts/pricing_cli.py
"""
pricing-cli — manage App Store pricing from the command line.
Commands:
read Show current price schedule
sale TIER --days N Drop price to TIER for N days, then restore
restore Restore base price immediately
"""
import sys
import typer
from datetime import datetime, timedelta, timezone
from rich import print
from rich.table import Table
from asc import APP_ID, session, find_price_point, BASE
app = typer.Typer(no_args_is_help=True)
@app.command()
def read():
"""Show the current/scheduled price schedule for the app."""
s = session()
r = s.get(f"{BASE}/v1/apps/{APP_ID}/appPriceSchedule")
r.raise_for_status()
schedule = r.json()
# Fetch related appPrices
schedule_id = schedule["data"]["id"]
r2 = s.get(f"{BASE}/v2/appPriceSchedules/{schedule_id}/manualPrices",
params={"include": "appPricePoint,territory", "limit": 50})
r2.raise_for_status()
body = r2.json()
table = Table(title=f"App {APP_ID} — Current Price Schedule")
table.add_column("Start Date")
table.add_column("Territory")
table.add_column("Price Tier")
table.add_column("Customer Price")
included_by_id = {(i["type"], i["id"]): i for i in body.get("included", [])}
for entry in body.get("data", []):
start = entry["attributes"].get("startDate") or "(immediate)"
rel = entry["relationships"]
ptid = rel["appPricePoint"]["data"]["id"]
terr = rel["territory"]["data"]["id"]
pp = included_by_id.get(("appPricePoints", ptid))
if pp:
tier = pp["attributes"].get("priceTier", "?")
price = pp["attributes"].get("customerPrice", "?")
else:
tier, price = "?", "?"
table.add_row(str(start), terr, str(tier), str(price))
print(table)
@app.command()
def sale(
sale_tier: str = typer.Argument(..., help="The discounted price tier, e.g. '5' for $4.99"),
days: int = typer.Option(7, help="How many days the sale should run"),
base_tier: str = typer.Option("10", help="Tier to restore to after sale"),
territory: str = typer.Option("USA", help="Apple territory code"),
dry_run: bool = typer.Option(False, help="Print payload instead of POSTing"),
):
"""Drop the price to SALE_TIER for N days, then restore to BASE_TIER."""
s = session()
sale_pp = find_price_point(s, APP_ID, sale_tier, territory)
restore_pp = find_price_point(s, APP_ID, base_tier, territory)
now = datetime.now(timezone.utc).replace(microsecond=0)
restore_at = (now + timedelta(days=days)).isoformat().replace("+00:00", "Z")
payload = {
"data": {
"type": "appPriceSchedules",
"relationships": {
"app": {"data": {"type": "apps", "id": APP_ID}},
"baseTerritory":{"data": {"type": "territories", "id": territory}},
"manualPrices": {"data": [
{"type": "appPrices", "id": "sale"},
{"type": "appPrices", "id": "restore"},
]},
},
},
"included": [
{
"type": "appPrices",
"id": "sale",
"attributes": {"startDate": None},
"relationships": {
"appPricePoint": {"data": {"type": "appPricePoints", "id": sale_pp["id"]}},
"territory": {"data": {"type": "territories", "id": territory}},
},
},
{
"type": "appPrices",
"id": "restore",
"attributes": {"startDate": restore_at},
"relationships": {
"appPricePoint": {"data": {"type": "appPricePoints", "id": restore_pp["id"]}},
"territory": {"data": {"type": "territories", "id": territory}},
},
},
],
}
if dry_run:
import json
print("[yellow]DRY RUN — payload that would be POSTed:[/yellow]")
print(json.dumps(payload, indent=2))
return
r = s.post(f"{BASE}/v2/appPriceSchedules", json=payload)
if r.status_code >= 400:
print(f"[red]ERROR {r.status_code}[/red]")
print(r.json())
sys.exit(1)
print(f"[green]✓[/green] Sale scheduled: tier {sale_tier} → restore to tier {base_tier} at {restore_at}")
@app.command()
def restore(
base_tier: str = typer.Option("10"),
territory: str = typer.Option("USA"),
dry_run: bool = typer.Option(False),
):
"""Restore the base price immediately (e.g. ending a sale early)."""
s = session()
restore_pp = find_price_point(s, APP_ID, base_tier, territory)
payload = {
"data": {
"type": "appPriceSchedules",
"relationships": {
"app": {"data": {"type": "apps", "id": APP_ID}},
"baseTerritory":{"data": {"type": "territories", "id": territory}},
"manualPrices": {"data": [{"type": "appPrices", "id": "restore"}]},
},
},
"included": [{
"type": "appPrices",
"id": "restore",
"attributes": {"startDate": None},
"relationships": {
"appPricePoint": {"data": {"type": "appPricePoints", "id": restore_pp["id"]}},
"territory": {"data": {"type": "territories", "id": territory}},
},
}],
}
if dry_run:
import json
print(json.dumps(payload, indent=2))
return
r = s.post(f"{BASE}/v2/appPriceSchedules", json=payload)
r.raise_for_status()
print(f"[green]✓[/green] Restored to tier {base_tier} immediately")
if __name__ == "__main__":
app()
Run it
# Read current pricing
python scripts/pricing_cli.py read
# Dry-run a Black Friday sale
python scripts/pricing_cli.py sale 5 --days 4 --dry-run
# Schedule it for real
python scripts/pricing_cli.py sale 5 --days 4
# Verify
python scripts/pricing_cli.py read
# Should show two scheduled prices: tier 5 (now) and tier 10 (in 4 days)
# Restore immediately if you change your mind
python scripts/pricing_cli.py restore
Annotated curl equivalents
If you prefer curl over Python:
# Set up
TOKEN=$(python -c "from scripts.asc import make_token; print(make_token())")
# 1. Find a price point
curl -s -H "Authorization: Bearer $TOKEN" \
"https://api.appstoreconnect.apple.com/v1/apps/$APP_ID/appPricePoints?filter[priceTier]=5&filter[territory]=USA" \
| jq '.data[0]'
# → returns the opaque price-point ID
# 2. Read current schedule
curl -s -H "Authorization: Bearer $TOKEN" \
"https://api.appstoreconnect.apple.com/v1/apps/$APP_ID/appPriceSchedule" \
| jq
# 3. Schedule a sale (POST /v2/appPriceSchedules with manualPrices array)
# See payload in pricing_cli.py — too long for inline curl
GitHub Actions integration
# .github/workflows/black-friday.yml
name: Black Friday Sale
on:
schedule:
- cron: '0 7 27 11 *' # Nov 27 2026, 07:00 UTC = midnight PT
workflow_dispatch:
jobs:
start-sale:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with: { python-version: '3.12' }
- run: pip install pyjwt cryptography requests typer rich python-dotenv
- name: Materialize ASC key from secret
env:
ASC_KEY_BASE64: ${{ secrets.ASC_KEY_BASE64 }}
run: |
echo "$ASC_KEY_BASE64" | base64 -d > AuthKey_AAAA1111BB.p8
chmod 600 AuthKey_AAAA1111BB.p8
- name: Schedule sale
env:
ASC_KEY_ID: ${{ secrets.ASC_KEY_ID }}
ASC_ISSUER_ID: ${{ secrets.ASC_ISSUER_ID }}
ASC_KEY_PATH: ./AuthKey_AAAA1111BB.p8
APP_ID: ${{ secrets.APP_ID }}
run: python scripts/pricing_cli.py sale 5 --days 4
- name: Notify Slack
if: success()
uses: slackapi/slack-github-action@v1
with:
payload: |
{"text": "🛍️ Black Friday sale live — tier 5 ($4.99) until Monday"}
env:
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK }}
Stash the .p8 file in a secret as base64:
base64 -i AuthKey_AAAA1111BB.p8 | pbcopy
# Paste into GitHub repo Settings → Secrets → ASC_KEY_BASE64
Stretch
- Multi-territory pricing: extend
saleto accept--territory alland iterate the 175 territories from/v1/territories, applying PPP-aware tier maps from a config YAML. - Subscription repricing: add a
sub-repricesubcommand usingPOST /v1/subscriptionPriceswithpreserveCurrentPrice: true. - Audit log: log every schedule change to a JSON file in the repo with
git commit— git becomes your pricing audit trail. - Slack approval workflow: post the dry-run payload to Slack with Approve/Reject buttons; only POST after approval.
- Diff mode:
pricing_cli.py diffreads current state and compares against a YAML config; prints what would change.
Notes
- Apple’s pricing pipeline takes 5–30 minutes to apply. Don’t panic if “tier 5 effective immediately” shows tier 10 for the first 15 minutes after POST.
- App Store Connect API is rate-limited at roughly 50 requests/minute. Pricing scripts won’t hit this; bulk territory operations might — add
time.sleep(1.5)between requests. - For subscription pricing, the API is
/v1/subscriptionPrices(not/v2/appPriceSchedules). The semantics differ: subscriptions can preserve existing subscribers at their current price viapreserveCurrentPrice: true. - The
.p8key gives Admin access to your entire App Store Connect account. Store as carefully as you would an AWS root key. - Test on a secondary app first. There’s no “undo” — you correct a botched schedule by scheduling another change.
Phase 11 complete. Next: Phase 12 — Architecture & Interview Prep (15 chapters + 4 labs covering MVC/MVVM/Clean/VIPER, The Composable Architecture, dependency injection, modular Swift packages, and senior-level system-design + interview question patterns).
12.1 — MVC: What Apple Actually Ships
Opening scenario
Your new teammate, fresh from a React job, opens UIKit and sees UIViewController with viewDidLoad containing 800 lines: networking, validation, layout, animations, analytics. He whispers: “This is Massive View Controller. We need to rewrite to MVVM.” You ask: “Do we, though?”
The answer is sometimes yes, sometimes no. Understanding what MVC actually is at Apple — and where it really breaks down — is the prerequisite to picking a better architecture (or knowing when you don’t need one).
Context — the MVC taxonomy
| Variant | Where it lives | Strength | Weakness |
|---|---|---|---|
| Smalltalk MVC (the original, 1979) | Academic | Pure separation | Doesn’t map to modern UI |
| Apple MVC (UIKit) | Every UIKit app | Quick to learn, integrated with framework | View Controller becomes catch-all |
| MVC-N (Networking out) | Convention in many shops | Removes one source of bloat | Still leaves View Controller heavy |
| MVC + Coordinators | Used by Khanlou-influenced shops | Removes navigation | Doesn’t solve binding/state |
What Apple’s MVC actually means
Apple’s “MVC” is not the textbook MVC. In the textbook version, View observes Model directly via the Observer pattern. In Apple’s MVC:
- Model owns data + business logic. Pure Swift, no
UIKit. - View is a
UIViewsubclass — dumb, configured from outside. - Controller mediates everything. View talks to Controller via targets/delegates. Controller talks to View via outlets. Controller observes Model via KVO/
NotificationCenter/closures.
This means all communication between View and Model goes through Controller. That’s why View Controllers grow: they’re the only place the wiring can live.
Concept → Why → How → Code
Concept: Apple MVC is triangular. Model and View never know about each other; Controller is the sole mediator.
Why: View reuse. A UITableViewCell rendered in 30 screens stays dumb; each screen’s Controller decides what to render.
How: Outlet from Controller to View. Delegate from View back to Controller. Reference from Controller to Model.
// Model — plain Swift, no UIKit
struct Article {
let id: UUID
let title: String
let body: String
}
final class ArticleService {
func fetchArticle(id: UUID) async throws -> Article { /* … */ }
}
// View — dumb, configured from outside
final class ArticleView: UIView {
let titleLabel = UILabel()
let bodyLabel = UILabel()
func configure(with article: Article) {
titleLabel.text = article.title
bodyLabel.text = article.body
}
}
// Controller — the mediator
final class ArticleViewController: UIViewController {
private let articleView = ArticleView()
private let service = ArticleService()
private let articleID: UUID
init(articleID: UUID) {
self.articleID = articleID
super.init(nibName: nil, bundle: nil)
}
required init?(coder: NSCoder) { fatalError() }
override func loadView() { view = articleView }
override func viewDidLoad() {
super.viewDidLoad()
Task {
let article = try await service.fetchArticle(id: articleID)
articleView.configure(with: article)
}
}
}
Already in this 30-line skeleton you can see how easy it is for the Controller to absorb everything: error handling, retry, loading state, analytics, deep-link parsing. Each addition feels like just one more responsibility.
In the wild
- Apple’s sample code (Pet, Photos, system Settings panes) is mostly MVC. Apple itself uses MVC for every WWDC sample under 1,000 lines.
- Older codebases at Twitter, Pinterest, Lyft started as MVC; their architecture migrations took years (Lyft’s Plato, Pinterest’s PINRemoteImage refactors). MVC didn’t get rewritten — parts did.
- Simple utility apps on the App Store — calculators, single-purpose tools — overwhelmingly remain MVC because the cost of more architecture exceeds the benefit.
Common misconceptions
- “MVC means 1000-line view controllers.” No — MVC means the mediator pattern. The bloat is a discipline failure, not a pattern failure.
- “Apple recommends MVVM now.” Apple has never said this. Apple ships SwiftUI (which is closer to Elm/React than to MVVM) and continues to write UIKit samples in MVC.
- “MVC can’t be tested.” The Model is trivially testable. The View Controller is testable if you inject its dependencies.
- “MVC is dead.” MVC ships in every iOS app on launch day; UIKit isn’t going anywhere for a decade.
- “Use MVVM by default.” MVVM has its own failure mode (Massive View Model). Both patterns degrade under the same root cause: unmanaged growth.
Seasoned engineer’s take
MVC’s real bug is gravity: every new requirement has only one obvious home — the View Controller. MVVM, Clean, VIPER all introduce extra homes so the gravity disperses. But adding architecture before you have the load is over-engineering; adding it after the load arrives is refactoring. The senior skill is reading the trajectory.
TIP: A
UIViewControllerunder ~300 lines with one responsibility is a feature, not a bug. Don’t refactor it for ideology.
WARNING: If you find yourself adding
// MARK: - Networkingand// MARK: - Validationand// MARK: - Layoutin the same controller, the gravity is winning. Time to extract.
Interview corner
Junior: “What does MVC stand for in iOS?”
Model–View–Controller. Model holds data, View renders, Controller mediates between them. In UIKit, the Controller is a UIViewController.
Mid: “Why do iOS apps suffer from Massive View Controller?” Apple’s MVC routes all View ↔ Model communication through the Controller. Networking, layout, navigation, validation, analytics all naturally land there. Without extracting helpers (network clients, presenters, coordinators), the Controller absorbs everything.
Senior: “When is MVC the right choice today?” For apps where the View Controller’s responsibilities can be bounded to one screen of one user flow, MVC is the cheapest and most idiomatic choice — Apple’s samples remain MVC. I’d reach for MVVM when binding logic grows complex (forms, multi-state lists) and TCA or Clean when I need testable side-effect orchestration across many screens. The architecture should follow the load profile, not the other way around.
Red-flag answer: “MVC is bad, always use MVVM/VIPER/TCA.” The interviewer immediately knows the candidate hasn’t shipped enough to know architectures are tradeoffs.
Lab preview
Lab 12.1 takes a deliberately messy MVC app — a 600-line View Controller for a notes app — and refactors it step-by-step to MVVM with @Observable. You’ll learn what each extraction buys, and what it costs.
Next: 12.2 — MVVM patterns
12.2 — MVVM: ViewModel Responsibilities & Binding
Opening scenario
Same notes app. You’ve extracted a NotesViewModel. The View Controller is now 80 lines. The ViewModel is 700. Welcome to Massive View Model — MVC’s gravity didn’t disappear, you just renamed it. This chapter is about doing MVVM in a way that actually disperses load, with @Observable as the binding mechanism in Swift 6.
Context — the MVVM family tree
| Variant | Binding | Typical context |
|---|---|---|
| Classical MVVM (Microsoft WPF) | Two-way bindings via INotifyPropertyChanged | Desktop, XAML |
| MVVM-C (Coordinator) | Coordinator owns navigation; VM owns state | UIKit shops escaping MVC |
| MVVM + Combine | @Published + sink | iOS 13+ UIKit/SwiftUI hybrids |
MVVM + @Observable | Tracking via property access | iOS 17+, SwiftUI-first |
| MVVM + RxSwift | Observable<T> + BehaviorRelay | Legacy reactive iOS |
What MVVM actually adds
MVVM splits the Controller into two pieces:
- ViewModel: pure presentation logic. Takes input (user actions), produces output (display state). No
UIKit/SwiftUIimports. - View: renders output, forwards input.
Crucially, the ViewModel is headless — testable without a window.
Concept → Why → How → Code
Concept: a ViewModel exposes publishable state and async intent methods. The View binds to state and calls intents.
Why: testability + reusability. The same ViewModel can drive a SwiftUI screen, a UIKit screen, and a snapshot test.
How (Swift 6, @Observable):
import Observation
import SwiftUI
@Observable @MainActor
final class NotesViewModel {
enum State { case idle, loading, loaded([Note]), error(String) }
private(set) var state: State = .idle
private let store: NoteStore
init(store: NoteStore) { self.store = store }
func load() async {
state = .loading
do { state = .loaded(try await store.all()) }
catch { state = .error(error.localizedDescription) }
}
func delete(_ note: Note) async {
guard case .loaded(var notes) = state else { return }
notes.removeAll { $0.id == note.id }
state = .loaded(notes) // optimistic
do { try await store.delete(note.id) }
catch { await load() } // revert on failure
}
}
struct NotesView: View {
@State private var vm: NotesViewModel
init(store: NoteStore) {
_vm = State(initialValue: NotesViewModel(store: store))
}
var body: some View {
Group {
switch vm.state {
case .idle, .loading: ProgressView()
case .loaded(let notes): list(notes)
case .error(let msg): Text(msg).foregroundStyle(.red)
}
}
.task { await vm.load() }
}
private func list(_ notes: [Note]) -> some View {
List(notes) { note in
Text(note.title)
.swipeActions { Button("Delete", role: .destructive) {
Task { await vm.delete(note) }
} }
}
}
}
The View is passive. The ViewModel is testable with no UI:
@MainActor func testDeleteRollsBackOnFailure() async {
let failing = MockStore(deleteThrows: true)
let vm = NotesViewModel(store: failing)
await vm.load()
await vm.delete(Note.sample)
// state should still contain the note (reverted)
}
Binding mechanism comparison
| Mechanism | iOS | Pros | Cons |
|---|---|---|---|
@Observable macro | 17+ | Granular tracking, zero boilerplate | Requires iOS 17 deployment |
@Published + Combine | 13+ | Works with Combine pipelines | Verbose, full-class invalidation |
Closures (onChange: (State) -> Void) | any | Trivial, dependency-free | No diffing, manual subscription |
| KVO | any | Built-in | Objective-C only, awkward |
For new code in 2026, start with @Observable.
In the wild
- Airbnb iOS uses MVVM heavily; their ViewModel layer is named
*Presenterin some places (synonyms). - Lyft moved from MVC → MVVM-C in their 2017–2019 architecture rewrite; Coordinators removed navigation from VMs.
- Apple’s own SwiftUI samples (Landmarks, Scrumdinger) are MVVM-shaped without using the term.
Common misconceptions
- “MVVM solves Massive View Controller.” No — it relocates the mass. Discipline (one VM per screen, extract sub-services) does the work.
- “ViewModel must expose
Observable<T>for every field.” No. Onestateenum is often cleaner than 12 individual properties. - “ViewModel should never import
Foundation.” It should never importUIKit/SwiftUI;Foundationis fine and usually necessary. - “MVVM means two-way binding.” Apple’s MVVM is usually one-way: state down, intent up. Two-way binding is a XAML convention.
- “You need a framework (Combine/RxSwift) to do MVVM.” No. Plain
@Observable+async/awaitcovers 90 % of real cases.
Seasoned engineer’s take
MVVM is a naming convention + a testability boundary. The win is not the V-V-M letters; it’s that you can unit-test the screen’s logic without a host app. If your ViewModel imports SwiftUI, you’ve already lost the testability win.
TIP: Cap each ViewModel at ~200 lines. When it grows past that, extract collaborators (a
Validator, aPagination, aSorter) — don’t split intoNotesViewModel+NotesHelperViewModel.
WARNING: Don’t create a ViewModel for every tiny view. A static
Text("Welcome")doesn’t need one. MVVM is for screens with state transitions.
Interview corner
Junior: “What is a ViewModel?” A plain object that owns the screen’s display state and intent methods, with no UI framework imports. The View observes the state and calls intents.
Mid: “How is MVVM different from MVC?” MVC routes everything through the View Controller, so logic, state, and UI mediation pile up in one class. MVVM splits that into a presentation-logic-only ViewModel (testable headless) and a thin View. Navigation usually moves to a Coordinator.
Senior: “When does MVVM stop being enough?” When side effects span screens or need orchestrated time travel (undo, replay), reach for a unidirectional architecture like TCA or Redux-style stores. When the domain has rich invariants spanning many use cases, Clean Architecture’s interactor/entity split pays off. MVVM is the sweet spot for single-screen scope; it breaks down at app-scope coordination.
Red-flag answer: “MVVM is better than MVC because it has more layers.” More layers ≠ better. The candidate needs to articulate what testability or scaling problem MVVM solves.
Lab preview
Lab 12.1 finishes the MVC → MVVM refactor of the notes app: extracts state into @Observable, demonstrates rollback-on-failure deletion, and writes the first headless test. After this lab you’ll be able to do the conversion on a real codebase in an afternoon.
Next: 12.3 — Clean Architecture / VIP / VIPER
12.3 — Clean Architecture, VIP & VIPER
Opening scenario
A staff engineer at a fintech walks you through their codebase. Every feature is a folder with eight files: LoginViewController, LoginPresenter, LoginInteractor, LoginRouter, LoginEntity, LoginWorker, LoginAssembler, LoginConfigurator. You ask: “How long to add a ‘forgot password’ link?” Answer: “Three days.” This chapter is about when that ceremony is worth it — and when it’s malpractice.
Context — the heavy-architecture family
| Pattern | Origin | Layers | Ideal scale |
|---|---|---|---|
| Clean Architecture | Uncle Bob (2012) | Entity, UseCase, Interface Adapter, Framework | Domain-heavy apps |
| VIP (View-Interactor-Presenter) | Raymond Law (Clean Swift) | View, Interactor, Presenter, Router, Worker | Enterprise iOS |
| VIPER | Mutual Mobile (2014) | View, Interactor, Presenter, Entity, Router | Banking, healthcare |
| RIBs | Uber (2017) | Router, Interactor, Builder | Hundreds of engineers |
These all share an ethos: separate the business rules from the framework. They differ in how strictly they enforce it.
What Clean Architecture actually is
Uncle Bob’s diagram has four rings. Inner depends on no outer. iOS-adapted:
| Ring | Examples | Knows about |
|---|---|---|
| Entities | Account, Transaction | Pure Swift only |
| Use Cases | TransferFunds, ListTransactions | Entities, repository protocols |
| Interface Adapters | TransactionPresenter, AccountRepository impl | Use Cases, framework types |
| Frameworks & Drivers | UIKit, CoreData, URLSession | Everything |
The boundary is enforced by protocols pointing inward (dependency inversion). The Use Case defines a AccountRepositoryProtocol; CoreData implements it.
Concept → Why → How → Code
Concept: business rules become executable specifications that read like the product spec, with frameworks plugged in behind protocols.
Why: testability without a host app, frameworks swappable (CoreData → SwiftData), and the codebase still makes sense after 10 years of staff turnover.
How — minimal Clean Swift skeleton:
// MARK: Entity (innermost)
struct Account {
let id: UUID
var balanceCents: Int
}
// MARK: Use Case
protocol AccountRepository {
func load(_ id: UUID) async throws -> Account
func save(_ account: Account) async throws
}
struct TransferFunds {
let repo: AccountRepository
func execute(from: UUID, to: UUID, cents: Int) async throws {
guard cents > 0 else { throw TransferError.nonPositive }
var src = try await repo.load(from)
var dst = try await repo.load(to)
guard src.balanceCents >= cents else { throw TransferError.insufficient }
src.balanceCents -= cents
dst.balanceCents += cents
try await repo.save(src)
try await repo.save(dst)
}
enum TransferError: Error { case nonPositive, insufficient }
}
// MARK: Interface Adapter (Presenter)
@Observable @MainActor
final class TransferPresenter {
enum State { case idle, working, success, error(String) }
private(set) var state: State = .idle
private let useCase: TransferFunds
init(useCase: TransferFunds) { self.useCase = useCase }
func transfer(from: UUID, to: UUID, cents: Int) async {
state = .working
do {
try await useCase.execute(from: from, to: to, cents: cents)
state = .success
} catch {
state = .error(error.localizedDescription)
}
}
}
// MARK: Framework (CoreData impl)
final class CoreDataAccountRepository: AccountRepository {
func load(_ id: UUID) async throws -> Account { /* fetch NSManagedObject, map */ fatalError() }
func save(_ account: Account) async throws { /* mutate, save context */ }
}
The TransferFunds use case has zero UIKit/CoreData imports. You can unit-test it with a mock repository in 5 lines.
VIP / VIPER differences
| Concern | Clean Swift (VIP) | VIPER |
|---|---|---|
| Communication | Unidirectional (V → I → P → V) | Bidirectional via protocols |
| Routing | Router class | Router class (more central) |
| Boilerplate per feature | High (5–6 files) | Very high (7–8 files) |
| Mainstream uptake (2026) | Low | Declining; mostly legacy code |
Both pre-date modern Swift concurrency. In greenfield 2026 work, you’d reach for Clean Architecture with async/await rather than VIPER’s Wireframe → Router → Presenter ceremony.
In the wild
- Uber built RIBs (a VIPER cousin) for the Rider/Driver apps with thousands of engineers; open-sourced 2017. The justification is modularization at extreme scale, not architectural purity.
- Square Cash publicly discussed using a Clean-ish architecture with KMP shared business logic.
- Most banking apps (Chase, Wells Fargo, Monzo internally) use heavy patterns because regulatory testing requires the business rules to be independently verifiable.
Common misconceptions
- “VIPER is just VIPER.” No — every shop’s VIPER is custom. Without a team-wide template, every feature becomes its own dialect.
- “Clean Architecture means more files = more clean.” Cleanliness is about dependency direction, not file count.
- “You can’t use SwiftUI with Clean Architecture.” You can — the View ring just becomes SwiftUI views observing the Presenter.
- “Use cases must be classes.” Often a
structwith one method is cleaner. - “Clean Architecture is overkill for everything under 1M users.” Not user count — domain complexity. A 1k-user healthcare app may need it; a 10M-user wallpaper app does not.
Seasoned engineer’s take
Heavy architectures are insurance policies: you pay premiums (boilerplate, onboarding cost, slower iteration) for protection against future change. Pay the premium when the domain is genuinely complex (banking, insurance, healthcare, multi-platform via KMP) or when teams will scale past ~25 iOS engineers. Don’t pay it for a content app with five screens.
TIP: If you’re tempted by VIPER, first try MVVM + Coordinators. 80 % of the testability win, 30 % of the boilerplate.
WARNING: A half-applied Clean Architecture is worse than MVC. If you don’t enforce the dependency rule everywhere, you’ve just added boilerplate without the testability payoff.
Interview corner
Junior: “What’s the difference between VIPER and MVVM?” VIPER splits the screen into View, Interactor (business logic), Presenter (display formatting), Entity (data), Router (navigation). MVVM has only View + ViewModel + Model. VIPER trades more files for stricter separation.
Mid: “Why would you choose Clean Architecture over MVVM?” When the business logic must be testable independently of UI frameworks, when the same domain might run on iOS + macOS + a backend (Kotlin Multiplatform), or when a team needs strict layer boundaries to scale safely. The cost is more files per feature.
Senior: “How would you adopt Clean Architecture gradually in an existing UIKit MVC app?”
I’d start by extracting use cases from the most-changed View Controllers — leave the VCs as-is but route their logic through a Repository protocol + UseCase struct. That gives me unit-testable business rules without rewriting the View layer. Once those are stable, I’d introduce Presenters between use cases and VCs only where state formatting is non-trivial. I wouldn’t introduce Routers until navigation logic becomes a bottleneck. The point is incremental dependency inversion, not a Big Rewrite.
Red-flag answer: “We rewrote everything to VIPER and it’s much better now” — without metrics, without acknowledging the cost. Senior engineers can point to specific bugs avoided or velocity changes.
Lab preview
No dedicated lab for Clean/VIPER — Lab 12.2 (modularize a monolith) covers the physical separation that makes these patterns enforceable. The conceptual exercise here is to imagine refactoring your favorite small app to VIPER, count the new files, and decide if you’d actually ship it.
Next: 12.4 — Dependency injection
12.4 — Dependency Injection Patterns
Opening scenario
You write a test:
func testCheckoutChargesCard() async {
let vm = CheckoutViewModel()
await vm.checkout()
// …how do I assert the card was charged? It actually called Stripe's API. In a test. Oops.
}
Welcome to the moment every developer realizes hardcoded dependencies are a tax. This chapter is about how to inject collaborators cleanly — in plain Swift, in SwiftUI, and across module boundaries.
Context — DI styles in Swift
| Style | Where it shines | Where it hurts |
|---|---|---|
| Constructor injection | Pure types, most use cases | Verbose when many deps |
| Property injection | Optional/late-bound deps | Hides dependencies |
| Method injection | One-off uses | Doesn’t scale |
| Factory pattern | When deps need params at creation | More indirection |
Service Locator / DIContainer | Quick prototypes | Hides deps, anti-pattern at scale |
@Environment (SwiftUI) | View-tree-scoped values | Only inside SwiftUI |
Property wrapper DI (e.g. @Injected) | Reduces boilerplate | Still hides deps, makes navigation harder |
The first principle
A dependency is anything your type calls that has side effects or external state: networking, storage, system clocks, randomness, analytics. Injection means handing the dependency in from outside instead of constructing it internally.
Constructor injection — the default
struct CheckoutViewModel {
let payments: PaymentsAPI
let analytics: Analytics
let clock: Clock
func checkout(amount: Money) async throws {
analytics.track(.checkoutStarted(amount))
let charge = try await payments.charge(amount, at: clock.now)
analytics.track(.checkoutCompleted(charge.id))
}
}
In tests:
let vm = CheckoutViewModel(
payments: MockPayments(),
analytics: SpyAnalytics(),
clock: FixedClock(date: .testDate)
)
This is the only DI style you need to master. Everything else is sugar or workaround.
Concept → Why → How → Code
Concept: pass collaborators in via the initializer. The compiler enforces that nothing is forgotten.
Why: total testability, no hidden globals, dependency graph readable at the type signature.
How (factory for parameter-time deps):
struct ArticleViewModelFactory {
let articleService: ArticleService
let analytics: Analytics
func make(articleID: UUID) -> ArticleViewModel {
ArticleViewModel(
articleID: articleID,
service: articleService,
analytics: analytics
)
}
}
The Factory carries the singleton-scoped deps; the articleID is supplied at navigation time.
SwiftUI’s @Environment as DI
For values shared down a view tree without explicit threading:
private struct PaymentsKey: EnvironmentKey {
static let defaultValue: PaymentsAPI = LivePaymentsAPI()
}
extension EnvironmentValues {
var payments: PaymentsAPI {
get { self[PaymentsKey.self] }
set { self[PaymentsKey.self] = newValue }
}
}
// At root:
ContentView().environment(\.payments, LivePaymentsAPI(token: tokenFromKeychain))
// In a deep child:
struct CheckoutScreen: View {
@Environment(\.payments) private var payments
var body: some View { /* … */ }
}
Strengths: no prop drilling. Weakness: dependencies are implicit — a screen’s needs aren’t visible at its call site.
@Observable + @Environment in iOS 17+
@Observable final class AppDependencies {
let payments: PaymentsAPI
let analytics: Analytics
let clock: Clock
init(payments: PaymentsAPI, analytics: Analytics, clock: Clock) {
self.payments = payments; self.analytics = analytics; self.clock = clock
}
}
@main struct App: SwiftUI.App {
@State private var deps = AppDependencies(
payments: LivePaymentsAPI(), analytics: LiveAnalytics(), clock: SystemClock()
)
var body: some Scene {
WindowGroup { ContentView().environment(deps) }
}
}
struct CheckoutScreen: View {
@Environment(AppDependencies.self) private var deps
var body: some View { /* deps.payments… */ }
}
Cleanly bundles app-scope deps; previews override one container.
Service locator (the anti-pattern that won’t die)
final class Services {
static let shared = Services()
var payments: PaymentsAPI = LivePaymentsAPI()
}
// Anywhere:
let charge = try await Services.shared.payments.charge(…)
This works, scales to medium codebases, and every large codebase eventually regrets it: dependencies hidden inside method bodies, impossible to fully mock in tests, race conditions on shared mutation.
Use as scaffolding only, never as architecture.
Property wrapper DI
Libraries like Resolver, Factory, Swinject provide @Injected var payments: PaymentsAPI. Internally they’re service locators with sugar. Useful for legacy migration; for greenfield work, prefer plain constructor injection.
In the wild
- Apple’s SwiftUI samples use
@Environmentfor system services (locale, color scheme) and a customAppDependenciesfor app-scoped deps. - Square open-sourced
swift-needle— a compile-time DI graph generator inspired by Java’s Dagger. Used at massive scale. - Most mid-sized iOS shops use constructor injection + a small
Composition Root(single file building the graph at app launch).
Common misconceptions
- “DI requires a framework.” It does not. The simplest DI is
init(dep: X). - “Singletons are DI.” They’re the opposite — they remove the choice.
- “Property wrappers like
@Injectedare best practice.” They’re a service locator with cleaner syntax. Same testability cost. - “SwiftUI’s
@StateObjectis DI.” It’s storage with lifecycle; it doesn’t inject collaborators into the type. - “DI hurts performance.” Modern Swift inlines tiny injections; the overhead is unmeasurable.
Seasoned engineer’s take
Pick constructor injection as your default. Wrap your app-scope dependencies in one container (AppDependencies). Build the graph once in your @main struct. Use @Environment to thread that container through SwiftUI without prop drilling. Avoid singletons and DI frameworks until your codebase justifies them — usually past 100k lines.
TIP: Build a
LiveServicesand aTestServicesfactory. Your tests instantiateTestServices()once; your previews instantiatePreviewServices()once.
WARNING: If your
inithas 10 parameters, that’s not a DI problem — it’s a single responsibility problem. Split the type before reaching for a DI framework.
Interview corner
Junior: “What is dependency injection?” Handing a collaborator (network client, database, clock) into a type from outside, rather than the type constructing it itself. Usually via the initializer.
Mid: “How do you do DI in SwiftUI?”
Constructor injection works for ViewModels. For values shared across the view tree, @Environment with a custom EnvironmentKey (or @Environment(MyDeps.self) for @Observable classes in iOS 17+). I avoid singletons.
Senior: “When would you reach for a DI framework like Swinject or Factory?”
Almost never in greenfield work. Plain constructor injection plus a single AppDependencies container handles most apps. I’d consider a framework only if the codebase already uses one extensively and migrating away would cost more than living with it, or if I needed compile-time-verified graphs across 50+ modules — in which case I’d evaluate swift-needle over a runtime resolver. The risk of any DI framework is hiding the dependency graph behind macros and decorators.
Red-flag answer: “I always use a DI container so my code is decoupled.” Containers don’t decouple — programming against protocols does. The candidate is conflating mechanism with goal.
Lab preview
No dedicated DI lab. Every previous lab and the upcoming Lab 12.2 (modularization) practices constructor injection. The pattern becomes muscle memory through repetition, not through a single exercise.
Next: 12.5 — The Composable Architecture (TCA)
12.5 — The Composable Architecture (TCA)
Opening scenario
A mid-level engineer asks: “Everyone at this Swift conference is using TCA. Should we?” Honest answer: maybe. TCA from Point-Free is a powerful, opinionated, unidirectional architecture. It solves real problems — and adds real cost. This chapter is your decision framework, with enough code to start a feature.
Context — TCA at a glance
| Concept | Role |
|---|---|
State | Immutable value type capturing everything the feature displays |
Action | Enum cataloguing every event (user tap, network response, timer fire) |
Reducer | Pure function (inout State, Action) -> Effect<Action> |
Effect | Anything async (network, timer, dependency call); returns more Actions |
Store | Owns state, runs reducer, dispatches effects |
TestStore | Lets you assert every state transition + effect during tests |
Dependencies | Compile-time-verified injection (their @Dependency macro) |
The mental model is Redux for Swift, with first-class async, first-class effects, and Apple-shaped ergonomics via ViewStore and SwiftUI integration.
Concept → Why → How → Code
Concept: every change to your screen is an Action; the only way to mutate state is via a pure reducer; side effects return more actions, which the reducer handles in turn.
Why: total time-travel debuggability, deterministic tests, exhaustive assertions, navigation as state.
How (modern TCA Reducer macro):
import ComposableArchitecture
import SwiftUI
@Reducer
struct CounterFeature {
@ObservableState
struct State: Equatable {
var count = 0
var isLoadingFact = false
var fact: String?
}
enum Action {
case incrementButtonTapped
case decrementButtonTapped
case getFactButtonTapped
case factResponse(Result<String, any Error>)
}
@Dependency(\.numberFact) var numberFact
var body: some ReducerOf<Self> {
Reduce { state, action in
switch action {
case .incrementButtonTapped:
state.count += 1
return .none
case .decrementButtonTapped:
state.count -= 1
return .none
case .getFactButtonTapped:
state.isLoadingFact = true
state.fact = nil
return .run { [count = state.count] send in
await send(.factResponse(Result { try await numberFact.fetch(count) }))
}
case .factResponse(.success(let text)):
state.isLoadingFact = false
state.fact = text
return .none
case .factResponse(.failure):
state.isLoadingFact = false
state.fact = "Couldn't load fact."
return .none
}
}
}
}
struct CounterView: View {
let store: StoreOf<CounterFeature>
var body: some View {
VStack(spacing: 16) {
Text("\(store.count)").font(.largeTitle)
HStack {
Button("-") { store.send(.decrementButtonTapped) }
Button("+") { store.send(.incrementButtonTapped) }
}
Button("Number fact") { store.send(.getFactButtonTapped) }
if store.isLoadingFact { ProgressView() }
if let fact = store.fact { Text(fact).padding() }
}
}
}
Dependency client
import Dependencies
struct NumberFactClient {
var fetch: (Int) async throws -> String
}
extension NumberFactClient: DependencyKey {
static let liveValue = NumberFactClient { number in
let (data, _) = try await URLSession.shared.data(
from: URL(string: "http://numbersapi.com/\(number)")!
)
return String(data: data, encoding: .utf8) ?? ""
}
static let testValue = NumberFactClient { _ in "test fact" }
}
extension DependencyValues {
var numberFact: NumberFactClient {
get { self[NumberFactClient.self] }
set { self[NumberFactClient.self] = newValue }
}
}
Testing with TestStore
@MainActor
func testFetchFact() async {
let store = TestStore(initialState: CounterFeature.State(count: 5)) {
CounterFeature()
} withDependencies: {
$0.numberFact = NumberFactClient { n in "\(n) is great" }
}
await store.send(.getFactButtonTapped) {
$0.isLoadingFact = true
}
await store.receive(\.factResponse) {
$0.isLoadingFact = false
$0.fact = "5 is great"
}
}
TestStore fails the test if any action or state change goes unasserted. This exhaustiveness catches bugs your unit tests would never see.
What TCA costs
- Steeper learning curve: junior engineers need 1–2 weeks of focused work to be productive.
- Compile times: macro expansion adds noticeable build time on large feature graphs (work around with smaller modules).
- Library lock-in: Point-Free moves fast; you’ll be tracking their breaking changes.
- Verbose for simple screens: a single-button screen has 40+ lines of TCA before any logic.
In the wild
- Isowords (Point-Free’s own multiplayer Scrabble app) — full TCA reference codebase, open source.
- Wikipedia iOS — gradually adopting TCA for new features.
- NY Times Cooking — TCA in some flows.
- Many teams adopt TCA at a feature level (one screen flow), not whole-app, to limit risk.
Common misconceptions
- “TCA is just Redux for Swift.” Closer to Elm; it bakes in side effects and dependency injection in a way Redux doesn’t.
- “You don’t need MVVM if you have TCA.” TCA replaces MVVM for that feature; mixing both adds confusion.
- “TCA is too heavy for any app.” It’s heavy per screen but scales beautifully — feature composition (
Scope,forEach) shines for complex flows. - “You can’t use UIKit with TCA.” You can — TCA has
UIViewControllerbindings viaobserve { }, though SwiftUI is the primary path. - “Effects must be async.” They can be sync (
.runwith noawait), but you rarely need that.
Seasoned engineer’s take
TCA is a team decision more than a technical one. Adopt it when (a) the team is committed to the Point-Free ecosystem, (b) you have at least one engineer who can mentor others, and (c) you’re building something complex enough to amortize the boilerplate (real-time apps, multi-step flows, undo/redo). Don’t adopt it just because you saw it on the conference circuit.
TIP: Try TCA on one isolated feature first — a settings screen or onboarding flow. Measure team velocity before and after. Decide based on data.
WARNING: TCA + SwiftData has rough edges; TCA + Core Data is well-trodden. Match your data layer choice to TCA maturity for that layer.
Interview corner
Junior: “What is TCA?”
A Swift library that brings unidirectional data flow (Redux-style) to SwiftUI/UIKit apps: every event is an Action, state mutations go through a pure reducer, side effects return more Actions, and there’s a built-in TestStore for exhaustive testing.
Mid: “How does TCA’s Effect differ from Combine.Publisher?”
Effect is async-first and tightly integrated with the reducer’s Action enum — every effect ultimately produces zero or more Actions the reducer can handle. Publisher is general-purpose reactive plumbing without the action/state framing.
Senior: “When would you advise against TCA?” For small apps where the boilerplate exceeds the testability win; for teams without a TCA champion to mentor adopters; and where compile-time constraints matter — TCA’s macros and exhaustive switch coverage add measurable build time that’s worth measuring in CI before commitment. I’d also avoid mixing TCA features with MVVM features in the same module: pick one paradigm per module to avoid cognitive thrash.
Red-flag answer: “TCA is the best architecture, period.” Architectures are tradeoffs; this answer reveals lack of production experience with multiple paradigms.
Lab preview
No dedicated TCA lab — the Point-Free tutorials (free, excellent, ~6 hours) are the canonical way in. Once you’ve completed those, retrofit one screen of your portfolio app to TCA and live with it for two weeks; you’ll know whether it’s right for your style.
Next: 12.6 — Modularization with SPM
12.6 — Modularization with Swift Package Manager
Opening scenario
Your app’s clean build is 9 minutes. Incremental builds are 90 seconds because changing a single button color recompiles the universe. You add a new engineer; they wait 20 minutes for the first build. This is the universal symptom of a monolithic target — and SPM-driven modularization is the cure.
Context — modularization options
| Approach | When | Drawbacks |
|---|---|---|
| Single target | < 20k lines, 1–2 engineers | Doesn’t scale |
| Xcode targets/frameworks | Older codebases pre-SPM | Project file conflicts, harder to share |
| SPM packages (local) | 2026 default | Initial setup cost |
| Tuist / XcodeGen | Very large teams generating projects | Extra tooling layer |
| Bazel / Buck | Hundreds of engineers (Uber, Lyft, Airbnb) | Massive infra investment |
For 90 % of iOS teams in 2026, local SPM packages is the right tool.
The modularization rules
- One feature = one package, with one or more products (
Library). - Core packages (Networking, Persistence, DesignSystem) have no feature dependencies.
- Feature packages depend on Core, never on each other directly.
- Interfaces vs implementations: split when feature A needs to call into feature B without owning the build dependency.
The dependency graph is a DAG pointing toward Core. Cycles are a build error in SPM, which is exactly the discipline you want.
Concept → Why → How → Code
Concept: every feature lives in Modules/<Feature>/Package.swift. The app target links them all and runs the composition root.
Why: parallel compilation, granular cache invalidation, enforced boundaries, previewable in isolation.
How — a typical layout:
MyApp/
├── App/ ← Xcode project (App target only)
│ └── MyApp.xcodeproj
├── Modules/
│ ├── Core/
│ │ ├── DesignSystem/Package.swift
│ │ ├── Networking/Package.swift
│ │ ├── Persistence/Package.swift
│ │ └── Analytics/Package.swift
│ ├── Features/
│ │ ├── Onboarding/Package.swift
│ │ ├── Feed/Package.swift
│ │ ├── Profile/Package.swift
│ │ └── Settings/Package.swift
│ └── Interfaces/
│ └── FeedInterface/Package.swift ← protocols only
A feature package
// Modules/Features/Feed/Package.swift
// swift-tools-version: 6.0
import PackageDescription
let package = Package(
name: "Feed",
platforms: [.iOS(.v17)],
products: [
.library(name: "Feed", targets: ["Feed"]),
],
dependencies: [
.package(path: "../../Core/DesignSystem"),
.package(path: "../../Core/Networking"),
.package(path: "../../Core/Analytics"),
.package(path: "../../Interfaces/FeedInterface"),
],
targets: [
.target(
name: "Feed",
dependencies: [
"DesignSystem", "Networking", "Analytics", "FeedInterface"
],
path: "Sources/Feed"
),
.testTarget(
name: "FeedTests",
dependencies: ["Feed"],
path: "Tests/FeedTests"
),
]
)
Interface segregation
Onboarding may need to navigate to Feed.RootView without compiling the entire Feed module. Solution: FeedInterface package containing only the navigation protocol.
// Modules/Interfaces/FeedInterface/Sources/FeedInterface/FeedRoute.swift
import SwiftUI
public protocol FeedNavigator {
func makeRootView() -> AnyView
}
// Modules/Features/Feed → conforms
public struct LiveFeedNavigator: FeedNavigator {
public init() {}
public func makeRootView() -> AnyView { AnyView(FeedRootView()) }
}
// Onboarding only imports FeedInterface (cheap)
import FeedInterface
struct OnboardingDoneView: View {
let feed: FeedNavigator
var body: some View { feed.makeRootView() }
}
Now editing Feed doesn’t trigger an Onboarding rebuild — only the App target links both.
Binary targets
For closed-source SDKs (analytics vendors, video players):
.binaryTarget(
name: "Mixpanel",
url: "https://github.com/mixpanel/mixpanel-iphone/releases/download/v5.1.0/Mixpanel.xcframework.zip",
checksum: "abc123…"
)
Compute checksum: swift package compute-checksum Mixpanel.xcframework.zip.
Build-time wins (measured)
| Before (monolith) | After (12 modules) |
|---|---|
| Clean: 9 min | Clean: 5 min |
| Incremental (1 file in 1 feature): 90 s | Incremental: 8 s |
| SwiftUI Preview compile: 45 s | Preview: 6 s |
Numbers are from a real ~120k-line codebase; your mileage varies, but the order of magnitude improvement on incremental + preview is consistent.
In the wild
- Airbnb iOS broke from monolith to 300+ modules with Bazel; the SPM-only equivalent is feasible up to ~100 modules.
- Lyft iOS: hundreds of modules, custom tooling on top of SPM.
- Apple’s own Xcode templates (the SwiftUI App template’s new “App + Package” variant in Xcode 16) ship with a 2-module split as the suggested starting point.
Common misconceptions
- “Modularization slows builds.” Initial clean builds may be marginally slower; incremental builds dramatically faster. The incremental case is what you live in daily.
- “You should modularize on day one.” Premature modularization makes early refactoring painful. Extract modules when files exceed ~30k lines or builds exceed ~3 min.
- “Every feature needs an Interface module.” Only those that other features must call into. Most features need no interface.
- “Mixing local and remote SPM dependencies is unsafe.” It’s fine —
dependencies: [.package(path: "..."), .package(url: "...")]works. - “Xcode previews break with modular SPM.” They work better — previews compile only the target module.
Seasoned engineer’s take
Start with two modules: App (the runnable target) and AppCore (everything else). Split further when build pain or team coordination forces it. Never split modules for theoretical purity. Track build times in CI as a first-class metric — they’re the canary for unmanaged growth.
TIP: Use
swift package generate-documentation(DocC) per module. A modular app generates browsable per-module docs for free.
WARNING: Circular dependencies between modules are a compiler error in SPM — that’s a feature, not a bug. Resolve by extracting an Interface module, never by breaking the rule.
Interview corner
Junior: “Why would you split an iOS app into multiple Swift packages?” Faster incremental compilation, enforced boundaries between features, parallel team work without merge conflicts, and the ability to develop and test features in isolation with SwiftUI previews.
Mid: “How do you handle a feature that needs to navigate to another feature without a build dependency?”
Extract an Interface package containing the protocol (e.g. FeedNavigator). Both features import the Interface; only the App target links the concrete implementation and wires it up at composition root. No circular dependency, decoupled compile units.
Senior: “Walk me through how you’d modularize a 200k-line monolith iOS app.”
First I’d add metrics: instrument the build to track clean and incremental times per change pattern. Then I’d identify the lowest-coupling extractable pieces — DesignSystem, Analytics, Networking — and lift them to packages without changing their callers (the package import is the only diff). I’d measure build improvement after each. For features I’d start with the smallest leaf feature, modularize it, and use that as the team template. I’d avoid the “big bang” rewrite — every modularization is a separate PR with measurable wins. Around 30+ modules I’d start evaluating whether to introduce a project-generation tool (XcodeGen/Tuist) to keep the .xcodeproj manageable, or whether to fully eliminate the xcodeproj in favor of a pure SPM workspace.
Red-flag answer: “Modularize everything for cleanliness” — without acknowledging the cost of premature splits or the importance of measuring.
Lab preview
Lab 12.2 takes a single-target, 4-screen toy app and walks you through extracting DesignSystem and Networking packages, then the first feature module. By the end you’ll have the template for modularizing any codebase.
Next: 12.7 — 100+ Interview Questions
12.7 — 100+ Interview Questions, Organized
Every answer below comes in three levels: Junior (correct surface), Mid (mechanism), Senior (tradeoffs, recovery, and “I’d also consider…”). The 3-level system is explained in 12.8. For now: read the level matching your target role; one level up tells you what the next role expects.
Swift Language (20)
1. What is an optional?
Junior: A type that can hold a value or nil; Optional<T> is an enum with .some(T) and .none.
Mid: Optionals make absence explicit at the type level; unwrap via if let, guard let, ??, optional chaining, or ! (force-unwrap, only when invariants guarantee non-nil).
Senior: Optionals are Swift’s correctness lever — they push nullability into the type system so the compiler enforces handling. I avoid force-unwrap except at integration boundaries where the invariant is provable (e.g., URL(string: "https://known.com")!). At API boundaries I prefer Result<T, E> or throwing functions over T? because they carry error information.
2. struct vs class?
J: struct is a value type, copied on assignment; class is a reference type, shared by reference.
M: Structs get free Equatable/Hashable synthesis if all stored properties conform; classes participate in inheritance and reference identity; structs are stack-allocated when possible.
S: Default to struct. Reach for class only when (a) identity matters (a Window controller), (b) inheritance is required, (c) ARC lifecycle is needed (deinit hooks), or (d) you want shared mutable state (rare and intentional). Swift’s COW (copy-on-write) on Array/Dictionary means struct copies are cheap; performance is rarely a reason to choose class.
3. What is Codable?
J: Type alias for Encodable & Decodable; conforming types can be serialized to/from JSON, plist, etc.
M: Synthesis works when properties are themselves Codable. Customize with CodingKeys, init(from:), encode(to:). Use JSONDecoder.dateDecodingStrategy, keyDecodingStrategy = .convertFromSnakeCase, etc.
S: I match server payload to Swift idioms via CodingKeys and keyDecodingStrategy rather than corrupting domain types with snake_case. For polymorphic JSON I use a type discriminator + a custom init(from:). For schema migration I keep separate DTO types and map to domain models — it isolates wire-format change from app code.
4. ARC and retain cycles?
J: ARC counts references; when count hits zero, the object deallocates. A retain cycle is two objects strongly referencing each other so neither can deallocate.
M: Break cycles with weak (optional, becomes nil when target deallocs) or unowned (non-optional, crashes if accessed after dealloc). Closures capture self strongly by default — use [weak self] to break.
S: I run Instruments’ Leaks/Allocations on every feature before shipping. Common cycles: delegate properties that should be weak, closures stored on self capturing self, NSTimer or DispatchSourceTimer holding their target. For closures whose lifetime is bounded by the function call, no capture list is needed; for long-lived stored closures, [weak self] is the default with explicit unwrap inside.
5. weak vs unowned?
J: Both prevent strong references; weak is optional (becomes nil), unowned is non-optional.
M: Use unowned when the lifetime guarantees the reference outlives the use; weak when the target may deallocate first.
S: Default to weak — the cost is one optional unwrap, the upside is crash safety. I use unowned only when (a) the relationship is parent-owns-child and the child has a back-reference, and (b) the child’s lifetime is strictly bounded by the parent’s. In Swift 5.7+ I prefer [weak self] in guard let self else { return } over unowned self to avoid even the theoretical crash.
6. Generics: when and why?
J: Generics let you write code parameterized by type, like Array<Element>.
M: Constrain with where T: Equatable or T: SomeProtocol. The compiler monomorphizes — generates specialized code per type — so there’s no runtime cost.
S: I reach for generics to express relationships between types (a Repository<Element>), not just to avoid duplication. For polymorphic dispatch I prefer existentials (any Protocol) at boundaries and generics inside hot code. Since Swift 5.7, opaque return types (some Protocol) often replace generic parameters at the API surface for cleaner signatures.
7. Protocols with associated types (PATs)?
J: Protocols that have associatedtype Element so conformers specify the concrete type.
M: PATs can’t be used as existentials before Swift 5.7. Since then any Collection works but has performance overhead; some Collection (opaque) avoids the overhead.
S: PATs model abstract algorithms (Sequence, Collection, Identifiable’s ID). The mid 2020s rule: use some PAT for opaque returns when the caller doesn’t need the concrete type; any PAT when you need to store heterogeneous conformers; generics for full type-parameter freedom. The right choice depends on whether the concrete type leaks into the API contract.
8. Equatable and Hashable — synthesized when?
J: For structs/enums where all stored properties are Equatable/Hashable; just declare conformance.
M: Classes don’t synthesize — provide == and hash(into:) manually. Synthesis respects access level.
S: I synthesize where safe; manually implement == when only a subset of properties define identity (e.g., Account equality should compare id only, not cachedAvatar). For caching/sets, Hashable must agree with Equatable — combining only the identity field in both.
9. Error handling: throws vs Result?
J: throws propagates errors up the call stack; Result<Success, Failure> carries the outcome as a value.
M: throws integrates with try/catch and async; Result is useful for callbacks or storing errors for later.
S: For Swift Concurrency code, throws is idiomatic. Result shines when you need to defer error handling — TCA effects, batch results, multi-error collection. I avoid mixing both styles in a single API; pick one and stick with it. Define typed errors (enum NetworkError) instead of leaking any Error so callers can reason about handling.
10. async/await vs Combine?
J: async/await writes async code linearly; Combine models event streams as Publishers.
M: async shines for one-shot operations; Combine for streams (UI events, polling). They interop via .values (publisher → AsyncSequence) and Future/Subject.
S: I’d lean fully on async/await for new code, with AsyncStream/AsyncSequence for streams. Combine is great but Apple’s investment in it has slowed; AsyncStream is the future. Bridging code (NotificationCenter, KVO) still benefits from Combine wrappers, but new architectures should be concurrency-first.
11. Actors?
J: A reference type that serializes access to its mutable state.
M: Methods on actors are implicitly async from outside; only one task can be inside the actor at a time. @MainActor ensures execution on the main thread.
S: Actors solve data races by construction. The catch: reentrancy — an await inside an actor method can let another task in. Hold invariants across awaits explicitly. For UI state, @MainActor is the right tool; for shared mutable caches, a custom actor. Pure value types passed across actor boundaries must be Sendable.
12. Sendable?
J: A marker protocol for types safe to send across concurrency domains.
M: Value types whose components are Sendable get conformance synthesized. Reference types need to be immutable or explicitly synchronized; mark them @unchecked Sendable if you’ve manually verified safety.
S: Swift 6 strict concurrency makes Sendable violations into compile errors. The migration cost is real but the payoff is the elimination of an entire class of data-race bugs. For legacy types I can’t easily make Sendable, I isolate them inside an actor or wrap them in @MainActor until I can refactor.
13. Property wrappers?
J: Syntax (@State, @Published) that lets a type augment a property’s storage and access.
M: Defined with @propertyWrapper struct; provides wrappedValue, optional projectedValue (the $ syntax).
S: Useful for cross-cutting concerns (persistence with @AppStorage, validation, threading). But every wrapper hides behavior at the use site — I use them sparingly, with names that hint at the cost (@MainActor-aware, lazy, etc.). For business logic, plain methods communicate intent better.
14. KeyPaths?
J: A type-safe reference to a property: \Person.name has type KeyPath<Person, String>.
M: Used by SwiftUI’s \Person.name-style API, sort(by:) with key paths, dynamic member lookup.
S: KeyPaths enable powerful generic APIs without runtime reflection — they’re compile-time. I lean on them for sorting, filtering, and DSL-style APIs. Combined with @dynamicMemberLookup, they enable Swift’s modern reactive frameworks (Observation, SwiftUI bindings).
15. Result builders (function builders)?
J: The DSL machinery behind SwiftUI’s body { … } and RegexBuilder.
M: An @resultBuilder type provides buildBlock, buildOptional, buildEither, etc. that compose nested expressions.
S: Powerful for declarative DSLs but easy to abuse — error messages from misuse are notoriously bad. I’d use them for stable APIs where the DSL benefit (readability) clearly outweighs the diagnostic cost.
16. Existentials (any P)?
J: A type-erased box that holds any conformer of protocol P.
M: Pre-Swift 5.7, written P directly (now warned). Since 5.7, requires any P. Has dynamic-dispatch overhead vs generics.
S: I use existentials at API boundaries (heterogeneous collections of conformers); generics inside hot loops. For PATs, existentials were impossible before 5.7 — Apple now allows them but the runtime cost is real. The compiler hint to write any P is teaching the cost.
17. lazy properties?
J: A stored property computed on first access, not at init.
M: Only var can be lazy; not thread-safe (multiple threads may race the first access).
S: Useful for expensive initialization; dangerous in concurrent contexts. For thread safety I’d wrap with an actor or use dispatch_once-style synchronization. In actors, lazy is implicitly safe because actor methods serialize access.
18. final keyword?
J: Prevents a class from being subclassed or a method from being overridden.
M: Enables compiler optimization (devirtualization). For modules with library evolution off (apps), defaults to non-final; with library evolution on (frameworks), defaults to final.
S: I mark every class final by default. Inheritance is a design choice, not a default; the compiler optimizations are a bonus. For SwiftUI/UIKit subclasses (UIViewController subclasses) final is fine because we don’t subclass our own subclasses.
19. Memory ordering / inout?
J: inout lets a function mutate its caller’s variable. Caller writes &value.
M: inout uses copy-in-copy-out semantics; the function operates on a local copy and writes back on return. Be careful with aliasing rules (a single inout can’t alias another argument).
S: For performance-sensitive code, inout avoids retain/release on classes. The Law of Exclusivity (Swift 5+) statically prevents most aliasing bugs. For batch mutations, prefer inout over reassigning a class reference for clarity.
20. Macros (Swift 5.9+)?
J: Compile-time code generation, like #Preview, @Observable, @Model.
M: Two kinds: freestanding (#fn(args)) and attached (@Macro). Implemented as SwiftPM packages that the compiler invokes during build.
S: Macros eliminate boilerplate without runtime overhead. The downsides: longer compile times, harder debugging (need to expand the macro to see generated code), and platform/version constraints. I trust Apple’s macros (@Observable, @Model); third-party macros I evaluate carefully for project longevity.
UIKit (15)
21. View controller lifecycle?
J: loadView → viewDidLoad → viewWillAppear → viewWillLayoutSubviews → viewDidLayoutSubviews → viewDidAppear. Mirror on disappear.
M: viewDidLoad runs once. viewWillAppear runs every show (including back navigation). Layout passes can run multiple times.
S: Don’t do animations in viewDidAppear — too late, view’s already visible. Don’t fetch data in viewWillAppear unless you want it to refire on every navigation back. Heavy work in viewDidLoad blocks first paint — defer to a Task. Memory pressure: implement didReceiveMemoryWarning to clear non-critical caches.
22. Auto Layout vs frame-based layout?
J: Auto Layout uses constraints (relationships between views); frame-based sets explicit positions.
M: Auto Layout adapts to dynamic content, multiple screen sizes, RTL languages. Frame-based is faster but brittle.
S: Modern UIKit is Auto Layout by default. Frame-based survives in custom collection view layouts and gesture-driven animations where performance is critical. For static, dense layouts I sometimes drop to manual layout in layoutSubviews. Always anchor to safeAreaLayoutGuide not view for top/bottom constraints.
23. UITableView vs UICollectionView?
J: TableView is single-column lists; CollectionView is flexible (grids, custom layouts).
M: Both support cell reuse via dequeueReusableCell. CollectionView has UICollectionViewLayout for custom arrangements; iOS 13+ added UICollectionViewCompositionalLayout.
S: I default to CollectionView with Compositional Layout in new code — it does everything TableView does plus more, with better APIs (diffable data source). TableView remains for true table semantics (Settings-style screens with insets and sections). For very large lists, profile cell configuration time; complex cells benefit from prefetch and async image loading.
24. Diffable data source?
J: API (iOS 13+) where you describe the data state via snapshots; UIKit diffs and animates.
M: UICollectionViewDiffableDataSource<Section, Item>; items must be Hashable; snapshots are applied atomically.
S: Diffable replaces the error-prone performBatchUpdates dance. Gotchas: item identity must be stable (use a stable ID hash, not the entire object); reconfiguring vs reloading items has different animation behavior. For performance, reuse cell registrations via UICollectionView.CellRegistration.
25. Auto Layout performance tips?
J: Avoid too many constraints; reuse views via dequeue.
M: Profile with Instruments’ “Layout” tool. Common culprits: setting translatesAutoresizingMaskIntoConstraints wrong, conflicting priorities, unnecessary setNeedsLayout calls.
S: For dense lists (chat apps, feeds), self-sizing cells with Auto Layout can hit ~50 layout passes per scroll second. Optimizations: cache cell heights with a heuristic, use UICollectionViewCompositionalLayout’s estimated sizes carefully, or for extreme cases drop to manual layout in sizeThatFits and layoutSubviews.
26. Delegate pattern?
J: An object holds a weak reference to a delegate (protocol-conformer) and calls back into it.
M: Always weak to avoid retain cycles. Protocol can be class-bound to allow weak, or use AnyObject constraint.
S: Delegate is the idiomatic UIKit callback pattern, but it doesn’t scale to many observers — for that use NotificationCenter or Combine. For multi-callback APIs, consider protocols with default implementations to make adoption cheap. In modern Swift, replacing delegates with AsyncStream works well for event sequences.
27. Responder chain?
J: Touch events flow through view hierarchy: UIView → superview → … → UIViewController → UIApplication.
M: First responder handles text input. becomeFirstResponder()/resignFirstResponder() control focus. Custom actions via UIResponder.canPerformAction.
S: The responder chain is iOS’s secret event router — useful for cross-cutting actions (a “save” toolbar button that finds the right responder). Custom UIKit components benefit from participating in the chain via overriding next and canPerformAction. SwiftUI hides this; bridging back via UIViewControllerRepresentable exposes it again.
28. Threading: main vs background?
J: UIKit must be touched from the main thread; long work on background queues.
M: DispatchQueue.main.async, DispatchQueue.global(qos:), OperationQueue. Wrap UI updates: DispatchQueue.main.async { self.label.text = … }.
S: Concurrency in 2026 means @MainActor annotation for UIKit-touching code; Task.detached for background work. The Main Thread Checker catches violations in debug. Profile with Instruments’ Time Profiler to find work blocking the main thread — common culprits: JSONDecoder of large payloads, image decoding, Auto Layout passes.
29. viewWillTransition(to:with:)?
J: Called on orientation changes or split-screen resize.
M: coordinator.animate(alongsideTransition:) lets you animate alongside.
S: For iPad multitasking, this fires often. Use traitCollectionDidChange for richer adaptation (size class changes). For SwiftUI, this is replaced by @Environment(\.horizontalSizeClass) and GeometryReader.
30. UIView animations vs Core Animation?
J: UIView.animate(withDuration:) is a high-level wrapper; under the hood, Core Animation animates CALayer properties.
M: Properties animatable: position, bounds, transform, opacity, backgroundColor, etc. CA-only properties (cornerRadius, custom layers) need explicit CABasicAnimation.
S: For physics-y feel, UIViewPropertyAnimator allows interruptible, reversible animations. For 60+fps custom animations, use CADisplayLink driving manual CATransform3D updates. For Metal-backed effects, CAMetalLayer. Knowing when each layer of the abstraction is needed is the senior skill.
31. CALayer performance traps?
J: cornerRadius + masksToBounds triggers offscreen rendering, hurting scroll performance.
M: Profile in Instruments → Core Animation → toggle “Color Offscreen-Rendered Yellow”. Pre-rasterize with shouldRasterize = true (set rasterizationScale).
S: Modern iOS hardware handles corner rounding cheaply for most cases, but stacked translucent layers, shadows, and complex masks still tank scroll FPS. Pre-rendering rounded images at the image-loading layer beats per-frame corner masking. For lists with many shadows, use a static shadowPath instead of automatic shadow computation.
32. UIScrollView contentOffset vs contentInset?
J: contentOffset is the scroll position; contentInset adds padding inside the scroll bounds.
M: adjustedContentInset includes safe area + keyboard. iOS 11+ added contentInsetAdjustmentBehavior for fine control.
S: Mishandled insets are the #1 source of “content under nav bar” bugs. Always read adjustedContentInset, not raw contentInset. For keyboard avoidance, observe UIResponder.keyboardWillShowNotification and adjust contentInset.bottom. SwiftUI’s .scrollDismissesKeyboard removes much of this pain.
33. Storyboards vs programmatic UI?
J: Storyboards are visual editors; programmatic UI is code-only. M: Storyboards merge poorly in git, can be slow to render in Xcode, and segue lifecycle is opaque. Programmatic gives full control. S: For teams larger than two engineers, programmatic UI (or SwiftUI) wins. For solo prototypes, Storyboards are fast. XIBs (single-view) are a middle ground — used for individual cells/views. Apple’s own samples have been increasingly programmatic since iOS 14.
34. UICollectionViewCompositionalLayout?
J: A flexible CollectionView layout system: define groups of items into sections.
M: Compose NSCollectionLayoutItem into NSCollectionLayoutGroup into NSCollectionLayoutSection. Supports orthogonal scrolling per section.
S: This is the right layout for 2026 — replaces UICollectionViewFlowLayout for any non-trivial grid. Pairs with diffable data sources for declarative collection screens. Performance is excellent; the API is verbose but the readability win is real once you’ve internalized the composition model.
35. UIKit + SwiftUI interop?
J: UIHostingController wraps a SwiftUI view in UIKit; UIViewRepresentable wraps UIKit in SwiftUI.
M: UIViewControllerRepresentable for full VC bridging. Bindings flow via @Binding for two-way state.
S: Bridge at the largest sensible boundary — a whole screen, not individual labels. For navigation, wrap SwiftUI in UIHostingController inside an existing UINavigationController. The performance overhead of hosting is small; the code complexity overhead of mixing is what to manage.
SwiftUI (15)
36. @State vs @StateObject vs @ObservedObject vs @Bindable?
J: @State for value-type local state; @StateObject for reference-type state owned by this view; @ObservedObject for reference-type state injected from outside; @Bindable (iOS 17+) for @Observable macro types.
M: @StateObject initializes once per view lifetime; @ObservedObject re-initializes when the parent re-creates the view (a common bug source).
S: With @Observable macro (Swift 5.9+), the world simplified: @State for owned reference types, @Environment(MyType.self) for injected. The Combine-era distinctions are mostly legacy. For new code I default to @Observable + @State/@Environment and avoid ObservableObject entirely.
37. View identity in SwiftUI?
J: Identity is what makes SwiftUI know “this is the same view, animate the change.”
M: Position-based by default; .id(value) makes identity explicit. Changing identity tears down and rebuilds the view.
S: Identity bugs are SwiftUI’s hardest — a view “loses its state” because identity changed (often via .id(UUID()) in body, fatal). Use .id() only for intentional resets. For ForEach, the identifier must be stable across data updates. Misunderstanding identity is the most common cause of SwiftUI animation glitches.
38. body is called how often?
J: Whenever the view’s dependencies change.
M: SwiftUI tracks property reads inside body. Only reads of @State/@Observable properties trigger re-evaluation; computed properties not stored as state don’t.
S: Treat body as a pure function called frequently — never put side effects in it. For expensive computation, cache in @State or move out via .task modifier. The @Observable macro is more granular than ObservableObject: it tracks per-property reads, so unrelated properties don’t trigger redraws.
39. NavigationStack vs NavigationView?
J: NavigationView is the iOS 13 API; NavigationStack (iOS 16+) is the modern replacement.
M: NavigationStack exposes the navigation path as state (@State var path: [Route]), enabling deep linking, programmatic navigation, and proper SwiftUI-style declarative navigation.
S: NavigationStack’s biggest win is deterministic state-driven navigation. Build a Route enum, bind a NavigationPath (or typed array), and navigation becomes testable. Avoid mixing NavigationLink(isActive:) legacy API with NavigationStack — they conflict.
40. task vs onAppear?
J: task runs async work; onAppear is a sync callback.
M: task auto-cancels when the view disappears. onAppear doesn’t, so async work started there can leak past view lifetime.
S: Default to .task for any async work — the auto-cancellation is critical. Use .task(id:) to restart work when an ID changes. onAppear is for sync analytics/logging only. The .refreshable modifier ties into the same cooperative cancellation model.
41. EnvironmentObject vs @Environment(MyType.self)?
J: Both inject shared values down the view tree.
M: EnvironmentObject is the Combine-era API for ObservableObject; @Environment(MyType.self) is the iOS 17+ API for @Observable.
S: For new code, use @Observable + @Environment(MyType.self). Missing environment objects crash at runtime in the legacy API — a footgun. The new API also crashes if missing, but with @Observable you can provide defaults via @Bindable patterns more easily.
42. GeometryReader — when and when not?
J: A view that reads its parent’s size.
M: Use to read available space; pitfall is it claims all available space, breaking layouts that expect natural sizing.
S: I avoid GeometryReader for layout — use stack alignment and frame modifiers first. Reach for it only when I need coordinate transforms (drag offsets) or precise read-back of size for animations. iOS 16+ ViewThatFits and Grid removed most legitimate uses.
43. PreferenceKey?
J: A way for child views to send values up to ancestors.
M: Define a PreferenceKey with defaultValue and reduce; children set values via .preference(key:value:); ancestor reads via .onPreferenceChange(_:perform:).
S: Useful for “tell my parent how tall I am” patterns and for cross-cutting concerns like collecting all visible items in a scroll view. Overuse leads to upward data flow that fights SwiftUI’s declarative grain — use sparingly, prefer state at the right level instead.
44. View modifiers — when to extract?
J: When you copy the same chain of modifiers more than twice, extract into a ViewModifier.
M: struct MyStyle: ViewModifier { func body(content: Content) -> some View { … } }, then .modifier(MyStyle()) or .myStyle() via extension.
S: Custom modifiers are the right abstraction for cross-cutting visual concerns (a design-system “card” style). Don’t wrap them in functions that return some View just to save a few characters — actual ViewModifier types compose better and participate in identity properly.
45. Animations: implicit vs explicit?
J: .animation(.default, value: state) is implicit; withAnimation { … } is explicit.
M: Implicit attaches to a specific value change; explicit wraps the state mutation. Animation modifier order matters — modifiers after .animation aren’t animated.
S: I prefer explicit withAnimation because the cause-effect link is at the call site. The iOS 17+ .animation(_:value:) form with a closure is even cleaner. For complex sequenced animations, Animation.spring(response:dampingFraction:) + withAnimation + Task.sleep patterns are more readable than nested completion handlers.
46. Transactions?
J: A Transaction carries animation context through a state change.
M: withTransaction(Transaction(animation: nil)) { state = newValue } disables animation for one mutation; transaction { $0.disablesAnimations = true } for view-level control.
S: Useful when child animations should override parent context — e.g., disabling animation for a scroll position restore inside an otherwise-animated parent. Power user feature; most apps never need it explicitly.
47. @Bindable (iOS 17+)?
J: Lets you create bindings ($model.name) into an @Observable reference type that you don’t own.
M: Replaces the @ObservedObject + $ pattern. For state owned by the view, @State already provides bindings.
S: The @Bindable macro fills the gap where you need two-way bindings into a reference type passed down. Avoid overusing — if a child needs to mutate a parent’s state, often a callback (onCommit:) is clearer than a binding.
48. SwiftUI performance debugging?
J: Use Self._printChanges() inside body to see what triggered re-evaluation.
M: Instruments has a “SwiftUI” template tracking view body evaluations and update frequency. Equatable views can short-circuit comparisons.
S: For 60fps lists, the typical wins: make rows Equatable, hoist filtering out of body, avoid GeometryReader in row cells, use LazyVStack/LazyHStack over eager stacks for long lists. The @Observable macro is more performant than @Published because it tracks per-property reads.
49. List vs LazyVStack?
J: List provides built-in styling (separators, swipe actions); LazyVStack is a stack that only renders visible children.
M: List lazily renders rows too. LazyVStack is bare — you provide everything. List uses UITableView under the hood (iOS), so it inherits some UIKit quirks.
S: List for system-conforming list UI; LazyVStack inside ScrollView for custom layouts (chat, feeds with mixed cell heights). For very large lists in LazyVStack, give items stable IDs via ForEach(items, id: \.id) to enable diffing.
50. Cross-platform with SwiftUI?
J: One codebase targets iOS, macOS, watchOS, tvOS, visionOS.
M: Conditional code via #if os(iOS); size classes adapt layouts. Some modifiers are platform-specific.
S: True cross-platform shares ~70 % of UI code; the rest is adaptation. Build with .modifier(PlatformSpecificStyle()) patterns to keep platform ifs contained. macOS often needs custom keyboard handling, window scenes, and different navigation conventions. visionOS adds spatial considerations. Apple’s Backyard Birds sample is a good reference.
Data & Networking (15)
51. URLSession configuration types?
J: .default, .ephemeral (no disk caching), .background (downloads survive app suspension).
M: Background sessions require a URLSessionDelegate and the app’s handleEventsForBackgroundURLSession.
S: Most apps use .default with a custom URLSessionConfiguration setting timeoutIntervalForRequest, httpAdditionalHeaders, and urlCache. Background sessions are for large file ops (video downloads, podcast prefetch); they’re overkill and complex for normal API calls.
52. Codable performance?
J: JSONDecoder is generally fast; pre-decoded Data to model.
M: Decoding huge arrays of small types can be slow due to repeated keyed container lookups. For megabyte+ payloads, consider streaming or JSONSerialization for selective parsing.
S: I profile decode time in Instruments for any payload > 100kB. Wins: avoid wrapping single-property containers (extra allocation per item), use snake_case decoding strategy rather than per-property CodingKeys where possible, and stream paginated APIs rather than loading everything. For binary protocols, BinaryCodable or Protobuf beats JSON by 5–10×.
53. Networking error handling strategy?
J: Catch errors at the call site, show alerts on failure.
M: Define a typed error enum (enum APIError: Error { case network, decoding, server(Int, String) }) and translate URLError, DecodingError, HTTP status codes into it.
S: Errors are part of the API contract. I distinguish recoverable (retry — network blip, 5xx) from non-recoverable (4xx, decode failures = bug, propagate to error tracking). Use exponential backoff for retries; never retry POST without idempotency keys; surface user-actionable messages without leaking technical detail.
54. Authentication: where to store tokens?
J: Keychain — never UserDefaults.
M: Keychain Services API; for cross-device sync, iCloud Keychain via kSecAttrSynchronizable. Wrap with a typed KeychainStore interface.
S: For OAuth refresh tokens, store in Keychain with kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly (won’t sync to other devices, won’t be in iCloud backups). For access tokens that are short-lived, in-memory only. Implement automatic refresh in a request interceptor; serialize refresh attempts to avoid stampede.
55. Pagination strategies?
J: Offset/limit; cursor-based.
M: Cursor is preferred for live data (no skipped items when new data inserts). Implement infinite scroll by triggering load on visible-row threshold.
S: For client UX, optimistic insert (prepend new items immediately, reconcile after server confirm) eliminates perceived latency. For very large datasets, virtual scrolling with windowing — but LazyVStack covers most needs. Trickiest: handling concurrent edits to the same page across devices.
56. CoreData vs SwiftData?
J: SwiftData (iOS 17+) is the modern, Swift-native replacement for Core Data.
M: SwiftData uses the @Model macro; under the hood it’s Core Data with a nicer API. Same migration, sync, and CloudKit story.
S: For new iOS 17+ apps, SwiftData with the @Model macro. For legacy code or iOS 16 support, Core Data. SwiftData is still maturing — expect rough edges around complex predicates, batch operations, and some sync scenarios. The interop story is good: a Core Data model can be exposed as SwiftData incrementally.
57. CloudKit basics?
J: Apple’s iCloud backend; database-as-a-service with private and public scopes.
M: Three databases: private (user’s iCloud), shared (collaboration), public (app-wide). Records are CKRecord with typed fields. Push notifications notify clients of changes.
S: CloudKit shines for personal-data sync (notes, journals, fitness) where Apple’s free tier and zero-config auth are killer features. It fails for: heavy server-side logic (no compute), arbitrary querying, cross-platform (only Apple devices), and apps needing custom auth. For those, build a real backend.
58. Image caching strategy?
J: Library like Kingfisher or Nuke; for simple cases, URLCache.
M: Two-tier cache: in-memory (NSCache) for hot images, disk for cold. Decode on background thread; downsample large images to displayed size.
S: For lists with many images, prefetching the next N images during scroll prevents pop-in. Downsampling at decode time (not display time) saves memory by 5–10×. For Retina displays, downsample to targetSize * UIScreen.main.scale. Avoid setting image on a UIImageView from an async callback if the cell was already reused — pass an identifier and check before setting.
59. WebSockets in iOS?
J: URLSessionWebSocketTask (iOS 13+) provides built-in WebSocket support.
M: webSocketTask(with:) returns a task; .receive() returns the next message as async value.
S: For chat/real-time apps, WebSockets are the standard. Implement: heartbeat ping every 30s, automatic reconnect with backoff, queue outgoing messages during disconnect. For massive scale, prefer a vendor (Pusher, Ably, PubNub) or roll your own atop Vapor or a Go backend. SSE (Server-Sent Events) is simpler when one-way streaming suffices.
60. Combine vs AsyncStream?
J: Combine is reactive streams; AsyncStream is async iteration.
M: Combine has rich operators (debounce, combineLatest); AsyncStream is bare but integrates with async/await natively.
S: New code: AsyncStream/AsyncSequence. For UI debounce, throttle, combine-latest patterns, AsyncAlgorithms (Apple’s Swift Algorithms for async) covers most needs. Combine remains for KVO/Notification bridging; gradually retire it. For background pipelines that need backpressure, AsyncStream with bufferingPolicy: .bufferingNewest(N).
61. Push notification delivery model?
J: APNs delivers via Apple’s servers; app receives in foreground (userNotificationCenter:willPresent:) or background.
M: Silent push (content-available: 1) wakes the app for background work; user-visible push displays UI. Token-based (.p8) or certificate (.p12) auth to APNs.
S: Silent push is throttled by Apple; don’t rely on it for time-critical updates. For high-frequency real-time updates, WebSocket beats silent push. Always store the latest push payload in your backend even if delivery fails — APNs is best-effort, not guaranteed.
62. Background tasks?
J: BGTaskScheduler (iOS 13+) schedules background app refresh and processing tasks.
M: Register tasks at launch; schedule with BGAppRefreshTaskRequest or BGProcessingTaskRequest. System decides when to run.
S: iOS aggressively kills apps; expect background tasks to run less often than you ask for. For reliable delivery use push + server-side state. For periodic sync, schedule but never assume timing. Wake-from-suspend has tight CPU/network budgets — keep tasks short and resumable.
63. Concurrent network requests?
J: Use async let for parallel awaits; TaskGroup for dynamic counts.
M: async let is good for a fixed handful; TaskGroup for collections.
S: For N independent calls, parallelism speeds up wall time but increases peak memory. For very large N, throttle with a semaphore or withThrowingTaskGroup + for try await with a concurrency cap. Apple’s AsyncAlgorithms’ chunked(into:) and mapAsync(maxConcurrency:) help. Always profile — sometimes serial is fast enough and simpler.
64. GraphQL vs REST in iOS?
J: GraphQL fetches exactly the fields you ask for; REST returns fixed payloads. M: Apollo iOS is the dominant GraphQL client; generates type-safe queries. S: GraphQL shines for clients that need many variants of the same data (different feeds, different screens). The cost is server complexity and tooling overhead. For most B2C apps, well-designed REST with good caching is simpler. I’ve seen GraphQL adopted for frontend velocity reasons (no backend changes needed for new fields) and abandoned for backend complexity reasons (caching, N+1, schema sprawl).
65. Offline-first design?
J: Persist all data locally; sync to server in background. M: Local-first store (SwiftData/Realm/SQLite); conflict resolution policy (last-write-wins, three-way merge, CRDT). S: True offline-first requires designing the data model with conflict resolution in mind from day one. CRDTs (e.g., Yjs in iOS via Yniffer or custom Swift CRDTs) provide automatic merge. Simpler: per-field last-write-wins with timestamps + manual conflict resolution UI for irreconcilable cases. Watch out for clock skew — use server timestamps or vector clocks for ordering.
Architecture (15)
66. When MVC, MVVM, VIPER, or TCA?
See 12.1–12.5. Quick answer: MVC for small; MVVM for testable single screens; Clean/VIPER for domain-heavy enterprise; TCA for unidirectional + exhaustive testing.
67. Coordinator pattern?
J: Pulls navigation logic out of view controllers into a Coordinator object.
M: Parent coordinator owns children; each coordinator handles one flow.
S: Coordinators decouple navigation from screen logic, easing deep linking and tests. With NavigationStack (iOS 16+), much of this is state-based in SwiftUI; UIKit codebases still benefit. The hard part is communication back to coordinator on completion — closures, delegate, or Combine.
68. Single source of truth?
J: Each piece of state lives in one canonical location; other places derive from it.
M: SwiftUI’s @State/@Observable enforce this; in UIKit, you must discipline yourself.
S: Violations create the “stale UI” class of bugs (two views showing different values for the same logical thing). For server-synced state, the server is the truth and local store is a cache. For local-only state, pick the layer (model, view model) and never duplicate.
69. Repository pattern?
J: Abstracts data access behind a protocol; underlying source (network, cache, DB) can change.
M: protocol UserRepository { func fetch(id: UUID) async throws -> User }. Implementations: RemoteUserRepo, CachedUserRepo, MockUserRepo.
S: Repositories are testability sweet spot — swap mock for real. The cache layer can wrap remote: CachedUserRepo(remote: RemoteUserRepo(...), store: SwiftDataStore()). Avoid leaking implementation types into the protocol (e.g., don’t return NSManagedObject).
70. State management beyond SwiftUI?
J: For shared mutable state, central store (TCA, Redux-style) or actor.
M: Avoid singletons; pass dependencies. For cross-screen state, use composition root.
S: The trap: every app eventually grows a “current user” singleton, then a “current document,” then a “current network state.” The solution is one composition root (an AppDependencies-style container) injected via DI, with state owned by typed stores or actors. SwiftUI’s environment is one mechanism; constructor injection is the other.
71. Feature flags?
J: Toggle features on/off without redeploying via a remote config. M: Vendors: Firebase Remote Config, LaunchDarkly, Statsig. Wrap behind a typed interface. S: Feature flags enable trunk-based development and gradual rollouts. Discipline: every flag has an owner and an end-of-life date. Otherwise codebases accumulate decade-old flags. For experimentation, flags with cohort assignment + analytics integration.
72. A/B testing infrastructure?
J: Assign users to variants; track outcomes; compare statistically.
M: Vendors handle assignment (sticky per user) + analytics. Critical: don’t change a user’s variant mid-experiment.
S: I integrate experiments behind a typed Experiment<Variant> interface so the rest of the code doesn’t know about the testing infrastructure. For client-side experiments, the variant assignment should be deterministic given the user ID (no network call needed for assignment). Server-side flags are simpler but tie variant changes to deploys.
73. Push-to-test architecture?
J: Ability to push a change to a test cohort first. M: Combine feature flags + TestFlight + analytics segmentation. S: Mature shops have a “canary” cohort (internal users + opted-in beta) receiving every change first, then a 1 %, 10 %, 50 %, 100 % gradual rollout. The infra cost is real but the safety net for a 10M+ user app is invaluable.
74. Mobile + backend contract design?
J: REST API or GraphQL schema; mobile and backend agree on shape.
M: Backward compatibility: never break existing client versions. Mobile clients can’t be force-updated overnight.
S: API versioning is a discipline: every change is additive (add fields, never remove or rename). Old fields are deprecated, kept indefinitely. Minimum supported app version forces eventual cleanup. For breaking changes, use new endpoint versions (/v2/) with mobile detecting and routing. Schema-first design tools (OpenAPI, GraphQL SDL) catch issues early.
75. Local + remote state reconciliation?
J: Optimistic update locally; if server rejects, revert. M: Pattern: send mutation, mark local item as “pending”; on success, mark “synced”; on failure, mark “failed” with retry. S: For multi-step user flows, accumulate optimistic mutations in a queue; replay in order with reconciliation. CRDTs make this automatic; explicit code requires careful handling of out-of-order responses and partial failures. Show “syncing” UI for transparency; don’t hide partial-state failures.
76. SwiftUI state pyramid?
J: Local view state in @State; shared state lifted to nearest common ancestor.
M: Don’t lift state higher than necessary — keeps re-render scope small.
S: The hardest call is “do I lift this to app scope or keep it per-screen?” Rule of thumb: if two screens need the same state, lift to their nearest common ancestor (often the navigation parent). For global concerns (current user, theme), lift to root via @Environment. Premature global state makes testing hard.
77. Reactive vs imperative?
J: Reactive describes state→UI as a function; imperative mutates UI step by step. M: SwiftUI is reactive; UIKit is imperative (with reactive extensions via Combine). S: Reactive scales better to complex UI but has a steeper learning curve (identity, animations, debugging are harder). Imperative gives finer control but more bugs around state synchronization. Most modern iOS work is reactive; legacy is imperative. Mixing per screen is fine; mixing within a screen is asking for bugs.
78. Modularization heuristic?
See 12.6. TL;DR: extract when builds slow or merge conflicts spike; otherwise wait.
79. Server-driven UI?
J: Server sends layout descriptions (JSON), client renders dynamically. M: Used by Airbnb (Epoxy + DLS), Netflix (Falcor + UI specs). Reduces app updates for content changes. S: Powerful for content-heavy apps where layouts change often; overkill for traditional product UI. The cost: client must support a UI DSL (a mini interpreter), and the server team owns layout decisions. For apps with weekly App Store reviews wanted as a release valve, it’s a strategic investment.
80. Plugin/extension architecture?
J: App can load additional functionality dynamically. M: iOS app extensions (share, widgets, intents) are sandboxed bundles. Internal modularization via SPM features is a softer form. S: For consumer apps, plugin systems are rare (App Store review wants known surfaces). For developer tools (Xcode, BBEdit), they’re essential. Internal “plugin” patterns — feature flagged modules, dynamic config — give similar flexibility without the security/review headache.
Performance (10)
81. Profiling tools?
J: Instruments — Time Profiler, Allocations, Leaks, Network, Energy. M: Time Profiler shows what’s running on each thread; Allocations tracks heap growth; SwiftUI template for view body churn. S: I profile before optimizing. The intuition for “this is slow” is usually wrong. Time Profiler with “Hide System Libraries” and “Invert Call Tree” surfaces user code hotspots. For energy, the Energy Log instrument on device is the only reliable measure — Simulator is misleading.
82. Main thread blocking — finding it?
J: App freezes; Main Thread Checker reports during debug.
M: Instruments’ Time Profiler with main thread filter. Watch for “Hangs” warnings in Xcode Organizer for production crashes.
S: Common culprits: large JSON decoding, image decoding, synchronous file I/O, Core Data fetches without performBackgroundTask. The fix is moving to background queue or Task.detached; the discipline is having a code review rule that any data(contentsOf:) outside Task fails review.
83. App launch optimization?
J: Reduce work done in application(_:didFinishLaunchingWithOptions:).
M: Instruments’ “App Launch” template breaks down dyld, ObjC runtime, app init. Defer non-essential setup.
S: For complex apps, “first frame” is the metric — measured via os_signpost from applicationDidFinishLaunching to first UIWindow.makeKeyAndVisible. Wins: lazy-init analytics, push registration after first view, async-init persistence stores. iOS 16+ MetricKit + Xcode Organizer give per-version launch percentiles in production.
84. Memory leaks vs retain cycles?
J: Leak = unreachable memory not deallocated; retain cycle = mutual strong references. M: Instruments’ Leaks instrument finds true leaks; cycles often need Memory Graph Debugger (Xcode → Debug Memory Graph). S: Most “leaks” in iOS are retain cycles, not true leaks. Memory Graph Debugger shows the strong-reference graph — find cycles visually. Common: closure captures, delegate not weak, NotificationCenter observers not removed (less common with block-based API). For NSCache vs Dictionary: NSCache auto-evicts under pressure.
85. Image performance?
J: Downsample images at load time, not display time.
M: CGImageSourceCreateThumbnailAtIndex with kCGImageSourceThumbnailMaxPixelSize. Decode off main thread.
S: Big wins in feeds: progressive JPEG for perceived speed, downsample to displayed size (counted in pixels not points), recycle UIImage allocations via cache, prefetch via UICollectionView/UITableView prefetch APIs. For HEIC, decode is faster but encoding takes longer — fine for assets.
86. Scroll performance?
J: Aim for 60fps; profile in Instruments → Core Animation.
M: Avoid offscreen rendering (corner radius + masksToBounds), reduce layer count, cache cell heights.
S: For 120Hz ProMotion devices, target 120fps. Common wins: pre-compose static layers once (shouldRasterize), use opaque backgrounds (isOpaque = true), avoid shadows or use shadowPath. For text-heavy cells, profile attributed string rendering — NSAttributedString parsing can dominate.
87. Battery drain?
J: Background work, GPS, network all drain battery.
M: Energy Log instrument shows draw per subsystem.
S: Common offenders: location with kCLLocationAccuracyBest always-on, background uploads with cellular fallback, frequent CPU wakeups, animations that keep the display on. Use CLLocationManager.allowsBackgroundLocationUpdates only when needed; coalesce network into batches; respect Low Power Mode (ProcessInfo.processInfo.isLowPowerModeEnabled).
88. Networking efficiency?
J: Compress requests/responses (gzip), use HTTP/2, cache aggressively.
M: URLSessionConfiguration.allowsConstrainedNetworkAccess, allowsExpensiveNetworkAccess for Low Data Mode. Use HTTP/3 (QUIC) where server supports.
S: For chatty APIs, batch endpoints save round trips. Cache headers (Cache-Control, ETag) reduce redundant transfers. Image CDN with content negotiation (WebP/AVIF for supported clients). For bandwidth-constrained users, downsample images on the server based on Save-Data header.
89. Core Data performance?
J: Fetch in batches; use NSFetchedResultsController for table-driven UI.
M: Background contexts for writes; merge to view context for reads. setReturnsObjectsAsFaults(false) to materialize objects.
S: Common pitfalls: faulting in loops (N+1 fault queries), holding objects across context boundaries, unindexed predicates causing full-table scans. Profile with SQL Debug (-com.apple.CoreData.SQLDebug 1). For very large stores, denormalize for read-heavy queries.
90. SwiftUI render performance?
J: Use _printChanges() and Instruments’ SwiftUI template.
M: Lazy stacks, equatable views, minimal @Observable property reads in body.
S: For lists of 1000+ items, ensure LazyVStack is used (not VStack) and each row is Equatable. Hoist filtering/sorting out of body — compute once, store in @State. Avoid GeometryReader in cells. The @Observable macro’s per-property tracking is a major win over ObservableObject.
Security (10)
See Phase 9 — Security for deep coverage. Quick answers:
91. Keychain best practices?
Store credentials with kSecAttrAccessibleWhenUnlockedThisDeviceOnly by default; never use UserDefaults; wrap with typed Swift API.
92. ATS (App Transport Security)?
Require HTTPS by default; exceptions only with technical justification (legacy backend on the migration path).
93. Jailbreak detection?
Best-effort, never security; check for known paths (/Applications/Cydia.app), URL(string: "cydia://"), fork() succeeding. Defense in depth: sensitive ops should validate server-side too.
94. Code obfuscation in Swift?
Limited utility — Swift compiles to native code already. Strip symbols in Release. For high-value IP, server-side execution beats client obfuscation.
95. Certificate pinning?
Pin to a SubjectPublicKeyInfo hash to survive cert rotation; never pin to a leaf cert that rotates frequently; ship multiple pins (current + next-rotation backup) to prevent app-bricking.
96. Secure storage of API keys?
Don’t ship API keys in plist or code — they’re trivially extractable. Use server-mediated auth (mobile → your backend → third-party API) or short-lived tokens issued by your backend.
97. OWASP Mobile Top 10 highlights?
Improper credential storage, insufficient cryptography, insecure communication, code tampering. Map each to iOS controls: Keychain, CryptoKit, ATS, TestFlight + server-side validation.
98. WebView XSS?
Treat WKWebView like a browser: never inject untrusted HTML, use WKContentRuleList to block scripts, validate evaluateJavaScript inputs.
99. Deep link validation?
Treat URL parameters as untrusted input. Validate schemes (only your app’s), sanitize parameters before use, never auto-execute actions without user confirmation.
100. Privacy: what data goes where?
Privacy Manifest (iOS 17+) declares data types collected. App Tracking Transparency (ATTrackingManager) gates IDFA. Audit third-party SDKs — they’re often the data leakage.
How to use this list
- Print it. Skim weekly.
- Pick five questions a week, write your answers, then read the senior level.
- Note the gaps. The senior answers reveal what experience teaches.
- For your target role, ensure you can answer your level and one level up.
Phase 12.8 explains the three-level system itself — once you internalize it, you’ll spot it in every interview answer for the rest of your career.
Next: 12.8 — The 3-Level Answer System
12.8 — The 3-Level Answer System
Opening scenario
Interviewer: “What’s a struct?”
Candidate A: “It’s a value type.”
Candidate B: “It’s a value type — copied on assignment, stack-allocated when possible, no inheritance, gets free Equatable/Hashable synthesis.”
Candidate C: “It’s a value type — copied on assignment, stack-allocated when possible, no inheritance. I default to structs over classes because they prevent shared mutable state bugs; I reach for class only when identity matters, inheritance is required, or I need ARC lifecycle hooks. The COW optimization means even large collections are cheap to copy.”
All three are right. Candidate A is technically correct. Candidate B demonstrates knowledge. Candidate C demonstrates judgment. This chapter is about how to be C, on demand, for any question.
Context — why levels exist
Interviewers calibrate by listening for scope of consideration. A junior should know the right answer. A mid should know how it works. A senior should know when not to use it.
| Level | Question signal | Answer pattern |
|---|---|---|
| Junior | “What is X?” | Define X, give one example |
| Mid | “How does X work?” | Define + mechanism + tradeoff |
| Senior | “When would you use X?” / “How would you design Y?” | Define + mechanism + tradeoff + recovery + adjacent options |
The 3-level template
LEVEL 1 (Junior — 1 sentence): Definition in your own words.
LEVEL 2 (Mid — 2–3 sentences): Mechanism / how it works under the hood.
LEVEL 3 (Senior — bridge phrase + alternatives + costs):
"I'd also consider…" + when it breaks + maintenance angle.
Examples for “What is Combine?”:
- L1: “Apple’s reactive framework — chains of publishers and subscribers for event streams.”
- L2: “Built on
Publisherprotocol with operators (map,filter,debounce); subscribers receive values viasinkorassign. Backpressure via demand.” - L3: “For new code I’d actually lean on
AsyncStreamandAsyncSequence— Apple’s investment in Combine has slowed since Swift Concurrency landed. Combine remains the right tool for KVO/NotificationCenter bridging and existing pipelines. The cost of migrating Combine to async is mostly mechanical; the cost of building greenfield on Combine is taking on an aging API.”
The bridge phrases
Memorize three. Use one per senior answer:
- “I’d also consider…” — signals you’re aware of alternatives without disparaging the question.
- “In production I’ve seen…” — adds operational experience to theory.
- “The tradeoff is…” — directly names the cost/benefit.
Avoid: “Actually…” (sounds corrective), “It depends…” without specifying on what (sounds evasive), “Best practice is…” (sounds dogmatic).
Recognizing question scope
Listen to the verb:
| Verb | Likely scope |
|---|---|
| What | Junior / definition |
| How does it work | Mid / mechanism |
| When would you | Senior / judgment |
| Why is it / why would | Senior / rationale |
| How would you design | Senior / system thinking |
| Tell me about a time | Behavioral (separate framework) |
| Walk me through | Mid + Senior / process narration |
When the verb is ambiguous, default to L2 and offer L3 as a continuation. “It’s X (L1+L2). I’d also consider… (L3)” lets the interviewer stop you if they want to go elsewhere.
Answering questions about unfamiliar technologies
You will be asked about something you don’t know. The senior move:
- Be honest about scope: “I haven’t shipped X in production, but I’ve read about it…”
- Reason from first principles: “Based on the name and what I know about similar tools…”
- Compare to what you know: “If it’s similar to Y, then I’d expect the tradeoffs to be…”
- Express curiosity: “I’d want to spike on it for a day to verify.”
Never fabricate. Interviewers smell it instantly and you lose more than the answer was worth.
Recovering from a wrong start
You’re 30 seconds into an answer and realize you misunderstood the question. Two options:
Bad: silently change course mid-answer, hope they don’t notice. Good: “Let me restart that — I was answering for X but I think you’re asking about Y.” Restate the question, then proceed.
Senior engineers do this all the time in code review. Interviewers respect the meta-skill of self-correction.
The Lab approach to practicing
This works alone or with a study buddy:
- Pick 5 questions from 12.7.
- Set a 90-second timer. Answer out loud.
- Listen to the recording.
- Score yourself: Did I hit L1? L2? L3 with bridge phrase?
- Rewrite the L3 answer in writing — that becomes your reference.
Two weeks of this rebuilds your answer muscle. By week four it’s instinctive.
Common misconceptions
- “Senior answers are longer.” No — they’re deeper, not longer. A great senior answer can be 30 seconds.
- “You should always go to L3.” No — match the question. L3-ing a “what’s an optional” answer reads as showing off.
- “Bridge phrases sound rehearsed.” They sound rehearsed only if you don’t use them in daily speech. Practice them at work first.
- “Admitting limits hurts you.” Interviewers prefer “I don’t know but here’s how I’d find out” over fabrication, every single time.
- “This only matters for FAANG.” Every interviewer at every level filters for judgment. The framework is universal.
Seasoned engineer’s take
The 3-level system is not a script — it’s making your thinking audible. The interviewer needs to hear that you’ve considered the tradeoffs; if you have, you’ll say so; if you haven’t, the silence reveals it. Once internalized, you stop thinking about levels and just naturally include the right amount of context.
TIP: Apply this on the job too. Code review comments at L3 (“I’d also consider…”) land softer than corrections, and they’re more useful.
WARNING: Don’t perform the system. If you’re saying “I’d also consider…” without having an actual alternative in mind, the interviewer knows. The framework helps you organize knowledge you have, not fake knowledge you don’t.
Interview corner
Junior: “Walk me through how you’d answer a question you don’t know.” “I’d be honest about scope, reason from analogies, and ask clarifying questions. Then I’d commit to looking it up after the interview.”
Mid: “How do you handle a question that has multiple right answers?” “I’d pick the one I’d actually use, explain the mechanism briefly, then say ‘I’d also consider X if the constraint were Y’ — surfacing the alternatives without seeming wishy-washy.”
Senior: “How do you calibrate the depth of your answer to the interviewer?” “I listen to the verb in their question — ‘what’ for definition, ‘how’ for mechanism, ‘when’ for judgment. I default to mid-level depth with a senior bridge sentence, and watch for the interviewer’s body language or follow-up — they’ll either invite me to go deeper or move on. Mid-interview I’m reading whether they want breadth or depth and adjusting accordingly. The skill is treating the interview as a conversation, not a quiz.”
Red-flag answer: Always L3 for every question. Reads as performative.
Lab preview
Lab 12.3 (mock technical interview) has a section on practicing the 3-level system: 20 questions with self-scoring rubric across all three levels. Done weekly for a month, it becomes how you naturally answer at work.
Next: 12.9 — 5 iOS System Design Scenarios
12.9 — 5 iOS System Design Scenarios
How system design interviews work
System design at the senior iOS level isn’t about whiteboarding databases — it’s about the iOS half of a distributed system. Expect 45–60 minutes:
- Clarify scope (5 min): users, scale, platforms, must-have vs nice-to-have.
- High-level architecture (10 min): client + server boundary, data shape, caching strategy.
- Deep dive on iOS (20 min): screens, data flow, offline behavior, edge cases.
- Tradeoffs + scale (10 min): what breaks at 10×, what you’d add at 100×.
- Wrap-up (5 min): summarize the design, name what you’d want to learn more about.
The interviewer is listening for: explicit tradeoffs, asking the right clarifying questions, knowing iOS-specific patterns (background tasks, push notifications, offline), and managing time across the discussion.
Five canonical prompts follow. Treat them as scripts — practice each out loud in 45 min.
Scenario 1 — Instagram Feed
Clarifying questions
- How many users? (assume 100M MAU, 10M DAU)
- Feed source? (people you follow + algorithmic suggestions)
- Media types? (image + short video)
- Realtime requirements? (no — eventual consistency is fine; new posts appear within 30s)
- Offline? (read cached feed; posting queues for retry)
High-level architecture
[iOS app] ──HTTPS──> [Feed API] ──> [Feed Service + Ranker]
──> [Media CDN]
[iOS app] ──HTTPS──> [Post API] ──> [Storage + Notification fan-out]
iOS architecture
Data layer:
- Local cache: SwiftData with
Postmodel (id, authorId, mediaURL, caption, likeCount, timestamp). - Server-driven page size: API returns cursor + N items. Default N=20.
- Image cache: 2-tier (NSCache memory + URLCache disk + CDN).
View layer:
LazyVStackinsideScrollView, withForEach(posts, id: \.id).- Each post row is
Equatableto skip redundant re-renders. - Prefetch +5 ahead on scroll via
.task(id: post.id)per visible row.
State:
FeedViewModel(@Observable) withstate: enum { idle, loading, loaded([Post], cursor), error }.- Pull-to-refresh via
.refreshable.
Concurrency:
- Feed fetch:
await api.feed(after: cursor). - Image decode: async on
Task.detached, downsampled to display size. - Optimistic like: increment
likeCountlocally, fireawait api.like(postId); revert on failure.
Critical iOS-specific concerns
- First-paint perceived latency: render cached feed instantly; refresh in background.
- Memory pressure during scroll: cap NSCache to 50 images; downsample on decode.
- Cell reuse: SwiftUI handles automatically; in UIKit,
prepareForReusecancels image load. - Background refresh:
BGAppRefreshTaskprefetches new posts when system allows. - Push notifications: silent push triggers
feed-refresh-availablebadge.
Tradeoffs to mention
- Server-driven pagination (cursor) vs client-driven (offset): cursor wins for stability.
- Inline video autoplay: drains battery; default to play-on-visible-and-Wi-Fi.
- Real-time updates: poll-on-foreground is simpler than WebSocket for feeds.
At 10× scale
- CDN regionalization.
- Image format negotiation (HEIC/AVIF where supported).
- Ranker on-device with Core ML for personalized re-ranking of cached feed.
Scenario 2 — Real-Time Chat (WhatsApp / iMessage)
Clarifying questions
- 1:1 only, or group? (both)
- Media types? (text + image + voice + file)
- End-to-end encryption? (yes — assume Signal protocol)
- Offline behavior? (read history offline; queue outgoing for retry)
- Multi-device sync? (yes — same account on phone + iPad)
High-level architecture
[iOS] ──WebSocket──> [Chat Gateway] ──> [Message Service]
──> [Media uploader → CDN]
──> [E2EE key server]
[iOS] ←─APNs────── [Notification Fan-out]
iOS architecture
Persistence:
- SQLite (via SwiftData or GRDB) with tables:
Conversations,Messages,Attachments. - Messages keyed by
(conversationId, serverId, clientId). Indexed on(conversationId, timestamp DESC).
Network:
URLSessionWebSocketTaskfor live messages; auto-reconnect with exponential backoff.- Background
URLSessionfor large media uploads. - HTTP for history fetch (paginated by time cursor).
State:
ChatViewModelper conversation; subscribes to AsyncStream from a centralConnectionManager.- Outgoing messages go through a queue actor with retry logic.
Crypto:
- Signal protocol — keys stored in Keychain with biometric protection.
- Each message encrypted client-side before send; server only sees ciphertext.
Critical iOS-specific concerns
- Background message delivery: APNs with
mutable-content+ Notification Service Extension to decrypt before display. - Energy: WebSocket connection only when app is foreground or recently used; APNs handles background.
- Multi-device: Each device has its own keypair; sender encrypts per-recipient-device.
- Voice notes: record with
AVAudioRecorder; play withAVAudioPlayer+ audio session category.playback. - Read receipts: send when message appears on screen (Time Profiler — don’t fire from scroll without debounce).
- Typing indicators: throttle to 1/sec; cancel on send.
Tradeoffs
- WebSocket vs APNs polling: hybrid is mandatory — WebSocket for foreground latency, APNs for background reliability.
- Local-first vs server-of-truth: messages local-first with server as durable backup; conflict resolution via server timestamp.
- Group fan-out: client-fan-out (sender encrypts per-recipient) is more private but burns more uplink than server-fan-out (server holds plain copies briefly).
At 10× scale
- Sharded gateways by user ID.
- Lazy decryption of group messages (decrypt only when displayed).
- Sync offload via
BGProcessingTaskovernight.
Scenario 3 — Offline-First Note App (Bear / Notion mobile)
Clarifying questions
- Sync across devices? (yes — iOS + iPad + Mac)
- Conflict resolution? (last-write-wins is acceptable; CRDT preferred for collaboration)
- Backend? (vendor’s own — assume CloudKit-style)
- Search? (full-text across all notes, instant)
High-level architecture
[iOS] ──optimistic──> [Local SwiftData store]
↓ sync
[Sync engine] ──> [Server]
↑ pull
[Mac/iPad] ─────────────────────────── [Server]
iOS architecture
Persistence:
- SwiftData with
Note(id, title, body, modifiedAt, deletedAt, syncState). - Soft-delete via
deletedAtfor tombstone-based sync. - Full-text search via SQLite FTS5 (
@Modelwith custom predicate).
Sync engine:
- Bidirectional with vector clocks or HLC (Hybrid Logical Clock) per device.
- Pull: fetch changes since last sync token.
- Push: send local changes since last sync token.
- Conflict: server-merge or last-write-wins; surface conflict via “Two versions exist” UI.
State:
- Always read from local store; UI never blocks on network.
SyncStatusobservable showing “Synced” / “Syncing” / “Offline”.
Critical iOS-specific concerns
- Editor performance: debounced auto-save (every 2 s of inactivity).
- Background sync:
BGAppRefreshTaskon app suspend; trigger immediately on foreground. - Spotlight: index notes via
CSSearchableItemfor system-wide search. - iCloud Drive support: optional — store notes as files in
~/Library/Mobile Documents/iCloud~com.acme.notes. - Universal Clipboard: copy-paste works across devices via system.
- Handoff: continue editing across devices via
NSUserActivity. - Widgets: today’s note via
WidgetKit.
Tradeoffs
- CRDT vs LWW: CRDT is invisible-merge magic but heavier to implement; LWW is trivial but loses one user’s edit.
- SwiftData vs Core Data: SwiftData easier for new code, Core Data battle-tested for sync edge cases.
- CloudKit vs custom backend: CloudKit is free and zero-config but Apple-only; custom backend enables Android/web.
At 10× scale
- Lazy-load body for very large notebooks (load metadata only, body on tap).
- Differential sync (send only changed lines, not full body).
- E2EE option for paid users — keys derived from passphrase.
Scenario 4 — Navigation / Maps App
Clarifying questions
- Pedestrian + driving? (both)
- Offline maps? (yes — region download)
- Real-time traffic? (yes — server-driven)
- ETA accuracy? (within 10 % of actual time)
High-level architecture
[iOS] ──> MapKit ──> [Apple Maps tiles]
[iOS] ──> [Routing API]
[iOS] ←─ APNs ←─ [Traffic incident push]
iOS architecture
Map rendering:
MapKitMap { }SwiftUI view (iOS 17+) withMapPolylinefor route.- Custom tile overlay for offline regions (cached as
MKTileOverlay).
Location:
CLLocationManagerwithkCLLocationAccuracyBestForNavigationduring turn-by-turn.- Background updates enabled; geofence-based wake for nav resumption.
Routing:
- Online:
MKDirections(Apple) or server-side router (custom). - Offline: pre-downloaded routing graph for region.
Voice nav:
AVSpeechSynthesizerfor turn announcements.AVAudioSessioncategory.playback+.duckOthersto lower music.
Battery:
- Tone down GPS frequency when stopped at lights.
CLLocationManager.allowsBackgroundLocationUpdatesonly during active route.
Critical iOS-specific concerns
- Lock-screen widget: Live Activity for ongoing turn-by-turn.
- CarPlay: separate scene with simplified UI.
- Battery during navigation: aggressive — communicate to user with “high battery use” warning if route > 2hr.
- Privacy: location history stored locally, optional opt-in for “improve maps” upload.
Tradeoffs
- MapKit vs Mapbox vs Google Maps SDK: MapKit is free + integrated but less rich; Mapbox is most flexible but adds 10MB binary; Google has better global coverage.
- On-device vs server routing: on-device for offline + privacy; server for live traffic.
- CarPlay vs phone-only: CarPlay is App Store gold but adds a second UI to maintain.
At 10× scale
- Edge-cached routing per region.
- ML on-device for ETA refinement using personal driving history.
- Predictive offline downloads (auto-cache commute regions on Wi-Fi).
Scenario 5 — Video Streaming App (Netflix / Hulu)
Clarifying questions
- VOD only, or live? (VOD primarily; live for sports as stretch)
- DRM? (yes — FairPlay required for studio content)
- Offline downloads? (yes — for premium tier)
- AirPlay + Chromecast? (AirPlay yes; Chromecast nice-to-have)
High-level architecture
[iOS] ──> [Catalog API] ──> [Recommendation engine]
[iOS] ──HLS──> [CDN edge] ──> [Origin]
[iOS] ──> [License server (FairPlay)]
iOS architecture
Playback:
AVPlayer+AVPlayerLayer(UIKit) orVideoPlayer(SwiftUI iOS 14+).- HLS streaming with adaptive bitrate (server selects rendition by bandwidth).
AVPlayerItemlifecycle: prepare → play → end.
DRM:
AVAssetResourceLoaderDelegateto handle FairPlay key request.- Communicate with license server; cache license per asset for offline.
Offline downloads:
AVAssetDownloadTask(subclass ofURLSessionTask).- Background download via background
URLSession. - Manage storage limits; auto-evict oldest on space pressure.
Casting:
- AirPlay built into
AVPlayer(just enableAVRoutePickerView). - Chromecast via Google Cast SDK (extra dependency).
State:
- Watch progress synced to server (last position, last device).
- Resume across devices via “Continue Watching” rail.
Critical iOS-specific concerns
- Picture-in-Picture:
AVPlayerViewController.allowsPictureInPicturePlayback = true. - Background audio:
AVAudioSession.Category.playback+ Info.plistUIBackgroundModes: audio. - Now Playing info:
MPNowPlayingInfoCenterfor lock screen + AirPods controls. - Subtitles + closed captions:
AVMediaSelectionGroupfor language switching. - Bandwidth detection: HLS handles automatically; surface “low bandwidth” warning in UI.
- Video quality on cellular: respect
URLSessionConfiguration.allowsExpensiveNetworkAccessfor Low Data Mode.
Tradeoffs
- HLS vs DASH: HLS is iOS-native; DASH needs custom player. HLS wins on iOS.
- FairPlay vs Widevine: FairPlay required for iOS DRM; Widevine for Android. Most content goes through both.
- Pre-roll ads vs subscription: ad-supported tier needs
IMA SDKintegration. - Studio agreements: certain content geo-fenced; client must respect.
At 10× scale
- Predictive caching (download next episode of current binge on Wi-Fi).
- ML-based bitrate selection from device + network history.
- Live sports: low-latency HLS variant; sub-second glass-to-glass.
Common interview traps
- Jumping to code immediately. Spend 5 min clarifying; you’ll save 15 min mid-design.
- Designing the server. You’re being interviewed for iOS — keep server at “API contract” level unless asked.
- Ignoring offline. Every iOS design must address: app launched in airplane mode, what happens?
- Ignoring background. iOS aggressively suspends apps; designs that assume always-foreground are naive.
- Skipping privacy. ATT, Privacy Manifest, location justification — name them.
Lab preview
Lab 12.4 takes one of the scenarios above (Apple Notes sync) and walks you through producing a written design doc — the deliverable you’d send to a hiring manager for a take-home, or write up as a portfolio piece.
Next: 12.10 — Live Coding Playbook
12.10 — Live Coding Playbook
Opening scenario
Screen share opens. Interviewer says: “Implement an LRU cache in Swift, generic over key and value.” You have 30 minutes. The cursor blinks. You feel your heart rate spike. What you do in the next 60 seconds matters more than the code you eventually write.
The narration rule
Talk continuously. Silent typing is hostile to the interviewer — they can’t tell if you’re stuck or thinking. Narrate at three altitudes:
- What you’re about to do: “I’ll start with the signature.”
- Why you’re choosing it: “Generic over Key and Value; Key needs Hashable.”
- What you’d revisit: “I’ll come back to thread safety after correctness.”
Even silent thinking takes voice: “Let me think for ten seconds about the eviction order.” That signals scope and gives the interviewer permission to wait.
The clarification ritual
Before typing, ask 3–5 questions. This is expected — not asking is the red flag.
For LRU cache:
- “Fixed capacity at init, or resizable?”
- “Thread safety required?”
- “Should
getcount as a use (move to front)?” - “Are we optimizing for read-heavy or balanced workload?”
- “Should
setof existing key update the value or just bump recency?”
Write the answers in a comment. They become your spec.
// Spec:
// - Fixed capacity at init
// - Single-threaded (caller's problem)
// - get bumps recency
// - set of existing key updates value AND bumps recency
// - Evict least-recently-used on overflow
The common iOS live-coding patterns
You should be able to write each of these blind in 10 minutes.
LRU cache
final class LRUCache<Key: Hashable, Value> {
private let capacity: Int
private var dict: [Key: Node] = [:]
private var head: Node? // most recently used
private var tail: Node? // least recently used
private final class Node {
let key: Key
var value: Value
var prev: Node?
var next: Node?
init(_ k: Key, _ v: Value) { key = k; value = v }
}
init(capacity: Int) {
precondition(capacity > 0)
self.capacity = capacity
}
func get(_ key: Key) -> Value? {
guard let node = dict[key] else { return nil }
moveToFront(node)
return node.value
}
func set(_ key: Key, _ value: Value) {
if let node = dict[key] {
node.value = value
moveToFront(node)
return
}
let node = Node(key, value)
dict[key] = node
addToFront(node)
if dict.count > capacity, let lru = tail {
removeNode(lru)
dict.removeValue(forKey: lru.key)
}
}
private func addToFront(_ node: Node) {
node.next = head
head?.prev = node
head = node
if tail == nil { tail = node }
}
private func removeNode(_ node: Node) {
node.prev?.next = node.next
node.next?.prev = node.prev
if head === node { head = node.next }
if tail === node { tail = node.prev }
node.prev = nil; node.next = nil
}
private func moveToFront(_ node: Node) {
guard head !== node else { return }
removeNode(node)
addToFront(node)
}
}
Narration cues: “I’m using a doubly-linked list + dictionary for O(1) get and set; dictionary maps key to node, list maintains LRU order.”
Debounce
actor Debouncer {
private let delay: Duration
private var task: Task<Void, Never>?
init(delay: Duration) { self.delay = delay }
func call(_ action: @escaping @Sendable () async -> Void) {
task?.cancel()
task = Task {
try? await Task.sleep(for: delay)
guard !Task.isCancelled else { return }
await action()
}
}
}
// Usage:
let d = Debouncer(delay: .milliseconds(300))
await d.call { await search(query: text) }
Thread-safe counter (actor)
actor Counter {
private(set) var value = 0
func increment() { value += 1 }
func decrement() { value -= 1 }
}
If asked for a pre-actor version: DispatchQueue with .barrier flag for writes.
Simple @Observable from scratch
@propertyWrapper
struct Tracked<Value> {
private var storage: Value
var wrappedValue: Value {
get { storage }
set { storage = newValue; notify() }
}
init(wrappedValue: Value) { storage = wrappedValue }
var listeners: [(Value) -> Void] = []
mutating func subscribe(_ cb: @escaping (Value) -> Void) {
listeners.append(cb)
}
private func notify() { listeners.forEach { $0(storage) } }
}
Useful when interviewer asks “how does @Observable work under the hood?”
Async image fetcher with cancellation
actor ImageLoader {
private var cache: [URL: UIImage] = [:]
private var inFlight: [URL: Task<UIImage, Error>] = [:]
func image(for url: URL) async throws -> UIImage {
if let cached = cache[url] { return cached }
if let task = inFlight[url] { return try await task.value }
let task = Task<UIImage, Error> {
let (data, _) = try await URLSession.shared.data(from: url)
guard let img = UIImage(data: data) else { throw URLError(.cannotDecodeContentData) }
return img
}
inFlight[url] = task
defer { inFlight[url] = nil }
let img = try await task.value
cache[url] = img
return img
}
}
SwiftUI live coding expectations
For SwiftUI questions (“build a TODO app live”):
- Start with the model (
struct Todo: Identifiable, Hashable). - Then
@Observablestore (TodoStore). - Then root view with
@Statestore,NavigationStack. - Then
List + ForEachwith add/delete. - Comment on
@Observablevs@StateObjectif iOS 17 is allowed. - Talk about persistence: “If we wanted to persist, I’d reach for SwiftData.”
The 3-step stuck recovery
You’re 15 minutes in. Stuck. What to do:
- Verbalize the gap: “I’m stuck because I’m not sure how to handle the case where X.” Naming the obstacle often reveals the fix.
- Reduce the problem: “Let me solve the simpler version first — without thread safety / generics / cancellation — and then add the missing piece.”
- Ask for a small hint: “Could I get a nudge on the data structure?” — Interviewers expect this; they’re rooting for you.
Never silently flounder. Silence past 30 seconds is the red flag.
What interviewers score
- Speed to first working code: 10–15 min for a working naive solution beats 30 min of perfect code that doesn’t run.
- Test mentality: even a quick
let cache = LRUCache<String, Int>(capacity: 2); cache.set("a",1); cache.set("b",2); cache.set("c",3); assert(cache.get("a") == nil)shows discipline. - Tradeoff verbalization: “If write-heavy, I’d switch to…”
- Composure under correction: when the interviewer says “what if capacity is 0?” your reaction tells them everything.
Common misconceptions
- “Live coding tests algorithms.” Mostly it tests communication under uncertainty. The algorithm is the medium.
- “You should code in silence to focus.” Silence loses you points even with perfect code.
- “Optimize prematurely.” Get it working first. Optimization talk comes after.
- “Tests are skippable.” A 30-second sanity test impresses more than another minor optimization.
- “Compile errors lose points.” Honest typos are fine; live-fix and move on. Conceptual errors are what matter.
Seasoned engineer’s take
Live coding is performance art with code. The interviewer is hiring a teammate — they’re judging “do I want to pair with this person on a hard problem?” The code matters; the collaboration vibe matters more. Be the person who narrates clearly, asks good questions, recovers gracefully, and tests their work. The exact algorithm choice rarely decides the outcome.
TIP: Practice in a code editor without autocompletion. Your future interview will be in a shared web editor with mediocre tooling. Build the muscle of writing Swift from memory.
WARNING: Do not use AI assistance during live interviews unless explicitly invited. Even if not banned, it reads as poor judgment.
Interview corner
Junior: “What’s the first thing you do when given a live coding problem?” Restate the problem and ask 2–3 clarifying questions to lock down the spec. Then write the type signature.
Mid: “How do you handle being stuck mid-interview?” Verbalize the obstacle, reduce to a simpler problem, then either solve the simpler version or ask for a small hint. Silence is what hurts.
Senior: “How do you balance speed vs correctness in live coding?” I aim for a working naive solution first — even O(n²) is fine for round one — then iterate to optimal once tests pass. I narrate the tradeoff: ‘this is O(n²) but readable; if perf matters I’d switch to a heap.’ The interviewer learns that I think in tradeoffs and can ship a correct-but-imperfect solution under pressure, which mirrors actual production work.
Red-flag answer: “I just code it in silence; talking slows me down.” Senior interviews look for collaboration, not code-golf speed.
Lab preview
Lab 12.3 (mock technical interview) includes a live-coding section with 5 problems, timer, and rubric. Pair with a friend and switch roles each week.
Next: 12.11 — Behavioral STAR Templates
12.11 — Behavioral STAR Templates
STAR refresher
Situation — context, briefly. Task — what you specifically had to do. Action — what you did, in detail, using “I” not “we”. Result — measurable outcome.
Keep it under 90 seconds. Practice each template until the structure is invisible.
The 10 essential templates
1. Performance improvement
Situation: Our chat list scrolled at 35fps on iPhone 11, painful below the home indicator.
Task: I owned a sprint to hit 60fps without UI changes.
Action: I profiled with Instruments, found the bottleneck in cell layout (auto-layout running 8 passes per row due to nested stacks). I refactored the cell to a single UIView with manual layoutSubviews, cached attributed-string sizes in a memoized helper, and switched image loading to downsample-on-decode at the cell’s content size. I added a CI assertion measuring scroll perf via XCUITest.
Result: Steady 60fps; the regression test caught two future PRs that would have re-introduced layout slowdowns.
Adapt by swapping in your real bottleneck (decode time, JSON parsing, Core Animation, network).
2. Difficult technical decision
Situation: We were 4 weeks into adopting RxSwift across the team when Swift Concurrency landed. Task: I had to decide whether to keep adopting Rx or pivot to async/await. Action: I built a small spike of both for our auth flow; compared LOC, test surface, on-call burden, and onboarding cost for a new hire. I wrote an ADR (architecture decision record) recommending we freeze Rx adoption at the auth boundary and write all new features with async/await. I socialized it via two engineering wide reviews to align dissent before locking the decision. Result: 18 months later, no async/Rx hybrid bugs in our incident log; the new hire ramp time dropped from 4 weeks to 2.
3. Working in a legacy codebase
Situation: I inherited a 6-year-old Obj-C app with 200k LOC, no tests, six engineers had left over its history. Task: Add subscription IAP within 8 weeks without breaking the existing flows. Action: I treated the inheritance like an archaeological dig — read every commit message on the touched modules. Wrote characterization tests for the receipt validation code before touching it. Built the IAP layer as a new Swift module isolated behind a typed interface so the Obj-C layer only knew about a protocol. Shipped behind a feature flag, opened to 1 % then 10 % over two weeks. Result: IAP live on schedule, zero regressions, characterization tests caught two unrelated bugs in the legacy receipt code.
4. Designer disagreement
Situation: Designer wanted a custom blur effect requiring real-time UIVisualEffectView updates on every scroll frame. Task: Decide whether to implement as-spec’d or push back. Action: I built two prototypes — one with the spec, one with a precomputed blur — and measured battery + frame rate side by side. Showed the data to the designer and PM together, proposed the precomputed version as default with the spec version as an “iPhone Pro+” toggle. The designer kept ownership of the visual decision; I provided the constraint. Result: Shipped the precomputed version; team felt heard; battery telemetry stayed flat on launch.
5. Debugging a hard bug
Situation: 3 % of users reported the app crashing on launch after the 4.2 release. No repro on internal devices.
Task: Diagnose and ship a fix within 48 hours.
Action: I cross-referenced Xcode Organizer crash logs with the affected user device list, noticed all were iPhone 8 on iOS 16.1 only. Reproduced by installing 16.1 on an iPhone 8 from our device lab. Crash was in a force-unwrap of a CGFloat from a CGColor that returned nil only on that GPU/OS combination. Patched with safe unwrap, added a unit test exercising the code path with nil, shipped 4.2.1 via expedited review.
Result: Crash-free rate back to 99.95 % within 48 hours; the postmortem produced a team-wide rule against force-unwrapping CG types.
6. Failed project
Situation: I led a 6-month effort to migrate our app from VIPER to TCA across all features. Task: Get team consensus on architecture, port 18 features. Action: I ported 4 features myself as templates, ran weekly workshops, paired with each engineer on their first port. Six months in we’d ported 9 of 18 features but velocity on new features had dropped 30 %. I called for a retro; we found the migration cognitive load was the bottleneck, not TCA itself. I recommended freezing migrations, keeping TCA only for the ported 9, and not requiring new features to use it. Result: Project formally closed as partial success; we kept TCA where it was paying off and avoided a half-migration trap. The retro doc became required reading for future big-bang proposals on the team.
7. Staying current
Situation: I noticed our team’s Swift knowledge was 2 versions behind (we still used Result types where async/await would be idiomatic). Task: Level up the team without disrupting feature delivery. Action: I started a “Swift Friday” 30-min weekly slot — five minutes of one team member showing one Swift feature, twenty-five minutes of discussion. I curated the topics over six months: async/await, actors, macros, Observation, parameter packs. Took summary notes that became our internal wiki page. Result: New hires onboarded faster; the team adopted Swift Concurrency cleanly in our next big feature; one engineer started speaking about Swift at meetups based on what they learned.
8. Mentoring
Situation: A new mid-level engineer was struggling with PR feedback — their PRs took 8 review rounds on average. Task: Help them ship cleaner PRs without micromanaging. Action: I shadowed three of their PRs end-to-end, then we did a 1:1 where I asked them to walk me through their thinking before writing code. Diagnosis: they coded first, asked questions later. I taught them a 5-minute “design before code” habit: write the type signature + 3 test names in a comment block, get a thumbs up on the design, then code. Paired with them for 2 weeks on this. Result: Their PR rounds dropped to 2 on average; six months later they were mentoring another new hire on the same pattern.
9. Hardest bug you’ve debugged
Situation: Sporadic background-sync data corruption in a notes app — users reported notes “merging” with other notes’ content.
Task: Find the root cause.
Action: I instrumented every sync operation with os_signpost and uploaded traces from affected devices via a debug build. After a week of trace analysis I found the pattern: two BGAppRefreshTask instances were running concurrently because we’d registered the same identifier twice during an init refactor. They were both writing to the SwiftData store without coordination. Fixed by guarding registration with a single dispatch_once-equivalent, added a unit test asserting single registration.
Result: Corruption reports went to zero; the test caught a similar bug 6 months later when someone duplicated the registration during a feature flag cleanup.
10. Why this company
Situation/Task: This is the meta-question — interviewers screen for genuine fit vs résumé spray. Action: I researched your iOS team via your engineering blog (the post on your SwiftUI migration was particularly relevant — I’ve done a similar one) and noticed three engineers I respect on your team via conference talks. I’m specifically excited because [concrete reason: scale, problem domain, tech, mission]. I want to learn [specific gap] and contribute [specific strength]. Result (implied): I’d treat the first 90 days as listening + shipping one small thing to learn the systems, then take on more.
Always have two concrete reasons drawn from public material, one named team member you’d want to learn from, and one specific contribution you’d want to make.
Adaptation rules
- Use “I” not “we”. Interviewers need to know what you did. “We shipped it” is invisible.
- One story can serve multiple prompts. The chat performance story above also answers “tell me about a time you set a quality bar” and “tell me about a time you used Instruments.”
- Have 3 stories ready per dimension (technical depth, leadership, conflict, failure, learning). Total ~15 stories cover most interview prompts.
- Practice the result. Vague results (“it went better”) kill the story. Be specific: “33% reduction,” “shipped 2 weeks early,” “zero regressions.”
- Time yourself. 90 seconds is the limit. Practice with a stopwatch.
Common misconceptions
- “STAR is mechanical/robotic.” Done badly, yes. Done well, it’s invisible — the listener just hears a clear story.
- “You need a different story for every question.” No — the same 15 stories cover ~80 % of behavioral prompts with light adaptation.
- “Negative stories hurt you.” Concealed negatives hurt. Owned failures with learnings are some of the strongest answers.
- “Results must be technical.” Sometimes the result is a team change, a process improvement, or a saved relationship.
- “Behavioral doesn’t matter at senior level.” It matters more. At senior level, soft signal dominates; technical bar is assumed.
Seasoned engineer’s take
Behavioral interviews are the part most engineers under-prepare. Spend at least 25 % of your interview prep here. Write your 15 stories in a document; have someone else read them; iterate until they’re tight. The interviewer is asking themselves “Would I want this person handling a Saturday-night incident with me?” — your stories are the only answer.
TIP: Record yourself answering 5 behavioral questions. Watch the playback. You’ll cringe; that’s the point. Iterate.
WARNING: Don’t lie or embellish. Senior interviewers cross-check claims — “tell me more about that 33% number” — and inconsistency tanks the loop.
Interview corner
Junior: “What’s STAR?” Situation, Task, Action, Result — a structure for answering behavioral questions clearly.
Mid: “How do you prepare for behavioral interviews?” I write 12–15 stories covering technical depth, conflict, failure, mentoring, and ambiguity. I practice telling each in 90 seconds. I cross-reference each story against common prompts so I know which one to pull for which question.
Senior: “How does a senior behavioral interview differ from junior?” At senior level, interviewers probe for judgment and influence — how you change minds, how you handle disagreement, how you scale yourself by upleveling others. The technical depth is assumed; the questions look for leadership signals. I make sure my stories include moments of negotiation, owning failures publicly, and making decisions with incomplete information — not just “I shipped the feature.”
Red-flag answer: “I don’t really prep for behavioral, I just answer honestly.” Honesty without structure produces rambling answers and missed signals.
Lab preview
No dedicated lab. Write your own 15 stories this week. Read them to a friend on a 90-second timer. Iterate until each is sharp.
Next: 12.12 — Take-Home Assignment Strategy
12.12 — Take-Home Assignment Strategy
Opening scenario
You get the prompt: “Build an iOS app that consumes the Hacker News API, shows the top 30 stories, supports detail view + comments. Should take 4–6 hours.” It’s 6 PM on a Friday. The deadline is Monday morning. What do you do in the next 10 hours of work that turns a passing submission into a standout one?
What companies actually evaluate
Hint: not feature completeness.
| What you think they care about | What they actually care about |
|---|---|
| Pixel-perfect UI | Architecture (is the code readable?) |
| All features implemented | Tests + meaningful coverage |
| Fancy animations | Error handling + edge cases |
| Best-of-breed third-party libs | Justified library choices, not kitchen-sink |
| Clever Swift tricks | Idiomatic Swift, readable to a junior |
If you ship 70 % of features with a thoughtful architecture, tests, and a clear README, you beat the candidate who shipped 100 % with a 800-line View Controller.
The 30/60/90 rule
If the prompt says “4–6 hours,” treat it as a time-boxed exercise. Don’t go to 12.
- First 30 % of time — design + skeleton. Build the architecture; stub the features.
- Next 60 % — implement features in priority order. Skip anything that doesn’t fit.
- Last 10 % — polish: README, tests pass, no warnings, build instructions verified on a fresh clone.
This forces tradeoffs that mirror real engineering. Interviewers know the prompt is bigger than the time; they want to see what you chose to skip and why.
Architecture over features
Every take-home should demonstrate:
- Layer separation: Model / Service / ViewModel / View clearly distinguishable.
- Dependency injection: ViewModels take services in
init, not via singletons. - Protocol boundaries: at least one service hidden behind a protocol with both real and test impl.
- Async correctness:
async/awaitused idiomatically; no DispatchQueue dance. - Error handling: every async path has an error state visible to the user.
Even if the prompt is small, baking these in costs maybe 30 min upfront and pays the architecture-readability dividend.
The minimum README
# Hacker News iOS
A take-home for [Company].
## Build
- Xcode 16+, iOS 17+ deployment target
- `git clone …`, open `HackerNews.xcodeproj`, build & run on simulator
- No third-party dependencies (or: SPM resolves on first build)
## What I built
- Top stories list (live API, pull-to-refresh)
- Story detail with WebView for article
- Comments tree (lazy-loaded)
- Offline cache via SwiftData
## What I deliberately skipped (given the 5-hour time box)
- Search — would add a `SearchService` + dedicated view
- User profiles — out of scope for the API question being asked
- Vote submission — requires auth, larger scope
## Architecture
MVVM with constructor-injected services. `HNService` (protocol) has a live URLSession implementation and a `MockHNService` for previews and tests. SwiftData layer for cache, kept behind a `StoryCache` protocol.
## Testing
`HNServiceTests` covers JSON decoding edge cases. `StoryListViewModelTests` covers state transitions for fetch / refresh / error paths. Run with ⌘U.
## Tradeoffs
- I used SwiftUI throughout (iOS 17 target made this clean); for iOS 15 support I'd have built equivalent UIKit screens.
- Comment tree uses a flat list with indentation rather than recursive nested lists — better scroll performance for deep threads.
- I chose not to add a third-party HTTP library; URLSession + Codable was sufficient.
## What I'd do with another day
- Persist scroll position across launches
- Add accessibility VoiceOver tests
- Add a UI test for the full happy path
- Wire crash reporting (Sentry-equivalent) for a real submission
## Time
5h 20m total: 1h design + skeleton, 3h features, 1h tests + polish, 20m README.
This README is itself the deliverable. Companies read it before they read your code.
Minimum tests required
The bar is not “high coverage.” The bar is “evidence of test discipline.”
- One service test with decode round-trip on a sample JSON.
- One view model test asserting state transitions for happy path + error path.
- One mock implementation of your main service protocol.
That’s ~50 lines of test code and signals everything. Going further (UI tests, snapshot tests) is bonus.
What gets take-homes rejected
- No README or one-line README. Auto-fail.
- Doesn’t build on fresh clone. Always test by cloning to a new folder and building before submitting.
- 800-line View Controllers with no separation.
- Singleton-everywhere —
URLSession.shared,UserDefaults.standardsprinkled through views without abstraction. - No tests at all.
- Force-unwraps in production paths.
- Warnings when building. Treat warnings as errors — fix them all.
- Includes
.xcuserdataorDerivedDatain the zip..gitignoreit. - Took 20 hours. You’re hiring yourself for a job; engineers who can’t time-box overwork themselves.
- API keys committed. Use environment /
.xcconfig/ a config file documented in README.
What makes take-homes stand out
- A “what I’d do next” section in the README — signals you know the prompt is incomplete and you have a roadmap.
- One unexpected delightful detail — pull-to-refresh haptics, a thoughtful empty state, a clever error message.
- A commit history that tells a story. Six commits each named clearly beats one giant “initial commit” with everything.
- A short demo video (Loom, 2 min) walking through the app. Optional, but huge signal.
- A
Decisions.mdlog of architecture tradeoffs you considered. - Build success on a fresh device. Test on simulator and a physical iPhone if possible.
SwiftUI vs UIKit choice
If the prompt doesn’t specify, pick based on:
- The company’s stack (research their engineering blog).
- iOS deployment target listed.
- Your strength — don’t pick the one you’re weaker in to “look modern.”
When in doubt, SwiftUI for iOS 16+ targets in 2026.
Library choices
- Networking: URLSession (no third-party needed).
- Image loading: Kingfisher or Nuke if you genuinely need caching; for a 30-image grid, neither is required.
- Architecture: avoid TCA in take-homes unless the company explicitly uses it. The boilerplate cost is too high for the budget.
- Tests: built-in XCTest. Add
swift-snapshot-testingonly if visual regression matters.
Every dependency you add is a signal — “I chose to take on this maintenance cost because…”. Justify it in the README.
Submission checklist (run before zipping)
-
Product → Clean Build Folderthen build — zero warnings, zero errors - All tests pass (⌘U)
- Fresh clone to a separate directory builds and runs first try
- README updated with accurate features list
-
.gitignoreexcludesxcuserdata,DerivedData,.DS_Store - No API keys or tokens committed
- No force-unwraps in shipping code paths
-
Bundle ID + display name are sensible (not
com.example.app) - Verified on iPhone simulator (or device) at iOS deployment target version
Common misconceptions
- “More features = better grade.” Inverted: missing key architecture pieces overrides any feature completeness.
- “Use the latest Swift tricks to impress.” Use the simplest correct code. Clever code reads as ego.
- “Spend whatever time it takes.” Going far over budget is itself a signal — it shows poor time management. Cap at 1.5× the stated estimate.
- “Skip the README, the code speaks for itself.” No code speaks for itself. The README is your chance to explain choices the code can’t.
- “Build the perfect app.” Build the demonstrably thoughtful app. Perfection isn’t the bar; judgment is.
Seasoned engineer’s take
Take-homes test what a first week of work would look like — can you scope, prioritize, architect, ship something useful, and document your reasoning? The candidate who treats it as a hackathon (“ship maximum features”) loses to the candidate who treats it as a paid contract (“ship the most valuable subset on time with quality”).
TIP: Keep a personal “take-home template” repo — your standard project structure, README template, test scaffolding. Reuse it for every take-home. Drops setup time by an hour.
WARNING: Never use AI to write the take-home wholesale and submit without disclosure. Most companies now ask in the follow-up interview about specific design choices; you’ll be unable to defend code you didn’t write. Use AI for spike research, snippets, and review — disclose its use if asked.
Interview corner
Junior: “How long should a take-home take?” The time estimated in the prompt, plus 25 % maximum. Time-box and document what you skipped.
Mid: “What’s the most important deliverable in a take-home besides the code?” The README. It explains your tradeoffs, what you skipped and why, and demonstrates engineering judgment that the code alone cannot show.
Senior: “How would you evaluate a take-home if you were the interviewer?” I’d read the README first — does it show scoping? — then check that it builds clean on a fresh machine. I’d skim the architecture for layer separation and DI. I’d run the tests and see what they cover. Only then would I read the feature code. If the candidate spent obvious extra time on polish at the expense of architecture, I’d downgrade — that’s a senior trap. If they shipped less but with clearer tradeoff thinking, I’d upgrade. The signal is judgment under constraint, not feature count.
Red-flag answer: “I always go above and beyond on take-homes and add lots of features.” This signals poor time management and inverted priorities.
Lab preview
Build your own portable take-home template this weekend: empty SwiftUI app, README template, sample test file, gitignore, and a Decisions.md stub. Reuse it for the next 5 take-homes you do.
Next: 12.13 — Code Review & Pair Programming
12.13 — Code Review & Pair Programming
Opening scenario
Your onsite includes a “code review exercise”: the interviewer hands you a 200-line Swift PR and asks you to review it out loud in 30 minutes. They’re not testing whether you can find bugs — they’re testing how you communicate technical feedback to a teammate.
Context — what reviews really evaluate
| Surface signal | Deeper signal |
|---|---|
| Number of issues found | Prioritization (critical vs nit) |
| Technical depth | Communication tone |
| Confidence | Humility — “I might be wrong, but…” |
| Speed | Care — did you read it twice? |
The interviewer is a future teammate imagining you reviewing their PR. Be the reviewer you’d want.
The reviewer mindset
- Read the PR description first — even in interviews, ask if there is one. Context matters.
- Pass 1: skim the diff structure. Are file changes scoped? Naming sensible? Tests included?
- Pass 2: read line-by-line top to bottom. Note issues without typing immediately.
- Pass 3: prioritize. Group your notes into Blocking / Important / Nit.
- Verbalize in the order Blocking → Important → Nit, with rationale.
The comment categories
Every comment should self-tag:
blocking: must be fixed before merge (bug, security, breaks production).important: should be fixed before merge (architectural concern, missing test).nit: optional, style preference, won’t block merge.question: I don’t understand; explain the intent.praise: positive note — surface what’s good.
The labels make priority unambiguous. Junior reviewers leave 20 nit-level comments without tagging; the author can’t tell which to fix first.
What good reviews look like
// ❌ Bad comment:
// Why are you using force-unwrap here??
// ✅ Good comment:
// [blocking] Force-unwrap on line 42 will crash if `URL(string:)` returns nil.
// Suggest: `guard let url = URL(string: rawURL) else { throw URLError(.badURL) }`.
// Even if we control the input today, a future caller might not.
// ❌ Bad:
// This is wrong, use SwiftData instead.
// ✅ Good:
// [question] Is there a reason we're rolling our own caching here vs SwiftData?
// I might be missing context — happy to chat if there's a constraint I don't see.
// ❌ Bad: (silently approves without comment)
// ✅ Good:
// [praise] Love the protocol abstraction here — the test impl made the review easy.
Tone rules
- Use “we” or “the code” — never “you”. “You forgot…” lands as accusation; “this missed…” or “we’d want…” lands as collaboration.
- Frame suggestions as questions when you’re not sure: “Would it work to…?” beats “Do this”.
- When you spot something serious, lead with the impact, then the fix.
- Match formality to the team. Reviewer comments at FAANG are often more clipped; small teams can be more conversational. Read the existing PR culture.
What to review in iOS Swift code
A mental checklist while reading:
Correctness
- Force-unwraps without invariant
- Force-cast (
as!) without runtime check - Threading: UIKit mutation off main thread
- Retain cycles: closures capturing
selfstrongly - Optional chaining producing silent nil instead of error
Concurrency
Taskstarted without storage (can’t cancel)- Missing
[weak self]in long-livedTasks Sendableviolations under Swift 6 strict mode@MainActorannotations missing on UIKit-touching code
Architecture
- View doing data fetching (should be in VM/service)
- Service knowing about UI types
- Hardcoded dependencies (singletons referenced directly)
- New class added to a feature module that doesn’t belong there
Testing
- New logic has tests
- Tests cover error paths, not just happy path
- Mocks are reusable, not duplicated
Performance
- Synchronous I/O on main thread
- N+1 queries in Core Data / SwiftData
- Image loading without downsampling
- New view in a scroll list without
Equatable
Style (low priority)
- Naming follows team convention
- File length / function length within team norms
- Comment hygiene
If reviewing in an interview, voice the framework: “I’ll first scan for correctness issues, then concurrency, then architecture…”
PR hygiene (as the author)
When you open PRs, make them reviewable:
- Title + description that explain the why, not just the what.
- Scope: one logical change. Don’t bundle refactors with features.
- Size: < 400 lines preferred; > 800 lines almost always means split it.
- Self-review first: leave inline comments on tricky parts before requesting review.
- Tests included in the same PR (not “tests in a follow-up”).
- Screenshots/video for UI changes.
The single biggest reviewer time-saver is a PR description that lets the reviewer skip 80 % of the diff and focus on the 20 % that needs attention.
Pair programming in interviews
Pair sessions are similar to live coding but with a collaborator. Differences:
- Talk to the partner, not at them. Pause for input.
- Type at moderate speed — too fast feels like showing off, too slow loses momentum.
- Ask before going down a rabbit hole: “Want me to add error handling now or keep moving on the happy path?”
- Take suggestions gracefully: if the partner proposes a different approach, try it for 2 minutes before defending yours.
- Switch driver/navigator if the format allows. Be a good navigator: ask before suggesting, point at lines not just describe them.
The interviewer is asking: would I want to pair with this person on a hard production bug at 9 PM on a Friday? Be that person.
Recovering from a wrong review
You confidently said “this is a memory leak” and the author replies “no, I added [weak self] on line 18 which you missed.” Recovery:
- Acknowledge directly: “You’re right, I missed that — apologies.” No defensive softening.
- Course-correct: “Disregard the memory point; the rest of my comments stand.”
- Move on: don’t dwell or over-apologize. Two seconds, then next comment.
Senior reviewers are wrong all the time. The grace of being wrong well is itself a signal of seniority.
Common misconceptions
- “More comments = better review.” Inverted. Three high-quality blocking comments beat 30 nits.
- “Reviewers should never suggest code.” Suggest code freely for non-trivial changes — saves the author guessing what you mean. Use GitHub’s suggestion blocks.
- “You should approve only if you’d write the code that way.” No — approve if the code is correct, safe, and on-team-convention. Personal preference isn’t blocking.
- “Reviews are about gatekeeping.” Reviews are about collaboration + knowledge sharing. A good review teaches both author and reviewer.
- “Senior engineers leave fewer comments.” They leave more impactful comments, often fewer in count but with higher per-comment value.
Seasoned engineer’s take
Code review is the highest-leverage activity in a senior engineer’s day. A great review prevents one bug from shipping AND teaches the author a pattern they’ll apply for years. Treat every review as both quality gate and mentoring opportunity. The cost of a careful 30-min review is far less than the cost of fixing a bad merge.
TIP: Adopt the “two-pass minimum” rule personally. Never approve on first read of a non-trivial PR. The bugs hide on the second pass.
WARNING: Don’t review-block out of personal style preferences. Distinguish “this is incorrect” from “I’d write it differently” — only the first blocks. The second is a discussion, not a block.
Interview corner
Junior: “What do you look for first in a code review?” Correctness — force-unwraps, threading, error handling — then test coverage. Style comments come last and are usually optional.
Mid: “How do you handle disagreement during a code review?” I tag my own confidence: “I’m not certain, but…” for opinions, firm phrasing for facts. If the author pushes back with a reason I missed, I update. If we still disagree, we resolve over a quick call or escalate to a third reviewer — but I never block over a tie.
Senior: “What does a great code reviewer do that an average one doesn’t?” A great reviewer prioritizes ruthlessly — blocking comments come first, nits come last and tagged as such. They write comments that teach, not just correct: ‘this would crash if X happens; consider Y’ beats ‘don’t do this’. They surface what’s good, not just what’s wrong, so the author learns the pattern to replicate. They read the PR twice before commenting. And they recover gracefully when they’re wrong — acknowledging directly, no defensiveness. The result is a team where reviews are anticipated, not dreaded.
Red-flag answer: “I leave detailed comments on every line.” This is exhausting for the author and signals inability to prioritize.
Lab preview
Open the latest PR in a public Swift open-source project (Apple’s swift-package-manager, Alamofire, Vapor). Write a hypothetical review without submitting it. Self-grade against the categories above. Repeat weekly.
Next: 12.14 — Portfolio, GitHub & LinkedIn
12.14 — Portfolio, GitHub & LinkedIn
Opening scenario
A recruiter pastes your GitHub URL into a slack channel. The hiring manager has 90 seconds to decide whether to forward your résumé to the iOS team. What do they see in those 90 seconds?
Context — your public surface
Three artifacts get reviewed for senior iOS roles:
| Artifact | Audience | Time to decide |
|---|---|---|
| GitHub profile | Hiring manager + engineers | 90 sec |
| Shipped apps (App Store) | Hiring manager + design + product | 5 min installing + using |
| Recruiter, then hiring manager | 60 sec |
Each must do its job in the time allotted. Polished beats voluminous.
The “2–3 polished beats 10 half-finished” rule
A common mistake: 47 repos, all half-finished, no README, last commit 18 months ago. This hurts — it signals starting without shipping.
Better: 2–3 pinned repos that are clearly finished, well-documented, with recent commits. The rest hidden or archived.
What “finished” looks like:
- README with screenshots/GIF and clear build instructions
- Tests (even minimal)
- License (MIT for personal projects)
- Recent activity in the last 3 months (even one commit fixing a deprecation)
- No build warnings on latest Xcode
GitHub profile checklist
- Profile photo: clear, professional-ish. No avatar = -10 points of trust.
- Bio: one sentence — “Senior iOS engineer, ex-Acme, Swift Concurrency / SwiftUI.”
- Pinned repos (6 slots, use 2–3):
- One substantive app or library that demonstrates iOS expertise.
- One small tool that shows your taste (CLI, macro, framework).
- Optionally one contribution to a well-known OSS project (PR in main).
- README.md on your profile (the meta-README) with: 1-line bio, current focus, links to apps you’ve shipped, blog/talks if any.
- Contribution graph: not a daily streak target, but mostly-green months show ongoing activity.
- Stars and follows: build them via genuine work; never buy or solicit. Reviewers check inflation.
What pinned repos should look like
Example: a published Swift Package
SwiftThrottle — A throttle/debounce utility built on Swift Concurrency.
[badge] Swift 6 [badge] iOS 17+ macOS 14+ [badge] MIT [badge] tests passing
Brief 2-paragraph explanation of what + why.
## Install
.package(url: “https://github.com/you/SwiftThrottle”, from: “1.2.0”)
## Usage
```swift
let throttle = Throttle(interval: .milliseconds(300))
await throttle.run { await search() }
Why this package
Swift Concurrency’s missing debounce. Built actor-based to be Sendable-safe under Swift 6.
Test coverage
85% (run swift test --enable-code-coverage).
### Example: a sample iOS app
HackerWatch — A SwiftUI Hacker News client, my testing ground for new APIs.
[Screenshots: 3 device shots in a row, light + dark]
What’s interesting in here
- TCA architecture
- SwiftData offline cache
- WidgetKit widget
- Live Activity for top stories
Why I built it
Reading HN every day; wanted a calmer UI than the alternatives.
Build
Xcode 16+, iOS 17+. Open HackerWatch.xcodeproj, build & run.
## Commit hygiene
- Commit messages in imperative mood ("Add throttle actor" not "Added throttle actor").
- One logical change per commit. Squash before merging giant branches.
- Use conventional commits (`feat:`, `fix:`, `refactor:`) if your team does; otherwise be consistent.
- Authors who push 30 commits with "wip", "wip2", "fix typo" reveal undisciplined habits.
## App Store apps
If you've shipped, link them. Include in the LinkedIn featured section *and* on GitHub profile README:
🚀 Apps I’ve shipped
- [App Name](App Store link) — 50k downloads, 4.7★, Swift + Core Data
- [Other App](App Store link) — internal tool, B2B
Hiring managers click. They see the polish, ratings, last updated date. An app that hasn't been updated in 3 years but still works is fine; one that hasn't been updated and is broken is bad.
## Blog posts and talks
Even 3–5 technical blog posts move the needle significantly. They demonstrate:
- Ability to write and communicate
- Depth in some area you can speak to
- Self-marketing without being obnoxious
Topics that work: a hard bug you debugged with the process, a deep-dive on a Swift feature, a comparison of architectures with data. Avoid: "10 tips for iOS devs" listicles. Hiring managers skim.
For talks: even a meetup talk recorded counts. Conference talks count more.
## LinkedIn
### Headline formula
[Role] @ [Company] | [Tech stack] | [Differentiator]
Examples:
- "Senior iOS Engineer @ Acme | Swift, SwiftUI, Combine | Shipping for 5M MAU"
- "iOS Engineer @ Stealth | Swift Concurrency, SwiftData | Ex-Apple"
- "Independent iOS Contractor | Swift 6, visionOS | Available Q2 2026"
Skip vague: "Passionate technologist" / "10x developer" — instant downgrade.
### About section
3 paragraphs:
1. What you build and for whom.
2. What technologies you specialize in.
3. What you're looking for next (optional but useful for recruiters).
### Experience entries
For each role, three bullets:
- What the company / team does
- What you specifically owned
- One outcome with a number
Example:
iOS Engineer · Acme Corp · 2022–Present Acme makes a B2C health app, 8M MAU, top 100 Health & Fitness.
- Led migration of core meal-tracking flow from UIKit to SwiftUI (12 screens, 40k LOC).
- Built offline sync layer using SwiftData + CloudKit; reduced sync-related crashes 80%.
- Mentored 2 mid-level engineers, both promoted within 18 months.
Numbers and outcomes are the differentiator. "Worked on iOS app" is invisible.
### Featured section
Pin your best 3:
- A shipped app (link)
- A blog post or talk
- A GitHub repo
This is the carousel a recruiter sees first.
### Skills
iOS / Swift / SwiftUI / UIKit + 2–3 specialties (Concurrency, Core Data, etc.). Don't pad with 30 skills; recruiters discount overstuffed lists.
## Recommendations
3–5 thoughtful recommendations from prior managers or senior peers beat 30 generic ones. Ask for specifics ("could you mention the time we shipped X").
## iOS-specific job boards
Beyond LinkedIn:
- [iosdevjobs.com](https://iosdevjobs.com)
- [WeWorkRemotely](https://weworkremotely.com) — iOS tag
- [Otta](https://otta.com) — curated, well-designed
- [HN Who Is Hiring](https://news.ycombinator.com) — monthly thread, often great roles
- [Hacker News Who's Hiring](https://www.hntohired.com/) — searchable mirror
- iOS-Dev Weekly's job section — high quality, low volume
## Contractor path
If targeting contract/freelance iOS work:
- [Toptal](https://www.toptal.com) — vetted network, 30 % cut, premium rates ($100–200/hr).
- [Gun.io](https://www.gun.io) — engineering-focused contract platform.
- Direct via LinkedIn — best rates, requires marketing yourself.
Build a public "Hire me" page on your portfolio site: what you do, rates (or NDA range), past clients (with permission), case studies.
## Company tier map (rough 2026 guide)
| Tier | Examples | Typical TC senior iOS (US) |
|---|---|---|
| Faang | Apple, Google, Meta, Amazon | $400–600k |
| Top product | Stripe, Airbnb, Shopify, Notion | $350–500k |
| Mid product | DoorDash, Robinhood, Pinterest | $280–400k |
| Mid SaaS | Atlassian, GitLab, Datadog | $250–350k |
| Series B–D startup | Various | $200–320k + equity range |
| Series A startup | Various | $170–250k + meaningful equity |
| Bootstrap / agency | Various | $130–200k |
| Big enterprise | Banks, insurance | $180–260k |
These vary wildly by location, remote-vs-in-office, and individual negotiation. See [12.15](salary-negotiation-offers.md) for negotiation craft.
## Common misconceptions
1. **"More repos = better signal."** Inverted — quality and recency matter, not count.
2. **"LinkedIn doesn't matter for engineers."** It's 60 % of where recruiters source. Treat it as your inbound funnel.
3. **"My job history speaks for itself."** Only if every reader recognizes your past employers. For everyone else, the bullet point detail matters.
4. **"I should have a personal blog."** Optional but high ROI if you actually write. Don't start one you'll abandon in 2 months.
5. **"I need a fancy portfolio website."** A clean GitHub profile README often suffices for engineers. A separate site adds value only if you have something specific to host (case studies, contract availability).
## Seasoned engineer's take
Your public surface is your slow-acting recruiter. Spend an afternoon every quarter polishing it: refresh pinned repos, update LinkedIn headline, ensure shipped apps still build on latest Xcode. The maintenance cost is small; the optionality it creates (recruiters reaching out, opportunities you didn't apply for) compounds across a career.
> **TIP**: Add a Plausible/Fathom analytics tag to your personal site or blog. Seeing real recruiter traffic each week is motivating.
> **WARNING**: Don't post controversial takes that the hiring manager will see during background research. You're not censoring yourself; you're being strategic about a public surface that lasts decades.
## Interview corner
**Junior**: "What should be on my GitHub for iOS roles?"
2–3 pinned repos with clean READMEs, screenshots if it's an app, and a profile README with 1-line bio + apps you've shipped if any.
**Mid**: "How do I move from inbound recruiter spam to good opportunities?"
Polish the headline + about section on LinkedIn so it filters for the right role; pin 3 outcome-focused recommendations; link to shipped apps. Then ignore generic recruiters and reply only to ones who reference specifics from your profile.
**Senior**: "How would you build a personal brand as an iOS engineer over 2 years?"
I'd anchor on one technical area I find genuinely interesting — say, Swift Concurrency or visionOS — and produce 4 deliverables per year there: a blog post, a meetup talk, an OSS contribution, and ideally a small package or tool people use. Each compounds: the blog post leads to talk invites; the OSS contribution lands me in maintainers' networks; the tool drives GitHub stars that recruiters notice. Two years in, I'd have a public résumé that I never need to send out cold — relevant opportunities find me, and the quality of inbound is dramatically higher than the LinkedIn-only baseline.
**Red-flag answer**: "I don't believe in personal branding." Even if you're not actively marketing, your public surface is being read. Better to curate than to leave it to chance.
## Lab preview
This weekend: archive any GitHub repo that's not finished. Update your pinned 3 with current screenshots. Rewrite your LinkedIn headline using the formula above. Refresh the featured section. Time-box to 2 hours.
---
Next: [12.15 — Salary Negotiation & Offer Evaluation](salary-negotiation-offers.md)
12.15 — Salary Negotiation & Offer Evaluation
Opening scenario
The recruiter calls: “We’d like to extend an offer — $250k base, 200k RSUs over 4 years, 15% bonus target. We’ll need an answer by Friday.” Your heart says yes. Your bank account says yes. The data says you’re leaving $40–80k/year on the table. This chapter is the framework for negotiating with confidence and grace.
Context — what total comp actually means
| Component | Typical share at senior iOS | Notes |
|---|---|---|
| Base salary | 50–65 % | Most negotiable component long-term |
| RSU / equity | 20–40 % | Vesting schedule matters as much as amount |
| Sign-on bonus | one-time, 5–15 % of year-1 TC | Often clawback if you leave < 12 mo |
| Performance bonus | 0–20 % | Target is theoretical; ask actual avg payout |
| Benefits (insurance, 401k match, etc.) | 10–20 % effective value | Big-co benefits are real $$ |
| Perks (WWDC, hardware, devices) | 1–5 % effective | iOS-specific perks |
Total compensation (TC) = sum of all above per year, usually quoted as year-1 + year-2-on-blended.
Always compare offers in TC, not base.
The data sources
Before you negotiate, you need market data:
- Levels.fyi — the gold standard. Filter by company, level, location, year.
- Glassdoor — second-tier but useful for non-FAANG.
- Blind — anonymous teamblind app; iOS folks share specifics.
- LinkedIn Salary — third-tier.
- Personal network — quietly ask 2–3 senior peers in similar roles.
For each company you’re talking to:
- Pull the L+1, L, and L-1 levels at your target band.
- Look at p50 / p75 / p90.
- Note total comp, not just base.
Walk in knowing the band. The recruiter does; you should too.
RSU vesting schedules — the hidden variable
A “$200k over 4 years” offer can mean wildly different things:
| Schedule | First-year vest | Notes |
|---|---|---|
| Even 25/25/25/25 | $50k | Standard at most companies |
| Front-loaded (35/30/20/15 — Meta, Snap, sometimes Amazon for high band) | $70k | Bigger early; mid-tenure cliff |
| Back-loaded (5/15/40/40 — Amazon historically) | $10k | Brutal early; counter with sign-on |
| Cliff-vest 1yr then monthly (Apple, Google) | $50k after 1 yr | Usual; plan for the 1-yr cliff |
| 4-yr cliff (rare; some startups) | $0 until year 4 | Almost never accept |
When evaluating, always compute the year-1 number explicitly. Two “200k over 4 years” offers can have $60k year-1 difference.
For private companies (startups), RSUs may be:
- Strike-price stock options — you pay to convert. Often illiquid until IPO.
- RSUs that vest but don’t convert until liquidity event — common at growth-stage.
- Phantom equity / profit share — less standard, read the docs.
Always ask: “What’s the strike price?” “What’s the current 409A valuation?” “When did the last secondary or fundraise happen, at what price?”
The negotiation script
Phase 1 — never anchor first
Recruiter: “What are you looking for in compensation?”
❌ “I’d like $X base.” ✅ “I’d rather understand the full picture of your offer first — base, equity, bonus structure. Then I can speak to whether it’s competitive for me.”
If they push: “What range did you have in mind for this level?”
If they truly insist: give a range from your market data, with the top of the range being your target.
✅ “Based on my research and other conversations, for a senior iOS role at your stage, I’d expect total comp in the $400–500k range. Where does your band sit for this level?”
Phase 2 — receive the offer in writing
Always: “Could you send the full offer in writing? I want to review it carefully before we discuss further.”
Never accept verbally on the call. Buy time — 48 hours minimum, ideally a week.
Phase 3 — counter
Counter in writing too. Structure:
Hi [Recruiter],
Thank you for the offer and for walking me through the details. I'm genuinely excited about [team / mission / specific thing].
After comparing this against the market data and a couple other conversations I'm in, I'd like to discuss the following adjustments:
1. Base: $260k → $290k
2. Equity: $200k over 4yr → $260k over 4yr (or front-loaded 35/30/20/15)
3. Sign-on: $30k → $50k to bridge unvested equity at my current role
I'd love to make this work. Let me know what's possible.
[You]
Notes:
- Always justify (market data, unvested equity, competing offers).
- Counter on multiple dimensions — if they can’t move on base, they may move on equity or sign-on.
- One round of counter is normal; two is acceptable; three signals trouble. Don’t drag.
Phase 4 — competing offers as leverage
If you have a competing offer, mention it factually:
✅ “I have another offer at $X TC. I’d prefer your team for [reason], but the gap is real. Can we close it?”
Never lie about competing offers. Recruiters in the same city talk and often verify casually. Caught fabricating, you lose the offer.
If you don’t have a competing offer, don’t fake one. Lean on market data and your conviction.
Phase 5 — when to accept
You should feel comfortable that:
- TC is at or above the company’s published band for the level.
- The equity vest schedule is reasonable.
- The role/team are what you want for 2+ years.
- You’d accept this offer even if a slightly better one came later — i.e., no regrets.
Sleep on it one night. Then accept in writing, with grace and enthusiasm.
Evaluating offers beyond comp
For each offer, score on:
| Dimension | Weight |
|---|---|
| TC (year-1 + year-4 vested) | High |
| Team / manager quality | Highest |
| Tech stack alignment | Medium |
| Growth trajectory (will I learn?) | High |
| Remote/hybrid/onsite | High (personal) |
| Brand strength for next role | Medium |
| Equity upside (private cos) | Variable |
| Work-life balance reputation | Medium |
A higher-paying role under a bad manager loses every time over 5 years. Optimize for the role, not just the paycheck.
iOS-specific perks worth asking about
These are real cash equivalent — ask explicitly:
- WWDC access: $1,599 ticket + travel. Many iOS-heavy companies cover annually.
- Hardware budget: latest iPhone + iPad + Mac for testing? $3–5k/yr value.
- Apple Developer Program: $99/yr — small but signals iOS investment.
- Conference budget: try! Swift, iOSDevUK, AltConf, dotSwift. $2–5k/yr.
- TestFlight + internal tooling access: not a perk but indicates engineering maturity.
- External iOS contractor agency contracts: some big-cos cover Toptal/freelance budget for design or specialized work.
- App Store assets: design, localization budget.
Red flags during negotiation
- Refusal to put offer in writing: hard pass.
- Pressure to decide same-day: “this offer expires tonight” — fake. Always ask for 48 hours, push back if refused.
- Reluctance to share band or salary range: signals a low-comp culture. Walk if persistent.
- Counter-offer dropped below original verbal: huge red flag for trust.
- Recruiter promising verbally what isn’t in writing: only writing matters. Verbal promises are nothing.
- “We don’t do that here” for sign-on, equity refresh, etc. — without explanation: get clarity. Sometimes legit; sometimes a brushoff.
Counter-offers from your current employer
If you’ve resigned and your current company offers a counter:
- Typical advice: don’t take it. The reasons you wanted to leave usually don’t go away with money.
- Exception: if the counter addresses a specific concern (boss, team, role, not just money) and you trust it’ll stick.
- Reality: ~70 % of accepted counter-offers result in the engineer leaving within 12 months anyway (industry survey data, take with salt).
Equity refresh and yearly review
After year-1, ask about equity refresh:
- Most big-cos give an annual refresh (10–30 % of original grant) on top of vesting schedule. Without it, your TC steps down sharply after year-4.
- Negotiate refresh visibility in the offer: “What’s the typical annual refresh band?”
- At review time (annual or biannual), come prepared with the same market data exercise.
Common misconceptions
- “Negotiating offends the company.” It doesn’t — they expect it. Not negotiating signals you may underperform on advocacy too.
- “Top of band is greedy.” Top of band is the band. They published it; they expect to fill it.
- “Don’t negotiate at a small company / startup.” Negotiate everywhere. Smaller companies often have more flexibility on equity than base.
- “Sign-on is just a perk.” Sign-on rebalances vest schedules. Negotiate it explicitly to bridge unvested equity at current role.
- “Always take the highest TC.” Highest TC under a toxic manager beats your nervous system every time. Optimize whole career value, not just first paycheck.
Seasoned engineer’s take
Negotiation is not adversarial. The recruiter wants to close the offer — your interests are 90 % aligned. You both want a “yes” that you’ll be happy with for 2+ years. Approaching it as collaboration (“help me make this work”) consistently outperforms adversarial framing (“give me more”). The data shows: average successful counter at senior iOS levels lifts TC by 10–25 %, with no offers rescinded for politely negotiating.
TIP: Always negotiate base over equity at growth-stage startups — base is portable and certain; equity may be worth zero. Reverse at IPO-near or public companies — equity may be the multiplier.
WARNING: Don’t ghost recruiters once you’ve started a negotiation. Even if you decline, do it professionally. The iOS community is small and reputational; you’ll see these people again at conferences and at future companies.
Interview corner
Junior: “Should I negotiate my first offer?” Yes, every time. Even a 5–10 % bump compounds over a career. Lead with gratitude, then mention you’d like to discuss the package given market data.
Mid: “What’s the biggest negotiation mistake mid-level engineers make?” Anchoring first — giving a number when asked “what are you looking for” before knowing the company’s band. Always invite their offer first; use market data to evaluate; counter on multiple dimensions.
Senior: “How do you decide between two offers with similar TC?” I score them on: team and manager quality (highest weight), growth trajectory, tech alignment, remote flexibility, equity vesting profile, and brand for the next role after this one. A high-TC role under a bad manager loses to a lower-TC role with a great team over a 3-year horizon — both for happiness and for the doors it opens. I also factor risk: a $400k offer at a stable public co with 25/25/25/25 vesting is different from a $400k startup offer where the equity might be worth $0 or $4M, with a 4-year cliff. I’d usually take the higher-expected-value-with-lower-variance option unless I had specific information that tilts the variance.
Red-flag answer: “I always just take the highest number.” Reveals lack of long-term thinking about career, growth, and risk-adjusted value.
Lab preview
No formal lab. This week: pull your current market data on Levels.fyi for your target level + location, write down your “walk away” TC and your “delighted” TC. Update annually whether or not you’re job-searching — it shapes how you evaluate any opportunity that surfaces.
Phase 12 complete. You now have the technical, communication, and career-craft toolkit for the senior iOS interview loop. Next stop: Phase 13 (capstone projects) brings it all together in shippable form.
Labs in this phase:
- Lab 12.1 — Refactor to MVVM
- Lab 12.2 — Modularize a Monolith
- Lab 12.3 — Mock Technical Interview
- Lab 12.4 — System Design Whiteboard
Lab 12.1 — Refactor to MVVM
Goal: take a single 400-line UIViewController doing everything (network, parsing, state, presentation) and refactor it cleanly to MVVM with @Observable while preserving behavior.
Time: ~3 hours.
Prereqs: Xcode 16+, iOS 17+ simulator, comfort with async/await.
Setup
Create a new iOS App project, “RefactorMVVM”, SwiftUI lifecycle but with a UIKit screen via UIViewControllerRepresentable. Drop in the following starter ArticlesViewController:
final class ArticlesViewController: UIViewController, UITableViewDataSource, UITableViewDelegate {
private let tableView = UITableView()
private var articles: [[String: Any]] = []
private var isLoading = false
private var errorMessage: String?
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .systemBackground
title = "Articles"
tableView.dataSource = self
tableView.delegate = self
tableView.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(tableView)
NSLayoutConstraint.activate([
tableView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor),
tableView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
tableView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
tableView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
])
load()
}
private func load() {
isLoading = true
let url = URL(string: "https://hacker-news.firebaseio.com/v0/topstories.json")!
URLSession.shared.dataTask(with: url) { data, _, error in
DispatchQueue.main.async {
self.isLoading = false
if let error = error {
self.errorMessage = error.localizedDescription
self.tableView.reloadData()
return
}
guard let data = data,
let ids = try? JSONSerialization.jsonObject(with: data) as? [Int] else {
self.errorMessage = "Decode failed"
self.tableView.reloadData()
return
}
self.fetchDetails(for: Array(ids.prefix(20)))
}
}.resume()
}
private func fetchDetails(for ids: [Int]) {
let group = DispatchGroup()
var fetched: [[String: Any]] = []
for id in ids {
group.enter()
let url = URL(string: "https://hacker-news.firebaseio.com/v0/item/\(id).json")!
URLSession.shared.dataTask(with: url) { data, _, _ in
defer { group.leave() }
guard let data = data,
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return }
fetched.append(json)
}.resume()
}
group.notify(queue: .main) {
self.articles = fetched
self.tableView.reloadData()
}
}
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
articles.count
}
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = UITableViewCell()
cell.textLabel?.text = articles[indexPath.row]["title"] as? String ?? "(no title)"
return cell
}
}
(Yes, this is a horror show on purpose — most legacy code looks like this.)
Build
Run it, confirm the table populates with 20 Hacker News stories.
Tasks
Task 1 — Model (30 min)
Replace [String: Any] with a real struct Article: Identifiable, Codable, Equatable:
struct Article: Identifiable, Codable, Equatable {
let id: Int
let title: String
let url: String?
let by: String
let score: Int
}
Update parsing to use JSONDecoder not JSONSerialization.
Task 2 — Service (30 min)
Extract networking into:
protocol HackerNewsService {
func topStoryIDs() async throws -> [Int]
func article(id: Int) async throws -> Article
}
final class LiveHackerNewsService: HackerNewsService { /* … */ }
Use async/await. Use withThrowingTaskGroup to fetch article details in parallel.
Task 3 — ViewModel (45 min)
@Observable @MainActor
final class ArticlesViewModel {
enum State { case idle, loading, loaded([Article]), error(String) }
private(set) var state: State = .idle
private let service: HackerNewsService
init(service: HackerNewsService) { self.service = service }
func load() async { /* set loading, fetch IDs, fetch details, set loaded or error */ }
}
Task 4 — View (30 min)
Replace UITableView with a SwiftUI List:
struct ArticlesView: View {
@State private var viewModel: ArticlesViewModel
init(service: HackerNewsService = LiveHackerNewsService()) {
_viewModel = State(initialValue: ArticlesViewModel(service: service))
}
var body: some View {
NavigationStack {
content
.navigationTitle("Articles")
.task { await viewModel.load() }
.refreshable { await viewModel.load() }
}
}
@ViewBuilder var content: some View {
switch viewModel.state {
case .idle, .loading: ProgressView()
case .loaded(let articles): List(articles) { article in /* row */ }
case .error(let msg): VStack { Text(msg); Button("Retry") { Task { await viewModel.load() } } }
}
}
}
Task 5 — Tests (45 min)
Create ArticlesViewModelTests:
struct MockService: HackerNewsService {
let result: Result<[Article], any Error>
func topStoryIDs() async throws -> [Int] { [1] }
func article(id: Int) async throws -> Article {
switch result { case .success(let a): a.first!; case .failure(let e): throw e }
}
}
Write tests for: load() → .loaded; failure → .error; pre-load state is .idle.
Stretch
- Add pull-to-refresh state distinct from initial load.
- Add per-article detail screen via
NavigationLink. - Persist last-fetched articles via SwiftData; show cached on launch before network completes.
- Add a Combine-based variant for comparison; note where async/await is cleaner.
Notes
The point isn’t to memorize MVVM syntax — it’s to feel the difference between the starter mess and the refactored version. After this lab, you should be able to spot Massive View Controller smell in a code review within seconds.
Next: Lab 12.2 — Modularize a Monolith
Lab 12.2 — Modularize a Monolith
Goal: take a single-target SwiftUI app and extract a DesignSystem package, a Networking package, and one feature package, learning the mechanics and pain points of SPM-based modularization.
Time: ~3 hours.
Prereqs: Xcode 16+, basic familiarity with Swift Package Manager (consuming, not yet authoring).
Setup
Create a new iOS App, “MonolithToModular”. Add the following starter code in ContentView.swift:
import SwiftUI
struct PrimaryButton: View {
let title: String
let action: () -> Void
var body: some View {
Button(action: action) {
Text(title)
.font(.headline)
.foregroundColor(.white)
.padding()
.frame(maxWidth: .infinity)
.background(Color.accentColor)
.cornerRadius(12)
}
}
}
struct Card<Content: View>: View {
let content: Content
init(@ViewBuilder content: () -> Content) { self.content = content() }
var body: some View {
content.padding().background(Color(.secondarySystemBackground)).cornerRadius(16)
}
}
struct Quote: Codable, Identifiable {
let id = UUID()
let content: String
let author: String
enum CodingKeys: String, CodingKey { case content, author }
}
final class QuoteAPI {
static let shared = QuoteAPI()
func fetchQuote() async throws -> Quote {
let url = URL(string: "https://api.quotable.io/random")!
let (data, _) = try await URLSession.shared.data(from: url)
return try JSONDecoder().decode(Quote.self, from: data)
}
}
@Observable @MainActor
final class QuoteViewModel {
var quote: Quote?
var isLoading = false
func load() async {
isLoading = true
defer { isLoading = false }
quote = try? await QuoteAPI.shared.fetchQuote()
}
}
struct QuoteView: View {
@State private var vm = QuoteViewModel()
var body: some View {
VStack(spacing: 20) {
if vm.isLoading { ProgressView() }
if let q = vm.quote {
Card {
VStack(alignment: .leading, spacing: 8) {
Text("\u{201C}\(q.content)\u{201D}").font(.body)
Text("\u{2014} \(q.author)").font(.caption).foregroundColor(.secondary)
}
}
}
PrimaryButton(title: "New quote") { Task { await vm.load() } }
}
.padding()
.task { await vm.load() }
}
}
struct ContentView: View {
var body: some View { QuoteView() }
}
Run it, confirm a quote loads.
Tasks
Task 1 — Create the workspace structure (15 min)
Quit Xcode. In Finder, create:
MonolithToModular/
├── MonolithToModular.xcodeproj ← existing
├── App/ ← move .xcodeproj here later (optional)
└── Modules/
├── Core/
│ ├── DesignSystem/
│ └── Networking/
└── Features/
└── Quote/
Reopen Xcode.
Task 2 — Extract DesignSystem (45 min)
In Xcode, File → New → Package → Library. Name DesignSystem. Save under Modules/Core/. In the package’s Package.swift:
// swift-tools-version: 6.0
import PackageDescription
let package = Package(
name: "DesignSystem",
platforms: [.iOS(.v17)],
products: [.library(name: "DesignSystem", targets: ["DesignSystem"])],
targets: [
.target(name: "DesignSystem", path: "Sources/DesignSystem"),
.testTarget(name: "DesignSystemTests", dependencies: ["DesignSystem"]),
]
)
Move PrimaryButton and Card into Sources/DesignSystem/. Mark them public. Mark init and any properties used externally public.
In Xcode’s project navigator, drag the DesignSystem folder into the Xcode project. Then in the app target’s General → Frameworks, Libraries, and Embedded Content, add DesignSystem.
In ContentView.swift, add import DesignSystem. Delete the duplicate PrimaryButton and Card from the app target. Build. Fix public/private errors as they surface.
Task 3 — Extract Networking (45 min)
Same pattern. Create Modules/Core/Networking/Package.swift. Move Quote and QuoteAPI into Sources/Networking/. Mark everything public. Drop the static let shared singleton — replace with constructor injection:
public final class QuoteAPI {
private let session: URLSession
public init(session: URLSession = .shared) { self.session = session }
public func fetchQuote() async throws -> Quote { /* … */ }
}
Link Networking to the app target. Update QuoteViewModel to take a QuoteAPI in its init.
Task 4 — Extract the Quote feature (45 min)
Create Modules/Features/Quote/Package.swift. Declare it depends on DesignSystem and Networking:
dependencies: [
.package(path: "../../Core/DesignSystem"),
.package(path: "../../Core/Networking"),
],
targets: [
.target(name: "Quote", dependencies: ["DesignSystem", "Networking"]),
.testTarget(name: "QuoteTests", dependencies: ["Quote"]),
]
Move QuoteViewModel and QuoteView into Sources/Quote/. Mark public.
Add Quote to the app target. In ContentView.swift, just import Quote and use QuoteView().
The app target now contains: @main struct, root ContentView, and Info.plist. Everything else lives in packages.
Task 5 — Verify isolation (15 min)
Make a trivial change in DesignSystem (e.g., change PrimaryButton corner radius from 12 to 14). Build incrementally. Observe: only DesignSystem and Quote recompile, not Networking. This is the modularization payoff.
Task 6 — Add tests (30 min)
In QuoteTests, write:
import XCTest
import Networking
@testable import Quote
final class QuoteViewModelTests: XCTestCase {
func testLoadSetsQuote() async {
// create a mock URLSession that returns a canned Quote payload
// assert vm.quote != nil after load()
}
}
The fact you can @testable import Quote without the whole app target compiling is another modularization win.
Stretch
- Extract an
Analyticspackage with a no-opLogAnalyticsand aMockAnalyticsfor tests. - Add a second feature package (
Settings) that depends onDesignSystemonly. Verify changing it doesn’t recompileQuote. - Introduce an
Interfaces/QuoteInterfacepackage containing only theQuoteFeatureRoutingprotocol. HaveSettingsdepend onQuoteInterface(notQuote) to navigate to the quote screen. - Measure clean and incremental build times before and after modularization. Record in a table.
Notes
The first time you do this, expect 30 minutes of “why won’t this compile” — public modifiers, missing target memberships, package cycle errors. The second time you’ll do it in 90 minutes. By the fifth you’ll set up a new modular project in 30. This is muscle memory you build by doing.
Next: Lab 12.3 — Mock Technical Interview
Lab 12.3 — Mock Technical Interview
Goal: simulate a full 60-minute senior iOS technical interview, alone or with a partner, with self-grading rubrics.
Time: 60 min interview + 15 min self-debrief = 75 min total.
Prereqs: Quiet room, Xcode or a web Swift playground (https://swiftfiddle.com), timer, recording device.
Setup
If solo: record yourself (audio + screen). If with a partner: they read prompts and play interviewer.
Pick one of three difficulty paths below before starting.
Format
0:00 – 5:00 Warm-up Q&A (3 conceptual questions, ~90 sec each)
5:00 – 35:00 Live coding (one of the prompts below)
35:00 – 50:00 System design (lighter version of one Phase 12.9 scenario)
50:00 – 60:00 Behavioral (one STAR-style question)
Warm-up Q&A bank — pick 3
- Difference between
@State,@StateObject,@ObservedObject, and@Environment? - How does
actorprevent data races, and what’s reentrancy? - When would you choose
classoverstruct? - Walk me through
URLSession.shared.data(from:)from start to finish. - What problem does SwiftData solve that Core Data didn’t?
- Explain
Sendableand Swift 6 strict concurrency in one minute. - Difference between
weakandunowned? - How does
@Observablediffer fromObservableObject? - What’s the responder chain in UIKit?
- How do you debug a retain cycle in a SwiftUI app?
Score each answer L1/L2/L3 per 12.8.
Live coding — pick one difficulty
Easy (target: junior/mid)
Implement a
Debounceractor in Swift Concurrency. It should let callers schedule an async closure to run after N milliseconds of inactivity. Each new call cancels the previously scheduled run.
Bonus: write a unit test using Task.sleep proving rapid successive calls only fire once.
Medium (target: mid/senior)
Implement an LRU cache generic over
Key: HashableandValue. Constructor takes capacity.get(_:)returns optional value and bumps recency.set(_:_:)inserts or updates; evicts least-recently-used when over capacity. All operations O(1).
Bonus: make it thread-safe via an actor variant.
Hard (target: senior/staff)
Implement a
RateLimiteractor: a token bucket allowing N requests per T seconds. Methodacquire()is async — returns immediately if a token is available, or waits until one is. Cancellation must clean up waiters.
Bonus: support priority — high-priority callers jump the queue.
Live coding self-grading rubric
| Dimension | 0 (poor) | 1 (passable) | 2 (strong) |
|---|---|---|---|
| Clarifying questions | None asked | 1–2 generic | 3+ specific, written down |
| Narration | Silent typing | Some narration, gaps | Continuous; thinking audible |
| Type signature first | No | Yes, but inconsistent | Yes, explicit, justified |
| Tests / verification | None | Manually walked through | At least one sanity test |
| Tradeoff awareness | None mentioned | One in passing | Multiple explicit |
| Recovery from stuck | Silent or gave up | Asked for hint | Verbalized obstacle, simplified, asked for hint |
| Code correctness | Doesn’t compile | Compiles, has bugs | Compiles and runs correctly |
| Code clarity | Hard to read | Readable | Idiomatic, self-documenting |
Pass bar for senior level: average ≥ 1.5 with no dimension at 0.
System design — pick one
Choose one scenario from 12.9 (Instagram Feed, Real-Time Chat, Offline Note App, Maps, Video Streaming) and discuss for 15 min covering:
- 3 clarifying questions (record what you’d ask)
- High-level architecture sketch
- iOS-specific deep-dive on data layer + state + concurrency
- Two tradeoffs you’d flag to interviewer
- What changes at 10× scale
Grade against the rubric:
| Dimension | 0 | 1 | 2 |
|---|---|---|---|
| Clarifying | Skipped | Generic | Specific to scope |
| iOS focus | Server-heavy | Mixed | Predominantly iOS |
| Tradeoff verbalization | None | One | Multiple, named |
| Scaling discussion | None | Hand-wavy | Concrete (CDN, sharding, etc.) |
| Offline / background concerns | Ignored | Mentioned | Designed for |
Behavioral — pick one
- Tell me about a time you owned a difficult technical decision.
- Tell me about your hardest bug.
- Tell me about a time you disagreed with a designer or PM.
- Tell me about a project that failed and what you learned.
- Why do you want to work here? (research a real company)
Use a STAR template from 12.11. 90-second timer.
Self-grade:
| Dimension | 0 | 1 | 2 |
|---|---|---|---|
| STAR structure | Missing pieces | All present, weak transitions | All present, smooth |
| “I” language | Mostly “we” | Mixed | Mostly “I” with team context |
| Concrete result | None | Vague (“it went well”) | Specific number / outcome |
| Time discipline | > 2 min | 90 s – 2 min | < 90 s |
Debrief (15 min)
- Re-listen to your recording.
- Score each section using the rubrics above.
- Identify the single weakest area.
- Pick one action to improve next week.
Common weak areas and remedies:
| Weak area | Remedy |
|---|---|
| Silent live coding | Practice narrating while doing daily work tasks |
| Skipped clarifications | Write the 5 standard questions on a sticky note next to your monitor |
| Vague STAR results | Rewrite stories with measurable outcomes; verify numbers |
| Wandering system design | Time-box each section in a 4×4 grid: clarify / architecture / iOS deep dive / tradeoffs |
| Defensive when corrected | Practice the “you’re right, I missed that” acknowledgment phrase aloud |
Stretch
- Run the same exercise 4 weeks running. Track scores. Look for the inflection point (typically week 3) where it stops feeling unnatural.
- Pair with a peer; switch interviewer/candidate roles each week.
- Record yourself answering a single behavioral question 5 times in a row, listening to each playback. By the fifth, it’ll be tight.
Notes
The single most valuable feedback comes from listening to your own recording. You will cringe. That’s the data; iterate on it. Most candidates skip recording and then wonder why interviews don’t go well — they’ve never heard themselves under simulated pressure.
Next: Lab 12.4 — System Design Whiteboard
Lab 12.4 — System Design Whiteboard: Apple Notes Sync
Goal: produce a written system design document for “Design the iOS half of Apple Notes” — the kind of deliverable you’d submit as a take-home or paste into a senior interview transcript.
Time: 90 min writing + 30 min self-review = 2 hours.
Prereqs: Read 12.9 — System Design Scenarios. Have a markdown editor or paper handy.
The prompt
Design the iOS app architecture for Apple Notes. Specifically:
- Single-user sync across iPhone, iPad, and Mac
- Rich text + image attachments + sketches (PencilKit)
- Offline-first (full functionality without network)
- Real-time collaboration with shared notes (multiple cursors)
- 100M+ users at scale
You have 90 minutes. Write a design doc that an iOS team lead could hand to two senior engineers and have them start implementation.
Required sections
Your doc must include:
- Assumptions & clarifying questions (10 min)
- High-level architecture diagram + description (15 min)
- iOS data layer design (15 min)
- iOS state + concurrency model (10 min)
- Offline + sync strategy (15 min)
- Real-time collaboration (10 min)
- Tradeoffs & open questions (10 min)
- What I’d build first (MVP slice) (5 min)
Time-box each section. If you run out of time on one, mark it [TODO: revisit] and move on. Better incomplete than imbalanced.
Template
Use this skeleton, fill in your own answers:
# Apple Notes Sync — iOS System Design
## 1. Assumptions & clarifying questions
I'm assuming:
- Backend exists (iCloud-style CKContainer); my scope is iOS half.
- Targeting iOS 17+ (uses SwiftData, NavigationStack, @Observable).
- Single-user MVP; multi-user collaboration is V2.
Questions I'd ask the PM:
- (List 5 questions about scope, scale, conflict-resolution policy, etc.)
## 2. High-level architecture
[ASCII diagram or text description of: iOS app → Local store → Sync engine → CloudKit → other devices]
Components:
- Local persistence layer (SwiftData)
- Sync engine (background task + push notification triggered)
- UI layer (SwiftUI with @Observable view models)
- Editor (custom rich-text + PencilKit)
## 3. iOS data layer design
Models:
```swift
@Model final class Note {
var id: UUID
var title: String
var body: AttributedString // serialized as RTF
var attachments: [Attachment]
var createdAt: Date
var modifiedAt: Date
var deletedAt: Date? // tombstone
var folderID: UUID?
var serverChangeTag: String? // for sync
init(...) { ... }
}
@Model final class Folder { ... }
@Model final class Attachment { ... }
Indexes: modifiedAt DESC, folderID, deletedAt IS NULL.
Full-text search: SQLite FTS5 via SwiftData’s underlying store.
4. iOS state + concurrency
NoteListViewModel(@Observable, @MainActor) per folder.NoteEditorViewModel(@Observable, @MainActor) per open note.SyncEngine(actor) coordinates background sync.- All reads from local store (synchronous, fast).
- Writes go through SyncEngine.enqueue() for replay.
5. Offline + sync strategy
- Local-first: every write is local immediately.
- Sync triggered by: app foreground, BGAppRefreshTask, push notification, manual pull-to-refresh.
- Per-record sync via CKQueryOperation with serverChangeToken.
- Conflict resolution: last-write-wins with HLC timestamps, surface conflicts in UI as “two versions exist” banner.
- Deletes: soft via tombstone with deletedAt; hard delete after 30 days.
[Discuss: what happens when same note edited on two offline devices.]
6. Real-time collaboration (V2)
- Per-note WebSocket session for shared notes.
- Operational Transform (OT) or CRDT (e.g., Yjs port to Swift) for character-level merge.
- Presence: send cursor position; receive others’ cursors.
- Fallback to per-section locking if real-time engine unavailable.
[Discuss: tradeoff between OT and CRDT.]
7. Tradeoffs & open questions
- SwiftData vs Core Data: SwiftData chosen for iOS 17+ greenfield; risk = newer/less battle-tested.
- CloudKit vs custom backend: CloudKit for free zero-config sync; locks to Apple ecosystem.
- HLC vs CRDT: HLC + LWW simpler but loses concurrent edits; CRDT more code but auto-merges.
- PencilKit vs custom canvas: PencilKit native and free; less control over stroke data format.
Open: how to migrate from local-only V1 to collab V2 without breaking offline users mid-deploy.
8. MVP slice — what I’d build first
Week 1: Local-only notes (create/read/update/delete) with SwiftData; no sync. Week 2: Basic CloudKit sync, no conflict UI (assume single-device). Week 3: Conflict detection + manual resolution UI. Week 4: Background sync via BGAppRefreshTask + silent push. Week 5: Attachments (images). Week 6: Polish, perf testing on 5k-note libraries.
Real-time collab is V2, 2+ months out.
## Self-review checklist (30 min)
After writing, score against this rubric:
| Section | 0 (missing/weak) | 1 (acceptable) | 2 (strong) |
|---|---|---|---|
| Clarifying Qs | < 3 generic | 3–5 specific | 5+ scoped to actual gaps |
| Architecture diagram | None or unclear | Clear text description | ASCII or labeled diagram |
| Data model code | Pseudocode only | Swift with key fields | Swift with indexes + sync metadata |
| Concurrency model | "use async" | Names actors / @MainActor boundaries | Explicit concurrency-boundary justification |
| Sync triggers | Vague | Enumerated | Enumerated + battery considerations |
| Conflict resolution | Hand-waved | Named strategy | Named + UI surface + edge cases |
| Tradeoffs section | None or 1 | 2–3 named | 3+ with explicit alternatives |
| MVP slice | Missing | Listed | Time-boxed + dependencies |
Pass bar for senior level: ≥ 12/16 total, with no section at 0.
## Stretch
- Repeat the exercise for a different prompt (real-time chat, video upload pipeline).
- Send your design to a senior peer for review; ask them to grade against the rubric and find what you missed.
- Re-time it down to 60 min for tighter interview practice; then to 45 min.
## Notes
The hardest part of system design is *not* knowing the answer — it's *managing the conversation in real time*. Writing a doc removes the time pressure but builds the skeleton. Once you can write the doc cold, the live whiteboard version is just spoken version of the same content.
This lab is also good portfolio material — a polished design doc on your GitHub (gist or repo) gives recruiters a deeper signal than another sample app.
---
**End of Phase 12 labs.** Next phase covers full capstone projects bringing everything together.
Phase 13 — Capstone Projects
Six end-to-end projects you ship, talk about in interviews, and put on your portfolio. Each is sized for 1–3 weeks of focused work and is designed to make a specific kind of conversation possible: “Here’s a thing I built. Here are the tradeoffs. Here’s what I’d do differently.”
The capstones are deliberately diverse — Apple frameworks, third-party SDKs, multiplatform UI, real-money StoreKit, machine learning, AR. Pick the ones whose conversations you want to be having in interviews.
The Projects
| # | Name | Primary tech | What it proves |
|---|---|---|---|
| 1 | SkyWatch | WeatherKit, MapKit, CloudKit, WidgetKit | You can integrate first-party Apple SDKs end-to-end and ship widgets people use every day |
| 2 | FitTrack | HealthKit, SwiftData+CloudKit, watchOS, Swift Charts | You understand health data, sync, watch complications, and Charts |
| 3 | ShopKit | StoreKit 2, networking layer, Keychain, GitHub Actions | You can ship a real-money product with a production CI/CD pipeline |
| 4 | NoteSync | Sign in with Apple, CloudKit shared DB, AppIntents, iOS+macOS | You can build a multi-user, multi-device sync product |
| 5 | DevPortfolio | CoreML, ARKit, TCA, App Store submission walkthrough | You can use ML + AR and submit through Review without flinching |
| 6 | PlanBoard | SwiftUI multiplatform, SwiftData+CloudKit, macOS toolbar/CommandMenu, WidgetKit, AppIntents | You can ship a true universal binary with platform-respectful UX on both iOS and macOS |
How to use this phase
You don’t need to build all six. Pick two or three that map to the jobs you want.
- Targeting consumer apps with widgets, location, or maps? → SkyWatch
- Targeting health/fitness? → FitTrack
- Targeting commerce, subscriptions, paid apps? → ShopKit
- Targeting collaboration, productivity, communication? → NoteSync
- Targeting ML/AR or “innovation engineer” roles? → DevPortfolio
- Targeting senior cross-platform roles or Mac-first companies? → PlanBoard (and one other)
PlanBoard is the universal-binary capstone — if you’re a senior candidate and only have time for one, make it this one. It’s the project that lets you say “I shipped a single codebase to iOS and macOS, here’s the architectural decision record for every #if os(...) branch.”
File structure (each project)
README.md— overview, tech stack, the 30-second elevator pitchrequirements.md— full user stories, acceptance criteria, platform-specific UX requirementsarchitecture.md— diagrams, module layout, ADRs (architecture decision records)implementation-guide.md— step-by-step build walkthroughhardening-checklist.md— production-ready + security review checklistinterview-talking-points.md— the 30-second pitch, 3-minute deep dive, and 10–15 questions the project prepares you to answerplatform-decision-record.md— PlanBoard only; every#if os(...)decision documented with rationale
The interview test for every capstone
Before you call a capstone “done,” you must be able to:
- Pitch it in 30 seconds so the listener knows what it does and why it’s interesting
- Deep-dive any subsystem for 3 minutes without referring to the code — data model, sync strategy, error handling, the gnarly bug you fixed
- Answer 10+ interview questions the project gave you the right to be asked
Each project’s interview-talking-points.md gives you all three.
What “shipped” means
- App Store submission is not required for capstones to count. TestFlight + a public GitHub repo is the minimum bar.
- The exception is ShopKit and DevPortfolio — those have an App Store submission walkthrough as part of the lab because dealing with Review is itself the skill being practiced.
Start with Capstone 1 — SkyWatch, or jump to whichever project matches the job you’re targeting.
Capstone 1 — SkyWatch
Tagline: A delightful, location-aware weather app with widgets that update in the background, severe-weather alerts, and a saved-location map you can scrub through.
Tech stack: WeatherKit · MapKit · CloudKit · WidgetKit · App Intents · SwiftUI · Fastlane CI
Time budget: ~2 weeks (one full week if you skip widgets and complications)
What this capstone proves:
- You can integrate multiple first-party SDKs end-to-end without each one becoming a half-finished demo
- You can build widgets people actually want (not the toy widgets every tutorial ships)
- You understand the WeatherKit usage-based pricing and how to design a UI that respects it
- You can sync CloudKit private database correctly with conflict handling
- You can ship a Fastlane TestFlight pipeline
The 30-second pitch
“SkyWatch is a weather app I built end-to-end on Apple’s stack — WeatherKit for forecasts, MapKit for a scrubbable radar-style map, CloudKit private DB for saved locations that sync across devices, and a Lock Screen widget that updates every 15 minutes with the next hour’s precipitation. The interesting bit was designing the WeatherKit call budget — Apple gives you 500K calls a month free, and I built a caching layer with TTL keyed by
(location, forecast type)that brought us to under 5K calls/month even with 100 daily users. Source is on GitHub, TestFlight build is live.”
Why this capstone
WeatherKit + CloudKit + WidgetKit hit three different parts of the iOS stack in one project. You get to talk about:
- API budget design (caching, TTL, deduplication) — a real engineering conversation, not a CRUD app conversation
- Widget timelines and refresh policy — separates juniors from mids
- CloudKit conflict resolution — separates mids from seniors
- MapKit performance with many annotations and overlays
- App Intents for Siri integration and Shortcuts
Files
- requirements.md — full feature list, user stories, acceptance criteria
- architecture.md — module layout, data flow, ADRs
- implementation-guide.md — step-by-step build walkthrough
- hardening-checklist.md — production + security review
- interview-talking-points.md — the conversation this capstone earns you
What “done” looks like
- Live on TestFlight with at least 5 external testers
- GitHub repo public with a README that shows screenshots and the architecture diagram
- All seven sections in
hardening-checklist.mdticked off - You can deliver the 30-second pitch above without looking at notes
- You can answer all 12 interview questions in
interview-talking-points.md
Stretch goals
- Apple Watch complication with the next hour’s precipitation
- Live Activity for active weather alerts (severe thunderstorm, tornado warning)
- App Intent for Siri: “Hey Siri, will it rain in Portland tomorrow?”
- Localize for Spanish, Japanese, German — and validate the
MeasurementFormatterpaths
Next: Requirements
SkyWatch — Requirements
Personas
- Casual checker — opens the app once a day, wants to know if it’s going to rain. Sees the home screen, leaves. Should be sub-5-second-experience.
- Outdoor planner — checks the hourly forecast before deciding to go for a run, picks a saved location near the trailhead, checks precipitation map. Should be sub-15-second.
- Severe-weather watcher — has notifications on, wants Lock Screen widget to surface the alert immediately and an in-app deep dive into the alert details.
User stories
Onboarding
- As a first-time user, I see a single screen explaining what SkyWatch does and tap “Get Started,” which triggers the location permission prompt with a clear
NSLocationWhenInUseUsageDescription. - If I deny location, the app falls back to a search bar where I can type a city. Denial is not a dead-end.
- After granting permission, I land on the home screen showing the current location’s weather within 2 seconds (cached if possible).
Home
- The home screen shows: current temperature, condition icon, “feels like,” 24-hour scrubbable timeline, next 7 days, and the WeatherKit attribution badge per Apple’s requirement.
- The timeline updates the displayed hour as I scrub; numbers don’t flicker.
- I can swipe left/right to switch between my saved locations.
- Pull-to-refresh forces a fresh WeatherKit call (subject to a 5-minute minimum interval to protect the budget).
Saved locations
- I can tap “+” to add a saved location via map pin drop, search, or “Use My Current Location.”
- Saved locations sync via CloudKit private DB across my devices automatically; I do not have to log in (CloudKit uses my Apple ID).
- I can reorder and delete saved locations. Deletion is immediate, no undo (matches Apple Weather).
- If sync fails (network, quota), the app shows a non-modal status indicator, never blocks the UI.
Map
- From a saved location, I can tap “Map” to see a MapKit view centered on that location with a heatmap overlay for precipitation in the next hour.
- The overlay scrubs forward in 5-minute intervals via a slider at the bottom.
- Annotations show nearby saved locations as smaller pins so I can quickly switch.
Widgets
- A Lock Screen widget (
accessoryCircularandaccessoryRectangular) shows the next-hour precipitation chance. - A Home Screen widget (
systemSmallandsystemMedium) shows the current temperature, condition icon, and high/low. - Widgets refresh at most every 15 minutes and use cached forecast data; they never call WeatherKit directly from the widget extension.
- If the data is stale > 6 hours, the widget shows “Tap to refresh” rather than fake data.
App Intents
- The Shortcut “Get weather for [location]” returns the current temperature, condition, and the next hour’s precipitation chance.
- The Shortcut works without launching the app (background execution allowed by intent).
Alerts
- If WeatherKit reports any severe alert for a saved location, the user receives a local notification using
UNUserNotificationCenterkeyed to the alert ID (to avoid duplicates). - Tapping the notification deep-links to the alert detail screen.
Acceptance criteria
- Cold-start to home screen with cached data: < 2 seconds on iPhone 13.
- Cold-start to home screen with fresh data: < 5 seconds on a typical LTE connection.
- Widget refresh failure rate: < 1% over a 7-day window (measure via os_signpost + a debug counter).
- WeatherKit calls per user per day: < 8 under normal use (cache hit rate > 80%).
- CloudKit sync conflict rate (locations modified concurrently on two devices): handled gracefully — last-writer-wins with a logged audit trail.
Non-goals
- No paid tier. WeatherKit’s free quota is the limit; no monetization in this capstone.
- No social features (no sharing locations between users, no comments).
- No precipitation forecasts beyond 1 hour for the map (WeatherKit’s
minuteForecastcovers exactly this window). - No iPad-specific UI beyond what SwiftUI gives for free (this is iPhone-first).
Constraints
- iOS 17+ (WeatherKit’s
SwiftAPI, App Intents 2.0, Live Activities). - US-only by default for alerts (WeatherKit alerts have limited international coverage; document this in the README).
- Apple Developer Program membership required (WeatherKit needs a paid account and a
WeatherKitcapability in the App ID).
Out of scope (explicitly)
These will be tempting to add — say no:
- Push notifications via APNs (use local notifications keyed to the saved location’s timeline; APNs requires a server and is a different capstone)
- Marketing site (your GitHub README is enough)
- iCloud key-value store (CloudKit private DB is enough; do not add a second sync layer)
- A backend service of any kind — WeatherKit + CloudKit cover everything
Next: Architecture
SkyWatch — Architecture
High-level diagram
+----------------------+ +----------------------+
| SwiftUI views | | Widget extension |
| (HomeView, MapView, | | (Lock Screen, |
| AlertView) | | Home Screen) |
+----------+-----------+ +----------+-----------+
| |
v v
+----------------------+ +----------------------+
| AppState | | TimelineProvider |
| (@Observable) | | (reads cache only) |
+----------+-----------+ +----------+-----------+
| |
v |
+----------------------+ |
| WeatherService |<------------------+
| (cache + WeatherKit)|
+----------+-----------+
|
v
+----------------------+ +----------------------+
| WeatherKit API | | CloudKit private DB |
| (Apple, throttled) | | (SavedLocation, |
| | | user-scoped) |
+----------------------+ +----------------------+
Module layout (SPM packages)
SkyWatch/
App/ # main app target (iOS)
Widget/ # widget extension target
Packages/
SkyWatchCore/ # models, errors, no UIKit/SwiftUI
WeatherService/ # WeatherKit + cache
LocationStore/ # CloudKit + local cache
SkyWatchUI/ # SwiftUI views, reusable design system
AppIntentsKit/ # App Intents (separate target so the
# intent metadata builds independently)
Why split this way:
SkyWatchCoreis pure-Swift. Both the app and the widget can depend on it without dragging UI frameworks into the widget binary.WeatherServiceandLocationStoreare independently testable. Mock each by injecting protocol-conforming fakes.SkyWatchUIis the only target that imports SwiftUI. Keep it lean.
Data flow — typical home view load
HomeView.task { ... }callsappState.refresh(for: location).AppStatecallsWeatherService.weather(for: location).WeatherServicechecksWeatherCache(in-memory + on-disk).- Cache hit (entry < TTL): return immediately.
- Cache miss: call
WeatherKit.WeatherService.shared.weather(for: location)(Apple’s API), persist the result to cache with a TTL, return. AppStatepublishes the result;HomeViewre-renders.
The cache layer (the interesting bit)
This is the single most interview-worthy piece of SkyWatch. WeatherKit charges per call beyond the free quota, so designing the cache is real engineering, not bookkeeping.
public actor WeatherCache {
public struct Key: Hashable {
public let coordinate: CLLocationCoordinate2D
public let kinds: Set<WeatherQuery.Kind>
}
private struct Entry {
let weather: Weather
let storedAt: Date
}
private var memory: [Key: Entry] = [:]
private let diskURL: URL
private let ttl: TimeInterval // e.g. 30 min for currentWeather, 6h for daily
public func get(_ key: Key) -> Weather? {
if let e = memory[key], Date().timeIntervalSince(e.storedAt) < ttl {
return e.weather
}
// try disk
return nil
}
public func set(_ key: Key, weather: Weather) {
memory[key] = Entry(weather: weather, storedAt: Date())
// persist to disk via Codable
}
}
Key design choices:
- Per-kind TTL:
currentWeatheris 30 min;hourlyForecastis 1 h;dailyForecastis 6 h;minuteForecastis 5 min. - In-memory + disk: in-memory for the running app, disk so the widget can read without a fresh API call.
- Coordinate quantization: bucket coordinates to ~3 decimals (~110 m) so users near the same place share cache entries.
- Stale-while-revalidate: home screen shows cached data immediately, kicks off a background refresh if data is older than 50% of TTL.
Widget timeline strategy
struct SkyWatchProvider: AppIntentTimelineProvider {
func timeline(for config: ConfigIntent, in context: Context) async -> Timeline<Entry> {
let cached = await SharedCache.read(for: config.locationID)
let entries = (0..<4).map { i in
Entry(date: .now.addingTimeInterval(Double(i) * 15 * 60),
weather: cached)
}
return Timeline(entries: entries, policy: .after(.now.addingTimeInterval(15 * 60)))
}
}
- Widget reads from a shared App Group cache; never calls WeatherKit itself.
- Timeline produces 4 entries (1 hour of 15-minute intervals), refreshes after the last.
- Background refresh in the main app updates the shared cache via
BGAppRefreshTask.
CloudKit schema
| Record type | Field | Type | Notes |
|---|---|---|---|
SavedLocation | name | String | user-editable |
latitude | Double | ||
longitude | Double | ||
order | Int64 | for ordering | |
createdAt | Date | system |
- Database: Private DB only. No public records.
- Subscription: silent push subscription on
SavedLocationso other devices auto-sync. - Conflict resolution: server change tag mismatch → reload + merge by
createdAt; for the same record edited concurrently, last-write-wins onnameandorder.
ADRs (Architecture Decision Records)
ADR-001: WeatherKit, not OpenWeatherMap
- Status: Accepted
- Context: Need a weather API. Free tier matters. WeatherKit is free for 500K calls/month, integrates natively, and meets Apple’s review preference for first-party APIs.
- Decision: Use WeatherKit.
- Consequences: Locked to iOS 16+ (acceptable). Must include attribution per Apple’s brand guidelines. Apple Developer Program ($99/yr) required.
ADR-002: CloudKit private DB, not SwiftData with CloudKit
- Status: Accepted
- Context: Need to sync saved locations across user’s devices. SwiftData+CloudKit is convenient but obscures the conflict resolution layer.
- Decision: Direct CKRecord + CKDatabase, with a thin
LocationStoreactor wrapping it. - Consequences: More code than SwiftData+CloudKit; better control. The capstone proves I understand CloudKit primitives, which is the goal.
ADR-003: SPM modules, not a single target
- Status: Accepted
- Context: Widget extension cannot depend on SwiftUI app target.
- Decision: Split into SPM packages so widget and app share core/service code without UI dependencies.
- Consequences: Slightly more Package.swift maintenance; massive testability win.
ADR-004: Local notifications, not APNs
- Status: Accepted
- Context: Need to alert users to severe weather. APNs requires a server.
- Decision: Background app refresh polls WeatherKit; if a new alert is found, schedule a
UNUserNotificationCenterlocal notification. - Consequences: No real-time alerts (refresh interval is iOS-controlled, ~15 min minimum). Acceptable tradeoff for capstone scope. Note this in interview answers as a known limitation with a clear remediation path (APNs + server).
Threading model
@MainActor:AppState, all SwiftUI views (default).- Actors:
WeatherCache,LocationStore,WeatherService. - Background tasks:
BGAppRefreshTaskfor widget cache warming and alert polling, registered inInfo.plistunderBGTaskSchedulerPermittedIdentifiers.
Error handling philosophy
- Recoverable errors (network down, WeatherKit rate-limited, CloudKit quota): show cached data with a non-modal status indicator.
- Unrecoverable errors (Apple ID signed out, WeatherKit entitlement missing): show a full-screen error with action.
- Programmer errors (force-unwraps): there are zero in this codebase; SwiftLint rule enforces.
Next: Implementation guide
SkyWatch — Implementation Guide
This is a step-by-step build walkthrough. Each step has a checkpoint: “if you see X, you’re on track.” Total estimated time: 40–60 hours spread across two weeks.
Day 1 — Project setup, capabilities, first WeatherKit call
Step 1. Create the project
mkdir SkyWatch && cd SkyWatch
# In Xcode: File → New → Project → iOS → App
# Product Name: SkyWatch, Interface: SwiftUI, Storage: None (we'll add CloudKit later)
Step 2. Enable WeatherKit
- In Apple Developer portal, edit your App ID → enable WeatherKit.
- In Xcode project → Signing & Capabilities → “+ Capability” → WeatherKit.
- Verify
WeatherKit.entitlementsis generated and committed.
Checkpoint: Building produces no signing errors. If you see com.apple.developer.weatherkit not allowed, the App ID isn’t right.
Step 3. First WeatherKit call
import WeatherKit
import CoreLocation
@MainActor
@Observable
final class HomeViewModel {
var current: CurrentWeather?
var errorMessage: String?
func load() async {
do {
let loc = CLLocation(latitude: 37.7749, longitude: -122.4194)
let weather = try await WeatherService.shared.weather(for: loc)
self.current = weather.currentWeather
} catch {
self.errorMessage = error.localizedDescription
}
}
}
Run it. You should see a CurrentWeather populated. Checkpoint: print weather.currentWeather.temperature — should be a Measurement<UnitTemperature> with a sane value for San Francisco.
Step 4. WeatherKit attribution
Apple requires displaying the attribution badge and a link to legal text. Add to HomeView:
HStack {
Image("weatherkit-badge") // download from Apple's brand assets
Link("Apple Weather Data Sources", destination: URL(string: "https://weatherkit.apple.com/legal-attribution.html")!)
}
.font(.caption2)
Do this before writing more features. Apple Review fails apps missing the attribution.
Day 2–3 — SPM modules + cache layer
Step 5. Extract SkyWatchCore
# In Xcode: File → New → Package → "SkyWatchCore"
# Add it as a local package, then make the main app depend on it.
Move your Weather, Location, error types here. No SwiftUI imports.
Step 6. Build WeatherService module
Create the WeatherService package with the cache from architecture.md. Expose a protocol so callers don’t depend on WeatherKit directly:
public protocol WeatherProviding {
func weather(for location: CLLocation) async throws -> Weather
}
public final class CachedWeatherService: WeatherProviding {
private let cache: WeatherCache
private let upstream: WeatherService
public init(cache: WeatherCache = WeatherCache(),
upstream: WeatherService = .shared) {
self.cache = cache
self.upstream = upstream
}
public func weather(for location: CLLocation) async throws -> Weather {
let key = WeatherCache.Key(coordinate: location.coordinate, kinds: [.current, .hourly, .daily])
if let cached = await cache.get(key) { return cached }
let fresh = try await upstream.weather(for: location)
await cache.set(key, weather: fresh)
return fresh
}
}
Checkpoint: write a unit test using a fake WeatherProviding to confirm HomeViewModel calls through and caches subsequent loads.
Day 4 — Saved locations + CloudKit
Step 7. Enable CloudKit
- Signing & Capabilities → + iCloud → check CloudKit + Key-value storage (off — we don’t use it).
- Add a container
iCloud.com.yourorg.skywatch. - Open CloudKit Dashboard → create the
SavedLocationrecord type in Development.
Checkpoint: launch the app, sign into iCloud in the simulator. CKContainer.default().accountStatus should return .available.
Step 8. Build LocationStore
public actor LocationStore {
private let db: CKDatabase
public init(container: CKContainer = .default()) {
self.db = container.privateCloudDatabase
}
public func fetchAll() async throws -> [SavedLocation] {
let query = CKQuery(recordType: "SavedLocation", predicate: NSPredicate(value: true))
let (results, _) = try await db.records(matching: query)
return results.compactMap { _, result in
guard case .success(let record) = result else { return nil }
return SavedLocation(record: record)
}
}
public func save(_ location: SavedLocation) async throws { /* ... */ }
public func delete(_ id: CKRecord.ID) async throws { /* ... */ }
public func subscribeToChanges() async throws { /* CKQuerySubscription */ }
}
Step 9. CloudKit subscription for sync
let sub = CKQuerySubscription(
recordType: "SavedLocation",
predicate: NSPredicate(value: true),
options: [.firesOnRecordCreation, .firesOnRecordUpdate, .firesOnRecordDeletion]
)
let info = CKSubscription.NotificationInfo()
info.shouldSendContentAvailable = true // silent push
sub.notificationInfo = info
try await db.save(sub)
Handle the silent push in application(_:didReceiveRemoteNotification:fetchCompletionHandler:). Reload LocationStore.
Checkpoint: edit a saved location on the simulator, see it appear on a real device signed into the same iCloud account within 30 seconds.
Day 5–6 — Map view
Step 10. MapKit with overlays
Use the iOS 17+ Map { ... } SwiftUI API:
Map(initialPosition: .region(region)) {
ForEach(savedLocations) { loc in
Marker(loc.name, coordinate: loc.coordinate)
}
}
Step 11. Precipitation overlay
WeatherKit’s minuteForecast gives 60 minutes of 1-minute precipitation amounts at a single coordinate. To build a “map” overlay, you sample multiple coordinates in a grid around the user’s center, batch-call WeatherKit, and draw a Canvas overlay tinted by intensity.
Heuristic budget: a 5×5 grid is 25 calls per map open. Cache aggressively (5-minute TTL on minuteForecast) and require a manual refresh.
Step 12. Time slider
A Slider from 0 to 60 (minutes ahead) drives which minute of the minute-forecast we render. Tint the overlay accordingly.
Checkpoint: open the map over San Francisco, scrub the slider, see overlay intensity change.
Day 7–8 — Widgets
Step 13. Add the widget extension
File → New → Target → Widget Extension → name SkyWatchWidget. Embed in app.
Step 14. App Group for shared cache
- Both app and widget targets → Signing & Capabilities → + App Groups →
group.com.yourorg.skywatch. WeatherCachewrites its disk file toFileManager.default.containerURL(forSecurityApplicationGroupIdentifier:).
Step 15. TimelineProvider
struct Provider: AppIntentTimelineProvider {
func snapshot(for configuration: ConfigurationAppIntent, in context: Context) async -> WeatherEntry { /* ... */ }
func timeline(for configuration: ConfigurationAppIntent, in context: Context) async -> Timeline<WeatherEntry> {
let cached = await SharedCache.read(for: configuration.locationID) ?? .placeholder
let entries = (0..<4).map { i in
WeatherEntry(date: .now.addingTimeInterval(Double(i) * 900), weather: cached)
}
return Timeline(entries: entries, policy: .after(.now.addingTimeInterval(900)))
}
}
Step 16. Widget views
Implement accessoryCircular (a precipitation icon + percentage), accessoryRectangular (next 4 hours sparkline), systemSmall (current temp + icon), systemMedium (current + next 12 hours).
Checkpoint: long-press home screen → Add Widget → SkyWatch — your widgets appear. Drop one on screen and watch it populate from the cached data.
Day 9 — Background refresh
Step 17. Register BGAppRefreshTask
BGTaskScheduler.shared.register(forTaskWithIdentifier: "com.yourorg.skywatch.refresh", using: nil) { task in
Task {
await self.refreshAllLocations()
task.setTaskCompleted(success: true)
}
}
Schedule the next one after each successful refresh.
Step 18. Severe-weather notifications
After refresh, compare the new alerts list against the persisted set; for new alerts, schedule a UNNotificationRequest keyed by alert.id so re-runs don’t duplicate.
Checkpoint: install a build, leave the app in the background overnight, wake up to confirm widget content advanced (you can also force a refresh from Xcode’s debug menu).
Day 10 — App Intents
Step 19. “Get weather for location” intent
struct GetWeatherIntent: AppIntent {
static var title: LocalizedStringResource = "Get Weather"
@Parameter(title: "Location") var location: LocationEntity
func perform() async throws -> some IntentResult & ProvidesDialog {
let weather = try await CachedWeatherService.shared.weather(for: location.clLocation)
let temp = weather.currentWeather.temperature
return .result(dialog: "It is \(temp.formatted()) in \(location.name).")
}
}
Register LocationEntity as an AppEntity conforming to IndexedEntity so saved locations appear in Shortcuts.
Checkpoint: open Shortcuts, search “SkyWatch” — your intent appears. Run it. Siri reads the result.
Day 11 — Fastlane + TestFlight
Step 20. Fastfile
See Phase 10 Lab 10.1 for the template. Add a lane:
lane :beta do
match(type: "appstore", readonly: true)
gym(scheme: "SkyWatch", export_method: "app-store")
pilot(skip_waiting_for_build_processing: true)
end
Step 21. First TestFlight upload
bundle exec fastlane beta
Add 5 external testers via App Store Connect. Wait for build processing (~5 min) + tester approval (~1 hour for first build).
Checkpoint: at least one external tester installs the build via TestFlight and reports the home screen loads.
Day 12–14 — Polish, hardening, screenshots
- Walk through
hardening-checklist.md. - Take screenshots in the simulator at all required sizes using Fastlane
snapshot. - Write the README on GitHub.
- Record a 60-second screen capture for your portfolio.
Next: Hardening checklist
SkyWatch — Hardening Checklist
Before you call SkyWatch “done” — before TestFlight invites go out and before you list it on your portfolio — every box below should be ticked. This is the pre-release review I’d run on a team.
1. Functional correctness
- Home screen renders in < 2 s with cached data on a cold launch
- Pull-to-refresh enforces a 5-minute minimum interval per location
- Saved locations sync across two devices within 30 s
- Map overlay scrub doesn’t drop frames on iPhone 13 or newer
- Widget refreshes every ~15 min on a device left idle overnight
- Severe-weather notification is delivered within 15 min of the alert appearing in WeatherKit
-
All currency/temperature/distance values use
MeasurementFormatterand respect locale
2. Security
- No third-party SDKs included (WeatherKit + CloudKit + Apple-only)
-
Info.plistNSAppTransportSecurityhas zero exceptions -
No API keys, tokens, or secrets in the binary (
stringsscan clean) - CloudKit container scoped to private DB; no public records used
-
PrivacyInfo.xcprivacydeclares accessed APIs and required reasons -
NSLocationWhenInUseUsageDescriptionis honest and specific -
No
print()of user data;os_loguses%{public}@only for non-PII -
No force-unwraps in app or widget code (
SwiftLint force_unwrapping = error)
3. Privacy
- Privacy Nutrition Label declares: Location (App Functionality, not linked to identity, not tracking)
-
No analytics SDK, no Firebase, no Sentry — verify in
Package.resolved - Background refresh task identifier is documented and disclosed in the app description
- Severe-weather notifications are clearly explained at first permission request
- User can disable notifications and delete all saved locations from within the app
4. Performance
-
Launch time < 400 ms (cold) measured by
os_signpostin Instruments - WeatherKit calls per user per day average < 8 (instrument with a counter and log to Console)
- Widget extension binary < 5 MB (Xcode → product → show → check)
- No retain cycles (Instruments → Leaks, run a 5-minute session)
- Map overlay redraws < 16 ms per frame (Instruments → Time Profiler)
- App responds within 100 ms after the user taps a saved location
5. Accessibility
-
All interactive elements have a
.accessibilityLabeland a.accessibilityHintwhere needed - VoiceOver reads the home screen meaningfully (“75 degrees, partly cloudy, light rain in 30 minutes”)
-
Dynamic Type up to
accessibility5doesn’t break layouts - Reduce Motion disables the map overlay animation
- Color contrast for all text meets WCAG AA (use the Accessibility Inspector)
- Widgets respect the system color scheme and tint mode (iOS 18)
6. Localization
-
All user-facing strings use
String(localized:) - App localized for English (Base), Spanish, German, Japanese at minimum
- Date/temperature/distance formatting verified in each locale
- No truncation in German (long compound words)
- Right-to-left layout verified in Arabic pseudo-localization
7. CI/CD + Release
- Unit tests pass on GitHub Actions on every PR
- UI test for “load home screen with cached data” passes
-
Fastlane
betalane uploads to TestFlight without manual intervention -
Build number auto-increments via
agvtoolin CI - App icon set complete for all sizes (1024 marketing icon included)
- Launch screen storyboard or Info.plist key configured
- App Store metadata (description, keywords, screenshots) drafted
8. Documentation
- README on GitHub with screenshot, architecture diagram, “how to run locally”
-
Architecture decision records (ADRs) in
architecture.mdreflect the shipped build - Interview talking points rehearsed at least twice
- You can deliver the 30-second pitch from memory
- A 60-second screen capture is on your portfolio
Sign-off
If you can tick every box above, SkyWatch ships. You can put it on your CV, link it on LinkedIn, and answer interview questions about it with confidence. Now read Interview talking points and rehearse.
SkyWatch — Interview Talking Points
This file gives you the three things you need to talk about SkyWatch in a real interview: a 30-second pitch, a 3-minute deep dive, and 12 likely questions with senior-level answers.
The 30-second pitch
“SkyWatch is a weather app I shipped to TestFlight, built end-to-end on Apple’s stack — WeatherKit for forecasts, CloudKit private DB for saved locations that sync across devices, MapKit with a scrubbable precipitation overlay, and a Lock Screen widget. The interesting engineering was the cache layer — WeatherKit charges per call beyond 500 K a month, so I built a per-forecast-type TTL cache with coordinate quantization that kept the per-user budget under 8 calls a day. Two-week build, all SwiftUI, Swift 6 concurrency throughout.”
Why it works: the pitch names specific Apple frameworks, gives a concrete engineering challenge, and quantifies the outcome. The interviewer now has three follow-up threads they can pull.
The 3-minute deep dive
If the interviewer says “tell me more about the cache layer”:
“Sure. The constraint was Apple’s pricing — WeatherKit is free up to 500 K calls a month. Cheap, but easy to blow through if you’re naive: every home-screen load is potentially 3 to 4 calls (current, hourly, daily, minute), times 5 saved locations, times pull-to-refresh.
So I built an actor-based cache with three layers. First, per-forecast-type TTLs —
currentWeatherstays fresh 30 minutes;dailyForecastlasts 6 hours. Second, coordinate quantization to about 3 decimal places, so users near the same place share entries. Third, an in-memory + disk split — disk is in a shared App Group container so the widget reads the same cache the main app writes, without ever making its own API call.The widget is the part most people get wrong. Their
TimelineProvidercalls the network directly, which on a cold widget refresh is a 5-second wait that violates Apple’s recommendation. Mine reads from cache only; if the cache is stale beyond 6 hours, the widget shows ‘Tap to refresh’ rather than fake data.The result: empirically, our test users came in around 4 to 6 WeatherKit calls per day. That’s safely inside the budget even if SkyWatch grew to 50 K daily users for free. And the architecture means I can swap WeatherKit out — if a future iOS deprecates it or we add Android, only
WeatherProviding’s implementation changes; everything above the protocol is untouched.“
That’s a 3-minute answer that signals: you understand cost, you’ve made tradeoffs, you’ve gotten the widget pattern right, and you’ve designed for change.
12 questions this capstone earns you
For each, the question is what the interviewer asks; the answer is the senior-level response you should be ready to give.
1. “Why WeatherKit and not OpenWeatherMap or Weather.gov?”
WeatherKit’s free tier is enough for indie scale, it integrates natively (no API key in the binary, no token rotation), and Apple Review prefers first-party APIs. The trade is iOS-16-minimum and Apple Developer Program required — both acceptable for an iOS-only consumer app. If I were going cross-platform, I’d pick weather.gov or NOAA for the US-only case (truly free, government source) and build a fallback layer.
2. “How do you handle WeatherKit being down?”
Two layers. First, the cache layer returns stale data on any error — better stale than blank. Second, a non-modal status indicator at the top tells the user “Last updated 12 min ago” so they know the data isn’t live. I never show a modal error or a spinner that can’t be dismissed. The only fatal path is “user signed out of iCloud” — there I show a full-screen ‘sign in to sync’ message.
3. “Walk me through what happens when I pull to refresh.”
The view sends a refresh event to AppState. AppState rate-limits to once per 5 minutes per location. If allowed, it calls WeatherService.weather(for:) with forceRefresh: true, which bypasses the cache, calls WeatherKit, writes the result to the cache (memory + disk), and returns. AppState publishes the new weather; the view re-renders. If WeatherKit fails, we keep the previous data and show the status indicator. The refresh either succeeds or is a no-op from the user’s perspective — never a half-state.
4. “How do widgets stay fresh without burning the API budget?”
The widget never calls WeatherKit itself. It reads from a shared App Group cache that the main app populates. A BGAppRefreshTask in the main app runs every ~15 minutes (the iOS-managed minimum), refreshes the cache, and the widget’s timeline picks up the new value at its next reload. If the user hasn’t opened the app for hours, the widget shows the last known data with a timestamp, not stale-data-pretending-to-be-fresh.
5. “How would you scale this to 1 million users?”
Two scales to think about. WeatherKit-wise, at 5 calls/user/day × 1 M users = 150 M calls/month. We’d blow Apple’s free tier and pay around $150 K/month in WeatherKit overage if my math is right (Apple charges $49.99 per million calls beyond 500 K). At that point I’d add a server: a thin proxy that fans out one WeatherKit call to many users for the same coordinate bucket. CloudKit-wise, the private DB scales per-user, so it scales automatically; no work needed.
6. “How do you test the cache layer?”
The cache is an actor with a clear protocol-mockable upstream. Tests inject a fake WeatherProviding that records calls, and assert: (a) first call hits upstream; (b) second call within TTL doesn’t; (c) third call after TTL expires hits upstream again. I also have a deterministic clock injected so tests don’t actually sleep. Coverage on WeatherCache is 100% — it’s the highest-risk file in the codebase.
7. “Why CloudKit and not SwiftData with CloudKit, or Core Data?”
For just saved locations — 4 fields, < 100 records per user, no relationships — SwiftData adds machinery I don’t need. Direct CKRecord lets me see and control the conflict resolution policy. SwiftData+CloudKit hides that behind a black box that’s hard to debug when sync misbehaves. I used SwiftData in a different project (FitTrack); here, the raw CloudKit was the right tool.
8. “What’s your CloudKit conflict resolution strategy?”
Last-write-wins on name and order, but I keep an audit log. When a record fetch returns a server change tag mismatch, I refetch the latest version, compare the user-editable fields, and write back with the resolved values. For ordering specifically — which is the most likely user-visible conflict — I rebuild the order on conflict by sorting all locations by createdAt if the explicit order field is contested. I documented this in ADR-002.
9. “How do you handle the WeatherKit call budget at the architectural level?”
Three layers. (1) Caching with per-type TTL. (2) Coordinate quantization to about 3 decimal places. (3) A monthly counter, surfaced via a metrics debug panel I can pull up to spot regressions. The counter is keyed by call type so I can attribute spikes — a single regression where minuteForecast started fetching on every scroll could blow the budget; the counter catches it within a day.
10. “Tell me about a bug you fixed.”
Widget extension memory limit is 30 MB. Mine was crashing on launch with no useful log. Took 3 hours to track down — I was loading the entire Weather Codable from disk for every entry, including all forecasts. Refactored the shared cache to expose a WidgetCache projection that only loads the fields the widget needs. Memory dropped to 8 MB, no more OOM. The lesson: widget extension RAM is generally about a third of the main app’s, treat it as a strictly bounded environment.
11. “Why no analytics?”
I value the Privacy Nutrition Label more than I value funnel data on a capstone project. If SkyWatch were a real product with subscription monetization, I’d add a thin first-party analytics layer logged to my own server — not Firebase, not Mixpanel, because I’d rather report “we don’t share data with anyone” honestly than “we collect for app functionality only” with an asterisk. For a portfolio project, that asterisk isn’t worth the friction.
12. “Walk me through your CI/CD.”
GitHub Actions, macos-14 runner. On every PR: SPM resolve, build, run unit + UI tests, SwiftLint, fail on any error. On merge to main: same, plus fastlane beta lane — match pulls the cert from a private repo, gym builds, pilot uploads to TestFlight with skip-waiting-for-processing. Build numbers auto-increment via agvtool. Average green build is about 18 minutes. If I were doing this commercially I’d cache the SPM build folder more aggressively, which would cut that to about 8.
Red-flag answers to avoid
If the interviewer asks “why did you build this,” don’t say “to learn WeatherKit.” Say: “to build a serious portfolio piece that demonstrates I can integrate multiple Apple SDKs in a coherent product with real engineering tradeoffs.” The first answer is a tutorial; the second is a project.
If they ask “how long did it take,” don’t say “a weekend.” Even if technically true, it makes the work sound trivial. Say: “Two weeks of focused work, plus ongoing polish — the implementation guide is in the repo.”
Now go build the next one: Capstone 2 — FitTrack.
Capstone 2 — FitTrack
Tagline: A workout & health-data companion that pulls from HealthKit, syncs via SwiftData + CloudKit, surfaces watchOS complications, and renders rich Swift Charts dashboards.
Tech stack: HealthKit · SwiftData + CloudKit · watchOS complications · Swift Charts · Live Activities · WidgetKit
Time budget: ~2 weeks (3 weeks if you include the watchOS app from scratch)
What this capstone proves:
- You can ask for HealthKit read & write authorization correctly and survive Apple Review
- You understand SwiftData + CloudKit sync model, including schema migrations
- You can ship a watchOS companion with a complication on the Modular and Smart Stack faces
- You can render production-quality Swift Charts — not the toy bar chart from a tutorial
- You can implement Live Activities + Dynamic Island for an in-progress workout
The 30-second pitch
“FitTrack is a workout-logging app that pulls heart rate, active energy, and distance directly from HealthKit on iPhone and Apple Watch, persists workouts in SwiftData with CloudKit sync, and visualizes 30-day trends with Swift Charts. The watchOS app runs the workout session with Live Activity updates on the Dynamic Island. The interesting engineering was the HealthKit query observer pattern — I built a single
HealthQueryStreamthat surfaces deltas asAsyncSequencevalues, which collapses what’s normally 200 lines ofHKObserverQueryboilerplate into a cleanfor try await sample in streamloop.”
Why this capstone
HealthKit is the single most-asked-about iOS framework in fitness-company interviews (Strava, Whoop, Garmin, Apple Health team, Peloton). It has the worst API ergonomics in the Apple ecosystem — building around it well is itself a signal. Combined with SwiftData+CloudKit (the current Apple-recommended path) and a watchOS complication, this capstone covers an unusually wide surface.
Files
- requirements.md — feature list, user stories, acceptance criteria
- architecture.md — module layout, sync strategy, ADRs
- implementation-guide.md — step-by-step build walkthrough
- hardening-checklist.md — production + security review
- interview-talking-points.md — pitch and Q&A
What “done” looks like
- iOS app + watchOS app + complication, all on TestFlight
- HealthKit read/write authorizations limited to the minimum set you actually use
- 30-day trend chart for at least 3 metrics (heart rate, steps, active energy)
- SwiftData+CloudKit syncing workouts between two physical devices
- One Live Activity per active workout, visible on Dynamic Island
- Hardening checklist fully ticked
Next: Requirements
FitTrack — Requirements
Personas
- Casual logger — opens after a run, taps “+ Run”, picks duration, saves. Wants the experience to take under 10 seconds.
- Active tracker — starts the workout on the Watch, lets it run with heart rate and pace, ends it; iPhone is only used later for review.
- Trend watcher — checks the dashboard weekly to see 30-day trends; wants beautiful, readable charts.
User stories
Onboarding & permissions
- As a first-time user I see a clear screen explaining what HealthKit data FitTrack reads and writes, before the system permission sheet.
- I am asked only for the minimum data: read heart rate / steps / active energy / distance, write workouts. Nothing else.
- If I deny everything, the app falls back to manual workout entry. Denial is not a dead-end.
Logging a workout (iPhone)
- From the home screen I tap “+” to log a workout: pick activity type (run/cycle/swim/walk/strength), duration, optional notes, optional photo.
- Workout saves to SwiftData and writes a corresponding HKWorkout to HealthKit (visible in Apple Health).
- If CloudKit sync is delayed, my saved workout is still visible immediately; sync indicator shows pending.
Starting a workout (watchOS)
- From the Watch app I tap an activity type to start a session. Heart rate and distance show live.
- The session runs in the foreground; double-tap on Apple Watch S9+ pauses/resumes.
- Ending the session saves the HKWorkout (with samples) to HealthKit and the FitTrack SwiftData store.
- While a session is active, a Live Activity on the iPhone shows duration + heart rate + a “Stop on Watch” hint.
Dashboard
- The dashboard shows 30-day trend charts for: heart rate (resting + active), steps, active energy, total workouts per week.
- Charts are tappable; tapping a data point opens that day’s detail with the contributing workouts.
- Charts gracefully handle missing days (shown as gaps, not as zero).
Sharing & export
- I can export my last 90 days of workouts as a
.csvvia the share sheet. - I can delete all FitTrack data — workouts and any cached samples — with one explicit two-tap confirmation. This must NOT delete HealthKit data written before (Apple’s rules).
Complications
- The Modular Compact face shows my last workout’s date + activity icon.
- The Smart Stack widget shows today’s active-energy progress vs goal.
Acceptance criteria
- Cold launch to home: < 1.5 s on iPhone 13.
- HealthKit query for 30 days of heart rate samples: < 800 ms end-to-end.
- Watch session start → first heart rate sample on screen: < 5 s.
- Live Activity update interval: 5 s during active workout, never less than the system permits.
- CloudKit sync of a new workout between two devices: < 30 s typical.
- Zero crashes in 100 simulated workouts (CI test).
Non-goals
- No social feed, no follower system, no “shares per workout” — not Strava.
- No GPS route tracking — that’s a third capstone’s worth of work and adds little here.
- No personalized coaching, no ML-derived recommendations.
- No third-party integration (Garmin, Whoop, etc.) — first-party Apple data only.
- No web dashboard.
Constraints
- iOS 17+ (SwiftData + CloudKit, Live Activities, Swift Charts 2.0).
- watchOS 10+ (modern workout session API, Smart Stack widget kind).
- Apple Developer Program — HealthKit capability requires it.
HealthKit privacy contract
The app’s purpose strings must be exact and honest. Drafts:
NSHealthShareUsageDescription: “FitTrack reads your heart rate, steps, distance, and active energy to show trends and power your workout history.”NSHealthUpdateUsageDescription: “FitTrack writes the workouts you log so they appear in Apple Health and contribute to your move ring.”
Apple Review rejects vague strings. Be specific.
Next: Architecture
FitTrack — Architecture
High-level diagram
+--------------------+ +---------------------+
| iOS app (SwiftUI) | | watchOS app |
| - Dashboard | | - Workout sessions |
| - Workout list | | - Live HR/distance |
| - Charts | | - Complications |
+----------+---------+ +----------+----------+
| |
v v
+--------------------+ +---------------------+
| SwiftData store |<------>| HealthKit |
| (CloudKit-backed) | | (HKWorkout, |
| | | HKQuantitySample) |
+--------------------+ +---------------------+
^
|
v
+--------------------+
| CloudKit private |
| DB (auto via |
| ModelConfig) |
+--------------------+
Two data systems intentionally:
- HealthKit is the source of truth for samples (heart rate, steps, energy) and for the canonical
HKWorkoutrecord. It syncs through Apple Health, not us. - SwiftData is our projection — workout metadata (notes, photos, tags) the user attaches that HealthKit doesn’t model. It syncs via CloudKit.
This separation is the most interview-worthy decision in FitTrack. We could put everything in SwiftData, but then user-deleted-FitTrack-data wouldn’t reach Apple Health. We could put everything in HealthKit, but HealthKit can’t store our custom notes/tags. So we keep both, linked by HKWorkout.uuid.
Module layout
FitTrack/
iOSApp/ # iOS target
WatchApp/ # watchOS target
WidgetExtension/ # widget + complications
Packages/
FitTrackCore/ # models, errors, no UI
HealthKitBridge/ # HealthKit query streams
Persistence/ # SwiftData container + queries
FitTrackUI/ # SwiftUI views shared across iOS targets
FitTrackWatchUI/ # watchOS-specific views
SwiftData schema
@Model
final class Workout {
@Attribute(.unique) var id: UUID // matches HKWorkout.uuid
var activityType: ActivityType // enum, custom
var startDate: Date
var duration: TimeInterval
var notes: String?
var photoData: Data? // small, < 200 KB; large goes to CK asset
@Relationship(deleteRule: .cascade) var tags: [WorkoutTag]
var createdAt: Date
init(id: UUID = UUID(), activityType: ActivityType,
startDate: Date, duration: TimeInterval) {
self.id = id
self.activityType = activityType
self.startDate = startDate
self.duration = duration
self.createdAt = .now
}
}
@Model
final class WorkoutTag {
var name: String
var createdAt: Date
init(name: String) { self.name = name; self.createdAt = .now }
}
Configured for CloudKit:
let config = ModelConfiguration(
cloudKitDatabase: .private("iCloud.com.yourorg.fittrack")
)
let container = try ModelContainer(for: Workout.self, WorkoutTag.self, configurations: config)
CloudKit container must have the schema deployed to Production before App Store submission.
HealthKit query stream pattern
The interesting design: wrap HealthKit’s awkward HKObserverQuery + HKAnchoredObjectQuery boilerplate behind an AsyncSequence.
public actor HealthQueryStream<Sample: HKSample> {
public typealias Element = [Sample]
private let store: HKHealthStore
private let sampleType: HKSampleType
private let predicate: NSPredicate?
private var anchor: HKQueryAnchor?
public init(store: HKHealthStore, sampleType: HKSampleType, predicate: NSPredicate? = nil) {
self.store = store
self.sampleType = sampleType
self.predicate = predicate
}
public func samples() -> AsyncThrowingStream<[Sample], Error> {
AsyncThrowingStream { continuation in
let query = HKObserverQuery(sampleType: sampleType, predicate: predicate) { [weak self] _, _, error in
if let error { continuation.finish(throwing: error); return }
Task { [weak self] in
guard let self else { return }
if let newSamples = try? await self.fetchSinceAnchor() {
continuation.yield(newSamples)
}
}
}
store.execute(query)
continuation.onTermination = { _ in
self.store.stop(query)
}
}
}
private func fetchSinceAnchor() async throws -> [Sample] {
// HKAnchoredObjectQuery with self.anchor; update self.anchor on completion
// ...
}
}
Now callers write:
let stream = HealthQueryStream<HKQuantitySample>(store: store, sampleType: .quantityType(forIdentifier: .heartRate)!)
for try await samples in stream.samples() {
// process new heart rate samples
}
That collapses the typical 200-line “implement two query types and route between them” pattern into 5 lines. It’s the single piece of code I’d lead with in an interview.
ADRs
ADR-001: SwiftData + CloudKit over Core Data + CloudKit
Status: Accepted.
Context: We need a local store that syncs to CloudKit. SwiftData (iOS 17+) is the new Apple recommendation; Core Data is legacy.
Decision: SwiftData with ModelConfiguration(cloudKitDatabase: .private(...)).
Consequences: iOS 17+ floor (acceptable). Less battle-tested than Core Data; we accept some sharp edges (schema migration is harder; debugging is sparser). Future-proof: this is where Apple is investing.
ADR-002: HealthKit is the source of truth for samples
Status: Accepted.
Context: Workouts produce both metadata (notes, tags — our model) and samples (heart rate, energy — Apple’s model). We must pick a system of record per category.
Decision: HealthKit owns samples + the canonical HKWorkout; SwiftData owns metadata linked by UUID.
Consequences: Two systems to keep in sync. Deletion is one-way (we delete from SwiftData; Apple Health requires the user to delete there). Trade is necessary: HealthKit can’t hold our notes; SwiftData can’t replace Apple Health.
ADR-003: AsyncSequence wrapper over raw HealthKit observers
Status: Accepted.
Context: HealthKit observers + anchored queries are 200+ lines of boilerplate per sample type.
Decision: A single HealthQueryStream actor exposes new samples as AsyncThrowingStream.
Consequences: One place to fix HealthKit bugs. Easier to test (mock the stream). Slight performance overhead from the actor hop — negligible compared to HealthKit’s own query cost.
ADR-004: Watch app starts workouts; iPhone displays them
Status: Accepted.
Context: Either device can host a workout session. Splitting roles makes the UX coherent.
Decision: Watch is the workout-session controller (HR sensor lives there). iPhone shows the running workout via Live Activity and the history afterward.
Consequences: User cannot start a workout from iPhone (they can only log one retroactively, which is different). Acceptable, matches Apple’s own design.
Threading model
- All SwiftUI views on
@MainActor. HealthQueryStreamis an actor.- SwiftData
ModelContextis bound to the main actor; background imports use a separateModelActor(Swift 5.9+). - Live Activity updates are throttled to once per 5 seconds on the iPhone side.
Error handling
- HealthKit-denied: graceful fallback to manual entry; never crash.
- CloudKit account unavailable: SwiftData operates locally; sync resumes when account returns. Non-modal status indicator.
- Watch–iPhone connectivity lost: Watch session continues independently; sync on reconnect.
Next: Implementation guide
FitTrack — Implementation Guide
This guide assumes you’ve finished SkyWatch and are comfortable with SwiftUI + Swift concurrency. Total estimated time: 50–70 hours.
Day 1 — Project + HealthKit auth
Step 1. Create iOS + watchOS targets
In Xcode: File → New → Project → iOS → App → Product Name FitTrack. Then File → New → Target → watchOS → Watch App → embed in companion iOS app.
Step 2. Capabilities
Both targets:
-
- Capability → HealthKit.
- iOS target: also + iCloud → CloudKit, container
iCloud.com.yourorg.fittrack. - iOS target: + App Groups →
group.com.yourorg.fittrack(for widget data sharing).
Step 3. Info.plist usage strings
<key>NSHealthShareUsageDescription</key>
<string>FitTrack reads your heart rate, steps, distance, and active energy to show trends and power your workout history.</string>
<key>NSHealthUpdateUsageDescription</key>
<string>FitTrack writes the workouts you log so they appear in Apple Health and contribute to your move ring.</string>
Step 4. Request authorization
import HealthKit
actor HealthAuth {
let store: HKHealthStore
init() { self.store = HKHealthStore() }
func request() async throws {
guard HKHealthStore.isHealthDataAvailable() else {
throw HealthError.unavailable
}
let read: Set<HKObjectType> = [
.quantityType(forIdentifier: .heartRate)!,
.quantityType(forIdentifier: .stepCount)!,
.quantityType(forIdentifier: .activeEnergyBurned)!,
.quantityType(forIdentifier: .distanceWalkingRunning)!,
HKObjectType.workoutType()
]
let write: Set<HKSampleType> = [HKObjectType.workoutType()]
try await store.requestAuthorization(toShare: write, read: read)
}
}
Checkpoint: launch the app, see the HealthKit permission sheet, allow all, no crash.
Day 2 — SwiftData + CloudKit setup
Step 5. Define Workout model (see architecture.md)
Step 6. Configure ModelContainer
@main
struct FitTrackApp: App {
let container: ModelContainer = {
let config = ModelConfiguration(
cloudKitDatabase: .private("iCloud.com.yourorg.fittrack")
)
return try! ModelContainer(for: Workout.self, WorkoutTag.self, configurations: config)
}()
var body: some Scene {
WindowGroup { ContentView() }
.modelContainer(container)
}
}
Step 7. Deploy schema to CloudKit
- Run the app once on a device signed into iCloud — SwiftData seeds the dev container.
- CloudKit Dashboard → your container → Schema → Deploy to Production.
Checkpoint: insert a Workout on one device, see it appear on another within 30 s.
Day 3–4 — HealthKit query stream
Step 8. Build the HealthQueryStream actor
See the implementation in architecture.md. Add to HealthKitBridge package.
Step 9. Consume in a SwiftUI view
struct HeartRateLiveView: View {
@State private var latestBPM: Double?
let store = HKHealthStore()
var body: some View {
Text(latestBPM.map { "\(Int($0)) bpm" } ?? "—")
.task {
let stream = HealthQueryStream<HKQuantitySample>(
store: store,
sampleType: .quantityType(forIdentifier: .heartRate)!
)
do {
for try await samples in stream.samples() {
if let last = samples.last {
latestBPM = last.quantity.doubleValue(for: HKUnit(from: "count/min"))
}
}
} catch {
// log
}
}
}
}
Checkpoint: while wearing an Apple Watch, the value updates within seconds.
Day 5 — Workout logging on iPhone
Step 10. Log workout form
A SwiftUI Form with activity type picker, date pickers, duration stepper, notes field, optional photo picker.
Step 11. Save to both stores
func saveWorkout(...) async throws {
let id = UUID()
// 1. HealthKit
let hkWorkout = HKWorkout(
activityType: activityType.hkType,
start: startDate, end: startDate.addingTimeInterval(duration),
duration: duration,
totalEnergyBurned: nil, totalDistance: nil,
metadata: [HKMetadataKeyExternalUUID: id.uuidString]
)
try await store.save(hkWorkout)
// 2. SwiftData
let model = Workout(id: id, activityType: activityType, startDate: startDate, duration: duration)
model.notes = notes
modelContext.insert(model)
try modelContext.save()
}
Both use the same UUID so we can correlate them later.
Checkpoint: log a workout. Open Apple Health → Browse → Workouts. Your workout is there. Open FitTrack on another device — same workout shows up.
Day 6–7 — Watch workout session
Step 12. Set up HKWorkoutSession
@MainActor
final class WatchWorkoutController: NSObject, ObservableObject, HKWorkoutSessionDelegate, HKLiveWorkoutBuilderDelegate {
@Published var heartRate: Double = 0
@Published var elapsed: TimeInterval = 0
var session: HKWorkoutSession?
var builder: HKLiveWorkoutBuilder?
func start(activity: HKWorkoutActivityType) {
let config = HKWorkoutConfiguration()
config.activityType = activity
config.locationType = .indoor
do {
session = try HKWorkoutSession(healthStore: HKHealthStore(), configuration: config)
builder = session?.associatedWorkoutBuilder()
builder?.dataSource = HKLiveWorkoutDataSource(healthStore: HKHealthStore(), workoutConfiguration: config)
session?.delegate = self
builder?.delegate = self
let start = Date()
session?.startActivity(with: start)
builder?.beginCollection(withStart: start) { _, _ in }
} catch {
// log
}
}
func end() async {
session?.end()
try? await builder?.endCollection(at: Date())
try? await builder?.finishWorkout()
}
// delegate methods omitted — collect heart rate samples and update self.heartRate
}
Step 13. Watch UI
Three views: activity picker, in-workout (heart rate big, elapsed, end button), summary.
Checkpoint: start a workout on Watch. Heart rate updates live. End it. The workout appears in Apple Health and in the iPhone FitTrack history within 30 s.
Day 8 — Swift Charts dashboard
Step 14. 30-day heart rate trend
struct HeartRateTrendChart: View {
let samples: [HRSample] // pre-aggregated daily averages
var body: some View {
Chart(samples) { s in
LineMark(x: .value("Day", s.date), y: .value("BPM", s.average))
.interpolationMethod(.catmullRom)
.foregroundStyle(.pink)
AreaMark(x: .value("Day", s.date), y: .value("BPM", s.average))
.foregroundStyle(LinearGradient(colors: [.pink.opacity(0.3), .clear], startPoint: .top, endPoint: .bottom))
}
.chartXAxis { AxisMarks(values: .stride(by: .day, count: 7)) }
.chartYScale(domain: 40...160)
.frame(height: 220)
}
}
Aggregate samples in a background ModelActor query, not on the main thread.
Checkpoint: chart renders for 30 days of real data. Sample-rich days show higher accuracy; sparse days show gaps, not zeros.
Day 9 — Live Activity + Dynamic Island
Step 15. Add the Widget Extension
Already in place from earlier steps. Add an ActivityAttributes:
struct WorkoutActivityAttributes: ActivityAttributes {
public struct ContentState: Codable, Hashable {
var heartRate: Int
var elapsed: TimeInterval
}
var activityName: String
}
Step 16. Start the Live Activity from the iPhone
The iPhone receives Watch session start via Watch Connectivity (or via HealthKit’s HKWorkoutSessionMirroredObject on iOS 17+), then:
let attrs = WorkoutActivityAttributes(activityName: "Run")
let initial = WorkoutActivityAttributes.ContentState(heartRate: 0, elapsed: 0)
let activity = try Activity.request(
attributes: attrs,
contentState: initial,
pushType: nil
)
Update every 5 s with activity.update(...).
Step 17. Dynamic Island regions
Implement compact leading/trailing, minimal, and expanded views in the widget bundle. Compact leading: activity icon. Compact trailing: heart rate.
Checkpoint: start a workout on Watch — Live Activity appears on the iPhone Lock Screen and Dynamic Island. Updates every few seconds. Ends when the workout ends.
Day 10 — Complications
Step 18. Modular Compact complication
struct ComplicationProvider: TimelineProvider {
func getTimeline(in context: Context, completion: @escaping (Timeline<Entry>) -> Void) {
// Read latest workout from SwiftData via App Group container
// ...
completion(Timeline(entries: entries, policy: .after(.now.addingTimeInterval(3600))))
}
}
Provide families: .accessoryCircular, .accessoryRectangular, .accessoryCorner.
Checkpoint: long-press the watch face → Edit → add the FitTrack complication → it displays last workout date/icon.
Day 11–12 — Polish + TestFlight
- Run through
hardening-checklist.md. - Fastlane lanes for both iOS and watchOS uploads.
- Privacy Nutrition Label (Health & Fitness, Linked to user, NOT used for tracking).
- Screenshots for 6.7“, 6.1“, 5.5“, 12.9“ iPad if you support iPad.
Next: Hardening checklist
FitTrack — Hardening Checklist
1. Functional correctness
- HealthKit auth sheet shows only the data types we use
- Workouts logged in FitTrack appear in Apple Health within 5 s
- Watch workout sessions correctly record HR samples for the full duration
- Live Activity updates at the specified cadence; never older than 30 s
- CloudKit sync of new workouts works across two paired devices
- Complications refresh after a new workout is added
2. Security & privacy
- HealthKit read/write set is the minimum we actually need (no overreach)
-
NSHealthShareUsageDescriptionandNSHealthUpdateUsageDescriptionare specific and honest - No HealthKit data leaves the device (CloudKit private DB only; no third-party analytics)
-
PrivacyInfo.xcprivacydeclares Health & Fitness data type, Linked to user, Not used for tracking - No force-unwraps in app, watch, or widget code
- No background fetch of HealthKit data outside an active workout (battery + privacy)
- All photo data attached to workouts is stored locally + CloudKit; not uploaded elsewhere
- Privacy Nutrition Label accurately reports health data collection
3. Performance
- Cold launch < 1.5 s on iPhone 13
- 30-day HR query < 800 ms
- No retained HealthKit observers after view dismissal (verify with Instruments → Allocations)
- SwiftData query for 1000 workouts < 200 ms
- Watch app cold launch < 2 s
- Watch battery cost of a 1-hour workout: < 8% drain (measure on physical Watch)
- Widget extension memory peak < 20 MB
4. Accessibility
- VoiceOver describes each chart data point on tap
-
Dynamic Type up to
accessibility5doesn’t break the dashboard -
All actionable buttons have
.accessibilityLabel - Reduce Motion disables chart entrance animations
- Complications respect tinted face mode
5. Localization
-
All strings via
String(localized:) - Distance: km vs mi based on locale
- Energy: kcal vs kJ based on locale (HealthKit gives joules; format respects user)
- Time format: 12h vs 24h based on locale
- Date pickers and chart axis labels respect locale
6. Apple Review
- HealthKit purpose strings explain why and what clearly (Apple Review checks these)
- Demo account credentials provided in App Review notes (if any login)
- App icon does NOT mimic Apple Health, Fitness, or Workouts
- No medical claims in marketing copy (Apple-prohibited)
- Privacy policy URL live and accurate
7. CI/CD
-
Unit tests pass for
HealthQueryStream(with mocked HKHealthStore) - Snapshot tests for Swift Charts views
- Watch UI test for “start workout → end workout → save”
- Fastlane lanes for iOS app, watchOS app, and metadata
- Build numbers auto-incremented in CI
8. Documentation
- GitHub README has screenshots from iPhone + Watch + Live Activity
- Architecture diagram in repo
- ADRs reflect the shipped build
- Interview talking points rehearsed
- 60-second screen capture on portfolio
Sign-off
If every box is ticked, FitTrack ships. Move on to interview-talking-points.md.
FitTrack — Interview Talking Points
The 30-second pitch
“FitTrack is a workout-logging app on iPhone + Watch that pulls samples from HealthKit, persists workout metadata in SwiftData with CloudKit sync, and renders trend dashboards in Swift Charts. The Watch app runs an HKWorkoutSession with Live Activity + Dynamic Island updates on the iPhone. The interesting design was wrapping HealthKit’s awkward observer-query boilerplate behind an AsyncSequence — the consumer writes
for try await samples in stream.samples()instead of implementing two query types and routing between them.”
The 3-minute deep dive
If asked “tell me about the HealthKit query layer”:
“HealthKit’s observer pattern is one of the rougher Apple APIs. To keep a view current with new heart rate samples, you need
HKObserverQueryto learn that something changed, thenHKAnchoredObjectQueryto fetch the delta since your last seen anchor, then merge that into your local state. It’s about 200 lines per sample type if you write it idiomatically, plus you have to manage the anchor’s persistence across app launches yourself.I wrapped all of that in a
HealthQueryStreamactor that exposes new samples as anAsyncThrowingStream. Behind the scenes it owns the observer query and the anchored query; on the consumer side you writefor try await samples in stream.samples() { ... }. The anchor lives in actor state and persists to UserDefaults on shutdown.This collapses three view models to one and makes the consumer testable — a fake stream is just an
AsyncStreamthat yields canned samples on a schedule. We use it for heart rate, steps, energy, distance. The same actor type, four instances, four lines of view code each.The tradeoff: a single actor hop per delivery, which adds about 50 microseconds. For HealthKit samples that arrive every few seconds at fastest, that’s nothing. If we needed to consume samples at 60 Hz from a hypothetical CoreMotion sensor, I’d reconsider — but for HealthKit it’s the right tool.“
12 interview questions
1. “Why SwiftData and not just storing everything in HealthKit?”
HealthKit can store HKWorkout but not the user’s notes, tags, or attached photo. We need a place to attach FitTrack-specific metadata to each workout. SwiftData is the lightest local store that also gives us CloudKit sync for free. The HKWorkout’s UUID is the join key — both records carry the same UUID, so we can correlate them.
2. “Why not just SwiftData and not HealthKit?”
Then the user’s workouts wouldn’t appear in Apple Health, wouldn’t contribute to the move ring, wouldn’t sync to their other Apple-Health-aware apps. HealthKit is the platform-standard place; we have to write there. Plus, Apple Watch heart rate samples arrive through HealthKit — there’s no other API. So we’re already integrated with HealthKit for reads; writing the workout to it is one more line.
3. “What happens when the user denies HealthKit?”
Graceful fallback to manual entry. The Workout model has all the fields a HealthKit-denied user needs; we just don’t write to HKHealthStore. The Watch app, which depends on live HR, shows a “HealthKit required” message. The iPhone app remains fully functional. Critically: I never re-prompt — Apple’s rule is that HealthKit auth requests are one-shot per type, and re-asking is bad UX.
4. “How do you handle SwiftData + CloudKit schema migrations?”
This is the rough edge of the current SwiftData. For additive changes (new optional property), CloudKit handles it. For breaking changes (renaming a field, changing a type), I plan a v2 model alongside the v1, migrate on first launch after the upgrade, then drop v1 in v3. I document this in the README so future-me remembers the process.
5. “Walk me through starting a workout on Watch.”
User taps an activity type. We create an HKWorkoutSession + HKLiveWorkoutBuilder configured for that activity. Session starts; builder begins collection. The delegate fires for each new sample (heart rate, active energy) and we update our @Published properties. The view stays alive while the session runs. On end, we call session.end(), builder.endCollection(at:), builder.finishWorkout() — which produces the persisted HKWorkout. Then we create the matching SwiftData Workout with the same UUID.
6. “How does the Live Activity get updated?”
The Watch publishes session state via HKWorkoutSessionMirroredObject (iOS 17+) to the iPhone-side workout. The iPhone observes that, and every 5 seconds calls activity.update(state) with the latest heart rate and elapsed time. Apple throttles Live Activity updates internally; 5-second cadence is comfortably within their limits. When the workout ends, we call activity.end(...) to dismiss the Dynamic Island.
7. “How would you make the charts feel snappier on a phone with 5 years of workouts?”
Two layers. First, aggregate. Don’t chart raw samples; chart pre-computed daily averages stored in a separate SwiftData entity. Compute on save, not on read. Second, paginate the time window. Show 30 days by default; on user request, load 90 or 365 with a brief loading state. Third, render via Swift Charts with a fixed Y-domain so axes don’t recalculate. With those, even 5 years of dense HR data charts in under 200 ms.
8. “How do you test the HealthKit code?”
HealthQueryStream accepts a HealthStoreProtocol instead of HKHealthStore directly. Tests inject a fake that yields pre-canned samples on demand. The high-value test asserts the anchor advances correctly across multiple deliveries — that’s the bug that’s caused half the HealthKit-related issues I’ve ever debugged. UI tests run on a simulator with HealthKit denied; we verify the fallback paths.
9. “Tell me about a bug.”
Live Activity wouldn’t update for users who started a workout, locked the phone, then unlocked an hour later. Took two days. Turned out the Activity.update call was happening but the system was throttling because we hadn’t requested the background runtime extension correctly. Fix: set the Activity staleDate correctly so iOS knows when to refresh, and ensure the iPhone-side activity controller uses Task.detached rather than relying on UI-tied lifecycle. Lesson: Live Activities require explicit staleness contracts, not implicit lifecycle.
10. “Why no GPS / route map?”
Scope. GPS adds CoreLocation always-on permission, route storage, MapKit polyline rendering, watch battery impact, and Apple Review scrutiny on background location. Each is a multi-day investment for a feature that’s tangential to “log a workout.” For a capstone, the line is “tracked-route apps are a different product”; for a real product, I’d ship without it and add later if users asked.
11. “What about Apple Health’s deletion model?”
Important nuance. When the user deletes data inside FitTrack, I delete only my SwiftData records and the matching HKWorkout I wrote. I do NOT delete samples (heart rate, etc.) — Apple explicitly forbids third-party apps from deleting user health samples; the user must do that themselves in Apple Health. The FitTrack delete sheet states this clearly so users aren’t surprised when their Apple Health view still shows the underlying data.
12. “How would you scale this to 1 million users?”
CloudKit private DB scales automatically per user. HealthKit is local-only — no server cost. The only scaling work is making the dashboard fast for users with years of data (covered in question 7) and making the complication/widget extensions stay under their memory limit. For 1 M users I’d also add a thin crash-reporting integration (Sentry, but with PII scrubbing) so I can spot regressions. No backend service required.
Red-flag answers to avoid
If asked “did you ship it to the App Store,” don’t lie. TestFlight is enough for a capstone. Answer: “TestFlight, with five external testers. Full App Store submission would require a few more days of polish on screenshots and a privacy policy review — the build is ready, I deprioritized that step.”
If asked “why no machine learning,” don’t say “I didn’t get to it.” Say: “Out of scope. HealthKit + Charts + Live Activities was a coherent product story. Adding ML — say, predicting tomorrow’s energy from past samples — is a separate concern with its own model selection and accuracy story. I’d build it in a separate phase.”
Next: Capstone 3 — ShopKit.
Capstone 3 — ShopKit
Tagline: A minimalist in-app subscription marketplace with a paid Pro tier, secure Keychain-backed user accounts, a production-grade networking layer, and a GitHub Actions CI/CD pipeline that ships builds to TestFlight on every merge.
Tech stack: StoreKit 2 · Custom URLSession networking layer · Keychain · async/await · GitHub Actions · Fastlane
Time budget: ~2 weeks
What this capstone proves:
- You can ship real money through Apple — StoreKit 2 subscriptions with a free trial, restore-purchases flow, server-side validation alternative (via App Store Server API)
- You can build a production networking layer — typed errors, retry with exponential backoff, request/response logging, cert pinning, all from scratch
- You can store credentials in Keychain the right way (accessibility flags, biometric gating)
- You can wire a GitHub Actions pipeline that runs tests on PRs and ships to TestFlight on merge to
main - You can survive Apple Review for in-app purchase — the highest-rejection-rate category
The 30-second pitch
“ShopKit is a subscription-based notes-meets-marketplace app where users can browse a catalog of articles and unlock the premium archive via a StoreKit 2 subscription with a 7-day free trial. I built the networking layer from scratch around URLSession + async/await with typed errors, request/response logging, retry/backoff, and TLS pinning. Auth tokens live in the Keychain gated by Face ID. CI/CD runs on GitHub Actions — every merge to main builds, tests, and pushes to TestFlight via Fastlane. The interesting engineering was the subscription state machine that handles the seven possible subscription states StoreKit 2 surfaces, including billing-retry grace periods and refunds, all in one
@ObservableSubscriptionStatus.”
Why this capstone
Subscription mechanics are the single hardest part of consumer iOS to get right and the most lucrative skill to be able to talk about. Combined with a real CI/CD pipeline and a well-designed networking layer, ShopKit covers three of the most common interview-deep-dive topics:
- “Walk me through your StoreKit integration.”
- “Show me your networking layer.”
- “How do you handle CI/CD?”
Files
- requirements.md — feature list, user stories, acceptance criteria
- architecture.md — module layout, subscription state machine, ADRs
- implementation-guide.md — step-by-step build walkthrough
- hardening-checklist.md — production + security review
- interview-talking-points.md — pitch and Q&A
What “done” looks like
- App live on App Store (not just TestFlight — this capstone is about going through Review)
- A working purchase flow tested with sandbox accounts: subscribe, restore, cancel, refund
- Networking layer documented and unit-tested
- Keychain integration with biometric-gated read for the auth token
- GitHub Actions pipeline visible (green badge in README)
Next: Requirements
ShopKit — Requirements
Personas
- Browser — opens the app, scrolls free articles, tries to open a Pro one, sees the paywall, decides whether to start a free trial.
- Trialist — starts a free 7-day trial, uses Pro features, weighs converting.
- Returning subscriber — has an active subscription, opens the app, immediately accesses Pro content.
- Lapsed user — was subscribed, canceled, comes back to the paywall — should see a “welcome back” offer if eligible.
User stories
Onboarding
- First launch shows a 3-screen onboarding (what the app does, free vs Pro, “sign up to save reading progress”).
- Sign up uses Sign in with Apple — no email/password forms.
- The sign-up screen is optional skip, but limits sync.
Browsing
- Home shows the article catalog from a remote API; each article card shows title, author, “Pro” badge, estimated read time.
- Pull-to-refresh reloads. Failed loads show a non-modal error toast, never a blank screen.
- Tapping a free article opens it immediately.
- Tapping a Pro article either opens it (if subscribed) or routes to the paywall.
Paywall
- Paywall shows: tier name, monthly + annual prices (with “save 30%” badge on annual), feature list, “Start 7-day free trial” CTA, fine print (auto-renews, manage in Settings, restore button).
- Annual is the default selection (highest LTV).
- Free trial introductory offer is available only to users who haven’t previously subscribed (StoreKit handles this).
- Restore Purchases button is prominent (Apple Review requires).
- Privacy & Terms links are visible (Apple Review requires for subscriptions).
Subscription flow
- Tapping “Start trial” presents Apple’s purchase sheet via
Product.purchase(). - After successful purchase, the user lands on the article that triggered the paywall.
- If purchase fails (cancelled, payment issue), the user returns to the paywall with a contextual message.
- Active subscription gates every Pro article check via
SubscriptionStatus.current.
Account
- Settings → Manage Subscription opens Apple’s
manageSubscriptionsSheet. - Settings → Account shows the Sign in with Apple email (or “anonymous”) and a Sign Out button.
- Sign out clears the Keychain token; does NOT cancel the subscription (the user must do that in Apple Settings).
- Delete Account: deletes the user’s server-side data and signs out. Subscription continues per Apple’s lifecycle.
Networking
- All API calls go through the typed
APIClient. NoURLSession.sharedcalls anywhere in the app. - Auth token is attached to every request via an injected
AuthProvider. - 401 responses trigger a one-time token refresh; if refresh fails, sign out gracefully.
- Network errors are mapped to typed
APIErrorcases (unauthorized, notFound, server, decoding, transport).
Acceptance criteria
- Cold launch to home: < 1.5 s
- Paywall display to purchase sheet: < 500 ms after tap
- Subscription state change (purchase, restore, refund) reflects in UI within < 2 s
- Test the full flow with sandbox accounts for: purchase, cancel during trial, refund-after-purchase, sub-billing-retry
- GitHub Actions: PR build + test < 18 min; merge-to-main → TestFlight < 30 min
Non-goals
- No social features (comments, sharing).
- No offline reading. (Stretch goal.)
- No DRM or content encryption — the Pro gate is purely server-side via subscription status.
- No support for unlimited tiers — only one Pro tier (monthly + annual).
- No promotional offers (besides the standard 7-day free trial).
Constraints
- iOS 17+ (StoreKit 2 latest,
manageSubscriptionsSheet, Sign in with Apple). - Apple Developer Program — Sign in with Apple capability + StoreKit + WeatherKit-style entitlements.
- Demo server: small Express + Postgres for the article catalog and user data (not part of this capstone — provided via Docker Compose in the starter).
Next: Architecture
ShopKit — Architecture
High-level diagram
+---------------------+ +---------------------+
| SwiftUI views | | StoreKit 2 |
| - Home / Article |<------>| Transaction stream |
| - Paywall | | Product.purchase() |
| - Settings | +----------+----------+
+----------+----------+ |
| v
v +---------------------+
+---------------------+ | SubscriptionStatus |
| AppState |<-------| (@Observable) |
+----------+----------+ +---------------------+
|
v
+---------------------+ +---------------------+
| APIClient |<------>| AuthProvider |
| (typed errors) | | (Keychain-backed) |
+----------+----------+ +---------------------+
|
v
+---------------------+
| Backend (Express |
| + Postgres) |
| /articles, /me |
+---------------------+
Module layout
ShopKit/
App/ # main iOS target
Packages/
ShopKitCore/ # models, errors
ShopKitAPI/ # APIClient, request types
ShopKitAuth/ # Sign in with Apple + Keychain
ShopKitStore/ # StoreKit 2 wrapper + SubscriptionStatus
ShopKitUI/ # views, design system
The networking layer
Centerpiece of the capstone’s “show me your code” moment. Build it from scratch:
public protocol APIRequest {
associatedtype Response: Decodable
var path: String { get }
var method: HTTPMethod { get }
var query: [URLQueryItem] { get }
var body: Data? { get }
var requiresAuth: Bool { get }
}
public enum APIError: Error, Equatable {
case unauthorized
case notFound
case server(Int, message: String?)
case decoding(any Error)
case transport(URLError)
case cancelled
public static func == (lhs: Self, rhs: Self) -> Bool { /* ... */ }
}
public final class APIClient {
private let base: URL
private let session: URLSession
private let auth: AuthProviding
private let decoder: JSONDecoder
public init(base: URL, session: URLSession, auth: AuthProviding,
decoder: JSONDecoder = .iso8601) {
self.base = base
self.session = session
self.auth = auth
self.decoder = decoder
}
public func send<R: APIRequest>(_ request: R) async throws -> R.Response {
var url = base.appending(path: request.path)
if !request.query.isEmpty {
url = url.appending(queryItems: request.query)
}
var urlRequest = URLRequest(url: url)
urlRequest.httpMethod = request.method.rawValue
urlRequest.httpBody = request.body
urlRequest.setValue("application/json", forHTTPHeaderField: "Content-Type")
if request.requiresAuth {
urlRequest.setValue("Bearer \(try await auth.token())",
forHTTPHeaderField: "Authorization")
}
do {
let (data, response) = try await session.data(for: urlRequest)
guard let http = response as? HTTPURLResponse else {
throw APIError.transport(.init(.badServerResponse))
}
switch http.statusCode {
case 200...299:
do { return try decoder.decode(R.Response.self, from: data) }
catch { throw APIError.decoding(error) }
case 401:
if request.requiresAuth, try await auth.refresh() {
return try await send(request) // one retry
}
throw APIError.unauthorized
case 404:
throw APIError.notFound
case 500...599:
throw APIError.server(http.statusCode, message: String(data: data, encoding: .utf8))
default:
throw APIError.server(http.statusCode, message: nil)
}
} catch let error as URLError {
throw error.code == .cancelled ? APIError.cancelled : APIError.transport(error)
}
}
}
Key points:
- Typed requests — every endpoint is a struct conforming to
APIRequestwith its ownResponsetype. No stringly-typed dispatch. - Single error type —
APIErrorcovers every failure mode the UI needs to distinguish. - One-shot 401 retry — refresh token once on 401; if that fails, propagate
unauthorized. Prevents infinite retry loops. - No bare
URLSession.shared— testable by injecting aURLSessionProtocolfake or aURLProtocol-based stub.
The subscription state machine
StoreKit 2 surfaces transaction state, but the UI needs to know one of seven user-facing states:
public enum SubscriptionStatus: Equatable {
case notSubscribed
case inFreeTrial(expires: Date)
case active(expires: Date)
case inBillingRetry(expires: Date)
case inGracePeriod(expires: Date)
case expired
case revoked(reason: RevocationReason)
public var allowsProAccess: Bool {
switch self {
case .active, .inFreeTrial, .inGracePeriod: return true
case .inBillingRetry: return true // Apple recommends honoring during retry
case .notSubscribed, .expired, .revoked: return false
}
}
}
A SubscriptionWatcher actor listens to Transaction.updates and Transaction.currentEntitlements, maps each to a SubscriptionStatus, and publishes via @Observable. The UI observes subscription.status.allowsProAccess to decide gating.
ADRs
ADR-001: Custom networking layer over Alamofire
Building it ourselves is the point of the capstone. URLSession + async/await is enough for our scope. Alamofire would add a dependency for marginal benefit and weaken the interview story.
ADR-002: StoreKit 2, not legacy receipt validation
StoreKit 2 (iOS 15+) gives JWS-signed Transaction values we can trust on-device without a server. We still recommend a server-side App Store Server API check for revenue-critical apps, but for ShopKit’s scope, on-device validation is correct.
ADR-003: Sign in with Apple, no email/password
One-tap. No password DB. No ‘forgot password’ flow. Apple’s brand legitimacy. Required anyway for any app that offers third-party login (we don’t, but it’s still the best default).
ADR-004: Keychain access with .userPresence, not biometric-only
For the auth token, .userPresence (biometric OR passcode) is the right policy — biometric-only locks out users who disabled Face ID. .userPresence requires both: token in Keychain + recent device unlock — strong enough.
ADR-005: GitHub Actions over Xcode Cloud
Both work. We picked GitHub Actions because the readers’ interviewers are more likely to ask about it (more universal CI knowledge), and because GitHub Actions config is in the repo and reviewable. Xcode Cloud is fine if you’re an Apple-only shop; pick what fits.
Test strategy
- APIClient:
URLProtocol-based stub server to test every status code + error path. - SubscriptionWatcher: inject a fake
TransactionStream; assert state machine transitions. - AuthProvider: in-memory Keychain mock; assert read/write/refresh paths.
- UI: snapshot tests for paywall variants; XCUITest for purchase flow against StoreKit Configuration file.
Threading
- All views
@MainActor. APIClient,AuthProvider,SubscriptionWatcherare actors.- StoreKit’s
Transaction.updatesis consumed inApp.task { }so it runs for the app’s lifetime.
Next: Implementation guide
ShopKit — Implementation Guide
Total estimated time: 40–60 hours, plus 1–2 weeks of App Store Review iteration.
Day 1 — Project + Sign in with Apple
Step 1. Create project
Standard SwiftUI app, iOS 17 minimum.
Step 2. Capabilities
- Sign in with Apple
- In-App Purchase
- Keychain Sharing (with your team identifier)
Step 3. Sign in with Apple integration
import AuthenticationServices
struct SignInView: View {
var body: some View {
SignInWithAppleButton(.signIn) { request in
request.requestedScopes = [.email]
} onCompletion: { result in
switch result {
case .success(let auth):
handle(auth)
case .failure(let error):
// log
break
}
}
.signInWithAppleButtonStyle(.black)
.frame(height: 50)
}
func handle(_ auth: ASAuthorization) {
guard let cred = auth.credential as? ASAuthorizationAppleIDCredential,
let tokenData = cred.identityToken,
let tokenString = String(data: tokenData, encoding: .utf8) else { return }
// Send tokenString to your backend; backend verifies and returns your own session token
}
}
Checkpoint: tap Sign In, complete the Apple flow, receive an identity token.
Day 2 — Keychain auth storage
Step 4. KeychainStore
public actor KeychainStore {
private let service: String
public init(service: String) { self.service = service }
public func save(_ data: Data, for account: String, biometric: Bool = false) throws {
let access = biometric
? SecAccessControlCreateWithFlags(nil, kSecAttrAccessibleWhenPasscodeSetThisDeviceOnly, .userPresence, nil)!
: SecAccessControlCreateWithFlags(nil, kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly, [], nil)!
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: account,
kSecValueData as String: data,
kSecAttrAccessControl as String: access
]
SecItemDelete(query as CFDictionary)
let status = SecItemAdd(query as CFDictionary, nil)
guard status == errSecSuccess else { throw KeychainError.unhandled(status) }
}
public func read(account: String) throws -> Data? {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: account,
kSecReturnData as String: true,
kSecMatchLimit as String: kSecMatchLimitOne
]
var result: AnyObject?
let status = SecItemCopyMatching(query as CFDictionary, &result)
if status == errSecItemNotFound { return nil }
guard status == errSecSuccess else { throw KeychainError.unhandled(status) }
return result as? Data
}
public func delete(account: String) throws { /* ... */ }
}
Checkpoint: save a token, read it back, delete it, verify deletion.
Day 3–4 — Networking layer
Step 5. Implement APIClient per architecture.md
Step 6. Define request types
struct ArticleListRequest: APIRequest {
typealias Response = [Article]
let path = "/articles"
let method = HTTPMethod.get
let query: [URLQueryItem] = []
let body: Data? = nil
let requiresAuth = true
}
Step 7. URLProtocol stub for tests
final class StubProtocol: URLProtocol {
static var handler: ((URLRequest) -> (HTTPURLResponse, Data))?
override class func canInit(with request: URLRequest) -> Bool { true }
override class func canonicalRequest(for request: URLRequest) -> URLRequest { request }
override func startLoading() {
guard let handler = Self.handler else { return }
let (response, data) = handler(self.request)
client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)
client?.urlProtocol(self, didLoad: data)
client?.urlProtocolDidFinishLoading(self)
}
override func stopLoading() {}
}
Use it in tests by configuring a URLSession with URLSessionConfiguration.ephemeral and protocolClasses = [StubProtocol.self].
Checkpoint: write tests for APIClient covering 200, 401-with-refresh, 404, 500, and URLError.notConnectedToInternet. All pass.
Day 5 — Article browsing
Step 8. Article model + HomeView
struct Article: Codable, Identifiable {
let id: String
let title: String
let author: String
let isPro: Bool
let publishedAt: Date
let readTimeMinutes: Int
}
HomeView calls APIClient.send(ArticleListRequest()) in .task, shows a List of cards. Pro articles show a “Pro” badge.
Step 9. Article detail
Tapping a free article opens the detail view. Tapping a Pro article checks SubscriptionStatus.allowsProAccess; if no, push the paywall.
Checkpoint: list renders; Pro gating works (paywall pushes on Pro tap when not subscribed).
Day 6–7 — StoreKit 2 + paywall
Step 10. Create products in App Store Connect
com.yourorg.shopkit.pro.monthly— Auto-Renewing Subscription, $4.99/mocom.yourorg.shopkit.pro.yearly— Auto-Renewing Subscription, $39.99/yr- Subscription group: “ShopKit Pro”
- Introductory offer (7-day free trial) on both, eligibility: New Subscribers
Step 11. StoreKit Configuration file for local testing
Xcode → File → New → StoreKit Configuration → “Sync with App Store Connect.” Use as the StoreKit Configuration in your scheme.
Step 12. SubscriptionWatcher actor
@MainActor
@Observable
public final class SubscriptionStatusStore {
public private(set) var status: SubscriptionStatus = .notSubscribed
private var updateTask: Task<Void, Never>?
public func start() {
updateTask = Task { [weak self] in
for await update in Transaction.updates {
if case .verified(let transaction) = update {
await self?.refreshFromCurrentEntitlements()
await transaction.finish()
}
}
}
Task { await refreshFromCurrentEntitlements() }
}
public func refreshFromCurrentEntitlements() async {
var newStatus: SubscriptionStatus = .notSubscribed
for await result in Transaction.currentEntitlements {
guard case .verified(let txn) = result,
txn.productType == .autoRenewable else { continue }
// Map txn state to SubscriptionStatus
if let expires = txn.expirationDate {
newStatus = txn.offerType == .introductory
? .inFreeTrial(expires: expires)
: .active(expires: expires)
}
}
self.status = newStatus
}
}
Step 13. Paywall view
Product.products(for:) fetches the two products. Display them with Product.displayPrice. Purchase via Product.purchase().
struct PaywallView: View {
@State private var products: [Product] = []
@State private var selected: Product?
var body: some View {
VStack {
// tier cards
Button("Start 7-day free trial") {
Task { await purchase(selected!) }
}
Button("Restore Purchases") {
Task { try? await AppStore.sync() }
}
}
.task {
products = try await Product.products(for: [
"com.yourorg.shopkit.pro.monthly",
"com.yourorg.shopkit.pro.yearly"
])
selected = products.first { $0.id.hasSuffix("yearly") }
}
}
func purchase(_ product: Product) async {
do {
let result = try await product.purchase()
switch result {
case .success(let verification):
if case .verified(let txn) = verification {
await txn.finish()
}
case .userCancelled, .pending:
break
@unknown default:
break
}
} catch {
// log
}
}
}
Checkpoint: launch with StoreKit Configuration, complete a sandbox purchase, see SubscriptionStatus flip to .inFreeTrial(...).
Day 8 — Cert pinning
Step 14. Add PinnedSessionDelegate from Phase 9 lab 9.2
Configure the URLSession used by APIClient with this delegate. Hardcode the SPKI hashes of your backend’s leaf + backup intermediate.
Checkpoint: verify against your real backend that requests succeed. Then with mitmproxy interposing, verify they fail.
Day 9–10 — GitHub Actions CI/CD
Step 15. .github/workflows/ci.yml
name: CI
on:
pull_request:
push:
branches: [main]
jobs:
test:
runs-on: macos-14
steps:
- uses: actions/checkout@v4
- uses: maxim-lobanov/setup-xcode@v1
with:
xcode-version: '16.0'
- name: Test
run: |
xcodebuild test \
-scheme ShopKit \
-destination 'platform=iOS Simulator,name=iPhone 15,OS=17.0' \
-enableCodeCoverage YES \
| xcbeautify
deploy:
needs: test
if: github.ref == 'refs/heads/main'
runs-on: macos-14
steps:
- uses: actions/checkout@v4
- uses: maxim-lobanov/setup-xcode@v1
with:
xcode-version: '16.0'
- name: Install Fastlane
run: bundle install
- name: Beta lane
env:
MATCH_PASSWORD: ${{ secrets.MATCH_PASSWORD }}
ASC_KEY_ID: ${{ secrets.ASC_KEY_ID }}
ASC_ISSUER_ID: ${{ secrets.ASC_ISSUER_ID }}
ASC_KEY_CONTENT: ${{ secrets.ASC_KEY_CONTENT }}
run: bundle exec fastlane beta
Step 16. Fastlane setup per Phase 10 chapter 5
Step 17. Secrets
MATCH_PASSWORD— passphrase for the encrypted certs repoASC_KEY_ID,ASC_ISSUER_ID,ASC_KEY_CONTENT— App Store Connect API key (base64 of the.p8file)
Checkpoint: open a PR with a test change. CI runs, build + tests pass. Merge it. The deploy job runs and a TestFlight build appears about 25 minutes later.
Day 11–14 — App Store Review
Step 18. Submit for Review
- Compelling screenshots (use Fastlane
snapshot). - Honest Privacy Nutrition Label.
- A demo account for App Review if your backend requires login.
- Review notes explaining how to use the subscription with sandbox testing.
Step 19. Expect 1–3 rejection rounds
Common rejections:
- Unclear paywall fine print → add “Auto-renews unless cancelled 24h before” copy.
- Missing Restore Purchases button → add it.
- Vague Sign in with Apple usage → clarify in App Review Notes.
Iterate, resubmit, ship.
Next: Hardening checklist
ShopKit — Hardening Checklist
1. Functional correctness
- Sign in with Apple works end-to-end on first install and after sign-out
- Auth token survives app relaunch (Keychain)
- 401 from API triggers a one-time refresh; second 401 signs out
- Paywall shows accurate pricing in all supported locales
- Free trial offer is shown only to eligible users
- Restore Purchases works for users on a new device
- Subscription state machine handles billing retry + grace period correctly
-
Refund of a transaction surfaces as
.revokedand gates Pro content
2. Security
-
Auth token in Keychain with
.userPresenceaccess control - No tokens in UserDefaults or files
- TLS pinning enforced for all API hosts
-
No third-party analytics or trackers (verify
Package.resolved) -
Info.plistATS exceptions: zero -
No
printof token, headers, or response bodies -
os_loguses%{private}@for any user-attached values - No force-unwraps; SwiftLint enforces
3. StoreKit / billing
-
All
Transactionvalues verified via.verifiedcase before granting entitlement - Sandbox test for: purchase, cancel during trial, refund, upgrade/downgrade
-
Transaction.updatesis consumed for the app’s lifetime (no missed updates) -
manageSubscriptionsSheetopens correctly from Settings - Server-side validation path documented (App Store Server API) even if not implemented
- Promo offer eligibility checked against Apple’s rules
4. Privacy
- Privacy Nutrition Label: Identifier (User ID), Usage Data, Purchases (App Functionality, linked to user, NOT used for tracking)
-
PrivacyInfo.xcprivacylists every accessed API category with required reasons - No tracking SDKs, no AdServices integration
- Sign in with Apple email is the only user identifier; hashed before being stored on the server
5. Performance
- Cold launch < 1.5 s
- Paywall display < 500 ms after tap
- API request average latency < 400 ms on LTE
- No retain cycles (Instruments → Leaks)
- Subscription state change reflected in UI < 2 s
6. CI/CD
- PR build + test passes consistently (no flake)
- Merge to main → TestFlight build appears within 30 min
- Build numbers auto-incremented
- Match-managed certificates working without manual intervention
- Slack notification on successful deploy
7. App Store Review readiness
- Demo Sign in with Apple sandbox account in Review notes
- Sandbox StoreKit test account in Review notes
- Subscription terms in paywall match Apple’s requirements (auto-renewal text, length, cancel-anytime)
- Restore Purchases button visible (Apple Review will reject without)
- Privacy policy and terms of use URLs live
- Compelling screenshots showing the paywall, content, and a feature
- No mention of Stripe, PayPal, or external purchases (would trigger rejection)
8. Documentation
- GitHub README with green CI badge, screenshots, architecture diagram
- CONTRIBUTING.md documenting how to run the backend locally
- ADRs reflect shipped build
- Interview talking points rehearsed
- App Store URL on portfolio
Sign-off
Read interview-talking-points.md.
ShopKit — Interview Talking Points
The 30-second pitch
“ShopKit is a subscription content app I shipped to the App Store — a notes-meets-articles marketplace where Pro content unlocks via a StoreKit 2 subscription with a 7-day free trial. I built the networking layer from scratch around URLSession with typed errors, a one-shot 401 retry, and TLS pinning. Auth tokens live in the Keychain with
.userPresenceaccess control. CI/CD on GitHub Actions ships to TestFlight on every main merge. The interesting design was the subscription state machine — StoreKit 2 surfaces transaction state, but the UI needs a higher-level view that includes billing-retry grace periods and revocation reasons. I built that in a single@Observablethat the rest of the app reads through one boolean.”
The 3-minute deep dive (subscription state machine)
“StoreKit 2 gives you
Transaction.currentEntitlementsandTransaction.updates, bothAsyncSequences. But neither directly answers the question the UI cares about: ‘should I show this Pro article right now?’Because the answer is more nuanced than ‘is the user paying.’ Apple wants you to honor access during a billing-retry grace period — the credit card declined but Apple’s retrying for up to 16 days. Apple also wants you to handle in-grace-period state separately from in-billing-retry. And refunded transactions surface as a separate signal you have to act on within hours, or you’re giving away content the user didn’t pay for.
So I built a
SubscriptionStatusenum with seven cases: notSubscribed, inFreeTrial, active, inBillingRetry, inGracePeriod, expired, revoked. ASubscriptionWatcheractor listens to both StoreKit streams and maps every state change to one of those. The UI reads a singleallowsProAccess: Boolderived from the enum — which keeps the gate one place, not scattered everywhere.The actor pattern matters here because
Transaction.updatesruns forever and you need to handle updates even when the app is backgrounded but reattached. I start the watcher inApp.task { }so it lives for the app lifetime, not view lifetime. Tests inject a fakeTransactionStreamthat yields canned transactions and assert state transitions.The hardest case was refund. When Apple processes a refund, you get a
Transaction.updatesnotification, but theexpirationDateis already in the past, so you have to userevocationDateandrevocationReason. I missed this for a week and was technically letting refunded users keep Pro access until their original expiration. The audit-log query showed maybe 12 incidents. Lesson: always test revocation explicitly with a sandbox refund.“
12 interview questions
1. “Walk me through your networking layer.”
APIClient is a thin async/await wrapper around URLSession. Every endpoint is a struct conforming to APIRequest with its own Response: Decodable. The client serializes the request, attaches the auth header from an injected AuthProvider, runs URLSession.data(for:), maps the HTTP status to a typed APIError (unauthorized, notFound, server, decoding, transport), and decodes on success. 401 triggers one token refresh and a single retry — never a loop. The whole thing is about 80 lines. Testability comes from injecting a stub URLSession via URLProtocol.
2. “Why not Alamofire?”
For our scope, async/await + URLSession is enough. Alamofire adds a dependency for marginal benefit — interceptors, certificate management, multi-part uploads we don’t need. The capstone is partly about showing I can build the layer; pulling Alamofire defeats that. For a real product with heavy file upload or multipart form-data needs, Alamofire is a fine choice.
3. “How does cert pinning work?”
URLSessionDelegate.urlSession(_:didReceive:completionHandler:) fires for the TLS handshake. I evaluate the server trust normally, then walk the cert chain, extract the public key from each cert via SecCertificateCopyKey, hash it with SHA-256, and check against my hardcoded set of base64-encoded SPKI hashes. If any cert in the chain matches a pin, accept; otherwise, cancel the connection. I pin two hashes — the leaf and the backup intermediate — so cert renewals don’t break the app.
4. “How do you store the auth token?”
Keychain, with SecAccessControl flags .userPresence and kSecAttrAccessibleWhenPasscodeSetThisDeviceOnly. The .userPresence flag means the token is gated by a recent device unlock — Touch ID, Face ID, or passcode. WhenPasscodeSetThisDeviceOnly means the item is non-syncable and only readable if the device has a passcode. If the user has no passcode set, the item can’t be read — which is fine; we then prompt the user to set one or fall back to a less secure tier.
5. “What if the API token expires while the app is in use?”
The 401 path. APIClient catches a 401, asks AuthProvider.refresh(), retries the original request once. If refresh fails (refresh token also expired), it throws .unauthorized and the UI signs the user out. There’s no infinite retry — exactly one refresh attempt per failed call. Race condition: if multiple requests fail with 401 simultaneously, they shouldn’t all trigger refresh. AuthProvider.refresh() deduplicates by holding the in-flight refresh Task and returning the same task for concurrent callers.
6. “How do you handle subscription validation server-side?”
For ShopKit’s scope I rely on on-device Transaction JWS verification, which Apple signs and StoreKit 2 verifies for me. For a higher-revenue app I’d add server-side validation via the App Store Server API — the server checks subscriptions for the user’s original transaction ID, validates expiration, and acts as the source of truth. That prevents jailbroken devices from spoofing. I documented this in the ADR as a future enhancement.
7. “What happens if a user gets a refund?”
Transaction.updates yields a transaction with a non-nil revocationDate and a revocationReason. SubscriptionWatcher maps it to .revoked(reason:). The next time the user opens a Pro article, the gate rejects them. They see a message: “Your subscription was refunded; tap to view options.” I learned this is critical — Apple expects you to act on revocation within hours, and dragging your feet here is a real business risk.
8. “Tell me about your CI pipeline.”
GitHub Actions on macos-14. Every PR runs xcodebuild test — about 12 minutes including resolve. Merge to main triggers the deploy job: Fastlane match pulls certs from a private repo, gym builds, pilot uploads to TestFlight. Build numbers auto-increment via agvtool. Total merge-to-TestFlight time is about 25 minutes. Secrets are GitHub repo secrets (App Store Connect API key, match passphrase). Slack webhook notifies the team on success.
9. “What about Xcode Cloud?”
I considered it. Xcode Cloud has tighter Apple integration (signing managed for you) and lower configuration overhead. The downsides are vendor lock-in and less flexibility for non-Apple steps (running a custom linter, syncing to S3). For a portfolio piece I chose GitHub Actions because the configuration lives in the repo, and reviewing it tells you more about the developer’s CI knowledge.
10. “How would you test the purchase flow without using real money?”
Three layers. (1) Xcode StoreKit Configuration file — local, no Apple involvement, instant. (2) Sandbox testing on TestFlight with a sandbox Apple ID for end-to-end against real Apple infrastructure. (3) UI tests against the StoreKit Configuration file in CI, asserting the paywall renders products and the state machine flips after a simulated purchase. The first catches 90% of integration bugs; the second catches the edge cases (refund, billing retry); the third prevents regressions.
11. “Walk me through an App Store rejection you got.”
First submission was rejected for “missing functionality” — the reviewer couldn’t find the Sign in with Apple flow because the onboarding’s skip button wasn’t obvious. I clarified in App Review Notes, added a more prominent label, resubmitted. Approved on the next round. The lesson is that the reviewer doesn’t read your code; they spend 10 minutes tapping around. Make the happy paths impossible to miss.
12. “How would you scale this to multiple platforms?”
The shared modules — ShopKitCore, ShopKitAPI, ShopKitAuth, ShopKitStore — are pure-Swift, no UIKit. They compile on macOS, watchOS, tvOS with no changes. ShopKitUI would need a platform-specific equivalent. StoreKit 2 is cross-Apple-platform, so subscriptions work everywhere. The cross-Apple-platform story for ShopKit is straightforward; the cross-Android story requires a backend that issues its own entitlements based on Google Play transactions — different and out of scope for this capstone.
Red-flag answers
If asked “what’s the hardest thing about subscriptions,” don’t say “the API is complicated.” Say: “Handling state transitions when the app isn’t running — refunds, billing retries, grace periods — and getting the UI to reflect them within the SLA Apple expects.”
If asked “did you have any bugs in production,” don’t say “no.” Say: “Yes, two. The refund-honoring window I mentioned. And one where my Keychain item used the wrong accessibility flag and broke for users who hadn’t set a passcode. Both caught within a week via my crash + analytics signal; I have postmortems in the repo.”
Next: Capstone 4 — NoteSync.
Capstone 4 — NoteSync
Tagline: A collaborative notes app where you can share individual notes or entire folders with other iCloud users, with real-time sync via CloudKit shared zones, AppIntents for shortcut automation, and partial iOS+macOS support.
Tech stack: Sign in with Apple · CloudKit shared zones · CKShare · AppIntents · iOS + macOS (partial universal)
Time budget: ~2 weeks
What this capstone proves:
- You can build a multi-user CloudKit experience — not just private DB, but
CKShared zones with collaborator permissions - You understand the CKShare invitation flow (UICloudSharingController on iOS, the macOS equivalent)
- You can resolve edit conflicts in a shared store with
CKRecordserver change tokens - You can expose AppIntents that work in Shortcuts, Siri, and Spotlight on both iOS and macOS
- You can ship a partial universal macOS catalyst-or-native build alongside iOS, picking the right tradeoffs
The 30-second pitch
“NoteSync is a notes app that lets you share individual notes or whole folders with other iCloud users. Sharing uses CloudKit’s CKShare model — you generate a share URL, the recipient accepts, and from that point both clients write to the shared zone with real-time deltas. The interesting work was the conflict resolution layer; when two users edit the same note simultaneously, CloudKit reports a server-change-token mismatch and you get back the divergent versions. I built a three-way merge based on the common ancestor that preserves both edits when possible and otherwise prefers the more recent timestamp with a ‘see both versions’ option. The app ships on iOS and macOS — same Swift package core, separate SwiftUI shells.”
Why this capstone
CloudKit private DB is straightforward. CloudKit shared DB with CKShare is the part nobody in the Apple ecosystem fully understands — and the part that companies that depend on multi-user iCloud sync (Bear, GoodNotes, Things) lose engineers over. Building this end-to-end is a senior-level signal. Adding iOS+macOS shows you can think about cross-platform code organization.
Files
- requirements.md — feature list, user stories, sharing flow
- architecture.md — CloudKit zone topology, conflict resolution, ADRs
- implementation-guide.md — step-by-step build walkthrough
- hardening-checklist.md — production + security review
- interview-talking-points.md — pitch and Q&A
What “done” looks like
- iOS + macOS apps, both on TestFlight
- A folder shared between two iCloud accounts, syncing within ~5 s
- Conflict resolution demonstrated: simultaneous edit on both devices → merged output
- AppIntents for “Create note”, “Append to note”, “Find notes containing” — usable in Shortcuts and Siri
- Hardening checklist ticked
Next: Requirements
NoteSync — Requirements
Personas
- Solo writer — uses NoteSync as a private notebook, syncing across her iPhone and Mac.
- Couple — shares a single “Household” folder for shopping lists and shared planning.
- Team of 5 — a writing group sharing a “Drafts” folder with read-write permission and a “Published” folder with read-only permission for reviewers.
User stories
Account & onboarding
- First launch requires Sign in with Apple only if the user wants sharing; otherwise the app works locally + private CloudKit only.
- The first screen explains the privacy model: notes live in the user’s iCloud, never on third-party servers.
- iCloud account status is checked on launch; if signed out, the app shows a clear “Sign in to iCloud in Settings” banner.
Notes & folders
- Users can create unlimited notes and unlimited folders.
- Notes have rich text (bold, italic, lists, links), a title, a created/modified timestamp, and optional tags.
- Folders can be nested one level deep (no folder-of-folders-of-folders for v1).
- Search is full-text across all notes the user has access to (private + shared).
Sharing — sender
- From any note or folder context menu, “Share…” presents
UICloudSharingController(iOS) or the macOS equivalent. - The sender can choose read-only vs read-write permission.
- The sender can choose “anyone with link” vs “only invited people.”
- Sharing generates a
https://www.icloud.com/share/...URL that opens NoteSync on the recipient’s device.
Sharing — recipient
- Tapping a share link with NoteSync installed opens the app and adds the shared item to a “Shared with me” section.
- Tapping a share link without the app shows a screen pointing to the App Store.
- Recipients with read-only permission see a lock icon on the note and cannot edit.
- Recipients with read-write permission can edit; changes propagate to all participants within ~5 s.
Conflict resolution
- If two users edit the same note simultaneously, the app reconciles automatically with a three-way merge.
- If the three-way merge is impossible (both changed the same paragraph), the app keeps both versions and shows a “Conflicts (2)” banner the user can resolve.
- The user can choose between the two versions, or merge manually.
AppIntents
- Shortcuts shows: “Create Note”, “Append to Note”, “Find Notes”, “Open Note by Title”.
- Each intent’s parameters use NoteSync’s
EntityQueryso the user can pick from a list of real notes. - Siri can invoke “Create a note saying [text]” without launching the app.
- Spotlight indexes all note titles and bodies; tapping a result deep-links into the note.
macOS
- The macOS app uses the same Swift package core as iOS. Only the views differ.
- macOS adds: menu bar shortcuts (
⌘Nnew note,⌘Fsearch), a sidebar selection (folders | shared | tags), and inline drag-and-drop reordering. - macOS supports universal clipboard from iOS without modification.
Acceptance criteria
- Cold launch (iOS): < 1.5 s
- Cold launch (macOS): < 1.5 s
- Sync latency, simultaneous edits, two devices on same WiFi: < 5 s
- Conflict resolution UI appears within 2 s of receiving a server token mismatch
- Spotlight reindex on note save: < 500 ms
- App size: < 25 MB
Non-goals
- No collaboration cursors (no “Susan is typing”). v2.
- No comments. v2.
- No web access — Apple’s CloudKit JS is a separate world.
- No version history beyond the last conflict resolution point.
- No end-to-end encryption beyond what CloudKit provides (CloudKit uses transport + at-rest encryption; we don’t add a separate layer).
Constraints
- iOS 17+, macOS 14+
- Apple Developer Program (CloudKit + Sign in with Apple capabilities)
- All shared zones use a single CloudKit container
Next: Architecture
NoteSync — Architecture
CloudKit zone topology
+--------------------------------+
| Private DB (per user) |
| - Zone: "NoteSync" |
| - Note records |
| - Folder records |
+--------------------------------+
+--------------------------------+
| Shared DB (per participant) |
| - Zone: "Shared:Drafts" | <-- shared by Owner A
| - Note records |
| - CKShare record |
| - Zone: "Shared:Household" | <-- shared by Owner B
| - Note + Folder records |
| - CKShare record |
+--------------------------------+
Key fact: in CloudKit, sharing happens at the zone level. To share a single note, you create a zone for it. To share a folder of 50 notes, you create one zone holding all 50. The sharing mental model is: the share URL grants access to a zone.
So when the user picks “Share this note,” internally we move it to a new shared zone before generating the CKShare.
Module layout
NoteSync/
iOSApp/
macOSApp/
ShortcutsExtension/ # AppIntents extension
Packages/
NoteSyncCore/ # models, errors
NoteSyncCloud/ # all CloudKit code
NoteSyncSearch/ # full-text search index
NoteSyncUI/ # views shared between iOS/macOS where possible
NoteSyncIntents/ # AppIntent definitions
Data model
public struct Note: Identifiable, Codable, Equatable {
public let id: UUID
public var title: String
public var bodyMarkdown: String
public var tags: [String]
public var createdAt: Date
public var modifiedAt: Date
public var folderID: UUID?
public var lastSeenServerChangeTag: Data? // for conflict detection
}
Note is plain Swift. Persistence and CloudKit translation happens in NoteSyncCloud, not on the model.
Sync engine
A CloudKitSyncEngine actor:
- Subscribes to
CKDatabase.changesfor both Private + Shared DBs - On change, fetches deltas via
CKFetchDatabaseChangesOperation→CKFetchRecordZoneChangesOperation - Decodes each
CKRecordinto aNoteorFolder - Writes to a local SQLite store via GRDB (or SwiftData if you prefer)
- On local writes, queues a
CKModifyRecordsOperation
public actor CloudKitSyncEngine {
private let container = CKContainer(identifier: "iCloud.com.yourorg.notesync")
private var privateChangeToken: CKServerChangeToken?
private var sharedChangeToken: CKServerChangeToken?
public func sync() async throws {
try await fetchDatabaseChanges(.private)
try await fetchDatabaseChanges(.shared)
}
private func fetchDatabaseChanges(_ scope: CKDatabase.Scope) async throws {
let db = container.database(with: scope)
// CKFetchDatabaseChangesOperation -> changed zone IDs
// For each zone, CKFetchRecordZoneChangesOperation with the per-zone token
// ...
}
}
Three-way merge for conflict resolution
When CKModifyRecordsOperation returns serverRecordChanged, CloudKit gives you:
serverRecord— current server versionclientRecord— your local pending version
You also have your last successfully synced version locally (the common ancestor). With those three, you can attempt a structural merge:
public func merge(local: Note, server: Note, ancestor: Note) -> MergeOutcome {
if local.title == server.title || local.title == ancestor.title {
// Server's title wins if local didn't touch it
}
// Body merge: diff(ancestor → local) + diff(ancestor → server)
// If no overlapping ranges, apply both
// If overlap, return .conflict([local, server])
// ...
}
For text, use a line-level diff (Myers algorithm via the Difference API in Swift 5.1+). If the two patches don’t touch overlapping line ranges, apply them sequentially. If they do, surface to the user.
AppIntents
import AppIntents
public struct CreateNoteIntent: AppIntent {
public static var title: LocalizedStringResource = "Create Note"
public static var description = IntentDescription("Create a new note in NoteSync.")
@Parameter(title: "Title") public var title: String
@Parameter(title: "Body") public var body: String?
public init() {}
@MainActor
public func perform() async throws -> some IntentResult & ReturnsValue<NoteEntity> {
let note = try await NoteStore.shared.create(title: title, body: body ?? "")
return .result(value: NoteEntity(note))
}
}
public struct NoteEntity: AppEntity {
public let id: UUID
public let title: String
public static var typeDisplayRepresentation: TypeDisplayRepresentation = "Note"
public var displayRepresentation: DisplayRepresentation { .init(title: "\(title)") }
public static var defaultQuery = NoteEntityQuery()
}
public struct NoteEntityQuery: EntityQuery {
public init() {}
public func entities(for identifiers: [UUID]) async throws -> [NoteEntity] { /* ... */ }
public func suggestedEntities() async throws -> [NoteEntity] { /* ... */ }
}
ADRs
ADR-001: Native macOS, not Catalyst
We share the Swift package core, not the UI. Catalyst would be faster to build but would feel iOS-derivative on macOS; native SwiftUI on macOS produces a Mac-feeling app with sidebars, toolbars, and menu commands done right.
ADR-002: Custom sync engine, not NSPersistentCloudKitContainer
NSPersistentCloudKitContainer is the easy path but doesn’t expose conflict-resolution hooks the way raw CloudKit does. For a capstone whose point is multi-user conflict handling, we need raw control.
ADR-003: Shared zone per share, not single shared zone
CloudKit’s sharing model is zone-scoped. Putting everything in one shared zone would mean any share grants access to everything in that zone. Per-share zones give us fine-grained control.
ADR-004: GRDB for local storage, not SwiftData
GRDB’s deterministic SQLite-backed model fits our sync semantics better than SwiftData (which adds Core Data abstraction layers and obscures the change-token model we need). SwiftData would be fine for a v1; we picked GRDB for explicit control.
Threading
- Views
@MainActor. CloudKitSyncEngineis an actor.NoteSyncSearchruns on a background actor; reindex on save is fire-and-forget.
Next: Implementation guide
NoteSync — Implementation Guide
Total estimated time: 50–70 hours. CloudKit sharing alone consumes a large fraction.
Day 1 — Project + capabilities
Step 1. Multi-platform Xcode project
File → New → Project → Multiplatform → App. Two targets: iOS, macOS. SwiftData not selected (we use GRDB).
Step 2. Capabilities (both targets)
- iCloud → CloudKit, container
iCloud.com.yourorg.notesync - Sign in with Apple
- App Groups:
group.com.yourorg.notesync(for AppIntents extension)
Step 3. Local storage
Add GRDB via SwiftPM. Build a NoteDatabase actor wrapping DatabaseQueue with schema for Note and Folder tables.
Checkpoint: create a note via UI, see it saved locally and survive relaunch.
Day 2–3 — Sync engine, Private DB only
Step 4. CloudKitSyncEngine for Private DB
Implement sync() for .privateCloudDatabase:
CKFetchDatabaseChangesOperationto learn which zones changed- For each changed zone,
CKFetchRecordZoneChangesOperationwith persistedCKServerChangeToken - Decode
CKRecord→Note, upsert into GRDB - On local writes, queue
CKModifyRecordsOperationto push
Step 5. Subscriptions
Create a silent push subscription per zone so the device wakes when CloudKit has new data. CKDatabaseSubscription with notificationInfo.shouldSendContentAvailable = true.
Step 6. App delegate to handle silent push
func application(_ application: UIApplication,
didReceiveRemoteNotification userInfo: [AnyHashable : Any],
fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void) {
Task {
try? await CloudKitSyncEngine.shared.sync()
completionHandler(.newData)
}
}
Checkpoint: create note on device A. Note appears on device B within 30 s without manual refresh.
Day 4–6 — Shared zones & sharing flow
Step 7. “Share this note” action
@MainActor
func shareNote(_ note: Note) async throws -> CKShare {
// 1. Create a new zone for this note
let zoneID = CKRecordZone.ID(zoneName: "Shared:\(note.id.uuidString)", ownerName: CKCurrentUserDefaultName)
let zone = CKRecordZone(zoneID: zoneID)
try await container.privateCloudDatabase.save(zone)
// 2. Move the note record to the new zone
let noteRecord = try CKRecord(note: note, zoneID: zoneID)
// 3. Create the share
let share = CKShare(rootRecord: noteRecord)
share[CKShare.SystemFieldKey.title] = note.title as CKRecordValue
share.publicPermission = .none
// 4. Save both
let _ = try await container.privateCloudDatabase.modifyRecords(
saving: [noteRecord, share], deleting: []
)
return share
}
Step 8. Present UICloudSharingController (iOS)
struct ShareSheet: UIViewControllerRepresentable {
let share: CKShare
let container: CKContainer
func makeUIViewController(context: Context) -> UICloudSharingController {
UICloudSharingController(share: share, container: container)
}
func updateUIViewController(_ ui: UICloudSharingController, context: Context) {}
}
Step 9. macOS sharing
Use NSSharingService(named: .cloudSharing) (deprecated path) or build a custom view that shows the share URL via share.url.
Step 10. Handle CKShare.Metadata on the recipient
func application(_ application: UIApplication, userDidAcceptCloudKitShareWith metadata: CKShare.Metadata) {
Task {
let op = CKAcceptSharesOperation(shareMetadatas: [metadata])
op.qualityOfService = .userInitiated
try await container.accept(op)
try await CloudKitSyncEngine.shared.sync()
}
}
For SwiftUI app lifecycle, hook this via App.handlesExternalEvents and URL parsing for the share URL.
Checkpoint: device A shares a note with iCloud account B (different device). B accepts. The note appears in B’s “Shared with me” within 10 s. B edits it. A sees the edit within 10 s.
Day 7 — Conflict resolution
Step 11. Detect serverRecordChanged
In your CloudKit modify response:
catch let error as CKError where error.code == .serverRecordChanged {
let serverRecord = error.serverRecord
let clientRecord = error.clientRecord
let ancestor = error.ancestorRecord // last seen
// Run 3-way merge; if mergeable, retry the save; if not, store as conflict
}
Step 12. Three-way merge for body
import struct Foundation.CollectionDifference
func mergeText(ancestor: String, local: String, server: String) -> String? {
let ancestorLines = ancestor.split(separator: "\n").map(String.init)
let localLines = local.split(separator: "\n").map(String.init)
let serverLines = server.split(separator: "\n").map(String.init)
let localDiff = localLines.difference(from: ancestorLines)
let serverDiff = serverLines.difference(from: ancestorLines)
// If diffs don't overlap, apply both
// Detect overlap by comparing changed indices
// ...
return mergedText
}
If mergeable, save and continue. If not, store the divergent versions as ConflictRecord(noteID, localBody, serverBody) and surface to the user with a conflict resolution UI.
Checkpoint: airplane mode both devices. Edit different paragraphs. Reconnect. Both edits appear. Now edit the same paragraph on both. Reconnect. UI shows “1 conflict — resolve.”
Day 8 — AppIntents
Step 13. Add AppIntents Extension
File → New → Target → Intents Extension. Move CreateNoteIntent, AppendToNoteIntent, FindNotesIntent into the extension’s package.
Step 14. EntityQuery for note picker
Per architecture.md. Returns up to 50 most recently modified notes for the Shortcuts picker.
Step 15. Spotlight indexing
In NoteStore.save(_:):
import CoreSpotlight
let attributeSet = CSSearchableItemAttributeSet(itemContentType: UTType.text.identifier)
attributeSet.title = note.title
attributeSet.contentDescription = String(note.bodyMarkdown.prefix(200))
let item = CSSearchableItem(uniqueIdentifier: note.id.uuidString,
domainIdentifier: "notes",
attributeSet: attributeSet)
try await CSSearchableIndex.default().indexSearchableItems([item])
Handle Spotlight tap via onContinueUserActivity(CSSearchableItemActionType).
Checkpoint: open Shortcuts → “+ Action” → NoteSync → “Create Note” appears with parameters. Run it; note is created. Open Siri: “Hey Siri, create a note saying hello.” Works without launching the app.
Day 9 — macOS shell
Step 16. macOS sidebar layout
NavigationSplitView with sidebar (Folders | Shared | Tags), content list, detail. Add Toolbar items for “New note,” “Share.”
Step 17. CommandMenus
@main struct NoteSyncApp: App {
var body: some Scene {
WindowGroup { ContentView() }
.commands {
CommandMenu("Note") {
Button("New Note") { /* ... */ }.keyboardShortcut("n")
Button("Search") { /* ... */ }.keyboardShortcut("f")
}
}
}
}
Checkpoint: open on Mac. ⌘N creates a note. ⌘F focuses search. Sidebar selection works.
Day 10–14 — Polish + TestFlight
- Run
hardening-checklist.md. - Fastlane lanes for iOS and macOS (separate
pilotinvocations). - macOS gets notarized via
gymautomatically. - TestFlight requires Mac TestFlight invitation.
Next: Hardening checklist
NoteSync — Hardening Checklist
1. Functional correctness
- Notes sync between two iCloud devices on the same account within 30 s
- Sharing a note to a different iCloud account works end-to-end
- Read-only sharing actually prevents writes (CloudKit enforces; verify)
- Three-way merge correctly combines non-overlapping edits
- Conflict UI appears for overlapping edits and resolves without data loss
- AppIntents all work in Shortcuts and Siri
- Spotlight returns hits within 1 s after note save
2. Security & privacy
- All user data lives in user’s iCloud — no third-party servers
- Privacy Nutrition Label: User Content (Linked, Not used for tracking)
-
PrivacyInfo.xcprivacydeclares User Defaults + File Timestamp APIs with reasons - No analytics, no crash reporters that ship PII
- Share URLs are not logged or persisted anywhere outside CloudKit
- No force-unwraps
-
All
try?are deliberate (verify each)
3. CloudKit hygiene
- Production schema deployed
-
Indexes created on
Note.modifiedAtandNote.folderIDfor query performance - Subscriptions cleaned up on sign-out
- Server change tokens persisted per zone, not globally
- Recovery from “ChangeTokenExpired” implemented (full re-sync of zone)
- Quota-exceeded surfaced with actionable UI
4. Performance
- Cold launch < 1.5 s on both platforms
- Sync triggered by silent push completes in < 5 s for typical deltas
- Spotlight reindex on save < 500 ms
- No retain cycles
- Memory peak during initial sync of 1000 notes < 100 MB
5. Accessibility
- VoiceOver navigates note list and detail
- Dynamic Type up to accessibility5 works
- Color contrast meets WCAG AA for all text
- Keyboard navigation full on macOS
6. Localization
-
All UI strings via
String(localized:) -
Date formatting via
Date.FormatStyle(locale-aware) - Sharing UI labels respect locale
- AppIntent parameter prompts localized
7. App Store Review
- iOS: demo iCloud account in Review notes if testing sharing
- macOS: notarization passes
- Privacy policy URL live
- Sign in with Apple flow tested by reviewer can complete in sandbox
8. Documentation
- README with architecture diagram + screenshots
- CloudKit zone topology documented
- Conflict resolution algorithm documented with examples
- AppIntents listed with example Shortcuts use cases
- Interview talking points rehearsed
Sign-off
Read interview-talking-points.md.
NoteSync — Interview Talking Points
The 30-second pitch
“NoteSync is a collaborative notes app on iOS and macOS where users can share individual notes or whole folders with other iCloud users. Sharing uses CloudKit’s CKShare model: I generate a per-share zone, drop the shared records into it, and produce a share URL. The recipient accepts on their device and from that point both clients read and write to the shared zone with real-time deltas via silent push subscriptions. The interesting work was conflict resolution — when two users edit the same note simultaneously, CloudKit returns a server-record-changed error with the divergent versions. I built a three-way merge using the common ancestor that combines non-overlapping line-level edits automatically and surfaces overlapping conflicts to the user for manual resolution.”
The 3-minute deep dive (sharing topology)
“The hardest mental model in CloudKit sharing is that sharing is zone-scoped, not record-scoped. You can’t just share one record while keeping the rest private — the share gives access to the zone containing that record. So when the user says ‘share this note,’ my code first moves the note into a new dedicated zone, then creates a
CKSharerooted on it.That has a subtle consequence: every share creates a new zone. Users with 100 shared notes have 100 shared zones — but each zone is tiny, so it works. The alternative — one big ‘Shared’ zone — would mean any share grants access to everything.
For folders, the same logic: I move all the folder’s notes plus the folder record itself into one new zone, share at the folder level, and the recipient gets a coherent unit.
Sync is two streams in parallel:
CKFetchDatabaseChangesOperationagainst.privateCloudDatabasefor user-owned content, and the same against.sharedCloudDatabasefor content shared with them. Each database has its own change token. Each zone within each database has its own change token. So my engine persists a tree of tokens and walks it.Silent push subscriptions wake the app when CloudKit has new data. The
didReceiveRemoteNotificationhandler kicks off a sync. From device-write to remote-device-display is around 5 seconds in good network conditions.The piece I’m proudest of is the conflict resolution. When
CKModifyRecordsOperationfails with.serverRecordChanged, the error carries the server’s current record, my client’s pending version, and the ancestor I had locally. I run a line-level diff from ancestor to local and from ancestor to server. If the changed line ranges don’t overlap, I apply both diffs and resave. If they overlap, I store both versions as aConflictRecordand the UI shows the user ‘resolve 1 conflict.’ Lossless by construction.“
12 interview questions
1. “Why CloudKit and not Firebase / Supabase / your own backend?”
Three reasons. (1) The user’s data lives in their iCloud — strong privacy story, no third-party trust. (2) No server infrastructure to run, scale, or pay for. (3) Sign in is free — iCloud account handles auth. The tradeoffs: no web access (CloudKit JS is a separate world we’re not entering), Apple-platform-only, and CKShare is finicky to learn. For a notes app where privacy is core to the value prop, CloudKit is the right choice.
2. “Walk me through how sharing works.”
User taps Share on a note. My code creates a new CKRecordZone in their private DB. Moves the note’s CKRecord into that zone. Creates a CKShare(rootRecord:) pointing at the note. Saves both. I then present UICloudSharingController with the CKShare; it generates a share.url and lets the user pick a delivery method. The recipient receives the URL via iMessage or email, taps it, iOS opens NoteSync, the system calls application(_:userDidAcceptCloudKitShareWith:), and my handler runs CKAcceptSharesOperation. From that point the shared zone appears in the recipient’s .sharedCloudDatabase and syncs normally.
3. “Why a zone per share?”
CloudKit sharing is zone-scoped. The share grants access to a whole zone. If I put all shared content in one zone, any share would expose everything. Per-share zones isolate each grant. The cost is more zones to track, but each is small and CloudKit doesn’t limit zone count meaningfully.
4. “What’s the limit on CloudKit zones?”
There’s no explicit per-user zone limit in current CloudKit docs; informal community findings suggest you can comfortably get to thousands per user before things get sluggish on bulk fetches. We’re nowhere near that — a heavy user might have 50 zones (one per shared item). For an app expecting users to share 10,000 items, you’d want a different model — maybe a single shared zone with record-level ACLs handled via your own server.
5. “Tell me about the conflict resolution.”
CloudKit returns serverRecordChanged when your save assumes a stale server version. The error carries serverRecord, clientRecord, ancestorRecord (the version you saw last). I run a line-level diff from ancestor to local and ancestor to server. If changed ranges don’t overlap, I produce a merged record applying both. If they overlap, I treat it as a true conflict — store both versions in a ConflictRecord table and surface to the user. The user picks one, merges manually, or accepts an auto-suggestion. Lossless.
6. “What about offline edits?”
Local writes go to GRDB immediately. The sync engine queues them as pending CKModifyRecordsOperations. When connectivity returns, they flush. Conflict path triggers if the server changed in the interim. Critical detail: writes are persisted as queue entries in the database, not held in memory — so app kills don’t lose them.
7. “Why GRDB and not SwiftData or Core Data?”
For conflict resolution I need deterministic control over the schema, change tokens, and the diff between local and server records. GRDB’s SQLite-backed model gives that with zero ambiguity. SwiftData and Core Data both add abstractions (lazy faulting, automatic save merging, change-set generation) that interfere with the explicit token-based reconciliation I need. For a simpler app I’d happily use SwiftData.
8. “How do AppIntents work cross-platform?”
The same intent compiled into both iOS and macOS targets. The Shortcuts app on each platform discovers them. Same Swift code, same parameter UI. The intent runs in either the AppIntents Extension (no app launch, used for Siri) or in-app (for actions requiring UI). For NoteSync, CreateNoteIntent and AppendToNoteIntent run extension-only; OpenNote requires the app to come forward.
9. “What’s your CloudKit subscription strategy?”
One CKDatabaseSubscription per database with silent push. On receipt, app triggers a sync. Cheap to set up, low push volume, and CloudKit handles delivery. I don’t use zone-level subscriptions because they’d multiply by zone count and offer no benefit at our scale.
10. “How would you add comments?”
Comments are first-class records linked to a note. Add Comment to the schema. When sharing a note, the share zone contains the note + all its comments. Permissions: writable for collaborators, immutable after author posts (or editable for 5 min). Sync the same way as notes. The UI shows them as a thread below the note body. About 3 days of work.
11. “How would you add a web view?”
CloudKit JS — Apple’s JS SDK for CloudKit. Runs in a browser, authenticates against the user’s iCloud, talks to the same container. The schema’s the same; just a different client. The catch is that CKShare flows from CloudKit JS are limited — sharing UI mostly happens on Apple devices. Acceptable for a read-mostly web view.
12. “Walk me through a bug you debugged.”
Two users were sharing a folder; one user kept seeing a ‘duplicate’ note: the same title, different bodies. Took two days. Turned out the silent push was waking the app while a write was in-flight, my sync ran concurrently, both wrote slightly-different records with the same UUID, and CloudKit accepted both because they had different recordChangeTags. Fix: the sync engine takes a Task-local lock; concurrent sync calls wait. Lesson: assume CloudKit will surprise you with concurrency edge cases, and serialize where it matters.
Red-flag answers
If asked “why not real-time presence (typing indicators),” don’t say “I didn’t get to it.” Say: “Out of scope for v1. CloudKit isn’t a real-time channel — its latency floor is in the seconds, not milliseconds. For presence I’d add a thin WebSocket service alongside CloudKit, used only for ephemeral state, with CloudKit still owning persistence.”
If asked “what about end-to-end encryption,” don’t say “CloudKit does it.” Say: “CloudKit encrypts at rest and in transit. For true end-to-end encryption where Apple can’t read the content, I’d layer a client-side encryption scheme on top — but key distribution across shared zones is its own multi-week problem. v2.”
Next: Capstone 5 — DevPortfolio.
Capstone 5 — DevPortfolio
Tagline: A developer portfolio + business-card app powered by CoreML (on-device resume parsing) and ARKit (drop your projects as 3D cards in the world), structured with The Composable Architecture, and shipped end-to-end through Apple Review.
Tech stack: CoreML · Vision · ARKit · RealityKit · TCA (The Composable Architecture) · App Store submission
Time budget: ~2.5 weeks
What this capstone proves:
- You can ship CoreML with a real, useful model (text classification — tag a resume snippet as “experience,” “education,” “skill”)
- You can build a working ARKit experience (3D project cards placed in the real world)
- You can architect a non-trivial app with TCA, demonstrating you understand redux-style unidirectional flow in a Swift context
- You can walk a reviewer through the full App Store submission including metadata, screenshots, privacy nutrition label, App Review Notes, and post-submission iteration
The 30-second pitch
“DevPortfolio is a developer’s interactive business card — you upload your resume, an on-device CoreML model classifies each paragraph as experience / skill / education / project, and the app builds a structured portfolio you can share via a QR code or a deep link. The AR mode lets you drop your projects into the room as floating 3D cards a recruiter can walk around. The whole app is structured in TCA, which gave me a single deterministic state tree and made testing the resume parser end-to-end straightforward — every classifier output flows through a Reducer I can assert against.”
Why this capstone
Three independent senior-signal technologies in one app: ML, AR, and a non-default architecture. None of them are toy demos — the ML model does real classification on real text, the AR mode interacts with detected planes, and TCA structures the actual flow, not just the README. Combined with a full App Store submission walkthrough, this is the capstone that shows “I can ship anything.”
Files
- requirements.md — feature list, user stories, acceptance criteria
- architecture.md — TCA structure, ML pipeline, AR session lifecycle, ADRs
- implementation-guide.md — step-by-step build walkthrough
- hardening-checklist.md — production + Review + privacy
- interview-talking-points.md — pitch and Q&A
What “done” looks like
- App live on the App Store
- Resume parsing works on three test resumes; classifier accuracy ≥ 80%
- AR mode places at least 3 cards on a detected horizontal plane
- TCA structure visible — root
AppFeature, child features forResumeParser,Portfolio,ARSession - Full App Review walkthrough documented in the README
Next: Requirements
DevPortfolio — Requirements
Personas
- Junior dev — first job hunt, wants a striking portfolio they can scan to a QR code at meetups.
- Senior IC — wants to share a curated set of side projects with one tap from their iPhone to a recruiter’s screen.
- Recruiter — receives a portfolio link, scans it, browses the contents on her own phone, or views in AR if she’s curious.
User stories
Setup
- First launch shows a 2-screen onboarding (what the app does, “upload your resume to get started”).
- User picks a resume PDF from Files, iCloud Drive, or Photos.
- The app extracts text via Vision’s
VNRecognizeTextRequest, then runs each paragraph through a CoreML text classifier to label it (experience / skill / education / project / other). - The user reviews the classification, can edit labels, and saves.
Portfolio building
- After parsing, the app shows the structured portfolio: Profile (name, title), Experience (list of roles), Skills (chips), Projects (cards with titles + descriptions), Education.
- Each section is editable — user can rename, reorder, hide.
- User can add Projects manually (title, summary, image, link).
Sharing
- Tapping “Share” generates a QR code linking to a portfolio URL (
devportfolio://portfolio/{id}orhttps://yourorg.com/p/{id}if you host a redirect). - The recipient can tap the link on their phone (with the app installed) to view; without the app, they see a web view fallback.
- Sharing a portfolio always uses the user’s current version (no stale snapshots).
AR mode
- From Projects, tap “AR” to enter ARKit mode.
- The app detects horizontal planes and places one card per project floating above the plane.
- User can tap a card to expand it; pinch to scale; rotate via two-finger drag.
- Recording an AR session as a video is supported via
RealityKit’sARView.snapshot(...)over time, or a simpler “save AR screenshot” button.
Privacy
- Resume parsing is fully on-device. The PDF is never uploaded.
- The CoreML model is bundled, not downloaded.
- The portfolio URL points to user-owned data (CloudKit private DB); only the URL holder can resolve it.
Acceptance criteria
- Cold launch: < 1.5 s
- Resume parsing (1-page PDF): < 3 s end-to-end on iPhone 13
- Classifier accuracy on labeled test set: ≥ 80%
- AR mode FPS: ≥ 30 FPS on iPhone 13 in good lighting
- AR plane detection: < 5 s to detect first horizontal plane
- App size including model: < 50 MB
- Crash-free sessions: ≥ 99.5% in first 7 days post-launch
Non-goals
- No portfolio web hosting (the QR code can point to a static page you host elsewhere; the iOS app is the canonical viewer).
- No multi-page resume tracking beyond plain text extraction (no preserving original PDF layout).
- No collaborative editing.
- No analytics dashboard (“who viewed your portfolio”) — privacy invasive and out of scope.
Constraints
- iOS 17+, ARKit-capable device required for AR mode (older iPads work for non-AR features).
- Apple Developer Program (CloudKit + App Store)
- The CoreML model must be redistributable under a permissive license (we’ll train ours from open data).
Next: Architecture
DevPortfolio — Architecture
TCA structure
AppFeature
├── OnboardingFeature
├── ResumeParserFeature
│ ├── PDFTextExtractor (Vision)
│ └── Classifier (CoreML)
├── PortfolioFeature
│ ├── ProfileFeature
│ ├── ExperienceListFeature
│ ├── ProjectListFeature
│ ├── SkillsFeature
│ └── EducationFeature
├── ShareFeature
└── ARSessionFeature
Each feature is a Reducer with its own State, Action, and body. Parent features compose children via Scope.
@Reducer
public struct AppFeature {
@ObservableState
public struct State: Equatable {
public var onboarding: OnboardingFeature.State?
public var portfolio: PortfolioFeature.State
public var resumeParser: ResumeParserFeature.State?
public var ar: ARSessionFeature.State?
}
public enum Action {
case onboarding(OnboardingFeature.Action)
case portfolio(PortfolioFeature.Action)
case resumeParser(ResumeParserFeature.Action)
case ar(ARSessionFeature.Action)
case startResumeImport(URL)
case enterAR
}
public var body: some Reducer<State, Action> {
Reduce { state, action in
switch action {
case .startResumeImport(let url):
state.resumeParser = .init(sourceURL: url)
return .none
case .resumeParser(.delegate(.parsingComplete(let portfolio))):
state.portfolio = portfolio
state.resumeParser = nil
return .none
case .enterAR:
state.ar = .init(projects: state.portfolio.projects)
return .none
// ...
default:
return .none
}
}
.ifLet(\.onboarding, action: \.onboarding) { OnboardingFeature() }
.ifLet(\.resumeParser, action: \.resumeParser) { ResumeParserFeature() }
.ifLet(\.ar, action: \.ar) { ARSessionFeature() }
Scope(state: \.portfolio, action: \.portfolio) { PortfolioFeature() }
}
}
ML pipeline
PDF/Image
│
▼
[VNRecognizeTextRequest] ← Vision (built-in, no model required)
│ produces text blocks
▼
[Paragraph splitter] ← simple newline + heuristics
│
▼
[Text Classifier] ← our CoreML model
│ produces {label, confidence}[]
▼
[Section builder] ← group consecutive same-label paragraphs
│
▼
Portfolio (Profile, Experience, Skills, Projects, Education)
The CoreML model is a text classifier trained via Create ML. Training data: ~500 manually labeled paragraphs from anonymized resumes (Kaggle has open datasets we can curate). Five classes: experience, skill, education, project, other.
Inference is sub-millisecond per paragraph on modern devices. A 1-page resume has maybe 20 paragraphs → 20 ms classifier overhead, dominated by the Vision text extraction (~2 s).
AR session lifecycle
import RealityKit
import ARKit
@MainActor
public final class ARSessionController: ObservableObject {
public let arView = ARView(frame: .zero)
@Published public var placedCards: [UUID: Entity] = [:]
public func start() {
let config = ARWorldTrackingConfiguration()
config.planeDetection = [.horizontal]
config.environmentTexturing = .automatic
arView.session.run(config)
}
public func placeCard(for project: Project, on plane: ARPlaneAnchor) {
let anchor = AnchorEntity(plane: .horizontal)
let card = makeCardEntity(for: project)
anchor.addChild(card)
arView.scene.addAnchor(anchor)
placedCards[project.id] = card
}
private func makeCardEntity(for project: Project) -> Entity {
// A flat card mesh with the project's image as a texture
let mesh = MeshResource.generatePlane(width: 0.3, height: 0.4, cornerRadius: 0.02)
var material = SimpleMaterial(color: .white, isMetallic: false)
// ... add texture from project image
let entity = ModelEntity(mesh: mesh, materials: [material])
return entity
}
}
ARSessionFeature in TCA wraps this controller. The reducer sends “plane detected” actions; the controller renders.
Module layout
DevPortfolio/
App/
Packages/
DevPortfolioCore/ # models, errors
DevPortfolioML/ # CoreML wrapper, paragraph splitter
DevPortfolioFeatures/ # all TCA reducers
DevPortfolioUI/ # SwiftUI views
DevPortfolioAR/ # ARSessionController + RealityKit scenes
The ML module is isolated so we can swap models (e.g., move to a larger LLM later) without touching features.
ADRs
ADR-001: TCA over MV or MVVM
For an app with 4+ major flows (onboarding, parsing, portfolio, AR) and complex shared state, TCA’s deterministic reducer model pays off. The state tree is the single source of truth; effects are explicit; tests are pure-function assertions. Cost: more boilerplate up front. Benefit: any state bug can be reproduced from a sequence of actions.
ADR-002: On-device ML, no server fallback
User-uploaded resumes are sensitive. Shipping them to a server — even our own — is a worse story than running a slightly less accurate model on-device. Modern iPhones run CoreML text models with imperceptible latency; the accuracy ceiling is high enough for our 80% target.
ADR-003: Create ML, not a third-party training pipeline
Create ML produces .mlmodel files Apple optimizes for Apple Silicon. Training is faster than PyTorch + CoreMLTools for our scale, the developer ergonomics are better, and we get on-device Neural Engine acceleration for free. For an LLM-scale task we’d use PyTorch + CoreMLTools; for paragraph classification, Create ML is the right tool.
ADR-004: ARKit + RealityKit, not SceneKit
RealityKit is the modern path: ECS-based, designed for AR, integrates with ARView cleanly. SceneKit is legacy and missing AR-first features (people occlusion, environment texturing). For new AR work, RealityKit is the default in 2024+.
ADR-005: QR code links, not deep links from email
QR codes are friction-free at meetups. The link can be a universal link (https://yourorg.com/p/{id}) that opens the app if installed or falls back to a web preview. Email-based sharing is slower and less impressive in person.
Threading
- TCA reducers are pure and called on the main queue by the Store.
- Effects (ML inference, file I/O) run on background tasks.
- ARView updates on the main thread; physics + rendering happen on RealityKit’s render thread automatically.
Next: Implementation guide
DevPortfolio — Implementation Guide
Total estimated time: 60–80 hours, plus 1–2 weeks for App Store Review.
Day 1 — Project + TCA setup
Step 1. Create project
iOS app, iOS 17+. Add TCA via SwiftPM (https://github.com/pointfreeco/swift-composable-architecture).
Step 2. Capabilities
- ARKit usage (Info.plist
NSCameraUsageDescriptionmandatory) - CloudKit (for portfolio storage)
- File Provider extension (for picking resume PDF)
Step 3. Root feature
@main struct DevPortfolioApp: App {
let store = Store(initialState: AppFeature.State()) {
AppFeature()._printChanges() // dev only
}
var body: some Scene { WindowGroup { AppView(store: store) } }
}
Checkpoint: launch shows empty AppView, no crash.
Day 2 — Vision text extraction
Step 4. PDF → text
import PDFKit
import Vision
func extractText(from pdfURL: URL) async throws -> String {
guard let doc = PDFDocument(url: pdfURL) else { throw ParseError.invalidPDF }
var allText = ""
for i in 0..<doc.pageCount {
guard let page = doc.page(at: i), let image = page.thumbnail(of: CGSize(width: 1200, height: 1500), for: .mediaBox).cgImage else { continue }
let request = VNRecognizeTextRequest()
request.recognitionLevel = .accurate
let handler = VNImageRequestHandler(cgImage: image)
try handler.perform([request])
let pageText = (request.results ?? [])
.compactMap { $0.topCandidates(1).first?.string }
.joined(separator: "\n")
allText += pageText + "\n\n"
}
return allText
}
Checkpoint: pick a resume PDF, see extracted text logged to console.
Day 3–5 — Train CoreML classifier
Step 5. Collect training data
Sources:
- Public anonymized resume datasets on Kaggle
- Your own past resumes
- 100+ paragraph samples per class minimum
Format as .csv:
text,label
"Senior iOS Engineer at Acme 2020-2024","experience"
"Built a custom networking layer with URLSession","experience"
"Swift, ARKit, CoreML","skill"
"BSc Computer Science Stanford 2018","education"
"PartyMode - an iOS app that...","project"
Step 6. Create ML project
Xcode → File → New → File → ML → Text Classifier. Drag your CSV. Train. Evaluate. Iterate on bad training examples. Export as .mlmodel.
Target accuracy: ≥ 80% on a held-out test set. If lower, the labeled data is too noisy or too small.
Step 7. Add the model to your Xcode target
Drag the .mlmodel file into Xcode. Xcode generates a Swift class.
import CoreML
public actor ResumeClassifier {
private let model: ResumeClassifierMLModel // Xcode-generated
public init() throws {
let config = MLModelConfiguration()
self.model = try ResumeClassifierMLModel(configuration: config)
}
public func classify(_ text: String) throws -> (label: String, confidence: Double) {
let input = ResumeClassifierMLModelInput(text: text)
let output = try model.prediction(input: input)
return (output.label, output.labelProbability[output.label] ?? 0)
}
}
Checkpoint: feed a sample paragraph, get back a label + confidence.
Day 6 — Resume parser feature
Step 8. ResumeParserFeature reducer
@Reducer
public struct ResumeParserFeature {
@ObservableState
public struct State: Equatable {
public var sourceURL: URL
public var extractedText: String?
public var classifiedParagraphs: [ClassifiedParagraph] = []
public var isProcessing: Bool = false
}
public enum Action {
case start
case extractedText(String)
case classified([ClassifiedParagraph])
case delegate(Delegate)
public enum Delegate {
case parsingComplete(PortfolioFeature.State)
}
}
@Dependency(\.pdfTextExtractor) var extractor
@Dependency(\.resumeClassifier) var classifier
public var body: some Reducer<State, Action> {
Reduce { state, action in
switch action {
case .start:
state.isProcessing = true
let url = state.sourceURL
return .run { send in
let text = try await extractor.extract(url)
await send(.extractedText(text))
}
case .extractedText(let text):
state.extractedText = text
return .run { send in
let paragraphs = text.split(separator: "\n\n").map(String.init)
let classified = try await withThrowingTaskGroup(of: ClassifiedParagraph.self) { group in
for p in paragraphs {
group.addTask { try await classifier.classify(p) }
}
var results: [ClassifiedParagraph] = []
for try await r in group { results.append(r) }
return results
}
await send(.classified(classified))
}
case .classified(let classified):
state.classifiedParagraphs = classified
let portfolio = PortfolioBuilder.build(from: classified)
return .send(.delegate(.parsingComplete(portfolio)))
case .delegate:
return .none
}
}
}
}
Checkpoint: pick a resume in the UI, see the AppFeature state transition into a populated PortfolioFeature.State within 3 s.
Day 7–8 — Portfolio UI
Step 9. PortfolioView
Profile header at top, then sections (Experience, Skills, Projects, Education). Each section a Section in a List with edit affordances.
Step 10. Persistence
Save PortfolioFeature.State to CloudKit private DB on every change. Use a simple CKRecord per portfolio with JSON-encoded Data for the body — we’re not querying fields, just round-tripping the whole structure.
Checkpoint: portfolio survives app restart. Confirmed via CloudKit Dashboard.
Day 9–10 — Sharing + QR codes
Step 11. Generate share URL
func makeShareURL(portfolioID: UUID) -> URL {
URL(string: "https://yourorg.com/p/\(portfolioID.uuidString)")!
}
func makeQRCode(for url: URL) -> UIImage? {
let data = url.absoluteString.data(using: .utf8)
let filter = CIFilter.qrCodeGenerator()
filter.setValue(data, forKey: "inputMessage")
guard let output = filter.outputImage else { return nil }
let scaled = output.transformed(by: CGAffineTransform(scaleX: 10, y: 10))
let context = CIContext()
guard let cg = context.createCGImage(scaled, from: scaled.extent) else { return nil }
return UIImage(cgImage: cg)
}
Step 12. Universal link handling
Configure your domain with an apple-app-site-association file. Handle onOpenURL in the app to load the linked portfolio.
Checkpoint: share a QR. Scan with another phone. App opens or, if not installed, web fallback shows.
Day 11–13 — ARKit
Step 13. ARSessionController
Per architecture.md. Wrap ARView in UIViewRepresentable.
Step 14. Plane detection delegate
extension ARSessionController: ARSessionDelegate {
public func session(_ session: ARSession, didAdd anchors: [ARAnchor]) {
for anchor in anchors {
if let plane = anchor as? ARPlaneAnchor, plane.alignment == .horizontal {
Task { @MainActor in
self.placeNextProject(on: plane)
}
}
}
}
}
Step 15. Card placement & gestures
Place project cards spaced 30 cm apart on the detected plane. Add tap gesture to expand details. Pinch to scale.
Checkpoint: enter AR mode in a well-lit room. Within 5 s a horizontal plane is detected and 3 cards appear floating above the floor.
Day 14–15 — App Store submission
Step 16. Pre-submission
- Run
hardening-checklist.md - Screenshots: 6.7“, 6.1“, 5.5“, 12.9“ iPad
- App Preview video (optional but improves conversion)
- Privacy Nutrition Label
- App Review Notes — include a sample resume PDF and a test AR-friendly room photo
Step 17. Submit and respond to Review
Common rejections for this kind of app:
- Camera usage description vague → make it specific to AR
- ML model not clearly working from screenshots → improve screenshot 1
- AR mode doesn’t have a fallback for non-AR devices → add an alert
Step 18. Document the Review journey in the README
Take screenshots of every Apple Review email. Annotate. This becomes a powerful interview artifact — proves you’ve actually gone through the process.
Next: Hardening checklist
DevPortfolio — Hardening Checklist
1. Functional correctness
- Resume parsing works on 5 different real resumes (PDF + image)
- Classifier accuracy ≥ 80% on held-out test set
- Manual label editing persists
- Sharing flow generates a valid QR + universal link
- AR mode detects a plane within 5 s and places 3+ cards
- AR mode degrades gracefully on devices without AR (shows alert + non-AR project list)
- Universal link opens the app or web fallback correctly
2. ML / privacy
- CoreML model is bundled, not downloaded
- Resume PDF never leaves the device
- No telemetry on user resume content
- Privacy Nutrition Label: User Content (Linked, Not used for tracking)
-
PrivacyInfo.xcprivacydeclares Required Reason APIs (file timestamp for PDF, user defaults)
3. ARKit
-
NSCameraUsageDescriptionis specific: “Used for AR mode to place your portfolio cards in the room.” - AR session is paused when the app backgrounds
-
AR mode is gated on
ARWorldTrackingConfiguration.isSupported - No crashes when AR mode is entered then exited rapidly
- Memory peak in AR mode < 200 MB
-
Thermal state monitored; AR mode dims rendering if
.seriousor.critical
4. Architecture
- TCA store mutations are deterministic — same actions produce same state
- Effects are cancellable on view dismissal
-
_printChanges()removed from Release builds - No circular dependencies between features
5. Performance
- Cold launch < 1.5 s
- Resume parsing < 3 s for 1-page PDF
- AR mode ≥ 30 FPS sustained
- App size including model < 50 MB
6. Accessibility
- VoiceOver describes each portfolio section
- Dynamic Type accessibility5 doesn’t break layout
- AR mode includes a “list mode” toggle for users who can’t use AR
-
All actionable elements have
.accessibilityLabel
7. App Store Review
- App Review Notes include: sample resume PDF link, AR demo instructions, how to test sharing
- Screenshots show ML classification result and AR mode
- App Preview video (highly recommended for AR apps — increases approval rate)
- Privacy policy URL live
- No mention of “AI” without backing model description (some reviewers are skeptical)
8. Documentation
- README with screenshots + AR demo GIF
- ML training script committed (so reviewers can reproduce)
- TCA architecture diagram
- App Review journey documented (rejection emails + responses)
- Interview talking points rehearsed
Sign-off
Read interview-talking-points.md.
DevPortfolio — Interview Talking Points
The 30-second pitch
“DevPortfolio is an interactive developer business card. You upload a resume PDF, Vision extracts the text, an on-device CoreML classifier tags each paragraph as experience / skill / education / project, and the app builds a structured portfolio you can share via QR code. The AR mode lets you drop your projects as 3D cards onto a detected horizontal plane that a recruiter can walk around. The whole app is built in TCA — every action flows through a deterministic reducer tree, which made the resume parser end-to-end testable without spinning up a real PDF in tests.”
The 3-minute deep dive (ML pipeline)
“The ML side is more of a systems story than a model story. The model is small — a Create ML text classifier with five classes — but getting it production-ready was instructive.
Data was the bottleneck. Open resume datasets exist but are noisy. I curated ~500 paragraphs across the five classes from anonymized sources, hand-corrected the labels, held out 20% for evaluation. First pass hit 72% accuracy. The 28% errors were almost entirely between ‘experience’ and ‘project’ — paragraphs like ‘Built a payments service for Acme’ look like both. Adding a contextual feature — ‘is this paragraph in a list with a date range?’ — pushed it to 84%.
Inference is sub-millisecond per paragraph on Neural Engine. A 1-page resume yields about 20 paragraphs, so classification overhead is 20ms. The end-to-end time is dominated by Vision’s
VNRecognizeTextRequestat about 2 seconds for a page rasterized to 1200×1500.The integration was clean because I wrapped the model in an actor that exposes a single
asyncclassify method. The TCA reducer dispatches an effect that runs classification in aTaskGroupfor parallelism across paragraphs. Result drops back into state as a.classified(...)action. Tests can swap the actor for a fake that returns canned results — no model loading required in test, which keeps test time under a second.The honest limitation: 84% accuracy means roughly 3 paragraphs per resume are mislabeled. So the UI presents the classification as a draft — the user reviews and corrects. That UX choice — ‘AI assists, human confirms’ — is more important than the model’s accuracy for this product.“
12 interview questions
1. “Walk me through TCA.”
The Composable Architecture is Point-Free’s redux-style framework for Swift. Every feature has a State, an Action, and a Reducer that maps (State, Action) -> State + Effects. Views observe state and dispatch actions. Effects (async work, side effects) are described declaratively and executed by the store. The win: deterministic. Same actions produce same state. Tests are pure-function assertions. The cost: more boilerplate than MV — you write Action cases for every interaction. For an app with 4+ flows and shared state, it’s worth it.
2. “Why TCA over MV?”
MV (Model-View, the post-MVVM SwiftUI default) is great when your state is small and local. DevPortfolio has resume parsing producing state consumed by portfolio editing, then by AR rendering, then by sharing — that’s a state tree, not isolated views. TCA gives me a single source of truth for the whole tree, child reducers per feature, and Scope to compose them. The deterministic property also helps debugging — I can serialize an action sequence from a crash report and replay it locally.
3. “How did you train the CoreML model?”
Create ML’s Text Classifier template. Trained on ~500 labeled paragraphs in a .csv. Iterated on the training set when accuracy plateaued — added contextual examples for the experience/project boundary, which were the hardest class pair. Final model is ~600 KB, sub-millisecond inference on Neural Engine. Could improve further with a transformer model, but the marginal accuracy isn’t worth the model size jump for this product.
4. “Why on-device ML and not a server call?”
Resumes are private. Sending them to a server — even mine — is a worse privacy story than running a slightly less accurate model locally. Modern iPhones run CoreML text models with imperceptible latency. The accuracy ceiling for this task on-device is high enough; for a more complex task (e.g., extracting nested entities) I might reach for a server with a larger model, with explicit user consent.
5. “How does the AR mode work?”
ARWorldTrackingConfiguration with horizontal plane detection. ARView is wrapped in UIViewRepresentable and driven by an ARSessionController actor. On plane detection delegate callback, I instantiate AnchorEntity(plane: .horizontal) and add card ModelEntitys. Cards are flat planes with the project image texture-mapped. Gestures (tap to expand, pinch to scale) are RealityKit’s built-in gesture recognizers attached to entities.
6. “What about people occlusion?”
ARWorldTrackingConfiguration.frameSemantics = [.personSegmentationWithDepth]. Enables on iPhone XS or later. When a person walks in front of the card, the card is correctly occluded. I added this in v1.1 after a user reported it looked fake without occlusion. Small change, big perceived-quality lift.
7. “How do you handle AR not being available?”
Gate AR entry on ARWorldTrackingConfiguration.isSupported. If false, show an alert: ‘AR requires an A12 or newer device’ and offer a list-mode view of the same projects. No crash, no dead UI.
8. “How do you test TCA?”
The TestStore from TCA. Send an action, assert the state change, assert effects. Effects can be stubbed with withDependencies { ... }. For DevPortfolio’s resume parser feature, my test sends .start, asserts isProcessing flips, the extractor and classifier dependencies are swapped for fakes returning canned text and labels, and the final state matches the expected Portfolio. End-to-end deterministic test, runs in milliseconds.
9. “What’s the App Store submission journey like?”
First submission was rejected for two reasons. One: my NSCameraUsageDescription was ‘Used for AR.’ Too vague — the reviewer flagged it. I rewrote to ‘Used for AR mode to place your portfolio’s project cards in the physical space around you.’ Two: my screenshots didn’t show the AR mode prominently; the reviewer couldn’t see why the camera permission was needed. I added an AR-mode screenshot and an App Preview video. Approved on resubmission.
10. “Why CoreML over a hosted LLM API?”
Cost, latency, privacy. A hosted LLM call costs cents per call, adds 500–2000 ms latency, and ships the user’s resume to a third party. CoreML costs nothing, completes in <100 ms, never leaves the device. For a paragraph-classification task, CoreML is the strictly better choice. If I wanted to generate portfolio summaries from the resume — a creative task — then a hosted LLM with consent makes sense.
11. “How would you scale the model to non-English resumes?”
The current model is English-only. Two paths: (1) train language-specific models, ship them all bundled — works for ~10 languages before app size becomes a problem. (2) train a single multilingual model on a combined corpus — harder but scales better. I’d start with (1) for the top 5 languages, evaluate accuracy, then decide if (2) is worth the training effort.
12. “Tell me about a bug.”
AR mode would freeze for 2-3 seconds after returning from background. The session was being paused on background and resumed on foreground, but the render loop wasn’t waking up cleanly. Took half a day. Fix: explicitly call arView.session.run(config) on scenePhase change to .active, not relying on RealityKit’s automatic handling. Lesson: ARKit’s lifecycle assumptions don’t always match SwiftUI scene phase changes — be explicit.
Red-flag answers
If asked “is your model better than ChatGPT,” don’t say “yes.” Say: “No, but it doesn’t need to be. GPT-4 would classify these paragraphs more accurately than my model, but at the cost of latency, money, and shipping the user’s resume to a third party. For an 80%-accuracy ‘draft for human review’ UI, on-device is the better product decision.”
If asked “did you write the TCA library yourself,” don’t be vague. Say: “No, it’s Point-Free’s open-source TCA. I use it because the deterministic reducer model fits this app’s state complexity. I could roll my own redux-style store, but TCA has refined ergonomics — Scope, _printChanges, TestStore — that I’d just be reinventing.”
Next: Capstone 6 — PlanBoard.
Capstone 6 — PlanBoard
Tagline: A true universal-binary SwiftUI app — a Kanban-style planning board that runs natively on iPhone, iPad, and Mac from a single codebase, with SwiftData + CloudKit, WidgetKit, AppIntents, and a fully native macOS shell (toolbar, CommandMenu, multi-window).
Tech stack: SwiftUI multiplatform · SwiftData + CloudKit · WidgetKit · AppIntents · macOS toolbar + CommandMenu · multi-window
Time budget: ~3 weeks
What this capstone proves:
- You can ship a genuine universal binary — not Catalyst, not an iPad app that runs on Mac, but native Mac + iPad + iPhone from one codebase
- You can make the cross-platform decisions explicit — when to share, when to split, when to use
#if os(macOS) - You can build a Mac that feels native — sidebar, toolbar, CommandMenu, multi-window, drag-and-drop, keyboard shortcuts, menu bar item
- You can layer WidgetKit + AppIntents on top across all platforms
- You can document the entire architecture decision record so a future maintainer (or interviewer) understands every choice
The 30-second pitch
“PlanBoard is a Kanban app shipped as a true universal binary — iPhone, iPad, and Mac, one codebase. SwiftUI handles 80% of the layout; the last 20% is platform-specific code I deliberately split. SwiftData + CloudKit syncs across all three. The Mac app has full sidebar + toolbar + CommandMenu + multi-window + a menu bar item. The widget shows your top three cards. AppIntents expose ‘Add card’ / ‘Move card’ to Shortcuts and Siri. The interesting work was the decision record — for every
#if os(macOS)block I added, I wrote a one-line rationale that lives in aplatform-decision-record.mdfile. So when someone asks ‘why is the sidebar layout different on Mac,’ I have a written, dated answer rather than a guess.”
Why this capstone
This is the senior-candidate capstone. It synthesizes everything from the first five — SwiftData, CloudKit, WidgetKit, AppIntents — and adds the hardest cross-platform challenge: making one app feel native on three platforms without devolving into either “iPad app on Mac” mediocrity or three-codebases sprawl.
The platform-decision-record.md file is the interview artifact. Every cross-platform Apple app has these decisions; almost none have them documented. Having yours documented is a strong senior signal.
Files
- requirements.md — feature list, user stories, per-platform expectations
- architecture.md — module layout, sync model, platform abstraction layer, ADRs
- implementation-guide.md — step-by-step build walkthrough
- hardening-checklist.md — production + Review across platforms
- interview-talking-points.md — pitch and Q&A
- platform-decision-record.md — every
#if os(...)decision, dated, with rationale
What “done” looks like
- App live on the App Store on both iOS and macOS
- Same boards sync across all three form factors within ~5 s
- Mac app passes a native-app smell test — feels like a Mac app, not an iPad port
- Widget on iOS + macOS (the menu bar item is the macOS widget)
- AppIntents work in Shortcuts on all three platforms
platform-decision-record.mddocuments every cross-platform call
Next: Requirements
PlanBoard — Requirements
Personas
- Solo planner — uses PlanBoard on iPhone in the morning to plan the day, switches to Mac at the desk for deep work, glances at the iPad on the side.
- Project lead — manages 3 boards across iOS and macOS, expects keyboard shortcuts and multi-window on Mac.
- Casual user — only uses the iOS widget to track 3 things; never opens the full app.
Universal user stories
(Apply to iPhone, iPad, Mac unless noted.)
Boards & columns
- User can create multiple boards.
- Each board has user-defined columns (default: “To Do”, “In Progress”, “Done”).
- Columns are reorderable via drag.
Cards
- Cards have title, optional description, due date, priority, color tag, optional checklist.
- Cards can be moved between columns via drag.
- Cards can be reordered within a column via drag.
- Tapping a card opens detail in a sheet (iPhone) or inspector (iPad/Mac).
Sync
- All boards sync across the user’s devices via CloudKit private DB.
- New boards/cards appear on other devices within 5 s when online.
Widget
- iOS: widget shows top 3 cards of the user’s “primary” board.
- macOS: a menu bar item shows the same (acts as the macOS widget).
AppIntents
- Shortcuts shows: “Add Card”, “Move Card”, “Find Cards by Tag”, “Open Board”.
- Siri invocations work without launching the app.
iPhone-specific user stories
- Board list is the root; tap a board to drill into columns.
- Columns are a horizontally-paged view (one column per page on small phones).
- Pull-to-refresh forces a CloudKit sync.
iPad-specific user stories
- Default split view: board list (sidebar) | columns (content) | card detail (inspector).
- Drag-and-drop between columns is fluid with multitouch.
- Apple Pencil scribble works in card detail.
Mac-specific user stories
- NSToolbar with: “+ New Card”, “+ New Board”, “Sync now,” “Show inspector” buttons.
- CommandMenu entries:
⌘Nnew card,⇧⌘Nnew board,⌘Rrefresh sync,⌘Itoggle inspector,⌘Fsearch. - Multi-window: open multiple boards side-by-side, each in its own window.
- Menu bar item: click to show top 3 cards in a popover; keyboard shortcut
⌥⌘Popens it. - Sidebar visible by default; user can collapse via
⌘0. - Drag a card from one window to another window (different boards) moves it.
Acceptance criteria
- Cold launch on each platform: < 1.5 s
- CloudKit sync latency, online: < 5 s
- Mac app passes “Mac-app smell test” — sidebar, toolbar, command menu, native menu items, no iOS popover-on-Mac UX antipatterns
- iOS widget refresh: every 30 min background, immediate on board change
- macOS menu bar popover opens in < 100 ms from click
- Zero crashes in 50 simulated user sessions per platform
Non-goals
- No collaboration / multi-user sharing (covered by NoteSync capstone).
- No automation beyond AppIntents.
- No Apple Watch app for v1.
- No web view.
- No support for Linux or Windows.
Constraints
- iOS 17+, iPadOS 17+, macOS 14+
- One App Store Connect record per platform (iOS app + macOS app; iPad shares iOS).
- Single Xcode project, multiple targets sharing 80%+ of code.
Next: Architecture
PlanBoard — Architecture
Project structure
PlanBoard/
Targets/
PlanBoard-iOS/ # iOS + iPadOS target
App.swift
Info.plist
PlanBoard-macOS/ # macOS target
App.swift
Info.plist
PlanBoardWidget-iOS/ # iOS widget extension
PlanBoardWidget-macOS/ # macOS menu bar widget
Packages/
PlanBoardCore/ # models, SwiftData schema, errors
PlanBoardSync/ # CloudKit configuration wrappers
PlanBoardShared/ # SwiftUI views shared across all platforms
PlanBoardiOS/ # iOS/iPad-specific views and modifiers
PlanBoardMac/ # macOS-specific views, toolbar, menu bar
PlanBoardIntents/ # AppIntents
The split between PlanBoardShared, PlanBoardiOS, and PlanBoardMac is the heart of the cross-platform strategy. See platform-decision-record.md for the per-decision rationale.
Data model (SwiftData)
@Model public final class Board {
@Attribute(.unique) public var id: UUID
public var name: String
public var sortOrder: Int
public var createdAt: Date
@Relationship(deleteRule: .cascade, inverse: \Column.board)
public var columns: [Column] = []
public init(id: UUID = UUID(), name: String, sortOrder: Int = 0) {
self.id = id; self.name = name; self.sortOrder = sortOrder; self.createdAt = .now
}
}
@Model public final class Column {
public var name: String
public var sortOrder: Int
public var board: Board?
@Relationship(deleteRule: .cascade, inverse: \Card.column)
public var cards: [Card] = []
public init(name: String, sortOrder: Int) {
self.name = name; self.sortOrder = sortOrder
}
}
@Model public final class Card {
@Attribute(.unique) public var id: UUID
public var title: String
public var notes: String?
public var dueDate: Date?
public var priority: Int // 0=low, 1=med, 2=high
public var colorTag: String? // hex
public var sortOrder: Int
public var column: Column?
public var createdAt: Date
public init(id: UUID = UUID(), title: String, sortOrder: Int = 0) {
self.id = id; self.title = title; self.sortOrder = sortOrder
self.priority = 0; self.createdAt = .now
}
}
CloudKit-backed:
let config = ModelConfiguration(
cloudKitDatabase: .private("iCloud.com.yourorg.planboard")
)
SwiftUI composition strategy
Three layers:
Layer 1: Shared views (90% of UI)
// In PlanBoardShared
public struct CardView: View {
@Bindable public var card: Card
public init(card: Card) { self.card = card }
public var body: some View {
VStack(alignment: .leading) {
Text(card.title).font(.headline)
if let due = card.dueDate {
Label(due.formatted(date: .abbreviated, time: .omitted), systemImage: "calendar")
.font(.caption)
}
}
.padding(8)
.background(.regularMaterial)
.clipShape(RoundedRectangle(cornerRadius: 8))
}
}
This compiles unchanged on iOS, iPadOS, macOS. The card looks at home on all three.
Layer 2: Platform-conditional modifiers
// In PlanBoardShared
extension View {
public func planboardNavigationStyle() -> some View {
#if os(macOS)
self.navigationSplitViewStyle(.balanced)
#else
self.navigationSplitViewStyle(.automatic)
#endif
}
public func planboardCardHover() -> some View {
#if os(macOS)
self.onHover { _ in /* visual feedback */ }
#else
self
#endif
}
}
Layer 3: Platform-specific shells
PlanBoardiOS/RootView.swift and PlanBoardMac/RootView.swift are distinct files. Each composes the shared views inside a platform-native shell. Mac gets a NavigationSplitView with Toolbar, CommandMenu, and a MenuBarExtra. iOS gets a NavigationStack (iPhone) or NavigationSplitView (iPad).
CloudKit sync
Same engine as NoteSync but simpler — no shared zones, only private DB. SwiftData’s auto-sync handles most of it; we add a manual CKContainer.requestApplicationPermission flow + a “Sync Now” button.
Widget + menu bar item
iOS widget (WidgetKit):
struct PlanBoardWidget: Widget {
var body: some WidgetConfiguration {
StaticConfiguration(kind: "PlanBoard", provider: TopCardsProvider()) { entry in
TopCardsView(entry: entry)
}
.supportedFamilies([.systemSmall, .systemMedium])
}
}
macOS menu bar (MenuBarExtra is built into SwiftUI on macOS 13+):
@main struct PlanBoardMacApp: App {
var body: some Scene {
WindowGroup { RootView() }
MenuBarExtra("PlanBoard", systemImage: "rectangle.3.group") {
MenuBarContent()
}
.menuBarExtraStyle(.window)
}
}
Same TopCardsView is reused in both — the only platform delta is the host.
AppIntents
One shared package. Available to Shortcuts on every platform. Each intent uses @MainActor and reads/writes the SwiftData store via a shared BoardStore actor.
ADRs
ADR-001: Native macOS, not Catalyst
Same rationale as NoteSync ADR-001. Catalyst would have been faster but produces a less Mac-feeling app. For a portfolio piece whose point is “I can ship a Mac app that feels native,” Catalyst defeats the demonstration.
ADR-002: Three layers of view sharing (shared / conditional / platform shell)
Alternatives considered: (a) all #if os(...) everywhere — works, but conditionals leak into every file, hard to read. (b) Two completely separate UI codebases — defeats the universal-binary goal, doubles maintenance. (c) Our approach: shared views by default, conditional modifiers where the change is small, platform shells where the change is structural. Hits the sweet spot.
ADR-003: SwiftData + CloudKit (auto-sync), not custom engine
Unlike NoteSync (which needed conflict-resolution hooks), PlanBoard’s data is mostly add/move/delete operations on small records. Conflicts are rare and SwiftData’s last-writer-wins is acceptable. Choosing the easy path here is the right tradeoff for the scope.
ADR-004: MenuBarExtra over a separate menu bar app
MenuBarExtra lives inside the main app — same process, same SwiftData container, no extension boundary to cross. Simpler than building a dedicated menu bar app talking to the main via IPC.
ADR-005: platform-decision-record.md as a first-class artifact
Every #if os(...) block in the codebase has a corresponding entry in platform-decision-record.md with a date and one-line rationale. Reviewable, dated, evolvable.
Threading
- All views
@MainActor. - SwiftData operations on
MainActorfor read; backgroundModelActorfor batch writes. - CloudKit sync is SwiftData-managed.
Next: Implementation guide
PlanBoard — Implementation Guide
Total estimated time: 80–100 hours. The Mac shell alone consumes a large chunk.
Week 1 — Foundation
Day 1. Project setup
Xcode → New → Multiplatform → App. Two targets: PlanBoard-iOS, PlanBoard-macOS.
Add SwiftPM packages: PlanBoardCore, PlanBoardShared, PlanBoardiOS, PlanBoardMac, PlanBoardIntents.
Day 2. SwiftData schema
Implement Board, Column, Card per architecture.md. Configure container with CloudKit.
@main struct PlanBoardApp: App {
let container: ModelContainer = {
let config = ModelConfiguration(
cloudKitDatabase: .private("iCloud.com.yourorg.planboard")
)
return try! ModelContainer(for: Board.self, Column.self, Card.self,
configurations: config)
}()
var body: some Scene {
WindowGroup { RootView() }.modelContainer(container)
}
}
Checkpoint: launch on both targets. Create a Board in code. It persists and survives relaunch.
Day 3. Shared views
Build CardView, ColumnView, BoardView in PlanBoardShared. Each takes a SwiftData model via @Bindable and looks reasonable on all platforms.
Checkpoint: in a SwiftUI preview, CardView(card: …) renders identically across iOS Preview and macOS Preview.
Week 1 — iOS shell
Day 4. iPhone navigation
// PlanBoardiOS/RootView.swift
public struct RootView: View {
@Query(sort: \Board.sortOrder) var boards: [Board]
public init() {}
public var body: some View {
NavigationStack {
BoardListView(boards: boards)
.navigationTitle("PlanBoard")
}
}
}
Day 5. iPad split view
#if os(iOS)
public struct iPadRootView: View {
@State private var selectedBoard: Board?
@State private var selectedCard: Card?
@Query var boards: [Board]
public var body: some View {
NavigationSplitView {
BoardListView(boards: boards, selection: $selectedBoard)
} content: {
if let board = selectedBoard { BoardView(board: board, selection: $selectedCard) }
} detail: {
if let card = selectedCard { CardInspector(card: card) }
}
}
}
#endif
Dispatch in RootView:
#if os(iOS)
@Environment(\.horizontalSizeClass) var sizeClass
public var body: some View {
if sizeClass == .regular {
iPadRootView()
} else {
iPhoneRootView()
}
}
#endif
Checkpoint: app feels right on iPhone (stack) and iPad (3-pane).
Week 2 — Mac shell
Day 6. Mac root + toolbar
// PlanBoardMac/RootView.swift
public struct MacRootView: View {
@State private var selectedBoard: Board?
@State private var selectedCard: Card?
@Query var boards: [Board]
public var body: some View {
NavigationSplitView {
BoardListView(boards: boards, selection: $selectedBoard)
.navigationSplitViewColumnWidth(min: 200, ideal: 240)
} content: {
if let b = selectedBoard {
BoardView(board: b, selection: $selectedCard)
.navigationSplitViewColumnWidth(min: 600, ideal: 800)
} else {
ContentUnavailableView("Select a board", systemImage: "rectangle.3.group")
}
} detail: {
if let c = selectedCard { CardInspector(card: c) } else { Color.clear }
}
.navigationSplitViewStyle(.balanced)
.toolbar { MacToolbar() }
}
}
struct MacToolbar: ToolbarContent {
var body: some ToolbarContent {
ToolbarItemGroup {
Button { /* new card */ } label: { Label("New Card", systemImage: "plus") }
Button { /* sync */ } label: { Label("Sync", systemImage: "arrow.triangle.2.circlepath") }
}
}
}
Day 7. CommandMenu
@main struct PlanBoardMacApp: App {
var body: some Scene {
WindowGroup { MacRootView() }
.commands {
CommandGroup(replacing: .newItem) {
Button("New Card") { /* dispatch */ }.keyboardShortcut("n")
Button("New Board") { /* dispatch */ }.keyboardShortcut("n", modifiers: [.command, .shift])
}
CommandMenu("Sync") {
Button("Sync Now") { /* sync */ }.keyboardShortcut("r")
}
CommandGroup(after: .toolbar) {
Button("Toggle Inspector") { /* dispatch */ }.keyboardShortcut("i")
}
}
}
}
Day 8. Multi-window
WindowGroup(id: "board", for: UUID.self) { $boardID in
if let id = boardID {
BoardWindowView(boardID: id)
}
}
Open new window for a board:
@Environment(\.openWindow) var openWindow
openWindow(id: "board", value: board.id)
Day 9. Menu bar item
MenuBarExtra("PlanBoard", systemImage: "rectangle.3.group") {
MenuBarContent()
}
.menuBarExtraStyle(.window)
MenuBarContent shows top 3 cards of the primary board with quick-add input.
Checkpoint: Mac app passes the “Mac-app smell test.” Sidebar collapses with ⌘0. ⌘N creates a card. Menu bar item works. Multiple board windows can be open simultaneously.
Week 2 — Widget
Day 10. iOS widget extension
struct PlanBoardWidget: Widget {
var body: some WidgetConfiguration {
StaticConfiguration(kind: "PlanBoardTop3", provider: TopCardsProvider()) { entry in
TopCardsView(entry: entry)
}
.supportedFamilies([.systemSmall, .systemMedium])
}
}
struct TopCardsProvider: TimelineProvider {
func getTimeline(in context: Context, completion: @escaping (Timeline<Entry>) -> Void) {
let cards = SharedStorage.topCards(count: 3)
completion(Timeline(entries: [Entry(date: .now, cards: cards)], policy: .after(.now.addingTimeInterval(1800))))
}
}
Share data via App Group so the widget reads the SwiftData container.
Checkpoint: Add widget on iOS home screen. Shows current top 3 cards. Move a card in the app — widget updates within minutes (or call WidgetCenter.shared.reloadAllTimelines() for instant update).
Week 2 — AppIntents
Day 11. AppIntents package
public struct AddCardIntent: AppIntent {
public static var title: LocalizedStringResource = "Add Card"
@Parameter(title: "Title") public var title: String
@Parameter(title: "Board") public var board: BoardEntity?
public init() {}
@MainActor
public func perform() async throws -> some IntentResult & ReturnsValue<CardEntity> {
let card = try await BoardStore.shared.addCard(title: title, to: board?.id)
return .result(value: CardEntity(card))
}
}
Verify in Shortcuts on iOS, iPadOS, macOS.
Checkpoint: Shortcuts on Mac shows “Add Card to PlanBoard” — runs without launching the app.
Week 3 — Polish + ship
Day 12–14. Cross-platform polish
- Drag-and-drop card across columns (works on all three; macOS needs slight different gesture sensitivity)
- Apple Pencil scribble in card detail (iPad only)
- VoiceOver across all platforms
- Universal clipboard from iOS to macOS (free with system; verify it works for “copy card link, paste on Mac”)
Day 15–17. Maintain platform-decision-record.md
For every #if os(...) block you’ve added, write the rationale. Done at write time, not after — that’s the point.
Day 18–21. App Store submission for both platforms
- Separate App Store Connect records for iOS app and macOS app
- Shared bundle ID across platforms is fine
- Mac app needs notarization (Fastlane
gymhandles it) - Screenshots: 6.7“ + 6.1“ + iPad 12.9“ + Mac 16:10 (e.g., 1280x800 or 2560x1600)
- App Preview videos optional but recommended for the Mac listing
Next: Hardening checklist
PlanBoard — Hardening Checklist
1. Functional correctness (per platform)
- iPhone: navigation, drag, card detail sheet all work
- iPad: split-view, drag between columns, inspector all work
- Mac: sidebar, toolbar, command menu, multi-window, menu bar all work
- CloudKit sync round-trips across all three within 5 s
- Widget on iOS updates within 30 min of card change
- Menu bar on Mac updates immediately on card change
- AppIntents work in Shortcuts on every platform
2. Cross-platform smell tests
- Mac app feels like a Mac app (passes a Mac user’s first-impression test)
- iOS app does not show Mac-only chrome (toolbars, command menus)
- iPad app uses split-view, not a phone-style stack
- No “iOS popover on Mac” antipatterns
- Keyboard navigation works on Mac (Tab cycles focus, arrows navigate lists)
- Right-click context menus on Mac match left-side gestures on iOS
3. Platform decision record
-
Every
#if os(...)block in the codebase has an entry in platform-decision-record.md - Each entry has a date, file reference, and one-line rationale
- Entries reviewable in PR diffs
4. Security & privacy
- No analytics, no third-party trackers
- Privacy Nutrition Label: User Content (Linked, Not used for tracking)
-
PrivacyInfo.xcprivacydeclares accessed APIs with reasons - No force-unwraps
-
All Keychain access uses appropriate
kSecAttrAccessibleflags
5. Performance
- Cold launch < 1.5 s on each platform
- CloudKit sync latency < 5 s when online
- Card drag-and-drop is smooth at 60 FPS
- Widget refresh < 500 ms
- Mac menu bar popover opens < 100 ms
- Memory peak with 5 boards × 50 cards: < 80 MB
6. Accessibility
- VoiceOver works on all three platforms
- Dynamic Type up to accessibility5
- Keyboard-only navigation works on Mac
- Color contrast WCAG AA
- No motion-required interactions
7. App Store Review (per platform)
- iOS: screenshots, privacy policy, App Review notes
- macOS: notarization passes, App Review notes
- Both: privacy nutrition label accurate
- Both: same bundle ID (acceptable; different is also fine)
- Test the upgrade path: install old version, install new version, data preserved
8. Documentation
- README with screenshots from all 3 platforms
- Architecture diagram showing the 3-layer view sharing
- Platform decision record committed
- ADRs reflect shipped build
- Interview talking points rehearsed
- Demo video showing universal sync across devices
Sign-off
Read interview-talking-points.md, then platform-decision-record.md.
PlanBoard — Interview Talking Points
The 30-second pitch
“PlanBoard is a Kanban app I shipped as a true universal binary — iPhone, iPad, and Mac, one codebase, native everywhere. About 80% of the SwiftUI views are shared across all three. The remaining 20% splits into two layers: small platform-conditional modifiers, and structural platform-specific shells where the Mac gets its own sidebar+toolbar+CommandMenu+MenuBarExtra and the iPhone gets a stack. CloudKit syncs through SwiftData. WidgetKit on iOS, MenuBarExtra on Mac (same view, different host). AppIntents work in Shortcuts on every platform. The artifact I’m proudest of is the platform decision record — every
#if os(...)in the codebase has a dated entry explaining why.”
The 3-minute deep dive (cross-platform strategy)
“The hardest thing about a universal SwiftUI app isn’t the code — SwiftUI compiles across platforms — it’s the decisions. Should the sidebar look the same on Mac and iPad? Should the Mac use a popover or a window for card detail? Should I write a CommandMenu or rely on the global File menu? Each one is small in isolation; together they decide whether the app feels native everywhere or iOS-on-Mac.
I settled on three layers. Layer one is shared views — a
CardViewrenders identically on every platform because the underlying SwiftUI primitives translate well. About 80% of the UI fits here. Layer two is conditional modifiers — same view, but with#if os(macOS)for things like hover effects or sidebar style. About 15%. Layer three is platform shells — completely separateiPhoneRootView,iPadRootView,MacRootView. About 5%, but it’s where the most user-perceptible decisions live.The Mac shell is the most work. SwiftUI gives you
NavigationSplitView,Toolbar,CommandGroup,MenuBarExtra, multi-WindowGroup— but each one has Mac-specific configuration that doesn’t apply on iOS. I had to learn the right balance: enough native Mac chrome to feel right, not so much that the codebase becomes unmaintainable.The artifact that captures this is the
platform-decision-record.md. Every#if os(...)block in the code has a dated entry. ‘Why does the sidebar collapse differently?’ has an answer. ‘Why no Toolbar on iPhone?’ has an answer. The cost of maintaining it is low — write the entry when you write the conditional — and the payoff is huge in code review and onboarding. Companies that ship cross-Apple-platform almost never have this document; I think it should be table stakes for senior work.“
12 interview questions
1. “Why universal SwiftUI and not Catalyst?”
Catalyst would compile faster — it’s literally taking iOS UIKit and giving it a Mac windowed surface. But the result feels iOS-derivative. Buttons are tinted with iOS blue. Toolbars are sized for touch. CommandMenu doesn’t quite work. For a portfolio piece demonstrating “I can ship a Mac app,” Catalyst defeats the demonstration. Native SwiftUI on Mac is more work but produces an app a Mac user can’t tell is cross-platform.
2. “What about Swift Package Manager vs Xcode targets?”
I use both. SPM packages for code (PlanBoardCore, PlanBoardShared, etc.) — they’re easy to share between targets, build independently, and test in isolation. Xcode targets for the platform shells, app extensions, and widgets — those need Info.plist, entitlements, capabilities. The boundary is: pure Swift code lives in packages, app metadata lives in targets.
3. “Walk me through the layer strategy.”
Three layers. Layer 1: shared views. A CardView works unchanged on iOS, iPadOS, macOS. About 80% of the UI. Layer 2: platform-conditional modifiers, applied via extensions on View. Same view, but with #if os(macOS) for things like hover or sidebar style. About 15%. Layer 3: platform-specific shells — separate iPhoneRootView, iPadRootView, MacRootView files. About 5% but where the most user-perceptible decisions live. The discipline: default to layer 1, escalate to 2 only if the conditional is small, escalate to 3 if it’s structural.
4. “Why a platform-decision-record.md?”
Every cross-platform app accumulates #if os(...) blocks. A year later, nobody remembers why. The PDR (platform decision record) is a single file with a dated entry per conditional explaining the why. It’s reviewable in PRs (‘this change adds an #if; update the PDR’). It’s onboarding material. It’s an interview artifact. The cost is one paragraph per conditional; the payoff is that the codebase’s cross-platform decisions are explicit rather than implicit folklore.
5. “How does MenuBarExtra work?”
It’s a SwiftUI Scene type added in macOS 13. You declare it alongside your WindowGroup. It creates a menu bar item; its body is a SwiftUI view shown in a popover (.menuBarExtraStyle(.window)) or a menu (.menu). Same process as the main app — shared SwiftData container, shared state. Much simpler than implementing a separate menu bar app talking to the main via IPC.
6. “Multi-window?”
WindowGroup(id: "board", for: UUID.self). Each window has its own state. Open via @Environment(\.openWindow). Cross-window drag-and-drop works because SwiftUI’s Transferable protocol works across windows. Cards have a Transferable conformance carrying their UUID; the receiving window resolves it via SwiftData.
7. “How does CloudKit handle sync across platforms?”
Same iCloud.com.yourorg.planboard container is used on iOS, iPadOS, macOS. SwiftData’s CloudKit integration handles upload/download. Conflict resolution is last-writer-wins (fine for this app, would not be for a multi-user app). Latency is 1-5 s typical. Devices auto-sync via silent push when one is awake; if both are asleep, sync happens on next foreground.
8. “Why no conflict resolution like in NoteSync?”
Different problem. PlanBoard is single-user. The only conflict is the user edits on iPhone and iPad simultaneously — rare, and last-writer-wins is good enough. NoteSync is multi-user, where conflicts are common and lossy resolution would be unacceptable. Match the conflict-resolution complexity to the actual conflict rate.
9. “What’s the WidgetKit story across iOS and macOS?”
iOS: widget extension shows on home screen. macOS: MenuBarExtra plays the same role (Apple doesn’t have widgets in the macOS sense; the menu bar item IS the widget). I built one shared TopCardsView and instantiated it in both hosts. About 20 lines of platform-specific glue for each host; the view itself is identical.
10. “AppIntents — how do they work cross-platform?”
Same intents package built into both targets. Shortcuts on each platform discovers them. Same parameter UI. Siri works the same on every platform that has Siri (iOS, iPadOS, watchOS — macOS Siri is more limited). I tested on each platform and they all worked without modification.
11. “Tell me about a cross-platform bug.”
Drag-and-drop on Mac wouldn’t work between columns. Worked on iPad. Took half a day. Turned out my Transferable conformance used .suggestedFileName which iOS ignored but macOS required to be non-nil. Fix: provide a default. Lesson: SwiftUI’s cross-platform APIs sometimes have platform-specific behavioral requirements even when the types are identical. Test on every target.
12. “How would you add Apple Watch?”
watchOS is a fourth target. Same PlanBoardCore + PlanBoardShared SwiftPM packages reused. Watch shell is dedicated — WKApplicationDelegate lifecycle, smaller UI, complications for the watch face. CloudKit sync is the same private DB. About a week’s work after the iOS/iPad/Mac story is solid. The reason I didn’t include it in v1: scope. Each platform is a non-trivial polish task, and stopping at 3 lets me get to v1 in 3 weeks instead of 4.
Red-flag answers
If asked “could you have done this with Catalyst,” don’t be defensive. Say: “Yes, in less time. But the resulting Mac app would have felt iOS-derivative. For PlanBoard’s portfolio purpose — demonstrating native Mac development — native SwiftUI is the right tradeoff. For a different app where Mac is secondary to iOS, I’d happily use Catalyst.”
If asked “why no Apple Watch app,” don’t say “I didn’t get to it.” Say: “Scope. Watch is a fourth platform with its own UX patterns, complications, and shell. Adding it well is a week of work after the iOS/iPad/Mac story is stable. I’d add it in v2 once those three are battle-tested in production.”
Next: Platform decision record, then on to the Appendix.
PlanBoard — Platform Decision Record
A dated entry for every cross-platform decision baked into the codebase. Treat this as a living document — every PR that adds an #if os(...) block updates this file.
Format:
## PDR-NNN — Short title
**Date**: YYYY-MM-DD
**File(s)**: relative path
**Decision**: what we did
**Rationale**: why
**Alternatives considered**: brief
**Revisit if**: condition
PDR-001 — Native macOS, not Catalyst
Date: 2024-09-01
Files: project-level (Xcode target setup)
Decision: Two separate targets — PlanBoard-iOS and PlanBoard-macOS — sharing Swift packages, not a single iOS target with Catalyst.
Rationale: Mac-feeling app. Catalyst’s iOS chrome doesn’t pass a Mac user’s smell test (button tints, toolbar sizing, missing keyboard nav).
Alternatives considered: Catalyst (faster, worse UX); SwiftUI multiplatform single target with #available(macOS ...) (couples shipping cadences).
Revisit if: Apple converges Catalyst + SwiftUI lifecycle further and the Mac chrome story improves.
PDR-002 — NavigationStack on iPhone, NavigationSplitView on iPad and Mac
Date: 2024-09-03
Files: PlanBoardiOS/RootView.swift, PlanBoardMac/RootView.swift
Decision: iPhone uses a stack-based navigation root; iPad and Mac use 3-pane split view.
Rationale: Phone screen is too small for split view to be useful. iPad and Mac users expect sidebar/content/detail.
Alternatives considered: Split view everywhere (cramped on iPhone); stack everywhere (wastes screen on iPad/Mac).
Revisit if: iPhone introduces a new form factor where a sidebar makes sense (foldable).
PDR-003 — MenuBarExtra on Mac, no equivalent on iOS
Date: 2024-09-05
Files: PlanBoardMac/App.swift
Decision: Mac app declares a MenuBarExtra scene with a popover showing top 3 cards. iOS has no equivalent.
Rationale: Menu bar is a native Mac affordance; iOS has Widgets which fill the same role.
Alternatives considered: NSStatusItem via NSApplicationDelegate (legacy, more boilerplate).
Revisit if: Apple adds a system-tray-like surface to iOS.
PDR-004 — CommandMenu only on Mac
Date: 2024-09-05
Files: PlanBoardMac/App.swift
Decision: .commands { ... } modifier is applied only to the Mac scene.
Rationale: iOS doesn’t render command menus. Including them does nothing on iOS but adds clutter.
Alternatives considered: Apply to all platforms (no harm but no benefit; we prefer explicit).
Revisit if: SwiftUI on iPad gets first-class menu support.
PDR-005 — Multi-window only on Mac
Date: 2024-09-06
Files: PlanBoardMac/App.swift
Decision: WindowGroup(id: "board", for: UUID.self) only declared in Mac target.
Rationale: iPad does support multi-window via scenes, but the UX is less common and adds complexity. Defer to v2.
Alternatives considered: Add iPad multi-window now (longer scope).
Revisit if: a user explicitly requests iPad multi-window.
PDR-006 — Card drag with Transferable everywhere, custom for cross-window only on Mac
Date: 2024-09-08
Files: PlanBoardShared/Card+Transferable.swift, PlanBoardMac/MultiWindowDragHandler.swift
Decision: Card conforms to Transferable for all platforms; cross-window drag handler is Mac-only.
Rationale: Single-window drag works the same everywhere via Transferable. Cross-window drag only matters on Mac (only Mac has multi-window in v1).
Alternatives considered: Implement custom drag everywhere (over-engineered).
Revisit if: iPad multi-window is added (then this handler needs iPad support too).
PDR-007 — Hover effects only on Mac
Date: 2024-09-10
Files: PlanBoardShared/View+Hover.swift
Decision: A .planboardCardHover() modifier applies .onHover only when os(macOS).
Rationale: iPad has pointer events too, but the design feedback is similar to touch. Mac is the primary hover-driven platform.
Alternatives considered: Enable hover on iPad with pointer (could revisit).
Revisit if: iPad pointer usage becomes a primary interaction model worth designing around.
PDR-008 — Different “primary action” gesture per platform
Date: 2024-09-12
Files: PlanBoardShared/CardView.swift
Decision: Tap on iOS, click on Mac (same .onTapGesture); long-press on iOS shows context menu, right-click on Mac shows the same menu.
Rationale: .contextMenu handles both — single API, platform-correct trigger.
Alternatives considered: Custom gesture handling per platform (unnecessary).
Revisit if: tvOS or visionOS support is added.
PDR-009 — Inspector as sheet on iPhone, third pane on iPad/Mac
Date: 2024-09-14
Files: PlanBoardiOS/RootView.swift, PlanBoardMac/RootView.swift
Decision: Card detail is a .sheet on iPhone, the detail column on iPad/Mac.
Rationale: iPhone screen can’t fit a third pane; sheet is the iOS-idiomatic modal.
Alternatives considered: Sheet on all platforms (wastes screen on iPad/Mac); navigation push on iPhone (loses context).
Revisit if: a new iPhone form factor changes this.
PDR-010 — Widget shape varies by host
Date: 2024-09-16
Files: PlanBoardWidget-iOS/WidgetEntryView.swift, PlanBoardMac/MenuBarContent.swift
Decision: Same TopCardsContent view; iOS wraps in containerBackground(.fill.tertiary, for: .widget), Mac wraps in a popover-shaped container.
Rationale: Widget hosts have different visual conventions; the content is identical.
Alternatives considered: Two completely separate widget implementations (duplication).
Revisit if: Apple introduces unified widget chrome across platforms.
PDR-011 — Keyboard shortcuts only declared on Mac
Date: 2024-09-18
Files: PlanBoardMac/App.swift
Decision: .keyboardShortcut(...) modifiers exist only inside Mac CommandMenu declarations. iOS shortcuts (for hardware keyboards) are not declared in v1.
Rationale: Scope. iOS hardware keyboard shortcuts are a v2 polish.
Alternatives considered: Declare shortcuts in shared scenes (works but doesn’t address discoverability on iOS).
Revisit if: a user with an iPad+keyboard requests it.
PDR-012 — NavigationSplitViewStyle.balanced on Mac, .automatic elsewhere
Date: 2024-09-20
Files: PlanBoardShared/View+Navigation.swift
Decision: .planboardNavigationStyle() modifier uses .balanced on Mac, .automatic elsewhere.
Rationale: Balanced gives Mac users a familiar 3-equal-column layout; automatic adapts well on iPad.
Alternatives considered: .prominentDetail (Mac feels off); .automatic everywhere (Mac sidebar collapses too aggressively).
Revisit if: SwiftUI improves the default Mac sidebar behavior.
How to maintain this document
When you add an #if os(...) block:
- Append a new PDR entry below the last.
- Number sequentially.
- Always include date, files, decision, rationale, alternatives, revisit condition.
- In your PR description, link to the PDR entry.
When you remove an #if os(...) block:
- Add a new PDR entry noting the removal (don’t delete the historical one).
- Reference the original PDR.
- Explain why the conditional is no longer needed.
This way the file grows monotonically and contains the full history of cross-platform decisions for the app’s lifetime.
Continue to the Appendix.
Appendix
Reference material that didn’t fit cleanly into a phase but you’ll want at hand. Skim once, then return when you need it.
Contents
- Swift version matrix — which Swift, Xcode, and OS versions ship together; what each Swift release added
- Xcode keyboard shortcuts — the shortcuts senior engineers actually use, grouped by workflow
- Interview cheat sheet — condensed top 50 Q&A across the book, for the night before
- Deployment checklist — every gate between “feature complete” and “live on the App Store”
- Security checklist — OWASP Mobile Top 10 mapped to iOS, with the actual mitigation for each
- Glossary — every acronym and term used in the book
- Career launch playbook — the 60-day plan from finishing this book to signed offer
How to use the appendix
These pages are reference material, not chapters. You don’t read them linearly. You jump in when:
- You’re about to start an interview tomorrow → Interview cheat sheet
- You’re about to submit an app to Review → Deployment checklist
- You’re about to start a security review → Security checklist
- You’re confused by an acronym → Glossary
- You want to know “what’s in Swift 6” without re-reading Phase 1 → Swift version matrix
- You’re 60 days from being ready and need a plan → Career launch playbook
End of book. You’ve earned the title.
Swift Version Matrix
Which Swift, which Xcode, which OSes ship together — and what each Swift release introduced. Use this to answer “what’s the minimum Xcode I need?” or “when did async/await ship?”
Compatibility table
| Swift | Xcode (min) | iOS / iPadOS | macOS | watchOS | tvOS | visionOS | Released |
|---|---|---|---|---|---|---|---|
| 6.0 | 16 | 18 | 15 (Sequoia) | 11 | 18 | 2 | Sept 2024 |
| 5.10 | 15.3 | 17.4 | 14.4 | 10.4 | 17.4 | 1.1 | Mar 2024 |
| 5.9 | 15 | 17 | 14 (Sonoma) | 10 | 17 | 1 | Sept 2023 |
| 5.8 | 14.3 | 16.4 | 13.3 | 9.4 | 16.4 | — | Mar 2023 |
| 5.7 | 14 | 16 | 13 (Ventura) | 9 | 16 | — | Sept 2022 |
| 5.6 | 13.3 | 15.4 | 12.3 | 8.5 | 15.4 | — | Mar 2022 |
| 5.5 | 13 | 15 | 12 (Monterey) | 8 | 15 | — | Sept 2021 |
| 5.3 | 12 | 14 | 11 (Big Sur) | 7 | 14 | — | Sept 2020 |
Older versions exist; you almost never write new code against them. Apps in the App Store today commonly target iOS 16 or 17 minimum, which means Swift 5.7 or 5.9 features are universally available.
What each Swift release added
Swift 6 (Sept 2024)
- Strict concurrency by default — opt-in via
-strict-concurrency=completewas rolled into the default. Data race safety enforced at compile time. - Typed throws —
func f() throws(MyError). Errors are now part of the type system. - Existential
any— required forany Ptypes wherePhas associated types or generics. - Sendable inference improvements — many more types automatically become
Sendable. - Pack iteration — variadic generics finally have iteration sugar.
Swift 5.10 (Mar 2024)
- Data isolation transitions — closing the last holes in actor isolation.
AccessLevelon imports —internal import Footo keep transitive deps internal.
Swift 5.9 (Sept 2023)
- Macros —
@attached,@freestanding. Use this for@Observable,#Preview,@Model. if/switchas expressions —let x = if cond { 1 } else { 2 }.- Noncopyable types —
~Copyableconstraint. - Generic parameter packs — variadic generics minus iteration.
Swift 5.8 (Mar 2023)
- Function back-deployment —
@backDeployedlets new stdlib APIs work on older OSes. unsafeForcedSyncforResult— minor ergonomics.
Swift 5.7 (Sept 2022)
- Existential improvements —
any Psyntax introduced. - Type inference for generic args — many call sites stop needing explicit generic params.
- Regex literals —
/(\d+)-(\d+)/.
Swift 5.5 (Sept 2021) — the big one
async/await— structured concurrency arrives.- Actors —
actor MyType { ... }. TaskandTaskGroup.@MainActor.async let.- AsyncSequence.
Everything before 5.5 is “pre-concurrency Swift” — it works, but Combine and callback-based APIs dominated.
Swift 5.3 (Sept 2020)
- SwiftPM resources — bundle assets in a package.
- Multi-pattern catch —
catch ErrorA, ErrorB. @main— single attribute for app entry point.
Mapping versions to “what can I use?”
When deciding minimum iOS version for an app:
- iOS 17+ (Swift 5.9): use Macros, Observation, SwiftData. Strongly recommended for new apps in 2024+.
- iOS 16+ (Swift 5.7): regex literals, existential
any. No SwiftData; use SwiftUI navigationNavigationStack. - iOS 15+ (Swift 5.5): async/await available, but
AsyncSequenceergonomics are weaker. NoObservablemacro. - iOS 14+ (Swift 5.3): pre-concurrency. Use
Combineor callbacks. Last refuge for legacy apps.
App Store submission Xcode requirement
Apple periodically raises the minimum Xcode required for App Store submissions. As of late 2024, submissions require Xcode 15.4 or later. Check Apple’s submission requirements before shipping; this changes ~annually.
When to target what
| Audience | Suggested min iOS | Why |
|---|---|---|
| New consumer app, 2025 | iOS 17 | Get SwiftData, Macros, Observation; market still growing |
| New consumer app, broader reach | iOS 16 | Still ~98%+ of active devices |
| Enterprise / regulated | iOS 15 | Covers slow MDM rollouts |
| Specific accessibility need | older | Confirm the device class is actively in use |
Don’t target older than necessary — every supported version adds testing surface and #available checks.
Xcode Keyboard Shortcuts
The shortcuts senior iOS engineers actually use. Memorize the starred ones; the rest are nice to know.
Navigation (between files / lines)
| Shortcut | Action | Notes |
|---|---|---|
⌘⇧O | ★ Open Quickly (any file by fuzzy name) | The single most-used shortcut. Type LoginV to jump to LoginViewController.swift. |
⌃⌘↑ | Switch between .h / .m or counterpart file | Less useful in Swift but works |
⌃6 | ★ Jump to symbol in current file | Method picker dropdown |
⌃⌘J | Jump to definition | Same as ⌘ + click |
⌃⌘← / → | Navigate back / forward (like browser history) | Essential for navigation-heavy work |
⌘L | Jump to line number | |
⌘1–⌘9 | Switch navigator tabs (Project, Find, etc.) | |
⌘0 | Hide/show navigator (left sidebar) | |
⌘⌥0 | Hide/show inspector (right sidebar) | |
⌘⇧Y | Hide/show debug area (bottom) |
Find & replace
| Shortcut | Action |
|---|---|
⌘F | Find in current file |
⌘⇧F | ★ Find in workspace |
⌘⌥F | Find & replace in current file |
⌘⌥⇧F | Find & replace in workspace |
⌘E | Use selection for find |
⌘G / ⌘⇧G | Find next / previous |
Build, run, test
| Shortcut | Action |
|---|---|
⌘B | Build |
⌘R | ★ Run |
⌘. | ★ Stop |
⌘U | Run all tests |
⌃⌘U | Build for testing (no run) |
⌃⌥⌘U | Run currently-edited test |
⌘⇧K | Clean build folder |
Editor
| Shortcut | Action |
|---|---|
⌃I | Re-indent current line(s) |
⌘/ | ★ Toggle line comment |
⌥⌘[ / ] | Move line up / down |
⌃K | Delete to end of line |
⌘D | Duplicate line(s) (in some Xcode versions; otherwise via Editor menu) |
⌘⇧A | ★ Show code actions (refactor, fix, generate) |
⌥ + click | Reveal documentation for symbol |
⌥⌘← / → | Fold / unfold code block |
Multiple cursors / selection
| Shortcut | Action |
|---|---|
⌃⇧↑ / ↓ | Add cursor above / below |
⌘⌥E | Insert next occurrence as cursor |
⌘⇧L | Select all occurrences of current symbol |
Refactoring
| Shortcut | Action |
|---|---|
⌘⇧A then Rename | Rename symbol across project (uses LSP, safe) |
⌘⇧A then Extract to Function | Pull selection into method |
Debugging
| Shortcut | Action |
|---|---|
⌘\ | Toggle breakpoint at current line |
⌘Y | Activate / deactivate all breakpoints |
F6 | Step over |
F7 | Step into |
F8 | Step out |
⌃⌘Y | Continue |
⌘K | Clear console |
Documentation & quick help
| Shortcut | Action |
|---|---|
⌥ + click symbol | Quick Help popover |
⌘⇧0 | Developer Documentation window |
⌃⌘? | Quick Help inspector |
Source control
| Shortcut | Action |
|---|---|
⌘2 | Source Control navigator |
⌥⌘C | Commit (opens commit sheet) |
Window / layout
| Shortcut | Action |
|---|---|
⌘⌥⏎ | ★ Open Assistant Editor (split view, e.g. show counterpart or preview) |
⌘⇧↩ | Close all editor splits except focused |
⌘\\` | Move focus between editor splits |
⌃⌘F | Full-screen toggle |
The 10 shortcuts to memorize first
If you only memorize ten, make them these:
⌘⇧O— Open Quickly (jump to any file)⌘⇧F— Find in workspace⌘B/⌘R/⌘.— Build, Run, Stop⌃6— Jump to symbol in file⌘0— Hide navigator (more screen)⌘/— Toggle comment⌘\\— Toggle breakpoint⌘⇧A— Code actions⌘U— Run tests⌘⇧K— Clean build folder
SwiftUI Preview shortcuts
| Shortcut | Action |
|---|---|
⌘⌥P | Resume preview |
⌘⌥⏎ | Show preview |
⌘⌥⇧↩ | Refresh preview (when stuck) |
Bonus — terminal-feeling editing
Xcode supports many emacs-style key bindings in its editor:
| Shortcut | Action |
|---|---|
⌃A | Move to start of line |
⌃E | Move to end of line |
⌃F / ⌃B | Forward / back one char |
⌃P / ⌃N | Previous / next line |
⌃D | Delete forward |
⌃K | Kill to end of line |
⌃Y | Yank (paste killed text) |
If you came from a terminal-heavy background, these reduce hand travel substantially.
Stick the 10-shortcut list on a sticky note next to your monitor for a week. After that, your hands know them.
Interview Cheat Sheet
Top 50 Q&A from across the book, condensed for the night before your interview. If you can answer all 50 cleanly, you’re ready.
Swift language
1. Class vs struct? Struct is a value type — copied on assignment, lives on the stack typically. Class is a reference type — shared via reference, lives on the heap. Default to struct unless you need identity, inheritance, or shared mutable state.
2. What’s a Swift actor?
A reference type with serialized access to its mutable state. Methods are implicitly async from outside. Use to protect mutable state from data races without manual locking.
3. Sendable?
A protocol marking a type as safe to pass across concurrency domains. Value types of Sendable components are auto-Sendable. Classes need to be final and either immutable or @unchecked Sendable with manual synchronization.
4. async/await vs Combine?
async/await is the modern path for one-shot async work. Combine is event streams over time (publishers). For new code, prefer async/await; reach for AsyncSequence if you need event streams.
5. weak vs unowned?
Both avoid retain cycles. weak is optional and becomes nil when the referent deallocates. unowned is non-optional and crashes if accessed after the referent is gone. Use weak unless you can prove the lifetime relationship guarantees safety.
6. Optional internals?
It’s an enum: case none and case some(Wrapped). ?. is sugar for pattern matching the .some case. ! force-unwraps and crashes on .none.
7. protocol with associated type?
Defines a generic placeholder the conforming type provides. Can’t be used directly as a variable type (existential limitations) — need any or generic constraints.
8. @escaping closure?
A closure that can outlive the function it was passed into. Required for callbacks stored or dispatched asynchronously. Implies you need to manage references carefully (cycles).
9. Result builders?
@resultBuilder lets a type aggregate sub-expressions into a single value. SwiftUI’s ViewBuilder is one. Foundation for DSLs like SwiftUI views, regex builder, etc.
10. Macros (Swift 5.9+)?
Compile-time code generation. @attached macros modify types/properties (e.g., @Observable, @Model). #freestanding macros expand to code (e.g., #Preview). Run as Swift programs at compile time.
SwiftUI
11. View identity?
SwiftUI uses the View’s structural position + explicit id to track identity across redraws. Identity changes cause state reset; same identity preserves state. id(...) modifier overrides.
12. @State vs @StateObject vs @Observable?
@State for value-type local state. @StateObject for reference-type owned by the view. @Observable (Swift 5.9+) is the modern replacement for ObservableObject — no more @Published, just @Observable macro on the class and @Bindable/@State in the view.
13. @Binding?
A two-way reference to state owned elsewhere. Passes write-back to parent. Use for child views that mutate parent state.
14. View lifecycle?
.onAppear, .task, .onDisappear. .task is async-aware and auto-cancels when the view disappears. Prefer .task over .onAppear for async work.
15. NavigationStack vs NavigationView?
NavigationStack (iOS 16+) is the modern API with proper programmatic navigation via a path binding. NavigationView is deprecated.
Concurrency
16. What is a Task?
A unit of asynchronous work. Created via Task { ... } or implicit in .task modifier. Has its own cancellation and priority.
17. Structured concurrency?
async let and TaskGroup — child tasks tied to parent’s lifetime. Cancellation propagates. Errors propagate. Easier to reason about than free Task {}.
18. @MainActor?
An actor pinned to the main thread. Mark types or methods to force main-thread execution. SwiftUI views are implicitly @MainActor.
19. Data race in Swift 6?
Compile error. Swift 6 enforces data race safety. Cross-actor mutable state without Sendable won’t compile.
20. Task.detached?
A task not inheriting parent’s actor, priority, or cancellation. Use sparingly — usually a code smell. Normal Task {} is almost always what you want.
Memory & performance
21. Retain cycle in a closure?
A closure that captures self strongly, stored on self. Break via [weak self] or [unowned self] capture.
22. ARC?
Automatic Reference Counting. Compiler inserts retain/release calls. Cycles aren’t broken automatically — you need weak/unowned.
23. Profiling an app? Instruments. Time Profiler for CPU, Allocations for memory, Leaks for cycles, SwiftUI instrument for view-body churn.
24. lazy property?
Computed on first access, stored after. Cannot be on a struct stored property that needs mutation without mutating. Use for expensive one-time initialization.
Data
25. Core Data vs SwiftData?
SwiftData is the modern Swift-native wrapper around Core Data. Uses @Model macro. Same engine, friendlier API. New apps targeting iOS 17+ use SwiftData; older apps stay on Core Data.
26. CloudKit private DB vs public DB? Private DB: one per user, syncs to that user’s iCloud, only they read/write. Public DB: app-wide, all users see, app developer controls schema.
27. CKShare? Lets a user share a record (or zone) with other iCloud users. Requires recipients to be on iCloud.
28. Codable?
Protocol combining Encodable and Decodable. Free implementation for types whose properties are all Codable. Customize via CodingKeys.
29. JSON parsing?
JSONDecoder().decode(MyType.self, from: data). Handle missing fields with optional properties. Use keyDecodingStrategy = .convertFromSnakeCase for API conventions.
Architecture
30. MVC vs MVVM vs MV (SwiftUI)? MVC: classic UIKit pattern, controller mediates. MVVM: ViewModel exposes formatted state, view binds. MV (SwiftUI): model + view directly; SwiftUI’s reactivity makes the ViewModel often redundant. Use what fits — don’t impose MVVM everywhere.
31. Dependency injection? Pass dependencies as init parameters or via environment. Avoid global singletons; they kill testability.
32. The Composable Architecture? Point-Free’s redux-style framework. State + Action + Reducer + Effects. Deterministic, testable, more boilerplate. Worth it for complex state trees.
33. Module boundaries? Use SwiftPM packages to split features. Core / shared utilities at the bottom, feature modules above, app at top. Hide implementation; expose protocols.
Testing
34. Unit vs UI vs integration test? Unit: pure logic, fast, isolated. UI: drives the actual app, slow, brittle. Integration: multiple components together but no real network/DB. Pyramid: lots of units, fewer integrations, very few UI tests.
35. Mocking dependencies? Define a protocol. Production class implements it. Mock class implements it for tests. Inject via init.
36. XCTest vs Swift Testing?
Swift Testing (@Test, #expect) is the new framework added in Swift 6 era. More expressive, parallelism-first, native to Swift. XCTest still works; use it for existing code and ObjC interop.
37. Snapshot tests? Render UI to an image and compare against baseline. Catches visual regressions. Pair with normal logic tests.
Networking
38. URLSession basics?
URLSession.shared.data(for: request) returns (Data, URLResponse). Use async/await. Custom session for caching, timeout, headers.
39. HTTPS / TLS? ATS (App Transport Security) enforces HTTPS by default since iOS 9. Override only with justification.
40. Retry strategy? Exponential backoff with jitter. Cap retries (3-5). Don’t retry on 4xx (client error). Retry on 5xx and network errors.
Security
41. Keychain vs UserDefaults? Keychain for secrets (tokens, passwords, keys) — encrypted, survives uninstall optionally, hardware-backed. UserDefaults for plain preferences — plaintext, not for secrets.
42. Sign in with Apple? Privacy-first authentication. Returns a stable user ID. Apple may give a hidden email relay. Required if you offer third-party social login.
43. Code obfuscation? Limited value on iOS — App Store binaries are already protected. Don’t waste budget on obfuscation.
Apple ecosystem
44. WidgetKit? Extension-based widgets on home screen + lock screen. SwiftUI views with a TimelineProvider supplying entries. No interactivity beyond AppIntent buttons (iOS 17+).
45. AppIntents? Define actions Shortcuts and Siri can invoke. SwiftUI-like declarative API. Replaces older SiriKit / Intents framework.
46. Live Activities? A persistent piece of UI on lock screen + Dynamic Island. ActivityKit. Limited duration. Updates via push or in-app.
47. WatchKit / watchOS apps? Independent SwiftUI app on the watch. Communicates with phone via WatchConnectivity. Complications for the watch face.
Deployment
48. Code signing? Each binary signed with your developer certificate, embedded in a provisioning profile listing the entitlements and devices/distribution rules. Xcode handles most via “Automatically manage signing.”
49. TestFlight? Apple’s beta testing. Internal testers (your team, no review) and external (up to 10,000, requires App Review of the build). Builds live 90 days.
50. App Store Review common rejections? Vague privacy policy, missing usage description strings, crashes on launch, third-party login without Sign in with Apple, IAP outside Apple’s IAP system, misleading screenshots.
The night-before drill
Read these 50, then do the PlanBoard talking points drill out loud — the full 30-second pitch + 3-minute deep dive of your strongest capstone. Then sleep.
Deployment Checklist
Every gate between “feature-complete in Xcode” and “live in the App Store.” Don’t skip steps; each one catches a class of bug or rejection.
1. Pre-build
-
All tests passing locally (
⌘U) - No warnings in build log (treat warnings as errors)
- Latest dependencies pulled
- Git working tree clean; tagged with version number
- CHANGELOG.md updated
2. Versioning
-
MARKETING_VERSION(visible to users) bumped per semver: bug fix → patch, feature → minor, breaking → major -
CURRENT_PROJECT_VERSION(build number) bumped (monotonically increasing across all uploads) - Build number never reused for the same marketing version
- Version matches the git tag
3. Capabilities & entitlements
-
.entitlementsfile lists only what you actually use - Push notification entitlement matches APNs key configured in App Store Connect
- CloudKit container exists and is in the right environment (Development → Production promotion)
- Sign in with Apple capability registered if you use it
- App Groups configured if you have widget / share extension
4. Info.plist
-
NSCameraUsageDescriptionetc. — every privacy-sensitive API has a string that describes the actual use - Vague strings (“Used for app features”) are auto-rejected; be specific
-
CFBundleDisplayNamematches your App Store name -
LSApplicationCategoryTypeset correctly -
URL schemes registered with
LSApplicationQueriesSchemesif you query them -
ITSAppUsesNonExemptEncryption = falseif you use only standard system crypto (saves a Compliance form per build)
5. Privacy Manifest (PrivacyInfo.xcprivacy)
- File present in main app target
- All Required Reason APIs declared (file timestamp, UserDefaults, system uptime, disk space, active keyboards)
- Tracking domains listed (or empty if no tracking)
- Collected data types declared
- Third-party SDKs that ship privacy manifests are linked
6. Code signing
- Production certificate not expired
- Provisioning profile valid (preferably “Xcode Managed” automatic)
- Bundle ID matches App Store Connect record
- Bitcode disabled (Apple deprecated it; new uploads should have it off)
- dSYM uploaded for crash symbolication (Crashlytics, Sentry, or App Store Connect Crashes)
7. Performance smoke
- Cold launch on the lowest-supported device: < 2 s preferred
- Memory at idle on launch: reasonable (< 100 MB for most apps)
- No obvious main-thread blocks (test by scrolling main screens)
- Frame rate ≥ 60 FPS on the lowest-supported device
8. App Store Connect metadata
- App description (≤ 4,000 chars; preview first 2-3 lines)
- Keywords (≤ 100 chars, comma-separated)
- Promotional text (≤ 170 chars, updatable without resubmission)
- Support URL (live, not a 404)
- Marketing URL (optional but recommended)
- Privacy Policy URL (required, live)
- Age rating answered honestly
- App Privacy section (“Nutrition Label”) completed and accurate
9. Screenshots & previews
- 6.7“ iPhone (1290×2796 portrait or rotated) — required
- 6.1“ iPhone (1179×2556) — optional but recommended
- 5.5“ iPhone (1242×2208) — required for some older apps
- 12.9“ iPad (2048×2732) if you support iPad
- Mac screenshots (e.g., 1280×800 or 2560×1600) if you support Mac
- First screenshot must show the app’s value within 2 seconds of looking
- No competitor logos / brand violations
- App Preview video (15-30s) recommended for AR / animation-heavy apps
10. Pricing & availability
- Price tier selected
- In-App Purchases created in App Store Connect (and submitted with first build)
- Subscription groups configured
- Availability regions confirmed (often start US-only, expand later)
11. App Review preparation
-
App Review notes filled in with:
- Demo account credentials (if login required)
- How to access subscription / paid features in test mode
- Any non-obvious gestures or onboarding
- Reproduction steps for unusual features
- Test build runs cleanly on a fresh device (delete and reinstall to verify)
- Demo account verified working from Apple’s reviewers’ likely location (US)
12. TestFlight
- Internal testers tested the exact build you’re submitting
- External beta passed (recommended for major releases)
- Crash-free sessions > 99.5% over the beta
- No outstanding P0 / P1 bugs from beta
13. Submission
- Build uploaded via Xcode → Archive → Distribute App → App Store Connect
- Build processed in App Store Connect (~10 min after upload)
- Build attached to the version record
- “Submit for Review” clicked
- Phased release toggle decided (recommend: enabled, 7-day ramp)
- Released automatically vs manually decided
14. Post-submission
- Watch email for Review status changes (typically 24-48h)
- If rejected: read carefully, fix, resubmit promptly (don’t argue first)
- If approved: announce, monitor crashes for first 24h
- If issues: pull the version (App Store Connect → Pricing & Availability → Remove from Sale)
15. Day-1 post-launch
- Monitor Xcode Organizer → Crashes for new crash signatures
- Monitor analytics for adoption curve
- Watch App Store reviews; respond professionally to early ones
- Have a hotfix path ready (next version with build number bumped)
16. Mac-specific extras
- App notarized (Xcode does this on Archive → Distribute)
- Notarization ticket stapled
- Hardened runtime enabled
- Sandboxed (required for App Store)
- No private API usage (App Store Mac apps are sandboxed; private APIs would fail)
17. Watch-specific extras
- Independent watch app capability set
- Complications work on the watch face after install
- WatchConnectivity tested across pairing scenarios
The 10-item express checklist (small updates)
For a small bugfix release where you’re not changing capabilities:
- Bump build number
- Run tests
- Update changelog
- Archive + Distribute
- Verify build appears in App Store Connect
- Attach to a new version record
- Update Promotional Text (no resubmission required if only this changes)
- Submit for Review
- Monitor email
- Release on approval
When in doubt, work the full list. The 30 minutes you save by skipping is a week if you get rejected.
Security Checklist
OWASP Mobile Top 10 mapped to iOS, with the actual mitigation. Run this list before every release; do a deep audit annually or after major architectural changes.
M1: Improper Credential Usage
Risk: Hardcoded credentials, plaintext credentials in storage, credentials in logs.
iOS mitigations:
- No API keys, secrets, tokens checked into source
-
Secrets injected via build config or fetched at runtime (xcconfig +
.gitignore) - User credentials (passwords, tokens) stored in Keychain only — never UserDefaults
-
Keychain items use
kSecAttrAccessibleWhenUnlockedThisDeviceOnly(most restrictive that fits) -
No tokens / passwords in
os_logorprint(use.privatefor sensitive log args) - No tokens / passwords in error messages shown to user
M2: Inadequate Supply Chain Security
Risk: A compromised SDK / SPM dependency ships malicious code.
iOS mitigations:
-
Pin dependencies to exact versions (
Package.resolvedcommitted) - Review every new dependency before adoption (popularity, last commit, maintainers)
- Audit third-party SDKs for unexpected network calls (use Charles / mitmproxy in dev)
- Verify privacy manifests of SDKs match what they’re permitted to do
- Subscribe to security advisories for each major dependency
-
CI build runs
swift package show-dependenciesand alerts on changes
M3: Insecure Authentication / Authorization
Risk: Weak auth flows, predictable session tokens, missing authorization checks.
iOS mitigations:
- OAuth2 / OIDC for any third-party auth; never roll your own
- Use Sign in with Apple where appropriate (privacy-friendly, supported by Apple)
- Tokens short-lived; refresh tokens rotated
- Biometric (FaceID / TouchID) protection for sensitive operations via LocalAuthentication
- Server enforces authorization on every endpoint (never trust client-side checks)
- Sign-out clears Keychain entries
M4: Insufficient Input/Output Validation
Risk: Server returns unexpected data; user input not sanitized.
iOS mitigations:
-
All server responses decoded through
Codabletypes (fails fast on schema drift) - User input length-limited at the UI
-
URL inputs validated before opening (avoid arbitrary
URL-based code paths) - Deep link / universal link payloads validated and bounded
- WKWebView-loaded URLs whitelisted to your domains
M5: Insecure Communication
Risk: HTTP, weak TLS, MITM-able traffic.
iOS mitigations:
-
ATS (App Transport Security) enabled by default (no
NSAllowsArbitraryLoads) - Per-domain exceptions justified and minimal
- HTTPS-only for all network calls
-
Certificate pinning for highest-value endpoints (e.g., auth, payments) via
URLSessionDelegate - TLS 1.2 minimum (1.3 if possible)
-
Don’t trust user-installed CA certificates for critical endpoints (set
serverTrustpolicy)
M6: Inadequate Privacy Controls
Risk: User data collected without consent or beyond stated use.
iOS mitigations:
- Privacy Nutrition Label completed accurately in App Store Connect
-
PrivacyInfo.xcprivacyreflects actual API usage - App Tracking Transparency prompt shown before any tracking
- No collection of data beyond what’s declared
- Analytics events scrubbed of PII
- User can delete their account and data (required by App Store guideline 5.1.1(v))
M7: Insufficient Binary Protections
Risk: Reverse engineering, tampering, jailbreak abuse.
iOS mitigations:
- App built with optimization (Release config) — symbols and SwiftUI bodies are not directly readable
-
No debugger attached check for highly sensitive apps (sysctl
KERN_PROC+P_TRACED) - Code signing verified at install (Apple does this)
- Don’t waste time on obfuscation — App Store binaries are reasonably protected; focus on server-side checks
M8: Security Misconfiguration
Risk: Debug settings enabled in production, verbose errors, unsafe defaults.
iOS mitigations:
-
No
print()of sensitive data in Release builds -
No debugging UI accessible in Release builds (use
#if DEBUG) - Crash reports scrubbed of PII before transmission
- App Group permissions reviewed (only the extensions that need access)
- No development entitlements in production binaries
M9: Insecure Data Storage
Risk: Sensitive data on disk in plaintext or world-readable locations.
iOS mitigations:
- Sensitive data only in Keychain
-
Files stored with
.completeUntilFirstUserAuthenticationor.completeprotection - Photos / videos containing personal info stored in the app sandbox, not Photos library
- SQLite / SwiftData files in protected directories
-
No sensitive data in
NSTemporaryDirectory()without cleanup -
Pasteboard not used for sensitive transfers (use
UIPasteboardOptions.localOnlyif needed)
M10: Insufficient Cryptography
Risk: Weak algorithms, custom crypto, bad key management.
iOS mitigations:
-
Use CryptoKit (Apple’s modern API) —
AES.GCM,Curve25519,SHA256 - No custom crypto algorithms
- Keys derived from passwords use PBKDF2 / Argon2 with sufficient iterations
-
Symmetric keys stored in Keychain or Secure Enclave (
SecureEnclave.P256.Signing.PrivateKey) - No deprecated algorithms (MD5, SHA1, DES, ECB)
-
Random values from
SecRandomCopyBytesor CryptoKit, neverarc4random()for cryptographic purposes
Bonus — iOS-specific security hygiene
- Pasteboard usage minimized (system shows banner when read in iOS 16+)
-
Universal links validated against
apple-app-site-association - URL schemes documented and limited
-
Custom keyboards disallowed for password fields (
.textContentType(.password)discourages 3rd-party keyboards) -
Screenshot in app switcher hidden for sensitive content (
SceneDelegateblurs on background) - FaceID / TouchID prompt before showing sensitive screens after backgrounding
- Background tasks complete or are cancelled cleanly (no lingering work that could expose data)
CI/CD security
- CI secrets stored as GitHub Actions / similar encrypted secrets — never in repo
- Signing certificates in CI use temporary keychains per build (cleaned after)
- App Store Connect API key restricted to needed scopes
- Production deploys gated behind required approvals
- Audit log of who deployed what, when
Annual review
Once a year, do a full security audit:
- Re-run this checklist
- Audit third-party SDKs for new advisories
- Re-test certificate pinning (rotate certs if needed)
- Run a network capture against the app and look for unexpected traffic
- Test on a jailbroken device to confirm graceful behavior
- Re-read App Store Review Guidelines 5.1 (Privacy) for changes
When a security incident happens (and one will): be transparent with users, follow your privacy policy’s disclosure terms, revoke affected credentials immediately, ship a fix fast, document the timeline publicly. Trust is built by handling failures well.
Glossary
Acronyms and terms used in this book.
A
ABI — Application Binary Interface. The contract between compiled binaries. Swift is ABI-stable as of Swift 5 on Apple platforms.
ADR — Architecture Decision Record. A short document capturing why an architectural choice was made.
APNs — Apple Push Notification service. Apple’s push notification delivery system.
ARC — Automatic Reference Counting. Swift/ObjC memory management; the compiler inserts retain/release.
ARKit — Apple’s augmented reality framework.
ATS — App Transport Security. iOS default that enforces HTTPS.
AsyncSequence — Swift’s protocol for asynchronous iterators (for await x in seq).
B
Background Task — Work scheduled to run when the app is not in the foreground (BGTaskScheduler, URLSession background uploads).
Binding — @Binding in SwiftUI; a two-way reference to state owned elsewhere.
C
Catalyst (Mac Catalyst) — Apple’s technology to run iPad apps on Mac with windowed chrome.
CKShare — A CloudKit record representing a sharing relationship between iCloud users.
CloudKit — Apple’s cloud storage backed by iCloud. Private and public databases.
Combine — Apple’s reactive streams framework (publishers, subscribers, operators).
CommandMenu — SwiftUI’s mechanism for adding entries to the macOS menu bar.
CoreData — Apple’s older object graph and persistence framework.
CoreML — Apple’s on-device machine learning framework.
CryptoKit — Apple’s modern cryptography API (AES.GCM, Curve25519, etc.).
D
Dependency Injection (DI) — Pattern of supplying dependencies to a type via initializer parameters instead of constructing them internally.
DispatchQueue — GCD’s serial/concurrent execution context.
dSYM — Debug Symbol file; lets Apple symbolicate crash reports.
Dynamic Island — Status / activity area on iPhone Pro models since iPhone 14 Pro.
E
Entitlement — A capability granted to an app via a .entitlements file and provisioning profile.
Existential — A type-erased value of some protocol; any P in modern Swift.
F
FairPlay — Apple’s DRM for video; also covers some App Store DRM.
FoundationModels — Apple’s framework for on-device LLM (Apple Intelligence).
G
GCD — Grand Central Dispatch. Apple’s queue-based concurrency primitive (older but still common).
H
HIG — Human Interface Guidelines. Apple’s design rules per platform.
HealthKit — Apple’s health/fitness data framework.
I
IAP — In-App Purchase. Purchases through Apple’s payment system (required for digital goods).
Instruments — Apple’s profiler (Time Profiler, Allocations, Leaks, Network).
IDFA — Identifier for Advertisers. Gated behind ATT (App Tracking Transparency) since iOS 14.5.
K
Keychain — Apple’s encrypted secret storage (passwords, tokens, keys).
L
Live Activity — A persistent UI on lock screen + Dynamic Island. ActivityKit framework.
M
MainActor — Global actor pinned to the main thread. @MainActor annotation.
MVC — Model-View-Controller. Classic UIKit pattern.
MVVM — Model-View-ViewModel. View binds to ViewModel state.
MV (in SwiftUI) — Model-View. The pattern where SwiftUI’s reactivity makes a ViewModel layer often unnecessary.
Module — A SwiftPM package or Xcode framework that publishes types and imports as a unit.
N
Notarization — Apple’s automated security check for Mac apps distributed outside the App Store; also required for App Store Mac apps.
NavigationStack — Modern SwiftUI navigation API (iOS 16+) replacing NavigationView.
O
Observable — Swift 5.9 macro @Observable marking a class as observable by SwiftUI.
OWASP — Open Worldwide Application Security Project. Authors of the Mobile Top 10 risk list.
P
PassKit — Apple Pay + Wallet pass framework.
Phased Release — App Store feature gradually rolling new versions to a percentage of users over 7 days.
Provisioning Profile — A file tying a developer’s certificate, entitlements, and devices/distribution rules; embedded in signed builds.
Q
QuickLook — Apple’s preview framework for documents, images, etc.
R
RealityKit — Apple’s AR rendering framework, ECS-based.
Result Builder — @resultBuilder attribute; foundation for SwiftUI’s ViewBuilder.
RxSwift — Third-party reactive framework (predecessor to Combine for many).
S
Sandbox — iOS / macOS isolation that restricts what an app can access.
Sendable — Protocol marking a type as safe to share across concurrency domains.
SceneKit — Apple’s older 3D rendering framework (largely superseded by RealityKit).
Secure Enclave — Apple’s hardware-isolated chip for cryptographic key storage.
Sign in with Apple — Apple’s privacy-focused federated identity. Required if you offer third-party social login.
SiriKit — Older framework for Siri integration (largely superseded by AppIntents).
SkyWatch / FitTrack / ShopKit / NoteSync / DevPortfolio / PlanBoard — The six capstone projects in Phase 13.
SPM — Swift Package Manager.
StoreKit 2 — Modern Swift-first API for In-App Purchases and subscriptions.
SwiftData — Apple’s Swift-native persistence framework (iOS 17+); built on Core Data.
Swift Testing — The new testing framework introduced in the Swift 6 era (@Test, #expect).
SwiftUI — Apple’s declarative UI framework.
T
TCA (The Composable Architecture) — Point-Free’s redux-style Swift framework.
TestFlight — Apple’s beta distribution platform.
TLS — Transport Layer Security. The protocol underlying HTTPS.
Transferable — Swift protocol for drag-and-drop / share content.
U
UIKit — Apple’s older imperative UI framework for iOS.
Universal Binary — A single Mac binary containing both Apple Silicon (arm64) and Intel (x86_64) code; metaphorically also used for iOS + iPadOS + Mac apps from one codebase.
Universal Link — A URL that opens the app if installed, or a web fallback if not. Configured via apple-app-site-association.
V
Vision — Apple’s framework for computer vision (text recognition, face detection, etc.).
VoiceOver — Apple’s screen reader.
visionOS — Apple Vision Pro’s OS.
W
WatchConnectivity — Framework for iPhone ↔ Apple Watch communication.
WidgetKit — Apple’s framework for home screen + lock screen widgets.
WKWebView — Modern WebKit-based web view (replaces UIWebView).
X
Xcconfig — Build configuration file (key-value, .xcconfig extension).
Xcode — Apple’s IDE.
XCTest — Apple’s older testing framework (still widely used).
Missing a term? File an issue or PR.
Career Launch Playbook
The 60-day plan from “I finished the book” to “I signed an offer.” Concrete weekly milestones, not vibes.
Premise
You’ve worked through 13 phases. You have at least 1 capstone shipped (ideally 2-3) and a defensible understanding of the iOS/macOS stack. Now you need to convert that capability into a job. This playbook assumes you’re starting from cold — no current iOS role, no insider referrals, no existing portfolio site. Adjust days if you’re further along.
Week 1 — Foundation
Goal: portfolio assets exist in shareable form.
- Day 1: Polish your strongest capstone’s README. Top of file: 30-sec pitch, GIF, App Store link if live, GitHub link. Make it scannable in 60 seconds.
- Day 2: Write GitHub profile README. Pin the 3 best capstones. One-line description of each.
- Day 3: Update LinkedIn headline. Format: “iOS Engineer — Swift, SwiftUI, SwiftData, CloudKit.” Skills section: add every framework you can defend in interview.
-
Day 4: Buy a domain (
yourname.dev). One-page site linking to GitHub + LinkedIn + your shipped apps. Don’t over-design; clarity wins. - Day 5: Record a 60-second demo video of your strongest capstone. Unlisted YouTube. Link from README.
- Day 6: Write 1 blog post (Medium / dev.to / personal site) about a non-obvious thing you learned during the book. “What I learned building a SwiftData + CloudKit app from scratch.” 800-1500 words.
- Day 7: Rest. Review week.
Week 2 — Interview prep base
Goal: you can answer any question in the interview cheat sheet out loud, fluently.
- Days 8-10: Re-read Phase 12 — Architecture & Interview Prep end-to-end.
- Day 11: Drill the 50 cheat-sheet questions. For each, speak the answer out loud (not just think). Record yourself; re-listen.
-
Day 12: Rehearse the 30-sec pitch and 3-min deep dive of each capstone (see each capstone’s
interview-talking-points.md). - Day 13: Find a friend / mentor / paid coach for a mock technical interview. Schedule it for week 3.
- Day 14: Rest.
Week 3 — Applications start
Goal: 30 applications sent, 1 mock interview done.
- Day 15: Compile job-board list — LinkedIn, AngelList, Hacker News “Who is Hiring,” YC Work at a Startup, iOS-specific boards.
- Day 16: Build a target list of 50 companies. Mix: 20 startups, 20 mid-size, 10 FAANG-tier. Note which use iOS.
- Day 17: Write a base cover letter. Customize per application later.
- Day 18-20: Apply to 30 of them. Take 30-45 minutes per application — research the company, customize the cover letter intro paragraph, link directly to the most relevant capstone for their domain.
- Day 20: Mock interview. Record. Review the recording. Note 3 things to improve.
- Day 21: Rest.
Week 4 — Outreach + networking
Goal: 10 warm conversations initiated; 50 applications total sent.
- Day 22: List every iOS engineer you know (former colleagues, classmates, online acquaintances). Message 5 to catch up and mention you’re job hunting. Don’t ask for a job; ask if they know what their company’s hiring for.
- Day 23: LinkedIn search for “iOS engineering manager” at your top 10 target companies. Send 5 connection requests with a short note (“Hi X, I’m working through interviews for iOS roles and your team’s work on Y caught my eye. Would you be open to a 15-min chat?”).
- Day 24-26: Apply to 20 more roles. Total now 50.
- Day 27: Send a thoughtful comment on 3 iOS-engineer blog posts you’ve read. Quality engagement, not spam.
- Day 28: Rest. By now first responses should be coming in.
Week 5 — Phone screens
Goal: convert applications into phone screens; ace them.
- Continue applying — aim 10 more per week (60+ total). Stale leads die quickly.
-
When a phone screen is scheduled:
- Research the interviewer on LinkedIn
- Read 3 of the company’s blog posts
- Prepare 3 questions to ask them
- Rehearse your 30-sec pitch and your strongest capstone’s 3-min deep dive
-
Phone screen day-of:
- Be on time
- Use a headset (audio quality > visual setup)
- Smile when you talk
- Have your capstone open in front of you for reference
- After every phone screen: write down what was asked, what went well, what to improve
Week 6 — Technical rounds
Goal: pass technical rounds; reach onsites.
-
Before each technical round:
- LeetCode warm-up: 1 easy Swift problem the morning of
- Re-read the most relevant phase (e.g., concurrency-heavy company → re-read Phase 5)
- Set up a quiet, well-lit space with your laptop
-
During:
- Think out loud; pause before complex sub-problems
- Verify understanding before coding (“just to confirm, the goal is X?”)
- Test your code mentally before claiming done
- If stuck, narrate the stuck-ness — interviewers often help
- After: write down every question; identify weak topics; drill them in the evening
Week 7 — Onsites + offers
Goal: convert onsites into offers.
-
Onsite prep:
- Sleep 8 hours night before
- Re-rehearse your top 2 capstone pitches the morning of
- Bring water; have snacks between rounds
- System design — your Lab 12.4 — System design whiteboard prep is the right framework
- Behavioral rounds: STAR (Situation, Task, Action, Result) for every answer; 3 prepared “tell me about a time” stories
- After every onsite: send a thank-you email to each interviewer within 24h
- If you get an offer: don’t accept on the spot. “Thank you so much. I’d like to take a few days to review. Can I get back to you by [date]?”
Week 8 — Negotiation + decision
Goal: optimize the offer; sign.
- Re-read Phase 12 — Salary negotiation & offer evaluation.
- If you have multiple offers: tell each you have other offers. Don’t disclose numbers; let them compete.
- Negotiation script: “I’m excited about the role. The base is below market for someone with my profile. Is there flexibility to bring it to $X?”
- Negotiate at minimum: base, signing bonus, equity / RSU count, start date, vacation.
- Once signed: announce on LinkedIn (great for future referral leverage). Thank everyone who helped (referrals, mentors, mocks).
Throughout: daily routines
- Morning (30 min): read iOS news (Swift Forums highlights, NSHipster, Hacker News iOS-tagged stories). Stay current.
- Lunch (15 min): 1 LeetCode easy in Swift. Keep the syntax fresh.
- Evening (30 min): review applications, respond to messages, schedule interviews.
- Weekly: 1 mock interview, 1 blog post, 1 small capstone improvement.
Red flags to avoid
- Don’t apply to 200 jobs with the same cover letter. Quality > quantity. 50 thoughtful > 200 spammy.
- Don’t lie or exaggerate experience. The first technical round will catch it.
- Don’t argue with rejections. Thank them and move on. Sometimes you get a 6-month “let’s revisit” — be the candidate they remember positively.
- Don’t accept the first offer just because it’s an offer. A bad fit costs you a year.
- Don’t undersell. New-grad iOS engineers in major US markets in 2024 hit $130-180k base. Senior iOS $180-280k. Adjust for region.
When you’re 60 days in and don’t have an offer
- Get a paid technical coach for a single 90-min session. Outside perspective on what’s blocking you.
- Lower your target seniority by one level temporarily — sometimes the bar is genuinely off.
- Build one more capstone — concrete shipped work moves more interviews than a polished resume.
- Take a contract or freelance role — proves recent paid experience.
Final note
Job hunting is a numbers game with quality multipliers. The book gave you the quality. The playbook gives you the numbers. Run both.
Good luck. When you sign, email me.