刷新SwiftUI视图的教程

1,525 阅读5分钟

在WWDC21上,苹果推出了一个新的SwiftUI API,使我们能够将刷新动作附加到任何视图上,这反过来又给了我们对非常流行的拉动刷新机制的本地支持。让我们来看看这个新的API是如何工作的,以及它如何使我们能够建立完全自定义的刷新逻辑。

由async/await驱动

为了能够知道刷新操作何时完成,SwiftUI使用了async/await模式,该模式将在Swift 5.5中引入(预计将在今年晚些时候与苹果的新操作系统一起发布)。因此,在我们开始采用新的刷新API之前,我们需要一个async-marked函数,只要我们的视图的刷新动作将被触发,我们就可以调用它。

举个例子,假设我们正在开发一个包括某种形式的书签功能的应用程序,我们已经建立了一个BookmarkListViewModel ,负责为我们的书签列表UI提供数据。为了使这些数据能够被刷新,我们添加了一个异步的reload 方法,该方法反过来调用一个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 视图中应用新的refreshable 修改器--像这样:

struct BookmarkList: View {
    @ObservedObject var viewModel: BookmarkListViewModel

    var body: some View {
        List(viewModel.bookmarks) { bookmark in
            ...
        }
        .refreshable {
            await viewModel.reload()
        }
    }
}

只要这样做,我们的List-powered UI现在就可以支持pull-to-refresh。当我们的刷新动作被执行时,SwiftUI会自动隐藏和显示一个加载旋钮,甚至会确保没有重复的刷新动作在同一时间被执行。真的很酷!

作为额外的奖励--鉴于Swift支持第一类函数--我们甚至可以将我们的视图模型的reload 方法直接传递给refreshable 修改器,这给了我们一个稍微紧凑的实现:

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 视图代码不再能编译,因为refreshable 修改器只接受不抛出的异步闭包。为了解决这个问题,我们可以,例如,将对视图模型的reload 方法的调用包裹在一个do/catch 语句中--这将让我们捕捉到任何抛出的错误,以便使用类似于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 来解散自己。 要了解更多,请查看我的SwiftUI的状态管理系统指南

虽然上述实现确实有效,但可以说,将我们所有的视图状态(包括任何抛出的错误)封装在我们的视图模型中会更好,这将允许我们的视图专注于渲染我们的视图模型所提供的数据。为了实现这一点,让我们先把上面的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
        }
    }
}

有了上面的改变,我们现在可以使我们的视图变得更简单,因为我们的reload 方法可以抛出错误的事实现在成了我们视图模型的一个实现细节。我们的视图现在只需要知道有一个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()
        }
    }
}

非常好。但是,这个新的refreshable 修改器最有趣的方面是,它不仅仅局限于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)
        }
    }
}

注意我们是如何在显示我们的加载旋钮的同时渲染一个隐藏版本的标签的。这是为了防止按钮的尺寸在其空闲和加载状态之间转换时发生变化。

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模式(如拉动刷新),又能用于构建完全自定义的重载逻辑。

谢谢你的阅读!