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, theimageView
's top should have0px
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, theimageView
's top should have0px
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 pointbottom - containerMaxY == view.height + containerHeight - bottomInset
,parallaxRatio == 1
- the image is at its highest pointcenter - 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.