Project 3 · Milestone 3 — the locations list, search, and location
Unit 23 · Project 3 · Milestone 3. You build the locations list (saved city cards), a city search that adds a location, value-based navigation to the detail, and a CoreLocation wrapper for the current location. By the end Skycast is complete.
Milestone 2 built one city's detail. Now you need the surface in front of it — a list of saved cities and a search — plus the device's location for "here."
Step 1 — the city and the list
A City is a plain value; the list navigates to the detail by pushing a City value:
struct City: Identifiable, Hashable { let id: String let name: String let region: String let latitude: Double let longitude: Double } struct LocationsView: View { @State private var saved: [City] @State private var search = "" private var query: String { search.trimmingCharacters(in: .whitespaces) } var body: some View { List { if query.isEmpty { ForEach(saved) { city in ZStack { CityCard(city: city) NavigationLink(value: city) { EmptyView() }.opacity(0) } .listRowSeparator(.hidden) } .onDelete { saved.remove(atOffsets: $0) } } else { Section("Search Results") { searchResults } } } .listStyle(.plain) .navigationTitle("Weather") .searchable(text: $search, placement: .navigationBarDrawer(displayMode: .always), prompt: "Search for a city") } }
The ZStack with a hidden, full-bleed NavigationLink behind the card is the trick for making a
custom-styled row tappable as a link — the card draws, the link handles the push. City is
Hashable because it goes in the navigation path.
Step 2 — city search
When the query is non-empty, show matches (from a directory, or a geocoder in a real build) with
an add button that appends to saved:
@ViewBuilder private var searchResults: some View { ForEach(directory.filter { $0.name.localizedCaseInsensitiveContains(query) && !saved.contains($0) }) { city in Button { if !saved.contains(city) { saved.append(city) } search = "" } label: { HStack { VStack(alignment: .leading) { Text(city.name).foregroundStyle(.primary) Text(city.region).font(.caption).foregroundStyle(.secondary) } Spacer() Image(systemName: "plus.circle.fill").foregroundStyle(.blue) } } } }
Step 3 — wire navigation
The root holds the stack and maps a City to its detail. Each detail owns its own view model, so
opening a city fetches that city's forecast:
NavigationStack(path: $path) { LocationsView() .navigationDestination(for: City.self) { city in WeatherView(city: city) } }
Step 4 — the current location, Swift 6-safe
Wrap CLLocationManager in an @Observable model. The delegate methods are nonisolated
(CoreLocation calls them off the main actor), so they must not capture the non-Sendable
manager into a main-actor task — read the Sendable value first, then hop:
import CoreLocation @MainActor @Observable final class LocationProvider: NSObject, CLLocationManagerDelegate { private let manager = CLLocationManager() private(set) var authorization: CLAuthorizationStatus private(set) var coordinate: CLLocationCoordinate2D? override init() { authorization = manager.authorizationStatus super.init() manager.delegate = self } func request() { manager.requestWhenInUseAuthorization() } func requestLocation() { manager.requestLocation() } nonisolated func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) { let status = manager.authorizationStatus // Sendable value, read off-actor Task { @MainActor in authorization = status if status == .authorizedWhenInUse { requestLocation() } } } nonisolated func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) { guard let coordinate = locations.first?.coordinate else { return } // Sendable; extract first Task { @MainActor in self.coordinate = coordinate } } nonisolated func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) { // keep coordinate nil; the screen falls back to a saved city } }
This is a data-race trap Swift 6 catches at compile time. Capturing manager (a non-Sendable
CLLocationManager) inside Task { @MainActor in … } — or capturing the CLLocation object
instead of its Sendable .coordinate — fails to build. Read the Sendable value on the
delegate's thread, then send only that across the actor hop. Don't forget the
NSLocationWhenInUseUsageDescription key in the Info.plist, or the prompt never appears.
Step 5 — permission states
Gate the "here" entry on authorization: notDetermined shows a "requesting location" spinner,
denied/restricted shows a ContentUnavailableView pointing at Settings, and
authorizedWhenInUse drives a fetch for the current coordinate. These are the same first-class
states as loading and error — a permission the user hasn't granted is a state, not a crash.
Checkpoint
The finished app should:
- List saved cities as cards and open each to its full detail.
- Search for a city and add it to the list.
- Ask for location permission, use the current coordinate when granted, and degrade gracefully when denied.
- Read correctly in light and dark, and be navigable by VoiceOver.
Stretch goals
- Persist the saved-locations list with a one-model SwiftData store (you built the skill in Project 2).
- Add a condition background that tints the detail by weather code and time of day.
- Cache the last successful forecast so a cold launch shows something before the network returns.
Knowledge check
Q: Why can't locationManagerDidChangeAuthorization just set authorization directly?
It's a nonisolated delegate method called off the main actor, but authorization is main-actor
state. It reads the Sendable status on the delegate's thread, then hops to the main actor to
assign it — and it must not carry the non-Sendable manager across that hop, which Swift 6
rejects.
Q: Why is a denied location a "state" and not an error? Because it's an expected, recoverable condition the UI must represent — a prompt to enable location in Settings — not a failure of the request. Treating it as a first-class state (like loading) is what keeps the app usable when permission isn't granted.