Home > Blockchain >  How to use .refreshable in SwiftUI to call API and refresh list
How to use .refreshable in SwiftUI to call API and refresh list

Time:11-15

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 = ""
}

CodePudding user response:

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()
}

}

CodePudding user response:

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)
        }
    }
}
  • Related