Chapter 7. NSUndoManager

Using NSUndoManager, you can add undo capabilities to your applications in a very elegant manner. As objects are added, deleted, and edited, the undo manager keeps track of all messages that must be sent to undo these changes. As you invoke the undo mechanism, the undo manager keeps track of all messages that must be sent to redo those changes. This mechanism works by utilizing two stacks of NSInvocation objects.

This is a pretty heavy topic to cover so early in a book. (Sometimes when I think about undo, my head starts to swim a bit.) However, undo interacts with the document architecture. If we tackle this work now, you will see in the next chapter how the document architecture is supposed to work.

NSInvocation

As you might imagine, it is handy to be able to package up a message (including the selector, the receiver, and all arguments) as an object that can be invoked at your leisure. Such an object is an instance of NSInvocation.

One exceedingly convenient use for invocations is in message forwarding. When an object is sent a message that it does not understand, before raising an exception, the message-sending system checks whether the object has implemented:

- (void)forwardInvocation:(NSInvocation *)x

If the object has such a method, the message sent is packed up as an NSInvocation and forwardInvocation: is called.

One common use of forwardInvocation: is to pass the message on to another object that might be able to handle it (hence the name).

In the process of packing the message into an NSInvocation, the system needs to know the signature for that message. To get the signature, it calls

- (NSMethodSignature *)methodSignatureForSelector:(SEL)aSel

Thus, if you were an object that had a helper object, you could forward all messages that you were unable to understand to the helper by adding these three methods to your class:

- (void)forwardInvocation:(NSInvocation *)invocation
{
    SEL aSelector = [invocation selector];
    if ([helper respondsToSelector:aSelector])
        [invocation invokeWithTarget:helper];
    else
        [self doesNotRecognizeSelector:aSelector];
}

- (BOOL)respondsToSelector:(SEL)aSelector
{
    BOOL result = [super respondsToSelector:aSelector];
    if (result == NO) {
        result = [helper respondsToSelector:aSelector];
    }
    return result;
}

// This gets run before forwardInvocation:
- (NSMethodSignature *)methodSignatureForSelector:(SEL)aSelector
{
    NSMethodSignature *result;
    result = [super methodSignatureForSelector:aSelector];
    if (!result){
        result = [helper methodSignatureForSelector:aSelector];
    }
    return result;
}

The other nifty use for invocations is the undo manager.

How the NSUndoManager Works

Suppose that the user opens a new RaiseMan document and makes three edits:

  • Inserts a new record

  • Changes the name from “New Employee” to “Rex Fido”

  • Changes the raise from zero to 20

As each edit is performed, your controller will add an invocation that would undo that edit to the undo stack. For the sake of simplifying the prose, let's say, “The inverse of the edit gets added to the undo stack.”

Figure 7.1 shows what the undo stack would look like after these three edits.

The Undo Stack

Figure 7.1. The Undo Stack

If the user now chooses the Undo menu item, the first invocation is taken off the stack and invoked. This would change the person's raise back to zero. If the user chooses the Undo menu item again, it would change the person's name back to “New Employee.”

Each time an item is popped off the undo stack and invoked, the inverse of the undo operation must be added to the redo stack. Thus, after undoing the two operations as described above, the undo and redo stacks should look like Figure 7.2.

The Revised Undo Stack

Figure 7.2. The Revised Undo Stack

The undo manager is actually quite clever: When the user is doing edits, the undo invocations go onto the undo stack. When the user is undoing edits, the undo invocations go onto the redo stack. When the user is redoing edits, the undo invocations go onto the undo stack. These tasks are handled automatically for you; your only job is to give the undo manager the inverse invocations that need to be added.

Now suppose that you are writing a method called makeItHotter and that the inverse of this method is called makeItColder. Here is how you would enable the undo:

- (void)makeItHotter
{
    temperature = temperature + 10;
    [[undoManager prepareWithInvocationTarget:self] makeItColder];
    [self showTheChangesToTheTemperature];
}

As you might guess, the prepareWithInvocationTarget: method notes the target and returns the undo manager itself. Then, the undo manager cleverly overrides forwardInvocation: such that it adds the invocation for makeItColder: to the undo stack.

To complete the example, you would implement makeItColder:

- (void)makeItColder
{
    temperature = temperature - 10;
    [[undoManager prepareWithInvocationTarget:self] makeItHotter];
    [self showTheChangesToTheTemperature];
}

Note that we have again registered the inverse with the undo manager. If makeItColder is invoked as a result of an undo, this inverse will go onto the redo stack.

The invocations on either stack are grouped. By default, all invocations added to a stack during a single event are grouped together. Thus, if one user action causes changes in several objects, all the changes are undone by a single click of the Undo menu item.

The undo manager can also change the label on the undo and redo menu items. For example, “Undo Insert” is more descriptive than just “Undo." To set the label, use the following code:

[undoManager setActionName:"Insert"];

How do you get an undo manager? You can create one explicitly, but note that each instance of NSDocument already has its own undo manager.

Adding Undo to RaiseMan

Let's give the user the ability to undo the effects of clicking the Create New and Delete buttons, as well as the ability to undo the changes they make to Person objects in the table. The necessary code will go into your MyDocument class.

Currently, the NSArrayController object keeps its own array of Person objects. To give the user the ability to undo insertions and deletions, MyDocument must be the object that manages the array of employees. You plan to store the array inside MyDocument and to set the NSArrayController's contentArray binding so as to tell it to use MyDocument's array as its content. You will also write two methods that will be called when NSArrayController wishes to insert or remove a Person object.

Open MyDocument.h and add two instance variables and two actions:

@interface MyDocument : NSDocument
{
    IBOutlet NSArrayController *personController;
    NSMutableArray *employees;
}
- (void)insertObject:(Person *)p inEmployeesAtIndex:(int)index;
- (void)removeObjectFromEmployeesAtIndex:(int)index;
- (void)setEmployees:(NSMutableArray *)array
@end

Because these declarations include the class Person, you will need to add the following line to MyDocument.h after #import <Cocoa/Cocoa.h>:

@class Person;

Drag the MyDocument.h file from the Xcode project into the MyDocument.nib window.

Control-drag from the file's owner to the array controller. Set the outlet personController as shown in Figure 7.3.

Setting the personController Outlet

Figure 7.3. Setting the personController Outlet

In the bindings inspector for the array controller, expand the settings for the contentArray binding. Choose File's Owner (MyDocument) from the Bind to: pop-up menu. Enter employees for the Model Key Path, as shown in Figure 7.4.

Setting the contentArray Binding

Figure 7.4. Setting the contentArray Binding

Back in Xcode, edit the MyDocument.m file. First, import the Person.h file:

#import "Person.h"

Next, edit the init and dealloc methods to create and destroy the employees array:

- (id)init
{
    self = [super init];
    if (self) {
        employees = [[NSMutableArray alloc] init];
    }
    return self;
}

- (void)setEmployees:(NSMutableArray *)array
{
    if (array == employees)
        return;

    [employees release];
    [array retain];
    employees = array;
}

- (void)dealloc
{
    [self setEmployees:nil];
    [super dealloc];
}

Finally, implement the two methods in MyDocument that will be called automatically to insert and remove Person objects from the employees array. Notice that these methods are inverses of each other:

- (void)insertObject:(Person *)p inEmployeesAtIndex:(int)index
{
    // Add the inverse of this operation to the undo stack
    NSUndoManager *undo = [self undoManager];
    [[undo prepareWithInvocationTarget:self]
                          removeObjectFromEmployeesAtIndex:index];
    if (![undo isUndoing]) {
        [undo setActionName:@"Insert Person"];
    }

    // Add the Person to the array
    [employees insertObject:p atIndex:index];
}

- (void)removeObjectFromEmployeesAtIndex:(int)index
{
    Person *p = [employees objectAtIndex:index];
    // Add the inverse of this operation to the undo stack
    NSUndoManager *undo = [self undoManager];
    [[undo prepareWithInvocationTarget:self] insertObject:p
                                       inEmployeesAtIndex:index];
    if (![undo isUndoing]) {
        [undo setActionName:@"Delete Person"];
    }

    [employees removeObjectAtIndex:index];
}

These methods will be called when the NSArrayController wishes to insert or remove Person objects (for example, when the Create New and Delete buttons send it insert: and remove: messages).

At this point, you have made it possible to undo deletions and insertions. Undoing edits will be a little trickier. Before tackling this task, build and run your application. Test the undo capabilities that you have at this point.

Key-Value Observing

In Chapter 6, we discussed key-value coding. To review, key-value coding is a way to read and change a variable's value using its name. Key-value observing allows you to be informed when these sorts of changes occur.

To enable undo capabilities for edits, you will want your document object to be informed of changes to the keys expectedRaise and personName for all of its Person objects.

A method in NSObject allows you to register to be informed of these changes:

- (void)addObserver:(NSObject *)observer
         forKeyPath:(NSString *)keyPath
            options:(NSKeyValueObservingOptions)options
            context:(void *)context;

You supply the object that should be informed as observer and the key path for which you wish to be informed about changes. The options variable defines what you would like to have included when you are informed about the changes. For example, you can be told about the old value (before the change) and the new value (after the change). The context variable is a pointer to data that you would like sent with the rest of the information. You can use it for whatever you wish. I typically leave it NULL.

When a change occurs, the observer is sent the following message:

- (void)observeValueForKeyPath:(NSString *)keyPath
                       ofObject:(id)object
                         change:(NSDictionary *)change
                        context:(void *)context;

The observer is told which key path changed in which object. Here change is a dictionary that (depending on the options you asked for when you registered as an observer) may contain the old value and/or the new value. Of course, it is sent the context pointer supplied when it was registered as an observer. I typically ignore context.

Undo for Edits

The first step is to register your document object to observe changes to its Person objects. Add the following methods to MyDocument.m:

- (void)startObservingPerson:(Person *)person
{
    [person addObserver:self
             forKeyPath:@"personName"
                options:NSKeyValueObservingOptionOld
                context:NULL];

    [person addObserver:self
             forKeyPath:@"expectedRaise"
                options:NSKeyValueObservingOptionOld
                context:NULL];
}

- (void)stopObservingPerson:(Person *)person
{
    [person removeObserver:self forKeyPath:@"personName"];
    [person removeObserver:self forKeyPath:@"expectedRaise"];
}

Call these methods every time a Person enters or leaves the document:

- (void)insertObject:(Person *)p inEmployeesAtIndex:(int)index
{
    // Add the inverse of this operation to the undo stack
    NSUndoManager *undo = [self undoManager];
    [[undo prepareWithInvocationTarget:self]
         removeObjectFromEmployeesAtIndex:index];
    if (![undo isUndoing]) {
        [undo setActionName:@"Insert Person"];
    }

    // Add the Person to the array
    [self startObservingPerson:p];
    [employees insertObject:p atIndex:index];
}

- (void)removeObjectFromEmployeesAtIndex:(int)index
{
    Person *p = [employees objectAtIndex:index];
    // Add the inverse of this operation to the undo stack
    NSUndoManager *undo = [self undoManager];
    [[undo prepareWithInvocationTarget:self] insertObject:p
                                       inEmployeesAtIndex:index];
    if (![undo isUndoing]) {
        [undo setActionName:@"Delete Person"];
    }
    [self stopObservingPerson:p];
    [employees removeObjectAtIndex:index];
}

- (void)setEmployees:(NSMutableArray *)array
{
    if (array == employees)
        return;

    NSEnumerator *e = [employees objectEnumerator];
    Person *person;
    while (person = [e nextObject]) {
        [self stopObservingPerson:person];
    }

    [employees release];
    [array retain];
    employees = array;

    e = [employees objectEnumerator];
    while (person = [e nextObject]) {
        [self startObservingPerson:person];
    }
}

Now, implement the method that does edits and is its own inverse:

- (void)changeKeyPath:(NSString *)keyPath
             ofObject:(id)obj
              toValue:(id)newValue
{
    // setValue:forKeyPath: will cause the key-value observing method
    // to be called, which takes care of the undo stuff
    [obj setValue:newValue forKeyPath:keyPath];
}

Implement the method that will be called whenever a Person object is edited, either by the user or by the changeKeyPath:ofObject:toValue: method. Note that it puts a call to changeKeyPath:ofObject:toValue: on the stack with the old value for the changed key.

- (void)observeValueForKeyPath:(NSString *)keyPath
                      ofObject:(id)object
                        change:(NSDictionary *)change
                       context:(void *)context
{
    NSUndoManager *undo = [self undoManager];
    id oldValue = [change objectForKey:NSKeyValueChangeOldKey];
    NSLog(@"oldValue = %@", oldValue);
    [[undo prepareWithInvocationTarget:self] changeKeyPath:keyPath
                                                  ofObject:object
                                                   toValue:oldValue];
    [undo setActionName:@"Edit"];
}

That should do it. Once you build and run your application, undo should work flawlessly.

Notice that as you make changes to the document, a dot appears in the red close button in the window's title bar to indicate that changes have been made but have not been saved. You will learn how to save them to a file in the next chapter.

For the More Curious: Windows and the Undo Manager

A view can add edits to the undo manager. NSTextView, for example, can put each edit that a person makes to the text onto the undo manager. How does the text view know which undo manager to use? First, it asks its delegate. NSTextView delegates can implement this method:

- (NSUndoManager *)undoManagerForTextView:(NSTextView *)tv;

Next, it asks its window. NSWindow has a method for this purpose:

- (NSUndoManager *)undoManager;

The window's delegate can supply an undo manager for the window by implementing the following method:

- (NSUndoManager *)windowWillReturnUndoManager:(NSWindow *)window;

The undo/redo menu items reflect the state of the undo manager for the key window (Figure 7.5). (The key window is what most users call the “active window.” Cocoa developers call it key because it is the one that will get the keyboard events if the user types.)

NSTextView Inspector

Figure 7.5. NSTextView Inspector

..................Content has been hidden....................

You can't read the all page of ebook, please click here login for view all page.
Reset