Elastic or “stretchy” table headers are all over the place. They add that extra juice to a polished app, and provide a nice solution for the otherwise revealing native scroll view bounce in iOS. I’m talking about one of these…
Don’t forget that translucency thing Apple seems so fond of these days! A good header implementation should properly underlap the translucent navigation bar, and auto-adjust view margins to push content within the safe area! It’s subtle, but notice how the background image extends under the navigation bar, matching the behavior of other native views!
So graceful! So fluid! Just look at that Auto Layout spring! Clearly, this is a must-have. Ask any iOS developer, and they’ll happily explain their own favorite way of implementing an elastic header. Unfortunately, many of these techniques are “icky”.
The most common implementation is to have the UITableViewController
handle the layout and positioning of the header, usually in the viewDidLayoutSubviews()
method. This should be setting off all kinds of alarms. While it’s a quick and easy solution, it relies on a ViewController to dictate the layout and display of data… that’s kind of the point of a View, isn’t it? The ViewController is ideally responsible for the formatting of data, so that it can later be presented in a view. By handling the table header layout in the ViewController, we’re tightly coupling the implementation of the controller with the UI design of the app. The ViewController shouldn’t care whether the header is elastic or static. All it should care about is assigning the data for the table to display.
So what’s the plan?
We need to build a nice re-usable table view which does what we want, but maintains a consistent interface with a “normal” table view. This way, it can be a drop-in replacement for all the UITableView
instances in your app. ViewControllers shouldn’t care about layout.
Therefore, we need a UITableView
subclass which…
- features an elastic header.
- has an identical interface to UITableView
- plays nice with UIKit (nav-bars, safe area, etc.)
- can be loaded from a Storyboard
Let’s start off nice and simple. A new TableView subclass.
import Foundation import UIKit class ElasticHeaderTableView : UITableView { override func layoutSubviews() { // Obviously, something goes here! } }
We’ve immediately hit a problem! Table views already have a tableHeaderView
property which has all sorts of fancy logic attached to it! Any UIView
you assign to this field will mess with the content insets, tweak the scroll rect, and generally wreak havoc on our layout! Since we’re implementing custom layout logic, we can’t just update the rect of this view! We also can’t ignore it, since our target was to provide an identical interface to a standard UITableView
!
This is a great opportunity to do some overriding! How about we write a new get/set
, and forward the value to a new field? This effectively allows us to bypass the superclass’s implementation, since (as far as the Obj-C class definition is concerned), the stored value in the synthesized tableHeaderView
field will always be nil
.
override var tableHeaderView: UIView? { set { _elasticHeaderView = newValue } get { return _elasticHeaderView } } private var _elasticHeaderView: UIView? { willSet { _elasticHeaderView?.removeFromSuperview() } didSet { if let headerView = _elasticHeaderView { addSubview(headerView) bringSubviewToFront(headerView) updateHeaderMargins() let headerSize = headerView.systemLayoutSizeFitting(UIView.layoutFittingCompressedSize) contentInset.top = headerSize.height - safeAreaInsets.top contentOffset.y = -headerSize.height } } } private func updateHeaderMargins () { _elasticHeaderView?.directionalLayoutMargins = NSDirectionalEdgeInsets( top: safeAreaInsets.top, leading: safeAreaInsets.left, bottom: 0, trailing: safeAreaInsets.right) }
You’ll notice some observer blocks attached to the _elasticHeaderView
. The first is a willSet
. This will remove the existing header from the view hierarchy, so we don’t leave it around if the header view is changed! The second is a didSet
block. This will add the new header subview, calls a a method called “updateHeaderMargins”, and adjusts the content inset for the tableView to account for the new header height, so our table view rows don’t overlap the header. updateHeaderMargins()
will simply copy the safe area of the tableView into the header’s directional layout margins. This allows constraints to the view margin to factor in the portion of the tableView obscured by nav bars, or the iPhone X “notch”. While not “necessary” for many applications, this makes life a lot easier as you start using the class.
Alright, now we need to actually put things where they need to go! Every time the tableView is adjusted using Auto Layout or the scroll view scrolls, the elastic header must be adjusted to account for the new space. Let’s define a new function for that.
override var contentOffset: CGPoint { didSet { layoutHeaderView() } } override func layoutSubviews() { super.layoutSubviews() layoutHeaderView() } private func layoutHeaderView () { updateHeaderMargins() if let headerView = _elasticHeaderView { let headerSize = headerView.systemLayoutSizeFitting(UIView.layoutFittingCompressedSize) let stretch = -min(0, contentOffset.y + headerSize.height) let headerRect = CGRect( x: 0, y: -headerSize.height - stretch, width: bounds.width, height: headerSize.height + stretch) headerView.frame = headerRect contentInset.top = headerSize.height - safeAreaInsets.top } }
Here, we override layoutSubviews
and attach an observer to the contentOffset
parameter. This allows us to listen for changes to the scroll position, and the view layout without blocking the delegate outlet of the view, or requiring a viewController’s didLayout
method. Then, the layoutHeaderView
method does the majority of the work.
- First, we update the header margins. It’s possible that the safe area has changed since the last update, and it’s important to keep the margins current.
- Next, calculate the ideal size of the header using systemLayoutSizeFitting. This allows headers to be defined with Auto Layout constraints (which is super nice)
- Next, calculate the “stretch”. This is the distance the header must expand beyond its ideal height to account for the scroll view’s scroll position.
- Calculate a rect using all of these properties, pushing the header upward beyond the scroll view’s content. This allows the header to extend above the top of the scroll view. Assigning this new rect to the header view’s frame.
- Lastly, update the content inset of the scroll view to account for the possibility that a safe area has changed.
Now, we have a UITableView subclass which will automatically position a stretchy Auto Layout header whenever the view changes or is scrolled! The last thing to account for is storyboard compatibility! This is a nice easy fix, since we’ve already got a convenient method for it!
override func awakeFromNib() { super.awakeFromNib() let header = super.tableHeaderView super.tableHeaderView = nil _elasticHeaderView = header }
When the view loads from a Nib or Storyboard, fetch the tableHeaderView
from the superclass, set it to nil
, and push the view into our internal header view field. This last piece of the puzzle lets our custom UITableView
subclass play nicely with Storyboards. Dragging and dropping a new UIView
to the top of an elastic table view will automatically assign the header, just like any old view!
And there you have it!
With just under 100 lines of Swift, you can create an entirely self-contained UITableView with an elastic header!
It’s a drop-in replacement for any table view in your project, plays nice with Auto Layout, can be configured and loaded from a Nib or Storyboard, and requires no special treatment on the part of your ViewController!