Articles, podcasts and news about Swift development, by John Sundell.

Building an asynchronous SwiftUI button

Published on 22 Dec 2021
Discover page available: SwiftUI

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 {
                    // 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)
    }
}

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!

Support Swift by Sundell by checking out this sponsor:

Bitrise

Bitrise: Kick off 2022 by easily setting up fast, rock-solid continuous integration for your project with Bitrise. In just a few minutes, you can set up builds, tests, and automatic App Store and beta deployments for your project, all running in the cloud on every pull request and commit. Try it for free today.