When building modern applications, it’s incredibly common to want to trigger some form of asynchronous action in response to a UI event. For example, within the following SwiftUI-based PhotoView
, we’re using a Task
to trigger an asynchronous onLike
action whenever the user tapped that view’s button:
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)
}
}
}
The above implementation is definitely a good starting point. However, if our Photo
model’s isLiked
property isn’t updated until after our asynchronous call has completed, then we might end up with duplicate onLike
calls if the user taps the button multiple times in quick succession — since we’re currently only disabling our button once that property has been set to true
.
Now, we could choose to fix that issue by performing a local model update right before we call onLike
. However, doing so would introduce multiple sources of truth for our data model, which is something that’s typically good to avoid. So, ideally, we’d like to keep having our PhotoView
simply render the Photo
model that it gets from its parent view, without having to make any local copies or modifications.
So, instead, let’s explore how we could make our button disable itself while its action is being performed. Since that’ll involve introducing additional state that’s only really relevant to our button itself — let’s encapsulate all of that code within a new AsyncButton
view that’ll also display a loading spinner while waiting for its async action to complete:
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 {
label().opacity(isPerformingTask ? 0 : 1)
if isPerformingTask {
ProgressView()
}
}
}
)
.disabled(isPerformingTask)
}
}
If you’re curious about the @ViewBuilder
attribute that the above button’s label
closure is annotated with, then check out “Annotating properties with result builder attributes”.
Since our new AsyncButton
has an API that perfectly matches SwiftUI’s built-in Button
type, we’ll be able to update our PhotoView
by simply changing the type of button that it creates, and by removing the Task
within its action
closure (since we can now use await
directly within that closure, as it’s marked with the async
keyword):
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)
}
}
}
Very nice! Now, if that was the only place within our app in which we needed to perform the above kind of asynchronous action, then we could wrap things up here. But let’s say that our code base also contains many other, similar async
-function-calling buttons, and that we’d like to reuse our new AsyncButton
within those places as well.
To make things even more interesting, let’s also say that within some parts of our code base, we don’t want to show a loading spinner while our async action is being performed, and that we’d also like to have the option to perform multiple actions at the same time.
To support those kinds of options, let’s introduce an ActionOption
enum, which will enable each part of our code base to tweak how it wants our AsyncButton
to behave when performing its action:
extension AsyncButton {
enum ActionOption: CaseIterable {
case disableButton
case showProgressView
}
}
The reason that we’re making that new enum conform to CaseIterable
is because doing so lets us easily default to enabling all options (and thus making this a backward compatible change) using that protocol’s automatically generated allCases
property. We’ll then check whether the specified options contain either disableButton
or showProgressView
before activating those behaviors — like this:
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)
}
}
With those changes in place, our AsyncButton
is now flexible enough to accommodate many different use cases, but there’s still room for a few finishing touches to make both its API and internal behavior even nicer before calling it done.
First, let’s use a delayed task to only show our button’s ProgressView
if its task ended up taking longer than 150 milliseconds to complete. That way, we’ll avoid quickly showing and hiding that loading spinner when performing fast operations, which is a very common type of glitch within asynchronous UI code:
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)
}
}
Note that, depending on the app and the type of operations that we’re looking to have our AsyncButton
perform, we might want to tweak that 150 millisecond value either upwards or downwards. We might also want to introduce logic to always show the loading spinner for a given duration, to give the user proper feedback that an action was indeed performed.
Finally, let’s also introduce two convenience APIs — one for when we want to render an AsyncButton
that shows a Text
as its label, and one for when we want to use a system icon displayed using an Image
. That can be done by extending our button type using generic type constraints — like this:
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)
}
}
}
With the above in place, we can now use our new Image
-based initializer to construct the AsyncButton
within our PhotoView
in a very lightweight way:
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)
}
}
}
Very nice! Of course, there are multiple ways that we could continue iterating on our AsyncButton
type to make it even more flexible, or to adopt a different type of design (for example by show its loading spinner next to its label instead), but I hope that this article has given you some inspiration as to how async actions can be integrated into SwiftUI views, and how such views can be generalized to become much more versatile and reusable.
If you have any questions, comments, or feedback, then feel free to reach out via email.
Thanks for reading!