11
Oct
2011
 

Core Data and the Undo Manager

by Marcus Zarra

Note: This is a re-print from the Mac Developer Network.

One of the nice things about developing software for OS X is all the “freebies” we get out of Cocoa. For example, when we are building a UI with text input we get undo support for free! How cool is that?

Likewise, when we are working with Core Data, it also has undo support built right in. Every NSManagedObjectContext has a NSUndoManager that we can use. However there are some situations where the default undo support is insufficient.

In this article we are going to walk through one such situation.

Edit Window Undo

There are situations where we want to group actions together so that from the user’s point of view they are an atomic operation. For example, if we have a window with an edit sheet; once the edit sheet is closed we want to avoid walking through the individual edits that occurred while the sheet was present.

However, while the sheet is present, we do want to give the user the ability to undo each individual edit. To perform this, we will be creating a commit group on the undo manager contained within the NSManagedObjectContext and we will use a separate Undo Manager that is responsible for the edit sheet. We could use the undo manager within Core Data for both but I have found it to be cleaner to keep them separated in this situation.

Building Our Application

In this application the main window will present the user with a single table view which has two columns, a date and a name. The table itself is non-editable but double clicking on a row will present a sheet for the user to edit the row. In addition, clicking on the add button will present the same sheet but with a new entry. The edit sheet contains three fields for data entry as shown.

The data model for our project contains a single NSManagedObject named TestEntity with three properties: name, date and desc. The model is shown in.

Implementing the App Delegate

Most of the AppDelegate is straight out of the Core Data Application template provided by Xcode. However we are going to add a double click action to the table view as well as handling for the add and remove buttons. To do this we need to add the table view as an outlet with the AppDelegate. We also need to add the NSArrayController as an outlet.

@interface AppDelegate : NSObject
{
  IBOutlet NSWindow *window;
  IBOutlet NSArrayController *arrayController;
  IBOutlet NSTableView *tableView;

  NSPersistentStoreCoordinator *persistentStoreCoordinator;
  NSManagedObjectModel *managedObjectModel;
  NSManagedObjectContext *managedObjectContext;
}

- (NSManagedObjectContext *)managedObjectContext;

- (IBAction)saveAction:(id)sender;
- (IBAction)addEntity:(id)sender;
- (IBAction)removeEntity:(id)sender;

@end

Once we have those bound in Interface Builder we can finish the configuration within the -awakeFromNib: method.

- (void)awakefromNib
{
  [tableView setTarget:self];
  [tableView setDoubleAction:@selector(editEntry:)];
}

In the -awakeFromNib: we set ourselves as the target for the tableView and add the -editEntry: method as its double click action. Now when a user double-clicks on a row our method will be called.

- (void)editEntry:(id)sender
{
  id object = [[arrayController selectedObjects] lastObject];
  if (!object) return;
  SEL endSelector = @selector(editSheetDidEnd:returnCode:object:);
  [[[self managedObjectContext] undoManager] beginUndoGrouping];
  [[[self managedObjectContext] undoManager] setActionName:@"Undo Object Edit"];
  [EditWindowController editSheetForWindow:window
                                  delegate:self
                               endSelector:endSelector
                                    entity:object];
}

In the -editEntry: method we first get the object for the row that was clicked on. We do a quick nil check and then hand the object off to our edit window controller. As you can see we have a convenience method in our EditWindowController to make it easy to create one, pass off the object and display the sheet. The method also accepts a delegate and a callback @selector.

You should pay particular attention to the line of code in the middle; the call to the NSManagedObjectContext’s NSUndoManager. In that line we are starting a grouping before we present the sheet. This guarantees that any changes made while the sheet is displayed will be considered “atomic” to the NSUndoManager contained within the NSManagedObjectContext.

Implementing the EditWindowController

The EditWindowController is a subclass of NSWindowController with a fairly simple header.

#import <Cocoa/Cocoa.h>

@interface EditWindowController : NSWindowController 
{
  NSUndoManager *undoManager;
  NSManagedObject *testEntity;
  IBOutlet NSObjectController *entityController;
}

@property (assign) NSManagedObject *testEntity;

+ (void)editSheetForWindow:(id)window 
                  delegate:(id)delegate 
               endSelector:(SEL)selector 
                    entity:(NSManagedObject*)object;

- (IBAction)saveAction:(id)sender;
- (IBAction)cancelAction:(id)sender;

@end

We retain a reference to the NSManagedObject that gets passed in, an NSObjectController which is initialized within Interface Builder and a NSUndoManager which we will discuss below. In addition to the iVars, we also have the accessor method that we used in the AppDelegate and two IBActions for the buttons on the edit sheet.

The interface builder xib only has a couple of objects:

The idea behind this design is that the window controller will receive an object which we will set into the NSObjectController. The panel/sheet will then feed from this NSObjectController to display all of the editable fields. Once the edit is complete the user will click either save or cancel which will close the sheet and cause the callback @selector to fire.

+ (void)editSheetForWindow:(id)window 
                  delegate:(id)delegate 
               endSelector:(SEL)selector 
                    entity:(NSManagedObject*)object;
{
  EditWindowController *controller;
  controller = [[EditWindowController alloc] initWithWindowNibName:kNibName];

  [controller setTestEntity:object];

  [NSApp beginSheet:[controller window] 
     modalForWindow:window 
      modalDelegate:delegate 
     didEndSelector:selector 
        contextInfo:object];
}

Within our helper method we take advantage of the existing underlying API. We first initialize an instance of the EditWindowController using the parent’s -initWithWindowNibName: which will load in the nib and attach all of the bindings for us. Once that is complete we use the -setTestEntity: method to pass in the NSManagedObject which will populate all of the fields. We then use the NSApplication call +beginSheet: modalForWindow: modalDelegate: didEndSelector: contextInfo: to present our associated window (a NSPanel really) as a sheet on top of the passed in window; which in this case happens to be the main window for our application. The beauty of using this method to present the sheet is that we do not need to retain the delegate or the callback method, the existing structure handles it for us.

- (void)saveAction:(id)sender;
{
  [[self window] orderOut:sender];
  [NSApp endSheet:[self window] returnCode:NSOKButton];
}

- (void)cancelAction:(id)sender;
{
  [[self window] orderOut:sender];
  [NSApp endSheet:[self window] returnCode:NSCancelButton];
}

We can further take advantage of the underlying structure in the save and cancel methods. In each of these methods the only thing we need to do is call -orderOut: on our window and then call -endSheet: returnCode: on the NSApplication itself. This will in turn call the delegate’s callback method with the appropriate return code. Keeps our sheet very simple and clean.

The interesting part of this sheet is the undo manager. In Interface Builder, I assigned the EditWindowController as the delegate to the window. This means that the window looks to the EditWindowController to get a reference to the NSUndoManager that it should be using. It is possible to use the NSUndoManager that is part of Core Data but we want to keep the edits made in this window local. Therefore we are going to create a separate NSUndoManager.

- (NSUndoManager*)windowWillReturnUndoManager:(NSWindow*)window
{
  if (!undoManager) {
    undoManager = [[NSUndoManager alloc] init];
  }
  return undoManager;
}

We create this NSUndoManager lazily. We wait until it is requested the first time and then initialize it.

Handling the Sheet Closing

When the user is done with the sheet they will either hit save or cancel. Both of those actions will cause the delegate’s callback method to be invoked.

- (void)editSheetDidEnd:(id)sheet returnCode:(int)returnCode object:(id)object
{
  [[[self managedObjectContext] undoManager] endUndoGrouping];
  if (returnCode == NSOKButton) return;
  [[[self managedObjectContext] undoManager] undo];
}

In the callback method within the AppDelegate we first end the undo grouping that we started just before presenting the sheet. We then check to see if the user pressed save or cancel. If they pressed save we return and are done. If they pressed cancel, however, we then request an undo on the NSManagedObjectContext‘s NSUndoManager. This will roll the NSManagedObject back to the state it was before we presented the sheet to the user. Because we named this action the user will actually see it in the Edit menu.

Handling Add Item

We can reuse this edit sheet for when we are dealing with a new entity as well as editing an existing entity. The only difference is in the AppDelegate‘s -addEntity: method.

- (void)addEntity:(id)sender;
{
  NSManagedObject *newObject;
  NSManagedObjectContext *moc = [self managedObjectContext];
  SEL endSelector = @selector(editSheetDidEnd:returnCode:object:);

  [[[self managedObjectContext] undoManager] beginUndoGrouping];
  [[[self managedObjectContext] undoManager] setActionName:@"Undo Object Add"];
  newObject = [NSEntityDescription insertNewObjectForEntityForName:@"TestEntity"
                                            inManagedObjectContext:moc];
  [EditWindowController editSheetForWindow:window
                                  delegate:self
                               endSelector:endSelector
                                    entity:newObject];
}

Instead of grabbing the selected object from the NSArrayController we construct a new one. However we set the undoGrouping to begin before we create the object. This allows us to include the creation of the NSManagedObject as part of the atomic undo. If the user hits cancel on the sheet, the NSManagedObject will automatically be removed from the NSManagedObjectContext for us.

Handling the Remove Item

Like the Add menu item above, we can use the NSUndoManager to make the removing of objects easy for us as well.

- (void)removeEntity:(id)sender;
{
  id object = [[arrayController selectedObjects] lastObject];
  if (!object) return;

  [[[self managedObjectContext] undoManager] beginUndoGrouping];
  [[[self managedObjectContext] undoManager] setActionName:@"Undo Object Remove"];
  [[self managedObjectContext] deleteObject:object];
  [[[self managedObjectContext] undoManager] endUndoGrouping];
}

Here we are grabbing the selected object from the table and if it exists we remove it from the NSManagedObjectContext. However, we make sure to give the action a name. Since it is a single action there is no need for a grouping but it is helpful, and improves the user experience to name the action so that the user understands exactly what they are undoing.

Wrap-Up

In this short article, we walked through how the NSUndoManager plays very nicely with Core Data and how we can use more than one NSUndoManager to keep the edits separate from each other instead of rolling them all into one undo stack and thereby avoiding all of the complication that entails.

It is possible to combine all of this work within one NSUndoManager but that causes the undo stack to be larger and more complicated unnecessarily. There is no reason to retain the edits that were made within the edit sheet in the same NSUndoManager that handles the changes to the context. By pulling these into their own NSUndoManager we can keep each separate function of the UI separate and easier to manage.

Download Sample Code

Marcus Zarra

Marcus S. Zarra is a founding partner of MartianCraft, LLC. He has been developing Cocoa software since 2003, Java software since 1996, and has been in the industry since 1985. Currently Marcus is producing software for iOS and OS X. In addition to writing software, he assists other developers by blogging about development and supplying code samples on Cocoa Is My Girlfriend. Marcus is also the author of Core Data (2nd edition): Data Storage and Management for iOS, OS X, and iCloud and Co-Author of Core Animation: Simplified Animation Techniques for Mac and iPhone Development. You can find Marcus on Twitter, on App.net and on StackOverflow.

More Posts - Website

Follow Me:
Twitter