Overriding Scrollbar Behavior in OS X

ScrollbarI recently ran into a situation where I wanted to significantly customize the behavior of a scrollbar in a Cocoa WebView and was disappointed in how little access I had to it. There are two factors that complicated my situation. First was the fact that the text I was displaying in the WebView was being loaded dynamically. As the user scrolls up or down, Javascript methods call into my Objective-C code to request additional blocks of text to display either above or below the text already displayed. As the user scrolls up (or down), blocks that are “too far” below (or above) the displayed text are removed by the Javascript on the page. As a result, the built-in WebView scrollbar is displaying completely irrelevant information.

Second, the feature of OS X 10.7+ that allows the user to control whether or not scrollbars are always displayed interfered with my attempts to simply drop an NSScroller on top of the region of the WebView where the scrollbar appears. If the user turned on “overlay scrollbars”, the WebView would re-wrap the text so that it continued under my scrollbar. On top of that, when the system drew its scrollbar, it would draw it on top of mine, even though mine was on top of the WebView in z-order. Furthermore, the WebView would “scroll” the pixels on my scrollbar in the process of scrolling its own text.

It was a mess.

The solution is fairly simple, though:

  1. Drop an NSScroller instance over the right side of the WebView in Interface Builder. The width doesn’t matter as we’ll adjust it in -awakeFromNib to match the width of a standard-sized NSScroller.
  2. In the window’s -awakeFromNib method, enable the NSScrollbar instance, set its size to NSRegularControlSize, and set its initial knob size and position. Also set the WebView‘s scroller style to “legacy”:
    [[[[[webView mainFrame] frameView] documentView] enclosingScrollView] setScrollerStyle:NSScrollerStyleLegacy];
  3. As blocks are loaded and the Javascript reports its position within the displayed text, update the position of the knob.
  4. Connect the NSScroller action to a handler in the code to position the text appropriately with changes to the knob.
  5. Add a handler to intercept distributed notifications:
    [[NSDistributedNotificationCenter defaultCenter] addObserver:self selector:@selector(observeDistributedNotifications:) name:nil object:nil];
  6. In the handler for the distributed notifications, look for the notification named AppleShowScrollBarsSettingChanged and when it occurs, call a method that forces the WebView scrollbar back to the “legacy” style. You must call this method on a short delay because notifications are not delivered in any particular order. You need to allow the system to change the WebView scroller style before you change it back.

That’s it. The basic idea is to provide your own NSScroller that is updated by position changes in the WebView and which moves the WebView as the user interacts with it. This scrollbar lays on top of the WebView scroller so the user can’t see it. And to keep OS X from inappropriately drawing, we intercept system preference changes that would alter the style of the WebView scroller.

I think this solution would work with any Cocoa view object that is based on NSScrollView. One notable side-effect is that your scrollbar does not auto-hide. In my case, I can live with this.