Sunday, September 16, 2007

Cocoa, Custom View scrolling

I spent a few hours banging against custom views and scrolling thereof, and learned a number of useful things.

The scenario is a custom view wrapped in a scroll view. Instead of doing the drawing in drawRect: what I'll do instead is manage the layout of my sub-views, specifically a number of NSImageViews. That way I get the drawing for free, all drawRect: needs to do is draw the background (trivial). When my view changes size I can automatically re-layout the images, arranging them into columns and rows.

Handling the resize is a bit tricky. The problem is the frame size of this custom view is semi-independent of the enclosing clip view. When the custom view frame size is larger than the clip view port, you get scrollbars (desired behaviour). When the scroll view grows, you want your custom view to fill up the entire area so you don't end up with weird background drawing.

I played with a bunch of the auto-resizing toggles, but I wasn't able to get things working - for some reason my frame always ended up too tall. So instead, the solution is to ask the enclosing scroll view what the size of their content area is, and size yourself to that. The logic is "make myself the same width, but if I need to be higher to accommodate my images, then be higher, otherwise be the same height as well".

So the first thing is you need to post frame change notifications and register to get view frame notifications... do something like this in initWithFrame: (or maybe awakeFromNib? Apple's docs indicate that initFromFrame: doesn't get called for objects from nibs, but mine got initWithFrame:)

NSNotificationCenter *nc = [NSNotificationCenter defaultCenter];
[nc addObserver:self
selector: @selector( frameSizeChanged: )
name: NSViewFrameDidChangeNotification
object: nil];

The problem is you get notifications for nearly every change in the system, but you really only care about if your enclosing scroll view and it's clip view changed, so what you do is:

- (void)frameSizeChanged:(NSNotification*) note
id obj = [note object];
if (obj == [self enclosingScrollView] ||
obj == [[self enclosingScrollView] contentView]) {
// change the layout of my subviews
// (accessible via [self subviews])

The key here is comparing the object the notification is for against your enclosing scroll view, AND it's clip view. If you don't have the second comparison your frame won't handle the start-up quite right.

Now, in your resize handler, you need to figure out the height and width you should set yourself too. Since my goal is to layout images, the minimum you can be is a single column. That way you get horizontal scrollbars when you get too small. Also since I want to display all the images, the view frame height has to be at least as tall as all the images and padding. But, if that isn't quite tall enough, I need to be the same height as my enclosing scrollview or else you get the scrollview background instead of your own (my background is 0.5 white, so problem).

So you need to call:

NSSize newSize = [[self enclosingScrollView] contentSize];

and the later on call [self setFrameSize: newSize]; to set your new height in response to the resizing.

Since I re-layout each re-size the behaviour is I end up with flowing columns of pictures responding to a window resize.

One of the problems I had was the internal bounds origin was at the bottom left corner. The upper right corner would have been more handy, but I adjusted my math accordingly.

Then I had a problem with scroll behaviour - for some reason when the app started it would start scrolled to the bottom. When I stretched the window big then small again, the scroll bar was always at the bottom.

Google solved my problem for me - it turns out that the scrollbar always starts out displaying the origin. To solve both problems just do:

- (BOOL) isFlipped
return YES;

and both problems are solved. Now image 0 at row,col 0,0 has a origin of (padding,padding).

The basic problem with this approach is the custom view must be the full size of the content area of your scroll-view. If you needed to coexist with other elements inside your scroll view, you would have to adjust accordingly. For example, you might have to not be as wide for a vertical strip, or you might have to adjust your origin of your frame point. Some of the auto-resizing might help here, but I haven't experimented with it.

The view documentation is not the most complete, and there are lots of tips and tricks. There are several approaches too, currently I am using sub-NSImageView instances, but other code I've seen does the drawing straight from NSImage instances instead. That code is more complex, but it may be more efficient since you don't need to instantiate view objects (which can kill performance if there are too many). However it is harder, since NSImageView can draw cool frames for you.


Seth said...

Hey Ryan,

I just found your blog through Google, and while it was mostly what I was looking for, I thought I'd offer one quick tip.

You can only get the notifications you want by passing the scroll view's content view (i.e. [[self enclosingScrollView] contentView]) as the object argument to the NSNotificationCenter's -addObserver method. The notification center will then only send you notifications posed by that view, so you'll only be notified when you actually need to be.



Seth said...
This comment has been removed by the author.