A Bindable Custom NSView Subclass
In this post I’ll walk through writing a custom subclass of NSView that can bind to an NSArrayController to display information either from some generic array connected to the array controller, or from a Core Data object model.
The view we’ll be creating is a custom view to display a pie-chart; in the sample app the pie-chart will be drawn from data entered in an NSTableView. The finished application looks like the following:

If you’ve read previous posts on this site, you just might possibly have realized that I’m quite a big fan of Core Data. Unsurprisingly, therefore, the sample application is a Core Data document-based app. Its project and code can be downloaded here: customnsview.zip.
We’ll be looking at binding to an array controller’s ‘arrangedObjects’ key, and also its ’selectionIndexes’ key to enable the user to change which object is selected by clicking in our view. The segments on our pie-chart have both a ‘name’ and a numeric ‘value’ so, in the same way that you can bind a model’s keys to different column tables in an NSTableView, we’ll make it possible to bind a model’s keys to the names and values on the pie-chart. This would allow us to bind, for example, a department’s employees’ ‘fullNames’ and their ’salaries’ to view relative salaries, or in a file-viewing application, ‘fileNames’ and ‘fileSizes’ etc.
Basic Principles
To be able to display our information in the view, the view needs to maintain its own store of the data. We’ll maintain one array of segment names and one array of segment values. When the view is told to display itself, it will sum the values and use the total to determine how big each segment should be relative to the others; the view will then draw each segment using various geometric functions and bezier paths.
Creating our View
We’ll begin by creating a very basic subclass of NSView to which we can add various functionality throughout this article.
The View Subclass
- Begin by creating a new Xcode ‘Cocoa Core Data Document-Based Application’, called ‘CustomNSView’.
- In the new project’s browser, right-click (or control-click) on the Classes group and choose Add -> New File…; in the window that appears, chose the ‘Objective-C NSView Subclass’ and click Next.
- Name the file ‘MyPieChartView.m’ and make sure the ‘Also create “MyPieChartView.h” checkbox is ticked.
- After clicking ‘Finish’ to create the files, open up ‘MyPieChartView.m’ and change the template
drawRect:method with the one below; we’ll also override the ‘isFlipped’ method to return YES so that our coordinate system has {0,0} set to the top left of the view:- (void)drawRect:(NSRect)rect { [[NSColor blackColor] set]; NSRectFill([self bounds]); } - (BOOL)isFlipped { return YES; }
Next we need to add an instance of our view to the Window of our document object.
- Open up MyDocument.xib in Interface Builder and delete the placeholder label from the Window.
- Drag a custom view onto the Window, set it to resize with the window and use the identity inspector to change the class of the view to ‘MyPieChartView’. My interface looks like this:
If you build and run the application, you should find that each new document opens a window with our black box view showing.
Adding the Arrays and Bindings
At this point, we need to add a couple of arrays to the view that will hold the names and values of each pie segment.
- Open the MyPieChartView.h header file and add in two NSArrays for the names and values, together with KVC accessors:
@interface MyPieChartView : NSView { NSArray *_segmentNamesArray; NSArray *_segmentValuesArray; } - (NSArray *)segmentNamesArray; - (void)setSegmentNamesArray:(NSArray *)newArray; - (NSArray *)segmentValuesArray; - (void)setSegmentValuesArray:(NSArray *)newArray; @end
- We’ll be allowing other objects to bind to these arrays so we’ll need to add an
initialize:method to our view that exposes them for binding. Whenever the arrays change, we need to get the view to redisplay itself so in the setter methods we need to call[self setNeedsDisplayInRect:]. We also need to release the stored arrays when the view is deallocated. In MyPieChartView.m, implement these methods:+ (void)initialize { [self exposeBinding:@"segmentNamesArray"]; [self exposeBinding:@"segmentValuesArray"]; } - (NSArray *)segmentNamesArray { return [[_segmentNamesArray retain] autorelease]; } - (void)setSegmentNamesArray:(NSArray *)newArray { [self willChangeValueForKey:@"segmentNamesArray"]; [_segmentNamesArray release]; _segmentNamesArray = [newArray copy]; [self didChangeValueForKey:@"segmentNamesArray"]; [self setNeedsDisplayInRect:[self visibleRect]]; } - (NSArray *)segmentValuesArray { return [[_segmentValuesArray retain] autorelease]; } - (void)setSegmentValuesArray:(NSArray *)newArray { [self willChangeValueForKey:@"segmentValuesArray"]; [_segmentValuesArray release]; _segmentValuesArray = [newArray copy]; [self didChangeValueForKey:@"segmentValuesArray"]; [self setNeedsDisplayInRect:[self visibleRect]]; } - (void)dealloc { [_segmentNamesArray release]; [_segmentValuesArray release]; [super dealloc]; }
With our arrays setup, we need to create a data model and some objects to which we can bind.
The Data Model
For the purposes of this demonstration, we need only a very simple data model together with an NSTableView in the interface to add and remove objects.
- Open MyDocument.xcdatamodel and add a new entity called ‘PieSegment’. Add two attributes, a String and a Decimal, called ‘name’ and ‘amount’ respectively. Give the ‘name’ attribute a default string like ‘new segment’ and ‘amount’ a default value of 0.
- Return to MyDocument.xib in Interface Builder and add a new NSArrayController called ’segmentsArrayController’ for the ‘PieSegment’ entity, that prepares content. Bind its Managed Object Context to the File’s Owner managedObjectContext.
- Add an NSTableView and two buttons to the document window; bind the columns of the table view to the ‘name’ and ‘amount’ path of the segmentsArrayController arrangedObjects key.
- Add a decimal NSNumberFormatter to the ‘amount’ column.
- Connect the buttons to the array controller’s add: and remove: actions and make any other adjustments as you wish. My interface looks like this:

Build and Run the application to make sure that the table view and buttons work as expected.
Setting up the Bindings and Displaying the Data
Obviously, nothing is happening yet with our custom NSView since we haven’t bound anything to our names and values arrays, nor have we modified the view’s drawRect: method. We’ll rectify that now:
- Open the MyDocument.h header file and add in IBOutlets for the array controller and view:
@interface MyDocument : NSPersistentDocument { IBOutlet NSArrayController *segmentsArrayController; IBOutlet NSView *pieChartView; } @end
- Connect these outlets to the relevant view and controller in Interface Builder.
- In MyDocument.m, modify the
windowControllerDidLoadNib:method so that we bind the view to the array controller:- (void)windowControllerDidLoadNib:(NSWindowController *)windowController { [super windowControllerDidLoadNib:windowController]; [pieChartView bind:@"segmentNamesArray" toObject:segmentsArrayController withKeyPath:@"arrangedObjects.name" options:nil]; [pieChartView bind:@"segmentValuesArray" toObject:segmentsArrayController withKeyPath:@"arrangedObjects.amount" options:nil]; }
- In MyPieChartView.m, modify the
drawRect:method with some very simple text display code that will just output the two arrays side-by-side:- (void)drawRect:(NSRect)rect { [[NSColor blackColor] set]; NSRectFill([self bounds]); NSPoint midPoint = NSMakePoint(NSMidX([self bounds]), NSMidY([self bounds])); NSString *namesText = [[self segmentNamesArray] description]; NSString *valuesText = [[self segmentValuesArray] description]; NSDictionary *textAttributes = [NSDictionary dictionaryWithObjectsAndKeys: [NSColor whiteColor], NSForegroundColorAttributeName, [NSFont systemFontOfSize:10], NSFontAttributeName, nil]; NSSize namesSize = [namesText sizeWithAttributes:textAttributes]; NSSize valuesSize = [valuesText sizeWithAttributes:textAttributes]; NSPoint namesTextPoint = NSMakePoint(midPoint.x - namesSize.width - 2, midPoint.y - (namesSize.height / 2)); NSPoint valuesTextPoint = NSMakePoint(midPoint.x + valuesSize.width + 2, midPoint.y - (valuesSize.height / 2)); [namesText drawAtPoint:namesTextPoint withAttributes:textAttributes]; [valuesText drawAtPoint:valuesTextPoint withAttributes:textAttributes]; }
If you build and run the application, you’ll find that values added to the table view will show up in columns in the pie-chart view:

Displaying the Data as a Pie-Chart
Whilst at this point we have a nicely-bound custom NSView subclass, we still need to use the provided data to display a pie-chart. There are many different ways to go about displaying such a chart; what follows is the path I decided to take. You may have different suggestions, in which case please feel free to make comments. The methods I use to calculate the sizes and shapes of the segments may not be the fastest, or least memory-intensive, but should be fairly easily understood by anyone with basic mathematical and geometric knowledge.
We’ll draw the segments of the ‘pie’ using NSBezierPaths. Since it isn’t only the changing of data that can cause a view to be told to draw itself, we’ll ‘cache’ these paths in an array held by the view, updating them only if the data changes. To make it easy to color each segment we’ll maintain a static class array of colors, generated randomly when needed, such that additional instances of our class can also use the same colors.
Adding Drawing Information to our View
We need to add an array pointer to MyPieChartView that will hold the segment paths. We also need a method to access this paths array, and a method that will regenerate it when necessary. We need a method that will produce a random color, and another method that will supply a color from a static colors array if given a zero-based index, generating it if necessary.
- Change the MyPieChartView.h header file by adding the new array pointer and methods:
@interface MyPieChartView : NSView { NSArray *_segmentNamesArray; NSArray *_segmentValuesArray; NSMutableArray *_segmentPathsArray; } - (NSArray *)segmentNamesArray; - (void)setSegmentNamesArray:(NSArray *)newArray; - (NSArray *)segmentValuesArray; - (void)setSegmentValuesArray:(NSArray *)newArray; - (NSArray *)segmentPathsArray; - (void)generateDrawingInformation; - (NSColor *)randomColor; - (NSColor *)colorForIndex:(unsigned)index; @end
- In MyPieChartView.m implement the
segmentPathsArray:method and modify thedealloc:method to release the array:- (NSArray *)segmentPathsArray { return _segmentPathsArray; } - (void)dealloc { [_segmentNamesArray release]; [_segmentValuesArray release]; if( _segmentPathsArray ) [_segmentPathsArray release]; [super dealloc]; }
- Implement the
randomColor:andcolorForIndex:methods like this:- (NSColor *)randomColor { float red = (random()%1000)/1000.0; float green = (random()%1000)/1000.0; float blue = (random()%1000)/1000.0; float alpha = (random()%1000)/1000.0; return [NSColor colorWithCalibratedRed:red green:green blue:blue alpha:alpha]; } - (NSColor *)colorForIndex:(unsigned)index { static NSMutableArray *colorsArray = nil; if( colorsArray == nil ) { colorsArray = [[NSMutableArray alloc] init]; } if( index >= [colorsArray count] ) { unsigned currentNum = 0; for( currentNum = [colorsArray count]; currentNum <= index; currentNum++ ) { [colorsArray addObject:[self randomColor]]; } } return [colorsArray objectAtIndex:index]; }
- The
generateDrawingInformation:method is quite complicated. It keeps hold of its own pointer to the segmentValuesArray, and uses this to generate the paths array. Here I’m also using#defineto set a value for the padding around the pie-chart.- (void)generateDrawingInformation { // Keep a pointer to the segmentValuesArray NSArray *cachedSegmentValuesArray = [self segmentValuesArray]; // Get rid of any existing Paths Array if( _segmentPathsArray ) { [_segmentPathsArray release]; _segmentPathsArray = nil; } // If there aren't any values to display, we can exit now if( [cachedSegmentValuesArray count] < 1 ) return; // Get the sum of the amounts and exit if it is zero float sumOfAmounts = 0; for( NSNumber *eachAmountToSum in cachedSegmentValuesArray ) sumOfAmounts += [eachAmountToSum floatValue]; if( sumOfAmounts == 0 ) return; _segmentPathsArray = [[NSMutableArray alloc] initWithCapacity:[cachedSegmentValuesArray count]]; #define PADDINGAROUNDGRAPH 20.0 NSRect viewBounds = [self bounds]; NSRect graphRect = NSInsetRect(viewBounds, PADDINGAROUNDGRAPH, PADDINGAROUNDGRAPH); // Make the graphRect square and centred if( graphRect.size.width > graphRect.size.height ) { double sizeDifference = graphRect.size.width - graphRect.size.height; graphRect.size.width = graphRect.size.height; graphRect.origin.x += (sizeDifference / 2); } if( graphRect.size.height > graphRect.size.width ) { double sizeDifference = graphRect.size.height - graphRect.size.width; graphRect.size.height = graphRect.size.width; graphRect.origin.y += (sizeDifference / 2); } // Calculate how big a 'unit' is float unitSize = (360.0 / sumOfAmounts); if( unitSize > 360 ) unitSize = 360; float radius = graphRect.size.width / 2; NSPoint midPoint = NSMakePoint( NSMidX(graphRect), NSMidY(graphRect) ); // cycle through the segmentValues and create the bezier paths float currentDegree = 0; unsigned currentIndex; for( currentIndex = 0; currentIndex < [cachedSegmentValuesArray count]; currentIndex++ ) { NSNumber *eachValue = [cachedSegmentValuesArray objectAtIndex:currentIndex]; float startDegree = currentDegree; currentDegree += ([eachValue floatValue] * unitSize); float endDegree = currentDegree; NSBezierPath *eachSegmentPath = [NSBezierPath bezierPath]; [eachSegmentPath moveToPoint:midPoint]; [eachSegmentPath appendBezierPathWithArcWithCenter:midPoint radius:radius startAngle:startDegree endAngle:endDegree]; [eachSegmentPath closePath]; // close path also handles the lines from the midPoint to the start and end of the arc [eachSegmentPath setLineWidth:2.0]; [_segmentPathsArray addObject:eachSegmentPath]; } }
- The paths array should be recalculated whenever the bound arrays change, so modify the two setter methods:
- (void)setSegmentNamesArray:(NSArray *)newArray { [self willChangeValueForKey:@"segmentNamesArray"]; [_segmentNamesArray release]; _segmentNamesArray = [newArray copy]; [self didChangeValueForKey:@"segmentNamesArray"]; [self generateDrawingInformation]; [self setNeedsDisplayInRect:[self visibleRect]]; } - (void)setSegmentValuesArray:(NSArray *)newArray { [self willChangeValueForKey:@"segmentValuesArray"]; [_segmentValuesArray release]; _segmentValuesArray = [newArray copy]; [self didChangeValueForKey:@"segmentValuesArray"]; [self generateDrawingInformation]; [self setNeedsDisplayInRect:[self visibleRect]]; }
- Finally, we can now replace the
drawRect:method so that it displays the paths:- (void)drawRect:(NSRect)rect { [[NSColor whiteColor] set]; // white background NSRectFill([self bounds]); NSArray *pathsArray = [self segmentPathsArray]; unsigned count; for( count = 0; count < [pathsArray count]; count++ ) { NSBezierPath *eachPath = [pathsArray objectAtIndex:count]; // fill the path with the drawing color for this index [[self colorForIndex:count] set]; [eachPath fill]; // draw a black border around it [[NSColor blackColor] set]; [eachPath stroke]; } }
When you build and run the project, you’ll find that segments added to the table view with non-zero amounts will show up as a basic, colored pie-chart in the view:

Displaying the Text
So far we haven’t done anything at all with the segment names; we still need to display these on our pie-chart. We’ll maintain an array of dictionaries containing information to display the text. We’ll modify the generateDrawingInformation: method so that it creates this array of dictionaries, and change the drawRect: method to display the text on the pie-chart.
- Open the MyPieChartView.h header file and add in an array for the text information, along with an accessor:
@interface MyPieChartView : NSView { NSArray *_segmentNamesArray; NSArray *_segmentValuesArray; NSMutableArray *_segmentPathsArray; NSMutableArray *_segmentTextsArray; } - (NSArray *)segmentNamesArray; - (void)setSegmentNamesArray:(NSArray *)newArray; - (NSArray *)segmentValuesArray; - (void)setSegmentValuesArray:(NSArray *)newArray; - (NSArray *)segmentPathsArray; - (NSArray *)segmentTextsArray; - (void)generateDrawingInformation; - (NSColor *)randomColor; - (NSColor *)colorForIndex:(unsigned)index; @end
- Add the accessor method to MyPieChartView.m and change
dealloc:to release it:- (NSArray *)segmentTextsArray { return _segmentTextsArray; } - (void)dealloc { [_segmentNamesArray release]; [_segmentValuesArray release]; if( _segmentPathsArray ) [_segmentPathsArray release]; if( _segmentTextsArray ) [_segmentTextsArray release]; [super dealloc]; }
- Now modify the
generateDrawingInformation:method so that it stores necessary information in this texts array. We draw each segment arc using two ‘half’ arcs rather than one in order to find out the midpoint from which we decide where to put the text. We also check to see in which ‘quarter’ of the pie-chart view that midpoint falls and offset it by a#definedTEXTPADDING value accordingly, also making sure that the resultant point doesn’t fall outside of the view’s bounds:- (void)generateDrawingInformation { // Keep pointers to the segmentValuesArray and segmentNamesArray NSArray *cachedSegmentValuesArray = [self segmentValuesArray]; NSArray *cachedSegmentNamesArray = [self segmentNamesArray]; // Get rid of any existing Paths Array if( _segmentPathsArray ) { [_segmentPathsArray release]; _segmentPathsArray = nil; } // Get rid of any existing Texts Array if( _segmentTextsArray ) { [_segmentTextsArray release]; _segmentTextsArray = nil; } // If there aren't any values to display, we can exit now if( [cachedSegmentValuesArray count] < 1 ) return; // Get the sum of the amounts and exit if it is zero float sumOfAmounts = 0; for( NSNumber *eachAmountToSum in cachedSegmentValuesArray ) sumOfAmounts += [eachAmountToSum floatValue]; if( sumOfAmounts == 0 ) return; _segmentPathsArray = [[NSMutableArray alloc] initWithCapacity:[cachedSegmentValuesArray count]]; _segmentTextsArray = [[NSMutableArray alloc] initWithCapacity:[cachedSegmentValuesArray count]]; #define PADDINGAROUNDGRAPH 20.0 #define TEXTPADDING 5.0 NSRect viewBounds = [self bounds]; NSRect graphRect = NSInsetRect(viewBounds, PADDINGAROUNDGRAPH, PADDINGAROUNDGRAPH); // Make the graphRect square and centred if( graphRect.size.width > graphRect.size.height ) { double sizeDifference = graphRect.size.width - graphRect.size.height; graphRect.size.width = graphRect.size.height; graphRect.origin.x += (sizeDifference / 2); } if( graphRect.size.height > graphRect.size.width ) { double sizeDifference = graphRect.size.height - graphRect.size.width; graphRect.size.height = graphRect.size.width; graphRect.origin.y += (sizeDifference / 2); } // get NSRects for the different quarters of the pie-chart NSRect topLeftRect, topRightRect; NSDivideRect(viewBounds, &topLeftRect, &topRightRect, (viewBounds.size.width / 2), NSMinXEdge ); NSRect bottomLeftRect, bottomRightRect; NSDivideRect(topLeftRect, &topLeftRect, &bottomLeftRect, (viewBounds.size.height / 2), NSMinYEdge ); NSDivideRect(topRightRect, &topRightRect, &bottomRightRect, (viewBounds.size.height / 2), NSMinYEdge ); // Calculate how big a 'unit' is float unitSize = (360.0 / sumOfAmounts); if( unitSize > 360 ) unitSize = 360; float radius = graphRect.size.width / 2; NSPoint midPoint = NSMakePoint( NSMidX(graphRect), NSMidY(graphRect) ); // Set the text attributes to be used for each textual display NSDictionary *textAttributes = [NSDictionary dictionaryWithObjectsAndKeys: [NSColor whiteColor], NSBackgroundColorAttributeName, [NSColor blackColor], NSForegroundColorAttributeName, [NSFont systemFontOfSize:12], NSFontAttributeName, nil]; // cycle through the segmentValues and create the bezier paths // Also add the text details (note we expect the texts' indexes to tie up with the values' indexes) float currentDegree = 0; unsigned currentIndex; for( currentIndex = 0; currentIndex < [cachedSegmentValuesArray count]; currentIndex++ ) { NSNumber *eachValue = [cachedSegmentValuesArray objectAtIndex:currentIndex]; float startDegree = currentDegree; currentDegree += ([eachValue floatValue] * unitSize); float endDegree = currentDegree; float midDegree = startDegree + ((endDegree - startDegree) / 2); NSBezierPath *eachSegmentPath = [NSBezierPath bezierPath]; [eachSegmentPath moveToPoint:midPoint]; [eachSegmentPath appendBezierPathWithArcWithCenter:midPoint radius:radius startAngle:startDegree endAngle:midDegree clockwise:NO]; NSPoint textPoint = [eachSegmentPath currentPoint]; [eachSegmentPath appendBezierPathWithArcWithCenter:midPoint radius:radius startAngle:midDegree endAngle:endDegree clockwise:NO]; [eachSegmentPath closePath]; // close path also handles the lines from the midPoint to the start and end of the arc [eachSegmentPath setLineWidth:2.0]; [_segmentPathsArray addObject:eachSegmentPath]; // Get the text to be displayed, if it exists, and see how big it is NSString *eachText = @""; if( [cachedSegmentNamesArray count] > currentIndex ) eachText = [cachedSegmentNamesArray objectAtIndex:currentIndex]; NSSize textSize = [eachText sizeWithAttributes:textAttributes]; // Offset it by TEXTPADDING in direction suitable for whichever quarter of the view it is in if( NSPointInRect(textPoint, topLeftRect) ) { textPoint.y -= (textSize.height + TEXTPADDING); textPoint.x -= (textSize.width + TEXTPADDING); } else if( NSPointInRect(textPoint, topRightRect) ) { textPoint.y -= (textSize.height + TEXTPADDING); textPoint.x += TEXTPADDING; } else if( NSPointInRect(textPoint, bottomLeftRect) ) { textPoint.y += TEXTPADDING; textPoint.x -= (textSize.width + TEXTPADDING); } else if( NSPointInRect(textPoint, bottomRightRect) ) { textPoint.y += TEXTPADDING; textPoint.x += TEXTPADDING; } // Make sure the point isn't outside the view's bounds if( textPoint.x < viewBounds.origin.x ) textPoint.x = viewBounds.origin.x; if( (textPoint.x + textSize.width) > (viewBounds.origin.x + viewBounds.size.width) ) textPoint.x = viewBounds.origin.x + viewBounds.size.width - textSize.width; if( textPoint.y < viewBounds.origin.y ) textPoint.y = viewBounds.origin.y; if( (textPoint.y + textSize.height) > (viewBounds.origin.y + viewBounds.size.height) ) textPoint.y = viewBounds.origin.y + viewBounds.size.height - textSize.height; // Finally add the details as a dictionary to our segmentTextsArray. // We include here the textAttributes lest we decide later to e.g. color the texts the same color as the segment fill [_segmentTextsArray addObject:[NSDictionary dictionaryWithObjectsAndKeys:[NSNumber numberWithFloat:textPoint.x], @"textPointX", [NSNumber numberWithFloat:textPoint.y], @"textPointY", eachText, @"text", textAttributes, @"textAttributes", nil]]; } }
- Modify the
drawRect:method to display the text:- (void)drawRect:(NSRect)rect { [[NSColor whiteColor] set]; // white background NSRectFill([self bounds]); NSArray *pathsArray = [self segmentPathsArray]; unsigned count; for( count = 0; count < [pathsArray count]; count++ ) { NSBezierPath *eachPath = [pathsArray objectAtIndex:count]; // fill the path with the drawing color for this index [[self colorForIndex:count] set]; [eachPath fill]; // draw a black border around it [[NSColor blackColor] set]; [eachPath stroke]; } NSArray *textsArray = [self segmentTextsArray]; for( count = 0; count < [textsArray count]; count++ ) { NSDictionary *eachTextDictionary = [textsArray objectAtIndex:count]; NSPoint textPoint = NSMakePoint( [[eachTextDictionary valueForKey:@"textPointX"] floatValue], [[eachTextDictionary valueForKey:@"textPointY"] floatValue] ); NSDictionary *textAttributes = [eachTextDictionary valueForKey:@"textAttributes"]; NSString *text = [eachTextDictionary valueForKey:@"text"]; [text drawAtPoint:textPoint withAttributes:textAttributes]; } }
The view should now display text on the chart:

Indicating the Selected Segment(s)
In order to display which segment is selected, we need to bind to the array controller’s ’selectionIndexes’; the view needs to keep its own local copy of these indexes, and expose binding for them:
- Add an NSIndexSet for the selectionIndexes to the MyPieChartView.h header file, along with a getter and setter:
@interface MyPieChartView : NSView { NSArray *_segmentNamesArray; NSArray *_segmentValuesArray; NSMutableArray *_segmentPathsArray; NSMutableArray *_segmentTextsArray; NSIndexSet *_selectionIndexes; } - (NSArray *)segmentNamesArray; - (void)setSegmentNamesArray:(NSArray *)newArray; - (NSArray *)segmentValuesArray; - (void)setSegmentValuesArray:(NSArray *)newArray; - (NSArray *)segmentPathsArray; - (NSArray *)segmentTextsArray; - (void)generateDrawingInformation; - (NSColor *)randomColor; - (NSColor *)colorForIndex:(unsigned)index; - (NSIndexSet *)selectionIndexes; - (void)setSelectionIndexes:(NSIndexSet *)newIndexes; @end
- Implement these accessor methods and expose the binding:
- (NSIndexSet *)selectionIndexes { return [[_selectionIndexes retain] autorelease]; } - (void)setSelectionIndexes:(NSIndexSet *)newIndexes { if ((_selectionIndexes != newIndexes) && (![_selectionIndexes isEqualToIndexSet:newIndexes])) { [self willChangeValueForKey:@"selectionIndexes"]; [_selectionIndexes release]; _selectionIndexes = [newIndexes copy]; [self didChangeValueForKey:@"selectionIndexes"]; [self generateDrawingInformation]; [self setNeedsDisplayInRect:[self visibleRect]]; } } + (void)initialize { [self exposeBinding:@"segmentNamesArray"]; [self exposeBinding:@"segmentValuesArray"]; [self exposeBinding:@"selectionIndexes"]; }
- In MyDocument.m, bind the selectionIndexes to the array controller:
- (void)windowControllerDidLoadNib:(NSWindowController *)windowController { [super windowControllerDidLoadNib:windowController]; [pieChartView bind:@"segmentNamesArray" toObject:segmentsArrayController withKeyPath:@"arrangedObjects.name" options:nil]; [pieChartView bind:@"segmentValuesArray" toObject:segmentsArrayController withKeyPath:@"arrangedObjects.amount" options:nil]; [pieChartView bind:@"selectionIndexes" toObject:segmentsArrayController withKeyPath:@"selectionIndexes" options:nil]; }
- We also need to modify the
generateDrawingInformation:method to offset the selected segment (and its text) by a#definedamount:- (void)generateDrawingInformation { // Keep pointers to the segmentValuesArray and segmentNamesArray NSArray *cachedSegmentValuesArray = [self segmentValuesArray]; NSArray *cachedSegmentNamesArray = [self segmentNamesArray]; // Get rid of any existing Paths Array if( _segmentPathsArray ) { [_segmentPathsArray release]; _segmentPathsArray = nil; } // Get rid of any existing Texts Array if( _segmentTextsArray ) { [_segmentTextsArray release]; _segmentTextsArray = nil; } // If there aren't any values to display, we can exit now if( [cachedSegmentValuesArray count] < 1 ) return; // Get the sum of the amounts and exit if it is zero float sumOfAmounts = 0; for( NSNumber *eachAmountToSum in cachedSegmentValuesArray ) sumOfAmounts += [eachAmountToSum floatValue]; if( sumOfAmounts == 0 ) return; _segmentPathsArray = [[NSMutableArray alloc] initWithCapacity:[cachedSegmentValuesArray count]]; _segmentTextsArray = [[NSMutableArray alloc] initWithCapacity:[cachedSegmentValuesArray count]]; NSIndexSet *selectionIndexes = [self selectionIndexes]; BOOL shouldOffsetSelectedSegment = ([selectionIndexes count] > 0) ? YES : NO; #define PADDINGAROUNDGRAPH 20.0 #define TEXTPADDING 5.0 #define SELECTEDSEGMENTOFFSET 5.0 NSRect viewBounds = [self bounds]; NSRect graphRect = NSInsetRect(viewBounds, PADDINGAROUNDGRAPH, PADDINGAROUNDGRAPH); // Make the graphRect square and centred if( graphRect.size.width > graphRect.size.height ) { double sizeDifference = graphRect.size.width - graphRect.size.height; graphRect.size.width = graphRect.size.height; graphRect.origin.x += (sizeDifference / 2); } if( graphRect.size.height > graphRect.size.width ) { double sizeDifference = graphRect.size.height - graphRect.size.width; graphRect.size.height = graphRect.size.width; graphRect.origin.y += (sizeDifference / 2); } // get NSRects for the different quarters of the pie-chart NSRect topLeftRect, topRightRect; NSDivideRect(viewBounds, &topLeftRect, &topRightRect, (viewBounds.size.width / 2), NSMinXEdge ); NSRect bottomLeftRect, bottomRightRect; NSDivideRect(topLeftRect, &topLeftRect, &bottomLeftRect, (viewBounds.size.height / 2), NSMinYEdge ); NSDivideRect(topRightRect, &topRightRect, &bottomRightRect, (viewBounds.size.height / 2), NSMinYEdge ); // Calculate how big a 'unit' is float unitSize = (360.0 / sumOfAmounts); if( unitSize > 360 ) unitSize = 360; float radius = graphRect.size.width / 2; NSPoint midPoint = NSMakePoint( NSMidX(graphRect), NSMidY(graphRect) ); // Set the text attributes to be used for each textual display NSDictionary *textAttributes = [NSDictionary dictionaryWithObjectsAndKeys: [NSColor whiteColor], NSBackgroundColorAttributeName, [NSColor blackColor], NSForegroundColorAttributeName, [NSFont systemFontOfSize:12], NSFontAttributeName, nil]; // cycle through the segmentValues and create the bezier paths // Also add the text details (note we expect the texts' indexes to tie up with the values' indexes) float currentDegree = 0; unsigned currentIndex; for( currentIndex = 0; currentIndex < [cachedSegmentValuesArray count]; currentIndex++ ) { NSNumber *eachValue = [cachedSegmentValuesArray objectAtIndex:currentIndex]; float startDegree = currentDegree; currentDegree += ([eachValue floatValue] * unitSize); float endDegree = currentDegree; float midDegree = startDegree + ((endDegree - startDegree) / 2); NSBezierPath *eachSegmentPath = [NSBezierPath bezierPath]; [eachSegmentPath moveToPoint:midPoint]; [eachSegmentPath appendBezierPathWithArcWithCenter:midPoint radius:radius startAngle:startDegree endAngle:midDegree clockwise:NO]; NSPoint textPoint = [eachSegmentPath currentPoint]; [eachSegmentPath appendBezierPathWithArcWithCenter:midPoint radius:radius startAngle:midDegree endAngle:endDegree clockwise:NO]; [eachSegmentPath closePath]; // close path also handles the lines from the midPoint to the start and end of the arc [eachSegmentPath setLineWidth:2.0]; // Check to see whether we should offset this segment if it's currently selected in the array controller if( shouldOffsetSelectedSegment && [selectionIndexes containsIndex:currentIndex] ) { float differenceRatio = (SELECTEDSEGMENTOFFSET / radius) + (SELECTEDSEGMENTOFFSET / (endDegree - startDegree)); float diffY = (textPoint.y - midPoint.y) * differenceRatio; float diffX = (textPoint.x - midPoint.x) * differenceRatio; NSAffineTransform *transform = [NSAffineTransform transform]; [transform translateXBy:diffX yBy:diffY]; [eachSegmentPath transformUsingAffineTransform: transform]; textPoint = [transform transformPoint:textPoint]; } [_segmentPathsArray addObject:eachSegmentPath]; // Get the text to be displayed, if it exists, and see how big it is NSString *eachText = @""; if( [cachedSegmentNamesArray count] > currentIndex ) eachText = [cachedSegmentNamesArray objectAtIndex:currentIndex]; NSSize textSize = [eachText sizeWithAttributes:textAttributes]; // Offset it by TEXTPADDING in direction suitable for whichever quarter of the view it is in if( NSPointInRect(textPoint, topLeftRect) ) { textPoint.y -= (textSize.height + TEXTPADDING); textPoint.x -= (textSize.width + TEXTPADDING); } else if( NSPointInRect(textPoint, topRightRect) ) { textPoint.y -= (textSize.height + TEXTPADDING); textPoint.x += TEXTPADDING; } else if( NSPointInRect(textPoint, bottomLeftRect) ) { textPoint.y += TEXTPADDING; textPoint.x -= (textSize.width + TEXTPADDING); } else if( NSPointInRect(textPoint, bottomRightRect) ) { textPoint.y += TEXTPADDING; textPoint.x += TEXTPADDING; } // Make sure the point isn't outside the view's bounds if( textPoint.x < viewBounds.origin.x ) textPoint.x = viewBounds.origin.x; if( (textPoint.x + textSize.width) > (viewBounds.origin.x + viewBounds.size.width) ) textPoint.x = viewBounds.origin.x + viewBounds.size.width - textSize.width; if( textPoint.y < viewBounds.origin.y ) textPoint.y = viewBounds.origin.y; if( (textPoint.y + textSize.height) > (viewBounds.origin.y + viewBounds.size.height) ) textPoint.y = viewBounds.origin.y + viewBounds.size.height - textSize.height; // Finally add the details as a dictionary to our segmentTextsArray. // We include here the textAttributes lest we decide later to e.g. color the texts the same color as the segment fill [_segmentTextsArray addObject:[NSDictionary dictionaryWithObjectsAndKeys:[NSNumber numberWithFloat:textPoint.x], @"textPointX", [NSNumber numberWithFloat:textPoint.y], @"textPointY", eachText, @"text", textAttributes, @"textAttributes", nil]]; } }
- We’ll also modify the
drawRect:method so that it fills selected segments with blue rather than the usual colour:- (void)drawRect:(NSRect)rect { [[NSColor whiteColor] set]; // white background NSRectFill([self bounds]); NSArray *pathsArray = [self segmentPathsArray]; unsigned count; for( count = 0; count < [pathsArray count]; count++ ) { NSBezierPath *eachPath = [pathsArray objectAtIndex:count]; // fill the path with the drawing color for this index unless it's selected if( [[self selectionIndexes] containsIndex:count] )