Lukas Pistrol Logo

Lukas Pistrol

Swift Concurrency: Task Trigger

A couple of weeks ago I posted a thread on X where I shared some thoughts on how to handle calling async functions from SwiftUI. I thought it'd publish it as an article on my website too.

It all started with @azamsharp asking how one handles the following scenario:

Button("Save") {
  Task {
    await saveProduct()
  }
}

Now I want to share some thoughts and considerations on this topic.

What's wrong about this approach?

While the approach shown in said tweet might work in prototyping I wouldn't use it in production. This is because the created Task actually isn't attached to the view and therefore doesn't get cancelled should the view dismiss early. This might not be a problem for small workloads that execute almost immediately but when working with any external APIs, databases, or expensive compute operations, it might as well be.

In any of aforementioned cases one should expect a task to get cancelled when dismissing a view prematurely. E.g. when loading a huge PDF from some server and before succession dismissing a view the task would live on and continue loading the PDF.

SwiftUI allows you to attach a task to a View using the task modifier. While the standard task {} might be something you are very well aware of, there's a version with a parameter id available as well. The task {} modifier will execute whenever the view is created. task(id:priority:_:) allows you to trigger execution. In particular it will execute whenever id changes.

However you might want to reset the id after execution and this needs to be done manually in every task you add to your view. We used this approach on a couple of projects and improved it over time. The solution we came up with is quite neat and tidy in my opinion.

Introducing TaskTrigger

This basically holds a state active & inactive and both a trigger & cancel method. It also provides an unique id to any state change in case you call trigger more than once with the same value.

public struct TaskTrigger<Value: Equatable>: Equatable where Value: Sendable {

    internal enum TaskState<T: Equatable>: Equatable {
        case none
        case active(value: T, uuid: UUID? = nil)
    }

    public init() {}

    internal var state: TaskState<Value> = .none

    mutating public func trigger(value: Value, id: UUID? = .init()) {
        self.state = .active(value: value, uuid: id)
    }

    mutating public func cancel() {
        self.state = .none
    }
}

public typealias PlainTaskTrigger = TaskTrigger<Bool>

public extension PlainTaskTrigger {
    mutating func trigger() {
        self.state = .active(value: true)
    }
}

Then we can add our own version of the task modifier which takes a binding to a TaskTrigger and attaches a task to the applied view. In case the trigger state is active we execute the async closure and also provide the attached value as an argument. At the end the trigger resets.

struct TaskTriggerViewModifier<Value: Equatable>: ViewModifier where Value: Sendable {

    typealias Action = @Sendable (_ value: Value) async -> Void

    internal init(
        trigger: Binding<TaskTrigger<Value>>,
        action: @escaping Action
    ) {
        self._trigger = trigger
        self.action = action
    }

    @Binding 
    private var trigger: TaskTrigger<Value>
    
    private let action: Action

    func body(content: Content) -> some View {
        content
            .task(id: trigger.state) {
                // check that the trigger's state is indeed active and obtain the value.
                guard case let .active(value, _) = trigger.state else {
                    return
                }

                // execute the async work.
                await action(value)

                // if not already cancelled, reset the trigger.
                if !Task.isCancelled {
                    self.trigger.cancel()
                }
            }
    }
}

We can add View extensions as well:

public extension View {
    func task<Value: Equatable>(
        _ trigger: Binding<TaskTrigger<Value>>,
        _ action: @escaping @Sendable @MainActor (_ value: Value) async -> Void
    ) -> some View where Value: Sendable {
        modifier(TaskTriggerViewModifier(trigger: trigger, action: action))
    }

    func task(
        _ trigger: Binding<PlainTaskTrigger>,
        _ action: @escaping @Sendable @MainActor () async -> Void
    ) -> some View {
        modifier(TaskTriggerViewModifier(trigger: trigger, action: { _ in await action() }))
    }
}

Using TaskTrigger in action

This first example simply acts as a boolean trigger without any attached value. Once you call trigger(), the async task will execute. By being attached to the view it will also get cancelled once the view gets dismissed.

struct ContentView: View {

    @State private var asyncTrigger: TaskTrigger<Bool> = TaskTrigger()
    
    var body: some View {
        Button("Do async stuff") {
            asyncTrigger.trigger()
        }
        .task($asyncTrigger) {
            await someAsyncFunction()
        }
    }    
}

This example attaches any equatable value (e.g. an integer) to the trigger. Once you call trigger(value:), the async task will execute and the attached value is passed to the task. This might be useful when fetching some API based on variable parameters.

struct ContentView: View {

    @State private var asyncTrigger: TaskTrigger<Int> = TaskTrigger()
    
    var body: some View {
        Button("Do async stuff") {
            asyncTrigger.trigger(value: 42)
        }
        .task($asyncTrigger) { value in
            await someAsyncFunctionWithInteger(value)
        }
    }    
}

TaskTrigger as a Swift Package

To make things easier I created a TaskTrigger repo on GitHub which can just be adopted from any Project working on Swift 5.8+. It also has an even more technical writeup on how the mechanic works under the hood and a sample project to see it in action.

If you have any further questions hit me up on X. I'm happy to discuss this topic further 🙂

References

Here I just wanted to leave some links for your convenience:

Another article you might like: