What You'll Build
A screen with a city text field. Submitting fetches current weather from a public API, showing a spinner while loading and either the result or an error message.
Prerequisites
- Xcode with a basic SwiftUI "App" template project
- A free API key from OpenWeatherMap
- Read Networking with URLSession and Codable & JSON Parsing first if these are new to you
Step by Step
1 Model the API response with Codable
// WeatherResponse.swift
struct WeatherResponse: Decodable {
let name: String
let main: Main
let weather: [WeatherDesc]
struct Main: Decodable {
let temp: Double
let feels_like: Double
let humidity: Int
}
struct WeatherDesc: Decodable {
let description: String
}
}
2 Write the networking function
// WeatherService.swift
enum WeatherError: Error { case badResponse, badURL }
struct WeatherService {
static let apiKey = "YOUR_API_KEY"
static func fetch(city: String) async throws -> WeatherResponse {
guard var components = URLComponents(string: "https://api.openweathermap.org/data/2.5/weather") else {
throw WeatherError.badURL
}
components.queryItems = [
URLQueryItem(name: "q", value: city),
URLQueryItem(name: "appid", value: apiKey),
URLQueryItem(name: "units", value: "metric")
]
guard let url = components.url else { throw WeatherError.badURL }
let (data, response) = try await URLSession.shared.data(from: url)
guard let http = response as? HTTPURLResponse, http.statusCode == 200 else {
throw WeatherError.badResponse
}
return try JSONDecoder().decode(WeatherResponse.self, from: data)
}
}
Never hardcode a real API key in source you commit — read it from an .xcconfig file excluded from version control in a production app.
3 Model UI state explicitly
// WeatherUiState.swift
enum WeatherUiState {
case idle
case loading
case success(WeatherResponse)
case failure(String)
}
4 Build the view model
// WeatherViewModel.swift
@MainActor
class WeatherViewModel: ObservableObject {
@Published var state: WeatherUiState = .idle
func search(city: String) {
guard !city.isEmpty else { return }
state = .loading
Task {
do {
let result = try await WeatherService.fetch(city: city)
state = .success(result)
} catch {
state = .failure("Couldn't load weather — check the city name and your connection")
}
}
}
}
@MainActor guarantees state is only ever mutated on the main thread, which SwiftUI requires for triggering view updates safely.
5 Build the SwiftUI screen
// WeatherView.swift
struct WeatherView: View {
@StateObject private var viewModel = WeatherViewModel()
@State private var city = ""
var body: some View {
VStack(spacing: 20) {
HStack {
TextField("City name", text: $city)
.textFieldStyle(.roundedBorder)
Button("Search") { viewModel.search(city: city) }
}
switch viewModel.state {
case .idle:
Text("Enter a city to see the weather.")
case .loading:
ProgressView()
case .failure(let message):
Text("⚠️ \(message)").foregroundColor(.red)
case .success(let data):
VStack(alignment: .leading, spacing: 6) {
Text(data.name).font(.title2).bold()
Text("\(data.main.temp, specifier: "%.1f")°C — feels like \(data.main.feels_like, specifier: "%.1f")°C")
Text(data.weather.first?.description ?? "")
Text("Humidity: \(data.main.humidity)%")
}
}
}
.padding()
}
}
The exhaustive switch over the enum means Swift's compiler forces every case to be handled — add a new state later and the build fails until you handle it in the view too.
6 Run it
Set WeatherView() as your root view in the App struct, run, type a city, and tap Search. You should see the spinner briefly, then either weather details or an error — try an invalid city name to confirm the error path renders correctly.
Final Working Code
The five files above — WeatherResponse.swift, WeatherService.swift, WeatherUiState.swift, WeatherViewModel.swift, and WeatherView.swift — form the complete app exactly as written; nothing was trimmed for the walkthrough.