Search code examples
cocoauser-interfacecore-dataswift2nsundomanager

How to use NSUndoManager with coredata and keep user interace and model in sync?


Core data supports undo/redo out of the box. But it's behaving unexpectedly.

To keep my user interface in sync with my model, I send out notifications. My user interface receives the notification message and updates the affected views.

@objc(Entity)
class Entity : NSManagedObject
{
    var title : String? {
        get {
            self.willAccessValueForKey("title")
            let text = self.primitiveValueForKey("title") as? String
            self.didAccessValueForKey("title")
            return text
        }
        set {
            self.willChangeValueForKey("title")
            self.setPrimitiveValue(newValue, forKey: "title")
            self.didChangeValueForKey("title")
            self.sendNotification(self, key:"title")
            print("title did change: \(title)")
        }
    }
}

Now I want to add undo/redo support to the app. Core data has an NSUndoManager, so I figured that no additional work would be required. Or at least not much. To test this assumption I made a test app with two NSTextFields and one core data entity (aptly named Entity).

The NSViewController subclass has access to an Entity instance (aptly named testObject). I observe every keystroke to update the testObject via controlTextDidChange:.

override func controlTextDidChange(obj: NSNotification)
{           
    guard let value = self.textField?.stringValue else { return }
    self.testObject?.setValue(value, forKey: "title")
}

func valueDidChange(sender: Entity, key: String)
{
   self.textField?.stringValue = sender.valueForKey("title") as? String ?? ""

}

The managedObjectContent and the two textFields have the same NSUndoManager (same pointer in the debug console).

When I am editing a NSTextField and perform undo/redo actions, both the NSTextField and the underlying NSManagedObject attribute are kept in sync. As expected.

But when I change focus (first responder) to the second NSTextField (without any editing) and undo/redo actions, the first NSTextField is (correctly) updated but the underlying NSManagedObject attribute is not. The title property never gets called.

So the first NSTextField and the Entity instance have different values after undo/redo actions.

Updating the underlying core data instance but not the user interface would have made more sense to me. What is going wrong here?

A sidenote: because I am observing the NSManagedObject for any changes and because controlTextDidChange: is sending out notifications (because its updating the NSManagedObject), I get an unnecessary call to valueDidChange. Is there a trick to avoid this or how can I improve my architecture?


Solution

  • I have done something similar and the way I found to work best is to separate the UI controller code (the C in MVC) into two separate "paths".

    One that observes changes in the core data model through listening for notifications from the core data model NSManagedObjectContextObjectsDidChangeNotification filtering out if the change affects the controllers UI and adjusts the display accordingly. This "path" is blindly following the coreData changes and need no interaction with the user and no undo knowledge.

    The other path records changes the user requests and modifies the core data model accordingly. For example if we have a stepper control and a label with a number next to it. User clicks the stepper. Then the controller updates the relevant property on the core data object by adding or subtracting one. This automatically generate undo actions with the core data model. If the user change affects more than one property in the core data all the changes are wrapped in an undo grouping. Then this change to the core data object will trigger the other controller path to update all UI things (the label in the example).

    Now undo works automatically opposite. By calling undo on the MOC undo manager coreData will revert the changes to the object which will trigger the first path again and the UI follows automatically.

    If the user is editing a text field I usually do not bother tracking changes keystroke by keystroke and instead only capture the results when the text field informs that editing did end. With this approach an undo after edit removes all changes in the previous edit session which is usually what is wanted. If also undo within the text field (e.g. typing aa and cmd-z to undo second a) is desired that can be achieved by providing another undo manager to the window while the textfield is editing - thus avoiding all the keystroke undos in the same undo stack as the core data actions.

    One thing to remember is that coreData does sometimes wait with executing some actions which make things appear out of sync. Calling -processPendingChanges on the MOC before ending an undo grouping will fix this.

    The other thing to think about is what you want to undo. Are you looking to be able to undo user key entries or to undo changes in the data model. I have found sometimes both but not at the same time hence I have found multiple undo managers useful as noted before. Keep the doc undo manager for only changes to the data model which is things the user may care about long term. Then make a new undo manager and use that while the user is in an edit mode to track individual key presses. Once the user confirms he is happy with the edit as a whole by leaving the text field or pressing OK in a dialog etc throw away that undo manager and get the end result of the edit and stuff that into core data with the use of the doc undo manager. To me these two types of undos are fundamentally different and should not be intertwined in the undo stack.

    Below is some code, first an example of the listener for changes (called after receiving a NSManagedObjectContextObjectsDidChangeNotification:

    -(void)coreDataObjectsUpdated:(NSNotification *)notif {
    
    // Filter for relevant change dicts
    NSPredicate *isSectorObject = [NSPredicate predicateWithFormat: @"className == %@", @"Sector"];
    
    NSSet *set;
    BOOL changes = NO;
    
    set = [[notif.userInfo objectForKey:NSDeletedObjectsKey] filteredSetUsingPredicate:isSectorObject];
    if (set.count > 0) {
        changes = YES;
    }
    else {
        set = [[notif.userInfo objectForKey:NSInsertedObjectsKey] filteredSetUsingPredicate:isSectorObject];
        if (set.count > 0) {
            changes = YES;
        }
        else {
            set = [[notif.userInfo objectForKey:NSUpdatedObjectsKey] filteredSetUsingPredicate:isSectorObject];
            if (set.count > 0) {
                changes = YES;
            }
        }
    }
    if (changes) {
        [self.sectorTable reloadData];
    }
    

    }

    This is an example of creating a compound undo action, edits are done in a separate sheet and this snippet moves all the changes into a core data object as a single undoable action with a name.

    -(IBAction) editCDObject:(id)sender{ 
    
    NSManagedObject *stk = [self.objects objectAtIndex:self.objectTableView.clickedRow];
    
    [self.editSheetController EditObject:stk attachToWindow:self.window completionHandler: ^(NSModalResponse returnCode){
    
        if (returnCode == NSModalResponseOK) {  // Write back the changes else do nothing
    
            NSUndoManager *um = self.moc.undoManager;
            [um beginUndoGrouping];
            [um setActionName:[NSString stringWithFormat:@"Edit object"]];
    
            stk.overrideName = self.editSheetController.overrideName;
            stk.sector = self.editSheetController.sector;
    
            [um endUndoGrouping];
        }
    }];
    

    }

    Hope this gave some ideas.