TweetFollow Us on Twitter

Table Techniques Taught Tastefully (part 2)

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

Table Techniques Taught Tastefully (part 2)

Using NSTableView for Real-World Applications

by Dan Wood

Introduction

Part one of this series of articles introduced the wonderful NSTableView class in Cocoa, going over the basics--displaying textual data, adding and deleting rows, and adjusting the columns of a table. With those techniques, you should be able to write code with some pretty useful displays. But there's so much more you can do with NSTableView, and this part of the series goes into some intermediate techniques that will help you feel like a "Table Jockey."

In this article, we'll cover a little bit more in the topic of deleting rows that we introduced in part 1. Then we'll introduce alphabetic type-ahead, which allows the user to start typing the first few letters of a row's relevant text to get the selection established. Next, we'll cover several aspects of sorting and reordering a table's rows, including sorting by clicking on a column header and drag-and-drop rearrangement. Finally, we'll cover exporting of data from a table via drag-and-drop and via the clipboard.

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 part one, in case you missed it the first time around.

Deleting Rows (The Sequel)

In part 1, we discussed how to delete the selected rows in a table, responding to a button or the Clear menu. How about deleting the selected rows when the delete key is pressed? This requires us to create a new subclass of NSTableView and override the keyDown: method to pass along the same deleteSelectedRowsInTableView: method to the data source. To create the user interface for this, we create a table but then set its custom class to our subclass, DeletableTableView, using the class inspector in Interface Builder.

Listing 1: DeletableTableView.m

keyDown:
Trap out delete keys and pass along the method to delete the selected rows.  Otherwise, just let the 
superclass handle the event.  The table must be "first responder" for this to be processed.

- (void)keyDown:(NSEvent *)theEvent
{
   NSString *keyString
      = [theEvent charactersIgnoringModifiers];
   unichar   keyChar = [keyString characterAtIndex:0];
   switch (keyChar)
   {
      case 0177: // Delete Key
      case NSDeleteFunctionKey:
      case NSDeleteCharFunctionKey:
      if ( [self selectedRow] >= 0 && [[self dataSource]
         respondsToSelector:
            @selector(deleteSelectedRowsInTableView:)])
         {
            [[self dataSource]
               deleteSelectedRowsInTableView:self];
         }
         break;
      default:
         [super keyDown:theEvent];
   }
}

A feature not implemented above is undoability; we'll leave this as an exercise for the reader.

Alphabetic Type-ahead for table selection

When lists are long, keyboard navigation -- the ability to start typing on the keyboard to navigate to the desired elements in a list -- is quite convenient. This functionality isn't built into NSTableView, but it is possible by creating a subclass that pays attention to the keystrokes when the table is active.

The algorithm is fairly straightforward. When the user presses a key, the key is appended to a string, and that string is used to try to select the closest row in the table. As more keys are pressed, the string builds up, and the selection becomes more refined. If a time interval of a couple of seconds passes with no keystrokes, the typing buffer is cleared, so the user can make a fresh selection.

To handle the keystrokes, we override keyDown: to merely send the interpretKeyEvents: message to self. According to the NSResponder documentation, this is how to intercept keystrokes; it will cause the insertText: method to be invoked, which we handle below.

Listing 2: TypeaheadTableView.m

keyDown:
Indicate that the keystrokes from the event should be interpreted.  The table needs to be "first 
responder" for this to be effective.

- (void)keyDown:(NSEvent*)inEvent
{
   [self interpretKeyEvents:
      [NSArray arrayWithObject:inEvent]];
}

We then implement insertText: to add the typed characters to our buffer of keystrokes, and go select the appropriate row based on what has been typed so far. Thinking in terms of Model-View-Controller partitioning, we send a message from the view (the NSTableView subclass) to the Controller (the table's delegate) to perform the selection, by invoking typeAheadString: inTableView: on the delegate. We will provide a sample implementation later.

We also queue up a message to send in the near future to clear out the keystroke buffer. But what is the magic number for the time delay before the buffer is cleared? You could hard-wire a constant, but that might not satisfy all users. Instead, you can base the time delay on the "Delay Until Repeat" setting in the System Preferences. The chapter from Inside Macintosh (Remember Inside Macintosh?) on the List Manager recommended two times that threshold value, but no more than two seconds. Whereas Carbon programs get the delay from the low memory global function LMGetKeyThresh(), this value is available via the defaults mechanism via the "InitialKeyRepeat" key.

Listing 3: TypeaheadTableView.m

insertText:
Process the text by adding it to the typeahead buffer and selecting the appropriate row.

- (void)insertText:(id)inString
{
   // Make sure delegate will handle type-ahead message
   if ([[self delegate] respondsToSelector:
      @selector(typeAheadString:inTableView:)])
   {
      // We clear it out after two times the key repeat rate "InitialKeyRepeat" user
      // default (converted from sixtieths of a second to seconds), but no more than two
      // seconds. This behavior is determined based on Inside Macintosh documentation
      // on the List Manager.
      NSUserDefaults *defaults
         = [NSUserDefaults standardUserDefaults];
      int keyThreshTicks
         = [defaults integerForKey:@"InitialKeyRepeat"];
      NSTimeInterval clearDelay
         = MIN(2.0/60.0*keyThreshTicks, 2.0);
      
      if ( nil == mStringToFind )
      {
         mStringToFind = [[NSMutableString alloc] init];
         // lazily allocate the mutable string if needed.
      }
      [mStringToFind appendString:inString];
   
      // Cancel any previously queued future invocations
      [NSObject cancelPreviousPerformRequestsWithTarget:self
         selector:@selector(clearAccumulatingTypeahead)
         object:nil];
   
      // queue an invocation of clearAccumulatingTypeahead for the near future.
      [self performSelector:
         @selector(clearAccumulatingTypeahead)
         withObject:nil afterDelay:clearDelay];
   
      // Let the table's delegate do something with the string.
      // We use stringWithString to make an autoreleased copy so for its use,
      // since we may clear out the original string below before it can be used.
      [[self delegate] typeAheadString:
         [NSString stringWithString:mStringToFind]
         inTableView:self];
   }
}

clearAccumulatingTypeahead:
Clear out the string so our next typeahead will start from scratch.

- (void) clearAccumulatingTypeahead
{
   [mStringToFind setString:@""];   // clear out the queued string to find
}
@end

All that remains now is the nitty-gritty of finding the appropriate row to select based on the string the user has typed so far. Usually, this will mean finding a row that is a close match, not necessarily an exact match, to the search string. In a list of U.S. States, for example, "M" would select Maine; "MI" would select Michigan; "MIS" and "MISS" would select Mississippi; "MISSO" would select Missouri.

In our TableTester code, we select based upon whichever is the currently sorted column. We search linearly (ideally, it should be a binary search if the data set is large) for the row containing the dictionary with the value of the current sorting key that is a best match, using a case insensitive comparison. We use the last row if no match was found, e.g. if the user typed "Z" in a table with no "Z" entries. That row is selected and the table view scrolled to make that selection visible.

Listing 4: SortingDelegate.m

typeAheadString:inTableView:
Actually select the appropriate row based upon the string that has been typed.
(void) typeAheadString:(NSString *)inString
      inTableView:(NSTableView *)inTableView
{
   NSTableColumn *col = [inTableView highlightedTableColumn];
   if (nil != col)
   {
      NSString *key = [col identifier];
      int i;
      for ( i = 0 ; i < [oData count] ; i++ )
      {
         NSDictionary *rowDict = [oData objectAtIndex:i];
         NSString *compareTo = [rowDict objectForKey:key];
         NSComparisonResult order
            = [inString caseInsensitiveCompare:compareTo];
         if (order != NSOrderedDescending)
         {
            break;
         }
      }
      // Make sure we're not overflowing the row count.
      if (i >= [oData count])
      {
         i = [oData count] - 1;
      }
      // Now select row i -- either the one we found, or the last row if not found.
      [inTableView selectRow:i byExtendingSelection:NO];
      [inTableView scrollRowToVisible:i];
   }
}

One final note on typeahead: CodeWarrior has a nice feature of showing you the typeahead buffer so you can see what you have typed. In the TableTester program, but not listed here, are some minor additions to the TypeaheadTableView class that will display the current typeahead buffer if you have hooked up a text field to the appropriate outlet.

Displaying More Than Just Strings

Usually, a table cell displays a piece of plain text, just an NSString returned in tableView: objectValueForTableColumn: row: But this method is designed to return "id", meaning any object. Without any extra effort, you can return an NSAttributedString or NSNumber instead of an NSString. And with just a little bit of setup, you can display other kinds of cells such as images and buttons.

First, you need to set the table's columns to use a different cell type. In a convenient initialization method, such as your controller's awakeFromNib method, just allocate a new cell object, such as an NSButtonCell or an NSImageCell. You may need to adjust attributes of your cell (for instance, setting a button cell's type and image position). Then, find the NSTableColumn object corresponding to the column to affect, and replace the default text cell with your newly allocated instance, using setDataCell:. (Alternately, you can create a disembodied control in your nib file that represents the prototype cell and hook it all up in IB!)

In our TableTester program, we allocate a button cell and an image cell. The button cell is set up to be a checkbox (NSSwitchButton); the image cell doesn't need any adjustment. Finally, the button and image cells are hooked up to the columns.

Listing 5: CellDelegate.m

awakeFromNib
Set the table columns to use button and image cells rather than the default text cells.
- (void)awakeFromNib
{
   // Allocate the cells
   NSButtonCell *buttonCell
      = [[[NSButtonCell alloc] init] autorelease];
   NSImageCell  *imageCell
      = [[[NSImageCell alloc] init] autorelease];
   // Find the columns
   NSTableColumn *buttonColumn
      = [oTable tableColumnWithIdentifier:@"checked"];
   NSTableColumn *imageColumn
      = [oTable tableColumnWithIdentifier:@"icon"];
   // Set up the button cell and install into the column
   [buttonCell setButtonType:NSSwitchButton];
   [buttonCell setImagePosition:NSImageOnly];
   [buttonCell setTitle:@""];
   [buttonColumn setDataCell:buttonCell];
   // Install the image cell
   [imageColumn setDataCell:imageCell];
}

To display these non-textual cells, you need to provide appropriate data in tableView: objectValueForTableColumn: row: and perhaps also make per-cell adjustments in tableView: willDisplayCell: forTableColumn: row:. In TableTester, we implement the data source and provide an appropriate object based on the given column. For the "icon" column, we provide an NSImage object based on the name in the data table. (This relies on their being an image available for the name; we have a few sample images created with Stick Software's "Aquatint" program bundled with TableTester.) For the "checked" column, we provide an NSNumber object corresponding to the state of the checkbox. For the "name" column, we build up an attributed string to display in the default string cell. (And in the source code for TableTester, not shown here, we also have a cell for a "relevance" control, which uses a custom cell class.)

Listing 6: CellSource.m

tableView: objectValueForTableColumn: row:
Provide the data for a given cell. We provide different data depending on which column is passed in 
as a parameter.

(id)tableView:(NSTableView *)inTableView
      objectValueForTableColumn:(NSTableColumn *)inTableColumn
      row:(int)inRowIndex
{
   id result = nil;
   // Start out with the string that the SimpleSource returns from the dictionary
   id stringValue = [super tableView:inTableView
      objectValueForTableColumn:inTableColumn row:inRowIndex];
   // Now, handle special cases depending on the table column identifier.
   NSString *identifier = [inTableColumn identifier];
   if ([identifier isEqualToString:@"icon"])
   {
      if (nil != stringValue)
      {
         result = [NSImage imageNamed:stringValue];
      }
   }
   else if ([identifier isEqualToString:@"checked"])
   {
      // Return NSNumber of 1 or 0.  The line below builds the NSNumber
      // from from the string or NSNumber currently in the dictionary.
      result = [NSNumber numberWithInt:[stringValue intValue]];
   }
   else if ([identifier isEqualToString:@"name"])
   {
      // Really, these dictionaries should be created once and cached....
      NSDictionary *plainAttr
         = [NSDictionary dictionaryWithObject:
           [NSFont systemFontOfSize:[NSFont systemFontSize]]
            forKey:NSFontAttributeName];
      NSDictionary *boldAttr
         = [NSDictionary dictionaryWithObject:
        [NSFont boldSystemFontOfSize:[NSFont systemFontSize]]
            forKey:NSFontAttributeName];
      // Return an attributed string, with the first character boldface.
      result = [[[NSMutableAttributedString alloc] init]
                     autorelease];
      [result appendAttributedString:
         [[[NSAttributedString alloc]
            initWithString:[stringValue substringToIndex:1]
                  attributes:boldAttr] autorelease]];
      [result appendAttributedString:
         [[[NSAttributedString alloc]
            initWithString:[stringValue substringFromIndex:1]
                  attributes:plainAttr] autorelease]];
   }
   else
   {
      // If not the special cases, we've gotten the string result from the superclass.
      result = stringValue;
   }
   return result;
}

Sorting Tables

Displaying a table with its data sorted can enhance readability greatly. What's even more useful is if you give the user the ability to decide which column to sort a table by, and which direction to sort in. Astute readers will remember an article by Andrew Stone that covered sorting of tables in the August 2002 issue of MacTech; this section takes a slightly different approach (and offers Mac OS X 10.2 compatibility too).

To support column sorting, you need to implement tableView: didClickTableColumn: in your delegate. Your code would sort the data and redisplay it, highlighting the clicked column to provide feedback to the user as to what column the data is sorted by. If the user clicks on the sorted column a second time, the direction of the sort should change, and a small graphic in the column should indicate whether the table is sorted ascending or descending. For this to happen, you need to implement a bit of code.

First, let's deal with the actual sorting. NSArray and NSMutableArray provide a number of methods for sorting. The most convenient methods you can use are the methods sortUsingFunction: (if you have an NSMutableArray) or sortedArrayUsingFunction: (if you have an immutable NSArray). With these methods, you provide a C function that knows how to compare two objects and is given an arbitrary context for performing the sort. In our sample case, we use a simple structure specifying the key to sort upon, and the direction to sort in. Our function, ORDER_BY_CONTEXT, sets up the order for the comparison based upon the sorting direction, and then invokes either caseInsensitiveCompare: or compare: between the two objects. The former is useful for strings; the latter is useful for numbers or dates. Our method sortData invokes sortUsingFunction: on the data array using the current sorting key and direction, then causes the table to redisplay itself with the reloadData method.

Listing 7: SortingDelegate.m

ORDER_BY_CONTEXT
C function to return the sort ordering for the given two objects and the given context.
typedef struct { NSString *key; BOOL descending; }
   SortContext;
int ORDER_BY_CONTEXT (id left, id right, void *ctxt)
{
   SortContext *context = (SortContext*)ctxt;
   int order = 0;
   id key = context->key;
   if (0 != key)
   {
      id first,second;   // the actual objects to compare
      if (context->descending)
      {
         first  = [right objectForKey:key];
         second = [left  objectForKey:key];
      }
      else
      {
         first  = [left  objectForKey:key];
         second = [right objectForKey:key];
      }
      if ([first respondsToSelector:
            @selector(caseInsensitiveCompare:)])
      {
         order = [first caseInsensitiveCompare:second];
      }
      else   // sort numbers or dates
      {
         order = [(NSNumber *)first compare:second];
      }
   }
   return order;
}

sortData
Sort the data array based on the current sorting key (column) and sorting direction.

- (void) sortData
{
   SortContext ctxt={ mSortingKey, mSortDescending };
   [oData sortUsingFunction:ORDER_BY_CONTEXT context:&ctxt];
   [oTable reloadData];
}

Since we want to indicate the direction of sorting, we display a little triangle in the table column using the -[NSTableView setIndicatorImage: inTableColumn:] method. In Mac OS X 10.2, Apple provides official access to these images. But if you want your sorting-table application to work under 10.1, you have a couple of options. One is to include the images in your application's executable; another is to carefully make use of a private (undocumented) method in NSTableView. Using private methods is something you have to be very careful about, because Apple doesn't support them, and they could go away at any time. To make sure that the sample code will work on both 10.1 and 10.2, we test for compatibility using respondsToSelector: to fail gracefully, then add class methods to NSTableView called ascendingSortIndicator and descendingSortIndicator to safely return the images we'll need.

Listing 8: NSTableView+util.m

ascendingSortIndicator
Safely return the ascending sort triangle image; works on both 10.1 and 10.2.
+ (NSImage *) ascendingSortIndicator
{
   NSImage *result
      = [NSImage imageNamed:@"NSAscendingSortIndicator"];
   if (nil == result
      && [[NSTableView class] respondsToSelector:
         @selector(_defaultTableHeaderSortImage)])
   {
      result = [NSTableView _defaultTableHeaderSortImage];
   }
   return result;
}

descendingSortIndicator
Safely return the descending sort triangle image; works on both 10.1 and 10.2.

+ (NSImage *) descendingSortIndicator
{
   NSImage *result
      = [NSImage imageNamed:@"NSDescendingSortIndicator"];
   if (nil == result
      && [[NSTableView class] respondsToSelector:
         @selector(_defaultTableHeaderReverseSortImage)])
   {
      result
         = [NSTableView _defaultTableHeaderReverseSortImage];
   }
   return result;
}

Now to specify how clicking on a column sorts by that column. If it's a click on an already selected column, we reverse the sorting direction. Our method sortByColumn: is fairly straightforward; given a table column, we switch sort order if it's a click on the previously saved column; otherwise we change to a new sorting column. We invoke the column sorting method in the delegate method tableView: didClickTableColumn: to respond to column clicks. In a full application, you may want to store a preference of the sorted column and initially sort appropriately, perhaps calling sortByColumn: in your awakeFromNib method.

Listing 9: SortingDelegate.m

sortByColumn:
Sort the table by the given column, changing sort direction if already sorted by that column.
- (void)sortByColumn:(NSTableColumn *)inTableColumn
{
   if (mSortingColumn == inTableColumn)
   {
      // User clicked same column, change sort order
      mSortDescending = !mSortDescending;
   }
   else
   {
      // User clicked new column, change old/new column headers,
      // save new sorting column, and re-sort the array.
      mSortDescending = NO;
      if (nil != mSortingColumn)
      {
         [oTable setIndicatorImage:nil
            inTableColumn: mSortingColumn];
      }
      [self setSortingKey:[inTableColumn identifier]];
      [self setSortingColumn:inTableColumn];
      [oTable setHighlightedTableColumn:inTableColumn];
   }
   [oTable setIndicatorImage: (mSortDescending
            ? [NSTableView descendingSortIndicator]
            : [NSTableView ascendingSortIndicator])
         inTableColumn: inTableColumn];
   // Actually sort the data
   [self sortData];
}

tableView: didClickTableColumn:
User clicked on a table column, so sort (or invert the sort) by that column.

 (void)tableView:(NSTableView*)inTableView
      didClickTableColumn:(NSTableColumn *)inTableColumn
{
   [self sortByColumn:inTableColumn];
}

Maintaining Selection for a Sort

In the above example, one potential problem is what will happen if any rows are selected when you sort the table. The selected rows will remain the same positionally, but the items selected will no longer be the same. You could clear out any selection when you sort, but it's more useful to maintain the selected items though a sort. The method saveSelectionFromTable: gathers up the array items into an NSSet, and restoreSelection: toTable: finds those items after the array has been sorted and reselects them. These methods should handle simple cases, though the selection restoration code may not perform well for large arrays since -[NSArray indexOfObjectIdenticalTo:] is essentially a linear search.

Listing 10: SortingDelegate.m

saveSelectionFromTable
Create a set that represents the current selection from a table, for later restoral.
- (NSSet *) saveSelectionFromTable:(NSTableView *)inTableView
{
   NSMutableSet *result = [NSMutableSet set];
   NSEnumerator *theEnum
      = [inTableView selectedRowEnumerator];
   NSNumber *rowNum;
   while (nil != (rowNum = [theEnum nextObject]) )
   {
      id item = [oData objectAtIndex:[rowNum intValue]];
      [result addObject:item];
   }
   return result;
}

restoreSelection: toTable:
Restore the selection from the given set of row objects after a table has been sorted.

- (void) restoreSelection:(NSSet *)inSelectedItemNums toTable:(NSTableView *)inTableView
{
   NSEnumerator *theEnum
      = [inSelectedItemNums objectEnumerator];
   id item;
   int savedLastRow;
   [inTableView deselectAll:nil];
   while (nil != (item = [theEnum nextObject]) )
   {
      int row = [oData indexOfObjectIdenticalTo:item];
      // look for an exact match, which is OK here.
      if (NSNotFound != row)
      {
         [inTableView selectRow:row byExtendingSelection:YES];
         savedLastRow = row;
      }
   }
   [inTableView scrollRowToVisible:savedLastRow];
}

To make use of these selection methods, we just save the selection before sorting and restore it afterwards. This is the new implementation of sortData from above.

Listing 11: SortingDelegate.m

sortData
Sort the data, but this time, save the selection before the sort and restore it afterwards.
- (void) sortData
{
   SortContext ctxt={ mSortingKey, mSortDescending };
   NSSet *oldSelection = [self saveSelectionFromTable:oTable];
   // sort the NSMutableArray
   [oData sortUsingFunction: ORDER_BY_CONTEXT context:&ctxt];
   [oTable reloadData];
   [self restoreSelection:oldSelection toTable:oTable];
}

Drag and Drop to Rearrange Rows

Some tables benefit from the ability to drag rows around to reorder the table's contents. For instance, the "International" panel of the System Preferences allows you to specify the languages you prefer to use when using the Mac.

According to the documentation on the NSTableDataSource informal protocol, there are three methods you would implement in your table's data source to facilitate drag and drop. One is for grabbing the data when you drag; one validates whether data can be dropped on your table; one performs the drop. It sounds simple, but there are always subtleties to work through.

If your table is going to handle drag and drop, you need to decide what operations that entail. In this segment, we are merely rearranging rows in the table, then it is simplest to merely keep track of the row index (or indexes) being dragged, so that your code can directly manipulate the data array's ordering.

The first of the three methods, tableView: writeRows: toPasteboard: is tasked with putting data corresponding to the given rows that are being dragged into a pasteboard. In Cocoa, there are multiple pasteboards available, and each pasteboard can contain multiple kinds of data. In our case, we put a representation of which row indexes were dragged, for the purposes of mere row reordering. This is identified as a constant string, "MyRowListPasteboardType", identified in the code as kPrivateRowPBType. (If your program were to have multiple table views -- where it would be possible to drag from one table to the other -- you'd have to take care to specify separate pasteboard data types, and/or encode the source of the row indexes, so you wouldn't inadvertently mix apples and oranges.)

To encode is the list of dragged row indexes, we just use the NSArchiver class to turn the NSArray we are handed containing all of the row indexes into a single NSData object.

Listing 12: DraggableSource.m

tableView: writeRows: toPasteboard:
Put a list of the given rows onto a private pasteboard.
- (BOOL)tableView:(NSTableView *)inTableView writeRows:(NSArray*)inRows 
toPasteboard:(NSPasteboard*)inPasteboard
{
   NSData *archivedRowData
      = [NSArchiver archivedDataWithRootObject:inRows];
   [inPasteboard declareTypes:
         [NSArray arrayWithObjects:kPrivateRowPBType, nil]
      owner:nil];
   [inPasteboard setData: archivedRowData
      forType:kPrivateRowPBType];
   return YES;
}

The next method, tableView: validateDrop: proposedRow: proposedDropOperation:, is needed to validate whether a drop can occur; this is called repeatedly as the user drags over the table view. Tables can accept drops onto a row, or between rows; in our case, we only want to accept drops between rows (the given NSTableViewDropOperation must be NSTableViewDropAbove), and we only want to accept the drop if the clipboard has our own private data type.

Listing 13: DraggableSource.m

tableView: validateDrop: proposedRow: proposedDropOperation:
Determine whether a drop can take place, and how it will be treated.
(NSDragOperation)tableView:(NSTableView*)inTableView
      validateDrop:(id <NSDraggingInfo>)inInfo
      proposedRow:(int)inRow
      proposedDropOperation:
         (NSTableViewDropOperation)inOperation
{
   // Look for our private type for reordering rows.
   NSString *type
      = [[inInfo draggingPasteboard]
            availableTypeFromArray:[NSArray
               arrayWithObjects:kPrivateRowPBType, nil]];
   
   if (inOperation == NSTableViewDropAbove
      && [type isEqualToString:kPrivateRowPBType])
   {
      return NSDragOperationMove;
   }
   return NSDragOperationNone;
}

The final method, tableView: acceptDrop: row: dropOperation: actually performs the drop. This method checks the pasteboard type available, and if it is our private identifier representing row indexes, it performs the data rearrangement. It works by copying out the appropriate rows of data from our data array, replacing them with special NSNull values; then it inserts these data rows into the table; finally it compacts the table by removing the NSNull placeholders.

Listing 14: DraggableSource.m

tableView: acceptDrop: row: dropOperation:
Handle a drop. If it's our private pasteboard listing the rows to move, actually move the data.

- (BOOL)tableView:(NSTableView*)inTableView
      acceptDrop:(id <NSDraggingInfo>)inInfo
      row:(int)inRow
      dropOperation:(NSTableViewDropOperation)inOperation
{
   // Look for our private type for reordering rows.
   NSString *type = [[inInfo draggingPasteboard]
      availableTypeFromArray:[NSArray
         arrayWithObjects:kPrivateRowPBType, nil]];
   if ([type isEqualToString:kPrivateRowPBType])
   {
      NSData *archivedRowData
         = [[inInfo draggingPasteboard]
            dataForType:kPrivateRowPBType];
      NSArray *rows = [NSUnarchiver
         unarchiveObjectWithData:archivedRowData];
      NSMutableArray *movedRows
         = [NSMutableArray arrayWithCapacity:[rows count]];
      NSEnumerator *theEnum = [rows objectEnumerator];
      id theRowNumber;
      // First collect up all the selected rows, then put null where it was in the array
      while (nil != (theRowNumber = [theEnum nextObject]) )
      {
         int row = [theRowNumber intValue];
         [movedRows addObject:[oData objectAtIndex:row]];
         [oData replaceObjectAtIndex:row
            withObject:[NSNull null]];
      }
      // Then insert these data rows into the array
      [oData replaceObjectsInRange:NSMakeRange(inRow, 0)
         withObjectsFromArray:movedRows];
      // Now, remove the NSNull placeholders
      [oData removeObjectIdenticalTo:[NSNull null]];
      // And refresh the table.  (Ideally, we should turn off any column highlighting)
      [inTableView deselectAll:nil];
      [inTableView reloadData];
   }
   return YES;
}

One last step remains. The table view must register itself in being interested in the pasteboard type representing our row indexes; a good place to perform this is in your awakeFromNib method.

   [oTable registerForDraggedTypes:
      [NSArray arrayWithObjects:kPrivateRowPBType,nil]];

Drag and Drop of Data

It can also be useful to drag data from the tables into other applications, or import data via drag and drop. In this segment, we'll explore data export via drag and drop, leaving importing of data as another proverbial "exercise for the reader."

For dragging data out, only the first method, tableView: writeRows: toPasteboard:, is affected, as it packages up the data to export to another program. (If you were to allow dropping onto your table views from external sources, such as cells dropped in from a spreadsheet, you would want to modify tableView: validateDrop: proposedRow: proposedDropOperation: and tableView: acceptDrop: row: dropOperation: to accept other types of pasteboard data, and register for those pasteboard types in your awakeFromNib method.)

Our TableTester program adds two additional pasteboard types to the pasteboard in addition to the private type for rearranging rows: NSStringPboardType (generic text) and NSTabularTextPboardType (tabular text, as for a spreadsheet).

To build up the strings, we enumerate through each of the rows; for each row, we enumerate through all the columns. Each row of data fills up the buffer with strings for each cell, separated by a tab or a newline, with a final extra newline after each row. If your application needed the data formatted differently (for instance, always separated by tabs, or always in a specific column ordering regardless of the current display, or as rich text), you would modify this code to build the appropriately structured data.

Listing 15: DraggableSource.m

tableView: writeRows: toPasteboard:
Write the text data on the given rows onto the pasteboard.
(BOOL)tableView:(NSTableView *)inTableView
      writeRows:(NSArray*)inRows
      toPasteboard:(NSPasteboard*)inPasteboard
{
   NSData *archivedRowData
      = [NSArchiver archivedDataWithRootObject:inRows];
   NSArray *tableColumns = [inTableView tableColumns];
   NSMutableString *tabsBuf = [NSMutableString string];
   NSMutableString *textBuf = [NSMutableString string];
   NSEnumerator *rowEnum = [inRows objectEnumerator];
   NSNumber *rowNumber;
   while (nil != (rowNumber = [rowEnum nextObject]) )
   {
      int row = [rowNumber intValue];
      NSEnumerator *colEnum = [tableColumns objectEnumerator];
      NSTableColumn *col;
      while (nil != (col = [colEnum nextObject]) )
      {
         id columnValue
            = [self tableView:inTableView
               objectValueForTableColumn:col row:row];
         NSString *columnString = @"";
         if (nil != columnValue)
         {
            columnString = [columnValue description];
         }
         [tabsBuf appendFormat:@"%@\t",columnString];
         if (![columnString isEqualToString:@""])
         {
            [textBuf appendFormat:@"%@\n",columnString];
         }
      }
      // delete the last tab.  (But don't delete the last CR)
      if ([tabsBuf length])
      {
         [tabsBuf deleteCharactersInRange:
            NSMakeRange([tabsBuf length]-1, 1)];
      }
      // Append newlines to both tabular and newline data
      [tabsBuf appendString:@"\n"];
      [textBuf appendString:@"\n"];
   }
   // Delete the final newlines from the text and tabs buf.
   if ([tabsBuf length])
   {
      [tabsBuf deleteCharactersInRange:
         NSMakeRange([tabsBuf length]-1, 1)];
   }
   if ([textBuf length])
   {
      [textBuf deleteCharactersInRange:
         NSMakeRange([textBuf length]-1, 1)];
   }
   // Set up the pasteboard
   [inPasteboard declareTypes:
      [NSArray arrayWithObjects:NSTabularTextPboardType,
         NSStringPboardType, kPrivateRowPBType, nil] owner:nil];
   // Put the data into the pasteboard for our various types
   [inPasteboard setString:[NSString stringWithString:textBuf]
      forType:NSStringPboardType];
   [inPasteboard setString:[NSString stringWithString:tabsBuf]
      forType:NSTabularTextPboardType];
   [inPasteboard setData: archivedRowData forType:
      kPrivateRowPBType];
   return YES;
}

One obscure trick remains. In order for you to be able to drag data outside of your application, you need to override a method in NSTableView. Your table view must therefore be of a custom class. If you don't override this method, you will not be able to drag data out of a table into another application! Hopefully Apple will fix this in a future version of Mac OS X.

Listing 16: TypeaheadTableView.m

draggingSourceOperationMaskForLocal:
Allow drags outside of an application.
- (NSDragOperation)draggingSourceOperationMaskForLocal:
      (BOOL)isLocal
{
   if (isLocal) return NSDragOperationEvery;
   else return NSDragOperationCopy;
}

Copying Rows to the Clipboard

With drag and drop supported, it's actually quite easy to add the capability to copy selected rows of a table to the clipboard. We employ the method tableView: writeRows: toPasteboard:, passing in the general pasteboard, to respond to the Copy menu item. We check to make sure that the table's data source implements that method, so this method will fail gracefully if the current table doesn't support the clipboard.

Listing 17: AppController.m

copy:
Handle the request to copy rows from the table.
- (IBAction) copy:(id)sender
{
   // Get the NSTableView we want to copy from.  In this case, we determine the
   // "current" table view by getting the tab view item's initialFirstResponder,
   // which is set in the nib.
   id currentTable
      = [[oTabView selectedTabViewItem] initialFirstResponder];
   // Now put the selected rows in the general pasteboard.
   if ([[currentTable dataSource] respondsToSelector:
      @selector(tableView:writeRows:toPasteboard:)])
   {
      (void) [[currentTable dataSource] tableView:currentTable
         writeRows:[currentTable selectedRows]
         toPasteboard:[NSPasteboard generalPasteboard]];
   }
}

It's actually that simple! All that is missing is a method in NSTableView called selectedRows, to return an array of row indexes. This is fixed quickly by adding a category method to NSTableView.

Listing 18: NSTableView+util.m

selectedRows
Return an array of the selected row numbers of the table.
- (NSArray *) selectedRows
{
   NSEnumerator *theEnum = [self selectedRowEnumerator];
   NSNumber *rowNumber;
   NSMutableArray *rowNumberArray = [NSMutableArray
      arrayWithCapacity:[self numberOfSelectedRows]];
   while (nil != (rowNumber = [theEnum nextObject]) )
   {
      [rowNumberArray addObject:rowNumber];
   }
   return rowNumberArray;
}

Until We Meet Again

If you've made to the end of part two, congratulations -- you can go forth and create some amazingly rich table interfaces. But there are still more cool things you can do with NSTableView, and this is why there's another part in the series on the way. Tune in next month for part three, in which we'll some advanced techniques, including the technique for correctly displaying those trendy striped tables that you see in the "iApps," and a subclass that merges certain cells together into wider cells.


Dan Wood once took an introductory Arabic class, but nobody in the room knew what language they were being taught. He likes to buy fruits and vegetables from the farmer's market on Tuesday mornings. He missed the last two days of WWDC this year due to the birth of his son. He is the author of Watson, an application written in Cocoa. 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

Latest Forum Discussions

See All

The Legend of Heroes: Trails of Cold Ste...
I adore game series that have connecting lore and stories, which of course means the Legend of Heroes is very dear to me, Trails lore has been building for two decades. Excitedly, the next stage is upon us as Userjoy has announced the upcoming... | Read more »
Go from lowly lizard to wicked Wyvern in...
Do you like questing, and do you like dragons? If not then boy is this not the announcement for you, as Loongcheer Game has unveiled Quest Dragon: Idle Mobile Game. Yes, it is amazing Square Enix hasn’t sued them for copyright infringement, but... | Read more »
Aether Gazer unveils Chapter 16 of its m...
After a bit of maintenance, Aether Gazer has released Chapter 16 of its main storyline, titled Night Parade of the Beasts. This big update brings a new character, a special outfit, some special limited-time events, and, of course, an engaging... | Read more »
Challenge those pesky wyverns to a dance...
After recently having you do battle against your foes by wildly flailing Hello Kitty and friends at them, GungHo Online has whipped out another surprising collaboration for Puzzle & Dragons. It is now time to beat your opponents by cha-cha... | Read more »
Pack a magnifying glass and practice you...
Somehow it has already been a year since Torchlight: Infinite launched, and XD Games is celebrating by blending in what sounds like a truly fantastic new update. Fans of Cthulhu rejoice, as Whispering Mist brings some horror elements, and tests... | Read more »
Summon your guild and prepare for war in...
Netmarble is making some pretty big moves with their latest update for Seven Knights Idle Adventure, with a bunch of interesting additions. Two new heroes enter the battle, there are events and bosses abound, and perhaps most interesting, a huge... | Read more »
Make the passage of time your plaything...
While some of us are still waiting for a chance to get our hands on Ash Prime - yes, don’t remind me I could currently buy him this month I’m barely hanging on - Digital Extremes has announced its next anticipated Prime Form for Warframe. Starting... | Read more »
If you can find it and fit through the d...
The holy trinity of amazing company names have come together, to release their equally amazing and adorable mobile game, Hamster Inn. Published by HyperBeard Games, and co-developed by Mum Not Proud and Little Sasquatch Studios, it's time to... | Read more »
Amikin Survival opens for pre-orders on...
Join me on the wonderful trip down the inspiration rabbit hole; much as Palworld seemingly “borrowed” many aspects from the hit Pokemon franchise, it is time for the heavily armed animal survival to also spawn some illegitimate children as Helio... | Read more »
PUBG Mobile teams up with global phenome...
Since launching in 2019, SpyxFamily has exploded to damn near catastrophic popularity, so it was only a matter of time before a mobile game snapped up a collaboration. Enter PUBG Mobile. Until May 12th, players will be able to collect a host of... | Read more »

Price Scanner via MacPrices.net

Apple is offering significant discounts on 16...
Apple has a full line of 16″ M3 Pro and M3 Max MacBook Pros available, Certified Refurbished, starting at $2119 and ranging up to $600 off MSRP. Each model features a new outer case, shipping is free... Read more
Apple HomePods on sale for $30-$50 off MSRP t...
Best Buy is offering a $30-$50 discount on Apple HomePods this weekend on their online store. The HomePod mini is on sale for $69.99, $30 off MSRP, while Best Buy has the full-size HomePod on sale... Read more
Limited-time sale: 13-inch M3 MacBook Airs fo...
Amazon has the base 13″ M3 MacBook Air (8GB/256GB) in stock and on sale for a limited time for $989 shipped. That’s $110 off MSRP, and it’s the lowest price we’ve seen so far for an M3-powered... Read more
13-inch M2 MacBook Airs in stock today at App...
Apple has 13″ M2 MacBook Airs available for only $849 today in their Certified Refurbished store. These are the cheapest M2-powered MacBooks for sale at Apple. Apple’s one-year warranty is included,... Read more
New today at Apple: Series 9 Watches availabl...
Apple is now offering Certified Refurbished Apple Watch Series 9 models on their online store for up to $80 off MSRP, starting at $339. Each Watch includes Apple’s standard one-year warranty, a new... Read more
The latest Apple iPhone deals from wireless c...
We’ve updated our iPhone Price Tracker with the latest carrier deals on Apple’s iPhone 15 family of smartphones as well as previous models including the iPhone 14, 13, 12, 11, and SE. Use our price... Read more
Boost Mobile will sell you an iPhone 11 for $...
Boost Mobile, an MVNO using AT&T and T-Mobile’s networks, is offering an iPhone 11 for $149.99 when purchased with their $40 Unlimited service plan (12GB of premium data). No trade-in is required... Read more
Free iPhone 15 plus Unlimited service for $60...
Boost Infinite, part of MVNO Boost Mobile using AT&T and T-Mobile’s networks, is offering a free 128GB iPhone 15 for $60 per month including their Unlimited service plan (30GB of premium data).... Read more
$300 off any new iPhone with service at Red P...
Red Pocket Mobile has new Apple iPhones on sale for $300 off MSRP when you switch and open up a new line of service. Red Pocket Mobile is a nationwide MVNO using all the major wireless carrier... Read more
Clearance 13-inch M1 MacBook Airs available a...
Apple has clearance 13″ M1 MacBook Airs, Certified Refurbished, available for $759 for 8-Core CPU/7-Core GPU/256GB models and $929 for 8-Core CPU/8-Core GPU/512GB models. Apple’s one-year warranty is... Read more

Jobs Board

DMR Technician - *Apple* /iOS Systems - Haml...
…relevant point-of-need technology self-help aids are available as appropriate. ** Apple Systems Administration** **:** Develops solutions for supporting, deploying, Read more
Operating Room Assistant - *Apple* Hill Sur...
Operating Room Assistant - Apple Hill Surgical Center - Day Location: WellSpan Health, York, PA Schedule: Full Time Sign-On Bonus Eligible Remote/Hybrid Regular Read more
Solutions Engineer - *Apple* - SHI (United...
**Job Summary** An Apple Solution Engineer's primary role is tosupport SHI customers in their efforts to select, deploy, and manage Apple operating systems and Read more
DMR Technician - *Apple* /iOS Systems - Haml...
…relevant point-of-need technology self-help aids are available as appropriate. ** Apple Systems Administration** **:** Develops solutions for supporting, deploying, 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
All contents are Copyright 1984-2011 by Xplain Corporation. All rights reserved. Theme designed by Icreon.