Task {} syntax sugar

enqueue immediately and don't wait.

It would be really nice to have this feature.

But why is it so conspicuously missing?

3 Likes

I had originally hoped to address it during task executors, but those ended up going in a different direction.

We need to solve it but given the few proposals I linked above let's have them pan our first in Swift Evolution and then see what the best shape to do the "task immediately on actor" will be. Sadly inferring it from code like Task { await actor.hi() } isn't trivial, but may be something we'd consider if possible... otherwise it'd take shape of some kind of Task(on: target) { target.hi() } though we also need typesystem features to be able to express this -- there is no way to express "the isolation of the closure depends on a SPECIFIC paremeter passed to this method" so that's something the above linked proposal's future work count introduce and then we can do this trick.

4 Likes

People here already gave good insights, but wanted to point out that IMHO firing any task (even fire and forget) from UI code is a bad smell and should be avoided as it's easy to lose track or even land in unwanted state if some other parts are involved. So probably it's actually good it's annoying to write.

People mention function colouring often, but don't think it's an issue when you trying model all your state change handling functions green and keep red ones away as you can.

5 Likes

I don’t agree, the purpose this doesn’t exist is because firing a Task has some subtle implications people should think carefully about. It’s also such a niche use case that I don’t think has many practical usages, it just promotes bad practices.

Here’s a few things I don’t understand.

How often do you spin off tasks that call a single function and do nothing with the result?
Because if you need the result or do anything else after that one line, you’re back in Task {} land.

What happens if that function throws? Since you’re not in a nested context, you might assume you could just wrap the whole thing in a do {} catch {} but that wouldn’t work.

How do you handle cancellation with this solution? Is it really such a common operation that a user could tap the button 100 times in a row and you want to actually run the function 100 times? No cancellation?

How do you distinguish between Task and Task.detached with this solution? Besides inheriting the actor context, which might not matter much, you’re inheriting the Task local values with Task but not with Task.detached. Do you propose to add two new keywords to the language to support this use case?

How do you set the Task priority? What if you have a Button that sets a background task? A cleanup task? A ThisIsSuperUrgentDoItNow task? Are all your Tasks really equally important?

The thing is, the usage example you provided I think it’s a bad practice and it shouldn’t be made any simpler, it’s already too simple and people ignore a lot of subtle details all too often.

If you really want to achieve this, you can create your own extension on Button, but in general I think there should be very few situations where you actually need to create a Task and every place you do should be purposeful and thoughtful.

If you want to go the ViewModel route, I suggest using async functions that you call from SwiftUI using the .task modifier if you need to tie the lifetime of the call to some SwiftUI lifecycle, or just call Task {} inside the ViewModel and handle the cases appropriately.

But as a middle ground for Button, here's an implementation of a custom one I use in my projects, probably there's a lot of room for improvement even for this.

private let minimumTargetTimeIfProgressShownInMs = 500

struct AsyncButton<Label: View>: View {
    var actionOptions = Set(ActionOption.allCases)
    var action: () async -> Void
    let priority: TaskPriority
    @ViewBuilder var label: () -> Label

    init(actionOptions: Set<ActionOption> = Set(ActionOption.allCases),
         priority: TaskPriority = .userInitiated,
         action: @escaping () async -> Void,
         @ViewBuilder label: @escaping () -> Label)
    {
        self.actionOptions = actionOptions
        self.action = action
        self.priority = priority
        self.label = label
    }

    @State private var isDisabled = false
    @State private var showProgressView = false
    @State private var runningId = 0

    private func generateNewRunningId() {
        let currentId = runningId
        var nextId = Int.random(in: 1...10_000_000)

        while nextId == currentId {
            nextId = Int.random(in: 1...10_000_000)
        }

        runningId = nextId
    }

    private func resetRunningId() {
        runningId = 0
    }

    var body: some View {
        Button(
            action: {
                if actionOptions.contains(.disableButton) {
                    isDisabled = true
                }

                generateNewRunningId()
            },
            label: {
                ZStack {
                    label().opacity(showProgressView ? 0 : 1)

                    if showProgressView {
                        ProgressView()
                    }
                }
            }
        )
        .disabled(isDisabled)
        .task(id: runningId, priority: priority) {
            guard runningId != 0 else { return }

            var progressViewTask: Task<Void, Error>?

            if actionOptions.contains(.showProgressView) {
                progressViewTask = Task(priority: priority) {
                    try await Task.sleep(for: .milliseconds(150))
                    showProgressView = true
                }
            }

            let elapsedTime = await ContinuousClock().measure {
                await action()
            }
            
            // if task took more than 150 milliseconds, we know the spinner was shown
            if elapsedTime > .milliseconds(150) {
                let diff = elapsedTime - .microseconds(150)

                let targetTime = minimumTargetTimeIfProgressShownInMs

                // if the spinner was not shown for at least 350 milliseconds, we'll just wait a little to make sure the animation isn't too jarring  
                if diff < .milliseconds(targetTime) {
                    let remainingTimeUntilTarget = Duration.milliseconds(targetTime) - diff
                    try? await Task.sleep(for: remainingTimeUntilTarget)
                }
            }

            progressViewTask?.cancel()

            isDisabled = false
            showProgressView = false
            resetRunningId()
        }
    }
}

extension AsyncButton {
    enum ActionOption: CaseIterable {
        case disableButton
        case showProgressView
    }
}

// original inspiration for the AsyncButton: https://www.swiftbysundell.com/articles/building-an-async-swiftui-button/
5 Likes

well if liking a user in a dating app and using swift concurrency, there will inevitably be some call down that execution path that that is async. where do you think that call should be made?

Either in the View or a ViewModel is fine in my opinion, but still I'd suggest reverting the state of like if the action failed. Try use any social media app when offline and they'll revert the like after a few seconds and for that you probably need more than a single like in Task {}.

Also, if a user spams (or simply taps it twice) the like button and you're starting a bunch of Task {}, know that they might (will) run in an undefined order. It is not DispatchQueue.main.async, it doesn't enqueue the task in a deterministic manner. Here's an example, post is in a disliked stated: 1. user taps like, 2. user taps dislike -> Swift concurrentcy might run task 2 first and then task 1 and because you don't handle cancellation, you will send this data in the incorrect order to your backend

Not sure about the example, but imho putting logic to ViewModel at least is a good candidate here. Also good points given already here on why. :top:

SwiftUI View is a function of state so it's better to keep it as dumb as you can.

I agree with all of this. In my opinion, Task { ... } is already too lightweight.

One thing that I've noticed is a tendency for developers to think Swift Concurrency is some kind of magic, and to forget the concurrency best-practices they learned using GCD. To put it in GCD terms, Task { ... } (and Task.detached { ... }) are basically equivalent to DispatchQueue.async { ... }.

Sure, there are differences in the implementation, but as a user of these APIs you can treat them as equivalent.

For a UI application, it's generally not a good idea to DispatchQueue.async { ... } all of your event handlers, because those handlers are typically updating state. Once you enqueue the event handler you've already committed to some future state, but the UI isn't going to see it immediately and neither will other operations. That means the UI can feel unresponsive (like it's being streamed or something -- there's that extra bit of latency), and you can create logic errors where your state ends up with an inconsistent combination of values.

Developers have known this for a long time when using GCD, but I've seen (many times) that they forget or just don't connect the dots when it comes to Swift Concurrency's Task { ... }.

Maybe it's not clear enough at the point of use, or maybe developers just need time to get used to it. I remember that in the early days of GCD there was a lot of .async overuse as well.

2 Likes

This isn't true. Most importantly, Task isn't FIFO like DispatchQueue.async, leading to major issues when trying to understand when your code is executing. Equating the two actively harms developer understanding of Task.

As to your point, as with DispatchQueue, the key to avoiding apparent latency is avoiding async work altogether. I personally don't recall any real over use of async, but overuse of queues in general is an ongoing issue. Unfortunately, while Swift concurrency helps there, it's still not efficient enough to enqueue arbitrary amounts of work without thought.

New concurrency APIs could help here, as already discussed, but I don't see anything on the horizon.

6 Likes

Yeah the point I was making is that, when you use Task {}, it's helpful to think "would I use DispatchQueue.async here?"

There are some differences between them, but they both delay work to happen at some point in the future, and that's where I find the biggest problems tend to be introduced, and why I don't think this operation should be sugared to be even more brief.

As I said, it may be that Task {} isn't clear enough about the delay that it's introducing, or it may just be a familiarity issue. Either way, I don't think that sugar to make the syntax even shorter is a productive direction.

1 Like