2

I tried this post https://www.swiftbysundell.com/articles/building-an-async-swiftui-button/ where the code is from but have added the priority constant in the struct. The Button is doing what it should be (only show the ProgressView when the sleep time is over and then only so long until the action is done) if I use the priority of .utility but not if I use .userInitiated. Why is that? Shouldn't both be cancelled by the .cancel() function before the showProgressView = true statement gets fired?

When tapping on the button ".userInitiated" the console output is:

  1. task cancelled
  2. task created

video: https://i.stack.imgur.com/zhsnQ.jpg

struct ContentView: View{
    @State var count = 0
    @State var showProg = false
    func heavyTask() async throws ->Int{
        var k = count
        for _ in 0..<5_000_000{
            k += 1
        }
        return k
    }
    var body: some View{
        VStack{
            AsyncButton(action: {try? await count = heavyTask()},actionOptions: [.showProgressView], label: {
                Text(".utility")
            }, priority: .utility)
            AsyncButton(action: {try? await count = heavyTask()},actionOptions: [.showProgressView], label: {
                Text(".userInitiated")
            }, priority: .userInitiated)
            Text("result: \(count)")
        }
    }
}

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

    @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(priority: priority) {
                            try await Task.sleep(nanoseconds: 900_000_000)
                            showProgressView = true
                            print("task created")
                        }
                        
                    }

                    await action()
                    progressViewTask?.cancel()
                    print("task cancelled")
                    isDisabled = false
                    showProgressView = false
                }
            },
            label: {
                ZStack {
                    label().opacity(showProgressView ? 0 : 1)

                    if showProgressView {
                        ProgressView()
                    }
                }
            }
        )
        .disabled(isDisabled)
    }
}


extension AsyncButton {
    enum ActionOption: CaseIterable {
        case disableButton
        case showProgressView
    }
}
sheldor
  • 115
  • 12

1 Answers1

1

The Swift concurrency system can prioritize tasks. But, the priority behavior you are experiencing is distracting us from deeper issues. Once you fix those issues, the priority issue becomes largely irrelevant.

So, a few considerations:

  1. The heavyTask is not asynchronous. It is marked async, but in the absence of an await somewhere in that method, it simply is not asynchronous. Adding an async qualifier, alone, does not change the underlying behavior.

  2. Another concern is that you never want to run “heavy tasks” on the current actor, as it can block that actor. This is the source of the behavior you are experiencing.

    To avoid this, you want to run your heavy task in a detached Task (or on its own actor), which will let the cancelation logic happen concurrently. E.g.:

    func heavyTask() async -> Int {
        await Task.detached { [count] in
            var k = count
            for _ in 0 ..< 50_000_000 {
                k += 1
            }
            return k
        }.value
    }
    
  3. In a more advanced observation, you may also want to make it cancelable. You should try Task.checkCancellation() (or check for Task.isCancelled()).

    func heavyTask() async throws -> Int {
        let task = Task.detached { [count] in
            var k = count
            for _ in 0 ..< 50_000_000 {
                try Task.checkCancellation()
                k += 1
            }
            return k
        }
    
        return try await withTaskCancellationHandler {
            try await task.value
        } onCancel: {
            task.cancel()
        }
    }
    

    The Task.detached() gets it off the current actor, but because that opts out of structured concurrency, I wrap it in a withTaskCancellationHandler so that I handle cancelation. And I try Task.checkCancellation() so that it will actually stop if it is canceled.

But in your case, the key observation is that you have to move the heavyTask off the current actor.

Rob
  • 415,655
  • 72
  • 787
  • 1,044
  • Thanks for that detailed answer, I appreciate that as a newbie at this topic. I think I have to do a bit more research to understand this topic and your answer more. At the point with: `"Assuming the intent was to “run a task and/or cancel in 0.9 seconds,”` I think it is different from the question, because I would like to cancel the appear of the progression view if the heavyTask is finished earlier than the `0.9 sec` – sheldor Dec 20 '22 at 18:35
  • Perhaps I didnt explain it right: The idea of the Button in the post was to: 1. If the action is finished before the 0.9 sec cancel the task which shows the progress View. 2. If the action is longer than 0.9 sec the progress View appears and after that disappears if the action is finished – sheldor Dec 20 '22 at 19:24
  • OK, I get you now. So your `AsyncButton` is largely right (and I have deleted that half of my answer). Your concern is simply to make sure that you get your heavy task off the current actor with a detached task. – Rob Dec 20 '22 at 20:59
  • Thanks!!!, it works now, but I think I don't understand it really in the context. The progressviewtask doesn't get cancelled because the method under `await action()` (heavyTask) is not really async so the cancellation of the progressview gets blocked? But why is it working with the one button? – sheldor Dec 24 '22 at 14:12
  • The underlying threading behavior of Swift concurrency is not well defined, so it is impossible to say. All we can say is that the failure to move the heavy task off to a detached task violates the “runtime contract” to ensure forward progress (see [Swift Concurrency: Behind the Scenes](https://developer.apple.com/videos/play/wwdc2021/10254/?time=1480)). – Rob Dec 24 '22 at 16:15