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
- New Xcode project → App → SwiftUI → name
WeatherMapLab. - Project → Signing & Capabilities → + Capability → WeatherKit.
- Apple Developer portal → Certificates, Identifiers & Profiles → your App ID → enable WeatherKit. Allow ~30 min for propagation if first time.
Info.plistkeys: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
- Hourly forecast strip at the bottom: fetch
weather.hourlyForecastand render withScrollView(.horizontal)showing the next 12 hours. - Weather-based POI filtering: if
weather.conditionis.rain, hide outdoor-only POIs. - Map style toggle: a
Pickerswitching between.standard,.imagery,.hybrid. - Severe weather alerts: fetch
.alertsfrom WeatherKit and present a red banner when present. - 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).