Cocoa tip: using custom outline view cells designed in IB
August 8th, 2010Cocoa, Development, Objective C 10 Comments
I only started learning Objective-C and Cocoa in mid-May, and for the first time I think I actually have a tip to contribute to the wider community. It’s about using custom cells in NSOutlineView, but those which you want to design inside Interface Builder rather than drawing manually.
If you’re an iOS developer, you’ll be wondering why this deserves a blog post – it’s easy to do in Cocoa Touch! Well, yes it is easy on iOS, because Apple have specifically allowed you to design table view cells in Interface Builder. When targetting Mac OS X though, it’s actually pretty awkward, and here’s why: in Cocoa Touch, the class which draws the cells in a table is UITableViewCell, which is a subclass of UIView – meaning you can drag one onto the canvas in Interface Builder and lay stuff out right there. In Cocoa, in contrast, the cell is simply represented by NSCell, which is not an NSView subclass. This means Interface Builder will not let you play with them, you draw them by implementing drawWithFrame:inView: instead. I think Apple realised the problem with this design in time for Cocoa Touch but obviously felt they couldn’t break the existing Cocoa interfaces. There are also many differences between the instantiation of NSCell versus UITableViewCell – there’s only one NSCell for all rows in a table / outline view, compared to a type-indexed pool in Cocoa Touch.
The problem boiled down to this: if the contents of your table / outline cell is non-trivial, or if you just don’t want to write a bunch of layout code, it’s a PITA to implement a custom look in NSOutlineView, such as the one in the picture, especially if you want custom controls embedded in it. For my current Cocoa app, I really wanted to design my cells (and I have 2 different styles for group levels and detail levels) in Interface Builder to save me hassle, including using Cocoa Bindings to hook up some dynamic fields within. Many internet searches later, and mostly the answer I found was that it couldn’t be done. Luckily, I’m too stubborn to take no for an answer, and eventually I figured out a way to do it. Details are after the jump, with an example project to show it in action.
Note: The project is written with garbage collection required, that’s because my original project uses that approach (I’m happy to require ObjC 2.0 / Leopard and don’t have a need for low-level memory control in this project) and I didn’t have time to retrofit old-style retain/release patterns to it. If you still use manual memory management in Mac OS X you’ll need to alter the code.
The answer to this problem involved several pieces:
- We need a tree model to hold our data. I’ve included a pretty standard node implementation in the example (ModelTreeNodeBase), and then subclassed it for the specific example (MyTreeNode)
- Since we can’t design NSCell in Interface Builder, we need to design an NSView instead – in this case I designed one for the group level, and one for the detail level, but you could do more or less if you wanted. For convenience, I put both in one nib (CustomCells.xib).
- We need to attach our NSViews as subviews of the outline view, in the appropriate place, at the right time, in order to display them. I do that in our custom NSCell subclass, CustomCell
- Since there is only one instance of NSCell for all data rows of an outline view, we can’t use that to hold row-specific information, such as binding values to fields. So, I use a controller called CustomCellController which provides the linkage between the cell view and the model node (appropriate MVC practice). I use Cocoa Bindings to pull in data from this controller’s node per row, see the CustomCells.xib setup.
- Again because there’s only one NSCell instance, we need to associate the specific controller instance for the correct row with the NSCell just before drawing. I do this in the NSOutlineView delegate method outlineView:willDisplayCell: , which you’ll find in the CustomOutlineViewAppDelegate in this case.
- The association between the model node and the controller is kept in a simple NSDictionary, just to keep the coupling loose. Note that I use the model node itself as a key (which is why we’ve implemented the hash and isEqual methods in the model), in my app this is useful anyway because to identify a node uniquely regardless of content values is required.
- Because we’re manually attaching our subviews on display, we also need to remove them at the right time. I do this on outlineViewItemDidCollapse: (and in the real app, I also do it when deleting rows and on drag/drop)
- Outline views allow you to click or press enter to edit the main title text, and I support this here too. I have to do a bit of work though, firstly to make the edit box appear in the right place (see CustomCell’s editWithFrame: and selectWithFrame: methods) based on where we’ve placed the text in our custom view, and also to place the ‘hit box’ in the right place – in my case I’ve allowed the hit box to extend over the entire cell, barring the other controls which take precedence if the mouse hits them. See CustomCell hitTestForEvent: for that code.
- I also do a little bit of presentation nicety by changing the colour of the text when the row is selected. You get this for free with NSOutlineView normally, but since we’re using custom views, you don’t automatically get inverse video on text, so we have to do that ourselves.
- To make the cells resize correctly, you use Interface Builder’s sizing options as per usual. However, it’s very important that the size values in the NSOutlineView’s table column, the size of the scroll view containing it, and the initial size of the custom NSViews for the cells are set to the same values in the various nibs. If you get this wrong, resizing won’t work correctly. As you can see from the example, if you set it up right you can get all the usual content resizing options for free while designing your cells in Interface Builder.
Basically, that’s it – hopefully you can see the details from the code with those pointers. You’ll notice that although I used Cocoa Bindings to link the cell values to the model, I used the NSOutlineViewDataSource protocol for the main outline view structure, instead of binding it via a tree controller. The only reason for that was that the example from my project does it this way, because you need to use a custom datasource to handle drag/drop and the pasteboard, so it was simpler this way. There’s no reason why this wouldn’t work with a tree controller providing the data too, since all the key moving parts are in the delegate, custom cell and controllers.
Forgive me for any errors or bad practice here, I’m still relatively new to ObjC / Cocoa. But still, I hadn’t seen this done anywhere before so I wanted to share the technique I used, because when I was trying to do it I really wished someone had posted an example like this before. This code works well for me, but suggestions, patches & forks on BitBucket are of course welcome if you see something I’ve done wrong or sub-optimally.
I hope this is useful to someone!
September 6th, 2010 at 10:08 am
Hello Steve. I found this tip very interesting. I´m new to ObjC-Cocoa-xcode.
I´m trying to follow your tutorial but I couldn´t download your example-project files from BitBucket (and my knowledge of cocoa and my english is not good enough to understand every step).
Could you give me a link to download the entire project? Thanks in advance.
September 6th, 2010 at 10:55 am
The intention was that you use Mercurial to grab the source, but to make it easier I’ve added a zipped version on the downloads page as well: http://bitbucket.org/sinbad/cocoacustomoutlineviewcells/downloads
HTH
September 6th, 2010 at 1:55 pm
Thank you Steve. Very kind (and fast). I´ll try it again this afternoon…
October 25th, 2010 at 8:44 pm
I’m dealing with a simple NSTableView Right now i want to add this custom cells… hope this one helps
thx !
January 14th, 2011 at 10:56 pm
Saved me a lot of time!
January 16th, 2011 at 9:46 am
Easily reproducible glitch: open row 3, then row 1. Row 1′s contents will overlap row 3 now.
January 16th, 2011 at 11:35 am
I’ve actually made quite a few changes to this in the project where I’m using it to deal with occasional glitches, if I get chance I’ll port them back. Mostly, it means when expanding a node it’s best to hide all the existing nodes and let the pre-draw call re-enable the ones which are visible.
February 22nd, 2011 at 9:41 am
Hi,
First of all thanks for all the hard work. Second, what license do you make the code available under? I have a project where it could prove really useful.
Also, the glitch mentioned by @Coder above, it would be great if you could post a patch, or at least give me some pointers on where to start tweaking it.
Best,
Dragos
February 22nd, 2011 at 10:01 am
@Dragos: the license is at the top of every source file, basically the summary is ‘do whatever you want with it, just don’t blame me if it explodes’
Basically what I’ve done to fix the problem in my actual app is to catch cases where nodes can get pushed off the visible scroll area, and remove all the view components so that they have to get re-added by the draw call again. The problems with rendering were almost always that views were added when being asked to draw, but not removed if they were pushed off the screen, and because their draw calls were never called their frames were also never adjusted, leaving the remnants lying around.
So mostly it was about being over-cautious and calling hideViewsForItem: whenever it was remotely possible that nodes might end up out of the scroll region, and letting the draw put the views back. It means it’s slightly less efficient but it gets rid of the glitches.
Cheers
Steve
February 28th, 2011 at 4:23 am
Thanks
Now I have to experiment with it. Sorry for the delay, I was traveling, just settled down a day ago and checked all my work related stuff.
The license seems to be exactly what I need.
Basically, I want to use it for the desktop version of my iAdd app, already in the AppStore, you can have a look at t here
http://itunes.apple.com/us/app/iadd/id376802160?mt=8&ls=1
And if you ever feel like you want a promo code or soething, just hit me up with an email, I’d be happy to.
Best,
Dragsos.