TweetFollow Us on Twitter

Table Techniques Taught Tastefully (part 3)

Volume Number: 18 (2002)
Issue Number: 11
Column Tag: Cocoa Development

Table Techniques Taught Tastefully (part 3)

Using NSTableView for Real-World Applications

by Dan Wood

Introduction

This is the last of a series of articles about the wonderful NSTableView class in Cocoa. While the first part went over the basics, and part the second got your hands dirty, this last part is where we pull out all the stops and do some really cool things with tables that will make you the envy of all the Cocoa programmers on your block.

In this article, we'll show you how to give your tables those trendy blue and white alternating stripes and vertical grid-lines that you see on programs like iTunes. We'll make a subclass of NSTableView that merges certain cells together across multiple columns, suitable for display of a time schedule. We'll make a custom cell to indicate relevance, like you see when you search in Apple's Mail program or the Finder. And finally, we'll see how to animate the sorting of a table, like you see in iChat. (Warning: There will be math in the last segment!)

Be sure to follow along with the "TableTester" application (downloadable at www.karelia.com/tabletester/), a program showing off most of the table features described in this series. It contains the source code corresponding to the techniques in this article as well as those in the first two parts, in case you missed them the first time around.

Striped Table Rows

If you want your table display to look like one of Apple's applications like iTunes, or just to make your list more readable, you may want to consider alternating row background colors. At first glance, it seems that the best way to accomplish this is to intercept the tableView: willDisplayCell: forTableColumn: row: delegate message and set the cell's background color depending on whether the row is even or odd. Unfortunately, this only stripes the cells with data, rather than the entire table; it also works only for text cells, not button or image cells.

A better approach is to create a subclass of NSTableView and override highlightSelectionInClipRect: (Listing 1) to draw the stripes. This method draws stripes in the background by alternating between the "even" color of light blue and the "odd" color of white.


Figure 1. Alternating Rows and Vertical Grids

Listing 1: StripedTableView.m

highlightSelectionInClipRect:
Display the background for the table in the given clipping rectangle.
- (void)highlightSelectionInClipRect:(NSRect)clipRect
{
   NSColor *evenColor   // empirically determined color, matches iTunes etc.
      = [NSColor colorWithCalibratedRed:0.929
            green:0.953 blue:0.996 alpha:1.0];
   NSColor *oddColor  = [NSColor whiteColor];
   
   float rowHeight
      = [self rowHeight] + [self intercellSpacing].height;
   NSRect visibleRect = [self visibleRect];
   NSRect highlightRect;
   
   highlightRect.origin = NSMakePoint(
      NSMinX(visibleRect),
      (int)(NSMinY(clipRect)/rowHeight)*rowHeight);
   highlightRect.size = NSMakeSize(
      NSWidth(visibleRect),
      rowHeight - [self intercellSpacing].height);
   
   while (NSMinY(highlightRect) < NSMaxY(clipRect))
   {
      NSRect clippedHighlightRect
         = NSIntersectionRect(highlightRect, clipRect);
      int row = (int)
         ((NSMinY(highlightRect)+rowHeight/2.0)/rowHeight);
      NSColor *rowColor
         = (0 == row % 2) ? evenColor : oddColor;
      [rowColor set];
      NSRectFill(clippedHighlightRect);
      highlightRect.origin.y += rowHeight;
   }
   
   [super highlightSelectionInClipRect: clipRect];
}

To mimic the iTunes look even further, you may want to draw a grid, but only the vertical lines between columns, not the horizontal lines between rows. So we override drawGridInClipRect: and provide our own implementation (Listing 2) that draws light gray vertical lines.

Listing 2: StripedTableView.m

drawGridInClipRect:
Draw the grid, but only the vertical lines.
   - (void)drawGridInClipRect:(NSRect)rect
{
   NSRange columnRange = [self columnsInRect:rect];
   int i;
   [[NSColor lightGrayColor] set];
   for (   i = columnRange.location ;
         i < NSMaxRange(columnRange) ;
         i++ )
   {
      NSRect colRect = [self rectOfColumn:i];
      int rightEdge
         = (int) 0.5 + colRect.origin.x + colRect.size.width;
      [NSBezierPath strokeLineFromPoint:
            NSMakePoint(-0.5+rightEdge, -0.5+rect.origin.y)
         toPoint:
            NSMakePoint(-0.5+rightEdge, -0.5+rect.origin.y +
               rect.size.height)];
   }
}

Voila! Striped tables, as in Figure 1. Now you can write the next iApp!

Merging table cells together

So far, all of the subclassing of NSTableView that we've done in this series are pretty straightforward, and modify the default behavior only subtly. But what happens when the class is radically subclassed? Well, one example of this is Cocoa's own NSOutlineView, a subclass that barely resembles its parent in the way that it structures and presents its contents. In this segment, we'll try something a bit less ambitious, but significant nevertheless.

The challenge is this: to have a table view in which certain columns are merged together with their neighboring columns. An example application would be a daily schedule in which appointments take a variable amount of time. You want the cells to span across multiple columns, not constrained to individual columns. (Readers familiar with HTML can equate this to the "colspan" attribute of a <TD> tag.)

There are two sides to making this work. One is to modify the controller code that provides the data for the table to display; the other is to implement the view (the NSTableView subclass, which we call MergedColumnTableView) to display the data provided by the controller.

For the controller, we invent a new method for an informal protocol for your controller to define:

(int)tableView:(NSTableView *)tableView
   spanForTableColumn:(NSTableColumn *)tableColumn
   row:(int)row;

Our implementation should return 1 if the cell is one column wide (the usual case); 0 if no data is to be shown in the column (generally the case if it is to the right of a multiple-cell-spanning table), and a number greater than 1 if the cell is to span more than one column to the right. Because we pass in a pointer to the table view, this method can be used even if there is more than one table that your controller controls; because we pass in a row number, each row can have differ in its presentation.

The TableTester application accompanying this article reads in a sample "class schedule" from a property list file, and implements the standard NSTableView data source methods of numberOfRowsInTableView: and tableView:objectValueForTableColumn:row: as well as the spanning method for this protocol. We won't be examining the controller implementation in-depth here, since its very dependent on the data structure. Unlike a typical table display, where an array of dictionaries will usually suffice, the data to display is more complex. So if you need to display data that spans multiple columns, you can take advantage of MergedColumnTableView, but you are on your own for implementing the controller. (This is yet another reason why it's good to partition the view, the controller, and the model!)

How the MergedColumnTableView subclassing works, on the other hand, is significant, because it may help reveal techniques that you can use for other table subclassing needs. We override three methods of NSTableView: frameOfCellAtColumn: row:, to override the rectangle for a given cell to take the column spanning into account; drawRow: clipRect:, to manage the drawing of all the columns in a row; and drawGridInClipRect:, to draw the table grid lines in such a way that merged cells have no grid line between them. (Each of those methods provides a special behavior if the data source implements our additional method; if not, the superclass provides the default behavior.)

The most important override is frameOfCellAtColumn:row:. (See Listing 3.) This is the method that calculates the rectangle associated with a given cell for each column and row. Since our goal is to make the cells wider than they would normally be, this is the logical place to modify the default table behavior. All we have to do is find out the number of columns that the given cell should span, and return an appropriate rectangle. If the column span is zero, we return an empty rectangle, NSZeroRect. If the column span is one, we let the superclass calculate the rectangle, since nothing is different about a cell with a column span of one. If the column span is greater than one, we collect up all the rectangles of the current cell and the cells to the right by invoking the superclass's method, merging the rectangles together into one bigger rectangle.

Listing 3: MergedColumnTableView.m

frameOfCellAtColumn:row:
Return the rectangle for the given cell.  This may behave like its superclass, or it may return an 
empty rectangle or a wider rectangle if the table's column span is not one.

- (NSRect)frameOfCellAtColumn:(int)column row:(int)row
{
   int colspan;
   if (![[self dataSource]
      respondsToSelector:
         @selector(tableView:spanForTableColumn:row:)])
   {
      return [super frameOfCellAtColumn:column row:row];
   }
   colspan = [[self dataSource]
            tableView:self
            spanForTableColumn:
               [[self tableColumns] objectAtIndex:column]
            row:row];
   if (0 == colspan)
   {
      return NSZeroRect;
   }
   if (1 == colspan)
   {
      return [super frameOfCellAtColumn:column row:row];
   }
   else      // 2 or more, it's responsibility of data source to provide reasonable number
   {
      NSRect merged
         = [super frameOfCellAtColumn:column row:row];
      // start out with this one
      int i;
      for (i = 1; i < colspan; i++ )   // start from next one
      {
         NSRect next
            = [super frameOfCellAtColumn:column+i row:row];
         merged = NSUnionRect(merged,next);
      }
      return merged;
   }
}

The above override covers most of the needed functionality, but a couple of subtle items remain. When horizontal scrollbars are used in the table, you will find that some table cells don't get drawn on the left edge of the table. This is because the standard NSTableView draws only the cells that are currently visible within the NSScrollView, and it doesn't realize that a cell spanning multiple columns needs to be drawn even if it starts to the left of the visible rectangle. (See Figure 2.) So we override drawRow: clipRect: (Listing 4) to look at the leftmost column, and if the cell there has a column span of zero, it needs to "back up" to the left and find the cell that spans multiple columns. Once it finds that cell, it expands the clipping rectangle so that the wide cell will be drawn and thus appear in the visible region.


Figure 2. Missing Columns

Listing 4: MergedColumnTableView.m

typeAheadString:inTableView:
Actually select the appropriate row based upon the string that has been typed.
- (void)drawRow:(int)inRow clipRect:(NSRect)inClipRect
{
   NSRect newClipRect = inClipRect;
   if ([[self dataSource]
      respondsToSelector:
         @selector(tableView:spanForTableColumn:row:)])
   {
      int colspan = 0;
      int firstCol
         = [self columnsInRect:inClipRect].location;
      // Does the FIRST one of these have a zero-colspan?  If so, extend range.
      while (0 == colspan)
      {
         colspan = [[self dataSource]
                     tableView:self
                     spanForTableColumn:[[self tableColumns]
                        objectAtIndex:firstCol]
                     row:inRow];
         if (0 == colspan)
         {
            firstCol--;
            newClipRect = NSUnionRect(newClipRect,
               [self frameOfCellAtColumn:firstCol row:inRow]);
         }
      }
   }
   [super drawRow:inRow clipRect:newClipRect];
}


Figure 3: The grid lines don't look right.

With the above override, all of the cells will display, but what if you want to display a grid to make the cell sizes clearer? If we use the standard grid, the table would look like Figure 3, with vertical lines cutting across our wide cells. So we implement a new grid that takes the column spans into account, drawing vertical lines only before or after cells. If you have a "sparse" display (such as the example class schedule here), it looks even better if you give the table a distinguishing background color. The final result is in Figure 4.


Figure 4: Grid lines match the column span.

The drawGridInClipRect: override (Listing 5) determines all of the rows and columns that will need to be drawn for the given clipping rectangle. It then loops first through each row, drawing the horizontal lines using NSBezierPath methods, then loops through each row's columns to draw the vertical lines. Similarly to the frameOfCellAtColumn:row: override, it builds up the rectangle for a cell spanning multiple columns by growing a rectangle using the NSUnionRect operation.

Listing 5: MergedColumnTableView.m

typeAheadString:inTableView:
Actually select the appropriate row based upon the string that has been typed.

- (void)drawGridInClipRect:(NSRect)rect
{
   if (![[self dataSource] respondsToSelector:
      @selector(tableView:spanForTableColumn:row:)])
   {
      [super drawGridInClipRect:rect];
   }
   else
   {
      NSRange rowRange = [self rowsInRect:rect];
      NSRange columnRange = [self columnsInRect:rect];
      int row;
      // Adjust column range, always go from zero, so we can gather columns even to 
      // the left of what we are supposed to draw.
      columnRange = NSMakeRange(0,NSMaxRange(columnRange));
      [[NSColor grayColor] set];
      for (   row = rowRange.location ;
            row < NSMaxRange(rowRange) ;
            row++ )
      {
         int col = columnRange.location;
         int oldLeftEdge
            = 0.5 + [self rectOfColumn:col].origin.x;
         NSRect rowRect = [self rectOfRow:row];
         // here, frame not the top and not the left, but the bottom
         [NSBezierPath strokeLineFromPoint:
            NSMakePoint(rowRect.origin.x,
               -0.5+rowRect.origin.y+rowRect.size.height)
            toPoint:
            NSMakePoint(rowRect.origin.x + rowRect.size.width,
               -0.5+rowRect.origin.y+rowRect.size.height)];
         while (   col < NSMaxRange(columnRange) )
         {
            int colspan = [[self dataSource] tableView:self
               spanForTableColumn:[[self tableColumns]
                  objectAtIndex:col] row:row];
            NSRect gridRect = NSZeroRect;
            if (0 == colspan)
            {
               col++;      // no grid here, move along
            }
            else   // Now gather up the next <colspan> rectangles
            {
               int i, rightEdge, leftEdge;
               for ( i = 0 ; i < colspan ; i++ )
               {
                  NSRect thisRect = NSIntersectionRect(
                     [self rectOfColumn:col+i],
                     [self rectOfRow:row]);
                  gridRect = NSUnionRect(gridRect,thisRect);
               }
               col += colspan;
               // left edge.  Only draw if this left edge isn't one we just drew.
               leftEdge = (int) 0.5 + gridRect.origin.x;
               if (leftEdge != oldLeftEdge)
               {
                  [NSBezierPath strokeLineFromPoint:
                     NSMakePoint(
                        -0.5+leftEdge, -0.5+gridRect.origin.y)
                     toPoint:
                     NSMakePoint(-0.5+leftEdge,
                        -0.5+gridRect.origin.y
                        + gridRect.size.height)];
               }
               // right edge
               rightEdge = (int) 0.5 + gridRect.origin.x
                  + gridRect.size.width;
               [NSBezierPath strokeLineFromPoint:
                  NSMakePoint(-0.5+rightEdge,
                     -0.5+gridRect.origin.y)
                  toPoint:
                  NSMakePoint(-0.5+rightEdge,
                     -0.5+gridRect.origin.y
                     + gridRect.size.height)];
               oldLeftEdge = rightEdge;   // save edge for next pass through.
            }
         }
      }
   }
}

That's all there is to it. OK, maybe that one wasn't so easy. If you are going to be heavily subclassing NSTableView, realize that it is going to take a lot of trial and error, research, and help from other smart people to get it to work just right. Subclassing any object for which you don't have the source code is never easy, because you don't know all of the subtle interactions among the methods that you might be able to see if it was your code. Still, it is possible to modify NSTableView's default behavior by modifying just a few methods.

Custom Cell Classes

That last one was a bit deep, so let's take a breather and try something a little easier. How do you make a "relevance" indicator, like you see when you search for items in the Finder? (See Figure 5.) As of Mac OS X 10.2, there is now a standard widget you need to use, but no such class has been provided in Cocoa for us to use. Since a Table displays cells, we need to make our own custom subclass of NSCell. We'll call the class "RankCell" because it's easier to spell than "RelevanceCell." (See Listing 6.)


Figure 5: Relevance cells in a table

Listing 6: RankCell.m

// declare a static variable that will hold the pattern.
static NSImage *sRankPatternImage = nil;
initialize
When the class is initialized, load in the striped pattern from an image in the project.

+ (void) initialize
{
   sRankPatternImage
      = [[NSImage imageNamed:@"stripe"] retain];
}

floatValue
Return the value of the cell (a number from 0.0 to 1.0) as a floating point number.  The method 
verifies that the cell's associated object value is indeed an object that can return a float. (Both 
NSNumber and NSString respond to floatValue!)

- (float) floatValue
{
   float result = 0.0;
   id objectValue = [self objectValue];
   if ([objectValue respondsToSelector:
      @selector(floatValue)])
   {
      result = [(NSNumber *)objectValue floatValue];
   }
   return result;
}

setFloatValue:

Set the value of the cell to a number between 0.0 and 1.0.

- (void) setFloatValue:(float)inValue
{
   float value = inValue;
   if (value > 1.0) value = 1.0;
   if (value < 0.0) value = 0.0;
   [self setObjectValue:[NSNumber numberWithFloat:value]];
}

drawInteriorWithFrame: inView:

Draw the cell. The cell will be smaller if its controlSize is NSSmallControlSize.

(void) drawInteriorWithFrame: (NSRect)inFrame
      inView: (NSView*)inView;
{
   float drawWidth;
   NSRect fillFrame, eraseFrame;
   // Constrain the frame's height
   float yInset
      = (NSSmallControlSize == [self controlSize])
         ? 4.0 : 3.0;
   NSRect newFrame = NSInsetRect(inFrame, 3.0, yInset);
   // Calculate width of filled part
   drawWidth
      = floor([self floatValue] * newFrame.size.width);
   if (drawWidth < 1)
   {
      drawWidth = 1;   //  at least 1 pixel wide, so we see something!
   }
   NSDivideRect(newFrame, &fillFrame, &eraseFrame,
      drawWidth, NSMinXEdge);
   [[NSColor colorWithPatternImage:sRankPatternImage] set];
   [NSBezierPath fillRect:fillFrame];
}

All we need to do is to set our table to use our RankCell class. As usual, a good place to do this is in an awakeFromNib method. (See Listing 7.) Then you have your data source's tableView: objectValueForColumn: row: method return an NSNumber between 0.0 and 1.0. That's it!

Listing 7: CellDelegate.m

awakeFromNib
Create a RankCell and set it as a column's cell.
- (void)awakeFromNib
{
   RankCell    *rankCell
      = [[[RankCell alloc] init] autorelease];
   NSTableColumn *rankColumn
      = [oTable tableColumnWithIdentifier:@"rank"];
   [rankCell setControlSize:NSSmallControlSize];
   [rankColumn setDataCell:rankCell];
}

Animated Sorting

If you've seen iChat, you have probably noticed the slick animation when people on your Buddy List change status. The table elements animate as they move around. Very slick, but how did they do it? It probably involved subclassing NSTableView. Inspired by some Apple sample code called "AnimatedSlider" that animates the transition between values of an NSSlider (much like the preset equalizer settings in iTunes), I've determined out how to do this, and I present the technique here. (It's hard to show animations in print, but Figure 6 should give you an idea of what it looks like.)


Figure 6: Animated Sorting: Before, During, and After

The trick is to override the NSTableView method rectOfRow: and invoke it multiple times over a second or so, to move the rows from their old positions to their new ones. Unlike the "AnimatedSlider" example, which managed to put all the animation functionality into a category method on NSSlider, this is a little bit more complex, so we need the controller involved as well. So to keep the view separated as much as possible from the controller, the NSTableView subclass--AnimatedTableView--lets its delegate (or its data source, if you desire) take care of the logic associated with the animation. (See Listing 8.)

Listing 8: AnimatedTableView.m

rectOfRow:
Override NSTableView's method that calculates the rectangle of a given row.  Let the delegate 
determine what the rectangle for a given row is, if that delegate responds to the selector to do so.  
If animation is going on, it will probably return a value other than the default.

- (NSRect)rectOfRow:(int)rowIndex
{
   NSRect theDefaultRect = [super rectOfRow:rowIndex];
   if ([[self delegate] respondsToSelector:
            @selector(tableView:rectOfRow:defaultRect:)])
   {
      return [[self delegate]
         tableView:self rectOfRow:rowIndex
         defaultRect:theDefaultRect];
   }
   else
   {
      return theDefaultRect;
   }
}

unanimatedRectOfRow:
Return the default NSTableView's version of the rectangle for a given row; this method is provided so 
we can determine the rectangle as if animation weren't happening.

- (NSRect)unanimatedRectOfRow:(int)rowIndex
{
   return [super rectOfRow:rowIndex];
}

rowsInRect:
Override NSTableView's method that determines what rows are visible for a given rectangle.  Let the 
delegate determine what the range of rows is for a given rectangle, if that delegate responds to the 
selector to do so.  If animation is going on, it will probably return a value other than the default.

- (NSRange)rowsInRect:(NSRect)inRect
{
   NSRange theDefaultRange = [super rowsInRect:inRect];
   if ([[self delegate] respondsToSelector:
            @selector(tableView:rowsInRect:defaultRange:)])
   {
      return [[self delegate]
         tableView:self rowsInRect:inRect
         defaultRange:theDefaultRange];
   }
   else
   {
      return theDefaultRange;
   }
}

In order to animate the rows in a table, we need to keep track of both the original positions and the new positions of each row in the table! One way to do this is to build a new array of index values so we can lookup the original array position for any row in the sorted array. For example, if an array containing E, G, B, D, F is sorted into B, D, E, F, G, then we could build an array containing 2, 3, 0, 4, 1. Element 0 of the new array, B, used to be at position 2 in the old array; Element 1, D, used to be at position 3, and so forth. And what better way to implement this using a category method on NSArray! (See Listing 9.)

Listing 9: SortingDelegate.m

@interface NSArray (findPositions)
(NSArray *)
      findPositionsInUnsortedArray:(NSArray *)fromArray;
@end
@implementation NSArray (findPositions)
findPositionsInUnsortedArray:
Build an array of NSNumbers, representing the position each item of an array used to be before it was sorted.
(NSArray *)
      findPositionsInUnsortedArray:(NSArray *)fromArray
{
   NSMutableArray *result
      = [NSMutableArray arrayWithCapacity:[self count]];
   NSEnumerator *theEnum = [self objectEnumerator];
   id object;
   while (nil != (object = [theEnum nextObject]) )
   {
      int indexInOld = [fromArray indexOfObject:object];
      [result addObject:[NSNumber numberWithInt:indexInOld]];
   }
   return result;
}
@end

Now it's time for the actual animation, defined in SortingDelegate.m. (Note that this class contains three relevant instance variables: mTimer, a reference to the NSTimer used for sorting; mOldPositionsArray, the lookup array described above; and mAnimationPosition, a floating point value representing how far along in the animation we are.) Let's dive right into Listing 10 for the methods we need.

Listing 10: SortingDelegate.m

// The "frame rate" for animating the table sort.
const float kFrameRate = 1.0/30;
// Duration of the animation, probably shouldn't be more than a second.
const float kAnimationTime = 0.75;    
setOldPositionsArray:
Set the array that holds the old positions of the items before they were sorted.
- (void) setOldPositionsArray:(NSArray *)inNewValue
{
   [inNewValue retain];
   [mOldPositionsArray release];
   mOldPositionsArray = inNewValue;
}

stopAnimating
Remove any animating NSTimer.  Also clears mAnimationPosition to indicate that we are no longer 
animating.

- (void) stopAnimating
{
   [mTimer invalidate];
   mTimer = nil;
   mAnimationPosition = 0;
}
setTimer:
Set the animation timer, replacing any existing one.
- (void) setTimer:(NSTimer *)inNewValue
{
   [inNewValue retain];
   [mTimer release];
   [self stopAnimating];
   mTimer = inNewValue;
}

tableView:rowsInRect:defaultRange:
Invoked by AnimatedTableView.  If we're currently animating, we just return the entire range of all 
rows, so that all rows get drawn no matter where they are.  (They will still be clipped properly, but 
if we don't override this, then some rows may not be drawn.)  There are probably ways to make this 
more efficient, calculating which rows need to be displayed, but as long as our table isn't too big, 
this should be fine.

- (NSRange)tableView:(AnimatedTableView *)inTableView
      rowsInRect:(NSRect)inRect
      defaultRange:(NSRange)inDefaultRange
{
   if (mAnimationPosition > 0)      // are we currently animating?
   {
      return NSMakeRange(0, [oData count]);   // just return all rows
   }
   else
   {
      return inDefaultRange;
   }
}

tableView: rectOfRow: defaultRect:
Invoked by AnimatedTableView.  Returns a rectangle for the given row in the table.  If we are 
animating, we calculate a rectangle to be be some percentage of the way between the row's old 
rectangle and its new rectangle.

(NSRect)tableView:(AnimatedTableView *)inTableView
      rectOfRow:(int)inRowIndex
      defaultRect:(NSRect)inDefaultRect
{
   if (mAnimationPosition > 0)   // are we currently animating?
   {
      // Get the rectangles of where the row originally was, and where it will end up.
      int oldPosition = [[mOldPositionsArray
         objectAtIndex:inRowIndex] intValue];
      NSRect oldR
         = [inTableView unanimatedRectOfRow:oldPosition];
      NSRect newR
         = [inTableView unanimatedRectOfRow:inRowIndex];
      // t will be our fraction between 0 and 1 of how far along the row should be.
      float t = mAnimationPosition;      // linear position based on time
      // Calculate a rectangle between the original and the final rectangles.
      NSRect newRect = NSMakeRect(
         NSMinX(oldR) + (t * (NSMinX(newR) - NSMinX(oldR))),
         NSMinY(oldR) + (t * (NSMinY(newR) - NSMinY(oldR))),
         NSWidth(newR), NSHeight(newR) );
      return newRect;
   }
   else
   {
      return inDefaultRect;   // not animating, just return the standard value.
   }
}

animateStep:
Invoked by the NSTimer multiple times to handle the animation.  Calculate the position we are based on
the current time, and then either force a display of the table, or stop the animation.

- (void) animateStep:(NSTimer *)inTimer
{
   NSDate *start = [inTimer userInfo];
   NSTimeInterval elapsed
      = fabs([start timeIntervalSinceNow]);
   mAnimationPosition = MIN(1.0 , elapsed / kAnimationTime);
   // Done yet?  Allow a bit of "servo jitter" to allow for floating-point messiness
   if (fabs(mAnimationPosition - 1.0) <= 0.01)  
   {
      [self stopAnimating];
      [oTable display];      // force a display of the table in its normal state
   }
   else
   {
      [oTable display];      // force a display of the table in its new state.
   }
}

tableView: animateSortFromArray: toArray:
Initiate an animated sort.  We pass in the old array before the sort, and the new sorted array.  It 
kicks off a timer to start the animation with the current date/time passed in as user info so it can
keep track of how far along the animation it is.

(void)tableView:(NSTableView *)inTableView
      animateSortFromArray:(NSArray *)fromArray
      toArray:(NSArray *)toArray
{
   NSTimer *timer;
   NSRange visibleRows;
   [self stopAnimating];  // Stop any existing sort animation
   [self setOldPositionsArray:
      [toArray findPositionsInUnsortedArray:fromArray]];
   timer =
      [NSTimer scheduledTimerWithTimeInterval:kFrameRate
         target:self
         selector:@selector(animateStep:)
         userInfo: [NSDate date]   // store the starting date as user info
         repeats:YES];
   [self setTimer:timer];   // store the timer so we can stop it when done.
}

sortData
Actually sort the data.  This is an extension of the sortData method described in part 2 of this 
series.  For this sample, if the application delegate method animateSorts returns YES, then kick off 
the sort.  Otherwise, sort without animation.  In either case, the table selection is preserved across
the sort so that any rows selected before the sort will be properly selected in their new positions 
after the sort.

- (void) sortData
{
   SortContext ctxt={ mSortingKey, mSortDescending };
   NSSet *oldSelection
      = [self saveSelectionFromTable:oTable];
   if ([[NSApp delegate] animateSorts])
   {
      NSArray *originalOrderArray = [oData copy];
      [oData sortUsingFunction:ORDER_BY_CONTEXT
            context:&ctxt];
      [self restoreSelection:oldSelection toTable:oTable
         refresh:NO];
      [self tableView:oTable
         animateSortFromArray:originalOrderArray
         toArray:oData];
      // When done, the data should be displayed correctly.
   }
   else
   {
      [oData sortUsingFunction:ORDER_BY_CONTEXT
         context:&ctxt];
      [oTable reloadData];
      [self restoreSelection:oldSelection toTable:oTable
         refresh:YES];
   }
}

That's all that's needed for a basic animated sort. You may find that you need to implement your own animation code differently to deal with your application's specific needs, but this should give you the basic idea. You are warned: This is not going to work very well on very large tables, since the position of every row in the table is being calculated repeatedly, regardless of whether it is visible or not. But for reasonable sizes, the animation can add a nice extra dimension to your interface.

There are a couple of visual improvements that can be made to the animation, however. The first improvement, demonstrated in the AnimatedSlider example, is making the movement seem more natural by letting the movement accelerate rather than abruptly changing from standing still to moving full-speed. Currently, the Y value (vertical position) changes linearly with respect to time, as depicted mathematically in Figure 7. It is possible to adjust the position by inserting a sine function into the calculations to ease the transition. Listing 11 shows the function to convert a value between 0.0 and 1.0 into its eased equivalent, depicted in Figure 8. All we need to do is insert t = easeFunction(t); into tableView: rectOfRow: defaultRect:.


Figure 7: Linear Movement


Figure 8: A Sine wave eases the transition.

Listing 11: SortingDelegate.m

easeFunction
This function implements a sinusoidal ease-in/ease-out for t = 0 to 1.0.  T is scaled to represent 
the interval of one full period of the sine function, and transposed to lie above the X axis.

float easeFunction(float t)
{
   return (sin((t * M_PI) - M_PI_2) + 1.0 ) / 2.0;
}

The other visual improvement that can be made is only apparent when you animate the reversal of a sort, changing from ascending to descending sort order, or vice-versa. Since the first items in the table exchange themselves symmetrically with the last items, they all end up bunching together in the center of the table halfway through the process. (See Figure 9.) It doesn't look as cool as it could. How can we make the movement a little bit less symmetrical so that everything doesn't crowd the middle of the table?


Figure 9: Reversing sort order bunches up in the middle.

If we could vary the "speed" of each row so that the rows at the top of the table move at a different rate than the rows at the bottom of the table, this would prevent the traffic jam in the center of the table. One way to do this is to use a "curve" function to move rows, with a different parameter used for each row. A reasonable function is shown in Figure 9. All we need to do is vary the power (n). Rows at one end of the table use a value slightly less than one; the middle row uses a power of one (a straight line); Rows at the other end of the table use a value slightly greater than one. We get a different curve for each row, and each row moves at a different rate.


Figure 10: Power function with varying values of n

The effect of this is very nice; it looks as if the rows are doing a "back flip" as they swap their positions. Figure 11 might give you a sense of how it looks, but it's more fun to see it in the program itself.


Figure 11: Adding a "curve" makes a reversal look nicer.

If you happen to be viewing only a piece of a table--say, the top half or the bottom half--and the rest is scrolled out of view, It can look pretty strange for the rows to move out of view and then move back into view a moment later. So we can perform an adjustment to the swapping behavior so that the curve is "centered" on the half of the table that you are looking at. So if you are viewing the top half of a table, the curve will be flipped compared to viewing the bottom half of a table.

Listing 12 shows the function you need to convert the "t" value into its curved counterpart.

Listing 12: SortingDelegate.m

// How "curvy" the movement should be. 0.2 or 0.3 is a nice value; 0.5 is funky!
const float kCurve = 0.3; 
curveFunction

This function is used to de-center t (from 0 to 1.0) by a power p (a reasonable range is 0.8 to 1.2).
It will make the animated reordering a little more interesting to watch.

float curveFunction (float t, float p)
{
   return pow( 1 - pow((1-t),p) , 1/p );
}

You will need to determine if the user is viewing only the top half of the table before you kick off the timer in tableView: animateSortFromArray: toArray:. This code just checks if the topmost visible row is less than 0.6 times the number of rows.

visibleRows = [inTableView rowsInRect:[inTableView visibleRect]];
mViewingTop
   = NSMaxRange(visibleRows) < 0.6 * [fromArray count];

You need to insert the following lines into tableView: rectOfRow: defaultRect:.

float rowPos = ((float) inRowIndex / [oData count]);
// fractional position of row in table
float rowPosAdjusted
   = mViewingTop ? (1.0 - rowPos) : rowPos;
float p = rowPosAdjusted * (kCurve*2.0) + 1.0 - kCurve;
// e.g. 0 -> 0.8; n/2 -> 1.0; n -> 1.2
// (p actually could range from 1.0+kCurve to its reciprocal to be truly symmetrical)
t = curveFunction(t, p);

To bring this all together, we really want to have both the curve and the sinusoidal movement. So when we invoke curveFunction and then easeFunction together, we get movement as depicted in Figure 12. What more could you ask for?


Figure 12: Combining the power and the sine functions

Conclusion

Whew! That has been a long trip through Table Land. Hopefully you feel armed with just about everything you need to make your application really shine. Quite a bit of code needs to be added to a simple NSTableView to get all the bells and whistles in place, but it's still possible to keep a clean separation between your views and your controllers. Accessing your data from the controllers is still quite simple thanks to Cocoa's Foundation Kit.

If you want to take advantage of many of these features, you will find yourself needing to implement several data source and delegate methods, and probably build a subclass for your table view that is an amalgamation of the subclasses presented here. And example subclass might be DeletableStripedTypeaheadAnimatedSortingTableView, but you are of course welcome to invent a shorter class name. (Never use a long word when you can use a diminutive one!)

Further Reference

Many of these techniques are discussed on the two dominant Cocoa discussion lists, at Apple (http://lists.apple.com/mailman/listinfo/cocoa-dev) and OmniGroup (http://www.omnigroup.com/developer/mailinglists/macosx-dev/). A great way to search the archives of these lists is to use http://cocoa.mamasam.com. Search the archives and post questions to these lists if you are attempting to do something with NSTableView that isn't discussed here.

The Web holds other resources related to NSTableView as well. NSTableView is discussed on cocoadev.com; Stone Design (http://www.stone.com/dev/) has a subclass available online; OmniGroup (http://www.omnigroup.com/developer/sourcecode/) has a number of extensions in their OmniAppKit code; Stephane Sudre has some good NSTableView articles (in French) at http://www.mosx.net/dev/.


Dan Wood, the son of an organic cropduster pilot and a neurosurgeon, grew up in Amish Pennsylvania, back in the roaring twenties. As a child, he watched Lost in Space and Powerpuff Girls on TV. After graduating with a degree in Chocolatology from the University of Hershey, Dan joined the Peace Corps, teaching American Sign Language to underprivileged dolphins. He is currently a road crew foreman for the California Department of Transportation (CalTrans), and has written a successful application in Cocoa called Watson. Dan thanks Chuck Pisula at Apple for his technical help with this series, and acknowledges online code fragments from John C. Randolph, Stephane Sudre, Ondra Cada, Vince DeMarco, Harry Emmanuel, and others. You can reach him at dwood@karelia.com.

 

Community Search:
MacTech Search:

Software Updates via MacUpdate

Dropbox 193.4.5594 - Cloud backup and sy...
Dropbox is a file hosting service that provides cloud storage, file synchronization, personal cloud, and client software. It is a modern workspace that allows you to get to all of your files, manage... Read more
Google Chrome 122.0.6261.57 - Modern and...
Google Chrome is a Web browser by Google, created to be a modern platform for Web pages and applications. It utilizes very fast loading of Web pages and has a V8 engine, which is a custom built... Read more
Skype 8.113.0.210 - Voice-over-internet...
Skype is a telecommunications app that provides HD video calls, instant messaging, calling to any phone number or landline, and Skype for Business for productive cooperation on the projects. This... Read more
Tor Browser 13.0.10 - Anonymize Web brow...
Using Tor Browser you can protect yourself against tracking, surveillance, and censorship. Tor was originally designed, implemented, and deployed as a third-generation onion-routing project of the U.... Read more
Deeper 3.0.4 - Enable hidden features in...
Deeper is a personalization utility for macOS which allows you to enable and disable the hidden functions of the Finder, Dock, QuickTime, Safari, iTunes, login window, Spotlight, and many of Apple's... Read more
OnyX 4.5.5 - Maintenance and optimizatio...
OnyX is a multifunction utility that you can use to verify the startup disk and the structure of its system files, to run miscellaneous maintenance and cleaning tasks, to configure parameters in the... Read more
Hopper Disassembler 5.14.1 - Binary disa...
Hopper Disassembler is a binary disassembler, decompiler, and debugger for 32- and 64-bit executables. It will let you disassemble any binary you want, and provide you all the information about its... Read more
WhatsApp 24.3.78 - Desktop client for Wh...
WhatsApp is the desktop client for WhatsApp Messenger, a cross-platform mobile messaging app which allows you to exchange messages without having to pay for SMS. WhatsApp Messenger is available for... Read more
War Thunder 2.33.0.135 - Multiplayer war...
In War Thunder, aircraft, attack helicopters, ground forces and naval ships collaborate in realistic competitive battles. You can choose from over 1,500 vehicles and an extensive variety of combat... Read more
Iridient Developer 4.2 - Powerful image-...
Iridient Developer (was RAW Developer) is a powerful image-conversion application designed specifically for OS X. Iridient Developer gives advanced photographers total control over every aspect of... Read more

Latest Forum Discussions

See All

SwitchArcade Round-Up: Reviews Featuring...
Hello gentle readers, and welcome to the SwitchArcade Round-Up for March 4th, 2024. Another week is upon us, and while the Switch just blew out seven candles on its cake, things continue to move along around here at a rapid speed. We’ve got a... | Read more »
Gorgeous Tactical Puzzle Game ‘Howl’ is...
Following its release on PC and Nintendo Switch this past November, and it’s arrival on Xbox and PlayStation back in January, publisher Astragon Entertainment and developer Mi’pu’mi Games are now bringing their super stylish tactical puzzler Howl to... | Read more »
Best iPhone Game Updates: ‘Shoot the Moo...
Hello everyone, and welcome to the week! It’s time once again for our look back at the noteworthy updates of the last seven days. It feels like a bit of a dry spell this week, at least in terms of really interesting updates. I mean, I found some... | Read more »
Celebrate Phobies spooky second annivers...
Get ready to have that classic song stuck in your head, as Phobies celebrates its second anniversary with the release of its latest update; Birthday Bash, Monster Mash. Starting March 5th and lasting for four weeks, it will be a month of... | Read more »
‘Dissidia Final Fantasy Opera Omnia’ Sto...
Square Enix finally shut down Dissidia Final Fantasy Opera Omnia (Free) on iOS and Android last week following the end of service announcement back in November last year. Following the game shutting down, Square Enix | Read more »
‘Monster Hunter Now’ Is Celebrating the...
Niantic and Capcom have begun celebrating the 20th anniversary of Capcom’s best franchise from today inside Monster Hunter Now (Free) on iOS and Android for a limited time. | Read more »
New ‘Warframe Mobile’ Update Adds 60fps...
Warframe Mobile (Free) launched worldwide on iOS just under two weeks ago. I’ve been playing it for review across multiple iOS devices, but have also been picking it up on Steam Deck and Switch to compare. Right from launch, I was impressed with... | Read more »
Passionate About Fidget Toys – The Touch...
In this week’s episode of The TouchArcade Show we kick things off with some passionate discussion about… fidget toys? For some reason? We quickly change gears to talk about the card-based rogulike Balatro, which we’ve both been playing and enjoying... | Read more »
TouchArcade Game of the Week: ‘Flying Ta...
For me Hexage is one of those developers that harkens back to the early days of the App Store and really the beginnings of iPhone gaming. I have spent many collective hours playing the likes of Totemo, Radiant, Radiant Defense, EVAC, Reaper… the... | Read more »
SwitchArcade Round-Up: ‘Ufouria 2: The S...
Hello gentle readers, and welcome to the SwitchArcade Round-Up for March 1st, 2024. In today’s article, we’re looking at the remaining releases of the week. There are a few really good ones today, but the bin bunch certainly isn’t going home hungry... | Read more »

Price Scanner via MacPrices.net

Deal Alert! B&H is now selling 13-inch M2...
B&H Photo has 13″ MacBook Airs with M2 CPUs and 256GB of storage in stock and on sale for $100 off Apple’s new MSRP, now only $899. Free 1-2 day delivery is available to most US addresses. Their... Read more
At $999, Apple’s 13-inch M2 MacBook Air is th...
With today’s introduction of the new 13-inch M3 MacBook Air for $1099, Apple dropped prices on the previous-generation 13-inch M2 MacBook Air to $999. At the same time, Apple discontinued the 13-inch... Read more
Apple discontinues 15-inch M2 MacBook Airs, d...
With today’s introduction of new M3-powered 15″ MacBook Airs, Apple has dropped prices on clearance, Certified Refurbished, 15″ M2 MacBook Airs to a new low of $1019. These are the cheapest 15″... Read more
Price Drop! 13-inch M2 MacBook Airs at Apple...
Apple has dropped prices on Certified Refurbished 13″ M2 MacBook Airs to a new low of $849. These are the cheapest M2-powered MacBooks for sale at Apple. Apple’s one-year warranty is included,... Read more
Apple finally discontinues the 13-inch M1 Mac...
With the introduction of M3-powered 13″ MacBook Airs today, Apple has dropped prices on clearance 13″ M1 MacBook Airs, Certified Refurbished, to $759 for 8-Core CPU/7-Core GPU/256GB models and $929... Read more
Updated Apple iPad Price Trackers
Our Apple award-winning iPad Price Trackers are the best place to find the latest information on iPad sales and deals. We track prices from 20+ Apple retailers, including Apple, Amazon, Best Buy,... Read more
Updated Apple MacBook Price Trackers
Our Apple award-winning MacBook Price Trackers are continually updated with the latest information on prices, bundles, and availability for 16″, 14″, and (recently-discontinued) 13″ MacBook Pros... Read more
Mac Studios with Apple M2 Max and M2 Ultra CP...
B&H Photo has the standard-configuration Mac Studio model with Apple’s M2 Ultra CPU in stock today and on sale for $300 off MSRP, now $3699 (24-Core CPU and 64GB RAM/1TB SSD). B&H Photo has... Read more
Extended: Switch to Verizon and get the Apple...
Verizon has the iPhone 15 on sale for $0 per month when you add a new line if service. Discount is applied to your account monthly over a 36 month term and is valid for the 128GB model. For the first... Read more
Select 16-inch M3 Pro and M3 Max MacBook Pros...
B&H Photo has select 16-inch M3 Pro and M3 Max MacBook Pros on sale for $250 off MSRP. Their prices are the lowest currently available for these configurations. Free 1-2 day shipping is available... Read more

Jobs Board

Teller Part Time *Apple* Valley MN *Apple*...
…is not eligible for Visa sponsorship **Posting Location:** + 15574 Pilot Knod Road Apple Valley, MN 55124 @RWF22 **Posting End Date:** Job posting may come down Read more
*Apple* End User Support Specialist - North...
…that they are performed. + Responsible for support of all College owned Apple computers, mobile ios devices, and peripherals, and for diagnosing and resolving Read more
Omnichannel Associate - *Apple* Blossom Mal...
Omnichannel Associate - Apple Blossom Mall Location:Winchester, VA, United States (https://jobs.jcp.com/jobs/location/191170/winchester-va-united-states) - Apple Read more
Teller Part Time *Apple* Valley MN *Apple*...
…is not eligible for Visa sponsorship **Posting Location:** + 15574 Pilot Knod Road Apple Valley, MN 55124 @RWF22 **Posting End Date:** Job posting may come down Read more
Nurse Anesthetist - *Apple* Hill Surgery Ce...
Nurse Anesthetist - Apple Hill Surgery Center WellSpan Medical Group, York, PA | Advanced Practice Providers | Certified Registered Nurse Anesthetists | FTE: 1 | Read more
All contents are Copyright 1984-2011 by Xplain Corporation. All rights reserved. Theme designed by Icreon.