In the WWDC 23 lounge, this exchange with @Dave N (Apple) indicated that using ModelActor is the right way to do background work using swift concurrency. https://developer.apple.com/forums/thread/731338
I've figured out how to use this approach to do background work using swift concurrency (inside a Task), and example code is below for those who find it useful, however I'm not seeing view updates when the background work is complete.
There seems to be no way to trigger a view update based on a @Query when inserts happen in a ModelActor’s context. However, if a delete happens on the ModelActor context, this DOES trigger a view redraw. I believe this is a bug because I expect the behavior to be the same for inserts and deletes. I've submitted this as Feedback FB12689036.
Below is a minimal project which is the default SwiftData template, where the Add and Delete buttons point to a ModelActor instead of the main view ModelContext. You will see that using the addItem function in the ModelActor does not trigger a UI update in ContentView. However if you relaunch the app the added Item will be present. In contrast, using the delete function on the ModelActor context does trigger an immediate view update in ContentView.
If this is intended behavior, we need a way to merge changes from background contexts, similar to what is described in the Core Data document “Loading and Displaying a Large Data Feed”: https://developer.apple.com/documentation/swiftui/loading_and_displaying_a_large_data_feed
In Core Data we have automaticallyMergesChangesFromParent and mergeChanges(fromContextDidSave:) to do this manually. There seems to be no equivalent for Swift Data.
If anyone has solved this problem of merging changes from other contexts, or can confirm that this is a bug, please let me know.
import SwiftUI
import SwiftData
struct ContentView: View {
@Environment(\.modelContext) private var modelContext
@Query private var items: [Item]
@State private var simpleModelActor: SimpleModelActor!
var body: some View {
NavigationView {
List {
ForEach(items) { item in
NavigationLink {
Text("Item at \(item.timestamp, format: Date.FormatStyle(date: .numeric, time: .standard))")
} label: {
Text(item.timestamp, format: Date.FormatStyle(date: .numeric, time: .standard))
}
}
.onDelete(perform: deleteItems)
}
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
EditButton()
}
ToolbarItem {
Button(action: addItem) {
Label("Add Item", systemImage: "plus")
}
}
}
Text("Select an item")
}
.onAppear {
simpleModelActor = SimpleModelActor(modelContainer: modelContext.container)
}
}
private func addItem() {
Task {
await simpleModelActor.addItem()
}
}
private func deleteItems(offsets: IndexSet) {
Task {
for index in offsets {
await simpleModelActor.delete(itemWithID: items[index].objectID)
}
}
}
}
import Foundation
import SwiftData
final actor SimpleModelActor: ModelActor {
let executor: any ModelExecutor
init(modelContainer: ModelContainer) {
let modelContext = ModelContext(modelContainer)
executor = DefaultModelExecutor(context: modelContext)
}
func addItem() {
let newItem = Item(timestamp: Date())
context.insert(newItem)
try! context.save() // this does not impact a re-display by the @Query in ContentView. I would have expected it to cause a view redraw.
}
func delete(itemWithID itemID: Item.ID) {
let item = context.object(with: itemID)
context.delete(object: item) // this DOES cause a view redraw in ContentView. It triggers an update by @Query.
// try! context.save() // this makes do difference to view redraw behavior.
}
}