Skip to content

Build a SwiftUI Weather App — API Integration and Data Visualization

DodaTech Updated 2026-06-28 8 min read

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=...&current_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

  1. Hardcoding location: Always let users search and select their city. GPS permission adds complexity but is more user-friendly.

  2. Not handling rate limits: Free APIs have rate limits. Add a short delay between requests and cache aggressively.

  3. Blocking the main thread with JSON decoding: Weather responses are small, but for large payloads, decode on a background thread.

  4. Ignoring timezone handling: Weather times are in UTC. Convert to local time using the device timezone.

  5. Not caching weather data: Users expect to see the last loaded weather when offline. Always cache the latest response.

Practice Questions

  1. Why is Open-Meteo a good choice for this project compared to other weather APIs?
  2. How does the WeatherService actor prevent data races?
  3. What is the purpose of the WeatherCache actor?
  4. How would you add hourly forecast display?
  5. Challenge: Add a "Weather Map" view using MapKit that shows temperature annotations for the user's current location and nearby cities.

FAQ

Do I need an API key for Open-Meteo?

No. Open-Meteo is completely free and does not require an API key for non-commercial use. Check their terms for commercial usage.

How do I handle different measurement units?

Open-Meteo supports query parameters for temperature_unit (celsius/fahrenheit) and wind_speed_unit (kmh/ms/mph/knots). Pass the user's preference.

How do I add weather alerts?

Open-Meteo provides a separate alerts endpoint. Add a WeatherAlert model and an alerts section in the view.

Can I use Core Location instead of city search?

Yes. Import CoreLocation, request authorization, and pass the user's coordinates directly to the weather endpoint instead.

How should I handle the weathercode to condition mapping?

The WMO weather interpretation codes are standard. Create a mapping function like WeatherCondition.fromCode(_:) as shown above.

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