Creating a Collection View Inspired by Pinterest

August 5, 2018 ยท 5 minute read

This recipe calls for a simple collection view setup to already be implemented. It assumes you have an image in your collection view cell. It does not show you how to design your cell to look exactly like the screenshot below, but it will mimic the varying height layout of the cells based on the height of the pictures, similar to how Pinterest does for their app.

You will have to do some work on manipulation of the photo ratios, especially if you are using photos or images of differing sizes. I’ve added code that I used to ensure that the photos have the correct height ratio for my app.

Screenshot of Pinterest-inspired collection view

1. Create a custom layout class file:

Cocoa Touch Class: UICollectionViewLayout
Name: "PinterestLayout"
Language: Swift

2. Add the following code to the PinterestLayout.swift file:


import UIKit

// Delegate protocol declaration
protocol PinterestLayoutDelegate: class
{
	func collectionView(_ collectionView: UICollectionView, heightForPhotoAtIndexPath indexPath: IndexPath) -> CGFloat
}

class PinterestLayout: UICollectionViewLayout
{
	// Keeps reference to the delegate
	weak var delegate: PinterestLayoutDelegate?

	// Configures the layout: column number & cell padding
	fileprivate var numberOfColumns = 2
	fileprivate var cellPadding: CGFloat = 6

	// Array to store calculated attributes, more efficient to query than calling it multiple times when prepare() is called
	fileprivate var cache = [UICollectionViewLayoutAttributes]()

	// Store the content size, height and width
	fileprivate var contentHeight: CGFloat = 0

	fileprivate var contentWidth: CGFloat {
	guard let collectionView = collectionView else { return 0 }
	let insets = collectionView.contentInset

	return collectionView.bounds.width - (insets.left + insets.right)
	}

	// Override default content width & height with calculated values above
	override var collectionViewContentSize: CGSize
	{
		return CGSize(width: contentWidth, height: contentHeight)
	}

	override func prepare()
	{
	// calculate layout attributes if cache is empty and CV exists
	guard cache.isEmpty == true, let collectionView = collectionView else
	{
		return
	}

	// Declare & fill xOffset array for every column based on widths
	let columnWidth = contentWidth / CGFloat(numberOfColumns)
	var xOffset = [CGFloat]()

	for column in 0..<numberOfColumns
	{
		xOffset.append(CGFloat(column) * columnWidth)
	}

	//yOffset array tracks y-position for every column.
	var column = 0
	var yOffset = [CGFloat](repeating: 0, count: numberOfColumns)

	// Loops through all items in first section
	for item in 0..<collectionView.numberOfItems(inSection: 0)
	{
		let indexPath = IndexPath(item: item, section: 0)


		/* Perform frame calculation, width is the previously calculated cellWidth, with the padding between cells removed. You ask the delegate for the height of the photo and calculate the frame height based on this height and the predefined cellPaddingfor the top and bottom. You then combine this with the x and y offsets of the current column to create the insetFrame used by the attribute.
		*/
		let photoHeight = delegate?.collectionView(collectionView, heightForPhotoAtIndexPath: indexPath)
		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)

		// Creates instance of UICollectionViewLayoutAttribues, sets its frame & appends attributes to cache
		let attributes = UICollectionViewLayoutAttributes(forCellWith: indexPath)
		attributes.frame = insetFrame
		cache.append(attributes)

		// Expand content height to account for frame of newly calculated item and advances yOffset for current column based on frame.
		contentHeight = max(contentHeight, frame.maxY)
		yOffset[column] = yOffset[column] + height

		// Advance column so that next item will be placed in next column
		column = column < (numberOfColumns - 1) ? (column + 1) : 0
    }
  }
  
  // MARK: Override Attribute Methods
  // Override layoutAttributesForElements(in:), wich CV calls after prepare() to determine which items are visible in a given rect
	override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]?
	{
		var visibleLayoutAttributes = [UICollectionViewLayoutAttributes]()

		// Loop through cache and look for items in the rect
		for attributes in cache
		{
			if attributes.frame.intersects(rect)
			{
				visibleLayoutAttributes.append(attributes)
			}
		}

		return visibleLayoutAttributes
	}

  // Retrieve and return from cache the layout attributes which correspond to requested indexPath
	override func layoutAttributesForItem(at indexPath: IndexPath) -> UICollectionViewLayoutAttributes?
	{
		return cache[indexPath.item]
	}
}

3. Configure your “Collection View” in main.Storyboard to the following by opening Attributes Inspector and updating:

Layout: Custom 
Class: PinterestLayout

4. Add the extension to the bottom of your ViewController.swift file

// MARK: Implement PinterestLayout Extension
extension PhotoStreamViewController: PinterestLayoutDelegate
{
	func collectionView(_ collectionView: UICollectionView, heightForPhotoAtIndexPath indexPath: IndexPath) -> CGFloat
	{
		return photos[indexPath.item].image.size.height
	}
}

5. Add the following call to your viewDidLoad() after the super call:

 // MARK: Call Pinterest layout
    if let layout = collectionView?.collectionViewLayout as? PinterestLayout
    {
      layout.delegate = self
    }

BONUS:

Since I pull all my demo photos from Unsplash, the images come in all different sizes. They are not scaled to fit in my collection view cell’s photo outlet, and since the collection view cell inherits the height of the image, which leads to some cells being super tall and the image gets cut off.

To fix this problem, I used a function to rescale all of my photos without having to manually rescale each one and ensures that they all look good, because that’s just too much work to do it by hand and I’m lazy. The method takes two parameters, UIImage and a max width. It outputs an UIImage in the scale that I need it to be. I used this function/method in my Photos model class at initialization. I found that a max width of 300 works really well.

func scaleImage(image: UIImage, maximumWidth: CGFloat) -> UIImage
{
    let rect: CGRect = CGRect(x: 0, y: 0, width: image.size.width, height: image.size.height)
    
    let cgImage: CGImage = image.cgImage!.cropping(to: rect)!

    return UIImage(cgImage: cgImage, scale: image.size.width / maximumWidth, orientation: image.imageOrientation)   

}

Subscribe to my email list for a monthly newsletter and special updates!