swiftmacosswiftuiswiftdatansundomanager

Issue with observing SwiftData model and UndoManager


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:

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
    }
}

Solution

  • 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 = {
        ...
        }()