2

I am attempting to add .refreshable to my SwiftUI openweathermap app, in order to pull down and refresh values being returned to the app from an API. I set up my app to allow the user to enter a city name in the text field, hit a search button, and view weather details for that city in a sheet. After closing the sheet, the user can see all of his/her previously searched cities as navigation links in a list, with the city name and temperature visible in each list link. I attempted to add .refreshable {} to the List in my ContentView. I tried setting up .refreshable to call fetchWeather() in my ViewModel, which in turn is set up to pass the user-inputted cityName as a parameter into the API URL (also in the ViewModel). However, I'm now thinking this won't work to refresh the weather data, as the action for calling fetchWeather() is defined in the toolbar button and not in the list. Any idea how I can set up .refreshable to refresh the weather data for each of the searched cities in the list? See my code below. Thanks!

ContentView

struct ContentView: View {
    // Whenever something in the viewmodel changes, the content view will know to update the UI related elements
    @StateObject var viewModel = WeatherViewModel()
    @State private var cityName = ""
    @State private var showingDetail = false
    
                
    var body: some View {
        NavigationView {
            VStack {
                List {
                    ForEach(viewModel.cityNameList) { city in
                        NavigationLink(destination: DetailView(detail: city), label: {
                            Text(city.name).font(.system(size: 32))
                            Spacer()
                            Text("\(city.main.temp, specifier: "%.0f")°").font(.system(size: 32))
                        })
                    }.onDelete { index in
                        self.viewModel.cityNameList.remove(atOffsets: index)
                    }
                }.refreshable {
                    viewModel.fetchWeather(for: cityName)
                }
            }.navigationTitle("Weather")
            
            .toolbar {
                ToolbarItem(placement: (.bottomBar)) {
                    HStack {
                        TextField("Enter City Name", text: $cityName)
                            .frame(minWidth: 100, idealWidth: 150, maxWidth: 240, minHeight: 30, idealHeight: 40, maxHeight: 50, alignment: .leading)
                        Spacer()
                                                
                        Button(action: {
                            viewModel.fetchWeather(for: cityName)
                            cityName = ""
                            self.showingDetail.toggle()
                        }) {
                            HStack {
                                Image(systemName: "plus")
                                    .font(.title)
                            }
                            .padding(15)
                            .foregroundColor(.white)
                            .background(Color.green)
                            .cornerRadius(40)
                        }.sheet(isPresented: $showingDetail) {
                            ForEach(0..<viewModel.cityNameList.count, id: \.self) { city in
                                if (city == viewModel.cityNameList.count-1) {
                                    DetailView(detail: viewModel.cityNameList[city])
                                }
                            }
                        }
                    }
                }
            }
        }
    }
}
struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView()
    }
}

DetailView

struct DetailView: View {
        
    @StateObject var viewModel = WeatherViewModel()
    @State private var cityName = ""
    @State var selection: Int? = nil
    
    var detail: WeatherModel
        
    var body: some View {
        VStack(spacing: 20) {
            Text(detail.name)
                .font(.system(size: 32))
            Text("\(detail.main.temp, specifier: "%.0f")&deg;")
                .font(.system(size: 44))
            Text(detail.firstWeatherInfo())
                .font(.system(size: 24))
        }
    }
}

struct DetailView_Previews: PreviewProvider {
    static var previews: some View {
        DetailView(detail: WeatherModel.init())
    }
}

ViewModel

class WeatherViewModel: ObservableObject {
    @Published var cityNameList = [WeatherModel]()

    func fetchWeather(for cityName: String) {
        guard let url = URL(string: "https://api.openweathermap.org/data/2.5/weather?q=\(cityName)&units=imperial&appid=<MyAPIKey>") else { return }

        let task = URLSession.shared.dataTask(with: url) { data, _, error in
            guard let data = data, error == nil else { return }
            do {
                let model = try JSONDecoder().decode(WeatherModel.self, from: data)
                DispatchQueue.main.async {
                    self.cityNameList.append(model)
                }
            }
            catch {
                print(error) // <-- you HAVE TO deal with errors here
            }
        }
        task.resume()
    }
}

Model

struct WeatherModel: Identifiable, Codable {
    let id = UUID()
    var name: String = ""
    var main: CurrentWeather = CurrentWeather()
    var weather: [WeatherInfo] = []
    
    func firstWeatherInfo() -> String {
        return weather.count > 0 ? weather[0].description : ""
    }
}

struct CurrentWeather: Codable {
    var temp: Double = 0.0
}

struct WeatherInfo: Codable {
    var description: String = ""
}

JS_is_awesome18
  • 1,587
  • 7
  • 23
  • 67

2 Answers2

3

What I would do is this (or similar more concurrent and error-proof approach):

In WeatherViewModel add this function which updates all cities weather info:

func updateAll() {
    // keep a copy of all the cities names
    let listOfNames = cityNameList.map{$0.name}
    // remove all current info
    cityNameList.removeAll()
    // fetch the up-to-date weather info
    for city in listOfNames {
        fetchWeather(for: city)
    }
}

and in ContentView:

.refreshable {
     viewModel.updateAll()
}
  
            

Note: you should not have @StateObject var viewModel = WeatherViewModel() in DetailView. You should pass the model in (if need be), and have @ObservedObject var viewModel: WeatherViewModel.

EDIT1:

Since fetching/appending the new weather info is asynchronous, it can result in a different order in the cityNameList.

For small number of cities, you could try to sort the cities after each fetchWeather, such as:

func fetchWeather(for cityName: String)
...
                DispatchQueue.main.async {
                    self.cityNameList.append(model)
                    self.cityNameList.sort(by: {$0.name < $1.name}) // <-- here
                }
...

If this becomes troublesome when you have large number of cities to fetch, you will need a more robust and independent sorting mechanism.

EDIT2: here is a more robust sorting scheme.

Remove self.cityNameList.sort(by: {$0.name < $1.name}) from fetchWeather.

In ContentView sort the cities such as:

ForEach(viewModel.cityNameList.sorted(by: { $0.name < $1.name })) { city in ... }

and use:

.onDelete { index in
    delete(with: index)
}

with:

private func delete(with indexSet: IndexSet) {
    // must sort the list as in the body
    let sortedList = viewModel.cityNameList.sorted(by: { $0.name < $1.name })
    if let firstNdx = indexSet.first {
        // get the city from the sorted list
        let theCity = sortedList[firstNdx]
        // get the index of the city from the viewModel, and remove it
        if let ndx = viewModel.cityNameList.firstIndex(of: theCity) {
            viewModel.cityNameList.remove(at: ndx)
        }
    }
}

EDIT3: to keep the order as in the original additions.

Remove all mods from EDIT1 and EDIT2.

In WeatherViewModel add these functions:

func updateAllWeather() {
    let listOfNames = cityNameList.map{$0.name}
    // fetch the up-to-date weather info
    for city in listOfNames {
        fetchWeather(for: city)
    }
}

func addToList( _ city: WeatherModel) {
    // if already have this city, just update
    if let ndx = cityNameList.firstIndex(where: {$0.name == city.name}) {
        cityNameList[ndx].main = city.main
        cityNameList[ndx].weather = city.weather
    } else {
        // add a new city
        cityNameList.append(city)
    }
}

In fetchWeather, use:

DispatchQueue.main.async {
    self.addToList(model)
}
            

In ContentView,

.onDelete { index in
     viewModel.cityNameList.remove(atOffsets: index)
} 

.refreshable {
     viewModel.updateAll()
}

Note, you have an error in logic with the asynchronous function fetchWeather. You should use a completion handler to proceed when it has finished. Particularly when used in your add Button.

LAST EDIT:

Here is the code I used in my experiments using swift 5.5 async/await:

struct ContentView: View {
    @StateObject var viewModel = WeatherViewModel()
    @State private var cityName = ""
    @State private var showingDetail = false
    
    var body: some View {
        NavigationView {
            VStack {
                List {
                    ForEach(viewModel.cityNameList) { city in
                        NavigationLink(destination: DetailView(detail: city), label: {
                            Text(city.name).font(.system(size: 32))
                            Spacer()
                            Text("\(city.main.temp, specifier: "%.0f")&deg;").font(.system(size: 32))
                        })
                    }.onDelete { index in
                        viewModel.cityNameList.remove(atOffsets: index)
                    }
                }.refreshable {
                    viewModel.updateAllWeather()  // <--- here
                }
            }
            .environmentObject(viewModel)  // <--- here
            .navigationTitle("Weather")
            
            .toolbar {
                ToolbarItem(placement: (.bottomBar)) {
                    HStack {
                        TextField("Enter City Name", text: $cityName)
                            .frame(minWidth: 100, idealWidth: 150, maxWidth: 240, minHeight: 30, idealHeight: 40, maxHeight: 50, alignment: .leading)
                        Spacer()
                        
                        Button(action: {
                            Task {        // <--- here
                                await viewModel.fetchWeather(for: cityName)
                                cityName = ""
                                showingDetail.toggle()
                            }
                        }) {
                            HStack {
                                Image(systemName: "plus").font(.title)
                            }
                            .padding(15)
                            .foregroundColor(.white)
                            .background(Color.green)
                            .cornerRadius(40)
                        }
                        .sheet(isPresented: $showingDetail) {
                            ForEach(0..<viewModel.cityNameList.count, id: \.self) { city in
                                if (city == viewModel.cityNameList.count-1) {
                                    DetailView(detail: viewModel.cityNameList[city])
                                        .environmentObject(viewModel) // <--- here
                                }
                            }
                        }
                    }
                }
            }
        }
    }
}

struct DetailView: View {
    @EnvironmentObject var viewModel: WeatherViewModel // <--- here
    @State private var cityName = ""
    @State var selection: Int? = nil
    
    var detail: WeatherModel
    
    var body: some View {
        VStack(spacing: 20) {
            Text(detail.name)
                .font(.system(size: 32))
            Text("\(detail.main.temp, specifier: "%.0f")&deg;")
                .font(.system(size: 44))
            Text(detail.firstWeatherInfo())
                .font(.system(size: 24))
        }
    }
}

class WeatherViewModel: ObservableObject {
    @Published var cityNameList = [WeatherModel]()
    
    // add or update function
    func addToList( _ city: WeatherModel) {
        // if already have this city, just update it
        if let ndx = cityNameList.firstIndex(where: {$0.name == city.name}) {
            cityNameList[ndx].main = city.main
            cityNameList[ndx].weather = city.weather
        } else {
            // add a new city to the list
            cityNameList.append(city)
        }
    }
    
    // note the async
    func fetchWeather(for cityName: String) async {
        guard let url = URL(string: "https://api.openweathermap.org/data/2.5/weather?q=\(cityName)&units=imperial&appid=YOURKEY") else { return  }
        do {
            let (data, response) = try await URLSession.shared.data(for: URLRequest(url: url))
            
            guard let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 else {
                // throw URLError(.badServerResponse)   //  todo
                print(URLError(.badServerResponse))
                return
            }
            let result = try JSONDecoder().decode(WeatherModel.self, from: data)
            DispatchQueue.main.async {
                self.addToList(result)
            }
        }
        catch {
            return //  todo
        }
    }
    
    // fetch all the latest weather info concurrently
    func updateAllWeather() {
        let listOfNames = cityNameList.map{$0.name}
        Task {
            await withTaskGroup(of: Void.self) { group in
                for city in listOfNames {
                    group.addTask { await self.fetchWeather(for: city) }
                }
            }
        }
    }
    
}
  • I had a feeling you'd respond. Haha thanks. I'm trying this solution now. – JS_is_awesome18 Nov 15 '21 at 05:05
  • this solution appears to work in terms of fetching up-to-date weather info. The weather data seems to update just fine. However, when I pull down to refresh, it looks like the city list items are rearranged. Is it possible that ```cityNameList.removeAll()``` is also removing info about the order of the listItems in the WeatherModal array? – JS_is_awesome18 Nov 15 '21 at 06:19
  • Is there also a way to pull the list down to refresh without pulling the navigation title down as well? I loaded this app onto my phone via Xcode. When I pull down to refresh, the navigationTitle gets pulled down and in some instances gets stuck under the list in an odd glitch. – JS_is_awesome18 Nov 15 '21 at 06:44
  • updated my answer dealing with sorting. As for the pull down, you probably need to talk to Apple, good luck with that. – workingdog support Ukraine Nov 15 '21 at 07:31
  • Edit #3 appears to be the best solution. I implemented all 3, and the 3rd appears best suited to prevent reordering in the list, as well as handling duplicate values. Also, the glitch on the phone appears to be resolved. I believe this is also due to the value being prevented from reordering. Cool. I'll look up how to add a completion handler to fetchWeather, unless you have some suggestions on that as well. Much appreciated! – JS_is_awesome18 Nov 15 '21 at 13:22
  • updated my answer with a new approach. – workingdog support Ukraine Nov 15 '21 at 14:08
-1
import UIKit
class VC: UIViewController  {

var arrlabelpass = [String]()
var arrimagepass = [UIImage]()
var arrTable  = ["1","1","1","1","1","1"]
var arrTablelbl  = ["12","14","13","11","16","17"]
let itemcell = "CCell"
let itemcell1 = "TCell"

var refresh : UIRefreshControl {
    let ref = UIRefreshControl()
    ref.addTarget(self, action: #selector(handler(_:)), for: .valueChanged)
    return ref
}

@IBOutlet weak var tableView: UITableView!
@IBOutlet weak var collectionView: UICollectionView!
override func viewDidLoad() {
    super.viewDidLoad()
   
    tableView.delegate = self
    tableView.dataSource = self
    
    collectionView.delegate = self
    collectionView.dataSource = self
    
    let nib = UINib (nibName: itemcell, bundle: nil)
    collectionView.register(nib, forCellWithReuseIdentifier: itemcell)
    
    let nib1 = UINib(nibName: itemcell1, bundle: nil)
    tableView.register(nib1, forCellReuseIdentifier: itemcell1)
    collectionView.addSubview(refresh)
    collectionView.isHidden = true
}

@objc func handler(_ control:UIRefreshControl) {
    
//        collectionView.backgroundColor = self.randomElement()
    control.endRefreshing()
}

}

extension VC : UITableViewDelegate , UITableViewDataSource , UICollectionViewDelegate , UICollectionViewDataSource  {

func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
    return arrTable.count
}

func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
    let tCell = tableView.dequeueReusableCell(withIdentifier: itemcell1, for: indexPath)as! TCell
    tCell.tIMG.image = UIImage(named: arrTable[indexPath.row])
    tCell.LBL.text = arrTablelbl[indexPath.row]
    return tCell
}

func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
    let lblindex = arrTablelbl[indexPath.row]
    let imageindex = UIImage(named: arrTable[indexPath.row])
    arrlabelpass.append(lblindex)
    arrimagepass.append(imageindex!)
    collectionView.reloadData()
    collectionView.isHidden = false
    arrTablelbl[indexPath.row].removeAll()
    arrTable[indexPath.row].removeAll()
    tableView.reloadData()

}

func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
    return arrlabelpass.count
}

func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
    let ccell = collectionView.dequeueReusableCell(withReuseIdentifier: itemcell, for: indexPath)as! CCell
    ccell.cIMG.image = arrimagepass[indexPath.row]
    ccell.cLBL.text = arrlabelpass[indexPath.row]
    return ccell
    
}

func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
    tableView.reloadData()
    arrimagepass.remove(at: indexPath.row)
    arrlabelpass.remove(at: indexPath.row)
    collectionView.reloadData()
}

}