Creating a custom collection view layout / by Jake MacMullin

Apple introduced a significant new class in iOS6 that makes it easier to create a range of different user interfaces: collection views. Up until iOS6 the main class used to display a series of items was the table view. Despite its name, it doesn't support tabular information at all, but is really designed to display vertically scrolling lists of content. Whilst you can do a lot with a vertically scrolling list of content they have their limitations. No horizontal scrolling, only one item per row, no complex layouts. Prior to iOS6 if you wanted to do any of these things, you were on your own.

In iOS6 Apple introduced collection views which address all of these limitations of table views and make it much easier to display content in different ways.

Where table views were very narrowly defined, collection views are designed to be flexible. They can be used to display collections of content in a variety of different ways: horizontal or vertically scrolling, lists or grids or any other layout you want. They provide this flexibility by cooperating with collection view layouts to determine where each item in the collection view should appear. Apple have provided a single example of a layout that will meet many people's needs. The flow layout is a line-based layout for collections. Each item is displayed next to the previous item along a line until it reaches the end of a line and then items are positioned on the next line. Depending on how this is configured it can be used to display vertically or horizontally scrolling lists (every item on a single line) or grids (multiple items per line).

Even if your layout isn't as simple as this you can still use a collection view. You just need to provide your own layout. This post describes the process for creating such a custom layout. I'm not trying to provide a complete explanation of collection views or collection view layouts, Apple's documentation and WWDC videos (sessions 205 and 219) are the best place for that. Rather, this post aims to be a 'worked example' of creating a custom layout for one specific set of requirements.

An app I'm working on at the moment requires a collection of content to be displayed in a series of lines. If there's more content than will fit in a single view, users will be able to scroll horizontally to see more content. Sounds like just the thing for a collection view. At first glance it seems like the standard flow layout would be ideal too.

However, there is an important difference that means I'll need to develop my own custom layout: some cells are bigger than others and span multiple lines.

A more complex layout where some cells span multiple lines.

To support this kind of layout, I'll need to provide an implementation of UICollectionViewLayout that can tell the collection view where each item should be displayed.

The first thing my layout will need to do is to tell the collection view how big the content size is. My layout will display up to 12 items per 'page' and will scroll horizontally if there's more than one page. So calculating the content size is simply a matter of asking the collection view's data source how many items need to be displayed and then determining how many pages will be needed to display them all. I do this by implementing the -collectionViewContentSize method:

- (CGSize)collectionViewContentSize
{
// Ask the data source how many items there are (assume a single section)
id<UICollectionViewDataSource> dataSource = self.collectionView.dataSource;
int numberOfItems = [dataSource collectionView:self.collectionView numberOfItemsInSection:0];

// Determine how many pages are needed
int numberOfPages = ceil((float)numberOfItems / (float)kNumberOfItemsPerPage);

// Set the size
float pageWidth = self.collectionView.frame.size.width;
float pageHeight = self.collectionView.frame.size.height;
CGSize contentSize = CGSizeMake(numberOfPages * pageWidth, pageHeight);

return contentSize;
}

The next thing my custom layout needs to be able to do is to specify the position and size for each item. For each item, my layout will need to figure out: 

  • which page the item is on,
  • which row the item is on (or what its vertical offset is), 
  • which position the item is in the given row (or what its horizontal offset is) and
  • its size.

The collection view will ask the layout for this information in two different ways. When it needs to display a new area of the collection, it will ask the layout for the -layoutAttributesForElementsInRect: passing a rect to define the region of the collection view it is interested in. It may also ask for layout information for a given element from time to time by calling -layoutAttributesForItemAtIndexPath: and pass an index path to indicate which element's layout information should be returned. For layouts that might be used to display a significant number of elements it is probably worth calculating this layout information lazily just for the elements within the specified rect. However, the app I'm working on will only have about 100 elements to layout, so for simplicity I'll calculate the layout attributes up-front and cache them. I can always optimise this later if it turns out to be a performance problem.

According to Apple's documentation, layouts are initialised by calling the following methods in the following order:

  1. prepareLayout
  2. collectionViewContentSize
  3. layoutAttributesForElementsInRect

So I'll calculate all the layout information in the initial -prepareLayout method. There may be a more elegant way of calculating this layout information but for now I'll use some simple logic:

Given that I know how many elements will be displayed on a given page (12), I can determine which page a given element appears on by dividing the index of the element in question by 12. I can then set an initial horizontal offset for that page by multiplying the page number by the page width (the collection view width). This means all I need to do is to determine how far the given element is from the edges of the current page. Given I know that there are 12 possible positions per page, I can determine which of those 12 positions the current element should occupy by finding the remainder left when I divide the index of the given element by 12. Once I know which position the current element should occupy all I need to do is look-up the horizontal and vertical offsets and the size. There's only 4 possible horizontal offsets for each page, 4 possible vertical offsets and 3 possible sizes so it is quite straight-forward to determine the correct values using some simple switch statements. I can then use these values to create a layout attributes object with an appropriate frame for the given element.

- (CustomFlowLayoutAttributes *)layoutAttributesForItemAtIndex:(int)index
{
NSIndexPath *path = [NSIndexPath indexPathForItem:index inSection:0];
CustomFlowLayoutAttributes *attributes = (CustomFlowLayoutAttributes *)[super layoutAttributesForItemAtIndexPath:path];

// Figure out what page this item is on
int pageNumber = floor((float)index / (float)kNumberOfItemsPerPage);

// Set the horizontal offset for the start of the page
float pageWidth = self.collectionView.frame.size.width;
float horizontalOffset = pageNumber * pageWidth;

// Now, determine which position this cell occupies on the page.
int indexOnPage = index % kNumberOfItemsPerPage;

int column = 0;
switch (indexOnPage) {
case 0:
case 3:
case 5:
column = 0;
break;
case 1:
case 4:
case 6:
case 9:
column = 1;
break;
case 2:
case 7:
case 10:
column = 2;
break;
case 8:
case 11:
column = 3;
break;
default:
column = 0;
break;
}

int row = 0;
switch (indexOnPage) {
case 0:
case 1:
case 2:
row = 0;
break;
case 3:
case 4:
row = 1;
break;
case 5:
case 6:
case 7:
case 8:
row = 2;
break;
case 9:
case 10:
case 11:
row = 3;
break;
default:
row = 0;
break;
}

horizontalOffset = horizontalOffset + ((kColumnWidth + kPadding) * column);
float verticalOffset = (kRowHeight + kPadding) * row;

// finally, determine the size of the cell.
float width = 0.0;
float height = 0.0;

switch (indexOnPage) {
case 2:
width = kLargeCellWidth;
height = kLargeCellHeight;
break;
case 5:
width = kColumnWidth;
height = kMediumCellHeight;
break;
default:
width = kColumnWidth;
height = kRowHeight;
break;
}

CGRect frame = CGRectMake(horizontalOffset, verticalOffset, width, height);
[attributes setFrame:frame];

return attributes;
}

Having calculated the layout attributes up-front, I can easily return the appropriate ones when asked in layoutAttributesForItemAtIndexPath: or layoutAttributesForElementsInRect.

That's all I need to so that my custom layout can be used by a collection view. It'll allow the collection view to display the right number of cells of the right size in the right place. There's just one more small thing I can do to simplify things when it comes time to implement the display for my cells. Given that there are three quite different cell frames (a small landscape, a large landscape and a medium sized portrait) the cell's code will most likely need to be able to differentiate between the three different frames in order to request and display an appropriately sized image in the cell (for example). Rather than write a bunch of logic in my cell's class, I can get my custom layout to add some additional information to the layout attributes that it passes the cell.

To do this I need to:

Extend UICollectionViewLayoutAttributes to create a custom sub-class that has properties with the extra information I want my layout to pass to my cells (a cell type in this case). In addition to creating the extra property, I also need to override the superclass's implementation of copyWithZone: to make sure the additional property is copied as UICollectionView uses the NSCopying protocol to create copies of the properties to pass to the cells.

Implement a class method in my custom layout to specify the UICollectionViewLayoutAttributes sub-class that should be returned when my layout is asked for the layout attributes.

+ (Class)layoutAttributesClass
{
return [CustomFlowLayoutAttributes class];
}

Then I just add the additional information when I'm calculating the layout attributes and implement some logic in my UICollectionViewCell subclass to receive the layout attributes and do something useful with the extra information my layout is providing.

Whilst Apple's documentation (and countless mentions in their WWDC videos) make it clear that for most people they think the standard flow layout should be sufficient, should you find yourself in a situation where the flow layout doesn't quite meet your needs creating a custom layout isn't too much work.