Programming Mac OS X with Cocoa for Beginners/Adding finesse

From Wikibooks, open books for an open world
Jump to navigation Jump to search

Previous Page: Archiving

So far we have built a fairly crude drawing program, but one that is starting to look like a real application. We have covered many of the core principles of Cocoa that you'll need to write any application. If you look at how much code we've actually written, it's not that much, yet our application is already quite functional. In this section, we'll look at adding finesse - those touches that separate real, well-designed and implemented applications from those lesser amateur efforts we've no doubt all seen.

Undo[edit | edit source]

The first thing to tackle is Undo. A real application should allow almost all actions to be undoable, in the spirit of forgiveness of the user. Without Undo, your application is likely to be discarded in favour of another one that has it, as punishing the user for mistakes, or not allowing them to experiment is a cardinal design sin!

Undo is traditionally one of the "hard" parts of implementing an application. Cocoa helps us massively here, and so Undo is not so hard. Usually, adding Undo as an afterthought is not a good idea - you need to design your application from the ground up to cope with the undo task. So far we have not mentioned Undo, but we have in fact designed our application to allow it to be added easily.

The principle of Undo is straightforward - whenever we do anything to change the state of the data model, the previous state is recorded so it can be put back. We can record this previous state in different ways, either just record the whole state, or parts of it along with tags to tell us what was changed, or we can record actions and perform the opposite action to implement Undo. We'll be using a combination of these things. Cocoa automatically combines several undoable "recordings" into one Undoable action for each event, which really makes life very simple for us - we simply need to make sure the recording happens at each editable point of interest.

We'd like the following to be undoable:

  • adding new objects
  • deleting objects
  • changing the position or size of an object
  • changing the stroke and fill colours
  • changing the stroke width

Note that we could also make the selections themselves undoable, but in this exercise we won't do this.

In Cocoa, the object responsible for handling Undo is the undo manager. It is embodied in the class NSUndoManager. The way it works is by storing invocations. We already discussed how Objective-C uses runtime binding of class methods. An invocation is just a stored description of a method call. When the invocation is "replayed" the stored method call is actually made. The invocation not only records the target and method to be called, but also all of the method's parameters at the time the invocation was recorded. Thus we can create an invocation which when called, will undo the current action we are performing. Both the action and the invocation to undo the same action are created at the same time.

First let's add the code for undoing adding and deleting objects. Here is the modified code in MyDocument.m for addObject and removeObject:

- (void)		addObject:(id) object
{
	if(![_objects containsObject:object])
	{
		[[self undoManager] registerUndoWithTarget:self
							selector:@selector(removeObject:)
							object:object];
		
		[_objects addObject:object];
		[_mainView setNeedsDisplayInRect:[object drawBounds]];
	}
}

- (void)		removeObject:(id) object
{
	if([_objects containsObject:object])
	{
		NSRect br = [object drawBounds];
		
		[[self undoManager] registerUndoWithTarget:self
							selector:@selector(addObject:)
							object:object];

		[self deselectObject:object];
		[_objects removeObject:object];
		[_mainView setNeedsDisplayInRect:br];
	}
}

We obtain an undo manager by calling [self undoManager]. Every document has its own undo manager instance already set up, we just need to use it. We then use the registerUndoWithTarget:selector:object: method to build an invocation that the undo manager stores. When we add an object, we build an invocation to removeObject. When we remove an object, be build an invocation to addObject. In other words, we record the OPPOSITE of what we are doing. We have also added code here to refresh the main view when the objects come and go, so that we can see the effect that our actions have.

Compile and run the project. Create some shapes. Now Undo them. You'll find you can Undo and Redo as many actions as you want - everything works as you would expect for the Undo command. Not bad for just two lines of code!

Note that when Undo is performed, it calls, say removeObject, which in turn records another addObject undo action. This is how redo works - NSUndoManager knows the context it is called in, so can add tasks to the appropriate stack. Now let's make editable actions Undoable. For this, we'll need to add code to WKDShape, and at present shapes don't have an undomanager, nor do they know anything about the document they are part of, so they can't obtain one. So the first thing we need to do is to provide a way to allow shapes to obtain an appropriate undo manager.

In WKDShape.h, add a data member to the class called _owner, typed as NSDocument*. Add accessor methods setOwner: and owner. In the .m file, add the implementation:

- (void)		setOwner:(NSDocument*) doc
{
	_owner = doc;
}


- (NSDocument*)	owner
{
	return _owner;
}

In MyDocument.m, add a line to addObject that calls setOwner with self - this makes sure that whenever an object is added to a document, the object is updated with which document it belongs to. Note that since everything that ever adds an object to a document calls this method, including paste for example, this information is always up to date. This is a strong design principle - always try to minimise the number of places in your code that change the data model by factoring code into a few key methods. Then you can add code to those methods in the knowledge that they will always be called, and nothing can get in "through the back door". If we hadn't done this, implementing Undo would be very much harder than it has been so far.

Now we have a way for a WKDShape to obtain its owning document's undoManager, we can implement undo easily.

- (void)		setFillColour:(NSColor*) colour
{
	[[[self owner] undoManager] registerUndoWithTarget:self
								selector:@selector(setFillColour:)
								object:_fillColour];
	
	[colour retain];
	[_fillColour release];
	_fillColour = colour;
	[self repaint];
}

Here, we don't set up the undo using a different method, we use the same method, but with the OLD value of the colour. When the undo command is called, it calls this same method, passing back the original colour. The code for the stroke colour is identical.

The case for the stroke width is slightly more involved, because the undo manager doesn't directly store simple scalar values such as a float. Instead we need to pack the value up into an object. Cocoa provides a simple class for doing just this - NSNumber. As we need to be able to call a method that takes an object parameter, we must now create one. We'll refactor the code so that all the calls to set the stroke width now go through it.

- (void)		setStrokeWidth:(float) width
{
	[[[self owner] undoManager] registerUndoWithTarget:self
								selector:@selector(setStrokeWidthObject:)
								object:[NSNumber numberWithFloat:_strokeWidth]];
	_strokeWidth = width;
	[self repaint];
}


- (void)		setStrokeWidthObject:(NSNumber*) value
{
	[self setStrokeWidth:[value floatValue]];
}

Don't forget to add the method setStrokeWidthObject: to your class declaration. Now if anyone calls setStrokeWidth, the old value is turned into an NSNumber object and used to build the undo invocation on setStrokeWidthObject: When Undo is called, setStrokeWidthObject: unpacks the encapsulated value and calls setStrokeWidth:, so everything works correctly.

Compile and go to test that changing properties using the inspector is now fully undoable. They should be!

Notice how Cocoa has separated each editing action into different Undo commands for you. If you drag around in the colour picker for example, the colour of the object will change many, many times until you stop dragging. But only one Undo command is needed to put back the previous colour - it doesn't visibly "replay" all of the individual intermediate colours.

There is one thing that isn't so great now however. If we Undo an editing change, the Inspector doesn't change! If we think about it, it's obvious why - previously we only set up the inspector to respond to selection changes which also set up the state of the controls. After that the inspector changes the object's state so they were always in "synch". Undo isn't changing the selection state, but it is changing the object's state behind the inspector's back, so we need to tell the inspector to resynch when this occurs. To do this we need to add a new notification that the inspector can respond to.

This is very similar to the "repaint" notification - in fact it would be possible to use that one in this case, but since semantically it does have a different meaning, it is good practice to make it a separate notification. If later you extended the design you might find combining the notifications led to awkward problems. So create a new notification name and a method for sending it. I called it resynch. Call this method from every place that the object state can be changed from - setFillColour:, setStrokeColour:, setStrokeWidth: In the inspector, create a new responder method - I called it resynch: - taking a single NSNotification parameter. Subscribe to the resynch notification in the awakeFromNib method as before. The resynch: method looks like:

- (void)		resynch:(NSNotification*) note
{
	WKDShape* shp = (WKDShape*)[note object];
	
	if ( shp == editShape )
		[self setupWithShape:shp];
}

As you can se, it's very simple - we find out which object was changed. If it's the one we are in fact currently editing, we simply call our setup method so that the control states match the object's state. Compile and run again to verify that this time, Undoing an edit operation also reflects the change in the Inspector.

Finally, we need to Undo moves and resizes of the object. By factoring all of this information through the setBounds: method, we have just one place to add the undo recording to. As with setStrokeWidth, we'll need to use an NSValue object to store the old rect.

Here's the refactored and modified code in WKDShape:

- (void)		setBounds:(NSRect) bounds
{
	[self repaint];
	[[[self owner] undoManager] registerUndoWithTarget:self
								selector:@selector(setBoundsObject:)
								object:[NSValue valueWithRect:[self bounds]]];
	
	_bounds = bounds;
	[self repaint];
	[self resynch];
}


- (void)		setBoundsObject:(id) value
{
	[self setBounds:[value rectValue]];
}


- (void)		setLocation:(NSPoint) loc
{
	NSRect br = [self bounds];
	
	br.origin = loc;
	[self setBounds:br];
}


- (void)		offsetLocationByX:(float) x byY:(float) y
{
	NSRect br = [self bounds];
	
	br.origin.x += x;
	br.origin.y += y;
	[self setBounds:br];
}


- (void)		setSize:(NSSize) size
{
	NSRect br = [self bounds];
	br.size = size;
	[self setBounds:br];
}

Compile and run... create a shape and move it. Now Undo. Is the action undoable in the way you expected? No, it's not. The action is undoable, but unlike the colour case, each individual small amount of motion has been recorded as a separate Undo task. The Undo manager has failed to group the motions into one big single action. Why is that? NSUndoManager groups all undo actions that occur within a single event. However, dragging an object consists of multiple events. We can't change that, but we can change the way that the undo manager groups things by giving it some hints.

In WKDDrawView.m, in the method mouseDown:, add the following line at the top of the method:

[[[self delegate] undoManager] beginUndoGrouping];

and in the mouseUp: method, add the following line at the bottom of the method:

[[[self delegate] undoManager] endUndoGrouping];

This will fix the problem - compile and run to verify. What this does it do tell the undo manager to group any undo recording it receives from now until the call to endUndoGrouping. We beging grouping on a mouse down, and end on a mouse up, so all the actions that occur during the drag will end up in the same group, and will be replayed under the single Undo command. Each individual offset of the shape is still recorded, but when played back the entire move is replayed so the effect for the user is the expected behaviour.

Finally, you'll probably have noticed that the menu command only shows a generic "Undo". It would be nice if we could give our users a more meaningful indication of what the Undo operation will accomplish. To do this, we pass a string to the undo manager which is uses to build the menu command text. Whatever was the most recent string it received in a group will be the one used. The method is NSUndoManager's setActionName: method. For now we will just hard-code the strings for each action - in a real app we'd need to allow this to be localizable.

In WKDShape, this is straightforward. For example, in setFillColour, add:

	[[[self owner] undoManager] setActionName:@"Change Fill Colour"];

just after the current undo recording operation. For all similar state changes, you can add a line with a string indicating the thing actually changed. Remember that the user will see this; you don't need to include "Undo" or "Redo". To distinguish between resizing and moving a shape, you can include this line in the appropriate routines - they don't need to go directly in setBounds: also, don't forget the code where a handle is dragged interactively - mouseDragged:

The MyDocument changes are similar with one small kink - because we are using the 'opposite' method approach, we need to tell apart the case of undo/redo and a normal call and make sure we only update the string if we were called normally - i.e. not from an undo invocation. To do this, we use:

		if (! [[self undoManager] isUndoing] && ! [[self undoManager] isRedoing])
			[[self undoManager] setActionName:@"Delete Object"];

Run again to check that everything works as expected.

As a bonus, you'll notice that now, changes to the document automatically result in you being asked if you want to save changes when you quit or close a document. This is because by recording undo tasks, you are also notifying the document that its state is different from the disk file. Previously, it had no way to tell. Cocoa now steps in and adds that small piece of functionality for you "for free". Neat!

Further exercises:

  • Add the ability to undo selection changes. This will often result in a more usable application in practice.

Printing[edit | edit source]

Real applications should print. Again, in a traditional Mac application, adding the ability to print was usually hard work, and very often added as an afterthought, making it harder still! Cocoa is much easier, since there is no major difference between its on-screen graphics model and the printing model. In fact we already have most of the code written.

Printing is handled by an object called an NSPrintOperation, working in conjunction with an NSView. The view draws the content to the paper. For our simple exercise, we need very little code, since the default pagination scheme of tiling across pages is adequate. As you might imagine Cocoa provides numerous alternative approaches, but these are rather beyond the scope of just getting basic printing working.

As we have a document-based application, we use the printDocument: action method to get things going. Here's the code:

- (IBAction)	printDocument:(id) sender
{
    NSPrintOperation *op = [NSPrintOperation printOperationWithView:_mainView
												printInfo:[self printInfo]];
    
	[op runOperationModalForWindow:[self windowForSheet]
         delegate:self
         didRunSelector:
         @selector(printOperationDidRun:success:contextInfo:)
		 contextInfo:NULL];
}

We create an NSPrintOperation, passing it our main view which will render the drawing. Once we have this, we then just run it using a sheet dialog. That takes care of the rest. The sheet requires a completion routine, but in this case we don't need it to actually do anything, so we simply provide it as an empty method:

- (void)		printOperationDidRun:(NSPrintOperation*) printOperation
                success:(BOOL)success
                contextInfo:(void *)info
{
}

Don't forget to add this to your class definition.

That's it! Compile and run it and you'll find that you can print out your drawings.

There's one thing that isn't so great here, and this is that it prints the selection handles. Really, we don't need to see these handles when a drawing is printed, so we'll now just modify the main view drawing code so that it doesn't bother to render these when it's printing. This turns out to be very simple:

	while( shape = [iter nextObject])
	{
		if ( NSIntersectsRect( rect, [shape drawBounds]))
			[shape drawWithSelection:[selection containsObject:shape] &&
                                                                    [NSGraphicsContext currentContextDrawingToScreen]];
	}

This is the main loop inside our view's drawRect: method. We just add a check whether the current graphics context is drawing to the screen. If not, it must be drawing to the printer, so the objects are rendered without the handles, regardless of their selection state.

Menu item maintenance[edit | edit source]

We have seen how Cocoa automatically enables menu items that can find a target in the current context. That's pretty neat, but not always enough. For example, when we implemented Cut and Paste, we found these commands always enabled even if there was no selection to cut, or nothing on the clipboard we could paste. We'll now see how we deal with that.

Whenever a menu is displayed, the target is given a chance to overrule the normal enabling behaviour. It does this by implementing a method called validateMenuItem: which is called for each item. You can return YES to enable the item, NO to disable it. So in MyDocument, we add:

- (BOOL)		validateMenuItem:(NSMenuItem*) item
{
	SEL act = [item action];
	
	if ( act == @selector(cut:) ||
	     act == @selector(copy:) ||
		 act == @selector(delete:))
		return ([[self selection] count] > 0 );
	else if ( act == @selector(paste:))
	{
		NSPasteboard* cb = [NSPasteboard generalPasteboard];
		NSString* type = [cb availableTypeFromArray:[NSArray arrayWithObjects:@"wikidrawprivate", nil]];

		return ( type != nil );
	}
	else
		return YES;
}

As here, it's a good idea to compare the action selectors to determine which action the menu item is targetting, instead of looking at the menu item strings or position. An alternative would be to use the item's tag, but that would require that you carefully set these up in IB. By using the selector, you are immune from text and positional changes you might make to the menu - as long as it remains targetted at the same action method, it will behave correctly with respect to what that method actually achieves.

Here, if the item is targetting the cut, copy or delete methods, we see if there are any items in the selection array. If so, we enable the item, otherwise we disable it. For the paste case, we peek at the pasteboard to see if it has data of type 'wikidrawprivate' available - if so we can paste, so we enable that item.

You can implement a similar method in any target of a menu command so finely control the enabling of a menu item. You can also use the opportunity to change the text of a menu item to reflect some information you have in your data model if you like.

Previous Page: Archiving