Milestone 2: detail screen and load-more pagination
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.
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.
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) } }
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.