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, // We might have some labels below.
		iv.leadingAnchor.constraint(equalTo: self.contentView.leadingAnchor,
		iv.trailingAnchor.constraint(equalTo: self.contentView.trailingAnchor)]
	)

	return iv
}()

We would have to add a new property for its topAnchor constraint, add the container, change the constraints accordingly, and the result should be like this:

private var imageViewTop: NSLayoutConstraint? = nil
private lazy var imageView: UIImageView = {
	let container = UIView()
	self.contentView.addSubview(container)
	
	container.translatesAutoresizingMaskIntoConstraints = false
	// These are our old imageView constraints.
	NSLayoutConstraint.activate([
		container.topAnchor.constraint(equalTo: self.contentView.topAnchor,
		container.bottomAnchor.constraint(equalTo: self.contentView.bottomAnchor, constant: -60, // We might have some labels below.
		container.leadingAnchor.constraint(equalTo: self.contentView.leadingAnchor,
		container.trailingAnchor.constraint(equalTo: self.contentView.trailingAnchor)]
	)
	
	/*
	If we increase the height by 20, we need to increase the width accordingly,
	to keep the same ratio, then we divide by 2, to use the value as leading and trailing constants.

	let widthDelta = 10 * containerWidth / containerHeight // We'd need a way to access these, though, if the image is full-width.
	
	Or, we could use centerXAnchor, instead of leading and trailing,
	as the width will be automatically set, based on its intrinsic size.
	*/
	
	let iv = UIImageView(image: UIImage(named: "landscape"))
	container.addSubview(iv)
	
	iv.translatesAutoresizingMaskIntoConstraints = false
	// Its constant doesn't matter right now, 
	// because it will get updated instantly by the call from scrollViewDidScroll.
	self.imageViewTop = iv.topAnchor.constraint(equalTo: container.topAnchor)
	NSLayoutConstraint.activate([
		imageViewTop!,
		iv.heightAnchor.constraint(equalTo: container.heightAnchor,
								   constant: 20), // As we saw earlier, the image has to be bigger than the container.
		iv.centerXAnchor.constraint(equalTo: container.centerXAnchor)]
	)
	
	// We can still return the imageView, to be easier to set its image, for example.
	// And we can always and safely access imageView.superview! if needed, since we're sure it exists.
	return iv
}()

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

func updateImage(in collectionView: UICollectionView, in view: UIView) {
	// We need the cell's rect in the collectionView's containing view coordinates.
	let rect = collectionView.convert(frame, to: view)
	// The bottom of the imageView is 60px higher than its container's.
	let containerMaxY = rect.maxY - 60
	
	/* 
	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, for example:
	
	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, since we want to finish the parallax
	the moment the cell is not visible anymore, for maximum and closest to real effect.
	*/
	let topInset = collectionView.contentInset.top
	let bottomInset = collectionView.contentInset.bottom
	
	/*
	If we think about our three positions, we would have 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
	*/
	let parallaxRatio = (containerMaxY - topInset) / (view.height + containerHeight - topInset - bottomInset)
	/*
	For the bottom of the imageView we only take into consideration the top inset,
	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, because both affect the final value (the point where the image is leaving the visual field).
	*/
	
	// Since the rect will be also calculated while below the top / bottom bars,
	// even though it won't be visible to the user, it's still good practice to limit the values.
	imageTopConstraint?.constant = -20 * min(1, max(0, parallaxRatio))
}

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