Making a complex UICollectionView layout with self-sizing cells
POSTED : August 13, 2020
BY : Max Clarke

“Norwich City centre aerial” by John D Fielding is licensed under CC BY 2.0

In Part 1 I took you through the layout process of a UICollectionView with a custom UICollectionViewLayout that contains self-sizing cells. This post will continue where we left off, and go over some of the pain points while getting self sizing cells to work correctly.

Caveats for Self Sizing Cells

I ran into some issues with preferredLayoutAttributesFitting
 / systemLayoutSizeFitting when building my layout. According to this WWDC sessionsystemLayoutSizeFitting spins up an autolayout constraint solver engine. It seems to be a less comprehensive engine than normal though. It has trouble with proportional constraints, and multiline UILabels with automatic preferred widths. It also doesn’t take trait collection size class overrides (from storyboard / xibs) into account until the view has been completely laid out once. Unfortunately, none of this is documented, and I had to debug extensively to nail down the causes and solutions to these issues.

My conclusion is that it’s generally preferable to take a more specific and performant approach for each circumstance, rather than rely on the one size fits all preferredLayoutAttributesFitting / systemLayoutSizeFitting. Let’s go through some scenarios.

There are plenty of tutorials around that go over this in detail for layouts with static cell sizes. It’s fairly straight forward… until we add self-sizing cells to the mix.

When You Have One Dynamically Sized Subview

When your reusable view contains just one dynamically sized subview, you might be better off calculating the size of this dynamic view and adding it to a predefined static constant. For example, use boundingRect(with:options:attributes:context) on the text string for a label, and add this to any static vertical margins around the label to get the full reusable view height. Or if it contains an image view, ask it for sizeThatFits(_:).

When Size Class Overrides Are Ignored

The layout engine that systemLayoutSizeFitting uses will ignore size class overrides from storyboards / xibs on the first pass. So it’s not until you scroll away from a cell and back again to force a second layout pass that it has the correct size.

Are the size class constraint overrides that your view requires very complex? As outlined in the above WWDC video, you might be better off splitting your xib into two: one xib for one group of trait collections, and one xib for another. You can register a different xib for the view class depending on the current trait collection. That way no overrides are needed, so the engine will have no troubles and it will also calculate the cell layout quicker.

One thing to note with this approach: you will need to wait for the trait collection to be set on the owning view controller before you register cells. In iOS 13 this changed a little.

When You Use Proportional Constraints

If you rely on proportional constraints for your reusable view and it’s not trivial to calculate the size yourself, you need to get a little more creative.

One approach is to replace proportional constraints with constant constraints the first time preferredLayoutAttributesFitting is called. This may be trivial if your constraint is proportional to the static dimension of the element view, then you can just multiply the multiplier by the static dimension value and set that as the constant.

For anything more complex though, it might also be better to layout the view in code. That way you have all the constants and multipliers you need anyway, so you can just use constant constraints from the beginning.

When You Have Multiline UILabels

Your collection view then takes the preferred attributes returned by your view and asks the layout if it needs to invalidate for them. The answer is almost always yes if the size in the preferred attributes is different from the size in the original attributes: a new layout will need to be calculated to account for the new size.

Layout Sequence Inconsistencies

In Part 1 I stated that things happen in this order:

  1. Element attributes are asked for
  2. Preferred attributes are asked for
  3. Layout is invalidated
  4. Layout is prepared

But this isn’t always the case. Sometimes it goes:

  1. Preferred attributes are asked for
  2. Layout is invalidated
  3. Prepare is skipped ⚠️
  4. Element attributes are asked for ️⚠️

That means attributes that are calculated in prepare() are out of date, and now your layout looks like 💩. It’s not clear what causes this to happen, and it’s also unfortunately undocumented.

As a workaround, I used a flag to denote the layout data as “dirty” and in need of recalculation. This flag is checked at the start of each layoutAttributesFor(...). If it’s set, the layout data is recalculated and the flag reset before continuing. The flag is also reset at the end of prepare()too of course. This ensures the attributes supplied are always up to date, without any double handling.

End of Part 2

Hopefully, by now you should have a fully functioning layout with self-sizing cells. Scrolling performance however is another issue. So stay tuned for Part 3, where I’ll go through scrolling performance improvements by using invalidation contexts more thoroughly, so we can make our recalculations smarter and faster.