如何在 SwiftUI 中使用 .refreshable 调用 API 并刷新列表
Posted
技术标签:
【中文标题】如何在 SwiftUI 中使用 .refreshable 调用 API 并刷新列表【英文标题】:How to use .refreshable in SwiftUI to call API and refresh list 【发布时间】:2021-12-26 07:33:58 【问题描述】:我正在尝试将 .refreshable 添加到我的 SwiftUI openweathermap 应用程序中,以便下拉和刷新从 API 返回到应用程序的值。我将我的应用程序设置为允许用户在文本字段中输入城市名称,点击搜索按钮,然后在工作表中查看该城市的天气详细信息。关闭表格后,用户可以在列表中以导航链接的形式查看他/她之前搜索过的所有城市,每个列表链接中都可以看到城市名称和温度。我试图将.refreshable
添加到我的 ContentView 的列表中。我尝试在我的 ViewModel 中设置 .refreshable 以调用 fetchWeather()
,而后者又设置为将用户输入的 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")°")
.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 = ""
【问题讨论】:
【参考方案1】:我会做的是这个(或类似的更并发和防错的方法):
在WeatherViewModel
添加更新所有城市天气信息的功能:
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)
在ContentView
:
.refreshable
viewModel.updateAll()
注意:DetailView
中不应包含 @StateObject var viewModel = WeatherViewModel()
。
您应该将模型传入(如果需要),并拥有@ObservedObject var viewModel: WeatherViewModel
。
编辑1:
由于获取/附加新的天气信息是异步的,它
可能会导致cityNameList
中的顺序不同。
对于少数城市,您可以尝试在每个fetchWeather
之后对城市进行排序,例如:
func fetchWeather(for cityName: String)
...
DispatchQueue.main.async
self.cityNameList.append(model)
self.cityNameList.sort(by: $0.name < $1.name) // <-- here
...
如果当您需要获取大量城市时这会变得很麻烦, 您将需要一个更强大和独立的排序机制。
EDIT2:这是一个更强大的排序方案。
从fetchWeather
中删除self.cityNameList.sort(by: $0.name < $1.name)
。
在ContentView
中对城市进行排序,例如:
ForEach(viewModel.cityNameList.sorted(by: $0.name < $1.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: $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:保持原始添加中的顺序。
从EDIT1
和EDIT2
中移除所有模组。
在WeatherViewModel
中添加这些函数:
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)
在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")°").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")°")
.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)
【讨论】:
我感觉你会回应。哈哈谢谢。我现在正在尝试这个解决方案。 这个解决方案似乎在获取最新的天气信息方面有效。天气数据似乎更新得很好。但是,当我下拉刷新时,似乎城市列表项已重新排列。cityNameList.removeAll()
是否也可能删除有关 WeatherModal 数组中 listItems 顺序的信息?
是否还有一种方法可以下拉列表以刷新而不下拉导航标题?我通过 Xcode 将这个应用程序加载到我的手机上。当我下拉刷新时,navigationTitle 会被下拉,在某些情况下会出现奇怪的故障卡在列表下方。
更新了我处理排序的答案。至于下拉,你可能需要和苹果谈谈,祝你好运。
Edit #3 似乎是最好的解决方案。我实现了所有 3 个,第 3 个似乎最适合防止在列表中重新排序,以及处理重复值。此外,手机上的故障似乎已经解决。我相信这也是由于价值被阻止重新排序。凉爽的。我将查找如何向 fetchWeather 添加完成处理程序,除非您对此也有一些建议。非常感谢!【参考方案2】:
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()
【讨论】:
以上是关于如何在 SwiftUI 中使用 .refreshable 调用 API 并刷新列表的主要内容,如果未能解决你的问题,请参考以下文章
如何在 SwiftUI 中使用 SFSafariViewController?
如何在 SwiftUI 中使用 .fileimporter 保存音频文件并检索文件并在 SwiftUI 列表中显示它们的文件名?