Handling complex iOS CollectionViews with Compositional Layout

Ignasi Casulà
Inside_Wallapop
Published in
6 min readAug 1, 2024

Context

Wallapop is a second-hand marketplace founded in Barcelona in 2013, that currently operates in Spain, Italy and Portugal. With 19 million monthly users, the platform enables buying and selling across all consumer goods categories.

To set the stage, here’s what Wallapop iOS app’s Home screen currently looks like:

Inside a vertical UICollectionView it features multiple Recommendations sections — marked in green — and an infinite feed section — marked in red — that shows recently posted products.

Initially — the app was first published in 2013 — besides the top Category Selector, only the infinite feed section was present on the screen, so the implementation was quite simple: a vertical CollectionView that contained items distributed in a two column layout.

Over the past years, we’ve significantly expanded this Home screen by adding several personalized Recommendations sections, in order to boost the app’s engagement.

Since we started adding the sections one by one, the initial approach was just to embed a whole new horizontal UICollectionView into a UICollectionViewCell, so that it could be embedded into the vertical Home CollectionView. Our Home screen became a “collection of collections” so to speak.

The problem

More and more sections were added, right up until recently, when we potentially had 15 different sections on top of the initial Recent items section. That meant having up to 15 different UICollectionViews inside the same UICollectionView; one for each horizontal Recommendation section.

Performance-wise this wasn’t great. Each section had to be configured twice: first in the sizeForItemAt method to reserve the proper vertical space for the section, since the height was variable based on the content and desired layout, and then again to get the actual view and render it.

That overhead caused two main performance problems:

  • Scroll Hitch Rate: due to the double dequeuing of the sections, a fast vertical scroll of the Home screen showed signs of hitching
  • Excessive RAM consumption: when idle, the Home screen took up to 200MB in RAM.

Another problem that we faced, although not performance-related was something that was annoying to maintain: since each horizontal slider section was it’s own collection, the built-in recycling system of the main CollectionView wasn’t keeping the user’s horizontal scrolled position for each section when they scrolled up & down the Home screen.

To solve that problem we had to manually keep track of the horizontal offset for each section, so we could restore it when the section was going to be shown again in the screen. And since we use UICollectionViewDiffableDataSource, by default the section reloaded itself each time the user scrolled horizontally and the offset property changed, so we had to manually modify the Equatable and Hashable implementation of our sections models for them to ignore the offset property.

Understandably, in order to try and improve the aforementioned issues, while also aiming to be faster when developing new sections we researched a bit and found a solution.

UICollectionViewCompositionalLayout

It was first introduced by Apple in WWDC19 and it started as an in-house internal solution to better handle complex layouts such as their AppStore, that featured a vertical CollectionView based on multiple horizontal sections with different layouts.

It works based sections, groups and items. And allows each of them to be fully and easily configurable with a few lines of code.

Compositional Layout structure

Since the goal of this article is not providing an in-depth look at how the Compositional Layout works, for more detailed information about how to use it you can check:

Our implementation

The first step was to map each Recommendation section into its equivalent Compositional Layout style. In order to achieve that, we first coded a helper that would help us avoid some of the verbosity that the default Compositional Layout implementation requires.

For instance, to render a slider of items of size 120x120px with 8px spacing, we simplified the implementation from this:

let item = NSCollectionLayoutItem(layoutSize: .init(widthDimension: .fractionalWidth(1),
heightDimension: .fractionalHeight(1)))
let group = NSCollectionLayoutGroup.horizontal(layoutSize: .init(widthDimension: .absolute(120),
heightDimension: .absolute(120)),
subitems: [item])

let section = NSCollectionLayoutSection(group: group)
section.orthogonalScrollingBehavior = .continuous
section.interGroupSpacing = 8
return section

to this:

let style = CompositionalLayoutStyle.slider(.init(width: .fixed(120),
height: .fixed(120),
spacing: 8))

Another really useful advantage is that, if in the future we want to support new different layouts, we only have to add them to our Style enum and map it to the specific CompositionalLayout implementation in order to use it throughout our app.

enum CompositionalLayoutStyle: Equatable {
case grid(GridConfiguration)
case slider(SliderConfiguration)
case singleItem(SingleItemConfiguration)
}

Downsides

We realized that we had two specific use cases that were not supported by CompositionalLayout, so we had to manually handle them:

  • Horizontal scrolling event

In order to track user engagement with our horizontal Recommendation sliders, we were tracking when the user performed a horizontal scroll in a specific section. That was easy to handle since each section was its own CollectionView, so UIScrollViewDelegate’s method scrollViewWillBeginDragging was automatically called and easily tracked.

Compositional Layout only triggers that method for when the the main CollectionView’s is scrolled — in our case that meant only vertical scrolling — so we had to use visibleItemsInvalidationHandler from NSCollectionLayoutSection

section.visibleItemsInvalidationHandler = { _, offset, _ in
if offset.x > .zero {
delegate?.sectionDidScroll(offset: offset.x)
}
}

Checking that offset.x is greater than zero was a needed workaround to avoid triggering the event when the section was loaded, since visibleItemsInvalidationHandler was called automatically once without user interaction.

  • Infinite feed as a single section with Ads

Inside the infinite feed section we had to allow showing horizontal banner shaped Ads with dynamic positioning:

Infinite feed section with Ad banner

This business constraint prevented us from just mapping that section into just a grid section. The issue was that Compositional Layout is not really designed to dynamically setup different shaped groups inside the same section, since it’s based on having a consistent distribution for the items inside that section.

The workaround for this case was to define a new section for each row of the infinite feed section. That meant having two different kinds of sections joined vertically so we could represent both a two item section for the products and a single item section for the banner.

switch cellKind {
case .adBanner:
return .init(style: .singleItem(.init(widthDimension: .fractionalWidth(1),
heightDimension: .fixed(70),
spacing: 8)))
case .product:
return .init(style: .grid(.init(axis: .vertical,
itemCount: 2,
height: .fixed(300),
spacing: 8)))
}

Results

Dequeuing

The main advantage of the new approach is that we’ve been able to completely remove the RecommendationView — which contained the horizontal CollectionView — and it’s adapter — that handled the dequeuing for each different kind of cell — achieving a huge simplification of the Home rendering process:

Home Recommendation section dequeuing flow (after)

This allowed us to have a way simpler view hierarchy, with less subviews and calculations, and also to be way faster when developing new sections or modifying existing ones.

Plus, removing a few lines of code is always satisfying…

Performance

While we’re still rolling out the refactor and monitoring for potential issues, early results look promising.

Across locally tested devices, the startup time of the app to a fully loaded Home screen state has been reduced a 20% on average.

Also, RAM consumption when the user is idle in the Home screen has also been reduced, in this case a 46% on average.

We plan to publish a follow-up article with long-term performance data, further learnings and conclusions.

Stay tuned for more updates!

--

--