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:

NSScrollView Constraints

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:

  1. Using the Size Inspector on your scrollview, change Intrinsic Size to Placeholder.
  2. 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!