skip to Main Content

I’m facing an issue in my SwiftUI project where the UI is not updating automatically after fetching data. I have a WeatherViewModel fetching weather data for multiple locations, and the UI doesn’t reflect the changes until I manually trigger an update by pressing a button. I’ve tried using @Published properties and ObservableObject, but the problem persists.

I attempted to use @Published properties and ObservableObject in my code.Despite these efforts, the UI still doesn’t update automatically.

the weather data model:

import Foundation

import Foundation


// MARK: - Welcome
struct WeatherDataModel: Codable {
    let coord: Coord
    let weather: [Weather]
    let base: String
    let main: Main
    let visibility: Int
    let wind: Wind
    let clouds: Clouds
    let dt: Int
    let sys: Sys
    let timezone, id: Int
    let name: String
    let cod: Int
}

// MARK: - Clouds
struct Clouds: Codable {
    let all: Int
}

// MARK: - Coord
struct Coord: Codable {
    let lon, lat: Double
}

// MARK: - Main
struct Main: Codable {
    let temp, feelsLike, tempMin, tempMax: Double
    let pressure, humidity: Int

    enum CodingKeys: String, CodingKey {
        case temp
        case feelsLike = "feels_like"
        case tempMin = "temp_min"
        case tempMax = "temp_max"
        case pressure, humidity
    }
}

// MARK: - Sys
struct Sys: Codable {
    let type, id: Int
    let country: String
    let sunrise, sunset: Int
}

// MARK: - Weather
struct Weather: Codable {
    let id: Int
    let main, description, icon: String
}

// MARK: - Wind
struct Wind: Codable {
    let speed: Double
    let deg: Int
}

class Location: ObservableObject, Identifiable {
    let id = UUID()
    @Published var name: String
    @Published  var weatherDataModel: WeatherDataModel?

    init(name: String) {
           self.name = name
       }

}

class WeatherViewModel: ObservableObject {
    @Published var locations: [Location] = []

    func fetchWeather(for location: Location) {
        // Implement API request to OpenWeatherMap using URLSession
        // You'll need to replace "YOUR_API_KEY" with your actual OpenWeatherMap API key
        let apiKey = "8281a792c5f995747b19a57c8e52ea8d"
        let urlString = "https://api.openweathermap.org/data/2.5/weather?q=(location.name)&appid=(apiKey)"
//        let urlString = "https://api.openweathermap.org/data/2.5/weather?q=mexico&appid=(apiKey)"

        guard let url = URL(string: urlString) else { return }

        URLSession.shared.dataTask(with: url) { data, _, error in
            guard let data = data, error == nil else {

                return }

            do {
                let decodedData = try JSONDecoder().decode(WeatherDataModel.self, from: data)
                DispatchQueue.main.async {
                    location.weatherDataModel = decodedData
                    print("Weather data fetched successfully: (decodedData)")
                }
            } catch {
                print("Error decoding weather data: (error)")
            }
        }.resume()
    }
  





}









Also for some strange reason the view does update automatically with the .onAppear but then when I click the button which clearly has no code in it then the view updates and the data from the API is displayed.

the view receiving the data:

//
//  TestView.swift
//  Amun
//
//  Created by Richard Nkanga on 09/02/2024.
//

import SwiftUI

struct HomeView: View {
    @StateObject private var viewModel = WeatherViewModel()
    @State var names = ["UNITED STATES", "Montreal", "Nigeria", "poland", "London"]
    @State private var newLocation = "Canada"
    @State private var isEditing = false

    var body: some View {
        NavigationStack {
            ZStack {
                Color(.background)
                    .ignoresSafeArea(.all)
                    .onAppear {
                    }

                VStack {
                    List(viewModel.locations, id: .id) { locs in

                        HStack {
                            Text(locs.name)
                            Text("(Double(locs.weatherDataModel?.main.temp ?? 0))")

                            Spacer()

                            if let weatherData = locs.weatherDataModel {
                                Text("Temp: (Int(weatherData.main.temp))°C")
                                Text("Temp: (weatherData.name)")
                            } else {
                                Text("Loading...")
                            }
                        }
                    }
            
                    .onAppear {
                        for name in names {
                            let location = Location(name: name)
                            viewModel.locations.append(location)
                            viewModel.fetchWeather(for: location)
                        }
                    }

                    Button(action: {
                        isEditing.toggle()



                    }, label: {
                        Text(isEditing ? "Done" : "Edit")
                            .foregroundColor(Color.red)

                    })
                }
            }
            .background(Color.red)
            .navigationTitle("Weather")
        }
    }
}

#Preview {
    HomeView()
}

The ui never updates and just keeps showing loading…

2

Answers


  1. Playing around a bit with your code the solution I came up with was to, first of all, switch to async/await, because the code would be shorter and more readable. I also found a Coding Key error: in the Sys struct the type and id need to be optional.

    Here’s how I modified your REST call to be async:

        func fetchWeather(for location: Location) async throws -> WeatherDataModel? {
            // Implement API request to OpenWeatherMap using URLSession
            // You'll need to replace "YOUR_API_KEY" with your actual OpenWeatherMap API key
            let apiKey = "8281a792c5f995747b19a57c8e52ea8d"
            let urlString = "https://api.openweathermap.org/data/2.5/weather?q=(location.name)&appid=(apiKey)"
    //        let urlString = "https://api.openweathermap.org/data/2.5/weather?q=mexico&appid=(apiKey)"
    
            guard let url = URL(string: urlString) else { return nil }
            
            /// Shortest async way
            do {
                let (data, _) = try await URLSession.shared.data(from: url)
                let decodedResponse = try JSONDecoder().decode(WeatherDataModel.self, from: data)
                return decodedResponse
            } catch(let error) {
                print("Error decoding weather data: (error)")
                return nil
            }
            
            /// Longest async version
            /*return await withCheckedContinuation { contination in
                URLSession.shared.dataTask(with: url) { data, _, error in
                    guard let data = data, error == nil else {
    
                        return }
    
                    do {
                        let decodedData = try JSONDecoder().decode(WeatherDataModel.self, from: data)
                        DispatchQueue.main.async {
                            //location.weatherDataModel = decodedData
                            contination.resume(returning: decodedData)
                            print("Weather data fetched successfully: (decodedData)")
                        }
                    } catch {
                        print("Error decoding weather data: (error)")
                    }
                }.resume()
            }*/
        }
    

    Then your Sys struct:

    struct Sys: Codable {
        let type, id: Int?
        let country: String
        let sunrise, sunset: Int
    }
    

    As said in the comments, I also changed Location to be a struct:

    struct Location: Identifiable {
        let id = UUID()
        var name: String
        var weatherDataModel: WeatherDataModel?
    
        init(name: String) {
            self.name = name
        }
    
    }
    

    And, at the end change the onAppear with task modifier like so:

    .task {
         for name in names {
             var location = Location(name: name)
             let weatherDataModel = try? await viewModel.fetchWeather(for: location)
             location.weatherDataModel = weatherDataModel
             /// IMPORTANT!!!  Append the location at the end.
             viewModel.locations.append(location)
          }
    }
    

    Here is important to use the append at the end of every operations, if you place it before your data won’t be updated.
    Let me know what do you think of this solution!

    Login or Signup to reply.
  2. Nesting classes conforming to ObservableObject cannot work. In practice the Location class is not needed at all because WeatherDataModel fortunately provides an id and name property.

    This is a different async approach

    • Import Foundation with @preconcurrency to suppress a URLSession warning

      @preconcurrency import Foundation
      
    • Adopt Identifiable in WeatherDataModel

      struct WeatherDataModel: Decodable, Identifiable { …
      
    • Declare type and id in Sys as optional

      let type, id: Int?
      
    • Delete the CodingKeys in Main because the JSONDecoder manages the snake case keys

    • Create an enum for different states

      enum LoadingState<Value> {
          case idle, loading, loaded(Value), failed(Error)
      }
      
    • In WeatherViewModel publish the state and pass the names in fetchWeather which is marked as async and handles also the errors

      @MainActor
      class WeatherViewModel: ObservableObject {
            @Published var state: LoadingState<[WeatherDataModel]> = .idle
      
            private let apiKey = "••••••••••••••••••••"
      
            func fetchWeather(for names: [String]) async {
                state = .loading
                let decoder = JSONDecoder()
                decoder.keyDecodingStrategy = .convertFromSnakeCase
                var locations = [WeatherDataModel]()
                do {
                    for name in names {
                        let urlString = "https://api.openweathermap.org/data/2.5/weather?q=(name)&appid=(apiKey)&units=metric"
                        guard let url = URL(string: urlString) else { throw URLError(.badURL) }
                        let (data, _) = try await URLSession.shared.data(from: url)
                        let weather = try decoder.decode(WeatherDataModel.self, from: data)
                        locations.append(weather)
                    }
                    state = .loaded(locations)
                } catch {
                    state = .failed(error)
                }
            }
      }
      
    • In the view get the data asynchronously in a .task and switch on the published state

      struct HomeView: View {
        @StateObject private var viewModel = WeatherViewModel()
        @State var names = ["UNITED STATES", "Montreal", "Nigeria", "Poland", "London"]
        @State private var newLocation = "Canada"
        @State private var isEditing = false
      
        var body: some View {
            NavigationStack {
                ZStack {
                    Color.blue
                        .ignoresSafeArea(.all)
                    VStack {
                        switch viewModel.state {
                            case .idle: EmptyView()
                            case .loading: ProgressView()
                            case .loaded(let locations):
                                List(locations) { location in
                                    HStack {
                                        let temperature: Measurement<UnitTemperature> = Measurement(value: location.main.temp, unit: .celsius)
                                        Text("Temp: " + temperature.description)
                                        Spacer()
                                        Text("Name: " + location.name)
                                    }
                                }
      
                            case .failed(let error):
                                Text(error.localizedDescription)
                        }
                        Button(action: {
                            isEditing.toggle()
      
                        }, label: {
                            Text(isEditing ? "Done" : "Edit")
                                .foregroundColor(Color.red)
                        })
                    }
                }
                .task {
                    await viewModel.fetchWeather(for: names)
                }
                .background(Color.red)
                .navigationTitle("Weather")
            }
        }
      }
      
    Login or Signup to reply.
Please signup or login to give your own answer.
Back To Top
Search