Understanding UICollectionViewFlowLayout and its delegate

When you use a standard collection view, it uses a grid layout. In this grid layout, all items are evenly spaced, and all items have the same size. You can visualize this quite easily by giving the collection view cell a background color in your Storyboard and running your app. This standard grid layout makes adding a grid to your app a breeze. However, the standard implementation of the grid layout is not quite perfect. If you look at the grid you have set up, it should look good on, for instance, an iPhone 8 but when switching to a smaller screen such as the iPhone SE, the layout looks nothing like the beautiful grid layout you saw before. Viewing the grid on a larger screen such as an iPhone 8 Plus doesn't make it any better either. The grid would be significantly improved if every cell was sized based on the size of the screen. Let's see how this can be achieved.

In your storyboard, select the Collection View Flow Layout object in the Document Outline. In the Attributes Inspector, you can change several properties for the collection view's layout. For instance, you can change its scroll axis from vertical to horizontal. This is something that can't be achieved using a table view since table views only allow scrolling on their vertical axis. If you switch to the Size Inspector for the layout object, you will find several available options to change the cell's spacing and size. As you may or may not know, the iPhone SE has a display width of 320 points, so try updating the layout's item size from 110 points wide to a width of 106 points. That should allow the grid to lay out all items with just a single pixel in between every cell. Also, update the minimum spacing for cells and lines to 1 point. This value indicates the minimum spacing for cells and lines. Note that when the layout is actually calculated, the spacing could be larger than you specified to ensure everything fits the screen nicely. Because it's a minimum spacing, cells will never be placed closer to each other than you have specified.

Try running your app on an iPhone SE now. You will notice that the layout looks great for this device. However, on larger screens the spacing between cells is a lot larger than you would like it to be, so we haven't achieved much yet. Luckily, you can adjust the cell size for your collection view items in your code as well. This means that you can use the currently available space for the collection view to determine precisely what size every cell should be to look great on all devices.

The standard layout that is used by a collection view is an instance of UICollectionViewFlowLayout. This object has a delegate property that conforms to the UICollectionViewDelegateFlowLayout protocol. This protocol specifies several methods that you can implement to take fine-grained control over your collection view's layout, allowing you to dynamically calculate cell spacing, line spacing, and cell size. For this example, dynamically calculating the cell size is the most interesting one. Since the spacing between cells should always be at least 1 point regardless of screen size, this property doesn't have to be calculated dynamically.

The requirement for the cell size can be summed up as follows:

  • Three cells should fit on a single row in the grid.
  • The cell's sizing should allow for approximately 1 pixel of room between every cell in a row.

To meet these requirements, the method from UICollectionViewDelegateFlowLayout that needs to be implemented is collectionView(_:layout:sizeForItemAt:). This method will be used to return a size that is roughly one-third or less of the collection view's width, and that has a height of 90 points. To do this, add the following extension to your ViewController class:

extension ViewController: UICollectionViewDelegateFlowLayout {
  func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout,
                      sizeForItemAt indexPath: IndexPath) -> CGSize {

    let width = floor((collectionView.bounds.width - 2) / 3)
    return CGSize(width: width, height: 90)
  }
}

The method in this extension is called for every cell in the collection view. This means that you can dynamically calculate the size for each collection view cell. The implementation you just added uses the floor function to make sure the width for each item is always one-third or less of the collection view width. To account for the 1 point spacing between each cell, 2 is subtracted from the collection view's width before dividing it by 3.

If you run your app like this, you will find that the spacing between items is nice and tight. However, if you look at this layout on an iPhone 8, you might notice that the spacing between cells on the horizontal axis does not really match the spacing between the cells on the vertical axis. You might have to look real close, but once you notice this it is hard to not see it. You can fix this by dynamically calculating the spacing between each cell and then using this value as the new minimum line spacing. The following code snippet takes care of this calculation. Add it to your UICollectionViewDelegateFlowLayout extension in ViewController.swift:

func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout,
                    minimumLineSpacingForSectionAt section: Int) -> CGFloat {

  let availableWidthForCells = collectionView.bounds.width - 2
  let totalGutterSpace = availableWidthForCells.truncatingRemainder(dividingBy: 3)
  let cellSpacing = totalGutterSpace / 2

  return 1 + cellSpacing
}

The calculation to come up with the appropriate value is somewhat complex so breaking it down step by step helps to make sense of it. Since the spacing will never be less than 1, and there are two spacing gutters per row, these gutters are subtracted from the available width. This available width is the same width that is available for the collection view cells. Next, truncatingRemainder(dividingBy:) is used to find out how much space is left over after dividing it between the collection view cells. This remaining space is then divided by 2 since there are two gutters in the collection view. And lastly, because the standard spacing will always be 1, the calculated gutter width is incremented by 1.

The fact that you can take fine-grained control over a collection view's grid layout by implementing methods from UICollectionViewDelegateFlowLayout makes it even more powerful and flexible than its out-of-the-box implementation. You can create all kinds of grids with custom spacing, cells that have different heights or widths, and more. Sometimes all this power and flexibility isn't quite enough. Imagine, for instance, you want your grid to be a little bit more playful. Something where cells are a little bit more scattered across the screen, for instance. You can achieve this by creating your UICollectionViewLayout subclass.