Search code examples
objective-ccocoacore-datansundomanager

Undo/Redo Menu Items Never Enabled


I have a core data application with an NSTableView bound to an NSArrayController. I manage adding and removing objects using the array controller. I'm trying to add undo/redo support so when a person deletes an object from the table view, using a menu item, they can undo the delete.

My delete method is:

- (IBAction)removeHost:(id)sender
{
    NSInteger row = [bookmarkList selectedRow];

    // Get the object so we can get to the attributes of the host
    NSArray *a = [bookmarksController arrangedObjects];
    NSManagedObject *object = [a objectAtIndex:row];

    if (!object) return;
    NSManagedObjectContext *managedObjectContext = [self managedObjectContext];
    NSUndoManager *undoManager = [managedObjectContext undoManager];

    if (managedObjectContext.undoManager == nil)
    {
        NSLog(@"No undo manager in app controller!");
    } else {
        NSLog(@"We've got an undo manager in app controller!");
    }

    [undoManager registerUndoWithTarget:self selector:@selector(addBookmarkObject:) object:object];
    [bookmarksController removeObject:object];
    [undoManager setActionName:@"Bookmark Delete"];
}

Deleting the object works fine, but undo does not. The Command-Z menu item is never enabled. I setup a temporary menu item and action to test the undoManager,

- (IBAction)stupidUndoRemoveHost:(id)sender
{
    NSManagedObjectContext *managedObjectContext = [self managedObjectContext];
    NSUndoManager *undoer = [managedObjectContext undoManager];

    NSLog(@"canUndo? %hhd", [undoer canUndo]);
    NSLog(@"canRedo? %hhd", [undoer canRedo]);
    NSLog(@"isUndoRegistrationEnabled? %hhd", [undoer isUndoRegistrationEnabled]);
    NSLog(@"undoMenuItemTitle = %@", [undoer undoMenuItemTitle]);
    NSLog(@"redoMenuItemTitle = %@", [undoer redoMenuItemTitle]);

    [undoer undo];
}

Using this IBAction I can do the undo (well, sort of, it adds the object twice so clearly there's still more wrong here), but I can only do it once. If I delete another object canUndo returns 0, and stupidUndoRemoveHost does nothing.

I know I'm not understanding something here. I've read through more posts here than I can count, several blog posts, and the Apple documentation. I've done this before, but it was like ten years ago, so my skills are a bit rusty. Any help or pointers in the right direction are greatly appreciated.

Update: here is the addBookmarkObject method:

- (void)addBookmarkObject: (NSManagedObject *)object
{
    [bookmarksController addObject:object];
}

And here is windowWillReturnUndoManager from the AppDelegate:

- (NSUndoManager *)windowWillReturnUndoManager:(NSWindow *)window {
    // Returns the NSUndoManager for the application. In this case, the manager returned is that of the managed object context for the application.
    NSUndoManager *undoManager = [[NSUndoManager alloc] init];
    self.persistentContainer.viewContext.undoManager = undoManager;

    if (self.persistentContainer.viewContext.undoManager == nil)
    {
        NSLog(@"No undo manager!");
    } else {
        NSLog(@"We've got an undo manager!");
    }

    return self.persistentContainer.viewContext.undoManager;
}

Solution

  • windowWillReturnUndoManager: is called every time Appkit wants to register an undo operation and when it wants to enable/disable the Undo menu item. If windowWillReturnUndoManager: returns a new undo manager then the undo stack is empty and the Undo menu item is disabled.

    Core Data will register an undo operation when an object is removed, removeHost: shouldn't register an extra undo operation.

    - (IBAction)removeHost:(id)sender
    {
        [bookmarksController remove:sender];
        [undoManager setActionName:@"Bookmark Delete"];
    }
    

    The Xcode macOS Cocoa App with Core Data template has some flaws.

    NSWindowDelegate method windowWillReturnUndoManager: isn't called because in the xib, the delegate of the window isn't connected to the app delegate. Fix: connect the delegate of the window to the Delegate.

    self.persistentContainer.viewContext.undoManager is nil. Fix: create the undo manager once when the persistent container is created.

    - (NSPersistentContainer *)persistentContainer {
        // The persistent container for the application. This implementation creates and returns a container, having loaded the store for the application to it.
        @synchronized (self) {
            if (_persistentContainer == nil) {
                _persistentContainer = [[NSPersistentContainer alloc] initWithName:@"TestCDUndo"];
                [_persistentContainer loadPersistentStoresWithCompletionHandler:^(NSPersistentStoreDescription *storeDescription, NSError *error) {
                    if (error != nil) {
                        …
                        abort();
                    }
                    self->_persistentContainer.viewContext.undoManager = [[NSUndoManager alloc] init];
                }];
            }
        }
    
        return _persistentContainer;
    }