Custom UICollectionViewLayout Tutorial With Parallax
Introduced in iOS6, UICollectionView is a first-class choice for advanced customization and animation. Learn more in this UICollectionViewLayout tutorial. By .
Sign up/Sign in
With a free Kodeco account you can download source code, track your progress, bookmark, personalise your learner profile and more!
Create accountAlready a member of Kodeco? Sign in
Sign up/Sign in
With a free Kodeco account you can download source code, track your progress, bookmark, personalise your learner profile and more!
Create accountAlready a member of Kodeco? Sign in
Contents
Custom UICollectionViewLayout Tutorial With Parallax
30 mins
- Getting Started
- Layout Settings
- Layout Attributes
- The Role of UICollectionViewLayout
- Step 1: Subclassing the UICollectionViewLayout Class
- Step 2: Implementing the CollectionViewLayout Core Process
- Step 3: Adopting the CustomLayout
- Adding Stretchy, Sticky and Parallax Effects
- Affine Transforms
- Transforming Visible Attributes
- Where to Go From Here?
Step 3: Adopting the CustomLayout
Before building and running the project you need to:
- Make the collection view adopt the
CustomLayout
class. - Make the
JungleCupCollectionViewController
support the custom supplementary views.
Open Main.storyboard and select the Collection View Flow Layout in the Jungle Cup Collection View Controller Scene as shown below:
Next, open the Identity Inspector and change the Custom Class to CustomLayout
as shown below:
Next, open JungleCupCollectionViewController.swift.
Add the computed property customLayout
to avoid verbose code duplication.
Your code should look like the following:
var customLayout: CustomLayout? {
return collectionView?.collectionViewLayout as? CustomLayout
}
Next, replace setUpCollectionViewLayout()
with the following:
private func setupCollectionViewLayout() {
guard let collectionView = collectionView,
let customLayout = customLayout else {
return
}
// 1
collectionView.register(
UINib(nibName: "HeaderView", bundle: nil),
forSupplementaryViewOfKind: CustomLayout.Element.header.kind,
withReuseIdentifier: CustomLayout.Element.header.id
)
collectionView.register(
UINib(nibName: "MenuView", bundle: nil),
forSupplementaryViewOfKind: CustomLayout.Element.menu.kind,
withReuseIdentifier: CustomLayout.Element.menu.id
)
// 2
customLayout.settings.itemSize = CGSize(width: collectionView.frame.width, height: 200)
customLayout.settings.headerSize = CGSize(width: collectionView.frame.width, height: 300)
customLayout.settings.menuSize = CGSize(width: collectionView.frame.width, height: 70)
customLayout.settings.sectionsHeaderSize = CGSize(width: collectionView.frame.width, height: 50)
customLayout.settings.sectionsFooterSize = CGSize(width: collectionView.frame.width, height: 50)
customLayout.settings.isHeaderStretchy = true
customLayout.settings.isAlphaOnHeaderActive = true
customLayout.settings.headerOverlayMaxAlphaValue = CGFloat(0)
customLayout.settings.isMenuSticky = true
customLayout.settings.isSectionHeadersSticky = true
customLayout.settings.isParallaxOnCellsEnabled = true
customLayout.settings.maxParallaxOffset = 60
customLayout.settings.minimumInteritemSpacing = 0
customLayout.settings.minimumLineSpacing = 3
}
Here's what the code above does:
- First, register the custom classes used for the stretchy header and the custom menu. These are
UICollectionReusableView
subclasses already implemented in the starter project. - Finally, set sizes, behaviours and spacings of the
CustomLayout
settings.
Before you build an run the app, add the following two case
options to viewForSupplementaryElementOfKind(_:viewForSupplementaryElementOfKind:at:)
to handle custom supplementary view types:
case CustomLayout.Element.header.kind:
let topHeaderView = collectionView.dequeueReusableSupplementaryView(
ofKind: kind,
withReuseIdentifier: CustomLayout.Element.header.id,
for: indexPath)
return topHeaderView
case CustomLayout.Element.menu.kind:
let menuView = collectionView.dequeueReusableSupplementaryView(
ofKind: kind,
withReuseIdentifier: CustomLayout.Element.menu.id,
for: indexPath)
if let menuView = menuView as? MenuView {
menuView.delegate = self
}
return menuView
Well done! It was a long journey, but you're almost done.
Build and run the project! You should see something similar to the following:
The UICollectionView
from the starter project now has some extra features:
- At the top there's a big header showing the Jungle Cup's logo.
- Below that, there's a menu with four buttons, one for each team. If you tap a button, the collection view reloads with the corresponding team.
You've already done a good job, but you can do better. It’s time to go for some nice visual effects to dress up your UICollectionView
.
Adding Stretchy, Sticky and Parallax Effects
In the final section of this UICollectionViewLayout
tutorial, you're going to add the following visual effects:
- Make the header stretchy and bouncy.
- Add a sticky effect to the menu and the section headers.
- Implement a smooth parallax effect to make the user interface more engaging.
CGATransform
, you can check out this tutorial before continuing. The following part of the UICollectionViewLayout
tutorial implies a basic knowledge of affine transforms.Affine Transforms
The Core Graphics
CGAffineTransform
API is the best way to apply visual effects to the elements of a UICollectionView
.
Affine transforms are quite useful for a variety of reasons:
- They let you create complex visual effects like translation, scaling and rotation, or a combination of the three, in very few lines of code.
- They interoperate in a flawless way with
UIKit
components andAutoLayout
. - They help you keep performance optimal even in complicated scenarios.
The math behind affine transforms is really cool. However, explaining how matrices work behind the scenes of CGATransform
is out of scope for this UICollectionViewLayout
tutorial.
If you’re interested in this topic, you can find more details in Apple’s Core Graphic Framework Documentation.
Transforming Visible Attributes
Open CustomLayout.swift and update layoutAttributesForElements(in:)
to the following:
override public func layoutAttributesForElements(
in rect: CGRect) -> [UICollectionViewLayoutAttributes]? {
guard let collectionView = collectionView else {
return nil
}
visibleLayoutAttributes.removeAll(keepingCapacity: true)
// 1
let halfHeight = collectionViewHeight * 0.5
let halfCellHeight = cellHeight * 0.5
// 2
for (type, elementInfos) in cache {
for (indexPath, attributes) in elementInfos {
// 3
attributes.parallax = .identity
attributes.transform = .identity
// 4
updateSupplementaryViews(
type,
attributes: attributes,
collectionView: collectionView,
indexPath: indexPath)
if attributes.frame.intersects(rect) {
// 5
if type == .cell,
settings.isParallaxOnCellsEnabled {
updateCells(attributes, halfHeight: halfHeight, halfCellHeight: halfCellHeight)
}
visibleLayoutAttributes.append(attributes)
}
}
}
return visibleLayoutAttributes
}
Here's a step-by-step explanation of what's happening above:
- You store some useful values to avoid calculating them in the loop.
- This is the same loop as the previous version of this method. You iterate on all the cached attributes.
- Reset to the default value
parallax
transform and the element attributestransform
. - For the moment, you simply call a method to update the different kind of supplementary views. You'll implement it after this code block.
- Check whether the current attributes belong to a cell. If the parallax effect is activated in the layout settings, call a method to update its attributes. Just as above, you'll implement this method after this code block.
Next, it's time to implement the two methods called in the above loop:
updateSupplementaryViews(_:attributes:collectionView:indexPath:)
updateCells(_:halfHeight:halfCellHeight:)
Add the following:
private func updateSupplementaryViews(_ type: Element,
attributes: CustomLayoutAttributes,
collectionView: UICollectionView,
indexPath: IndexPath) {
// 1
if type == .sectionHeader,
settings.isSectionHeadersSticky {
let upperLimit =
CGFloat(collectionView.numberOfItems(inSection: indexPath.section))
* (cellHeight + settings.minimumLineSpacing)
let menuOffset = settings.isMenuSticky ? menuSize.height : 0
attributes.transform = CGAffineTransform(
translationX: 0,
y: min(upperLimit,
max(0, contentOffset.y - attributes.initialOrigin.y + menuOffset)))
}
// 2
else if type == .header,
settings.isHeaderStretchy {
let updatedHeight = min(
collectionView.frame.height,
max(headerSize.height, headerSize.height - contentOffset.y))
let scaleFactor = updatedHeight / headerSize.height
let delta = (updatedHeight - headerSize.height) / 2
let scale = CGAffineTransform(scaleX: scaleFactor, y: scaleFactor)
let translation = CGAffineTransform(
translationX: 0,
y: min(contentOffset.y, headerSize.height) + delta)
attributes.transform = scale.concatenating(translation)
if settings.isAlphaOnHeaderActive {
attributes.headerOverlayAlpha = min(
settings.headerOverlayMaxAlphaValue,
contentOffset.y / headerSize.height)
}
}
// 3
else if type == .menu,
settings.isMenuSticky {
attributes.transform = CGAffineTransform(
translationX: 0,
y: max(attributes.initialOrigin.y, contentOffset.y) - headerSize.height)
}
}
Taking each numbered comment in turn:
- Test whether the current element is a section header. Then, if the sticky behaviour is activated in the layout settings, compute the
transform
. Finally assign the calculated value to the attributes'transform
property. - Same routine as above, but this time check whether the element is the top header. If the stretchy effect is activated, perform the transform calculations.
- Same routine again. This time perform transform calculations for the sticky menu.
Now it's time to transform the collection view cells:
private func updateCells(_ attributes: CustomLayoutAttributes,
halfHeight: CGFloat,
halfCellHeight: CGFloat) {
// 1
let cellDistanceFromCenter = attributes.center.y - contentOffset.y - halfHeight
// 2
let parallaxOffset = -(settings.maxParallaxOffset * cellDistanceFromCenter)
/ (halfHeight + halfCellHeight)
// 3
let boundedParallaxOffset = min(
max(-settings.maxParallaxOffset, parallaxOffset),
settings.maxParallaxOffset)
// 4
attributes.parallax = CGAffineTransform(translationX: 0, y: boundedParallaxOffset)
}
Here's the play-by-play:
- Calculate the distance of the cell from the
center
of the collection view. - Map proportionally the cell's distance from the center on the maximum
parallax
value (set in the layout settings) - Bound the
parallaxOffset
to avoid visual glitches. - Create a
CAAffineTransform
translation with the computedparallax
value. Finally, assign the translation to the cell's attributestransform
property.
To achieve the parallax effect on the PlayerCell
, the image's frame should have top and bottom negative insets. In the starter project these constraints are set for you. You can check them in the Constraint inspector (see below).
Before building, you have to fix one final detail. Open JungleCupCollectionViewController.swift. Inside setupCollectionViewLayout()
change the following value:
customLayout.settings.headerOverlayMaxAlphaValue = CGFloat(0)
to the following:
customLayout.settings.headerOverlayMaxAlphaValue = CGFloat(0.6)
This value represents the maximum opacity value the layout can assign to the black overlay on the headerView
.
Build and run the project to appreciate all the visual effects. Let it scroll! Let it scroll! Let it scroll! :]