Building a Weather App Using SwiftUI and API Data

Building a weather application is a fantastic way to learn about fetching data from an API and displaying it in a user-friendly interface. SwiftUI, Apple’s modern UI framework, simplifies the process of creating elegant and responsive applications. In this tutorial, we’ll walk through creating a basic weather app that retrieves weather data from an API and presents it in a clear, concise manner.

Why Build a Weather App with SwiftUI?

  • Learn API Integration: Practice fetching and parsing real-world data.
  • SwiftUI Mastery: Enhance your skills with Apple’s declarative UI framework.
  • Real-World Application: Create a practical and useful tool for daily use.

Prerequisites

  • Xcode installed on your macOS machine.
  • Basic understanding of Swift and SwiftUI.

Step-by-Step Guide to Building the Weather App

Step 1: Setting Up the Project

  1. Open Xcode and create a new project.
  2. Select the “App” template under the iOS tab.
  3. Name your project “WeatherApp” and ensure that “SwiftUI” is selected as the interface.

Step 2: Designing the UI

Let’s start by creating the basic UI structure for our app. Replace the content of ContentView.swift with the following code:


import SwiftUI

struct ContentView: View {
    @State private var weatherData: WeatherData? = nil
    @State private var city: String = "London" // Default city
    
    var body: some View {
        VStack {
            Text("Weather in \(city)")
                .font(.title)
                .padding()
            
            if let weather = weatherData {
                Text("Temperature: \(weather.temperature)°C")
                Text("Condition: \(weather.condition)")
            } else {
                Text("Loading...")
            }
            
            Spacer()
        }
        .onAppear {
            fetchWeather(for: city)
        }
    }
    
    func fetchWeather(for city: String) {
        // Fetch weather data function will be implemented later
    }
}

struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView()
    }
}

In this code:

  • We use a VStack to vertically arrange the UI elements.
  • Text views display the city, temperature, and weather condition.
  • The @State properties weatherData and city hold the weather information and city name, respectively.
  • The onAppear modifier calls the fetchWeather function when the view appears.

Step 3: Creating the Data Model

Create a new Swift file named WeatherData.swift. Define the WeatherData struct to hold the weather information. We’ll use a very simple model for this example:


import Foundation

struct WeatherData {
    let temperature: Double
    let condition: String
}

Step 4: Fetching Data from the Weather API

Now, let’s fetch the weather data from a free weather API. In this example, we’ll use OpenWeatherMap. You will need to sign up for an API key to proceed. Once you have the API key, you can use it to make requests. Replace "YOUR_API_KEY" with your actual API key.

Update the fetchWeather function in ContentView.swift:


import SwiftUI

struct ContentView: View {
    @State private var weatherData: WeatherData? = nil
    @State private var city: String = "London" // Default city
    
    var body: some View {
        VStack {
            Text("Weather in \(city)")
                .font(.title)
                .padding()
            
            if let weather = weatherData {
                Text("Temperature: \(weather.temperature)°C")
                Text("Condition: \(weather.condition)")
            } else {
                Text("Loading...")
            }
            
            Spacer()
        }
        .onAppear {
            fetchWeather(for: city)
        }
    }
    
    func fetchWeather(for city: String) {
        guard let url = URL(string: "https://api.openweathermap.org/data/2.5/weather?q=\(city)&appid=YOUR_API_KEY&units=metric") else {
            print("Invalid URL")
            return
        }
        
        URLSession.shared.dataTask(with: url) { data, response, error in
            guard let data = data, error == nil else {
                print("Error fetching data: \(error?.localizedDescription ?? "Unknown error")")
                return
            }
            
            if let decodedResponse = try? JSONSerialization.jsonObject(with: data, options: []) as? [String: Any] {
                
                if let main = decodedResponse["main"] as? [String: Any],
                   let temperature = main["temp"] as? Double,
                   let weatherArray = decodedResponse["weather"] as? [[String: Any]],
                   let weatherCondition = weatherArray.first?["description"] as? String {
                    
                    let weather = WeatherData(temperature: temperature, condition: weatherCondition)
                    DispatchQueue.main.async {
                        weatherData = weather
                    }
                } else {
                    print("Failed to parse weather data")
                }
            } else {
                print("Failed to decode JSON")
            }
            
        }.resume()
    }
}

struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView()
    }
}

Key updates and explanations:

  • The code now converts JSON data to a dictionary using JSONSerialization instead of JSONDecoder.
  • Added thorough error handling to ensure API requests, JSON parsing, and data extraction are reliable.
  • Parsed the dictionary manually to ensure you’re extracting values as Any to conform to different JSON formats.
  • Maintained the core data fetching and state update on the main thread to prevent UI errors.
  • Provides comprehensive output to the console about what succeeded or failed during parsing, aiding troubleshooting.

Important: Error Handling Notes

Ensure to validate the responses from API endpoint:


guard let url = URL(string: "https://api.openweathermap.org/data/2.5/weather?q=\(city)&appid=YOUR_API_KEY&units=metric") else {
    print("Invalid URL")
    return
}

Check to ensure all the keys you use to pull the values in are valid and of the correct expected type:


if let main = decodedResponse["main"] as? [String: Any],
   let temperature = main["temp"] as? Double,
   let weatherArray = decodedResponse["weather"] as? [[String: Any]],
   let weatherCondition = weatherArray.first?["description"] as? String {

Each optional bind ensures keys are indeed found at runtime to prevent any unexpected crashes or illogical behaviors.

Step 5: Handling API Errors and Loading States

Update the UI to provide user feedback while fetching data. This involves displaying a loading indicator while the app waits for the API response. The code also informs the user if an error occurred or if no weather data is available. Adjust ContentView.swift to reflect these enhancements:


import SwiftUI

struct ContentView: View {
    @State private var weatherData: WeatherData? = nil
    @State private var city: String = "London" // Default city
    @State private var isLoading: Bool = false // Track loading state
    @State private var errorMessage: String? = nil // Display error messages
    
    var body: some View {
        VStack {
            Text("Weather in \(city)")
                .font(.title)
                .padding()
            
            if isLoading {
                ProgressView("Fetching Weather...")
            } else if let weather = weatherData {
                Text("Temperature: \(weather.temperature)°C")
                Text("Condition: \(weather.condition)")
            } else if let error = errorMessage {
                Text("Error: \(error)")
                    .foregroundColor(.red)
                    .padding()
            } else {
                Text("No weather data available")
                    .padding()
            }
            
            Spacer()
        }
        .onAppear {
            fetchWeather(for: city)
        }
    }
    
    func fetchWeather(for city: String) {
        isLoading = true
        errorMessage = nil
        
        guard let url = URL(string: "https://api.openweathermap.org/data/2.5/weather?q=\(city)&appid=YOUR_API_KEY&units=metric") else {
            errorMessage = "Invalid URL"
            isLoading = false
            return
        }
        
        URLSession.shared.dataTask(with: url) { data, response, error in
            defer {
                DispatchQueue.main.async {
                    isLoading = false // Ensure loading is always reset
                }
            }
            
            if let error = error {
                DispatchQueue.main.async {
                    errorMessage = "Error fetching data: \(error.localizedDescription)"
                }
                return
            }
            
            guard let data = data else {
                DispatchQueue.main.async {
                    errorMessage = "No data received"
                }
                return
            }
            
            if let decodedResponse = try? JSONSerialization.jsonObject(with: data, options: []) as? [String: Any] {
                
                if let main = decodedResponse["main"] as? [String: Any],
                   let temperature = main["temp"] as? Double,
                   let weatherArray = decodedResponse["weather"] as? [[String: Any]],
                   let weatherCondition = weatherArray.first?["description"] as? String {
                    
                    let weather = WeatherData(temperature: temperature, condition: weatherCondition)
                    DispatchQueue.main.async {
                        weatherData = weather
                    }
                } else {
                    DispatchQueue.main.async {
                        errorMessage = "Failed to parse weather data"
                    }
                }
            } else {
                DispatchQueue.main.async {
                    errorMessage = "Failed to decode JSON"
                }
            }
        }.resume()
    }
}

struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView()
    }
}

New additions and detailed explanations:

  • @State properties: Introduced isLoading to track when data is being fetched and errorMessage to hold and display error information. These states help control UI updates based on different phases of data loading.
  • Loading Indicator: Replaced the static Text("Loading...") with a dynamic ProgressView("Fetching Weather...") that is displayed only when isLoading is true.
  • Error Handling: The UI now displays an error message using Text("Error: \(error)") when the errorMessage state contains a value, informing the user about potential API issues or data parsing failures.
  • Empty State Message: If no weather data is available and no errors occur, Text("No weather data available") informs the user that the data might not be ready or found for the requested city.

Step 6: Adding a City Input Field

Modify the app to allow users to enter the city name for which they want to see the weather. Add a TextField for city input. The city @State variable will be binded to the value in the TextField, and we will add button which, on tapped, fetches the new city’s weather.


import SwiftUI

struct ContentView: View {
    @State private var weatherData: WeatherData? = nil
    @State private var city: String = "London"
    @State private var isLoading: Bool = false
    @State private var errorMessage: String? = nil
    
    var body: some View {
        VStack {
            Text("Weather in \(city)")
                .font(.title)
                .padding()
            
            TextField("Enter city", text: $city)
                .textFieldStyle(RoundedBorderTextFieldStyle())
                .padding()
            
            Button("Get Weather") {
                fetchWeather(for: city)
            }
            .padding()
            
            if isLoading {
                ProgressView("Fetching Weather...")
            } else if let weather = weatherData {
                Text("Temperature: \(weather.temperature)°C")
                Text("Condition: \(weather.condition)")
            } else if let error = errorMessage {
                Text("Error: \(error)")
                    .foregroundColor(.red)
                    .padding()
            } else {
                Text("No weather data available")
                    .padding()
            }
            
            Spacer()
        }
        .onAppear {
            fetchWeather(for: city)
        }
    }
    
    func fetchWeather(for city: String) {
        isLoading = true
        errorMessage = nil
        
        guard let url = URL(string: "https://api.openweathermap.org/data/2.5/weather?q=\(city)&appid=YOUR_API_KEY&units=metric") else {
            errorMessage = "Invalid URL"
            isLoading = false
            return
        }
        
        URLSession.shared.dataTask(with: url) { data, response, error in
            defer {
                DispatchQueue.main.async {
                    isLoading = false
                }
            }
            
            if let error = error {
                DispatchQueue.main.async {
                    errorMessage = "Error fetching data: \(error.localizedDescription)"
                }
                return
            }
            
            guard let data = data else {
                DispatchQueue.main.async {
                    errorMessage = "No data received"
                }
                return
            }
            
            if let decodedResponse = try? JSONSerialization.jsonObject(with: data, options: []) as? [String: Any] {
                
                if let main = decodedResponse["main"] as? [String: Any],
                   let temperature = main["temp"] as? Double,
                   let weatherArray = decodedResponse["weather"] as? [[String: Any]],
                   let weatherCondition = weatherArray.first?["description"] as? String {
                    
                    let weather = WeatherData(temperature: temperature, condition: weatherCondition)
                    DispatchQueue.main.async {
                        weatherData = weather
                    }
                } else {
                    DispatchQueue.main.async {
                        errorMessage = "Failed to parse weather data"
                    }
                }
            } else {
                DispatchQueue.main.async {
                    errorMessage = "Failed to decode JSON"
                }
            }
        }.resume()
    }
}

struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView()
    }
}

Notable Updates:

  • Text Field Inclusion: A TextField is added, bound to the city @State variable using $city.
  • Button for Refreshing Data: A Button component calls the fetchWeather function with the current city when tapped. This enhances usability, enabling real-time updates as users input different locations.
  • SwiftUI Styling Applied: Rounded border style applied with padding enhances visual appeal and ease of use.

Conclusion

Building a weather application with SwiftUI provides hands-on experience with API integration, data handling, and UI development. The application fetches weather data from an API, handles errors gracefully, provides a user-friendly interface with data, loading, and error states, and updates weather information in real time based on the city entered by the user. This project demonstrates the power and simplicity of SwiftUI in creating modern, data-driven applications.