|
Centering a NSClipView
There are a number of haques 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 throught 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:
- Create SBCenteringClipView as a subclass of NSClipView
- Create a new method to calculate the center position of the subview
- Override the constrainScrollPoint: method to return coordinates for a centered subview
- Override the few frame methods that get called when the NSClipView frame changes so that the subview can be centered afterwards
- Swap a SBCenteringClipView object for a NSClipView object at runtime
In SBCenteringClipView.h:
#import <AppKit/AppKit.h>
@interface SBCenteringClipView : NSClipView
{
}
-(void)centerDocument;
@end
In SBCenteringClipView.m:
#import "SBCenteringClipView.h"
@implementation SBCenteringClipView
// ----------------------------------------
-(void)centerDocument
{
NSRect docRect = [[self documentView] frame];
NSRect clipRect = [self bounds];
// We can leave these values as integers (don't need the "2.0")
if( docRect.size.width < clipRect.size.width ) clipRect.origin.x = roundf( ( docRect.size.width - clipRect.size.width ) / 2.0 );
if( docRect.size.height < clipRect.size.height ) clipRect.origin.y = roundf( ( docRect.size.height - clipRect.size.height ) / 2.0 );
// Probably the most efficient way to move the bounds origin.
[self scrollToPoint:clipRect.origin];
// We could use this instead since it allows a scroll view to coordinate scrolling between multiple clip views.
// [[self superview] scrollClipView:self toPoint: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];
NSPoint newScrollPoint = proposedNewOrigin;
float maxX = docRect.size.width - clipRect.size.width;
float maxY = docRect.size.height - clipRect.size.height;
// If the clip view is wider than the doc, we can't scroll horizontally
if( docRect.size.width < clipRect.size.width ) newScrollPoint.x = roundf( maxX / 2.0 );
else newScrollPoint.x = roundf( MAX(0,MIN(newScrollPoint.x,maxX)) );
// If the clip view is taller than the doc, we can't scroll vertically
if( docRect.size.height < clipRect.size.height ) newScrollPoint.y = roundf( maxY / 2.0 );
else newScrollPoint.y = roundf( MAX(0,MIN(newScrollPoint.y,maxY)) );
return newScrollPoint;
}
// ----------------------------------------
// These two methods get called whenever the subview changes
-(void)viewBoundsChanged:(NSNotification *)notification
{
[super viewBoundsChanged:notification];
[self centerDocument];
}
-(void)viewFrameChanged:(NSNotification *)notification
{
[super viewFrameChanged:notification];
[self centerDocument];
}
// ----------------------------------------
// These 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.
- (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.
Brock Brandenberg
|