Programming Mac OS X with Cocoa for Beginners/Document-based applications

From Wikibooks, open books for an open world
< Programming Mac OS X with Cocoa for Beginners
Jump to: navigation, search

Previous Page: Graphics - Drawing with Quartz | Next Page: Implementing Wikidraw

By now we have seen that Cocoa supports a "document based" multiple window type of application, which we will be basing our drawing program, Wikidraw, on. A better understanding of this will help us work through our design implementation.

A document is not itself a window. A window is used to view the contents of the document, and in many straightforward applications, there is a one-to-one mapping from NSDocument, the class that manages the document, and NSWindow, the window class. However, this is not the only approach - an application could offer several alternative ways of looking at a document, and therefore support more than one window per document. This is one reason why two different objects are used.

In Wikidraw, we will subclass NSDocument so that we have a place to store our drawing data as we draw it, and a place to place the various actions that we will need, such as Cut and Paste, etc. The view in our window simply provides a means of displaying the current state of the document. Ideally, we'd like to minimise the interdependence between the two classes, so that the internal workings of the document are not exposed to the view, and vice versa, in accordance with the principle of encapsulation. Because the view provides not just the drawing of the objects, but also the interaction with the user, the view will need to have some knowledge of the objects, so that it can pass on mouse clicks and so forth to them.

Delegation and protocols[edit]

Cocoa makes much use of a common design pattern called delegation. This is where an object relies on another object to supplement some of the details about its state, etc. The helper object is known as the delegate of the first object. Unlike subclassing, the delegate can be any other suitable object, and the runtime binding of methods means that the original object only needs to have the vaguest knowledge about the delegate to get information from it.

In Wikidraw, we can make the document a helper of our view. That way, the view can ask its delegate for basic information such as the list of objects in the drawing, or the list of selected objects, without requiring too much knowledge about the document object itself. Both objects agree to conform to a protocol for exchanging information. This is merely an agreed convention for some of the method names. Because the protocol is just a casual agreement, it's called an informal protocol. Objective-C supports a stricter type of protocol, called a formal protocol, using the '@protocol' keyword, but we won't be using that here.

Implementing MyDocument[edit]

In setting up our document-based application, Xcode and Interface Builder have already provided a starting point for us, a class called 'MyDocument'. This is a subclass of NSDocument that we will use to manage each Wikidraw document. If you find this file in XCode (Wikidraw->Classes->MyDocument.h) and select it, you'll see that it's an empty subclass of NSDocument. Select the .m file to show the current implementation. It contains a number of skeletal methods to get you started, though enough has been included to make the basics actually work, such as showing the window.

Now we need to add some data members and methods to MyDocument.h so that it can start to handle the task of managing our drawing.

@interface MyDocument : NSDocument
{
        NSMutableArray* _objects;
        NSMutableArray* _selection;
}

/* drawing maintenance */

- (void)                addObject:(id) object;
- (void)                removeObject:(id) object;
- (NSArray*)    objects;

/* selection maintenance */

- (void)                selectObject:(id) object;
- (void)                deselectObject:(id) object;
- (void)                selectAll;
- (void)                deselectAll;
- (NSArray*)    selection;

/* clicks */

- (id)                  objectUnderMousePoint:(NSPoint) mp;

@end

We'll add more later, but these are the basic methods we'll need. There are two data members, each a mutable array. One contains all the objects in the drawing, the other just the objects in the selection. When we create a new object, it is added to the _objects list using addObject:, if we delete an object, it is removed using removeObject: We can make these actions undoable easily by recording the state of the _objects array before each of these operations. We'll come to that later, but for now it's only important to understand that to make Undo work simply, defining a couple of obvious places where the data is changed is needed. We'll do the same in our actual objects where they are edited, for example by having their size or position changed.

The selection works similarly. Objects are added to the selection array or removed from it. When the view comes to draw the drawing, it checks both arrays. If an object is found in both, it "knows" that the object should be drawn in its selected state. The object itself handles its own drawing, so all that is needed is for the view to request that it is drawn with selection highlighting or not. This way, the view simply acts as a mediator between the document, which maintains WHICH objects are selected, and the actual objects, which know HOW to portray the selected state. The view itself is not interested in what is selected. We'll see that this approach is quite straightforward and elegant, though it is not the only possible one. (A common alternative is for the selected state to be a boolean flag within each stored object, but that isn't usually the best approach in an application like this. Many document-level commands apply to the set of selected objects, so to implement these with our scheme involves a simple iteration of the _selection array. Using the flag approach, we'd have to go through the entire list, checking which objects had the flag set, and ignoring the others. In general this can hamper performance once the drawing gets large).

The 'objectUnderMousePoint:' method is provided as a simple way for the view to determine what was clicked. The view itself will handle the basic mouse events, such as clicking and dragging, and this method allows it to determine which object the operation is dealing with. Once it has that information, the view is able to implement the clicks and drags by simply handing them off to the object itself, which knows what to do (resize, move, etc). The document doesn't need to know what the user is doing, it just needs to track what's in the drawing. You'll see that we are pushing as much of the 'smarts' into the drawing objects themselves as possible.

Saving and restoring from files[edit]

A key ability of a document is to convert its internal representation to and from a file representation of the same data. You'll see in MyDocument.m that two skeleton methods to do this have been provided:

- (NSData*)   dataRepresentationOfType:(NSString*)  aType
- (BOOL)       loadDataRepresentation:(NSData*) data ofType:(NSString*) aType

These are overrides from NSDocument, so are not declared in our class definition. The first is responsible for saving to a file, the second reading in a file. The 'type' string is used when we have more than one type of file we can read and write to — many real applications will support different formats. Here we won't, we'll just have our own. The string passed is actually the file extension of the file, so if you opened a JPEG file, the type would be the string "jpg".

So what do we do to make the file representations, or read from a file? It's easy. Cocoa supports this using something called an archiver. Most objects can be archived, meaning that they already know how to read and write themselves to a file stream. Doing this requires no more than writing values to a dictionary using a key, which we already know how to do. Each object in the drawing is asked to archive itself, so it must record as much information as necessary to completely recreate that object later — its position, size, colour, etc. Each value is given a unique key, usually just a string. When the file is read back in later, each object is reinstantiated and given the opportunity to re-establish its data values from the stream. Using the same keys, the values are read back in, thus restoring the object to the saved state.

At the document level, all we need to do is to archive the '_objects' array. When an array is archived, all of the objects it contains are also automatically archived. We'll find that at this level, there is almost nothing to do — all the hard work is, once again, done by the drawing object itself. The selection state is not saved, so we don't need to care about that.

Since the entire _objects array will be recreated from the file, MyDocument needs a way to set the whole array in one go. The obvious method would be one called setObjects, so let's add that to our definition.

- (void)           setObjects:(NSMutableArray*) arr;

For now, this is enough to get started on our document implementation. We need to expand the methods we've declared in the implementation file. One way is to cut and paste them from the header, then add curly braces to turn them into actual methods. Do that now.

@implementation MyDocument
 
- (id)		init
{
    self = [super init];
    if (self)
	{
		_objects = [[NSMutableArray alloc] init];
		_selection = [[NSMutableArray alloc] init];
    }
    return self;
}
 
 
- (void)	dealloc
{
	[_objects release];
	[_selection release];
	[super dealloc];
}
 
- (NSString *)	windowNibName
{
    return @"MyDocument";
}
 
- (void)		windowControllerDidLoadNib:(NSWindowController *) aController
{
    [super windowControllerDidLoadNib:aController];
}
 
/*
dataRepresentationOfType: Deprecated in Mac OS X v10.4. Use dataOfType:error: instead.
- (NSData *)dataOfType:(NSString *)typeName error:(NSError **)outError
{
    // Insert code here to write your document to data of the specified type. If the given outError != NULL, ensure that you set *outError when returning nil.
 
    // You can also choose to override -fileWrapperOfType:error:, -writeToURL:ofType:error:, or -writeToURL:ofType:forSaveOperation:originalContentsURL:error: instead.
 
    // For applications targeted for Panther or earlier systems, you should use the deprecated API -dataRepresentationOfType:. In this case you can also choose to override -fileWrapperRepresentationOfType: or -writeToFile:ofType: instead.
 
    if ( outError != NULL ) {
		*outError = [NSError errorWithDomain:NSOSStatusErrorDomain code:unimpErr userInfo:NULL];
	}
	//return nil;
	return[NSKeyedArchiver archivedDataWithRootObject:[self objects]];
}
 
- (BOOL)readFromData:(NSData *)data ofType:(NSString *)typeName error:(NSError **)outError
{
    // Insert code here to read your document from the given data of the specified type.  If the given outError != NULL, ensure that you set *outError when returning NO.
 
    // You can also choose to override -readFromFileWrapper:ofType:error: or -readFromURL:ofType:error: instead. 
 
    // For applications targeted for Panther or earlier systems, you should use the deprecated API -loadDataRepresentation:ofType. In this case you can also choose to override -readFromFile:ofType: or -loadFileWrapperRepresentation:ofType: instead.
 
    if ( outError != NULL ) {
		*outError = [NSError errorWithDomain:NSOSStatusErrorDomain code:unimpErr userInfo:NULL];
	}
 
	NSArray* arr = [NSKeyedUnarchiver unarchiveObjectWithData:data];
	NSMutableArray* marr = [arr mutableCopy];
 
	[self setObjects:marr];
	[marr release];
    return YES;
}
*/
- (NSData *)	dataRepresentationOfType:(NSString*) aType
{
	return[NSKeyedArchiver archivedDataWithRootObject:[self objects]];
}
 
- (BOOL)		loadDataRepresentation:(NSData*) data ofType:(NSString*) aType
{
	NSArray* arr = [NSKeyedUnarchiver unarchiveObjectWithData:data];
	NSMutableArray* marr = [arr mutableCopy];
 
	[self setObjects:marr];
	[marr release];
	return YES;
}
 
 
- (void)		addObject:(id) object
{
	if(![_objects containsObject:object])
		[_objects addObject:object];
}
 
- (void)		removeObject:(id) object
{
	[self deselectObject:object];
	[_objects removeObject:object];
}
 
- (NSArray*)	objects
{
	return _objects;
}
 
- (void)		setObjects:(NSMutableArray*) arr
{
	[arr retain];
	[_objects release];
	_objects = arr;
	[self deselectAll];
}
 
- (void)		selectObject:(id) object
{
	if([_objects containsObject:object] && ![_selection containsObject:object])
		[_selection addObject:object];
}
 
- (void)		deselectObject:(id) object
{
	[_selection removeObject:object];
}
 
- (void)		selectAll
{
	[_selection setArray:_objects];
}
 
- (void)		deselectAll
{
	[_selection removeAllObjects];
}
 
- (NSArray*)	selection
{
	return _selection;
}
 
- (id)			objectUnderMousePoint:(NSPoint) mp
{
	NSEnumerator*	iter = [_objects reverseObjectEnumerator];
	id				obj;
 
	while( obj = [iter nextObject])
	{
		if ([obj containsPoint:mp])
			return obj;
	}
 
	return nil;
}

The implementation is pretty straightforward. In the init method, we allocate _objects and _selection. We need a dealloc method to make sure they get released when our document is deallocated.

addObject: and removeObject: simply map to the NSMutableArray methods, though we additionally check that we are not adding the same object more than once. When an object is removed, it is also deselected, to make sure that we don't leave a stale reference in the selection list to an object that has gone altogether. objects simply returns the array itself as a read-only type (this gets around Objective-C's lack of a 'const' operator.) setObjects: replaces the entire array, which we'll use in a moment when we read a file.

The selection methods work similarly. selectObject: merely adds the object to the selection list, first checking that it isn't already selected and is indeed part of the drawing. deselectObject: removes it from the selection list. selectAll makes the selection list match the entire drawing using setArray:, and deselectAll simply makes the list empty.

The objectUnderMousePoint: method gives us a chance to use an iterator. We iterate over the objects array, calling containsPoint: on each object. As soon as one of them return YES, the method returns that object. It is up to the object itself to implement containsPoint: in a sensible manner. Why do we iterate here in reverse? It's because in the drawing, objects can be placed such that they overlap. To the user, this will appear as if some objects are behind others. The view will draw the list of objects in forward order, meaning that objects at the end of the list will get drawn 'on top of' any earlier ones. By detecting clicks in reverse, we make sure that we find the topmost objects before any underlying ones, if the click occurs on an overlapping area. It means that the user gets the expected behaviour—they can click on what they can see. If nothing gets clicked, nil is returned.

The methods that implement archiving and dearchiving a file are straightforward. Most of the magic is hidden from us at this level. We use NSKeyedArchiver's factory method 'archivedDataWithRootObject:' to pass it our objects array. It does its magic and returns us an NSData object, which we return. That's all we need to do, Cocoa handles the rest, writing the data to the file. When we go the other way, we use NSKeyedUnarchiver's 'unarchiveObjectWithData:' factory method to reverse the process. What we get back is an autoreleased NSArray object (because we saved an NSArray object earlier). You may ask how the archiver and unarchiver know how to encode or decode WKDShape objects. The answer is simple: they don't. As usual in object oriented programming, the WKDShape object is responsible for encoding and decoding itself. The (un)archiver will try to call corresponding methods of WKDShape which are not yet implemented. This is done in the chapter Archiving. At this point you may save a document and open a saved document, but it will be blank and not contain any shapes.

However we need the array obtained from the unarchiver to be mutable, so we use mutableCopy: to make a mutable copy. This is passed to the setObjects: method replacing any objects we already have. Because we made an explicit copy, we must release the object here. setObjects: retains it anyway, so it still sticks around, but we don't want to let it remain retained twice, otherwise we'll have a potential memory leak. setObjects: also deselects everything, since we are starting with a new freshly opened document—this just makes sure that no stray objects are left selected. This could happen after a revert, for example.

File types[edit]

How does Cocoa know what type of file we are saving or reading? It doesn't yet - if we Build and Go now, choosing Open gives us the file picker but we won't be able to pick any files. To set this, in the left hand panel of Xcode, Drill down from Targets to Wikidraw. Select Wikidraw and choose Get Info. In the dialog that opens, select the 'properties' tab. In the 'Document types' area, edit the first and only item so that Name is 'Wikidraw drawing', Extensions is set to 'wkd', OSTypes is set to '.wkd' and class is MyDocument. This sets up a mapping between files of type'.wkd', which will be our drawing file type, and the MyDocument class, which knows how to handle that file type. (Please note: dataRepresentationOfType: is Deprecated in Mac OS X v10.4. Use dataOfType:error: instead. for save to work)

While we are here, lets also change a few other settings. Change 'Identifier' to 'com.wiki.wikidraw', and Creator to 'WIKD'.

Close the Get Info window, and Build and Go again. We still have no files available in the open dialog, because there are no files with an extension .wkd yet. So let's save one. Do a Save As and save the file as 'wikidrawtest' or something similar. The open window should change to the name you choose. Now when you try an Open, you should be able to select and open this file. If you locate the file in the Finder, do a Get Info, you should be able to check that it does indeed have an extension of '.wkd'. Note that the file will not contain anything yet, since we don't have enough of the application built to actually create any drawings.

We'll tackle that next.

Previous Page: Graphics - Drawing with Quartz | Next Page: Implementing Wikidraw