I have some code that has a long-running task inside of a .perform{}
block. I'd like to be able to show the status of this long-running code in a Swift UI view by passing data through a @Published var
. But .perform{}
seems to prevent @Published
from actually propagating to the view.
Is there a way to get the view to update in the middle of this perform{} block?
I've managed to boil my problem down to a playground that illustrates the issue . See the ?????
comment.
import SwiftUI
import PlaygroundSupport
import CoreData
struct ContentView: View {
@StateObject var contentModel = ContentModel()
var body: some View {
VStack {
HStack {
Text("Status:")
Text(contentModel.status)
}
Spacer()
Button("Do stuff.") {
contentModel.updateStatus()
}
}
Spacer()
}
}
class ContentModel: ObservableObject {
@Published var status = "foo"
func updateStatus() {
let managedObjectContext = PersistenceController.preview.container.viewContext
// this perform is needed in the Real App™ to manage data fetch + output
managedObjectContext.perform {
// it doesn't matter that this is async
DispatchQueue.main.async {
let oldStatus = self.status
// this status does not propagate into the @Published
// until after the perform{} block finishes
// (read: we don't see it show "X" in the UI)
self.status = "X"
// ????? how can I make this propagate the the view right away?
// simulate a long-running (blocking) operation
sleep(3)
// cheap toggle
if oldStatus == "foo" {
self.status = "bar"
} else {
self.status = "foo"
}
}
}
}
}
class PersistenceController {
static let shared = PersistenceController()
let container: NSPersistentContainer
static var preview: PersistenceController = {
let result = PersistenceController(inMemory: true)
let viewContext = result.container.viewContext
return result
}()
init(inMemory: Bool = false) {
let model = NSManagedObjectModel()
container = NSPersistentContainer(name: "Example", managedObjectModel: model)
container.viewContext.automaticallyMergesChangesFromParent = true
container.viewContext.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy
}
}
PlaygroundPage.current.setLiveView(ContentView())
As the code is written above, the problem would be that this…
DispatchQueue.main.async {
// ...
self.status = "X"
sleep(3)
self.status = "bar"
}
is blocking the main thread. The @Published
var may very be sending its updates, but because the main thread is blocked, you can't actually see that update in the UI. When you push a change to a @Published
var, it sends a willSet
event that lets the UI record the previous value. It then schedules an update on the next pass of the run loop to compare the previous value to the current value. That lets it schedule an animation from the previous state to the current state, for example.
But as you've got the code written there, the main thread is blocked until the entire operation is done. That means that UI will never update to see "X"
in the status field; by the time that next run loop pass is actually able to run, we're already to "bar"
.
I would look at whether it's possible to move the long-running blocking operation to a background thread. You could then either use a completion handler to finish updating the UI, or wrap it in an async function. That is, I'm suggesting you use one of the two following patterns:
// Option A (Modern Concurrency)
Task { @MainActor in
self.status = "X"
await runLongOperation(input: "some input here")
self.status = "bar"
}
// Since this is an async function, it will never run
// on the main thread unless specifically annotated
// with @MainActor. We haven't done that, so this
// automatically runs in the background.
func runLongOperation(input: String) async {
sleep(3)
}
or...
// Option B (Legacy Concurrency)
DispatchQueue.main.async {
self.status = "X"
runLongOperation(input: "some input", completion: {
DispatchQueue.main.async {
self.status = "bar"
}
}
}
func runLongOperation(input: String, completion: @escaping ()->Void) {
DispatchQueue.global().async {
sleep(3)
completion()
}
}