Jump to content

Programming Mac OS X with Cocoa for Beginners/Wikidraw's view class

From Wikibooks, open books for an open world

Previous Page: Implementing Wikidraw | Next Page: More shapes

We have our basic shape class in place, and a document structure to store them. The final main piece of the jigsaw is the view, which pulls everything together to allow the user to create drawings interactively. Let's consider its design.

Design of the view class

[edit | edit source]

A generic NSView provides two main pieces of functionality - the ability to draw graphics in a window, which we have looked at already, and the ability to handle the user's mousing, which is something new. We've already discussed that this comes in three parts - a mouse down event, a series of mouse dragged events, and a mouse up event. Our shape class is all ready to handle these when they are passed along from our view.

The view will need to do more than just pass along these events however, since it will need to deal with other aspects of user interaction, such as the fact there will be many different shapes in the drawing, and the need to manage the selection of them as the user clicks.

In addition, the creation of shapes will depend on which tool is currently selected in the tool palette (which we haven't designed yet). Since shape creation is also accomplished by using the mouse and drawing in the view, it is the view that will need to provide a means of distinguishing between the various tools.

When it comes to drawing, the view needs to render the entire drawing, and also determine if an object is selected. While the shape itself handles the actual rendering of itself, the view must make sure that every object's drawWithSelection: method is called, and pass along the selection state.

Where does the view obtain the information from the document? We'll set things up so that the view is able to call upon the document as a delegate. We established the informal protocol for getting the objects and the selection using the document's objects and selection methods. Provided we set up a valid delegate, the view can call it using this informal protocol, and get the information it needs. NSView doesn't have a delegate by default, so we need to add that ourselves. Add a _delegate data member to the WKDDrawView class, with type 'id'. Then add methods for setting and getting this - setDelegate, and delegate. Note that the view doesn't need to retain its delegate in this case - it is an exception to the usual rule about creating new references to object. In this case it's OK because we know that the document and the view will always be created and destroyed together.

- (void)	setDelegate:(id) aDelegate
{
	_delegate = aDelegate;
}


- (id)		delegate
{
	return _delegate;
}

Now we need to make sure the delegate gets set to the document when the view is created. To do this, we'll create a link in Interface Builder so that the document can find the view, then call its setDelegate method, passing itself. This seems a bit roundabout, but it prevents too much interdependency between the view and the document.

In MyDocument.h, add a data member as follows:

	IBOutlet id	     _mainView;

Then Save the file. What we've done here is create a reference to an object called _mainView. We've also flagged it as an IBOutlet. Earlier we saw how a return type of IBAction can be used to provide information to Interface Builder. This is similar. It tells IB that this particular data member can be an outlet, that is, one end of a connection to another object, which can be accessed using the control-drag connection method. In Xcode, IBOutlet is defined as nothing, so it doesn't affect the code in any way.

Drag MyDocument.h to the MyDocument.nib window open in Interface Builder. It will reparse the file, and pick up the new outlet. Now we need to connect this outlet to the window's view (WKDDrawView).

You might be wondering how we can do this, since the MyDocument class doesn't seem to be respresented in Interface Builder. Indeed this class is created by Cocoa when we open a file or choose New, so how do we solve this one? This is where the mysterious "file's owner" icon comes in. The object that owns 'MyDocument.nib' is in fact, the MyDocument class. So file's owner represents it. Having dragged in MyDocument.h you'll notice a small exclamation mark on the File's Owner icon. This means that there are unconnected outlets in that object, which is true, since we just added the outlet _mainView, but haven't connected it yet.

Control-drag from File's owner to the view within the window. Make sure it is WKDDrawView that is highlighted, not the enclosing scroller. When you let go, the Inspector shows the Connections, and switches to Outlets. Select '_mainView' and click connect. Save the file and return to Xcode.

Now when MyDocument is instantiated, the data member _mainView will be automatically set up pointing to the WKDDrawView in the window. So all we now need to do is to use this reference to set the view's delegate. When MyDocument is instantiated, one of the last methods called as part of the initialisation is 'windowControllerDidLoadNib:'. At this point all connections declared in the .nib file, like the one we just made, are guaranteed to be in place, so we can simply add our code here:

- (void)		windowControllerDidLoadNib:(NSWindowController *) aController
{
    [super windowControllerDidLoadNib:aController];
	[_mainView setDelegate:self];
}

From now on, whenever the view needs anything from the document, it calls its delegate method to obtain the document, and the informal protocol to obtain what it wants. The view doesn't otherwise know anything about the document, and vice versa - overcoupling has been avoided. Note that the view could be used in another application that used an entirely different object but implementing the same informal protocol to get its work done. This is one way that you can design classes for reuse among a variety of projects.

Rendering the document contents

[edit | edit source]

The first job our view has to do is to render the contents of the document. Here's the code:

- (void)	drawRect:(NSRect) rect
{
	[[NSColor whiteColor] set];
	NSRectFill( rect );
	
	NSArray*		drawList = [[self delegate] objects];
	NSArray*		selection = [[self delegate] selection];
	NSEnumerator*	iter = [drawList objectEnumerator];
	WKDShape*		shape;
	
	while( shape = [iter nextObject])
	{
		if ( NSIntersectsRect( rect, [shape drawBounds]))
			[shape drawWithSelection:[selection containsObject:shape]];
	}
}

As before, we start by erasing the part of the background that is to be redrawn. Next we ask our delegate for the objects and the selection using the informal protocol we designed earlier. Then it's simply a case of iterating over the objects and drawing each one. For efficiency, we also check whether the refresh rect intersects the bounds of the object, and if not we don't bother - this just avoids drawing anything that isn't visible or affected by the refresh, speeding up drawing. The selection state is determined simply by seeing if the object is in the selection array.

Sharp-eyed readers will notice that a method called drawBounds is being called on the shape object. We have not so far discussed this, but we need to. If we were to use the shape's bounds, we'd find that small parts of the things it draws will not always get erased when they need to, because some things are actually drawn outside of the area defined by bounds. The selection handles for example, are centred ON the bounds, so at least half of the handle is outside the bounds. Likewise, when we stroke the shape, the bounds defines the centre of the stroked lines. We need to take these into account so that we can correctly refresh the entire area that is drawn. the method drawBounds returns a rectangle that is based on bounds, but slightly expanded from it:

- (NSRect)		drawBounds
{
	return NSInsetRect([self bounds], -kHandleSize - [self strokeWidth], -kHandleSize - [self strokeWidth]);
}

This takes into account both the size of the handles and the stroke width so that everything is guaranteed to fall within this area. Any drawing that simply repaints or refreshes an object will use drawBounds rather than bounds, but it must be remembered that bounds is the strictly mathematical boundary of the shape.

Handling the mouse

[edit | edit source]

That's all there is to drawing. The case of handling the mouse is more involved. Let's look at that now.

- (void)	mouseDown:(NSEvent*) evt
{
	NSPoint pt = [self convertPoint:[evt locationInWindow] fromView:nil];
	_dragShape = [[self delegate] objectUnderMousePoint:pt];
	
	if (_dragShape == nil)
	{
		_dragShape = [self shapeForCurrentTool];
		[[self delegate] addObject:_dragShape];
		[_dragShape setLocation:pt];
	}
	
	if (([evt modifierFlags] & NSShiftKeyMask) == 0)
	{
		[[self delegate] deselectAll];
		[[self delegate] selectObject:_dragShape];
	}
	else
	{
		if ([[[self delegate] selection] containsObject:_dragShape])
		{
			[[self delegate] deselectObject:_dragShape];
			_dragShape = nil;
		}
		else
			[[self delegate] selectObject:_dragShape];
	}
	
	[self setNeedsDisplay:YES];
	[_dragShape mouseDown:pt];
}


- (void)	mouseDragged:(NSEvent*) evt
{
	NSPoint pt = [self convertPoint:[evt locationInWindow] fromView:nil];
	NSRect update = [_dragShape drawBounds];
	
	[_dragShape mouseDragged:pt];
	[self setNeedsDisplayInRect:NSUnionRect([_dragShape drawBounds], update)];
}


- (void)	mouseUp:(NSEvent*) evt
{
	NSPoint pt = [self convertPoint:[evt locationInWindow] fromView:nil];
	[_dragShape mouseUp:pt];
	[self setNeedsDisplayInRect:[_dragShape drawBounds]];
	_dragShape = nil;
}

Three methods are involved — mouseDown, mouseDragged and mouseUp. Each is passed an NSEvent object, representing the mouse event that triggered the call. The first thing we need to do is to adjust the mouse coordinates from the window to our local view. NSEvent always stores mouse coordinates relative to the window, and this information is extracted using the locationInWindow method. NSView's convertPoint:fromView: will translate the coordinates, and a fromView: of nil means from the window itself.

In mouseDown:, once we are in local coordinates, we can query the document (the delegate) for the object under the mouse, using the method we wrote earlier. This either returns the topmost object hit, or nil meaning nothing was hit. We store the result in a data member called _dragShape. This keeps track of which shape we hit when later the mouseDragged and mouseUp methods are called.

If we hit empty space, we will (for now) use this to mean create a new object according to the current tool. Later we'll refine this so that we implement the ability to select objects by dragging a marquee (selection box) over them. So let's see what happens when we create a new shape object. First we call another method, shapeForCurrentTool, to make us the appropriate shape. This is a factory method so the returned object will be autoreleased. We immediately add the new object to the document, and set its location to be where our mouse point is. If we had hit an existing object, this step of creating the object and adding it to the document is skipped.

Next we test whether the shift key is down. If it is, we want to shift-select the item, which involves not deselecting everything first. Then we select the object we hit. Again, we use the shift key to add another refinement — if shift is down, we toggle the selection state of an object, otherwise we simply select it and deselect everything else. Finally, we pass the click on to the object's own mouseDown method, which we've already looked at. Because we have changed the selection state of possibly any number of objects which could be anywhere in the drawing, we flag the need to refresh the entire thing to make sure that all these changes have an immediate effect on screen. Note that this approach is simple, but not necessarily optimum. Once the drawing gets complex, refreshing the entire thing every time can become very expensive and slow. Instead it can pay to track which objects are actually changed, and only refresh those areas. However for this simple tutorial, we won't attempt to complicate things by doing that.

mouseDragged: converts the coordinates as before, and passes the drag down to the current object as indicated by _dragShape, which we set up in mouseDown: Because a drag can move and resize an object, we need to refresh the area affected by both the new position of the object, and its old position. Thus we record the state of its bounds before and after the drag, combine them together and refresh that area. If we didn't do this, we'd find that objects would leave ugly trails behind them as they were dragged.

mouseDragged: doesn't currently attempt to deal with the situation where more than one object can be dragged. In most apps of this type, if there are two objects selected and one is dragged, both are dragged together. As an exercise, you might want to think about how mouseDragged: can be modified to achieve this.

Finally, mouseUp: cleans up by passing the call to the dragged object, refreshing the screen at that place, and then setting _dragShape to nil.

Handling the tools

[edit | edit source]

As we saw, the shape to be added to the drawing is dependent on shapeForCurrentTool. This simply needs to look at which tool we have selected, and create the right sort of object. However, we don't yet have an interface for selecting a tool, so let's make one so that we can implement this. Since the tool palette is common to all documents in the application, we'll add it to MainMenu.nib.

One of the key design paradigms of Cocoa is its adherence to the model-view-controller (MVC) approach to object-oriented design. So far we haven't really encountered this directly, but we will have to say something about it now. MVC is a good method of separating functionality between the model, which manages the actual meaningful data your application handles, and the view, which is the visual representation onscreen of that data, and the controls that manipulate it. Sitting between them is the controller which does the job of mapping the view to the data and vice versa. So far we have used MyDocument as a combined model and controller, but with the tool palette, we'll be using a more strictly delineated MVC approach.

The tool palette itself will be the view. We'll need a controller to handle the selection of the tool button in the palette, and pass this along in the form of the selected tool. The model will be just a single variable, storing the current tool choice.

The view can be entirely created in Interface Builder. The controller can be partially set up in Interface Builder, supplemented by a little code we'll need to write. First let's look at our "model". Because our WKDDrawView is going to take on the task of creating objects in response to the chosen tool, the obvious approach would be for the view to simply ask the tool controller what the current tool is whenever it needs to know. However, there is a snag. There is no easy way for the view (which is only one among many possible, given that we can have many documents open) to locate the tool controller without having an explicit reference to it. One solution would be a global variable which referred to the tool controller, and that is a viable approach. However, globals are discouraged in an object-oriented application, and besides, simply setting up this global would complicate things in a way that we'd rather avoid in this tutorial. Instead, we can declare a static variable in our view class which will store the ID of the chosen tool. Being a static variable, it can be accessed by any view (static variables are like globals that are only visible to code in the same file that they are declared in). This variable thus contains a COPY of the tool ID that the tool controller has. Normally we'd avoid this because copies of the same data need careful management to ensure they don't get out of step, but in this case it's fairly simple.

So how do we synchronise the tool ID when the user clicks the palette button? The answer is using a notification. A notification is Cocoa's way of passing information around without having explicit object references. The controller will post a notification every time the tool changes. Anyone interested in this can subscribe to the message and respond in a suitable way. In this case each view will subscribe to the message, but set the static variable in response. There is some overkill here - if there are two views, each will receive the message and the variable will get set twice to the same value. In this case we can tolerate this because it's only a single simple integer we are talking about.

What about when a new view is created, but the tool is set to some odd value? The view will still know about the tool, because it is a static variable, shared among all instances of the view. Thus we don't have to worry about the case of a new view never receiving a notification before it can use the correct tool, as we would if the variable was local to each view.

OK, let's make it happen.

Open 'MainMenu.nib' in IB. Select the Windows panel in the widgets palette (fourth button from the left) and drag a PANEL into the main window. Double-click the new icon to open the panel. In the Inspector, delete the window's title. Use the Size panel to set min w. to 60, and min h. to 100. Resize the panel to be narrow and tall, and position it at top left of the screen. Use the Attributes inspector to check 'Utility Window (panel only)' and 'Non activating panel (panel only)'. Make sure the 'Minimize' and 'Zoom' boxes are unchecked.

Switch to the Controls palette and drag a large square button to the window. Press the option key and drag the selection handle at the bottom downwards until you have four vertically organised buttons. Option-dragging a button creates what is known as a MATRIX of buttons, rather than a single button. With the matrix selected, use the attributes inspector to set the behaviour to 'Radio'.

In new versions of the interface builder you create the list of buttons option-dragging the buttons. Then select all of the buttons and use Layout->Embed Objects in->Matrix. This will create a Matrix encompassing all the buttons.

Double-click each button in turn and set the behaviour of each one to On/Off. Notice as you go through that the 'tag' field gives a different value for each button. This tag value is what will become our tool ID. Once done, we'll have a crude but functional tool palette interface. You can test its operation in IB by choosing File->Test Interface. Verify that the tool palette operates as a set of radio buttons with only one selected at a time, and that you can tell from the highlighting which one it is. To return to IB, choose 'Quit'.

Now we need a controller for the tool palette. IB again helps us by actually making this object for us, and creating skeletal .h and .m files that we can add code to later.

In the 'Classes' tab of the main window, locate the NSWindowController class (it is a subclass of NSResponder). Highlight NSWindowController and choose Classes->Subclass NSWindowController. A new name is added; name it ToolsController. Now we can ask IB to actually create this object, giving us an actual instance we can hook up to the view. To do this, highlight the new class, and choose Classes->Instantiate ToolsController. A new object is added to the main window, which looks like a box and has the name ToolsController. Go back to the 'Classes' view (a quick way is to double-click the new object). In the Inspector, use the Actions panel to add a new action: click Add, then type the action name selectionDidChange: (remember the colon!). This makes two actions, there should already be a showWindow: action.

Go back to Instances. control-drag FROM the ToolsController object TO the tools window (you can drag to the title bar of the actual window, or just to the icon in the main window). Connect this to the 'window' outlet. Next, control-drag FROM the NSMatrix (the four buttons - make sure you have the four buttons selected as a set not just one of them - the easiest way is to start the drag from a blank area just off the edge of one of the buttons) TO the ToolsController. Connect that to the action you just added, 'selectionDidChange:'.

Finally, we need a way to show the palette when we need it. Drag a new menu item to the Windows menu, placing it below Zoom item. Name it 'Show Tools'. control-drag FROM the menu item to the ToolsController. Connect it to the action 'showWindow:'

Save the file. With ToolsController selected, choose Classes->Create Files for ToolsController. Add the .h and .m files to Wikidraw. We're done in IB, so return to Xcode.

Coding the controller

[edit | edit source]

Add an int data member to our ToolsController class, called _curTool. Add a method to return it, called currentTool. Your class definition should look like this:

@interface ToolsController : NSWindowController
{
	int		_curTool;
}

- (IBAction)	selectionDidChange:(id) sender;
- (int)			currentTool;

@end


extern NSString* notifyToolSelectionDidChange;

We also declare an extern string variable that will contain the name for our notification.

Now switch to the implementation. We don't have an 'init' method here, since the one we inherit from NSWindowController is fine, but we do need to do a little bit of set up after we've been created from the .nib file. We can do this by implementing an 'awakeFromNib' method. It's just a method (with no parameters) called when we are brought to life from the .nib file. Here's what we need:

- (void)	awakeFromNib
{
	[(NSPanel*)[self window] setFloatingPanel:YES];
	[(NSPanel*)[self window] setBecomesKeyOnlyIfNeeded:YES];
	_curTool = 0;
}

We need to tell our window to be a floating panel, and not to become key unless absolutely necessary. This is to prevent clicks in the tool palette taking focus away from the current document, which would be rather odd behaviour. We also take the opportunity to initialise _curTool to 0.

The main method of our controller is the action selectionDidChange;. Here it is.

- (IBAction)	selectionDidChange:(id) sender
{
	_curTool = [[sender selectedCell] tag];
	[[NSNotificationCenter defaultCenter] postNotificationName:notifyToolSelectionDidChange object:self];
}

You'll recall that the sender of an action was an NSMatrix. The matrix will track the currently selected item and return it when we call selectedCell. Then we need to obtain the tag value associated with that item, so we call 'tag'. Then it's just assigned to _curTool.

The interesting thing comes next. We post a notification which tells any interested object that the tool selection was just changed by the user. We use something called the 'default notification center' to actually transmit this message. This is a global Cocoa object that exists for this purpose - to forward messages on to anyone who is interested. Because it's visible to all classes (in a way that our ToolsController isn't), it's an ideal way to pass information to objects we don't know about. The postNotificationName:object: method does the job. The notification name is just a string that we define which uniquely indicates what's going on, and object is the controller itself. The name is set up as a string:

NSString* notifyToolSelectionDidChange = @"toolSelectionChanged";

The string itself is really irrelevant, though it can be useful to give it a descriptive name because you later might want to log what notifications you receive somewhere as part of debugging - if the names are meaningful you'll be easily able to tell where it came from.

That's our controller in its entirety - all it does is to receive the change of the buttons and forward that as a notification.

Receiving the notification

[edit | edit source]

Back in WKDDrawView.h, we need to declare a method to handle reception of notifications. We'll call it toolChange:, and it looks like this:

- (void)		toolChange:(NSNotification*) note;

At the top of the.m file, outside the implementation, add:

static int	sCurrentTool = 0;

This will be our local copy of the current tool ID. We declare it as static so that it's visible to any instance of our view class. Next we need to register for the notification. We do this as part of our init method:

- (id)		initWithFrame:(NSRect)frameRect
{
	if ((self = [super initWithFrame:frameRect]) != nil)
	{
		_delegate = nil;
		
		[[NSNotificationCenter defaultCenter] addObserver:self
											  selector:@selector(toolChange:)
											  name:notifyToolSelectionDidChange
											  object:nil];
	}
	return self;
}

- (void)		dealloc {
	[[NSNotificationCenter defaultCenter] removeObserver:self];
	[super dealloc];
}

We use addObserver:selector:name:object: to register an interest in this notification. When deallocation the object we have to deregister to avoid an exception for any notification sent after the deallocation. When a notification matching the name is sent, the nominated method whose selector we passed is called:

- (void)		toolChange:(NSNotification*) note
{
	sCurrentTool = [[note object] currentTool];
}

All we do is ask the notification which object sent the message ([note object]), and since we know that it is a ToolsController, we can then simply ask it for the tool ID, which we store in sCurrentTool. Thus sCurrentTool should always match the current value of the actual selected tool. Should the view need to know at any time which tool is selected, it can simply look at sCurrentTool rather than trying to figure out where the ToolsController is and asking it.

Of course, for this to compile we need to #import the ToolsController.h file into WKFDDrawView.m:

#import "ToolsController.h"

Before we continue, let's build and go and check that we are in business as far as it goes. You should be able to choose "Show Tools" from the Window menu to make the palette visible, and clicking on the buttons should select them. We can't tell yet whether it's doing anything useful, as we haven't written code that acts on the value of the chosen tool. We can however, log the reception of the notification. Logging this sort of thing is always handy when developing and.or debugging an application.

To log anything, use the NSLog() function. This accepts a NSString constant which will be written out to the log. The log itself is visible as the main window in Xcode when the application is running. The string passed to NSLog accepts printf-like formatting instructions, so let's add:

NSLog(@"tool changed to %d", sCurrentTool);

To the end of the toolChange: method. Build and Go again, and verify that clicking the tool buttons causes the tool ID to be written to the log.

Previous Page: Implementing Wikidraw | Next Page: More shapes