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
cachedictionary is empty or not. This is crucial to not to mess up old and newattributesinstances. - If the
cachedictionary 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
attributesfirst. You create an instance of theCustomLayoutAttributesclass 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 itsattributescorrectly. - The sticky menu is the second element of the collection view. You calculate its
attributesthe same way as before. - This loop is the most important of the core layout process. For every
itemin everysectionof the collection view you:- Create and prepare the
attributesfor the section's header. - Create the
attributesfor theitems. - Associate them to a specific
indexPath. - Calculate and set the items
frameandzIndex. - Update the
contentHeightof theUICollectionView. - Store the freshly created attributes in the
cachedictionary using thetype(in this case a cell) andindexPathof the element as keys. - Finally, you create and prepare the
attributesfor the section's footer.
- Create and prepare the
- Last but not least, you call a method to update all
zIndexvalues. 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
sizeor not. If the element has no size, there's no reason to cache itsattributes - Next, assign the frame's
originvalue to the attribute'sinitialOriginproperty. 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
zIndexvalue to prevent overlapping between different elements. - Once you've created and saved the required information, update the collection view's
contentHeightsince you've added a new element to yourUICollectionView. A smart way to perform this update is by assigning the attribute's framemaxYvalue to thecontentHeightproperty. - Finally add the attributes to the
cachedictionary using the elementtypeandindexPathas 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 elementkindproperty and return the cached attributes matching the correctkindandindexPath. - Inside
layoutAttributesForItem(at:)you do exactly the same for the cells’s attributes. - Inside
layoutAttributesForElements(in:)you empty thevisibleLayoutAttributesarray (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 itsframeintersects the collection view’sframe. Finally return thevisibleAttributesarray.