How to execute a CPU-bound task in background using Swift Concurrency without blocking UI updates?

I have an ObservableObject which can do a CPU-bound heavy work:

import Foundation
import SwiftUI

@MainActor
final class Controller: ObservableObject {
    @Published private(set) var isComputing: Bool = false
    
    func compute() {
        if isComputing { return }
        
        Task {
            heavyWork()
        }
    }
    
    func heavyWork() {
        isComputing = true
        sleep(5)
        isComputing = false
    }
}

I use a Task to do the computation in background using the new concurrency features. This requires using the @MainActor attribute to ensure all UI updates (here tied to the isComputing property) are executed on the main actor.

I then have the following view which displays a counter and a button to launch the computation:

struct ContentView: View {
    @StateObject private var controller: Controller
    @State private var counter: Int = 0
    
    init() {
        _controller = StateObject(wrappedValue: Controller())
    }
    
    var body: some View {
        VStack {
            Text("Timer: \(counter)")
            Button(controller.isComputing ? "Computing..." : "Compute") {
                controller.compute()
            }
            .disabled(controller.isComputing)
        }
        .frame(width: 300, height: 200)
        .task {
            for _ in 0... {
                try? await Task.sleep(nanoseconds: 1_000_000_000)
                counter += 1
            }
        }
    }
}

The problem is that the computation seems to block the entire UI: the counter freezes.

Why does the UI freeze and how to implement .compute() in such a way it does not block the UI updates?


What I tried

  • Making heavyWork and async method and scattering await Task.yield() every time a published property is updated seems to work but this is both cumbersome and error-prone. Also, it allows some UI updates but not between subsequent Task.yield() calls.
  • Removing the @MainActor attribute seems to work if we ignore the purple warnings saying that UI updates should be made on the main actor (not a valid solution though).
  • Wrapping every state update on a MainActor.run { } but this was too much boilerplate.

You need to create a detached Task in compute: https://docs.swift.org/swift-book/LanguageGuide/Concurrency.html#ID643

How to execute a CPU-bound task in background using Swift Concurrency without blocking UI updates?
 
 
Q