SwiftUI Tutorial for Beginners (2026)

SwiftUI tutorial for complete beginners: learn to build real iOS apps across twelve hands-on stages, from your first view all the way to shipping on the App Store.
Written by

Chris C

Updated on

Apr 20 2026

Table of contents
Full Curriculum

Each stage builds on the previous one. Work through them in order and you’ll go from zero to shipping a real SwiftUI app.

Learn SwiftUI Stage 1: Your First SwiftUI View

You’ve been writing Swift logic — variables, functions, loops, structs. Now something exciting happens: you’re about to make something you can actually see.

This stage runs across 6 lessons and each one takes roughly 20 to 35 minutes. You’ll need Xcode installed and the canvas preview open as you work through the code examples. If you don’t see the canvas, go to Editor > Canvas in the menu bar, or press Option + Command + Return. If the preview is paused, click Resume or press Option + Command + P to bring it back to life.

Canvas not showing? In Xcode, go to Editor > Canvas (or press Option + Command + Return). If the preview appears paused, press Option + Command + P to resume it. You’ll use this constantly throughout the stage, so make it a reflex.

Don’t skip the challenge at the end of each lesson. They’re short and they’re how you convert watching into doing. By the time you finish Stage 1, you’ll be able to explain how declarative UI works, place Text, Image, and Button views on screen, style them with modifiers, arrange them using VStack, HStack, and ZStack, and build a complete, polished profile card layout entirely from scratch.

01
Stage 1
Your First SwiftUI View
6 lessons · ~2.5 hrs
1.1
What SwiftUI Is and How It Thinks
⏱ 20 min SwiftUI Basics

Before you write a single line of SwiftUI code, there’s a mental shift you need to make. It’s not a big one, but skipping it causes a lot of confusion later. This lesson is about that shift — and it’s entirely conceptual. No code yet.

You’ve been writing Swift in a style called imperative programming. You tell the computer exactly what to do, step by step. “Create a variable. Loop through this array. Call this function.” The computer follows your instructions in sequence. That’s the approach you’ve been using and it works great for logic, data, and algorithms.

SwiftUI uses a completely different approach called declarative programming. Instead of telling the computer how to build your interface step by step, you describe what you want it to look like — and SwiftUI figures out how to make it happen. By the end of this lesson, you’ll have a clear mental model of what that actually means, and why it changes everything about the way you build screens.

The Restaurant Analogy

Here’s the clearest way I know to explain the difference. Imagine you’re hungry.

Imperative approach: You walk into the kitchen, find a pan, turn on the stove, add oil, crack two eggs, wait until the edges set, flip once, plate it, add salt. You are doing every step yourself, in order.

Declarative approach: You sit down at a restaurant and say “I’d like two eggs over easy, please.” You describe the result you want. The kitchen figures out how to make it happen.

SwiftUI is the restaurant. You describe your interface — “I want a blue button, centered on screen, with the text ‘Get Started'” — and SwiftUI handles everything needed to actually draw it. You don’t manage pixels, layout passes, or drawing calls. You just describe the outcome.

The key shift: In UIKit (the older iOS framework), you told the app how to build the UI — create a button, position it at these coordinates, add it as a subview. In SwiftUI, you tell the app what the UI should look like and it handles the how. This feels strange at first, but it becomes incredibly natural.

What “You Describe, SwiftUI Renders” Actually Means

In SwiftUI, your job is to write code that describes the current state of your interface. SwiftUI watches that description and keeps the screen in sync with it automatically.

Think about a light switch. Imperatively, you’d say: “When someone taps the switch, reach into the screen, find the light bulb image, change its color from gray to yellow, then update the label underneath.” Declaratively, you’d say: “When isOn is true, show a yellow bulb. When it’s false, show a gray bulb.” SwiftUI watches isOn and updates the screen whenever it changes. You describe the two possible states — SwiftUI handles the transitions.

This is one of the biggest reasons SwiftUI code tends to be shorter and easier to read than the older UIKit approach. You’re not writing instructions for every possible change. You’re writing a description of how things should look given any possible state.

A mental model to keep: Think of your SwiftUI code as a function that takes the current state of your app and returns a picture of what the screen should look like. SwiftUI calls that function whenever the state changes, and updates the display to match. You describe. It renders.

Why This Matters for You Right Now

If you try to approach SwiftUI the same way you’d write imperative code, you’ll fight it constantly. You’ll want to “reach in and change” things manually, and you’ll be confused when that doesn’t work the way you expect. The moment you stop thinking about commands and start thinking about descriptions, SwiftUI clicks.

Don’t worry if this still feels a bit abstract. It will become concrete the moment you start writing actual views in Lesson 1.2. This lesson is just making sure the mental model is in place before the code arrives.

Common misconception: “Declarative” doesn’t mean SwiftUI magically knows what you want from a vague description. You still have to be precise. The difference is that your precision describes the result, not the steps to get there.

One More Thing: Views Are Structs

In SwiftUI, everything you see on screen is a view. A piece of text is a view. A button is a view. An image is a view. Even the invisible containers that arrange other views are views.

Here’s the Swift connection: every SwiftUI view is a struct that conforms to the View protocol. You already know what structs are. A SwiftUI view is just a struct with one required property — body — that returns a description of what should be on screen.

New to Swift? SwiftUI is built on Swift, so a solid foundation helps. If you want to build that foundation first, check out the Learn Swift series — then come back here.

When you open a new SwiftUI project in Xcode, you’ll see a struct called ContentView with a body property already in place. That’s the pattern. Every view you ever build will follow it.

Using AI to Test Your Mental Model

AI tools are great for testing whether you actually understand a concept — not just whether you’ve read about it. Try these prompts to pressure-test what you just learned before moving on to Lesson 1.2.

Test Your Mental Model Use AI to check your understanding — no code yet
I just learned the difference between imperative and declarative UI programming. Can you quiz me on the concept? Ask me questions one at a time and tell me when I’m wrong or when I could explain something more clearly.
Can you give me three more real-world analogies for the difference between imperative and declarative programming — different from the restaurant one? I want to see if the concept clicks in multiple contexts before I write any code.
🎯
Challenge 1.1
Explain It Without Code

Before moving to Lesson 1.2, write a two or three sentence explanation of the difference between declarative and imperative UI — in your own words, as if you’re explaining it to a friend who has never coded. Don’t use the restaurant analogy. Come up with your own. If you can do this, the mental model is in place.

Hint: Think about something in your own life where you give instructions vs. where you describe an outcome and let someone else handle the process.
1.2
Your First View: Text, Image, Button
⏱ 30 min SwiftUI Basics

This is the lesson where things get real. You’re going to place your first views on screen and see them appear in the canvas preview. That moment — when you type a line of code and a button or a piece of text appears instantly on the right side of your screen — is one of those small milestones worth appreciating. It never gets old.

In SwiftUI, every visual element is a view. Text is a view. Images are views. Buttons are views. They’re all structs that conform to the View protocol, and they all live inside the body property of your ContentView. You already know what structs and protocols are — here you’re seeing them used in a very practical, visual context.

By the end of this lesson, you’ll know how to place Text, Image, and Button views in your interface, and you’ll understand the basic structure that every SwiftUI view file shares.

The ContentView Template

When you create a new SwiftUI project in Xcode, it generates a file called ContentView.swift that looks like this:

// Import the SwiftUI framework — this gives you access to all SwiftUI views and tools
import SwiftUI

// Define a struct called ContentView that conforms to the View protocol
struct ContentView: View {

    // body is a computed property that returns what this view looks like
    var body: some View {

        // This is where you place your views — SwiftUI renders whatever you put here
        Text("Hello, world!")
    }
}
Xcode canvas preview showing the default ContentView with 'Hello, world!' text centered on a white background, and the Swift code visible on the left side of the editor
LineWhat it does
import SwiftUI Loads the SwiftUI framework so you can use all of its views, modifiers, and tools. You’ll have this at the top of every SwiftUI file.
struct ContentView: View Defines a new struct called ContentView and says it conforms to the View protocol. Any type that conforms to View must have a body property.
var body: some View The required property that every View must have. It returns “some View” — meaning it returns some kind of view, but SwiftUI handles the specifics. You describe what goes here.
Text("Hello, world!") Creates a Text view displaying the string you pass in. This is the simplest possible view — one line, and it shows up on screen immediately.
What is some View? The keyword some means “an opaque type.” You’re telling Swift “this returns some concrete type that conforms to View, but I’m not going to specify exactly which one.” It lets you return complex combinations of views without worrying about naming every type involved. For now, just treat some View as the return type for every view’s body.

The Three Foundation Views

Text Display a string of text on screen
// Text displays any string you pass to it
Text("Welcome to SwiftUI")
Canvas preview showing the text 'Welcome to SwiftUI' in the default system font, centered on a white background
Text is the most basic building block in SwiftUI. Pass any String and it renders it on screen in the system font. You’ll chain modifiers onto it to control font size, color, weight, and more — that comes in Lesson 1.3.
Image Display an icon from SF Symbols or an image from your assets
// Use systemName: to load an icon from SF Symbols (Apple's free icon library)
Image(systemName: "star.fill")

// Or load an image you've added to your Assets.xcassets folder
Image("profile-photo")
Canvas preview showing a filled star icon (SF Symbol) rendered in black on a white background
Image has two common forms. systemName: loads icons from SF Symbols — Apple’s built-in library of thousands of free icons that you can use in any app. The other form loads images you’ve added to your project’s asset catalog. You’ll use SF Symbols constantly throughout your SwiftUI career.
Button A tappable element that runs code when pressed
// Button takes two arguments: an action closure and a label closure
Button(action: {
    // This code runs when the user taps the button
    print("Button tapped!")
}) {
    // This is what the button looks like
    Text("Get Started")
}
Canvas preview showing a blue 'Get Started' text button centered on a white background, styled in the default SwiftUI button appearance
Button takes two things: an action (what happens when it’s tapped) and a label (what it looks like). The action is a closure — a block of code that runs when the user taps. The label can be any view, not just text. You’ll see Image icons, combinations of text and icons, and more as you go further.
Shorter Button syntax: You’ll often see Button written with trailing closure syntax: Button("Get Started") { print("tapped") }. This is shorthand for the same thing — the label is inferred from the string, and the trailing closure is the action. Both forms work the same way.

Quick Reference

SyntaxWhat It Does
Text(“Hello”)Displays a string on screen in the default system font
Image(systemName: “star.fill”)Displays an SF Symbol icon by name
Image(“photo-name”)Displays an image from your asset catalog
Button(action: { }) { }Creates a tappable button with an action and a label view
Button(“Label”) { }Shorthand button with a string label and trailing action closure
var body: some ViewRequired property in every SwiftUI view — returns what to display

Using AI to Go Further

Deepen Your Understanding Use AI as a tutor — no code generation yet
I’m learning SwiftUI and just encountered the return type “some View”. Can you explain what “some” means in Swift and why SwiftUI uses it for the body property? Give me a plain-English explanation before showing any code.
In SwiftUI, Button takes an action closure and a label closure. Can you explain what a closure is in Swift — using a real-world analogy — and then show me how the Button’s two closures connect to that explanation?
Build a Practice View Get a commented example you can run in your canvas preview
Write a SwiftUI ContentView that uses Text, Image (with systemName), and Button. Add a comment on every single line explaining what it does and why — write the comments for someone who is seeing SwiftUI for the first time. I want to paste this into Xcode and read through it.
🎯
Challenge 1.2
Build a Simple Profile Preview

In your ContentView, place three things on screen: a person icon using Image(systemName: "person.circle.fill"), a Text view with your name (or any name), and a Button with the label “Follow”. They’ll stack on top of each other for now — that’s fine, layout comes in Lesson 1.4. The goal is just to have all three visible in the canvas preview at the same time.

Hint: SwiftUI only allows one view directly inside body. If you try to put three views there without a container, you’ll get an error. Try wrapping them in a VStack { } — we’ll cover that properly in Lesson 1.4.
1.3
Modifiers — Styling Your Views
⏱ 30 min SwiftUI Basics

Right now, your views exist — but they look pretty plain. The text is a default size, the button is unstyled, and the image is tiny. That’s where modifiers come in. Modifiers are how you style and configure views in SwiftUI, and they’re one of the most powerful ideas in the whole framework.

If you’ve used CSS for web development, modifiers will feel familiar. If you haven’t, here’s the analogy: think of a modifier like an adjective. A plain noun is “a button.” Add modifiers and it becomes “a large, bold, blue, rounded button with padding.” Each modifier adds one quality to the view, and you can chain as many as you need.

By the end of this lesson, you’ll know how to chain modifiers confidently, understand why the order of modifiers matters, and use the most common ones: .font(), .foregroundStyle(), .padding(), .background(), .cornerRadius(), and .frame().

How Modifiers Work

A modifier is a method you call on a view by typing a dot after it. It returns a new, modified view. Chain multiple modifiers by adding another dot. Here’s a simple example:

// Start with a Text view
Text("Hello, SwiftUI!")
    // Make the font large and bold
    .font(.largeTitle)
    .fontWeight(.bold)
    // Change the text color to blue
    .foregroundStyle(.blue)
    // Add breathing room around the text
    .padding()
Canvas preview showing the text 'Hello, SwiftUI!' in a large bold blue font with visible padding around it, centered on a white background
LineWhat it does
Text("Hello, SwiftUI!") Creates the base text view with default styling.
.font(.largeTitle) Sets the font to Apple’s built-in “largeTitle” style — a large, prominent size used for main headings.
.fontWeight(.bold) Makes the font bold. You can also use .semibold, .medium, .light, and others.
.foregroundStyle(.blue) Sets the text color to blue. This is the modern SwiftUI way to set color (replaces the older .foregroundColor).
.padding() Adds equal spacing on all four sides of the view using the system default padding amount.
Order matters: Modifiers are applied in sequence, top to bottom. A .padding() applied before .background(.yellow) gives you a yellow background that includes the padding. Reverse the order and the yellow background only covers the text — the padding sits outside it. This trips up a lot of beginners. When something looks wrong, try reordering your modifiers.

The Essential Modifiers

.font() Set the text size and style using built-in semantic sizes
// Built-in font styles scale automatically with the user's accessibility settings
Text("Large Title").font(.largeTitle)
Text("Title").font(.title)
Text("Headline").font(.headline)
Text("Body").font(.body)
Text("Caption").font(.caption)
Canvas preview showing five Text views stacked vertically with decreasing font sizes from largeTitle at the top to caption at the bottom
Use semantic sizes like .largeTitle, .title, .headline, .body, and .caption instead of hardcoded point sizes. These respect the user’s Dynamic Type settings so your app stays accessible.
.foregroundStyle() Set the color of text or icons
// Set text color using built-in Color values
Text("Ocean").foregroundStyle(.blue)
Text("Fire").foregroundStyle(.orange)
Text("Muted").foregroundStyle(.secondary)
.foregroundStyle() is the modern way to set text color in SwiftUI. .secondary is especially useful — it gives you an automatically appropriate muted color that adapts to light and dark mode.
.padding() Add spacing around a view
// Default padding on all sides
Text("All sides").padding()

// Specific amount on all sides
Text("Custom amount").padding(20)

// Padding on specific edges only
Text("Top only").padding(.top, 16)
.padding() with no arguments adds a system-default amount on all sides — usually 16 points. Pass a number to specify the exact amount. Pass an edge like .top, .bottom, .leading, or .trailing to target specific sides.
.background() Set a color or view behind the view
// Yellow background fills only the text area
Text("Highlighted")
    .background(.yellow)

// Adding padding BEFORE background extends the colored area
Text("With padding")
    .padding()
    .background(.yellow)
This is where modifier order really shows its effect. .background() fills the area of whatever view it’s applied to — which changes depending on what modifiers came before it. Padding before background = larger background. Background before padding = tighter background.
.cornerRadius() Round the corners of a view
// A rounded button appearance using padding, background, and cornerRadius together
Text("Get Started")
    .padding()
    .background(.blue)
    .foregroundStyle(.white)
    .cornerRadius(12)
Canvas preview showing a blue rounded rectangle button with white 'Get Started' text, styled with padding, blue background, and corner radius of 12
Combine .padding(), .background(), and .cornerRadius() and you have a professional-looking button without any custom drawing. A radius of 8 to 16 is common for buttons; higher values create more pill-like shapes.
.frame() Set explicit width and height for a view
// Fixed size
Color.blue
    .frame(width: 100, height: 100)

// Full width, fixed height
Color.green
    .frame(maxWidth: .infinity, height: 50)
.frame() sets explicit dimensions. Use width: and height: for fixed sizes. Use maxWidth: .infinity to make a view expand to fill all available horizontal space — this is very common for full-width buttons and backgrounds.

Quick Reference

ModifierWhat It Does
.font(.largeTitle)Sets text to a semantic font size that respects Dynamic Type
.fontWeight(.bold)Sets the weight of the text — bold, semibold, medium, light, etc.
.foregroundStyle(.blue)Sets the text or icon color
.padding()Adds default spacing on all sides
.padding(.top, 16)Adds 16pt padding on a specific edge
.background(.yellow)Sets a background color behind the view
.cornerRadius(12)Rounds the corners by the given number of points
.frame(width: 100, height: 100)Sets a fixed size for the view
.frame(maxWidth: .infinity)Expands the view to fill all available width

Using AI to Go Further

Deepen Your Understanding Use AI as a tutor — focus on understanding, not copying code
I’m learning about SwiftUI modifiers and I understand they apply in order. Can you give me 3 examples where the order of two specific modifiers changes the visual result? Describe what the output looks like for each — don’t just show code, explain what I’d actually see.
What is the difference between .foregroundColor() and .foregroundStyle() in SwiftUI? Why did Apple switch to .foregroundStyle? When should I use which one?
Build a Practice View Get a heavily commented example you can run in Xcode
Write a SwiftUI view that uses at least 6 different modifiers to style a Text view and a Button. Add a comment above every modifier explaining what it does and why it’s placed in that position in the chain. The comments should be written for a beginner who is learning about modifier order for the first time.
🎯
Challenge 1.3
Build a Styled Call-to-Action Button

Using only Text and modifiers (no actual Button yet), create something in your canvas that looks like a tappable button. It should have a background color, rounded corners, white text, and visible padding. Aim for something you’d actually want in a real app — not just a blue rectangle with “button” on it. Check your canvas preview to verify the result looks right.

Hint: Try: Text("...").font(.headline).foregroundStyle(.white).padding().background(.blue).cornerRadius(12) as a starting point, then customize it from there.
1.4
Layout Containers: VStack, HStack, ZStack
⏱ 35 min SwiftUI Basics

So far your views have been sitting alone on screen. That works for a single element, but real app interfaces combine dozens of views — some stacked vertically, some side by side, some layered on top of each other. This lesson is about the three containers that make all of that possible.

Think about how you’d describe a physical layout: “the label is above the button,” “the icon is to the left of the title,” “the badge is on top of the image.” SwiftUI has a container for each of those relationships. VStack for vertical, HStack for horizontal, and ZStack for layered. You nest views inside them like items in a box.

By the end of this lesson, you’ll know how each stack works, how to combine them, and how to control alignment and spacing. These three containers are the backbone of virtually every SwiftUI layout you’ll ever build.

How Stacks Work

// VStack arranges its children from top to bottom
VStack {
    Text("First")
    Text("Second")
    Text("Third")
}

// HStack arranges its children side by side, left to right
HStack {
    Image(systemName: "star.fill")
    Text("Favorites")
}

// ZStack layers its children on top of each other, back to front
ZStack {
    // Background layer — renders first, appears furthest back
    Color.blue
    // Foreground layer — renders last, appears on top
    Text("On top")
        .foregroundStyle(.white)
}
Canvas preview showing three stacks side by side: a VStack with three vertically arranged text items, an HStack with a star icon and Favorites text side by side, and a ZStack with white text reading 'On top' centered over a blue background
ContainerHow it arranges children
VStack { } Stacks views vertically — first child at the top, last child at the bottom.
HStack { } Arranges views horizontally — first child on the left, last child on the right.
ZStack { } Layers views on top of each other — first child furthest back, last child on top.

The Three Stack Containers

VStack Arrange views vertically with optional alignment and spacing
// VStack with leading alignment and custom spacing between items
VStack(alignment: .leading, spacing: 12) {
    Text("Chris Ching")
        .font(.title)
        .fontWeight(.bold)
    Text("iOS Developer")
        .font(.subheadline)
        .foregroundStyle(.secondary)
}
Canvas preview showing a VStack with 'Chris Ching' in large bold text and 'iOS Developer' in a smaller muted gray subheadline, both left-aligned with 12 points of spacing between them
alignment: controls how children line up horizontally within the stack — .leading (left), .center (default), or .trailing (right). spacing: controls the gap between each child view in points.
HStack Arrange views side by side with optional alignment and spacing
// HStack with a Spacer to push views to opposite ends
HStack {
    Text("Username")
        .font(.headline)

    // Spacer expands to fill all available space between the two views
    Spacer()

    Image(systemName: "chevron.right")
        .foregroundStyle(.secondary)
}
Canvas preview showing an HStack with 'Username' text on the far left and a right-pointing chevron icon on the far right, separated by a flexible spacer, in a list-row style layout
Spacer() is one of the most useful tools in SwiftUI layouts. It expands to fill all available space in the direction of the stack — which pushes neighboring views apart. An HStack with a Spacer in the middle pushes the left view to the left edge and the right view to the right edge.
ZStack Layer views on top of each other
// ZStack for a card with an image and a text overlay
ZStack(alignment: .bottomLeading) {
    // The background image fills the frame
    Color.blue
        .frame(width: 300, height: 200)
        .cornerRadius(16)

    // Text sits on top, aligned to the bottom left
    Text("Mountain View")
        .font(.title2)
        .fontWeight(.bold)
        .foregroundStyle(.white)
        .padding()
}
Canvas preview showing a blue rounded rectangle card (300x200 points) with bold white 'Mountain View' text overlaid in the bottom left corner, styled with padding
ZStack‘s alignment: parameter controls where layered views appear within the stack’s bounds. Use it to position overlaid text at the top, bottom, corners, or center of the background view beneath it.
Nested Stacks Combine stacks to build complex layouts
// An HStack containing an image and a VStack of text — classic list row pattern
HStack(spacing: 14) {
    // Icon on the left
    Image(systemName: "person.circle.fill")
        .font(.largeTitle)
        .foregroundStyle(.blue)

    // Text details stacked vertically on the right
    VStack(alignment: .leading, spacing: 4) {
        Text("Chris Ching")
            .font(.headline)
        Text("iOS Educator")
            .font(.caption)
            .foregroundStyle(.secondary)
    }
}
Canvas preview showing a horizontal stack with a large blue person circle icon on the left, and on the right a VStack with 'Chris Ching' in headline weight and 'iOS Educator' in a smaller muted caption below it
Nesting stacks is the fundamental technique for building complex layouts. An HStack around a VStack gives you the classic “icon on the left, text details on the right” pattern you see in almost every iOS app’s list rows, profile cards, and settings screens.
Up to 10 children per stack: A SwiftUI stack can contain a maximum of 10 direct children. If you need more, wrap some of them in a Group { }. You’ll rarely hit this limit in practice, but it’s worth knowing before it surprises you.

Quick Reference

SyntaxWhat It Does
VStack { }Stacks views top to bottom, centered by default
VStack(alignment: .leading) { }Stacks views top to bottom, left-aligned
VStack(spacing: 12) { }Stacks views top to bottom with 12pt gaps
HStack { }Arranges views side by side, vertically centered
ZStack { }Layers views on top of each other, centered
ZStack(alignment: .bottomLeading) { }Layers views, aligning to the bottom left
Spacer()Fills available space in the direction of the containing stack

Using AI to Go Further

Deepen Your Understanding Use AI as a tutor — no code generation yet
I’m learning VStack, HStack, and ZStack in SwiftUI. Can you quiz me by describing a UI layout in plain English (like “an icon on the left, with a title and subtitle stacked vertically to the right”) and asking me which stacks I’d use and how I’d nest them? Do one at a time and give me feedback.
What is Spacer() in SwiftUI and how does it behave differently inside a VStack vs an HStack? Can you explain the concept without writing code first, then show a simple example after?
Build a Practice View Get a heavily commented layout example you can run in Xcode
Write a SwiftUI view that uses nested VStack and HStack to build a contact row — the kind you’d see in an iOS contacts app. Include an avatar icon, a name, and a phone number. Add a comment above every line explaining the structure decisions — why each stack was chosen and why things are in the order they are. Write the comments for a beginner.
🎯
Challenge 1.4
Build a Stats Row

Build a horizontal row of three stats — like you’d see on a social media profile. Each stat has a bold number on top and a muted label underneath (e.g. “248” with “Posts” below it, “12K” with “Followers”, “540” with “Following”). The three stats should be evenly spaced across the full width of the screen. Check your canvas — the result should look like something you’d actually see in an app.

Hint: You’ll need an HStack containing three VStacks. Use Spacer() between each VStack to distribute the spacing evenly. Give the number text .font(.title2).fontWeight(.bold) and the label .font(.caption).foregroundStyle(.secondary).
1.5
The Canvas Preview and the Simulator
⏱ 20 min SwiftUI Basics

You’ve been using the canvas preview to check your views as you build them. But there’s a lot more to it than just glancing at the right side of Xcode. This lesson covers how to use both the canvas preview and the simulator effectively — and how to deal with the moments when they decide to stop cooperating.

The canvas preview and the simulator solve the same basic problem — “what does this look like?” — but in different contexts. The preview is fast, lightweight, and perfect for iterating on layout and styling. The simulator is slower to launch but runs real app code and is better for testing actual behavior like taps, navigation, and animations.

By the end of this lesson, you’ll know how to preview your views across multiple device sizes and color schemes, how to run your app in the simulator, and what to do when the preview crashes — which it will, eventually.

Working with the Canvas Preview

// Your ContentView code
struct ContentView: View {
    var body: some View {
        Text("Hello, Preview!")
    }
}

// This macro enables the live canvas preview at the bottom of the file
// It's automatically generated by Xcode — you normally don't need to edit it
#Preview {
    ContentView()
}
Xcode split-screen showing ContentView code on the left and the canvas preview panel on the right, with a white phone frame displaying 'Hello, Preview!' centered on screen. The canvas toolbar with Resume button is visible at the top.
ElementWhat it does
#Preview { } A Swift macro that tells Xcode to render the view inside it in the canvas panel. Added automatically to new SwiftUI files.
Resume button Appears when the preview is paused. Click it (or press Option + Command + P) to update the preview with your latest changes.
Live Preview mode Runs an interactive version of your view — you can tap buttons, scroll lists, and trigger animations without launching the simulator.
Static preview Default mode — renders a snapshot of your view. Faster to build and ideal for layout checks.
When the preview crashes: A red error screen in the canvas usually means there’s a compile error somewhere in your code. Check the error message — it often points directly to the problem. If the preview is stuck or not updating, press Option + Command + P to force a resume. If it still won’t cooperate, try closing and reopening the file.
Multiple Previews Preview the same view across different devices or color schemes at once
// Preview in both light and dark mode simultaneously
#Preview("Light Mode") {
    ContentView()
        .preferredColorScheme(.light)
}

#Preview("Dark Mode") {
    ContentView()
        .preferredColorScheme(.dark)
}
You can have multiple #Preview blocks in the same file. Each one renders as its own preview in the canvas. This is great for checking light and dark mode at the same time, or previewing how your layout looks on different device sizes.
The Simulator Run your full app on a virtual iOS device
// No code changes needed — just press the Run button (▶) in Xcode
// or use the keyboard shortcut: Command + R
// Xcode builds and launches your app in the selected simulator device
Use the device selector dropdown at the top of Xcode to choose which simulated iPhone or iPad to run on. The simulator takes longer to launch than the canvas preview, but it runs your full app — including navigation, state changes, and animations. Use it to test real interactions, not just static layouts.
Previewing Any View Preview a sub-view in isolation without running the full app
// You can preview any view — not just ContentView
struct ProfileCard: View {
    var body: some View {
        Text("Profile content here")
    }
}

// Preview this specific component directly
#Preview {
    ProfileCard()
}
Every view file can have its own #Preview block. This is one of SwiftUI’s best development features — you can iterate on a small component without building and running the entire app. Faster feedback means faster learning.
Build habit early: Get into the habit of keeping the canvas open while you code. The instant visual feedback loop — type a modifier, see the result — is one of SwiftUI’s greatest advantages. Closing the canvas and running the simulator every time slows you down significantly.

Quick Reference

ActionHow to do it
Open the canvasEditor > Canvas, or Option + Command + Return
Resume a paused previewClick Resume or press Option + Command + P
Run in the simulatorClick ▶ or press Command + R
Preview in dark mode.preferredColorScheme(.dark) on the view inside #Preview { }
Preview a specific componentAdd #Preview { YourView() } at the bottom of any SwiftUI file
Switch simulator deviceUse the device dropdown at the top of Xcode

Using AI to Go Further

Deepen Your Understanding Use AI to clarify the Xcode workflow
What are the most common reasons the SwiftUI canvas preview crashes or fails to update in Xcode? Give me a ranked list from most to least common, and for each one explain how to diagnose and fix it.
When should I use the canvas preview vs running the full app in the simulator? Are there specific things I can’t test in the preview that I can only test in the simulator? Give me concrete examples of each.
Build a Practice View Get an example that demonstrates multiple preview configurations
Write a SwiftUI view with three #Preview blocks below it: one in light mode, one in dark mode, and one with a larger text size using the .dynamicTypeSize modifier. Add a comment above each #Preview explaining what it’s testing and why checking all three matters. Write comments for a beginner.
🎯
Challenge 1.5
Preview in Three Configurations

Take any view you built in a previous lesson and add three #Preview blocks below it: one default, one with .preferredColorScheme(.dark), and one with a different device size (set it in the Preview device picker). Open all three previews and check that your layout looks reasonable in all three. If dark mode breaks something, note what and why.

Hint: You can name each preview by passing a string to #Preview("Dark Mode") { ... } — this makes the previews easier to identify in the canvas when multiple are open.
1.6
Building a Profile Card
⏱ 35 min SwiftUI Basics

This is the mini-project that brings everything from Stage 1 together. You’re going to build a polished profile card — a circular avatar, a name, a title, a stats row, and a follow button — entirely from scratch using only what you’ve learned so far.

No new concepts in this lesson. Everything here is Text, Image, VStack, HStack, ZStack, and modifiers. What’s new is putting them together intentionally, thinking about the layout before writing the code, and ending up with something that looks like it belongs in a real app.

Work through each step in order. After each one, check your canvas preview before moving to the next. The goal isn’t to finish fast — it’s to understand what each piece is doing and why.

Step 1: The Avatar

Start with a fresh ContentView and build the avatar first. A circular SF Symbol icon styled to look like a profile picture.

// Step 1: Start with just the avatar
struct ContentView: View {
    var body: some View {

        // Use a person icon scaled up to act as an avatar placeholder
        Image(systemName: "person.circle.fill")
            // Scale the SF Symbol to a large display size
            .font(.system(size: 90))
            // Color it blue to give a profile-picture feel
            .foregroundStyle(.blue)
    }
}
Canvas preview showing a large blue person.circle.fill SF Symbol at 90pt font size, centered on a white background — serving as a profile avatar placeholder

Step 2: Add the Name and Title

Wrap the avatar in a VStack and add the name and tagline beneath it.

var body: some View {

    // VStack holds the avatar and text details vertically, centered
    VStack(spacing: 8) {

        // Avatar from Step 1
        Image(systemName: "person.circle.fill")
            .font(.system(size: 90))
            .foregroundStyle(.blue)

        // Full name in a prominent bold style
        Text("Chris Ching")
            .font(.title2)
            .fontWeight(.bold)

        // Tagline or job title in a smaller, muted style
        Text("iOS Educator & App Developer")
            .font(.subheadline)
            .foregroundStyle(.secondary)
    }
}
Canvas preview showing the profile card after Step 2: a large blue person icon at the top, followed by 'Chris Ching' in bold title2 text, and 'iOS Educator & App Developer' in a smaller muted gray subheadline, all centered vertically

Step 3: Add the Stats Row

Add a horizontal stats row below the name. Three stats — Posts, Followers, Following — evenly distributed using Spacer.

// Add this below the tagline Text, still inside the outer VStack
HStack {
    // Each stat is a VStack with a number and a label
    VStack {
        Text("248").font(.title3).fontWeight(.bold)
        Text("Posts").font(.caption).foregroundStyle(.secondary)
    }

    // Spacer pushes the stats apart to fill the full width
    Spacer()

    VStack {
        Text("12.4K").font(.title3).fontWeight(.bold)
        Text("Followers").font(.caption).foregroundStyle(.secondary)
    }

    Spacer()

    VStack {
        Text("540").font(.title3).fontWeight(.bold)
        Text("Following").font(.caption).foregroundStyle(.secondary)
    }
}
.padding(.horizontal)
Canvas preview showing the profile card after Step 3: avatar, name, and tagline at the top, followed by a horizontal stats row with '248 Posts', '12.4K Followers', and '540 Following' evenly distributed across the width

Step 4: Add the Follow Button and Final Padding

The last piece is a full-width Follow button and some overall padding to give the card breathing room.

// Add this below the HStack stats row, still inside the outer VStack
Button(action: {
    // Action will go here once we learn about state in Stage 2
}) {
    // Button label styled to look like a solid primary button
    Text("Follow")
        .font(.headline)
        .foregroundStyle(.white)
        .frame(maxWidth: .infinity)
        .padding()
        .background(.blue)
        .cornerRadius(12)
}

// Apply overall padding and top spacing to the outer VStack using modifiers
// The modifier goes on the closing brace of the outer VStack
.padding()
.padding(.top, 20)
Canvas preview showing the complete finished profile card: a large blue person avatar icon, 'Chris Ching' in bold title2 text, 'iOS Educator & App Developer' in muted subheadline, a three-stat row (248 Posts, 12.4K Followers, 540 Following), and a full-width blue 'Follow' button with rounded corners — all centered and padded on a white background
You just built a real thing. What you’re looking at in the canvas right now is a production-quality SwiftUI component. The layout pattern — avatar, name, stats, action button — is something you’ll find in Twitter, Instagram, LinkedIn, GitHub, and hundreds of other apps. The same fundamentals you just used to build it are the fundamentals behind all of them.
The button doesn’t do anything yet: That’s intentional. Making views respond to taps and state changes requires @State — which is Stage 2. Right now, the goal was to get comfortable building layouts. The behavior comes next.

The Complete Profile Card

import SwiftUI

struct ContentView: View {
    var body: some View {

        // Outer container — everything stacks vertically with 16pt gaps
        VStack(spacing: 16) {

            // Avatar
            Image(systemName: "person.circle.fill")
                .font(.system(size: 90))
                .foregroundStyle(.blue)

            // Name and tagline grouped with tighter spacing
            VStack(spacing: 4) {
                Text("Chris Ching")
                    .font(.title2)
                    .fontWeight(.bold)
                Text("iOS Educator & App Developer")
                    .font(.subheadline)
                    .foregroundStyle(.secondary)
            }

            // Stats row
            HStack {
                VStack {
                    Text("248").font(.title3).fontWeight(.bold)
                    Text("Posts").font(.caption).foregroundStyle(.secondary)
                }
                Spacer()
                VStack {
                    Text("12.4K").font(.title3).fontWeight(.bold)
                    Text("Followers").font(.caption).foregroundStyle(.secondary)
                }
                Spacer()
                VStack {
                    Text("540").font(.title3).fontWeight(.bold)
                    Text("Following").font(.caption).foregroundStyle(.secondary)
                }
            }
            .padding(.horizontal)

            // Follow button — full width, blue, rounded
            Button(action: {}) {
                Text("Follow")
                    .font(.headline)
                    .foregroundStyle(.white)
                    .frame(maxWidth: .infinity)
                    .padding()
                    .background(.blue)
                    .cornerRadius(12)
            }
        }
        .padding()
        .padding(.top, 20)
    }
}

#Preview {
    ContentView()
}
Canvas preview of the complete final profile card: large blue avatar icon at top, 'Chris Ching' in bold, 'iOS Educator & App Developer' in muted text, a three-stat row (248 Posts / 12.4K Followers / 540 Following), and a full-width blue 'Follow' button with rounded corners, all padded and centered on a white iPhone screen

Using AI to Go Further

Deepen Your Understanding Review what you built and understand the structure
Here is the SwiftUI profile card I just built: [paste your code]. Can you explain the role of each VStack and HStack in the layout without changing anything? I want to make sure I understand why each container is there before I try modifying anything.
Looking at this profile card layout, what would I need to change if I wanted to: (1) add a bio text line below the tagline, (2) make the avatar circular with a border, and (3) add a “Message” button next to the “Follow” button? Don’t write the code — just explain what changes I’d make and which concepts are involved.
Build a Practice View Get a variation of the profile card you can study and compare
Write a SwiftUI profile card in a different layout style — for example, a horizontal layout where the avatar is on the left and the name, tagline, and stats are stacked vertically on the right (like a compact contact row). Add a comment above every line explaining the structure decisions. Use only Text, Image, VStack, HStack, and modifiers — no other concepts yet.
🎯
Challenge 1.6
Customize the Profile Card

Modify the profile card you just built in three ways. First, change the accent color from blue to a different color that still looks professional (try green, indigo, or teal — avoid red, as it implies a warning). Second, add a fourth line below the tagline: a location line using Image(systemName: "location.fill") placed in an HStack next to a Text view. Third, change the Follow button to say “Following” and update its background to gray to represent a toggled state. You’ll make this interactive in Stage 2 — for now, just make it look right statically. Verify all three changes in the canvas before moving on.

Hint: For the location line, wrap the icon and text in an HStack with spacing of 4. Set the icon’s foregroundStyle to match your chosen accent color. For the “Following” button, swap .background(.blue) to .background(.gray.opacity(0.2)) and change .foregroundStyle(.white) to .foregroundStyle(.primary).

Stage 1 Recap: Your First SwiftUI View

Six lessons in, and you’ve made the leap from Swift logic to real, visible UI. Here’s what you covered:

  • Lesson 1.1 — What SwiftUI Is and How It Thinks: The declarative mental model — you describe what the interface should look like and SwiftUI handles the rendering. Every SwiftUI view is a struct with a body property.
  • Lesson 1.2 — Your First View: Text, Image, Button: The three foundation views that every SwiftUI interface is built from, plus the structure of ContentView that every SwiftUI file shares.
  • Lesson 1.3 — Modifiers: How to style and configure views by chaining modifiers, why order matters, and the most important modifiers: font, foregroundStyle, padding, background, cornerRadius, and frame.
  • Lesson 1.4 — VStack, HStack, ZStack: The three layout containers that arrange views vertically, horizontally, and in layers — plus how to nest them and use Spacer to control distribution.
  • Lesson 1.5 — The Canvas Preview and the Simulator: How to use the canvas preview effectively, preview across device sizes and color schemes, run your app in the simulator, and recover when the preview crashes.
  • Lesson 1.6 — Building a Profile Card: A complete mini-project combining everything from Lessons 1.1 to 1.5 — a polished, real-world layout built step by step using Text, Image, VStack, HStack, and modifiers.

If you skipped any challenges along the way, go back and do them. The profile card in particular is worth building twice — once following the steps, and once from scratch on your own without looking at the code. That second attempt is where the real learning happens.

Stage 2 is where your views come alive. You’ll learn about State and Data Flow — how to make views respond to taps, track user input, and update automatically when data changes. Static layouts are just the beginning. Once your views can react to data, you’ll start building things that actually feel like apps.

Learn SwiftUI Stage 2: State and Data Flow

In Stage 1 you built views that looked great but didn’t move — this stage is where they come alive and start responding to the user.

Stage 2 has six lessons and runs about three hours total, though some lessons are shorter conceptual ones and others have more code to work through. You’ll need Xcode open with the canvas preview active so you can see your views update in real time as you work. Make sure you complete the challenge at the end of each lesson before moving on — these aren’t optional extras, they’re the part where the concepts actually stick.

By the end of this stage you’ll understand why plain variables don’t trigger UI updates and why SwiftUI needs state to know when to re-render, how to use @State to store and update values local to a view, how to pass state to child views using @Binding so they can both read and write the same value, how to move state into a separate class using the @Observable macro for more complex data, how to read built-in app-wide values from the environment, and how to apply the single source of truth principle to keep your data clean and your bugs rare.

02
Stage 2
State and Data Flow
6 lessons · ~3 hrs
2.1
Why Views Need State
⏱ 20 min SwiftUI Basics

Imagine a scoreboard at a basketball game. Every time a team scores, someone updates the number on the board. But what if the scoreboard wasn’t connected to anything — what if changing the score behind the scenes had no way to tell the board to refresh? The number on the board would just sit there, frozen, no matter what was really happening. That’s exactly the problem SwiftUI’s state system solves.

In Stage 1 you built views using properties, modifiers, and layout containers. Those views worked great — but everything about them was fixed at the moment the view was created. If you wanted the UI to change in response to something the user did, you’d quickly hit a wall.

By the end of this lesson you’ll understand exactly why a plain variable inside a SwiftUI view doesn’t cause the UI to update when it changes, and you’ll have the mental model for what state actually is and why SwiftUI needs it. There’s no code in this lesson — just the concept that makes everything else in Stage 2 make sense.

What happens when you use a plain variable

Here’s the problem in concrete terms. Suppose you want to show a counter that goes up when the user taps a button. Your first instinct might be to write something like this:

struct CounterView: View {
    // A plain variable — NOT wrapped in @State
    var count = 0

    var body: some View {
        VStack {
            Text("Count: \(count)")
            Button("Tap me") {
                // This line will cause a compile error — structs are immutable
                count += 1
            }
        }
    }
}

This won’t even compile. SwiftUI views are structs, and structs are value types — their properties are immutable by default inside methods. But even if you got around that, there’s a deeper problem: SwiftUI has no way of knowing that count changed. It can’t see you mutate a plain variable. So even if the mutation happened, SwiftUI would never re-render the view to show the new number.

The core insight: SwiftUI doesn’t redraw your view every millisecond hoping something changed. It only redraws when it knows something changed. For it to know, you have to use a property wrapper that tells it what to watch.

What “source of truth” means

You’ll hear the phrase “source of truth” a lot in SwiftUI. It just means: one authoritative place where a piece of data lives. When that data changes, everything that depends on it updates automatically. The view is a reflection of the data — not the other way around.

Think of it like a thermostat and every room in your house. The thermostat holds the actual temperature reading (the source of truth). Every display in every room just shows what the thermostat says. You don’t update each room display individually — you update the thermostat and everything else follows.

In SwiftUI, state is your thermostat. Your views are the room displays. When state changes, SwiftUI figures out which views depend on it and re-renders only those parts. This is the system that makes reactive UIs possible.

The mental model to carry forward

Before diving into the code in the next lesson, here’s the model to keep in mind: a SwiftUI view is a function of its state. Given the same state, you always get the same view. Change the state, and SwiftUI calls that function again and produces a new view. You describe what the UI should look like for any given state, and SwiftUI handles the rest.

Why this matters so much: Every bug involving “the UI isn’t updating” or “old data is showing” in SwiftUI comes down to this concept. Once you truly get the state-drives-view relationship, those bugs become obvious to diagnose. Stick with this stage — it pays off.

Quick Reference

ConceptWhat It Means
Plain var in a viewImmutable in a struct — and even if mutated, SwiftUI won’t know to re-render
StateData that SwiftUI watches — when it changes, affected views automatically re-render
Source of truthOne authoritative location where a piece of data lives and is owned
View as a functionSame state always produces the same view — change state to change the UI
Reactive UIThe UI reacts to data changes automatically, rather than you updating it manually
🎯
Challenge 2.1
Spot the Problem

Before moving on, try this in Xcode. Create a new SwiftUI view called ScoreboardView. Add a var score = 0 property and a Button that tries to do score += 1. Read the compiler error you get. Then try adding the mutating keyword to the button’s closure (you can’t — the closure isn’t a function on the struct). The goal is to see exactly why plain variables don’t work, so the solution in 2.2 makes complete sense.

Hint: The error “cannot assign to property: ‘self’ is immutable” is the one to look for. Read it carefully — it’s telling you exactly what the problem is.
Test Your Mental Model Use AI to check your understanding before writing any code
I’m learning SwiftUI and just read about why views need state. I understand that plain variables in a struct can’t be mutated and that SwiftUI won’t re-render if they could be. Can you quiz me on this concept? Ask me one question at a time, tell me if I’m right or wrong, and correct any gaps in my understanding. Don’t show me code yet — I just want to test my mental model.
Explain the concept of “source of truth” in SwiftUI using a real-world analogy I haven’t heard before. Then explain why it matters — what goes wrong in an app when there are two competing sources of truth for the same data?
2.2
@State — Local View State
⏱ 35 min SwiftUI Basics

Think of @State as a special storage locker that SwiftUI manages for your view. When you put a value in the locker, SwiftUI watches it. The moment something changes that value, SwiftUI automatically redraws every part of the view that uses it. You don’t have to do anything extra — the connection is automatic.

In lesson 2.1 you saw exactly why a plain variable doesn’t work: SwiftUI can’t see the change, and structs are immutable anyway. @State solves both problems at once. It stores the value outside the struct (so mutating it is valid), and it tells SwiftUI to watch for changes (so re-rendering happens automatically).

By the end of this lesson you’ll know how to declare and use @State properties, understand why they’re always marked private, and build interactive views with counters, toggles, and text fields that respond to user input in real time.

New to Swift? SwiftUI is built on Swift, so a solid foundation helps. If you want to build that foundation first, check out the Learn Swift series — then come back here.

Your first @State property

Here’s the counter from lesson 2.1 — now fixed with @State:

import SwiftUI

struct CounterView: View {
    // @State tells SwiftUI to own and watch this value
    @State private var count = 0

    var body: some View {
        VStack(spacing: 20) {
            // SwiftUI reads count here — when count changes, this Text re-renders
            Text("Count: \(count)")
                .font(.largeTitle)

            // The button's action mutates count — this triggers a re-render
            Button("Tap to Increment") {
                count += 1
            }
            .buttonStyle(.borderedProminent)
        }
    }
}
Xcode canvas showing CounterView with a large 'Count: 0' text and a blue 'Tap to Increment' button below it
LineWhat it does
@State private var count = 0 The @State wrapper tells SwiftUI to store this value in its own managed storage and watch it for changes. private means only this view should own or modify it directly.
Text("Count: \(count)") This view reads count. SwiftUI knows this view depends on that state, so whenever count changes, this Text will re-render with the new value.
count += 1 This mutates the state value from inside the button’s action closure. Because @State is involved, SwiftUI detects the change and re-renders the view.
.buttonStyle(.borderedProminent) A modifier that gives the button Apple’s filled blue style. Not required — just makes it look nicer.
Why private? @State properties should always be marked private. State is meant to be owned by one view. If another view needs to read or write it, you’ll use @Binding instead — that’s exactly what lesson 2.3 covers.

@State with different value types

@State Bool Toggle something on and off
@State private var isOn = false

VStack {
    // Toggle reads and writes isOn automatically
    Toggle("Enable notifications", isOn: $isOn)
        .padding()

    // Ternary operator shows different text based on isOn's value
    Text(isOn ? "Notifications: On" : "Notifications: Off")
        .foregroundStyle(isOn ? .green : .red)
}
The $isOn syntax creates a binding to the state — the Toggle needs to both read and write the value, so it needs a binding rather than the value directly. You’ll fully understand bindings in lesson 2.3.
@State String Capture text input from the user
@State private var name = ""

VStack(spacing: 12) {
    // TextField needs a binding because it writes back to name as the user types
    TextField("Enter your name", text: $name)
        .textFieldStyle(.roundedBorder)
        .padding()

    // This Text re-renders every keystroke as name changes
    Text("Hello, \(name.isEmpty ? "stranger" : name)!")
        .font(.title2)
}
Each character the user types updates the name state value, which triggers the Text to re-render. You can see this live in the simulator — the greeting updates with every keystroke.
@State with conditional views Show or hide parts of the UI based on state
@State private var showDetails = false

VStack(spacing: 16) {
    Button(showDetails ? "Hide Details" : "Show Details") {
        // Toggle the Bool — SwiftUI re-renders the conditional block below
        showDetails.toggle()
    }

    // This entire block only renders when showDetails is true
    if showDetails {
        Text("Here are the details you asked for.")
            .padding()
            .background(.blue.opacity(0.1))
            .cornerRadius(8)
    }
}
SwiftUI evaluates the if block as part of the view hierarchy. When showDetails changes, SwiftUI adds or removes that block from the rendered output. This is a very common pattern for disclosure sections, alerts, and expanded content.
@State with animation Animate a state change smoothly
@State private var isExpanded = false

VStack {
    Button("Toggle Size") {
        // withAnimation wraps the state change — SwiftUI animates the diff
        withAnimation(.spring()) {
            isExpanded.toggle()
        }
    }

    // The frame size is driven by state — the animation happens between the two sizes
    RoundedRectangle(cornerRadius: 12)
        .fill(.blue)
        .frame(width: isExpanded ? 200 : 80, height: isExpanded ? 200 : 80)
}
Wrapping a state mutation in withAnimation tells SwiftUI to animate between the old and new rendered states rather than snapping instantly. The animation system figures out what changed — you just tell it how to animate.

Quick Reference

SyntaxWhat It Does
@State private var x = valueDeclares a state property — SwiftUI owns and watches it
x = newValueMutates state from inside the view — triggers re-render
x.toggle()Flips a Bool state value to its opposite
$xCreates a binding to the state — required when passing to a child view or control that needs to write back
withAnimation { x = y }Animates the UI transition caused by the state change
🎯
Challenge 2.2
Build a Like Button

Build a view called LikeButtonView that has two pieces of state: a Bool for whether the post is liked, and an Int for the like count (start it at 42). When the button is tapped, toggle the liked state and either add or subtract 1 from the count. Show a filled red heart (heart.fill) when liked and an outline heart (heart) when not. Display the current count next to the heart. Wrap the state mutation in withAnimation(.spring()) for a satisfying tap feel.

Hint: Use Image(systemName: isLiked ? "heart.fill" : "heart") inside an HStack with a Text for the count. Use .foregroundStyle(.red) when liked.

Using AI to Go Further

Deepen Your Understanding Solidify @State without having AI write your code
I just learned about @State in SwiftUI. I understand that it tells SwiftUI to watch a value and re-render when it changes. Can you quiz me on it? Ask me one question at a time — about when to use it, why it’s marked private, what the $ prefix does, and what happens under the hood. Tell me if I’m right or wrong and correct any gaps.
What are the most common mistakes beginners make with @State in SwiftUI? For each one, explain what it looks like, why it happens, and how to fix it. Don’t write complete apps — just illustrate each mistake with a short snippet and a clear explanation.
Build a Practice View Generate a heavily commented example to study
Write a SwiftUI view that uses @State in three different ways: a counter (Int), a text input (String), and a show/hide toggle (Bool). Add a comment on every single line explaining what it does and why — write the comments as if explaining to a complete beginner who has never seen @State before.
Give me three progressively more complex @State examples in SwiftUI — simple, medium, and complex. For each one, add inline comments throughout explaining the state design choices. Focus on why each property is a separate @State rather than combined.
2.3
@Binding — Passing State to Child Views
⏱ 30 min SwiftUI Basics

Think of a TV remote. The remote doesn’t store what channel the TV is on — the TV stores that. But the remote can change the channel. When you press a button, the remote sends the instruction to the TV, and the TV updates. The remote is a binding: it can read and write a value that lives somewhere else.

In lesson 2.2 you learned that @State is always marked private because the owning view should be the only one to hold it. But what happens when you split your UI into multiple views — a common thing to do to keep code clean — and a child view needs to change a parent’s state? That’s exactly where @Binding comes in.

By the end of this lesson you’ll understand the difference between a state owner and a binding consumer, how the $ prefix passes a binding down to a child view, and why this pattern keeps data flowing in one direction rather than creating confusion about where data lives.

The problem: split views need shared state

import SwiftUI

// The PARENT owns the state — it's the single source of truth
struct ParentView: View {
    @State private var isOn = false

    var body: some View {
        VStack(spacing: 20) {
            // Parent reads the state directly
            Text(isOn ? "The switch is ON" : "The switch is OFF")
                .font(.title2)

            // Pass a BINDING to the child — use $ to create it from the @State
            ToggleRow(isOn: $isOn)
        }
    }
}

// The CHILD receives a binding — it can read and write, but doesn't own the value
struct ToggleRow: View {
    // @Binding, not @State — this view doesn't own the value
    @Binding var isOn: Bool

    var body: some View {
        HStack {
            Text("Enable feature")
            Spacer()
            // Toggle mutates isOn via the binding — the parent's @State is what actually changes
            Toggle("", isOn: $isOn)
                .labelsHidden()
        }
        .padding()
        .background(.gray.opacity(0.1))
        .cornerRadius(10)
    }
}
Xcode canvas showing ParentView with 'The switch is OFF' text at the top and ToggleRow with an 'Enable feature' label and Toggle control below it
LineWhat it does
@State private var isOn = false The parent owns this value. It’s the source of truth. Only this view stores the actual Bool.
ToggleRow(isOn: $isOn) The $ prefix converts the state property into a binding and passes it to the child. The child gets a reference to the parent’s storage, not a copy.
@Binding var isOn: Bool The child declares it receives a binding. No private, no initial value — the value lives in the parent, not here.
$isOn in the child When the child passes the binding further (to a Toggle), it uses $ again — the binding wraps itself into another binding, passing the reference along the chain.
The most common @Binding mistake: Declaring @State in the child when you actually need @Binding. If the child uses @State, it gets its own separate copy of the data — changes in the child won’t be visible in the parent, and you now have two conflicting sources of truth.

@Binding patterns

$binding syntax Pass state as a binding to a child view
// In the parent — $ creates a Binding from a @State property
ChildView(value: $myStateProperty)

// In the child — declare with @Binding, no default value, no private
@Binding var value: String
The $ prefix is the syntax for “give me a binding to this state property.” The child gets a two-way connection: reading value returns the current value, and writing to value updates the parent’s @State.
Binding.constant Preview or test a binding with a fixed value
// Use .constant() in Previews when you need to pass a binding but don't have a parent
struct ToggleRow_Previews: PreviewProvider {
    static var previews: some View {
        // .constant wraps a value in a non-mutating binding — read-only, for previewing
        ToggleRow(isOn: Binding.constant(true))
    }
}
Binding.constant() is used in previews when you need to satisfy a @Binding parameter but you don’t have a parent with @State. It creates a read-only binding that can’t actually be mutated.
Two-level binding chain Pass a binding through multiple levels of views
// Grandparent owns the state
@State private var text = ""

// Grandparent passes a binding to Parent
ParentView(text: $text)

// Parent holds a binding and passes it down to Child
struct ParentView: View {
    @Binding var text: String
    var body: some View {
        // Use $ again to pass the binding to the next child
        ChildView(text: $text)
    }
}
Bindings chain cleanly. Each level passes the binding further using $, and the actual @State at the root is the only place the value is stored. No matter how many views deep you are, a change at any level flows back to the single source of truth.
Binding to built-in controls TextField, Toggle, Stepper, Slider all need bindings
@State private var volume: Double = 0.5
@State private var quantity = 1

VStack(spacing: 16) {
    // Slider reads and writes volume via binding
    Slider(value: $volume, in: 0...1)

    // Stepper reads and writes quantity via binding
    Stepper("Quantity: \(quantity)", value: $quantity, in: 1...10)
}
Built-in SwiftUI controls that need to write back to your data — TextField, Toggle, Slider, Stepper, Picker, DatePicker — all accept bindings for their data parameter. This is why you always use $ when passing data to these controls.

Quick Reference

SyntaxWhat It Does
@Binding var x: TypeDeclares a binding — this view can read and write x, but doesn’t own it
ChildView(x: $myState)Passes a binding from a @State property to a child view
Binding.constant(value)A read-only binding for use in previews or testing
$binding in childPass an existing @Binding further down to the next child
x = newValue in childWriting to a @Binding updates the parent’s @State — triggers re-render at parent level
🎯
Challenge 2.3
Color Picker Panel

Build a ColorSwatch view that has three @State Double properties: red, green, and blue, each starting at 0.5. Display a large RoundedRectangle filled with Color(red: red, green: green, blue: blue). Below it, create a separate ColorSlider view that takes a label String and a @Binding var value: Double, and displays that label next to a Slider. Use three ColorSlider views in the parent, passing $red, $green, and $blue. The rectangle should update its color live as you drag any slider.

Hint: Slider(value: $value, in: 0...1) is the slider you need. The in: 0...1 range constrains it to valid color component values.

Using AI to Go Further

Deepen Your Understanding Clarify @Binding vs @State before it becomes a persistent confusion
I’m learning about @Binding in SwiftUI. I understand it’s like a TV remote — the child can change the channel but the TV owns the actual channel value. Can you quiz me on when to use @Binding vs @State? Give me scenarios and I’ll tell you which one to use. One at a time, please.
What goes wrong in a SwiftUI app when a child view uses @State instead of @Binding for a value that the parent also cares about? Can you walk me through a concrete example of the bug this creates and why it’s hard to notice at first?
Build a Practice View Generate an annotated parent-child binding example to study
Write a SwiftUI example with a parent view that owns @State and passes @Binding to two different child views. Both children should be able to modify the parent’s state and see the result reflected in the other child. Add a comment on every line explaining the data flow. Write comments for a beginner who is still learning why @Binding exists.
Give me three real-world iOS UI scenarios where you’d use @Binding between a parent and child view — not counters or toggles, but realistic app features. For each one, show the parent/child split and explain why the data lives in the parent rather than the child.
2.4
@Observable and @State with Reference Types
⏱ 35 min Intermediate SwiftUI

So far, the state you’ve been managing has been small and simple — a counter, a toggle, a text field. But real apps have real data: a list of tasks, a user’s profile, a shopping cart with dozens of items. Cramming all of that into @State properties inside a view gets messy fast. The solution is to move your data into a separate class and have SwiftUI watch that instead.

In lessons 2.2 and 2.3 you owned state directly inside the view. That’s perfect for local UI state — whether a sheet is open, what the user has typed in a field. But anything that represents your app’s actual data model belongs outside the view, in its own class, with clear responsibilities. This is a big step toward building real apps.

By the end of this lesson you’ll know how to use the @Observable macro (iOS 17+) to create an observable data model, how to use @State to hold an instance of that model inside a view, and how to recognize the older @ObservableObject and @StateObject pattern when you find it in existing code.

New to Swift? This lesson introduces classes — a core Swift concept. If you’re not sure what a class is or how it differs from a struct, check out the Learn Swift series for a solid foundation. Classes vs structs is a topic worth understanding properly.

Lifting state into a model class

import SwiftUI
import Observation

// @Observable is a macro (iOS 17+) that makes this class trackable by SwiftUI
@Observable
class CounterModel {
    // These stored properties are automatically tracked — no property wrappers needed
    var count = 0
    var label = "Counter"

    // A method on the model that mutates its own state
    func increment() {
        count += 1
    }

    func reset() {
        count = 0
    }
}

struct CounterView: View {
    // @State holds the model instance — SwiftUI will re-render when any tracked property changes
    @State private var model = CounterModel()

    var body: some View {
        VStack(spacing: 20) {
            // Reading model.count — SwiftUI re-renders this when count changes
            Text("\(model.label): \(model.count)")
                .font(.largeTitle)

            HStack(spacing: 16) {
                Button("+") { model.increment() }
                    .buttonStyle(.borderedProminent)

                Button("Reset") { model.reset() }
                    .buttonStyle(.bordered)
            }
        }
    }
}
Xcode canvas showing CounterView with 'Counter: 0' in large text and two buttons below: a blue '+' button and a grey 'Reset' button
LineWhat it does
@Observable class CounterModel The @Observable macro instruments the class so SwiftUI can track which properties are read and re-render only views that depend on properties that actually changed.
var count = 0 (inside @Observable) No property wrapper needed here — @Observable handles tracking automatically for all stored properties. This is what makes it cleaner than the older approach.
@State private var model = CounterModel() The view uses @State to hold the model instance. SwiftUI knows this is an observable object and sets up tracking automatically.
model.increment() Calling a method on the model mutates count. SwiftUI detects the change (because of @Observable) and re-renders the parts of the view that read count.
iOS version note: @Observable requires iOS 17+. If you need to support older iOS versions, you’ll use the older pattern: @ObservableObject on the class, @Published on each tracked property, and @StateObject instead of @State in the view. The concept is identical — the syntax is just more verbose.

@Observable patterns

@Observable (modern, iOS 17+) The clean, macro-based approach
@Observable
class UserProfile {
    var name = "Chris"
    var isPremium = false
}

struct ProfileView: View {
    @State private var profile = UserProfile()

    var body: some View {
        Text("Hello, \(profile.name)")
    }
}
With @Observable, every stored property is tracked automatically. SwiftUI only re-renders views that actually read a property that changed — this is more efficient than the older approach.
@ObservableObject (older pattern) You’ll see this in older tutorials and existing codebases
// Older approach — class conforms to ObservableObject protocol
class UserProfile: ObservableObject {
    // @Published marks each property that should trigger re-renders when changed
    @Published var name = "Chris"
    @Published var isPremium = false
}

struct ProfileView: View {
    // @StateObject instead of @State — owns and creates the object
    @StateObject private var profile = UserProfile()

    var body: some View {
        Text("Hello, \(profile.name)")
    }
}
You’ll encounter this pattern frequently in tutorials and projects that target iOS 16 or earlier. The concepts are the same — it’s just more manual. @Published does what @Observable does automatically, and @StateObject is the older equivalent of @State for reference types.
Passing a model to child views Share the same model instance across multiple views
@Observable
class CartModel {
    var items: [String] = []

    func addItem(_ item: String) {
        items.append(item)
    }
}

struct ShopView: View {
    @State private var cart = CartModel()

    var body: some View {
        VStack {
            // Pass the same cart model to both children — they share the same instance
            ProductListView(cart: cart)
            CartSummaryView(cart: cart)
        }
    }
}

struct ProductListView: View {
    // No property wrapper needed in child for @Observable — just a regular property
    var cart: CartModel

    var body: some View {
        Button("Add Item") { cart.addItem("Widget") }
    }
}
Because classes are reference types, both ProductListView and CartSummaryView receive a reference to the same CartModel instance. When one child adds an item, the other child sees the change automatically. No bindings needed — they share one object.

Quick Reference

Syntax / PatternWhat It Does
@Observable class MyModelMakes all stored properties trackable — SwiftUI re-renders views that read properties when they change
@State var model = MyModel()Holds an observable object in a view — SwiftUI creates and owns the instance
@ObservableObject (older)Protocol-based alternative to @Observable — requires @Published on each tracked property
@Published var x (older)Marks a property as triggering re-renders when changed — used with @ObservableObject
@StateObject (older)Older equivalent of @State for reference types conforming to ObservableObject
🎯
Challenge 2.4
To-Do List with a Model

Create a TaskModel class marked @Observable with a var tasks: [String] = [] property and an addTask(_ task: String) method. In a TaskListView, hold the model as @State. Use a TextField with @State private var newTask = "" so the user can type a task name. Add a button that calls model.addTask(newTask) and then clears newTask. Display the tasks in a List. The list should update live every time a task is added.

Hint: List(model.tasks, id: \.self) { task in Text(task) } is the pattern for rendering the array.

Using AI to Go Further

Deepen Your Understanding Understand when to lift state into a model vs keep it in the view
I’m learning about @Observable in SwiftUI and the difference between keeping state in a view with @State and moving it into a class with @Observable. Can you give me a set of scenarios — one at a time — and I’ll tell you whether I’d use @State in the view or @Observable in a separate class? Then tell me if I’m right and explain the reasoning.
What’s the difference between @Observable (iOS 17) and the older @ObservableObject pattern? I want to understand both so I can read existing code that uses @ObservableObject. Explain the comparison without writing a full app — just explain what each piece (@Published, @StateObject, @ObservedObject) corresponds to in the newer system.
Build a Practice View Study a real-world @Observable model example with thorough comments
Write a SwiftUI example using @Observable for a simple shopping cart. The model should have an array of items and a total. The view should let you add and remove items and always show an up-to-date total. Add a comment on every line explaining what it does and why — including why certain logic lives in the model rather than the view.
Show me the same SwiftUI data model written two ways: once with @Observable (iOS 17+) and once with the older @ObservableObject/@Published pattern. Add comments explaining what each piece does and why the newer version is simpler. I want to be able to read older codebases and recognize what maps to what.
2.5
@Environment — Reading App-Wide Values
⏱ 25 min Intermediate SwiftUI

Some information in your app needs to be accessible everywhere, not just passed down through bindings. Think about the system’s current color scheme — light or dark mode. Every view in your entire app might want to know this, and it would be exhausting to pass it as a parameter through every view. The environment is the solution: a shared bag of values that any view in the hierarchy can reach into and read.

In lessons 2.3 and 2.4 you passed data explicitly — either through bindings or by passing a model instance as a property. The environment is different: it flows down the entire view hierarchy automatically, without you having to thread it through every intermediate view. Views can also inject values into the environment, making them available to all of their descendants.

By the end of this lesson you’ll know how to read common built-in environment values like colorScheme, dismiss, and locale, and you’ll understand the environment’s role in the broader SwiftUI data flow picture.

Reading built-in environment values

import SwiftUI

struct AppearanceAwareView: View {
    // @Environment reads a value from the SwiftUI environment — no parent needed
    @Environment(\.colorScheme) var colorScheme

    var body: some View {
        VStack(spacing: 16) {
            // colorScheme is either .light or .dark — the system sets this automatically
            Text(colorScheme == .dark ? "Dark mode is active" : "Light mode is active")
                .font(.title2)

            Image(systemName: colorScheme == .dark ? "moon.fill" : "sun.max.fill")
                .font(.largeTitle)
                .foregroundStyle(colorScheme == .dark ? .white : .orange)
        }
        .padding()
    }
}
Xcode canvas showing AppearanceAwareView with 'Light mode is active' text and a sun icon in orange, alongside the dark mode preview showing 'Dark mode is active' and a white moon icon
LineWhat it does
@Environment(\.colorScheme) var colorScheme Reads the colorScheme value from the SwiftUI environment. The backslash syntax (\.colorScheme) is a key path — it identifies which environment value you want.
colorScheme == .dark Compares the environment value to the .dark case of the ColorScheme enum. When the user switches between light and dark mode, this view will automatically re-render.

Commonly used environment values

\.dismiss Dismiss a sheet or navigation destination from inside it
struct SettingsSheet: View {
    // dismiss is an action — calling it dismisses this view
    @Environment(\.dismiss) var dismiss

    var body: some View {
        Button("Done") {
            // Calling dismiss() closes this sheet — no binding needed
            dismiss()
        }
    }
}
dismiss is an action, not a value — you call it like a function. It tells SwiftUI to dismiss whatever container this view is inside, whether that’s a sheet, fullscreen cover, or navigation destination. Much cleaner than passing a dismiss binding down.
\.locale Read the user’s current locale for formatting decisions
struct LocaleView: View {
    @Environment(\.locale) var locale

    var body: some View {
        // locale.identifier shows something like "en_CA" or "fr_FR"
        Text("Your locale: \(locale.identifier)")
    }
}
Useful when you need to format numbers, dates, or currencies correctly for the user’s region. SwiftUI’s built-in formatters use the locale automatically, but you can read it directly when you need more control.
\.dynamicTypeSize Adapt your layout to the user’s preferred text size
struct AdaptiveView: View {
    @Environment(\.dynamicTypeSize) var typeSize

    var body: some View {
        // Switch layout based on type size — large type users may need a different layout
        if typeSize >= .accessibility1 {
            VStack { /* stacked layout for big text */ }
        } else {
            HStack { /* side-by-side layout for normal text */ }
        }
    }
}
Reading dynamicTypeSize lets you adapt your layout for users with accessibility text size settings enabled. This is an important accessibility consideration for any app you intend to ship.
\.openURL Open URLs from inside any view
struct LinkView: View {
    // openURL is an action — call it with a URL to open Safari
    @Environment(\.openURL) var openURL

    var body: some View {
        Button("Visit CodeWithChris") {
            openURL(URL(string: "https://codewithchris.com")!)
        }
    }
}
Like dismiss, openURL is an action in the environment. Calling it opens the URL in the appropriate system handler — usually Safari for web URLs.

Quick Reference

Environment KeyWhat It Provides
\.colorSchemeCurrent light/dark mode setting (.light or .dark)
\.dismissAn action to dismiss the current sheet or navigation push
\.localeThe user’s current locale (language, region)
\.dynamicTypeSizeThe user’s preferred text size from Accessibility settings
\.openURLAn action to open a URL in the appropriate system app
\.isPresentedA Bool binding indicating whether this view is currently presented
🎯
Challenge 2.5
Theme-Aware Card

Build a ThemeCard view that reads \.colorScheme from the environment. Display a RoundedRectangle with a background color and border that changes depending on whether the scheme is light or dark — use a light gray background with a dark border in light mode, and a dark gray background with a light border in dark mode. Inside the rectangle, show text that names the current mode. In Xcode’s canvas, use the “Color Scheme” variant option (in the editor at the bottom) to preview both light and dark mode simultaneously and verify your card adapts correctly.

Hint: .environment(\.colorScheme, .dark) can be used as a modifier in the Preview to force dark mode if you’re not using the canvas variant option.

Using AI to Go Further

Deepen Your Understanding Understand the environment’s role in SwiftUI data flow
I’m learning about @Environment in SwiftUI. I understand it lets any view in the hierarchy read shared values without having them passed explicitly. Can you quiz me on the difference between @Environment, @Binding, and passing a model instance as a plain property? Give me scenarios and I’ll identify which approach is best. One at a time, please.
What are the most useful built-in @Environment values I should know about as a beginner iOS developer? For each one, give me a concrete example of when I’d actually use it in a real app. Keep the explanations short — I just want to know what exists so I can reach for the right tool.
Build a Practice View Build an environment-aware view with thorough annotation
Write a SwiftUI view that uses at least three different @Environment values: colorScheme, dismiss, and one other of your choice. Present this view as a sheet from a parent. Add a comment on every meaningful line explaining what the environment value is, where it comes from, and why using @Environment is better than passing these values manually. Write for a beginner.
Show me a realistic example of a Settings screen in SwiftUI that uses @Environment to handle dismissal and reads colorScheme to adapt its appearance. Add comments throughout explaining the data flow choices.
2.6
The Single Source of Truth
⏱ 20 min SwiftUI Basics

Imagine two whiteboards in different rooms, both showing the same meeting schedule. If someone updates one but not the other, they get out of sync. Now nobody is sure which one is correct. This is what happens in code when the same piece of data lives in two different places — both are “true” but they disagree, and your app behaves unpredictably as a result.

Everything you’ve learned in this stage — @State, @Binding, @Observable, @Environment — is in service of one principle: each piece of data should have exactly one owner, one place where it lives and is authoritative. Every other view that needs it gets a binding or a reference to that single source, never its own copy.

By the end of this lesson you’ll be able to look at a SwiftUI view hierarchy and identify whether state is owned correctly, recognize the common patterns that break single source of truth, and refactor a broken example into one that works reliably.

What good data ownership looks like

import SwiftUI

// The model is the single source of truth — one place, one owner
@Observable
class ProfileModel {
    var name = "Alex"
    var isVerified = false
}

// Root view owns the model — it flows downward from here
struct RootView: View {
    @State private var profile = ProfileModel()

    var body: some View {
        VStack(spacing: 24) {
            // HeaderView reads from the model — same instance
            HeaderView(profile: profile)

            // EditView also uses the same instance — a change here shows up in HeaderView
            EditView(profile: profile)
        }
    }
}

struct HeaderView: View {
    var profile: ProfileModel

    var body: some View {
        Text("Hello, \(profile.name)")
            .font(.title)
    }
}

struct EditView: View {
    @Bindable var profile: ProfileModel

    var body: some View {
        TextField("Name", text: $profile.name)
            .textFieldStyle(.roundedBorder)
            .padding(.horizontal)
    }
}
Xcode canvas showing RootView with 'Hello, Alex' header text and a text field below containing 'Alex' — editing the text field live updates the header
Design decisionWhy it’s the right choice
Model lives in RootView RootView is the highest view that needs access to the profile. Owning it here means all children can share the same instance.
var profile in HeaderView Because ProfileModel is an @Observable class (a reference type), passing it as a plain property gives HeaderView a reference to the same object — not a copy. It reads properties but never writes them, so no special wrapper is needed.
@Bindable var profile in EditView @Bindable tells SwiftUI that you want to create bindings to properties of this @Observable object. Without it, the $profile.name syntax used by TextField would not compile — Swift doesn’t know how to derive a Binding from a plain property.
$profile.name in EditView Once profile is marked @Bindable, the $ prefix creates a two-way Binding to that specific property. The TextField reads from it and writes back to it — the change propagates to the underlying model, which updates HeaderView automatically.
The pattern that breaks this: If EditView declared its own @State private var localName = profile.name, it would make a copy at init time. Editing it would never update HeaderView. The two views would be showing different data. This is a very common bug — and it always comes back to violating single source of truth.

The data flow summary for Stage 2

@State This view owns the value and is the source of truth
// Use when: local UI state that only this view cares about
@State private var isExpanded = false
The default tool for state. If only this view needs to know a piece of information, keep it as @State. Don’t over-engineer — not every value needs a model class.
@Binding Another view owns this — I can read and write it, but don’t store it
// Use when: a child view needs to mutate a parent's @State
@Binding var isSelected: Bool
When a child view needs to both read and write a value the parent owns, use @Binding. This keeps the source of truth in one place while giving the child view access to update it.
@Observable class The data model — shared by reference across the view hierarchy
// Use when: state is complex, shared between multiple views, or belongs outside the view
@Observable
class MyModel { var data = "" }
Move state to a model when it’s too complex for a view, when multiple unrelated views need the same data, or when you want to keep business logic separate from your UI code.
@Environment App-wide or system values — readable anywhere in the hierarchy
// Use when: system values (colorScheme, locale) or app-wide actions (dismiss)
@Environment(\.colorScheme) var colorScheme
The environment is for values that need to be available everywhere without being passed down the tree manually. Use it for system information and cross-cutting concerns. Don’t use it as a general-purpose substitute for proper data ownership.

Quick Reference

ToolWhen to use it
@StateLocal UI state owned by this view and not needed elsewhere
@BindingChild view needs to read/write a parent’s @State
@Observable class + @StateComplex data, multiple views sharing the same model instance
@EnvironmentSystem-provided values or app-wide actions accessible anywhere
Plain property (var)Read-only data passed from parent — no write-back needed
@BindableChild view needs to create bindings ($property) into an @Observable object it received as a property
🎯
Challenge 2.6
Refactor the Bug

Here’s a broken data flow to fix. You have a ParentView with @State private var score = 0. It creates a ScoreDisplayView and a ScoreControlView. The broken version has ScoreControlView declaring its own @State private var score = 0 and incrementing it — so ScoreDisplayView always shows 0 no matter how many times you tap the button. Refactor ScoreControlView to use @Binding var score: Int instead, update the parent to pass $score, and verify that both views now show the same number. This exercise is the whole stage in one bug fix.

Hint: Start by building the broken version intentionally — run it, confirm the display always shows 0, then fix it. Seeing the bug before the fix makes the lesson land much better.

Using AI to Go Further

Deepen Your Understanding Consolidate Stage 2 into a coherent mental model
I’ve just finished learning about SwiftUI state and data flow — @State, @Binding, @Observable, and @Environment. Can you quiz me on which tool to use in different scenarios? Give me a situation and I’ll name the right property wrapper or pattern. One at a time, please, and tell me if I’m wrong and explain why.
Describe three SwiftUI code examples that violate the single source of truth principle. For each one, explain what bug it would cause at runtime, then show the corrected version. Don’t make the examples trivially simple — use realistic UI scenarios that a beginner might actually write.
Build a Practice View Study a complete well-architected view hierarchy
Write a SwiftUI view hierarchy for a simple order tracking screen — a parent view with an @Observable model, two child views that display and edit the order details, and a third child that uses @Environment to dismiss. Add comments on every meaningful line explaining which data flow tool is used, where the data lives, and why that choice was made over the alternatives.
I want to review my understanding of the whole SwiftUI data flow system: @State, @Binding, @Observable, and @Environment. Can you write a single SwiftUI file that demonstrates all four tools working together in one coherent example? Add inline comments explaining each choice, then challenge me with a question at the end that tests whether I understood the architecture.

Stage 2 Recap: State and Data Flow

You’ve just completed the most important stage in the entire Learn SwiftUI curriculum. The concepts here — state ownership, reactive rendering, single source of truth — are the foundation everything else is built on. Here’s what you covered:

  • Lesson 2.1 — Why Views Need State: Plain variables in SwiftUI structs can’t be mutated and don’t trigger re-renders — you need a state system for the UI to react to change.
  • Lesson 2.2 — @State: @State is the fundamental tool for local view state — SwiftUI owns the storage and re-renders any view that reads a state value whenever it changes.
  • Lesson 2.3 — @Binding: @Binding lets a child view read and write a parent’s @State — the child gets a reference, not a copy, preserving single source of truth across the hierarchy.
  • Lesson 2.4 — @Observable: Complex data belongs in a separate @Observable class — the view holds an instance with @State, and SwiftUI tracks changes to the model’s properties automatically.
  • Lesson 2.5 — @Environment: Built-in environment values like colorScheme, dismiss, and locale are readable anywhere in the view hierarchy without passing them manually — perfect for system-wide and app-wide information.
  • Lesson 2.6 — Single Source of Truth: Every piece of data should have exactly one owner — everything else reads from or binds to that owner, never duplicating the value into a separate @State.

This stage is a milestone. Developers who genuinely understand data flow write apps that are easier to debug, easier to extend, and far less likely to have confusing UI bugs. If any lesson felt shaky, go back and redo the challenge before moving on — it’s worth it.

Stage 3 takes on Layout in Depth — you’ll go deep on stacks, grids, GeometryReader, and building layouts that hold up across screen sizes and orientations.

Learn SwiftUI Stage 3: Layout in Depth

VStack and HStack got your views on screen — now it’s time to make them go exactly where you want.

This stage is all about precision. You’ll work in Xcode with the canvas preview open so you can see the results of every change in real time. There are 7 lessons totalling about 3 hours of content. Each lesson ends with a challenge — build it, check the preview, and don’t move on until it looks right.

By the end of Stage 3 you’ll know how to push views apart with Spacer, constrain and size views with frame() and padding(), read actual screen dimensions with GeometryReader, build grid layouts, respect and override the safe area, and let SwiftUI choose the right layout automatically with ViewThatFits.

03
Stage 3
Layout in Depth
7 lessons · ~3 hrs
3.1
Spacer and Divider
⏱ 20 min SwiftUI Basics

By default, SwiftUI stacks views as tightly as their content allows. That’s fine for simple layouts, but most real apps need breathing room — a title pushed to the top, a button pinned to the bottom, or a line separating two sections. That’s exactly what Spacer and Divider are for.

Spacer is an invisible view that expands to fill all available space in the direction it’s placed. Think of it like a spring: drop one between two views and it pushes them apart as far as the container allows. Divider is a thin horizontal (or vertical) line that visually separates content — the same kind of separator you see in Settings or menus.

Neither Spacer nor Divider display any visible content on their own. Spacer is pure layout; Divider is a single line. Together they give you two of the most commonly needed layout tools in any SwiftUI app.

import SwiftUI

struct ContentView: View {
    var body: some View {
        VStack {
            // Title pinned to the top
            Text("My App")
                .font(.largeTitle)

            // Spacer pushes everything below it to the bottom
            Spacer()

            // Divider draws a horizontal line
            Divider()

            // Button sits at the bottom
            Button("Get Started") { }
                .buttonStyle(.borderedProminent)
        }
        .padding()
    }
}
VStack with 'My App' large title at the top, a large gap of empty space in the middle, a thin horizontal divider line, and a blue 'Get Started' button pinned to the bottom. The entire stack has padding on all sides.
LineWhat it does
VStack { }A vertical container. Spacer inside a VStack expands vertically.
Spacer()Fills all remaining space in the stack direction. With one Spacer, the title goes up and the button goes down.
Divider()Draws a 1pt horizontal line. Inside a VStack it stretches to the full width of the container.
.padding()Adds default padding on all four sides of the VStack so content doesn’t touch the screen edges.
Common mistake: Placing a Spacer inside a ZStack doesn’t push things apart — ZStack layers views on top of each other, so Spacer just fills the whole area silently. Spacer only works as expected inside VStack and HStack.

Spacer and Divider Variations

Multiple Spacers Distribute space evenly between views
VStack {
    // Each Spacer claims an equal share of the empty space
    Spacer()
    Text("Top third")
    Spacer()
    Text("Middle third")
    Spacer()
    Text("Bottom third")
    Spacer()
}
Four Spacers around three Text views distribute all available space equally — the views end up evenly spaced from top to bottom. Useful for onboarding screens or splash layouts.
VStack with four spacers and three text labels. 'Top third' appears near the upper quarter of the screen, 'Middle third' is perfectly centered, and 'Bottom third' is near the lower quarter. Equal empty space surrounds each label.
minLength Set a minimum size for a Spacer
VStack {
    Text("Section Title").font(.headline)
    // This Spacer will always be at least 32 points tall
    Spacer(minLength: 32)
    Text("Body content below")
}
By default, Spacer can shrink to zero if a container is too small. minLength: guarantees a floor. Useful when you want breathing room that never collapses even on smaller devices.
Spacer in HStack Push views to the left or right edge
HStack {
    Text("Left")
    // Spacer inside HStack expands horizontally
    Spacer()
    Text("Right")
}
Inside an HStack, Spacer pushes horizontally instead of vertically. “Left” hugs the leading edge; “Right” hugs the trailing edge. This is the standard pattern for navigation-bar-style rows.
HStack row with 'Left' text aligned to the left edge of the screen and 'Right' text aligned to the right edge. A large horizontal gap fills the space between them.
Divider in HStack Draw a vertical separator between horizontal views
HStack {
    Text("Option A")
    // Divider inside HStack draws a vertical line
    Divider()
    Text("Option B")
}
.fixedSize(horizontal: false, vertical: true)
Inside an HStack, Divider draws vertically. The .fixedSize modifier gives the HStack a defined height so the Divider has something to fill. Without it, Divider may not appear.
View / ModifierWhat It Does
Spacer()Fills all available space in the stack direction
Spacer(minLength: n)Same as above but never smaller than n points
Divider()Draws a horizontal line (vertical when inside HStack)
Spacer in VStackExpands vertically, pushes adjacent views toward edges
Spacer in HStackExpands horizontally, pushes adjacent views to sides
🎯
Challenge 3.1
Profile Row Layout

Build a view that looks like a profile row in a settings screen. From left to right: a circle (use Circle().fill(.blue).frame(width: 44, height: 44)), then a VStack with a name and subtitle in a smaller font, then a Spacer, then the text “Edit” in blue. The whole row should have padding on all sides. Check your canvas — it should look like a typical iOS settings cell.

Hint: Wrap everything in an HStack. Put the circle, then the VStack, then the Spacer, then the “Edit” text — all as direct children of the HStack.

Using AI to Go Further

Deepen Your Understanding Use AI as a tutor — no code generation required
Explain how Spacer works in SwiftUI like I’m a beginner. Why does it behave differently inside a VStack vs an HStack? Give me a real-world analogy before showing any code.
I think I understand Spacer but I’m not sure about the difference between using one Spacer vs two Spacers in a VStack. Can you explain the difference without writing any code, just using plain English?
Build a Practice View Generate a commented example you can learn from
Write a SwiftUI view that uses both Spacer and Divider in a realistic layout — like a simple card or settings row. Add a comment on every line explaining what it does and why. Write the comments for a beginner who is seeing this for the first time.
Give me 3 different examples of Spacer being used in SwiftUI, each one slightly more interesting than the last. Add inline comments throughout so I can follow what’s happening and why.
3.2
frame(), padding(), and offset()
⏱ 30 min SwiftUI Basics

SwiftUI views size themselves to fit their content by default. A Text is exactly as wide as its text. A Button wraps its label. That’s often exactly what you want — but not always. When you need explicit control over how big a view is, where it sits, or how much space surrounds it, frame(), padding(), and offset() are the tools you reach for.

Think of frame() as setting the outer box that a view fits inside. Think of padding() as adding cushioning around a view. Think of offset() as sliding a view away from where it would normally sit — without disrupting the views around it.

These three modifiers cover the vast majority of explicit sizing and positioning work in SwiftUI. You’ll use them in almost every view you build.

import SwiftUI

struct ContentView: View {
    var body: some View {
        VStack(spacing: 20) {

            // Fixed frame: exactly 200 wide, 60 tall
            Text("Fixed Frame")
                .frame(width: 200, height: 60)
                .background(.blue.opacity(0.2))

            // Padding: adds 16pt space on all sides
            Text("With Padding")
                .padding(16)
                .background(.green.opacity(0.2))

            // Offset: slides 30pt right, 10pt down — doesn't affect layout
            Text("Offset Text")
                .offset(x: 30, y: 10)
                .background(.orange.opacity(0.2))
        }
    }
}
VStack with three rows. First row: a blue-tinted rectangle 200 wide and 60 tall containing 'Fixed Frame'. Second row: 'With Padding' text surrounded by 16pt of space on all sides inside a green-tinted background. Third row: 'Offset Text' inside an orange-tinted background that is shifted 30 points to the right and 10 points down from where the VStack would normally place it.
ModifierWhat it does
.frame(width:height:)Sets a fixed size. The view’s content is placed inside this box, aligned center by default.
.padding(16)Adds 16 points of space between the view’s content and anything outside it on all four sides.
.offset(x:y:)Visually shifts the view by x/y points. Other views don’t move — they still think this view is at its original position.
.background()Here used to make the frame visible so you can see exactly what area each modifier affects.
Offset vs position: offset() moves a view visually but doesn’t change where the layout system thinks it is. Other views won’t shift to make room. If you need a view to actually move in the layout, use padding() or frame() instead.

frame(), padding(), and offset() Variations

.frame(maxWidth: .infinity) Stretch a view to fill available width
// Fills the full width of its container
Text("Full Width Button")
    .frame(maxWidth: .infinity)
    .padding()
    .background(.blue)
    .foregroundStyle(.white)
    .cornerRadius(12)
Setting maxWidth: .infinity tells SwiftUI the view wants as much horizontal space as available. This is how you make buttons stretch full-width — a very common pattern for primary CTAs.
A blue rounded rectangle button stretching the full width of the screen with white 'Full Width Button' text centered inside it. The button has padding between the text and its edges, and 12-point corner radius.
.frame(min:max:) Set flexible bounds instead of a fixed size
// At least 100 wide, at most 300 wide, grows to fill what's available
Text("Flexible width text")
    .frame(minWidth: 100, maxWidth: 300)
    .background(.yellow.opacity(0.4))
Flexible frames let a view grow or shrink within bounds. The view will try to be as wide as the available space, but never smaller than 100 or larger than 300. Great for responsive layouts across device sizes.
.frame(alignment:) Control where content sits inside its frame
// Content aligns to the leading (left) edge of the frame
Text("Left aligned")
    .frame(maxWidth: .infinity, alignment: .leading)
    .padding()
When you stretch a view’s frame beyond its content size, the content defaults to center. The alignment: parameter lets you pin the content to any edge. Combined with maxWidth: .infinity, this is the clean way to left-align text in a full-width view.
A text label reading 'Left aligned' positioned at the left edge of the screen. The background extends the full screen width, making it clear the frame is full-width but the text content is pinned to the leading edge.
.padding(.top, 24) Apply padding to specific edges only
VStack(alignment: .leading) {
    // Only adds padding above the heading
    Text("Section Header")
        .font(.headline)
        .padding(.top, 24)
    Text("Body text with no extra top space")
}
Pass an edge set and a value to add padding on specific sides only. Options include .top, .bottom, .leading, .trailing, .horizontal, and .vertical.
ModifierWhat It Does
.frame(width:height:)Fixed size box
.frame(maxWidth: .infinity)Stretch to fill available width
.frame(minWidth:maxWidth:)Flexible bounds — grows between min and max
.frame(alignment:)Controls where content sits inside the frame
.padding()Default padding (16pt) on all edges
.padding(n)Custom padding amount on all edges
.padding(.edge, n)Padding on specific edge(s) only
.offset(x:y:)Slide a view visually without affecting layout
🎯
Challenge 3.2
Card Component

Build a card view: a VStack containing a title and subtitle, both left-aligned using .frame(maxWidth: .infinity, alignment: .leading). The VStack should have 16pt padding on all sides, a white background, 12pt corner radius, and a light shadow. Below the card, add a button that stretches full width. Check your canvas — the card should look like something from a real app.

Hint: Apply .background(.white).cornerRadius(12).shadow(radius: 4) to the VStack itself after the padding modifier.

Using AI to Go Further

Deepen Your Understanding Use AI as a tutor — no code generation required
Explain the difference between .frame(), .padding(), and .offset() in SwiftUI using plain English analogies. I want to understand conceptually what each one does to a view before I memorize the syntax.
I’m confused about why .background() produces different results depending on whether it’s placed before or after .padding(). Can you explain how modifier order affects the layout without writing code for me to copy?
Build a Practice View Generate a commented example you can learn from
Write a SwiftUI view that demonstrates frame(), padding(), and offset() all in one realistic layout. Add a comment above every modifier explaining what it does and what would happen if you removed it. Write the comments for a beginner.
Here’s a SwiftUI view I wrote: [paste your code]. Can you tell me if I’m using frame() and padding() correctly? Are there places where I’m fighting the layout system instead of working with it? Explain before suggesting changes.
3.3
GeometryReader
⏱ 25 min Intermediate SwiftUI

Most of the time, SwiftUI knows how to size and position your views without any help. But occasionally you need actual numbers — the real width of the screen, or the exact height of a container — so you can do math on them. That’s what GeometryReader is for.

GeometryReader is a container view that tells you the size of the space it occupies. You wrap your content inside it, and you get access to a GeometryProxy — an object with a .size property telling you the available width and height in points.

Here’s the important caveat: GeometryReader should be a last resort, not a first instinct. It has a side effect — it expands to fill all available space, which can throw off your layout unexpectedly. In most cases, .frame(maxWidth: .infinity) or a Spacer will do the job without any of the complications GeometryReader introduces.

Use sparingly: GeometryReader is powerful but it has a reputation for causing unexpected layout problems for beginners who reach for it too early. If you find yourself typing GeometryReader, first ask: can I solve this with .frame(maxWidth: .infinity), a Spacer, or a different layout container? If yes, use that instead.
import SwiftUI

struct ContentView: View {
    var body: some View {
        // GeometryReader gives us access to the available size
        GeometryReader { geometry in
            VStack {
                // Use geometry.size.width to make a proportional width
                Rectangle()
                    .fill(.blue)
                    // Always 80% of the available container width
                    .frame(width: geometry.size.width * 0.8, height: 100)

                // Display the actual dimensions so we can see what's happening
                Text("Container is \(Int(geometry.size.width))pt wide")
                    .font(.caption)
            }
        }
    }
}
A blue rectangle taking up 80% of the screen width centered in a VStack. Below it, small caption text reads 'Container is 390pt wide' (or the device's actual screen width). The GeometryReader fills the full available space.
LineWhat it does
GeometryReader { geometry in }Creates a container that fills all available space and provides a GeometryProxy named “geometry” inside the closure.
geometry.size.widthThe actual pixel-independent width of the GeometryReader’s container in points.
geometry.size.width * 0.880% of the available width — this value automatically adapts to any screen size.
geometry.size.heightThe available height — also accessible the same way.

GeometryReader Variations

Proportional Height Size a view relative to screen height
GeometryReader { geometry in
    // Hero image that's always 40% of the screen height
    Rectangle()
        .fill(.indigo)
        .frame(height: geometry.size.height * 0.4)
}
Useful for hero images or banner areas that should maintain a proportional relationship to screen height across different devices — iPhone SE vs iPhone Pro Max, for example.
Scoped GeometryReader Measure a specific parent container, not the whole screen
VStack {
    Text("Header")

    // GeometryReader only measures this VStack's remaining space
    GeometryReader { geometry in
        HStack {
            // Sidebar takes up 30% of the remaining horizontal space
            Color.blue.frame(width: geometry.size.width * 0.3)
            Color.green
        }
    }
}
GeometryReader measures the space offered to it by its parent — not always the whole screen. Nesting it inside other containers lets you measure specific sections of your layout.
geometry.frame(in:) Get a view’s position relative to a coordinate space
GeometryReader { geometry in
    // minY gives the view's distance from the top of the global screen
    let topOffset = geometry.frame(in: .global).minY

    Text("I am \(Int(topOffset))pt from the top")
}
geometry.frame(in: .global) returns the view’s frame in screen coordinates. This is advanced — most beginners don’t need it. The common use case is scroll-linked animations where you need to know how far a view has scrolled.
Property / MethodWhat It Does
geometry.size.widthAvailable width in points
geometry.size.heightAvailable height in points
geometry.frame(in: .global)View’s frame in screen coordinates
geometry.frame(in: .local)View’s frame relative to itself
geometry.safeAreaInsetsThe safe area insets at the time of measurement
Before reaching for GeometryReader: Ask yourself — can I use .frame(maxWidth: .infinity)? Can I use a Spacer? Can I use a flexible frame with min/max? In most cases, one of those simpler tools will do the job without GeometryReader’s layout side effects.
🎯
Challenge 3.3
Proportional Progress Bar

Build a simple progress bar using GeometryReader. The bar background should span the full width of the screen. The filled portion should be 65% of that width, using a blue rectangle. The bar should be 16pt tall with 8pt corner radius. Below it, show the text “65% complete”. Check the canvas — the blue fill should end cleanly at 65% of the screen width.

Hint: Use a ZStack to layer the filled bar on top of the background bar. Set the filled bar’s width to geometry.size.width * 0.65 and align it to the leading edge using .frame(maxWidth: .infinity, alignment: .leading) on the ZStack.

Using AI to Go Further

Deepen Your Understanding Use AI as a tutor — no code generation required
Explain what GeometryReader does in SwiftUI and why it should be used as a last resort rather than a first instinct. What are the specific layout problems it can cause for beginners? Give me concrete examples of situations where I should NOT use it.
I want to understand when GeometryReader is genuinely the right tool versus when there’s a simpler alternative. Can you walk me through 3 scenarios and tell me whether GeometryReader is the right choice for each one and why?
Build a Practice View Generate a commented example you can learn from
Write a SwiftUI view that demonstrates a legitimate use case for GeometryReader — something where you genuinely need the actual dimensions and simpler tools won’t work. Add a comment on every significant line explaining what it does and why. Write the comments for a beginner.
Here’s my SwiftUI code that uses GeometryReader: [paste your code]. Am I using it correctly, or is there a simpler approach that would work better? Explain the tradeoffs before suggesting any changes.
3.4
LazyVStack and LazyHStack
⏱ 20 min SwiftUI Basics

A regular VStack creates every single child view the moment it loads — even if most of those views are offscreen. That’s fine for a small number of views, but if you have 500 rows in a list, creating all 500 at once wastes memory and slows things down. LazyVStack solves this by only creating views as they scroll into view.

The word “lazy” here means the same thing it does in everyday language: it waits until it has to do the work. A LazyVStack only creates a row when it’s about to become visible on screen. When rows scroll off screen, they can be released from memory. This is how apps with hundreds or thousands of rows stay fast.

The API is identical to VStack and HStack — you just change the name. The key rule: LazyVStack must be placed inside a ScrollView to be useful. Without a ScrollView, there’s no scrolling and no reason to be lazy.

import SwiftUI

struct ContentView: View {
    var body: some View {
        // ScrollView provides the scrollable container
        ScrollView {
            // LazyVStack only creates rows as they scroll into view
            LazyVStack(spacing: 12) {
                // ForEach creates a view for each number 1 through 200
                ForEach(1...200, id: \.self) { number in
                    Text("Row \(number)")
                        .frame(maxWidth: .infinity, alignment: .leading)
                        .padding()
                        .background(.white)
                        .cornerRadius(8)
                }
            }
            .padding()
        }
        .background(Color(.systemGroupedBackground))
    }
}
A vertically scrollable list of white card rows on a gray grouped background. Each row shows 'Row 1', 'Row 2', etc. with left-aligned text and padding. Only the rows currently visible on screen are shown — rows 1 through approximately 12 are visible before the list continues off the bottom of the screen.
LineWhat it does
ScrollView { }Makes its content scrollable. Without this, LazyVStack has no space to scroll and creates all views eagerly.
LazyVStack(spacing: 12)A vertical stack that creates child views on demand. The spacing parameter adds 12pt between each row.
ForEach(1...200, id: \.self)Creates 200 iterations. id: \.self tells SwiftUI to use each integer as its own unique identifier.
VStack vs LazyVStack: For small, fixed numbers of views (say, under 20–30), a regular VStack is simpler and just as fast. Only switch to LazyVStack when the number of views is large or dynamic. For most simple layouts, VStack is the right choice.

Lazy Stack Variations

LazyHStack Horizontally scrolling lazy stack
// Horizontal scroll view with lazy loading
ScrollView(.horizontal, showsIndicators: false) {
    // Creates cards as they scroll into horizontal view
    LazyHStack(spacing: 16) {
        ForEach(1...50, id: \.self) { i in
            // Square card for each item
            RoundedRectangle(cornerRadius: 12)
                .fill(.blue.opacity(0.2))
                .frame(width: 120, height: 120)
                .overlay { Text("\(i)") }
        }
    }
    .padding()
}
Use LazyHStack inside a horizontal ScrollView for horizontally scrolling carousels. This is the pattern behind Netflix-style horizontal content rows.
A horizontal scroll row showing blue-tinted square cards numbered 1 through 6, with cards 7 and beyond partially visible or cut off at the right edge. Cards have 16pt spacing between them and rounded corners.
Pinned headers Keep section headers visible while scrolling
ScrollView {
    LazyVStack(pinnedViews: [.sectionHeaders]) {
        Section {
            ForEach(1...20, id: \.self) { i in
                Text("Item \(i)").padding()
            }
        } header: {
            // This header sticks to the top while its section scrolls
            Text("Section A")
                .font(.headline)
                .frame(maxWidth: .infinity, alignment: .leading)
                .padding()
                .background(.white)
        }
    }
}
The pinnedViews: parameter on LazyVStack lets section headers stick to the top as content scrolls past them — the same behavior you see in iOS Contacts or Calendar apps.
View / ModifierWhat It Does
LazyVStackVertical stack that creates views on demand as they scroll into view
LazyHStackSame concept, horizontal direction
ScrollView(.vertical)Vertical scroll container — required wrapper for LazyVStack
ScrollView(.horizontal)Horizontal scroll container — required wrapper for LazyHStack
pinnedViews: [.sectionHeaders]Keeps Section headers pinned to the top edge while scrolling
🎯
Challenge 3.4
Horizontal Carousel

Build a horizontally scrolling card row. Each card should be 160pt wide and 200pt tall, with a rounded corner of 16pt and a gradient background that changes color per item. Use [.blue, .purple, .pink, .orange, .green] as a colors array and cycle through them using colors[i % colors.count]. Show a number centered on each card. The row should scroll without showing scroll indicators. Check the canvas — you should see colorful cards that scroll horizontally.

Hint: Wrap a LazyHStack inside a ScrollView(.horizontal, showsIndicators: false). Add .padding(.horizontal) to the LazyHStack so the first and last cards aren’t flush with the screen edge.

Using AI to Go Further

Deepen Your Understanding Use AI as a tutor — no code generation required
Explain the difference between VStack and LazyVStack in SwiftUI without using the words “lazy loading” — I want to understand it conceptually. When should I choose one over the other, and what’s the real-world cost of choosing the wrong one?
I understand that LazyVStack needs to be inside a ScrollView, but I don’t understand why. Can you explain what would happen if I used LazyVStack without a ScrollView, and why ScrollView is required for the lazy behavior to work?
Build a Practice View Generate a commented example you can learn from
Write a SwiftUI view that uses LazyVStack inside a ScrollView to display a list of 100 items. Make each item look like a real app row — not just plain text. Add a comment above each significant block explaining what it does and why LazyVStack was chosen over VStack here.
Here’s my SwiftUI code with a VStack: [paste your code]. Should I switch this to a LazyVStack? Tell me whether it would make a meaningful difference and explain your reasoning.
3.5
Grid and LazyVGrid
⏱ 30 min SwiftUI Basics

VStack and HStack are great for one-dimensional layouts. But when you need rows and columns at the same time — a photo grid, a product catalog, an icon launcher — you need a grid. SwiftUI has two options: Grid for small, fixed layouts where you need precise alignment across rows and columns, and LazyVGrid for large, scrollable grids with dynamic content.

The key to both is GridItem — a value type that defines one column (for LazyVGrid) or acts as a sizing description. You create an array of GridItems, and that array defines how many columns your grid has and how they’re sized.

There are three column types: fixed (always exactly N points wide), flexible (takes a share of available space), and adaptive (fits as many columns as possible given a minimum size). Adaptive is the most powerful — it automatically adjusts the number of columns based on available width.

import SwiftUI

struct ContentView: View {
    // Three flexible columns — each takes one-third of the width
    let columns = [
        GridItem(.flexible()),
        GridItem(.flexible()),
        GridItem(.flexible())
    ]

    var body: some View {
        ScrollView {
            // LazyVGrid lays out items in the defined columns
            LazyVGrid(columns: columns, spacing: 12) {
                ForEach(1...30, id: \.self) { item in
                    // Square cell for each grid item
                    RoundedRectangle(cornerRadius: 10)
                        .fill(.blue.opacity(0.3))
                        .aspectRatio(1, contentMode: .fit)
                        .overlay { Text("\(item)") }
                }
            }
            .padding()
        }
    }
}
A three-column grid of blue-tinted square cells numbered 1 through 30. Each cell has rounded corners and a number centered inside. The grid has 12pt spacing between cells and is inset from the screen edges with padding. Rows continue below the visible area.
LineWhat it does
GridItem(.flexible())Creates one column that takes an equal share of available space. Three flexible items = three equal columns.
LazyVGrid(columns:spacing:)Lays views into columns using the GridItem array. spacing: is the gap between rows.
.aspectRatio(1, contentMode: .fit)Makes each cell square — height equals width. The 1 means a 1:1 width-to-height ratio.

Grid Column Type Variations

GridItem(.fixed) Columns with an exact fixed width
// Two fixed columns: one narrow sidebar, one wide main area
let columns = [
    GridItem(.fixed(80)),
    GridItem(.fixed(240))
]
Fixed columns are always exactly the width you specify, regardless of screen size. Useful when you need precise alignment — like a label column next to a value column in a form.
GridItem(.adaptive) Fit as many columns as possible
// One adaptive column — SwiftUI decides how many fit
let columns = [
    GridItem(.adaptive(minimum: 100))
]

// On a 390pt wide screen, this creates 3 columns (390 / 100 ≈ 3-4)
// On a wider iPad, it automatically creates more columns
Adaptive columns are the most powerful option. Give SwiftUI a minimum column width and it fits as many columns as possible in the available space. This makes your grid automatically responsive to different device sizes and orientations.
Two side-by-side comparisons: On the left, an iPhone showing 3 adaptive columns of grid cells. On the right, the same code running on a wider device showing 5 columns — demonstrating how the adaptive column type automatically adjusts the column count based on available width.
Grid (fixed layout) Precise row-and-column control for static content
// Grid is for fixed, known content — not dynamic lists
Grid(alignment: .leading, horizontalSpacing: 20, verticalSpacing: 12) {
    GridRow {
        Text("Name").fontWeight(.bold)
        Text("Chris Ching")
    }
    GridRow {
        Text("Role").fontWeight(.bold)
        Text("iOS Developer")
    }
}
The Grid view (different from LazyVGrid) uses explicit GridRow containers. It automatically aligns cells across rows — perfect for forms, data tables, or comparison layouts with a fixed number of rows.
View / TypeWhat It Does
LazyVGrid(columns:)Scrollable grid using a columns array — lazy loading
GridItem(.flexible())Column that takes an equal share of available width
GridItem(.fixed(n))Column that is always exactly n points wide
GridItem(.adaptive(minimum:))Fits as many columns as possible given a minimum width
Grid { GridRow { } }Fixed grid with precise row-level alignment control
.aspectRatio(1, contentMode: .fit)Makes a view square — height equals its width
🎯
Challenge 3.5
Photo-Style Grid

Build a grid that looks like iOS Photos using an adaptive layout. Each cell should be a square with a gray background and show an SF Symbol image centered inside it — use Image(systemName: "photo"). The minimum column width should be 100pt. The spacing between cells should be 2pt (tight, like the real Photos app). The whole thing should be scrollable. Check the canvas — cells should tile tightly across the full width.

Hint: Use GridItem(.adaptive(minimum: 100)) as your single column definition. Set both spacing: on the LazyVGrid and the spacing inside your GridItem to 2 for tight cell gutters. No padding needed on the ScrollView.

Using AI to Go Further

Deepen Your Understanding Use AI as a tutor — no code generation required
Explain the difference between GridItem(.flexible()), GridItem(.fixed()), and GridItem(.adaptive()) in SwiftUI. When would I choose each one? Give me a real-world app example for each type where that column type is clearly the right choice.
I’m confused about when to use Grid vs LazyVGrid in SwiftUI. Can you explain the difference without writing code for me? What’s the deciding factor that tells me which one to reach for?
Build a Practice View Generate a commented example you can learn from
Write a SwiftUI view that uses LazyVGrid with adaptive columns to display a realistic content grid — like app icons or product cards. Add a comment on every meaningful line explaining what it does. Write comments for a beginner who hasn’t used GridItem before.
Here’s my LazyVGrid code: [paste your code]. Am I using the column types correctly? Is there a more appropriate GridItem type for what I’m trying to achieve? Explain before suggesting any changes.
3.6
Safe Area and ignoresSafeArea()
⏱ 20 min SwiftUI Basics

Modern iPhones have areas of the screen that are either physically cut into (the Dynamic Island, the notch) or reserved by the system (the home indicator bar at the bottom, the status bar at the top). SwiftUI calls these regions the safe area — and by default, your content stays inside it.

That default is a good one. It prevents your buttons from hiding behind the home indicator and your text from overlapping the status bar clock. But sometimes you want to break out intentionally — to extend a background color or image all the way to the screen edges, for example. The .ignoresSafeArea() modifier lets you do that.

The important mental model here is that .ignoresSafeArea() expands a view’s frame to include safe area regions — but you don’t have to move your content into those regions. You can extend a background behind the status bar while keeping your text inside the safe area.

import SwiftUI

struct ContentView: View {
    var body: some View {
        ZStack {
            // Background extends behind status bar and home indicator
            Color.indigo
                .ignoresSafeArea()

            // Content stays within the safe area by default
            VStack {
                Text("Full bleed background")
                    .foregroundStyle(.white)
                    .font(.title)
                Spacer()
                Text("Content stays safe")
                    .foregroundStyle(.white.opacity(0.7))
            }
            .padding()
        }
    }
}
Full-screen indigo background that extends behind the Dynamic Island at the top and behind the home indicator at the bottom. White text 'Full bleed background' appears inside the safe area near the top. Lighter white text 'Content stays safe' appears at the bottom within the safe area, above the home indicator.
LineWhat it does
Color.indigo.ignoresSafeArea()Extends the color fill behind the status bar and home indicator — the full screen edge to edge.
VStack { } (no ignoresSafeArea)Without the modifier, the VStack respects the safe area by default. Text stays clear of the Dynamic Island and home indicator.
ZStack { }Layers the background and content on top of each other. The background goes full-bleed; the content sits on top inside the safe area.
Best practice: Apply .ignoresSafeArea() to background colors and images — not to your interactive content. Buttons and text that land behind the home indicator or under the notch become hard to see and tap.

Safe Area Variations

.ignoresSafeArea(.keyboard) Stop the keyboard from pushing content up
TextField("Search", text: $searchText)
    .textFieldStyle(.roundedBorder)
    .padding()
    // Prevents the keyboard from shifting this text field's container
    .ignoresSafeArea(.keyboard)
By default, SwiftUI shifts content up when the keyboard appears to keep focused text fields visible. Applying .ignoresSafeArea(.keyboard) to a container disables that behavior — useful when you’re managing keyboard avoidance yourself.
Specific edges Ignore safe area on only certain edges
Color.blue
    // Only extend behind the top (status bar / Dynamic Island)
    .ignoresSafeArea(edges: .top)
Pass an edges: parameter to limit which safe area edges are ignored. Options: .top, .bottom, .leading, .trailing, .horizontal, .vertical, .all.
safeAreaInset() Add content that floats over the safe area edge
ScrollView {
    LazyVStack {
        /* list content */
    }
}
// Adds a floating button that sits above the bottom safe area
.safeAreaInset(edge: .bottom) {
    Button("Compose") { }
        .buttonStyle(.borderedProminent)
        .padding()
}
.safeAreaInset() places a view at the edge of the safe area and automatically adds inset to the main content so nothing is hidden behind the floating overlay. It’s the correct way to add a floating action button or toolbar above a scroll view.
A scroll view with list content. A 'Compose' blue button floats above the bottom of the screen, sitting just above the home indicator. The list content's last item has extra bottom padding so it's not hidden behind the floating button — this demonstrates safeAreaInset pushing the scroll content up.
ModifierWhat It Does
.ignoresSafeArea()Extends the view behind all safe area regions
.ignoresSafeArea(edges: .top)Ignores safe area on specific edge(s) only
.ignoresSafeArea(.keyboard)Prevents the keyboard from shifting content
.safeAreaInset(edge:)Adds overlaid content at a safe area edge with automatic content inset
🎯
Challenge 3.6
Hero Header Screen

Build a screen with a full-bleed gradient header at the top. Use a ZStack: a gradient background using LinearGradient from .purple to .indigo that extends behind the status bar using .ignoresSafeArea(edges: .top), and a VStack on top containing a large white title and a subtitle. Below the header area, add a few rows of white content on a gray background. Check the canvas — the gradient should fill all the way to the top of the screen including behind the status bar.

Hint: The gradient doesn’t need a fixed height if you constrain the VStack that contains your header content. Apply .ignoresSafeArea(edges: .top) to the gradient color itself, not the VStack containing your text.

Using AI to Go Further

Deepen Your Understanding Use AI as a tutor — no code generation required
Explain what the safe area is in iOS and why SwiftUI respects it by default. What are the physical regions that make up the safe area on a modern iPhone? Why is the default behavior usually correct, and when is it appropriate to override it?
I want to understand the difference between ignoresSafeArea() and safeAreaInset() in SwiftUI. Can you explain the conceptual difference without writing code — when would I use one vs the other?
Build a Practice View Generate a commented example you can learn from
Write a SwiftUI view that demonstrates a practical use of ignoresSafeArea() — like a full-screen onboarding screen with a background that goes edge to edge. Add a comment on every significant line explaining what ignoresSafeArea() is doing and what would happen if it was removed.
Here’s a SwiftUI view where my background isn’t filling to the screen edges: [paste your code]. Can you identify exactly what I’m missing and explain why it’s happening before suggesting a fix?
3.7
ViewThatFits
⏱ 15 min Intermediate SwiftUI

Sometimes you want to show a large, full layout on a big screen and a compact version on a small one — without writing complicated conditional logic. ViewThatFits lets SwiftUI make that decision for you.

You give it several alternative views in order of preference. SwiftUI tries to fit the first one in the available space. If it doesn’t fit, it tries the next one. It uses whichever fits first. If none fit, it falls back to the last option regardless. Think of it like giving someone a list of dress code options from most formal to most casual — wear the first one that works.

ViewThatFits is especially useful for button rows that should be horizontal on wide screens and vertical on narrow ones, or for headlines that should show their full text on large devices but a shorter version on small ones.

import SwiftUI

struct ContentView: View {
    var body: some View {
        // ViewThatFits tries each option in order and uses the first that fits
        ViewThatFits {

            // Preferred: horizontal layout for wide containers
            HStack(spacing: 12) {
                Button("Save Draft") { }.buttonStyle(.bordered)
                Button("Preview") { }.buttonStyle(.bordered)
                Button("Publish Post") { }.buttonStyle(.borderedProminent)
            }

            // Fallback: vertical layout for narrow containers
            VStack(spacing: 10) {
                Button("Publish Post") { }
                    .buttonStyle(.borderedProminent)
                    .frame(maxWidth: .infinity)
                Button("Save Draft") { }
                    .buttonStyle(.bordered)
                    .frame(maxWidth: .infinity)
                Button("Preview") { }
                    .buttonStyle(.bordered)
                    .frame(maxWidth: .infinity)
            }
        }
        .padding()
    }
}
Side by side comparison: On the left, a wide container showing three buttons ('Save Draft', 'Preview', 'Publish Post') arranged horizontally in a single row with the HStack layout. On the right, a narrow container showing the same three buttons stacked vertically with full-width styles, using the VStack fallback layout.
LineWhat it does
ViewThatFits { }A container that evaluates its children in order and renders the first one that fits in the available space.
First child (HStack)The preferred layout — wide, horizontal. Shown if the container is wide enough for all buttons side by side.
Second child (VStack)The fallback — compact, vertical. Used when the HStack version doesn’t fit.
Order matters: Always put your largest, most ideal layout first and your most compact version last. SwiftUI will use the first one that fits and stop evaluating. If you put the VStack first, it will always be used because it almost always fits.

ViewThatFits Variations

in: .horizontal Only check horizontal fit, ignore vertical
// Only checks whether views fit horizontally — ignores height
ViewThatFits(in: .horizontal) {
    Text("Get Started with CodeWithChris").font(.title)
    Text("Get Started").font(.title)
    Text("Start").font(.title)
}
By default, ViewThatFits checks both dimensions. Passing in: .horizontal makes it only evaluate horizontal fit — useful for text truncation where you want the longest string that doesn’t wrap.
in: .vertical Only check vertical fit
// Only checks height fit — useful for tall content in a fixed container
ViewThatFits(in: .vertical) {
    // Full detail layout
    DetailedInfoView()
    // Compact layout used if there isn't enough vertical space
    CompactInfoView()
}
Use in: .vertical when your concern is height — for example, inside a sheet or a fixed-height container that might not accommodate your ideal tall layout.
UsageWhat It Does
ViewThatFits { }Checks both width and height to find the first fitting view
ViewThatFits(in: .horizontal)Only checks horizontal fit — ignores height
ViewThatFits(in: .vertical)Only checks vertical fit — ignores width
First childMost preferred / largest layout — shown if it fits
Last childFallback — always used if nothing else fits
🎯
Challenge 3.7
Adaptive Headline

Build a title banner using ViewThatFits(in: .horizontal). The first option should be a long tagline in .title font: “Start building your first iOS app today”. The second should be “Build your first iOS app”. The third should be “Start building”. Check the canvas by wrapping the ViewThatFits in a container of different widths — try constraining it with .frame(width: 200) and .frame(width: 350) to see different options activate.

Hint: Wrap your ViewThatFits in a container view and use .frame(width: 200) on that container in the Xcode canvas preview to simulate a narrow space. Change the width to see different text options kick in.

Using AI to Go Further

Deepen Your Understanding Use AI as a tutor — no code generation required
Explain ViewThatFits in SwiftUI using a plain English analogy. How does it decide which child view to use? What happens if none of the options fit? What happens if multiple options would fit?
Can you compare ViewThatFits to other adaptive layout approaches in SwiftUI — like using different views based on size classes, or using conditional logic in the body? When is ViewThatFits clearly the best tool and when might the alternatives be better?
Build a Practice View Generate a commented example you can learn from
Write a SwiftUI view that demonstrates ViewThatFits in a realistic scenario — like a toolbar, a tag row, or a header that needs to adapt to different widths. Add a comment on every significant line explaining what is happening and why. Write comments for a beginner.
Here’s my SwiftUI view where I’m using an if/else to switch between two layouts based on screen width: [paste your code]. Would ViewThatFits be a better approach here? Explain the tradeoffs before suggesting any changes.

Stage 3 Recap: Layout in Depth

You now have precise control over how your views are sized, spaced, and positioned. Here’s what you covered across the seven lessons in this stage:

  • Lesson 3.1 — Spacer and Divider: Use Spacer to push views apart and Divider to add a thin separator. Spacer behaves differently inside VStack vs HStack — it always expands in the stack’s direction.
  • Lesson 3.2 — frame(), padding(), offset(): frame() sets a view’s size, padding() adds space around it, and offset() slides it visually without affecting the layout of surrounding views. Use maxWidth: .infinity to stretch a view full-width.
  • Lesson 3.3 — GeometryReader: GeometryReader gives you the actual dimensions of its container — useful for proportional sizing, but a last resort. Most sizing problems are better solved with frame() or Spacer.
  • Lesson 3.4 — LazyVStack and LazyHStack: Lazy stacks create child views only as they scroll into view. Use them inside ScrollView when your content list is large or dynamic. For small, fixed lists, regular VStack is simpler.
  • Lesson 3.5 — Grid and LazyVGrid: LazyVGrid creates grid layouts using GridItem columns. Flexible columns share space evenly, fixed columns are exact, and adaptive columns automatically fit as many columns as possible for any screen width.
  • Lesson 3.6 — Safe Area: SwiftUI respects the safe area by default to keep content away from the Dynamic Island and home indicator. Use .ignoresSafeArea() intentionally on backgrounds and images, and .safeAreaInset() for floating overlays that don’t hide content.
  • Lesson 3.7 — ViewThatFits: Give SwiftUI multiple layout alternatives in order of preference and it will use the first one that fits the available space. Ideal for responsive layouts without conditional logic.

If you skipped any of the challenges, go back and do them. The best way to internalize layout concepts is to build something and check it visually in the canvas — no tutorial can replace that.

In Stage 4 you’ll learn Navigation — how to push views onto a stack, present sheets and alerts, and build the multi-screen flows that every real app needs.

Learn SwiftUI Stage 4: Navigation

Every real app has more than one screen — and this is where you learn to connect them. Navigation is where SwiftUI starts to feel like an actual app.

This stage covers 7 lessons and takes around 3 hours to work through. You’ll need Xcode open and the canvas preview running, but here’s a heads-up: navigation is one of those topics you really have to test in the simulator. Tapping between screens, pushing views, and dismissing sheets all need a running app — the canvas can show you what a single screen looks like, but it can’t simulate the full navigation experience. Get comfortable hitting the Run button early.

By the end of Stage 4, you’ll know how to use NavigationStack and NavigationLink to push views onto a stack, present screens modally with sheets and full-screen covers, show alerts and confirmation dialogs, build tab bar navigation with TabView, and pass data between screens in both directions. These are the building blocks of virtually every multi-screen iOS app.

04
Stage 4
Navigation
7 lessons · ~3 hrs
4.1
NavigationStack
⏱ 30 min SwiftUI Basics

Think about the Settings app on your iPhone. You tap “General,” a new screen slides in from the right. You tap “About,” another screen slides in. Hit the back button and you go back the way you came. That sliding-in, sliding-back behavior is called push/pop navigation, and in SwiftUI it’s powered by NavigationStack.

NavigationStack is a container view — you wrap it around your content, and it manages a stack of screens. “Stack” is a good mental model here: screens pile up as you navigate deeper, and they come back off the pile as you go back. The screen you’re currently looking at is always on top of the stack.

Before we add any navigation links, let’s start with the simplest possible NavigationStack — a container with a title. This alone gives you the navigation bar at the top, which is where back buttons and titles appear.

import SwiftUI

struct ContentView: View {
    var body: some View {
        // NavigationStack is the container — wrap your content inside it
        NavigationStack {
            // This Text is the content of the root (first) screen
            Text("Welcome to my app")
                // .navigationTitle sets the large title in the nav bar
                .navigationTitle("Home")
        }
    }
}
Xcode canvas showing a SwiftUI view with a NavigationStack — a large bold 'Home' title at the top and 'Welcome to my app' text below it in the center of the screen
LineWhat it does
NavigationStack { } Creates the navigation container. Everything inside it becomes part of the navigation stack. The first view inside is the root — the starting screen.
Text("Welcome to my app") The content of the root screen. In a real app this would be a full view — a list, a grid, whatever your first screen needs to show.
.navigationTitle("Home") Sets the title shown in the navigation bar at the top of the screen. Apply this to the content inside NavigationStack, not to NavigationStack itself.
NavigationView vs NavigationStack: If you’re watching older tutorials online, you’ll see NavigationView instead of NavigationStack. NavigationView still works, but Apple deprecated it in iOS 16. NavigationStack is the modern replacement and handles many edge cases better. Use NavigationStack for any new project.

Customizing the Navigation Bar

.navigationTitle() Set the title shown in the navigation bar
NavigationStack {
    Text("Content here")
        // String literal becomes the nav bar title
        .navigationTitle("My App")
}
Apply this to the view inside NavigationStack, not to NavigationStack itself. The title defaults to the large style — big text below the nav bar that collapses as you scroll.
.navigationBarTitleDisplayMode() Control whether the title is large or inline
Text("Content")
    .navigationTitle("Settings")
    // .large = big collapsible title (default), .inline = smaller centered title
    .navigationBarTitleDisplayMode(.inline)
Use .large for root screens and .inline for detail screens pushed deeper in the stack. This matches the pattern Apple uses in its own apps.
.toolbar { } Add buttons to the navigation bar
Text("Content")
    .navigationTitle("Home")
    .toolbar {
        // ToolbarItem places a button in the nav bar
        ToolbarItem(placement: .navigationBarTrailing) {
            Button("Add") {
                // action goes here
            }
        }
    }
Use .navigationBarTrailing for the right side (common for Add or Edit buttons) and .navigationBarLeading for the left. The system handles layout automatically.
.toolbarBackground() Customize the nav bar background color
Text("Content")
    .navigationTitle("Home")
    // Set the nav bar background to a custom color
    .toolbarBackground(Color.blue, for: .navigationBar)
    .toolbarBackground(.visible, for: .navigationBar)
iOS 16+. You need both lines — one sets the color and one makes the bar visible (it’s often transparent by default when content hasn’t scrolled). Apply to the content view inside the stack.
ModifierWhat It Does
.navigationTitle(“Title”)Sets the text shown in the nav bar
.navigationBarTitleDisplayMode(.large)Big collapsible title — good for root screens
.navigationBarTitleDisplayMode(.inline)Small centered title — good for detail screens
.toolbar { }Adds buttons or items to the nav bar
.toolbarBackground(color, for: .navigationBar)Sets nav bar background color (iOS 16+)
Challenge: Create a new SwiftUI project and wrap ContentView’s body in a NavigationStack. Give it a title, add a toolbar button on the trailing side, and try switching the display mode between .large and .inline. Run in the simulator and scroll to see the large title collapse.

Using AI to Go Further

Deepen Your Understanding Use AI as a tutor — no code generation required
Explain NavigationStack to me like I’ve never used it before. What is a “stack” in this context? Use a real-world analogy and then walk me through how it relates to how screens work in iOS apps.
I understand that NavigationStack is a container. Can you quiz me on where I should apply modifiers like .navigationTitle and .toolbar — on the stack itself, or on the content inside? Ask me a few scenarios and tell me if I get them right.
Build a Practice View Generate a commented example you can study line by line
Write a SwiftUI view that uses NavigationStack with a navigationTitle, a large display mode, and a toolbar button on the trailing side. Add a comment on every single line explaining what it does and why — write the comments for a complete beginner.
Show me three different NavigationStack examples: one with just a title, one with a toolbar button, and one with a custom background color. Add inline comments throughout so I can follow along and understand when I’d use each version.
4.2
NavigationLink
⏱ 25 min SwiftUI Basics

You have a NavigationStack. Now you need something the user can tap to move to the next screen. That’s NavigationLink. Think of it like a hyperlink on a webpage — you tap it and you go somewhere. The difference is that instead of loading a URL, you’re pushing a new SwiftUI view onto the navigation stack.

NavigationLink works in two parts: the label (what the user sees and taps) and the destination (the view that appears when they tap it). The label can be anything — text, an image, a whole custom row. The destination is any SwiftUI view you want to show next.

Here’s the most common pattern you’ll use as a beginner — a list of items where each row is a NavigationLink that pushes to a detail screen:

import SwiftUI

struct ContentView: View {
    var body: some View {
        NavigationStack {
            // List gives us rows — each row can be a NavigationLink
            List {
                // NavigationLink: label is what you see, destination is where you go
                NavigationLink("Go to Detail") {
                    // This view appears when the user taps the link
                    DetailView()
                }
                NavigationLink("Go to Settings") {
                    SettingsView()
                }
            }
            .navigationTitle("Home")
        }
    }
}

struct DetailView: View {
    var body: some View {
        // The back button appears automatically — no code needed
        Text("You're on the detail screen")
            .navigationTitle("Detail")
            .navigationBarTitleDisplayMode(.inline)
    }
}

struct SettingsView: View {
    var body: some View {
        Text("Settings screen")
            .navigationTitle("Settings")
            .navigationBarTitleDisplayMode(.inline)
    }
}
Simulator screenshot showing two screens: left is the Home list screen with two rows ('Go to Detail' and 'Go to Settings') each showing a chevron arrow on the right; right is the Detail screen with an inline 'Detail' title and a back button labeled 'Home' in the top left
LineWhat it does
NavigationLink("Go to Detail") { } The string is the label — what appears in the row. The closure is the destination — the view that gets pushed when tapped.
DetailView() The destination view. You’re creating an instance of your custom view right here. In a real app you’d often pass data into it as well (more on that below).
Back button SwiftUI adds the back button automatically — you don’t need to write any code for it. It’s labeled with the title of the previous screen.
.navigationBarTitleDisplayMode(.inline) On detail screens pushed from a list, inline mode looks cleaner. It keeps the title small and leaves room for the back button.
Important: NavigationLink only works when it’s inside a NavigationStack. If you place a NavigationLink outside of any NavigationStack, nothing will happen when the user taps it — no error, just silence. This is one of the most common beginner confusions.

Passing Data to the Destination

Most of the time you’re not just navigating to a blank screen — you’re passing data along with you. Here’s how to send data forward to a detail view:

NavigationLink with data Pass a value to the destination view
struct ContentView: View {
    // Simple array of fruit names to display in a list
    let fruits = ["Apple", "Banana", "Cherry"]

    var body: some View {
        NavigationStack {
            List(fruits, id: \.self) { fruit in
                // Pass `fruit` into the destination so it knows which item to show
                NavigationLink(fruit) {
                    FruitDetailView(fruit: fruit)
                }
            }
            .navigationTitle("Fruits")
        }
    }
}

struct FruitDetailView: View {
    // The destination receives the value as a stored property
    let fruit: String

    var body: some View {
        Text("You selected: \(fruit)")
            .navigationTitle(fruit)
            .navigationBarTitleDisplayMode(.inline)
    }
}
The destination view receives data through its initializer — just like any other SwiftUI view. Declare the property with let since you’re receiving it, not changing it.
Custom label Use any view as the tappable label
// Instead of a plain string, use a trailing closure for a custom label
NavigationLink {
    DetailView()
} label: {
    // Anything here becomes the tappable area
    HStack {
        Image(systemName: "star.fill")
            .foregroundColor(.yellow)
        Text("Featured Item")
            .font(.headline)
    }
}
Use the label: closure form when you want a custom-looking row instead of plain text. The entire label view becomes tappable and gets the chevron arrow automatically when inside a List.
.navigationDestination(for:) Modern approach — declare destinations on the stack
NavigationStack {
    List(fruits, id: \.self) { fruit in
        // Just pass the value — no destination closure needed here
        NavigationLink(fruit, value: fruit)
    }
    .navigationTitle("Fruits")
    // Declare what view to show for a given type of value
    .navigationDestination(for: String.self) { fruit in
        FruitDetailView(fruit: fruit)
    }
}
This is the preferred pattern in iOS 16+. You define the destination once on the NavigationStack rather than in every individual link. It also enables programmatic navigation (covered in Lesson 4.3). Use this when you have many links navigating to the same type of view.
PatternWhen to Use It
NavigationLink(“Label”) { DestView() }Simple navigation, small number of links, destination doesn’t need data
NavigationLink(fruit) { FruitDetailView(fruit: fruit) }Passing a value forward, one destination per link
NavigationLink(“Label”) { } label: { CustomView }When you want a custom-designed row instead of plain text
NavigationLink(value: item) + .navigationDestinationiOS 16+, many links to the same destination type, or programmatic navigation
Challenge: Build a list of 5 items (pick anything — cities, movies, animals). Each row should be a NavigationLink that pushes to a detail view showing the item’s name in a large title and some placeholder text. Run it in the simulator and tap through the list. Try customizing one of the rows with a custom label using an HStack with an icon.

Using AI to Go Further

Deepen Your Understanding Use AI as a tutor — no code generation required
Explain the difference between putting the destination view directly inside NavigationLink versus using .navigationDestination(for:) on the NavigationStack. When would I choose one over the other? Don’t write full code — just explain the tradeoffs.
I’m confused about what “pushing a view onto the stack” actually means. Can you explain using a concrete analogy and then describe step by step what SwiftUI is doing when a user taps a NavigationLink?
Build a Practice View Generate a commented example you can study line by line
Write a SwiftUI example with a NavigationStack containing a List of 4 countries. Each row should be a NavigationLink that passes the country name to a detail view showing that name. Add a comment on every line explaining what it does — write for a beginner who is seeing this for the first time.
Give me two NavigationLink examples: one using a plain string label and one using a custom HStack label with an icon and text. Add inline comments throughout so I understand which parts are the label and which parts are the destination.
4.3
Programmatic Navigation with NavigationPath
⏱ 30 min Intermediate SwiftUI

So far, navigation happens because the user taps something. But what if you need your code to navigate — without a user tap? Imagine an onboarding flow that automatically moves to the next step after something completes, or a deep link that opens the app directly on a specific screen. That’s programmatic navigation.

NavigationPath is a value that represents the current stack of screens. When you hand that path to your NavigationStack, you’re in control: append a value to the path and a new screen gets pushed. Remove the last value and you go back. Empty the whole path and you’re back at the root.

This is an intermediate concept — you don’t need it for most beginner apps. But once you start building real multi-screen flows, you’ll reach for it regularly.

import SwiftUI

struct ContentView: View {
    // @State holds the navigation path — changes to it drive navigation
    @State private var path = NavigationPath()

    var body: some View {
        // Pass a binding to the path so NavigationStack can read and write it
        NavigationStack(path: $path) {
            VStack(spacing: 20) {
                // Tapping this button pushes "Detail" onto the path programmatically
                Button("Go to Detail from code") {
                    path.append("Detail")
                }
                // This navigates two levels deep at once
                Button("Skip to Settings") {
                    path.append("Detail")
                    path.append("Settings")
                }
                // This jumps back to root no matter how deep we are
                Button("Go back to root") {
                    path.removeLast(path.count)
                }
            }
            .navigationTitle("Home")
            // Declare what view to show when a String value is pushed
            .navigationDestination(for: String.self) { screen in
                Text("Screen: \(screen)")
                    .navigationTitle(screen)
            }
        }
    }
}
Simulator screenshot showing two states: the Home screen with three buttons, and the Detail screen after tapping 'Go to Detail from code', with the screen title 'Detail' and a back button labeled 'Home'
LineWhat it does
@State private var path = NavigationPath() Creates an empty navigation path stored in state. Because it’s @State, any change to it will automatically update the UI — screens get pushed or popped.
NavigationStack(path: $path) The binding ($path) connects the stack to your state. The stack reads the path to know which screens to show, and writes to it when the user hits the back button.
path.append("Detail") Pushes a new screen. SwiftUI looks at the appended value’s type, finds the matching .navigationDestination, and presents that view.
path.removeLast(path.count) Removes all items from the path, which pops all screens and returns to the root. This is the “go home” pattern.
New to Swift? @State is how SwiftUI stores values that can change over time and drive UI updates. When path changes — a value is appended or removed — SwiftUI automatically re-renders the NavigationStack to reflect the new state. You’ll learn more about @State in Stage 2 of the State and Data series.

NavigationPath Patterns

path.append() Push a screen onto the stack
// Append any Hashable value — the type must match a .navigationDestination
path.append("SettingsScreen")

// You can also append custom types if they conform to Hashable
path.append(selectedUser)
The value you append must be Hashable — a Swift protocol that means the value can be used as a unique key. Strings, Ints, and most basic types are already Hashable. Custom structs need to explicitly conform.
path.removeLast() Pop one or more screens
// Go back one screen (same as user tapping the back button)
path.removeLast()

// Go back two screens at once
path.removeLast(2)

// Go all the way back to root
path.removeLast(path.count)
Use this any time you need to navigate backwards from code — after a form submission, after a successful action, or on a “Done” button. The user’s back button does the same thing automatically, but now you can trigger it from anywhere.
Multiple destination types Navigate to different view types from one stack
NavigationStack(path: $path) {
    ContentView()
    // One destination for String values
    .navigationDestination(for: String.self) { name in
        UserDetailView(name: name)
    }
    // A separate destination for Int values
    .navigationDestination(for: Int.self) { id in
        ItemDetailView(id: id)
    }
}
You can register multiple .navigationDestination modifiers on the same stack — one per type. When you append a String, it goes to UserDetailView. Append an Int, it goes to ItemDetailView. SwiftUI routes based on the type automatically.
OperationWhat It Does
NavigationPath()Creates an empty path (no extra screens)
path.append(value)Pushes a new screen for the given value’s type
path.removeLast()Pops the top screen (go back one)
path.removeLast(path.count)Pops all screens (go to root)
path.countNumber of screens currently on the stack above root
path.isEmptyTrue when you’re at the root screen
Challenge: Build a simple onboarding flow with 3 screens: Welcome, Features, and GetStarted. Use NavigationPath and buttons (not NavigationLinks) to move forward through the screens. Add a “Start Over” button on the final screen that empties the path and returns to Welcome. Test everything in the simulator.

Using AI to Go Further

Deepen Your Understanding Use AI as a tutor — no code generation required
Explain NavigationPath to me without writing any code. What is it conceptually? How is it different from just using NavigationLink? Give me a real-world scenario where I’d need NavigationPath but couldn’t use a plain NavigationLink instead.
I understand that appending to a NavigationPath pushes a screen, and removing pops it. Can you quiz me with 3-4 scenarios — describe a situation and ask me whether I’d use append, removeLast, or removeLast(count)? Tell me when I get it wrong and explain why.
Build a Practice View Generate a commented example you can study line by line
Write a SwiftUI view that uses NavigationStack with a NavigationPath binding. Include buttons to push a screen, go back one screen, and go back to root. Add a comment on every line explaining what it does — write the comments for someone who has never seen NavigationPath before.
Show me a NavigationStack that can navigate to two different types of destination views — one for String values and one for Int values. Add inline comments explaining how Swift knows which destination to use for each type.
4.4
Sheet and fullScreenCover
⏱ 25 min SwiftUI Basics

Push navigation isn’t the only way to show a new screen. Sometimes a screen should slide up from the bottom rather than push in from the side — like when you tap “New Message” in Mail or “New Event” in Calendar. That bottom-up presentation is called a modal, and SwiftUI has two modifiers for it: .sheet and .fullScreenCover.

The key difference is how much screen they cover. A sheet slides up and shows a bit of the underlying screen peeking out at the top — the user can see they’re temporarily leaving the main view. A fullScreenCover takes over the entire screen, just like push navigation but coming from the bottom. Use sheets for temporary forms, filter panels, and details that don’t need their own nav bar. Use fullScreenCover for things like login screens or camera views.

Both work the same way under the hood: you bind them to a Bool. When the Bool is true, the sheet appears. When it’s false, the sheet is dismissed.

import SwiftUI

struct ContentView: View {
    // @State bool controls whether the sheet is showing
    @State private var showSheet = false

    var body: some View {
        Button("Open Sheet") {
            // Setting to true triggers the sheet to appear
            showSheet = true
        }
        // .sheet watches the bool — appears when true, dismisses when false
        .sheet(isPresented: $showSheet) {
            SheetContentView()
        }
    }
}

struct SheetContentView: View {
    // @Environment(\.dismiss) gives access to the dismiss action
    @Environment(\.dismiss) var dismiss

    var body: some View {
        VStack {
            Text("This is a sheet")
            // Calling dismiss() closes the sheet from inside it
            Button("Close") {
                dismiss()
            }
        }
    }
}
Simulator screenshot showing two states: first the ContentView with an 'Open Sheet' button; second the sheet slid up from the bottom with the sheet content view visible and the ContentView partially visible behind it at the top
LineWhat it does
@State private var showSheet = false The switch that controls the sheet. True = sheet is visible. False = sheet is hidden. SwiftUI watches this and reacts automatically.
.sheet(isPresented: $showSheet) Attaches the sheet to the view. The binding means SwiftUI can both read (is it showing?) and write (set it back to false when dismissed) this value.
@Environment(\.dismiss) var dismiss Gives the sheet’s content access to the dismiss action. Calling dismiss() sets the parent’s Bool back to false and closes the sheet.
Common mistake: Forgetting that the user can also dismiss a sheet by swiping it down. If your sheet has unsaved changes, you need to handle that case — use the .interactiveDismissDisabled() modifier on the sheet’s content to prevent accidental dismissal.

Sheet Variations

.fullScreenCover Cover the entire screen — no peeking behind
// Same API as .sheet — just swap the modifier name
.fullScreenCover(isPresented: $showCover) {
    LoginView()
}
Use fullScreenCover for experiences that should take over completely — login/onboarding screens, camera views, or any modal that shouldn’t feel temporary. The user cannot swipe it away like a sheet; they must use your explicit dismiss mechanism.
.sheet(item:) Show a sheet bound to an optional item
// When selectedFruit is non-nil, the sheet appears with that fruit's data
@State private var selectedFruit: String? = nil

Button("Show Apple") {
    selectedFruit = "Apple"
}
// sheet(item:) takes an optional Identifiable value — show when non-nil
.sheet(item: $selectedFruit) { fruit in
    // fruit is the unwrapped value, available directly
    FruitDetailView(fruit: fruit)
}
When you need to pass data into a sheet, binding to an optional is cleaner than using a separate Bool plus a separate variable. Set the optional to the item you want, and the sheet opens with it. Set it to nil to dismiss.
.presentationDetents() Control how tall a sheet grows (iOS 16+)
.sheet(isPresented: $showSheet) {
    FilterView()
        // Sheet stops at medium height — user can drag to expand
        .presentationDetents([.medium, .large])
}
By default a sheet takes up most of the screen. Use .presentationDetents for a bottom sheet that starts half-height — perfect for filter panels, quick actions, or supplementary content. Users can drag between the sizes you define.
ModifierWhat It Does
.sheet(isPresented: $bool) { View }Shows a view modally from the bottom; Bool controls visibility
.fullScreenCover(isPresented: $bool) { View }Same as sheet but covers the full screen; no swipe-to-dismiss
.sheet(item: $optionalItem) { item in View }Sheet bound to optional data — appears when non-nil
@Environment(\.dismiss) var dismissDismiss action — call dismiss() to close the sheet from inside
.presentationDetents([.medium, .large])Half-height sheet that can expand (iOS 16+)
.interactiveDismissDisabled()Prevents swipe-to-dismiss — useful for unsaved data
Challenge: Create a ContentView with a “New Item” button that opens a sheet. The sheet should have a text field (you can make it non-functional for now), a “Save” button that dismisses it, and a “Cancel” button that also dismisses it. Try adding .presentationDetents([.medium]) to make it a half-height sheet. Run in the simulator.

Using AI to Go Further

Deepen Your Understanding Use AI as a tutor — no code generation required
Explain the difference between .sheet and .fullScreenCover without using code. When would I choose each one? Give me 3 real-world examples of apps and screens where you’d use a sheet, and 3 where you’d use fullScreenCover.
I’m confused about @Environment(\.dismiss). Why do we need it? Can’t the parent just set the Bool back to false? Walk me through what’s happening on both sides — the parent and the sheet’s content — when a sheet is dismissed.
Build a Practice View Generate a commented example you can study line by line
Write a SwiftUI ContentView with a button that opens a sheet. The sheet should use @Environment(\.dismiss) to close itself with a Done button. Add a comment on every line — write them for a beginner who hasn’t seen sheets before.
Show me three versions of a sheet: one using .sheet(isPresented:), one using .sheet(item:) with an optional, and one using .presentationDetents for a half-height sheet. Add inline comments explaining what’s different about each approach and why you’d choose it.
4.5
Alert and confirmationDialog
⏱ 20 min SwiftUI Basics

Alerts and confirmation dialogs are the two ways iOS asks the user to confirm an action or choose between options. Think about what happens when you try to delete a photo — the system shows a small popup asking if you’re sure. That’s an alert. When you tap the share button in Safari and see a menu sliding up from the bottom with several choices, that’s a confirmation dialog (sometimes called an action sheet).

The rule of thumb: use an alert for urgent, important messages where you want the user’s full attention — two options maximum, usually “OK” and “Cancel.” Use a confirmationDialog when you have three or more choices or a destructive action that needs a clear presentation.

Both follow the same pattern you saw with sheets: bind them to a Bool, and when the Bool is true, they appear.

import SwiftUI

struct ContentView: View {
    // Bool that controls alert visibility
    @State private var showDeleteAlert = false

    var body: some View {
        Button("Delete Item") {
            // Show the alert when the button is tapped
            showDeleteAlert = true
        }
        // .alert watches the bool and presents when true
        .alert("Delete this item?", isPresented: $showDeleteAlert) {
            // Buttons go in the first trailing closure
            Button("Delete", role: .destructive) {
                // Perform the delete action here
            }
            // .cancel buttons are styled automatically by iOS
            Button("Cancel", role: .cancel) { }
        } message: {
            // Optional supporting text shown below the title
            Text("This cannot be undone.")
        }
    }
}
Simulator screenshot showing a 'Delete this item?' alert centered on the screen with the message 'This cannot be undone.' and two buttons: a red 'Delete' and a 'Cancel'
LineWhat it does
.alert("Title", isPresented: $bool) Attaches the alert to the view. The string is the alert’s headline. The Bool binding controls whether it’s showing.
role: .destructive Makes the button appear in red — iOS’s standard way to signal a dangerous action. Always use this on delete or irreversible buttons.
role: .cancel iOS styles this button in bold and positions it at the bottom. It automatically dismisses the alert — you don’t need any action inside.
message: { Text(...) } Optional secondary text shown below the title. Use it to give the user more context about what will happen.

Alert and Dialog Variations

.confirmationDialog A bottom sheet with multiple action choices
@State private var showOptions = false

Button("Share") {
    showOptions = true
}
// confirmationDialog slides up from the bottom like an action sheet
.confirmationDialog("Share this item", isPresented: $showOptions) {
    Button("Save to Photos") { }
    Button("Copy Link") { }
    Button("Delete", role: .destructive) { }
    Button("Cancel", role: .cancel) { }
}
Use confirmationDialog when you have three or more choices. It slides up from the bottom and can hold many buttons comfortably. The Cancel button is always shown at the bottom, separated from the other options.
.alert(item:) Bind an alert to optional data
// Alert appears when errorMessage is non-nil, dismisses when set to nil
@State private var errorMessage: String? = nil

.alert("Error", isPresented: Binding(
    get: { errorMessage != nil },
    set: { if !$0 { errorMessage = nil } }
)) {
    Button("OK", role: .cancel) { }
} message: {
    Text(errorMessage ?? "")
}
This pattern is common for showing error messages. When a network call fails, set errorMessage to the error text — the alert appears. When dismissed, it sets itself back to nil. The Binding initializer here manually connects the optional to the Bool the alert needs.
ModifierWhen to Use
.alert(“Title”, isPresented: $bool)Urgent or important messages — 1 to 2 buttons
.confirmationDialog(“Title”, isPresented: $bool)3 or more choices, or destructive multi-option actions
role: .destructiveRed button styling — signals irreversible actions
role: .cancelBold styled cancel button — auto-dismisses on tap
message: { Text(…) }Secondary supporting text shown below alert title
Gotcha: Both .alert and .confirmationDialog reset the Bool back to false automatically when the user dismisses them — you don’t need to do it manually in every button action. The Bool is set to false the moment any button is tapped (unless you suppress that behavior).
Challenge: Build a view with a list of 3 items and a Delete button for each. Tapping Delete should show an alert asking for confirmation. If the user confirms, print the item name to the console. Also add a Share button that shows a confirmationDialog with at least 3 options. Test both in the simulator.

Using AI to Go Further

Deepen Your Understanding Use AI as a tutor — no code generation required
Explain the difference between .alert and .confirmationDialog without writing code. When should I use each one? Give me 5 real-world examples of actions in iOS apps and tell me which presentation style each one would use and why.
Why does the .alert modifier get applied to a view rather than being a standalone view itself? How does this pattern of “attaching a modifier to a view to present something” compare to how sheets work? Help me see the underlying pattern.
Build a Practice View Generate a commented example you can study line by line
Write a SwiftUI view with a “Delete Account” button that triggers an alert with a destructive confirm button and a cancel button. Add a comment on every line — especially explaining what role: .destructive and role: .cancel do visually on screen.
Give me two examples: one .alert for a simple “Are you sure?” confirmation, and one .confirmationDialog for a share menu with 4 options. Add inline comments explaining what’s different about when and why you’d use each one.
4.6
TabView
⏱ 25 min SwiftUI Basics

Tab bar navigation is one of the most recognizable patterns in iOS. If you’ve used Instagram, the App Store, or the Clock app, you’ve used a tab bar. Those icons at the bottom of the screen that let you jump between completely separate sections of an app — that’s TabView.

The key mental model for TabView is that each tab is an independent section. Unlike push navigation where screens are stacked on top of each other, tabs sit side by side. Each tab has its own navigation state — you can be deep in a push stack on one tab and it won’t affect what’s happening in another.

Setting up a basic tab bar takes just a few lines: wrap your views in TabView and give each one a .tabItem modifier that describes its icon and label.

import SwiftUI

struct ContentView: View {
    var body: some View {
        // TabView is the container — every direct child becomes a tab
        TabView {
            // Each view gets a .tabItem that defines its icon and label
            HomeView()
                .tabItem {
                    Label("Home", systemImage: "house.fill")
                }

            SearchView()
                .tabItem {
                    Label("Search", systemImage: "magnifyingglass")
                }

            ProfileView()
                .tabItem {
                    Label("Profile", systemImage: "person.fill")
                }
        }
    }
}

struct HomeView: View {
    var body: some View {
        // Each tab gets its own NavigationStack for independent navigation
        NavigationStack {
            Text("Home Screen")
                .navigationTitle("Home")
        }
    }
}

struct SearchView: View {
    var body: some View {
        NavigationStack {
            Text("Search Screen")
                .navigationTitle("Search")
        }
    }
}

struct ProfileView: View {
    var body: some View {
        NavigationStack {
            Text("Profile Screen")
                .navigationTitle("Profile")
        }
    }
}
Simulator screenshot showing a TabView with three tabs at the bottom: a house icon for Home (currently selected, blue), a magnifying glass for Search, and a person for Profile; the main area shows 'Home Screen' with a large nav title
LineWhat it does
TabView { } The container. Every direct child view inside becomes a tab. Tabs appear in the order you write them.
.tabItem { } Required on every tab’s view. Defines what appears in the tab bar for that tab. Only Label, Text, or Image work inside here — custom styled views are not supported.
Label("Home", systemImage: "house.fill") Creates the tab bar item with both a text label and an SF Symbol icon. The system handles layout, sizing, and tint color automatically.
NavigationStack inside each tab Each tab manages its own navigation stack independently. Push a detail view on the Home tab and the Search tab stays exactly where it was.
Common mistake: Forgetting the .tabItem modifier on one of your tabs. Without it, that tab will appear blank in the bar — no icon, no label. SwiftUI doesn’t warn you, it just shows nothing. Every view inside a TabView needs its own .tabItem.

TabView Customization

.tag() + selection Track and control which tab is selected
// @State variable tracks the currently active tab
@State private var selectedTab = 0

// Pass a binding so the TabView can read and write the selected tab
TabView(selection: $selectedTab) {
    HomeView()
        .tabItem { Label("Home", systemImage: "house") }
        // .tag() assigns an identifier — must match the type of selectedTab
        .tag(0)
    ProfileView()
        .tabItem { Label("Profile", systemImage: "person") }
        .tag(1)
}

// Jump to Profile tab from anywhere in your code
Button("Go to Profile") {
    selectedTab = 1
}
Once you add tags and a selection binding, you can switch tabs from code — useful when a button in one part of the app needs to jump the user to a different tab. The @State variable tells you which tab is active at any point.
.badge() Show a notification count on a tab icon
MessagesView()
    .tabItem {
        Label("Messages", systemImage: "envelope")
    }
    // Shows a red badge with the number — drive it from your data model
    .badge(3)
Adds a small red notification badge — just like the Mail or Messages app. Pass an integer for a count, or pass 0 to hide it. In a real app you’d use a computed property from your data instead of hardcoding a number.
.tint() Change the selected tab highlight color
TabView {
    // tabs here
}
// Apply .tint to the TabView itself to change the selected tab color
.tint(.orange)
The selected tab icon and label default to blue. Use .tint() on the TabView itself to match your app’s brand color. iOS 16+. Use .accentColor() for older target deployments.
ModifierWhat It Does
.tabItem { Label(“Title”, systemImage: “icon”) }Required — defines the tab bar icon and label for each tab
.tag(value)Assigns an identifier to a tab for programmatic switching
TabView(selection: $selectedTab)Binds a @State variable to the active tab
.badge(count)Shows a red badge number on the tab icon
.tint(.color)Changes the selected tab’s highlight color
Challenge: Build a 3-tab app: Home, Favorites, and Profile. Give each tab its own NavigationStack. Add a badge of 5 to the Favorites tab. Add a button on the Home tab that programmatically switches to the Profile tab using a @State selection binding and .tag(). Test it in the simulator.

Using AI to Go Further

Deepen Your Understanding Use AI as a tutor — no code generation required
Explain the difference between tab bar navigation and push navigation. When should I use TabView and when should I use NavigationStack? Give me 3 examples of apps that use each style and explain why that style fits those apps.
Why does each tab in a TabView need its own NavigationStack instead of sharing one? What would go wrong if all tabs shared a single NavigationStack? Help me understand the independence model for tabs.
Build a Practice View Generate a commented example you can study line by line
Write a SwiftUI TabView with 3 tabs, each containing its own NavigationStack. Include programmatic tab switching using @State and .tag(), and add a badge to one of the tabs. Comment every line — write for a beginner who is building their first multi-tab app.
Show me two TabView examples: one simple version without tags or selection binding, and one advanced version with @State selection, .tag() on each tab, and a .tint() modifier. Add inline comments explaining what the advanced version adds and why you’d need it.
4.7
Passing Data Between Screens
⏱ 20 min SwiftUI Basics

Navigation is about moving between screens. Data flow is about what travels with you. These two topics come together here — because understanding how to push to a new screen is only half the picture. The other half is making sure the right data arrives there, and knowing how to send changes back.

There are two directions data travels between screens. Forward is straightforward: you’re going somewhere and you bring what you need with you — just like passing a parameter to a function. Backward is trickier: the detail screen needs to tell the parent screen something changed. That’s where @Binding and @Environment(\.dismiss) come in.

This lesson pulls together what you’ve learned in Stage 4 with the data flow concepts from Stage 2. Let’s look at both directions with a realistic example.

import SwiftUI

struct ContentView: View {
    // @State owns the username — this is the source of truth
    @State private var username = "Alex"

    var body: some View {
        NavigationStack {
            VStack(spacing: 20) {
                Text("Hello, \(username)")
                    .font(.title)
                // Pass a binding so EditView can write back to this @State
                NavigationLink("Edit Profile") {
                    EditView(username: $username)
                }
            }
            .navigationTitle("Home")
        }
    }
}

struct EditView: View {
    // @Binding connects to the parent's @State — changes here update the parent
    @Binding var username: String

    var body: some View {
        VStack {
            // TextField with a binding — the user types and it updates instantly
            TextField("Username", text: $username)
                .textFieldStyle(.roundedBorder)
                .padding()
            Text("Preview: \(username)")
        }
        .navigationTitle("Edit Profile")
        .navigationBarTitleDisplayMode(.inline)
    }
}
Simulator showing two screens side by side: the Home screen displaying 'Hello, Alex' and an 'Edit Profile' navigation link; then the Edit Profile screen with a text field pre-filled with 'Alex' and a preview label showing the name updating as you type
LineWhat it does
@State private var username = "Alex" The parent owns the data. @State is the source of truth — only one place in your code should own each piece of data.
EditView(username: $username) Passing a binding ($username) instead of a plain value. The $ prefix creates a two-way connection — changes in EditView write back to ContentView’s @State.
@Binding var username: String The child receives a binding — not its own copy, but a reference to the parent’s @State. Changes here propagate back immediately.
TextField("Username", text: $username) TextField also takes a binding. As the user types, it writes to $username, which updates both this view and the parent at the same time.
New to Swift? The difference between passing a value and passing a binding is like the difference between handing someone a photocopy vs. letting them write on the original. A plain value (let username: String) is a copy — changes stay local. A binding (@Binding var username: String) points back to the original — changes are shared.

Data Direction Patterns

Forward: let (passing read-only data) Pass data forward that the destination won’t change
// Parent passes a value — the child gets a read-only copy
NavigationLink("View Details") {
    DetailView(item: selectedItem)
}

struct DetailView: View {
    // let = constant, can't be changed — this is display-only data
    let item: Item

    var body: some View {
        Text(item.name)
    }
}
Use a plain let property when the destination only needs to display data, not change it. This is the simplest pattern and the right default — only reach for @Binding when you need changes to travel back.
Backward: @Binding (passing editable data) Let the destination modify the parent’s data
// Parent owns the data with @State
@State private var isEnabled = true

// Pass a binding — the sheet can toggle this value
.sheet(isPresented: $showSettings) {
    SettingsSheet(isEnabled: $isEnabled)
}

struct SettingsSheet: View {
    @Binding var isEnabled: Bool

    var body: some View {
        // Toggle writes back to the parent's @State via the binding
        Toggle("Enable Feature", isOn: $isEnabled)
            .padding()
    }
}
@Binding creates a two-way connection to the parent’s @State. The child view can read and write the value, and changes are reflected everywhere the @State is used — including back in the parent.
Dismiss without returning data Close a sheet or modal without passing anything back
struct AddItemSheet: View {
    @Environment(\.dismiss) var dismiss

    var body: some View {
        VStack {
            Text("Add a new item")
            Button("Done") {
                // Save your data, then dismiss
                dismiss()
            }
        }
    }
}
When a sheet or presented view just needs to close — without returning a specific value — @Environment(\.dismiss) is the right tool. It asks the presentation system to remove this view, regardless of how it was presented.
PatternUse When
let property in destinationPassing data forward that won’t be edited
@Binding in destination + $value at call siteDestination needs to modify the parent’s data
@Environment(\.dismiss) + dismiss()Closing a modal/sheet from inside, no data to return
@State in parent + @Binding in childParent owns data, child edits it — the canonical pattern
Challenge: Build a ContentView with a list showing a person’s name and age. Add a “Edit” button that opens a sheet. Inside the sheet, show two TextFields — one for name, one for age — bound back to the parent’s @State so changes are reflected immediately on the list when the sheet is dismissed. Run in the simulator and verify the data round-trips correctly.

Using AI to Go Further

Deepen Your Understanding Use AI as a tutor — no code generation required
Explain the difference between passing a let constant and passing a @Binding to a destination view. Use a real-world analogy first, then explain technically what each one does. Don’t write code — just help me build the mental model.
Give me 5 scenarios and ask me whether I should use a plain let, a @Binding, or @Environment(\.dismiss) for each one. Ask them one at a time and tell me when I get it wrong and why.
Build a Practice View Generate a commented example you can study line by line
Write a SwiftUI ContentView that navigates to a detail view using NavigationLink, passing a String forward. The detail view should also have an Edit button that opens a sheet where the user can modify the String and have it reflected back on the ContentView. Comment every line — especially focus on explaining @State, @Binding, and the $ prefix.
Show me three complete examples side by side: one passing data forward with let, one passing data both ways with @Binding, and one using @Environment(\.dismiss) to close a sheet. Add inline comments for each, explaining the data flow direction and why each pattern is used.

Stage 4 Recap: Navigation

You’ve covered all the essential navigation patterns in SwiftUI — and your apps can now have multiple screens that are genuinely connected. That’s a real, meaningful milestone.

  • Lesson 4.1 — NavigationStack: The foundation of push/pop navigation. Wrap your content in NavigationStack and use .navigationTitle, .toolbar, and .toolbarBackground to build polished nav bars.
  • Lesson 4.2 — NavigationLink: The tap target that pushes new views. Use it with plain string labels, custom label closures, or the modern .navigationDestination(for:) pattern for data-driven navigation.
  • Lesson 4.3 — Programmatic Navigation: NavigationPath lets you drive navigation from code — append values to push screens, call removeLast to go back, and empty the path to return to root without a user tap.
  • Lesson 4.4 — Sheet and fullScreenCover: Modal presentations that slide up from the bottom. Bind to a Bool or an optional item, and use @Environment(\.dismiss) to close them from inside.
  • Lesson 4.5 — Alert and confirmationDialog: Alert for urgent 1–2 button confirmations; confirmationDialog for 3+ choices. Use role: .destructive on dangerous actions and role: .cancel for the escape route.
  • Lesson 4.6 — TabView: Side-by-side section navigation. Every tab needs a .tabItem, its own NavigationStack, and — when you need programmatic switching — a .tag() and selection binding.
  • Lesson 4.7 — Passing Data Between Screens: Pass data forward with a plain let; pass it back with @Binding. Use @Environment(\.dismiss) when you just need to close. The parent always owns the data with @State.

Go run one of this stage’s challenges in the simulator if you haven’t already. Tapping between screens, pushing detail views, dismissing sheets — these things have to be felt in a running app to really stick.

Stage 5 is Lists and Data Display — where you’ll learn to take real data and render it efficiently in lists, grids, and scroll views.

Learn SwiftUI Stage 5: Lists and Data Display

Almost every app you’ve ever used is, at its core, a list of something. Contacts, messages, songs, recipes, transactions. This is the stage where that fundamental pattern clicks — and where your apps start feeling like real products.

You’ll need Xcode open with the canvas preview running throughout this stage. There are seven lessons and roughly three hours of material. Every lesson ends with a challenge — don’t skip them. The challenges in this stage all connect to the same running project, so each one builds on the last.

By the end of Stage 5 you’ll understand how to display collections of data using List and ForEach, how to build custom row views, how to organise content with sections and headers, how to add swipe actions and pull-to-refresh, and — crucially — how to decide which display approach to reach for in any given situation.

05
Stage 5
Lists and Data Display
7 lessons · ~3 hrs
5.1
List Basics
⏱ 25 min SwiftUI Basics

Every app that shows more than one item of the same kind — a contact list, a task list, a feed — needs a way to display repeating data. In SwiftUI, the primary tool for that job is List. It wraps your content in a scrollable, system-styled table that looks right on every device and handles a lot of the work for you automatically.

You can think of a List like a vending machine shelf. Each slot holds one item of the same shape, arranged from top to bottom, and the user scrolls down to see more. You define what each slot looks like once, and SwiftUI fills in the rest.

In this lesson you’ll start with the simplest possible list — a static one with hardcoded rows — then move on to dynamic lists that read from an array. You’ll also meet the Identifiable protocol, which is how SwiftUI tells your rows apart.

import SwiftUI

struct ContentView: View {
    var body: some View {
        // List wraps its children in a scrollable, system-styled container
        List {
            // Each view inside becomes one row
            Text("Milk")
            Text("Eggs")
            Text("Bread")
            Text("Butter")
        }
    }
}
iPhone simulator showing a List with four rows: Milk, Eggs, Bread, Butter — each on a white background with a thin divider line between rows and system inset padding on the left
LineWhat it does
List { } Creates the scrollable container. Everything you put inside becomes a row.
Text("Milk") A single row. SwiftUI wraps each child view in a row automatically — you don’t have to add any row styling yourself.
The divider lines SwiftUI adds separator lines between rows for free. You don’t write any code for them.
Static vs dynamic: The example above is a static list — the rows are hardcoded directly in the view. In most real apps you’ll use a dynamic list that reads from an array, which you’ll build in the next section below.

Dynamic Lists from an Array

Hardcoded rows only work when you know every item at build time. For anything data-driven, you pass an array to List and tell it how to display each element. Here’s a grocery list built from a Swift array:

List from array of strings The simplest dynamic list — items are plain strings
struct ContentView: View {
    // An array of strings to display
    let items = ["Milk", "Eggs", "Bread", "Butter"]

    var body: some View {
        // id: \.self tells SwiftUI to use the string value itself as the unique identifier
        List(items, id: \.self) { item in
            Text(item)
        }
    }
}
Use id: \.self when your items are plain strings or other simple values. SwiftUI uses the value itself as the unique identifier for each row.
List from array of structs The standard pattern for real app data
// Conform your model to Identifiable so List can tell rows apart
struct GroceryItem: Identifiable {
    // id is required by Identifiable — must be unique for each item
    let id = UUID()
    let name: String
    let quantity: Int
}

struct ContentView: View {
    let items: [GroceryItem] = [
        GroceryItem(name: "Milk", quantity: 2),
        GroceryItem(name: "Eggs", quantity: 12),
        GroceryItem(name: "Bread", quantity: 1)
    ]

    var body: some View {
        // No need for id: parameter — List finds it automatically via Identifiable
        List(items) { item in
            Text("\(item.name) × \(item.quantity)")
        }
    }
}
When your model conforms to Identifiable, you skip the id: parameter entirely. List finds the id property on its own. UUID() generates a unique identifier every time it’s called.
List with NavigationStack Adding a navigation title above the list — very common pattern
struct ContentView: View {
    let items = ["Milk", "Eggs", "Bread"]

    var body: some View {
        // Wrap in NavigationStack to get the title bar at the top
        NavigationStack {
            List(items, id: \.self) { item in
                Text(item)
            }
            // .navigationTitle appears in the navigation bar above the list
            .navigationTitle("Groceries")
        }
    }
}
Wrapping a List in a NavigationStack is the standard iOS pattern. The large title collapses as the user scrolls down, just like Apple’s built-in apps.
New to Swift? SwiftUI is built on Swift, so a solid foundation helps. If you want to build that foundation first, check out the Learn Swift series — then come back here.

Understanding Identifiable

When SwiftUI displays a dynamic list, it needs a way to tell each row apart — especially when rows are added, removed, or reordered. That’s what Identifiable is for. It’s a protocol (a contract) that says: “my type has an id property, and that id is unique.”

The id can be any type that’s unique — a UUID, an Int, a String. The only rule is that no two items in the list share the same id. UUID() is the safest default because it generates a globally unique value every time.

SyntaxWhat It Does
List(items) { item in }Dynamic list from an Identifiable array — no id parameter needed
List(items, id: \.self) { item in }Dynamic list from a plain array using the value itself as id
List { Text(“…”) }Static list with hardcoded rows
struct Foo: IdentifiableMarks a type as having a unique id property
let id = UUID()Generates a unique identifier automatically
.navigationTitle(“Title”)Adds a large navigation title above the list
Common mistake: Forgetting Identifiable or the id: parameter when building a dynamic list. If Xcode gives you a confusing error about “initializer” or “generic”, check that first — it’s almost always a missing identifier.

Challenge 5.1

Challenge: Create a Book struct with id, title, and author properties. Make it conform to Identifiable. Build a List view that displays at least five books, showing the title in each row. Wrap the list in a NavigationStack with an appropriate title. You’ll keep expanding this book list throughout the rest of Stage 5.

Using AI to Go Further

Deepen Your Understanding Use AI as a tutor — no code generation required
Explain the Identifiable protocol to me like I’m a beginner. Why does SwiftUI’s List need each item to have a unique id? What happens if two items share the same id? Walk me through your explanation step by step without writing any code for me.
I’m confused about when to use id: \.self vs conforming to Identifiable. Can you quiz me on the difference? Ask me one question at a time and tell me when I get something wrong.
Build a Practice View Generate a commented example you can study and learn from
Write a SwiftUI view that shows a List of movies using a Movie struct that conforms to Identifiable. Add a comment on every single line explaining what it does and why — write the comments for a complete beginner who has never seen List before.
Give me three progressively more complex examples of SwiftUI List — starting from a static list of Text views, then a dynamic list from a string array, then a dynamic list from a custom Identifiable struct. Add inline comments throughout so I can follow the progression.
5.2
ForEach
⏱ 25 min SwiftUI Basics

In the last lesson you passed an array directly to List and it looped through your data automatically. Under the hood, that’s ForEach doing the work. In this lesson you’ll use it directly, which gives you a lot more flexibility.

Think of ForEach like a cookie cutter. You give it a tray of dough (your array) and a cutter shape (your row view), and it stamps out one view per item. It doesn’t know or care whether the results end up in a list, a stack, or anywhere else.

This matters because ForEach is not a scrollable container — it’s just a view builder that loops. You can drop it inside a List, a VStack, a ScrollView, or anywhere else that accepts multiple views.

struct ContentView: View {
    let fruits = ["Apple", "Banana", "Cherry", "Date"]

    var body: some View {
        List {
            // ForEach loops through the array and produces one view per item
            ForEach(fruits, id: \.self) { fruit in
                // This closure runs once for every element in the array
                Text(fruit)
            }
        }
    }
}
iPhone simulator showing a List with four rows inside a plain white background: Apple, Banana, Cherry, Date — each separated by a thin divider line
LineWhat it does
ForEach(fruits, id: \.self) Starts a loop over the fruits array. id: \.self uses each string value as its own unique identifier.
{ fruit in } A closure. The constant fruit holds the current element on each pass through the loop.
Text(fruit) The view to create for each element. SwiftUI calls this once per item in the array.
ForEach is not a loop in the traditional sense: It doesn’t execute at runtime like a for loop in Swift. It’s a SwiftUI view that describes a group of views to render. The distinction matters when you’re thinking about performance and layout.

ForEach Inside Different Containers

ForEach inside VStack No scrolling, no row styling — just stacked views
var body: some View {
    VStack(alignment: .leading) {
        // Views are stacked vertically, no scrolling, no separators
        ForEach(fruits, id: \.self) { fruit in
            Text(fruit)
                .padding(.vertical, 4)
        }
    }
}
Use ForEach inside a VStack when you want to lay out items vertically without the system list styling. Good for cards, tag clouds, or custom layouts where you don’t want the default list chrome.
ForEach inside List with extra static rows Mix dynamic and static content in the same List
List {
    // A static row above the dynamic content
    Text("Today's picks")
        .font(.headline)

    // ForEach generates one row per fruit
    ForEach(fruits, id: \.self) { fruit in
        Text(fruit)
    }

    // Another static row below
    Text("End of list")
        .foregroundStyle(.secondary)
}
This is one of the key reasons to use ForEach explicitly inside List: you can mix dynamic content with static rows, headers, footers, or other fixed views all in the same list.
ForEach with Identifiable struct Using a custom model type instead of id: \.self
struct Book: Identifiable {
    let id = UUID()
    let title: String
    let author: String
}

List {
    // No id: parameter needed — ForEach finds it via Identifiable
    ForEach(books) { book in
        Text(book.title)
    }
}
Just like with List, if your model conforms to Identifiable you can skip the id: parameter. This is the most common pattern you’ll see in real SwiftUI codebases.
ForEach with index using enumerated When you need the position of each item
List {
    // indices gives you 0, 1, 2... — useful when you need the position
    ForEach(fruits.indices, id: \.self) { index in
        // Access the element using the index
        Text("\(index + 1). \(fruits[index])")
    }
}
Use .indices when you need the row number alongside the data. Note that this approach can cause issues if items are deleted — use it for read-only lists where items won’t be removed.
SyntaxWhat It Does
ForEach(array, id: \.self) { item in }Loop over an array of simple values using the value as id
ForEach(array) { item in }Loop over an Identifiable array — no id parameter needed
ForEach inside List { }Generates dynamic rows inside a scrollable list container
ForEach inside VStack { }Generates stacked views without list chrome or scrolling
ForEach(array.indices, id: \.self) { i in }Loop with access to each item’s index position

Challenge 5.2

Challenge: Take your book list from Challenge 5.1. Replace the shorthand List(books) initializer with an explicit List { ForEach(books) { } } pattern. Then add a static row at the top of the list that says “Reading List” in a headline font. Confirm the static row appears above the dynamic rows.

Using AI to Go Further

Deepen Your Understanding Use AI as a tutor — no code generation required
What is the difference between List(items) { } and List { ForEach(items) { } } in SwiftUI? When would I choose one over the other? Explain the tradeoffs without writing any code for me.
I know that ForEach is not a real loop like a Swift for loop. Can you help me understand what it actually is in SwiftUI terms? What does “view builder” mean in this context? No code — just explanation.
Build a Practice View Generate a commented example you can study and learn from
Write a SwiftUI view that uses ForEach in two different ways: once inside a List and once inside a VStack. Use the same array for both. Add a comment on every line explaining what each part does — write it for a complete beginner.
Show me a SwiftUI List that mixes static rows and a ForEach loop in the same List block. Use a Task struct that conforms to Identifiable. Add inline comments throughout explaining what’s static and what’s dynamic and why that distinction matters.
5.3
Custom List Rows
⏱ 30 min SwiftUI Basics

A plain Text row works fine for a simple list, but most real apps need rows with more structure — a title, a subtitle, maybe an icon or image. In this lesson you’ll learn to build your own row views and organise your code in a way that scales.

Think of a custom row like a business card template. You design the layout once, and then the same card prints out for every contact in your address book, filled with different data each time. The template stays the same; only the content changes.

You’ll also learn why extracting your row into a separate struct is a good habit — even when the row is simple. It keeps your ContentView clean, makes the row reusable, and makes Xcode’s preview faster.

struct Book: Identifiable {
    let id = UUID()
    let title: String
    let author: String
    let genre: String
}

// A standalone struct that renders one book as a row
struct BookRow: View {
    // The row receives its data via a property
    let book: Book

    var body: some View {
        // HStack lays out the icon and text side by side
        HStack {
            // SF Symbol icon — no image file needed
            Image(systemName: "book.fill")
                .foregroundStyle(.blue)
                .frame(width: 32)

            // VStack stacks the title and author vertically
            VStack(alignment: .leading) {
                Text(book.title)
                    .font(.headline)
                Text(book.author)
                    .font(.subheadline)
                    .foregroundStyle(.secondary)
            }
        }
    }
}

struct ContentView: View {
    let books: [Book] = [
        Book(title: "Dune", author: "Frank Herbert", genre: "Sci-Fi"),
        Book(title: "1984", author: "George Orwell", genre: "Dystopia"),
        Book(title: "The Hobbit", author: "J.R.R. Tolkien", genre: "Fantasy")
    ]

    var body: some View {
        NavigationStack {
            List(books) { book in
                // Pass each book into the custom row view
                BookRow(book: book)
            }
            .navigationTitle("Reading List")
        }
    }
}
iPhone simulator showing a Reading List with three rows. Each row has a blue book SF Symbol icon on the left, a bold title on the first line (Dune, 1984, The Hobbit), and a smaller gray author name on the second line (Frank Herbert, George Orwell, J.R.R. Tolkien)
LineWhat it does
struct BookRow: View A custom view struct dedicated to rendering one book. It’s just a normal SwiftUI view — there’s nothing special about it being used as a row.
let book: Book The row receives its data as a property. The parent view passes in a different Book value for each row.
HStack { Image ... VStack { ... } } The layout: an icon on the left, a vertical stack of title and author text on the right.
Image(systemName: "book.fill") An SF Symbol — Apple’s built-in icon library. No image assets needed. You can browse all symbols in the free SF Symbols app.
BookRow(book: book) Passes the current loop element into the row. SwiftUI creates one BookRow for every item in the array.
Extract early, extract often: Even if your row only has two lines of code, putting it in its own struct pays off quickly. Your ContentView stays readable, you can preview the row in isolation, and if you need the same row shape elsewhere you already have it.

Row Layout Patterns

Icon + title + disclosure Classic iOS Settings-style row
struct SettingsRow: View {
    let icon: String
    let title: String
    let color: Color

    var body: some View {
        Label(title, systemImage: icon)
            .foregroundStyle(color)
    }
}
Label is a convenient shorthand for an icon + text pair. The system automatically applies the right spacing and sizing for a list row context.
Leading image + trailing detail Push a secondary value to the far right
HStack {
    VStack(alignment: .leading) {
        Text(book.title).font(.headline)
        Text(book.author).font(.caption)
    }
    // Spacer() pushes everything after it to the trailing edge
    Spacer()
    Text(book.genre)
        .font(.caption)
        .foregroundStyle(.secondary)
}
Spacer() inside an HStack pushes views apart. Place it between your main content and your trailing detail to create the classic left-content / right-detail row layout.
Square thumbnail + multi-line text Image-led row common in media apps
HStack(spacing: 12) {
    // Rounded square thumbnail using a system image as a placeholder
    Image(systemName: "photo")
        .resizable()
        .scaledToFill()
        .frame(width: 56, height: 56)
        .clipShape(RoundedRectangle(cornerRadius: 8))
        .background(Color.gray.opacity(0.2))

    VStack(alignment: .leading, spacing: 2) {
        Text(book.title).font(.headline)
        Text(book.author).font(.subheadline).foregroundStyle(.secondary)
        Text(book.genre).font(.caption).foregroundStyle(.tertiary)
    }
}
This three-line layout with a fixed-size thumbnail is common in music, podcast, and book apps. .clipShape(RoundedRectangle(...)) gives the image rounded corners without needing a custom overlay.
PatternWhen to Use It
Text onlySimple data where the text is all that matters
HStack + icon + VStackMost common row layout — icon left, title+subtitle right
HStack + Spacer() + trailing TextWhen you need a value on the far right (price, date, status)
HStack + thumbnail + VStackMedia apps where artwork is part of the identity
Label(title, systemImage:)Quick icon + text when you don’t need custom layout
Common mistake: Putting too much logic inside the row body. If a row starts doing calculations, fetching data, or running more than a couple of modifiers per view, extract sub-components or move logic into a view model. Rows should describe layout, not compute values.

Challenge 5.3

Challenge: Extract your book row into its own BookRow struct. Update the row layout to show a book SF Symbol icon on the left, the title in headline font, and the author in subheadline font beneath it. Add a genre label pushed to the trailing edge using Spacer(). Add an #Preview for BookRow with sample data so you can see it in isolation.

Using AI to Go Further

Deepen Your Understanding Use AI as a tutor — no code generation required
Why is it a good idea to extract a List row into its own SwiftUI struct, even when the row is simple? What are the practical benefits in terms of code organisation, previewing, and reuse? Explain this without writing any code — I want to understand the principle first.
I’m building a custom list row with HStack, VStack, and Spacer. Can you help me think through when I’d use each one? Ask me a few questions about what my row should look like, then guide me to the right combination — don’t write the code, just guide my thinking.
Build a Practice View Generate a commented example you can study and learn from
Write a SwiftUI ContactRow view that displays a contact’s name, email, and a phone icon SF Symbol. Use HStack and VStack to lay it out. Add a comment on every single line explaining what it does and why — write the comments for someone who is still learning layout in SwiftUI.
Show me three different custom row layouts for a movie list: one text-only, one with an icon, and one with a thumbnail placeholder and trailing rating. Add inline comments explaining the layout choice for each one.
5.4
Sections and Headers
⏱ 20 min SwiftUI Basics

As your lists grow, a flat collection of rows can feel overwhelming. Section is SwiftUI’s way of grouping related rows together under a shared header, just like Contacts groups names alphabetically or Settings groups controls by category.

Think of sections like chapters in a book. Each chapter has a title that tells you what’s coming, and all the related content sits under it. The table of contents gives you the big picture; you drill into whatever chapter you need.

Sections work inside any List. You can have as many as you need, and each one can have its own header, footer, or both.

struct ContentView: View {
    let reading = ["Dune", "1984"]
    let finished = ["The Hobbit", "Ender's Game", "Foundation"]

    var body: some View {
        NavigationStack {
            List {
                // Section groups related rows and adds a header label above them
                Section("Currently Reading") {
                    ForEach(reading, id: \.self) { title in
                        Text(title)
                    }
                }
                // A second Section creates a separate group below
                Section("Finished") {
                    ForEach(finished, id: \.self) { title in
                        Text(title)
                    }
                }
            }
            .navigationTitle("My Books")
        }
    }
}
iPhone simulator showing a grouped list with two sections. The first section has a 'Currently Reading' header above two rows: Dune and 1984. The second section has a 'Finished' header above three rows: The Hobbit, Ender's Game, Foundation. Each section is visually separated from the other.
LineWhat it does
Section("Currently Reading") { } Creates a section with a header string. All views inside the closure become rows in that section.
The second Section Adds another group. SwiftUI renders a visual gap and a new header between sections automatically.
Header text is automatically styled: SwiftUI capitalises and styles the section header string to match the platform conventions. You don’t need to set fonts or colors on it unless you want to customise the appearance.

Section Variations

Section with header and footer Add explanatory text above and below a group
Section {
    Toggle("Notifications", isOn: $notificationsOn)
    Toggle("Sounds", isOn: $soundsOn)
} header: {
    // Custom header view — can be any view, not just Text
    Text("Alerts")
} footer: {
    // Footer appears below the section in smaller muted text
    Text("Notifications will appear on your lock screen.")
}
The header: and footer: parameters accept any view — not just a Text string. Footers are great for adding context below a group of settings or form fields.
.listStyle(.grouped) Inset grouped style — the standard modern look
List {
    Section("Account") { /* rows */ }
    Section("Privacy") { /* rows */ }
}
// .insetGrouped is the default on iOS 16+ — explicitly set for clarity
.listStyle(.insetGrouped)
SwiftUI’s default list style on iOS is .insetGrouped, which gives each section a rounded card appearance with inset margins. Other options include .plain, .grouped, and .sidebar.
Section with custom header view Replace the plain text header with a styled view
Section {
    ForEach(books) { book in BookRow(book: book) }
} header: {
    // Any view works as a header — not just a plain string
    HStack {
        Image(systemName: "star.fill")
            .foregroundStyle(.yellow)
        Text("Top Picks")
    }
}
Replacing the header string with a full view lets you add icons, colors, or any layout. Note that SwiftUI may apply automatic styling to list headers — you might need .textCase(nil) to prevent automatic uppercasing.
SyntaxWhat It Does
Section(“Header”) { }Creates a section with a plain text header
Section { } header: { } footer: { }Section with custom header and footer views
.listStyle(.insetGrouped)Rounded card sections with inset margins (default on iOS)
.listStyle(.plain)No section card styling — full width separators
.listStyle(.grouped)Full-width sections, older iOS style
Text(“header”).textCase(nil)Prevents automatic uppercasing on section headers

Challenge 5.4

Challenge: Add a status property to your Book struct with values like "Reading", "Finished", and "Want to Read". Reorganise your list into three sections — one per status — using Section. Filter the books array using Swift’s .filter() to populate each section with matching books. Add a footer to the “Finished” section showing the count of books completed.

Using AI to Go Further

Deepen Your Understanding Use AI as a tutor — no code generation required
What is the difference between .listStyle(.plain), .listStyle(.grouped), and .listStyle(.insetGrouped) in SwiftUI? When would I choose each one? Describe what each looks like visually — no code needed, just explain in plain English.
I want to use Section headers that show an icon alongside the text, but SwiftUI keeps applying automatic uppercase styling. Can you explain why that happens and what options I have to control it? Walk me through the concepts before I try to write any code.
Build a Practice View Generate a commented example you can study and learn from
Write a SwiftUI List with three Sections — “Breakfast”, “Lunch”, and “Dinner” — each containing a ForEach of meal names. Add a header and footer to each section. Add a comment on every line explaining what it does and why — write the comments for a complete beginner.
Show me a SwiftUI settings screen (like iOS Settings) using List with at least three Section groups. Each section should have a header, some Toggle or Text rows, and a footer. Use inline comments throughout to explain how Sections create that visual grouping.
5.5
Swipe Actions
⏱ 20 min SwiftUI Basics

Swipe-to-delete is one of the most recognisable iOS interactions. Users swipe left on a row and a red delete button appears. Swipe far enough and the row vanishes. SwiftUI makes this surprisingly easy — and gives you the hooks to add your own custom swipe actions too.

Think of swipe actions like the back of a drawer. The front face (the row) is what users always see. Swiping reveals what’s hidden behind it — delete, archive, flag, whatever you put there. The row itself doesn’t change; you’re just exposing tools attached to it.

In this lesson you’ll add delete with .onDelete, then build custom swipe actions using .swipeActions. You’ll also see how EditButton enables a system-style edit mode with selection circles.

struct ContentView: View {
    // @State makes the array mutable so we can remove items
    @State private var books = ["Dune", "1984", "The Hobbit"]

    var body: some View {
        NavigationStack {
            List {
                ForEach(books, id: \.self) { book in
                    Text(book)
                }
                // .onDelete tells ForEach what to do when a row is swiped-to-delete
                .onDelete(perform: deleteBook)
            }
            .navigationTitle("Books")
            // EditButton toggles edit mode — shows selection circles on rows
            .toolbar { EditButton() }
        }
    }

    // This function receives the IndexSet of rows to remove
    func deleteBook(at offsets: IndexSet) {
        // .remove(atOffsets:) removes the elements at the given positions
        books.remove(atOffsets: offsets)
    }
}
iPhone simulator showing a Books list with three rows. The middle row (1984) is swiped left, revealing a red Delete button on the trailing edge. The delete button shows a trash can icon and the word Delete.
LineWhat it does
@State private var books The array must be @State (mutable and owned by this view) for deletion to work. When the array changes, SwiftUI automatically re-renders the list.
.onDelete(perform: deleteBook) Attaches the delete gesture to the ForEach. When a row is deleted, SwiftUI calls your function with an IndexSet of affected positions.
IndexSet A set of integer index positions. Usually just one position (the row the user deleted), but can be multiple in edit mode.
books.remove(atOffsets: offsets) Removes the elements at those positions from the array. Because books is @State, the view updates immediately.
EditButton() A system button that toggles the list’s edit mode. In edit mode, rows show delete controls and can be reordered.

Custom Swipe Actions

.swipeActions Add your own buttons to the trailing swipe area
ForEach(books) { book in
    BookRow(book: book)
        // .swipeActions adds buttons revealed by swiping left (trailing)
        .swipeActions(edge: .trailing) {
            // Each Button inside becomes one swipe action
            Button(role: .destructive) {
                deleteBook(book)
            } label: {
                Label("Delete", systemImage: "trash")
            }
        }
        // .swipeActions on leading edge reveals on swipe right
        .swipeActions(edge: .leading) {
            Button {
                toggleFavourite(book)
            } label: {
                Label("Favourite", systemImage: "star.fill")
            }
            .tint(.yellow)
        }
}
You can add swipe actions to both edges. role: .destructive automatically colours the button red. Use .tint() to set a custom colour for non-destructive actions.
.onMove Let users reorder rows in edit mode
ForEach(books, id: \.self) { book in
    Text(book)
}
.onDelete(perform: deleteBook)
// .onMove enables drag handles in edit mode for reordering
.onMove(perform: moveBook)

func moveBook(from source: IndexSet, to destination: Int) {
    // .move(fromOffsets:toOffset:) updates the array in place
    books.move(fromOffsets: source, toOffset: destination)
}
.onMove shows drag handles (three horizontal lines) in edit mode. The user long-presses and drags a row to reorder. Your function updates the underlying array and SwiftUI animates the change.
SyntaxWhat It Does
.onDelete(perform:)Adds swipe-to-delete to a ForEach — calls your function with an IndexSet
books.remove(atOffsets: offsets)Removes elements from the array at the given index positions
.swipeActions(edge: .trailing)Adds custom buttons revealed by swiping left
.swipeActions(edge: .leading)Adds custom buttons revealed by swiping right
Button(role: .destructive)Automatically styles the button red — use for delete actions
.onMove(perform:)Enables drag-to-reorder in edit mode
EditButton()System button that toggles list edit mode
Common mistake: Using let instead of @State for your array. If the array isn’t @State, removing items won’t update the list — you’ll get a crash or no visual change at all. Any list you plan to modify must use @State.

Challenge 5.5

Challenge: Convert your books array from let to @State. Add swipe-to-delete using .onDelete. Then add a leading swipe action that toggles a isFavourite bool on each book, changing the row’s icon to a filled star when true. Add an EditButton to the toolbar and confirm that edit mode shows delete controls.

Using AI to Go Further

Deepen Your Understanding Use AI as a tutor — no code generation required
Explain what IndexSet is in Swift and why SwiftUI uses it when deleting list rows. What information does it carry? Why is it a Set rather than a single Int? Explain this without writing any code for me.
I’m confused about the difference between .onDelete on a ForEach and .swipeActions on a view inside a ForEach. When would I use each approach? What can .swipeActions do that .onDelete can’t? Walk me through the tradeoffs.
Build a Practice View Generate a commented example you can study and learn from
Write a SwiftUI task list that supports swipe-to-delete and a leading swipe action to mark a task as complete. Use a Task struct with Identifiable and a isComplete Bool. Add a comment on every single line explaining what it does and why — write for a beginner who hasn’t used @State or swipe actions before.
Show me three examples of swipe actions in SwiftUI: one using .onDelete only, one using .swipeActions with a destructive button, and one with both a leading and trailing swipe action. Add inline comments explaining when each approach is the right choice.
5.6
Pull to Refresh
⏱ 15 min SwiftUI Basics

Pull-to-refresh is the gesture where a user drags down from the top of a list, sees a loading spinner, and the content updates. You’ve done it a thousand times in Twitter, Mail, and News. In SwiftUI, you add it with a single modifier: .refreshable.

The reason this lesson is short is because .refreshable does almost everything for you: it handles the gesture, shows the spinner, and waits for your work to finish before hiding it again. Your job is just to describe what “refreshing” means for your app.

The one thing to know upfront: .refreshable uses Swift’s async/await syntax. You’ll get a proper deep dive on async/await in Stage 8 (Networking). For now, you just need to know how the pattern looks and what the keywords mean at a surface level.

struct ContentView: View {
    @State private var books = ["Dune", "1984", "The Hobbit"]

    var body: some View {
        NavigationStack {
            List(books, id: \.self) { book in
                Text(book)
            }
            .navigationTitle("Books")
            // .refreshable runs when the user pulls down from the top of the list
            .refreshable {
                // await pauses here and waits for fetchLatestBooks() to finish
                await fetchLatestBooks()
            }
        }
    }

    // async marks a function that can be paused mid-execution
    func fetchLatestBooks() async {
        // Simulate a network delay — in a real app this calls an API
        try? await Task.sleep(for: .seconds(1))
        // Update the array after the fake "network call" completes
        books = ["Dune", "1984", "The Hobbit", "Foundation"]
    }
}
iPhone simulator showing a Books list being pulled down from the top. A circular loading spinner appears above the first row while the refresh is in progress. The navigation title 'Books' is visible at the top.
LineWhat it does
.refreshable { } Attaches the pull-to-refresh gesture to the list. The closure runs when the user pulls down. SwiftUI shows a spinner automatically until the closure finishes.
await fetchLatestBooks() await pauses execution here and waits for the async function to complete before continuing. The spinner stays visible the whole time.
func fetchLatestBooks() async The async keyword marks a function that can be paused and resumed. It can contain await calls to other async work.
Task.sleep(for: .seconds(1)) Pauses the async function for 1 second to simulate a network delay. In a real app, you’d call your API here instead.
Async/await in depth: This lesson gives you the surface-level pattern. In Stage 8 you’ll learn async/await properly — what it means, why it exists, and how to use it to call real APIs and parse real data. For now, just know that async on a function means it can do work in the background, and await means “wait here until that work is done.”
.refreshable with @MainActor Ensuring UI updates happen on the main thread
.refreshable {
    // Always update @State properties from the main thread
    await loadData()
}

// @MainActor ensures this function always runs on the main thread
@MainActor
func loadData() async {
    let newBooks = await fetchFromAPI()
    books = newBooks
}
SwiftUI views must be updated on the main thread. Using @MainActor on a function guarantees this. You’ll use this pattern frequently once you start calling real APIs in Stage 8.
SyntaxWhat It Does
.refreshable { await … }Attaches pull-to-refresh to a List or ScrollView
func name() asyncMarks a function as asynchronous — it can be paused
await someFunction()Waits for an async function to complete before continuing
try? await Task.sleep(for: .seconds(n))Pauses async execution for n seconds — useful for simulating delays
@MainActorEnsures a function runs on the main thread — required for UI updates
Common mistake: Updating @State from a background thread. If your async function does real network work and updates state from the wrong thread, you’ll see warnings in Xcode. Use @MainActor on your update function to fix it. More on this in Stage 8.

Challenge 5.6

Challenge: Add .refreshable to your book list. When the user pulls to refresh, add a new book to the array after a one-second simulated delay using Task.sleep. Run the app in the simulator and confirm the spinner appears for the full second before the new row appears.

Using AI to Go Further

Deepen Your Understanding Use AI as a tutor — no code generation required
Explain async/await to me in plain English — like you’re teaching a beginner who has never heard of concurrency. What problem does it solve? What do async and await each mean in simple terms? Use a real-world analogy before any technical explanation.
Why does SwiftUI’s .refreshable modifier require async/await? Why can’t you just write a regular function? Help me understand the connection between refreshable and async work — don’t write any code, just explain the relationship.
Build a Practice View Generate a commented example you can study and learn from
Write a SwiftUI List of news headlines that supports pull-to-refresh. Simulate a 1.5 second loading delay and then update the array with new items. Add a comment on every line explaining what it does — write for a beginner who hasn’t used async/await before and needs the async keyword explained in every place it appears.
Show me a SwiftUI .refreshable example that correctly uses @MainActor to update state after async work. Add inline comments explaining why @MainActor is needed, what “main thread” means, and what could go wrong without it.
5.7
When to Use List vs ForEach vs ScrollView
⏱ 20 min SwiftUI Basics

By now you’ve used List, ForEach, and you’ve probably seen ScrollView mentioned. They all involve displaying collections of views, so it’s easy to reach for the wrong one and wonder why something isn’t working. This lesson is entirely about making that decision confidently.

There are no new APIs in this lesson — just clarity. You’ll see what each approach does and doesn’t do, the kinds of bugs that happen when you pick the wrong one, and a simple set of questions to ask yourself when you’re starting a new screen.

Getting this right early saves a lot of debugging time later. Choosing ScrollView + ForEach when you needed List, or vice versa, produces layout headaches that can feel mysterious until you understand the difference.

The Three Tools at a Glance

ToolWhat it isWhat it gives you
List A scrollable container with built-in system row styling Separators, swipe actions, edit mode, selection, pull-to-refresh, section headers — all automatic
ForEach A view builder that loops — no container, no scrolling Nothing except the generated views. Must be placed inside a container (List, VStack, ScrollView, etc.)
ScrollView A scrollable container with no row styling Scrolling, full layout control. You’re responsible for all spacing, dividers, and interaction.

When to Use Each One

Use List when… You want system iOS row behaviour out of the box
// List is the right choice when you need any of these:
// - Swipe to delete or custom swipe actions
// - Edit mode with selection or reordering
// - Pull to refresh
// - Section headers with the platform-standard appearance
// - NavigationLink rows that push a detail view
List(contacts) { contact in
    NavigationLink(contact.name, value: contact)
}
.swipeActions { /* ... */ }
.refreshable { await reload() }
If you want any of the standard iOS list behaviours — swipe actions, edit mode, pull-to-refresh, NavigationLink rows — reach for List. Trying to replicate these behaviours manually in a ScrollView is a lot of unnecessary work.
Use ScrollView + ForEach when… You need custom layout that List can’t provide
// ScrollView is right when you need:
// - Custom card layouts (no row separators or inset styling)
// - Horizontal scrolling
// - Mixed content types (banners, cards, text, images together)
// - Full control over spacing and visual styling
ScrollView {
    VStack(spacing: 16) {
        ForEach(recipes) { recipe in
            RecipeCard(recipe: recipe)
        }
    }
    .padding()
}
Use ScrollView + ForEach when the design calls for cards, horizontal carousels, or mixed layouts that don’t fit the row/separator pattern of List. You have complete visual control, but you give up all the automatic List features.
Use ForEach alone when… You’re generating views inside an existing container
// ForEach alone (no ScrollView or List wrapper) is right when:
// - You're generating views inside a Form, Group, or HStack
// - The parent container already handles layout and scrolling
// - You need to mix generated and static views in one block
Form {
    Section("Tags") {
        // ForEach generates Toggle rows inside an existing Form container
        ForEach(tags) { tag in
            Toggle(tag.name, isOn: $tag.isSelected)
        }
    }
}
When you already have a container — a Form, a Group, or even another ListForEach alone is exactly right. Don’t wrap it in another List or ScrollView just because you’re generating multiple views.

Common Mistakes and What Goes Wrong

MistakeWhat happensFix
Using List when you want full layout control Row separators, inset margins, and system styling appear where you don’t want them Switch to ScrollView + VStack + ForEach
Using ScrollView + ForEach then adding swipe-to-delete manually Lots of code, inconsistent feel, won’t match system behaviour Use List with .onDelete
Nesting List inside ScrollView Layout breaks, scrolling conflicts, unpredictable heights Never nest a List inside a ScrollView
Using ForEach without a scrollable container for long content Content overflows off screen with no way to scroll Wrap in ScrollView or List
Using ScrollView when you need List selection Selection highlighting and edit mode don’t work Use List(selection:) for selectable lists
The decision question: Ask yourself: “Does this screen need any built-in iOS list behaviour — swipe actions, edit mode, selection, pull-to-refresh, NavigationLink rows?” If yes, use List. If no, use ScrollView + ForEach for the layout control you need.
ScenarioBest Choice
Settings screen with toggles and navigation linksList
Social feed with photo cards and custom spacingScrollView + VStack + ForEach
Horizontal scrolling carousel of itemsScrollView(.horizontal) + HStack + ForEach
Task list with swipe-to-complete and deleteList
Toggle rows inside a Form sectionForEach inside Form Section (no wrapper needed)
Grid of image thumbnailsLazyVGrid (covered in Stage 9)
Contact list with A–Z section headersList with Section per letter

Challenge 5.7

Challenge: Build a second view alongside your book list — a “Featured Books” card strip that shows the same books in a horizontal ScrollView with custom card styling (no list separators). Use ForEach inside a HStack inside a horizontal ScrollView. Notice how different the same data looks when presented through each approach.

Using AI to Go Further

Deepen Your Understanding Use AI as a tutor — no code generation required
I keep confusing List, ForEach, and ScrollView in SwiftUI. Can you quiz me on when to use each one? Give me a scenario, I’ll pick the right tool, and you tell me if I’m right and why. Go through at least five scenarios.
What actually happens internally when you nest a List inside a ScrollView in SwiftUI? Why does it break? Explain the underlying reason — not just “don’t do it” — so I understand what the conflict is. No code needed.
Build a Practice View Generate a commented example you can study and learn from
Write a SwiftUI screen that shows the same array of movies in two ways: a List at the top half and a horizontal ScrollView of cards at the bottom half. Add a comment on every line explaining the layout choice — write it for someone trying to understand when to use List vs ScrollView.
Show me a SwiftUI example of each mistake from the “common mistakes” list: List inside ScrollView, ForEach without a scrollable container, and ScrollView when List was needed. For each, show the problem code with a comment explaining what goes wrong, then show the corrected version.

Stage 5 Recap: Lists and Data Display

Seven lessons in and you can now build the data-display layer that sits at the heart of almost every real iOS app. Here’s what you covered:

  • Lesson 5.1 — List Basics: List creates a scrollable, system-styled container. Dynamic lists read from arrays, and Identifiable gives SwiftUI the unique IDs it needs to track each row.
  • Lesson 5.2 — ForEach: ForEach is a view builder, not a container. Use it inside List, VStack, or ScrollView to generate multiple views from an array.
  • Lesson 5.3 — Custom List Rows: Extract row layouts into their own structs — even simple ones. Use HStack, VStack, Spacer(), and SF Symbols to build any row shape you need.
  • Lesson 5.4 — Sections and Headers: Section groups related rows under a shared header. Add footers, customise headers with any view, and use .listStyle() to control the overall appearance.
  • Lesson 5.5 — Swipe Actions: .onDelete on a ForEach adds swipe-to-delete. .swipeActions lets you build custom leading and trailing swipe buttons. Arrays must be @State for modifications to update the UI.
  • Lesson 5.6 — Pull to Refresh: .refreshable adds the pull-to-refresh gesture. It requires async/await, which you’ll fully explore in Stage 8. For now, the pattern is the important thing.
  • Lesson 5.7 — List vs ForEach vs ScrollView: Use List when you need system iOS behaviours. Use ScrollView + ForEach for custom layouts. Use ForEach alone when you’re generating views inside an existing container.

If you skipped any of the challenges, go back and do them — they’re all part of the same book list project and each one builds on the last. Running code you wrote yourself is the fastest way to make everything stick.

In Stage 6 you’ll move into Forms and User Input — how to collect text, toggle settings, pick dates, and build the kind of data-entry screens that every app needs.

Learn SwiftUI Stage 6: Forms and User Input

Reading data is one thing — letting users create and change it is what makes an app actually useful. In this stage, you’ll build the skills to accept real input from real people.

You’ll need Xcode open with a new SwiftUI project for this stage. One important note before you begin: most of the controls in this stage work best when tested in the iOS Simulator rather than the canvas preview. The keyboard won’t appear in canvas, so running in the simulator lets you see the full experience. This stage has 7 lessons and takes roughly 2.5 hours — work through them in order, and don’t skip the challenges at the end of each lesson. The challenge builds are where the concepts actually stick.

By the end of Stage 6, you’ll be comfortable working with TextField and SecureField for text input, Toggle, Slider, and Stepper for binary and numeric values, Picker and DatePicker for selections, Form and Section for settings-style layouts, @FocusState for keyboard management, and practical input validation patterns that disable buttons and show inline errors.

06
Stage 6
Forms and User Input
7 lessons · ~2.5 hrs
6.1
TextField and SecureField
⏱ 30 min SwiftUI Basics

Every app that asks for a name, an email address, or a password uses a text field. In SwiftUI, TextField is the view that lets users type free-form text. It looks simple on the surface — a box you can type into — but there’s a lot going on underneath: it’s connected to a piece of state, it responds to the keyboard, and you can configure it in ways that make a real difference to the user experience.

The key thing to understand about TextField is that it’s always bound to a String variable. That binding is a two-way connection: when the user types, the variable updates. When the variable changes in code, the text field updates. That’s the same @State and Binding pattern you’ve already used with buttons and toggles — text input is just another form of the same idea.

SecureField is a close cousin of TextField. It works exactly the same way — same binding, same modifiers — but it hides the characters the user types. You use it for passwords. Once you understand TextField, using SecureField is almost no extra work at all.

import SwiftUI

struct LoginView: View {

    // Store the text the user types into the email field
    @State private var email = ""

    // Store the text the user types into the password field
    @State private var password = ""

    var body: some View {
        VStack(spacing: 16) {

            // TextField takes a placeholder string and a binding to a String variable
            TextField("Email address", text: $email)
                // Style it as a rounded rectangle input box
                .textFieldStyle(.roundedBorder)
                // Tell iOS this is an email address so it shows the right keyboard
                .keyboardType(.emailAddress)
                // Disable autocorrect — no one wants autocorrect on an email
                .autocorrectionDisabled()

            // SecureField hides the typed characters — perfect for passwords
            SecureField("Password", text: $password)
                .textFieldStyle(.roundedBorder)

            // A disabled button that only activates when both fields have content
            Button("Log In") {
                print("Logging in as \(email)")
            }
            .disabled(email.isEmpty || password.isEmpty)
        }
        .padding()
    }
}
Simulator screenshot showing a VStack with two rounded-border text fields — one labelled 'Email address' containing typed text 'hello@example.com', and one labelled 'Password' with dots masking the entered characters — and a blue Log In button below, active because both fields have content.
LineWhat it does
@State private var email = "" Declares a State variable that holds whatever the user types. It starts as an empty string. SwiftUI re-renders the view whenever this value changes.
TextField("Email address", text: $email) Creates a text field. The first argument is the placeholder — the greyed-out hint text shown when the field is empty. text: $email creates the two-way binding to your State variable.
.textFieldStyle(.roundedBorder) Applies a rounded rectangle border around the field. Without this the field has no visible border and looks invisible against a white background.
.keyboardType(.emailAddress) Tells iOS to show the email keyboard — which has @, a .com shortcut, and no space bar prominence. The right keyboard type improves user experience significantly.
.autocorrectionDisabled() Prevents iOS from trying to autocorrect the user’s typing. Essential for email addresses, usernames, and any field where autocorrect would cause frustration.
SecureField("Password", text: $password) Identical to TextField in every way except the typed characters are replaced with dots. Uses the same binding pattern. No additional configuration needed for password masking.
.disabled(email.isEmpty || password.isEmpty) Disables the button when either field is empty. isEmpty is a property on String that returns true when the string has no characters. This prevents the user from submitting an incomplete form.
Common mistake: Forgetting the $ before the variable name in text: $email. Without the dollar sign, you’re passing the current value of the string — not the binding. SwiftUI will show you a compile error, but it’s easy to miss when you’re in flow. The $ is what makes the connection two-way.

TextField Modifiers Worth Knowing

.keyboardType() Match the keyboard to what the field expects
// Email address keyboard — includes @ and .com
TextField("Email", text: $email)
    .keyboardType(.emailAddress)

// Numeric pad — digits only, no letters
TextField("Phone number", text: $phone)
    .keyboardType(.phonePad)

// Number pad with decimal point — good for prices
TextField("Amount", text: $amount)
    .keyboardType(.decimalPad)
Choose a keyboard type that matches the expected input. Using the wrong type — like a default QWERTY keyboard for a phone number — adds friction for your user. Common options: .default, .emailAddress, .numberPad, .phonePad, .decimalPad, .URL.
.textContentType() Enable AutoFill and password suggestions
// Tells iOS this is an email field — enables AutoFill from Contacts
TextField("Email", text: $email)
    .textContentType(.emailAddress)

// Tells iOS this is a new password — triggers strong password suggestion
SecureField("Create password", text: $password)
    .textContentType(.newPassword)

// Tells iOS this is an existing password — enables Keychain AutoFill
SecureField("Password", text: $password)
    .textContentType(.password)
This modifier tells iOS what kind of data the field contains. iOS uses it to offer AutoFill from Keychain, Contacts, and the strong password generator. It’s a small addition that makes your app feel professional and reduces friction for the user.
.submitLabel() Change what the return key says
// "Next" on the return key — great for multi-field forms
TextField("First name", text: $firstName)
    .submitLabel(.next)

// "Search" on the return key
TextField("Search", text: $query)
    .submitLabel(.search)

// "Done" on the return key — signals end of input
TextField("Notes", text: $notes)
    .submitLabel(.done)
The return key label is a small detail with a big impact on usability. Options include .done, .next, .search, .send, .go, .continue, and .join. Pair with .onSubmit to handle the tap.
.autocapitalization() Control how iOS auto-capitalises input
// No autocapitalisation — good for usernames and emails
TextField("Username", text: $username)
    .textInputAutocapitalization(.never)

// Capitalise the first letter of each word — good for names
TextField("Full name", text: $name)
    .textInputAutocapitalization(.words)

// Capitalise every sentence (the default)
TextField("Bio", text: $bio)
    .textInputAutocapitalization(.sentences)
Use .never for emails, usernames, passwords, and URLs. Use .words for names and titles. The default .sentences is good for free-text notes and bio fields.
Modifier / PropertyWhat It Does
TextField(“placeholder”, text: $var)Creates a text input bound to a String State variable
SecureField(“placeholder”, text: $var)Same as TextField but hides typed characters with dots
.textFieldStyle(.roundedBorder)Adds a visible rounded rectangle border around the field
.keyboardType(.emailAddress)Shows a keyboard suited to the expected input type
.textContentType(.password)Enables AutoFill from Keychain or Contacts
.submitLabel(.next)Changes the text on the keyboard’s return key
.autocorrectionDisabled()Turns off autocorrect for this field
.textInputAutocapitalization(.never)Controls whether iOS capitalises typed text automatically
New to Swift? SwiftUI is built on Swift, so a solid foundation helps. If you want to build that foundation first, check out the Learn Swift series — then come back here.
🎯
Challenge 6.1
Build a Sign-Up Form

Build a sign-up screen with three fields: first name, email address, and password. Use the appropriate keyboard types and autocapitalisation settings for each. Add a “Create Account” button that is disabled until all three fields contain at least one character. Test in the simulator — type into each field and confirm the button activates only when all three have content. Bonus: add a “Confirm Password” SecureField and update the button logic to also require that the two password fields match.

Using AI to Go Further

Deepen Your Understanding Use AI as a tutor — no code generation required
Explain how @State and binding work together in a SwiftUI TextField. Use a plain-English analogy first, then walk me through what actually happens step by step when a user types a character — without writing any code.
What’s the difference between keyboardType and textContentType in SwiftUI? I keep confusing the two. Can you explain each one’s purpose and give me a table of when I’d use each?
Build a Practice View Generate a commented example you can study and run
Write a SwiftUI view with a TextField and a SecureField that forms a simple login screen. Add a comment on every single line explaining what it does and why — write the comments for a complete beginner. Include the @State variables, the binding syntax, and at least two modifiers on each field.
Give me three versions of a SwiftUI TextField setup — basic, intermediate, and advanced — each slightly more complex than the last. Add inline comments throughout. The advanced version should show how to handle submission with onSubmit and submitLabel together.
6.2
Toggle, Slider, and Stepper
⏱ 25 min SwiftUI Basics

Not every piece of user input is text. Sometimes you just want to know if something is on or off. Sometimes you need a number between two limits. SwiftUI has a control for each of these situations: Toggle for booleans, Slider for a value within a range, and Stepper for stepping up and down through a number by a fixed amount.

All three controls follow the same binding pattern you used in Lesson 6.1. Toggle binds to a Bool. Slider and Stepper bind to a numeric type — most commonly a Double. The control reads the current value to know what to display, and it writes back to the variable when the user interacts with it.

Think of these controls as physical dials and switches on a settings panel, but digital. The binding is the wire connecting the dial to the part of your app that actually cares about the value. The control is just a way for the user to change what’s on the other end of that wire.

import SwiftUI

struct SettingsView: View {

    // A boolean — Toggle will flip this between true and false
    @State private var notificationsEnabled = true

    // A Double between 0 and 100 — Slider will set this
    @State private var brightness: Double = 50

    // An Int that Stepper will increment and decrement
    @State private var fontSize = 16

    var body: some View {
        VStack(alignment: .leading, spacing: 24) {

            // Toggle takes a label string and a Bool binding
            Toggle("Enable Notifications", isOn: $notificationsEnabled)

            VStack(alignment: .leading) {
                // Display the current slider value so the user can see it
                Text("Brightness: \(Int(brightness))%")

                // Slider takes a value binding and an in: range
                Slider(value: $brightness, in: 0...100)
            }

            VStack(alignment: .leading) {
                Text("Font Size: \(fontSize)pt")

                // Stepper takes a label, a value binding, and an in: range
                Stepper("Font Size", value: $fontSize, in: 10...32)
            }
        }
        .padding()
    }
}
Simulator screenshot showing three controls in a VStack: a Toggle labelled 'Enable Notifications' in the on position (blue switch), a Text label showing 'Brightness: 72%' above a Slider with the thumb roughly three-quarters to the right, and a Text label showing 'Font Size: 16pt' above a Stepper with plus and minus buttons.
LineWhat it does
Toggle("Enable Notifications", isOn: $notificationsEnabled) Creates an iOS-style switch. The label text appears to the left of the switch. isOn: takes a binding to a Bool — true means the switch is on (blue), false means it’s off (grey).
Slider(value: $brightness, in: 0...100) Creates a horizontal drag handle. value: takes a binding to a Double. in: defines the closed range. As the user drags, the Double updates continuously.
Int(brightness) Converts the Double to an Int before displaying it, so you see “72%” rather than “72.456382943%”. This is a common pattern when displaying a Slider’s value as text.
Stepper("Font Size", value: $fontSize, in: 10...32) Creates a plus/minus control. Each tap increments or decrements by 1 by default. The in: range prevents the value going below 10 or above 32. The label is read by VoiceOver.
Accessibility note: Always provide a meaningful label for Toggle, Slider, and Stepper. VoiceOver reads these labels aloud to users who are visually impaired. A toggle that says nothing is unusable for these users. The label doesn’t have to be visible in the UI — you can hide it with .labelsHidden() if your design calls for it — but always pass a real string.

Useful Variations

Slider with step Snap to specific values instead of sliding freely
// step: 5 means the value jumps 0, 5, 10, 15... when dragged
Slider(value: $volume, in: 0...100, step: 5)
Adding step: makes the slider snap to discrete values. Useful for things like volume where you want 0, 5, 10, 15 rather than every value in between. Works well with integer display since the value always lands on a clean number.
Stepper with custom step Change how much each + or – tap increments
// step: 5 means each tap adds or subtracts 5
Stepper("Quantity", value: $quantity, in: 0...100, step: 5)
The default step is 1. Useful when the user is selecting something that comes in multiples — pack quantities, page counts, or intervals in minutes.
Toggle with custom style Use a button-style toggle instead of a switch
// Button style — looks like a button that activates/deactivates
Toggle("Bold", isOn: $isBold)
    .toggleStyle(.button)
The .button toggle style renders as a pill-shaped button that highlights when active. Great for text formatting toolbars or any UI where a row of on/off options makes more sense than a list of switches.
Slider with labels Show min and max labels on either side
// minimumValueLabel and maximumValueLabel show on the slider ends
Slider(value: $rating, in: 1...5, step: 1) {
    Text("Rating")
} minimumValueLabel: {
    Text("1")
} maximumValueLabel: {
    Text("5")
}
This longer initialiser gives the slider context labels at each end. The first trailing closure is the accessibility label (hidden from view). The min and max labels help users understand the scale without needing a separate description nearby.
SyntaxWhat It Does
Toggle(“Label”, isOn: $bool)On/off switch bound to a Bool variable
Slider(value: $double, in: 0…100)Draggable control within a range, bound to a Double
Slider(value: $val, in: 0…100, step: 10)Slider that snaps to discrete values at each step
Stepper(“Label”, value: $int, in: 0…10)Plus/minus buttons that step a value within a range
Stepper(“Label”, value: $int, in: 0…100, step: 5)Stepper that increments by 5 on each tap
.toggleStyle(.button)Renders Toggle as a button instead of a switch
.labelsHidden()Hides the visible label while keeping it for accessibility
🎯
Challenge 6.2
Build a Settings Screen

Build a simple settings screen with four controls: a Toggle for “Dark Mode”, a Slider for screen brightness (0–100), a Stepper for text size (12–24, step 2), and a Toggle for “Show Badge Count”. Below all the controls, add a Text view that displays a live summary of the current values — for example “Dark Mode: On | Brightness: 65% | Text Size: 16pt”. Test in the simulator and confirm all values update in real time as you interact with the controls.

Using AI to Go Further

Deepen Your Understanding Use AI as a tutor — no code generation required
Explain the difference between Toggle, Slider, and Stepper in SwiftUI. When would I choose one over the other? Give me a real-world example for each — like the kind of app screen where each one would appear naturally.
Why does Slider bind to a Double instead of an Int? What would go wrong if I tried to use an Int? Explain the reasoning without writing any code.
Build a Practice View Generate a commented example you can study and run
Write a SwiftUI view that uses Toggle, Slider, and Stepper together. Add a comment on every line explaining what it does. Include a Text view that displays a summary of all three values below the controls.
Give me an example of a Slider that uses the full initialiser with minimum and maximum value labels and a step value. Comment every line for a beginner, and explain why each argument is used.
6.3
Picker
⏱ 25 min SwiftUI Basics

Picker lets users choose one option from a list. Think of a “Country” dropdown, a “Sort by” control, or the tab at the top of a screen that switches between “Day”, “Week”, and “Month” views. Picker handles all of these with a single, flexible component that changes its appearance based on the style you choose.

The binding for a Picker is slightly different from what you’ve seen. You bind it to a variable that holds the currently selected value, and you tag each option inside the Picker with the value it represents. When the user picks an option, the binding updates to that tag’s value. It sounds like more pieces to juggle, but the pattern is very consistent once you’ve seen it a couple of times.

Picker works naturally with enums, and that’s usually the best approach. An enum gives you a clearly defined set of options, meaningful names, and you get compiler errors if you add a case and forget to handle it somewhere. You’ll use this pattern constantly in real iOS apps.

import SwiftUI

// Define the options as an enum — String gives us free raw values
enum SortOption: String, CaseIterable, Identifiable {
    case newest = "Newest"
    case oldest = "Oldest"
    case popular = "Most Popular"

    // Identifiable requires an id — using self works for String enums
    var id: String { rawValue }
}

struct SortPickerView: View {

    // Hold the currently selected sort option
    @State private var selectedSort: SortOption = .newest

    var body: some View {
        VStack(spacing: 20) {

            // Picker takes a label, a selection binding, and a content block
            Picker("Sort by", selection: $selectedSort) {
                // Each option is a view tagged with the value it represents
                ForEach(SortOption.allCases) { option in
                    Text(option.rawValue).tag(option)
                }
            }
            // Segmented style shows all options as a horizontal control
            .pickerStyle(.segmented)

            // Display the selected value below so we can see the binding working
            Text("Sorted by: \(selectedSort.rawValue)")
                .foregroundStyle(.secondary)
        }
        .padding()
    }
}
Simulator screenshot showing a segmented picker with three segments — 'Newest', 'Oldest', 'Most Popular' — with 'Newest' selected (filled background). Below it a grey Text label reads 'Sorted by: Newest'.
LineWhat it does
enum SortOption: String, CaseIterable, Identifiable Defines the set of options. String gives each case a raw string value. CaseIterable gives you .allCases to loop through all options. Identifiable is required by ForEach.
var id: String { rawValue } Satisfies the Identifiable protocol. For a String enum, using rawValue as the id is the easiest approach since each raw value is already unique.
Picker("Sort by", selection: $selectedSort) Creates the picker. The label "Sort by" is used by VoiceOver. selection: takes the binding to whatever variable holds the chosen value.
Text(option.rawValue).tag(option) Each row in the picker is a view with a .tag(). When the user selects this row, the binding updates to the tagged value. The tag type must match the type of your selection variable.
.pickerStyle(.segmented) Changes the visual style to a segmented control. Other styles are .menu (dropdown), .wheel (scrollable drum), and .inline (list rows).
Tip: The .menu style is usually the best default when you have more than 3 options. It shows the current selection with a small disclosure arrow and expands to a popover menu on tap. It takes up minimal space in your UI and handles long option names gracefully.

Picker Styles

.pickerStyle(.menu) Compact dropdown — best for 4+ options
Picker("Country", selection: $selectedCountry) {
    ForEach(countries, id: \.self) { country in
        Text(country).tag(country)
    }
}
.pickerStyle(.menu)
Renders as a label showing the current selection and a small arrow. Tapping it presents a menu popup with all options. Space-efficient and familiar to iOS users. The default style when used inside a Form.
.pickerStyle(.segmented) Horizontal pill buttons — best for 2–4 options
Picker("View", selection: $selectedView) {
    Text("List").tag("list")
    Text("Grid").tag("grid")
}
.pickerStyle(.segmented)
All options are visible at once as a horizontal control. The selected segment has a white fill. Best for switching between two or three clearly labelled views. Looks out of place with long labels or more than four options.
.pickerStyle(.wheel) Scrollable drum wheel — for large lists
Picker("Hour", selection: $selectedHour) {
    ForEach(0..<24, id: \.self) { hour in
        Text("\(hour):00").tag(hour)
    }
}
.pickerStyle(.wheel)
The classic spinning drum wheel. Good for time selection or any scenario where the user is scrolling through a larger ordered list. Takes up vertical space — best used when the Picker is a prominent part of the screen rather than one item in a list.
SyntaxWhat It Does
Picker(“Label”, selection: $var) { }Creates a picker bound to a State variable
Text(“Option”).tag(value)Marks a picker row — selection updates to this value when chosen
ForEach(MyEnum.allCases) { }Loops over all enum cases to generate picker rows
.pickerStyle(.menu)Compact dropdown popup — good default for 4+ options
.pickerStyle(.segmented)Horizontal segmented control — best for 2–4 options
.pickerStyle(.wheel)Scrollable drum wheel — good for ordered numeric lists
.pickerStyle(.inline)Renders as List rows — natural inside a Form
🎯
Challenge 6.3
Build a Profile Setup Screen

Create a profile setup screen with three pickers: a segmented Picker for “Skill Level” (Beginner, Intermediate, Advanced), a menu Picker for “Preferred Language” (at least 5 programming languages), and a wheel Picker for “Years of Experience” (0–20). Display the current selections in a summary Text below the pickers. Make sure all three pickers use different styles so you can compare how each one looks and behaves in the simulator.

Using AI to Go Further

Deepen Your Understanding Use AI as a tutor — no code generation required
Explain why we need to use .tag() on each option inside a SwiftUI Picker. What would happen without it? What is the connection between the tag type and the selection binding type?
Why is it a good idea to use an enum for Picker options instead of just an array of strings? What are the advantages? Walk me through the reasoning without writing code.
Build a Practice View Generate a commented example you can study and run
Write a complete SwiftUI view with a Picker using an enum for the options. Add a comment on every line. Use the .menu style and show how the selected value is displayed in a Text below the picker.
Show me three versions of a SwiftUI Picker — one with .segmented style, one with .menu style, and one with .wheel style — all using the same data source. Comment every line and explain how each style is best suited to different use cases.
6.4
DatePicker
⏱ 20 min SwiftUI Basics

DatePicker is exactly what it sounds like — a control that lets users pick a date, a time, or both. Booking apps, reminder apps, calendar apps, countdown timers — if your app involves time in any way, you’ll use DatePicker. It handles all the complexity of date and time selection for you, including locale formatting and time zones.

Like all the other input controls you’ve seen, DatePicker uses a binding. This time the binding is to a Date value. Date is a type built into Swift’s Foundation framework that represents a specific point in time. You initialize it with Date() to get the current date and time as a starting value.

You can restrict the range of selectable dates with the in: parameter. This is useful for things like “departure date must be in the future” or “date of birth must be in the past”. You can also control whether the picker shows a date, a time, or both using displayedComponents.

import SwiftUI

struct EventSchedulerView: View {

    // Date() gives us the current date and time as the starting value
    @State private var eventDate = Date()

    // Date.now gives a reference point — used to restrict selectable range
    let dateRange: ClosedRange<Date> = Date.now...Date.now.addingTimeInterval(60 * 60 * 24 * 365)

    var body: some View {
        VStack(alignment: .leading, spacing: 20) {

            // Full date AND time picker — graphical calendar style
            DatePicker(
                "Event Date",
                selection: $eventDate,
                // Only allow dates from now up to one year from today
                in: dateRange,
                // Show both date and time components
                displayedComponents: [.date, .hourAndMinute]
            )
            // Graphical style shows a full calendar and clock
            .datePickerStyle(.graphical)

            // Display the selected date using a formatter
            Text("Selected: \(eventDate.formatted(date: .abbreviated, time: .shortened))")
                .foregroundStyle(.secondary)
        }
        .padding()
    }
}
Simulator screenshot showing a graphical DatePicker displaying a monthly calendar for the current month with today's date highlighted, a time selector below showing 2:30 PM, and a Text label at the bottom reading 'Selected: Apr 15, 2025 at 2:30 PM'.
LineWhat it does
@State private var eventDate = Date() Creates a State variable of type Date. Date() with no arguments returns the current date and time, which is a sensible starting value.
Date.now.addingTimeInterval(...) Creates a date one year from now. addingTimeInterval takes seconds — so 60 × 60 × 24 × 365 = one year’s worth of seconds. This defines the upper bound of selectable dates.
in: dateRange Restricts the picker to only allow dates within the range. Dates outside the range appear greyed out and cannot be selected. You can also use a one-sided range like Date.now... for “any future date”.
displayedComponents: [.date, .hourAndMinute] Controls what parts of a date are shown. .date shows year/month/day. .hourAndMinute shows the time. Use just [.date] if you only need a day, or [.hourAndMinute] for time-only input.
eventDate.formatted(date: .abbreviated, time: .shortened) Formats the Date for display. .abbreviated produces something like “Apr 15, 2025”. .shortened produces “2:30 PM”. This uses Swift’s modern formatted() API which automatically localises the output.
About one-sided ranges: You can use in: Date.now... (open upper bound) to allow any future date, or in: ...Date.now (open lower bound) to allow any past date. This is perfect for birthdate pickers that should only allow dates before today.

DatePicker Styles and Configurations

.datePickerStyle(.compact) Inline tappable labels — default and space-efficient
// Compact style shows tappable date/time labels that expand on tap
DatePicker("Reminder", selection: $reminderDate)
    .datePickerStyle(.compact)
The default style on iOS. Shows the date and time as tappable text labels. Tapping the date opens a calendar popup; tapping the time opens a clock. Takes up one line of space, making it ideal inside a Form or settings list.
Date only Show just the calendar — hide the time component
// Only show the date — no time picker
DatePicker(
    "Birthday",
    selection: $birthday,
    in: ...Date.now,
    displayedComponents: .date
)
Pass just .date to displayedComponents to hide the time selector entirely. The one-sided range ...Date.now ensures the user can only pick a past date — perfect for birthdates.
Time only Show just a time selector — no calendar
// Only show the time picker — no date
DatePicker(
    "Wake up time",
    selection: $wakeTime,
    displayedComponents: .hourAndMinute
)
Pass just .hourAndMinute to show a time-only picker. The Date value still holds a full timestamp, but the user only interacts with the hour and minute portion. Great for alarm or reminder time settings.
SyntaxWhat It Does
DatePicker(“Label”, selection: $date)Date and time picker bound to a Date State variable
displayedComponents: .dateShows only the date — hides the time selector
displayedComponents: .hourAndMinuteShows only the time — hides the calendar
in: Date.now…Restricts selection to future dates only
in: …Date.nowRestricts selection to past dates only
.datePickerStyle(.graphical)Full inline calendar and clock wheels
.datePickerStyle(.compact)Tappable labels that expand on tap — space-efficient default
date.formatted(date: .abbreviated, time: .shortened)Formats a Date for user-facing display
🎯
Challenge 6.4
Build a Trip Planner Input

Build a trip planner screen with two DatePickers: a departure date (date only, must be in the future) and a departure time (time only). Display the selected departure datetime formatted as a sentence below the pickers — for example “Your trip departs Apr 15 at 9:00 AM”. Bonus: add a third DatePicker for the return date that is restricted to be after the departure date (hint: use the departure date State variable as the lower bound of the return date’s range).

Using AI to Go Further

Deepen Your Understanding Use AI as a tutor — no code generation required
Explain what the Date type is in Swift. How is it different from a String that says “April 15”? Why do we use a proper Date type instead of just storing dates as strings? No code — just the reasoning.
What does displayedComponents do in a SwiftUI DatePicker? If I use just .date, does the time portion of the Date value disappear? What actually happens to the time when only the date component is shown?
Build a Practice View Generate a commented example you can study and run
Write a SwiftUI view with a DatePicker in .graphical style that shows both date and time. Add a comment on every line. Include a Text below it that formats the selected date using the .formatted() API.
Give me an example of a SwiftUI DatePicker that only accepts past dates — like a birthdate field. Add comments throughout explaining the range syntax and displayedComponents. Include a calculation that displays the person’s age based on the selected date.
6.5
Form and Section
⏱ 20 min SwiftUI Basics

All the input controls you’ve learned so far — TextField, Toggle, Picker, DatePicker — can be dropped into any view. But when you want to create a proper settings screen or data-entry form in the iOS style, Form is the container to use. It automatically applies the grouped, rounded-rect list styling that iOS settings screens use, and it adapts each control placed inside it to look right in that context.

Form is basically a specialised List that is optimised for input controls. Drop a Toggle inside a Form and it automatically gets a label on the left and a switch on the right — just like the iOS Settings app. Drop a Picker inside and it gets a disclosure chevron and opens as a navigation push. Everything just looks right with almost no extra work.

Section organises a Form into labelled groups. Think of it as a chapter heading for a group of related settings. You give it an optional header and footer, and it draws a visual separator between groups. Any real settings screen you build will use multiple Sections to keep things organised and readable.

import SwiftUI

struct AppSettingsView: View {

    // Input state for all the form fields
    @State private var displayName = ""
    @State private var notificationsEnabled = true
    @State private var soundEnabled = true
    @State private var theme = "System"

    var body: some View {
        // NavigationStack gives the form a title bar
        NavigationStack {
            // Form lays out its children as a grouped settings list
            Form {

                // Section with a header string groups related rows together
                Section("Profile") {
                    // TextField inside Form auto-styles as a labelled row
                    TextField("Display Name", text: $displayName)
                }

                Section("Notifications") {
                    // Toggle inside Form shows label left, switch right
                    Toggle("Push Notifications", isOn: $notificationsEnabled)
                    // Second row inside the same section
                    Toggle("Sound", isOn: $soundEnabled)
                        // Disable Sound if notifications are off
                        .disabled(!notificationsEnabled)
                }

                // Section with a header AND a footer note
                Section {
                    Picker("Theme", selection: $theme) {
                        Text("Light").tag("Light")
                        Text("Dark").tag("Dark")
                        Text("System").tag("System")
                    }
                } header: {
                    Text("Appearance")
                } footer: {
                    // Footer appears below the section as small grey text
                    Text("System follows your device's appearance setting.")
                }
            }
            .navigationTitle("Settings")
        }
    }
}
Simulator screenshot of a settings-style Form view with a 'Settings' navigation title. Three grouped sections are visible: 'Profile' containing a TextField row, 'Notifications' containing two Toggle rows (second Toggle greyed out because first is off), and 'Appearance' with a Picker row showing 'System' selected and a small grey footer note below.
LineWhat it does
Form { } A container that applies iOS settings-list styling to everything inside it. Controls like TextField, Toggle, and Picker automatically adapt their appearance to fit the Form context.
Section("Profile") { } Groups a set of rows under a section header. The string becomes the header label shown above the group. The visual separator between sections is drawn automatically.
.disabled(!notificationsEnabled) Disables the Sound toggle when notifications are off. The control appears greyed out and does not respond to taps. This kind of conditional disabling is common in settings screens.
Section { } header: { } footer: { } The full Section initialiser with both header and footer. The header appears above the group, the footer appears below in smaller grey text. Both are optional.
Tip: Form works best inside a NavigationStack. Without a navigation container, the Form is functional but lacks the title bar that makes settings screens feel complete. Always wrap your settings views in a NavigationStack and set a .navigationTitle().

Form and Section Patterns

Section with view header Use a styled view instead of a plain string header
// Use a view builder for a custom styled header
Section {
    Toggle("Location Access", isOn: $locationEnabled)
} header: {
    Label("Privacy", systemImage: "lock.shield")
}
The header closure accepts any view — not just a string. Using a Label adds an icon next to the section heading, which can help users quickly scan a long settings screen.
Button inside Form Add action rows like “Sign Out” or “Delete Account”
Section {
    // A destructive button at the bottom of the form
    Button("Delete Account", role: .destructive) {
        print("Delete tapped")
    }
}
Buttons inside a Form render as list rows. Using role: .destructive automatically colours the text red, signalling danger to the user without any extra styling. This matches Apple’s HIG recommendation for destructive actions.
.formStyle() Control the visual layout of the Form
// Grouped (default on iPhone) — rounded rectangle sections
Form { }
    .formStyle(.grouped)

// Columns (default on iPad/Mac) — two-column layout
Form { }
    .formStyle(.columns)
The default form style adapts automatically between platforms. On iPhone you get the grouped list style. On iPad and Mac Catalyst, the .columns style displays labels and controls side by side. You rarely need to set this explicitly unless you’re overriding platform defaults.
SyntaxWhat It Does
Form { }Container that styles children as a grouped settings list
Section(“Header”) { }Groups rows under a labelled section with a string header
Section { } header: { } footer: { }Section with both a header view and a footer view
Button(“Label”, role: .destructive) { }Destructive action row with automatic red text
.disabled(condition)Greys out and disables a control when condition is true
.formStyle(.grouped)Explicit grouped style — the default on iPhone
🎯
Challenge 6.5
Build a Full App Settings Screen

Build a settings screen inside a NavigationStack using Form and at least three Sections: an “Account” section with a TextField for display name and a static row showing an email address as Text, a “Notifications” section with three Toggles (Push, Email, SMS), and an “About” section with a Button that prints “Support tapped” and a destructive Button labelled “Sign Out”. Add a .navigationTitle(“Settings”). Bonus: make the Email and SMS toggles disabled when the Push toggle is off, since those notifications depend on push being enabled.

Using AI to Go Further

Deepen Your Understanding Use AI as a tutor — no code generation required
Explain the difference between using a VStack with plain controls versus using a Form with Sections. What does Form do to its child views that a plain VStack doesn’t? Describe the visual and behavioural differences without writing code.
When would I use a Form instead of a List in SwiftUI? What’s the conceptual difference between them, even though they look similar? Walk me through when each is the right choice.
Build a Practice View Generate a commented example you can study and run
Write a complete SwiftUI settings screen using Form, Section, Toggle, TextField, and Picker. Comment every line. Wrap it in a NavigationStack with a title. Use three Sections with meaningful headers.
Show me how to use the Section initialiser with both a header view and a footer view. Add a Label with an SF Symbol icon in the header. Comment every part so I understand what each closure does.
6.6
Keyboard Handling and @FocusState
⏱ 25 min SwiftUI Basics

Here’s a frustration every iOS user has experienced: you tap into a text field, the keyboard appears, and now there’s no obvious way to dismiss it. Or you fill in a field and tap the return key, and nothing happens — focus doesn’t move to the next field. These are keyboard handling problems, and fixing them is what separates a polished app from a rough one.

SwiftUI’s @FocusState property wrapper gives you programmatic control over which field has focus — meaning which field the keyboard is attached to. You can read it to know which field is active, and you can write it to move focus from one field to another or dismiss the keyboard entirely.

Think of focus like a spotlight. Only one field can have the spotlight at a time. @FocusState lets you move that spotlight around in code, rather than waiting for the user to tap each field manually. When no field has focus, the keyboard hides. That’s your keyboard dismissal mechanism.

import SwiftUI

// Define an enum for each focusable field in the form
enum Field: Hashable {
    case firstName, lastName, email
}

struct SignUpFormView: View {

    @State private var firstName = ""
    @State private var lastName  = ""
    @State private var email     = ""

    // @FocusState tracks which field currently has keyboard focus
    @FocusState private var focusedField: Field?

    var body: some View {
        VStack(spacing: 14) {
            TextField("First name", text: $firstName)
                .textFieldStyle(.roundedBorder)
                // Bind this field's focus state to the .firstName case
                .focused($focusedField, equals: .firstName)
                // Return key says "Next" because more fields follow
                .submitLabel(.next)
                // When return is tapped, move focus to lastName
                .onSubmit { focusedField = .lastName }

            TextField("Last name", text: $lastName)
                .textFieldStyle(.roundedBorder)
                .focused($focusedField, equals: .lastName)
                .submitLabel(.next)
                .onSubmit { focusedField = .email }

            TextField("Email", text: $email)
                .textFieldStyle(.roundedBorder)
                .focused($focusedField, equals: .email)
                .keyboardType(.emailAddress)
                // Last field — "Done" dismisses the keyboard
                .submitLabel(.done)
                // Setting focusedField to nil removes focus from all fields = keyboard hides
                .onSubmit { focusedField = nil }

            Button("Continue") {
                // Dismiss keyboard when user taps the button
                focusedField = nil
            }
            .buttonStyle(.borderedProminent)
        }
        .padding()
        // Tap anywhere outside a field to dismiss the keyboard
        .onTapGesture { focusedField = nil }
    }
}
Simulator screenshot of the sign-up form with the keyboard visible. The 'Last name' TextField has a blue highlight border indicating it has focus. The keyboard's return key shows 'Next'. Above it the 'First name' field shows filled-in text. A 'Continue' button is visible above the keyboard.
LineWhat it does
enum Field: Hashable An enum identifying each focusable field. Hashable is required by @FocusState. Using an enum means the compiler will warn you if you reference a field that doesn’t exist.
@FocusState private var focusedField: Field? The optional type means it can be nil — no field has focus — or set to a specific Field case. When you set it to nil, all fields lose focus and the keyboard dismisses.
.focused($focusedField, equals: .firstName) Links this TextField to the focusedField state. When focusedField == .firstName, this field has focus. When focus moves away, focusedField updates to the new field’s value.
.onSubmit { focusedField = .lastName } Called when the user taps the return key on this field. Setting focusedField = .lastName moves keyboard focus to the last name field — the “Tab” key equivalent for forms on iOS.
.onTapGesture { focusedField = nil } Adds a tap gesture to the VStack container. Tapping anywhere outside a field sets focusedField to nil, which dismisses the keyboard. This is the “tap to dismiss” behaviour users expect.
Important: @FocusState only works correctly when tested in the simulator or on a real device. In the Xcode canvas preview, the keyboard doesn’t appear and focus state changes have no visible effect. Always test keyboard behaviour in the simulator for this lesson.

Focus Patterns

Bool @FocusState Simpler focus tracking for a single field
// Bool version — useful when there's only one field to track
@FocusState private var isEmailFocused: Bool

TextField("Email", text: $email)
    // No equals: — Bool version just tracks focused/not focused
    .focused($isEmailFocused)

Button("Done") {
    // Setting to false dismisses the keyboard
    isEmailFocused = false
}
When you only have one TextField to manage, a Bool @FocusState is simpler than the enum approach. The field has focus when the bool is true and loses it when set to false.
.onSubmit on parent Handle return from any field in one place
// Apply onSubmit to the container to handle any field's return key
VStack {
    TextField("Username", text: $username)
    SecureField("Password", text: $password)
}
// This fires when return is tapped in ANY field inside the VStack
.onSubmit {
    login()
}
When you apply .onSubmit to a container rather than an individual field, it fires when the return key is tapped in any field inside that container. Useful for simple forms where any return key submission should trigger the same action.
SyntaxWhat It Does
@FocusState var focused: Field?Tracks which field (or none) currently has keyboard focus
.focused($focusedField, equals: .fieldName)Links a TextField to a specific FocusState value
.focused($isFocused) — Bool versionSimpler focus tracking for a single field
.onSubmit { }Runs a closure when the user taps the return key
focusedField = .nextFieldMoves keyboard focus to the next field
focusedField = nilRemoves focus from all fields — keyboard dismisses
.onTapGesture { focusedField = nil }Tap-to-dismiss keyboard on any background tap
🎯
Challenge 6.6
Build a Multi-Step Login Form

Build a login form with a username field, a password field (SecureField), and a “Log In” button. Use @FocusState with an enum to manage focus between the two fields. The username field should have a .submitLabel(.next) that moves focus to the password field on return. The password field should have a .submitLabel(.go) that dismisses the keyboard on return. Tapping anywhere outside the fields should also dismiss the keyboard. Test the complete flow in the simulator.

Using AI to Go Further

Deepen Your Understanding Use AI as a tutor — no code generation required
Explain @FocusState to me like I’ve never heard of it. What problem does it solve? What would my form experience be like without it? Use a real-world analogy before getting into the technical explanation.
Why does @FocusState use an optional type (Field?) rather than just a non-optional enum? What is the significance of the nil state? Explain without code.
Build a Practice View Generate a commented example you can study and run
Write a SwiftUI sign-up form with three TextFields using @FocusState and an enum to advance focus through the fields in sequence. Add a comment on every single line. Include the onSubmit chain and a tap gesture to dismiss the keyboard.
Show me both versions of @FocusState — the Bool version for a single field and the enum version for multiple fields — side by side in the same file. Comment every line explaining when I’d choose one over the other.
6.7
Input Validation Patterns
⏱ 20 min SwiftUI Basics

Getting input from the user is only half the job. The other half is checking that the input is actually usable before you act on it. If someone tries to sign up with an empty username or a password that’s only two characters long, you need to stop them, tell them what’s wrong, and let them fix it. That’s input validation.

SwiftUI makes basic validation patterns surprisingly clean. The most common approach is computed properties: a property that returns true when the form is valid, and false when it isn’t. You use that property to disable your submit button, so the user literally can’t proceed until the form is valid. This is called disabling the “happy path” — it’s simpler and more reliable than showing an error message after the fact.

For a better user experience, you can also show inline error messages that appear as the user types. This involves another computed property — one that returns an optional string — and a conditional Text view that displays it when it’s not nil. Keep validation logic close to the view for now. When your app grows, you can move it to a separate model.

import SwiftUI

struct RegistrationView: View {

    @State private var username = ""
    @State private var password = ""
    @State private var confirmPassword = ""

    // Returns an error message if the username is invalid, nil if it's fine
    var usernameError: String? {
        if username.isEmpty { return "Username is required" }
        if username.count < 3 { return "Must be at least 3 characters" }
        return nil
    }

    // Returns an error message if the password is invalid, nil if it's fine
    var passwordError: String? {
        if password.count < 8 { return "Must be at least 8 characters" }
        if password != confirmPassword { return "Passwords do not match" }
        return nil
    }

    // The form is only valid when both error properties return nil
    var isFormValid: Bool {
        usernameError == nil && passwordError == nil && !confirmPassword.isEmpty
    }

    var body: some View {
        VStack(alignment: .leading, spacing: 12) {

            TextField("Username", text: $username)
                .textFieldStyle(.roundedBorder)

            // Only show the error message when there is one (non-nil)
            if let error = usernameError, !username.isEmpty {
                Text(error)
                    .foregroundStyle(.red)
                    .font(.caption)
            }

            SecureField("Password", text: $password)
                .textFieldStyle(.roundedBorder)

            SecureField("Confirm Password", text: $confirmPassword)
                .textFieldStyle(.roundedBorder)

            // Show password error only after the confirm field has been touched
            if let error = passwordError, !confirmPassword.isEmpty {
                Text(error)
                    .foregroundStyle(.red)
                    .font(.caption)
            }

            // Button is disabled until the form passes all validation checks
            Button("Create Account") {
                print("Creating account for \(username)")
            }
            .buttonStyle(.borderedProminent)
            .disabled(!isFormValid)
            .frame(maxWidth: .infinity)
        }
        .padding()
    }
}
Simulator screenshot of the registration form. The username field contains 'ab' and below it a small red caption reads 'Must be at least 3 characters'. The password field contains some characters and the confirm password field is empty. The 'Create Account' button at the bottom is grey and disabled.
LineWhat it does
var usernameError: String? { ... } A computed property — one that calculates its value on demand. It returns a String describing the problem if validation fails, or nil if the field is valid. SwiftUI re-evaluates this automatically whenever username changes.
var isFormValid: Bool { ... } Another computed property that combines all the individual checks into a single boolean. Using this to drive the .disabled() modifier is cleaner than putting all the logic inline on the button.
if let error = usernameError, !username.isEmpty Unwraps the optional error string. The second condition !username.isEmpty prevents showing an error when the field is completely empty (the user hasn’t tried yet). This is a better UX than showing errors before the user has done anything.
.foregroundStyle(.red).font(.caption) Styles the error message as small red text. .caption is smaller than the default body size, which visually communicates that this is supplementary information rather than the main content.
.disabled(!isFormValid) Disables the button when the form is not valid. This is the key interaction — the user can see the button exists, but cannot tap it until all validation passes. The visual greying-out signals that something needs to be fixed.
Design principle: Show validation errors after the user has attempted to fill in a field — not before. Showing red errors on an empty form the moment it loads is aggressive and puts users on the defensive. The !username.isEmpty guard in the example above is a simple way to implement this. The button being disabled handles the “can’t submit yet” message without any text error needed at that stage.

Validation Patterns

Email format check Validate with contains() for basic format checking
var emailError: String? {
    if email.isEmpty { return "Email is required" }
    // Basic check: must contain @ and at least one dot after it
    if !email.contains("@") || !email.contains(".") {
        return "Enter a valid email address"
    }
    return nil
}
A basic email check using contains() catches the most obvious mistakes without requiring a regex. For production apps you might use a more thorough validation or Apple’s dataDetector, but this is sufficient for most beginner projects.
Border color feedback Change the field border to signal invalid state
TextField("Username", text: $username)
    .padding(8)
    .// Red border when invalid, default grey when valid or empty
    .overlay(
        RoundedRectangle(cornerRadius: 8)
            .stroke(usernameError != nil && !username.isEmpty
                   ? Color.red
                   : Color.gray.opacity(0.4),
                   lineWidth: 1.5)
    )
Changing the border colour of an invalid field provides a visual cue beyond just a text message. This pattern uses .overlay to draw a RoundedRectangle over the field, with its stroke colour driven by the validation state.
Character limit Cap input length using onChange
TextField("Bio", text: $bio)
    // Watch for changes and trim if over the limit
    .onChange(of: bio) { _, newValue in
        if newValue.count > 160 {
            bio = String(newValue.prefix(160))
        }
    }

// Show remaining characters like a Twitter-style counter
Text("\(160 - bio.count) characters remaining")
    .foregroundStyle(bio.count > 140 ? .red : .secondary)
    .font(.caption)
The .onChange(of:) modifier fires whenever the bound value changes. Using prefix(160) to trim the string keeps it at or below the limit. The counter below turns red as you approach the limit — a familiar pattern from Twitter/X and many other apps.
New to Swift? SwiftUI is built on Swift, so a solid foundation helps. If concepts like computed properties and if let optional unwrapping feel unfamiliar, check out the Learn Swift series — then come back here.
PatternWhat It Does
var error: String? { }Computed property that returns an error string or nil
var isFormValid: Bool { }Single boolean combining all validation checks
.disabled(!isFormValid)Disables submit button until all validation passes
if let error = fieldError, !field.isEmpty { }Shows inline error only after the user has typed something
.foregroundStyle(.red).font(.caption)Styles an error message as small red text
.onChange(of: field) { _, new in }Fires whenever the field’s value changes — use for character limits
String(value.prefix(160))Trims a string to a maximum length
🎯
Challenge 6.7
Build a Complete Validated Sign-Up Form

Build a sign-up form that validates all of the following: username (required, at least 4 characters), email (required, must contain @ and .), password (required, at least 8 characters), and confirm password (must match password). Show inline error messages under each field — but only after the user has started typing in that field. Disable the “Create Account” button until all four fields pass validation. Add a character counter below the username field that shows remaining characters (max 20). Test the complete form in the simulator and verify that the button only activates when all validations pass.

Using AI to Go Further

Deepen Your Understanding Use AI as a tutor — no code generation required
Explain the difference between disabling the submit button for validation versus showing an error message after submission. What are the UX tradeoffs of each approach? When would you use one strategy over the other?
Why do we use computed properties for validation logic in SwiftUI rather than writing the logic directly inside the Button action? What would go wrong if I put all the validation inside the button’s closure instead?
Build a Practice View Generate a commented example you can study and run
Write a SwiftUI registration form with email and password validation using computed properties. Add a comment on every line. The form should show inline error messages only after the user has started typing, and disable the submit button until valid.
Give me three levels of the same form: a basic version with just .disabled() on the button, an intermediate version that adds inline error text, and an advanced version that also changes the TextField border colour when invalid. Comment every line explaining the difference in approach at each level.

Stage 6 Recap: Forms and User Input

You’ve covered every essential input control in SwiftUI and learned how to build forms that are polished, keyboard-friendly, and validated. Here’s what you learned in each lesson:

  • Lesson 6.1 — TextField and SecureField: Accepting text input with a two-way String binding. Using keyboard types, autocorrect settings, and textContentType to make fields smarter and more user-friendly. SecureField for passwords.
  • Lesson 6.2 — Toggle, Slider, and Stepper: The three controls for boolean and numeric input. Toggle binds to Bool, Slider binds to Double within a range, Stepper increments and decrements an integer value.
  • Lesson 6.3 — Picker: Choosing from a list of options using an enum and .tag() to connect each row to a value. Three visual styles — .menu, .segmented, and .wheel — each suited to different contexts.
  • Lesson 6.4 — DatePicker: Accepting date and time input with range restrictions. Using displayedComponents to show just the date, just the time, or both. Formatting a Date value for display with the .formatted() API.
  • Lesson 6.5 — Form and Section: The Form container that automatically styles input controls as a settings-style list. Section organises rows into labelled groups with optional header and footer text.
  • Lesson 6.6 — Keyboard Handling and @FocusState: Programmatic focus management using @FocusState and a Field enum. Moving focus between fields with .onSubmit, and dismissing the keyboard by setting focusedField to nil.
  • Lesson 6.7 — Input Validation Patterns: Using computed properties to produce optional error strings. Showing inline error messages conditionally, disabling the submit button until all validation passes, and limiting input length with .onChange.

If you skipped any of the challenges, go back and build them — especially the Challenge 6.7 full validated form. That one brings together everything from this stage and the result is something you could drop into a real app.

Up next in Stage 7: Animations and Transitions — you’ll learn how to bring your interface to life with movement, giving users visual feedback that makes your app feel smooth and responsive.

Learn SwiftUI Stage 7: Animations and Transitions

The difference between an app that works and an app that feels great is almost always animation — and in SwiftUI it takes surprisingly little code to get there.

For this stage you’ll need Xcode open with a SwiftUI project. One important heads-up before you start: animations won’t play in the canvas preview — the canvas only shows static states. To actually see your animations move, run the app in the Simulator or on a real device. That’s true for every lesson in this stage, so get the Simulator ready before you begin. There are 6 lessons totalling about 2.5 hours, and each one ends with a challenge that you test in the Simulator.

By the end of Stage 7 you’ll know how to add implicit animations with .animation(), trigger animations explicitly using withAnimation, control timing with curves and springs, animate views appearing and disappearing with .transition(), build hero animations using matchedGeometryEffect, and sequence multi-step animations with PhaseAnimator. That’s the full toolkit most iOS apps use.

07
Stage 7
Animations and Transitions
6 lessons · ~2.5 hrs
7.1
Implicit Animations with .animation()
⏱ 25 min SwiftUI Basics

Animations in SwiftUI start with a simple idea: describe what your view looks like in two states, and SwiftUI smoothly fills in the in-between frames automatically. The modifier that makes this happen is .animation(), and it’s the easiest entry point into the whole animation system.

Think of it like a light dimmer switch instead of a regular on/off switch. Without animation, views snap instantly between states. With .animation() attached, the change plays out gradually — SwiftUI does all the work of interpolating the values in between.

The key thing to understand is that .animation() watches a specific value for changes. When that value changes, any animatable properties on the view animate to their new state. You’re not manually describing the motion — you’re just saying “when this value changes, animate.”

import SwiftUI

struct ContentView: View {
    // @State toggles between two sizes when the button is tapped
    @State private var isLarge = false

    var body: some View {
        VStack(spacing: 40) {
            Circle()
                // frame changes based on isLarge state
                .frame(width: isLarge ? 200 : 80,
                       height: isLarge ? 200 : 80)
                // foregroundStyle changes between two colors
                .foregroundStyle(isLarge ? Color.red : Color.blue)
                // .animation watches isLarge — any animatable change plays smoothly
                .animation(.easeInOut(duration: 0.4), value: isLarge)

            Button("Tap Me") {
                // flipping the bool triggers the animation
                isLarge.toggle()
            }
        }
    }
}
Before state: a small blue circle with a Tap Me button below it. After state: a large red circle, showing the result after the scale and color animation completes.
LineWhat it does
@State private var isLarge = false A boolean that controls whether the circle is big or small. When this flips, SwiftUI re-renders the view.
.frame(width: isLarge ? 200 : 80, ...) The circle’s size is determined by the current value of isLarge. The ternary operator picks one of two sizes.
.foregroundStyle(isLarge ? .red : .blue) Same pattern for color. Both the size and color are “animatable” — SwiftUI knows how to interpolate between them.
.animation(.easeInOut(duration: 0.4), value: isLarge) This is the key line. It tells SwiftUI: “when isLarge changes, animate any animatable changes on this view using an ease-in-out curve over 0.4 seconds.”
isLarge.toggle() Flips the boolean from false to true (or back). That state change triggers the animation.
The value: parameter is required. In older SwiftUI code you might see .animation(.easeInOut) without a value: argument. That still compiles but it’s deprecated and can cause unexpected animations elsewhere in your view. Always pass value: to be precise about what triggers the animation.

What is Animatable?

Not every property in SwiftUI can be animated. Properties that represent continuous values — size, position, opacity, color, rotation, scale — are animatable because SwiftUI can calculate intermediate values between start and end. A boolean itself isn’t animatable, but the visual properties that depend on it are.

.opacity() Fade a view in or out
Text("Hello")
    // 1.0 is fully visible, 0.0 is invisible
    .opacity(isVisible ? 1.0 : 0.0)
    .animation(.easeOut(duration: 0.3), value: isVisible)
Opacity ranges from 0 (invisible) to 1 (fully visible). SwiftUI can smoothly interpolate any decimal in between, so fades are a natural fit for .animation().
.scaleEffect() Grow or shrink a view
Image(systemName: "heart.fill")
    .foregroundStyle(.red)
    // 1.0 is normal size, 1.5 is 50% larger
    .scaleEffect(isLiked ? 1.5 : 1.0)
    .animation(.spring(duration: 0.3), value: isLiked)
A value of 1.0 is the view’s natural size. Go above 1.0 to grow it, below 1.0 to shrink it. This is how most “like” button animations are built.
.rotationEffect() Rotate a view by an angle
Image(systemName: "chevron.right")
    // Rotate 90 degrees when expanded is true
    .rotationEffect(.degrees(isExpanded ? 90 : 0))
    .animation(.easeInOut(duration: 0.25), value: isExpanded)
Classic use case: a disclosure chevron that rotates when a section expands. .degrees() takes a Double, and SwiftUI animates the rotation smoothly between values.
.offset() Move a view horizontally or vertically
Rectangle()
    .frame(width: 100, height: 100)
    // Slide 200 points right when active
    .offset(x: isActive ? 200 : 0)
    .animation(.easeInOut, value: isActive)
Offset moves the view’s rendered position without affecting layout. It’s useful for sliding things in and out of view, like a menu drawer or a notification banner.
SyntaxWhat It Does
.animation(.easeInOut, value: x)Slow in, slow out — the most natural-feeling default
.animation(.easeIn, value: x)Starts slow, ends at full speed — good for exits
.animation(.easeOut, value: x)Starts fast, decelerates — good for entrances
.animation(.linear, value: x)Constant speed throughout — mechanical feel
.animation(.spring(), value: x)Bouncy, physical feel — great for interactive elements
.animation(.easeInOut(duration: 0.4), value: x)Same as easeInOut but with an explicit duration in seconds
.animation(nil, value: x)Explicitly disables animation for this value — useful to opt out
Where to attach .animation(): Apply it to the view whose properties you want to animate, not to a container. If you attach it to a VStack containing five views, all five will animate. Be specific to keep things predictable.
🎯
Challenge 7.1
Animate a Rectangle

Build a view with a rounded rectangle that starts at 60×60 points with a blue fill. Add a button below it. When the button is tapped, the rectangle should animate to 180×180 points and change its fill to orange using an .easeInOut curve. Test in the Simulator — you should see the rectangle grow and shift color smoothly when you tap. Tap again to shrink it back.

Using AI to Go Further

Deepen Your Understanding Use AI as a tutor — no code generation required
Explain how SwiftUI’s .animation() modifier works. What does the “value:” parameter do and why is it important? Use an analogy to explain the concept of animatable properties — don’t write any code, just explain the idea.
I’m learning about implicit animations in SwiftUI. Can you quiz me on which properties are animatable and which aren’t? Ask me to guess for five different modifiers, tell me if I’m right, and explain why for each one.
Build a Practice View Generate a commented example to study, not just copy
Write a SwiftUI view that uses .animation() to animate at least three different properties at once when a button is tapped. Add a comment above every line explaining what it does and why — write the comments for a complete beginner.
Give me three progressively more complex examples of implicit animations in SwiftUI using .animation(). Start with a basic opacity fade, then add scale, then combine color and rotation. Add inline comments throughout so I can follow the logic step by step.
7.2
Explicit Animations with withAnimation
⏱ 25 min SwiftUI Basics

In Lesson 7.1 you attached .animation() directly to a view. That works well when you want a specific view to animate a specific value. But sometimes you want to animate a state change that affects many views at once — and attaching .animation() to each one is repetitive and fragile.

That’s where withAnimation comes in. Instead of putting the animation instruction on the view, you wrap the state change itself in a withAnimation block. SwiftUI then animates every view that responds to that state change. Think of it like making an announcement to the whole room instead of whispering to each person individually.

This approach gives you more control and is the preferred pattern when a single user action drives changes across multiple views.

import SwiftUI

struct ContentView: View {
    // Two state variables that drive the UI
    @State private var isExpanded = false
    @State private var showDetail = false

    var body: some View {
        VStack(spacing: 24) {
            // This view responds to isExpanded
            RoundedRectangle(cornerRadius: 16)
                .fill(Color.blue)
                .frame(width: isExpanded ? 280 : 100,
                       height: isExpanded ? 160 : 60)

            // This view also responds to isExpanded
            Text(isExpanded ? "Tap to collapse" : "Tap to expand")
                .opacity(showDetail ? 1.0 : 0.4)

            Button("Toggle") {
                // withAnimation wraps the state change — both views animate
                withAnimation(.easeInOut(duration: 0.4)) {
                    isExpanded.toggle()
                    // Multiple state changes inside the block all animate together
                    showDetail = !isExpanded
                }
            }
        }
    }
}
Before: a small blue rounded rectangle and faded label text. After: a wide tall blue rounded rectangle with full-opacity label text, showing both views animated simultaneously by a single withAnimation call.
LineWhat it does
withAnimation(.easeInOut(duration: 0.4)) { } This is the explicit animation wrapper. Every state change inside the curly braces will cause affected views to animate using the provided animation curve.
isExpanded.toggle() The state change happens inside the block. SwiftUI captures the before and after states and animates the difference.
showDetail = !isExpanded A second state change in the same block. Both changes animate at the same time, in sync, using the same animation.
The RoundedRectangle and Text Neither of these has .animation() attached. They animate purely because withAnimation was used at the call site. That’s the key difference.
withAnimation vs .animation() — which to use? Use .animation() when you want a single view to always animate a specific value, regardless of where the change comes from. Use withAnimation when you control the moment of the change (usually inside a button or gesture handler) and want all affected views to animate together.

withAnimation Patterns

Default animation Use SwiftUI’s built-in default animation
Button("Animate") {
    // No argument = SwiftUI picks the default animation
    withAnimation {
        isVisible.toggle()
    }
}
Calling withAnimation with no argument uses SwiftUI’s default animation, which is a smooth ease-in-out. Good for quick prototyping.
Delayed animation Start the animation after a short pause
Button("Animate") {
    withAnimation(.easeOut(duration: 0.3).delay(0.15)) {
        // Waits 0.15 seconds before the animation begins
        isShowing.toggle()
    }
}
Chaining .delay() onto an animation pushes its start time back. Useful when you want to stagger animations across multiple views.
Repeated animation Play the animation more than once
Button("Animate") {
    withAnimation(.easeInOut(duration: 0.3).repeatCount(3, autoreverses: true)) {
        // Plays 3 times, reversing direction each time
        isShaking.toggle()
    }
}
.repeatCount(3) plays the animation 3 times. autoreverses: true makes it play forward, then backward, then forward again. Classic use: a “wrong password” shake effect.
Forever animation Loop indefinitely — use for loaders and pulses
Circle()
    .scaleEffect(isPulsing ? 1.3 : 1.0)
    .opacity(isPulsing ? 0.4 : 1.0)
    .animation(
        // .repeatForever loops until isPulsing becomes false
        .easeInOut(duration: 0.8).repeatForever(autoreverses: true),
        value: isPulsing
    )
    .onAppear { isPulsing = true }
.repeatForever() loops indefinitely. Triggering it in .onAppear is a common pattern for loading indicators, ambient pulsing effects, and attention-drawing animations.
SyntaxWhat It Does
withAnimation { }Animates all state changes inside the block using the default animation
withAnimation(.spring()) { }Uses a spring animation for all changes in the block
.delay(0.2)Chains onto any animation to delay its start by 0.2 seconds
.repeatCount(3, autoreverses: true)Plays the animation 3 times, reversing direction each time
.repeatForever(autoreverses: true)Loops the animation indefinitely until the view disappears or state resets
.speed(2.0)Chains onto any animation to play it at double speed
New to Swift? SwiftUI is built on Swift, so a solid foundation helps. If you want to build that foundation first, check out the Learn Swift series — then come back here.
🎯
Challenge 7.2
Multi-Property withAnimation

Create a view with two elements: a colored square and a label below it showing “Collapsed” or “Expanded”. When a button is tapped, use withAnimation to simultaneously change the square’s size, its color, and the label text. All three changes should animate together in one smooth motion. Test in the Simulator — you should see all three elements update at the same time when the button is tapped.

Using AI to Go Further

Deepen Your Understanding Use AI as a tutor — no code generation required
Explain the difference between withAnimation and .animation() in SwiftUI. When would I choose one over the other? Give me a rule of thumb I can remember. Don’t write any code — just explain the mental model.
I’m learning about withAnimation in SwiftUI. Without writing any code, walk me through what happens step by step when I call withAnimation and change a @State variable inside the block. What does SwiftUI do internally to produce the animation?
Build a Practice View Generate a commented example to study, not just copy
Write a SwiftUI view that uses withAnimation to animate three different views simultaneously when a button is tapped. Add a comment above every line explaining what it does and why — write the comments for someone who is just learning SwiftUI.
Show me a realistic SwiftUI example of a “wrong password” shake animation using withAnimation and repeatCount. Add inline comments throughout explaining every choice — the duration, the repeat count, why autoreverses is true, and what offset value creates the shake feel.
7.3
Animation Curves and Springs
⏱ 20 min SwiftUI Basics

All the animations you’ve written so far used words like .easeInOut and .spring() without much explanation of what they actually mean. This lesson fills that gap.

A curve describes the rate of change over time. Think about how a car moves: it accelerates from a stop, travels at speed, then decelerates before stopping. That’s not a constant speed — it’s a curve. The same idea applies to UI animations. The same distance traveled at a constant speed feels robotic. Ease in, speed up, ease out feels alive.

Springs are different from curves. Instead of following a predetermined mathematical path, a spring animation is physically simulated — the view overshoots its target slightly and bounces back, just like a real spring. That’s why spring-based UIs feel so natural to interact with.

import SwiftUI

struct CurveComparisonView: View {
    @State private var isMoved = false

    var body: some View {
        VStack(spacing: 20) {
            // Each circle uses a different animation curve
            Circle().fill(.blue).frame(width: 50, height: 50)
                .offset(x: isMoved ? 120 : 0)
                .animation(.linear(duration: 0.6), value: isMoved)

            Circle().fill(.green).frame(width: 50, height: 50)
                .offset(x: isMoved ? 120 : 0)
                .animation(.easeInOut(duration: 0.6), value: isMoved)

            // Spring uses response and dampingFraction, not duration
            Circle().fill(.orange).frame(width: 50, height: 50)
                .offset(x: isMoved ? 120 : 0)
                .animation(.spring(response: 0.5, dampingFraction: 0.6), value: isMoved)

            Button("Move Circles") { isMoved.toggle() }
                .padding()
        }
    }
}
Three circles stacked vertically in blue, green, and orange. When the button is tapped, all three move to the right by the same distance, but each at a different pace and motion feel — linear is mechanical, easeInOut decelerates gently, spring overshoots and bounces.
LineWhat it does
.linear(duration: 0.6) Constant speed all the way through. Covers the distance evenly. Feels mechanical — you’ll rarely use this for UI motion, but it’s good for things like loading progress bars.
.easeInOut(duration: 0.6) Starts slow, speeds up in the middle, then decelerates. Matches how physical objects actually move. The most natural-feeling curve for most UI transitions.
.spring(response: 0.5, dampingFraction: 0.6) A physics-based animation. response controls how quickly the spring reacts (like stiffness). dampingFraction controls how much it bounces — 1.0 is no bounce, lower values bounce more.
Spring dampingFraction cheat sheet: A dampingFraction of 1.0 means no bounce at all — it settles smoothly. Around 0.7–0.8 gives a gentle, Apple-like feel. Around 0.4–0.5 gives a more playful bounce. Below 0.3 can feel excessive and distracting.

Curve and Spring Options

.easeIn Starts slow, ends at full speed
Circle()
    .offset(x: isMoved ? 200 : 0)
    // Accelerates as it moves — good for exits
    .animation(.easeIn(duration: 0.4), value: isMoved)
Eases in means it starts slow and reaches full speed by the end. This is best for views that are leaving the screen — it feels like they’re picking up speed to fly away.
.easeOut Starts fast, decelerates at the end
Circle()
    .offset(x: isMoved ? 200 : 0)
    // Decelerates near destination — good for entrances
    .animation(.easeOut(duration: 0.4), value: isMoved)
Eases out means it starts at full speed and decelerates as it arrives. This is best for views entering the screen — they arrive with a satisfying deceleration, like something landing.
.spring(duration:) Simpler spring syntax (iOS 17+)
Circle()
    .scaleEffect(isTapped ? 1.4 : 1.0)
    // iOS 17+ simplified spring — just set the duration
    .animation(.spring(duration: 0.4, bounce: 0.3), value: isTapped)
iOS 17 introduced a simpler spring syntax. duration controls the total animation time and bounce controls how much overshoot you get (0.0 to 1.0). This is now the recommended approach for most spring animations.
.interpolatingSpring Spring with explicit stiffness and damping
Circle()
    .offset(y: isDropped ? 300 : 0)
    // stiffness: how quickly it moves. damping: how quickly it settles.
    .animation(.interpolatingSpring(stiffness: 120, damping: 10), value: isDropped)
Lower stiffness = sluggish, rubber-band feel. Higher stiffness = snappy, responsive feel. Lower damping = more bouncing. Higher damping = settles quickly. Useful when you want precise physical control over the spring behavior.
SyntaxWhat It Does
.linear(duration: 0.4)Constant speed — mechanical feel, good for loaders
.easeIn(duration: 0.4)Accelerates — best for views leaving the screen
.easeOut(duration: 0.4)Decelerates — best for views arriving on screen
.easeInOut(duration: 0.4)Slow → fast → slow — most natural-feeling default
.spring(response: 0.5, dampingFraction: 0.7)Physics-based spring — response is speed, dampingFraction controls bounce
.spring(duration: 0.4, bounce: 0.3)iOS 17+ simplified spring syntax — duration + bounce amount
.interpolatingSpring(stiffness: 100, damping: 10)Full physical spring control via stiffness and damping values
🎯
Challenge 7.3
Bouncy Checkmark

Build a view with a button that shows a checkmark icon. When the button is tapped, the checkmark should scale up to 1.5× its normal size using a spring animation with a noticeable bounce, then use a second button or a short delay to return it to normal. The goal is to make the checkmark feel satisfying to tap — like a real confirmation moment. Test in the Simulator and adjust the spring’s bounce value until it feels right to you.

Using AI to Go Further

Deepen Your Understanding Use AI as a tutor — no code generation required
Explain the difference between easeIn, easeOut, and easeInOut in animation. Use real-world physical analogies — like a car, a ball, or something I can picture — to describe what each curve feels like. Don’t write any code.
Explain what dampingFraction does in a SwiftUI spring animation. What’s the difference between a dampingFraction of 1.0, 0.7, and 0.3? Describe what each would look and feel like to a user — no code, just the concept.
Build a Practice View Generate a commented example to study, not just copy
Write a SwiftUI view that places five identical circles in a row, each using a different animation curve when a button is tapped: linear, easeIn, easeOut, easeInOut, and a spring. Add a label above each circle naming the curve. Comment every line for a beginner.
Give me a SwiftUI example of a “like” button — a heart icon that uses a spring animation with a satisfying bounce when tapped. Add comments explaining every line, especially the spring parameters and why those values were chosen.
7.4
View Transitions
⏱ 25 min SwiftUI Basics

So far, the animations you’ve built have all been about changing properties on a view that’s always present. But what about views that appear or disappear entirely? When you show or hide a view with an if statement, SwiftUI normally just snaps it in or out of existence. Transitions make that snap into a smooth animation.

Think of a transition as the “entrance and exit choreography” for a view. You attach .transition() to a view to describe how it should appear when inserted into the view hierarchy and how it should disappear when removed. The actual animation is still triggered by withAnimation — the transition just defines the choreography.

Transitions only work with views that are conditionally shown using if or if/else. If a view is always present, there’s nothing to transition — it’s already there.

import SwiftUI

struct ContentView: View {
    @State private var showBanner = false

    var body: some View {
        VStack {
            // This view only exists when showBanner is true
            if showBanner {
                Text("Welcome back!")
                    .padding()
                    .background(Color.blue)
                    .foregroundStyle(.white)
                    .cornerRadius(10)
                    // .transition describes how the view enters and exits
                    .transition(.slide)
            }

            Spacer()

            Button("Toggle Banner") {
                // withAnimation is required — .transition alone doesn't animate
                withAnimation(.easeInOut(duration: 0.4)) {
                    showBanner.toggle()
                }
            }
            .padding(.bottom, 32)
        }
    }
}
Before: only the Toggle Banner button is visible at the bottom. After: a blue 'Welcome back!' banner has slid in from the leading edge of the screen and appears at the top. Tapping again makes the banner slide back out.
LineWhat it does
if showBanner { ... } This is what makes the view conditional. When showBanner becomes true, SwiftUI inserts the banner view. When it becomes false, SwiftUI removes it.
.transition(.slide) This tells SwiftUI: “when this view is inserted, slide it in from the leading edge; when it’s removed, slide it out to the leading edge.” This modifier only matters at the moment of insertion or removal.
withAnimation { showBanner.toggle() } This is what actually triggers the transition animation. Without withAnimation, the view would still appear and disappear — just without any animation. The two pieces work together.
.transition() without withAnimation does nothing. This is the most common beginner mistake with transitions. The .transition() modifier just declares the animation style — it doesn’t activate anything on its own. You must also wrap the state change in withAnimation, otherwise the view just snaps in and out instantly.

Built-in Transitions

.opacity Fade in and fade out
if isShowing {
    Text("Hello")
        // Fades from 0 opacity on insert, to 0 on removal
        .transition(.opacity)
}
The simplest transition. The view fades in when it appears and fades out when it disappears. Great for notifications, tooltips, and overlays.
.scale Grow in from a point, shrink out to a point
if isShowing {
    Image(systemName: "checkmark.circle.fill")
        .font(.system(size: 64))
        // Scales from 0 to full size on insert, 0 on removal
        .transition(.scale)
}
The view appears by growing from nothing and disappears by shrinking to nothing. Feels energetic and satisfying — great for confirmation states, checkmarks, or success messages.
.move(edge:) Slide in from a specific edge
if isShowing {
    VStack { /* menu content */ }
        // Slides in from the trailing (right) edge
        .transition(.move(edge: .trailing))
}
More precise than .slide — you control which edge the view enters and exits from. Options are .top, .bottom, .leading, and .trailing. Perfect for slide-out menus, drawers, and bottom sheets.
.asymmetric Different transitions for insert vs removal
if isShowing {
    Text("Notification")
        // Slides in from the top but fades out when dismissed
        .transition(.asymmetric(
            insertion: .move(edge: .top),
            removal: .opacity
        ))
}
The most useful and flexible transition. You can specify completely different animations for when the view appears versus when it disappears. This is how iOS notification banners work — they slide in from the top but fade out when dismissed.
SyntaxWhat It Does
.transition(.opacity)Fade in on insert, fade out on removal
.transition(.scale)Grow in from center, shrink out to center
.transition(.slide)Slide in from leading edge, slide out to leading edge
.transition(.move(edge: .bottom))Enter and exit from a specific edge: .top, .bottom, .leading, .trailing
.transition(.scale.combined(with: .opacity))Run two transitions at the same time using .combined(with:)
.transition(.asymmetric(insertion: .slide, removal: .opacity))Different animations for appearing vs disappearing
🎯
Challenge 7.4
Notification Banner

Build a notification-style banner that appears at the top of the screen. It should slide down from the top when a button is tapped and slide back up when a dismiss button on the banner itself is tapped. Use .asymmetric to make the entrance different from the exit — for example, slide in from the top but fade out when dismissed. Test in the Simulator and make sure both the appearance and dismissal animate correctly.

Using AI to Go Further

Deepen Your Understanding Use AI as a tutor — no code generation required
Explain how .transition() works in SwiftUI and why it requires withAnimation to produce an animation. What’s the relationship between the two? What happens if you use .transition() without withAnimation? Don’t write any code.
I’m trying to understand .asymmetric transitions in SwiftUI. Without code, explain a real-world UI example that would benefit from a different entrance animation versus exit animation, and why that asymmetry makes the UI feel better.
Build a Practice View Generate a commented example to study, not just copy
Write a SwiftUI view that shows a bottom sheet-style panel that slides up from the bottom when a button is tapped and slides back down when dismissed. Use .transition(.move(edge: .bottom)) and withAnimation. Comment every line for a beginner who hasn’t seen transitions before.
Give me three different SwiftUI views that each use a different transition: one with .opacity, one with .scale combined with .opacity, and one with .asymmetric. Add inline comments explaining what each transition does and when you’d choose it over the others.
7.5
matchedGeometryEffect
⏱ 30 min Intermediate SwiftUI

You’ve seen apps where tapping a small thumbnail causes it to expand into a full-screen detail view — and the image itself seems to fly from its original position to fill the screen. That effect is called a hero animation, and in SwiftUI you build it with matchedGeometryEffect.

The idea is that you have two different views — one small (the card in a list) and one large (the expanded detail) — and you want SwiftUI to animate between them as if they’re the same view moving and resizing. matchedGeometryEffect does this by giving both views a shared identifier and a namespace, then SwiftUI interpolates between their frames when one replaces the other.

It requires two things: a @Namespace property to create a shared animation space, and .matchedGeometryEffect(id:in:) applied to both the source and destination views with the same ID.

New to Swift? SwiftUI is built on Swift, so a solid foundation helps. If you want to build that foundation first, check out the Learn Swift series — then come back here.
import SwiftUI

struct ContentView: View {
    // @Namespace creates a shared animation context for matchedGeometryEffect
    @Namespace private var animation
    @State private var isExpanded = false

    var body: some View {
        if isExpanded {
            // Expanded state — a full-width card at the top
            VStack {
                RoundedRectangle(cornerRadius: 24)
                    .fill(Color.blue)
                    .frame(maxWidth: .infinity)
                    .frame(height: 260)
                    // Same id "card" and same namespace — SwiftUI links these two
                    .matchedGeometryEffect(id: "card", in: animation)
                    .onTapGesture {
                        withAnimation(.spring(response: 0.5, dampingFraction: 0.75)) {
                            isExpanded = false
                        }
                    }
                Spacer()
            }
            .padding()
        } else {
            // Collapsed state — a small card in the center
            VStack {
                Spacer()
                RoundedRectangle(cornerRadius: 16)
                    .fill(Color.blue)
                    .frame(width: 160, height: 100)
                    // Same id "card" and same namespace — SwiftUI connects these
                    .matchedGeometryEffect(id: "card", in: animation)
                    .onTapGesture {
                        withAnimation(.spring(response: 0.5, dampingFraction: 0.75)) {
                            isExpanded = true
                        }
                    }
                Spacer()
            }
        }
    }
}
Before: a small blue rounded rectangle card centered on screen. After: the same blue shape has expanded to fill nearly the full width and height of the screen, with the corners and position animated smoothly using matchedGeometryEffect.
LineWhat it does
@Namespace private var animation Creates a namespace — a shared animation context. Think of it as a channel that connects the two views. Both views must reference this same namespace.
.matchedGeometryEffect(id: "card", in: animation) Applied to both the small and large version with the same ID. When one is removed and the other appears within a withAnimation call, SwiftUI animates the transition between their sizes and positions automatically.
if isExpanded { ... } else { ... } The key structure. Only one of the two views exists at a time. When the state flips, SwiftUI sees that a view with ID “card” is being removed and another with the same ID is being inserted — and it animates the morph between them.
withAnimation(.spring(...)) Just like transitions, matchedGeometryEffect requires withAnimation to produce the animation. Without it, the view still switches — just without the hero effect.
Only one view with a given ID should exist at a time. If both the source and destination views are visible simultaneously with the same ID, SwiftUI will show a warning and the effect won’t work correctly. Use if/else — not two separate if statements — to ensure only one is present at any moment.

matchedGeometryEffect Patterns

Animating just part of a card Match geometry on a sub-view, not the whole card
// In the list row — match just the image
Image("photo")
    .resizable()
    .frame(width: 60, height: 60)
    .matchedGeometryEffect(id: "photo-\(item.id)", in: animation)

// In the detail view — same image, full width
Image("photo")
    .resizable()
    .frame(maxWidth: .infinity)
    .frame(height: 300)
    .matchedGeometryEffect(id: "photo-\(item.id)", in: animation)
You don’t have to match the whole card. Matching just the image lets it fly from the list into the detail view while the surrounding text fades in separately. Use string interpolation with the item’s ID to give each item a unique match ID.
Tab bar indicator Animate an underline or pill between tabs
@Namespace private var tabNamespace

HStack {
    ForEach(tabs, id: \.self) { tab in
        VStack {
            Text(tab)
            if selectedTab == tab {
                // The underline moves between tabs using matched geometry
                Capsule()
                    .frame(height: 3)
                    .matchedGeometryEffect(id: "indicator", in: tabNamespace)
            }
        }
        .onTapGesture {
            withAnimation(.spring()) { selectedTab = tab }
        }
    }
}
A classic use case that doesn’t involve expanding views at all. The tab indicator is a single capsule that moves between tabs — matchedGeometryEffect makes it slide smoothly from one tab to the next instead of popping between positions.
SyntaxWhat It Does
@Namespace var animationCreates the shared namespace — required, declared once in the view
.matchedGeometryEffect(id: “key”, in: animation)Links two views with the same string ID and namespace together
id: “item-\(item.id)”Dynamic IDs using string interpolation — use this in lists so each item has a unique match
if/else (not two if statements)Ensures only one view with a given ID exists at a time
withAnimation(.spring(…))Required to trigger the animated transition between matched views
🎯
Challenge 7.5
Expandable Card Grid

Build a simple grid of three colored cards. When you tap a card, it should expand to fill most of the screen — the card itself should animate from its original position and size into the full-screen version using matchedGeometryEffect. Show a close button on the expanded view that returns to the grid. Test in the Simulator — the card should feel like it’s flying from the grid position to full screen and back. Use a spring animation with a subtle bounce.

Using AI to Go Further

Deepen Your Understanding Use AI as a tutor — no code generation required
Explain matchedGeometryEffect in SwiftUI without writing any code. What problem does it solve? What does @Namespace do and why is it needed? What’s the rule about having only one view with a given ID visible at a time, and why does it matter?
I’ve written a matchedGeometryEffect animation but it’s not working — the view just snaps instead of animating. Without seeing my code, what are the three most common reasons this happens and how would I fix each one?
Build a Practice View Generate a commented example to study, not just copy
Write a SwiftUI view that uses matchedGeometryEffect to animate a card expanding from a thumbnail to a detail view. Add a comment above every line explaining what it does and why — focus especially on the @Namespace declaration, the matched IDs, and why if/else is used instead of two if statements.
Show me how to build a custom tab bar indicator that slides between tabs using matchedGeometryEffect. Add detailed inline comments explaining every line, particularly the namespace, the conditional capsule, and why withAnimation is essential here.
7.6
Phase Animators and Keyframes
⏱ 20 min Intermediate SwiftUI

Every animation you’ve built so far has had two states: a start and an end. But some animations need to pass through multiple states in sequence — like an icon that bounces up, pauses, then settles down, or a progress indicator that pulses through three distinct steps. That’s what PhaseAnimator is for.

Introduced in iOS 17, PhaseAnimator lets you define an ordered sequence of values — called phases — and SwiftUI animates between them automatically. Think of it like a flipbook: instead of just “page 1 to page 2,” you define pages 1, 2, 3, and 4, and SwiftUI flips through them in order.

KeyframeAnimator goes even further — it gives you precise control over individual animatable properties at specific moments in time, like specifying that the scale should be 1.5 at 0.2 seconds, then 0.8 at 0.4 seconds, then back to 1.0 at 0.6 seconds. It’s the most complex tool in the animation toolkit and you’ll need it less often than the others.

import SwiftUI

struct BounceView: View {
    @State private var trigger = 0

    var body: some View {
        VStack(spacing: 40) {
            // PhaseAnimator takes an array of phases and cycles through them
            PhaseAnimator(
                [0.0, -30.0, 0.0],
                // trigger: when this value changes, restart the phase sequence
                trigger: trigger
            ) { phase in
                Image(systemName: "bell.fill")
                    .font(.system(size: 60))
                    .foregroundStyle(.orange)
                    // phase is the current value — 0.0, -30.0, or 0.0
                    .offset(y: phase)
            } animation: { phase in
                // Different animation curves for each phase transition
                phase == 0.0 ? .spring(response: 0.3, dampingFraction: 0.5) : .easeOut(duration: 0.2)
            }

            Button("Ring Bell") {
                // Incrementing trigger restarts the phase animation from the beginning
                trigger += 1
            }
        }
    }
}
An orange bell icon in its resting position (phase 0.0 offset). When the Ring Bell button is tapped, the bell moves up 30 points (phase -30.0), then returns to rest with a spring bounce (phase 0.0 again), creating a ringing bell effect.
LineWhat it does
PhaseAnimator([0.0, -30.0, 0.0], trigger: trigger) The array defines the phases in order. PhaseAnimator will cycle through these values one by one. trigger restarts the cycle when it changes — incrementing an integer is a clean way to do this.
{ phase in ... } The view content closure. phase is the current value from the array (a Double in this case). You use it to set properties on the view.
.offset(y: phase) The offset is driven directly by the current phase value. Phase 0.0 means no offset. Phase -30.0 means 30 points upward. Phase 0.0 again returns to rest.
animation: { phase in ... } The animation closure lets you specify a different animation curve for each phase transition. When the view is moving to phase -30, it uses .easeOut. When returning to 0, it uses a spring.
trigger += 1 Incrementing the trigger integer restarts the entire phase sequence from the beginning. This is the cleanest way to fire a PhaseAnimator on demand.
PhaseAnimator requires iOS 17+. If you’re targeting earlier iOS versions, you’ll need to build multi-step animations manually using chained withAnimation calls and DispatchQueue.main.asyncAfter. PhaseAnimator is significantly cleaner, so use it when your deployment target allows.

PhaseAnimator and KeyframeAnimator Patterns

Looping phase animation Cycle continuously without a trigger
// No trigger = loops automatically and continuously
PhaseAnimator([1.0, 1.2, 1.0]) { phase in
    Circle()
        .fill(.blue)
        .frame(width: 60, height: 60)
        // phase is 1.0, 1.2, 1.0, repeating forever
        .scaleEffect(phase)
} animation: { _ in
    .easeInOut(duration: 0.8)
}
Without a trigger parameter, PhaseAnimator loops through the phases continuously from when the view appears. Great for ambient pulsing, loading effects, and attention-drawing animations.
Enum as phases Named phases for more readable multi-step animations
enum BouncePhase: CaseIterable {
    case rest, up, down

    var offset: Double {
        switch self {
        case .rest: return 0
        case .up:   return -20
        case .down: return 10
        }
    }
}

// Use the enum's CaseIterable conformance as the phase array
PhaseAnimator(BouncePhase.allCases, trigger: trigger) { phase in
    Image(systemName: "star.fill")
        .offset(y: phase.offset)
}
For complex multi-property animations, defining phases as an enum with computed properties is much more readable than working with raw numbers. Each case can compute multiple values (offset, scale, opacity) in one place.
KeyframeAnimator basics Precise control over multiple properties at exact moments
KeyframeAnimator(initialValue: CGFloat(1.0), trigger: trigger) { scale in
    Image(systemName: "checkmark.seal.fill")
        .font(.system(size: 60))
        .scaleEffect(scale)
} keyframes: { _ in
    // Snap immediately to 1.5x scale
    LinearKeyframe(1.5, duration: 0.1)
    // Spring back down past 1.0 to 0.8 — overshoot
    SpringKeyframe(0.8, duration: 0.2)
    // Return to normal size with another spring
    SpringKeyframe(1.0, duration: 0.25)
}
KeyframeAnimator lets you define exact values at exact moments using keyframe types: LinearKeyframe, SpringKeyframe, and CubicKeyframe. Use this when PhaseAnimator isn’t precise enough for the effect you need.
SyntaxWhat It Does
PhaseAnimator([…], trigger: t) { phase in }Cycles through the array of values, restarting when trigger changes
PhaseAnimator([…]) { phase in }Loops through phases continuously without needing a trigger
animation: { phase in .easeOut }Closure that returns a different animation curve per phase
KeyframeAnimator(initialValue:, trigger:)Animate properties along a precise timeline with keyframe values
LinearKeyframe(1.5, duration: 0.1)Reach this value linearly over 0.1 seconds
SpringKeyframe(1.0, duration: 0.25)Spring toward this value over 0.25 seconds
When to use what: Start with .animation() and withAnimation for most things. Reach for PhaseAnimator when you need more than two steps. Only use KeyframeAnimator when you need surgical control over individual property values at specific moments in time.
🎯
Challenge 7.6
Send Button Phase Animation

Build a “send message” button that plays a multi-phase animation when tapped. Use PhaseAnimator with at least three phases: the button should compress slightly when tapped, then a paper-plane icon should scale up, and finally everything returns to rest. Define your phases as an enum for readability. Test in the Simulator — tapping the button should feel like a satisfying confirmation of the send action, not just a color change.

Using AI to Go Further

Deepen Your Understanding Use AI as a tutor — no code generation required
Explain the difference between PhaseAnimator and KeyframeAnimator in SwiftUI without writing any code. What does each one do better than the other? Give me a real-world UI scenario where I’d reach for PhaseAnimator, and a different scenario where I’d need KeyframeAnimator instead.
I’m learning about PhaseAnimator in SwiftUI. Walk me through what happens step by step when I tap the trigger button — what does SwiftUI do with the phases array, how does it decide when to move to the next phase, and what happens at the end of the array?
Build a Practice View Generate a commented example to study, not just copy
Write a SwiftUI view using PhaseAnimator with an enum-based phase type. The animation should cycle through at least three phases and affect two different properties (like scale and offset). Add a comment above every line explaining what it does — write the comments for someone who has never seen PhaseAnimator before.
Show me a SwiftUI example using KeyframeAnimator to create a satisfying “like” animation — the heart should scale up quickly, overshoot slightly, then spring back to rest. Add inline comments explaining every keyframe choice: the type used (Linear vs Spring), the target value, and the duration.

Stage 7 Recap: Animations and Transitions

You’ve just gone from zero animations to a full animation toolkit. Here’s what you covered across the six lessons.

  • Lesson 7.1 — Implicit Animations: Attaching .animation(modifier:value:) to a view makes any animatable property changes play smoothly whenever the specified value changes. Always pass the value: parameter.
  • Lesson 7.2 — Explicit Animations: Wrapping a state change in withAnimation { } animates every view that responds to that change — no need to attach .animation() to each view individually.
  • Lesson 7.3 — Curves and Springs: Curves (easeIn, easeOut, easeInOut, linear) describe the rate of change over time. Springs are physics-based and feel more natural for interactive elements. Use dampingFraction or bounce to control how much the spring overshoots.
  • Lesson 7.4 — View Transitions: .transition() animates views appearing and disappearing when they’re wrapped in an if statement. It requires withAnimation to actually animate — without it, the view just snaps.
  • Lesson 7.5 — matchedGeometryEffect: Creates hero animations between two views with the same id and @Namespace. Use if/else to ensure only one view with a given ID exists at a time.
  • Lesson 7.6 — Phase Animators and Keyframes: PhaseAnimator lets you sequence animations through multiple values automatically. KeyframeAnimator gives you precise per-property control at exact moments in time. Both require iOS 17+.

If you skipped any of the challenges, go back and do them — this is the stage where building in the Simulator matters most. You can’t really feel the difference between a spring and an ease-in-out by reading about it. Run the code and feel it.

Up next is Stage 8: Networking and Data — where you’ll learn to fetch real data from the internet, decode JSON, and display it in your SwiftUI views using async/await.

Learn SwiftUI Stage 8: Networking and Data

Every app worth using talks to the internet. This is the stage where your app stops living in isolation and starts connecting to the real world.

You’ll need Xcode open for this stage, and one important note upfront: network requests don’t run in the canvas preview. You’ll need to use the simulator or a real device to see your networking code actually execute. Don’t let that slow you down — just hit the run button. This stage has 6 lessons and takes roughly 3 hours to complete. Each lesson ends with a hands-on challenge using real public APIs, so you’ll be fetching actual data by the end of lesson 8.2.

By the time you finish Stage 8, you’ll understand how the request/response cycle works in iOS, how to fetch data from an API using URLSession with async/await, how to decode JSON responses using Codable, how to model loading and error states so your app behaves gracefully, how to load remote images using AsyncImage, and what you need to know about caching and performance to avoid common pitfalls. This is a big stage — and a very satisfying one.

08
Stage 8
Networking and Data
6 lessons · ~3 hrs
8.1
How Networking Works in iOS
⏱ 20 min SwiftUI Basics

Before you write a single line of networking code, it helps to have a clear mental model of what’s actually happening when your app talks to the internet. A lot of beginners skip this step and end up confused when things don’t work — not because the code is wrong, but because they didn’t understand what the code was supposed to do.

Think about ordering food at a restaurant. You look at a menu, you tell the waiter what you want, and the waiter goes to the kitchen and comes back with your food. You don’t go into the kitchen yourself. You don’t care how the food is prepared. You just make a request and eventually get a response. That’s networking in a nutshell. Your app is the customer. The server is the kitchen. The API is the menu — a list of things you’re allowed to ask for. And URLSession is your waiter.

The specific format that most modern APIs use to send data back is called JSON. JSON stands for JavaScript Object Notation, but don’t let that name confuse you — it has nothing to do with JavaScript. It’s just a plain-text format for describing structured data. When your app makes a request to an API, the server usually sends back a JSON response — a bunch of text that represents the data you asked for. Your job as an iOS developer is to take that text and turn it into real Swift objects your app can use.

The Request/Response Cycle: Your app sends a request to a URL — something like https://api.example.com/posts. The server at that URL receives the request, does whatever it needs to do, and sends back a response. The response has two parts: a status code (a number like 200 for success, or 404 for not found) and a body (usually JSON). Your app then reads the body and does something with the data. That’s the entire cycle — request, response, decode, display.
Canvas Preview Won’t Work Here: Network requests require an actual HTTP connection. The SwiftUI canvas preview doesn’t make real network calls. Always run networking code in the Simulator or on a physical device. If you tap run and see a live preview but no data appears, check that you’re using the simulator, not the canvas.
What Is an API? API stands for Application Programming Interface. For our purposes, it means a web address your app can call to request data. The company or service that owns the API defines what requests you can make and what format the response will be in. Some APIs require a key (a kind of password that proves who is making the request). Others, like the ones you’ll use in this stage’s challenges, are completely open and free to use.

Key Terms to Know Before Lesson 8.2

URL The web address your app makes a request to
A URL (Uniform Resource Locator) is just a web address — like https://jsonplaceholder.typicode.com/posts. In networking, a URL is what you hand to URLSession to say “go get me the data at this address.”
JSON The text format most APIs use to send data
JSON is structured plain text. An object in JSON looks like {"id": 1, "title": "Hello"}. An array of objects looks like [{"id": 1}, {"id": 2}]. Once you’ve seen a few examples, it becomes very readable — it’s basically a dictionary written as text.
Status Code The number in a response that tells you if the request succeeded
Every HTTP response comes with a status code. 200 means success. 404 means the resource wasn’t found. 401 means unauthorized. 500 means something went wrong on the server. You’ll use these to decide how to handle a response in your app.
Endpoint A specific URL that returns a specific type of data
An API can have many endpoints — one for posts, one for users, one for comments. Each endpoint is a different URL path. /posts might return a list of posts. /posts/1 might return a single post with id 1. The base URL stays the same; the path changes.
🎯
Challenge 8.1
Trace the Cycle

This challenge is a written reflection — no code required. Open your browser and go to https://jsonplaceholder.typicode.com/posts/1. You just made a GET request from your browser. Look at the JSON response that appears.

Now answer these three questions in your own words: (1) What URL did you make a request to? (2) What data did the server send back? (3) If you were building a blog app, what Swift properties would you need to represent this data? Write down your answers before moving to lesson 8.2 — this mental model is the foundation everything else builds on.

Using AI to Go Further

Test Your Mental Model Use AI to check your understanding of how networking works before writing any code
I’m learning iOS development and I want to check my understanding of how networking works. Can you quiz me on the request/response cycle, what JSON is, and what an API endpoint is? Ask me one question at a time and tell me if my answer is correct or where I went wrong.
I know that my app sends a request and gets a response, but I’m fuzzy on how the app actually “reads” JSON. Can you explain, step by step, what happens between the server sending a JSON response and my app having usable data — in plain English, without writing the Swift code yet?
8.2
Fetching Data with URLSession and async/await
⏱ 35 min Intermediate SwiftUI

Now that you have the mental model, it’s time to write the code. Fetching data from the internet in modern Swift uses two things together: URLSession (Apple’s built-in networking layer) and async/await (Swift’s way of handling work that takes time to complete). If you haven’t used async/await before, don’t worry — I’ll explain it here in plain English before we use it.

Here’s the core idea behind async/await. Some operations in your app take time — like fetching data from a server. You don’t want your entire app to freeze while it waits for a response. That would make for a terrible user experience. async/await lets you write code that says “start this task, and while you’re waiting for it to finish, let the rest of the app keep running.” The await keyword marks the exact spot where the waiting happens. Think of it like placing a bookmark — your code pauses at that line until the result arrives, then picks up right where it left off.

In SwiftUI, the .task modifier is the cleanest way to kick off an async network call when a view appears. It automatically cancels the task if the view disappears before the data arrives — which is exactly what you want.

New to Swift? SwiftUI is built on Swift, so a solid foundation helps. async/await is a Swift language feature that appears throughout iOS development. If you want to build that foundation first, check out the Learn Swift series — then come back here.
import SwiftUI

// A simple model to hold one post from the API
struct Post: Codable, Identifiable {
    let id: Int
    let title: String
    let body: String
}

struct PostListView: View {
    // State variable to hold our fetched posts
    @State private var posts: [Post] = []

    var body: some View {
        List(posts) { post in
            VStack(alignment: .leading, spacing: 4) {
                Text(post.title)
                    .font(.headline)
                Text(post.body)
                    .font(.subheadline)
                    .foregroundStyle(.secondary)
            }
        }
        // .task runs when the view appears and cancels if the view disappears
        .task {
            await fetchPosts()
        }
    }

    // async marks this function as one that can be awaited
    func fetchPosts() async {
        // Build the URL — guard let safely handles a bad URL string
        guard let url = URL(string: "https://jsonplaceholder.typicode.com/posts") else { return }

        // do/catch lets us handle errors without crashing
        do {
            // await here — this line pauses until data and response arrive
            let (data, _) = try await URLSession.shared.data(from: url)

            // Decode the JSON data into an array of Post structs
            let decoded = try JSONDecoder().decode([Post].self, from: data)

            // Update state on the main thread so the UI refreshes
            await MainActor.run {
                posts = decoded
            }
        } catch {
            // Print the error so you can see what went wrong
            print("Fetch error: \(error)")
        }
    }
}
Simulator screenshot showing the List populated with post titles and body text after the fetch completes
LineWhat it does
struct Post: Codable, Identifiable Defines the data model. Codable lets Swift automatically decode JSON into this struct. Identifiable lets SwiftUI use it in a List without needing an explicit id: parameter.
@State private var posts: [Post] = [] Declares the state that holds the fetched posts. It starts as an empty array. When this updates, SwiftUI automatically re-renders the List.
.task { await fetchPosts() } Triggers the async fetch when the view appears. SwiftUI automatically cancels the task if the view disappears before the work completes.
func fetchPosts() async The async keyword marks this function as one that does asynchronous work. It means callers need to use await when calling it.
try await URLSession.shared.data(from: url) Makes the actual network request. await pauses here until the data arrives. try is needed because this can throw an error if the network is unavailable.
let (data, _) = ... URLSession returns a tuple of (Data, URLResponse). The underscore _ discards the response object — we only need the data for now.
await MainActor.run { posts = decoded } Updates the UI state on the main thread. SwiftUI requires all state updates that affect the UI to happen on the main thread. MainActor.run ensures this.
The Most Common Mistake: Forgetting that UI updates must happen on the main thread. If you set posts = decoded directly without MainActor.run, you may see a purple runtime warning in Xcode saying you’re updating state on a background thread. Always route UI state changes through MainActor.run inside async functions, or mark your view model with @MainActor.

Key Patterns for Networking

.task { } Trigger async work when a view appears
.task {
    await loadData()
}
The preferred way to kick off async work in SwiftUI. Runs when the view appears and is automatically cancelled when the view disappears — preventing wasted work and memory leaks.
do / catch Handle errors without crashing
do {
    let (data, _) = try await URLSession.shared.data(from: url)
    // use data
} catch {
    print("Error: \(error)")
}
Network calls can fail for many reasons: no internet connection, server errors, timeout. Wrapping them in do/catch means a failed request prints an error instead of crashing your app.
guard let url Safely create a URL from a string
guard let url = URL(string: "https://api.example.com/data") else {
    print("Invalid URL")
    return
}
URL(string:) returns an optional because not every string is a valid URL. Using guard let here is the standard pattern — if the URL is invalid, you exit early with a clear message instead of crashing later.
await MainActor.run Update UI state safely from async code
await MainActor.run {
    self.items = decoded
}
Network responses arrive on a background thread. SwiftUI requires state changes to happen on the main thread. MainActor.run moves the assignment back to the main thread. An alternative is to mark your entire class with @MainActor.
SyntaxWhat It Does
func load() asyncDeclares an asynchronous function that must be called with await
await someAsyncFunction()Calls an async function, pausing execution until it returns
try await URLSession.shared.data(from: url)Makes a network GET request and returns the raw data and response
let (data, _) = try await …Destructures the tuple, keeping data and discarding the response
.task { await … }SwiftUI modifier that runs async work when the view appears
await MainActor.run { }Runs the closure on the main thread, safe for UI updates
🎯
Challenge 8.2
Fetch Real Posts

Build a new SwiftUI view called UserListView. Instead of posts, fetch users from https://jsonplaceholder.typicode.com/users. Create a User struct with id, name, and email properties. Display each user’s name and email in a List. Run in the simulator and confirm you see 10 real users appear.

Bonus: add a NavigationStack with a .navigationTitle("Users").

Using AI to Go Further

Deepen Your Understanding Clarify async/await and URLSession without having AI write the code for you
I’m learning async/await in Swift. Can you explain what actually happens at runtime when a function marked async hits an await keyword? Use a plain-English analogy before showing any code.
I know how to fetch data with URLSession.shared.data(from:), but I don’t fully understand why I need MainActor.run when updating state. Can you explain this without rewriting my code — just explain the concept?
Build a Practice View Generate a heavily-commented example to study how a real fetch function fits together
Write a SwiftUI view that fetches data from https://jsonplaceholder.typicode.com/todos and displays the results in a List. Add a comment on every line explaining what it does and why — write the comments for a beginner learning async/await for the first time.
Here’s a fetch function I wrote: [paste your code]. Can you review it for correctness and point out any issues with thread safety or error handling? Explain the issues before suggesting any changes.
8.3
Decoding JSON with Codable
⏱ 30 min Intermediate SwiftUI

In lesson 8.2, you saw Codable used on the Post struct, but we didn’t dig into what it actually does. In this lesson, you’ll learn how Codable works, what to do when the JSON property names don’t match your Swift property names, and how to decode nested JSON structures — which is extremely common in real-world APIs.

Codable is a Swift protocol that gives your struct the ability to convert itself to and from external data formats like JSON. When you add Codable to a struct, Swift automatically generates the code needed to encode (convert your struct to JSON) and decode (convert JSON into your struct). You don’t have to write any of that logic yourself — the compiler handles it. That said, there are a few situations where you do need to step in and provide some hints, and those are the ones we’ll cover here.

The most common situation where automatic decoding breaks down is when the JSON property names don’t match your Swift property names. JSON APIs often use snake_case (like user_name) while Swift convention is camelCase (like userName). CodingKeys is how you bridge that gap.

New to Swift? SwiftUI is built on Swift, so a solid foundation helps. Codable is a Swift language feature — protocols, enums, and conformance are all Swift concepts. If you want to build that foundation first, check out the Learn Swift series — then come back here.
// A JSON response might look like this:
// { "user_name": "Chris", "post_count": 42, "is_verified": true }

struct UserProfile: Codable {
    let userName: String
    let postCount: Int
    let isVerified: Bool

    // CodingKeys maps your Swift names to the JSON field names
    enum CodingKeys: String, CodingKey {
        case userName = "user_name"
        case postCount = "post_count"
        case isVerified = "is_verified"
    }
}

// Nested JSON: { "id": 1, "address": { "city": "Toronto", "zip": "M1M 1M1" } }
struct Address: Codable {
    let city: String
    let zip: String
}

struct Person: Codable, Identifiable {
    let id: Int
    // A nested Codable struct mirrors a nested JSON object automatically
    let address: Address
}

// Decoding is the same regardless of nesting depth
let decoded = try JSONDecoder().decode(Person.self, from: data)
print(decoded.address.city) // "Toronto"
LineWhat it does
struct UserProfile: Codable Adding Codable conformance gives this struct the ability to decode from (and encode to) JSON automatically. No extra code required for simple cases.
enum CodingKeys: String, CodingKey A special nested enum that tells Swift how to map between JSON keys and Swift property names. Required only when names don’t match.
case userName = "user_name" The left side is your Swift property name. The right side is the exact string key in the JSON. Swift matches these up during decoding.
struct Address: Codable Any nested struct also needs to be Codable. When the decoder encounters the “address” key in JSON, it recursively decodes it as an Address.
JSONDecoder().decode(Person.self, from: data) Creates a JSONDecoder and tells it to turn raw Data into a Person instance. Person.self is how you pass a type as a parameter in Swift.
If Any Property Fails, the Whole Decode Fails: Swift’s JSONDecoder is strict. If your struct has a non-optional property like let title: String and the JSON doesn’t include a "title" key, decoding throws an error. The fix is to either make the property optional (let title: String?) or add a CodingKey that maps to the correct JSON key name.

Codable Patterns You’ll Use Constantly

Automatic (names match) The simplest case — no extra code needed
// JSON: { "id": 1, "title": "Hello", "body": "World" }
struct Post: Codable {
    let id: Int
    let title: String
    let body: String
}
When your Swift property names exactly match the JSON key names, you don’t need CodingKeys at all. Swift figures it out automatically. This is the happy path — design your models to match the JSON when you can.
snake_case → camelCase Automatic conversion using a decoder strategy
let decoder = JSONDecoder()
// Automatically converts user_name → userName, post_count → postCount
decoder.keyDecodingStrategy = .convertFromSnakeCase

let decoded = try decoder.decode(UserProfile.self, from: data)
If your entire API uses snake_case keys, this one setting handles all the name mapping for you. You don’t need CodingKeys at all — just set the strategy once on the decoder and Swift converts every key automatically.
Optional properties Handle JSON fields that might not always be present
struct Article: Codable, Identifiable {
    let id: Int
    let title: String
    // subtitle may not exist in every JSON object
    let subtitle: String?
    let imageURL: URL?
}
If a JSON field is sometimes absent, make the property optional. Codable will set it to nil when the key is missing rather than throwing an error. This is extremely common with real-world APIs that have inconsistent responses.
Nested structs Mirror nested JSON objects as nested Codable structs
// JSON: { "name": "Chris", "company": { "name": "CodeWithChris" } }
struct Company: Codable {
    let name: String
}

struct Developer: Codable {
    let name: String
    let company: Company
}
Nested JSON objects map naturally to nested Swift structs. As long as every struct in the chain conforms to Codable, the decoder handles the nesting automatically — no extra code required.
SyntaxWhat It Does
struct Model: CodableAdds encoding and decoding capability to the struct
JSONDecoder().decode(T.self, from: data)Decodes raw Data into type T — throws on failure
enum CodingKeys: String, CodingKeyProvides custom mapping between JSON keys and Swift properties
decoder.keyDecodingStrategy = .convertFromSnakeCaseAutomatically converts snake_case JSON keys to camelCase Swift properties
let value: String?Makes a Codable property optional — decodes to nil if the JSON key is absent
🎯
Challenge 8.3
Decode a Nested Response

Fetch the users endpoint: https://jsonplaceholder.typicode.com/users. Each user has a nested address object that contains a city field. Create the necessary Codable structs to decode this nested structure. Display each user’s name and their city in a List. Try to do it without looking back at the lesson — this will solidify the pattern.

Using AI to Go Further

Deepen Your Understanding Understand CodingKeys and Codable without generating production code
I’m learning Codable in Swift. Can you explain when I need CodingKeys vs when I don’t? Give me three different scenarios — one where they’re not needed, one where they are, and one that would be tricky — and explain each.
What happens internally when JSONDecoder encounters a JSON key that doesn’t match any Swift property name and there’s no CodingKeys enum? Walk me through the failure mode and what error gets thrown.
Build a Practice View Get a commented Codable example with nested data to study
Write a Codable struct that maps to this JSON, including a nested object. Add a comment on every line explaining what it does and why. JSON: { “post_id”: 1, “post_title”: “Hello”, “author”: { “id”: 99, “full_name”: “Chris” } }
Here are my Codable structs: [paste your code]. The decoder is throwing an error and I can’t figure out why. Can you review the structs and explain what might be causing the issue without rewriting them for me?
8.4
Modelling Loading States
⏱ 25 min Intermediate SwiftUI

Here’s the reality of every network request: it might be loading, it might have succeeded with data, it might have come back empty, or it might have failed with an error. If you only handle the success case — which a lot of beginners do — your app will look broken in every other situation. Loading states are how you make your app feel like a polished, professional product instead of a rough prototype.

The standard pattern in SwiftUI is to represent these states with a Swift enum. Enums are perfect here because a request can only be in one state at a time — it can’t be both loading and loaded simultaneously. When you model your state as an enum, the compiler helps you handle every case, which prevents whole categories of bugs.

You’ll use a @State variable of your enum type, and then a switch statement in your view to display different UI for each state. This pattern is sometimes called a “view state machine” and it scales well as your app grows.

import SwiftUI

struct Post: Codable, Identifiable {
    let id: Int
    let title: String
}

// An enum that covers every possible state of the network request
enum LoadingState {
    case loading
    case loaded([Post])
    case empty
    case error(String)
}

struct PostsView: View {
    // State starts as loading so we show a spinner immediately
    @State private var state: LoadingState = .loading

    var body: some View {
        // Switch over the state enum to render the right UI
        switch state {
        case .loading:
            ProgressView("Loading...")

        case .loaded(let posts):
            List(posts) { post in
                Text(post.title)
            }

        case .empty:
            ContentUnavailableView("No Posts", systemImage: "tray")

        case .error(let message):
            ContentUnavailableView("Error", systemImage: "exclamationmark.triangle",
                                  description: Text(message))
        }
    }
}
LineWhat it does
enum LoadingState Defines all possible states. The loaded case has an associated value — it carries the actual array of posts so you don’t need a separate variable to store them.
case loaded([Post]) An enum case with an associated value. When you set state to .loaded(posts), the data travels with the state change. When you switch on it, you extract the data with let posts.
@State private var state: LoadingState = .loading Starts in the loading state so the spinner appears immediately when the view first renders, before any network request completes.
switch state { } Swift’s switch on an enum is exhaustive — the compiler forces you to handle every case. This means you can’t accidentally forget the error state.
ContentUnavailableView A built-in SwiftUI view (iOS 17+) for showing empty or error states. It follows Apple’s design conventions automatically.
The Anti-Pattern to Avoid: Using a separate isLoading: Bool and errorMessage: String? alongside your data array looks simple but creates impossible states — like isLoading = true and posts = [...] at the same time. An enum with cases makes illegal states unrepresentable.

Loading State Patterns

ProgressView Show a spinner while loading
case .loading:
    ProgressView("Fetching data...")
        .progressViewStyle(.circular)
The standard iOS loading indicator. It automatically matches the system style. Adding a label is optional but helps accessibility.
Skeleton / redacted Show placeholder content while data loads
case .loading:
    List(0..<5, id: \.self) { _ in
        Text("Placeholder post title")
    }
    .redacted(reason: .placeholder)
The .redacted(reason: .placeholder) modifier replaces text and images with rounded gray rectangles — creating a skeleton loading effect identical to what you see in the App Store and Apple News.
Retry button Let users try again after a failure
case .error(let message):
    VStack(spacing: 16) {
        Text(message).foregroundStyle(.secondary)
        Button("Try Again") {
            Task { await fetchPosts() }
        }
        .buttonStyle(.borderedProminent)
    }
A retry button is a small but meaningful UX improvement. Use Task { await ... } inside a Button’s action to bridge between synchronous Button callbacks and async functions.
SyntaxWhat It Does
enum LoadingStateDefines the set of all possible UI states for a network operation
case loaded([Post])Enum case with an associated value — the data travels with the state
switch state { case .loaded(let posts): }Exhaustive switch — compiler forces you to handle every case
ProgressView(“Loading…”)Displays a circular spinner with an optional label
.redacted(reason: .placeholder)Replaces content with gray skeleton shapes while loading
ContentUnavailableViewSystem view for empty and error states (iOS 17+)
Task { await … }Creates an async task from a synchronous context (e.g. a Button action)
🎯
Challenge 8.4
Handle All Four States

Take your PostListView from lesson 8.2 and refactor it to use a LoadingState enum. Your enum should have four cases: loading, loaded([Post]), empty, and error(String). Update the view’s switch statement to show a ProgressView while loading, a List when loaded, and a ContentUnavailableView for empty and error states. To test the error state, temporarily change the URL to something invalid and confirm the error case displays correctly.

Using AI to Go Further

Deepen Your Understanding Understand why enum-based state is superior to multiple Bool flags
I’m learning about loading state management in SwiftUI. Can you explain why using an enum for loading states is better than having separate isLoading, isError, and data variables? Give me a specific example of a bug that the enum approach prevents.
What are “associated values” in Swift enums and why are they useful for modelling loaded data and error messages? Explain the concept before showing any code.
Build a Practice View Study a complete state machine implementation with heavy comments
Write a SwiftUI view that uses an enum with loading, loaded, empty, and error cases to fetch from https://jsonplaceholder.typicode.com/albums. Add a comment on every line explaining what it does and why — focus especially on explaining the associated value pattern and the switch statement.
Here is my state enum and view: [paste your code]. Can you review whether I’m handling all states correctly and whether there are any missing cases or impossible states I’ve created? Explain the issues before suggesting fixes.
8.5
AsyncImage
⏱ 20 min SwiftUI Basics

Most apps that display data also display images — profile photos, product thumbnails, article headers. Loading images from a URL used to require third-party libraries, but SwiftUI now has AsyncImage built right in. It handles the network request, the loading state, and the failure case all in one clean view.

The simplest version of AsyncImage takes just a URL and loads the image automatically. But the real power comes from the phase-based API, which gives you explicit control over what to show while the image is loading and what to show if the load fails. This is the version you’ll use in any production app.

One thing to be aware of: AsyncImage doesn’t cache images by default. If the same URL appears multiple times in a List and the user scrolls, the image gets re-fetched each time a cell comes back into view. For simple use cases this is fine. For apps with long image-heavy lists, you’ll want a caching solution — which we cover in lesson 8.6.

import SwiftUI

struct ProfileCard: View {
    let imageURL: URL

    var body: some View {
        // Phase-based API gives us full control over loading, success, and failure
        AsyncImage(url: imageURL) { phase in
            switch phase {
            case .empty:
                // Shown while the image is being fetched
                ProgressView()
                    .frame(width: 80, height: 80)

            case .success(let image):
                // The loaded image — apply modifiers here
                image
                    .resizable()
                    .scaledToFill()
                    .frame(width: 80, height: 80)
                    .clipShape(.circle)

            case .failure:
                // Shown when the image fails to load
                Image(systemName: "person.circle.fill")
                    .resizable()
                    .frame(width: 80, height: 80)
                    .foregroundStyle(.secondary)

            @unknown default:
                EmptyView()
            }
        }
    }
}
LineWhat it does
AsyncImage(url: imageURL) { phase in } The phase-based initializer. phase is an AsyncImagePhase enum value that changes as the image loads. The closure you provide decides what to display for each phase.
case .empty The image hasn’t loaded yet. This is shown immediately while the request is in-flight. A ProgressView or a gray placeholder works well here.
case .success(let image) The image loaded successfully. image is a SwiftUI Image view — apply .resizable(), .scaledToFill(), and any other modifiers here.
case .failure The image failed to load — bad URL, network error, or server error. Show a fallback image or icon here so the UI doesn’t look broken.
@unknown default A future-proofing case. If Apple adds a new phase in a future iOS version, this prevents your switch from breaking. Always include it with EmptyView().
Always Set a Frame on the Placeholder: The loading and failure cases need an explicit .frame() modifier. Without it, they’ll collapse to zero size, which makes the layout jump when the image loads. Match the frame to the size you want the final image to occupy.

AsyncImage Patterns

Simple (no phase) Quick and easy — no loading or error handling
AsyncImage(url: URL(string: "https://example.com/photo.jpg"))
    .frame(width: 100, height: 100)
The simplest form. SwiftUI handles loading internally and shows a gray placeholder automatically. Good for prototyping but not for production apps where you want to control the loading experience.
With placeholder closure Provide a custom placeholder without the full phase API
AsyncImage(url: url) { image in
    image.resizable().scaledToFit()
} placeholder: {
    Color.gray.opacity(0.3)
}
.frame(height: 200)
A middle-ground API: you provide the success state and a placeholder, but not an explicit failure handler. Good when you want a simple colored placeholder while loading but don’t need to handle failures differently.
With URL string Pass a URL built from a string directly
let urlString = "https://picsum.photos/200"

AsyncImage(url: URL(string: urlString)) { phase in
    // handle phases
}
AsyncImage takes an optional URL?, so you can pass URL(string: urlString) directly without unwrapping it. If the string is invalid, AsyncImage silently shows the failure state.
SyntaxWhat It Does
AsyncImage(url:)Loads an image from a URL — simplest form, auto placeholder
AsyncImage(url:) { phase in }Phase-based API — handle loading, success, and failure explicitly
case .emptyImage is loading — show a spinner or placeholder
case .success(let image)Image loaded — apply resizable, scaledToFill, clipShape, etc.
case .failureImage failed — show a fallback icon or color
@unknown defaultFuture-proofing case — include with EmptyView()
🎯
Challenge 8.5
Build a Photo Grid

Create a LazyVGrid that displays a 2-column grid of images from https://picsum.photos/200?random=N where N is a number from 1 to 20. Use the phase-based AsyncImage API to show a ProgressView while each image loads and a gray rectangle on failure. Each cell should be 160×160 points with a slight corner radius.

Using AI to Go Further

Deepen Your Understanding Understand AsyncImage phases and when to use each API variant
I’m learning AsyncImage in SwiftUI. What are the three different ways to initialize it and when would I use each one? Explain the tradeoffs without showing too much code — I want to understand the design first.
Why do I need @unknown default in a switch on AsyncImagePhase? What would happen if I left it out and Apple added a new phase in a future iOS release? Explain this to me in plain English.
Build a Practice View Get a commented AsyncImage example to study loading state handling
Write a SwiftUI List row that uses AsyncImage to show a thumbnail on the left and a text label on the right. Use the phase-based API and add a comment on every line explaining what it does. Include a realistic loading placeholder and failure fallback.
Here is my AsyncImage implementation: [paste your code]. The image layout is jumping when the photo loads — can you review and explain what might be causing the layout to shift, without rewriting the code for me?
8.6
Caching and Performance Considerations
⏱ 20 min Intermediate SwiftUI

You now know how to fetch data and display images. But there’s a layer of practical knowledge that separates apps that feel fast from apps that feel sluggish: understanding what gets cached, what doesn’t, and how to keep your networking off the main thread. This lesson is intentionally conceptual — the goal is awareness, not implementation complexity.

By default, URLSession does some caching automatically. When you make a GET request, the response may be stored in an on-disk cache depending on the HTTP cache headers the server sends back. The next time you make the same request, URLSession might serve the cached version instead of hitting the network again. This is a good thing for JSON data that doesn’t change often. You generally don’t need to configure this — the default behavior is reasonable for most apps.

Images are a different story. AsyncImage does not maintain a persistent image cache between app launches. Each fresh run of your app re-downloads every image it displays. For a simple app showing a handful of images this is fine. For an app with a long, scrolling list of photos, this will feel slow and waste the user’s data. That’s where image caching libraries come in.

import SwiftUI

// URLSession response caching — happens automatically for GET requests
// The URLCache.shared instance manages the on-disk cache

// You can inspect or configure the cache size if needed:
let cache = URLCache.shared
print("Memory capacity: \(cache.memoryCapacity / 1024 / 1024)MB")
print("Disk capacity: \(cache.diskCapacity / 1024 / 1024)MB")

// For most apps, you won't need to touch this. But you can increase it:
URLCache.shared = URLCache(
    memoryCapacity: 50_000_000,  // 50 MB in memory
    diskCapacity: 200_000_000,   // 200 MB on disk
    directory: nil
)
ConceptWhat You Need to Know
URLCache.shared The system-managed cache for HTTP responses. Works automatically for most GET requests if the server sets appropriate cache headers. You rarely need to configure it directly.
AsyncImage caching AsyncImage has no persistent image cache. Images are re-downloaded on each app launch and when cells scroll off-screen in a List. Fine for small sets of images, problematic for image-heavy lists.
Main thread rule Network requests must never block the main thread. URLSession with async/await handles this automatically — your fetch happens on a background thread and only touches the main thread when you update state with MainActor.run.
Third-party image caching Kingfisher and Nuke are the two most popular Swift image caching libraries. They handle download, memory caching, disk caching, and smooth scrolling performance automatically. Add via Swift Package Manager.
You Don’t Need a Library for Most Apps: For beginner and intermediate projects, AsyncImage is perfectly fine. Don’t add a third-party library before you actually experience a performance problem. Premature optimization creates complexity — ship first, optimize when users tell you it’s slow.

Performance Patterns to Know

LazyVStack / LazyVGrid Only load views as they scroll into view
// LazyVStack only creates views as they become visible
ScrollView {
    LazyVStack {
        ForEach(items) { item in
            AsyncImage(url: item.imageURL) { phase in
                // handle phase
            }
        }
    }
}
In a LazyVStack, SwiftUI only creates the views (and triggers the AsyncImage downloads) for items that are currently on screen. This is critical for performance with long lists of images.
Task cancellation Stop in-flight requests when they’re no longer needed
// .task cancels automatically when the view disappears
.task {
    await fetchData()
}

// Avoid .onAppear with Task{} — this doesn't cancel automatically
// .
onAppear { Task { await fetchData() } } // less preferred
The .task modifier automatically cancels its work if the view disappears. If you use .onAppear with a manual Task { }, you need to manage cancellation yourself. Prefer .task whenever possible.
Adding Kingfisher via SPM When you need persistent image caching
// 1. In Xcode: File > Add Package Dependencies
// 2. Paste: https://github.com/onevcat/Kingfisher
// 3. Select the Kingfisher library and click Add Package

import Kingfisher

// Then use KFImage exactly like AsyncImage
KFImage(URL(string: "https://example.com/photo.jpg"))
    .resizable()
    .scaledToFill()
    .frame(width: 100, height: 100)
    .clipShape(.circle)
Kingfisher’s KFImage is a drop-in replacement for AsyncImage with persistent memory and disk caching built in. Only add this when you actually need it — not every project does.
ConceptKey Takeaway
URLCache.sharedAutomatic HTTP response cache — works without configuration for most apps
AsyncImage cachingNo persistent cache — images re-download on each launch
Main threadNetwork calls happen on background threads automatically with async/await
LazyVStack / LazyVGridOnly instantiates views in the current viewport — critical for long lists
.task vs .onAppearPrefer .task — it handles automatic cancellation when view disappears
Kingfisher / NukeThird-party libraries with persistent image caching — add only when needed
🎯
Challenge 8.6
Audit Your Stage 8 Code

Go back through the views you built in lessons 8.2–8.5. Check each one for these three things: (1) Are all state updates using MainActor.run or placed where they’re guaranteed to run on the main thread? (2) Are you using .task instead of .onAppear for async work? (3) If you have image lists, are they inside a LazyVStack or a List (which is lazy by default)? Fix any issues you find.

Using AI to Go Further

Deepen Your Understanding Understand caching, main thread rules, and lazy loading conceptually
I’m learning about networking performance in iOS. Can you explain what happens when a SwiftUI view with AsyncImage is inside a List and the user scrolls up and down repeatedly — does each image get re-downloaded? What would change if I used Kingfisher instead?
Why does URLSession use a background thread for network requests, and why does updating SwiftUI state need to happen on the main thread? Can you explain the main thread rule and why breaking it causes problems?
Build a Practice View Review your existing code for performance issues
Here is a SwiftUI view I wrote that fetches and displays images: [paste your code]. Can you review it for any common performance issues — specifically around thread safety, lazy loading, and task cancellation? Explain any issues you find before suggesting changes.
I want to add image caching to my SwiftUI app. Can you explain the tradeoffs between using URLSession’s built-in caching, building a simple in-memory cache myself, and using a library like Kingfisher — without writing production code, just the conceptual tradeoffs?

Stage 8 Recap: Networking and Data

Your app can now talk to the internet. That’s a genuinely significant capability unlock — and it’s one that the majority of real-world iOS apps depend on. Here’s what you covered in Stage 8:

  • Lesson 8.1 — How Networking Works: The request/response cycle in plain English. What an API, a URL, JSON, and a status code are — the mental model before the syntax.
  • Lesson 8.2 — URLSession and async/await: Making real network requests with URLSession.shared.data(from:), the .task modifier for triggering async work, and routing state updates through MainActor.run.
  • Lesson 8.3 — Decoding JSON with Codable: Using Codable structs to turn raw JSON into Swift objects, CodingKeys for mismatched property names, and nested structs for nested JSON.
  • Lesson 8.4 — Modelling Loading States: Representing loading, loaded, empty, and error states with an enum — and why this pattern prevents bugs that multiple Bool flags create.
  • Lesson 8.5 — AsyncImage: Loading remote images with SwiftUI’s built-in AsyncImage using the phase-based API for full control over loading and failure states.
  • Lesson 8.6 — Caching and Performance: What URLSession caches automatically, why AsyncImage lacks persistent caching, how lazy stacks improve scrolling performance, and when to reach for third-party libraries like Kingfisher.

If you skipped any of the stage challenges, now is a great time to go back. The 8.2, 8.3, and 8.4 challenges use JSONPlaceholder — a free, live API — so you’ll see real data moving through your code. That hands-on experience is what makes this stage stick.

Stage 9 covers Persistence — how to save data locally on the device using UserDefaults, SwiftData, and file storage, so your app remembers state between launches.

Learn SwiftUI Stage 9: Persistence

An app that forgets everything the moment it closes isn’t really useful. Every counter resets to zero, every preference vanishes, every piece of work disappears. This stage fixes that — for good.

You’ll need Xcode open with canvas preview running as you work through these six lessons. Each one builds on the last, so go in order. The whole stage takes around three hours, and every lesson ends with a challenge — the challenges here are especially important because persistence only means something when you actually close the app and reopen it. Don’t skip them.

By the end of Stage 9 you’ll know how to save simple preferences with @AppStorage, write and read custom data files, and model structured data with SwiftData. You’ll also walk away with a clear mental model for choosing the right tool — so you’re never guessing which one to reach for.

09
Stage 9
Persistence
6 lessons · ~3 hrs · Week 18–19
9.1
Why Persistence Matters
⏱ 15 min SwiftUI Basics

Picture a tap counter app. Every time you tap a button, a number goes up. You’ve been tapping all day — you’re at 47. You close the app to check a message. You come back. The counter is at zero.

That’s the problem this stage is about. In SwiftUI, @State variables live entirely in memory. They exist only while the app is running. The moment iOS terminates your app — whether the user force-quits it, the system reclaims memory, or the device restarts — every @State value disappears. It’s as if the app was opened for the very first time.

Persistence means saving data somewhere it will survive that. Instead of existing only in RAM, persisted data is written to the device’s storage — and read back the next time the app launches. Every app you’ve ever used that remembers something about you is using some form of persistence. This lesson gives you the map before Stage 9 hands you the tools.

The three tools in this stage: @AppStorage is the right choice for simple user preferences — think toggles and settings. File storage is right when you need to save custom data like a text document or a JSON export. SwiftData is right when you have structured, relational data — a list of tasks, a collection of journal entries, a set of contacts.

What happens when the app closes?

When you close an app on iOS, the system doesn’t necessarily terminate it right away — it suspends it in the background. While suspended, the app’s memory is still there. But if iOS needs memory for another app, or if the user force-quits, or if the device restarts, the process is terminated. At that point every variable is gone.

The lesson here is that you can’t rely on suspension to keep data around. If your app stores something the user cares about — a preference, a record, a piece of work — you need to write it to storage explicitly. Reading it back on launch is equally important, and that’s what the property wrappers and APIs in this stage handle for you.

In-memory state vs. persisted data — side by side

Characteristic@State (in-memory)Persisted data
Survives app termination No — gone when the process ends Yes — written to device storage
Speed Instantaneous — just a RAM read Slightly slower — a disk read on launch
Good for UI state: which tab is selected, whether a sheet is showing User data: preferences, records, documents
Risk if lost Low — UI resets gracefully High — user loses work or preferences
Common mistake: Using @State for data the user expects to still be there when they reopen the app. The symptom is always the same: users complain that “the app forgot everything.” If a user would be upset by the data disappearing, it should be persisted.
🎯
Challenge 9.1
Written Reflection

Think of two apps on your phone that rely on persistence. For each one, write a sentence describing what data would be lost if that app used only @State. Then, for each, decide which of the three tools from the tip box above would be the right fit — @AppStorage, files, or SwiftData. There’s no code to write here. The goal is building the mental model before you write a single line.

Using AI to Go Further

Test Your Mental Model Use AI to stress-test your understanding before writing any code
I’m learning iOS development. Can you quiz me on the difference between in-memory state and persisted data in SwiftUI? Ask me one question at a time — use realistic app scenarios — and tell me where I go wrong.
Give me five app feature descriptions. For each one, tell me whether it needs @AppStorage, file storage, or SwiftData — and explain why. Then quiz me on five more so I can test whether I’ve got the mental model right.
9.2
@AppStorage
⏱ 25 min SwiftUI Basics

@AppStorage is the SwiftUI-native way to read and write UserDefaults — the system’s built-in key-value store for simple app preferences. Think of UserDefaults like a tiny notepad that iOS keeps for your app. You write a value to a key, and it stays there even after the app closes.

The great thing about @AppStorage is how familiar it feels. It works almost exactly like @State — you declare a property, you read it, you write to it, and SwiftUI automatically updates the view whenever the value changes. The difference is that the value is backed by disk storage, not RAM.

You’ll use @AppStorage for things like a dark mode toggle, an onboarding completion flag, the user’s display name, or a saved font size preference. It’s not right for large or structured data — but for simple values, it’s exactly what you want.

import SwiftUI

struct SettingsView: View {

    // Reads and writes to UserDefaults under the key "isDarkMode"
    @AppStorage("isDarkMode") var isDarkMode: Bool = false

    // Reads and writes the user's chosen username
    @AppStorage("username") var username: String = ""

    var body: some View {
        Form {
            Section("Appearance") {
                // Toggle writes directly to UserDefaults on change
                Toggle("Dark Mode", isOn: $isDarkMode)
            }
            Section("Profile") {
                // TextField writes to UserDefaults as the user types
                TextField("Your name", text: $username)
            }
        }
        // preferredColorScheme reads isDarkMode to set light or dark mode
        .preferredColorScheme(isDarkMode ? .dark : .light)
    }
}
Xcode canvas preview showing a Form with a Dark Mode toggle and a name TextField. The toggle is on, and the preview has switched to dark mode.
LineWhat it does
@AppStorage("isDarkMode") Declares a property backed by UserDefaults. The string "isDarkMode" is the key — the name under which the value is stored and retrieved.
var isDarkMode: Bool = false Declares the type and default value. The default is only used the first time — if a value already exists in UserDefaults for this key, that value is used instead.
Toggle("Dark Mode", isOn: $isDarkMode) Binds the toggle directly to the @AppStorage property. When the user flips it, the value is written to UserDefaults immediately.
.preferredColorScheme(...) Reads isDarkMode to set the color scheme for the view. Because @AppStorage works like @State, the view re-renders automatically whenever the value changes.
Property wrapper quick note: The @ symbol before AppStorage marks it as a property wrapper — a Swift feature that adds behavior around a stored value. You’ve already used property wrappers: @State, @Binding, and @Observable are all property wrappers. @AppStorage adds persistence on top of the same reactivity pattern.

Common @AppStorage patterns

Bool preference On/off flags like onboarding completion or feature toggles
// Stored as a Bool — true once onboarding is done
@AppStorage("hasCompletedOnboarding") var hasCompletedOnboarding: Bool = false

// Mark it complete when the user taps "Get Started"
Button("Get Started") {
    hasCompletedOnboarding = true
}
Use a Bool for any yes/no flag. Reading it back on the next launch lets you skip the onboarding screen for returning users.
Int preference Numeric settings like a saved font size or a daily goal
// Stored as an Int — default font size is 16
@AppStorage("fontSize") var fontSize: Int = 16

Stepper("Font size: \(fontSize)", value: $fontSize, in: 12...24)
Works exactly like a Bool — just declare the type as Int. Use for any numeric preference that should survive relaunches.
String preference Text values like a display name or a saved theme name
// Stored as a String — empty by default
@AppStorage("displayName") var displayName: String = ""

// Shows a personalised greeting if a name has been saved
if !displayName.isEmpty {
    Text("Welcome back, \(displayName)!")
}
The empty string default means you can check isEmpty to tell whether the user has ever set a name — a clean way to gate personalised content.
RawRepresentable enum Persisting a custom enum with a raw String or Int value
// Enum must have a RawValue that UserDefaults understands
enum AppTheme: String {
    case light, dark, system
}

// Store the raw String value automatically
@AppStorage("theme") var theme: AppTheme = .system
When your enum has a String or Int raw value, @AppStorage can store and retrieve it directly. The raw value is what actually gets written to UserDefaults.
What @AppStorage can’t do: UserDefaults is designed for small, simple values. Don’t use it to store arrays of objects, large strings, images, or anything that would be better described as “data” rather than “settings.” The rule of thumb: if you’d show it in a Settings screen, @AppStorage is fine. If you’d show it in a list of records, reach for SwiftData instead.

Quick Reference

SyntaxWhat It Does
@AppStorage(“key”) var x: Bool = falsePersists a Bool to UserDefaults under “key”
@AppStorage(“key”) var x: String = “”Persists a String — empty string is the default
@AppStorage(“key”) var x: Int = 0Persists an Int — 0 is the default
@AppStorage(“key”) var x: Double = 0.0Persists a Double
$x in a bindingWorks exactly like @State — bind directly to controls
x = newValueWrites to UserDefaults immediately; view re-renders
🎯
Challenge 9.2
Persistent Preferences Screen

Build a settings screen with at least three @AppStorage properties: a Bool toggle, a String text field, and an Int stepper. Display the saved values on a separate view so you can see them. Close the app completely in the simulator (Device > Restart, or use the Home button and terminate from the app switcher), reopen it, and verify that all three values are still there. Persistence only counts if it survives a real close-and-reopen.

Using AI to Go Further

Deepen Your Understanding Use AI as a tutor — no code generation required
Explain @AppStorage to me like I’m a beginner. I understand @State already. What’s different about @AppStorage, and what problem does it solve that @State can’t?
What are the limitations of @AppStorage and UserDefaults? I want to understand when I should stop using it and reach for something else. Give me concrete examples of when it’s the wrong tool.
Build a Practice View Get a commented example you can study and run
Write a SwiftUI settings view that uses @AppStorage to save three different preference types — a Bool, a String, and an Int. Add a comment on every line explaining what it does and why. Write the comments for a complete beginner.
Show me how to use @AppStorage to persist a custom enum in SwiftUI. Add inline comments throughout so I can understand every step, including what RawRepresentable means and why the enum needs a raw value type.
9.3
Writing and Reading Files
⏱ 25 min Intermediate SwiftUI

Every app on iOS has its own private folder on the device — a space where it can read and write files freely. That folder is called the documents directory, and it’s yours to use. Nothing outside your app can see it, and it persists across launches just like UserDefaults does.

File storage is the right tool when you have data that doesn’t fit neatly into UserDefaults but also doesn’t need the structured querying that SwiftData provides. A notes app that saves each note as a text file. An export feature that writes JSON to a file the user can share. A local cache of data downloaded from an API. These are the use cases file storage was made for.

The two operations you’ll learn here are writing data to a file and reading it back. Both use FileManager to find the right directory, and Swift’s String and Data types to handle the actual content. You’ll also see how Codable — Swift’s built-in encoding protocol — makes saving structured types straightforward.

import Foundation

// Get the URL of the app's documents directory
func documentsDirectory() -> URL {
    FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
}

// Build a URL for a specific file inside that directory
func fileURL(named filename: String) -> URL {
    documentsDirectory().appendingPathComponent(filename)
}

// Write a String to a file — overwrites if it already exists
func saveNote(_ text: String) {
    let url = fileURL(named: "note.txt")
    try? text.write(to: url, atomically: true, encoding: .utf8)
}

// Read the String back — returns nil if the file doesn't exist yet
func loadNote() -> String? {
    let url = fileURL(named: "note.txt")
    return try? String(contentsOf: url, encoding: .utf8)
}
LineWhat it does
FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0] Asks the system for the URL of the app’s private documents directory. The [0] takes the first result — there’s always exactly one on iOS.
.appendingPathComponent(filename) Adds a filename to the directory URL — like combining a folder path and a filename into a full file path.
text.write(to: url, atomically: true, encoding: .utf8) Writes the string to disk. atomically: true means it writes to a temporary file first, then renames it — safer than writing directly, because a partial write can’t corrupt the file.
try? Turns a throwing function call into an optional result instead of crashing. If writing or reading fails, you get nil instead of an error being thrown.
String(contentsOf: url, encoding: .utf8) Reads the contents of the file at the given URL and converts it back to a String using UTF-8 encoding.
What is Codable? Codable is a Swift protocol that lets your custom types convert themselves to and from formats like JSON. If a struct conforms to Codable, you can encode it to Data, write that data to a file, then decode it back into your struct on the next launch. You’ll see this pattern in the modifier cards below.

File storage patterns

Write JSON data Encode a Codable struct to JSON and save it to a file
// The struct must conform to Codable to be encoded
struct UserProfile: Codable {
    var name: String
    var score: Int
}

func saveProfile(_ profile: UserProfile) {
    // JSONEncoder converts the struct into raw Data
    let data = try? JSONEncoder().encode(profile)
    // Write the Data bytes to a file named profile.json
    try? data?.write(to: fileURL(named: "profile.json"))
}
JSONEncoder converts any Codable type into a Data value. Data is just raw bytes — you can write them to any file. The .json extension is a convention that makes the file format obvious.
Read JSON data Read a file back and decode it into a Codable struct
func loadProfile() -> UserProfile? {
    // Read the raw Data from the file
    guard let data = try? Data(contentsOf: fileURL(named: "profile.json")) else {
        return nil
    }
    // JSONDecoder converts the Data back into a UserProfile
    return try? JSONDecoder().decode(UserProfile.self, from: data)
}
guard let safely unwraps the optional Data — if the file doesn’t exist yet, the function returns nil instead of crashing. JSONDecoder then turns the bytes back into your struct.
Check if a file exists Avoid trying to read a file that hasn’t been created yet
func fileExists(named filename: String) -> Bool {
    // Returns true only if a file exists at that path
    FileManager.default.fileExists(atPath: fileURL(named: filename).path)
}
Useful for first-launch checks. If the file doesn’t exist, load defaults instead of trying to decode an empty result.
Important: The documents directory is private to your app. Users can access it via the Files app only if you enable the UIFileSharingEnabled key in your app’s Info.plist. By default, everything you write there is invisible to other apps and to the user.

Quick Reference

SyntaxWhat It Does
FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]Returns the URL of the app’s documents directory
url.appendingPathComponent(“file.txt”)Creates a full file URL by adding a filename to a directory URL
string.write(to: url, atomically: true, encoding: .utf8)Writes a String to a file safely
String(contentsOf: url, encoding: .utf8)Reads a file and returns its contents as a String
JSONEncoder().encode(value)Encodes a Codable value to Data (JSON bytes)
JSONDecoder().decode(Type.self, from: data)Decodes Data back into a Codable type
FileManager.default.fileExists(atPath:)Returns true if a file exists at the given path
🎯
Challenge 9.3
Persistent Note Pad

Build a single-screen note-taking app with a TextEditor and two buttons: Save and Load. When the user taps Save, write the text to a file in the documents directory. When they tap Load, read it back and display it. Then test persistence: type a note, save it, close the app completely in the simulator, reopen it, tap Load, and confirm the note is still there. Extend the challenge by making it load automatically when the view appears using .onAppear.

Using AI to Go Further

Deepen Your Understanding Build understanding without just generating working code
Explain to me why file storage in iOS uses URL objects instead of plain strings for file paths. I’m a beginner — I’m used to file paths like “/Users/me/Desktop/file.txt”. Why does Swift use URL instead?
What does “atomically: true” mean when writing a file in Swift? What bad thing could happen if I wrote without it? Explain for a beginner who doesn’t have a computer science background.
Build a Practice View Get a fully commented example to study and modify
Write a SwiftUI view that saves and loads a Codable struct to a JSON file in the documents directory. Add a comment on every single line explaining what it does and why. Make the comments thorough enough that a beginner could understand even without prior experience.
Show me three different ways data can fail to save or load from a file in Swift — bad URL, file not found, decode error — and how to handle each one gracefully. Add inline comments throughout.
9.4
Intro to SwiftData
⏱ 35 min Intermediate SwiftUI

When you need to persist a whole collection of structured objects — tasks in a to-do app, journal entries, contacts, saved recipes — file storage starts to feel awkward. You’d be reading and writing an entire array every time anything changed, and querying or filtering would mean loading everything into memory first. That’s where SwiftData comes in.

SwiftData is Apple’s modern framework for structured, relational persistence. You describe your data model using regular Swift classes, add a single macro, and SwiftData handles everything else: creating the database, writing records, reading them back, and keeping your views updated when the data changes. It was introduced in iOS 17 and is the future of persistence in the Apple ecosystem.

Think of SwiftData like a smart filing cabinet. Instead of dumping everything into one file, it stores each record separately in a database — and it knows how to find, sort, and filter them quickly. You describe the shape of the cabinet with your model class, and SwiftData builds and manages it for you.

What about Core Data? If you search for iOS persistence tutorials online, you’ll encounter Core Data — the framework SwiftData replaces. Core Data has been part of the Apple ecosystem since 2005 and works well, but it requires significantly more boilerplate to set up. SwiftData does the same job with far less code. You don’t need to learn Core Data to build great apps today, but recognising the name is helpful when you’re reading older tutorials or working with legacy codebases.
New to Swift? SwiftUI is built on Swift, so a solid foundation helps. The @Model macro below uses a Swift class — if you want to understand what a class is and how it differs from a struct before continuing, check out the Learn Swift series — then come back here.
import SwiftData
import SwiftUI

// @Model turns this class into a SwiftData model
// Named TaskItem to avoid conflict with Swift's built-in Task type
@Model
class TaskItem {
    // Each property becomes a column in the underlying database
    var title: String
    var isCompleted: Bool
    // Date.now sets the creation time automatically
    var createdAt: Date

    // The initialiser sets up a new TaskItem instance
    init(title: String) {
        self.title = title
        self.isCompleted = false
        self.createdAt = .now
    }
}

// .modelContainer tells SwiftUI which model types to persist
@main
struct TaskApp: App {
    var body: some Scene {
        WindowGroup {
            ContentView()
        }
        // Pass TaskItem.self to register the model with the container
        .modelContainer(for: TaskItem.self)
    }
}
LineWhat it does
@Model This is a Swift macro — it generates all the persistence code SwiftData needs automatically. Without it, SwiftData doesn’t know about your class. Add it above any class you want to persist.
class TaskItem SwiftData models must be classes, not structs. This is because SwiftData tracks changes to objects over time — which classes support and structs don’t. The model is named TaskItem rather than Task to avoid a naming conflict with Swift’s built-in concurrency Task type.
var title: String Each stored property becomes a persistent field in the database. SwiftData infers the type from the property declaration.
.modelContainer(for: TaskItem.self) Attaches a model container to the app. The container is the object that manages the actual database file. Every view below this in the hierarchy can access it.

Key SwiftData concepts

@Model Marks a class as a persistent SwiftData model
// Add @Model above any class you want SwiftData to persist
@Model
class JournalEntry {
    var body: String
    var mood: String
    var date: Date

    init(body: String, mood: String) {
        self.body = body
        self.mood = mood
        self.date = .now
    }
}
One @Model class represents one type of record. Think of it like defining a row in a database table — each property is a column.
ModelContainer The database manager — set up once at the app level
// Register multiple models in a single container
.modelContainer(for: [TaskItem.self, JournalEntry.self])

// Or use an in-memory store for previews and tests
.modelContainer(for: TaskItem.self, inMemory: true)
The ModelContainer is the object that manages the on-disk database. Set it up once at the app root using the .modelContainer() modifier. Using inMemory: true is useful for Xcode Previews, because in-memory data doesn’t persist between preview renders.
ModelContext The active session for reading and writing records
struct SomeView: View {
    // @Environment injects the ModelContext provided by .modelContainer
    @Environment(\.modelContext) private var modelContext

    var body: some View {
        Button("Add Task") {
            // Insert a new TaskItem object into the context
            let task = TaskItem(title: "Buy groceries")
            modelContext.insert(task)
        }
    }
}
The ModelContext is your write access to the database. Use @Environment(\.modelContext) to access it inside any view. You insert, update, and delete records through it.

Quick Reference

SyntaxWhat It Does
@Model class MyType { }Marks the class as a SwiftData persistent model
.modelContainer(for: MyType.self)Attaches a database container to the app — do this once at the root
.modelContainer(for: MyType.self, inMemory: true)In-memory container for previews and tests — data is not saved
@Environment(\.modelContext) var modelContextInjects the active write context into a view
modelContext.insert(object)Adds a new model object to the database
modelContext.delete(object)Removes a model object from the database
🎯
Challenge 9.4
Your First SwiftData Model

Create a new Xcode project and define a @Model class called Note with three properties: title: String, content: String, and createdAt: Date. Add .modelContainer(for: Note.self) to your app entry point. Then add a button that inserts a test note using modelContext.insert. You won’t see the notes in a list yet — that’s Lesson 9.5. For now, just confirm the app compiles and runs without crashing, and that tapping the button doesn’t error. Close and reopen the app to verify it launches cleanly.

Using AI to Go Further

Deepen Your Understanding Understand the concepts before you build with them
Explain SwiftData to me like I’m a beginner who understands @AppStorage but has never used a database. What problem does SwiftData solve that @AppStorage and file storage can’t?
Why does SwiftData require a class instead of a struct for model types? I know the difference between classes and structs in Swift — explain why that distinction matters for persistence specifically.
Build a Practice View Study a commented example you can run and modify
Write a minimal SwiftUI app that uses SwiftData to persist a list of items. Include the @Model class, the .modelContainer setup, and a view that inserts a new item. Add a comment on every single line for a complete beginner.
Show me how to set up a SwiftData ModelContainer that works for both production use and Xcode Previews. Add comments explaining why in-memory containers are better for previews and when you’d switch between them.
9.5
Querying and Displaying SwiftData
⏱ 30 min Intermediate SwiftUI

Inserting a record into SwiftData is only half the job. You also need to read those records back and show them in your UI. That’s what the @Query macro does — it fetches model objects from the database and keeps the view in sync as the data changes.

@Query is beautifully simple: you declare a property with it, and SwiftData automatically populates it with the matching records. When you add a new record, delete one, or update one, @Query picks up the change and SwiftUI re-renders the view. You don’t have to do anything extra to keep the UI up to date.

This lesson also covers filtering and sorting — two things you’ll want in almost every real app. You’ll build a complete task manager view by the end, with add and delete support, that persists across launches.

import SwiftData
import SwiftUI

struct TaskListView: View {

    // @Query fetches all TaskItem objects from the database
    @Query var tasks: [TaskItem]

    // modelContext lets us insert and delete records
    @Environment(\.modelContext) private var modelContext

    var body: some View {
        NavigationStack {
            List {
                // ForEach iterates over every TaskItem returned by @Query
                ForEach(tasks) { task in
                    HStack {
                        // Show a checkmark if the task is completed
                        Image(systemName: task.isCompleted ? "checkmark.circle.fill" : "circle")
                            .foregroundStyle(task.isCompleted ? .green : .gray)
                            .onTapGesture {
                                // Toggling the property updates the database automatically
                                task.isCompleted.toggle()
                            }
                        Text(task.title)
                    }
                }
                // .onDelete removes items when the user swipes
                .onDelete(perform: deleteTasks)
            }
            .navigationTitle("Tasks")
            .toolbar {
                Button("Add", systemImage: "plus") {
                    // Create and insert a new TaskItem into the database
                    let task = TaskItem(title: "New task")
                    modelContext.insert(task)
                }
            }
        }
    }

    // Called by .onDelete with the IndexSet of rows to remove
    func deleteTasks(at offsets: IndexSet) {
        for index in offsets {
            // Delete the TaskItem at each index from the database
            modelContext.delete(tasks[index])
        }
    }
}
LineWhat it does
@Query var tasks: [TaskItem] Fetches all TaskItem records from the database and keeps the array up to date. When any task changes, tasks updates and the view re-renders.
ForEach(tasks) { task in } Iterates over the @Query results. SwiftData models are automatically Identifiable, so ForEach knows how to track each row.
task.isCompleted.toggle() Directly mutates a model property. SwiftData detects the change and writes it to the database automatically — you don’t need to call save.
.onDelete(perform: deleteTasks) Enables swipe-to-delete on List rows. When the user swipes, deleteTasks is called with the index of the row to remove.
modelContext.delete(tasks[index]) Removes the TaskItem object at the given index from the database. @Query updates the array automatically after deletion.
No explicit save needed: SwiftData saves changes automatically. When you mutate a property on a model object, or call insert or delete on the context, the changes are persisted without any extra step from you. This is very different from Core Data, where you had to call try context.save() manually.

Filtering and sorting with @Query

Sort descriptor Control the order of results returned by @Query
// Sort tasks alphabetically by title, ascending
@Query(sort: \TaskItem.title) var tasks: [TaskItem]

// Sort by creation date, newest first
@Query(sort: \TaskItem.createdAt, order: .reverse) var tasks: [TaskItem]
Pass a key path (\TaskItem.propertyName) to sort results by that property. Add order: .reverse for descending order.
#Predicate filter Fetch only records that match a condition
// Fetch only tasks that are not completed
@Query(filter: #Predicate<TaskItem> { $0.isCompleted == false })
var activeTasks: [TaskItem]

// Fetch tasks whose title contains "groceries"
@Query(filter: #Predicate<TaskItem> { $0.title.contains("groceries") })
var groceryTasks: [TaskItem]
#Predicate is a Swift macro that compiles your filter condition down to an efficient database query. The $0 refers to each record being tested — it’s a shorthand for the closure parameter.
Sort + filter combined Both in a single @Query declaration
// Active tasks only, sorted by date (newest first)
@Query(
    filter: #Predicate<TaskItem> { $0.isCompleted == false },
    sort: \TaskItem.createdAt,
    order: .reverse
)
var activeTasks: [TaskItem]
You can pass both filter: and sort: to the same @Query. SwiftData handles the database logic — you get back exactly the records you want, in the order you want them.
Updating a record: You don’t need a special update method. Just mutate the property directly: task.title = "Updated title". SwiftData detects the change via the @Model macro’s observation tracking and saves it automatically.

Quick Reference

SyntaxWhat It Does
@Query var items: [MyModel]Fetches all records of MyModel, stays live as data changes
@Query(sort: \MyModel.property) var itemsFetches records sorted by a property, ascending by default
@Query(sort: …, order: .reverse) var itemsSorts in descending order
@Query(filter: #Predicate { $0.x == y }) var itemsFetches only records where the condition is true
modelContext.insert(object)Adds a new model object — automatically saved
modelContext.delete(object)Removes a model object — automatically saved
object.property = newValueUpdates a record — automatically saved by SwiftData
🎯
Challenge 9.5
Persistent Task Manager

Build a complete task manager that persists across app launches. It should: display tasks in a List using @Query, let the user add new tasks via a TextField and an Add button, support swipe-to-delete, and let the user tap a task to toggle its completed state. Sort tasks by createdAt with newest at the top. Once it works, close the app completely in the simulator, reopen it, and verify all your tasks are still there — including their completed state.

Using AI to Go Further

Deepen Your Understanding Understand how @Query works before relying on it
How does @Query keep a SwiftUI view up to date when data changes? Explain the mechanism — I want to understand what’s happening under the hood, not just that it works.
What is a key path in Swift, like \Task.title? I’ve seen it in @Query sort descriptors but I don’t fully understand what it is. Explain it with a beginner-friendly analogy before showing any code.
Build a Practice View A complete commented example to study and run
Write a complete SwiftUI task list using SwiftData with @Query, insert, and delete. Add a comment on every single line. Make the comments explain both what the code does and why — for a complete beginner who has just learned about @Model.
Show me how to use #Predicate with @Query to filter SwiftData results. Give me three examples — filter by Bool, filter by String contains, and filter by Date — with inline comments throughout. Explain what $0 means in each predicate.
9.6
When to Use What
⏱ 15 min SwiftUI Basics

You now have three persistence tools in your toolkit. The most common source of confusion at this stage isn’t how to use them — you’ve already done that — it’s knowing which one to reach for. Choosing the wrong tool creates problems that are annoying to fix later, so it’s worth taking fifteen minutes to build a solid decision-making framework.

The short version: @AppStorage is for settings, files are for documents and exports, and SwiftData is for collections of structured records. Each tool has a sweet spot, and the overlap between them is smaller than it looks.

This lesson gives you a comparison table, walks through the most common mistakes developers make when choosing, and ends with a set of real-world scenarios you can use to test your own reasoning. There’s no new syntax here — just clarity.

Side-by-side comparison

Characteristic@AppStorageFile StorageSwiftData
Best for User preferences and simple flags Documents, exports, raw data files Collections of structured records
Data type Bool, String, Int, Double, RawRepresentable String, Data, any Codable type Any @Model class, including relationships
Querying / filtering No — reads one value at a time No — you load the whole file Yes — @Query with predicates and sort
SwiftUI reactivity Yes — view updates like @State No — you manage loading manually Yes — @Query keeps views live
iOS version required iOS 14+ Any version iOS 17+
Setup required None — just declare the property Minimal — helper functions for file URLs @Model class + .modelContainer at app root
Wrong when You have more than a handful of values, or structured data You need to query, filter, or sort records You just need a simple yes/no flag or a small setting

Common mistakes when choosing

@AppStorage overuse Trying to persist structured data in UserDefaults
// Don't do this — encoding a whole array into one AppStorage key
@AppStorage("tasks") var tasksJSON: String = "[]"

// Do this instead — SwiftData handles collections properly
@Query var tasks: [TaskItem]
A very common mistake. As soon as you find yourself JSON-encoding an array into an @AppStorage key, stop. That’s a strong signal you need SwiftData — or at minimum, file storage.
File storage for everything Using files when SwiftData would make querying trivial
// Inconvenient — you have to load all entries, filter in memory
let entries = loadEntries()
let todayEntries = entries.filter { Calendar.current.isDateInToday($0.date) }

// Cleaner — @Query handles the filter at the database level
@Query(filter: #Predicate<JournalEntry> { ... }) var todayEntries: [JournalEntry]
If you’re frequently filtering or sorting a collection stored in files, you’re re-implementing features that SwiftData provides for free. The rule: if you need to query it, use SwiftData.
SwiftData for simple preferences Reaching for SwiftData when @AppStorage is all you need
// Overkill — SwiftData for a single Bool
@Model
class AppSettings {
    var isDarkMode: Bool = false
}

// Correct — @AppStorage is exactly right for this
@AppStorage("isDarkMode") var isDarkMode: Bool = false
SwiftData has setup overhead that isn’t worth it for a single setting. Use the simplest tool that solves the problem — @AppStorage is genuinely the right choice for boolean flags and user preferences.
The decision in one sentence per tool: If it’s a setting the user controls, use @AppStorage. If it’s a file the user might share or export, use file storage. If it’s a list of records the app manages, use SwiftData.

Quick decision guide — real scenarios

ScenarioRight toolWhy
Saving whether the user has seen onboarding @AppStorage A single Bool flag — exactly what UserDefaults is for
Saving 200 journal entries with dates and tags SwiftData A collection of structured records you’ll want to filter and sort
Exporting a user’s data as a JSON file they can share File storage You’re creating a document the user explicitly wants as a file
Saving the user’s chosen accent color (a String) @AppStorage A single setting — no querying needed
A to-do app with hundreds of items, due dates, and categories SwiftData Complex structured data with filtering requirements
A local cache of a downloaded markdown article File storage It’s a document — a single file with no querying needed
🎯
Challenge 9.6
Audit Your Own App

Think about an app idea you have — or look at something you’ve already built in this course. For each piece of data in that app that needs to survive a relaunch, decide which persistence tool you’d use. Write a short note for each decision explaining your reasoning. Then pick one piece of data from that list and implement it using the correct tool. Close and reopen the app to confirm the data actually persists.

Using AI to Go Further

Deepen Your Understanding Test your decision-making with realistic scenarios
Give me ten app feature descriptions. For each one, I want to decide whether to use @AppStorage, file storage, or SwiftData. After I answer each one, tell me if I’m right and explain why. Go one at a time.
What are the signs that I’ve chosen the wrong persistence tool for a feature? Describe what bad symptoms look like for each of the three tools — @AppStorage, file storage, and SwiftData — when they’re being used for the wrong job.
Build a Practice View See all three tools used together in one app
Write a small SwiftUI app that uses all three persistence tools correctly — @AppStorage for a user preference, file storage for a text document, and SwiftData for a list of records. Add a comment on every line explaining which tool is being used and why that tool is the right choice for that piece of data.
Show me a real-world example where someone might accidentally use @AppStorage for data that belongs in SwiftData. Write the wrong version first with comments explaining why it’s problematic, then write the correct SwiftData version. Explain what breaks as the data grows.

Stage 9 Recap: Persistence

You’ve solved one of the most fundamental problems in app development: making data survive. Every app you build from here forward will use at least one of the tools you learned in this stage — and most will use more than one.

  • Lesson 9.1 — Why Persistence Matters: @State lives only in memory and is gone when the app terminates. Persistence means writing data to the device so it survives relaunches — and choosing the right tool depends on the type of data you need to save.
  • Lesson 9.2 — @AppStorage: The SwiftUI-native way to read and write UserDefaults. Perfect for simple preferences — Bool, String, Int, Double, and raw-value enums. Works like @State with automatic view updates. Not for large or structured data.
  • Lesson 9.3 — Writing and Reading Files: The app’s documents directory lets you save any data as a file. Use FileManager to build the URL, String.write or Data.write to save, and JSONEncoder/JSONDecoder with Codable types for structured content.
  • Lesson 9.4 — Intro to SwiftData: The @Model macro turns a Swift class into a persistent model. .modelContainer sets up the database at the app root. ModelContext — accessed via @Environment — is how you insert and delete records.
  • Lesson 9.5 — Querying and Displaying SwiftData: @Query fetches model objects and keeps views live as data changes. Sort with key paths, filter with #Predicate, and update records by mutating their properties directly — SwiftData saves automatically.
  • Lesson 9.6 — When to Use What: Use @AppStorage for settings, file storage for documents and exports, and SwiftData for collections of structured records. The most common mistake is using @AppStorage to store arrays — that’s a SwiftData job.

If you skipped any of the challenges, go back and do them — persistence is one of those topics where reading is not enough. You’ll only really understand it once you’ve closed the simulator, reopened the app, and watched your data still be there.

Stage 10 is App Architecture — you’ll learn how to organise a growing codebase with patterns like MVVM, how to separate concerns so your views stay clean, and how the persistence tools from this stage fit into a well-structured app.

Learn SwiftUI Stage 10: App Architecture

The bigger an app gets, the more your early decisions either save you or haunt you. This stage is about making the right ones from the start — so your code stays readable, changeable, and something you’re actually proud to look at six months later.

This stage has 6 lessons and takes roughly 3 hours to complete. You’ll need Xcode open with the canvas preview running so you can see your changes take effect in real time. Each lesson ends with a challenge — don’t skip them. Architecture skills only click when you practice restructuring real code, not just reading about patterns.

By the end of Stage 10, you’ll know how to separate your view logic from your business logic, how to hide data access behind the repository pattern, how to use dependency injection to keep your code flexible, how to organise a real project so it scales, and how to apply all of that in a guided refactoring exercise that shows every pattern working together.

10
Stage 10
App Architecture
6 lessons · ~3 hrs
10.1
Why Architecture Matters
⏱ 20 min SwiftUI Basics

When you first start building apps, everything goes in one place. You’ve got a ContentView, and that’s where the action happens. The data lives there. The network calls happen there. The formatting logic, the validation, the UI — all of it, in one file. And honestly, for a small practice app that’s fine. That’s not a problem. That’s just how it starts.

The problem comes when the app grows. Features get added. Bugs get fixed. New requirements arrive. And suddenly that ContentView is 500 lines long and nobody — including you — can confidently change one thing without worrying it breaks something else. This is what developers call the “massive view” problem, and it’s one of the most common traps beginners fall into.

Architecture is the word for intentional decisions about how you structure your code. It’s not about following rules for the sake of rules. It’s about making your app easier to understand, easier to change, and easier to test as it grows. The patterns you’ll learn in this stage aren’t abstract theory — they’re practical tools that solve real problems you’ve probably already started running into.

What a Massive View Looks Like

Here’s an example of what happens when everything ends up in one view. Read through it — not to understand every line, but just to feel the weight of it. This is what we’re trying to avoid.

import SwiftUI

struct ContentView: View {

    // Tracks everything — UI state, raw data, and loading flags all jumbled together
    @State private var tasks: [String] = []
    @State private var isLoading = false
    @State private var newTaskText = ""
    @State private var errorMessage: String? = nil
    @State private var filterText = ""

    var body: some View {
        NavigationStack {
            VStack {

                // Input row — UI code
                HStack {
                    TextField("Add task...", text: $newTaskText)
                    Button("Add") {

                        // Validation logic buried inside a button action
                        let trimmed = newTaskText.trimmingCharacters(in: .whitespaces)
                        guard !trimmed.isEmpty else {
                            errorMessage = "Task can't be empty."
                            return
                        }
                        guard trimmed.count <= 100 else {
                            errorMessage = "Task is too long."
                            return
                        }

                        // Data mutation also inside the button action
                        tasks.append(trimmed)
                        newTaskText = ""
                        errorMessage = nil
                    }
                }

                // Error display — more UI mixed with logic
                if let error = errorMessage {
                    Text(error)
                        .foregroundStyle(.red)
                }

                // Filtering — a data transformation step happening inside the view
                List(tasks.filter { filterText.isEmpty || $0.localizedCaseInsensitiveContains(filterText) }, id: \.self) { task in
                    Text(task)
                }

                // Network fetch — completely different concern, still in the view
                Button("Load from server") {
                    isLoading = true
                    Task {
                        let url = URL(string: "https://api.example.com/tasks")!
                        let (data, _) = try await URLSession.shared.data(from: url)
                        let decoded = try JSONDecoder().decode([String].self, from: data)
                        tasks = decoded
                        isLoading = false
                    }
                }

                if isLoading { ProgressView() }
            }
            .navigationTitle("Tasks")
        }
    }
}
Feel that? This view is doing at least four different jobs at once: rendering UI, validating user input, transforming data, and fetching from a network. Every one of those concerns is tangled up with the others. Changing the validation rules means editing the view. Testing the network fetch means testing the entire view. This is exactly what good architecture prevents.

What Good Architecture Buys You

When you separate concerns properly, each piece of your app does one thing and does it well. That unlocks three concrete benefits.

Readability

A view that only handles UI is easy to scan. A view model that only holds business logic is easy to reason about. When you return to a project after three weeks away, you know exactly where to look for each kind of problem.

Changeability

When your validation logic lives in the view, changing it means understanding the entire view. When it lives in its own type, you change it in one place and nothing else needs to know. Good architecture makes changes local and contained.

Testability

You can’t easily write a test for a ContentView that has 300 lines of mixed logic. But you can absolutely write a test for a small, focused type that validates a task name and returns an error if it’s too long. Separating concerns makes your logic testable without needing a running simulator.

The goal of this stage: You don’t need to memorise every architecture pattern that exists. You just need to develop the instinct to ask: “Does this code belong here?” When the answer is no, the patterns in the next five lessons will tell you where it should go instead.

Challenge

Open any app project you’ve built before — from this course or something you built on your own. Look at your largest view file and write down the answers to these three questions:

1. How many different “jobs” is this view doing? List each one (e.g. fetching data, formatting a string, validating input, displaying a list).

2. Which of those jobs feel like they belong in the view, and which feel like they don’t? Trust your instinct.

3. If you had to change the validation rules for one of the forms in this view, how many lines of code would you need to touch — and why?

You don’t need to refactor anything yet. The goal is just to see the problem clearly. By the end of Stage 10 you’ll know exactly how to fix it.

Using AI to Go Further

Test Your Mental Model Use AI to pressure-test your understanding of separation of concerns
I’m learning about app architecture in SwiftUI. I understand that I should separate my view logic from my business logic, but I’m not always sure where the line is. Can you give me 5 examples of code and tell me whether each one belongs in the view, in a view model, or somewhere else? Explain your reasoning for each one.
I have a SwiftUI view that does several things: it fetches data from a URL, validates user input, formats dates for display, and renders the UI. Walk me through which of those concerns belongs in the view and which ones should be moved out — without writing the code for me. I want to understand the reasoning first.
10.2
Separating View Logic from Business Logic
⏱ 35 min Intermediate SwiftUI

In lesson 10.1 you saw what happens when everything lives in the view. Now let’s fix it. The first tool is a view model — a separate Swift type that holds your business logic, your data transformations, and any state that isn’t purely about how the UI looks. Your view’s job becomes simple: read from the view model and display what it tells you to display.

Think of it like a restaurant. The view is the dining room — it’s what the customer sees and interacts with. The view model is the kitchen — it does the actual work. Customers don’t walk into the kitchen to cook their own food. They order, and the kitchen handles the rest. The view and the view model communicate through a clean interface, and neither needs to know the details of how the other works.

In SwiftUI, the modern way to build a view model is using the @Observable macro, which was introduced in iOS 17. It makes a class automatically observable — meaning your views will update whenever the view model’s properties change, without any extra wiring required. Let’s see a before and after.

New to Swift? SwiftUI is built on Swift, so a solid foundation helps. If you want to build that foundation first, check out the Learn Swift series — then come back here.

Before: Everything in the View

// All state, validation, and data mutation live inside the view
struct TaskView: View {
    @State private var tasks: [String] = []
    @State private var newTaskText = ""
    @State private var errorMessage: String? = nil

    var body: some View {
        VStack {
            TextField("New task", text: $newTaskText)
            Button("Add") {
                let trimmed = newTaskText.trimmingCharacters(in: .whitespaces)
                guard !trimmed.isEmpty else {
                    errorMessage = "Task can't be empty."
                    return
                }
                tasks.append(trimmed)
                newTaskText = ""
            }
            if let error = errorMessage {
                Text(error).foregroundStyle(.red)
            }
            List(tasks, id: \.self) { Text($0) }
        }
    }
}

After: Business Logic Moved to a View Model

// The @Observable macro makes this class automatically update any views that use its properties
@Observable
class TaskViewModel {

    // Published properties — views automatically re-render when these change
    var tasks: [String] = []
    var errorMessage: String? = nil

    // All validation and mutation logic lives here, away from the view
    func addTask(_ text: String) {
        let trimmed = text.trimmingCharacters(in: .whitespaces)
        guard !trimmed.isEmpty else {
            errorMessage = "Task can't be empty."
            return
        }
        guard trimmed.count <= 100 else {
            errorMessage = "Task is too long."
            return
        }
        tasks.append(trimmed)
        errorMessage = nil
    }
}

// The view is now lean — it only handles UI
struct TaskView: View {

    // @State creates and owns the view model — the view model is injected via @State
    @State private var viewModel = TaskViewModel()
    @State private var newTaskText = ""

    var body: some View {
        VStack {
            TextField("New task", text: $newTaskText)
            Button("Add") {

                // The view delegates the work — it doesn't do the work itself
                viewModel.addTask(newTaskText)
                newTaskText = ""
            }
            if let error = viewModel.errorMessage {
                Text(error).foregroundStyle(.red)
            }
            List(viewModel.tasks, id: \.self) { Text($0) }
        }
    }
}
CodeWhat it does
@Observable A macro that instruments the class so SwiftUI automatically tracks which properties a view reads, and re-renders only when those specific properties change. No extra wiring needed.
class TaskViewModel A class, not a struct — because view models are reference types that multiple views might share. The class lives outside the view file entirely.
func addTask(_ text: String) All the validation and mutation logic is now in a named, testable function. The view calls it; it doesn’t implement it.
@State private var viewModel = TaskViewModel() Creates and owns the view model instance. Using @State here is correct for an @Observable class — SwiftUI manages its lifetime.
viewModel.addTask(newTaskText) The view delegates to the view model. It says “please handle this” — not “let me figure this out myself.”
Common mistake: Putting UI state — things like whether a sheet is showing or whether a text field is focused — into the view model. That belongs in the view with @State. The view model holds business state: your data, your errors, your loading flags. The view holds presentation state: what’s visible, what’s expanded, what’s selected.

View Model Patterns

@Observable class iOS 17+ — the modern, preferred approach
@Observable
class CounterViewModel {
    var count = 0
    func increment() { count += 1 }
}
Properties are automatically tracked. No need for @Published or ObservableObject. Use @State in the view to own the instance.
ObservableObject iOS 13+ — still valid, but more verbose
class CounterViewModel: ObservableObject {
    @Published var count = 0
    func increment() { count += 1 }
}
You must explicitly mark each property with @Published. In the view, use @StateObject to own it or @ObservedObject to receive it from a parent.
async func in view model Running async work from the view model, not the view
@Observable
class PostsViewModel {
    var posts: [Post] = []
    var isLoading = false

    func loadPosts() async {
        isLoading = true
        posts = await PostService.fetchAll()
        isLoading = false
    }
}

// In the view — clean and declarative
.task { await viewModel.loadPosts() }
The view triggers the action; the view model owns the async work. The view doesn’t need to know whether it’s talking to a network, a database, or a mock.
computed property Derived values belong in the view model, not in the view body
@Observable
class TaskViewModel {
    var tasks: [String] = []
    var filterText = ""

    // Data transformation stays out of the view body
    var filteredTasks: [String] {
        guard !filterText.isEmpty else { return tasks }
        return tasks.filter { $0.localizedCaseInsensitiveContains(filterText) }
    }
}
Computed properties on the view model keep your view body simple. The view just reads viewModel.filteredTasks — it doesn’t know how the filtering works.
SyntaxWhat It Does
@Observable class VM {}Marks a class as observable so SwiftUI views automatically update when its properties change (iOS 17+)
@State var vm = VM()Creates and owns an @Observable view model inside a view
@StateObject var vm = VM()Creates and owns an ObservableObject view model (iOS 13+)
@ObservedObject var vm: VMReceives an ObservableObject from a parent — does not own it
var computed: T { … }Derived value on the view model — auto-recalculated when its inputs change

Challenge

Take the task view from the beginning of this lesson (or any view you’ve built that has logic mixed into the body) and refactor it. Create a new file called TaskViewModel.swift, mark the class with @Observable, and move the following out of the view: all validation logic, all data mutation functions, and any computed/filtered values. The view should have no guard statements and no direct array manipulation after the refactor. Confirm it still works correctly in the simulator.

Using AI to Go Further

Deepen Your Understanding Use AI as a tutor to solidify view model concepts
I’m learning about the MVVM pattern in SwiftUI. I understand that the view model holds business logic and the view handles the UI. But I’m not always sure whether something counts as “UI state” (which stays in the view) or “business state” (which goes in the view model). Can you give me 6 examples and tell me where each one belongs? Explain your reasoning.
What’s the difference between @Observable and ObservableObject in SwiftUI? I know one is newer, but I want to understand the practical tradeoffs — when would I still use ObservableObject instead of @Observable? Don’t write a full code example yet — just explain the concepts.
Build a Practice View Generate a commented example you can study and learn from
Write a SwiftUI example that shows a counter app with a view model. The view model should be @Observable and should have: a count property, an increment function, a decrement function that won’t go below 0, and a computed property called “isAtZero”. Add a comment on every line explaining what it does and why — write the comments for a beginner who is learning MVVM for the first time.
Here is a SwiftUI view I wrote: [paste your view code here]. Can you help me identify which parts of this view should be moved to a view model? List each piece and explain why it doesn’t belong in the view. Don’t refactor it yet — just identify and explain.
10.3
The Repository Pattern
⏱ 30 min Intermediate SwiftUI

In lesson 10.2 you moved business logic out of the view and into a view model. Now let’s look at the next layer down: where does the data actually come from? Right now, the view model might be making network calls or writing to a database directly. That means the view model is responsible for two different things — applying business rules and fetching data. The repository pattern separates those two concerns.

Think of a repository like a librarian. You ask the librarian for a book, and you get a book. You don’t know whether they went to the shelf, ordered it from another library, or pulled it from a digital archive. You just asked, and you received. That’s exactly what a repository does — it hides the details of where data comes from behind a simple, consistent interface.

In Swift, that interface is a protocol. You define what a repository can do, then write one or more concrete types that implement it. Your view model only ever talks to the protocol — which means you can swap the real implementation for a fake one during testing without changing a single line of view model code.

New to protocols? A protocol in Swift is like a contract. It says: “any type that conforms to me must provide these properties and methods.” The protocol defines the shape; the concrete type provides the implementation. You’ll see exactly how this works in the code below.
// Step 1: Define the protocol — this is the contract your view model will depend on
protocol BookRepository {
    func fetchBooks() async throws -> [Book]
    func save(_ book: Book) async throws
}

// Step 2: Write the real implementation that talks to the network
struct NetworkBookRepository: BookRepository {
    func fetchBooks() async throws -> [Book] {
        let url = URL(string: "https://api.example.com/books")!
        let (data, _) = try await URLSession.shared.data(from: url)
        return try JSONDecoder().decode([Book].self, from: data)
    }
    func save(_ book: Book) async throws {
        // POST to the API
    }
}

// Step 3: Write a fake implementation for testing and Previews — returns instant, predictable data
struct MockBookRepository: BookRepository {
    func fetchBooks() async throws -> [Book] {
        [Book(title: "Swift Programming"), Book(title: "Design Patterns")]
    }
    func save(_ book: Book) async throws {
        // Do nothing — this is a fake
    }
}

// Step 4: The view model depends on the protocol, not the concrete type
@Observable
class BookViewModel {
    var books: [Book] = []
    var isLoading = false

    // The view model holds a reference to the protocol type — not the concrete type
    private let repository: any BookRepository

    init(repository: any BookRepository = NetworkBookRepository()) {
        self.repository = repository
    }

    func loadBooks() async {
        isLoading = true
        do {
            books = try await repository.fetchBooks()
        } catch {
            // Handle error here
        }
        isLoading = false
    }
}
CodeWhat it does
protocol BookRepository Defines the interface — the contract. Any type that conforms to this must provide fetchBooks() and save(_:). The view model only knows about this protocol, nothing else.
struct NetworkBookRepository: BookRepository The real implementation. This is the type that makes actual network calls. It conforms to BookRepository by providing both required methods.
struct MockBookRepository: BookRepository A fake implementation used in tests and SwiftUI Previews. Returns instant, hardcoded data so your Previews don’t need a network connection to work.
private let repository: any BookRepository The any keyword says “this holds any type that conforms to BookRepository.” The view model doesn’t care which one — it just calls the methods defined in the protocol.
init(repository: any BookRepository = NetworkBookRepository()) The view model accepts a repository at initialization. In production, it defaults to the real one. In tests, you pass in the mock. This is dependency injection — covered in the next lesson.
Why this matters for Previews: If your view model makes a real network call, your SwiftUI Previews need a working internet connection to show data — and they still might fail. With the repository pattern, you inject MockBookRepository() for Previews. They load instantly, every time, with predictable data.

Repository Pattern Variations

Local storage repo Same pattern, different data source — SwiftData or UserDefaults
struct LocalBookRepository: BookRepository {
    let modelContext: ModelContext

    func fetchBooks() async throws -> [Book] {
        let descriptor = FetchDescriptor<Book>()
        return try modelContext.fetch(descriptor)
    }
    func save(_ book: Book) async throws {
        modelContext.insert(book)
        try modelContext.save()
    }
}
The view model never changes. Only the repository implementation changes. This is the power of the pattern — swap data sources without touching business logic.
Preview injection Using the mock repo in SwiftUI Previews for fast, reliable previews
#Preview {
    // Inject the mock — no network call, no waiting, predictable data every time
    let vm = BookViewModel(repository: MockBookRepository())
    BookListView(viewModel: vm)
}
Your Previews become fast and reliable. No network dependency. This alone is worth adopting the pattern.
Error-throwing mock Test error states by returning errors from the mock
struct FailingBookRepository: BookRepository {
    func fetchBooks() async throws -> [Book] {
        // Simulate a network failure every time
        throw URLError(.notConnectedToInternet)
    }
    func save(_ book: Book) async throws { }
}

// Use this in a Preview to see your error state UI without disabling Wi-Fi
#Preview("Error state") {
    BookListView(viewModel: BookViewModel(repository: FailingBookRepository()))
}
By creating a repository that always throws, you can preview and test your error handling UI without needing a real failing server. This is one of the most practical benefits of the pattern.
SyntaxWhat It Does
protocol Repo { func fetch() async throws -> [T] }Defines the repository interface — the contract all implementations must satisfy
struct RealRepo: Repo { … }The production implementation — talks to a real data source
struct MockRepo: Repo { … }A test/preview implementation — returns hardcoded data instantly
private let repo: any RepoStores any type conforming to the protocol — doesn’t care which one
init(repo: any Repo = RealRepo())Accepts a repo at init — defaults to real in production, swap for testing

Challenge

Take the TaskViewModel from lesson 10.2. Right now it holds the tasks array directly. Create a TaskRepository protocol with fetchTasks() and addTask(_:) methods. Write a LocalTaskRepository that uses an in-memory array as the backing store, and a MockTaskRepository that returns three hardcoded tasks. Update the view model to depend on any TaskRepository. Confirm the SwiftUI Preview still works using the mock repository.

Using AI to Go Further

Deepen Your Understanding Understand why protocols make the repository pattern powerful
I’m learning about the repository pattern in Swift. I understand that I define a protocol and multiple conforming types. What I’m fuzzy on is: why does the view model store “any RepositoryProtocol” rather than just “NetworkRepository” directly? What would break if I just used the concrete type? Explain without writing a full code example first.
In the repository pattern, what’s the difference between using a protocol and using a closure or a function to abstract the data source? I’ve seen both approaches — what are the tradeoffs? Which one is more common in real SwiftUI apps?
Build a Practice View Generate a commented repository pattern example to study
Write a complete Swift example of the repository pattern for a simple note-taking app. Include: a Note model, a NoteRepository protocol with fetch and save methods, a LocalNoteRepository that stores data in an array, a MockNoteRepository for previews, and a NoteViewModel that uses the protocol. Add a comment on every line for a beginner. Show how the Preview uses the mock.
Here’s a view model I wrote that makes network calls directly: [paste your code]. Help me refactor it to use the repository pattern. Before writing any code, explain the steps you’d take and what files you’d create. Then I’ll try it myself first.
10.4
Dependency Injection in SwiftUI
⏱ 25 min Intermediate SwiftUI

You’ve already seen dependency injection in the last lesson — when BookViewModel accepted a repository through its init. But we didn’t name it. Dependency injection just means: instead of creating your dependencies inside a type, you pass them in from outside. “Dependency” is a fancy word for “something your code needs to do its job.”

Why does this matter? Because when a type creates its own dependencies internally, you can’t control or replace them. If BookViewModel creates NetworkBookRepository() inside itself, there’s no way to swap in a mock for testing. But if it accepts the repository through its initializer, you’re in control — you can pass in whatever you want.

There are two main patterns for dependency injection in SwiftUI. Constructor injection works great for a single view or view model. Environment injection works better when a dependency needs to be available deep in a view hierarchy without passing it through every intermediate view.

// CONSTRUCTOR INJECTION — pass the dependency through init
@Observable
class BookViewModel {
    private let repository: any BookRepository

    // The dependency arrives here — not created here
    init(repository: any BookRepository) {
        self.repository = repository
    }
}

// In production: real dependency
let vm = BookViewModel(repository: NetworkBookRepository())

// In tests or Previews: mock dependency
let vm = BookViewModel(repository: MockBookRepository())

// ─────────────────────────────────────────────────

// ENVIRONMENT INJECTION — available anywhere in the view hierarchy below

// Step 1: Create an EnvironmentKey so SwiftUI knows how to store this dependency
private struct BookRepositoryKey: EnvironmentKey {
    static let defaultValue: any BookRepository = NetworkBookRepository()
}

// Step 2: Extend EnvironmentValues to add a named accessor
extension EnvironmentValues {
    var bookRepository: any BookRepository {
        get { self[BookRepositoryKey.self] }
        set { self[BookRepositoryKey.self] = newValue }
    }
}

// Step 3: Inject at the app or scene level
@main
struct MyApp: App {
    var body: some Scene {
        WindowGroup {
            ContentView()
                // Swap the whole app to use a mock by changing this one line
                .environment(\.bookRepository, NetworkBookRepository())
        }
    }
}

// Step 4: Read it from the environment anywhere below
struct BookListView: View {
    @Environment(\.bookRepository) var repository
    @State private var viewModel: BookViewModel?

    var body: some View {
        Text("Book list")
            .onAppear {
                viewModel = BookViewModel(repository: repository)
            }
    }
}
CodeWhat it does
init(repository: any BookRepository) Constructor injection — the dependency arrives at creation time. Simple and explicit. Best for view models and types that don’t need to share a dependency app-wide.
struct BookRepositoryKey: EnvironmentKey Registers a new key in SwiftUI’s environment system. The defaultValue is what views get if nobody has injected a value above them.
extension EnvironmentValues Adds a named property to SwiftUI’s environment so you can write \.bookRepository instead of using the key type directly.
.environment(\.bookRepository, NetworkBookRepository()) Injects the dependency into the environment for all views below this point in the hierarchy. One line changes the whole app’s data source.
@Environment(\.bookRepository) var repository Reads the dependency from the environment. The view doesn’t need to know where it came from or how it got there.
Which one should you use? Start with constructor injection — it’s simpler and more explicit. Move to environment injection when you have a dependency (like a repository or an analytics service) that many views across your app need to access without passing it through every view in between.

Dependency Injection Patterns

Default parameter A clean pattern for defaulting to the real implementation in production
init(repository: any BookRepository = NetworkBookRepository()) {
    self.repository = repository
}
The default parameter means you don’t need to pass anything in production — just call BookViewModel(). For testing, pass the mock explicitly. Clean and minimal.
.environment for @Observable Injecting an @Observable object directly into the environment
@Observable
class AppSettings {
    var isDarkMode = false
}

// In the app entry point
ContentView()
    .environment(AppSettings())

// In any child view — read it directly
@Environment(AppSettings.self) var settings
For @Observable objects, SwiftUI lets you inject them by type rather than by key — a simpler approach when the object is the whole dependency (not behind a protocol).
Preview with mock Injecting a mock via .environment in a #Preview block
#Preview {
    BookListView()
        // Swap the real repo for the mock — only in this Preview
        .environment(\.bookRepository, MockBookRepository())
}
Environment injection makes Previews trivial to set up. One modifier on the preview’s root view gives every child view access to the mock — no threading through view constructors.
SyntaxWhat It Does
init(dep: any Protocol = RealImpl())Constructor injection with a default — use real in production, pass mock for tests
struct Key: EnvironmentKey { … }Registers a new dependency in SwiftUI’s environment system
extension EnvironmentValues { var dep }Adds a named accessor so you can write \.depName in .environment()
.environment(\.depName, implementation)Injects a dependency for all views below in the hierarchy
@Environment(\.depName) var depReads a dependency from the environment — works anywhere in the subtree
.environment(MyObservable())Injects an @Observable object by type — simpler when not behind a protocol

Challenge

Take the TaskViewModel and TaskRepository from lesson 10.3. If you used a default parameter in the initializer, try switching to environment-based injection instead. Register TaskRepository as an environment value, inject LocalTaskRepository in MyApp, and update your Preview to inject MockTaskRepository via .environment. Verify that the view still loads the correct data in both the Preview and the simulator.

Using AI to Go Further

Deepen Your Understanding Clarify when to use constructor injection vs environment injection
I’m learning dependency injection in SwiftUI. I understand that constructor injection passes a dependency through init, and environment injection makes it available throughout the view hierarchy. Can you walk me through a realistic scenario where I’d start with constructor injection and then decide to switch to environment injection? I want to understand the signal that tells you which one to use.
What are the risks of NOT using dependency injection in a SwiftUI app? Give me 3 concrete examples of problems that come up when types create their own dependencies internally — not abstract theory, but specific scenarios I’m likely to encounter as my app grows.
Build a Practice View See constructor and environment injection side by side
Write a small SwiftUI example that demonstrates both constructor injection and environment injection for the same dependency — a weather service. Show: the WeatherService protocol, a real and a mock implementation, a WeatherViewModel using constructor injection, and an alternative where the service is injected via the SwiftUI environment. Add comments throughout for a beginner. Show the Preview using the mock in both approaches.
Here is my SwiftUI app’s entry point and a view model: [paste your code]. I want to refactor this to use environment injection for the main dependency. Walk me through the steps I’d take — which files to touch and in what order — before you write any code.
10.5
Organising a Real Project
⏱ 20 min SwiftUI Basics

Architecture patterns are only half the story. The other half is file and folder structure — where everything lives on disk. You can apply perfect MVVM and still end up with a project that’s hard to navigate if all your files are dumped in one folder with no organisation. As soon as a project has more than about 10 files, structure starts to matter.

There are two main schools of thought: group by type (all views in one folder, all view models in another, all models in another), or group by feature (everything related to Books in one folder, everything related to Profile in another). Both work. Feature-based grouping scales better as the app grows — when you’re working on the Books feature, all the relevant files are in one place rather than scattered across three folders.

This lesson is practical. No code patterns to memorise — just concrete decisions you can make right now to keep your projects clean and navigable from the start.

Feature-Based Structure (Recommended)

Folder / FileWhat goes here
App/ The app entry point (MyApp.swift), top-level configuration, environment setup, and app-level constants.
Features/Books/ Everything related to the Books feature: BookListView.swift, BookDetailView.swift, BookViewModel.swift, BookRepository.swift.
Features/Profile/ Everything related to the Profile feature: its view, view model, and any feature-specific models.
Shared/Models/ Data models used across multiple features — User.swift, Book.swift. If a model is only used in one feature, it can live in that feature’s folder.
Shared/Components/ Reusable UI components used in more than one feature — custom buttons, cards, loading views, empty state views.
Shared/Services/ App-wide services like NetworkService.swift, AnalyticsService.swift, or AuthService.swift.
Shared/Extensions/ Swift extensions that add utility methods — String+Extensions.swift, Date+Formatting.swift, etc.
When to extract a view into its own file: The rule of thumb is simple. If a view is used in more than one place, give it its own file. If a view is longer than about 100 lines, it’s probably doing too much — split it. If you name a view with a specific noun (like BookRow or ProfileHeader), it deserves its own file.

File Naming Conventions

File nameWhat it contains
BookListView.swiftA SwiftUI view that shows a list of books
BookDetailView.swiftA SwiftUI view that shows details for a single book
BookViewModel.swiftThe @Observable view model for the Books feature
BookRepository.swiftThe protocol and its implementations for book data access
Book.swiftThe Book model/struct
BookRow.swiftA reusable row component used inside the list view

Organisation Guidelines

One type per file Keep each file focused on a single struct, class, or protocol
// ✓ Good — BookViewModel.swift contains only BookViewModel
@Observable
class BookViewModel { ... }

// ✗ Avoid — mixing the view model and repository in one file
@Observable
class BookViewModel { ... }
struct NetworkBookRepository { ... }
One type per file makes Xcode’s navigator useful. You can find what you’re looking for without opening files and scanning for a specific type buried inside.
Small subviews in the same file Private helper views that are only used in one view can stay together
// BookListView.swift — the main view plus its private sub-views
struct BookListView: View {
    var body: some View { List { BookRow() } }
}

// Private — only used inside this file, so it can live here
private struct BookRow: View {
    var body: some View { Text("A book") }
}
If a helper view is marked private and only exists to break up a complex view body, it’s fine to keep it in the same file. Only extract to a new file when it’s reused or when the file gets too long.
Extensions in separate files Protocol conformances can live in their own extension file
// Book+Identifiable.swift — protocol conformance in its own file
extension Book: Identifiable {
    var id: UUID { uuid }
}

// Book+Comparable.swift — another conformance, another file
extension Book: Comparable {
    static func < (lhs: Book, rhs: Book) -> Bool {
        lhs.title < rhs.title
    }
}
This pattern keeps the core model type clean while giving each conformance its own focused file. Use the Type+Protocol.swift naming convention so the purpose is clear from the filename.
GuidelineWhy it helps
Group by feature, not by typeEverything for a feature is in one place — easier to find, easier to delete a feature entirely
One type per fileXcode’s navigator becomes a useful index of your codebase
Name files after what they containBookViewModel.swift, BookListView.swift — no ambiguity
Keep private subviews in the parent fileReduces file count without hiding anything important
Shared/ for cross-feature codeClear distinction between feature-specific and reusable code
App/ for entry point and configApp-level setup is isolated and easy to find

Challenge

Open any project you’ve been building across this stage. Create the folder structure described above: App/, Features/ (with a subfolder per feature), Shared/Models/, Shared/Components/. Move your existing files into the correct folders. Rename any files that don’t follow the convention (TypeName.swift for models, TypeNameView.swift for views, TypeNameViewModel.swift for view models). Confirm the project still builds after the reorganisation.

Using AI to Go Further

Deepen Your Understanding Understand the tradeoffs between project organisation approaches
I’m deciding how to organise my SwiftUI project. I’ve heard of two approaches: grouping by type (all views together, all view models together) and grouping by feature (everything for one feature in one folder). Can you walk me through the tradeoffs of each approach and tell me which one you’d recommend for a beginner building a medium-complexity app? Don’t write any code — I want to understand the reasoning.
I have a SwiftUI project and I want to reorganise it using feature-based folder grouping. Here’s my current file structure: [paste a list of your current files]. How would you suggest reorganising these files into feature folders? Walk me through your reasoning for each decision.
Build a Practice View See a well-organised project structure in a concrete example
Show me what a well-organised SwiftUI project structure looks like for a simple reading tracker app. The app has three features: a book list, book details, and a profile screen. Show me the folder structure and list every file with a one-line description of what it contains. Don’t write the file code yet — just the structure and file list.
Review this list of files from my SwiftUI project: [list your files]. Are there any naming conventions I’m not following? Are there any files that seem like they might be in the wrong place? What would you rename or move, and why?
10.6
Putting It Together: A Refactor Exercise
⏱ 30 min Intermediate SwiftUI

The best way to understand architecture patterns is to see them applied one by one to the same app — not as isolated examples, but as connected layers that build on each other. In this lesson, you’ll watch a messy single-file app get refactored step by step using every pattern from this stage.

The app is a simple reading tracker. You start with a ContentView that does everything — fetches books from an in-memory store, validates new entries, filters the list, and renders the UI. It works, but it’s a mess. By the end you’ll have a clean, well-structured project that you could confidently hand to another developer.

Follow along by creating the same files in a fresh Xcode project. After each step, run the app in the simulator and confirm it still produces the same visible result. The functionality doesn’t change — only the structure does.

Step 1 — The Starting Point: Everything in One File

Here’s the app before any refactoring. It works, but every concern is tangled together.

// ContentView.swift — the before state. One file doing everything.
import SwiftUI

struct Book: Identifiable {
    let id = UUID()
    var title: String
    var isRead: Bool
}

struct ContentView: View {
    @State private var books: [Book] = [
        Book(title: "The Swift Programming Language", isRead: true),
        Book(title: "Clean Code", isRead: false),
    ]
    @State private var newTitle = ""
    @State private var errorMessage: String? = nil
    @State private var showUnreadOnly = false

    var filteredBooks: [Book] {
        showUnreadOnly ? books.filter { !$0.isRead } : books
    }

    var body: some View {
        NavigationStack {
            VStack {
                Toggle("Show unread only", isOn: $showUnreadOnly)
                    .padding()
                HStack {
                    TextField("Book title", text: $newTitle)
                    Button("Add") {
                        let trimmed = newTitle.trimmingCharacters(in: .whitespaces)
                        guard !trimmed.isEmpty else {
                            errorMessage = "Title can't be empty."
                            return
                        }
                        books.append(Book(title: trimmed, isRead: false))
                        newTitle = ""
                        errorMessage = nil
                    }
                }
                .padding(.horizontal)
                if let error = errorMessage {
                    Text(error).foregroundStyle(.red)
                }
                List(filteredBooks) { book in
                    Text(book.title)
                }
            }
            .navigationTitle("Reading Tracker")
        }
    }
}

Step 2 — Extract the Model

First, move the Book struct into its own file. This is the simplest change, but it’s a good habit from the start — models don’t belong in view files.

// Book.swift — create this file in Shared/Models/
import Foundation

struct Book: Identifiable {
    let id = UUID()
    var title: String
    var isRead: Bool
}

Step 3 — Add the Repository

Next, create the BookRepository protocol and two implementations. This separates data access from the view and makes it swappable.

// BookRepository.swift — create this in Features/Books/
import Foundation

// The protocol defines the interface — the view model will only ever talk to this
protocol BookRepository {
    func fetchBooks() -> [Book]
    mutating func addBook(title: String)
}

// The real implementation — stores data in memory for now
struct InMemoryBookRepository: BookRepository {
    private var books: [Book] = [
        Book(title: "The Swift Programming Language", isRead: true),
        Book(title: "Clean Code", isRead: false),
    ]
    func fetchBooks() -> [Book] { books }
    mutating func addBook(title: String) {
        books.append(Book(title: title, isRead: false))
    }
}

// The mock — returns fast, predictable data for Previews
struct MockBookRepository: BookRepository {
    func fetchBooks() -> [Book] {
        [Book(title: "Preview Book One", isRead: true),
         Book(title: "Preview Book Two", isRead: false)]
    }
    mutating func addBook(title: String) { }
}

Step 4 — Add the View Model and Clean Up the View

Finally, create the view model to hold the business logic, and strip the view down to just UI. This is the biggest change — but look at how clean ContentView becomes.

// BookViewModel.swift — create this in Features/Books/
import Foundation

@Observable
class BookViewModel {
    var books: [Book] = []
    var showUnreadOnly = false
    var errorMessage: String? = nil

    // The filtered list is a derived value — the view just reads this property
    var filteredBooks: [Book] {
        showUnreadOnly ? books.filter { !$0.isRead } : books
    }

    private var repository: any BookRepository

    init(repository: any BookRepository = InMemoryBookRepository()) {
        self.repository = repository
        // Load initial data from the repository when the view model is created
        books = repository.fetchBooks()
    }

    // All validation and mutation lives here — the view just calls this function
    func addBook(title: String) {
        let trimmed = title.trimmingCharacters(in: .whitespaces)
        guard !trimmed.isEmpty else {
            errorMessage = "Title can't be empty."
            return
        }
        repository.addBook(title: trimmed)
        books = repository.fetchBooks()
        errorMessage = nil
    }
}

// ContentView.swift — after the refactor. Pure UI. No logic, no validation, no data access.
struct ContentView: View {
    @State private var viewModel = BookViewModel()
    @State private var newTitle = ""

    var body: some View {
        NavigationStack {
            VStack {
                Toggle("Show unread only", isOn: $viewModel.showUnreadOnly)
                    .padding()
                HStack {
                    TextField("Book title", text: $newTitle)
                    Button("Add") {
                        viewModel.addBook(title: newTitle)
                        newTitle = ""
                    }
                }
                .padding(.horizontal)
                if let error = viewModel.errorMessage {
                    Text(error).foregroundStyle(.red)
                }
                List(viewModel.filteredBooks) { book in
                    Text(book.title)
                }
            }
            .navigationTitle("Reading Tracker")
        }
    }
}

// Preview uses the mock — no dependency on real data
#Preview {
    ContentView()
        .// Inject the mock repository so the Preview is fast and predictable
}
Notice what didn’t change: The app looks and behaves exactly the same as it did at the start of this lesson. The UI is identical. All the refactoring happened under the hood. That’s the point — good architecture improves your code without changing your product.

Challenge

Extend the refactored reading tracker with one new feature: the ability to mark a book as read. The before state: you’d add this logic directly into the view’s button action. The after state: add a markAsRead(book: Book) method to BookViewModel and a corresponding markAsRead(_:) method to the BookRepository protocol. Wire it up in the view using a swipe action on the list row. The view should contain no logic — just viewModel.markAsRead(book: book).

Using AI to Go Further

Deepen Your Understanding Verify your architectural intuition with an AI code review
I just refactored a SwiftUI app to use MVVM with a repository pattern and dependency injection. Here’s the final structure: [describe your file structure and paste the main types]. Can you review this from an architectural standpoint? Are there any places where concerns are still mixed? Is there anything I should move or rename? Explain your reasoning before making any suggestions.
I want to add a new feature to my refactored app — [describe the feature]. Without writing the code for me, walk me through which files I’d need to touch, what new types I’d create, and where the new logic should live according to the patterns I’ve been applying in Stage 10.
Build a Practice View Apply all Stage 10 patterns to a new mini-app
Build a simple expense tracker SwiftUI app that uses all of the following from the start: an @Observable view model, a repository protocol with an InMemory implementation and a Mock implementation, constructor injection in the view model, and feature-based file structure. Add comments throughout explaining which pattern is being used and why. Show the complete file list before the code.
Here is a messy single-file SwiftUI app I wrote: [paste your code]. Walk me through a step-by-step refactor plan that applies MVVM, the repository pattern, and dependency injection. Don’t write the refactored code yet — just give me the steps in order, explaining what I’d move where and why at each step.

Stage 10 Recap: App Architecture

You’ve made it through the most conceptually dense stage in the entire curriculum. These patterns take time to internalise — that’s normal. What matters is that you now have a vocabulary and a toolkit for making deliberate decisions about where your code lives.

  • Lesson 10.1 — Why Architecture Matters: The “massive view” problem is real, and it’s caused by mixing UI, business logic, and data access in a single type. Architecture is what keeps code readable, changeable, and testable as it grows.
  • Lesson 10.2 — Separating View and Business Logic: The @Observable view model holds your data, your validation, and your mutations. The view’s only job is to read from the view model and display what it sees.
  • Lesson 10.3 — The Repository Pattern: Repositories hide data access behind a protocol, so your view model doesn’t care whether data comes from the network, a local store, or a mock. Swap implementations without touching business logic.
  • Lesson 10.4 — Dependency Injection: Pass dependencies in rather than creating them inside. Constructor injection is simple and explicit. Environment injection works when many views across a hierarchy need the same dependency.
  • Lesson 10.5 — Organising a Real Project: Group files by feature, not by type. One type per file. Name files after what they contain. Keep shared code in a Shared/ folder. These small decisions compound over time.
  • Lesson 10.6 — The Refactor Exercise: All four patterns applied to the same app, step by step. The UI never changed — only the structure did. That’s what good architecture buys you.

If you skipped the challenges, go back and do them. The refactoring exercises in this stage are where the patterns actually click — reading about them is one thing, restructuring your own code is another entirely.

Up next is Stage 11: Polish and Real-World Skills — where you’ll learn how to make your apps feel truly finished with accessibility, animations, error handling, and the details that separate a good app from a great one.

Learn SwiftUI Stage 11: Polish and Real-World Skills

Anyone can build an app that works. This stage is about building one that feels like it belongs on the App Store.

You’ll work through 8 lessons across roughly 3 hours. Most lessons run great in Xcode’s canvas preview, but a few — VoiceOver and haptics especially — are best tested on a real iPhone. Keep that in mind before you reach those sections. As always, don’t skip the challenges at the end of each lesson. That’s where the concepts actually stick.

By the time you finish Stage 11 you’ll know how to use SF Symbols effectively, extract reusable custom components, support dark mode and Dynamic Type, add VoiceOver labels and haptic feedback, configure an app icon and launch screen, and wire up a real preferences system with @AppStorage. These are the details that separate a practice project from something you’d actually submit.

11
Stage 11
Polish and Real-World Skills
8 lessons · ~3 hrs
11.1
SF Symbols
⏱ 20 min SwiftUI Basics

Every SwiftUI app needs icons. You could design your own, license a pack, or spend hours fiddling with SVGs — but Apple already solved this problem for you. SF Symbols is a library of over 6,000 icons built directly into iOS, and every single one is free to use in your apps.

The icons aren’t just images. They’re vector-based and scale cleanly at any size, they automatically adapt to the user’s preferred text size, and they support multiple rendering modes that let you add color and depth without any extra work. They also integrate naturally with Label, Button, and navigation components so everything lines up pixel-perfectly.

The most important tool for working with SF Symbols is the free SF Symbols app from Apple. Download it from developer.apple.com. You’ll use it to browse symbols, preview rendering modes, and copy the exact symbol name you need. Think of it as your icon search engine — open it alongside Xcode whenever you’re building UI.

import SwiftUI

struct SymbolDemoView: View {
    var body: some View {
        VStack(spacing: 24) {

            // Basic symbol — the simplest form
            Image(systemName: "star.fill")
                .font(.largeTitle)

            // Hierarchical rendering — foreground and secondary layers get different opacities
            Image(systemName: "cloud.sun.rain.fill")
                .font(.largeTitle)
                .symbolRenderingMode(.hierarchical)
                .foregroundStyle(.blue)

            // Palette rendering — assign specific colors to each symbol layer
            Image(systemName: "cloud.sun.fill")
                .font(.largeTitle)
                .symbolRenderingMode(.palette)
                .foregroundStyle(.yellow, .blue)

            // Multicolor rendering — uses Apple's preset per-symbol color scheme
            Image(systemName: "heart.fill")
                .font(.largeTitle)
                .symbolRenderingMode(.multicolor)

            // Symbol with a specific weight — matches a font weight
            Image(systemName: "bolt.fill")
                .font(.system(size: 40, weight: .ultraLight))

            // Variable symbol — value from 0.0 to 1.0 controls fill level
            Image(systemName: "speaker.wave.3.fill", variableValue: 0.4)
                .font(.largeTitle)
                .foregroundStyle(.green)
        }
    }
}
Xcode canvas showing six SF Symbol examples: a plain star, a hierarchical cloud with blue tones, a two-color sun and cloud, a multicolor heart, an ultraLight bolt, and a partially filled speaker wave symbol
LineWhat it does
Image(systemName:) Loads a symbol from the SF Symbols library by name. The name must match exactly — copy it from the SF Symbols app to avoid typos.
.font(.largeTitle) Controls symbol size using text size values. This is the preferred approach because it lets the symbol scale with Dynamic Type automatically.
.symbolRenderingMode(.hierarchical) Renders layered symbols with a single foreground color at varying opacities. Good for adding subtle depth without specifying multiple colors.
.symbolRenderingMode(.palette) Assigns specific colors to each layer of the symbol. Pass colors in the same order as the symbol’s layers — foreground first.
.symbolRenderingMode(.multicolor) Uses Apple’s predefined color scheme for that symbol. Not every symbol supports multicolor — check the SF Symbols app first.
variableValue: 0.4 For variable symbols, sets how much of the symbol is “filled in.” A value of 0.0 is empty, 1.0 is full. Great for volume sliders, signal strength, battery levels.
Common mistake: Typing symbol names by hand and then wondering why nothing shows up. Always copy the name directly from the SF Symbols app. Even a single character difference — like heart.fill vs heart.filled — will silently show a blank view.

Rendering Modes and Variants

.monochrome Single color, all layers treated as one
Image(systemName: "star.fill")
    .symbolRenderingMode(.monochrome)
    .foregroundStyle(.orange)
The default mode. Every part of the symbol is drawn in a single foreground color. Works for any symbol and is the safest choice when you just want a tinted icon.
.symbolVariant() Switch between fill, slash, circle, and other variants
Image(systemName: "bell")
    .symbolVariant(.fill)

Image(systemName: "bell")
    .symbolVariant(.slash)
Many symbols come in multiple variants. Instead of memorizing bell.fill and bell.slash as separate names, you can use the base name and apply a variant modifier. Useful when you’re toggling states programmatically.
Label with symbol Icon and text as a paired component
Label("Favorites", systemImage: "heart.fill")
    .font(.headline)
    .foregroundStyle(.pink)
The Label view pairs a symbol with text automatically. SwiftUI adapts its layout based on context — in a toolbar it might show only the icon, in a list row it shows both. This is the right component for tab bars, menus, and toolbars.
imageScale Scale symbol relative to surrounding text
Label("Download", systemImage: "arrow.down.circle.fill")
    .imageScale(.large)
When a symbol appears next to text, .imageScale() controls how large the icon is relative to the text. Options are .small, .medium, and .large. This is different from .font() — it’s relative scaling rather than an absolute size.
SyntaxWhat It Does
Image(systemName: “name”)Load a symbol from SF Symbols by name
.symbolRenderingMode(.hierarchical)Single color at multiple opacities per layer
.symbolRenderingMode(.palette)Specific color per layer — pass colors to foregroundStyle
.symbolRenderingMode(.multicolor)Apple’s preset coloring for that symbol
.symbolVariant(.fill)Apply a named variant (fill, slash, circle, square, etc.)
variableValue: 0.0…1.0Control fill level of variable symbols
.imageScale(.large)Scale symbol relative to adjacent text
Label(“Text”, systemImage: “name”)Symbol and text as a paired, context-aware component
Challenge: Open a view you built in a previous stage — your home screen from Stage 5 or your list view from Stage 6. Replace any plain Text buttons or navigation titles with Label components using appropriate SF Symbols. Then pick one symbol and try all four rendering modes to see which looks best in your app’s color scheme.

Using AI to Go Further

Deepen Your Understanding Use AI as a tutor — no code generation required
Explain the four SF Symbols rendering modes to me in plain English. Use a simple analogy for each one — no code yet. Then tell me when I’d pick one over another.
I’m trying to understand the difference between using .font() and .imageScale() on an SF Symbol. Can you explain the difference and when each one is the right choice?
Build a Practice View Generate a commented example you can study and learn from
Write a SwiftUI view that shows a grid of SF Symbols demonstrating all four rendering modes side by side. Add a comment on every line explaining what it does and why — write for a beginner who hasn’t seen symbolRenderingMode before.
Give me a SwiftUI example that uses a variable symbol tied to a Slider, so the symbol changes as the slider moves. Add inline comments throughout explaining each piece.
11.2
Custom Components and Reusable Views
⏱ 25 min SwiftUI Basics

If you’ve caught yourself copying and pasting the same block of SwiftUI code into multiple places, that’s the signal to extract it into a reusable component. A reusable view is just a struct that conforms to View — the same thing you’ve been building all along — but designed to be flexible enough to drop in anywhere.

Think of it like making a LEGO brick. The brick itself doesn’t know where it will end up in the final model. It just knows what shape it is and what color it is. You configure those details when you snap it into place. That’s exactly how a parameterized SwiftUI component works: you define the structure once, then pass in the specific values when you use it.

Getting this right is one of the things that separates beginners from developers who can build and maintain real projects. When a design changes, you update one component and every screen that uses it updates automatically. You’ll feel this pay off immediately in your own projects.

import SwiftUI

// A reusable stat card — used anywhere you want a labeled number
struct StatCard: View {

    // Properties make the component configurable at call sites
    let title: String
    let value: String
    let icon: String
    var accentColor: Color = .blue

    var body: some View {
        VStack(alignment: .leading, spacing: 8) {

            // Icon row using the injected symbol name and color
            Label(title, systemImage: icon)
                .font(.caption)
                .foregroundStyle(accentColor)

            // The main value, displayed prominently
            Text(value)
                .font(.title2.bold())
                .foregroundStyle(.primary)
        }
        .padding()
        .background(.regularMaterial)
        .clipShape(RoundedRectangle(cornerRadius: 12))
    }
}

// Using the component — different data, same visual structure
struct DashboardView: View {
    var body: some View {
        HStack {
            StatCard(title: "Steps", value: "8,421", icon: "figure.walk", accentColor: .green)
            StatCard(title: "Calories", value: "542", icon: "flame.fill", accentColor: .orange)
            StatCard(title: "Sleep", value: "7h 20m", icon: "moon.fill", accentColor: .indigo)
        }
        .padding()
    }
}
Xcode canvas showing a DashboardView with three StatCard components side by side — Steps in green, Calories in orange, Sleep in indigo — all using the same card shape but different data
LineWhat it does
struct StatCard: View Declares a new SwiftUI view. This is the same pattern you’ve used since Stage 1 — the difference is that this view is designed to be reused, not used just once.
let title: String A required property. Whoever creates a StatCard must pass a title. Swift enforces this at compile time.
var accentColor: Color = .blue An optional property with a default value. If you don’t pass an accentColor, it falls back to blue. This keeps call sites clean when the default works fine.
.background(.regularMaterial) Uses a material background — a frosted-glass effect that adapts automatically to light and dark mode. No extra work needed.
StatCard(title: "Steps", ...) Creates an instance of the component and passes in the specific values for this card. The component handles the rest.
When to extract: A good rule of thumb is the Rule of Three. If the same UI pattern appears in three different places, extract it into a component. One or two uses might just be coincidence — three is a pattern worth standardizing.

Component Patterns

@ViewBuilder content slot Let the caller inject any view into your component
struct CardContainer<Content: View>: View {
    // @ViewBuilder lets callers pass any SwiftUI view as content
    let content: Content

    init(@ViewBuilder content: () -> Content) {
        self.content = content()
    }

    var body: some View {
        content
            .padding()
            .background(.regularMaterial)
            .clipShape(RoundedRectangle(cornerRadius: 12))
    }
}

// Usage — any view goes inside the braces
CardContainer {
    Text("Anything can go here")
}
@ViewBuilder is what SwiftUI uses internally for all its own containers like VStack and HStack. Applying it to your own components gives callers the same flexibility — they can pass in any view content using the familiar trailing closure syntax.
Xcode Extract Subview Let Xcode do the extraction work for you
// Right-click any view in your code or canvas
// Choose "Extract Subview" from the menu
// Xcode creates the new struct and wires it up automatically

// Before: inline code in a larger view
Text("Hello")
    .font(.title)
    .padding()
    .background(.blue)
    .foregroundStyle(.white)
    .clipShape(Capsule())

// After extraction: a named, reusable component
GreetingBadge()
You don’t always have to create components from scratch. Select any view in Xcode’s editor, right-click, and choose “Extract Subview.” Xcode will pull it into its own struct. You can then add properties to make it configurable.
Custom ViewModifier Bundle a set of modifiers into a reusable modifier
struct PrimaryButtonStyle: ViewModifier {
    func body(content: Content) -> some View {
        content
            .font(.headline)
            .foregroundStyle(.white)
            .padding(.horizontal, 24)
            .padding(.vertical, 12)
            .background(.blue)
            .clipShape(Capsule())
    }
}

// Convenience extension so you can call it like any built-in modifier
extension View {
    func primaryButton() -> some View {
        modifier(PrimaryButtonStyle())
    }
}

// Usage — reads like a native modifier
Button("Get Started") { }
    .primaryButton()
When you find yourself applying the same chain of modifiers in multiple places, a custom ViewModifier bundles them into a single reusable modifier. Adding a convenience extension on View lets you call it with dot syntax, just like SwiftUI’s built-in modifiers.
SyntaxWhat It Does
struct MyView: View { }Define a reusable component
let property: TypeRequired property — must be passed at the call site
var property: Type = defaultOptional property with fallback value
@ViewBuilder content: () -> ContentAccept any SwiftUI view as injected content
struct S: ViewModifier { func body(content:) }Bundle modifier chains into a reusable modifier
extension View { func myMod() }Add dot-syntax convenience access to a custom modifier
Challenge: Look at your app from Stage 7 or Stage 8. Find a UI pattern that appears more than once — a card, a row, a styled button — and extract it into its own struct with at least two configurable properties. Then replace every instance in your existing views with the new component.

Using AI to Go Further

Deepen Your Understanding Use AI as a tutor — no code generation required
Explain the difference between extracting a subview and creating a custom ViewModifier in SwiftUI. When would I choose one over the other? Give me a real-world analogy for each before any code.
I’m confused about when to use @ViewBuilder on a property vs a function parameter. Can you quiz me on this topic? Ask me questions one at a time and correct me if I’m wrong.
Build a Practice View Generate a commented example you can study and learn from
Write a SwiftUI reusable card component that accepts a title, subtitle, and a @ViewBuilder content slot for an icon. Add comments on every line explaining what each piece is for — write for a beginner seeing components for the first time.
Give me three examples of the same UI pattern evolving: first as inline code, then extracted as a struct, then with a custom ViewModifier. Add inline comments at each stage showing what improved and why.
11.3
Dark Mode and Color Schemes
⏱ 20 min SwiftUI Basics

Dark mode isn’t optional anymore. A large portion of iOS users keep their device in dark mode permanently, and your app needs to look good in both. The good news is that SwiftUI does most of the heavy lifting for you — if you use semantic colors and system materials, you get dark mode for free.

Semantic colors are colors defined by their role rather than their specific shade. .primary is the main text color — it’s near-black in light mode and near-white in dark mode. .secondary is a softer version of that. .background flips between white and near-black. When you use these, the system handles the switching automatically.

Where you run into trouble is when you hardcode specific colors — like setting a background to Color.white or text to Color.black. Those don’t adapt. In dark mode, white text on a white background becomes invisible. This lesson shows you how to avoid that.

import SwiftUI

struct ColorSchemeExampleView: View {

    // Read the current color scheme from the environment
    @Environment(\.colorScheme) var colorScheme

    var body: some View {
        VStack(spacing: 20) {

            // Semantic colors — these adapt automatically
            Text("Primary text")
                .foregroundStyle(.primary)

            // Tinted background that adapts — do this, not Color.white
            RoundedRectangle(cornerRadius: 12)
                .fill(Color(.systemBackground))
                .frame(height: 60)

            // Use colorScheme to branch on light vs dark when necessary
            Text(colorScheme == .dark ? "Dark Mode" : "Light Mode")
                .foregroundStyle(.secondary)

            // Custom color from the asset catalog — defined once, adapts automatically
            Text("Branded text")
                .foregroundStyle(Color("AppBlue"))
        }
        .padding()
        .background(Color(.systemGroupedBackground))
    }
}
LineWhat it does
@Environment(\.colorScheme) Reads the active color scheme (.light or .dark) from the SwiftUI environment. The value updates automatically when the user switches modes.
.foregroundStyle(.primary) Uses the semantic primary color, which is near-black in light mode and near-white in dark mode. Always prefer this over Color.black.
Color(.systemBackground) Accesses a UIKit semantic color via its string name. systemBackground is white in light mode and near-black in dark mode. A reliable choice for card backgrounds.
colorScheme == .dark ? ... : ... Branches explicitly on the current color scheme. Use this sparingly — only when you need different content or behavior, not just different colors.
Color("AppBlue") Loads a named color from the asset catalog. In Xcode’s asset catalog you define both a light and a dark variant for the same name. SwiftUI picks the right one automatically.
Common mistake: Using Color.white or Color.black for backgrounds and text. These are fixed colors and don’t adapt. Use Color(.systemBackground) and .primary instead.

Color Tools and Techniques

Asset catalog color sets Define your brand colors once with automatic light/dark variants
// In Xcode: Assets.xcassets → + → Color Set
// Set the Appearances dropdown to "Any, Dark"
// Define the light color in the "Any" slot and dark in the "Dark" slot
// Name it "BrandAccent"

// Then use it anywhere in SwiftUI
Text("Hello")
    .foregroundStyle(Color("BrandAccent"))
Asset catalog color sets are the right place to define any custom color your brand uses. You get one name per color and the system automatically serves the correct variant. This is where brand colors belong — not hardcoded in your views.
.preferredColorScheme() Force a specific color scheme on a view or the entire app
// Force dark mode on a specific view for testing
MyView()
    .preferredColorScheme(.dark)

// Lock the app to light mode regardless of system setting
WindowGroup {
    ContentView()
        .preferredColorScheme(.light)
}
Useful for testing and for giving users a theme preference toggle. Apply it to the root view in your app to override the system setting globally, or apply it to a specific view to force a mode for just that screen.
Canvas preview variants Preview both modes simultaneously in Xcode
// Show both light and dark mode in the preview canvas
struct MyView_Previews: PreviewProvider {
    static var previews: some View {
        Group {
            MyView()
                .preferredColorScheme(.light)
            MyView()
                .preferredColorScheme(.dark)
        }
    }
}
Running both previews side by side is the fastest way to catch dark mode issues before you run on a device. If something looks wrong in dark mode — text disappearing, invisible backgrounds — you’ll catch it immediately in the canvas.
SyntaxWhat It Does
@Environment(\.colorScheme) var colorSchemeRead the active color scheme from the environment
.foregroundStyle(.primary)Semantic text color — adapts automatically
Color(.systemBackground)Semantic background — white in light, near-black in dark
Color(.systemGroupedBackground)Slightly different semantic background for grouped sections
Color(“NamedColor”)Load a named color set from the asset catalog
.preferredColorScheme(.dark)Force dark mode on a view or the app root
.background(.regularMaterial)Frosted-glass material that adapts automatically
Challenge: Open your most recent project and search for any hardcoded Color.white, Color.black, or hex color values in your views. Replace each one with a semantic color or an asset catalog color set. Then add both light and dark previews to your main view to verify everything looks correct.

Using AI to Go Further

Deepen Your Understanding Use AI as a tutor — no code generation required
Explain the difference between semantic colors and hardcoded colors in SwiftUI. Why does it matter for dark mode? Give me a concrete example of each before you show me any code.
I understand that Color.white doesn’t adapt to dark mode, but I’m not sure which semantic color to use as a replacement in each situation. Can you quiz me by describing a UI element and asking me which semantic color I’d use?
Build a Practice View Generate a commented example you can study and learn from
Write a SwiftUI settings screen that uses only semantic colors and asset catalog colors — no hardcoded Color.white or Color.black anywhere. Add a comment on every color usage explaining why that semantic color was chosen.
Give me a SwiftUI view that reads the colorScheme environment value and changes its content (not just its colors) between light and dark mode. Add inline comments explaining why you’d use @Environment here.
11.4
Dynamic Type and Accessibility
⏱ 25 min SwiftUI Basics

Dynamic Type is the iOS feature that lets users choose their preferred font size in Settings. Some people bump it up because their eyes need the extra help. Others shrink it to fit more content on screen. Your app is expected to respect this preference — and when you use SwiftUI’s text styles correctly, it does so automatically.

The trap that beginners fall into is hardcoding font sizes with something like .font(.system(size: 16)). That number is fixed. It never changes regardless of what the user has set. Use semantic text styles like .font(.body) or .font(.headline) instead, and the system handles the scaling for you.

When you need a custom size for a non-text element — like an icon, a circle, or a spacing value — @ScaledMetric is the tool for the job. It works like a property that automatically scales up or down in proportion to the user’s text size setting. This is how you make every dimension in your UI feel intentional and accessible.

import SwiftUI

struct AccessibleRowView: View {

    // @ScaledMetric scales this value proportionally with Dynamic Type
    @ScaledMetric var avatarSize: CGFloat = 48

    var body: some View {
        HStack(alignment: .top, spacing: 12) {

            // Size driven by @ScaledMetric — grows with the user's text preference
            Circle()
                .fill(.blue)
                .frame(width: avatarSize, height: avatarSize)

            VStack(alignment: .leading, spacing: 4) {

                // Semantic text style — scales automatically, no hardcoded size
                Text("Jane Doe")
                    .font(.headline)

                // Secondary line — a smaller semantic style
                Text("Joined 2023")
                    .font(.subheadline)
                    .foregroundStyle(.secondary)
            }

            Spacer()
        }
        .padding()
    }
}
Xcode canvas showing the same AccessibleRowView at the default text size and at the largest accessibility text size — demonstrating how the avatar circle and text scale up together proportionally
LineWhat it does
@ScaledMetric var avatarSize: CGFloat = 48 Declares a property whose value scales with the user’s Dynamic Type setting. At default text size, avatarSize is 48. At the largest accessibility size, it could be 80 or more.
.font(.headline) A semantic text style. SwiftUI knows the appropriate font size and weight for each style and scales them with Dynamic Type automatically.
.font(.subheadline) A slightly smaller semantic style, appropriate for supporting information like dates, subtitles, and secondary labels.
HStack(alignment: .top) Aligns the avatar and text to the top of the row. At large text sizes the text may wrap to multiple lines — top alignment keeps the layout from looking misaligned.
Testing tip: In Xcode’s canvas, open the preview settings and change the Dynamic Type size slider to the largest accessibility option. This is the most extreme case your app will face and the fastest way to catch broken layouts.

Dynamic Type Modifiers

.minimumScaleFactor() Allow text to shrink to fit when absolutely necessary
// Text will shrink up to 50% of its original size before truncating
Text("Long navigation title that might not fit")
    .font(.headline)
    .minimumScaleFactor(0.5)
    .lineLimit(1)
Use this sparingly and only where wrapping would break the layout. A value of 0.5 means the text can scale down to 50% of its normal size. Prefer wrapping over shrinking for body text — shrinking too aggressively makes content hard to read for users who already need large text.
@ScaledMetric(relativeTo:) Scale a value relative to a specific text style
// Scale the icon size in proportion to headline text specifically
@ScaledMetric(relativeTo: .headline) var iconSize: CGFloat = 20

Image(systemName: "star.fill")
    .frame(width: iconSize, height: iconSize)
The relativeTo: parameter ties the scaling behavior to a specific text style. If your icon appears next to headline text, scaling it relative to .headline keeps the proportions consistent at all text sizes.
Accessibility Inspector Test Dynamic Type and accessibility in Xcode
// Xcode menu: Xcode → Open Developer Tool → Accessibility Inspector
// Or press: Cmd + Shift + A in Xcode
//
// The Audit tab scans your app for accessibility issues automatically
// The Inspection tab lets you tap elements to read their accessibility info
// The Settings tab lets you simulate Dynamic Type sizes without leaving Xcode
The Accessibility Inspector is one of those tools most beginners don’t know exists. Run an Audit on your app and it will highlight any elements missing labels, any contrast issues, and any touch targets that are too small. Fix these before submitting to the App Store.
SyntaxWhat It Does
.font(.headline)Semantic text style — scales with Dynamic Type
.font(.body)Default body text style — the baseline semantic size
.font(.caption)Small secondary text — for labels, timestamps, hints
@ScaledMetric var size: CGFloat = 48Scale a custom measurement proportionally with text size
@ScaledMetric(relativeTo: .headline)Scale relative to a specific text style
.minimumScaleFactor(0.5)Allow text to shrink up to the given fraction before truncating
.dynamicTypeSize(.large … .accessibility3)Limit the range of Dynamic Type sizes a view responds to
Challenge: Go through your most recent project and replace every .font(.system(size:)) call with the appropriate semantic text style. Then find at least one hardcoded CGFloat size for a non-text element and convert it to a @ScaledMetric property. Preview the result at both the default and largest accessibility text sizes.

Using AI to Go Further

Deepen Your Understanding Use AI as a tutor — no code generation required
Explain the full list of SwiftUI semantic text styles to me — from largeTitle down to caption2. What is each one designed for? No code yet, just plain English descriptions of the intended use cases.
I know I should use @ScaledMetric for custom sizes, but I’m not sure when to use relativeTo: vs not. Can you walk me through the decision with some examples?
Build a Practice View Generate a commented example you can study and learn from
Write a SwiftUI profile card that uses only semantic text styles and @ScaledMetric for all custom sizes. Add a comment on every sizing decision explaining which text style was chosen and why.
Give me a SwiftUI view that demonstrates a layout that adapts gracefully from the smallest to the largest Dynamic Type size. Add inline comments explaining the techniques used to keep the layout from breaking.
11.5
VoiceOver Support
⏱ 20 min SwiftUI Basics

VoiceOver is Apple’s screen reader for blind and low-vision users. When it’s enabled, users navigate by swiping and tapping, and the system reads aloud everything on screen. SwiftUI does a reasonable job of providing default VoiceOver support — a Button with a text label gets announced automatically, for example. But the defaults have limits, and a few minutes of intentional work makes your app genuinely usable for everyone.

The most common problem is image-only buttons. A trash icon button with no label text is invisible to VoiceOver unless you add an accessibility label. VoiceOver will just say “image” — which tells the user nothing. The same applies to decorative images that shouldn’t be read at all — they need to be explicitly hidden from VoiceOver so they don’t interrupt the reading flow.

Testing on a real device with VoiceOver enabled is the only way to know how your app actually feels. Enable it in Settings, or triple-click the side button if you’ve set up the accessibility shortcut. You’ll immediately discover whether your app makes sense in audio form.

import SwiftUI

struct AccessibleCardView: View {
    var body: some View {
        VStack(alignment: .leading, spacing: 12) {

            // Decorative image — hidden from VoiceOver so it doesn't read "image"
            Image(systemName: "photo")
                .accessibilityHidden(true)

            // Label that VoiceOver will read as the accessible name
            Text("Article title")
                .font(.headline)
                .accessibilityLabel("Article: SwiftUI Animations")

            // Hint read after the label — describes what happens when activated
            Button("Read") { }
                .accessibilityLabel("Read article")
                .accessibilityHint("Opens the full article in a new screen")

            // Value — useful for progress, sliders, or counts
            ProgressView(value: 0.6)
                .accessibilityLabel("Reading progress")
                .accessibilityValue("60 percent")

            // Combine multiple elements into one VoiceOver-readable group
            HStack {
                Image(systemName: "heart.fill").accessibilityHidden(true)
                Text("128 likes")
            }
            .accessibilityElement(children: .combine)
        }
        .padding()
    }
}
LineWhat it does
.accessibilityHidden(true) Hides an element from VoiceOver completely. Use this for decorative images, dividers, or visual elements that don’t add meaning when read aloud.
.accessibilityLabel("...") The name VoiceOver reads for this element. For icon buttons, this is the most important modifier — without it, VoiceOver has nothing useful to say.
.accessibilityHint("...") Additional context read after the label. Describes what happens when the element is activated. Keep it brief — it’s supplementary, not the primary description.
.accessibilityValue("...") Communicates the current value of an element — especially useful for progress indicators, sliders, and counters.
.accessibilityElement(children: .combine) Merges all child elements into a single VoiceOver-readable unit. Instead of reading each child separately, VoiceOver reads the group as one item.
Easy win: Every icon-only button in your app needs an .accessibilityLabel. This is the most common VoiceOver failure in beginner apps. A trash button, a share button, a close button — if it has no visible text, add an explicit label.

VoiceOver Modifiers

.accessibilityAddTraits() Tell VoiceOver what kind of element this is
// Mark a view as a header so VoiceOver users can navigate by headings
Text("Today's Tasks")
    .font(.title2)
    .accessibilityAddTraits(.isHeader)

// Mark an image as a button since it has a tap gesture
Image(systemName: "star")
    .onTapGesture { /* favorite */ }
    .accessibilityAddTraits(.isButton)
    .accessibilityLabel("Add to favorites")
Traits tell VoiceOver how to treat an element. Without the right trait, a tappable Image gets announced as a static image — VoiceOver users won’t know it’s interactive. Add .isButton so they hear “Add to favorites, button.”
.accessibilityElement(children: .ignore) Create a custom VoiceOver element from complex UI
// Replace a complex layout with a single clean VoiceOver description
HStack {
    Circle().fill(.green).frame(width: 10, height: 10)
    Text("Active")
    Text("·")
    Text("2 min ago")
}
.accessibilityElement(children: .ignore)
.accessibilityLabel("Status: Active, 2 minutes ago")
When a layout uses multiple views to present a single piece of information, combining them makes VoiceOver’s reading more natural. The .ignore strategy discards all children and uses your custom label instead.
.accessibilityInputLabels() Provide alternative names for voice control
// Users can say any of these names to activate this button with Voice Control
Button("Save") { }
    .accessibilityLabel("Save document")
    .accessibilityInputLabels(["Save", "Save document", "Save file"])
Voice Control users navigate by speaking element names. Input labels give them multiple ways to refer to an element. This is especially helpful when your label is long — users can say a shorter version and still activate the control.
SyntaxWhat It Does
.accessibilityLabel(“name”)The name VoiceOver reads for this element
.accessibilityHint(“hint”)Additional context read after the label
.accessibilityValue(“value”)Current value of a control (slider, progress, counter)
.accessibilityHidden(true)Hide decorative elements from VoiceOver
.accessibilityElement(children: .combine)Merge children into one readable unit
.accessibilityElement(children: .ignore)Replace children with a custom label
.accessibilityAddTraits(.isHeader)Mark as a navigation header
.accessibilityAddTraits(.isButton)Mark as interactive/tappable
Challenge: Enable VoiceOver on your iPhone or iPad (Settings → Accessibility → VoiceOver) and navigate through your most recent app using only swipe gestures and taps. Note every element that VoiceOver reads poorly or doesn’t read at all. Then add the appropriate accessibility modifiers to fix at least three issues.

Using AI to Go Further

Deepen Your Understanding Use AI as a tutor — no code generation required
Explain the difference between accessibilityLabel, accessibilityHint, and accessibilityValue in VoiceOver. When is each one the right choice? Give me a real UI example for each before any code.
I know I need to hide decorative elements with accessibilityHidden, but I’m not always sure what counts as “decorative.” Can you quiz me by describing different UI elements and asking me whether I’d hide them from VoiceOver?
Build a Practice View Generate a commented example you can study and learn from
Write a SwiftUI notification card with an icon, title, message, timestamp, and a dismiss button. Add full VoiceOver support with comments explaining every accessibility modifier decision.
Give me three examples of SwiftUI views with VoiceOver problems, then show the corrected version of each one side by side with inline comments explaining what was wrong and why the fix works.
11.6
Haptics
⏱ 15 min SwiftUI Basics

Haptic feedback is the subtle vibration that tells your hand something happened. When you delete a message and feel that light tap, when a toggle snaps into place, when a payment goes through and the phone pulses — that’s haptics. Done well, it makes an app feel physical and responsive. Done badly — too often, too intense, for the wrong actions — it just becomes annoying.

iOS provides two main classes for haptic feedback. UIImpactFeedbackGenerator produces impact-style taps — good for button presses, completing an action, or acknowledging a gesture. UINotificationFeedbackGenerator produces notification-style feedback — three distinct patterns for success, warning, and error outcomes.

Haptics only work on a real device. The simulator ignores them silently. Make a habit of testing haptic-enabled features on a physical iPhone before shipping — what sounds right in code doesn’t always feel right in your hand.

import SwiftUI

struct HapticDemoView: View {
    var body: some View {
        VStack(spacing: 20) {

            // Light impact — subtle confirmation, like toggling a switch
            Button("Light Tap") {
                UIImpactFeedbackGenerator(style: .light).impactOccurred()
            }

            // Medium impact — general button press or selection
            Button("Medium Tap") {
                UIImpactFeedbackGenerator(style: .medium).impactOccurred()
            }

            // Heavy impact — significant action, like deleting an item
            Button("Heavy Tap") {
                UIImpactFeedbackGenerator(style: .heavy).impactOccurred()
            }

            // Success — task completed, payment confirmed, save successful
            Button("Success") {
                UINotificationFeedbackGenerator().notificationOccurred(.success)
            }

            // Warning — something needs attention, but not an error
            Button("Warning") {
                UINotificationFeedbackGenerator().notificationOccurred(.warning)
            }

            // Error — action failed, validation error, network failure
            Button("Error") {
                UINotificationFeedbackGenerator().notificationOccurred(.error)
            }
        }
        .padding()
    }
}
Xcode canvas showing the HapticDemoView with six buttons, alongside a note reminding that haptics require a real device to test
LineWhat it does
UIImpactFeedbackGenerator(style:) Creates a generator for impact-style haptics. The style parameter determines the intensity. You don’t need to store the generator — create it, fire it, and let it go.
.impactOccurred() Triggers the haptic immediately. Call this at the exact moment the impact should be felt — on button press, on drop, on snap.
UINotificationFeedbackGenerator() Creates a generator for notification-style haptics. These have a distinct three-beat pattern that iOS users recognize for specific outcomes.
.notificationOccurred(.success) Triggers the success haptic pattern. Use this when something went right — a form submitted, a file saved, a transaction completed.
.notificationOccurred(.error) Triggers the error haptic pattern. Use this for failures — a network error, a validation rejection, a failed payment. It’s distinct and communicates “something went wrong.”
Less is more: The most common haptics mistake is using them for everything. Every tap, every scroll, every hover. Reserve haptics for meaningful moments — completion, errors, and significant selections. When used sparingly, they feel satisfying. When overused, they feel cheap.

Haptic Patterns

sensoryFeedback() modifier The modern SwiftUI-native haptics approach (iOS 17+)
@State private var isSaved = false

Button("Save") {
    isSaved = true
}
// Trigger haptic when isSaved changes to true
.sensoryFeedback(.success, trigger: isSaved)
iOS 17 introduced .sensoryFeedback() as a native SwiftUI modifier. Instead of calling UIKit classes directly, you attach the modifier to a view and it fires when the trigger value changes. Cleaner and more SwiftUI-idiomatic for new projects targeting iOS 17+.
Preparing the generator Reduce latency for time-sensitive haptics
let generator = UIImpactFeedbackGenerator(style: .medium)

// Prepare before the action — reduces taptic engine warm-up delay
generator.prepare()

// Then fire when the moment arrives
generator.impactOccurred()
Calling .prepare() on a generator before firing reduces the latency from a cold start. For actions where timing matters — like a game tap target or an animation that needs a synchronized feel — prepare the generator as soon as the user starts the interaction.
UISelectionFeedbackGenerator Light feedback for selection changes
// Fire as the user scrolls through picker options
UISelectionFeedbackGenerator().selectionChanged()
Selection feedback is subtler than impact feedback. Use it when the user is cycling through options — like a picker, a segmented control, or a custom scroll-to-select interaction. It provides a light tick for each discrete selection change.
SyntaxWhat It Does
UIImpactFeedbackGenerator(style: .light)Light impact — subtle tap for minor actions
UIImpactFeedbackGenerator(style: .medium)Medium impact — standard button press
UIImpactFeedbackGenerator(style: .heavy)Heavy impact — significant or destructive action
.notificationOccurred(.success)Success pattern — task completed
.notificationOccurred(.warning)Warning pattern — needs attention
.notificationOccurred(.error)Error pattern — something failed
UISelectionFeedbackGenerator().selectionChanged()Subtle tick for selection changes
.sensoryFeedback(.success, trigger: value)SwiftUI-native haptics (iOS 17+)
Challenge: Pick an action in your existing app that has a meaningful outcome — saving data, completing a task, submitting a form. Add success haptic feedback when it succeeds and error feedback when it fails. Test on a real device and see if the feedback feels appropriate. Then decide whether it adds or distracts from the experience.

Using AI to Go Further

Deepen Your Understanding Use AI as a tutor — no code generation required
Explain the three types of haptic feedback generators in iOS — UIImpactFeedbackGenerator, UINotificationFeedbackGenerator, and UISelectionFeedbackGenerator. What is each one designed for? Give me a real app scenario for each.
I’m building a to-do app and I want to add haptics. Can you quiz me by describing different user actions and asking which haptic style I’d use for each?
Build a Practice View Generate a commented example you can study and learn from
Write a SwiftUI form submission view that fires success haptic feedback when a form submits successfully and error feedback when validation fails. Add inline comments explaining every haptic decision.
Give me a SwiftUI example that uses the modern .sensoryFeedback modifier (iOS 17+) and an older UIImpactFeedbackGenerator approach for the same action, side by side, with comments comparing the two approaches.
11.7
App Icons and Launch Screens
⏱ 20 min SwiftUI Basics

Your app icon is the first thing anyone sees. It appears on the home screen, in the App Store listing, in search results, and in Settings. A polished icon signals that you took the whole experience seriously. A placeholder icon — the default gray grid — signals the opposite. This lesson is about making sure your app never looks unfinished.

The launch screen is what appears between tapping your app icon and your first view loading. iOS shows it automatically. Without any configuration, it defaults to a black or white screen. A properly configured launch screen either mirrors your app’s first view closely (so the transition feels seamless) or shows a branded splash that sets the right visual tone from the first frame.

There are two approaches to launch screens: the Info.plist approach and the LaunchScreen storyboard approach. Both work — the Info.plist version is simpler and sufficient for most apps. The storyboard approach gives you more visual control. We’ll cover both.

// App Icon setup — done in the asset catalog, not in code
// 1. In Xcode: Assets.xcassets → AppIcon
// 2. Drag a 1024x1024 PNG into the slot
// 3. Xcode generates all required sizes automatically (iOS 14+)

// Dark and tinted icon variants (iOS 18+)
// In the AppIcon asset, switch Appearances to "Any, Dark, Tinted"
// Provide a separate icon image for each variant

// Launch screen via Info.plist — simplest approach
// In Info.plist, add UILaunchScreen key
// Set UIColorName to a named color from your asset catalog
// Set UIImageName to an image asset to show centered on the screen

// Changing the launch screen in code is NOT possible —
// it must be configured in the asset catalog or Info.plist
Xcode asset catalog showing the AppIcon asset with a single 1024x1024 image slot, and a second panel showing an iPhone home screen with the app icon displayed at full quality alongside other apps
StepWhat it does
Assets.xcassets → AppIcon The asset catalog slot for your app’s icon. iOS 14 and later only requires the 1024×1024 master image — Xcode generates all other sizes from it.
1024×1024 PNG requirement Your source image must be exactly 1024×1024 pixels with no alpha channel (transparency). A transparent background will cause App Store rejection.
Dark icon variant (iOS 18+) Users can opt into a dark home screen. Providing a dark variant of your icon ensures your app looks polished in that context — not just a darkened version of the original.
UILaunchScreen in Info.plist The simplest launch screen configuration. You can set a background color and a centered image without creating a storyboard.
LaunchScreen.storyboard The storyboard approach gives you full visual control over the launch screen layout. Still not SwiftUI — it’s a special UIKit view that exists outside your app’s SwiftUI scene.
Rejection risk: The two most common App Store rejections related to icons are using an image with transparency (alpha channel) and submitting at the wrong resolution. Export your icon as PNG with no transparency from Figma, Sketch, or any design tool. Check the pixel dimensions before uploading.

App Icon and Launch Screen Patterns

Info.plist launch config Simplest launch screen setup — no storyboard needed
/* In Info.plist — add these keys under UILaunchScreen */

/* UIColorName — a color set defined in your asset catalog */
"UIColorName": "LaunchBackground"

/* UIImageName — an image asset to show centered */
"UIImageName": "LaunchLogo"

/* UIImageRespectsSafeAreaInsets — keeps image away from notch/dynamic island */
"UIImageRespectsSafeAreaInsets": true
Add the UILaunchScreen dictionary to your Info.plist. Define your background color as a color set in the asset catalog and your logo as an image asset. This is the fastest path to a branded launch screen without a storyboard.
Storyboard launch screen Full visual control over your launch screen layout
// 1. File → New → File → Launch Screen (Storyboard)
// 2. Name it LaunchScreen.storyboard
// 3. In the canvas, set your background color and add image views
// 4. In project settings → General → App Icons and Launch Image
//    set Launch Screen File to LaunchScreen

// Important: use Auto Layout constraints, not fixed frames
// The launch screen must work on all iPhone screen sizes
The storyboard is a UIKit Interface Builder file, not SwiftUI. It looks different from the rest of your project but gives you full layout control. Use Auto Layout (pin to center, set proportional widths) so the layout works across all device sizes.
Alternate app icons Let users choose from multiple icon options
// Switch the app icon programmatically — user triggers this in settings
if UIApplication.shared.supportsAlternateIcons {
    UIApplication.shared.setAlternateIconName("DarkIcon") { error in
        if let error {
            print("Icon switch failed: \(error)")
        }
    }
}

// To restore the default icon, pass nil
UIApplication.shared.setAlternateIconName(nil) { /* ... */ }
Some apps offer seasonal icons, dark mode icons, or paid icon packs. Alternate icons are declared in the Info.plist and must be included in the app bundle — you can’t download them later. The user gets a system confirmation dialog when the icon changes; you can’t suppress it.
TopicKey Detail
App icon source size1024×1024 PNG, no alpha channel
Icon slot locationAssets.xcassets → AppIcon
Dark icon variantRequires iOS 18+, set Appearances to Any/Dark/Tinted
Simple launch screenUILaunchScreen dictionary in Info.plist
Advanced launch screenLaunchScreen.storyboard with Auto Layout
Alternate iconsDeclared in Info.plist, switched via UIApplication.setAlternateIconName
Common rejection causeTransparent icon or wrong pixel dimensions
Challenge: Give your most recent practice project a real app icon. Create a simple 1024×1024 design in Canva, Figma, or even Keynote — it doesn’t need to be production-quality, just not the default. Add it to the asset catalog and verify it appears on the home screen in the simulator. Then configure a simple launch screen using the Info.plist approach.

Using AI to Go Further

Deepen Your Understanding Use AI as a tutor — no code generation required
Explain the difference between the UILaunchScreen Info.plist approach and the LaunchScreen.storyboard approach. What are the tradeoffs? When would I choose one over the other?
I keep hearing that app icons can’t have transparency, but I don’t understand why the App Store rejects transparent icons. Can you explain the technical and visual reasons behind this rule?
Build a Practice View Generate a commented example you can study and learn from
Write a SwiftUI settings screen that includes an “App Icon” section allowing users to switch between a default icon and an alternate dark icon. Add inline comments explaining the UIApplication calls and why a completion handler is needed.
Give me a checklist format (as plain text, not code) of everything I need to do to properly configure an app icon and launch screen for an iOS app before submitting to the App Store. Explain why each step matters.
11.8
@AppStorage for User Preferences
⏱ 15 min SwiftUI Basics

User preferences are the settings that should survive the app being closed and reopened. Dark mode preference, notification opt-in status, a chosen display name, whether the user has completed onboarding — these need to persist. In Stage 9 you learned that @AppStorage writes values to UserDefaults automatically. This lesson puts that knowledge to work in a real preferences screen.

The key insight with @AppStorage is that it behaves exactly like @State from SwiftUI’s perspective. When the value changes, the view re-renders. When the app relaunches, the stored value is read back automatically. You get persistent state with almost no extra code compared to regular in-memory state.

This lesson builds a complete preferences screen that stores three real settings: a theme override, a notification opt-in toggle, and a display name. These are exactly the kinds of user preferences you’ll need in any real app.

import SwiftUI

struct PreferencesView: View {

    // @AppStorage reads from and writes to UserDefaults automatically
    @AppStorage("userDisplayName") private var displayName = ""

    // Bool preference — persists across launches
    @AppStorage("notificationsEnabled") private var notificationsEnabled = false

    // String preference used to drive preferredColorScheme
    @AppStorage("themePreference") private var themePreference = "system"

    var body: some View {
        Form {
            Section("Profile") {

                // TextField bound directly to the @AppStorage property
                TextField("Display name", text: $displayName)
            }

            Section("Notifications") {

                // Toggle reads and writes the persisted Bool directly
                Toggle("Enable notifications", isOn: $notificationsEnabled)
            }

            Section("Appearance") {

                // Picker lets user choose from three theme options
                Picker("Theme", selection: $themePreference) {
                    Text("System").tag("system")
                    Text("Light").tag("light")
                    Text("Dark").tag("dark")
                }
                .pickerStyle(.segmented)
            }
        }
        .navigationTitle("Preferences")

        // Apply the user's theme choice to the whole view
        .preferredColorScheme(colorSchemeFor(themePreference))
    }

    // Helper — converts the stored string to a SwiftUI ColorScheme optional
    private func colorSchemeFor(_ preference: String) -> ColorScheme? {
        switch preference {
        case "light": return .light
        case "dark": return .dark
        default: return nil // nil = follow system
        }
    }
}
Xcode simulator showing the PreferencesView Form with three sections: a text field for display name, a notification toggle set to on, and a segmented picker with System/Light/Dark options with Dark currently selected
LineWhat it does
@AppStorage("key") var value = default Declares a persistent property. The string key is how the value is stored in UserDefaults. The default value is only used the very first time — after that, the stored value takes over.
TextField("...", text: $displayName) Binds a text field directly to an @AppStorage property using the $ binding syntax, exactly like @State. Every keystroke is persisted immediately.
Toggle("...", isOn: $notificationsEnabled) Binds a toggle to the persisted Bool. When the user flips the switch, the new value is written to UserDefaults and the view re-renders.
Picker(..., selection: $themePreference) Binds a picker to the stored string. When the user selects a segment, the string updates and the helper function converts it to a ColorScheme.
colorSchemeFor(_:) A private helper function that converts the stored string (“system”, “light”, “dark”) to the ColorScheme? that .preferredColorScheme() expects. Returning nil means follow the system.
Key naming tip: Use descriptive, namespaced keys like "com.yourapp.notificationsEnabled" instead of short generic names like "notifications". Short names risk collisions with other frameworks or future settings. The key is just a string — make it unambiguous.

@AppStorage Patterns

Sharing across views Same key in multiple views — they stay in sync automatically
// In PreferencesView
@AppStorage("notificationsEnabled") private var notificationsEnabled = false

// In ProfileView — same key, always reads the same stored value
@AppStorage("notificationsEnabled") private var notificationsEnabled = false
You don’t have to pass @AppStorage values around with bindings. Any view that uses the same key reads from the same UserDefaults slot. When one view updates the value, all other views with the same key re-render automatically. This makes sharing preferences across your app’s screens effortless.
Custom store Use a different UserDefaults suite, like for App Groups
// Share preferences between an app and its widget extension
@AppStorage("widgetEnabled", store: UserDefaults(
    suiteName: "group.com.yourapp.shared"
))
private var widgetEnabled = false
For most apps the default UserDefaults store is perfect. But if you’re building a widget extension or sharing data with another app in the same group, you can specify a custom suite. Both the app and the extension read from and write to the same shared container.
Onboarding completion flag Show onboarding once, remember it’s done forever
struct RootView: View {

    // False by default — first launch always shows onboarding
    @AppStorage("hasCompletedOnboarding") private var hasCompletedOnboarding = false

    var body: some View {
        if hasCompletedOnboarding {
            MainTabView()
        } else {
            // Onboarding sets this flag to true when the user finishes
            OnboardingView(onComplete: { hasCompletedOnboarding = true })
        }
    }
}
This is one of the most common patterns in real apps. The root view checks a persisted flag and routes to onboarding or the main experience accordingly. Setting the flag to true from the onboarding completion callback causes the root view to re-render and navigate to the main app automatically.
SyntaxWhat It Does
@AppStorage(“key”) var value = defaultPersist a value in UserDefaults, read/write like @State
$value (binding)Pass to controls like Toggle, TextField, Picker for two-way binding
Same key in multiple viewsShared state — all views with the same key stay in sync
store: UserDefaults(suiteName:)Use an App Group shared store for widgets or extensions
Supported typesBool, Int, Double, String, URL, Data
Default valueUsed only on first launch — replaced by stored value on every subsequent launch
Challenge: Add a real preferences screen to your existing practice project. Include at least three settings: a display name text field, a notifications toggle, and a theme picker. Wire all three to @AppStorage. Then apply the theme preference to your app’s root view and verify it persists when you stop and relaunch the app in the simulator.

Using AI to Go Further

Deepen Your Understanding Use AI as a tutor — no code generation required
Explain the difference between @State, @AppStorage, and @EnvironmentObject in SwiftUI. When do I choose each one? No code yet — just plain English with a real-world analogy for each.
I know @AppStorage persists data, but I’m not clear on its limitations. What types does it support? What happens if I try to store something it can’t handle? What should I use instead for complex data?
Build a Practice View Generate a commented example you can study and learn from
Write a complete SwiftUI preferences screen with at least four @AppStorage properties including a theme toggle, a display name, and a notification setting. Add a comment on every @AppStorage declaration explaining the key name choice and default value.
Give me a SwiftUI root view that uses an @AppStorage onboarding flag to route between an OnboardingView and a MainTabView. Add inline comments explaining exactly when and why the view re-renders.

Stage 11 Recap: Polish and Real-World Skills

You’ve now covered everything that separates a working app from one that feels genuinely finished. These eight lessons aren’t extras — they’re the details that App Store reviewers notice, that users feel without knowing why, and that distinguish a beginner’s project from a real product.

  • Lesson 11.1 — SF Symbols: Use Apple’s built-in icon library with rendering modes, variants, and variable values — and never hardcode icon names by guessing.
  • Lesson 11.2 — Custom Components: Extract repeated UI into parameterized structs and custom modifiers so your codebase scales without copy-paste sprawl.
  • Lesson 11.3 — Dark Mode: Use semantic colors and asset catalog color sets so your app adapts automatically — no extra code per mode needed.
  • Lesson 11.4 — Dynamic Type and Accessibility: Replace hardcoded font sizes with semantic text styles and use @ScaledMetric for custom dimensions so your layout works at every text size.
  • Lesson 11.5 — VoiceOver: Add accessibility labels, hints, and values to every interactive element — especially icon-only buttons — so screen reader users can navigate your app meaningfully.
  • Lesson 11.6 — Haptics: Use UIImpactFeedbackGenerator and UINotificationFeedbackGenerator sparingly and intentionally to make interactions feel physical and responsive.
  • Lesson 11.7 — App Icons and Launch Screens: Provide a 1024×1024 icon with no transparency, configure a launch screen via Info.plist or storyboard, and avoid the common rejection mistakes.
  • Lesson 11.8 — User Preferences: Build a real preferences system using @AppStorage for settings that persist across launches — including theme choice, notification opt-in, and onboarding state.

If you skipped any challenges, go back and complete them now. The gap between understanding code and being able to apply it closes fastest when you build something of your own immediately after each lesson.

Stage 12 is the final stage: Shipping. You’ll learn how to prepare your app for App Store submission — TestFlight, App Store Connect, metadata, screenshots, review guidelines, and what to do after you launch.

Learn SwiftUI Stage 12: Shipping

Building the app is the part most tutorials teach. Shipping it is the part that turns a hobby project into something real — and this stage covers every step between “my app works on my phone” and “my app is live on the App Store.”

This stage is different from the others. It’s mostly process, not code. You’ll be working inside Xcode’s Organizer, App Store Connect, and TestFlight rather than writing Swift. One important thing to know upfront: submitting to the App Store requires an Apple Developer Program membership, which costs $99/year. If you don’t have one yet, you can follow along with everything up to the actual submission step and join when you’re ready to ship. This stage has 7 lessons covering roughly 3 hours of material. Each lesson ends with a concrete action item — something you can actually do right now, not just think about.

By the end of Stage 12, you’ll understand code signing and provisioning profiles (the part everyone finds confusing at first), how to set up a beta test with TestFlight, how to fill out your App Store Connect listing, how to write screenshots and keywords that actually convert, what the App Review process looks like and what to do when things go wrong, how to read crash reports and basic analytics, and how to maintain your app after it’s live. This is the last stage of Learn SwiftUI — and the one that makes everything before it feel worth it.

12
Stage 12
Shipping
7 lessons · ~3 hrs
12.1
Provisioning Profiles and Code Signing
⏱ 25 min SwiftUI Basics

Before your app can run on a real device or be submitted to the App Store, Apple needs to verify that it came from a trusted developer. That verification system is called code signing, and provisioning profiles are the files that make it work. If you’ve ever seen an error like “No signing certificate found” or “Provisioning profile doesn’t include the selected device” — this lesson explains exactly what went wrong and how to fix it.

The good news is that Xcode handles most of this automatically. The bad news is that when something goes wrong, the error messages are cryptic and the fix isn’t obvious. This lesson will demystify the whole system so that when you hit an error (and you will), you know what you’re looking at and what to do about it.

Think of code signing like a notary stamp. Apple is the notary, your Developer ID certificate is the stamp, and the provisioning profile is the document that says which devices your app is allowed to run on. When everything matches up, Xcode stamps your app and Apple trusts it. When something is missing or expired, the whole thing breaks down.

Xcode Signing & Capabilities tab showing Team selection, Bundle Identifier, and Automatically manage signing checkbox

The Two Types of Certificates

You’ll encounter two kinds of certificates in your developer account. Development certificates let you run your app on your own devices during testing. Distribution certificates are required for submitting to the App Store or distributing via TestFlight to external testers. Xcode can create both automatically — you typically don’t need to manage them manually.

Automatic Signing Recommended for most developers
Enable “Automatically manage signing” in the Signing & Capabilities tab. Xcode creates and updates your certificates and provisioning profiles on its own. This is the right choice for 95% of developers. The only reason to turn it off is if you’re working in a large team with a shared Apple Developer account and strict certificate policies.
Manual Signing For advanced team setups
You download and manage your own provisioning profiles from the Apple Developer portal. This gives you more control but requires much more maintenance. Unless your company specifically requires it, leave this alone and use automatic signing.
Development Profile Running on your own device
A development provisioning profile ties your app’s Bundle ID to a specific set of devices and a development certificate. Your physical device needs to be registered in your Apple Developer account before it will appear here. Go to Xcode → Window → Devices and Simulators to connect and register a device.
Distribution Profile Submitting to the App Store
A distribution provisioning profile is what you need to archive and upload your app for TestFlight or App Store submission. Xcode creates this automatically when you choose Product → Archive. You don’t need to create it manually.

Common Code Signing Errors and Fixes

Error: “No signing certificate found” This usually means Xcode isn’t connected to your Apple Developer account. Go to Xcode → Settings → Accounts, make sure your Apple ID is listed, and click “Download Manual Profiles” or let Xcode manage signing automatically.
Error: “Provisioning profile doesn’t include the selected device” Your device hasn’t been added to your developer account. Connect your iPhone to your Mac, open Xcode’s Devices and Simulators window, and Xcode will offer to register the device. Then let automatic signing regenerate the profile.
Error: “Signing certificate expired” Development certificates expire after one year. Open Keychain Access, search for “iPhone Developer”, and delete any expired certificates. Then return to Xcode and let it create a new one automatically.
Quick fix for most signing errors: Select your project in the file navigator, click your target, go to Signing & Capabilities, make sure “Automatically manage signing” is checked, and make sure your team is selected. Then try building again. This solves the majority of signing problems.
Apple Developer portal showing Certificates, Identifiers & Profiles section with a development certificate listed

Your Bundle ID

Your Bundle ID is a unique string that identifies your app across all of Apple’s systems. The standard format is reverse domain notation: com.yourname.appname. Once you submit your app to the App Store, the Bundle ID is permanent — you can never change it. Choose it carefully before submitting. If you’re just testing, anything works. But for your real app, use something you own.

Action Item
Sign Into Your Developer Account in Xcode

Open Xcode → Settings → Accounts. Add your Apple ID if it isn’t listed. Then open your project, select your target, go to Signing & Capabilities, enable “Automatically manage signing”, and select your team. Build to your simulator and verify there are no signing errors. If you’re submitting a real app, also connect your iPhone and confirm it runs on device.

Note: You can run on the simulator without a paid developer account. You need the $99/year membership to run on a physical device and to submit to the App Store.

Using AI to Go Further

Deepen Your Understanding Understand code signing without the frustration
Explain iOS code signing to me like I’m a beginner who has never shipped an app. What is a provisioning profile, what is a signing certificate, and how do they relate to each other? Use a real-world analogy.
What’s the difference between a development provisioning profile and a distribution provisioning profile in iOS development? When would I need each one?
Troubleshoot With AI Diagnose code signing errors faster
I’m getting this Xcode error when trying to build my app: [paste the exact error message here]. I have automatic signing enabled and my Apple ID is logged in. What are the most likely causes and how should I fix them?
My app runs fine in the simulator but when I try to run it on my iPhone I get a signing error. Walk me through the most common reasons this happens and how to fix each one step by step.
12.2
TestFlight — Beta Testing with Real Users
⏱ 25 min SwiftUI Basics

TestFlight is Apple’s official beta testing platform. It lets you share your app with real users before it goes live on the App Store — and those users can install it on their real iPhones without the app being publicly released. This is invaluable. Real users find bugs that you and your simulator never will. They tap in unexpected places, use the app in unexpected ways, and surface crashes you didn’t know existed.

TestFlight is free and built into App Store Connect. You upload a build, invite testers, and they get an email with a link to install the TestFlight app and your beta. It’s significantly simpler than it sounds, and you should use it for every app — even if your “beta testers” are just your friends and family.

There are two tiers of testers: internal and external. Internal testers are people in your Apple Developer account — team members. External testers are anyone else — up to 10,000 people via email invitation. External testing requires a brief Apple review first, which usually takes about a day.

App Store Connect TestFlight tab showing a build listed with tester groups and invitation status

How to Upload a Build

Step 1: Archive Create a distributable version of your app
In Xcode, select “Any iOS Device” (not your simulator) from the device picker at the top. Then choose Product → Archive from the menu. Xcode compiles your app in release mode and opens the Organizer window when it’s done. This can take a few minutes.
Step 2: Validate Catch problems before uploading
In the Organizer window, select your archive and click “Validate App”. Xcode will check for common issues — missing icons, incorrect entitlements, problems with your bundle ID. Fix any errors before uploading. Validation is faster than upload and catches most problems upfront.
Step 3: Distribute Upload to App Store Connect
Click “Distribute App”, choose “App Store Connect”, and follow the prompts. Choose “Upload” (not “Export”). Xcode will upload your build directly to App Store Connect. Once it’s done, the build appears in TestFlight within a few minutes — sometimes instantly, sometimes after a brief processing delay.
Step 4: Invite Testers Get real users on your beta
In App Store Connect, go to your app → TestFlight. Click the “+” next to “Internal Testers” or create an external tester group. Add email addresses. Your testers receive an email with a link to install the TestFlight app and your build. Internal testers can start immediately. External testers go through a brief review first.

Reading Crash Reports from TestFlight

When your app crashes for a tester, TestFlight captures a crash report and sends it to App Store Connect. You can view these under TestFlight → your build → Crashes. Crash reports show you the exact line of code that caused the crash, which is far more useful than a tester saying “it just stopped working.” Check crash reports regularly during your beta period.

Beta testing timeline: Run your beta for at least one to two weeks before submitting to the App Store. Use the first few days to fix crash-level bugs, then use the remaining time to polish the experience based on tester feedback. Don’t rush this step — a stable beta leads to a much smoother App Review.
Build numbers matter: Every build you upload to TestFlight must have a unique build number, even if the version number is the same. The version number is what users see (like 1.0.0). The build number is internal (like 1, 2, 3). Increment the build number in Xcode every time you upload a new build.
Xcode Organizer showing an archive ready to distribute with Validate App and Distribute App buttons visible
Action Item
Upload Your First Build to TestFlight

Select “Any iOS Device” in Xcode, then choose Product → Archive. Validate the archive, then distribute it to App Store Connect. Once the build appears in TestFlight, invite yourself as an internal tester and install it on your iPhone through the TestFlight app. Confirm it launches and runs correctly on your real device.

Note: You need an Apple Developer account and your app must have an App Store Connect record before you can upload. We’ll cover setting up that record in Lesson 12.3.

Using AI to Go Further

Deepen Your Understanding Get clear on the beta testing process
What’s the difference between internal testers and external testers in TestFlight? When should I use each type, and what are the limitations of each?
What should I be looking for during a TestFlight beta test? What kinds of feedback should I prioritize fixing before submitting to the App Store?
Troubleshoot With AI Fix TestFlight and upload problems
I’m trying to archive my app in Xcode but I’m getting this error: [paste error here]. I’ve already verified that I’m targeting “Any iOS Device” and not the simulator. What should I check next?
My TestFlight build is stuck in “Processing” on App Store Connect and has been for over an hour. What does this usually mean and what should I do?
12.3
App Store Connect Setup
⏱ 30 min SwiftUI Basics

App Store Connect is Apple’s web portal for managing your apps, viewing analytics, and submitting to the App Store. Before you can upload a build, you need to create an app record here — a kind of container that holds all your app’s metadata, builds, and review history. This lesson walks through every field you’ll need to fill in.

The first time you open App Store Connect it can feel overwhelming. There are dozens of fields and settings, and it’s not always obvious which ones matter and which ones you can ignore. The goal of this lesson is to cut through that noise and tell you exactly what you need to fill in and why.

A few things are permanent once you submit: your Bundle ID, your primary language, and your app’s primary category. Everything else — your name, description, screenshots, pricing — can be changed in a later update. Don’t let the fear of making the wrong choice stop you from moving forward.

App Store Connect My Apps page showing the New App button and the app creation form with Bundle ID, Name, and Primary Language fields

Creating Your App Record

Platform and Name What users see in the App Store
Choose iOS. Your app name can be up to 30 characters and must be unique in the App Store — search first to make sure it isn’t taken. This is different from your display name in Xcode. The App Store name is what appears under the app icon in search results.
Bundle ID Permanent identifier — choose carefully
Select the Bundle ID you registered in the Apple Developer portal. If you haven’t registered it yet, do that first at developer.apple.com → Identifiers. The Bundle ID must match exactly what’s in your Xcode project. Once submitted, this cannot be changed.
SKU Internal reference — not user-facing
SKU is a unique identifier for your own records. Users never see it. Use something simple and descriptive like your app name plus a number (e.g. “myapp-001”). It only needs to be unique within your developer account.
Pricing and Availability Free vs. paid and which countries
Choose “Free” unless your app has a paid upfront price. If you’re using In-App Purchases or subscriptions, the app itself is free. Select all territories unless there’s a specific legal or content reason to restrict certain countries. You can change this at any time.

Fields You Must Complete Before Submitting

FieldWhat to know
Privacy Policy URL Required for all apps. Even if you collect no data, you need a privacy policy. Use a free generator like app-privacy-policy-generator.firebaseapp.com and host it anywhere (Notion, GitHub, your own site).
Age Rating Answer the questionnaire honestly. Most simple apps rate as 4+. If your app has any user-generated content, even basic profile pictures, you’ll rate higher.
Category Choose the most accurate primary category. A secondary category is optional but can help visibility. Categories affect where your app appears in Browse — not just search.
Copyright Your name or company name plus the year. Example: “2025 Jane Smith”. Not a legal requirement to register separately — just type it in.
Support URL A URL where users can get help. This can be a contact page, a simple FAQ, or even a GitHub page. It must be a real, accessible URL.
Privacy Nutrition Labels: Before you submit, Apple requires you to disclose what data your app collects under “App Privacy” in App Store Connect. Be honest and thorough — Apple reviews these. If your app uses Firebase, it likely collects analytics data even if you don’t think of it that way.
App Store Connect App Information page showing the Category, Age Rating, and Privacy Policy URL fields filled in
Action Item
Create Your App Record in App Store Connect

Log in to App Store Connect at appstoreconnect.apple.com. Click My Apps → the “+” button → New App. Fill in the platform, name, primary language, Bundle ID, and SKU. Then navigate to App Information and fill in the category, age rating, and your privacy policy URL. Don’t worry about screenshots or description yet — that’s Lesson 12.4.

Note: If your Bundle ID doesn’t appear in the dropdown, you need to register it first at developer.apple.com → Certificates, Identifiers & Profiles → Identifiers.

Using AI to Go Further

Deepen Your Understanding Get clear on App Store Connect metadata
What are Apple’s privacy nutrition labels and what data does a typical SwiftUI app that uses Firebase collect? Help me understand what I’d need to disclose.
What’s the difference between an app’s version number and its build number in the context of App Store Connect? How should I manage these across multiple releases?
Troubleshoot With AI Fix App Store Connect setup issues
I’m trying to create a new app in App Store Connect but my Bundle ID isn’t showing up in the dropdown. I registered it in the Developer portal already. What are the most common reasons this happens?
App Store Connect is showing a warning on my app record that I need to resolve before I can submit. The warning says: [paste warning text here]. What does this mean and how do I fix it?
12.4
Screenshots, Descriptions, and Keywords
⏱ 25 min SwiftUI Basics

Your app’s listing is your sales page. It’s the first thing a potential user sees before they decide whether to download your app. Most developers treat it as an afterthought — they take quick screenshots in the simulator, write a few sentences, and move on. That’s a mistake. Your listing is where downloads are won or lost.

App Store Optimization (ASO) is the practice of improving your listing so more people find your app and more of them download it when they do. You don’t need to be an expert — but understanding a few basics will make a real difference, especially for your first app when you don’t have reviews or word-of-mouth working for you yet.

This lesson covers the three elements of your listing that matter most: your screenshots, your description, and your keyword field. Each one affects a different part of the funnel — keywords affect whether people find you, screenshots affect whether they click, and your description affects whether they download.

App Store product page showing screenshots, app name, subtitle, and description with the Get button visible

Screenshots That Convert

Required Sizes What Apple needs from you
You must submit screenshots for 6.5″ (iPhone 14 Pro Max and similar) and 5.5″ (iPhone 8 Plus). These two sizes cover all iPhone screen sizes. If you submit for iPad as well, you’ll need 12.9″ iPad Pro screenshots. Use Xcode’s simulator to take screenshots at the correct sizes: Hardware → Screenshot.
What to Show Make the value obvious immediately
Your first screenshot is by far the most important — it’s visible in search results without the user tapping into your listing. Show your app’s core value proposition in that first image. Don’t show a login screen or an empty state. Show the best, most useful moment in your app with real-looking data.
Adding Text Overlays Communicate what the screenshot shows
Most successful apps add a short headline above or below each screenshot explaining what the user is looking at. “Track every workout” or “See your progress at a glance” — simple, benefit-focused phrases. Use Canva, Figma, or even Keynote to add these. Screenshots with overlays consistently outperform plain screenshots.

Writing Your Description

Your description has two parts: the first three lines (visible without tapping “more”) and the rest. Put your strongest, clearest statement of what the app does in those first three lines. Most users never read further. After that, you can go into more detail about features, but keep the language user-focused rather than technical.

Keywords Field

The keyword field gives you 100 characters to add search terms — words that aren’t in your app name or subtitle already. Don’t repeat your app name. Don’t add spaces after commas — Apple ignores them and you’re wasting characters. Think about what your target user would search for when looking for an app like yours.

Name and Subtitle: Your app name (30 characters) and subtitle (30 characters) are indexed for search and carry more weight than the keyword field. Include your most important keyword naturally in your name or subtitle if at all possible.
Preview videos: Preview videos autoplay in the App Store. They can dramatically increase conversions — but only if they’re good. A poor-quality video is worse than no video. If you don’t have time to make a polished 15–30 second video that clearly shows the app working, skip it for your first release and add it later.
App Store Connect Media Manager showing screenshot upload slots for 6.5-inch and 5.5-inch iPhone sizes
Action Item
Create and Upload Your Screenshots and Description

Take screenshots of your app at 6.5″ and 5.5″ using the Xcode simulator. Add a short text overlay to at least your first screenshot using Canva or a similar tool. Write a description that leads with your app’s strongest benefit in the first three lines. Fill in your keyword field using 100 characters of relevant search terms with no spaces after commas. Upload everything to App Store Connect.

Note: Screenshot dimensions: 6.5″ is 1284 × 2778px. 5.5″ is 1242 × 2208px. Xcode’s simulator will take screenshots at the correct size automatically when you use Hardware → Screenshot.

Using AI to Go Further

Deepen Your Understanding Think strategically about your App Store listing
My app is [describe your app in one sentence]. Help me write an App Store description that leads with the strongest benefit in the first three lines, then covers key features in a scannable way. Keep it under 4000 characters.
I have 100 characters for my App Store keyword field. My app is [describe your app]. Suggest the best keywords I should use, keeping in mind I shouldn’t repeat words already in my app name “[your app name]”. Format as a comma-separated list with no spaces after commas.
Troubleshoot With AI Fix listing and metadata issues
App Store Connect is rejecting my screenshots with an error that says: [paste error here]. What’s the most likely cause and how do I fix it?
My app description was rejected during App Review for including [paste the feedback]. How should I rewrite it to comply with Apple’s guidelines while still communicating the app’s value?
12.5
The Review Process
⏱ 20 min SwiftUI Basics

Once you’ve filled in your metadata, uploaded your screenshots, and attached a build, you click “Submit for Review” — and then you wait. Apple’s App Review team manually examines your app to make sure it follows their guidelines. For most straightforward apps, review takes 24–48 hours. It can be faster. It can also be longer for complex apps or during busy periods like the holidays.

Getting rejected is more common than most people expect. About 40% of first submissions get rejected. That’s not a reflection of your work — it’s just how the process works. Rejections are fixable. Apple tells you exactly what the problem is, you fix it, and you resubmit. The average total time from first submission to App Store approval is about one week when you factor in potential rejections.

Understanding why rejections happen is the best way to avoid them. The vast majority of rejections fall into a small number of categories, and most of them are preventable if you know what to look for before you submit.

Common Rejection Reasons

Crashes and Bugs Guideline 2.1 — Most common rejection reason
Apple’s reviewers test your app like real users. If it crashes during review, it gets rejected. Test your app thoroughly on a real device before submitting — not just the simulator. Pay special attention to edge cases: empty states, network failures, and the very first launch experience with no cached data.
Incomplete Information Guideline 2.1 — Missing demo credentials
If your app requires login, Apple’s reviewers need a way in. Provide demo login credentials in the App Review Notes field. If you don’t, they can’t test the app and will reject it. Create a permanent demo account specifically for App Review — don’t use your own credentials.
Misleading Metadata Guideline 2.3 — Screenshots or description issues
Your screenshots must show the actual app. You can’t show features that don’t exist yet. Your description must accurately reflect what the app does. Using a competitor’s name in your keyword field is against the rules. Don’t promise features in your description that aren’t in this version.
In-App Purchase Issues Guideline 3.1 — Payment and monetization
If your app sells digital goods or services, you must use Apple’s In-App Purchase system — no linking to your website to buy, no directing users to pay outside the app. Physical goods and services (like Uber rides or Amazon orders) are exempt. This is strictly enforced.
Privacy Violations Guideline 5.1 — Data collection and permissions
Any permission your app requests (camera, location, contacts) must have a clear purpose string explaining why you need it. If you request a permission and don’t actually use it in the reviewed version, Apple will reject it. Your privacy nutrition labels in App Store Connect must match what your app actually does.

What To Do When You’re Rejected

Apple sends a rejection message through the Resolution Center in App Store Connect. Read it carefully — they usually specify the exact guideline and what the reviewer observed. Reply to ask for clarification if needed. Once you’ve fixed the issue, go to your submission, address the rejection in the Resolution Center notes, and resubmit. You don’t need to start over from scratch.

Appealing a rejection: If you believe the rejection was incorrect, you can appeal through the Resolution Center or use Apple’s App Review Board process. Be polite and specific about why you believe your app complies with the guideline in question. Appealing is underused and often works when you have a clear case.
First submission nervousness is normal: Almost every developer is anxious the first time they submit. The process feels opaque. But most straightforward apps get approved. And if you get rejected, it’s not a permanent verdict — it’s just a round-trip that costs you a few days.
Action Item
Pre-Submit Review Checklist

Before you click “Submit for Review”, go through this checklist: test your app on a real device with no prior data (delete and reinstall), test with no internet connection, verify all permissions have clear purpose strings in Info.plist, create a demo account if your app requires login and add those credentials to the App Review Notes, and read through your App Store listing once more to verify everything is accurate.

Note: App Review Notes are in App Store Connect under your app version → App Review Information. This is also where you can include a demo video or additional context for the reviewer.

Using AI to Go Further

Deepen Your Understanding Understand the App Review process deeply
What are the most common reasons first-time iOS apps get rejected by Apple App Review? For each reason, explain what it looks like in practice and how to avoid it before submitting.
My app requires users to log in with an account to use it. What do I need to provide to Apple’s reviewers so they can test the full app experience?
Troubleshoot With AI Navigate a rejection constructively
My app was rejected by Apple with this message: [paste the rejection message]. Help me understand exactly what they’re asking for and what I should change to get approved on resubmission.
Apple rejected my app citing Guideline [X]. I believe my app actually complies with this guideline because [your reasoning]. Help me write a clear, professional appeal response to submit through the Resolution Center.
12.6
Crash Reporting and Analytics
⏱ 20 min SwiftUI Basics

Your app is live. Real people are using it. Some of them are experiencing crashes you’ve never seen before, on device configurations you’ve never tested, doing things you never expected. Without crash reporting, those users quietly uninstall your app and leave a one-star review. With crash reporting, you get an exact stack trace pointing to the line of code that failed.

Analytics tell you how people actually use your app — which screens they visit, which features they use, and where they drop off. This data is how you make decisions about what to build next. Don’t fly blind. Even basic analytics data is enormously valuable for prioritizing your next update.

This lesson covers two things: reading crash reports from Xcode’s built-in Organizer, and setting up Firebase Crashlytics as a third-party crash reporter that gives you more real-time data. You don’t need both — Crashlytics is more powerful, but the built-in Organizer reports are free and require no setup.

Xcode Organizer showing Crashes section with a list of crash reports, device types, and a symbolicated stack trace

Reading Crash Reports in Xcode Organizer

Opening Organizer Where Apple sends your crash data
Go to Xcode → Window → Organizer → Crashes. Select your app from the left sidebar. Xcode shows you a list of crash groups, sorted by how frequently they occur. Click any crash group to see the stack trace — the sequence of function calls that led to the crash. The highlighted line is usually where the crash happened.
Symbolication Making crash logs readable
Raw crash logs show memory addresses, not function names. Symbolication translates those addresses back into readable code. Xcode does this automatically when you open a crash report, but only if the dSYM file for that exact build is still on your Mac. This is why you should archive your builds — the dSYM is stored with the archive.
Prioritizing Crashes Fix the most impactful ones first
Xcode Organizer sorts crashes by frequency. Start with the most frequent crash — fixing the top one typically resolves the majority of your users’ crash experiences. Look at which iOS versions and device types are affected. A crash that only happens on iOS 15 on an older device is lower priority than one that crashes every user on launch.

Firebase Crashlytics Setup

Firebase Crashlytics gives you real-time crash reports — you see crashes within minutes of them happening, rather than waiting for Apple to batch and deliver them. It also shows you what the user was doing just before the crash, which is invaluable for reproducing the issue.

Basic Analytics with Firebase

Firebase Analytics is free and gives you user counts, session lengths, screen views, and retention data without you writing any custom tracking code. For your first app, the default events Firebase tracks automatically are usually enough to answer your most important questions: how many people are using the app, and are they coming back?

What to track and what to ignore: For your first app, focus on three metrics: daily active users (are people using it?), crash-free users percentage (is it stable?), and Day 1 retention (do people come back the day after installing?). Everything else is noise until you have a meaningful user base.
Apple’s built-in analytics: App Store Connect also shows you downloads, product page views, and crash rates in the Analytics tab. This data is available for all apps automatically — no setup required. It’s less detailed than Firebase but it’s a good starting point and doesn’t require adding any dependencies to your app.
Action Item
Check Your App’s Crash Reports

Open Xcode → Window → Organizer → Crashes. If your app has been live for any time, check whether any crash groups appear. If you see crashes, click into the most frequent one and read the stack trace. Try to identify which file and function are involved. Even if you can’t immediately fix it, reading real crash reports is a skill worth practicing now.

Note: Crash reports appear in Organizer after Apple aggregates them — usually within a day or two of the crash occurring. If your app was just submitted, you may not see any data yet. Check back after your first few days of real users.

Using AI to Go Further

Deepen Your Understanding Understand crash reports and analytics
What is a dSYM file in iOS development and why is it important for reading crash reports? What happens if I lose the dSYM for a build that’s already on the App Store?
What are the most important metrics to track for a new iOS app in its first 30 days? What does each metric tell me and what should I do if a metric looks bad?
Troubleshoot With AI Diagnose crashes from stack traces
Here’s a crash report from Xcode Organizer for my iOS app: [paste the stack trace]. Can you help me understand what happened, which part of my code is likely involved, and what kind of bug this typically indicates?
My app’s crash-free users percentage in Firebase is [X%]. Is that good or bad for an app at my stage, and what should I prioritize to improve it?
12.7
Post-Launch: Updates and Maintenance
⏱ 15 min SwiftUI Basics

Shipping version 1.0 is not the end — it’s the beginning of a different kind of work. The post-launch phase is where you respond to users, fix bugs you couldn’t have anticipated, and decide what to build next. Most beginner developers underestimate how much of app development happens after launch.

The good news is that this phase is more predictable than building version 1.0. You have real data, real users giving you real feedback, and a clear app to iterate on rather than building from nothing. Many developers find post-launch work more satisfying than the initial build because the feedback loop is so much shorter.

This lesson covers the practical rhythm of maintaining a live app: when and how to update, how to handle App Store reviews, how to communicate with users, and how to decide what belongs in your next release versus what can wait.

App Store Connect Reviews section showing user reviews with star ratings and the Developer Response text field

The Update Cycle

Patch Update (1.0.1) For critical bug fixes
If users are experiencing crashes or significant bugs, get a patch out within a few days. Keep the scope minimal — fix only the critical issue, test it, and submit. A fast patch builds trust. Users who experience a bug and then see a fix in their updates feel heard. Users who experience a bug and see nothing for two months leave.
Minor Update (1.1) For feature additions and improvements
Minor updates are where you act on user feedback and analytics. Aim for a minor update every 4–8 weeks in your app’s early days — frequent enough to show the app is active, infrequent enough to let you bundle meaningful improvements. New iOS releases from Apple often require minor updates to maintain compatibility and take advantage of new APIs.
Major Update (2.0) For significant redesigns or pivots
Save major version numbers for significant changes — a redesigned UI, a new core feature, or a major technical rewrite. Major updates are marketing events as well as technical ones. They’re an opportunity to re-engage lapsed users and get featured coverage. Don’t increment the major version for routine improvements.

Responding to App Store Reviews

You can respond to user reviews directly in App Store Connect. Responding to negative reviews — especially quickly — dramatically improves the chance that users update their rating. A response that acknowledges the problem and tells the user what you fixed is more effective than any amount of marketing. Keep responses short, specific, and genuine. Users can tell the difference between a form response and a real one.

Monitoring ratings: App Store Connect shows you your average rating and review count in real time. Track your rating after each update — improvements (or drops) often correlate directly with specific changes you made. This feedback loop is one of the most useful signals you have.
When to leave it alone: Not every user request needs to become a feature. If one user asks for something and no one else does, that’s data — just lower-priority data. Focus your update energy on issues that appear repeatedly across multiple users and align with the core value your app provides. Scope creep is how apps become bloated and lose their focus.

Keeping Up with iOS Updates

Apple releases a major iOS version every fall. When a new iOS version ships, you should: test your app on the new version as soon as the betas are available, update to the latest Xcode SDK before Apple stops accepting apps built with older SDKs (usually in the spring following the release), and look at WWDC session videos for new SwiftUI APIs that might enhance your app. Apple publishes the submission deadline each year — don’t miss it or your app will be removed from sale.

Action Item
Plan Your First Update

Write down the three most important things you’d include in version 1.0.1 of your app — based on what you know about its current state. At least one should be a bug fix or polish improvement, not a new feature. Set a target date for having it submitted. Having a plan for your first update before you’ve even launched is a sign that you’re thinking about this like a developer, not just a one-time builder.

Note: You submit updates through the same App Store Connect flow as the original submission. Create a new version record, attach a new build, update your “What’s New” text (shown to users when they update), and submit. Each update goes through App Review as well, but typically faster than the original.

Using AI to Go Further

Deepen Your Understanding Think about app maintenance strategically
How should I prioritize what to include in my app’s first update after launch? What signals should I be looking at and what framework can I use to decide between bug fixes, performance improvements, and new features?
A user left a two-star review that says: “[paste the review text]”. Help me write a response that is genuine, specific, and likely to result in the user updating their rating.
Troubleshoot With AI Handle post-launch surprises
My app is getting reviews complaining about [specific issue]. I’m not sure if this is a bug, a design problem, or a misaligned user expectation. Help me think through what’s most likely causing this and what I should do about it.
Apple just announced a new iOS version and I’m not sure if my app needs to be updated before the SDK submission deadline. My app uses [list key technologies/frameworks]. What should I check and what’s likely to break or need updating?

Stage 12 Recap: Shipping

Stage 12 took you through the complete journey from a working app on your Mac to a live app on the App Store — and everything that comes after. Here’s what you covered.

  • Lesson 12.1 — Code Signing: Provisioning profiles and certificates are Apple’s trust system. Enable automatic signing, keep your Team selected, and Xcode handles the rest. Know how to diagnose the most common errors when they occur.
  • Lesson 12.2 — TestFlight: Archive in Xcode, upload to App Store Connect, and invite testers. Always beta test on real devices before submitting — real users find bugs that simulators and developers miss entirely.
  • Lesson 12.3 — App Store Connect: Your app record holds all your metadata. The Bundle ID is permanent. Fill in your privacy policy, age rating, category, and privacy nutrition labels before you submit.
  • Lesson 12.4 — Screenshots and ASO: Your first screenshot and your first three lines of description do most of the conversion work. Use keyword field characters efficiently. Show the app’s best moment, not its setup screens.
  • Lesson 12.5 — The Review Process: Rejection is common and fixable. Test thoroughly on device, provide demo credentials if your app requires login, and read the rejection message carefully before resubmitting.
  • Lesson 12.6 — Crash Reporting and Analytics: Xcode Organizer gives you symbolicated crash reports automatically. Firebase Crashlytics gives you real-time data. Track crash-free users, daily active users, and Day 1 retention above all else.
  • Lesson 12.7 — Post-Launch: Respond to reviews, ship patches quickly, and plan minor updates every 4–8 weeks. Stay ahead of iOS version requirements. The post-launch phase is where you build a real relationship with your users.

You’ve completed 12 stages of Learn SwiftUI. That’s not a small thing. Most people who say they want to build an iOS app never finish one. You just built one and shipped it — or you’re days away from doing so. That’s a real accomplishment, and you should feel genuinely proud of it.

You’ve Shipped. Here’s What’s Next.

Shipping your first app isn’t the end of the journey — it’s the beginning of a longer and more interesting one. Your first app taught you how to build and ship. Your second app will teach you what your first one couldn’t: how to build something people actually want, how to grow it, and how to make it sustainable. Most successful indie developers have 3–5 apps before they find their breakthrough. The developers who eventually succeed are the ones who keep going.

The natural next step from here depends on where you want to go. If you want to go deeper into SwiftUI — widgets, watchOS, macOS, or visionOS — each platform uses the same SwiftUI fundamentals you’ve built and adds its own patterns on top. If you’re interested in native AI features, Apple’s Core ML and the new on-device model APIs are genuinely exciting and approachable with your current foundation. If you want to build your second app with better architecture, diving deeper into Swift Concurrency, the Observation framework, and testing will pay dividends immediately. Or you might simply want to take everything you learned from shipping your first app and apply it more deliberately to a better idea. That’s often the most valuable next step of all.

If you want structured courses, a community of fellow developers going through the same journey, and someone in your corner as you figure out what comes next, CWC+ is where that happens. Whatever you build next — keep building. The skills you’ve developed over these 12 stages are real, they compound, and they get better with every app you ship.



Get started for free

Join over 2,000+ students actively learning with CodeWithChris