Centered NSScrollView
During the last couple of months, I’ve been working on several side projects ranging from compilers to other stuff I still need to announce, but a pattern I have noticed is that every time I need something quite specific, those side projects ends up deriving other side projects, placing me on a loop of never-ending side projects. To be honest, I have no idea to which extent this can be considered healthy, but hey! Side projects are fun!
This post is not about anyting special, but to give you a little context, I’ve been working on an embedded system to display anything I need in a somewhat pretty fashion. To make things even more fun, I decided to put Sharp’s Memory Display to good use, and for that, I also decided to mimic System 6’s UI. In my very humble opinion, System 6 — to be honest, the whole System 1-6 family — was a remarkable breakthrough for modern personal-computing. In case you are not acquinted with it, I suggest watching Susan Kare explains Macintosh UI ergonomics on the Computer Chronicles.
So, let’s step back from history itself and go to the point of this post. Writing that UI from scratch is a really fun (and mind-blowing) experience, and I do have some interesting hardware constraints to deal with (like having 32Kb of RAM), so keeping things minimal is really important. To handle images and icons, I ended up working on a really naïve implementation of a bitmap format, which ended up being quite an interesting adventure itself, but then I just realised drawing all those monochromatic images using zeroes and ones was a bit too much. I immediately thought about using aseprite, but I also always wanted to try to implement some graphical application, and given we have lots of time to spend on this little project, why not put my rusted (no pun intended) Cocoa skills to use?
Everything was going great until I found a major problem regarding
NSScrollView
, its clipview, centering, and zooming. We can find several folks
discussing this very same “problem” on StackOverflow and other places, but all
answers are dated, and Apple’s documentation wasn’t that helpful either. To be
honest, Cocoa development in general seems to be a quite wild field. There’s not
really much content available online, and folks seem to be keeping all their
secrets to themselves.
So, how can we center a document view in an NSScrollView
without jumping
through many hoops? (I do invite you to picture those hoops on fire, just for
the extra dramatic effect.)
It happens that AppKit being the monstrous legacy it is (20+ years, give or take), autolayout can’t save us every time. You can try to hook up some constraints, just to notice it will just not work at all (and if you insist, things will go awry, trust me.) This can be extra frustrating for folks coming from UIKit, which being so much younger than AppKit, makes dealing with this kind of stuff a walk in the park.
Achieving this required a bit of tinkering with a lot of dusty posts from all around the interwebs, but this is how I finally figured out how to make it work.
Create a Simple Project
Start by creating an empty Xcode project, I’ll be using Swift and Storyboards.
Once you have your view controller, place a NSScrollView
into it, and attach
some constraints to keep it the same size as its parent view:
Centered ClipView
NSScrollView
is composed by the scrollview itself, a NSClipView
, responsible
for doing some magic that provides the scrollview all its features, and finally,
the view to be displayed in the scrollview. In order to maintain everything
centered, we will need to subclass NSClipView
in order to align everything as
needed:
import AppKit
final class CenteringClipView: NSClipView {
override func constrainBoundsRect(_ proposedBounds: NSRect) -> NSRect {
var constrainedClipViewBounds = super.constrainBoundsRect(proposedBounds)
guard let documentView = documentView else {
return constrainedClipViewBounds
}
let documentViewFrame = documentView.frame
// If proposed clip view bounds width is greater than document view frame width, center it horizontally.
if documentViewFrame.width < proposedBounds.width {
constrainedClipViewBounds.origin.x = floor((proposedBounds.width - documentViewFrame.width) / -2.0)
}
// If proposed clip view bounds height is greater than document view frame height, center it vertically.
if documentViewFrame.height < proposedBounds.height {
constrainedClipViewBounds.origin.y = floor((proposedBounds.height - documentViewFrame.height) / -2.0)
}
return constrainedClipViewBounds
}
}
Then, head to the Document Outline panel, pick the scrollview’s clip view, and
use the Identity Inspector to set CenteringClipView
as its class. That will
be enough to keep things centered in the NSScrollView
.
The Document View
The document view is another thing that needs our attention. First, let’s
implement a simple NSView
subclass to let us actually see the view. Create
a new SimpleView.swift
file with those contents:
final class SimpleView: NSView {
override var wantsUpdateLayer: Bool {
return true
}
required init?(coder: NSCoder) {
super.init(coder: coder)
self.translatesAutoresizingMaskIntoConstraints = false
}
override func updateLayer() {
layer!.backgroundColor = NSColor.blue.cgColor
}
override var intrinsicContentSize: NSSize {
return NSSize(width: 64, height: 64)
}
}
To summarise the contents of that file, we override wantsUpdateLayer
, so we
can use updateLayer
to handle the drawing (instead of relying on CPU-bound
operations). Then, we also override our required initialiser to set
translatesAutoresizingMaskIntoConstraints
to false
. Otherwise, our (final)
overridden intrinsicContentSize
will not work.
There are two extra changes I also made using Interface Builder that provided me some positive results:
- Using the Size Inspector on your scrollview, change Intrinsic Size to Placeholder.
- Still using the Size Inspector, this time on your Document view (that one inside the clip view), disable all autoresizing masks.
The result is a fully functional NSScrollView that centers its contents and also doesn’t mangle magnification gestures nor anything of the likes.
That’s it!