I came upon Mark Alldritt’s SourceListView again after reading Marc Charbonneau’s Blog. I remember reading Mark’s blog entry before, but I never looked at the source code. Tonight I decided to check it out.
LNSSourceListView is a subclass of NSOutlineView which highlights selected rows differently. There are two styles, an iTunes style that uses a gradient background, and a Numbers style with a flat background.
The view is using [self setNeedsDisplay:YES] in three places. This forces the entire view to be redrawn, so you want to avoid calling that method if at all possible to get faster drawing. Calling setNeedsDisplayInRect: is better.
So I decided to see if I could improve the drawing. The two cases in question happen with the Numbers appearance of the the source list:
- (void)outlineViewSelectionDidChange:(NSNotification *)notification;
{
if (mAppearance == kSourceList_NumbersAppearance
&& [[self selectedRowIndexes] count] > 1)
[self setNeedsDisplay:YES];
}
- (void)outlineViewSelectionIsChanging:(NSNotification *)notification;
{
if (mAppearance == kSourceList_NumbersAppearance
&& [[self selectedRowIndexes] count] > 1)
[self setNeedsDisplay:YES];
}
I changed it to use that appearance and then watched the drawing with Quartz Debug. Sure enough, when more than one row is selected the entire view gets redrawn.
Were those method calls really necessary? I disabled them to see what would happen. The result was some darker lines drawn between the selected rows.
The highlighting occurs in highlightSelectionInClipRect:. It sets up a couple of colors, highlightColor and highlightFrameColor. I wanted to make it easy to see where these were used, so I changed them to something that would stand out:
highlightColor = [NSColor redColor];
highlightFrameColor = [NSColor blackColor];
From this I saw that it draws a one pixel high line in the highlightFrameColor above and below each contiguous set of selected rows.
Even with the setNeedsDisplay:YES enabled, I noticed a couple drawing bugs now that the colors really stood out. If you have two adjacent rows selected and deselect the second row (either by selecting the first row, or command-selecting the second row), the bottom border of the first row isn’t redrawn. Also, if you have three adjacent rows selected and then click on the second selected row, neither the top or bottom borders get redrawn.
The highlighting is a little hard to follow, but it’s using [self rowsInRect:clipRect] to find the range of rows to check, highlighting the selected ones and doing tests for drawing the top or bottom borders. I suspect this won’t work properly when the clip rect doesn’t cover an entire range of selected rows.
Cutting out the drawing for the top and bottom borders leaves me with this:
- (void)highlightSelectionInClipRect:(NSRect)clipRect;
{
switch (mAppearance) {
...
case kSourceList_NumbersAppearance:
NSRange rows = [self rowsInRect:clipRect];
...
for (row = rows.location; row < maxRow; ++row) {
if ([self isRowSelected:row]) {
NSRect selectRect = [self rectOfRow:row];
if (NSIntersectsRect(selectRect, clipRect)) {
[highlightColor set];
NSRectFill(selectRect);
}
}
}
...
}
}
That's easy to understand, but now we need to draw the borders again. There should be a line above the first row and below the last row of each range of contiguously selected rows. Hmm. Well, if we know indexes of all of the rows starting these ranges, we can easily draw above them. Similarly for the rows ending the ranges.
@implementation NSIndexSet (STExtensions)
- (void)findRangeStartIndexes:(NSMutableIndexSet *)startIndexes
endIndexes:(NSMutableIndexSet *)endIndexes;
{
unsigned int currentIndex;
for (currentIndex = [self firstIndex]; currentIndex != NSNotFound;
currentIndex = [self indexGreaterThanIndex:currentIndex]) {
if (currentIndex == 0 || [self containsIndex:currentIndex - 1] == NO)
[startIndexes addIndex:currentIndex];
if ([self containsIndex:currentIndex + 1] == NO)
[endIndexes addIndex:currentIndex];
}
}
That handles finding the start and end indexes. Drawing these borders now looks like this:
{
NSMutableIndexSet *startIndexes, *endIndexes;
NSRect selectRect;
unsigned int currentIndex;
[highlightFrameColor set];
startIndexes = [[NSMutableIndexSet alloc] init];
endIndexes = [[NSMutableIndexSet alloc] init];
[[self selectedRowIndexes] findRangeStartIndexes:startIndexes
endIndexes:endIndexes];
for (currentIndex = [startIndexes firstIndex]; currentIndex != NSNotFound;
currentIndex = [startIndexes indexGreaterThanIndex:currentIndex]) {
selectRect = [self rectOfRow:currentIndex];
selectRect.origin.y -= 1.0;
selectRect.size.height = 1.0;
if (NSIntersectsRect(selectRect, clipRect))
NSRectFill(selectRect);
}
for (currentIndex = [endIndexes firstIndex]; currentIndex != NSNotFound;
currentIndex = [endIndexes indexGreaterThanIndex:currentIndex]) {
selectRect = [self rectOfRow:currentIndex];
selectRect.origin.y = NSMaxY(selectRect) - 1.0;
selectRect.size.height = 1.0;
if (NSIntersectsRect(selectRect, clipRect))
NSRectFill(selectRect);
}
[startIndexes release];
[endIndexes release];
}
This draws properly if the entire view is redrawn, but doesn't redraw the borders properly as-is. We'll fix that.
It looks like outlineViewSelectionIsChanging: is called during drag selection, after each change to the selection, and outlineViewSelectionDidChange: is called at the end. The latter method is also called after the selection changes by other means, such as using selectAll: or using the up and down arrows to move the selection. We need to know which start and end rows changed from the selection, so we need to know which rows were selected before the selection changed. Unfortunately there isn't a public method that's called *before* the selection changes. (The outlineView:shouldSelectItem: delegate method might be called at the right time, but I don't want to handle this in the delegate.)
After some experimentation I came up with this. The view will have an NSMutableIndexSet to record the selected rows before they change. Since initWithFrame: isn't called when the view is unarchived from a nib, we also create it in awakeFromNib.
@interface LNSSourceListView : NSOutlineView
{
AppearanceKind mAppearance;
NSMutableIndexSet *previouslySelectedRows;
}
At the right times I'll call this method to update it:
- (void)_updatePreviouslySelectedRows;
{
[previouslySelectedRows removeAllIndexes];
[previouslySelectedRows addIndexes:[self selectedRowIndexes]];
}
And then when the selection changes, we dirty the borders of the start and end rows that changed from the change in selection. That is, a row that was a start row before but isn't now, or wasn't before but is now. Same for the end rows. Here's another NSIndexSet category method to help:
- (NSIndexSet *)exclusiveOrWithIndexSet:(NSIndexSet *)otherIndexSet;
{
NSMutableIndexSet *resultSet, *allSet;
unsigned int currentIndex;
allSet = [[NSMutableIndexSet alloc] initWithIndexSet:self];
[allSet addIndexes:otherIndexSet];
resultSet = [NSMutableIndexSet indexSet];
for (currentIndex = [allSet firstIndex]; currentIndex != NSNotFound;
currentIndex = [allSet indexGreaterThanIndex:currentIndex]) {
if (![self containsIndex:currentIndex]
|| ![otherIndexSet containsIndex:currentIndex])
[resultSet addIndex:currentIndex];
}
[allSet release];
return resultSet;
}
Here are the methods that dirty the border frames. We make sure they intersect the visible rect before calling setNeedsDisplayInRect: because that method can become expensive if called too many times.
- (void)_dirtyChangedRowFrames;
{
NSMutableIndexSet *previousStartIndexes, *previousEndIndexes;
NSMutableIndexSet *currentStartIndexes, *currentEndIndexes;
previousStartIndexes = [[NSMutableIndexSet alloc] init];
previousEndIndexes = [[NSMutableIndexSet alloc] init];
currentStartIndexes = [[NSMutableIndexSet alloc] init];
currentEndIndexes = [[NSMutableIndexSet alloc] init];
[previouslySelectedRows findRangeStartIndexes:previousStartIndexes endIndexes:previousEndIndexes];
[[self selectedRowIndexes] findRangeStartIndexes:currentStartIndexes endIndexes:currentEndIndexes];
[self _dirtyStartFrameOfRowsAtIndexes:[previousStartIndexes exclusiveOrWithIndexSet:currentStartIndexes]];
[self _dirtyEndFrameOfRowsAtIndexes:[previousEndIndexes exclusiveOrWithIndexSet:currentEndIndexes]];
[previousStartIndexes release];
[previousEndIndexes release];
[currentStartIndexes release];
[currentEndIndexes release];
}
- (void)_dirtyStartFrameOfRowsAtIndexes:(NSIndexSet *)indexes;
{
unsigned int currentIndex;
NSRect visibleRect, rect;
visibleRect = [self visibleRect];
for (currentIndex = [indexes firstIndex]; currentIndex != NSNotFound;
currentIndex = [indexes indexGreaterThanIndex:currentIndex]) {
rect = [self rectOfRow:currentIndex];
rect.origin.y -= 1.0;
rect.size.height = 1.0;
if (NSIntersectsRect(rect, visibleRect))
[self setNeedsDisplayInRect:rect];
}
}
- (void)_dirtyStartFrameOfRowsAtIndexes:(NSIndexSet *)indexes;
{
unsigned int currentIndex;
NSRect visibleRect, rect;
visibleRect = [self visibleRect];
for (currentIndex = [indexes firstIndex]; currentIndex != NSNotFound;
currentIndex = [indexes indexGreaterThanIndex:currentIndex]) {
rect = [self rectOfRow:currentIndex];
rect.origin.y -= 1.0;
rect.size.height = 1.0;
if (NSIntersectsRect(rect, visibleRect))
[self setNeedsDisplayInRect:rect];
}
}
Now we just need to call these methods at the appropriate times. It looks like mouseDown: is the easiest place to get the initial selection when using the mouse to select:
- (void)mouseDown:(NSEvent *)mouseEvent;
{
if (mAppearance == kSourceList_NumbersAppearance)
[self _updatePreviouslySelectedRows];
[super mouseDown:mouseEvent];
[previouslySelectedRows removeAllIndexes];
}
For the cases of moving the selection with the arrow keys we use keyDown:.
- (void)keyDown:(NSEvent *)keyEvent;
{
if (mAppearance == kSourceList_NumbersAppearance)
[self _updatePreviouslySelectedRows];
[super keyDown:keyEvent];
}
But that doesn't get called for Select All. We just override selectAll: to handle that case:
- (void)selectAll:(id)sender;
{
if (mAppearance == kSourceList_NumbersAppearance)
[self _updatePreviouslySelectedRows];
[super selectAll:sender];
}
And of course after each change we need to update it, after first dirtying the parts that changed:
- (void)outlineViewSelectionIsChanging:(NSNotification *) notification
{
if (mAppearance == kSourceList_NumbersAppearance) {
[self _dirtyChangedRowFrames];
[self _updatePreviouslySelectedRows];
}
}
That method isn't called for Select All or the keyboard changes to selection, so we also need this:
- (void)outlineViewSelectionDidChange:(NSNotification *) notification
{
if (mAppearance == kSourceList_NumbersAppearance)
[self _dirtyChangedRowFrames];
}
I think that's the last of the changes. You can watch with Quartz Debug and see that now it doesn't redraw the entire view when the selection changes. Plus the two drawing bugs noted earlier should be fixed.
Conclusion
When you're initially writing code, you're most concerned with getting the custom drawing to draw properly and it's easiest to just use setNeedsDisplay:YES. Once that's working you can go back and replace those with calls to setNeedsDisplayInRect:. This usually involves a lot more code because you need to know exactly what has changed. And you still need to be careful not to call it too much, since that can become a bottleneck, so checking against the visible rect is a good practice.
It can be a lot of work, but it's quite satisfying to watch the updates in Quartz Debug at the end.