Codementor Events

Vertical scrolling parallax

Published Jun 29, 2018Last updated Jul 01, 2019
Vertical scrolling parallax

If you're using WhatsApp, you probably saw the images have a small parallax effect, in the opposite scrolling direction. This gives the impression that the image is on a deeper level, and the "image container" is a window: if you climb on a chair, it's as if you lowered the window (you scrolled down), and now you can see more of the landscape below the window (it scrolled up) —crouching would have the opposite effect.

One of the biggest downsides to this, is that you need to force your image to be bigger than what the user sees: if you want to have a +/- 10px movement, you need to make it 20px taller, and wider by an amount that would keep the ratio the same. There are other ways to do this, for example with the help of UICollectionViewFlowLayout, but in my case, I had other elements below the image and only wanted the image to have this effect, not the whole cell.

The first step is to wrap the imageView in a container, so that we can move it up and down. It would have the same size as the original imageView, and clipsToBounds set to true. The second and third steps would be to create a method inside the cell, to be called from the collectionView's scrollViewDidScroll, and the last step is to update the imageView's frame / constraint.

We can describe this behavior by splitting it in three positions, and for simplicity's sake, we will assume that we have a fullscreen collectionView:

  • when the container is vertically centered on screen, so should the imageView be inside its container, with -10px on top and +10px at the bottom
  • when the container is leaving the top of the screen, or its maxY is equal to the top of the screen, the imageView's top should have 0px on top and -20px at the bottom
  • when the container is leaving the bottom of the screen, or its x is equal to the height of the screen, the imageView's top should have 0px on top and -20px at the bottom

Let's start with the collectionView:

func scrollViewDidScroll(_ scrollView: UIScrollView) {
  collectionView.visibleCells.forEach {
    ($0 as? CustomCell)?.updateImage(in: self.collectionView, in: self.view)
  }
}

And continue with the logic from within the cell, where if we had a property like this:

private lazy var imageView: UIImageView = {
  let iv = UIImageView(image: UIImage(named: "landscape"))
  self.contentView.addSubview(iv)
  
  iv.translatesAutoresizingMaskIntoConstraints = false
  NSLayoutConstraint.activate([
    iv.topAnchor.constraint(equalTo: self.contentView.topAnchor,
    iv.bottomAnchor.constraint(equalTo: self.contentView.bottomAnchor, constant: -60, // To accomodate some labels below.
    iv.leadingAnchor.constraint(equalTo: self.contentView.leadingAnchor,
    iv.trailingAnchor.constraint(equalTo: self.contentView.trailingAnchor)]
  )

  return iv
}()

We would need to make a few changes, that we'll break down right after:

private var imageViewTop: NSLayoutConstraint? = nil // 1
private lazy var imageView: UIImageView = {
  let container = UIView()
  self.contentView.addSubview(container) // 2
  
  container.translatesAutoresizingMaskIntoConstraints = false

  NSLayoutConstraint.activate([ // 3
    container.topAnchor.constraint(equalTo: self.contentView.topAnchor,
    container.bottomAnchor.constraint(equalTo: self.contentView.bottomAnchor, constant: -60,
    container.leadingAnchor.constraint(equalTo: self.contentView.leadingAnchor),
    container.trailingAnchor.constraint(equalTo: self.contentView.trailingAnchor)]
  )
  
  let iv = UIImageView(image: UIImage(named: "landscape"))
  container.addSubview(iv) // 4
  
  iv.translatesAutoresizingMaskIntoConstraints = false
  self.imageViewTop = iv.topAnchor.constraint(equalTo: container.topAnchor)
  // Its constant doesn't matter right now, 
  // because it will get updated instantly by the call from scrollViewDidScroll.
  
  NSLayoutConstraint.activate([ // 5
    imageViewTop!,
    iv.heightAnchor.constraint(equalTo: container.heightAnchor,
                   constant: 20),
    iv.centerXAnchor.constraint(equalTo: container.centerXAnchor)] // 6
  )
  
  return iv // 7
}()

We need a new property for its topAnchor constraint (1) since that's what we'll be using to create the parallax effect, add the imageView to a container (4) and add that to our contentView (2), add the previous constraints to the container (3), and align our imageView with the container (5).

Since we're increasing the height by 20 for the parallax, we would also need to increase the width in such a manner to preserve the image's ratio; then we'd have to divide that value by 2, and use it as a constant for the imageView's leading and trailing constraints. But, instead of complicating things like this, we're actually using centerXAnchor (6), because the width will be automatically set based on its intrinsic size.

We will still return the imageView, so it can be easier to reason with, to set its image, for example. We can always and safely access the container via imageView.superview! if needed, since we're sure it exists.

Lastly, where the real magic happens, the updateImage method:

func updateImage(in collectionView: UICollectionView, in view: UIView) {
  let rect = collectionView.convert(frame, to: view) // 1
  // We have some labels below the imageView.
  let containerMaxY = rect.maxY - 60
  
  let topInset = collectionView.contentInset.top // 2
  let bottomInset = collectionView.contentInset.bottom // 3

  let parallaxRatio = (containerMaxY - topInset) / (view.height + containerHeight - topInset - bottomInset) // 4
  
  imageTopConstraint?.constant = -20 * min(1, max(0, parallaxRatio)) // 5
}

Let's break all of this down:

We first need the cell's rect in the collectionView's containing view coordinates (1).

Our scenario is easy, with the labels' height known at 60, but we could also have a more complex scenario, with different heights for portrait / landscape; in that case we'd have to jump through a couple more steps to find containerMaxY:

let containerHeight = imageView.superview!.frame.height
let labelsHeight = rect.height - containerHeight
let containerMaxY = rect.maxY - labelsHeight

The three positions we used in the example at the start were for a fullscreen collectionView, but we might also need to take into consideration its insets (2, 3), because we want to finish the parallax the moment the cell is not visible anymore, for the most accurate effect. Our three positions translate into these values:

  • top - containerMaxY == topInset, parallaxRatio == 0 - the image is at its lowest point
  • bottom - containerMaxY == view.height + containerHeight - bottomInset, parallaxRatio == 1 - the image is at its highest point
  • center - containerMaxY == (view.height - bottomInset) * 0.5 + containerHeight * 0.5, parallaxRatio == 0.5 - the image is centered

For the bottom of the imageView we only take into consideration the top inset (4), because that's the only one affecting its final value in relation with the parent view.

For the bottom of the parent view we take into consideration both the top inset
and the bottom inset (4), because both affect the final value (the point where the image is leaving the visual field).

Since collectionView.visibleCells also returns cells that are below the top / bottom bars, even though they're not visible to the user, it's still good practice to limit the parallax values (5).

You can find more articles like this on my blog, or you can subscribe to my monthly newsletter. Originally published at https://rolandleth.com.

Discover and read more posts from Roland Leth
get started