Developer Musings of bergdesign

Really useful Mac software

Centering a NSView in a NSScrollView

There are several different ways to implement a view that centers itself in its superview.
  1. Replace the NSClipView with a subclass that centers
  2. Resize the view so that it always fits the superview, drawing its content in the center
  3. Put the view inside another view inside the scroll view, where the superview makes sure that it is the size of the scroll view, centering its subview.
NSClipView subclass does #1.

Pros
  • View remains size that you want it at all times. The enclosing NSClipView is doing the work
  • No frameworks to embed or copy
Cons
  • In order to prevent hacking methods, the NSClipView may draw the view in the old spot before refreshing it in the centered position. If you have a complex view, you may see this.
  • The area around the doc view does not respond to clicks. Since your subview does not cover the area surrounding it, it never gets clicks passed to it, so you can't use this area to say, deselect an object by clicking in the white space.
MOKit does #2.

Pros
  • Simple
Cons
  • NSView subclass may have bounds dimensions larger than you want at certain NSScrollView sizes. You need to keep track of the desired size when printing or getting data so that the bounds don't give you something larger than what you really want.
  • Need framework embedded in app or installed in Frameworks folder
OADocumentPositioningView does #3.

Pros
  • Simple
Cons
  • Need several frameworks embedded in app or installed in Frameworks folder
You will need to compile (if necessary) and copy the frameworks to ~/Library/Frameworks in order to play with the example code. And please don’t harass the authors because of my errors.

There are a number of hacks used to center a view in a scroll view, but most of them break under certain circumstances like scrolling with the mouse wheel or zooming a view by changing the bounds-to-frame ratio, or they require you to adjust your view bounds to add padding in order to "fake-out" the scroll view.

Since the NSClipView is the object responsible for positioning its subview when the subview is narrower and/or shorter than the clip view, the NSClipView is the proper object to address to alter this positioning behavior. NSScrollView is NOT the object to address for this behavior.

By subclassing NSClipView, we can simply change the standard behavior of shoving the subview into the corner, to one of centering the subview. While this method is not the ultimate solution, it is much better than forcing a view position during each pass through a drawRect: method (which wastes a lot of cpu time for views that provide live feedback) and much cleaner than adding padding to our subview bounds and watching frame changed notifications to readjust the padding.

In a nutshell, we want to change the NSClipView behavior so that anytime the frame rect changes or the view is scrolled, we want the subview to be re-centered in the clip view if it's small enough to do so. The basic steps are:
  1. Create SBCenteringClipView as a subclass of NSClipView
  2. Create a new method to calculate the center position of the subview
  3. Override the constrainScrollPoint: method to return coordinates for a centered subview
  4. Override the few frame methods that get called when the NSClipView frame changes so that the subview can be centered afterwards
  5. Swap a SBCenteringClipView object for a NSClipView object at runtime
In SBCenteringClipView.h:

//#import 

@interface SBCenteringClipView : NSClipView
{
	NSPoint _lookingAt; // the proportion up and across the view, not coordinates.
}

-(void)centerDocument;

@end

In SBCenteringClipView.m:

#import "SBCenteringClipView.h"

@implementation SBCenteringClipView

// ----------------------------------------

-(void)centerDocument
{
	NSRect docRect = [[self documentView] frame];
	NSRect clipRect = [self bounds];

	// The origin point should have integral values or drawing anomalies will occur.
	// We'll leave it to the constrainScrollPoint: method to do it for us.
	if( docRect.size.width < clipRect.size.width )
		clipRect.origin.x = ( docRect.size.width - clipRect.size.width ) / 2.0;
	else
		clipRect.origin.x = _lookingAt.x * docRect.size.width - ( clipRect.size.width / 2.0 );

	if( docRect.size.height < clipRect.size.height )
		clipRect.origin.y = ( docRect.size.height - clipRect.size.height ) / 2.0;
	else
		clipRect.origin.y = _lookingAt.y * docRect.size.height - ( clipRect.size.height / 2.0 );

	// Probably the best way to move the bounds origin.
	// Make sure that the scrollToPoint contains integer values
	// or the NSView will smear the drawing under certain circumstances.

	[self scrollToPoint:[self constrainScrollPoint:clipRect.origin]];
	[[self superview] reflectScrolledClipView:self];

	// We could use this instead since it allows a scroll view
	// to coordinate scrolling between multiple clip views.
	// [[self superview] scrollClipView:self toPoint:[self constrainScrollPoint:clipRect.origin]];
}

// ----------------------------------------
// We need to override this so that the superclass doesn't override our new origin point.

-(NSPoint)constrainScrollPoint:(NSPoint)proposedNewOrigin
{
	NSRect docRect = [[self documentView] frame];
	NSRect clipRect = [self bounds];
	float maxX = docRect.size.width - clipRect.size.width;
	float maxY = docRect.size.height - clipRect.size.height;

	clipRect.origin = proposedNewOrigin; // shift origin to proposed location

	// If the clip view is wider than the doc, we can't scroll horizontally
	if( docRect.size.width < clipRect.size.width )
		clipRect.origin.x = round( maxX / 2.0 );
	else
		clipRect.origin.x = round( MAX(0,MIN(clipRect.origin.x,maxX)) );

	// If the clip view is taller than the doc, we can't scroll vertically
	if( docRect.size.height < clipRect.size.height )
		clipRect.origin.y = round( maxY / 2.0 );
	else
		clipRect.origin.y = round( MAX(0,MIN(clipRect.origin.y,maxY)) );

	// Save center of view as proportions so we can later tell where the user was focused.
	_lookingAt.x = NSMidX(clipRect) / docRect.size.width;
	_lookingAt.y = NSMidY(clipRect) / docRect.size.height;

	return clipRect.origin;
}

// ----------------------------------------
// These two methods get called whenever the NSClipView's subview changes.
// We save the old center of interest, call the superclass to let it do its work,
// then move the scroll point to try and put the old center of interest
// back in the center of the view if possible.

-(void)viewBoundsChanged:(NSNotification *)notification
{
	NSPoint savedPoint = _lookingAt;
	[super viewBoundsChanged:notification];
	_lookingAt = savedPoint;
	[self centerDocument];
}

-(void)viewFrameChanged:(NSNotification *)notification
{
	NSPoint savedPoint = _lookingAt;
	[super viewFrameChanged:notification];
	_lookingAt = savedPoint;
	[self centerDocument];
}

// ----------------------------------------
// These NSClipView superclass methods change the bounds rect
// directly without sending any notifications,
// so we're not sure what other work they silently do for us.
// As a result, we let them do their
// work and then swoop in behind to change the bounds origin ourselves.
// This appears to work just fine without us having to
// reinvent the methods from scratch.
// ---
// Even though an NSView posts an NSViewFrameDidChangeNotification to the default notification center
// if it's configured to do so, NSClipViews appear to be configured not to. The methods
// setPostsFrameChangedNotifications: and setPostsBoundsChangedNotifications: appear
// to be configured not to send notifications.
// ---
// We have some redundancy in the fact that setFrame: appears to call/send setFrameOrigin:
// and setFrameSize: to do its work, but we need to override these individual methods in case
// either one gets called independently. Because none of them explicitly cause a screen update,
// it's ok to do a little extra work behind the scenes because it wastes very little time.
// It's probably the result of a single UI action anyway so it's not like it's slowing
// down a huge iteration by being called thousands of times.

-(void)setFrame:(NSRect)frameRect
{
	[super setFrame:frameRect];
	[self centerDocument];
}

-(void)setFrameOrigin:(NSPoint)newOrigin
{
	[super setFrameOrigin:newOrigin];
	[self centerDocument];
}

-(void)setFrameSize:(NSSize)newSize
{
	[super setFrameSize:newSize];
	[self centerDocument];
}

-(void)setFrameRotation:(float)angle
{
	[super setFrameRotation:angle];
	[self centerDocument];
}

@end

In our NSDocument subclass (or other appropriate object that owns the nib with the NSScrollView):

-(void)windowControllerDidLoadNib:(NSWindowController *) aController
{
	[super windowControllerDidLoadNib:aController];

	// graphicScrollView is an instance variable wired in IB to the NSScrollView

	id docView = [[graphicScrollView documentView] retain];
	id newClipView = [[SBCenteringClipView alloc] initWithFrame:[[graphicScrollView contentView] frame]];
	[newClipView setBackgroundColor:[NSColor windowBackgroundColor]];
	[graphicScrollView setContentView:(NSClipView *)newClipView];
	[newClipView release];
	[graphicScrollView setDocumentView:docView];
	[docView release];
}

It would be nice to do this with a category so that we don't have to manually swap out the NSClipView object at runtime, but we don't have a way to call the base object's methods if we override them in the category. We could instead use poseAsClass: to swap the NSClipView object, but either way, all NSClipViews would assume this behavior and that may not be what we always want.

It would also be nice to use notifications when the frame changes size instead of overriding the four frame methods, but I have not determined if the NSClipView has a single method that responds to the NSScrollView's frame changed notifications or whether the four NSClipView methods get called directly by the scrollview for expediency. The method we use here is safe for now.