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")°")
.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)
}
}
}