Project 3 · Milestone 2 — the view model, load states, and the detail
Unit 23 · Project 3 · Milestone 2. You build the view model with a LoadState enum and the full
city-detail screen: current header, hourly strip, a Swift Charts temperature graph, the 10-day
forecast, and the detail modules — with loading and error rendered on purpose.
Network data has four lives: nothing yet, loading, loaded, failed. Model that as an enum so the view can't forget a case — the compiler makes you handle each.
Step 1 — the load state and view model
import Observation enum LoadState { case idle case loading case loaded(Forecast) case failed(String) } @MainActor @Observable final class ForecastViewModel { private(set) var state: LoadState = .idle private let client: APIClient init(client: APIClient = APIClient()) { self.client = client } func load(latitude: Double, longitude: Double) async { state = .loading do { let forecast = try await client.forecast(latitude: latitude, longitude: longitude) state = .loaded(forecast) } catch { state = .failed(message(for: error)) } } private func message(for error: Error) -> String { switch error { case APIError.transport: return "No internet connection. Check your network and try again." case APIError.invalidResponse: return "The weather service is unavailable right now." case APIError.decoding: return "Received unexpected data. Please try again." default: return "Something went wrong." } } }
@MainActor puts every mutation of state on the main actor — the view reads it safely. The
load method hops to the network inside client.forecast, then comes back to set state. And
message(for:) turns your typed APIError into a specific human sentence per failure.
Step 2 — the detail screen skeleton
The whole screen is one switch over state. Loading and error are real views, not
afterthoughts; the success case composes the detail sections:
struct WeatherView: View { let city: City @State private var model = ForecastViewModel() var body: some View { ScrollView { switch model.state { case .idle, .loading: ProgressView("Loading forecast…").padding(.top, 120) case .failed(let message): ContentUnavailableView { Label("Couldn't load weather", systemImage: "cloud.slash") } description: { Text(message) } actions: { Button("Retry") { Task { await model.load(latitude: city.latitude, longitude: city.longitude) } } } .padding(.top, 80) case .loaded(let forecast): content(forecast) } } .navigationTitle(city.name) .navigationBarTitleDisplayMode(.inline) .task { await model.load(latitude: city.latitude, longitude: city.longitude) } } private func content(_ forecast: Forecast) -> some View { VStack(spacing: 16) { CurrentHeader(city: city, current: forecast.current, daily: forecast.daily) HourlyStrip(hourly: forecast.hourly) TemperatureGraph(hourly: forecast.hourly) DailyList(daily: forecast.daily) DetailTiles(current: forecast.current, daily: forecast.daily) } .padding() } }
Step 3 — the header and hourly strip
The header is the big number; the strip zips the hourly arrays into a horizontal scroller, an icon and a temperature per hour:
struct CurrentHeader: View { let city: City let current: Forecast.Current let daily: Forecast.Daily var body: some View { VStack(spacing: 2) { Text(city.name).font(.largeTitle) Text("\(Int(current.temperature.rounded()))°").font(.system(size: 84, weight: .thin)) Text(WeatherCode.label(current.weatherCode)).font(.headline).foregroundStyle(.secondary) if let hi = daily.tempMax.first, let lo = daily.tempMin.first { Text("H:\(Int(hi))° L:\(Int(lo))°").font(.subheadline).foregroundStyle(.secondary) } } .padding(.vertical, 8) } } struct HourlyStrip: View { let hourly: Forecast.Hourly var body: some View { VStack(alignment: .leading, spacing: 10) { Label("Hourly Forecast", systemImage: "clock").font(.caption).foregroundStyle(.secondary) Divider() ScrollView(.horizontal, showsIndicators: false) { HStack(spacing: 22) { ForEach(0..<min(12, hourly.time.count), id: \.self) { i in VStack(spacing: 8) { Text(i == 0 ? "Now" : hourly.time[i].formatted(.dateTime.hour())) .font(.caption).foregroundStyle(.secondary) Image(systemName: WeatherCode.symbol(hourly.weatherCode[i])) .symbolRenderingMode(.multicolor).font(.title3) Text("\(Int(hourly.temperature[i].rounded()))°").font(.callout.bold()) } } } } } .padding() .background(.quaternary.opacity(0.3), in: RoundedRectangle(cornerRadius: 16)) } }
Step 4 — the temperature graph with Swift Charts
Plot the next 24 hours as a smoothed line over a translucent area — the one place a real gradient is warranted (a chart fill), not decoration:
import Charts struct TemperatureGraph: View { let hourly: Forecast.Hourly var body: some View { VStack(alignment: .leading, spacing: 10) { Label("Temperature", systemImage: "chart.xyaxis.line").font(.caption).foregroundStyle(.secondary) Chart(0..<min(24, hourly.time.count), id: \.self) { i in LineMark(x: .value("Time", hourly.time[i]), y: .value("Temp", hourly.temperature[i])) .interpolationMethod(.catmullRom) AreaMark(x: .value("Time", hourly.time[i]), y: .value("Temp", hourly.temperature[i])) .interpolationMethod(.catmullRom) .foregroundStyle(.linearGradient(colors: [.orange.opacity(0.3), .clear], startPoint: .top, endPoint: .bottom)) } .chartXAxis { AxisMarks(values: .stride(by: .hour, count: 6)) } .frame(height: 150) } .padding() .background(.quaternary.opacity(0.3), in: RoundedRectangle(cornerRadius: 16)) } }
Step 5 — the 10-day forecast and detail modules
The daily list is a row per day with a high/low range bar — a custom GeometryReader
dataviz that positions each day's span inside the week's overall range. The detail modules are a
2-column grid of labelled tiles (feels-like, humidity, wind, UV, sunrise, sunset). Both read
straight off the decoded daily/current; build them as DailyList and DetailTiles, matched
to the finished screenshots in the spec.
The range bar is the interesting bit: normalize each day's (min, max) against the week's
overall (min, max) to get two x-offsets, then draw a capsule between them. It's the same idea
as a chart, hand-rolled — good practice for when Swift Charts isn't the right shape.
Checkpoint
Pointed at a city, the detail screen should:
- Show a proper loading spinner, then the forecast.
- Render the header, the hourly strip with icons, the temperature graph, the 10-day list, and the modules.
- Show a retryable error on airplane mode (throttle the sim's network or fail the request).
Knowledge check
Q: Why is state a single enum instead of separate isLoading, error, and forecast
properties?
Because those separate flags allow impossible combinations (loading and an error and a stale
forecast). One enum makes the states mutually exclusive, and the switch forces the view to
handle every case — you can't render a spinner over a failure by accident.
Q: Where does the AreaMark's gradient sit relative to the design contract? It's the sanctioned exception: a chart's area-fill is data, not decoration. The rest of the app stays flat; the gradient earns its place by encoding the temperature magnitude.