UICollectionView Custom Layout Tutorial: Pinterest
Build a UICollectionView custom layout inspired by the Pinterest app, and learn how to cache attributes and dynamically size cells. By Andrew Kharchyshyn.
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
UICollectionView Custom Layout Tutorial: Pinterest
15 mins
UICollectionView
, introduced in iOS 6, has become one of the most popular UI elements among iOS developers. What makes it so attractive is the separation between the data and presentation layers, which depends on a separate object to handle the layout. The layout is then responsible for determining the placement and visual attributes of the views.
You’ve likely used the default flow layout, a layout class provided by UIKit. It’s a basic grid layout with some customizations.
But you can also implement your own custom layouts to arrange the views any way you like. This makes the collection view flexible and powerful.
In this UICollectionView
custom layout tutorial, you’ll create a layout inspired by the popular Pinterest app.
In the process, you’ll learn:
- About custom layouts.
- How to calculate and cache layout attributes.
- How to handle dynamically sized cells.
- UICollectionView Tutorial Part 1: Getting Started
- UICollectionView Tutorial Part 2: Reusable Views and Cell Selection
- Video Tutorial: Collection Views Part 0: Introduction
UICollectionView
. If you’re not familiar with it, you can learn more in our written or video tutorial series:
Ready to jazz up your collection view? Read on!
Getting Started
Download the project materials by clicking the Download Materials button at the top or bottom of this tutorial. Open the starter project in Xcode.
Build and run the project. You’ll see the following:
The app presents a gallery of photos from RWDevCon. You can browse the photos and see how much fun the attendees had while at the conference.
The gallery uses a collection view with a standard flow layout. At first sight, it looks OK. But you could certainly improve the layout design.
The photos don’t completely fill the content area. Long captions get truncated. The user experience is boring and static because all cells are the same size.
You could improve the design with a custom layout where each cell is free to be the size that perfectly fits its needs.
Creating Custom Collection View Layouts
You’ll create a stunning collection view by first creating a custom layout class for your gallery.
Collection view layouts are subclasses of the abstract class UICollectionViewLayout
. They define the visual attributes of every item in your collection view.
The individual attributes are instances of UICollectionViewLayoutAttributes
. These contain the properties of each item in your collection view, such as the item’s frame
or transform
.
Create a new file inside the Layouts group. Select Cocoa Touch Class from the iOS ▸ Source list. Name it PinterestLayout and make it a subclass of UICollectionViewLayout.
Next, configure the collection view to use your new layout. Open Main.storyboard. Select the Collection View in the Photo Stream View Controller Scene as shown below:
Next, open the Attributes inspector. Select Custom in the Layout drop-down list. Then select PinterestLayout in the Class drop-down list:
Okay — time to see how it looks. Build and run your app:
Don’t panic! Believe it or not, this is a good sign.
This means the collection view is using your custom layout class. The cells don’t show because PinterestLayout
doesn’t implement any of the methods involved in the layout process yet.
Core Layout Process
Think about the collection view layout process. It’s a collaboration between the collection view and the layout object. When the collection view needs some layout information, it asks your layout object to provide it by calling certain methods in a specific order:
Your layout subclass must implement the following methods:
- collectionViewContentSize: This method returns the width and height of the collection view’s contents. You must implement it to return the height and width of the entire collection view’s content, not just the visible content. The collection view uses this information internally to configure its scroll view’s content size.
- prepare(): Whenever a layout operation is about to take place, UIKit calls this method. It’s your opportunity to prepare and perform any calculations required to determine the collection view’s size and the positions of the items.
-
layoutAttributesForElements(in:): In this method, you return the layout attributes for all items inside the given rectangle. You return the attributes to the collection view as an array of
UICollectionViewLayoutAttributes
. -
layoutAttributesForItem(at:): This method provides on demand layout information to the collection view. You need to override it and return the layout attributes for the item at the requested
indexPath
.
Okay, so you know what you need to implement. But how do you go about calculating these attributes?
Calculating Layout Attributes
For this layout, you need to dynamically calculate the height of every item since you don’t know the photo’s height in advance. You’ll declare a protocol that’ll provide this information when PinterestLayout
needs it.
Now, back to the code. Open PinterestLayout.swift. Add the following delegate protocol declaration before PinterestLayout
class:
protocol PinterestLayoutDelegate: AnyObject {
func collectionView(
_ collectionView: UICollectionView,
heightForPhotoAtIndexPath indexPath: IndexPath) -> CGFloat
}
This code declares a PinterestLayoutDelegate
protocol. It has a method to request the photo’s height. You’ll implement this protocol in PhotoStreamViewController
shortly.
There’s one more thing to do before implementing the layout methods. You need to declare some properties that’ll help with the layout process.
Add the following to PinterestLayout
:
// 1
weak var delegate: PinterestLayoutDelegate?
// 2
private let numberOfColumns = 2
private let cellPadding: CGFloat = 6
// 3
private var cache: [UICollectionViewLayoutAttributes] = []
// 4
private var contentHeight: CGFloat = 0
private var contentWidth: CGFloat {
guard let collectionView = collectionView else {
return 0
}
let insets = collectionView.contentInset
return collectionView.bounds.width - (insets.left + insets.right)
}
// 5
override var collectionViewContentSize: CGSize {
return CGSize(width: contentWidth, height: contentHeight)
}
This code defines some properties you’ll need later to provide the layout information. Here it is, explained step-by-step:
- This keeps a reference to the delegate.
- These are two properties for configuring the layout: The number of columns and the cell padding.
- This is an array to cache the calculated attributes. When you call
prepare()
, you’ll calculate the attributes for all items and add them to the cache. When the collection view later requests the layout attributes, you can efficiently query the cache instead of recalculating them every time. - This declares two properties to store the content size. You increment
contentHeight
as you add photos and calculatecontentWidth
based on the collection view width and its content inset. -
collectionViewContentSize
returns the size of the collection view’s contents. You use bothcontentWidth
andcontentHeight
from previous steps to calculate the size.
You’re ready to calculate the attributes for the collection view items. For now, it’ll consist of the frame. To understand how you’ll do this, take a look at the following diagram:
You’ll calculate the frame of every item based on its column and the position of the previous item in the same column. You do this by tracking xOffset
for the frame, and yOffset
for the position of the previous item.
You’ll first use the starting X coordinate of the column the item belongs to in order to calculate the horizontal position, and then add the cell padding. The vertical position is the starting position of the prior item in that column, plus the height of that prior item. The overall item height is the sum of the image height and the content padding.
You’ll do this in prepare()
. Your primary objective is to calculate an instance of UICollectionViewLayoutAttributes
for every item in the layout.
Add the following method to PinterestLayout
:
override func prepare() {
// 1
guard
cache.isEmpty,
let collectionView = collectionView
else {
return
}
// 2
let columnWidth = contentWidth / CGFloat(numberOfColumns)
var xOffset: [CGFloat] = []
for column in 0..<numberOfColumns {
xOffset.append(CGFloat(column) * columnWidth)
}
var column = 0
var yOffset: [CGFloat] = .init(repeating: 0, count: numberOfColumns)
// 3
for item in 0..<collectionView.numberOfItems(inSection: 0) {
let indexPath = IndexPath(item: item, section: 0)
// 4
let photoHeight = delegate?.collectionView(
collectionView,
heightForPhotoAtIndexPath: indexPath) ?? 180
let height = cellPadding * 2 + photoHeight
let frame = CGRect(x: xOffset[column],
y: yOffset[column],
width: columnWidth,
height: height)
let insetFrame = frame.insetBy(dx: cellPadding, dy: cellPadding)
// 5
let attributes = UICollectionViewLayoutAttributes(forCellWith: indexPath)
attributes.frame = insetFrame
cache.append(attributes)
// 6
contentHeight = max(contentHeight, frame.maxY)
yOffset[column] = yOffset[column] + height
column = column < (numberOfColumns - 1) ? (column + 1) : 0
}
}
Taking each numbered comment in turn:
If there is no delegate set, use default cell height. You then combine this with the x and y offsets of the current column to create insetFrame
used by the attribute.
- You only calculate the layout attributes if
cache
is empty and the collection view exists. - Declare and fill the
xOffset
array with the x-coordinate for every column based on the column widths. TheyOffset
array tracks the y-position for every column. You initialize each value inyOffset
to0
, since this is the offset of the first item in each column. - Loop through all the items in the first section since this particular layout has only one section.
- Perform the
frame
calculation.width
is the previously calculatedcellWidth
with the padding between cells removed. Ask thedelegate
for the height of the photo, then calculate the frame height based on this height and the predefinedcellPadding
for the top and bottom.If there is no delegate set, use default cell height. You then combine this with the x and y offsets of the current column to create
insetFrame
used by the attribute. - Create an instance of
UICollectionViewLayoutAttributes
, set its frame usinginsetFrame
and append the attributes tocache
. - Expand
contentHeight
to account for the frame of the newly calculated item. Then, advance theyOffset
for the current column based on the frame. Finally, advance thecolumn
so the next item will be placed in the next column.
These cases are out of scope for this tutorial, but it's important to be aware of them in a non-trivial implementation.
prepare()
is called whenever the collection view's layout becomes invalid, there are many situations in a typical implementation where you might need to recalculate attributes here. For example, the bounds of the UICollectionView
might change when the orientation changes. They could also change if items are added or removed from the collection.
These cases are out of scope for this tutorial, but it's important to be aware of them in a non-trivial implementation.
Now you need to override layoutAttributesForElements(in:)
. The collection view calls it after prepare()
to determine which items are visible in the given rectangle.
Add the following code to the very end of PinterestLayout
:
override func layoutAttributesForElements(in rect: CGRect)
-> [UICollectionViewLayoutAttributes]? {
var visibleLayoutAttributes: [UICollectionViewLayoutAttributes] = []
// Loop through the cache and look for items in the rect
for attributes in cache {
if attributes.frame.intersects(rect) {
visibleLayoutAttributes.append(attributes)
}
}
return visibleLayoutAttributes
}
Here, you iterate through the attributes in cache
and check if their frames intersect with rect
the collection view provides.
You add any attributes with frames that intersect with that rect to visibleLayoutAttributes
, which is eventually returned to the collection view.
The last method you must implement is layoutAttributesForItem(at:)
.
override func layoutAttributesForItem(at indexPath: IndexPath)
-> UICollectionViewLayoutAttributes? {
return cache[indexPath.item]
}
Here, you retrieve and return from cache
the layout attributes which correspond to the requested indexPath
.