This is a collection of notes on using floating subviews in a a scroll view within an AppKit Mac app. When you add a floating subview, it scrolls in sync with the document along one axis, while remaining fixed in position along the other axis, and it does so efficiently and with minimal code.
I wanted to publish these notes after adopting floating subviews earlier this year and finding some gaps in the documentation, some surprising behaviors, and not many good search results for my questions. (AppKit developers often run into this, especially for technologies which are so different from their UIKit equivalents.) The below notes are an attempt to capture, in one place, some key aspects of how floating subviews work and some things to watch out for.
Note that this will all make a lot more sense if you’re already familiar with
NSScrollView
; in particular, with how it works with NSClipView
to
implement scrolling over its document view (which is all flattened together
in the much simpler UIKit model).
Motivation
Among a TimeStory document’s subviews are some which float along an axis; for example, the time index at the top, which scrolls left and right with the document body but stays at the same place vertically.
Back during 2.2, I switched some of these views to start using floating subviews; previously, I had beeen using bounds-change notifications to directly synchronize their layouts, and this switch simplified the code and improved scrolling performance. Worth doing, but with a few gotchas waiting; see “Caveats”, below.
Basics
- A good but not very deep overview can be found back in WWDC13, when floating subviews were introduced.
- In
addFloatingSubview(_:for:)
, the supplied axis names the axis along which your floating subview does not scroll. - This will result in your
NSScrollView
first creating, if needed, a direct subview of private type_NSScrollViewFloatingSubviewsContainerView
, as a sibling to and Z-ordered on top of its clip view (NSScrollView.contentView
). Your floating subviews are added to this private container. You should ignore this container except when debugging, but it helps to understand how it works. - AppKit may create more than one such container, if you create these subviews over time. Don’t assume all your floating subviews have the same parent, and don’t assume that they don’t.
- The floating-subview containers will synchronize their frame rect (position) with the clip view and their bounds origin’s coordinate along the non-floating axis with the clip view’s corresponding bounds origin coordinate. The other bounds origin coordinate will be set to zero. The view’s scale will always be 1.0; that is, its bounds size will always equal its frame size. This is important.
Caveats
- You can’t really mix floating subviews with
NSScrollView.magnification
. If the magnification is anything but 1.0, then your clip view’s bounds size will be scaled relative to its frame size, but the floating subview container view’s bounds won’t. This means that the floating subviews don’t magnify, and that relative placement of floating subviews will not match relative placement within your document view. (I ultimately resolved this by doing my own math on subview placement and magnifying within the document view and floating views.) - After programmatically scrolling your main clip view, if the floating
subviews are no longer aligned, calling
NSScrollView.reflectScrolledClipView(_:)
will synchronize them. I found this necessary in some cases. - It would be nice to be able to use
NSView.backgroundFilters
to easily apply a Core Image filter (such as a blur) under the floating subview and over the scrolled document view. This won’t work, since the document view and the floating view have sibling parents. Layer background filters work over ancestor layers or sibling layers, but not over “cousin” layers like this.