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

标签 swift swiftui swiftui-list swiftui-navigationlink swiftui-navigationview

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

内容 View

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

View 模型

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

注意:您不应该有 @StateObject var viewModel = WeatherViewModel()DetailView 。 您应该传入模型(如果需要),并且有 @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:这是一个更强大的排序方案。

删除self.cityNameList.sort(by: {$0.name < $1.name})来自fetchWeather .

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")&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) }
                }
            }
        }
    }
    
}

关于swift - 如何在SwiftUI中使用.refreshable调用API并刷新列表,我们在Stack Overflow上找到一个类似的问题: https://stackoverflow.com/questions/69968555/

相关文章:

SwiftUI - TabView 禁用背景透明度

ios - 在SwiftUI列表中移动项目时不需要的动画

SwiftUI:删除行时使用 Array/Index 的 ForEach 崩溃

SwiftUI List() 在 macOS 上不显示 .children

ios - Swift - APNS 接收消息并单击目标 View Controller

swift - 来自字符串的 setTorchModeOnWithLevel

swift - 类 RTCCVPixelBuffer 在两者中都实现,将使用两者之一。哪个是未定义的

ios - 如何快速更新实例变量?

ios - 如何显示从数据库中获取的值?

ios - 有没有办法在 SwiftUI 中实现 "unbind"变量/对象