Programming Mac OS X with Cocoa for Beginners/Implementing Wikidraw
Previous Page: Document-based applications | Next Page: Wikidraw's view class
So far we have managed to get a very long way with hardly any code. However, our application still doesn't do much, and doesn't look too much like a real drawing program. So now we'll write the code at the heart of the application, which implements the actual shapes we can draw. We have deferred a lot of functionality by design to this class, so this will be a moderately complex object. However, once we are done, we'll have something very close to a drawing program!
Up until now we have used Interface Builder to create skeletal code files for us. This time, we'll have to do it manually. In XCode, find the file 'WKDDrawView.m' and select it. Then choose File->New File... In the assistant, locate and select 'Objective-C class', then click Next. Change the filename to 'WKDShape.m', then click Finish. A new .h and .m file are added to the 'Classes' list. You'll see that WKDShape is a subclass of NSObject, which is ideal.
Design of the shape class
[edit | edit source]Each shape will be an instance of the WKDShape class, but each type of shape will be a subclass, for example WKDRectShape and WKDOvalShape. In the Shape class itself, we need to put as much functionality that is common to all shapes as possible. In fact we'll see that this is almost all of it.
What properties does each shape need? There are graphical properties such as the colours to fill and stroke the shape, and whether the shape has a stroke or fill, and what thickness the stroke should be. There are geometrical properties such as the location and size of the shape, and if we were implementing rotation, a rotation angle (for simplicity we won't be doing this, but you might like to consider how it could be added as a further exercise). What we don't need to include is state information such as whether the object is selected - it doesn't need to know and it's already taken care of by the document/view combination.
Cocoa embodies colours in the NSColor object, so we can specify our stroke and fill colours using these. We can use the absence of either (a value of nil) to mean not to perform a stroke or a fill, allowing us to have hollow as well as solid shapes. The stroke thickness is a simple floating point value. The position of the object can be specified using an NSPoint, and the size using an NSSize. We'll decide that the location of the object refers to the top, left corner of its bounding box. An NSRect contains both an NSPoint specifying the origin of the rect, and an NSSize for its width and height, which is exactly what we need. A shape is drawn so that it fits this bounding box rectangle.
So let's add these data members:
@interface WKDShape : NSObject { NSRect _bounds; NSColor* _strokeColour; NSColor* _fillColour; float _strokeWidth; } @end
These immediately suggest the first set of methods we'll need, which simply set and get these properties. We also, for convenience, define some methods for setting the location and size independently. We also know from developing our document class that we need a 'containsPoint' method, returning a BOOL value:
- (NSRect) bounds; - (void) setBounds:(NSRect) bounds; - (void) setLocation:(NSPoint) loc; - (void) offsetLocationByX:(float) x byY:(float) y; - (void) setSize:(NSSize) size; - (BOOL) containsPoint:(NSPoint) pt; - (NSColor*) fillColour; - (void) setFillColour:(NSColor*) colour; - (NSColor*) strokeColour; - (void) setStrokeColour:(NSColor*) colour; - (float) strokeWidth; - (void) setStrokeWidth:(float) width;
Now let's think about what we need the shape object to do. The most obvious thing is to draw itself, so we'll need a 'draw' method. This will be called by the view for each object in turn. In addition, the view will inform us whether we are selected or not, so we need a way to pass this information in so we can draw accordingly.
The next thing is user interaction with the object. The view will pass us clicks and drags, allowing the user to resize or reposition an object directly. We can figure out ourselves which operation is intended from where the mouse point was initially clicked. A view handles mouse operations by implementing three methods for mouse down, mouse dragged and mouse up. We can follow the same model here, making it very easy for the view to pass these things on to us. We'll need to track what operation we are performing (resize or reposition) so that when we receive a drag message, we continue doing the right thing! We'll track this state in a simple integer data member.
When an object is selected, it is drawn with "selection handles" around its edges. If we drag a handle, we want the size of the shape to be altered. If we drag the object itself, it should remain the same size but move to a new position. When a handle is dragged, the opposite handle becomes an 'anchor point' for the resize, so we need a way to distinguish between handles to work out which anchor to use. We'll use the integer data member we already have for this - its value will be set to the number of the handle we originally clicked. We'll also need to record the original mouse click position so we can work out how far things have been moved. We'll provide a separate method for drawing the handles as sensible factoring of code, as well as a number of utility methods for drawing and hit testing each handle.
Finally we need to allow subclasses to actually provide the detail of the shape being drawn. We could rely on subclasses reimplementing draw as needed, but a better approach which keeps subclasses smaller and simpler is to ask for the shape's path as an NSBezierPath object, then implement everything else in the common shape object. All the subclass needs to do is to use the current value of bounds to return an appropriate path whenever asked.
So let's add these methods:
- (void) drawWithSelection:(BOOL) selected; - (void) drawHandles; - (void) drawAHandle:(int) whichOne; - (int) handleAtPoint:(NSPoint) pt; - (NSRect) handleRect:(int) whichOne; - (NSRect) newBoundsFromBounds:(NSRect) old forHandle:(int) whichOne withDelta:(NSPoint) p; - (void) mouseDown:(NSPoint) pt; - (void) mouseDragged:(NSPoint) pt; - (void) mouseUp:(NSPoint) pt; - (NSBezierPath*) path;
We also add an int data member, _dragState, and an NSPoint data member, _anchor.
When we begin to draw a shape, it will be created and added to the drawing at the point where we clicked. It will then be in exactly the same state as if an existing object were being resized, so there is no difference between creation and later editing - we just arrange things so that new objects get created under the mouse. The view will deal with this.
Shape implementation
[edit | edit source]Now we can implement the methods for WKDShape. We'll supply a default path method so that using an unsubclassed shape object works - in fact, we can make the generic shape object handle the simple rectangle case.
Cut and Paste the method prototypes into the WKDShape.m file. Here's a quick trick for expanding them, which will work at this stage since there is no code written yet. Expand one method by replacing the trailing semicolon with a pair of curly braces and an extra line. Select and copy just the braces and the extra line. Open the Find/Replace dialog and do a Replace All on a semicolon with the lines you copied (paste the lines into the Replace field). This replaces all semicolons with the empty method bodies and an extra line, thus expanding all the method prototypes to full methods.
Here is the implementation for the property methods and initialisation:
- (id) init { if ((self = [super init]) != nil ) { [self setFillColour:[NSColor whiteColor]]; [self setStrokeColour:[NSColor blackColor]]; [self setStrokeWidth:1.0]; _bounds = NSZeroRect; _dragState = 0; _anchor = NSZeroPoint; } return self; } - (void) dealloc { [_fillColour release]; [_strokeColour release]; [super dealloc]; } - (NSRect) bounds { return _bounds; } - (void) setBounds:(NSRect) bounds { _bounds = bounds; } - (void) setLocation:(NSPoint) loc { _bounds.origin = loc; } - (void) offsetLocationByX:(float) x byY:(float) y { _bounds.origin.x += x; _bounds.origin.y += y; } - (void) setSize:(NSSize) size { _bounds.size = size; } - (BOOL) containsPoint:(NSPoint) pt { return NSPointInRect( pt, [self drawBounds]); } - (NSColor*) fillColour { return _fillColour; } - (void) setFillColour:(NSColor*) colour { [colour retain]; [_fillColour release]; _fillColour = colour; } - (NSColor*) strokeColour { return _strokeColour; } - (void) setStrokeColour:(NSColor*) colour { [colour retain]; [_strokeColour release]; _strokeColour = colour; } - (float) strokeWidth { return _strokeWidth; } - (void) setStrokeWidth:(float) width { _strokeWidth = width; }
These should be straightforward. In our init method, we call our own set...Colour methods to set the default colours to a white fill and a black border, as well as setting a stroke width of 1.0 and setting the other data members to zero. NSZeroPoint, NSZeroSize and NSZeroRect are all handy constants in Cocoa representing those structures with all-zero members. Note that our set...Colour methods retain before release. We retain these objects because we are creating new references to them within the data members of our shape object.
The drawWithSelection: method comes next:
- (void) drawWithSelection:(BOOL) selected { NSBezierPath* path = [self path]; if ([self fillColour]) { [[self fillColour] setFill]; [path fill]; } if ([self strokeColour]) { [[self strokeColour] setStroke]; [path setLineWidth:[self strokeWidth]]; [path stroke]; } if ( selected ) [self drawHandles]; }
First we call our own path method to get the path of the shape. Subclasses of WKDShape will be overriding this to provide us with other shapes, but the default one will give us a basic rectangle. Having got the path, we can stroke and fill it if we have colours for them - the absence of a colour means don't perform the operation. Finally, we use the selection flag we are passed to determine if we should draw the selection handles or not. Let's look at how the handles are drawn.
- (void) drawHandles { int h; for( h = 1; h < 9; h++ ) [self drawAHandle:h]; } - (void) drawAHandle:(int) whichOne { NSRect hr = [self handleRect:whichOne]; [[NSColor redColor] set]; NSRectFill( hr ); }
The method drawHandles iterates a short loop, calling drawAHandle for each one. We identify each handle using the numbers 1 to 8, with 1 meaning the top, left handle, number 2 the top centre handle, and so on round the edges of the shape in a clockwise direction. The numbering is arbitrary, but we need to be consistent. We did not use zero as a handle ID, because we use 0 elsewhere to mean 'drag the whole object' rather than drag a particular handle. This scheme is very simple, though other numbering schemes like it could be devised that could make the later handle code a little bit more compact. However, for this exercise this is entirely adequate and will function just fine.
The drawAHandle method calls handleRect: to obtain a rectangle representing the handle, then simply blocks it in using a bright red colour.
- (NSRect) handleRect:(int) whichOne { NSPoint p; NSRect b = [self bounds]; switch( whichOne ) { case 1: p.x = NSMinX( b ); p.y = NSMinY( b ); break; case 2: p.x = NSMidX( b ); p.y = NSMinY( b ); break; case 3: p.x = NSMaxX( b ); p.y = NSMinY( b ); break; case 4: p.x = NSMaxX( b ); p.y = NSMidY( b ); break; case 5: p.x = NSMaxX( b ); p.y = NSMaxY( b ); break; case 6: p.x = NSMidX( b ); p.y = NSMaxY( b ); break; case 7: p.x = NSMinX( b ); p.y = NSMaxY( b ); break; case 8: p.x = NSMinX( b ); p.y = NSMidY( b ); break; } b.origin = p; b.size = NSZeroSize; return NSInsetRect( b, -kHandleSize, -kHandleSize ); }
HandleRect: consists of a big switch statement which sets up the position of the handle based on the current bounds of the shape. This is simple to understand but a bit dumb - we could have used bitfields to combine handles that share a side into a more compact function, but one that would be much harder to understand at a glance. This way is much easier. We use Cocoa's utility functions NSMinX, NSMinY, MSMidX, NSMaxY, etc to obtain particular corners of the bounding rect which is where we will locate the handles. Finally, we expand these points into a small rectangle and return it.
Now let's look at how we handle interaction with the mouse. The first thing that happens when the mouse is clicked is that the view determines which, if any, shape was hit, and passes the click down to its mouseDown method:
- (void) mouseDown:(NSPoint) pt { _dragState = [self handleAtPoint:pt]; _anchor = pt; }
All we do is call handleAtPoint: to see if any of the handles were hit. If a handle was hit, its number is returned and stored in _dragState. If no handle was hit, 0 is returned. This means 'drag the whole object'. We can be sure that we were hit at all because this method wouldn't even be called if we hadn't been. We also record the initial mouse click point in _anchor. At this stage, we only determine what a subsequent drag is going to do - we don't need to actually do any more. Let's look at how handleAtPoint: works.
- (int) handleAtPoint:(NSPoint) pt { int h; NSRect hr; if ([self bounds].size.width == 0 && [self bounds].size.height == 0 ) return 5; else { for ( h = 1; h < 9; h++ ) { hr = [self handleRect:h]; if (NSPointInRect( pt, hr )) return h; } } return 0; }
It's quite straightforward. We loop through the handles, obtaining the rect of each one, using handleRect: that we used before for drawing. If the rect contains the point, we return the handle's index number immediately. Otherwise, if we can't find any handles that the mouse has hit, we return 0. So what's this other bit, that checks the size? This is a little bit of a hack which simplifies code elsewhere. When an object is initially created, it has zero size and is placed under the mouse. From then on, the creation of the object proceeds just the same as for editing an existing one. However, at the moment of creation, we need to trick the system into thinking that we are dragging the bottom right corner of the shape, so that the user gets the expected behaviour. The index number for the bottom right corner's handle is 5, so if we have zero size, we immediately return 5 as if it were this handle that had been clicked.
After the view calls our mouseDown method, it will continually call our mouseDragged method until the user lets go of the button, at which point it will call our mouseUp method.
- (void) mouseDragged:(NSPoint) pt { NSPoint np; np.x = pt.x - _anchor.x; np.y = pt.y - _anchor.y; _anchor = pt; if ( _dragState == 0 ) { // dragging the object [self offsetLocationByX:np.x byY:np.y]; } else if ( _dragState >= 1 && _dragState < 9 ) { // dragging a handle NSRect nb = [self newBoundsFromBounds:[self bounds] forHandle:_dragState withDelta:np]; [self setBounds:nb]; } } - (void) mouseUp:(NSPoint) pt { _dragState = 0; }
mouseDragged: first calculates how far the mouse has moved since last time. _anchor is used to store the previous mouse position, as all we need to do is to subtract the co-ordinates of the new point from it, then update _anchor to the new point ready for next time.
Next we look at _dragState. If it's zero, we are dragging the whole object, so we can simply offset our position bodily using offsetLocationByX:byY: using our calculated delta. Otherwise if _dragState is one of the handle index values, we know we are dragging a handle, and which one it is. We defer to another method, newBoundsFromBounds:forHandle:withDelta: to calculate what our new bounding rectangle will be given the old bounds, the handle index number being dragged, and the delta offset of the handle. It returns the recalculated bounds, so we can simply call setBounds to make it take effect.
mouseUp: doesn't need to do much here - it just sets _dragState to 0. This isn't absolutely necessary, but we do it just in case someone else is using this information - since we've finished dragging, we forget about what handle, if any, we dragged.
The code for newBoundsFromBounds:forHandle:withDelta: does most of the hard work in determining how a handle drag affects the bounds. Here it is:
- (NSRect) newBoundsFromBounds:(NSRect) old forHandle:(int) whichOne withDelta:(NSPoint) p { // figure out the desired bounds from the old one, the handle being dragged and the new point. NSRect nb = old; switch( whichOne ) { case 4: nb.size.width += p.x; break; case 6: nb.size.height += p.y; break; case 2: nb.size.height -= p.y; nb.origin.y += p.y; break; case 8: nb.size.width -= p.x; nb.origin.x += p.x; break; case 1: nb.size.width -= p.x; nb.origin.x += p.x; nb.size.height -= p.y; nb.origin.y += p.y; break; case 3: nb.size.height -= p.y; nb.origin.y += p.y; nb.size.width += p.x; break; case 5: nb.size.width += p.x; nb.size.height += p.y; break; case 7: nb.size.width -= p.x; nb.origin.x += p.x; nb.size.height += p.y; break; } return nb; }
Similar to handleRect:, it uses a switch statement to figure out which edges of the rectangle get moved according to the handle index. It's straightforward - if a right or bottom edge moves, the delta can simply be added to the width or height. If a left or top edge moves, we need to adjust both the width or height and the origin position of the rectangle.
That's pretty much it. When we look at how our view works, we will find we need to add one or two additional minor methods here to help things along, but we'll worry about that later. The final thing to look at here is the implementation of path. You'll recall that this is what subclasses are intended to override to supply the particular shapes that we will be drawing. The default version looks like this:
- (NSBezierPath*) path { return [NSBezierPath bezierPathWithRect:[self bounds]]; }
All it does is use NSBezierPath's factory method for making a path from a rectangle, in this case our bounds. The path returned is already autoreleased, so we can just use it and forget it, which is what our drawWithSelection: method does. Other shape objects can return any path that they want, provided they honour the one rule that we define here - that bounds fully encloses the shape.
Previous Page: Document-based applications | Next Page: Wikidraw's view class