在WWDC21上,苹果引入了一个新的SwiftUI API,使我们能够将刷新动作附加到任何视图上,这反过来又为我们提供了非常流行的下拉刷新机制的本地支持。让我们看看这个新的API是如何工作的,以及它如何使我们能够构建完全自定义的刷新逻辑。
请注意,这里介绍的是作为iOS15和苹果2021年操作系统的一部分即将引入的api,这些api目前都处于测试阶段。
async/await能力加持
为了能够判断刷新操作何时完成,SwiftUI使用了async/await模式,这将在Swift 5.5中引入(预计将在今年晚些时候与苹果的新操作系统一起发布)。因此,在我们开始采用新的刷新API之前,我们需要一个异步标记的函数,每当视图的刷新动作被触发时,我们都可以调用这个函数。
例如,假设我们正在开发一个包含某种形式的书签特性的应用程序,并且我们已经构建了一个BookmarkListViewModel,它负责为我们的书签列表UI提供数据。为了使数据能够被刷新,我们添加了一个异步重新加载方法,该方法反过来调用DatabaseController来获取一个Bookmark模型数组。
class BookmarkListViewModel: ObservableObject {
@Published private(set) var bookmarks: [Bookmark]
private let databaseController: DatabaseController
...
func reload() async {
bookmarks = await databaseController.loadAllModels(
ofType: Bookmark.self
)
}
}
现在我们有了一个可以调用来刷新视图数据的async函数,让我们在BookmarkList视图中应用新的可刷新修饰符——像这样:
struct BookmarkList: View {
@ObservedObject var viewModel: BookmarkListViewModel
var body: some View {
List(viewModel.bookmarks) { bookmark in
...
}
.refreshable {
await viewModel.reload()
}
}
}
通过这样做,我们的List-powered视图现在将支持下拉刷新。当我们执行刷新操作时,SwiftUI会自动隐藏并显示加载spinner,甚至会确保在同一时间没有重复的刷新操作。很酷的!
作为额外的好处——考虑到Swift支持第一类函数——我们甚至可以将视图模型的reload方法直接传递给可刷新的修饰符,这给了我们一个稍微紧凑的实现:
struct BookmarkList: View {
@ObservedObject var viewModel: BookmarkListViewModel
var body: some View {
List(viewModel.bookmarks) { bookmark in
...
}
.refreshable(action: viewModel.reload)
}
}
这就是基本的下拉刷新支持的全部内容。但这仅仅是个开始,让我们继续探索吧!
错误处理
当涉及到加载动作时,很常见的是这些动作最终会抛出一个错误,我们需要以某种方式来处理这个错误。例如,如果我们的视图模型调用的底层loadAllModels API是一个抛出函数,那么我们必须使用try关键字来调用它,以处理任何可能抛出的错误。一种方法是简单地将任何此类错误传播到我们的视图,通过使我们的顶级reload方法也能够抛出:
class BookmarkListViewModel: ObservableObject {
...
func reload() async throws {
bookmarks = try await databaseController.loadAllModels(
ofType: Bookmark.self
)
}
}
然而,在完成上述更改后,我们之前的BookmarkList视图代码将不再编译,因为可刷新的修饰符只接受非抛出的异步闭包。 为了解决这个问题,我们可以,例如,在do/catch语句中包装对视图模型的reload方法的调用——这将让我们捕获任何抛出的错误,以便使用类似于ErrorView覆盖的东西来显示它们:
struct BookmarkList: View {
@ObservedObject var viewModel: BookmarkListViewModel
@State private var error: Error?
var body: some View {
List(viewModel.bookmarks) { bookmark in
...
}
.overlay(alignment: .top) {
if error != nil {
ErrorView(error: $error)
}
}
.refreshable {
do {
try await viewModel.reload()
error = nil
} catch {
self.error = error
}
}
}
}
ErrorView之所以接受一个到错误的绑定,而不仅仅是一个简单的error值,是因为我们希望通过将error属性设置为nil,该视图能够dimiss itself。
虽然上面的实现确实有效,但是在视图模型中封装我们所有的视图状态(包括任何抛出的错误)可能会更好,这将允许我们的视图专注于呈现视图模型提供给它的数据。 为了实现这一点,让我们先将上面的do/catch语句移动到我们的视图模型中——像这样:
class BookmarkListViewModel: ObservableObject {
@Published private(set) var bookmarks: [Bookmark]
@Published var error: Error?
...
func reload() async {
do {
bookmarks = try await databaseController.loadAllModels(
ofType: Bookmark.self
)
error = nil
} catch {
self.error = error
}
}
}
有了上面的更改,我们现在可以使我们的视图更简单,因为事实上,我们的重载方法可以抛出错误,现在某种程度上成为了我们的视图模型的实现细节。 我们的视图现在需要知道的是,有一个error属性,它可以用来显示遇到的任何错误(出于任何原因):
struct BookmarkList: View {
@ObservedObject var viewModel: BookmarkListViewModel
var body: some View {
List(viewModel.bookmarks) { bookmark in
...
}
.overlay(alignment: .top) {
if viewModel.error != nil {
ErrorView(error: $viewModel.error)
}
}
.refreshable {
await viewModel.reload()
}
}
}
很好。但也许这个新的可刷新修饰符最有趣的地方在于,它不仅仅局限于SwiftUI附带的内置下拉刷新功能。事实上,我们也可以用它来支持我们自己的、完全定制的刷新逻辑。
自定义刷新逻辑
为了能够更容易地构建自定义刷新特性,让我们从创建一个执行刷新操作的专用类开始。当传入一个系统提供的RefreshAction值时,它会在动作正在执行时将isPerforming属性设置为true,这反过来将使我们能够在任何想要构建的自定义刷新UI中观察该状态:
class RefreshActionPerformer: ObservableObject {
@Published private(set) var isPerforming = false
func perform(_ action: RefreshAction) async {
guard !isPerforming else { return }
isPerforming = true
await action()
isPerforming = false
}
}
接下来,让我们构建一个RetryButton,它允许用户在给定刷新操作失败时重试该操作。为此,我们将使用新的refresh环境值,它使我们能够访问使用refreshable修饰符注入到视图层次结构中的任何RefreshAction。 然后,我们将把任何这样的动作传递给我们新创建的RefreshActionPerformer的实例——像这样:
struct RetryButton: View {
var title: LocalizedStringKey = "Retry"
@Environment(\.refresh) private var action
@StateObject private var actionPerformer = RefreshActionPerformer()
var body: some View {
if let action = action {
Button(
role: nil,
action: {
await actionPerformer.perform(action)
},
label: {
ZStack {
if actionPerformer.isPerforming {
Text(title).hidden()
ProgressView()
} else {
Text(title)
}
}
}
)
.disabled(actionPerformer.isPerforming)
}
}
}
请注意,当 loading spinner 显示时,我们是如何渲染标签的隐藏版本的。这是为了防止按钮的大小在它空闲和加载状态之间转换时发生变化。
SwiftUI将我们的刷新操作插入到环境中,这是非常强大的,因为这让我们可以定义一个单一的操作,然后该特定视图层次结构中的任何视图都可以使用该操作。因此,无需对BookmarkList视图进行任何更改,如果我们现在简单地将新的RetryButton插入到ErrorView中,那么它将能够执行与List使用的完全相同的刷新操作——仅仅因为该操作存在于视图层次结构的环境中:
struct ErrorView: View {
@Binding var error: Error?
var body: some View {
if let error = error {
VStack {
Text(error.localizedDescription)
.bold()
HStack {
Button("Dismiss") {
self.error = nil
}
RetryButton()
}
}
.padding()
.background(Color.red)
.foregroundColor(.white)
.cornerRadius(10)
}
}
}
多酷啊! 我喜欢苹果在SwiftUI环境中放置这样的数据,并使其公开访问,因为这为构建自定义ui和逻辑提供了许多强大的方法,就像我认为上面的例子所显示的那样。
总结
这就是新的refreshable修饰符,以及如何使用它来实现系统提供的UI模式(如下拉刷新),以及如何使用它来构建完全自定义的重载逻辑。