UICollectionView
continues to be Apple’s favoured way for you to build screens full of repeating content in UIKit
. It now has super simple out-of-the-box list support, and an extremely flexible and powerful declarative layout system in UICollectionViewCompositionalLayout
. This article discusses how to use compositional layouts to build a spreadsheet-style layout with floating row and column headers.
A Basic Grid using UICollectionViewCompositionalLayout
A grid layout has a known number of rows and columns which must be kept in alignment. This differs from the flow-style layouts you may be familiar with from collection views, which are more often used to fit as many items in the width of the screen as possible, meaning that a section could fill more or less vertical space depending on the device or its orientation - think of the Photos app. Flow layouts are there to efficiently fit information onto screens of varying sizes. The content width of the layout is driven by the screen size, and the height is then calculated based on fitting the data into that width.
Consider a spreadsheet where you keep information about your favourite monsters. Each row would represent a single monster, and each column would contain a particular fact about the monster. In collection view terms, each monster would have its own section, and each fact would be an item within that section.
It’s important that all of the columns and rows line up, because you want all of the information about a single monster to be on a single row, and all of the facts, like roar volume, to be in a single column. This means that, in contrast to a flow layout, both the height and width of the layout is driven by the size of the content, not the screen, so you may have to scroll horizontally and vertically to get to the information you care about.
To make such a layout, you must define the cell size up front and build the layout from that:
let cellWidth: CGFloat = 100
let cellHeight: CGFloat = 40
let width = NSCollectionLayoutDimension.absolute(cellWidth)
let height = NSCollectionLayoutDimension.absolute(cellHeight)
let size = NSCollectionLayoutSize(
widthDimension: width,
heightDimension: height
)
let rowSize = NSCollectionLayoutSize(
widthDimension: .absolute(cellWidth * CGFloat(numberOfColumns)), heightDimension: height
)
let cell = NSCollectionLayoutItem(layoutSize: size)
let row = NSCollectionLayoutGroup.horizontal(
layoutSize: rowSize,
subitem: cell,
count: numberOfColumns
)
let section = NSCollectionLayoutSection(group: row)
let layout = UICollectionViewCompositionalLayout(section: section)
The dimensions are defined in absolute terms, which means the collection view will not attempt to wrap lines and will instead match its content size to the size of your grid, permitting scrolling in two directions where necessary.
There’s nothing here you can’t do with a UICollectionViewLayout
subclass, but this is considerably less code.
Adding floating row headers
If you’ve scrolled all the way across to number of teeth, you could have scrolled the name of the monster off the side of the screen. It’s important to be able to see that information, no matter which column you’re looking at, so you will want to add row headers, and you want them to be visible no matter where you have scrolled in the spreadsheet. This behaviour is called pin-to-bounds, as the header is pinned to the bounds of the scroll view, which represents the visible area.
Collection views support supplementary views, additional views which are traditionally associated with a given section of the data. You can get fancy pin-to-bounds behaviours for free with them. This is perfectly suited to using a single supplementary view per section in your monster list, which can act as your row “header” and be pinned to the leading edge of the table, floating over the cells as you scroll horizontally.
To add floating row headers to the layout above is very simple:
let headerSize = NSCollectionLayoutSize(
widthDimension: .absolute(nameWidth),
heightDimension: height
)
let header = NSCollectionLayoutBoundarySupplementaryItem(
layoutSize: size,
elementKind: "rowHeader",
alignment: .leading
)
header.pinToVisibleBounds = true
section.boundarySupplementaryItems = [header]
This creates a single supplementary item, on the leading edge of the section, which will remain on-screen as long as the section itself is on screen. You’ll also want to take the header width into account for your data row NSCollectionLayoutGroup
, both in its size (the size now has to include the width of the header) and the content insets (the first cell shouldn’t be right at the leading edge of the section):
let rowSize = NSCollectionLayoutSize(
widthDimension: .absolute(nameWidth + cellWidth * CGFloat(numberOfColumns)), heightDimension: height
)
...
row.contentInsets.leading = nameWidth
Again, you can do this with a UICollectionViewLayout
subclass, but it involves a lot of additional code. UICollectionViewFlowLayout
can also pin headers, but that layout isn’t suitable for our needs here.
Having an identifying view for your section as a supplementary view is an improvement in another way - its likely that the cells of your spreadsheet have different requirements or sources from the row identifiers themselves. Without supplementary views, you may find yourself doing arithmetic with your index path items to account for “header” type cells, either when populating data or when handling interaction. If you ever find yourself using values from index paths for doing anything other than directly accessing an item in an array, it’s worth taking some time for a rethink, as this is a common source of bugs.
Adding floating column headers
At the moment you have no way of telling which fact goes in which column. Column headers aren’t so straightforward. You could restructure your data so that each section is now a column, but that just flips the problem by 90 degrees as you now need a solution for the row headers.
You could add extra supplementary views to the first section only, but that makes creating your layout more complicated, and a compositional layout gets upset if you have multiple supplementary views of the same kind associated with a given section. You may also find yourself doing the index path manipulation you were just advised to avoid.
Adding supplementary views to a single section also only works for as long as the first section is on screen, since the headers will scroll off screen when the section is no longer visible (like they’re supposed to). You can overcome that by subclassing the layout and overriding the layout attribute creation, but at that point it’s starting to feel like this is all too much work.
The problem is that the item - section concept is suited to a one-dimensional list, but it doesn’t sit well when your data is two-dimensional. You can work around this issue by using a UICollectionViewCompositionalLayoutConfiguration
as part of your layout. This allows you to specify boundary supplementary items at the collection view level, rather than being tied to a particular section:
let configuration = UICollectionViewCompositionalLayoutConfiguration()
for columnIndex in 0..<numberOfColumns {
let columnHeader = NSCollectionLayoutBoundarySupplementaryItem(
layoutSize: size,
elementKind: "columnHeader",
alignment: .topLeading,
absoluteOffset: CGPoint(x: nameWidth + CGFloat(columnIndex) * 100, y: 0)
)
columnHeader.pinToVisibleBounds = true
configuration.boundarySupplementaryItems.append(columnHeader)
}
let layout = UICollectionViewCompositionalLayout(
section: section,
configuration: configuration
)
You need to add a separate supplementary item for each column header, and specify an absolute offset from the given margin.
When dequeuing and populating these supplementary views from your datasource, it’s important to note that supplementary views from the configuration will only have a single element in their indexPath
, so you shouldn’t use the item
or section
accessors. indexPath[0]
will give the correct column index.
This layout puts everything in the right place, but there are a couple of visual artefacts. First of all, it can get messy in the top leading corner - what should go “on top” when there’s a cell, a row header and a column header all in the same place? You can control this by setting the zIndex
property of the relevant boundary supplementary item, or you can add another boundary supplementary view to the configuration, with a higher z index, that is always pinned to the top leading corner. This view is just a blank area which covers up any messy overlap.
The second issue is that you’ve told the column headers to pin to the visible bounds, and since there is no section attached to them, that means the layout is going to make sure every column header is within the visible bounds of the entire collection view. In practice, that means you have a stack of column headers on the top trailing corner of your collection view.
You can solve this with zIndex
as well, assigning numberOfColumns - columnIndex
to each header, so the trailing columns are underneath the others, but this might not work if you are using transparency or visual effect views in your headers. To force your column headers to pin to only the top margin of the visible bounds, you’re going to have to subclass UICollectionViewCompositionalLayout
and override layoutAttributesForSupplementaryView(ofKind: at:)
and layoutAttributesForElements(in:)
to ensure that the x-position of your column headers is not adjusted by the layout. It feels a shame to have to do that, but single-margin pinning does not seem to be supported any other way.
Making a subclass will also make your layout more reusable; it is only a few lines of code to create a compositional layout, but the complexity can grow to the point where it really doesn’t belong inside a view controller.
Compositional layouts are incredibly powerful. I made the same layout with floating row and column headers in a UICollectionViewLayout
subclass, and it took 120 lines of code, overriding 8 separate methods. The compositional version is shorter, clearer and much easier to mess around with. Have fun with it.