Making a Complex UICollectionView Layout With Self Sizing Cells (Part 1)

Maximilian Clarke
6 min readJun 20, 2020

--

“Barcelona Dawn Aerial” by Rodney Campbell is licensed under CC BY-NC-ND 2.0

Recently I built a screen with a pretty complex layout containing self sizing cells. In the end it required a UICollectionView using a custom UICollectionViewLayoutUICollectionViewFlowLayout was too simple for the screen design. Parts of this proved to be a real mission to get right, so I thought I’d share my findings in the hope of saving someone else the trouble.

This post is split into three parts:

Part 1 will go over the whole process in detail, starting with the general layout process, then adding self sizing cells to the mix.

Part 2 will go over some workarounds to common problems and inconsistencies with layouts using self sizing views.

Part 3 will optimise this layout with proper use of invalidation contexts and intelligent cache adjustment strategies, for silky smooth scrolling performance.

Overview of the General Layout Process

The thing that kicks the whole layout process off is a call to invalidate(). This can happen for any number of reasons: initial load, view rotation, bounds change, and even directly by you when required. After it is invalidated, we are given an opportunity to prepare new layout data in prepare() to support the later calls to the layoutAttributesFor... methods, which are called on the main thread as the user scrolls.

Preparation of new data for this purpose usually means precalculating things like frames, or even constructing whole UICollectionViewAttributes objects, for all elements. This precalculated data is held onto by the layout, to be used directly when the collection view makes calls to the layoutAttributesFor* methods. This will ensure the user has a totally smooth scrolling experience.

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.

Overview of the Layout Process with Self Sizing Cells

Oh boy. The self sizing layout process can be difficult to wrap your head around at first, because it involves recursion: the invalidation that causes cells to self size usually leads to further invalidations as each cell returns different sizes than expected, before the actual views are laid out. And to make matters worse, this can happen as the user is scrolling too. We’ll get into this later — there are ways we can minimise the expense of prepare() calls during scrolling.

So let’s unwrap the recursion and step through func by func to get a good understanding of what’s going on, and what is expected of your self sizing views and layout.

Self Sizing Cells Layout — func by func

After the initial attributes are obtained for the elements in the initially visible rect, the self sizing process begins. The collection view asks your data source for the view at the index path of the first of these attributes. Then it asks this view for “preferred attributes fitting” these attributes.

UIReusableView (Cell, Supplementary or Decoration view): preferredLayoutAttributesFitting(_:)

This is your view’s chance to self size. Given the attributes calculated by your layout, it should calculate what these should be for it to display correctly — its preferred attributes. After all, your view knows it’s content in it’s subviews, so it knows how large it needs to be for everything to fit.

Contrary to a lot of blog posts and Stack Overflow answers, UIKit has a default implementation of this method that returns attributes with a fitting size calculated by autolayout. In a lot of cases this just works. It’s best to let it do it’s thing and only work around by overriding it when it fails. Usually this will be because you need to control which dimension is static if it is ambiguous (width with dynamic height, or height with dynamic width). For instance, if your cell just contains a UILabel, it really needs to know if it should grow vertically over multiple lines, or horizontally to fit the text.

You can use systemLayoutSizeFitting(...) to do this. It takes a priority for each dimension, so you can lock the static dimension by using the required priority for it, and give the other dimension the fitting priority.

If things are still not working as expected, you might be running into one of the limitations that needs a workaround. I talk about these towards the end of this post. But for now let’s continue stepping through the process.

UICollectionViewLayout: shouldInvalidateLayout(forPreferredLayoutAttributes:withOriginalAttributes:)

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.

UICollectionViewLayout: invalidationContext(forPreferredLayoutAttributes:withOriginalAttributes:)

Now your collection view asks for an invalidation context for these preferred attributes. There are 3 things that need to happen here.

1. Invalidate Index Paths

You need to specify which individual elements need new attributes calculated and applied to account for the preferred attributes. You do this by adding element index paths to the invalidation context.

Consider a vertically scrolling tableview like layout. If the height of an element changes, then the y position of every element underneath this one needs to be adjusted to make room. So you would need to add those index paths to the invalidation context as well.

By invalidating these index paths, the collection view knows to ask for them again using layoutAttributesFor... after the next prepare(). More on this later.

2. Adjust Content Size

You should also specify any adjustments to content size. You do this by setting a CGSize adjustment value. Positive values will grow the content size, negative values will shrink it. For the vertically scrolling tableview layout example, this would just be the difference between preferred and original attributes height. More complex layouts might need some more involved calculations.

3. Store Information from Preferred Attributes

The preferred attributes haven’t been used to precalculate new layout attributes yet. This will of course happen during the upcoming prepare(), so you need to hold onto the information you’ve calculated for it. Commonly this is done with a dictionary of [IndexPath: PreferredData]. For the vertically scrolling tableview layout example above, PreferredData can just be CGFloat for the preferred heights. For more complex layouts, it might help to use a data struct here.

UICollectionViewLayout: invalidateLayout(with:)

And, recursion. We are now invalidating again, which starts the process again (albeit with the more specific invalidation context that we built earlier).

Normally for preferred attributes invalidation contexts there’s nothing special to do here — just call super or don't bother overriding at all. But you will need to handle the next step differently...

UICollectionViewLayout: prepare()

Now when you precalculate your layout data, you must take the preferred attributes information you stored earlier into account. I suggest doing this naively first: just rebuild the whole layout using the preferred attributes. Once you have that working and tested, start optimising. Part 3 has more detail around optimisations here.

UICollectionViewLayout: layoutAttributesFor...

Now the collection view asks for the new attributes. It will usually call one of the at: methods for the index path that provided preferred attributes, but it may request the whole rect again.

UIReuseableView: preferredLayoutAttributesFitting(_:)

And finally, it asks the element view for preferred attributes again. The collection view is smart enough to finish up here for this index path if these preferred attributes are the same as the “fitting” (or “original”) attributes. This should be the case.

Then it then continues into the next index path in the invalidation context, and the next and the next. It continues this process for each index path until there’s nothing invalid left. Remember that each run of this process can invalidate more paths as elements give preferred sizes, and these paths will eventually be handled too, because it’s a recursive process.

Eventually, once there are no more invalid index paths left, the collection view knows the layout data is up to date. It then applies the layout to the actual views. Now you have your beautiful collection view with self sized cells 🎉

End of Part 1

Or do you? If your cells still aren’t behaving, stay tuned for part 2 where we’ll go over some of the limitations and workarounds.

Until then! 👋

--

--

Maximilian Clarke

Technical lead at Tigerspike, synth enthusiast, husband, not necessarily in that order 😜