Milestone 2: detail screen and load-more paginationswift-6.4/ios-26
Lesson 3 / 6
Unit 24 · Reeler

Milestone 2: detail screen and load-more pagination

Note

Unit 24 · Project 4, Milestone 2. Building on Milestone 1's search screen, this milestone adds a detail screen reached by value-based navigation, and grows the results list with load-more pagination. Milestone 3 adds caching and offline favorites.

Two patterns carry every list-detail app: tapping a row opens a detail screen, and scrolling to the bottom loads more. Modern SwiftUI does both without the old ceremony — navigation is driven by values, not by handing the child a reference, and pagination is a bounded async append, not an infinite-scroll library.

Value-based navigation

You already have Film: Hashable. That's the whole prerequisite. Make each row a NavigationLink(value:), then declare one navigationDestination(for:) that maps a Film value to its detail screen.

NavigationStack {
    List {
        // ... loaded case ...
        ForEach(films) { film in
            NavigationLink(value: film) {
                FilmRow(film: film)
            }
        }
    }
    .navigationDestination(for: Film.self) { film in
        FilmDetailScreen(film: film)
    }
}

The link carries a value (film), and the destination builder receives that value when the push happens. The list doesn't hold a reference to the detail screen, and the detail screen doesn't reach back into the list. They communicate through an immutable Film — value semantics all the way down.

Coming from older Swift

The UIKit way was pushViewController(DetailVC(film:), animated: true) — you constructed the destination and held a reference to it. Early SwiftUI used NavigationLink(destination:), which built every destination eagerly whether or not it was tapped. Value-based navigation (NavigationLink(value:) + navigationDestination(for:)) builds the destination lazily on push and decouples the two screens — the modern default since navigation was overhauled.

Tip

Why Hashable, not a binding. navigationDestination(for:) keys destinations by type, and NavigationStack stores the pushed values in its path. That path is just an array of Hashable values, which is why the destination type must be Hashable. It also means you can drive navigation programmatically by appending to a NavigationPath — a deep link becomes "push these values."

The detail screen

The detail screen starts as a plain view of an immutable Film — poster, title, and the metadata (director, genres, runtime), plus the overview. No view model yet: no async work, no logic, just presentation. (Milestone 3 adds the rating and the log actions, which are the one piece of persisted state it owns.)

struct FilmDetailScreen: View {
    let film: Film

    var body: some View {
        ScrollView {
            VStack(alignment: .leading, spacing: 16) {
                HStack(alignment: .top, spacing: 14) {
                    PosterImage(path: film.posterPath).frame(width: 120)   // built in Milestone 3
                    VStack(alignment: .leading, spacing: 6) {
                        Text(film.title).font(.title2.bold())
                        Text("\(String(film.year)) · \(film.runtime) min").foregroundStyle(.secondary)
                        Text("Directed by \(film.director)").font(.subheadline).foregroundStyle(.secondary)
                        Text(film.genres.joined(separator: " · ")).font(.caption).foregroundStyle(.secondary)
                    }
                    Spacer()
                }
                Text(film.overview)
            }
            .padding()
        }
        .navigationTitle(film.title)
        .navigationBarTitleDisplayMode(.inline)
    }
}

This is the MV judgment from Unit 8 in practice: a screen with no async work and no testable logic doesn't get a view model. A let film is enough — until Milestone 3 gives it your rating and review to persist.

Load-more pagination

Search returns pages. Rather than load everything, append the next page when the user reaches the last row. Extend the view model to track the current page and whether a fetch is already in flight, so scrolling doesn't fire five overlapping requests.

@Observable
@MainActor
final class SearchViewModel {
    var query = ""
    private(set) var films: [Film] = []
    private(set) var isLoadingPage = false
    private var page = 1
    private var canLoadMore = true

    private let client: FilmAPIClient
    init(client: FilmAPIClient) { self.client = client }

    func search() async {
        let trimmed = query.trimmingCharacters(in: .whitespaces)
        guard !trimmed.isEmpty else { films = []; return }
        page = 1
        canLoadMore = true
        films = []
        await loadNextPage()
    }

    func loadMoreIfNeeded(currentItem film: Film) async {
        guard film.id == films.last?.id else { return }   // only at the last row
        await loadNextPage()
    }

    private func loadNextPage() async {
        guard canLoadMore, !isLoadingPage else { return }  // the paging guard
        isLoadingPage = true
        defer { isLoadingPage = false }
        do {
            let next = try await client.search(query, page: page)
            if next.isEmpty { canLoadMore = false; return }
            films.append(contentsOf: next)
            page += 1
        } catch is CancellationError {
            // ignore
        } catch {
            canLoadMore = false
        }
    }
}

The guard canLoadMore, !isLoadingPage line is the whole trick. Without it, a fast scroll fires loadNextPage repeatedly before the first response lands, each incrementing page and appending duplicates. Because the view model is @MainActor, reading and setting isLoadingPage is atomic with respect to the UI — no lock needed, the main-actor isolation is the mutual exclusion.

Wire it from the list by triggering when a row appears:

ForEach(model.films) { film in
    NavigationLink(value: film) { FilmRow(film: film) }
        .task { await model.loadMoreIfNeeded(currentItem: film) }
}
Note

The double-fetch bug. The most common pagination defect is missing the in-flight guard: the list mounts several rows near the bottom at once, each .task calls loadNextPage, and you load page 2 three times. guard !isLoadingPage collapses those concurrent calls into one. If a /swift-review of AI-proposed pagination code is missing this guard, reject it.

Checkpoint

Your app should now: push a detail screen by tapping a result (value-based), show the film's poster placeholder, title, year, and overview, and append the next page of results as you scroll — with no duplicate rows and no overlapping requests.

Stretch: add a footer ProgressView that shows only while isLoadingPage is true, and hide it once canLoadMore is false.

Knowledge check

Q: Why must the navigation destination type be Hashable? NavigationStack stores the pushed values in its path (an array of Hashable values), and navigationDestination(for:) keys destinations by type. Passing a Hashable value keeps the two screens decoupled — the list never holds a reference to the detail screen.

Q: What does the guard canLoadMore, !isLoadingPage line prevent? Overlapping page loads. A fast scroll can trigger loadNextPage several times before the first response returns; the guard collapses them into a single in-flight request, preventing duplicate rows and a runaway page counter.