Parallax scrolling is a big UI design trend these days. In this article, I want to show you how to write a collection view that applies a nice parallax effect to its cells during scrolling. It is similar to the photos view in the WhatsApp app. Check out the video on the right for a demo.
The Idea
It looks like we will have to constantly reposition the images according to the current scroll position. We know that scroll views scroll by translating their bounds. This suggests a fairly simple solution for our problem:
-
Whenever the collection view scrolls, compute the center point of the collection view’s
bounds
. This will be the reference point: a cell’s distance from this point should determine the amount by which we shift the image the cell displays. -
For each visible cell, compute the distance of its center point (in the collection view’s coordinate system) to the reference point. Move the cell’s image view by a factor that is proportional to the (vertical) distance.
Since a UICollectionView
is a UIScrollView
, we could translate this directly into code. It would just be a matter of implementing the scrollViewDidScroll:
delegate method in your view controller, asking the collection view for all visible cells, and modifying them directly.1 Since we are dealing with a collection view, there is a more elegant way, however.
The positionign of the images inside the cells is a question of layout, and collection views provide a mechanism to encapsulate all layout code in a separate object. By writing a custom collection view layout, we can implement the parallax effect in a fairly transparent way.
A Custom Collection View Layout
A collection view layout is responsible for determining all attributes that are relevant for the positioning and sizing of the collection view’s cells (and of supplementary and decoration views, but we are not dealing with those in this example). Layouts use UICollectionViewLayoutAttributes
objects to pass layout properties to cells, and we should take a look at that class first before we implement our custom layout.
Update May 4, 2014: I updated the code and text with some fixes suggested by Lily Ballard. Thank you Lily!
UICollectionViewLayoutAttributes
UICollectionViewLayoutAttributes
supports several standard attributes and it can be subclassed to define custom ones. Our layout should compute another value called parallaxOffset
that tells the cell the amount by which it should shift its image up or down. So let’s create our own ParallaxLayoutAttributes
class that includes a parallaxOffset
property:
// ParallaxLayoutAttributes.h
@import UIKit;
@interface ParallaxLayoutAttributes : UICollectionViewLayoutAttributes
@property (nonatomic) CGPoint parallaxOffset;
@end
// ParallaxLayoutAttributes.m
#import "ParallaxLayoutAttributes.h"
@implementation ParallaxLayoutAttributes
- (id)copyWithZone:(NSZone *)zone
{
ParallaxLayoutAttributes *copy = [super copyWithZone:zone];
NSAssert([copy isKindOfClass:[self class]], @"copy must have the same class");
copy.parallaxOffset = self.parallaxOffset;
return copy;
}
- (BOOL)isEqual:(id)object
{
if (![object isKindOfClass:[ParallaxLayoutAttributes class]]) {
return NO;
}
ParallaxLayoutAttributes *otherObject = object;
if (!CGPointEqualToPoint(self.parallaxOffset, otherObject.parallaxOffset)) {
return NO;
}
return [super isEqual:otherObject];
}
@end
I declare parallaxOffset
as a CGPoint
although we are only dealing with vertical offsets in this example. This gives us the option to support horizontal offsets in the future, too.
As you can see, the class implementation makes sure to take the added property into account when instances are copied or compared. This requirement is clearly noted in the documentation for UICollectionViewLayoutAttributes
:
Because layout attribute objects may be copied by the collection view, make sure your subclass conforms to the
NSCopying
protocol by implementing any methods appropriate for copying your custom attributes to new instances of your subclass. … If you subclass and implement any custom layout attributes, you must also override the inheritedisEqual:
method to compare the values of your properties.
Update May 4, 2014: My original implementation of the isEqual:
method was missing the check of the class of the parameter.
Subclassing UICollectionViewFlowLayout
We can base our own layout on the existing UICollectionViewFlowLayout
implementation. The flow layout already performs all the computations to position and size the cells correctly — all we have to do is add our own calculation for the parallax offset to that result.
I am going to discuss the implementation of our ParallaxFlowLayout
class piece by piece:
// ParallaxFlowLayout.h
@import UIKit;
@interface ParallaxFlowLayout : UICollectionViewFlowLayout
@property (nonatomic, readonly) CGFloat maxParallaxOffset;
@end
Our layout class has one read-only property that reports the maximum parallax offset a (visible) cell can have. The collection view uses this information to report the correct size for each cell. It also passes this value to each cell so that the cell knows how big it should make its internal image view.
// ParallaxFlowLayout.m
#import "ParallaxFlowLayout.h"
#import "ParallaxLayoutAttributes.h"
static const CGFloat MaxParallaxOffset = 30.0;
@implementation ParallaxFlowLayout
+ (Class)layoutAttributesClass
{
return [ParallaxLayoutAttributes class];
}
- (CGFloat)maxParallaxOffset
{
return MaxParallaxOffset;
}
We first override the +layoutAttributesClass
class method to tell the layout which class it should use to instantiate layout attributes objects. This ensures that each layout attributes object has the parallaxOffset
property we defined above. Also, we return a constant value for maxParallaxOffset
.
- (BOOL)shouldInvalidateLayoutForBoundsChange:(CGRect)newBounds
{
return YES;
}
This tells the collection view that the layout needs to be recomputed on every bounds change (i.e., every time the view scrolls). The default is NO
because layout invalidations are potentially expensive and most layouts do not require an update on each scroll event — ours does because the cells’ parallax offsets change with scrolling.
- (NSArray *)layoutAttributesForElementsInRect:(CGRect)rect
{
NSArray *layoutAttributesArray =
[super layoutAttributesForElementsInRect:rect];
for (ParallaxLayoutAttributes *layoutAttributes in layoutAttributesArray) {
if (layoutAttributes.representedElementCategory ==
UICollectionElementCategoryCell)
{
layoutAttributes.parallaxOffset =
[self parallaxOffsetForLayoutAttributes:layoutAttributes];
}
}
return layoutAttributesArray;
}
- (UICollectionViewLayoutAttributes *)layoutAttributesForItemAtIndexPath:(NSIndexPath *)indexPath
{
ParallaxLayoutAttributes *layoutAttributes = (ParallaxLayoutAttributes *)
[super layoutAttributesForItemAtIndexPath:indexPath];
layoutAttributes.parallaxOffset =
[self parallaxOffsetForLayoutAttributes:layoutAttributes];
return layoutAttributes;
}
These two methods are invoked by the collection view whenever it needs to ask the layout to provide the layout attributes for one or multiple cells. Our implementation is simple: let super
do the work for the standard layout attributes and then invoke our own custom method parallaxOffsetForLayoutAttributes:
(discussed below) to compute the parallax offset.
Update May 4, 2014: Note that the layout attributes array in layoutAttributesForElementsInRect:
may include layout attributes for supplementary and decoration views. Since we don’t want to apply a parallax to those views, we check the representedElementCategory
to set the parallax offset only on cells.
Should performance become a problem due to the frequent layout invalidations, we could try caching the results returned from the super
invocations and only recompute the parallax offset during scrolling.2 To make this work, we would have to do the following:
- Subclass
UICollectionViewLayoutInvalidationContext
. Define a boolean property calledinvalidateParallaxOffset
in the subclass. - Override the
+invalidationContextClass
and return the customUICollectionViewLayoutInvalidationContext
subclass. - Override
invalidationContextForBoundsChange:
. In the implementation, invokesuper
first, then check if the bounds change only affected the origin (i.e., the size of the bounds has remained constant). If so, setinvalidateParallaxOffset = YES
on the context object before you return it. - Implement
invalidateLayoutWithContext:
. If the context parameter hasparallaxOffset == NO
set, throw away the cached layout information.
- (CGPoint)parallaxOffsetForLayoutAttributes:(ParallaxLayoutAttributes *)layoutAttributes
{
NSParameterAssert(layoutAttributes != nil);
NSParameterAssert([layoutAttributes isKindOfClass:[ParallaxLayoutAttributes class]]);
CGRect bounds = self.collectionView.bounds;
CGPoint boundsCenter = CGPointMake(CGRectGetMidX(bounds),
CGRectGetMidY(bounds));
CGPoint cellCenter = layoutAttributes.center;
CGPoint offsetFromCenter = CGPointMake(boundsCenter.x - cellCenter.x,
boundsCenter.y - cellCenter.y);
CGSize cellSize = layoutAttributes.size;
CGFloat maxVerticalOffsetWhereCellIsStillVisible =
(bounds.size.height / 2) + (cellSize.height / 2);
CGFloat scaleFactor = self.maxParallaxOffset /
maxVerticalOffsetWhereCellIsStillVisible;
CGPoint parallaxOffset = CGPointMake(0.0, offsetFromCenter.y * scaleFactor);
return parallaxOffset;
}
@end
The computation of the parallax offset follows the idea outlined above. We first determine the center of the collection view’s current bounds and calculate the cell’s distance from that point. The parallaxOffset
is then scaled in such a way that it reaches maxParallaxOffset
just as the cell moves outside the visible bounds.
Applying Parallax Offset to Cells
The final piece of the puzzle is the implementation of the collection view cells. The image view that displays the photo must be taller than the cell itself to provide room for shifting it. By setting up a layout constraint that makes the image view’s height equal to the cell’s height plus 2 * maxParallaxOffset
, we make sure we can move the image view up or down by maxParallaxOffset
without running out of content.
I also set up a second layout constraint that centers the image view vertically in the cell:
// ParallaxPhotoCell.m
self.imageViewCenterYConstraint = [NSLayoutConstraint constraintWithItem:self.imageView
attribute:NSLayoutAttributeCenterY
relatedBy:NSLayoutRelationEqual
toItem:self.contentView
attribute:NSLayoutAttributeCenterY
multiplier:1
constant:0];
[self.contentView addConstraint:self.imageViewCenterYConstraint];
By later setting the constant
of this constraint to the parallaxOffset
we get from the layout, we can shift the image view very easily. UICollectionViewCell
includes a method named applyLayoutAttributes:
for this purpose, which we override to apply the parallax offset to the constraint:
- (void)applyLayoutAttributes:(UICollectionViewLayoutAttributes *)layoutAttributes
{
[super applyLayoutAttributes:layoutAttributes];
NSParameterAssert(layoutAttributes != nil);
NSParameterAssert([layoutAttributes isKindOfClass:[ParallaxLayoutAttributes class]]);
ParallaxLayoutAttributes *parallaxLayoutAttributes =
(ParallaxLayoutAttributes *)layoutAttributes;
self.imageViewCenterYConstraint.constant =
parallaxLayoutAttributes.parallaxOffset.y;
}
Conclusion
And that’s it. I’m not showing the implementation of the collection view controller here as it is mostly boilerplate (setting up the data source and configuring the cells). The full code is available on GitHub.
Writing a custom collection view layout takes a few more lines of code than the straightforward implementation, but it is repaid by a much clearer separation of concerns.
Update February 21, 2015: Engin Kurutepe wrote a follow-up article in which he discusses possible performance problems with my approach. If your collection view layout is computation-intensive, you should definitely investigate caching or take a look at Engin’s solution.
-
If you want to check out how one would implement this, this project on GitHub uses this approach. ↩︎
-
You should measure carefully if caching actually improves performance in this case. It’s perfectly possible that
UICollectionViewFlowLayout
already performs some caching internally and adding another caching layer wouldn’t help a bit. ↩︎