Build a SwiftUI Weather App — API Integration and Data Visualization
In this tutorial, you will learn about Build a SwiftUI Weather App. We cover key concepts, practical examples, and best practices to help you master this topic.
Build a complete Weather app in SwiftUI that fetches live weather data from a free API, displays current conditions and 5-day forecast, handles loading states gracefully, and caches responses for offline use.
What You'll Build
- Current weather display with temperature, humidity, wind
- 5-day forecast with daily highs and lows
- City search using geocoding API
- AsyncImage for weather icons
- Pull-to-refresh
- Response Caching for offline support
Why It Matters
The Weather app is the classic networking project. It exercises API integration, JSON decoding, state management, image loading, and cache strategies — all skills you need for any data-driven iOS app.
Real-World Use
Every weather app on the App Store — from Apple's built-in Weather app to third-party apps like Carrot Weather — uses the same patterns: fetch JSON from a REST API, decode it into models, display it in SwiftUI views, and cache responses for performance.
Learning Path
flowchart LR A[Project: Todo App
Lesson 33] --> B[Project: Weather App
You are here] B --> C[Project: Networking Library
Lesson 35] B --> D[Project: Game
Lesson 36] style B fill:#f90,color:#fff
API Selection
We use Open-Meteo (open-meteo.com) — a free, no-API-key-required weather API.
- Current weather:
https://api.open-meteo.com/v1/forecast?latitude=...&longitude=...¤t_weather=true - Geocoding:
https://geocoding-api.open-meteo.com/v1/search?name=...
Data Models
import Foundation
struct WeatherResponse: Decodable {
let latitude: Double
let longitude: Double
let currentWeather: CurrentWeather
let hourly: HourlyData?
let daily: DailyData
enum CodingKeys: String, CodingKey {
case latitude, longitude, hourly, daily
case currentWeather = "current_weather"
}
}
struct CurrentWeather: Decodable {
let temperature: Double
let windspeed: Double
let winddirection: Double
let weathercode: Int
let time: String
}
struct HourlyData: Decodable {
let time: [String]
let temperature2m: [Double]
enum CodingKeys: String, CodingKey {
case time
case temperature2m = "temperature_2m"
}
}
struct DailyData: Decodable {
let time: [String]
let temperature2mMax: [Double]
let temperature2mMin: [Double]
let weathercode: [Int]
enum CodingKeys: String, CodingKey {
case time
case temperature2mMax = "temperature_2m_max"
case temperature2mMin = "temperature_2m_min"
case weathercode
}
}
struct GeocodingResponse: Decodable {
let results: [GeocodingResult]?
}
struct GeocodingResult: Decodable, Identifiable {
let id: Int
let name: String
let latitude: Double
let longitude: Double
let country: String?
let admin1: String?
}
enum WeatherCondition: String {
case sunny, cloudy, rainy, snowy, foggy, stormy
static func fromCode(_ code: Int) -> WeatherCondition {
switch code {
case 0: return .sunny
case 1...3: return .cloudy
case 45...48: return .foggy
case 51...67: return .rainy
case 71...77: return .snowy
case 95...99: return .stormy
default: return .cloudy
}
}
var iconName: String {
switch self {
case .sunny: return "sun.max.fill"
case .cloudy: return "cloud.fill"
case .rainy: return "cloud.rain.fill"
case .snowy: return "cloud.snow.fill"
case .foggy: return "cloud.fog.fill"
case .stormy: return "cloud.bolt.fill"
}
}
}
Weather Service
import Foundation
actor WeatherService {
private let baseURL = "https://api.open-meteo.com/v1/forecast"
private let geocodeURL = "https://geocoding-api.open-meteo.com/v1/search"
func searchCities(query: String) async throws -> [GeocodingResult] {
var components = URLComponents(string: geocodeURL)!
components.queryItems = [
URLQueryItem(name: "name", value: query),
URLQueryItem(name: "count", value: "10")
]
let (data, _) = try await URLSession.shared.data(from: components.url!)
let response = try JSONDecoder().decode(GeocodingResponse.self, from: data)
return response.results ?? []
}
func fetchWeather(latitude: Double, longitude: Double) async throws -> WeatherResponse {
var components = URLComponents(string: baseURL)!
components.queryItems = [
URLQueryItem(name: "latitude", value: String(latitude)),
URLQueryItem(name: "longitude", value: String(longitude)),
URLQueryItem(name: "current_weather", value: "true"),
URLQueryItem(name: "daily", value: "temperature_2m_max,temperature_2m_min,weathercode"),
URLQueryItem(name: "timezone", value: "auto")
]
let (data, response) = try await URLSession.shared.data(from: components.url!)
guard let httpResponse = response as? HTTPURLResponse,
httpResponse.statusCode == 200 else {
throw WeatherError.invalidResponse
}
return try JSONDecoder().decode(WeatherResponse.self, from: data)
}
}
enum WeatherError: LocalizedError {
case invalidResponse
case noResults
var errorDescription: String? {
switch self {
case .invalidResponse: return "Invalid response from server"
case .noResults: return "No cities found"
}
}
}
View Model
import SwiftUI
@MainActor
class WeatherViewModel: ObservableObject {
@Published var currentWeather: CurrentWeather?
@Published var dailyForecast: [(day: String, high: Double, low: Double, condition: WeatherCondition)] = []
@Published var cityName = ""
@Published var searchResults: [GeocodingResult] = []
@Published var isLoading = false
@Published var errorMessage: String?
private let service = WeatherService()
private let cache = WeatherCache()
private var selectedCity: GeocodingResult?
func searchCities(query: String) async {
guard query.count >= 2 else {
searchResults = []
return
}
do {
searchResults = try await service.searchCities(query: query)
} catch {
errorMessage = error.localizedDescription
}
}
func selectCity(_ result: GeocodingResult) {
selectedCity = result
cityName = [result.name, result.admin1, result.country]
.compactMap { $0 }
.joined(separator: ", ")
searchResults = []
}
func fetchWeather() async {
guard let city = selectedCity else { return }
isLoading = true
errorMessage = nil
do {
let response = try await service.fetchWeather(
latitude: city.latitude,
longitude: city.longitude
)
currentWeather = response.currentWeather
if let daily = response.daily {
dailyForecast = zip(daily.time, zip(daily.temperature2mMax,
zip(daily.temperature2mMin,
daily.weathercode))).map { date, data in
let (high, low, code) = (data.0, data.1.0, data.1.1)
return (date, high, low, WeatherCondition.fromCode(code))
}
}
await cache.save(response, for: city)
isLoading = false
} catch {
if let cached = await cache.load() {
currentWeather = cached.currentWeather
errorMessage = "Showing cached data"
} else {
errorMessage = error.localizedDescription
}
isLoading = false
}
}
}
Weather Cache
import Foundation
actor WeatherCache {
private let defaults = UserDefaults.standard
func save(_ response: WeatherResponse, for city: GeocodingResult) async {
if let data = try? JSONEncoder().encode(response) {
defaults.set(data, forKey: "cached_weather")
defaults.set(city.name, forKey: "cached_city")
}
}
func load() async -> WeatherResponse? {
guard let data = defaults.data(forKey: "cached_weather") else { return nil }
return try? JSONDecoder().decode(WeatherResponse.self, from: data)
}
}
Main Weather View
import SwiftUI
struct WeatherView: View {
@StateObject private var viewModel = WeatherViewModel()
@State private var searchText = ""
var body: some View {
NavigationStack {
VStack(spacing: 0) {
searchBar
if viewModel.isLoading {
Spacer()
ProgressView("Loading weather...")
Spacer()
} else if let error = viewModel.errorMessage,
viewModel.currentWeather == nil {
Spacer()
VStack(spacing: 12) {
Image(systemName: "exclamationmark.triangle")
.font(.largeTitle)
.foregroundColor(.orange)
Text(error)
.foregroundColor(.secondary)
Button("Try Again") {
Task { await viewModel.fetchWeather() }
}
}
Spacer()
} else if let current = viewModel.currentWeather {
ScrollView {
currentWeatherView(current)
forecastSection
}
} else {
Spacer()
Text("Search for a city to see weather")
.foregroundColor(.secondary)
Spacer()
}
}
.navigationTitle("Weather")
.refreshable {
await viewModel.fetchWeather()
}
}
}
var searchBar: some View {
VStack {
HStack {
Image(systemName: "magnifyingglass")
.foregroundColor(.gray)
TextField("Search city...", text: $searchText)
.onSubmit {
Task { await viewModel.searchCities(query: searchText) }
}
}
.padding(10)
.background(Color.gray.opacity(0.1))
.cornerRadius(10)
.padding(.horizontal)
if !viewModel.searchResults.isEmpty {
List(viewModel.searchResults) { result in
VStack(alignment: .leading) {
Text(result.name).fontWeight(.bold)
Text([result.admin1, result.country]
.compactMap { $0 }.joined(separator: ", "))
.font(.caption)
.foregroundColor(.secondary)
}
.onTapGesture {
viewModel.selectCity(result)
Task { await viewModel.fetchWeather() }
searchText = ""
}
}
.listStyle(.plain)
.frame(height: 200)
}
}
}
func currentWeatherView(_ current: CurrentWeather) -> some View {
VStack(spacing: 8) {
Text(viewModel.cityName)
.font(.title2)
.foregroundColor(.secondary)
Text("\(Int(current.temperature))°")
.font(.system(size: 72, weight: .thin))
Image(systemName: WeatherCondition.fromCode(current.weathercode).iconName)
.font(.largeTitle)
.foregroundColor(.blue)
HStack(spacing: 20) {
Label("\(Int(current.windspeed)) km/h", systemImage: "wind")
Label("\(current.winddirection)°", systemImage: "location.north")
}
.font(.subheadline)
.foregroundColor(.secondary)
}
.padding()
.frame(maxWidth: .infinity)
.background(Color.blue.opacity(0.1))
}
var forecastSection: some View {
VStack(alignment: .leading) {
Text("5-Day Forecast")
.font(.headline)
.padding(.horizontal)
.padding(.top)
ForEach(viewModel.dailyForecast, id: \.day) { day in
HStack {
Text(formatDay(day.day))
.frame(width: 80, alignment: .leading)
Image(systemName: day.condition.iconName)
.foregroundColor(.blue)
.frame(width: 30)
Text("\(Int(day.low))°")
.foregroundColor(.secondary)
.frame(width: 40)
GeometryReader { geo in
ZStack(alignment: .leading) {
Capsule()
.fill(Color.gray.opacity(0.3))
.frame(width: geo.size.width, height: 6)
Capsule()
.fill(temperatureColor(day.high))
.frame(width: geo.size.width * CGFloat((day.high + 10) / 50), height: 6)
}
}
.frame(height: 6)
Text("\(Int(day.high))°")
.frame(width: 40, alignment: .trailing)
}
.padding(.horizontal)
.padding(.vertical, 4)
}
}
}
func formatDay(_ dateString: String) -> String {
let formatter = DateFormatter()
formatter.dateFormat = "yyyy-MM-dd"
guard let date = formatter.date(from: dateString) else { return dateString }
let dayFormatter = DateFormatter()
dayFormatter.dateFormat = "EEE"
return dayFormatter.string(from: date)
}
func temperatureColor(_ temp: Double) -> Color {
switch temp {
case ..<0: return .blue
case 0..<15: return .cyan
case 15..<25: return .green
case 25..<35: return .orange
default: return .red
}
}
}
Key Takeaways
- Open-Meteo provides free weather data without API keys
- Actors provide thread-safe caching
- Pull-to-refresh works with async/await naturally
- Error states with cached fallback improve UX
- GeometryReader enables proportional bar charts
Common Mistakes
Hardcoding location: Always let users search and select their city. GPS permission adds complexity but is more user-friendly.
Not handling rate limits: Free APIs have rate limits. Add a short delay between requests and cache aggressively.
Blocking the main thread with JSON decoding: Weather responses are small, but for large payloads, decode on a background thread.
Ignoring timezone handling: Weather times are in UTC. Convert to local time using the device timezone.
Not caching weather data: Users expect to see the last loaded weather when offline. Always cache the latest response.
Practice Questions
- Why is Open-Meteo a good choice for this project compared to other weather APIs?
- How does the WeatherService actor prevent data races?
- What is the purpose of the WeatherCache actor?
- How would you add hourly forecast display?
- Challenge: Add a "Weather Map" view using MapKit that shows temperature annotations for the user's current location and nearby cities.
FAQ
What's Next
Build a reusable Project: Networking Library as an SPM package, or create a Project: Game using SpriteKit or SwiftUI animations.
Built by the developers of DodaTech
Doda Browser, DodaZIP & Durga Antivirus Pro