Custom UICollectionViewLayout Auto Layout and Dynamic Type

“The edge of the back part of a black iPhone” by Xavier Wendling on Unsplash

The iOS Team at Gousto use autolayout for all of its UI so that we are able to accommodate variable content, dynamic type and different size classes of devices. When we wanted to switch our recipe list to a grid on medium size class devices, we struggled to find any documentation on how we could achieve this using UICollectionView and XIBs for the UICollectionViewCells. This blog post talks about the issues we had and how we achieved it using a custom UICollectionViewLayout.

Why?

Since Dynamic Type was introduced, we thought it would be beneficial to our users to support it (which means being responsive to the font scale set by the user on their device). When requirements came to redesign our main recipe list to add another type of cell with a different height, we thought it was the perfect opportunity and shouldn’t be too much of a pain. It’s also always great when Apple say “Before you start building custom layouts, consider whether doing so is really necessary.”

Throughout this post, I’ll show some of our code, but I’ll also attach a demo project so you can see it working!

Fixing constraints

Previously, our cells were all given the same height and our recipe title labels all had height constraints with a minimum font size, so the font size would shrink to accommodate the text and in extreme circumstances, the title gets truncated. The first thing we did was remove all unnecessary constraints and made everything variable. The cell layouts we had were quite complex with lots of subviews (sometimes unnecessarily) and so we tidied this up and tried to make it as flat as possible, and had different cells for different scenarios.

Introducing the custom layout

If you want to create a custom layout, you essentially have to take care of everything yourself, which is probably why Apple don’t really recommend it. Within the layout, there are three main methods:

func prepare()

Initially I thought this method would only be called once at the beginning, however it’s called very frequently so we only want to calculate the estimated cell sizes if layoutAttributesForItems is empty (this is the cache we use for our cells). The first time it is called, our cache is empty so we create our initial layout.

Within the initialLayout we’re actually going to perform our cell size estimation. Starting with working out how many columns we need because we have either 1, 2 or 3 depending on the width of the window.

The above code creates two arrays, for our X and Y cell positions, we initialise our y array with just positions for the number of columns e.g. for three columns

yOffset = [0, 0, 0]

We complete the xOffset array again based on the columns, so if we had a 1024 pixel width device and three columns we’d have

xOffset = [0, 341, 682, 0, 341, 682, …] and so on for each of the cells.

Next we need to loop through each of the cells and create layout attributes for each, storing them in our local cache.

At the end of creating the frame we need to add the next yPosition so the cell below in that column knows it’s start position. We also set the column for our next pass through. Finally, we need to update our contentHeight property with the new height.

contentHeight += collectionView.contentInset.bottom

var collectionViewContentSize: CGSize

This simply returns the size of the collectionView content area that we’ve just calculated in the prepare method.

func preferredLayoutAttributesFitting(_ layoutAttributes: UICollectionViewLayoutAttributes) -> UICollectionViewLayoutAttributes

This was probably the most difficult part of getting the layout to work correctly. There wasn’t much documentation on this that we found helpful, so a lot of trial and error went in to this.

After we’ve prepared the layout, we dequeue a cell as normal and preferredLayoutAttributesFitting is called automatically. This is where the cell has a chance to indicate its preferred attributes, including size, which we calculate using auto layout.

We set the vertical fittingSize to compressed because we want the cells to be the smallest height that they can be while satisfying their constraints. The key point in this method is systemLayoutSizeFitting…because our collectionView scrolls vertically, we can set the horizontal priority to .required — meaning it will only ever be as big as the pre-calculated width. The vertical priority is set to .defaultLow as we need the cells to be able to grow. Initially we got this wrong and ended up with having some cells that would grow to 1000 pixels…

The layout now calls invalidate where we check if the height of the cell has changed since the originalAttributes. If it hasn’t we ignore it, but if it has…

func invalidationContext(forPreferredLayoutAttributes preferredAttributes: UICollectionViewLayoutAttributes, withOriginalAttributes originalAttributes: UICollectionViewLayoutAttributes) -> UICollectionViewLayoutInvalidationContext

Here we need to work out the difference between the cell heights and update the attributes with the new height.

If we update one cell in the column, we need to adjust the Y positions of all the other cells in that column and invalidate so we pass through all of the cells.

When all cell heights have been adjusted we need to set the full height of the collectionView to be the height of the tallest column using the layoutAttributes from the cells in the last row.

What did we learn?

There were some areas in which the documentation was lacking, predominately the preferredLayout method in the cell which is one of the most important parts of the layout.

If we had not have tried to support dynamic type, the task would have been a lot easier, we had to change a lot of our constraints and it got really fiddly at points. Our cells are now entirely calculated with auto layout in xibs, we didn’t really find any examples of people doing the same thing as us online, people tended to be calculating the cell height from the height of the labels in the code. We didn’t want to do this as our cells are complex and there would be a maintainability burden to doing so.

It is also important to realise that methods such as prepare are called a lot (when the collection view is loaded, when the user scrolls etc) so you must ensure that you keep a cache of your data and only recalculate it when necessary, or the relevant portions or it only when required.

One other thing would be to avoid changing too many things at once otherwise it’ll be harder to figure out what’s working and what’s not.

Demo project

Here’s a link to the demo project if you want to take a look!

Kiera O’Reilly
Software Engineer


Originally published at techbrunch.gousto.co.uk on June 5, 2018.