Building a stretchable UITableView header
Someone on Twitter recently asked how to implement a TableView header that will stretch and resize as the content is scrolled, so I thought I’d spend a few minutes to provide a good example. Since I thought it’s a neat trick that’s often overlooked, I felt it was worthy of wrapping a post around it to explain how it works, maybe giving others the ability to replicate this pattern for themselves.
TL;DR; If you don’t want to know how it works, you can skip ahead to the solution, or you can go to the sample project at the end.
Many apps exhibit this behavior, but it may not be intuitive at first glance. The header techically scrolls with the content, but only to a point, so you can’t make the content a part of the scrollable region itself. I’ve implemented this feature on a number of different apps in my career, and implementing it cleanly can help developers create very visually-engaging designs.
Lets break down the problem so we know what we need to solve, and how we might implement this UI feature.
- The header view needs to be added somewhere within the table view so it can be seen by the user, without covering up the scroll indicator.
- The first few cells of the table shouldn’t be hidden by the header.
- As the content is scrolled, the height of the header should shrink, causing the content within it to change its size.
- (Optional) Once the header reaches some minimum height, it should scroll off the screen to not obscure the content below it.
UITableView refresher
Before we dig into solving this problem, lets remind ourselves that UITableView (as well as UICollectionView) is a subclass of UIScrollView.
UITableView.h
@interface UITableView : UIScrollView
@property (nonatomic, weak, nullable) id <UITableViewDelegate> delegate;
@property (nonatomic, strong, nullable) UIView *backgroundView;
// ...
@end
@protocol UITableViewDelegate <NSObject, UIScrollViewDelegate>
// ...
@end
UITableView.swift
open class UITableView : UIScrollView {
weak open var delegate: UITableViewDelegate?
open var backgroundView: UIView?
// ...
}
public protocol UITableViewDelegate : UIScrollViewDelegate {
// ...
}
This means that all the capabilities that UIScrollView provides to us is available within tables and collection views. Additionally, you can see that UITableViewDelegate is a subprotocol of UIScrollViewDelegate.
backgroundView
property
Another property to look out for is the backgroundView
property. There’s a comment in the headers which has this to say:
the background view will be automatically resized to track the size of the table view. this will be placed as a subview of the table view behind all cells and headers/footers. default may be non-nil for some devices.
This solves our first requirement, which is to find someplace to hold our view. Moving on to our other requirements, it would be good to take a closer look at UIScrollView.
UIScrollView refresher
As I already mentioned above, UITableView is a subclass of UIScrollView. Scroll views are very powerful, with a ton of knobs and dials you can use to customize its behavior. Lets look into some of those properties to see what we can use to implement our header view.
UIScrollView.h
@interface UIScrollView : UIView
@property (nonatomic) CGPoint contentOffset;
@property (nonatomic) UIEdgeInsets contentInset;
@property(nullable,nonatomic,weak) id<UIScrollViewDelegate> delegate;
// ...
@end
@protocol UIScrollViewDelegate <NSObject>
@optional
- (void)scrollViewDidScroll:(UIScrollView *)scrollView;
// ...
@end
UIScrollView.swift
open class UIScrollView : UIView {
open var contentOffset: CGPoint
open var contentInset: UIEdgeInsets
weak open var delegate: UIScrollViewDelegate?
// ...
}
public protocol UIScrollViewDelegate : NSObjectProtocol {
optional func scrollViewDidScroll(_ scrollView: UIScrollView)
// ...
}
These properties here provide all the final details needed to implement our header! The contentOffset
property gives us the view’s scroll position, contentInset
is used to adjust where the content begins (to push cells
down below the header), and the -[UIScrollViewDelegate scrollViewDidScroll:]
UIScrollViewDelegate.scrollViewDidScroll
delegate method tells you when the content is scrolled, so you can calculate
and resize the header view’s frame.
Implementing a stretching UITableView header
The root of the solution is to realize that UITableView
is a subclass of UIScrollView
. The delegate
UITableView.delegate
property is a UITableViewDelegate
protocol, and since that is a sub protocol of UIScrollViewDelegate
, you can implement the -[UIScrollViewDelegate scrollViewDidScroll:]
UIScrollViewDelegate.scrollViewDidScroll
method to adjust the frame of your header view as needed. This method will be invoked any time the contentOffset
property is changed, either as the user manually drags the content around, or when animations are being performed, so it’s safe to use as a moment-in-time measure for where the content has been scrolled.
Adding the view to the table’s backgroundView
Our first step is to add the header view we want to show the user to the table’s background view, and adjust the contentInset
property to ensure the UITableViewCell
s don’t cover it up.
viewDidLoad.m
- (HeaderView *)headerView {
if (_headerView == nil) {
UINib *nib = [UINib nibWithNibName:@"HeaderView" bundle:nil];
_headerView = [nib instantiateWithOwner:self options:nil].firstObject;
}
return _headerView;
}
- (void)viewDidLoad {
[super viewDidLoad];
self.headerView.autoresizingMask = (UIViewAutoresizingFlexibleWidth |
UIViewAutoresizingFlexibleHeight);
self.headerView.titleLabel.text = @"The title of the header";
self.headerView.frame = CGRectMake(0,
self.tableView.safeAreaInsets.top,
CGRectGetWidth(self.tableView.frame),
250);
self.tableView.backgroundView = UIView.new;
[self.tableView.backgroundView addSubview:self.headerView];
self.tableView.contentInset = UIEdgeInsetsMake(250, 0, 0, 0);
}
viewDidLoad.swift
var headerView: HeaderView = {
let nib = UINib(nibName: "HeaderView", bundle: nil)
return nib.instantiate(withOwner: self, options: nil).first as! HeaderView
}()
override func viewDidLoad() {
super.viewDidLoad()
headerView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
headerView.titleLabel.text = "The title of the header"
headerView.frame = CGRect(
x: 0,
y: tableView.safeAreaInsets.top,
width: view.frame.width,
height: 250)
tableView.backgroundView = UIView()
tableView.backgroundView?.addSubview(headerView)
tableView.contentInset = UIEdgeInsets(
top: 250,
left: 0,
bottom: 0,
right: 0)
}
In this example I’ve defined the layout of the header view in a nib to simplify the code. The viewDidLoad
method just:
- Configures the header view and its initial size.
- Creates a fresh background view to be used within UITableView, and adds the header view as a subview.
- Adjusts the
contentInset
of the table view to expose the header.
You can see however that scrolling the table view will still simply overlap the header’s content.
If you look closely though, you’ll notice that there’s a small bug. The origin of the header view is positioned below the navigation bar. This is because the safeAreaInsets
aren’t set yet when viewDidLoad
gets called. This means when we adapt our code to adjust the size of the header as the table is scrolled, we’ll need to take the top of the visible area into account when positioning and sizing the header.
Updating the header frame when the scroll position changes
There are three major calculations that need to be made whenever the scroll position changes:
- Determine the minimum height the header can occupy.
- Calculate the height of the header such that it extends all the way from the top of the visible area to the top of the first cell of content.
- Find the vertical offset of the frame, in the event the header is being scrolled off the top of the screen.
These calculations also need to be updated whenever the device changes orientations, or the application’s trait collections change (e.g. iPad split screen). To simplify the code, and to improve performance, I’ve put this business logic within the header view class itself.
In my example, I’m using a Nib file in Interface Builder to define the layout, but you could just as easily define it either within a storyboard or in code.
HeaderView.h
@interface HeaderView : UIView
@property (strong, nonatomic, readonly) UIImageView *imageView;
@property (strong, nonatomic, readonly) UILabel *titleLabel;
@property (nullable, weak, nonatomic) IBOutlet UIScrollView *scrollView;
- (void)updatePosition;
@end
HeaderView.m
#import "HeaderView.h"
@interface HeaderView ()
@property (strong, nonatomic, readwrite) IBOutlet UIImageView *imageView;
@property (strong, nonatomic, readwrite) IBOutlet UILabel *titleLabel;
@property (strong, nonatomic) IBOutlet UILabel *sizeLabel;
@property (nonatomic, assign) CGSize cachedMinimumSize;
@property (nonatomic, assign) CGFloat minimumHeight;
@end
@implementation HeaderView
- (void)layoutSubviews {
[super layoutSubviews];
self.sizeLabel.text = [NSString stringWithFormat:@"%0.1fx%0.1f",
CGRectGetWidth(self.frame),
CGRectGetHeight(self.frame)];
}
// Calculate and cache the minimum size the header's constraints will fit.
// This value is cached since it can be a costly calculation to make, and
// we want to keep the framerate high.
- (CGFloat)minimumHeight {
UIScrollView *scrollView = self.scrollView;
if (scrollView == nil) {
return 0;
}
CGFloat width = CGRectGetWidth(scrollView.frame);
if (!CGSizeEqualToSize(self.cachedMinimumSize, CGSizeZero)) {
if (self.cachedMinimumSize.width == width) {
return self.cachedMinimumSize.height;
}
}
CGSize size = [self systemLayoutSizeFittingSize:CGSizeMake(width, 0)
withHorizontalFittingPriority:UILayoutPriorityRequired
verticalFittingPriority:UILayoutPriorityDefaultLow];
self.cachedMinimumSize = size;
return size.height;
}
- (void)updatePosition {
UIScrollView *scrollView = self.scrollView;
if (scrollView == nil) {
return;
}
// Calculate the minimum size the header's constraints will fit
CGFloat minimumSize = self.minimumHeight;
// Calculate the baseline header height and vertical position
CGFloat referenceOffset = scrollView.safeAreaInsets.top;
CGFloat referenceHeight = scrollView.contentInset.top - referenceOffset;
// Calculate the new frame size and position
CGFloat offset = referenceHeight + scrollView.contentOffset.y;
CGFloat targetHeight = referenceHeight - offset - referenceOffset;
CGFloat targetOffset = referenceOffset;
if (targetHeight < minimumSize) {
targetOffset += targetHeight - minimumSize;
}
// Update the header's height and vertical position.
CGRect headerFrame = self.frame;
headerFrame.size.height = MAX(minimumSize, targetHeight);
headerFrame.origin.y = targetOffset;
self.frame = headerFrame;
}
@end
HeaderView.swift
class HeaderView: UIView {
@IBOutlet private(set) var imageView: UIImageView!
@IBOutlet private(set) var titleLabel: UILabel!
@IBOutlet private var sizeLabel: UILabel!
@IBOutlet weak var scrollView: UIScrollView?
private var cachedMinimumSize: CGSize?
override func layoutSubviews() {
super.layoutSubviews()
// Debugging label to demonstrate the size of the header view.
sizeLabel.text = "\(frame.width)x\(frame.height)"
}
// Calculate and cache the minimum size the header's constraints will fit.
// This value is cached since it can be a costly calculation to make, and
// we want to keep the framerate high.
private var minimumHeight: CGFloat {
get {
guard let scrollView = scrollView else { return 0 }
if let cachedSize = cachedMinimumSize {
if cachedSize.width == scrollView.frame.width {
return cachedSize.height
}
}
// Ask Auto Layout what the minimum height of the header should be.
let minimumSize = systemLayoutSizeFitting(CGSize(width: scrollView.frame.width, height: 0),
withHorizontalFittingPriority: .required,
verticalFittingPriority: .defaultLow)
cachedMinimumSize = minimumSize
return minimumSize.height
}
}
func updatePosition() {
guard let scrollView = scrollView else { return }
// Calculate the minimum size the header's constraints will fit
let minimumSize = minimumHeight
// Calculate the baseline header height and vertical position
let referenceOffset = scrollView.safeAreaInsets.top
let referenceHeight = scrollView.contentInset.top - referenceOffset
// Calculate the new frame size and position
let offset = referenceHeight + scrollView.contentOffset.y
let targetHeight = referenceHeight - offset - referenceOffset
var targetOffset = referenceOffset
if targetHeight < minimumSize {
targetOffset += targetHeight - minimumSize
}
// Update the header's height and vertical position.
var headerFrame = frame;
headerFrame.size.height = max(minimumSize, targetHeight)
headerFrame.origin.y = targetOffset
frame = headerFrame;
}
}
The important step is the layout logic within the -[HeaderView updatePosition]
HeaderView.updatePosition()
method. As the content is scrolled, the available space in the top contentInset
is used
to fill up the header. Once the header view becomes too small for Auto Layout, the header is positioned higher until it is hidden off the top of the scrollable region.
All that remains is to update the position of the header view in response to rotations, safe area changes, and other events.
Responding to layout changes
The easiest is adapting the header size in response to rotations. Since we don’t care what orientation the device is in, but instead only want to adapt to changes in the scroll view’s width, we can simply call -[HeaderView updatePosition]
HeaderView.updatePosition()
within -[UIViewController viewWillLayoutSubviews]
UIViewController.viewWillLayoutSubviews()
. Since any layout changes made within this block will also be animated, we get a nice seamless transition while the device rotates.
viewDidLayoutSubviews.m
- (void)viewDidLayoutSubviews {
[super viewDidLayoutSubviews];
[self.headerView updatePosition];
}
viewDidLayoutSubviews.swift
override func viewWillLayoutSubviews() {
super.viewWillLayoutSubviews()
headerView.updatePosition()
}
Updating in response to safe area inset changes
Since our header needs to be positioned directly below the navigation bar, the layout of the header also needs to be updated when the safe area insets change. This can happen as a result of rotations, but also other events such as navigation bars becoming hidden, or if the in-call statusbar status changes.
In this case we need to adjust the table view’s contentInset
prior to calling -[HeaderView updatePosition]
HeaderView.updatePosition()
.
viewSafeAreaInsetsDidChange.m
- (void)viewSafeAreaInsetsDidChange {
[super viewSafeAreaInsetsDidChange];
self.tableView.contentInset = UIEdgeInsetsMake(250 + self.tableView.safeAreaInsets.top, 0, 0, 0);
[self.headerView updatePosition];
}
viewSafeAreaInsetsDidChange.swift
override func viewSafeAreaInsetsDidChange() {
super.viewSafeAreaInsetsDidChange()
tableView.contentInset = UIEdgeInsets(top: 250 + tableView.safeAreaInsets.top,
left: 0,
bottom: 0,
right: 0)
headerView.updatePosition()
}
Next Steps
UIKit has a wide variety of features that are innocuous on the surface (UITableView.backgroundView
for example), but when combined with a little creativity, it’s easy to create very engaging user experiences with little
effort.
I suggest you check out the GitHub repo AlexAstroCat/Example-HeaderScroll to try this for yourself.
And as an exercise for you, the reader, I have a few suggestions on where you can expand on this example for your own uses, such as:
- Adjust the opacity of the header as it scrolls under the navigation bar, after it reaches its minimum size.
- Use
contentInsets
on the bottom of the table view to reveal pagination features (“Next”, “Load More”, etc), rating feedback UI elements, or a “Contact Support” featurek - Set the title in the navigation bar as the header view’s title scrolls off the screen to prevent redundant text.
If you have any other examples of how you’ve used this, or have any feedback, feel free to comment below.