I have created a minimum example to demonstrate an issue with observing SwiftData model and UndoManager. This project includes a simple NavigationSplitView, an Item SwiftData model that is being persisted and an enabled UndoManager.
Problem: The SwiftData model Item
can be observed as expected. Changing the date in the DetailView
works as expected and all related views (ListElementView
+ DetailView
) are updated as expected. When pressing ⌘+Z to undo with the enabled UndoManager, deletions or inserts in the sidebar are visible immediately (and properly observed by ContentView
). However, when changing the timestamp and pressing ⌘+Z to undo that change, it is not properly observed and immediately updated in the related views (ListElementView
+ DetailView
).
Further comments:
timestamp
) are visible in the DetailView
when changing sidebar selectionstimestamp
) are visible in the ListElementView
when restarting the apptimestamp
) are are properly observed and immediately visible in the sidebar, when ommiting the ListElementView
(no view encapsulation)It seems that the UndoManager does not trigger a redraw of the ContentView
through the items
query?
Relevant code base:
struct ContentView: View {
@Environment(\.modelContext) private var modelContext
@Query private var items: [Item]
@State private var selectedItems: Set<Item> = []
var body: some View {
NavigationSplitView {
List(selection: $selectedItems) {
ForEach(items) { item in
ListElementView(item: item)
.tag(item)
}
.onDelete(perform: deleteItems)
}
.navigationSplitViewColumnWidth(min: 180, ideal: 200)
.toolbar {
ToolbarItem {
Button(action: addItem) {
Label("Add Item", systemImage: "plus")
}
}
}
} detail: {
if let item = selectedItems.first {
DetailView(item: item)
} else {
Text("Select an item")
}
}
.onDeleteCommand {
deleteSelectedItems()
}
}
private func addItem() {
withAnimation {
let newItem = Item(timestamp: Date())
modelContext.insert(newItem)
}
}
private func deleteItems(offsets: IndexSet) {
withAnimation {
for index in offsets {
modelContext.delete(items[index])
}
}
}
private func deleteSelectedItems() {
for selectedItem in selectedItems {
modelContext.delete(selectedItem)
selectedItems.remove(selectedItem)
}
}
}
struct ListElementView: View {
@Bindable var item: Item
var body: some View {
Text("Item at \(item.timestamp, format: Date.FormatStyle(date: .numeric, time: .standard))")
}
}
struct DetailView: View {
@Bindable var item: Item
var body: some View {
Text(item.timestamp, format: Date.FormatStyle(date: .numeric, time: .standard))
DatePicker(selection: $item.timestamp, label: { Text("Change Date:") })
}
}
@Model
final class Item {
var timestamp: Date
init(timestamp: Date) {
self.timestamp = timestamp
}
}
I see a few mistakes, try this to get it working:
Remove this:
// container.mainContext.undoManager = UndoManager()
And this:
.commands {
// AppCommands()
}
In ContentView
, add this:
@Environment(\.undoManager) var undoManager
...
.onChange(of: undoManager, initial: true) {
modelContext.undoManager = undoManager
}
For the list row either try this:
ListElementView(timestamp: item.timestamp)
.tag(item)
Or remove .tag()
and add this instead:
NavigationLink(value: item) { // sadly causes warning "multiple updates per frame"
ListElementView(item: item)
}
Depending on which you choose change ListElementView
to:
struct ListElementView: View {
let item: Item
// or better: let timestamp: Date
Change DetailView
to only take what it needs, that is write access to a timestamp, e.g.:
} detail: {
if let item = selectedItems.first {
DetailView(timestamp: Bindable(item).timestamp)
} else {
Text("Select an item")
}
}
And this:
struct DetailView: View {
@Binding var timestamp: Date
var body: some View {
Text(timestamp, format: Date.FormatStyle(date: .numeric, time: .standard))
DatePicker(selection: $timestamp, label: { Text("Change Date:") })
}
}
With these fixes, undo is correctly configured on the context, the side bar label updates and detail updates when undo command is sent.
You might have uncovered a bug in Observable by the way, it seems to me detail: { DetailView(item:item) }
doesn't call body
when item.timestamp
changes, but in ListElementView(item: item)
it does. detail:
has always been quite badly behaved.
Finally you should change your container init to this to prevent it happening twice if App's body
gets recomputed, e.g.
static var persistent: ModelContainer = {
...
}()