Welcome to another chilling edition of Friday Q&A. While I hope to be soaring over the scenic Shenandoah Valley on this fine Friday, I have taken the precaution of preparing my post in advance, so that you may see it even while I am incommunicado. Such is the magic of the modern world. This week, Michael Crawford has suggested that I give in example of implementing a custom control in Cocoa.
Specifically, he requested a diagonal slider. This slider works much like a regular Cocoa slider, except that it's oriented diagonally. To make the example more useful, I implemented it completely from scratch rather than trying to base it off of NSSlider (which would probably not be very easy for this anyway).
Getting the Code
As usual, the code that I wrote for this post is available in my Subversion repository:
svn cohttp://mikeash.com/svn/DiagonalSlider/Or just click the URL above to browse the source.
Planning
When building a custom control, it's helpful to break your tasks down as much as possible. The concept of building a custom control can be daunting, but when broken into small pieces, each small piece can become easy.
There are three main pieces to any custom control:
drawRect: to draw whatever you want your control to look like. In the case of the diagonal slider, it needs to draw the slider track and the knob.NSControl. It will implementsetDoubleValue: anddoubleValue to return its position. For instance variables, it needs to store its value. Also, becauseNSControl tends to assume that you have anNSCell, and I don't want to build a cell, I also need instance variables to hold the control's target and action:@interfaceDiagonalSlider :NSControl{double_value;id_target;SEL_action;}-(void)setDoubleValue:(double)value;-(double)doubleValue;@end
constCGFloatkInsetX=12;constCGFloatkInsetY=12;constCGFloatkSliderWidth=6;constCGFloatkKnobRadius=10;
First, two methods for getting the slider endpoints:
-(NSPoint)_point1{returnNSMakePoint(kInsetX,kInsetY);}-(NSPoint)_point2{NSRectbounds=[selfbounds];returnNSMakePoint(NSMaxX(bounds)-kInsetX,NSMaxY(bounds)-kInsetY);}
_value as the weight:-(NSPoint)_knobCenter{NSPointp1=[self_point1];NSPointp2=[self_point2];returnNSMakePoint(p1.x*(1.0-_value)+p2.x*_value,p1.y*(1.0-_value)+p2.y*_value);}
NSBezierPath that describes the knob. You might think that this belongs in drawing, not geometry. However, I plan to use this path not only for drawing the knob, but also for determining whether the mouse is within the knob or not. Conceptually, this bezier path is part of the common geometry code:-(NSBezierPath*)_knobPath{NSRectknobR={[self_knobCenter],NSZeroSize};return[NSBezierPathbezierPathWithOvalInRect:NSInsetRect(knobR,kKnobRadius,kKnobRadius)];}
NSPoint as my "vector" type. These three utility functions are then easy to write:staticNSPointsub(NSPointp1,NSPointp2){returnNSMakePoint(p1.x-p2.x,p1.y-p2.y);}staticCGFloatdot(NSPointp1,NSPointp2){returnp1.x*p2.x+p1.y*p2.y;}staticCGFloatlen(NSPointp){returnsqrt(p.x*p.x+p.y*p.y);}
-(double)_valueForPoint:(NSPoint)p{// vector from slider start to pointNSPointdelta=sub(p,[self_point1]);// vector of sliderNSPointslider=sub([self_point2],[self_point1]);// project delta onto sliderCGFloatprojection=dot(delta,slider)/len(slider);// value is projection length divided by slider lengthreturnprojection/len(slider);}
The concept for this code is similar. First, I use_valueForPoint: to see if the point is off the ends of the slider. If it is, instant rejection. Otherwise, I see how far to the side the point is from the slider. If this distance is within the slider width, then the point is contained by the slider.
Finding that distance is similar to the above code. Instead of projecting onto the slider's vector, I project onto a vector perpendicular to the slider. The length of that projection is the distance from the middle of the slider track:
-(BOOL)_sliderContainsPoint:(NSPoint)p{// if beyond the ends, then it's not containeddoublevalue=[self_valueForPoint:p];if(value<0||value>1)returnNO;// vector from slider start to pointNSPointdelta=sub(p,[self_point1]);// vector of sliderNSPointslider=sub([self_point2],[self_point1]);// vector of perpendicular to sliderNSPointsliderPerp={-slider.y,slider.x};// project delta onto perpendicularCGFloatprojection=dot(delta,sliderPerp)/len(sliderPerp);// distance to slider is absolute value of projection// see if that's within the slider widthreturnfabs(projection)<=kSliderWidth;}
_point1 and_point2. Then I get the_knobPath and fill it. And that's it!Note that I'm going for technical information, not graphical prettiness, so my slider is ugly. The track is just a blue line, and the knob is just a red circle. Making it beautiful is up to you!
Here's what the drawing code looks like:
-(void)drawRect:(NSRect)r{NSBezierPath*slider=[NSBezierPathbezierPath];[slidermoveToPoint:[self_point1]];[sliderlineToPoint:[self_point2]];[slidersetLineWidth:kSliderWidth];[[NSColorblueColor]setStroke];[sliderstroke];[[NSColorredColor]setFill];[[self_knobPath]fill];}
One way is to implementmouseDown:,mouseDragged:, andmouseUp:, to do what you need in each situation. The other way is to only implementmouseDown:, then run your own event loop inside that to look for dragged/up events.
This second way is how most (possibly all) Apple controls work, and in my opinion generally works better. You often have state which is generated by the mouse down event, and then needs to be referenced by the dragged/up handlers, and this is easier to manage when everything is in the same place instead of scattered through several different methods. The dragged and up code is also often similar, and a single event loop allows consolidating the two cases. Because I think this way is superior, that's howDiagonalSlider will handle event tracking.
To do this, implementmouseDown:. First, figure out whether to handle the event or not. In the case of the slider, we want to handle the event if the click was in the knob or slider track, but not if it fell outside them. Handle any necessary setup, then start the inner event loop.
The slider has two cases which act slightly differently. One case is clicking in the knob itself, which does nothing to begin with, then moves the knob relative to the mouse's movements. The other case is clicking directly in the slider track, which jumps the knob to that position, then tracks further movement.
These two cases handle the same tracking at the end, but have slightly different setup. To facilitate this, I split most of the tracking into a separate method,_trackMouseWithStartPoint, which can then be called by these two cases.
ThemouseDown: method first gets the location of the event, then sees if it's within the knob. If it is, then it just goes straight to tracking:
-(void)mouseDown:(NSEvent*)event{NSPointp=[selfconvertPoint:[eventlocationInWindow]fromView:nil];if([[self_knobPath]containsPoint:p]){[self_trackMouseWithStartPoint:p];}
elseif([self_sliderContainsPoint:p]){[selfsetDoubleValue:[self_valueForPoint:p]];[selfsendAction:[selfaction]to:[selftarget]];[self_trackMouseWithStartPoint:p];}}
Now for the actual event tracking. The first thing to do is compute a value offset. This is the difference between the slider's current value, and the value which corresponds to the location of the initial mouse down event. The purpose of this is to preserve the distance between the slider knob's center and the mouse cursor. If you click on the edge of the knob and drag, your cursor should stay on the edge, not have the knob suddenly jump to be centered. Note that this is only necessary when clicking the knob, not the track. However, when clicking the track, this value will be0 and thus do nothing, so it's not necessary to conditionalize the code:
-(void)_trackMouseWithStartPoint:(NSPoint)p{// compute the value offset: this makes the pointer stay on the// same piece of the knob when draggingdoublevalueOffset=[self_valueForPoint:p]-_value;
-[NSWindow nextEventMatchingMask:] in a loop, until aNSLeftMouseUp event is received. I also toss in anNSAutoreleasePool to ensure that memory doesn't build up if the loop continues for a long time:// create a pool to flush each time through the cycleNSAutoreleasePool*pool=[[NSAutoreleasePoolalloc]init];// track!NSEvent*event=nil;while([eventtype]!=NSLeftMouseUp){[poolrelease];pool=[[NSAutoreleasePoolalloc]init];event=[[selfwindow]nextEventMatchingMask:NSLeftMouseDraggedMask|NSLeftMouseUpMask];
NSPointp=[selfconvertPoint:[eventlocationInWindow]fromView:nil];doublevalue=[self_valueForPoint:p];[selfsetDoubleValue:value-valueOffset];[selfsendAction:[selfaction]to:[selftarget]];}
[poolrelease];}
setDoubleValue:. It performs several tasks. First, it clamps the incoming value to be between 0 and 1. Then it assigns the value, and finally marks the control as needing a redisplay, so that the GUI updates accordingly. Note that simply redisplaying the entire view is somewhat inefficient, and it would be better to compute a minimal changed rect. However, in the spirit of avoiding premature optimization, I didn't do this.-(void)setDoubleValue:(double)value{// clamp to [0, 1]value=MAX(value,0);value=MIN(value,1);_value=value;[selfsetNeedsDisplay:YES];}
-(double)doubleValue{return_value;}-(void)setTarget:(id)anObject{_target=anObject;}-(id)target{return_target;}-(void)setAction:(SEL)aSelector{_action=aSelector;}-(SEL)action{return_action;}
Using the Slider
Using this custom slider is much like using any other control, just with somewhat worse Interface Builder support. To create the slider in IB, you have to drag out a plainNSView, then set the class of that view toDiagonalSlider. IB doesn't know what aDiagonalSlider looks like, so it'll still show up as a plain box, but it will work correctly at runtime. IB is smart enough to notice thatDiagonalSlider is anNSControl subclass, and thus allows you to set the target/action of the slider right in the nib. Convenient!
Implement the action as you would for any other control. Then you can fetch the slider's current value usingdoubleValue. Update its value usingsetDoubleValue:. And that's it!
Conclusion
Building a custom control in Cocoa can be a daunting task, but if you break it down into components and build the control up from small pieces, it's really not that hard. By separating the code into geometry, drawing, and tracking sections, and buildingu p each section from parts, building a custom control can become relatively straightforward.
That's it for this edition of Friday Q&A. Come back next week for another one. Until then, keep sending your ideas for topics. Friday Q&A is driven by user ideas, so if you'd like to see a particular topic covered here, pleasesend it in!
sqrt(p.x * p.x + p.y * p.y);. I prefer to use the underappreciated hypot() function, likehypot(p.x, p.y).-accessibilityAttributeValue: to tell Accessibility what type of control it is and its current value, and then the corresponding setter to allow its value to be set.hypot would be better here, I just forgot about it. Themath.h header is full of great little helpers like this. (For others reading this, helper functions likehypot aren't just convenient, but they usually produce an answer with better precision than you get by writing the calculation out longhand.)
@interface DiagonalTracker : NSObject {
DiagonalSlider *owner_; // WEAK
double valueOffset_;
}
- (id)initWithOwner:(DiagonalSlider *)owner offset:(double)offset;
- (void)mouseDragged:(NSEvent *)theEvent;
@end
...
#pragma mark Event Tracking
- (void)_trackMouseWithStartPoint: (NSPoint)p {
// compute the value offset: this makes the pointer stay on the
// same piece of the knob when dragging
double offset = [self _valueForPoint: p] - value_;
[self setTracker:[[[DiagonalTracker alloc] initWithOwner:self offset:offset] autorelease]];
}
- (void)mouseDown: (NSEvent *)event {
NSPoint p = [self convertPoint: [event locationInWindow] fromView: nil];
if([[self _knobPath] containsPoint: p]) {
[self _trackMouseWithStartPoint: p];
} else if([self _sliderContainsPoint: p]) {
[self setDoubleValue: [self _valueForPoint: p]];
[self sendAction: [self action] to: [self target]];
[self _trackMouseWithStartPoint: p];
}
}
- (void)mouseDragged:(NSEvent *)event {
[tracker_ mouseDragged:event];
}
- (void)mouseUp:(NSEvent *)event {
[self setTracker:nil];
}
...
@implementation DiagonalTracker
- (id)initWithOwner:(DiagonalSlider *)owner offset:(double)offset {
self = [super init];
if (self) {
owner_ = owner;
valueOffset_ = offset;
}
return self;
}
- (void)mouseDragged:(NSEvent *)event {
NSPoint p = [owner_ convertPoint: [event locationInWindow] fromView: nil];
double value = [owner_ _valueForPoint: p];
[owner_ setDoubleValue: value - valueOffset_];
[owner_ sendAction: [owner_ action] to: [owner_ target]];
}
@end
NSEventTrackingRunLoopMode if we want them to fire during control tracking) then having third-party controls do the same thing really doesn't add any difficulty.Add your thoughts, post a comment:
Spam and off-topic posts will be deleted without notice. Culprits may be publicly humiliated at my sole discretion.