← All lessonsLesson 48 of 57 · Part 12: Architecture, testing, and performance
Lesson 48

One star, one card

Performance

You will buildSelf._printChanges() in the trip card, to see what starring one card costs; a GridCard that hands each card its trip as a value, after which the same tap runs one card; the SwiftUI instrument; and a tidy-up. Then Part 12's interview problem.

TimeAbout 45 minutes, then the interview problem

WhyMost SwiftUI performance work comes down to one question: how often does a body run, and how much does it do when it does? A body runs when something it read changed — and, for a subview, when its parent ran and handed it inputs that SwiftUI cannot tell are the same as last time. Lesson 7 met Self._printChanges(); in this lesson it finds a real case in Trips, and the fix is the one interviewers want to hear: give a view values it can compare, keep each update small, and measure again.

Step 1

Open your project

  1. Open your Trips project as lesson 47 left it: double-click Trips.xcodeproj in Finder, or use File ▸ Open… (⌘O) in Xcode.

  2. If you did not finish lesson 47, or would rather start from a known state, download Trips-lesson-47.zip instead. It is the project exactly as it stood at the end of lesson 47. Unzip it and open Trips.xcodeproj.

  3. Run it (⌘R) to check that it builds. You should see where lesson 47 ended:

    Trips, as it stood at the end of lesson 47 — screenshot
    Trips, as it stood at the end of lesson 47Screenshot · iPhone 17, iOS 27.0
Step 2

Count the cards

Starring a trip on the Overview changes one trip. How many cards does SwiftUI run to show it? Ask the card. Add a first line to body:

In TripCard.swift, replace the TripCard struct with this
struct TripCard: View {    @Binding var trip: Trip    @State private var showsFacts = false    var body: some View {        let _ = Self._printChanges()        VStack(alignment: .leading, spacing: 10) {            Image(trip.imageName)                .resizable()                .scaledToFill()                .frame(height: 120)                .clipShape(RoundedRectangle(cornerRadius: 8))                .contentShape(Rectangle())                .overlay(alignment: .topTrailing) {                    FavoriteButton(isFavorite: $trip.isFavorite)                        .accessibilityLabel("Favorite \(trip.city)")                        .padding(6)                        .background(.ultraThinMaterial, in: Circle())                        .padding(8)                }            ViewThatFits(in: .horizontal) {                HStack(alignment: .firstTextBaseline) {                    title                    Spacer()                    nights                }                VStack(alignment: .leading, spacing: 4) {                    title                    nights                }            }            ViewThatFits(in: .horizontal) {                HStack {                    start                    Spacer()                    price                }                VStack(alignment: .leading, spacing: 4) {                    start                    price                }            }            .font(.subheadline)            Text(trip.formattedNotes)                .font(.footnote)            Button(showsFacts ? "Hide the details" : "Show the details", systemImage: showsFacts ? "chevron.up" : "chevron.down") {                withAnimation {                    showsFacts.toggle()                }            }            .font(.footnote.weight(.semibold))            .accessibilityLabel(showsFacts ? "Hide the details of \(trip.city)" : "Show the details of \(trip.city)")            if showsFacts {                TripFacts(trip: trip)                    .font(.subheadline)                    .transition(.move(edge: .top).combined(with: .opacity))            }        }        .padding()        .background(.quaternary, in: RoundedRectangle(cornerRadius: 12))        .padding(.horizontal)    }    private var title: some View {        VStack(alignment: .leading, spacing: 2) {            Text(trip.city)                .font(.headline)            Text(trip.country)                .foregroundStyle(.secondary)        }    }    private var nights: some View {        Label("^[\(trip.nights) night](inflect: true)", systemImage: "moon.stars")    }    private var start: some View {        Text(trip.start, format: .dateTime.day().month().year())    }    private var price: some View {        Text(trip.price, format: .currency(code: "USD"))    }}
struct TripCard: View {
    @Binding var trip: Trip
    @State private var showsFacts = false

    var body: some View {
        let _ = Self._printChanges()
        VStack(alignment: .leading, spacing: 10) {
            Image(trip.imageName)
                .resizable()
                .scaledToFill()
                .frame(height: 120)
                .clipShape(RoundedRectangle(cornerRadius: 8))
                .contentShape(Rectangle())
                .overlay(alignment: .topTrailing) {
                    FavoriteButton(isFavorite: $trip.isFavorite)
                        .accessibilityLabel("Favorite \(trip.city)")
                        .padding(6)
                        .background(.ultraThinMaterial, in: Circle())
                        .padding(8)
                }
            ViewThatFits(in: .horizontal) {
                HStack(alignment: .firstTextBaseline) {
                    title
                    Spacer()
                    nights
                }
                VStack(alignment: .leading, spacing: 4) {
                    title
                    nights
                }
            }
            ViewThatFits(in: .horizontal) {
                HStack {
                    start
                    Spacer()
                    price
                }
                VStack(alignment: .leading, spacing: 4) {
                    start
                    price
                }
            }
            .font(.subheadline)
            Text(trip.formattedNotes)
                .font(.footnote)
            Button(showsFacts ? "Hide the details" : "Show the details", systemImage: showsFacts ? "chevron.up" : "chevron.down") {
                withAnimation {
                    showsFacts.toggle()
                }
            }
            .font(.footnote.weight(.semibold))
            .accessibilityLabel(showsFacts ? "Hide the details of \(trip.city)" : "Show the details of \(trip.city)")
            if showsFacts {
                TripFacts(trip: trip)
                    .font(.subheadline)
                    .transition(.move(edge: .top).combined(with: .opacity))
            }
        }
        .padding()
        .background(.quaternary, in: RoundedRectangle(cornerRadius: 12))
        .padding(.horizontal)
    }

    private var title: some View {
        VStack(alignment: .leading, spacing: 2) {
            Text(trip.city)
                .font(.headline)
            Text(trip.country)
                .foregroundStyle(.secondary)
        }
    }

    private var nights: some View {
        Label("^[\(trip.nights) night](inflect: true)", systemImage: "moon.stars")
    }

    private var start: some View {
        Text(trip.start, format: .dateTime.day().month().year())
    }

    private var price: some View {
        Text(trip.price, format: .currency(code: "USD"))
    }
}

Run the app, scroll down to the cards, and tap the star on Vancouver's:

Vancouver, starred — screenshot
Vancouver, starredScreenshot · iPhone 17, iOS 27.0
Console
TripCard: @self, _trip changed.
TripCard: @self, _trip changed.
TripCard: @self, _trip changed.
Saved 3 trips: 797 bytes

Three lines, one for each card in the grid — although only Vancouver changed. @self means the card's own value was different from last time, and _trip is its binding. The grid's ForEach($store.trips…) makes a new binding for every card each time the grid's body runs, and SwiftUI cannot see that a new binding leads to the same trip: to it, every card got a new input. With three cards nobody would notice. With three hundred, each with a picture, it is the difference between one card's work and the whole grid's, on every tap.

Step 3

A card that gets a value

The fix is to give each card something SwiftUI can compare. A trip is a value: if Vancouver's trip is the same as last time, the card made from it is the same. GridCard takes the trip, finds the store in the environment, and makes the binding the card needs inside its own body — which now runs only when its trip changed. Make a new SwiftUI View named GridCard, and replace everything in it:

New fileGridCard.swift — the whole file
import SwiftUI

/// A trip's card in the grid. It is given the trip — a value SwiftUI can compare with the last one — and makes the
/// card's binding itself, so that a change to one trip runs only that trip's card.
struct GridCard: View {
    let trip: Trip
    @Environment(TripStore.self) private var store

    var body: some View {
        TripCard(trip: Binding(get: { trip }, set: { store.update($0) }))
    }
}

In the grid, delete @Bindable var store = store at the top of body — the grid no longer makes bindings — and change the ForEach:

In TripGrid.swift, make these changes
import SwiftUI/// The trip cards that pass the filter, in an order that can be changed by dragging.struct TripGrid: View {    @Environment(TripStore.self) private var store    let filter: TripFilter    let closings: Int    var body: some View {        @Bindable var store = store        LazyVGrid(columns: [GridItem(.adaptive(minimum: 300))], spacing: 12) {            ForEach($store.trips.filter { filter.includes($0.wrappedValue) }) { $trip in                TripCard(trip: $trip)            ForEach(store.trips.filter { filter.includes($0) }) { trip in                GridCard(trip: trip)            }            .reorderable()        }        .reorderContainer(for: Trip.self, isEnabled: filter == .all) { difference in            switch difference.destination.position {            case .before(let id): store.move(difference.sources, before: id)            case .end: store.move(difference.sources, before: nil)            }        }        .id(closings)    }}#Preview {    ScrollView {        TripGrid(filter: .favorites, closings: 0)    }    .environment(TripStore())}
import SwiftUI

/// The trip cards that pass the filter, in an order that can be changed by dragging.
struct TripGrid: View {
    @Environment(TripStore.self) private var store
    let filter: TripFilter
    let closings: Int

    var body: some View {
        LazyVGrid(columns: [GridItem(.adaptive(minimum: 300))], spacing: 12) {
            ForEach(store.trips.filter { filter.includes($0) }) { trip in
                GridCard(trip: trip)
            }
            .reorderable()
        }
        .reorderContainer(for: Trip.self, isEnabled: filter == .all) { difference in
            switch difference.destination.position {
            case .before(let id): store.move(difference.sources, before: id)
            case .end: store.move(difference.sources, before: nil)
            }
        }
        .id(closings)
    }
}

#Preview {
    ScrollView {
        TripGrid(filter: .favorites, closings: 0)
    }
    .environment(TripStore())
}

Run the app again, and star Vancouver:

Vancouver, starred, after the change — screenshot
Vancouver, starred, after the changeScreenshot · iPhone 17, iOS 27.0
Console
TripCard: @self, _trip changed.
Saved 3 trips: 797 bytes

One line: Vancouver's card. The other two trips were the same as before, so SwiftUI skipped their GridCards, and the cards inside them never ran. Nothing changed on the screen; one star now costs one card.

Step 4

The SwiftUI instrument

_printChanges() explains one view. For a whole app — which views update most, and which take too long — use the SwiftUI instrument. Choose Product ▸ Profile (⌘I): Xcode builds the app for profiling and opens Instruments, where you choose the SwiftUI template and record while you use the app. Its first lanes show when SwiftUI was working at all (Update Groups) and which updates took too long — Long View Body Updates, Long Representable Updates, Other Long Updates — coloured by how likely each was to cause a hitch.

6248003032315232163256Guide · The attribute graphInside the Attribute GraphOne write, start to finish: the flags on the body node, the nine nodes marked below it, and the pull that reruns only them. It is what _printChanges() is reading.Open the guide →
Profile on a device

Apple advises it for every kind of profiling: "You get higher-fidelity measurements by profiling on a device instead of the simulator." For this template it is not even a choice. Recorded with Xcode 27's command-line Instruments, xctrace, against the iOS 27 simulator, the SwiftUI template stopped with "Hitches is not supported on this platform.", and the SwiftUI instrument on its own recorded no data. Connect an iPhone, choose it as the run destination, and profile there.

Where this comes from: Improving your app's performance · WWDC25: Optimize SwiftUI performance with Instruments

Step 5

Tidy up

Self._printChanges() is for debugging only — Apple says never to submit it to the App Store. Take it out again: delete the first line of body:

In TripCard.swift, replace the TripCard struct with this
struct TripCard: View {    @Binding var trip: Trip    @State private var showsFacts = false    var body: some View {        let _ = Self._printChanges()        VStack(alignment: .leading, spacing: 10) {            Image(trip.imageName)                .resizable()                .scaledToFill()                .frame(height: 120)                .clipShape(RoundedRectangle(cornerRadius: 8))                .contentShape(Rectangle())                .overlay(alignment: .topTrailing) {                    FavoriteButton(isFavorite: $trip.isFavorite)                        .accessibilityLabel("Favorite \(trip.city)")                        .padding(6)                        .background(.ultraThinMaterial, in: Circle())                        .padding(8)                }            ViewThatFits(in: .horizontal) {                HStack(alignment: .firstTextBaseline) {                    title                    Spacer()                    nights                }                VStack(alignment: .leading, spacing: 4) {                    title                    nights                }            }            ViewThatFits(in: .horizontal) {                HStack {                    start                    Spacer()                    price                }                VStack(alignment: .leading, spacing: 4) {                    start                    price                }            }            .font(.subheadline)            Text(trip.formattedNotes)                .font(.footnote)            Button(showsFacts ? "Hide the details" : "Show the details", systemImage: showsFacts ? "chevron.up" : "chevron.down") {                withAnimation {                    showsFacts.toggle()                }            }            .font(.footnote.weight(.semibold))            .accessibilityLabel(showsFacts ? "Hide the details of \(trip.city)" : "Show the details of \(trip.city)")            if showsFacts {                TripFacts(trip: trip)                    .font(.subheadline)                    .transition(.move(edge: .top).combined(with: .opacity))            }        }        .padding()        .background(.quaternary, in: RoundedRectangle(cornerRadius: 12))        .padding(.horizontal)    }    private var title: some View {        VStack(alignment: .leading, spacing: 2) {            Text(trip.city)                .font(.headline)            Text(trip.country)                .foregroundStyle(.secondary)        }    }    private var nights: some View {        Label("^[\(trip.nights) night](inflect: true)", systemImage: "moon.stars")    }    private var start: some View {        Text(trip.start, format: .dateTime.day().month().year())    }    private var price: some View {        Text(trip.price, format: .currency(code: "USD"))    }}
struct TripCard: View {
    @Binding var trip: Trip
    @State private var showsFacts = false

    var body: some View {
        VStack(alignment: .leading, spacing: 10) {
            Image(trip.imageName)
                .resizable()
                .scaledToFill()
                .frame(height: 120)
                .clipShape(RoundedRectangle(cornerRadius: 8))
                .contentShape(Rectangle())
                .overlay(alignment: .topTrailing) {
                    FavoriteButton(isFavorite: $trip.isFavorite)
                        .accessibilityLabel("Favorite \(trip.city)")
                        .padding(6)
                        .background(.ultraThinMaterial, in: Circle())
                        .padding(8)
                }
            ViewThatFits(in: .horizontal) {
                HStack(alignment: .firstTextBaseline) {
                    title
                    Spacer()
                    nights
                }
                VStack(alignment: .leading, spacing: 4) {
                    title
                    nights
                }
            }
            ViewThatFits(in: .horizontal) {
                HStack {
                    start
                    Spacer()
                    price
                }
                VStack(alignment: .leading, spacing: 4) {
                    start
                    price
                }
            }
            .font(.subheadline)
            Text(trip.formattedNotes)
                .font(.footnote)
            Button(showsFacts ? "Hide the details" : "Show the details", systemImage: showsFacts ? "chevron.up" : "chevron.down") {
                withAnimation {
                    showsFacts.toggle()
                }
            }
            .font(.footnote.weight(.semibold))
            .accessibilityLabel(showsFacts ? "Hide the details of \(trip.city)" : "Show the details of \(trip.city)")
            if showsFacts {
                TripFacts(trip: trip)
                    .font(.subheadline)
                    .transition(.move(edge: .top).combined(with: .opacity))
            }
        }
        .padding()
        .background(.quaternary, in: RoundedRectangle(cornerRadius: 12))
        .padding(.horizontal)
    }

    private var title: some View {
        VStack(alignment: .leading, spacing: 2) {
            Text(trip.city)
                .font(.headline)
            Text(trip.country)
                .foregroundStyle(.secondary)
        }
    }

    private var nights: some View {
        Label("^[\(trip.nights) night](inflect: true)", systemImage: "moon.stars")
    }

    private var start: some View {
        Text(trip.start, format: .dateTime.day().month().year())
    }

    private var price: some View {
        Text(trip.price, format: .currency(code: "USD"))
    }
}

What you built

Guide: Inside the Attribute Graph — SwiftUI's graph read out of a running app: a view's nodes, how a write reaches body, modifiers as nodes, and a debugger stopped at every step.

That is Part 12. Below the cards and the edge cases is its interview problem. Next, Part 13 begins: dark mode and theming.

The finished project

Stuck, or want to compare with your own? This is the project as it stands at the end of this lesson, built and run before it was put here.

Flash cards

8 cards for this lesson, each labelled with its topic. 6 ask how you would write something; the other 2 are trick questions, written to catch a misunderstanding. Answer in your head first, then tap the card.

Syntax · UpdatesMake a view say, in the console, why its body ran.
let _ = Self._printChanges()
Screenshot of this code running
Screenshot · iPhone 17, iOS 27.0

let _ = Self._printChanges() as the first line of body. At launch it printed "ContentView: @self, @identity, __nights changed." — @self and @identity for a view just made — and after the tap "ContentView: __nights changed.": the state that changed. __nights is the storage that Xcode 27's @State macro makes for nights. For debugging only: take it out before shipping.

Syntax · UpdatesGive each row of a ForEach its item as a value, not a binding, so that one change runs one row.
ForEach(store.trips) { trip in
    Row(trip: trip, store: store)
}
Screenshot of this code running
Screenshot · iPhone 17, iOS 27.0

ForEach(store.trips) { trip in … } — values in, and an action on the store for the change. After the tap only "Seattle's row" printed: the other rows' inputs compared equal, so SwiftUI skipped them. This lesson's tutorial measures the binding version in Trips.

Syntax · ObservationKeep a property in an @Observable model from updating the views that read it.
@ObservationIgnored var asked = 0
Screenshot of this code running
Screenshot · iPhone 17, iOS 27.0

@ObservationIgnored: the property is stored as usual, but Observation does not track it, so changing it updates nobody — here body ran once, at launch, and not for either tap. For bookkeeping, caches and counters that no screen shows.

Syntax · UpdatesShow a running timer without running body every second.
Text(start, style: .timer)
Screenshot of this code running
Screenshot · iPhone 17, iOS 27.0

Text(_:style:) with .timer — or .relative, .offset — counts up on screen by itself. body printed once in four seconds. A counter kept in @State and changed every second would run body every second (an edge case measures both).

Syntax · UpdatesHand a subview only the field it shows, not the whole struct.
CityLabel(city: draft.city)
Screenshot of this code running
Screenshot · iPhone 17, iOS 27.0

A let of exactly what it shows. Changing the nights did not run CityLabel again: its input, the city, was the same. Handed the whole Draft, it would have run for every change to any field.

Syntax · ProfilingProfile a SwiftUI app's updates with Instruments.

Product ▸ Profile (⌘I) builds the app for profiling and opens Instruments; choose the SwiftUI template. Its first lanes — Update Groups, Long View Body Updates, Long Representable Updates, Other Long Updates — show when SwiftUI worked and which updates took too long. Profile on a device: in Xcode 27 the template does not record in the simulator.

Trick question · UpdatesA view conforms to Equatable, but nobody writes .equatable(). Does SwiftUI ever call its ==?
Screenshot of this code running
Screenshot · iPhone 17, iOS 27.0

Yes — from the second tap on, "Row's == asked" printed and Row's body was skipped, just as with .equatable() (lesson 46's edge case). SwiftUI uses a view's own == whenever the view conforms to Equatable — and, as there, the first update after launch ran body without asking.

Trick question · Updates_printChanges() says @self changed. What changed?
Screenshot of this code running
Screenshot · iPhone 17, iOS 27.0

Its own value: the parent made it again with different inputs — here a new nights, a plain let, which is not named. It printed "Shown: @self changed." after the tap, and the same at launch. A name in the list — __nights on the card before — is one of the view's dynamic properties: state, a binding, an environment value.

Edge cases and tricky questions

8 questions of the kind an interviewer uses to find out whether you really know this — the corners, the traps, and the things that changed recently. Each has its source. Wherever the answer is what some code does, that code was compiled and run, and what you see under “Show the answer” is what it really did. Say your answer aloud before you open it.

Lesson 48 · question 1 of 8 · an == that is wrong

A row conforms to Equatable, and its == always says "the same". The parent hands it a number that grows with each tap. After three taps, what does the screen say — and does it matter that the row holds only an Int?

import SwiftUI

/// Only plain values — and an == that says every two rows are the same.
struct Row: View, Equatable {
    let number: Int

    // `nonisolated`: `Equatable` asks for an `==` that any code may call, and comparing two rows needs no actor
    nonisolated static func == (a: Row, b: Row) -> Bool {
        print("Row's == asked")
        return true
    }

    var body: some View {
        let _ = print("Row's body")
        Text("Row \(number)")
    }
}

struct ContentView: View {
    @State private var taps = 0

    var body: some View {
        VStack(spacing: 20) {
            Button("Tap") { taps += 1 }
            Row(number: taps)
        }
    }
}
Show the answer
Screenshot of this code running
Screenshot · iPhone 17, iOS 27.0

"Row 3" — the screen kept up, and the == was never asked. SwiftUI compares a view made only of plain values byte by byte, and ignores its ==:

The console, from launch through three taps

Console
Row's body
Row's body
Row's body
Row's body

Put a String beside the number, and SwiftUI does ask the == — from the second update on — and believes it. The screen stopped at the first change:

import SwiftUI

/// The same, with a String beside the number.
struct Row: View, Equatable {
    let number: Int
    let name: String

    // `nonisolated`: `Equatable` asks for an `==` that any code may call, and comparing two rows needs no actor
    nonisolated static func == (a: Row, b: Row) -> Bool {
        print("Row's == asked")
        return true
    }

    var body: some View {
        let _ = print("Row's body")
        Text("Row \(number), \(name)")
    }
}

struct ContentView: View {
    @State private var taps = 0

    var body: some View {
        VStack(spacing: 20) {
            Button("Tap") { taps += 1 }
            Row(number: taps, name: "Seattle")
        }
    }
}
Screenshot of this code running
Screenshot · iPhone 17, iOS 27.0

Its console

Console
Row's body
Row's body
Row's == asked
Row's == asked

So a custom == is used only where SwiftUI cannot compare the view by itself, and there it is trusted completely. It must say different whenever anything the screen shows has changed — or leave Equatable off the view.

Where this comes from: equatable() · WWDC21: Demystify SwiftUI (2021 — still the reference on identity) — Measured in the iOS 27 simulator; Apple does not document when a view's == is skipped.

Lesson 48 · question 2 of 8 · a body for every frame

Two bars grow from 50 to 300 points in withAnimation(.linear(duration: 1)). One is an ordinary view; the other conforms to Animatable, with its width as animatableData. During that second, how many times does each body run?

import SwiftUI

/// How many times each body has run. `@MainActor`: the bodies run there.
@MainActor var bodies = (bar: 0, plain: 0)

/// A bar whose width SwiftUI sets frame by frame, through `animatableData`.
struct Bar: View, Animatable {
    var width: Double
    // `nonisolated`, as `Animatable` declares it: SwiftUI may set it from any thread while it animates
    nonisolated var animatableData: Double {
        get {
            width
        }
        set {
            width = newValue
        }
    }

    var body: some View {
        let _ = bodies.bar += 1
        Rectangle().fill(.mint).frame(width: width, height: 20)
    }
}

/// An ordinary view with the same change.
struct Plain: View {
    let width: Double

    var body: some View {
        let _ = bodies.plain += 1
        Rectangle().fill(.orange).frame(width: width, height: 20)
    }
}

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

    var body: some View {
        VStack(spacing: 20) {
            Bar(width: wide ? 300 : 50)
            Plain(width: wide ? 300 : 50)
            Button("Grow") {
                bodies = (0, 0)
                withAnimation(.linear(duration: 1)) { wide.toggle() }
                Task {
                    try? await Task.sleep(for: .seconds(2))
                    print("during the animation: Bar's body \(bodies.bar) times, Plain's \(bodies.plain)")
                }
            }
        }
    }
}
Show the answer
Screenshot of this code running
Screenshot · iPhone 17, iOS 27.0

The ordinary bar once; the Animatable one about once a frame: "during the animation: Bar's body 60 times, Plain's 1"

An animation does not normally run body again: SwiftUI works out the start and the end once, and moves between them itself. Animatable is how a view asks to take part — SwiftUI sets animatableData and runs body on every frame — so it is what makes custom shapes and effects possible, and also what makes them expensive. Keep such a body small, and let ordinary views animate the ordinary way.

Where this comes from: Animatable · WWDC23: Explore SwiftUI animation — Measured in the iOS 27 simulator; the count is that run's.

Lesson 48 · question 3 of 8 · one element of an observed array

An @Observable store holds trips and a title. One subview shows only store.trips.count; another shows store.title. A button changes one trip's isFavorite. Which subview's body runs?

import SwiftUI

struct Trip: Identifiable {
    let id: Int
    var city: String
    var isFavorite = false
}

@MainActor
@Observable
final class Store {
    var trips = [Trip(id: 1, city: "Seattle"), Trip(id: 2, city: "Bergen")]
    var title = "My trips"
}

/// Reads only how many trips there are.
struct Count: View {
    let store: Store
    var body: some View {
        let _ = print("Count's body")
        Text("\(store.trips.count) trips")
    }
}

/// Reads only the title.
struct Title: View {
    let store: Store
    var body: some View {
        let _ = print("Title's body")
        Text(store.title)
    }
}

struct ContentView: View {
    @State private var store = Store()

    var body: some View {
        VStack(spacing: 20) {
            Title(store: store)
            Count(store: store)
            Button("Star Seattle") { store.trips[0].isFavorite.toggle() }
        }
    }
}
Show the answer
Screenshot of this code running
Screenshot · iPhone 17, iOS 27.0

The count's — although the count did not change:

The console, from launch through the tap

Console
Title's body
Count's body
Count's body

Observation tracks properties, not what is inside them. Count read trips, and changing any element of the array is a change to trips. Title read only title, so it was left alone. A view that needs only a number derived from a big collection can be given that number instead — or the model can keep it as a property of its own.

Where this comes from: Observable() macro · WWDC23: Discover Observation in SwiftUI — Measured in the iOS 27 simulator.

Lesson 48 · question 4 of 8 · an environment value changes

A screen sets .environment(\.highlight, …), and a button switches it from yellow to mint. Below it, one view reads @Environment(\.highlight); another reads nothing from the environment. Which body runs?

import SwiftUI

extension EnvironmentValues {
    @Entry var highlight = Color.yellow
}

struct Reader: View {
    @Environment(\.highlight) private var highlight
    var body: some View {
        let _ = print("Reader's body")
        Text("I read the highlight").foregroundStyle(highlight)
    }
}

struct NonReader: View {
    var body: some View {
        let _ = print("NonReader's body")
        Text("I do not")
    }
}

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

    var body: some View {
        VStack(spacing: 20) {
            Reader()
            NonReader()
            Button("Switch") { mint.toggle() }
        }
        .environment(\.highlight, mint ? .mint : .yellow)
    }
}
Show the answer
Screenshot of this code running
Screenshot · iPhone 17, iOS 27.0

Only the reader's:

The console, from launch through the switch

Console
Reader's body
NonReader's body
Reader's body

SwiftUI keeps track of which views read which environment values, and runs only those when one changes. So a value put high in the hierarchy — a theme, a store — is not, in itself, a reason for everything below to update; what matters is who reads it.

Where this comes from: Environment · EnvironmentValues — Measured in the iOS 27 simulator.

Lesson 48 · question 5 of 8 · a struct in @State

A screen keeps @State private var draft = Draft(), a struct with city and nights. One subview is handed the whole draft and shows the city; another is handed only draft.city. A button changes nights. Which body runs?

import SwiftUI

struct Draft {
    var city = "Seattle"
    var nights = 3
}

/// Shows only the city, but is handed the whole draft.
struct CityLabel: View {
    let draft: Draft
    var body: some View {
        let _ = print("CityLabel's body")
        Text(draft.city)
    }
}

/// Shows only the city, and is handed only the city.
struct JustCity: View {
    let city: String
    var body: some View {
        let _ = print("JustCity's body")
        Text(city)
    }
}

struct ContentView: View {
    @State private var draft = Draft()

    var body: some View {
        VStack(spacing: 20) {
            CityLabel(draft: draft)
            JustCity(city: draft.city)
            Text("\(draft.nights) nights")
            Button("More nights") { draft.nights += 1 }
        }
    }
}
Show the answer
Screenshot of this code running
Screenshot · iPhone 17, iOS 27.0

The one handed the whole draft:

The console, from launch through the tap

Console
CityLabel's body
JustCity's body
CityLabel's body

A struct is one value: change any field, and every view handed the struct gets a new input. Unlike an @Observable class, nothing tracks which fields a body read. Hand a subview the fields it shows — or keep the data in an @Observable model, where reads are tracked property by property.

Where this comes from: State · WWDC23: Discover Observation in SwiftUI — Measured in the iOS 27 simulator.

Lesson 48 · question 6 of 8 · a range that changes

ForEach(0..<count) { i in Text("Row \(i)") }, and a button adds one to count, which starts at 2. What happens?

import SwiftUI

struct ContentView: View {
    @State private var count = 2

    var body: some View {
        VStack(spacing: 12) {
            ForEach(0..<count) { i in
                Text("Row \(i)")
            }
            Button("Add a row") { count += 1 }
        }
    }
}
Show the answer
Screenshot of this code running
Screenshot · iPhone 17, iOS 27.0

Nothing appears. The screen still has two rows after the tap — and there were two warnings. The compiler's, at build time: "non-constant range: argument must be an integer literal". And SwiftUI's, in the log, when the count changed:

SwiftUI wrote this to the log while the app ran

ForEach<Range<Int>, Int, Text> count (3) != its initial count (2). `ForEach(_:content:)` should only be used for *constant* data. Instead conform data to `Identifiable` or use `ForEach(_:id:content:)` and provide an explicit `id`!

ForEach(_:content:) with a range identifies the rows by their positions, and SwiftUI expects the range never to change. For data that changes, give ForEach identities: ForEach(0..<count, id: \.self), or better, Identifiable items.

Where this comes from: ForEach · WWDC21: Demystify SwiftUI (2021 — still the reference on identity) — Built with Xcode 27 and measured in the iOS 27 simulator.

Lesson 48 · question 7 of 8 · a clock on the screen

Two ways to show seconds ticking by: Text(start, style: .timer), and a number in @State that a task adds one to every second. In four seconds, how many times does each body run?

import SwiftUI

/// The system redraws the time by itself.
struct Styled: View {
    let start: Date
    var body: some View {
        let _ = print("Styled's body")
        Text(start, style: .timer)
    }
}

/// A number in state, changed every second.
struct Counted: View {
    @State private var seconds = 0
    var body: some View {
        let _ = print("Counted's body")
        Text("\(seconds) s")
            .task {
                while !Task.isCancelled {
                    try? await Task.sleep(for: .seconds(1))
                    seconds += 1
                }
            }
    }
}

struct ContentView: View {
    @State private var start = Date.now

    var body: some View {
        VStack(spacing: 20) {
            Styled(start: start)
            Counted()
        }
        .font(.largeTitle)
    }
}
Show the answer
Screenshot of this code running
Screenshot · iPhone 17, iOS 27.0

The styled text once; the counter every second:

The console, from launch until the screenshot

Console
Counted's body
Styled's body
Counted's body
Counted's body
Counted's body
Counted's body
Counted's body
Counted's body

A date style — .timer, .relative, .offset — tells the system to keep the text current itself, with no body at all. For anything richer, TimelineView (lesson 41) runs only its own small closure on a schedule. A @State changed on a timer is the expensive way to make a clock.

Where this comes from: Text.DateStyle · TimelineView — Measured in the iOS 27 simulator.

Lesson 48 · question 8 of 8 · the same value again

A button sets three things to the values they already have: an @Observable model's city = "Seattle", the same model's place = Place(name: "Bergen") — a struct that is not Equatable — and a @State nights = 3. Which views run their body again?

import SwiftUI

@MainActor
@Observable
final class Model {
    var city = "Seattle"
    var place = Place(name: "Bergen")
}

/// Not Equatable.
struct Place {
    var name: String
}

struct FromModel: View {
    let model: Model
    var body: some View {
        let _ = print("FromModel's body")
        Text(model.city)
    }
}

struct FromModelPlace: View {
    let model: Model
    var body: some View {
        let _ = print("FromModelPlace's body")
        Text(model.place.name)
    }
}

struct FromState: View {
    let nights: Int
    var body: some View {
        let _ = print("FromState's body")
        Text("\(nights) nights")
    }
}

struct ContentView: View {
    @State private var model = Model()
    @State private var nights = 3

    var body: some View {
        let _ = print("ContentView's body")
        VStack(spacing: 20) {
            FromModel(model: model)
            FromModelPlace(model: model)
            FromState(nights: nights)
            Button("Set the same values") {
                model.city = "Seattle"
                model.place = Place(name: "Bergen")
                nights = 3
            }
        }
    }
}
Show the answer
Screenshot of this code running
Screenshot · iPhone 17, iOS 27.0

Only the one that shows the place — once per tap:

The console, from launch through two taps

Console
ContentView's body
FromModel's body
FromModelPlace's body
FromState's body
FromModelPlace's body
FromModelPlace's body

The @Observable macro writes, for every property, a check called shouldNotifyObservers (Editor ▸ Expand Macro shows it): for an Equatable value it tells the views only if the new value is different, for an object only if it is a different object, and for anything else always. String is Equatable, so setting the same city changed nothing; Place is not, so the same place counted as a change. @State also ignored the same Int. Making a model's struct Equatable is a one-word way to save updates.

Where this comes from: Observable() macro · WWDC23: Discover Observation in SwiftUI — Measured in the iOS 27 simulator; shouldNotifyObservers is in the macro's expansion with Xcode 27 (Swift 6.4).

Further reading

How would you test it
Interview problem · Part 12

Testing code that depends on today's date

“How would you unit test this? Take me from I can't to a passing test.”

final class CouponService {
    func canUse(code: String, expiry: Date) -> Bool {
        let used = UserDefaults.standard.bool(forKey: "used_\(code)")
        return !used && Date() < expiry
    }
}

Where this comes from: Senior iOS Interview Preparation (GitHub) · Swift Testing

Show the answer

"I can't" is the right first answer. The result depends on today's date and on whatever an earlier run left on disk. There are two hidden dependencies: the clock, and UserDefaults.standard. Turn each into something the test can supply:

struct CouponService {
    var now: () -> Date = Date.init          // seam 1: the clock
    var isUsed: (String) -> Bool             // seam 2: the disk

    func canUse(code: String, expiry: Date) -> Bool {
        !isUsed(code) && now() < expiry
    }
}

Production code passes the real clock and a closure that reads UserDefaults. The test passes a fixed moment and a constant. One parameterized test then covers the whole table — including the boundary, where an expiry equal to now has to count as expired:

@Test(arguments: [
    (used: false, secondsLeft: 60.0, expected: true),
    (used: true,  secondsLeft: 60.0, expected: false),
    (used: false, secondsLeft: -1.0, expected: false),
    (used: false, secondsLeft: 0.0,  expected: false),   // the boundary: equal is expired
])
func coupon(used: Bool, secondsLeft: Double, expected: Bool) {
    let now = Date(timeIntervalSince1970: 1_000_000)
    let service = CouponService(now: { now }, isUsed: { _ in used })
    #expect(service.canUse(code: "X", expiry: now.addingTimeInterval(secondsLeft)) == expected)
}

What interviewers listen for: That you name the hidden dependencies before you refactor, choose the lightest tool that works (a closure, not a hierarchy of protocols), and go looking for the boundary case yourself.