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.

4.4 (43) · 1 Review

Download materials
Save for later
Share
Update note: Andriy Kharchyshyn updated this tutorial for Xcode 11, Swift 5 and iOS 13. Paride Broggi wrote the original.

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.
Note: This tutorial requires a basic knowledge of 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:

Starter Project image

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:

storyboard_select_collection_view

Next, open the Attributes inspector. Select Custom in the Layout drop-down list. Then select PinterestLayout in the Class drop-down list:

storyboard_change_layout

Okay — time to see how it looks. Build and run your app:

build_and_run_empty_collection

collectionview empty meme

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:

Layout lifecycle

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:

  1. This keeps a reference to the delegate.
  2. These are two properties for configuring the layout: The number of columns and the cell padding.
  3. 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.
  4. This declares two properties to store the content size. You increment contentHeight as you add photos and calculate contentWidth based on the collection view width and its content inset.
  5. collectionViewContentSize returns the size of the collection view’s contents. You use both contentWidth and contentHeight 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:

customlayout calculations

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.

  1. You only calculate the layout attributes if cache is empty and the collection view exists.
  2. Declare and fill the xOffset array with the x-coordinate for every column based on the column widths. The yOffset array tracks the y-position for every column. You initialize each value in yOffset to 0, since this is the offset of the first item in each column.
  3. Loop through all the items in the first section since this particular layout has only one section.
  4. Perform the frame calculation. width is the previously calculated cellWidth with the padding between cells removed. Ask the delegate for the height of the photo, then calculate the frame height based on this height and the predefined cellPadding 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.

  5. Create an instance of UICollectionViewLayoutAttributes, set its frame using insetFrame and append the attributes to cache.
  6. Expand contentHeight to account for the frame of the newly calculated item. Then, advance the yOffset for the current column based on the frame. Finally, advance the column 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.

Note: Since 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.