30
Apr
2008
 

Cocoa Tutorial: Wiring Undo Management Into Core Data

by Marcus Zarra

Undo support in Cocoa is fantastic but for those who have tried to mix it with Core Data know that it can be a bit frustrating. Generally, undo support can be ignored in most applications and it will “just work”. But when Core Data is added to the recipe then things get a bit confusing and more complicated.

The Conflict With NSUndoManager

For those familiar with the inner workings of Cocoa’s undo support (and if you are not familiar with it, check out the April edition of MacTech ;-) , all of the undo events are registered with an NSUndoManager. In a normal document model application, the window controller (or the window’s delegate if set to something other than the controller) will supply an NSUndoManager for all of the text fields and other editable bits in the window.

This works fine until Core Data comes along. The reason for this is that the NSManagedObjectContext has its own NSUndoManager that is registering those same events and can easily get out of sync.

What I prefer to do is avoid the double NSUndoManager and just use the one included with the NSManagedObjectContext. This can be done by implementing one of the window delegate methods:

- (NSUndoManager *)windowWillReturnUndoManager:(NSWindow *)window
{
  return [self undoManager];
}

- (NSUndoManager*)undoManager
{
  return [[self managedObjectContext] undoManager];
}

By utilizing the NSUndoManager that is included in the context, some very powerful features are enabled.

Grouping sheet modifications

A common UI design is to allow the editing of an object in a sheet. The user double clicks on the item or otherwise indicates that they want to edit it and a sheet drops down allowing them to change the values. Naturally, that sheet has an NSUndoManager attached to it (can even use the one from Core Data) and each individual field can be undone. However, how to do we handle the cancel button or the ability to undo the entire sheet?

Both of the above can be handled with groupings inside of the NSManagedObjectContext object’s NSUndoManager. The methods would work as follows:

- (void)presentEditSheet:(id)sender
{
  [[self undoManager] beginUndoGrouping];
  [NSApp beginSheet:[self editSheet] 
     modalForWindow:[self window] 
      modalDelegate:nil 
     didEndSelector:NULL 
        contextInfo:nil];
}

- (void)acceptChanges:(id)sender
{
  [[self editSheet] orderOut:sender];
  [NSApp endSheet:[self editSheet]];
  [[self undoManager] endUndoGrouping];
  [[self undoManager] setActionName:@"Object edit"];
}

- (void)cancelChanges:(id)sender
{
  [[self editSheet] orderOut:sender];
  [NSApp endSheet:[self editSheet]];
  [[self undoManager] endUndoGrouping];
  [[self undoManager] undo];
}

In this example I have started an undo group before presenting the sheet. After the sheet is closed I then end the grouping and if the sheet was not cancelled I give the group a name. This name will display in the menu and allow all of the changes inside of the sheet to be undone in one stroke. If the sheet is cancelled then the group is still closed but I immediately call undo instead and everything that occurred within the sheet is wiped clean.

Conclusion

By only using one NSUndoManager it makes life a lot simplier and gives consistent results to the user. This technique can also be used in a document model application but each document will have to reference the appropriate NSUndoManager that is associated with its own NSManagedObjectContext.

Feedback

If you have some other interesting ways to use the NSUndoManager I would love to hear about them either here in the comments or you can email me directly at marcus at cimgf.com.

Comments

mj1531 says:

When you cancel the sheet using the sample code you provided, that last undo will push the undone changes to the undo manager’s redo stack and thus enable the Redo menu item in the application.

This would allow the user to redo the changes in the sheet without bringing it back up if you are using the same NSManagedObjectContext for the sheet and the document window. Unfortunately, NSUndoManager doesn’t have a method for removing only the top item from either stack.

What I usually end up doing is to create a new NSManagedObjectContext for the sheet (which involves non-trivial copying of the edited managed object to and from the 2 contexts). It’s non-trivial because you have to think about what copying means to relationships in your object model and there’s also the whole thing about NSManagedObjectIDs not being fetchable from another context if they are temporary in the first context.

Marcus Zarra says:

Sounds like you are creating a bigger headache than you are solving!

mj1531 says:

Actually, it’s not that bad for my object model. I created a category on NSManagedObjectContext in my app for copying model objects between contexts.

There’s also the easier option that if you don’t want undo support in the sheet, you can disable undo registration while the sheet is running and re-enable it after the user dismisses it.

Marcus Zarra says:

As a general rule I would not recommend your solution. Also, disabiling undo management for editable sheets kind of defeats the purpose of having an undo manager in the first place. Without an undo manager the developer would need to track all of the changes in the sheet manually so that if the user hits cancel the developer can back everything out.

If the possibility of a redo is that much of an issue I would recommend creating an independent undo manager for the sheet that tracks all of the changes in that sheet. Much cheaper than copying all of the managed objects and since the sheet is destroyed upon completion there is no chance of the redo issue.

mj1531 says:

You don’t have to copy all your managed objects to the sheet’s context, just the one your editing. Actually, I found a similar approach is given in the NSPersistentDocument Tutorial:

http://developer.apple.com/documentation/Cocoa/Conceptual/NSPersistentDocumentTutorial/08_CreationSheet/chapter_9_section_1.html#//apple_ref/doc/uid/TP40001799-CH284-SW2

Marcus Zarra says:

As I have said before, just because Apple does it, does not mean it is the best solution or even a good solution. This is especially true of their tutorials as opposed to running code.

There is always more than one way to solve an issue. As I said before I would not recommend yours as an optimal solution. Perhaps if your context is small it would be light enough to use but I would certainly not do it that way.

If it works for you then great — Enjoy it!

wackazong says:

I did the following: Created a NSWindow subclass that handles all that for me. All I have to do is to call it with a NSWindow*, it will open itself attached to that window, start the undo group and react on its own buttons, which are OK and Cancel in the way mentioned above. This way in the controller I can start editing with just one line:

[code
]#import

@interface CTCoreDataUndoWindow : NSWindow {

NSButton* _okButton;
NSButton* _cancelButton;
id _target;
SEL _ok;
SEL _cancel;

}

@property (readwrite, retain) IBOutlet NSButton* okButton;
@property (readwrite, retain) IBOutlet NSButton* cancelButton;

  • (void)showOnWindow:(NSWindow*)w;

@end
[/code]

[code]

import “CTCoreDataUndoWindow.h”

@implementation CTCoreDataUndoWindow

@synthesize okButton = _okButton;
@synthesize cancelButton = _cancelButton;

  • (NSUndoManager *)windowWillReturnUndoManager:(NSWindow *)window
    {
    return [self undoManager];
    }

  • (NSUndoManager*)undoManager
    {
    return [[self.delegate managedObjectContext] undoManager];
    }

  • (void)showOnWindow:(NSWindow*)w
    {
    [[self undoManager] beginUndoGrouping];
    [self.okButton setAction:@selector(okSheet:)];
    [self.cancelButton setAction:@selector(cancelSheet:)];
    [self.okButton setTarget:self];
    [self.cancelButton setTarget:self];
    [NSApp beginSheet:self modalForWindow:w modalDelegate:nil didEndSelector:NULL contextInfo:NULL];
    }

  • (void)cancelSheet:(id)sender
    {
    [[self undoManager] endUndoGrouping];
    [[self undoManager] undo];
    [self orderOut:self];
    [NSApp endSheet:self];
    }

  • (void)okSheet:(id)sender
    {
    [[self undoManager] endUndoGrouping];
    [[self undoManager] setActionName:@”Object edit”];
    [self orderOut:self];
    [NSApp endSheet:self];
    }

@end
[/code]

wackazong says:

BTW: Line 15 and 16 of your snippet need to be in reverse order. I think. Otherwise you close the undo group without giving it a name, and give the name to the next undo group on the stack.