In Homework 9, we learned how to call an API and display data in SwiftUI. Now we’re going to do it again — but this time we’ll write the whole thing ourselves, from scratch. The app is simple: it fetches the current temperature in Athens, GA from the Open-Meteo API and displays it on screen.
Here’s the finished file we ended up with. Let’s walk through it piece by piece — not just what each part does, but why it’s in the order it’s in.
The Big Picture: MVVM
Before we dive in, it helps to know that this file follows a pattern called MVVM — Model, View, ViewModel. Almost every modern SwiftUI app uses it. Here’s what those three things mean:
- Model — your data. Just raw structs that describe the shape of the information you’re working with.
- ViewModel — the logic layer. It fetches data, transforms it, and exposes it to the View. It doesn’t know anything about how the UI looks.
- View — the UI. It just displays what the ViewModel gives it. It doesn’t fetch or transform anything.
This separation keeps each piece focused on one job. And it determines the order our file is written in: Model → ViewModel → View. Each layer depends on the one below it, so the lower layers have to be defined first. Swift reads your file top to bottom, and you can’t reference something before it’s been defined.
Step 1: Imports
import SwiftUI
import Combine
Every Swift file starts by importing the frameworks it needs. SwiftUI gives us everything for building the UI — View, Text, VStack, NavigationView, and so on. Combine is Apple’s reactive programming framework; it gives us ObservableObject and @Published, which we’ll use in the ViewModel to automatically notify the UI when data changes.
Step 2: The Data Models
Next come the structs that describe our data. Before writing a single line of networking code, we need to know what shape the data coming back from the API is in.
Here’s what the Open-Meteo response actually looks like as JSON:
{
"current": {
"temperature_2m": 68.4
}
}
Notice that it’s nested — there’s an outer object with a key called current, and inside that is the actual temperature. That means we need two structs: one for the inner object, and one for the outer wrapper.
struct CurrentWeather: Codable {
let temperature_2m: Double
}
struct CurrentWeatherResponse: Codable {
let current: CurrentWeather
}
CurrentWeather maps to that inner object — it has one property, temperature_2m, which is a Double (a decimal number). The property name matches the JSON key exactly. That matters: Swift’s JSONDecoder matches property names to JSON keys by name, so they have to line up.
CurrentWeatherResponse maps to the outer wrapper. It has one property, current, of type CurrentWeather — the struct we just defined. Notice that CurrentWeather is used inside CurrentWeatherResponse. That’s exactly why CurrentWeather had to be defined first.
Both structs are marked Codable. That’s a Swift protocol that gives the struct the ability to be decoded from (and encoded to) formats like JSON — for free, with no extra code, as long as your property names match the JSON keys.
Step 3: The ViewModel
Now that we know what shape our data is, we can write the code that goes and gets it.
class WeatherViewModel: ObservableObject {
@Published var current: CurrentWeather?
func fetchWeather() {
guard let url = URL(string: "https://api.open-meteo.com/v1/forecast?latitude=33.95&longitude=-83.37¤t=temperature_2m&temperature_unit=fahrenheit") else { return }
URLSession.shared.dataTask(with: url) { data, response, error in
if let error = error { print("Error: \(error)"); return }
guard let data = data else { return }
do {
let decodedResponse = try JSONDecoder().decode(CurrentWeatherResponse.self, from: data)
DispatchQueue.main.async { self.current = decodedResponse.current }
} catch { print("Error decoding: \(error)") }
}.resume()
}
}
A few things to unpack here:
Why class instead of struct? The ViewModel needs to be a class because it conforms to ObservableObject, which requires reference type semantics. In Swift, structs are value types (copied when passed around) and classes are reference types (shared). SwiftUI needs to hold a single shared reference to the ViewModel so it can observe changes to it.
@Published var current: CurrentWeather? — The @Published property wrapper is the magic that connects the ViewModel to the View. Any time current changes, SwiftUI automatically knows to re-render any View that’s watching it. The ? makes it optional — it starts as nil (no data yet), and gets set once the fetch completes.
guard let url = ... — URL(string:) is failable — it returns an optional because not every string is a valid URL. The guard let safely unwraps it: if the URL is valid, we continue; if not, we return early. It’s a clean way to handle failure cases without deep nesting.
URLSession.shared.dataTask — This is the standard iOS way to make a network request. URLSession.shared is a built-in shared networking session. dataTask(with:) creates a task that fetches data from the URL. The trailing closure receives three things when it finishes: the raw data, the HTTP response metadata, and any error. Critically, this runs on a background thread — it does not block the UI while waiting for the network.
JSONDecoder().decode(CurrentWeatherResponse.self, from: data) — This is where our Codable structs earn their keep. We tell the decoder what type to expect (CurrentWeatherResponse.self) and hand it the raw data. It automatically maps JSON keys to struct properties and gives us back a fully populated CurrentWeatherResponse. The try means it can throw an error if the JSON doesn’t match — that’s caught by the surrounding do/catch.
DispatchQueue.main.async { self.current = ... } — Remember how dataTask runs on a background thread? Here’s the catch: UI updates in SwiftUI must happen on the main thread. If you try to update a @Published property from a background thread, you’ll get a runtime warning (or a crash). DispatchQueue.main.async jumps back to the main thread before making the update. This one line is what keeps our app safe.
Finally, .resume() — data tasks don’t start automatically. You have to call .resume() to kick them off. It’s easy to forget, and if you do, nothing will ever happen.
Step 4: The View
With the data model and the fetching logic in place, we can finally build the UI.
struct ContentView: View {
@StateObject var viewModel = WeatherViewModel()
var body: some View {
NavigationView {
VStack {
if let temp = viewModel.current?.temperature_2m {
Text("\(temp, specifier: "%.1f") °F")
.font(.system(size: 48, weight: .semibold, design: .rounded))
.monospacedDigit()
} else {
ProgressView("Loading current temperature…")
}
}
.onAppear { viewModel.fetchWeather() }
.navigationTitle("In Athens, GA, it is currently the following temperature:")
}
}
}
@StateObject var viewModel = WeatherViewModel() — @StateObject tells SwiftUI to create this object once and keep it alive for the lifetime of the view. It also subscribes to the ViewModel’s @Published properties so the view re-renders whenever they change. (You might also see @ObservedObject elsewhere — the difference is that @StateObject owns the object and manages its lifecycle, while @ObservedObject receives one that was created and owned somewhere else.)
if let temp = viewModel.current?.temperature_2m — This is optional binding. viewModel.current is nil at first (we haven’t fetched anything yet), so we can’t just write viewModel.current.temperature_2m — that would crash. The if let checks whether the value exists: if it does, it unwraps it into temp and runs the first branch; if it doesn’t, it falls through to the else.
Text("\(temp, specifier: "%.1f") °F") — Swift’s string interpolation lets you embed a format specifier directly. %.1f means “one decimal place,” so 68.4321 displays as “68.4”. .monospacedDigit() makes the digits fixed-width so the number doesn’t shift layout as it updates.
ProgressView("Loading current temperature…") — This is what shows while we’re waiting for data. SwiftUI’s built-in spinner with a label. It gives users immediate feedback that something is happening, rather than a blank screen.
.onAppear { viewModel.fetchWeather() } — This modifier fires as soon as the view appears on screen. It’s how we kick off the fetch: the moment ContentView loads, it tells the ViewModel to go get the weather. We do it here rather than in an initializer because .onAppear is tied to the view lifecycle — if you navigate away and come back, it’ll fetch fresh data automatically.
Step 5: The Preview
#Preview { ContentView() }
This one line is all Xcode needs to render a live preview of your app in the canvas. It just instantiates ContentView — nothing fancy. You’ll notice the preview shows the ProgressView spinner, since it won’t actually make a network call in the preview canvas.

Why Is It In This Order?
Here’s the key insight: each layer of the file depends on the one defined before it. The View uses the ViewModel. The ViewModel uses the data model structs. The structs use Swift’s built-in types.
Swift compiles files top to bottom. If you tried to put ContentView at the top, Swift would hit @StateObject var viewModel = WeatherViewModel() and complain that it doesn’t know what WeatherViewModel is yet. Same thing if you put WeatherViewModel before the structs — it references CurrentWeather and CurrentWeatherResponse, which wouldn’t exist yet.
So the order follows the dependency chain: define the things that are depended on before you define the things that depend on them. That’s true in most programming languages, but in SwiftUI it maps neatly onto MVVM — which is part of why the pattern feels so natural here.
Your Turn
Try making a few changes to make sure you understand how the pieces connect:
- Download the starter file to follow along: WeatherApp-Starter.zip
- If you’d like, you can also download the completed file (with bonus additional formatting and relative humidity call!): WeatherApp-Completed.zip
- Change the
navigationTitleto something shorter and snappier. - The Open-Meteo API also supports
wind_speed_10mas a current variable. Try adding it to the URL and displaying it alongside the temperature. You’ll need to add a property toCurrentWeather, update the URL string, and add a secondTextview. - What happens if you remove the
.resume()call? What does the app do? Why?