Building Sticky Headers for Web-based UI
I have previously blogged about how to build sticky headers for UICollectionView which mainly concerns when to pick up clues to transform each header element, and working out the minimal and maximum offset extents you can use to shift each element.
I recently had to implement sticky headers for a Web-based UI which was fraught with much peril. It is my hope that the following notes will be of your use.
A âsticky headerâ discussed in this article is something in a long, usually scrollable list that stays fixed to the top edge of its clipping view. In lists with multiple segments, each segment may have its own sticky header, and when the end user scrolls they will expect the upcoming header to push an existing header âout of sightâ.
Usually, you will end up implementing a repositioning subroutine which is invoked by some kind of event callback. On iOS this can happen in -layoutSubviews on your scroll view subclass where you query the offset, on the Web it may happen when your onScroll event handler is fired.
Within your callback, you will usually take the following steps:
Find all headers that need to be repositioned. This is usually done by first inferring where all headers normally rest if they were not to be sticky. The usual resting position for each header and each section is then considered, and you will end up with a collection of sections that are currently visible in the clipping view.
Each currently visible section should have its header visible, and if its header is currently out of bounds, it will need to be repositioned. The second step is to determine, for each header view, how much transformation needs to be applied so the header remains visible. At this step, you will compute the minimum and maximum extents that the header view can be in, and through your own derivation you will end up with a precise offset.
After all the offsets are computed you will apply the transformations to each header view.
Shifting Elements Properly
In order to write the subroutine mentioned above that makes your headers âstickyâ you will need to figure out:
Where each header element is at
Where each group holding its header element is at
Where the clipping view is at, and what its offsets are
After all that information is gathered you can compute the proper offsets, and you will need to figure out:
A way to quickly shift each header element that needs shifting to the right position, and reset each header element, that does not need shifting, back to its usual position.
Letâs dissect each step. First of all there is more than one way to figure out where an element sits, but the best way to know about an elementâs position is by querying getBoundingClientRect() (MDN, MSDN, eJohn, CanIUse). You may find that offsetTop is faster, though, and it may work for your use case. The thing about getBoundingClientRect() is that it also considers all transforms applied, which you may want to note for later.
By using getBoundingClientRect(), you get measurements and fulfill prerequisites 1, 2 and 3. Afterwards, you can write a subroutine as follows:
If a group is entirely out of view, nothing in it requires shifting.
Otherwise the header of that group should be sticky and the offset can probably be computed as:
desiredY = Math.min(Math.max(accordionRect.top, containerRect.top), accordionRect.bottom - headerRect.height)
This allows you to make the header sticky but not protruding. Note that by using getBoundingClientRect(), all measurements are relative to the viewport, meaning that you will not need to do any kind of transformations yourself: all of that work is normalized for you, at a very reasonable performance budget.
Now you know how much to shift each element, by comparing the desired position with its current position.
currentY = headerRect.top offsetY = desiredY - currentY
There is more than one way to shift an element, but there is only one way which is the fastest: translate3d() (CanIUse, TreeHouse). You can apply this as a transformation like this:
headerElement.style.transform = "translate3d(0, #{offsetY}px, 0)"
Just be careful that, as previously mentioned, any transformation will be taken into account when computing getBoundingClientRect(), there are two solutions around this:
Explicitly empty all transformations on all elements that could possibly have been transformed â giving them translate3d(0,0,0).
Note down previous offsets and deduct these offsets when computing resting rects.
I went with the first approach as it is surprisingly fast, anyway.
Scrolling, Event Callbacks and Compositor Behavior
Now you have a repositioning routine going, it is time to look at when to apply it. If you simply ran this code in scroll you may be surprised to find that it is quite jittery in Safari, or that it is inconsistently jittery on a couple devices and not other devices.
The main problem with approaching this for the Web is that most things can now be hardware accelerated, and the true meaning of a scroll event has changed. In the old days, the scroll event is always fired and the entire clipped area is repainted, so when you get the scroll event, you are sure that the underlying content has shifted by an integral number of points and that you can rely on it for your calculation. You are also sure that your layout changes will be committed, and most importantly your code will be called on every single frame that the browser has to compute.
Since repainting is too expensive and end users want to flick through an entire document quickly, the solution was to utilize the compositor and scroll asynchronously. In other words, the scroll event can be fired asynchronously and may not fire on every frame that the compositor does work in. For example, in Safari on El Capitan, the Scroll event is fired every other frame whilst the compositor works on every frame.
In plain English, this means that if you shift your headers around in a scroll event callback and rely on the browser to call your code on every frame, it wonât work. Your headers will jump around and look jittery. Your product starts to look like itâs made by sad elves.
If youâre not particularly resourceful to possess multiple different configurations to test your product on, you may not even discover this issue. (For example, the latest Chrome works fine and calls scroll on every frame, but when the same version of Chrome is run on the new MacBook it does something different.)
I personally think that this change was, ironically, only required because people were doing too much stuff in their scroll handlers, so in order to achieve smooth scrolling, the browser engineers had to make certain things asynchronous.
WebKit #45631: Scroll event should be fired asynchronously
You may think of using the wheel event but itâs even worse. The root cause is that your code is not in control of layout on every frame. The solution, therefore, is to regain control without sacrificing the smoothness afforded by hardware-accelerated scrolling.
There is more than one way to reposition a couple elements in a scrollable content area. Earlier in this article we determined that using CSS transformation is the best way to reposition a header; you may be tempted to make these elements position: fixed and reposition them manually, but that is pure madness because now you have to do an insane amount of housekeeping to ensure that the underlying layout flow still works, and it is still not repositioning on every frame. If you have another application framework on top of this (like Ember, or AngularJS) which generates elements this approach is nearly impossible.
There is only one solution left, which is to circumvent the browser and build your own scrolling handler âproperlyâ. That is a lot of responsibility.
You must be in control of every frame. The solution to header repositioning jitter, introduced by idiosyncracies, is to implement hardware-accelerated scrolling, mutated by your own event listeners only, with deterministic guarantee that each offset phase invokes your repositioning code.
In other words, the scrolling wrapper should only update its offset when your header repositioning callback has worked out how to shift visible headers so they remain sticky.
This is not a new concept. Back in the old days, Cappuccino used a transparent element, covering the entire viewport, to accept events and to proxy them, so first responder support and other Cocoa-like event handling logic can be ported properly. To build your own scroller, you will need to catch all kinds of events that usually result in the wrapper scrolling its content, and offset your child (or children) accordingly.
Fortunately, you can do this quite easily with iScroll as long as you use the Probe Edition. (The author of iScroll is quite proud of the many âfacesâ of iScroll.) You can initialize an iScroll element as such:
iScroll = new IScroll $element[0], mouseWheel: true scrollbars: true fadeScrollbars: true probeType: 3 disableTouch: true disableMouse: true disablePointer: true iScroll.on 'scroll', -> float() # your code
This more or less guarantees deterministic behavior. (Some additional tweaks were added by me in order to make it behave with jQuery UI Sortable.)
You can read more about the probeType option here:
This regulates the probe aggressiveness or the frequency at which the scroll event is fired. Valid values are: 1, 2, 3. The higher the number the more aggressive the probe. The more aggressive the probe the higher the impact on the CPU. probeType: 1 has no impact on performance. The scroll event is fired only when the scroller is not busy doing its stuff. probeType: 2 always executes the scroll event except during momentum and bounce. This resembles the native onScroll event. probeType: 3 emits the scroll event with a to-the-pixel precision. Note that the scrolling is forced to requestAnimationFrame (ie: useTransition:false).
This basically means that probeType 3 is precisely what we want.
Nevertheless, there is one hidden problem you may then discover if you do a lot of testing: iScroll may not resize itself properly when its content size changes (#596, #642). After learning more about flow events (a, b), I decided to not do it that way. Instead I went with Resize Sensor which is part of CSS Element Queries.
You can then attach a Resize Sensor to the wrapper, and a Resize Sensor to the content, and call iScrollâs refresh() method accordingly.
This article will not be complete without a functional AngularJS directive and sample code so here it is.
You may find it annoying that iScroll only translates the first element in its wrapper. Certain directives out in the wild (a, b) have required use of nested elements, but I think that is not elegant enough.
By utilizing AngularJS Transclusion (teropa), you can generate the wrapper for your child elements automatically. For example, the following markup triggers an esHeaderFloater directive:
%div{ 'es-header-floater' => '.panel-heading' } %div{ 'ng-if' => 'currentStudent' } = render 'shared/portfolio_content' âŚ
If you use transclusion properly like thisâŚ
es_app.directive 'esHeaderFloater', ['$rootScope', ($rootScope) -> template: '<div ng-transclude></div>', transclude: true, link: ($scope, $element, attrs) -> $scope.iScroll = new IScroll($element[0], âŚ) ]
âŚthen at execution time, the content wrapper will be generated automatically for you, without you having to litter wrapper elements everywhere in your codebase. This is also good for future-proofing when at a certain time in the future, you can eliminate use of iScroll as a clutch without having to modify all calling sites again.
Lastly, be sure to destroy iScroll and remove all Resize Sensors when the scope of your directive is being destroyed.