Smooth Scrolling in a Table View for Custom Shortcuts

NSTableView comes with a couple of default shortcuts in an override of its keyDown(with:) method. These aren’t mentioned anywhere, so Xcode’s documentation quick help will repeat the NSView docstring to you, saying:

The receiver can interpret event itself, or pass it to the system input manager using interpretKeyEvents(_:). The default implementation simply passes this message to the next responder. [Emphasis mine]

The last sentence is misleading in this case: while the default implementation does nothing, NSTableView’s does something. Like bind the Page Up/Page Down keys to scroll its enclosingScrollView with an animation.

Now if you find yourself in a situation where you want to handle the keyboard event yourself, you maybe using:

class MyTableView: NSTableView {
    override func keyDown(with event: NSEvent) {
        nextResponder?.keyDown(with: event)
    }
}

This will, as the original docs indicate, forward the event.

In the responder chain, there’ll likely be a view controller – and that’s where I usually handle my key events. Outside the view, that is.

class MyTableViewController: NSViewController {
    override func keyDown(with event: NSEvent) {
        interpretKeyEvents([event]}
    }
}

By interpreting key events, all the default and nicely named NSResponder methods are available. So you can implement different shortcuts for moving the selection. Like +Arrow Keys to maybe jump only 10 items, and +Arrow Keys to go to the first/last row in the table.

In that case, you need to re-implement the paging keys again, though.

While NSScrollView implements pageUp(_:) and pageDown(_:), these aren’t animated. So the default table view implementation of the pagination keys does something different. And sadly, NSTableView does not react to these methods at all, so it’s hidden as an implementation detail in the key handler which we just overwrote.

Observation: What AppKit Seems to be Doing

The scroll view’s document view, aka the NSClipView, is being scrolled using a private _scrollTop:animateScroll:flashScrollerKnobs: method. I found that by subclassing NSClipView and adding a breakpoint to scroll(to:):

#0	0x00000001028d0c74 in MyClipView.scroll(to:) at AppDelegate.swift:21
#1	0x00000001028d0d30 in @objc MyClipView.scroll(to:) ()
#2	0x00000001b68dc4e0 in -[NSScrollView scrollClipView:toPoint:] ()
#3	0x00000001b68740a4 in -[NSClipView _scrollTo:animateScroll:flashScrollerKnobs:] ()
#4	0x00000001b718103c in -[NSScrollView _scrollPageInDirection:] ()
#5	0x00000001b710e44c in _NSPostProcessKeyboardUIKey ()
#6	0x00000001b6b133f8 in -[NSWindow keyDown:] ()
#7	0x00000001b69f11d4 in forwardMethod ()
#8	0x00000001b69f11d4 in forwardMethod ()
#9	0x00000001b69f11d4 in forwardMethod ()
#10	0x00000001b69f11d4 in forwardMethod ()
#11	0x00000001b6b57b94 in -[NSControl keyDown:] ()
#12	0x00000001b6b6b32c in -[NSTableView keyDown:] ()
#13	0x00000001028d0550 in MyTableView.keyDown(with:) at AppDelegate.swift:12

That’s not helping at all in terms of replicating the behavior, but it confirms my suspicion.

Possible fix: Animate scrolling manually

NSClipView’s scroll methods don’t seem to work with the animator() proxy.

But “scrolling” a scroll view vertically, for example, boils down to changing the clip view’s bounds.origin.y. And we can animate that.

In principle, the call is this:

myTableView
    .enclosingScrollView?
    .contentView
    .animator()
    .bounds.origin.y = /* new value here */

What’s the new Y value when you use the pagination keys?

NSScrollView has a verticalPageScroll property. It’s oddly named in my opinion, since it doesn’t equal to the “delta Y”, i.e. the scrolled pixels, but the amount of pixels that should not be scrolled when paging.

The default value is 10.0 and that means every page up/page down scrolls 100% of the available content’s height, minus 10pt. This is basically the visual anchor that is left on screen. Like when you scroll down, you see the bottommost row at the top position.

This is an estimate that works for my case to calculate the actual ‘delta y’:

extension NSScrollView {
    /// Calculates the offset or delta based on the scrollable content
    /// height and ``verticalPageScroll``, which determines how much
    /// of the previously visible content should remain visible.
    var verticalScrollDelta: CGFloat {
        contentView.bounds.height - verticalPageScroll
    }
}

Using this value, it’s far simpler to custom scroll and animate that.

// In doCommand or similar callbacks for interpreting the key events:
switch selector {
case #selector(NSResponder.scrollPageUp(_:)),
     #selector(NSResponder.pageUp(_:)):
    if let scrollView = myTableView.enclosingScrollView {
        let newOriginY = scrollView.contentView.bounds.origin.y
          - scrollView.verticalScrollDelta
        scrollView.contentView.animator().bounds.origin.y =
            max(0, newOriginY)
    }
case #selector(NSResponder.scrollPageDown(_:)),
     #selector(NSResponder.pageDown(_:)):
    if let scrollView = myTableView.enclosingScrollView {
        let newOriginY = scrollView.contentView.bounds.origin.y
            + scrollView.verticalScrollDelta
        scrollView.contentView.animator().bounds.origin.y =
            min(newOriginY, myTableView.bounds.height)
    }
// ...
}

This scrolls smoothly, and it looks just like the NSTableView default.

I also found that it was necessary to use origin.y = min(..., ...) and max(..., ...), respectively. Merely adding or subtracting the delta via -= and += didn’t animate properly, even though the resulting clip view’s viewport was properly clamped, i.e. there was no “overscroll”. But clamping the values manually isn’t a hassle, either.

Keep in mind that your scroll view’s configuration may be different: you may have insets that should be taken into account during these calculations. YMMV. I have no active insets, so the heights and offsets I use work fine.

Receive new .