Scrollable lists using Protocol-Oriented Programming and UICollectionViewCompositionalLayout

Boris Bugor
8 min readAug 26, 2023
Photo by Mika Baumeister on Unsplash

I continue the series of articles devoted to the use of a protocol-oriented approach when scaling projects with a large code base.

If you have not read the previous article, then I strongly recommend that you familiarize yourself with the approaches and conclusions that were made in it. Briefly, a case was considered with the creation of a universal class that would allow creating a constructor for using scrolling lists based on UICollectionViewFlowLayout.

The motivation for this approach is very simple, we want to reduce the amount of boilerplate code by creating universal tools that will reduce the amount of routine and at the same time not lose flexibility.

In this article, we will continue to consider a similar task using UICollectionViewCompositionalLayout, which is supported from iOS 13+, and see what nuances this framework brings.

As in the previous problem, we will solve this problem in 4 stages.

  1. Writing an abstraction of the data type of scrolling elements;
  2. Writing a base class for scrollable elements;
  3. Writing an Implementation for Lists;
  4. Use cases

1. Abstract scrolling elements

The creation of abstraction — is undoubtedly the most important stage for design. To lay the foundation for a system open to scaling, it is necessary to abstract from the qualitative and quantitative characteristics of scrolling elements. It is only important to comply with the requirements for the same type of layout.

Let us introduce such a notion — as a section. A section is one or more elements with the same layout. We use the section as an abstraction over the scrollable elements:

Using a section as an abstraction over scrollable elements:

protocol BaseSection {
var numberOfElements: Int { get }

func registrate(collectionView: UICollectionView)
func cell(for collectionView: UICollectionView, indexPath: IndexPath) -> UICollectionViewCell
func header(for collectionView: UICollectionView, indexPath: IndexPath) -> UICollectionReusableView
func footer(for collectionView: UICollectionView, indexPath: IndexPath) -> UICollectionReusableView
func section() -> NSCollectionLayoutSection
func select(row: Int)
}

We will transfer the responsibility for configuring the layout to the section. The presence of supplementary views, such as a header or footer, will also be determined there.

2. Scrolling list

The base class will be used as the scrollable list. The task of the base class is to take the abstract data of the BaseSection and render it. In our case, UICollectionView and UICollectionViewCompositionalFlowLayout will be used as a visualization tool:

class SectionView: UIView {
override init(frame: CGRect) {
super.init(frame: frame)

commonInit()
}

required init?(coder: NSCoder) {
super.init(coder: coder)

commonInit()
}

private func commonInit() {
collectionView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
collectionView.frame = bounds
addSubview(collectionView)
}

private(set) lazy var flowLayout: UICollectionViewCompositionalLayout = {
let layout = UICollectionViewCompositionalLayout { [weak self] index, env in
self?.sections[index].section()
}
return layout
}()

private(set) lazy var collectionView: UICollectionView = {
let collectionView = UICollectionView(frame: .zero, collectionViewLayout: flowLayout)
collectionView.backgroundColor = .clear
collectionView.delegate = self
collectionView.dataSource = self
return collectionView
}()

private var sections: [BaseSection] = []

public func set(sections: [BaseSection], append: Bool) {
sections.forEach {
$0.registrate(collectionView: collectionView)
}

if append {
self.sections.append(contentsOf: sections)
} else {
self.sections = sections
}

collectionView.reloadData()
}

public func set(contentInset: UIEdgeInsets) {
collectionView.contentInset = contentInset
}
}

extension SectionView: UICollectionViewDataSource {
func numberOfSections(in collectionView: UICollectionView) -> Int {
sections.count
}

func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
sections[section].numberOfElements
}

func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
sections[indexPath.section].cell(for: collectionView, indexPath: indexPath)
}

func collectionView(_ collectionView: UICollectionView, viewForSupplementaryElementOfKind kind: String, at indexPath: IndexPath) -> UICollectionReusableView {
kind == UICollectionView.elementKindSectionHeader
? sections[indexPath.section].header(for: collectionView, indexPath: indexPath)
: sections[indexPath.section].footer(for: collectionView, indexPath: indexPath)
}
}

extension SectionView: UICollectionViewDelegate {
func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
sections[indexPath.section].select(row: indexPath.row)
}
}

UICollectionViewCompositionalLayout, compared to using UICollectionViewFlowLayout, allows you to transfer cell / header / footer layout configuration from delegate methods to the layout body.

3. Implementing Scrollable Elements

Based on the fact that the section, which includes the ability to show the footer and header, was taken as an abstraction, it is also necessary to take this into account in the implementation class.

In this case, the requirements for any cell will look like this:

protocol SectionCell: UICollectionViewCell {
associatedtype CellData: SectionCellData

func setup(with data: CellData) -> Self
static func groupSize() -> NSCollectionLayoutGroup
}

protocol SectionCellData {
var onSelect: VoidClosure? { get }
}

typealias VoidClosure = () -> Void

We move the configuration of the cell size to the area of responsibility of the cell, we also take into account the possibility of receiving an action by tapping on any cell.

The header and footer requirements will look like this:

protocol SectionHeader: UICollectionReusableView {
associatedtype HeaderData

func setup(with data: HeaderData?) -> Self
static func headerItem() -> NSCollectionLayoutBoundarySupplementaryItem?
}

protocol SectionFooter: UICollectionReusableView {
associatedtype FooterData

func setup(with data: FooterData?) -> Self
static func footerItem() -> NSCollectionLayoutBoundarySupplementaryItem?
}

Based on the requirements for scrolling elements, we can design the implementation of the section:

class Section<Cell: SectionCell, Header: SectionHeader, Footer: SectionFooter>: BaseSection {
init(items: [Cell.CellData], headerData: Header.HeaderData? = nil, footerData: Footer.FooterData? = nil) {
self.items = items
self.headerData = headerData
self.footerData = footerData
}

private(set) var items: [Cell.CellData]
private let headerData: Header.HeaderData?
private let footerData: Footer.FooterData?

var numberOfElements: Int {
items.count
}

func registrate(collectionView: UICollectionView) {
collectionView.register(Cell.self)
collectionView.registerHeader(Header.self)
collectionView.registerFooter(Footer.self)
}

func cell(for collectionView: UICollectionView, indexPath: IndexPath) -> UICollectionViewCell {
collectionView
.dequeue(Cell.self, indexPath: indexPath)?
.setup(with: items[indexPath.row])
?? UICollectionViewCell()
}

func header(for collectionView: UICollectionView, indexPath: IndexPath) -> UICollectionReusableView {
collectionView
.dequeueHeader(Header.self, indexPath: indexPath)?
.setup(with: headerData)
?? UICollectionReusableView()
}

func footer(for collectionView: UICollectionView, indexPath: IndexPath) -> UICollectionReusableView {
collectionView
.dequeueFooter(Footer.self, indexPath: indexPath)?
.setup(with: footerData)
?? UICollectionReusableView()
}

func section() -> NSCollectionLayoutSection {
let section = NSCollectionLayoutSection(group: Cell.groupSize())

if let headerItem = Header.headerItem() {
section.boundarySupplementaryItems.append(headerItem)
}

if let footerItem = Footer.footerItem() {
section.boundarySupplementaryItems.append(footerItem)
}

return section
}

func select(row: Int) {
items[row].onSelect?()
}
}

Generics that implement the requirements for them act as cell / header / footer types.

In general, the implementation is complete, but I would like to add a few helpers that further reduce the amount of boilerplate code. In particular, in practice it will not always be useful to have such a generic section, for the simple reason that the footer or header is not always used. Therefore, let’s add a section heir that takes into account similar cases:

class SectionWithoutHeaderFooter<Cell: SectionCell>: Section<Cell, EmptySectionHeader, EmptySectionFooter> {}

class EmptySectionHeader: UICollectionReusableView, SectionHeader {
func setup(with data: String?) -> Self {
self
}

static func headerItem() -> NSCollectionLayoutBoundarySupplementaryItem? {
nil
}
}

class EmptySectionHeader: UICollectionReusableView, SectionHeader {
func setup(with data: String?) -> Self {
self
}

static func headerItem() -> NSCollectionLayoutBoundarySupplementaryItem? {
nil
}
}

On this, the design can be considered complete, I propose to move on to the use cases themselves.

4. Use cases

Let’s create a section of cells with a fixed size and display it on the screen:

class ColorCollectionCell: UICollectionViewCell, SectionCell {

func setup(with data: ColorCollectionCellData) -> Self {
contentView.backgroundColor = data.color

return self
}

static func groupSize() -> NSCollectionLayoutGroup {
let itemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1), heightDimension: .fractionalWidth(0.5))
let item = NSCollectionLayoutItem(layoutSize: itemSize)
let group = NSCollectionLayoutGroup.horizontal(layoutSize: itemSize, subitem: item, count: 2)
group.interItemSpacing = .fixed(16)
return group
}
}

class ColorCollectionCellData: SectionCellData {
let onSelect: VoidClosure?
let color: UIColor

init(color: UIColor, onSelect: VoidClosure? = nil) {
self.onSelect = onSelect
self.color = color
}
}

Let’s create an implementation of the header and footer:

class DefaultSectionHeader: UICollectionReusableView, SectionHeader {
let textLabel: UILabel = {
let label = UILabel()
label.font = .systemFont(ofSize: 32, weight: .bold)
return label
}()

override init(frame: CGRect) {
super.init(frame: frame)

commonInit()
}

required init?(coder: NSCoder) {
super.init(coder: coder)

commonInit()
}

private func commonInit() {
addSubview(textLabel)
textLabel.numberOfLines = .zero
textLabel.translatesAutoresizingMaskIntoConstraints = false

NSLayoutConstraint.activate([
textLabel.topAnchor.constraint(equalTo: topAnchor),
textLabel.bottomAnchor.constraint(equalTo: bottomAnchor),
textLabel.leftAnchor.constraint(equalTo: leftAnchor),
textLabel.rightAnchor.constraint(equalTo: rightAnchor)
])
}

func setup(with data: String?) -> Self {
textLabel.text = data

return self
}

static func headerItem() -> NSCollectionLayoutBoundarySupplementaryItem? {
let headerSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .estimated(20))
let header = NSCollectionLayoutBoundarySupplementaryItem(
layoutSize: headerSize,
elementKind: UICollectionView.elementKindSectionHeader,
alignment: .top,
absoluteOffset: .zero
)
header.pinToVisibleBounds = true
return header
}
}

class DefaultSectionFooter: UICollectionReusableView, SectionFooter {
let textLabel: UILabel = {
let label = UILabel()
label.font = .systemFont(ofSize: 12, weight: .light)
return label
}()

override init(frame: CGRect) {
super.init(frame: frame)

commonInit()
}

required init?(coder: NSCoder) {
super.init(coder: coder)

commonInit()
}

private func commonInit() {
addSubview(textLabel)
textLabel.numberOfLines = .zero
textLabel.translatesAutoresizingMaskIntoConstraints = false

NSLayoutConstraint.activate([
textLabel.topAnchor.constraint(equalTo: topAnchor),
textLabel.bottomAnchor.constraint(equalTo: bottomAnchor),
textLabel.leftAnchor.constraint(equalTo: leftAnchor),
textLabel.rightAnchor.constraint(equalTo: rightAnchor)
])
}

func setup(with data: String?) -> Self {
textLabel.text = data

return self
}

static func footerItem() -> NSCollectionLayoutBoundarySupplementaryItem? {
let footerSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .estimated(20))
let footer = NSCollectionLayoutBoundarySupplementaryItem(
layoutSize: footerSize,
elementKind: UICollectionView.elementKindSectionFooter,
alignment: .bottom,
absoluteOffset: .zero
)

return footer
}
}

Let’s add a new section to the scrolling list:

class ViewController: UIViewController {

let sectionView = SectionView()

override func loadView() {
view = sectionView
}

override func viewDidLoad() {
super.viewDidLoad()
sectionView.backgroundColor = .white

sectionView.set(
sections: [
Section<ColorCollectionCell, DefaultSectionHeader, DefaultSectionFooter>(
items: [
.init(color: .blue) {
print(#function)
},
.init(color: .red) {
print(#function)
},
.init(color: .yellow) {
print(#function)
},
.init(color: .green) {
print(#function)
},
.init(color: .blue) {
print(#function)
}
],
headerData: "COLOR SECTION",
footerData: "footer text for color section"
)
],
append: false
)
}
}

In total, in just a few lines of code, we implemented a section of 5 multi-colored cells with a size proportional to the screen, a header and a footer.

Let’s try to use a similar approach for dynamically sized cells.

class DynamicCollectionCell: UICollectionViewCell, SectionCell {
let textLabel = UILabel()

override init(frame: CGRect) {
super.init(frame: frame)

commonInit()
}

required init?(coder: NSCoder) {
super.init(coder: coder)

commonInit()
}

private func commonInit() {
contentView.addSubview(textLabel)
textLabel.numberOfLines = .zero
textLabel.translatesAutoresizingMaskIntoConstraints = false

NSLayoutConstraint.activate([
textLabel.topAnchor.constraint(equalTo: contentView.topAnchor),
textLabel.bottomAnchor.constraint(equalTo: contentView.bottomAnchor),
textLabel.leftAnchor.constraint(equalTo: contentView.leftAnchor),
textLabel.rightAnchor.constraint(equalTo: contentView.rightAnchor)
])
}

func setup(with data: DynamicCollectionCellData) -> Self {
textLabel.text = data.text

return self
}

static func groupSize() -> NSCollectionLayoutGroup {
let itemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1), heightDimension: .estimated(20))
let item = NSCollectionLayoutItem(layoutSize: itemSize)
let group = NSCollectionLayoutGroup.vertical(layoutSize: itemSize, subitems: [item])
return group
}
}

class DynamicCollectionCellData: SectionCellData {
let text: String
var onSelect: VoidClosure?

init(text: String) {
self.text = text
}
}


class ViewController: UIViewController {

...

override func viewDidLoad() {
super.viewDidLoad()

...
sectionView.set(
sections: [
SectionWithoutHeaderFooter<DynamicCollectionCell>(
items: [
.init(text: "Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industry's standard dummy text ever since the 1500s"),
.init(text: "when an unknown printer took a galley of type and scrambled it to make a type specimen book. It has survived not only five centuries, but also the leap into electronic typesetting, remaining essentially unchanged."),
.init(text: "It was popularised"),
.init(text: "the 1960s with the release of Letraset sheets containing"),
.init(text: "Lorem Ipsum passages, and more recently with desktop publishing software like Aldus PageMaker including versions of Lorem Ipsum.")
]
),
...
],
append: false
)
}
}

As a result, we got rid of writing boilerplate code when creating scrolling lists based on UICollectionViewCompositionalLayout.

The source code can be viewed here.

Don’t hesitate to contact me on Twitter if you have any questions. Also, you can always buy me a coffee.

--

--

Boris Bugor

Senior iOS Developer | Co-founder of VideoEditor: Reels & Stories | Founder of Flow: Diary, Calendar, Gallery | #ObjC | #Swift | #UIKit | #SwiftUI