如何在 SwiftUI 中使用 .refreshable 调用 API 并刷新列表

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

我正在尝试将 .refreshable 添加到我的 SwiftUI openweathermap 应用程序,以便下拉和刷新从 API 返回到应用程序的值。我将我的应用程序设置为允许用户在文本字段中输入城市名称,点击搜索按钮,然后在 sheet 中查看该城市的天气详细信息。关闭 sheet 后,用户可以在列表中看到所有 his/her 之前搜索过的城市作为导航 link,城市名称和温度在每个列表中可见 link .我试图将 .refreshable {} 添加到我的 ContentView 中的列表中。我尝试设置 .refreshable 以在我的 ViewModel 中调用 fetchWeather(),而 ViewModel 又设置为将用户输入的 cityName 作为参数传递到 API URL(也在 ViewModel 中).但是,我现在认为这无法刷新天气数据,因为调用 fetchWeather() 的操作是在工具栏按钮中定义的,而不是在列表中定义的。知道如何设置 .refreshable 来刷新列表中每个搜索城市的天气数据吗?请参阅下面的代码。谢谢!

内容视图

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

详细视图

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

视图模型

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

型号

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

我会做的是这个(或类似的更并发和防错的方法):

WeatherViewModel中添加更新所有城市天气信息的功能:

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

并在 ContentView 中:

.refreshable {
     viewModel.updateAll()
}
  
            

注意:DetailView 中不应包含 @StateObject var viewModel = WeatherViewModel()。 您应该传入模型(如果需要),并具有 @ObservedObject var viewModel: WeatherViewModel.

编辑 1:

由于 fetching/appending 新的天气信息是异步的,它 可能导致 cityNameList.

中的顺序不同

对于少数城市,可以尝试在每个fetchWeather之后对城市进行排序,例如:

func fetchWeather(for cityName: String)
...
                DispatchQueue.main.async {
                    self.cityNameList.append(model)
                    self.cityNameList.sort(by: {[=12=].name < .name}) // <-- here
                }
...

如果要获取大量城市时这变得很麻烦, 你将需要一个更健壮和独立的排序机制。

EDIT2:这是一个更强大的排序方案。

fetchWeather 中删除 self.cityNameList.sort(by: {[=27=].name < .name})

ContentView中对城市进行排序,例如:

ForEach(viewModel.cityNameList.sorted(by: { [=13=].name < .name })) { city in ... }

并使用:

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

与:

private func delete(with indexSet: IndexSet) {
    // must sort the list as in the body
    let sortedList = viewModel.cityNameList.sorted(by: { [=15=].name < .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:保持原来添加的顺序。

删除 EDIT1EDIT2 的所有模组。

WeatherViewModel中添加这些函数:

func updateAllWeather() {
    let listOfNames = cityNameList.map{[=16=].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: {[=16=].name == city.name}) {
        cityNameList[ndx].main = city.main
        cityNameList[ndx].weather = city.weather
    } else {
        // add a new city
        cityNameList.append(city)
    }
}

fetchWeather中,使用:

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

ContentView,

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

.refreshable {
     viewModel.updateAll()
}

请注意,异步函数 fetchWeather 的逻辑存在错误。 您应该使用完成处理程序在完成后继续。 特别是在 add 按钮中使用时。

最后编辑:

这是我在实验中使用的代码 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: {[=19=].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{[=19=].name}
        Task {
            await withTaskGroup(of: Void.self) { group in
                for city in listOfNames {
                    group.addTask { await self.fetchWeather(for: city) }
                }
            }
        }
    }
    
}
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()
}

}