Codementor Events

Creating a sticky header for a UITableView

Published May 16, 2018Last updated Nov 12, 2018
  • *This tutorial uses Swift 2.3

I am the head of product and engineering at Rep, an influencer marketplace where brands and influencers can collaborate on marketing campaigns. I decided to share how I built this functionality into our app. (For any product hunters out here, feel free to hunt us 😃!)

I’ve been looking for an easy solution to this for a while and was strict about avoiding shady hacks to get it working. After a few hours of playing around with XCode I’ve come up with a great way to nail this. Im going to build the UI for a feed of articles. Here’s what it’ll look like:

I wont bother with the table view cells. I assume you guys know how to make and configure table view cells.

The general idea is to pin the header to the top of the view controller and the pin the top of the table view to the bottom of the header. Then using the scroll view delegate methods and some math, we can determine by how much to expand/contract the header.

Fire up Xcode and create a new single view application. Add a custom view called CustomHeaderView (File -> New File -> Cocoa Touch Class -> CustomHeaderView -> Create). Make sure its a subcalss of UIView.

Now lets step into some code. Lets add a title, background image view, article icon and a background alpha layer. I layout all my views with autolayout programmatically, but feel free to create your view however you want.

We will first need to add the required initializers and properties:

class CategoryHeaderView: UIView {

var imageView:UIImageView!
var colorView:UIView!
var bgColor = UIColor(red: 235/255, green: 96/255, blue: 91/255, alpha: 1)
var titleLabel = UILabel()
var articleIcon:UIImageView!

init(frame:CGRect, title: String) {
     self.titleLabel.text = title.uppercaseString
     super.init(frame: frame)
}

required init?(coder aDecoder: NSCoder) {
     fatalError("init(coder:) has not been implemented")
}

}

Next well layout all the views programmatically using autolayout and call this setUpView() function at the end of init(). We start laying the views out from back to front. The order is imageView, colorView, titleLabel, articleIcon.

func setUpView() {

self.backgroundColor = UIColor.whiteColor()

imageView = UIImageView()
imageView.translatesAutoresizingMaskIntoConstraints = false
self.addSubview(imageView)

colorView = UIView()
colorView.translatesAutoresizingMaskIntoConstraints = false
self.addSubview(colorView)

let constraints:[NSLayoutConstraint] = [
imageView.topAnchor.constraintEqualToAnchor(self.topAnchor),
imageView.leadingAnchor.constraintEqualToAnchor(self.leadingAnchor),
imageView.trailingAnchor.constraintEqualToAnchor(self.trailingAnchor),
imageView.bottomAnchor.constraintEqualToAnchor(self.bottomAnchor),
colorView.topAnchor.constraintEqualToAnchor(self.topAnchor),
colorView.leadingAnchor.constraintEqualToAnchor(self.leadingAnchor),
colorView.trailingAnchor.constraintEqualToAnchor(self.trailingAnchor),
colorView.bottomAnchor.constraintEqualToAnchor(self.bottomAnchor)
]

NSLayoutConstraint.activateConstraints(constraints)
imageView.image = UIImage(named: "testBackground")
imageView.contentMode = .ScaleAspectFill

colorView.backgroundColor = bgColor
colorView.alpha = 0.6

titleLabel.translatesAutoresizingMaskIntoConstraints = false
self.addSubview(titleLabel)

let titlesConstraints:[NSLayoutConstraint] = [
titleLabel.centerXAnchor.constraintEqualToAnchor(self.centerXAnchor),
titleLabel.topAnchor.constraintEqualToAnchor(self.topAnchor, constant: 28),
]

NSLayoutConstraint.activateConstraints(titlesConstraints)

titleLabel.font = UIFont.systemFontOfSize(15)
titleLabel.textAlignment = .Center

articleIcon = UIImageView()
articleIcon.translatesAutoresizingMaskIntoConstraints = false
self.addSubview(articleIcon)

let imageConstraints:[NSLayoutConstraint] = [
articleIcon.centerXAnchor.constraintEqualToAnchor(self.centerXAnchor),
articleIcon.centerYAnchor.constraintEqualToAnchor(self.centerYAnchor, constant: 6),
articleIcon.widthAnchor.constraintEqualToConstant(40),
articleIcon.heightAnchor.constraintEqualToConstant(40)
]
NSLayoutConstraint.activateConstraints(imageConstraints)

articleIcon.image = UIImage(named: "article")

}

The general pattern when using autolayout programmatically with any view is to first initialize the view, set its translatesAutoresizingMaskIntoConstraints property to false, add it to the view, set the constraints, activate the constraints, and then set all its properties, fonts, text, image etc.

We first pin the imageView to the top,leading,trailing, and bottom edges of the view. We then add the colorView on top of the image view and give it an alpha of 0.6. We then add the titleLabel and article Icon and activate all constraints. Its important to have the image view content mode set to scaleAspectFill and you will see why later on.

Dont forget to add this line in our init() function or the view wont build:

init(frame:CGRect,title: String) {
     self.titleLabel.text = title.uppercaseString
     super.init(frame: frame)
     setUpView()
}

Ok thats it for our custom view. Now lets set up our view controller. Jump to the ViewController.swift file and add a tableview property and a custom header property.

class ViewController: UIViewController {

var tableView:UITableView!
var headerView:CustomHeaderView!
var headerHeightConstraint:NSLayoutConstraint!

override func viewDidLoad() {

super.viewDidLoad()

}

}

I added a headerHeightConstraint property since we will be changing the height of the table view while scrolling and need to keep a reference to it.

Next set up the custom header with autolayout.

func setUpHeader() {

headerView = CustomHeaderView(frame: CGRectZero, title: "Articles")
headerView.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(headerView)

headerHeightConstraint = headerView.heightAnchor.constraintEqualToConstant(150)
headerHeightConstraint.active = true

let constraints:[NSLayoutConstraint] = [
headerView.topAnchor.constraintEqualToAnchor(view.topAnchor),
headerView.leadingAnchor.constraintEqualToAnchor(view.leadingAnchor),
headerView.trailingAnchor.constraintEqualToAnchor(view.trailingAnchor)
]
NSLayoutConstraint.activateConstraints(constraints)

}

Again we are following the programmatic autolayout convention. We pin the header to the top, leading and trailing edges of the view controller and give our height constraint property a constant value of 150.

Lets layout our tableView:

func setUpTableView() {

tableView = UITableView()
tableView.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(tableView)

let constraints:[NSLayoutConstraint] = [
tableView.topAnchor.constraintEqualToAnchor(headerView.bottomAnchor),
tableView.leadingAnchor.constraintEqualToAnchor(view.leadingAnchor),
tableView.trailingAnchor.constraintEqualToAnchor(view.trailingAnchor),
tableView.bottomAnchor.constraintEqualToAnchor(view.bottomAnchor)
]

NSLayoutConstraint.activateConstraints(constraints)

tableView.registerClass(UITableViewCell.self,forCellReuseIdentifier: "cell")
tableView.dataSource = self

}

Lets call both of these functions in viewDidLoad()

override func viewDidLoad() {

super.viewDidLoad()
   view.backgroundColor = UIColor.whiteColor()
   setUpHeader()
   setUpTableView()

}

Currently we will get an error since we haven’t set up the table views data source. Lets add the extension at the end of our class to get rid of these errors. You can copy paste this entire section since its not really part of this tutorial and is just basic set up.

extension ViewController:UITableViewDataSource {

func numberOfSectionsInTableView(tableView: UITableView) -> Int {

return 1

}

func tableView(tableView: UITableView, numberOfRowsInSection section: Int) -> Int {

return 20

}

func tableView(tableView: UITableView, cellForRowAtIndexPath indexPath: NSIndexPath) -> UITableViewCell {

let cell = tableView.dequeueReusableCellWithIdentifier("cell", forIndexPath: indexPath)
cell.textLabel?.text = "Article \(indexPath.row)"
return cell

}

}

Ok lets build and run the app now, this is what it should look like:

Great now that both the header view and the table view are set up and working properly its time to get to the business logic of this tutorial.

Since the table view is essentially a scroll view it can conform to the scroll view delegate methods. Lets add this line at the end of the setUpTableView() method

tableView.delegate = self

This will generate an error and well need to add the UITableViewDelegate extension as well as the UIScrollViewDelegate extension. Copy paste this at the end of the class

extension ViewController:UIScrollViewDelegate {

func scrollViewDidScroll(scrollView: UIScrollView) {   
print(scrollView.contentOffset.y)
}

func scrollViewDidEndDragging(scrollView: UIScrollView, willDecelerate decelerate: Bool) {

}

func scrollViewDidEndDecelerating(scrollView: UIScrollView) {

}

}

extension ViewController:UITableViewDelegate {

}

We don’t actually need the UITableViewDelegate, we just added it to get rid of the error. All the magic happens in the UIScrollViewDelegate

The scrollViewDidScroll method will be called anytime the table view is scrolled. This is a great place to manipulate our header views height. If the scrollView.contentOffset.y < 0 it means we are pulling downwards. If it is > 0 it means we are pushing upwards.

If we are pulling downwards we want to stretch the header view. This is really simple. Lets add the following lines of code to scrollViewDidScroll

func scrollViewDidScroll(scrollView: UIScrollView) {

if scrollView.contentOffset.y < 0 {

self.headerHeightConstraint.constant += abs(scrollView.contentOffset.y)

}

}

We add the offset to the height of the headerViewConstraint. Lets run and try stretching the view.

As you can see the header stretches when we pull. We also see that the image scales with the header views’ frame. This is because we set our content mode to aspect fill and gives it a nice behaviour for free!

Uh oh! When we let go we see the header view just stays there. If we keep pulling down it stretches even more.

We want the header view to snap back to its original height when we let go. The delegate methods scrollViewDidEndDecelerating and scrollViewDidEndDragging are perfect places to animate the view back into place but first lets create an animateHeader method.

Add this method below the setUpTableView method in the ViewController class:

func animateHeader() {

self.headerHeightConstraint.constant = 150UIView.animateWithDuration(0.4, delay: 0.0, usingSpringWithDamping: 0.7, initialSpringVelocity: 0.5, options: .CurveEaseInOut, animations: {
self.view.layoutIfNeeded()
}, completion: nil)

}

We set the height constraint back to our original height of 150 and animate the view with damping over 0.4 seconds.

Call animateHeader() in both scrollViewDidEndDecelerating and scrollViewDidEndDragging

func scrollViewDidEndDragging(scrollView: UIScrollView, willDecelerate decelerate: Bool) {

if self.headerHeightConstraint.constant > 150 {

animateHeader()

}

}

func scrollViewDidEndDecelerating(scrollView: UIScrollView) {

if self.headerHeightConstraint.constant > 150 {

animateHeader()

}

}

Now if you build and run the header will bounce back to its position after pulling the table view downwards!

If you want a collapsable header view we need to do a bit more math. The standard navigation controller height is 64 points. Lets add to the scrollViewDidScroll method to make the header collapsable when scrolling upwards.

func scrollViewDidScroll(scrollView: UIScrollView) {

if scrollView.contentOffset.y < 0 {

self.headerHeightConstraint.constant += abs(scrollView.contentOffset.y)

} else if scrollView.contentOffset.y > 0 && self.headerHeightConstraint.constant >= 65 {

self.headerHeightConstraint.constant -= scrollView.contentOffset.y/100

if self.headerHeightConstraint.constant < 65 {

self.headerHeightConstraint.constant = 65

}

}

}

Because we dont want the header view to move up too quickly we divide by 100 (you can play with this value if you want). The last check ensures that if we scroll up too quickly the header view will stay at 64 points (I set it to 65 though).

Build and run. Thats pretty cool but the article Icon gets in the way of the title. Lets add some alpha methods to manipulate the alpha of the color view and the article icon view so that when we scroll the background is no longer translucent and the icon disappears.

Add these methods to the CustomHeaderView() class:

func decrementColorAlpha(offset: CGFloat) {

if self.colorView.alpha <= 1 {

let alphaOffset = (offset/500)/85

self.colorView.alpha += alphaOffset

}

}

func decrementArticleAlpha(offset: CGFloat) {

if self.articleIcon.alpha >= 0 {

let alphaOffset = max((offset - 65)/85.0, 0)

self.articleIcon.alpha = alphaOffset

}

}

func incrementColorAlpha(offset: CGFloat) {

if self.colorView.alpha >= 0.6 {

let alphaOffset = (offset/200)/85

self.colorView.alpha -= alphaOffset

}

}

func incrementArticleAlpha(offset: CGFloat) {

if self.articleIcon.alpha <= 1 {

let alphaOffset = max((offset - 65)/85, 0)

self.articleIcon.alpha = alphaOffset

}

}

These methods just deal with some math. Lets call them in the scrollViewDidScroll.

func scrollViewDidScroll(scrollView: UIScrollView) {

if scrollView.contentOffset.y < 0 {

self.headerHeightConstraint.constant += abs(scrollView.contentOffset.y)

headerView.incrementColorAlpha(self.headerHeightConstraint.constant)

headerView.incrementArticleAlpha(self.headerHeightConstraint.constant)

} else if scrollView.contentOffset.y > 0 && self.headerHeightConstraint.constant >= 65 {

self.headerHeightConstraint.constant -= scrollView.contentOffset.y/100

headerView.decrementColorAlpha(scrollView.contentOffset.y)

headerView.decrementArticleAlpha(self.headerHeightConstraint.constant)

if self.headerHeightConstraint.constant < 65 {

self.headerHeightConstraint.constant = 65

}

}

}

func scrollViewDidEndDragging(scrollView: UIScrollView, willDecelerate decelerate: Bool) {

if self.headerHeightConstraint.constant > 150 {

animateHeader()

}

}

func scrollViewDidEndDecelerating(scrollView: UIScrollView) {

if self.headerHeightConstraint.constant > 150 {

animateHeader()

}

}

Thats it! You now have a beautiful stretchy header with collapsable animation!

You can find the project on my github jjjeeerrr111

jjjeeerrr111/StickyHeader
_StickyHeader - Stickey header with UITableView_github.com

**The article icon used was created by Shane Herzog

Follow me on twitter or Instagram
Twitter: @JeremySharvit
Instagram: @JeremySh

Discover and read more posts from Jeremy Sharvit
get started