Vertical scrolling parallax

:  ~ 4 min read

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); and 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.

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:

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:

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).

As always, I'd be more than happy to hear your feedback and discuss @rolandleth.