---
title: "Vertical scrolling parallax"
slug: vertical-scrolling-parallax
section: tech
date: 2016-12-16T23:12:00.000Z
canonical: https://roland.leth.ro/blog/tech/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); 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`:  

* 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`:

```swift
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:

```swift
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:

```swift
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:

```swift
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`:

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

As always, I'd be more than happy to hear your feedback and discuss [@roland.leth.ro][1].

[1]:	https://bsky.app/profile/roland.leth.ro