Lab 7.1 — Weather + Map app

Goal: Build a SwiftUI app that shows the user’s current location on a map, drops annotations for nearby points of interest, and overlays current weather conditions from WeatherKit.

Time: 90–120 minutes.

Prereqs:

  • Xcode 16+, iOS 18+ deployment target.
  • A paid Apple Developer Program account (WeatherKit requires entitlement).
  • Real device for first run (Simulator works for most paths but location coaching is awkward).

Setup

  1. New Xcode project → App → SwiftUI → name WeatherMapLab.
  2. Project → Signing & Capabilities → + Capability → WeatherKit.
  3. Apple Developer portal → Certificates, Identifiers & Profiles → your App ID → enable WeatherKit. Allow ~30 min for propagation if first time.
  4. Info.plist keys:
    • NSLocationWhenInUseUsageDescription = “We show your local weather on the map.”
    • NSLocationTemporaryUsageDescriptionDictionary = { "PreciseForWeather" : "Precise location gives more accurate forecasts." }

Build

LocationManager

Create LocationManager.swift:

import CoreLocation
import Observation

@Observable
@MainActor
final class LocationManager: NSObject, CLLocationManagerDelegate {
    private let manager = CLLocationManager()
    var lastLocation: CLLocation?
    var authorization: CLAuthorizationStatus = .notDetermined

    override init() {
        super.init()
        manager.delegate = self
        manager.desiredAccuracy = kCLLocationAccuracyHundredMeters
        authorization = manager.authorizationStatus
    }

    func requestWhenInUse() { manager.requestWhenInUseAuthorization() }
    func startUpdates() { manager.startUpdatingLocation() }

    nonisolated func locationManagerDidChangeAuthorization(_ m: CLLocationManager) {
        Task { @MainActor in
            authorization = m.authorizationStatus
            if authorization == .authorizedWhenInUse { startUpdates() }
        }
    }

    nonisolated func locationManager(_ m: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
        guard let location = locations.last else { return }
        Task { @MainActor in lastLocation = location }
    }

    nonisolated func locationManager(_ m: CLLocationManager, didFailWithError error: Error) {}
}

WeatherService wrapper

Create WeatherCache.swift:

import WeatherKit
import CoreLocation

actor WeatherCache {
    static let shared = WeatherCache()
    private let service = WeatherService.shared
    private var cache: [String: (Weather, Date)] = [:]
    private let ttl: TimeInterval = 15 * 60

    func currentWeather(at location: CLLocation) async throws -> CurrentWeather {
        let key = bucketKey(for: location)
        if let (cached, ts) = cache[key], Date().timeIntervalSince(ts) < ttl {
            return cached.currentWeather
        }
        let weather = try await service.weather(for: location)
        cache[key] = (weather, .now)
        return weather.currentWeather
    }

    private func bucketKey(for location: CLLocation) -> String {
        let lat = (location.coordinate.latitude * 100).rounded() / 100
        let lon = (location.coordinate.longitude * 100).rounded() / 100
        return "\(lat),\(lon)"
    }
}

Mock POIs

Create POIs.swift:

import CoreLocation
import MapKit

struct POI: Identifiable, Hashable {
    let id = UUID()
    let name: String
    let coordinate: CLLocationCoordinate2D
    let systemImage: String
}

extension POI {
    static func near(_ location: CLLocation) -> [POI] {
        let c = location.coordinate
        let offset = 0.005
        return [
            POI(name: "Coffee", coordinate: .init(latitude: c.latitude + offset, longitude: c.longitude),
                systemImage: "cup.and.saucer.fill"),
            POI(name: "Park",   coordinate: .init(latitude: c.latitude, longitude: c.longitude + offset),
                systemImage: "tree.fill"),
            POI(name: "Pharmacy", coordinate: .init(latitude: c.latitude - offset, longitude: c.longitude + offset),
                systemImage: "cross.case.fill"),
        ]
    }
}

Main view

Replace ContentView.swift:

import SwiftUI
import MapKit
import WeatherKit

struct ContentView: View {
    @State private var location = LocationManager()
    @State private var cameraPosition: MapCameraPosition = .automatic
    @State private var currentWeather: CurrentWeather?
    @State private var pois: [POI] = []
    @State private var weatherError: String?

    var body: some View {
        ZStack(alignment: .top) {
            Map(position: $cameraPosition) {
                UserAnnotation()
                ForEach(pois) { poi in
                    Annotation(poi.name, coordinate: poi.coordinate) {
                        Image(systemName: poi.systemImage)
                            .padding(8)
                            .background(.thinMaterial, in: .circle)
                    }
                }
            }
            .mapStyle(.standard)
            .mapControls {
                MapUserLocationButton()
                MapCompass()
            }
            .ignoresSafeArea()
            .task {
                location.requestWhenInUse()
            }
            .onChange(of: location.lastLocation) { _, newValue in
                guard let newValue else { return }
                pois = POI.near(newValue)
                cameraPosition = .region(.init(
                    center: newValue.coordinate,
                    latitudinalMeters: 1500, longitudinalMeters: 1500
                ))
                Task {
                    do {
                        currentWeather = try await WeatherCache.shared.currentWeather(at: newValue)
                    } catch {
                        weatherError = error.localizedDescription
                    }
                }
            }

            if let weather = currentWeather {
                WeatherBadge(weather: weather)
                    .padding()
            } else if let err = weatherError {
                Text("Weather: \(err)")
                    .padding(8)
                    .background(.regularMaterial, in: .capsule)
                    .padding()
            }
        }
        .safeAreaInset(edge: .bottom) {
            HStack {
                Link("Weather", destination: WeatherAttribution.legalPageURL)
                    .font(.caption2)
                Spacer()
                Link("Apple Weather", destination: URL(string: "https://weatherkit.apple.com/legal-attribution.html")!)
                    .font(.caption2)
            }
            .padding(.horizontal)
        }
    }
}

struct WeatherBadge: View {
    let weather: CurrentWeather

    var body: some View {
        HStack(spacing: 12) {
            Image(systemName: weather.symbolName)
                .font(.title2)
            VStack(alignment: .leading, spacing: 2) {
                Text(weather.temperature.formatted())
                    .font(.headline)
                Text(weather.condition.description)
                    .font(.caption)
                    .foregroundStyle(.secondary)
            }
        }
        .padding(.horizontal, 14)
        .padding(.vertical, 10)
        .background(.regularMaterial, in: .capsule)
    }
}

#Preview { ContentView() }

Run

Build to a real device. Tap Allow While Using App at the prompt. Within seconds you should see your map centered on your location, three annotated POIs around you, and a weather capsule with the current condition + temperature.

Stretch

  1. Hourly forecast strip at the bottom: fetch weather.hourlyForecast and render with ScrollView(.horizontal) showing the next 12 hours.
  2. Weather-based POI filtering: if weather.condition is .rain, hide outdoor-only POIs.
  3. Map style toggle: a Picker switching between .standard, .imagery, .hybrid.
  4. Severe weather alerts: fetch .alerts from WeatherKit and present a red banner when present.
  5. AR placement: tapping a POI presents a RealityView (Chapter 7.7) with a 3D weather icon hovering at the user’s eye height.

Notes

  • WeatherKit’s first call on a fresh device often takes 2–4 seconds. Build a loading state.
  • WeatherKit’s free tier is 500K calls/month per app — the bucketed cache above keeps you well under that for typical traffic.
  • The attribution links are mandatory by WeatherKit’s terms. Missing attribution gets your app rejected (and could lose your WeatherKit entitlement).
  • If you hit “Forecast unavailable for this location,” WeatherKit lacks coverage there (very rare, mostly Arctic / Antarctic).

Next: Lab 7.2 — Widget extension