Project 3 · Milestone 3 — the locations list, search, and locationswift-6.4/ios-26
Lesson 4 / 5
Unit 23 · Skycast

Project 3 · Milestone 3 — the locations list, search, and location

Note

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
    }
}
Note

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 .coordinatefails 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.