App Development

How to Create a Simple Collapsing Header with UIScrollView

If, like me, you’ve found yourself needing to implement a collapsing header in your iOS app, look no further. This blog post will walk you through a simple implementation of a collapsing header in an iOS app using UIKit.

The main components are:

  1. Containing View Controller
  2. Header View
  3. Scroll View

Start by constructing the Containing View Controller and adding the Header.

  1. Add a View Controller to your storyboard, call it ContainingViewController.
  2. Add a subview to ContainingViewController and call it HeaderView. For the purposes of this example, we apply a static height constraint to the header view. In a production application, the height can be determined by AutoLayout based off of the constrained elements within the HeaderView.
  3. Add constraints to the HeaderView, leading and trailing to the SafeArea, and top to Superview.Top (note, since we want this to appear to collapse offscreen, and we don’t want a blank space above the safe area, we constrain to the top of the screen.)

Next, we add a Container View to our Containing View Controller, this will hold our UIScrollView.

  1. Add a ContainerView to ContainingViewController, we connect this to our UIScrollView (in this example using a UITableViewController) via an Embed Segue.
  2. Add constraints to the ContainerView, leading, trailing, and bottom to the safe area. We constrain the top to Superview.Top, and the constant we set here will be the collapsed height of our header.

Now that we’ve got our UI constructed, we add the code. Our ContainingViewController should look like this (note, we’ve added the IBOutlets ahead of time).

class ContainingViewController: UIViewController {

    // header view or container for header view
    @IBOutlet weak var headerView: UIView!

    // scroll view container
    @IBOutlet weak var containerView: UIView!

    // constrain the height of the headerView
    // in a more complex UI, use frame, let autolayout calculate based on subviews
    @IBOutlet weak var headerViewHeight: NSLayoutConstraint!

    // constraint between the top of headerView and the top of the screen
    @IBOutlet weak var headerViewTop: NSLayoutConstraint!

    // constraint between the top of the tableView container and the top of the screen
    // also used for the "collapsed" height of the headerView
    @IBOutlet weak var containerViewTop: NSLayoutConstraint!
}

For the purposes of this example, our UIScrollView implementation is a UITableView. Note that this can be swapped out for any UIScrollView implementation.

class TableViewController: UITableViewController,
                           ScrollViewContained {

    // used to connect the scrolling to the containing controller
    weak var scrollDelegate: ScrollViewContainingDelegate?

    override func scrollViewDidScroll(_ scrollView: UIScrollView) {
        // pass scroll events to the containing controller
        scrollDelegate?.scrollViewDidScroll(scrollView)
    }
}

The majority of the code in the TableViewController is boilerplate, so I’ve excluded it from this post. The important points are above. It conforms to ScrollViewContained protocol. This protocol simply defines that it will have a scrollDelegate property. The other important point is that on scrollViewDidScroll, we call the delegate’s scrollViewDidScroll method.

The two protocols are defined as follows:

protocol ScrollViewContainingDelegate: NSObject {
    func scrollViewDidScroll(_ scrollView: UIScrollView)
}

protocol ScrollViewContained {
    var scrollDelegate: ScrollViewContainingDelegate? { get set }
}

Now going back to our ContainingViewController, we add a helper variable:

// how far the header view gets scrolled offscreen
var maxScrollAmount: CGFloat {
    let expandedHeight = headerViewHeight.constant
    let collapsedHeight = containerViewTop.constant
    return expandedHeight - collapsedHeight
}

and we override viewDidLoad:

override func viewDidLoad() {
    super.viewDidLoad()

    if let scrollView = containerView.subviews.first as? UIScrollView {
        // adjust the scroll view's top inset to account for scrolling the header offscreen
        scrollView.contentInset = UIEdgeInsets(top: maxScrollAmount, left: 0, bottom: 0, right: 0)
    }

    if var scrollViewContained = children.first as? ScrollViewContained {
        scrollViewContained.scrollDelegate = self
    }
}

Two important things to note in the viewDidLoad method:

  1. We set the scrollView’s content inset, with the top being set to maxScrollAmount, the helper variable we just created. This adjusts the inset to account for scrolling the header offscreen.
  2. We set ourself (i.e. ContainingViewController) as the scrollViewContained.scrollDelegate.

But wait! Our controller doesn’t implement the ScrollViewContainingDelegate protocol! Let’s add that code now:

extension ContainingViewController: ScrollViewContainingDelegate {
    func scrollViewDidScroll(_ scrollView: UIScrollView) {
        // need to adjust the content offset to account for the content inset
        // negative because we are moving the header offscreen
        let newTopConstraintConstant = -(scrollView.contentOffset.y + scrollView.contentInset.top)
        headerViewTop.constant = min(0, max(-maxScrollAmount, newTopConstraintConstant))
        let isAtTop = headerViewTop.constant == -maxScrollAmount

        // handle changes for collapsed state
        scrollViewScrolled(scrollView, didScrollToTop: isAtTop)
    }

    func scrollViewScrolled(_ scrollView: UIScrollView, didScrollToTop isAtTop:Bool) {
        headerView.backgroundColor = isAtTop ? UIColor.green : UIColor.systemIndigo
    }
}

This delegate implementation is the important part of the code so I’ll go over it in depth. The way that this implementation works, is that as you scroll your UIScrollView, the header is scrolled up offscreen, creating the collapsing effect. In this case, we just change the header color when we’re at the top (i.e. fully collapsed state), however, for more complex UI you could animate alongside this scrolling by calculating the scroll percentage as a percentage of the maxScrollAmount variable.

// need to adjust the content offset to account for the content inset
// negative because we are moving the header offscreen
let newTopConstraintConstant = -(scrollView.contentOffset.y + scrollView.contentInset.top)

We calculate the value of the newTopConstraintConstant, it is negative because we are adjusting the constraint to move the header offscreen.

headerViewTop.constant = min(0, max(-maxScrollAmount, newTopConstraintConstant))

We then set the constant, by taking the maximum between -maxScrollAmount and newTopConstraintConstant, and then the minimum between that value and 0. What does this accomplish? By doing this, we’re ensuring both that the header doesn’t get scrolled farther offscreen than its total height. Second, by not allowing it to be greater than zero, we’re saying that once the header is fully expanded it cannot scroll down further.

...

    // handle changes for collapsed state
    scrollViewScrolled(scrollView, didScrollToTop: isAtTop)
}

func scrollViewScrolled(_ scrollView: UIScrollView, didScrollToTop isAtTop:Bool) {
    headerView.backgroundColor = isAtTop ? UIColor.green : UIColor.systemIndigo
}

Lastly, we handle the state change of being at the top in the full collapsed state, and in this case we just change the background color.

And that’s it, you now have a functional collapsing header. If you want to take this further, it is possible to animate alongside the collapsing of the header, so that it changes as it scrolls. If you want to check out the source code for this example, it can be found here.

Join our team to work with Fortune 500 companies in solving real-world product strategy, design, and technical problems.

Find Your Role

WillowTree Recognized as one of the Fastest-Growing Companies in North America on Deloitte’s 2020 Technology Fast 500™ for the Second Year in a Row

WillowTree today announced that for the second year in a row that we were named...

Read the article