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 scatteringawait 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 subsequentTask.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.