Untied: When Bindings Aren’t Enough

I’ve found, when I try to use Cocoa bindings for my user interface, that it’s never quite flexible enough for what I need.

Instead of trying to list the hurdles I’ve encountered in the abstract, I’m going to walk you through the first steps of a simplified example, called Categorizer, which is also available for download. This way, you can see the things I’ve figured out how to do, in addition to the dealbreakers.

Categorizer initial table

The Categorizer’s data model consists of Things and Categories.

The UI pictured above is the master table, of Categories. The detail table, of Things in the selected Categories, will be to the left. We won’t get to the detail table today.

The first category, "All," is special: you can’t delete it or rename it. It will contain all the things, and is always listed at the top of the category table, which is otherwise alphabetically sorted. You "categorize" things by dragging them onto the rows you’ve made yourself in the category table.

Sorting
The first issue is sorting. As I said above, categories need to be sorted alphabetically except for the "All," which is always on top.

NSArrayController will automatically sort its array – the contents of the table – according to the Sort Key and Sort Selector of the columns in the table, which are set in Interface Builder. The Sort Key needs to be set to something, it can’t be left blank if I want sorting. So I can’t, for example, try to sort on the entire Category class; I need to pick an attribute. Normally I’d pick something like "name," but in my case I want to into account the special "All" status and name. So I make a "specialSortKey" Category method and specify that in IB.

The table doesn’t start out sorted. I fix this not by manipulating the table, but by manipulating the NSArrayController. I get the sortDescriptorPrototype from the column, stick it in an NSArray, and use it as the parameter for a setSortDescriptors: message to the controller. (See the download for the exact code.) This also selects the column header and sets the indicator image (the little up or down arrow).

Turns out, though, that I don’t want this to act like a normal table column. I want the contents to be sorted the same way all the time, so I don’t want the up or down arrow in the column header that the user can toggle between. Because this would put "All" at the bottom, for one. But because of the array/controller integration, it’s basically not possible to have one without the other if I want to use an NSArrayController. This, it turns out, is dealbreaker #1.

Editing
I also want specific behavior when adding a new category. The standard NSArrayController behavior, if I connect the "Add" button to the controller’s add: action, is that a new, empty instance is added to the array and to the table. What I want, in addition, is that you automatically start editing that instance.

My first thought is to connect the "Add" button to a method in my own delegate, newCategory:. This method invokes the controller’s add: method and then starts the editing session.

Unfortunately, the new row is not yet made by the time add: returns. I’m not sure why this would be an asynchronous process, but it is. Oh, wait, here we go, from the documentation: "Note: In Mac OS X v10.4 the target-action methods are deferred so that the error mechanism can provide feedback as a sheet."

I then try implementing MyDelegate’s key-value coding method insertObject:inCategoriesAtIndex: so that, after it does its business, it initiates editing of the added row. But for some reason (maybe because it’s a horrible hack) that doesn’t work either.

This, so far, is dealbreaker #2.

After I’m done editing, I want the renamed category to be re-sorted properly. This is pretty easy, but still involves a gotcha. On 10.4, the controlTextDidEndEditing: NSTableView delegate method is never invoked when you double-click a table cell and edit it in a table that uses an NSArrayController. In my tests, it seems to still work in a table that doesn’t use an NSArryController.

Luckily, there’s another Cocoa API in a superclass of NSTableView, NSControl’s controlTextDidEndEditing:. If I define that in my table delegate, I can use it to send the rearrangeObjects message to my controller, and the renamed category will be immediately resorted.

My last editing issue, and last issue for this post, since it’s getting long, is uniqueness. I don’t want you to be able to enter the same category name twice. If you try to end editing with an invalid name, I want the UI to beep and continue the editing session.

I try to do this in the key-value coding way by implementing the "name"-specific validation method validateName:error: in Category. There’s some finagling, since now Category needs to be able to access the full list of categories in MyDelegate. But, with that achieved, Category’s validateName:error: now returns NO if the new name is the same as any other name.

Can I make it beep? Nope. My only choice of error feedback for the user is an error dialog. There are plenty of options in NSError so I can customize what the dialog says, but no option to do something else.

Dealbreaker #3.

The download, called Categorizer01.zip, contains the work I’ve described so far, including the behavior I couldn’t get working right. It’s an Xcode 2.1 project, sorry, it can’t be opened in Xcode 2.0.

If my schedule permits, soon I will make a second Categorizer, one that doesn’t use Cocoa bindings, that customizes the behavior as I wanted.

7 comments

  1. Bertrand

    What ez this? Bindings are ze true way! Report to my office for a direct kool-aid injection. You *will* see ze light…

    Oh, and Steve says you’re fired. Adieux!

  2. Scott Anguish

    actually, dealbreaker #1 isn’t.

    subclass NSArrayController and override arrangedObjects. always return a sorted version, and always prepend the All.

  3. Michael James

    You should be able to call NSBeep() from your validate method to make the computer beep.

    Also, if you don’t want duplicate categories, you can use the contentSet binding of the NSArrayController. Just make sure your Category objects are comparable (which should just require that they implement a -compare: method, and perhaps override the -isEqual: method). In your isEqual: method, if both Categories have the same name, then they are equal, which the set, by definition, won’t allow.

  4. Annard Brouwer

    I’ve also noticed that if you want to use custom pasteboard types for dropping on an NSTableView, you’re forced to use a data source. This makes the use of bindings rather futile… A delegate mechanism would have been more useful.

  5. Andrew Pontious

    Scott:

    >subclass NSArrayController and override
    >arrangedObjects. always return a sorted version,
    >and always prepend the All.

    Thanks for the suggestion.

    I subclass NSArrayController, implement arrangedObjects the way I want, remove the Sort Key from my nib file, and remove the initial setSortDescriptors: message to the array controller.

    When the app launches, there’s no arrow, but things are sorted properly when I add and rename sets. Yay!

    Then I click in the table header. Oops! The arrow appears, pointing up. Click again – pointing down. Click again – pointing up, etc. The sorting stays the same, but the selected row moves around as if the sorting was being reversed and then put back.

    Hm, maybe if I also override setSortDescriptors: to do nothing. Yep, that helps. The table header now never shows an arrow and table selections aren’t changed by clicking on it. But clicking on it still momentarily highlights the header, as if it should be doing something, and ends the editing session, which is annoying.

  6. Brad Miller

    It’s easy to get a table not to sort by clicking in the column headers, it’s not something that jumps out at you though. In IB bind the table’s content binding to the array controller’s arrangedObjects key and the selectionIndexes binding to the array controllers selectionIndexes key. Leave the sort descriptor’s binding unbound. The table wont sort now when the headers are clicked. Normally when you bind a column to a controller those 3 bindings are automatically set for you.

    Also, here’s another way to do the “All” item at the top. I got this trick out of one of the sample apps, think CoreRecipes. Add an int key to your class. Have it default to 0 so that all objects will have that value. Then set the “All” object to have a different value, say 9. Then add sort descriptors. For the first item in the array make a sort descriptor on the int key and have a 2nd sort descriptor on the name or what ever value you want the list to be ordered by.