Gorgeous parallax scrolling with UITableViewCells

Parallax scrolling is a powerful weapon to wield. If it's used incorrectly, it's super annoying; but when it's done right the effect is magical. Lucky for me, I like to make stuff so the decision whether it should be used or not can wait ๐Ÿ˜.

Today I'm going to run through how I implemented parallax scrolling for individual cells of a UITableView. I haven't seen it done this way (uh-hum, the proper way), with layout constraints, so thought I should share it with you all. If you don't want to hear me ramble on, you can check out the example project on github.

At this point, I'm going to assume you have a UITableView set up, with some sub-class of UITableViewCell that contains an image - mine is called ImageCell. It's important the image you want to conduct parallax on is set to Aspect Fill.

My base project without any parallax added looks like this:

Base project without parallax

We want the parallax offset of each cell to depend on the cell's position in the table view. We're going to hook up the scroll listener for the table view to a function that can offset the cell's background image.

Here's the goal:

The goal of this post

When the cell (the red rectangle here) is at the bottom of the screen, it's image is pinned to the top and when the cell is at the top, the image is pinned to the bottom.

So, let's first see how we can monitor a cell's position in the table view. Insert the following code in your ViewController (the one with your UITableView):

func scrollViewDidScroll(scrollView: UIScrollView) {
    if (scrollView == self.tblMain) {
        for indexPath in self.tblMain.indexPathsForVisibleRows() as! [NSIndexPath] {
            self.setCellImageOffset(self.tblMain.cellForRowAtIndexPath(indexPath) as! ImageCell, indexPath: indexPath)
        }
    }
}

Note: my UITableView is hooked up to an outlet called tblMain.

The function above monitors the scroll progress of the table, looks at which cells are visible and then calls the setCellImageOffset function on the visible cells. Let's write this function now:

func setCellImageOffset(cell: ImageCell, indexPath: NSIndexPath) {
    var cellFrame = self.tblMain.rectForRowAtIndexPath(indexPath)
    var cellFrameInTable = self.tblMain.convertRect(cellFrame, toView:self.tblMain.superview)
    var cellOffset = cellFrameInTable.origin.y + cellFrameInTable.size.height
    var tableHeight = self.tblMain.bounds.size.height + cellFrameInTable.size.height
    var cellOffsetFactor = cellOffset / tableHeight
    cell.setBackgroundOffset(cellOffsetFactor)
}

Theres quite a bit going on in this function, so here's a quick run down, line by line:

  1. We find the frame of the cell within the tableview.
  2. Calculate the cell's frame in terms of the table's parent's coordinates. This is so that we get the cell's position on screen, rather than just it's position in the list (which is fixed for each cell).
  3. Get the cell's offset from the top. This is just the y coordinate of the cell's frame in the table's part, but we add the height of the cell so that the cell can keep parallax-ing even when it's top has gone over the edge.
  4. Get the visible height of the table. Again, we add the cell's height to it so that the image can keep moving even when the cell is partially off-screen.
  5. We calculate how much we want to offset the background by (on a scale of 0 to 1) by dividing the cell's position in the visible portion of the table by the total height of the visible part of the table.

Phew! That was the hard bit. Now we need to implement setBackgroundOffset in ImageCell to update the image as needed. Open up ImageCell. Mine currently looks like this:

class ImageCell: UITableViewCell {
    @IBOutlet weak var imgBack: UIImageView!
    //...(other stuff like title)...

The first step is to hook up the layout constraints for the image's top and bottom. If you've used a top + height combo (or bottom + height), the steps below will need to be slightly adjusted.

So, find the top and bottom constraints for your image and hook them up to outlets in the class; you should now have something like this:

class ImageCell: UITableViewCell {
    @IBOutlet weak var imgBack: UIImageView!
    @IBOutlet weak var imgBackTopConstraint: NSLayoutConstraint!
    @IBOutlet weak var imgBackBottomConstraint: NSLayoutConstraint!

    let imageParallaxFactor: CGFloat = 20

    var imgBackTopInitial: CGFloat!
    var imgBackBottomInitial: CGFloat!
    ...
}

The keen ๐Ÿ‘€ among you will notice I added some extra variables above. imageParallaxFactor is a control variable we will use to define how much parallax we want; it's gonna be fun to play with this variable later ๐Ÿ˜‹. The imgBack*Initial variables are there to keep track of what the starting values are for the constraints. Let's set these now.

We can initialise them in the UITableViewCell's awakeFromNib lifecycle event like so (thanks /u/lyinsteve for pointing this out):

override func awakeFromNib() {
    self.clipsToBounds = true
    self.imgBackBottomConstraint.constant -= 2 * imageParallaxFactor
    self.imgBackTopInitial = self.imgBackTopConstraint.constant
    self.imgBackBottomInitial = self.imgBackBottomConstraint.constant
}

Let's go over it:

  1. The first step is to set the cell to clip it's bounds. This works in my case because my image takes up the entire cell's background, so if you're cell is any smaller, you will need to clip it differently.
  2. We set the bottom constraint to be 2 * the parallax amount below its original value. This has the effect of elongating the image.
  3. We record the initial values of the constraints.

And now for the piรจce de rรฉsistance:

func setBackgroundOffset(offset:CGFloat) {
    var boundOffset = max(0, min(1, offset))
    var pixelOffset = (1-boundOffset)*2*imageParallaxFactor
    self.imgBackTopConstraint.constant = self.imgBackTopInitial - pixelOffset
    self.imgBackBottomConstraint.constant = self.imgBackBottomInitial + pixelOffset
}

This is where the magic happens. Here, we move the top and bottom constraints of the image so that it moves up and down with the offset. The core principal is that:

  • at an offset of 0 (cell is about to scroll off the top of the screen):
    • top = 2*imageParallaxSize
    • bottom = 0
  • at an offset of 1 (cell is about to disappear from the bottom of the screen):
    • top = 0
    • bottom = 2*imageParallaxSize

Run your project now, and if it doesn't blow you away, you've done something wrong ๐Ÿ˜“! It should look fantastic. The only problem left is that there's a sudden move between when the view first loads up and your first scroll. This is happening because the initial position of the background image is still how we specified it in the storyboard (and our small tweak above) - the cells have been offset during their placement in the tableview, but the backgrounds haven't been offset yet because there hasn't been a scroll. So let's fix that.

The solution is quite nice. We're going to implement a very handy method called tableView: willDisplayCell: forRowAtIndexPath. This function is called just before a cell is displayed for the first time, making it perfect to do any view initialisation. So in here, we'll offset the background view of the cell based on where it's going to be placed:

func tableView(tableView: UITableView, willDisplayCell cell: UITableViewCell, forRowAtIndexPath indexPath: NSIndexPath) {
    var imageCell = cell as! ImageCell
    self.setCellImageOffset(imageCell, indexPath: indexPath)
}

End result with lovely parallax

(If you can't see the parallax in the gif, focus on the title and it's position relative to the background ๐Ÿ˜‰.)

Note: I've tunred up the parallax to a very high value here (70) so it appears properly in the gif. I would never advise doing it like this for real, a value of about 0.1 * the cell height works much nicer ๐Ÿ‘.

Awesome. If that was too much rambling to keep up with, here are the two main classes involved. They're pretty short so will probably get the point across much faster ๐Ÿ˜›:

ViewController on GitHub

ImageCell on GitHub

Thanks for reading! As usual, leave your thoughts below!


© Krishan Patel 2015.