Is There A Better AsyncButton?
Can We Tap Into Button's Full Async Potential... Or Are We Pressing Luck?
Button. The classic and (arguably) most basic interactive component of modern interfaces.
Yet, in the age of Swift 6 and concurrency, it can be baffling that Button’s evolution into an AsyncButton remains an open topic. When Googled, there are numerous implementations demo’d and written about.
“But Captain,” you may be asking. “Is it worth sailing these seas if they’ve already been explored?”
“Aye,” said The Captain. “I do.”
Charting Course
In this article, we will explore three basic implementations of AsyncButton:
AsyncGoogledButton - based on your typical Google answer
AsyncStructuredButton - an attempt to fix the Googled answer with some structure
AsyncTaskButton - an exploration into a more SwiftUI-based solution
To test these out, I’ve created a simple macOS App (found on Github) that allows me to trigger an async (throwing) function that counts from 1 to 10, printing the current value every second.
for i in 1...10 {
try await Task.sleep(nanoseconds: 1000000000)
print("Task Count: \(i)")
}
The app looks like this:
It’s worth noting that for each type of AsyncButton, the buttons Task1 and Task2 switch the detail view on the right. This will be important to remember shortly.
The (Potentially) Unstructured Problem With A Googled AsyncButton
The typical answer on Google (or ChatGPT, if we want to be cheeky) is usually something like:
Button(
action: {
Task {
try await action()
}
},
label: {
label()
}
)
Perhaps, at a baseline, this is enough for many folks out there. However, what happens if the task is long running and the user decides they want to navigate away from the action?
In our testing app, I mentioned that for each implementation, we can switch between two detail views. If we start our task in the first view, and then switch to our second view:
We see that Task 1 continues to count. Even as we trigger Task 2, we see that Task 1 doesn’t stop until it hits 10. This is because we’ve triggered an unstructured task.
Disclaimer: maybe it’s an ok scenario to have an unstructured task for your use case. If the task was to trigger a save operation, maybe we want to allow that to continue even after the user navigates away. To read more, about when you may want to use an unstructured task, check out this article.
However, there are certainly cases where we would want a continuous task to stop (e.g. we’re awaiting values that are only relevant to the screen where the task was triggered). Simply starting a Task from a synchronous context immediately releases control over the Task, itself, leaving you at the mercy of it’s own ability to manage its own lifespan.
Come join The Captain and the rest of the Captain’s Crew at Office Hours!
Open to all paid subscribers! Join at the button below and take advantage of a special promo discount!
Adding Structure to AsyncButton’s Wild Nature
Fortunately, adding view-dismissal-safety (or structure) to our AsyncButton is rather simple:
@State var holdTask: Task<Void, Error>? = nil
...
Button(
action: {
holdTask = Task {
try await action()
}
},
label: {
label()
}
)
.onDisappear {
holdTask?.cancel()
}
We store the Task into a State property so that we can then reference it within onDisappear and cancel the task. This manually ties the life of the task to the appearance of the Button view.
Note: This leads to possible enhancements where we expose the task outside of AsnycButton, possibly to expand our structured handling to other views or objects. For the sake of simplicity in this article, I’ll leave it to you to explore those options.
One Step Further Using .task
It’s great that we were able to add structure to AsyncButton by having it be tied to the appearance of it’s calling view. However, as Captain SwiftUI, I have to ask: what about using the .task modifier? After all, it promises to automatically tie the life of a task to the life of the calling view.
Let’s give this a go.
@State private var isRunning = false
...
Button(action: {
isRunning = true
}) {
label()
}
.task(id: isRunning) {
guard isRunning else { return }
try? await action()
isRunning = false
}
Three less lines of code, no holding of a task property, and automatically tied to the life of a view. If this structured approach fits your use-case, then this seems like the ultimate answer.
A Cautionary Tale of The Sneaky Error
You might have noticed in the task implementation I used an optional try?
. Because we’re passing in an async throwable closure, the task modifier actually complains about the throw not being handled. We didn’t get this complaint with the other implementations, but, out of curiosity… what happens if we to swap “?” for a “!”…
🚨 MAYDAY! MAYDAY! 🚨
We get… a crash?!
Fatal error: 'try!' expression unexpectedly raised an error: Swift.CancellationError()
What the heck is a CancellationError and why are we getting this? As it turns out, CancellationError is thrown automatically when a task is cancelled. Which means if we add an “!” in our structured implementation, we get the same crash over there, too.
This might not seem entirely relevant to our goals here, but it’s worth mentioning this because it’s one of the most overlooked Task-related errors. Your use-case may accept using a try? to simply handle this error. However, if you need more granular control, we can handle with a do-catch block:
@State private var isRunning = false
...
Button(action: {
isRunning = true
}) {
label()
}
.task(id: isRunning) {
guard isRunning else { return }
do {
try await action()
} catch is CancellationError {
print("Task was cancelled")
} catch {
if Task.isCancelled {
print("Task was cancelled")
} else {
print(error.localizedDescription)
}
}
isRunning = false
}
This works for both the task modifier and structured approaches. It’s pointless for the unstructured approach because, well, there’s nothing to trigger a cancellation.
Conclusion: We Can Build a Better Button (and A Better One, and A Better One…)
We’ve explored previously traversed seas and pushed a little beyond, proving we can provide structure and better handling to AsyncButtons… if we need it.
That’s really at the crux of the AsyncButton topic, and probably why there is no native offering. There’s no few async contexts that can be easily managed by just one component.
I love the attempts, including this extensive and impressive one by Thomas Durance (which he goes through in this article). But ultimately, you may need to opt for building the AsyncButton that fits your needs best.
Thanks a lot for mentioning ButtonKit!
If you can see improvements in the task handling in my lib, feel free to open an issue or a pull request.
Open source software builds on knowledge sharing ;)
Dean
a.k.a Thomas Durand