Creating a Multi-Filter Collection View with Diffable Data Source

A proof of concept to validate complex UI changes on iOS production code

Andrea Scuderi
Just Eat Takeaway-tech
9 min readJun 21, 2024

--

As part of the expansion of Just Eat Takeaway.com to deliver not only food, but also groceries and many other different products, it was required to implement a new UI to allow selection among categories and refinements on sub-categories.

Requirements:

  • The users can select only one category on the first carousel. There is always a default category selected.
  • Once a category is selected, the second carousel will show all the available options and the user can select multiple options.
  • Multiple sections are added to the collection view reflecting the choices made on the first and the second carousel, allowing choices between items.

Production code required a big effort to achieve what’s described above, so we decided to use a simple proof of concept before we implemented the code in production with several variants.

The code in this tutorial was used as a proof of concept on iOS for creating a complex user interface in a collection view. The final goal we achieved was to implement the UI in the picture.

The Importance of Proof of Concept

A proof of concept (PoC) is a crucial step in the development process. It allows developers to test the feasibility of their ideas and implementations on a smaller scale before committing to full-scale development.

By creating a PoC, you can quickly identify potential issues, validate assumptions, and explore different design choices.

This approach can save time and resources by ensuring that only viable and effective solutions are pursued.

In this tutorial, the PoC demonstrates how to manage complex UI requirements with modern collection view techniques, providing a solid foundation for further development.

In the real-world scenarios we use our Restaurant API to fetch the data and display it in the collection view, but in this tutorial, we will use the Dog API to demonstrate the concept and have some fun!

This tutorial will guide you through creating a collection view with multiple filterable sections using UICollectionViewDiffableDataSource and UICollectionViewCompositionalLayout. We'll use the class MultiFilterViewController to demonstrate this setup.

Step 1: Define Your Data Models

Define your data models to represent the sections and items in the collection view. Ensure that they conform to Hashable. In this example, we define three data models: Category, Breed, and Image. The Content struct defines the section types and items that will be displayed in the collection view.

struct Category: Hashable {
let name: String
let range: ClosedRange<String>
let isSelected: Bool
}

struct Breed: Hashable {
let breed: String
let apiKey: String
let isSelected: Bool
}

struct Image: Hashable {
let url: URL
}

struct Content {
enum SectionType: Int, Hashable {
case category
case breed
case images
}

struct Section: Hashable {
var id: String
var type: SectionType
}

enum Item: Hashable {
case category(Category)
case breed(Breed)
case image(Image)

func hash(into hasher: inout Hasher) {
switch self {
case .category(let item):
hasher.combine(item.hashValue)
case .breed(let item):
hasher.combine(item.hashValue)
case .image(let image):
hasher.combine(image)
}
}
}
}

We have three types of sections: category, breed, and images. Each section has a unique identifier and type. The Item enum represents the items within each section. The hash(into:) method is implemented to ensure that each item is uniquely identified.

Step 2: Setup the View Controller

Create the view controller MultiFilterViewController and define its properties.

class MultiFilterViewController: UIViewController {

static let sectionHeaderElementKind = "section-header-element-kind"

var collectionView: UICollectionView!
var collectionViewLayout: UICollectionViewCompositionalLayout!

// Service for data fetching
let service: APIServing = Service()

// ViewModel for managing data
let viewModel = SectionViewModel()

var dataSource: UICollectionViewDiffableDataSource<Content.Section, Content.Item>?

override func viewDidLoad() {
super.viewDidLoad()
updateInBackground()
}

init() {
super.init(nibName: nil, bundle: nil)
self.setupView()
self.setupConstraints()
self.dataSource = self.makeDataSource()
}

required init?(coder: NSCoder) {
fatalError("Not implemented")
}
}

Note that we have defined a service property to handle data fetching and a viewModel property to manage the data. We also have a dataSource property to manage the collection view data source using UICollectionViewDiffableDataSource. The collection view layout is defined using UICollectionViewCompositionalLayout.

Step 3: Setup View and Constraints

Initialize and configure the collection view, setting its layout and registering the necessary cells and supplementary views.

extension MultiFilterViewController {
func setupView() {
collectionViewLayout = buildCompositionalLayout()
collectionView = UICollectionView(frame: .zero, collectionViewLayout: collectionViewLayout)
collectionView.translatesAutoresizingMaskIntoConstraints = false
collectionView.allowsMultipleSelection = true
collectionView.delegate = self
view.backgroundColor = .white
title = "Dog Breeds"
view.addSubview(collectionView)
}

func setupConstraints() {
NSLayoutConstraint.activate([
collectionView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor),
collectionView.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor),
collectionView.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor),
collectionView.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor),
])
}
}

In this step, we set up the collection view and add it to the view controller’s view. We also define the constraints to ensure that the collection view fills the entire screen.

Step 4: Create Cell and Supplementary View Registrations

Define cell registrations for different item types and a supplementary view registration for section headers.

extension MultiFilterViewController {

private func createLevelOneCellRegistration() -> UICollectionView.CellRegistration<LevelOneCollectionViewCell, Category> {
UICollectionView.CellRegistration<LevelOneCollectionViewCell, Category> { [weak self] (cell, indexPath, item) in
cell.item = item
cell.icon = indexPath.row.isMultiple(of: 2) ? UIImage(systemName: "pawprint") : UIImage(systemName: "pawprint.fill")
if item.isSelected {
self?.collectionView.selectItem(at: indexPath, animated: false, scrollPosition: [])
}
}
}

private func createLevelTwoCellRegistration() -> UICollectionView.CellRegistration<LevelTwoCollectionViewCell, Breed> {
UICollectionView.CellRegistration<LevelTwoCollectionViewCell, Breed> { [weak self] (cell, indexPath, item) in
cell.item = item.breed
Task { @MainActor in
guard let self else { return }
if let url = try await self.viewModel.randomImageURL(for: item, service: self.service) {
cell.image = try await ImageManager.shared.getImage(for: url)
} else {
cell.image = UIImage(systemName: "pawprint")
}
}
cell.position = indexPath.item
if item.isSelected {
self?.collectionView.selectItem(at: indexPath, animated: false, scrollPosition: [])
}
}
}

private func createCardCellRegistration() -> UICollectionView.CellRegistration<CardCollectionViewCell, Image> {
UICollectionView.CellRegistration<CardCollectionViewCell, Image> { (cell, indexPath, item) in
Task { @MainActor in
cell.image = try await ImageManager.shared.getImage(for: item.url)
}
}
}

private func headerRegistration() -> UICollectionView.SupplementaryRegistration<SectionTitleView> {
UICollectionView.SupplementaryRegistration
<SectionTitleView>(elementKind: MultiFilterViewController.sectionHeaderElementKind) { [weak self] (supplementaryView, string, indexPath) in
guard let section = self?.dataSource?.sectionIdentifier(for: indexPath.section) else { return }
supplementaryView.label.text = section.id
supplementaryView.backgroundColor = .white
}
}
}

In this step, we define cell registrations for the different item types: Category, Breed, and Image. We also define a supplementary view registration for section headers. The cell registrations configure the cells with the appropriate data and images. The supplementary view registration sets the section title based on the section identifier.

Step 5: Configure the Data Source

Create and configure the diffable data source, providing cell and supplementary view providers.

extension MultiFilterViewController {
func makeDataSource() -> UICollectionViewDiffableDataSource<Content.Section, Content.Item> {
let levelOneRegistration = createLevelOneCellRegistration()
let levelTwoRegistration = createLevelTwoCellRegistration()
let cardRegistration = createCardCellRegistration()
let headerRegistration = headerRegistration()

let dataSource = UICollectionViewDiffableDataSource<Content.Section, Content.Item>(
collectionView: collectionView,
cellProvider: { (collectionView, indexPath, item) ->
UICollectionViewCell? in
item.dequeueReusableCell(collectionView: collectionView, levelOneRegistration: levelOneRegistration, levelTwoRegistration: levelTwoRegistration, cardRegistration: cardRegistration, indexPath: indexPath)
})
dataSource.supplementaryViewProvider = { (view, kind, index) in
return self.collectionView.dequeueConfiguredReusableSupplementary(using: headerRegistration, for: index)
}
return dataSource
}
}

In this step, we create the diffable data source using UICollectionViewDiffableDataSource. We provide cell providers for different item types and a supplementary view provider for section headers. The cell provider dequeues the appropriate cell based on the item type, and the supplementary view provider dequeues the section header view.

Step 6: Build the Compositional Layout

Define the compositional layout for the collection view.

extension MultiFilterViewController {
func buildCompositionalLayout() -> UICollectionViewCompositionalLayout {
let sectionProvider: UICollectionViewCompositionalLayoutSectionProvider = { [weak self] section, _ in
guard let sectionId = self?.dataSource?.sectionIdentifier(for: section) else { return nil }
return sectionId.type.buildLayout()
}
return UICollectionViewCompositionalLayout(sectionProvider: sectionProvider)
}
}

In this step, we define the compositional layout for the collection view. The section provider returns the layout for each section based on the section type. Note: We use the dataSource to retrieve the section identifier and build the layout based on the section type.

Step 7: Fetch and Update Data

Fetch data from the view model and update the snapshot for the diffable data source.

extension MultiFilterViewController {
func update() async throws {
let sections = try await viewModel.fetchData(service: service)
var snapshot = NSDiffableDataSourceSnapshot<Content.Section, Content.Item>()
let sectionKeys = sections.keys.sorted { section0, section1 in
guard section0.type == section1.type else { return section0.type.rawValue < section1.type.rawValue }
return section0.id < section1.id
}
for sectionKey in sectionKeys {
if let items = sections[sectionKey] {
snapshot.appendSections([sectionKey])
snapshot.appendItems(items, toSection: sectionKey)
}
}
dataSource?.apply(snapshot, animatingDifferences: true, completion: {
print("Apply snapshot completed!")
})
}

func updateInBackground() {
Task {
do {
try await update()
} catch {
print(error)
}
}
}
}

In this step, we define the update() method to fetch data from the view model and update the snapshot for the diffable data source. We use the fetchData() method to retrieve the data from the view model and create a snapshot with the sections and items. The snapshot is then applied to the data source to update the collection view.

Step 8: Handle Selection with Delegate Methods

Implement UICollectionViewDelegate methods to handle item selection and deselection.

extension MultiFilterViewController: UICollectionViewDelegate {
func collectionView(_ collectionView: UICollectionView, shouldDeselectItemAt indexPath: IndexPath) -> Bool {
guard let item = dataSource?.itemIdentifier(for: indexPath) else { return false }
switch item {
case .category(let item):
return item.name != viewModel.selectedCategory
case .breed:
return true
case .image:
return true
}
}

func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
guard let item = dataSource?.itemIdentifier(for: indexPath) else { return }
switch item {
case .category(let item):
collectionView.selectOneIndexInSection(at: indexPath, animated: true)
viewModel.selectCategory(category: item.name)
updateInBackground()
case .breed(let breed):
viewModel.toggleBreed(breed: breed.breed)
updateInBackground()
case .image(let image):
print(image)
}
}

func collectionView(_ collectionView: UICollectionView, didDeselectItemAt indexPath: IndexPath) {
guard let item = dataSource?.itemIdentifier(for: indexPath) else { return }
switch item {
case .category(let category):
print(category)
case .breed(let breed):
viewModel.toggleBreed(breed: breed.breed)
updateInBackground()
case .image(let image):
print(image)
}
}
}

In this step, we implement the UICollectionViewDelegate methods to handle item selection and deselection. We use the shouldDeselectItemAt method to prevent deselection of certain items based on the item type. The didSelectItemAt method handles item selection and updates the view model accordingly. The didDeselectItemAt method handles item deselection and updates the view model.

Note that we have enabled multiple selection in the collection view to allow users to select multiple items within a section. This requires handling selection and deselection logic for each item type.

Warning: When you migrate UICollectionView to use compositional layout, sometimes the UICollectionViewDelegate refers to a model that is not updated. Note that the source of truth for the UICollectionView is the diffable data source and it’s required to use it to refer to sections and items, failing in doing it could lead to app crashes.

We learned at our expense, that it is required to ensure that UICollectionViewDelegate is implemented in the class that contains the UICollectionViewDiffableDataSource to prevent unintended referral to other models and always to use dataSource?.itemIdentifier(for: indexPath) and dataSource?.sectionIdentifier(for: section) to refer to items and section and avoid to retrieve them directly from the UICollectionView.

Step 9: Add Helper Methods for Selection Management

Add helper methods to handle selection and deselection within a section.

extension UICollectionView {
func deselectAllInSection(section: Int, animated: Bool) {
guard let selectedIndexesInSection = indexPathsForSelectedItems?
.filter({ $0.section == section }) else { return }
for index in selectedIndexesInSection {
deselectItem(at: index, animated: animated)
}
}

func selectOneIndexInSection(at indexPath: IndexPath, animated: Bool) {
deselectAllInSectionExcept(at: indexPath, animated: animated)
selectItem(at: indexPath, animated: animated, scrollPosition: [])
}

private func deselectAllInSectionExcept(at indexPath: IndexPath, animated: Bool) {
guard let selectedIndexesInSection = indexPathsForSelectedItems?
.filter({ $0.section == indexPath.section && $0.row != indexPath.row }) else { return }
for index in selectedIndexesInSection {
deselectItem(at: index, animated: animated)
}
}
}

Summary

You’ve now created a MultiFilterViewController that uses UICollectionViewDiffableDataSource and UICollectionViewCompositionalLayout to manage a collection view with multiple filterable sections. This setup allows for dynamic and efficient data updates, along with flexible item selection and deselection handling. We have learned that the source of truth of the UICollectionView is the UICollectionViewDiffableDataSource, and we must to use it when we want to refer to Items and Sections. The source code for this tutorial can be found here.

References

Just Eat Takeaway.com is hiring! Want to come work with us? Apply today.

--

--