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 2: Implementing the CollectionViewLayout Core Process
The collection view works directly with your CustomLayout
object to manage the overall layout process. For example, the collection view asks for layout information when it’s first displayed or resized.
During the layout process, the collection view calls the required methods of the CustomLayout
object. Other optional methods may be called under specific circumstances like animated updates. These methods are your chance to calculate the position of items and to provide the collection view with the information it needs.
The first two required methods to override are:
prepare()
shouldInvalidateLayout(forBoundsChange:)
prepare()
is your opportunity to perform whatever calculations are needed to determine the position of the elements in the layout. shouldInvalidateLayout(forBoundsChange:)
is where you define how and when the CustomLayout
object needs to perform the core process again.
Let’s start by implementing prepare()
.
Open CustomLayout.swift and add the following extension to the end of the file:
// MARK: - LAYOUT CORE PROCESS
extension CustomLayout {
override public func prepare() {
// 1
guard let collectionView = collectionView,
cache.isEmpty else {
return
}
// 2
prepareCache()
contentHeight = 0
zIndex = 0
oldBounds = collectionView.bounds
let itemSize = CGSize(width: cellWidth, height: cellHeight)
// 3
let headerAttributes = CustomLayoutAttributes(
forSupplementaryViewOfKind: Element.header.kind,
with: IndexPath(item: 0, section: 0)
)
prepareElement(size: headerSize, type: .header, attributes: headerAttributes)
// 4
let menuAttributes = CustomLayoutAttributes(
forSupplementaryViewOfKind: Element.menu.kind,
with: IndexPath(item: 0, section: 0))
prepareElement(size: menuSize, type: .menu, attributes: menuAttributes)
// 5
for section in 0 ..< collectionView.numberOfSections {
let sectionHeaderAttributes = CustomLayoutAttributes(
forSupplementaryViewOfKind: UICollectionElementKindSectionHeader,
with: IndexPath(item: 0, section: section))
prepareElement(
size: sectionsHeaderSize,
type: .sectionHeader,
attributes: sectionHeaderAttributes)
for item in 0 ..< collectionView.numberOfItems(inSection: section) {
let cellIndexPath = IndexPath(item: item, section: section)
let attributes = CustomLayoutAttributes(forCellWith: cellIndexPath)
let lineInterSpace = settings.minimumLineSpacing
attributes.frame = CGRect(
x: 0 + settings.minimumInteritemSpacing,
y: contentHeight + lineInterSpace,
width: itemSize.width,
height: itemSize.height
)
attributes.zIndex = zIndex
contentHeight = attributes.frame.maxY
cache[.cell]?[cellIndexPath] = attributes
zIndex += 1
}
let sectionFooterAttributes = CustomLayoutAttributes(
forSupplementaryViewOfKind: UICollectionElementKindSectionFooter,
with: IndexPath(item: 1, section: section))
prepareElement(
size: sectionsFooterSize,
type: .sectionFooter,
attributes: sectionFooterAttributes)
}
// 6
updateZIndexes()
}
}
Taking each commented section in turn:
- Prepare operations are resourse-intensive and could impact performance. For this reason, you’re going to cache the calculated attributes on creation. Before executing, you have to check whether the
cache
dictionary is empty or not. This is crucial to not to mess up old and newattributes
instances. - If the
cache
dictionary is empty, you have to properly initialize it. Do this by callingprepareCache()
. This will be implemented after this explanation. - The stretchy header is the first element of the collection view. For this reason, you take into account its
attributes
first. You create an instance of theCustomLayoutAttributes
class and then pass it toprepareElement(size:type:attributes)
. Again, you’ll implement this method later. For the moment keep in mind each time you create a custom element you have to call this method in order to cache itsattributes
correctly. - The sticky menu is the second element of the collection view. You calculate its
attributes
the same way as before. - This loop is the most important of the core layout process. For every
item
in everysection
of the collection view you:- Create and prepare the
attributes
for the section's header. - Create the
attributes
for theitems
. - Associate them to a specific
indexPath
. - Calculate and set the items
frame
andzIndex
. - Update the
contentHeight
of theUICollectionView
. - Store the freshly created attributes in the
cache
dictionary using thetype
(in this case a cell) andindexPath
of the element as keys. - Finally, you create and prepare the
attributes
for the section's footer.
- Create and prepare the
- Last but not least, you call a method to update all
zIndex
values. You're going to discover details later aboutupdateZIndexes()
and you'll learn why it’s important to do that.
Next, add the following method just below prepare()
:
override public func shouldInvalidateLayout(forBoundsChange newBounds: CGRect) -> Bool {
if oldBounds.size != newBounds.size {
cache.removeAll(keepingCapacity: true)
}
return true
}
Inside shouldInvalidateLayout(forBoundsChange:)
, you have to define how and when you want to invalidate the calculations performed by prepare()
. The collection view calls this method every time its bounds
property changes. Note that the collection view's bounds
property changes every time the user scrolls.
You always return true
and if the bounds size
changes, which means the collection view transited from portrait
to landscape
mode or vice versa, you purge the cache
dictionary too.
A cache purge is necessary because a change of the device’s orientation triggers a redrawing of the collection view’s frame
. As a consequence all the stored attributes won’t fit inside the new collection view's frame.
Next, you're going to implement all the methods called inside prepare()
but haven't yet implemented:
Add the following to the bottom of the extension:
private func prepareCache() {
cache.removeAll(keepingCapacity: true)
cache[.header] = [IndexPath: CustomLayoutAttributes]()
cache[.menu] = [IndexPath: CustomLayoutAttributes]()
cache[.sectionHeader] = [IndexPath: CustomLayoutAttributes]()
cache[.sectionFooter] = [IndexPath: CustomLayoutAttributes]()
cache[.cell] = [IndexPath: CustomLayoutAttributes]()
}
This first thing this method does is empty the cache
dictionary. Next, it resets all the nested dictionaries, one for each element family, using the element type
as primary key. The indexPath
will be the secondary key used to identify the cached attributes.
Next, you're going to implement prepareElement(size:type:attributes:)
.
Add the following definition to the end of the extension:
private func prepareElement(size: CGSize, type: Element, attributes: CustomLayoutAttributes) {
//1
guard size != .zero else {
return
}
//2
attributes.initialOrigin = CGPoint(x:0, y: contentHeight)
attributes.frame = CGRect(origin: attributes.initialOrigin, size: size)
// 3
attributes.zIndex = zIndex
zIndex += 1
// 4
contentHeight = attributes.frame.maxY
// 5
cache[type]?[attributes.indexPath] = attributes
}
Here's a step-by-step explanation of what's happening above:
- Check whether the element has a valid
size
or not. If the element has no size, there's no reason to cache itsattributes
- Next, assign the frame's
origin
value to the attribute'sinitialOrigin
property. Having a backup of the initial position of the element will be necessary in order to calculate the parallax and sticky transforms later. - Next, assign the
zIndex
value to prevent overlapping between different elements. - Once you've created and saved the required information, update the collection view's
contentHeight
since you've added a new element to yourUICollectionView
. A smart way to perform this update is by assigning the attribute's framemaxY
value to thecontentHeight
property. - Finally add the attributes to the
cache
dictionary using the elementtype
andindexPath
as unique keys.
Finally it’s time to implement updateZIndexes()
called at the end of prepare()
.
Add the following to the bottom of the extension:
private func updateZIndexes(){
guard let sectionHeaders = cache[.sectionHeader] else {
return
}
var sectionHeadersZIndex = zIndex
for (_, attributes) in sectionHeaders {
attributes.zIndex = sectionHeadersZIndex
sectionHeadersZIndex += 1
}
cache[.menu]?.first?.value.zIndex = sectionHeadersZIndex
}
This methods assigns a progressive zIndex
value to the section headers. The count starts from the last zIndex
assigned to a cell. The greatest zIndex
value is assigned to the menu's attributes
. This re-assignment is necessary to have a consistent sticky behaviour. If this method isn't called, the cells of a given section will have a greater zIndex
than the header of the section. This will cause ugly overlapping effects while scrolling.
To complete the CustomLayout
class and make the layout core process work correctly, you need to implement some more required methods:
layoutAttributesForSupplementaryView(ofKind:at:)
layoutAttributesForItem(at:)
layoutAttributesForElements(in:)
The goal of these methods is to provide the right attributes to the right element at the right time. More specifically, the two first methods provide the collection view with the attributes for a specific supplementary view or a specific cell. The third one returns the layout attributes for the displayed elements in a given moment.
//MARK: - PROVIDING ATTRIBUTES TO THE COLLECTIONVIEW
extension CustomLayout {
//1
public override func layoutAttributesForSupplementaryView(
ofKind elementKind: String,
at indexPath: IndexPath) -> UICollectionViewLayoutAttributes? {
switch elementKind {
case UICollectionElementKindSectionHeader:
return cache[.sectionHeader]?[indexPath]
case UICollectionElementKindSectionFooter:
return cache[.sectionFooter]?[indexPath]
case Element.header.kind:
return cache[.header]?[indexPath]
default:
return cache[.menu]?[indexPath]
}
}
//2
override public func layoutAttributesForItem(
at indexPath: IndexPath) -> UICollectionViewLayoutAttributes? {
return cache[.cell]?[indexPath]
}
//3
override public func layoutAttributesForElements(
in rect: CGRect) -> [UICollectionViewLayoutAttributes]? {
visibleLayoutAttributes.removeAll(keepingCapacity: true)
for (_, elementInfos) in cache {
for (_, attributes) in elementInfos where attributes.frame.intersects(rect) {
visibleLayoutAttributes.append(attributes)
}
}
return visibleLayoutAttributes
}
}
Taking it comment-by-comment:
- Inside
layoutAttributesForSupplementaryView(ofKind:at:)
you switch on the elementkind
property and return the cached attributes matching the correctkind
andindexPath
. - Inside
layoutAttributesForItem(at:)
you do exactly the same for the cells’s attributes. - Inside
layoutAttributesForElements(in:)
you empty thevisibleLayoutAttributes
array (where you’ll store the visibile attributes). Next, iterate on all cached attributes and add only visible elements to the array. To determinate whether an element is visibile or not, test if itsframe
intersects the collection view’sframe
. Finally return thevisibleAttributes
array.