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.