Attach async tasks to SwiftUI views using a trigger mechanism. See this for examples.
dependencies: [
.package(url: "https://github.com/lukepistrol/TaskTrigger.git", from: "0.1.0"),
],
Make sure to import TaskTrigger
in any location you'd want to use it.
The documentation is available here.
You can also have a look at the sample project
When using Swift's structured concurrency in SwiftUI it is good practice to tie the tasks to the relevant view's lifetime in order to support task cancellation should the view be dismissed.
Note Usually a task might not take all that long that we would even care for cancellation. But imagine downloading some large amounts of data from a remote server which – depending on the network connection – could take a consiterable amount of time. When the user decides to dismiss the view it might be good to also cancel the task to not keep doing no longer necessary work.
Of course this does not guarantee that the child tasks will instantly stop but rather they will be informed via
Task.isCancelled
that they've been cancelled. If or how they handle cancellation is entirely up to the implementation of the child task.
This can already be achieved by using the task(id:priority:_:)
view modifier.
However this requires additional housekeeping for the id
.
In a primitive example where we just want to trigger some task we might use a Bool
for the id
:
@State var triggerTask: Bool = false
var body: some View {
Button("Do Something") {
triggerTask = true
}
.task(id: triggerTask) {
guard triggerTask else { return }
await someAsyncOperation()
triggerTask = false
}
}
Note We need to check that
triggerTask
is indeedtrue
, and otherwise return. We also need to resettriggerTask
at the end of execution. Otherwise another tap wouldn't trigger thetask(id:priority:_:)
again.The first step is crucial becuase we would trigger the task again from in itself when we reset
triggerTask
at the end.
This makes even more sense in a more complicated example where we have a list of views where we would like to do something based on some identifier:
@State var triggerTaskId: Int?
var body: some View {
List(0..<10) { index in
Button("Item \(index)") {
triggerTaskId = index
}
}
.task(id: triggerTaskId) {
guard let triggerTaskId else { return }
await someAsyncOperation(for: triggerTaskId)
triggerTaskId = nil
}
}
Note In this case we have an optional integer and we set it to the index of the button in the list once pressed. We need to unwrap the optional value and reset it to
nil
afterwards.
This approach, while it really works well, comes with a lot of overhead directly in our view. Also we have to call the state value directly from within our task which might be cumbersome when we hold the state in a view model.
To make things simpler on the caller's side let's wrap all of this functionality inside
a simple type TaskTrigger
.
@State var trigger = TaskTrigger<Int>()
var body: some View {
List(0..<10) { index in
Button("Item \(index)") {
trigger.trigger(value: index)
}
}
.task($trigger) { index in
await someAsyncOperation(for: index)
}
}
- We declare a new state variable
trigger
and initialize theTaskTrigger
of typeInt
. - In our button we call the
trigger(value:id:)
method on ourtrigger
and pass in our value. - We attach a new variant of the
task
view modifier to our view and bind it to ourtrigger
. - The body will only execute when the
trigger
was triggered. The value we passed into thetrigger(value:id:)
method earlier gets passed into the closure as an argument. - All cancellation related handling, sanity checking, as well as resetting the state is handled automatically behind the scenes.
Note You might wonder why there's an optional parameter
id
on thetrigger(value:id:)
method. By default this will create a newUUID
whenever the method is called. This means we can tap the same button multiple times and prior operations will get cancelled if they are still running.In case you don't want that to happen explicitly set the
id
parameter and it won't cancel prior operations since both thevalue
andid
are still the same.
For triggers that don't need to attach a value, we can simply use PlainTaskTrigger
(which is a
typealias for TaskTrigger<Bool>
):
@State var trigger = PlainTaskTrigger()
var body: some View {
Button("Do Something") {
trigger.trigger()
}
.task($trigger) {
await someAsyncOperation()
}
}
To make it even simpler to use when using a TaskTrigger
with a button, we can also use
TaskTriggerButton
. The following example is equivalent to the previous example:
TaskTriggerButton("Do Something") {
await someAsyncOperation()
}
If you have any ideas on how to take this further I'm happy to discuss things in an issue.