Synchronize Scrolling of Two (or More) NSScrollViews

You can make two NSScrollViews scroll in concert quite easily because every scrolled pixel is broadcasted to interested parties.

Rows in TableFlip
Rows in TableFlip

In TableFlip, the main table is a NSTableView contained in a NSScrollView. You can view and hide row numbers in TableFlip; but I didn’t want to reload the whole table and mess with the table model to insert and remove the first column. Instead, I use a second table view with a single column. The upside of this approach: I can animate hiding the whole scroll view with the row numbers inside easily without affecting the main table.

Synchronizing two or more scroll views is pretty simple: upon scrolling, the NSScrollView’s NSClipView can post a NSView.boundsDidChangeNotification. Simply subscribe to that.

Note that you need to enable posting the notification first: set NSView.postsBoundsChangedNotifications = true for the NSClipView that you want to observe.

I put the logic for this into a NSScrollView subclass with an @IBOutlet to the scroll view that the current one should be synced to. This way, I can wire them in Interface Builder and don’t have to write code for that.

class SynchronizedScrollView: NSScrollView {

    @IBOutlet weak var sourceScrollView: NSScrollView!
    lazy var notificationCenter: NotificationCenter = NotificationCenter.default

    deinit {
        notificationCenter.removeObserver(self)
    }

    override func awakeFromNib() {

        super.awakeFromNib()

        let scrollingView = sourceScrollView.contentView
        scrollingView.postsBoundsChangedNotifications = true

        notificationCenter.addObserver(self, 
            selector: #selector(scrollViewContentBoundsDidChange(_:)), 
            name: NSView.boundsDidChangeNotification, 
            object: scrollingView)
    }

    @objc func scrollViewContentBoundsDidChange(_ notification: Notification) {

        guard let scrolledView = notification.object as? NSClipView else { return }

        let viewToScroll = self.contentView
        let currentOffset = viewToScroll.bounds.origin        
        var newOffset = currentOffset
        newOffset.y = scrolledView.documentVisibleRect.origin.y

        guard newOffset != currentOffset else { return }

        viewToScroll.scroll(to: newOffset)
        self.reflectScrolledClipView(viewToScroll)
    }
}