Rixstep
 About | ACP | Buy | Industry Watch | Learning Curve | News | Products | Search | Substack
Home » Learning Curve » Developers Workshop

NSTableView

It's obsequious.


Get It

Try It

Anyone doing any major work on a platform in a 'GUI' capacity has to run into the 'table view' (using Microsoft parlance the 'list view') sooner or later - most often sooner. People like to list things. People need to list things.

Strange then the most read tomes on Cocoa programming hardly bring the matter up. This article can hardly be a complete run through but it will point out a few things rarely mentioned and help get you past a few of the hurdles you might otherwise regard as 'immutable'.

[This discussion doesn't cover Cocoa bindings - it presents the 'raw' way of doing things. Ed.]

Complexity

The NSTableView is a complex control. It's organised into rows and columns of data. An NSTableView object contains NSTableColumn objects and each row and column contains a cell. It's here the data shows up.

You can create a table view on the fly but it's much more common and practical to just drag one from your Interface Builder palette onto your window and configure it. Earlier versions of IB incorrectly embedded cells; it's almost routine to immediately remove the two default columns and then replace them to get things right.

Row height by default is 17 pixels and assumes a font size of 13 pixels. For smaller sets of data this is fine; larger sets will encourage you to reduce sizes somewhat. Your default font size for your column headers is 11 pixels (Lucida Grande) so choose accordingly.

You may or may not want the focus ring: starting with 10.3 it's there by default - even in legacy NIBs - so you have to open those old projects and explicitly turn it off. Obviously if your window has more than one view that can become 'key' (get focus) you might want to retain it; otherwise it's ugly and redundant.

By default a table view will allow 'column selection' - meaning you can select all rows in a column at once. If you don't want this you have to turn it off.

By default a table view will not allow multiple row selections. If you want to be able to copy, cut, delete, or drag multiple rows you have to turn this on.

Column headers are an option. Obviously if you have only one column - which is perfectly legal - you might not need headers. Otherwise you might want them for several obvious reasons. It not only makes it easier for people to see what's what - it also lets you implement sorts.

Today you can get an 'alternating row background' automatically. This starts with a white row, then a light blue row, and so forth. [It used to be a blue row first but things change. Long live planned obsolescence. Ed.] in the old days you had to write the algorithm yourself. A downside to using this NIB switch is you won't be able to save the NIB in the old more economical format: changes to Interface Builder - for some inexcusable reason - don't propagate backwards. Your old code probably takes less space than the 'bloat' of the new NIB format.

You can however call the following method and not have to muck with your NIB.

-(void)setUsesAlternatingRowBackgroundColors:(BOOL)useAlternatingRowColors;

Other Stuff

Be sure to configure your column widths appropriately - both the minimum and maximum widths. If you want a column to be able to disappear completely you'll have to give it a minimum with of -3. The default maximum width of 1000 is a bit risky on today's screens.

Supplying an 'autosave name' is supposed to ensure user changes in the appearance of the table view will be persistent - that they'll go in the application's property list. Unfortunately this didn't work right for such a long time so most developers simply use the corresponding APIs instead.

-(void)setAutosaveName:(NSString *)name;
-(void)setAutosaveTableColumns:(BOOL)flag;

You might want to set a double click handler or programmatically size the last column to fit.

-(void)setDoubleAction:(SEL)aSelector;
-(void)sizeLastColumnToFit;

Getting Data In

Getting data in a table view is a matter of observing the good old 'MVC' - model view controller - paradigm. You are the model and controller and the table view is - unsurprisingly - the view.

You need two things to accomplish this. You need to set yourself up as both data source and delegate of the table view. You do this in Interface Builder by control dragging from your table view to your controller class. [Make sure you're dragging from the table view and not the enclosing scroll view or the targets you want won't show up.]

Once you've made the above two connections your project will burp when you try to run it. You'll be told you have to implement at least two methods to make the app work. [And if you can never remember the names of these methods this is a good way to refresh your memory. Ed.]

-(int)numberOfRowsInTableView:(NSTableView *)tableView;

-(id)tableView:(NSTableView *)tableView
        objectValueForTableColumn:(NSTableColumn *)tableColumn
        row:(int)row;

The (NSTableView *) argument in both cases is absolutely essential: your controller can obviously be managing more than one table view.

There's a lot more you can and will implement; but if you don't implement at least these two the app won't work.

For development it might be propitious in the beginning - just to get the code up and running - to return 0 (zero) for both. As long as your table view thinks there are no rows you're hardly going to run into trouble; and a zero (nil) object is appropriate in such case should the view come calling anyway.

The numberOfRowsInTableView: method is called rather frequently according to current documentation. [This doesn't seem to pan out at all. More later. Ed.] And so therefore, states the documentation, the response must be fast.

Which segues admirably into a discussion of how to model the display. And the obvious choice here is the array - specifically NSMutableArray. The answer to the first query in its simplest form becomes return [(NSMutableArray *) count]; and the second query already points to a specific index in your array so you're halfway home.

A curiosity (for some) might be the fact you don't normally index table view columns as you do rows. What you're told about columns is in the form of an object. To determine what column is being referred to you have to use some other tack.

One method might be to assemble an array of dictionaries in your model and then correlate either the column header or column identifier with a dictionary key. [In one way or another you're going to have to give your table columns identifiers - there's not much of a way around that.]

You might turn your code into something like this.

-(id)tableView:(NSTableView *)tableView
        objectValueForTableColumn:(NSTableColumn *)tableColumn
        row:(int)row {
    return [[myArray objectAtIndex:row] objectForKey:[tableColumn identifier]];
}

And that's about as tidy as anyone could desire. It might or might not suit your needs in any one particular case - that's another story.

Exactly how the table view goes about querying you about the number of rows and data for any one column and row is not something revealed by the documentation; however you can naturally assume the table view is most likely going to ask about the number of rows before it starts trying to populate them.

Also the note that the numberOfRowsInTableView: method is called 'very frequently' should be taken with a pinch of salt; in fact you cannot assume more than you should be quick in your response and in practice you'll find this method isn't called very frequently at all.

[You could always set up one mutable array per column. The table view itself seems oriented around the column so why shouldn't you be? It's up to you and it's good there's flexibility here. Although such a scheme would be very wild indeed. Ed.]

Drawbacks

The super space age NeXTSTEP table view is not perfect. And it isn't ideal either. Although ostensibly adhering in an almost 'Puritan' way to the MVC paradigm it could in fact make things a lot easier for itself - and for you - if it wanted to.

Terminology for 'focused' rows and 'selected' rows is very hazy. The table view has two APIs in this regard. This is the first.

-(int)selectedRow;

This can be very confusing as you might have any number of rows selected and yet this API will return a single index. What gives?

What gives is there is a strict distinction between 'focused' and 'selected'. Your 'focused' row is the row you last clicked on or used your arrow keys to move to.

-(NSIndexSet *)selectedRowIndexes;

This returns a set of the indexes of selected rows.

It might be good to point out that in NeXTSTEP a focused row is also a selected row - something that's not necessarily true for example in Windows. Also a selected row does not look at all different from a focused row, making it difficult for a user to see what's what.

An alternate way to see what rows are selected is to simply iterate through the table view using the following method.

-(BOOL)isRowSelected:(int)rowIndex;

And at the end of the day there are several shortcomings here. The distinction between focused and selected is hazy; the user runs into the same trouble; and there is no way to focus a row without selecting it or vice versa.

You can select or deselect rows programmatically.

-(void)deselectAll:(id)sender;
-(void)selectRow:(int)rowIndex byExtendingSelection:(BOOL)flag;

The latter is officially deprecated for the relatively unwieldy following method.

-(void)selectRowIndexes:(NSIndexSet *)indexes byExtendingSelection:(BOOL)extend;

It's OK as long as you're prepared to put together an index set first; you'll probably end up wasting more code this way.

Finally: if you want one row to 'stand out' as the focused row you just have to make sure you select it last. There is no specific API for setting it.

[This reflects back on the deterioration in 10.5 Leopard where some unwelcome alteration of table view code has made things a bit difficult for users. For one thing the table view was previously able to do on its own was remember the last selected row - the 'focus' row. And so when users clicked or shift-clicked on further rows the table view knew what was going on. Thanks to a typical Apple blooper in Leopard this is no longer possible. Usability's been reduced to near zero. Click here. Ed.]

What's even worse is that the table view, despite being a part of the famous NeXTSTEP legacy, is actually rather primitive in its display capabilities. Focus and selection are not per actual row object but per raw brute integer index. This of course is in strict contrast to the Microsoft way of doing things where rows in their list views retain display attributes for focus and selection and the controls can be queried about this status.

When inserting or deleting things in a Cocoa table view the selections will go all over the place: they might stay where they are or partially or fully disappear; it's all rather undefined depending on where the selections are. Some developers just decided they'd not worry about such subtleties but most likely you will.

Getting from status A where everything you want selected is selected to status B where there have been changes in your model and you still want the same selections isn't easy. It would be easy if the table view got off its fat backside and kept this data itself. But it doesn't so you have to write an egregious workaround.

The executive summary is as follows: prior to changing anything make an array of all selected rows. When this array is complete add a final object for the focused row. Keep the array on hand; do whatever you needed to do in your model; then pull out that array again.

By this point you should have had the perspicacity to 'deselect all'; now you'll start selecting again. Go through your saved array and look for matches in your new model; as you find them mark the corresponding row as selected. As the last object in your array is the 'focused' row it will turn up correctly when you're through.

You'll have to resort to this code anytime you paste or drag something in, cut or delete something out, or resort your data.

Mouse Down

Table view columns have headers. They can be selected or not. They can have flippies which indicate whether there's an ascending or descending sort in play.

This is the method you implement.

-(void)tableView:(NSTableView *)tableView
        mouseDownInHeaderOfTableColumn:(NSTableColumn *)tableColumn;

You're going to have to remember what the last sort column was and remove its highlight; then you have to set the new column; then you have to remember the column for future use.

-(void)tableView:(NSTableView *)tableView
        mouseDownInHeaderOfTableColumn:(NSTableColumn *)tableColumn {

    // Remove the old column
    [tableView setIndicatorImage:0
            inTableColumn:[tableView tableColumnWithIdentifier:oldIdentifier]];

    // Highlight the new column
    [tableView setHighlightedTableColumn:tableColumn];

    // Figure out the sign and sort and then set the new column
    [tableView setIndicatorImage:
            [NSImage imageNamed:(sign > 0)? NSAscendingSortIndicator: NSDescendingSortIndicator]
            inTableColumn:tableColumn]];

    // Do whatever else you need to do
}

The accepted way of dealing with header clicks is to use a second click on the same header to reverse the sort direction; sort directions per column are not retained as that makes things way too confusing for the user; a click on a new column is always for an 'ascending' sort.

You must therefore keep two tidbits of data around: the sort index and the sign. The sign need only be 1 or -1. That's all you need. Your sort index is merely the column index. [You might also save the table column identifier - that's a method you can get into on your own.]

There's two accessible ways to sort a mutable array.

-(void)sortUsingFunction:(int (*)(id, id, void *))compare context:(void *)context;

-(void)sortUsingSelector:(SEL)comparator;

What the former alternative gives you is a way to convey your sign and sort to your comparison function. And you yourself write your comparison function. As the 'context' is a generic void pointer of machine register size you can send anything. And if it's not a void pointer then you simply use explicit typecasts - but you probably know that by now already.

Dragging Rows

You need implement one method to enable dragging rows. If you don't and the user tries to drag rows nothing will happen - the selections will slip and slide all over the place is all. This is the 'deprecated' version of the method you need to implement. It's no longer in the documentation but it still works.

-(BOOL)tableView:(NSTableView *)tableView
        writeRows:(NSArray *)rows
        toPasteboard:(NSPasteboard *)pboard;

The new method is as follows.

-(BOOL)tableView:(NSTableView *)tableView
        writeRowsWithIndexes:(NSIndexSet *)rowIndexes
        toPasteboard:(NSPasteboard*)pboard;

As the latter method is a bit unwieldy you might want to stick with the 'cheat' for now. In either case you simply iterate through the indexes (objects whose numerical values you can extract) to get the indexes in your model array; assemble them; and then use the pasteboard pointer to place them all there.

As drag-drop is just fancy clipboard work anyway (just as it is on Windows) you simply deposit data on a 'drag' pasteboard and let the system manage things from that point on.

Important is to understand your return value indicates whether the user will experience a 'drag' or just selections slipping and sliding all over the place. As you need to do two things successively to place something on a pasteboard it's easiest to combine them with logical AND and return the value of the expression.

Using Images

The table view cell won't allow you to set images; NSBrowser's cells will. What you do is replace your default cell at startup with an NSBrowser cell. Then you add one more method to your delegate code.

-(void)tableView:(NSTableView *)aTableView
        willDisplayCell:(id)aCell
        forTableColumn:(NSTableColumn *)aTableColumn
        row:(int)rowIndex;

Here you literally set the image you want and optionally set the rest of the data you want which you'd normally set in the objectValueForTableColumn: method. [You still have to implement the latter. You'll use it for all columns not displaying images.]

Reload Data

When you make changes to your model you have to tell the table view so it updates its display. This is easy enough.

-(void)reloadData;

When you call this the cycle of 'how many rows' and 'what data for so and so row and column' starts all over again.

Caveats

Working on a Cocoa app with a table view you'd normally assume a single threaded model no matter your code is reentrant. And you'd especially assume this if you've done any low level messaging. For at some level there's a message pump collecting news about events and dispatching things to the appropriate handlers. And unless you've explicitly set up additional threads with their own message pumps you'd assume you can't get two messages at once. Or new messages before the current one completes. And you might be wrong.

All this is hidden in proprietary code no one outside the OS vendor will ever see but starting with Tiger things seem to have found a way to go multithreaded without your express knowledge or permission.

What effect does this have on your table view code? Consider the following scenario.

You have a table view set up with a mutable array in your model to back it up. Intermittently you refresh your data whereupon new records can appear and earlier records can disappear. Your array count can both go up and down.

Your code is set up to respond to the two basic questions as follows.

-(int)numberOfRowsInTableView:(NSTableView *)tableView {
    return [myArray count];
}

-(id)tableView:(NSTableView *)tableView
        objectValueForTableColumn:(NSTableColumn *)tableColumn
        row:(int)row {
    return [[myArray objectAtIndex:row] objectForKey:[tableColumn identifier]];
}

This looks innocent enough. Let's propose you're responding to a user action to refresh your model. The user action comes through the thread's message pump; it's routed to your handler; you're currently in the processing of handling it. So what can possibly go wrong?

Nothing - if all is as it seems. You remove all objects from your mutable array, you populate your mutable array again, you tell your table view 'reloadData'. Simple as that.

Not quite: it's only that simple if in fact there's only one way into your application - one message pump. It's only that simple if no one can call your code without going through the message pump (in additional threads) and in so doing upset the apple cart of event driven programming.

But that's what seems to happen starting with Tiger. If you look at the same code running on Panther and Tiger you'll notice the latter is extensively multithreaded whilst the former is not. How and in which way this violates the precepts of event driven programming isn't important; that you are ready for it is.

The key is in the call numberOfRowsInTableView:. Current documentation states it's called 'frequently'; empirical tests show it's not called frequently all - it's called once per your invocation of reloadData.

What happens is the table view - starting in Tiger - assumes your record count is what you last told it. But it only asks when you force the situation with reloadData. What you therefore have to do - admittedly messy - is the following.

-(void)myRefresh:(id)sender {
    [myArray removeAllObjects];
    [tableView reloadData];

    // And so forth
}

Once you've repopulated your array you of course have to call reloadData again. [To make sure the table view doesn't butt in where it's not wanted you might respond to numberOfRowsInTableView: with an external value that's set after you repopulate your array. You can set it to zero whilst you repopulate and keep the table view out of there. Ed.]

This forces the table view to recognise the fact you have absolutely no objects in your array. For otherwise you'll be sent requests for indexes that no longer exist - requests sent in new threads you didn't create and might not have known even existed.

Cocoa Bindings

Cocoa bindings is a technology that represents a way to - at times - simplify the correlation of model and view. It's an abstraction of what you've been doing here. It can be problematic to debug; especially larger projects can benefit from it.

But crawling always comes before walking or running.

Heavy Duty

NSTableView isn't really heavy duty. It's not been used extensively for huge data sets - until now at any rate. Displays such as those in Xfile and Xscan would be impossible using the model implementation outlined above. Xfile can display upwards of six thousand rows with ten columns each; Xscan can have several hundred thousand rows with eleven columns each. Were each of the data cells represented by a unique Cocoa object things would never get done.

And the fun would really start when it was time to repopulate the model. One hundred thousand rows times eleven columns is over one million calls to release objects no longer needed. And that's not counting additional 'dealloc' and other spawned calls.

Scot Hacker of BeOS bible fame got into this in the 2002 holiday season. His copy of iTunes - using the equivalent of NSTableView - was grinding to a halt with 5,000 songs and his father's copy with 15,000 songs became impossible to use.

Has anyone got a working app that displays 1,000,000 rows through a table view with reasonable performance? At least one application in my area of interest seems able to do this in X-windows.
About | ACP | Buy | Industry Watch | Learning Curve | News | Products | Search | Substack
Copyright © Rixstep. All rights reserved.