Implementing a Collapsing / Expanding UITableView Header

Oftentimes in content rich apps, you will find collapsing headers that shrink and grow as the user scroll through their content. A prime example of this can be found in Facebook’s app on the “News Feed” tab and in Twitter’s app on the profile page.

facebook

twitter

Having dynamic headers like these in your app are very useful for giving your content as much viewing real estate as possible yet also providing your user with the tools needed to navigate your content at just the right moment.

This tutorial will explain how to create a header like the examples above that will expand and collapse as you scroll up and down within a table view.

Boilerplate Setup

To start off, you need to create a ViewController that contains both a UIView and a UITableView. The UIView should be named “Header View” and constrained to the top, left, and right of the ViewController and have a height constraint of 88. Likewise, the UITableView should be constrained to the bottom, left, and right of the ViewController and to the bottom of our “Header View”. With these constraints in place, your view should now be fully constrained.

In order to manipulate the header height, we will want to expose the “Header View” height constraint as an IBOutlet to the source code. Finally, in order to add some dummy data to our table, we will need to make one last IBOutlet to expose the UITableView as well.

I’m assuming you’ve worked with UITableViews in the past, so I’m just going to give you some boilerplate code which will populate your table with 40 cells so that we can actually scroll our content up and down - you’re welcome!

import UIKit

class ViewController: UIViewController {

    @IBOutlet weak var tableView: UITableView!
    @IBOutlet weak var headerHeightConstraint: NSLayoutConstraint!

    override func viewDidLoad() {
        super.viewDidLoad()
        // Do any additional setup after loading the view, typically from a nib.

        self.tableView.delegate = self
        self.tableView.dataSource = self
    }
}

extension ViewController: UITableViewDataSource, UITableViewDelegate {
    func numberOfSectionsInTableView(tableView: UITableView) -> Int {
        return 1
    }

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

    func tableView(tableView: UITableView, cellForRowAtIndexPath indexPath: NSIndexPath) -> UITableViewCell {
        let cell = UITableViewCell()
        cell.textLabel!.text = "Cell \(indexPath.row)"
        return cell
    }
}

Defining Min and Max Values

Like Facebook, when you visit the News Feed (or your profile page in Twitter), the header at the top of the screen is fully expanded. Having the header fully expanded when a user arrives at a screen is useful because it shows the user all of the available information within the header up front. Then as the user scrolls down, the header discreetly collapses to get out of the way as to make more room for the user to read more of your content.

The difference in behavior between Facebook’s header and Twitter’s header is that Facebook’s header completely collapses as the user scrolls down while Twitter’s header simply condenses. Today we will be creating a header that behaves more like Twitter’s (i.e. Starts out fully expanded and then condenses as the user scrolls down).

To do this we need to start out by defining the minimum and maximum values for our header. We can accomplish this by adding a couple of class properties.

let maxHeaderHeight: CGFloat = 88;
let minHeaderHeight: CGFloat = 44;

With these values we are saying that we want the header to become half the size of the fully expanded (initial) state. If we wanted to implement Facebook’s header behavior we could simply change the minHeaderHeight to 0.

To make sure that our header is always fully expanded when the user arrives at the screen we assign the headerHeightConstraint the expanded height constant in the ViewController’s viewWillAppear method as shown below.

override func viewWillAppear(animated: Bool) {
    super.viewWillAppear(animated)
    self.headerHeightConstraint.constant = self.maxHeaderHeight
}

Now that we have the initial state of our header setup we are ready to start on the fun stuff - actually making our header animate!

Scrolling Up and Down

The UIScrollViewDelegate (implemented by the UITableViewDelegate protocol) has a lot of handy methods for helping us keep track of scroll state. The first method we will use is the scrollViewDidScroll(scrollView: UIScrollView) delegate method. This method is called every time the scroll position of our TableView changes and is exactly what we need to help us resize the header when the user scrolls. To figure out the current scroll position, we simply refer to the UIScrollView’s contentOffset.y property.

One of the things we need to determine right off the bat is the scrolling direction (i.e. are we scrolling up or scrolling down?). To do this, we can create another class property (i.e. previousScrollOffset) that we can use to compare the previous scroll position with the current scroll position. In general, if we were to subtract the current scroll position from the previous scroll position, a negative value represents the user scrolling up while a positive value represents the user scrolling down.

Pretending for a moment that previousScrollOffset already contains the correct value, our formula for determining the scroll direction would look something like this.

func scrollViewDidScroll(scrollView: UIScrollView) {
    let scrollDiff = scrollView.contentOffset.y - self.previousScrollOffset
    let isScrollingDown = scrollDiff > 0
    let isScrollingUp = scrollDiff < 0
}

So now, in order to set the previousScrollOffset properly so that it always contains the previous scroll position, we simply set it equal to the current scroll position at the very end of the method. Our revised method…

func scrollViewDidScroll(scrollView: UIScrollView) {
    let scrollDiff = scrollView.contentOffset.y - self.previousScrollOffset
    let isScrollingDown = scrollDiff > 0
    let isScrollingUp = scrollDiff < 0

    //
    // Will implement header height logic here next
    //

    self.previousScrollOffset = scrollView.contentOffset.y
}

Ok, now that we know which direction we are scrolling, we can manipulate the header height! Because we want the header to collapse at the same rate as the user is scrolling, we can use the scrollDiff variable defined above to manipulate its height. However, since we don’t want the header to grow or shrink past our min/max values, we need to do a little bit of math to make sure we stay within our predefined limits.

var newHeight = self.headerHeightConstraint.constant
if isScrollingDown {
    newHeight = max(self.minHeaderHeight, self.headerHeightConstraint.constant - abs(scrollDiff))
} else if isScrollingUp {
    newHeight = min(self.maxHeaderHeight, self.headerHeightConstraint.constant + abs(scrollDiff))
}

if newHeight != self.headerHeightConstraint.constant {
    self.headerHeightConstraint.constant = newHeight
}

In the code above, we use a newHeight variable to determine the what the height of our header should be depending on the direction the user has scrolled. We apply the newHeight value to our “Header View” if the new height is different from the current height. Our header now grows and shrinks as the user scrolls - neat!

If you are following along and debugged this code, you might have noticed some odd behavior with the header when scrolling all the way to the top or bottom of the scroll view. This is because of the fancy bounce animation that occurs when you scroll past the content within the table. Essentially our formula used to determine when we are scrolling up or down is becoming incorrectly true when we scroll past these end points. Luckily we can fix this with just a little more logic that checks if we have scrolled above or below the content within the table.

let absoluteTop: CGFloat = 0;
let absoluteBottom: CGFloat = scrollView.contentSize.height - scrollView.frame.size.height;

let isScrollingDown = scrollDiff > 0 && scrollView.contentOffset.y > absoluteTop
let isScrollingUp = scrollDiff < 0 && scrollView.contentOffset.y < absoluteBottom

With this update, you should now see that the header expands and collapses smoothly as you scroll even past the content within the table.

EDIT: 4/20/17 There are scenarios, however, where we do not want the header to collapse. For example, let’s say the table only has a couple of rows and the ScrollView’s content is less than the height of the screen. In this scenario, there is no reason to collapse the header since all of the content can already fit on the screen. Therefore, to prevent the header from collapsing in unideal situations, we need to check that there is still room to scroll even when the header is collapsed. A simple check before making our newHeight calculations is all that is needed to fix this edge case.

if canAnimateHeader(scrollView) {
    // newHeight calculations from above
}

Where canAnimateHeader() is a helper function defined as…

func canAnimateHeader(_ scrollView: UIScrollView) -> Bool {
    // Calculate the size of the scrollView when header is collapsed
    let scrollViewMaxHeight = scrollView.frame.height + self.headerHeightConstraint.constant - minHeaderHeight

    // Make sure that when header is collapsed, there is still room to scroll
    return scrollView.contentSize.height > scrollViewMaxHeight
}

Stop Scrolling While Header is Expanding/Collapsing

Another feature in continuing to refine the smoothness of our animating header is to freeze scrolling of the content while the header is opening/closing. Since we already have an if statement that is checking whether the newHeight is different from the current header height, we essentially know when the header is animating. Therefore we can simply update that if statement to set the current scroll position to the previous scroll position.

if newHeight != self.headerHeightConstraint.constant {
    self.headerHeightConstraint.constant = newHeight
    self.setScrollPosition(self.previousScrollOffset)
}

Where setScrollPosition() is a helper function defined as…

func setScrollPosition(position: CGFloat) {
    self.tableView.contentOffset = CGPointMake(self.tableView.contentOffset.x, position)
}

Now when we scroll, the content stops scrolling while the header is opening or closing

Snap Header Fully Expanded or Collapsed

This behavior is really useful if you don’t want your header to end up in an in-between state where the header is not fully open or closed. Ideally, you want the header to open all the way when more than half way open and collapsed otherwise.

The prime time to compute this logic is after the scrolling has stopped. However because there is not a scrollViewDidStop() method, we will use a combination of two UIScrollView delegate methods to determine when scrolling has stopped.

func scrollViewDidEndDecelerating(scrollView: UIScrollView) {
    // scrolling has stopped
}

func scrollViewDidEndDragging(scrollView: UIScrollView, willDecelerate decelerate: Bool) {
    if !decelerate {
        // scrolling has stopped
    }
}

As the methods suggest, scrollViewDidEndDecelerating() lets us know when the scroll view has stopped scrolling after a “fling” and scrollViewDidEndDragging lets us know when the scroll view has stopped scrolling after the user has removed their finger. The decelerate argument defined in the latter method lets us know the the user “flung” the content with their finger (in which case the scrollViewDidEndDecelerating() would eventually get called) or if the user brought the content to a stop and then removed their finger.

Now that we know when scrolling has stopped, we can create a helper method to implement the logic for snapping the header open/closed. The first thing we need to figure out is the position where the header is directly between being expanded/collapsed and assign it to a midPoint variable. Once we have the midpoint, we can check to see if the header height is greater than the midpoint and if so expand the header, otherwise collapse it.

func scrollViewDidStopScrolling() {
    let range = self.maxHeaderHeight - self.minHeaderHeight
    let midPoint = self.minHeaderHeight + (range / 2)

    if self.headerHeightConstraint.constant > midPoint {
        // expand header
        self.headerHeightConstraint.constant = self.maxHeaderHeight
    } else {
        // collapse header
        self.headerHeightConstraint.constant = self.minHeaderHeight
    }
}

If we were to run this code and remove our finger while collapsing/expanding the header, the header would snap to one of our predefined limits. You might say, “This is great, but the animation (or lack there of) is a bit jarring and not a great user experience.” I’m so glad you pointed this out. We can improve up our UI with some smooth animations by replacing the two lines where we set the headerHeightConstraint property with these corresponding helper methods.

func collapseHeader() {
    self.view.layoutIfNeeded()
    UIView.animateWithDuration(0.2, animations: {
        self.headerHeightConstraint.constant = self.minHeaderHeight
        // Manipulate UI elements within the header here
        self.view.layoutIfNeeded()
    })
}

func expandHeader() {
    self.view.layoutIfNeeded()
    UIView.animateWithDuration(0.2, animations: {
        self.headerHeightConstraint.constant = self.maxHeaderHeight
        // Manipulate UI elements within the header here
        self.view.layoutIfNeeded()
    })
}

With these helper methods implemented, our header animation is now as smooth as butter!

Animating Elements within the Header

In this last section, I want to demonstrate how you can animate content within the header while the header is collapsing/expanding. Returning to Facebook’s header, notice how the search bar fades in/out as the header expands/collapses. The animation is tied directly to the state of the header and changes as the header’s height changes. This is the behavior that we will be duplicating.

For simplicity, let’s say that I want a header that shows a MichiganLabs logo while the header is expanded and then only shows a label with the text “MichiganLabs” while the header is collapsed. While the header is expanding/collapsing, I want the title to slide in/out from the top of the header whereas I want the logo to fade in/out while being anchored to the bottom of the header.

To do this, I’ve added two elements to the ViewController in our storyboard - a UILabel for the title and a UIImageView for a logo. I’ve constrained the label to the top of the header and exposed the constraint as an IBOutlet so that I can manipulate it later. I constrained the UIImageView to the bottom of the header, gave it a constant height and width, and then made the UIImageView an IBOutlet so that I can change it’s alpha property.

Since our fade and position animations are tied directly to the current height of the header, we will make another method that will help us determine how much the header is open (openAmount) in relation to the range of how far the header can expand/collapse. In addition, we will use these two values to calculate the percentage of how much the header is expanded. We will then use openAmount to slide the UILabel up and down and the percentage value to manipulate the UIImageView’s alpha property.

func updateHeader() {
    let range = self.maxHeaderHeight - self.minHeaderHeight
    let openAmount = self.headerHeightConstraint.constant - self.minHeaderHeight
    let percentage = openAmount / range

    self.titleTopConstraint.constant = -openAmount + 10
    self.logoImageView.alpha = percentage
}

Because I’ve constrained the UILabel to the top of the header view, we need to assign a negative value to the UILabel’s top constraint so that it moves up past the top of the header view. Therefore we can simply assign titleTopConstraint the invert of amountOpen. This almost works, but because the invert of amountOpen will only have values between 0 and -44, we need to add some padding (which I’ve chosen to be 10) so that when the header is completely collapsed, the UILabel is 10 points from the top of the header.

The animation for the logo is a bit easier. Since we only want to change the alpha property and percentage has a range of values between 1.0 and 0.0, we can simply assign percentage directly to logoImageView.alpha as shown above.

All that is left to do is call updateHeader() in below the comment in our expandHeader() and collapseHeader() methods and in viewWillAppear() so that our header is in the correct state when the view appears.

Voila! We have a smooth animating header!

demo

Click here to see the finished example project on github.