在构建现代应用程序时,想要响应UI事件而触发某种形式的异步操作是非常普遍的。例如,在下面这个基于SwiftUI的PhotoView ,我们使用一个Task ,每当用户点击该视图的按钮时,就会触发一个异步的onLike 动作。
struct PhotoView: View {
var photo: Photo
var onLike: () async -> Void
var body: some View {
VStack {
Image(uiImage: photo.image)
Text(photo.description)
Button(action: {
Task {
await onLike()
}
}, label: {
Image(systemName: "hand.thumbsup.fill")
})
.disabled(photo.isLiked)
}
}
}
上面的实现无疑是一个好的起点。然而,如果我们的Photo 模型的isLiked 属性在我们的异步调用完成后才更新,那么如果用户连续多次点击按钮,我们可能会出现重复的onLike 调用--因为我们目前只在该属性被设置为true 后才禁用我们的按钮。
现在,我们可以选择在调用onLike 之前执行一次本地模型更新来解决这个问题。然而,这样做会给我们的数据模型引入多个真相来源,这通常是需要避免的事情。因此,在理想情况下,我们希望让我们的PhotoView ,简单地渲染它从其父级视图得到的Photo ,而不需要做任何本地拷贝或修改。
所以,让我们来探讨一下如何在执行动作的时候让我们的按钮自己失效。因为这将涉及到引入额外的状态,而这些状态只与我们的按钮本身有关--让我们把所有的代码封装在一个新的AsyncButton 视图中,在等待其异步操作完成时,也会显示一个加载旋钮。
struct AsyncButton<Label: View>: View {
var action: () async -> Void
@ViewBuilder var label: () -> Label
@State private var isPerformingTask = false
var body: some View {
Button(
action: {
isPerformingTask = true
Task {
await action()
isPerformingTask = false
}
},
label: {
ZStack {
// We hide the label by setting its opacity
// to zero, since we don't want the button's
// size to change while its task is performed:
label().opacity(isPerformingTask ? 0 : 1)
if isPerformingTask {
ProgressView()
}
}
}
)
.disabled(isPerformingTask)
}
}
如果你对上述按钮的label 闭包所注解的@ViewBuilder 属性感到好奇,那么请查看"用结果生成器属性注解属性"。
由于我们新的AsyncButton 的 API 与 SwiftUI 内置的Button 类型完全匹配,我们将能够通过简单地改变它所创建的按钮的类型来更新我们的PhotoView ,并删除其action 闭包中的Task (因为我们现在可以在该闭包中直接使用await ,因为它用async 关键字标记)。
struct PhotoView: View {
var photo: Photo
var onLike: () async -> Void
var body: some View {
VStack {
Image(uiImage: photo.image)
Text(photo.description)
AsyncButton(action: {
await onLike()
}, label: {
Image(systemName: "hand.thumbsup.fill")
})
.disabled(photo.isLiked)
}
}
}
非常好!现在,如果这是我们应用程序中唯一需要执行上述异步操作的地方,那么我们可以在这里把事情结束。但是,假设我们的代码库还包含许多其他类似的async-功能调用的按钮,而且我们也想在这些地方重用我们的新AsyncButton 。
为了使事情更有趣,我们还可以说,在我们代码库的某些部分,我们不希望在执行我们的异步操作时显示一个加载旋钮,而且我们还希望有一个选项可以同时执行多个操作。
为了支持这些选项,让我们引入一个ActionOption 枚举,这将使我们代码库的每个部分能够调整它希望我们的AsyncButton 在执行其动作时的行为:
extension AsyncButton {
enum ActionOption: CaseIterable {
case disableButton
case showProgressView
}
}
我们之所以让新的枚举符合 CaseIterable是因为这样做可以让我们使用该协议自动生成的allCases 属性轻松地默认启用所有选项(从而使这是一个向后兼容的变化)。然后,在激活这些行为之前,我们将检查指定的选项是否包含disableButton 或showProgressView - 就像这样:
struct AsyncButton<Label: View>: View {
var action: () async -> Void
var actionOptions = Set(ActionOption.allCases)
@ViewBuilder var label: () -> Label
@State private var isDisabled = false
@State private var showProgressView = false
var body: some View {
Button(
action: {
if actionOptions.contains(.disableButton) {
isDisabled = true
}
if actionOptions.contains(.showProgressView) {
showProgressView = true
}
Task {
await action()
isDisabled = false
showProgressView = false
}
},
label: {
ZStack {
label().opacity(showProgressView ? 0 : 1)
if showProgressView {
ProgressView()
}
}
}
)
.disabled(isDisabled)
}
}
有了这些变化,我们的AsyncButton ,现在已经足够灵活,可以适应许多不同的使用情况,但在称其为 "完成 "之前,仍有一些修饰的余地,使其API和内部行为更加完美。
首先,让我们使用一个延迟任务来显示我们的按钮ProgressView ,如果它的任务最终需要超过150毫秒的时间来完成。这样,我们就可以避免在执行快速操作时快速显示和隐藏那个加载旋钮,这是异步UI代码中非常常见的一种故障。
struct AsyncButton<Label: View>: View {
var action: () async -> Void
var actionOptions = Set(ActionOption.allCases)
@ViewBuilder var label: () -> Label
@State private var isDisabled = false
@State private var showProgressView = false
var body: some View {
Button(
action: {
if actionOptions.contains(.disableButton) {
isDisabled = true
}
Task {
var progressViewTask: Task<Void, Error>?
if actionOptions.contains(.showProgressView) {
progressViewTask = Task {
try await Task.sleep(nanoseconds: 150_000_000)
showProgressView = true
}
}
await action()
progressViewTask?.cancel()
isDisabled = false
showProgressView = false
}
},
label: {
ZStack {
label().opacity(showProgressView ? 0 : 1)
if showProgressView {
ProgressView()
}
}
}
)
.disabled(isDisabled)
}
}
请注意,根据应用程序和我们希望让我们的AsyncButton 执行的操作类型,我们可能要向上或向下调整150毫秒的值。我们可能还想引入逻辑,在给定的时间内始终显示加载旋钮,以便给用户适当的反馈,说明确实执行了一个动作。
最后,让我们也引入两个方便的API--一个是当我们想渲染一个AsyncButton ,显示一个Text 作为它的标签,另一个是当我们想使用一个系统图标显示的Image 。这可以通过使用通用类型约束来扩展我们的按钮类型--像这样:
extension AsyncButton where Label == Text {
init(_ label: String,
actionOptions: Set<ActionOption> = Set(ActionOption.allCases),
action: @escaping () async -> Void) {
self.init(action: action) {
Text(label)
}
}
}
extension AsyncButton where Label == Image {
init(systemImageName: String,
actionOptions: Set<ActionOption> = Set(ActionOption.allCases),
action: @escaping () async -> Void) {
self.init(action: action) {
Image(systemName: systemImageName)
}
}
}
有了以上这些,我们现在可以使用我们新的基于Image 的初始化器,以一种非常轻量级的方式在我们的PhotoView 中构建AsyncButton 。
struct PhotoView: View {
var photo: Photo
var onLike: () async -> Void
var body: some View {
VStack {
Image(uiImage: photo.image)
Text(photo.description)
AsyncButton(
systemImageName: "hand.thumbsup.fill",
action: onLike
)
.disabled(photo.isLiked)
}
}
}
非常好!当然,我们可以通过多种方式继续迭代我们的AsyncButton 类型,使其更加灵活,或者采用不同类型的设计(例如在其标签旁边显示其加载旋钮),但我希望这篇文章能给你一些灵感,告诉你如何将同步动作集成到SwiftUI视图中,以及如何将这种视图通用化,使其更加通用和可重用。
谢谢你的阅读!