Creating Scrollable Lists using Protocol-Oriented Programming and UICollectionViewFlowLayout

Written by bugorbn | Published 2024/02/12
Tech Story Tags: swift | ios-app-development | swift-programming | ios-programming | uicollectionviewflowlayout | protocol-oriented-programming | creating-an-app-on-ios | ios-app-guide

TLDREach developer in practice faced the need to make up a screen based on a scrolling list of elements. Or maybe not just one list, but several.via the TL;DR App

Each developer in practice faced the need to make up a screen based on a scrolling list of elements. Or maybe not just one list, but several.

It can be horizontal lists, vertical lists, lists with a complex layout. These can be lists with a predetermined size, or with a dynamic size. And the developer always has a choice — to use UITableView or UICollectionView. (We will not consider UIScrollView, since its use is limited to a finite list of a predetermined number).

Each of the above options has strengths and weaknesses:

  • UITableView, one of the advantages of which is the dynamic calculation of the content height out of the box, is used in vertical lists.

  • UICollectionView — always had more use cases chronologically, as they always covered more scroll direction. However, their biggest drawback prior to iOS 13 is the static size, which had to be calculated manually.

And as every developer knows, after choosing one or the other, the next step is to write a tangible amount of accompanying code in order to make this list work.

  • You have to register cells, headers, footers or a runtime crash is inevitable;

  • You have to monotonously create cells, configure their number in a section, declare the number of sections;

  • You have to fill in the cells with preset data;

  • Handle table or collection delegate methods, handle clicks inside a cell;

  • It is necessary to duplicate the code and wrap in cells, previously designed for a table, in cells for a collection if both classes are used in the project.

Each of us spent a huge amount of time on all these things, and this is not normal. This problem is called the problem of writing boilerplate code, and it must be solved by writing universal tools that will reduce the amount of routine and at the same time not lose flexibility.

In this article, we will solve the problem of writing boilerplate code based on UICollectionView using a protocol-oriented approach for UICollectionViewFlowLayout. This approach is suitable for any project for all currently supported versions of iOS.

In the next article, I will show how to use this approach for UICollectionViewCompositionalFlowLayout, which is supported from iOS 13+.

We are faced with the issue of reducing the amount of boilerplate code and we will solve it 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

Designing abstractions is one of the key points in creating a system that is well resistant to changes. It is important to understand that the requirements for abstraction must cover absolutely all possible cases of using objects, otherwise, when scaling the system, additional entities will have to be introduced, the value of the root abstraction will decrease, and the introduction of new features will become more complicated.

In the case of designing a scrolling list based on UICollectionView, we want to have a unit that would describe the behavior of the elements in the list, without being tied to their qualitative and quantitative characteristics.

In other words, it doesn’t matter how many objects we have, it doesn’t matter what exactly these objects contain — they should be able to get into the scrolling list if they meet the stated requirements.

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:

protocol BaseSection {
    var numberOfElements: Int { get }

    func registrate(collectionView: UICollectionView)
    func cell(for collectionView: UICollectionView, indexPath: IndexPath) -> UICollectionViewCell
    func cellSize(
        for collectionView: UICollectionView,
        layout: UICollectionViewFlowLayout,
        indexPath: IndexPath
    ) -> CGSize
    func header(for collectionView: UICollectionView, indexPath: IndexPath) -> UICollectionReusableView
    func headerSize(for collectionView: UICollectionView) -> CGSize
    func footer(for collectionView: UICollectionView, indexPath: IndexPath) -> UICollectionReusableView
    func footerSize(for collectionView: UICollectionView) -> CGSize
    func select(row: Int)
}

Since each section can have a header and footer, we will also provide their configuration in abstraction. In addition, the abstraction is supplemented by methods for calculating the size of cells / header / footer.

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 UICollectionViewFlowLayout 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) var flowLayout: UICollectionViewFlowLayout = {
        let layout = UICollectionViewFlowLayout()
        layout.minimumLineSpacing = .zero
        layout.minimumInteritemSpacing = .zero
        layout.scrollDirection = .vertical
        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(itemSpacing: CGFloat) {
        flowLayout.minimumInteritemSpacing = itemSpacing
    }
    
    public func set(lineSpacing: CGFloat) {
        flowLayout.minimumLineSpacing = lineSpacing
    }
    
    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, UICollectionViewDelegateFlowLayout {
    func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
        sections[indexPath.section].select(row: indexPath.row)
    }
    
    func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize {
        sections[indexPath.section].cellSize(
            for: collectionView,
            layout: flowLayout,
            indexPath: indexPath
        )
    }
    
    func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, referenceSizeForHeaderInSection section: Int) -> CGSize {
        sections[section].headerSize(for: collectionView)
    }
    
    func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, referenceSizeForFooterInSection section: Int) -> CGSize {
        sections[section].footerSize(for: collectionView)
    }
}

In addition to the usual rendering of objects, the base class solves the problem of writing boilerplate code. Instead of writing UICollectionViewdelegate method handling for each of the screens where the list will be used, we have only one single base class in which the delegate method processing will be performed once.

3. Implementing Scrollable Elements

Based on the fact that the section was taken as an abstraction, including the ability to show the footer and header, 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 size(for data: CellData, width: CGFloat) -> CGSize
}

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 size(for data: HeaderData?, width: CGFloat) -> CGSize
}

protocol SectionFooter: UICollectionReusableView {
    associatedtype FooterData
    
    func setup(with data: FooterData?) -> Self
    static func size(for data: FooterData?, width: CGFloat) -> CGSize
}

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 cellSize(for collectionView: UICollectionView, layout: UICollectionViewFlowLayout, indexPath: IndexPath) -> CGSize {
        Cell.size(
            for: items[indexPath.row],
            width: collectionView.frame.width - collectionView.contentInset.left - collectionView.contentInset.right - layout.minimumInteritemSpacing
        )
    }
    
    func headerSize(for collectionView: UICollectionView) -> CGSize {
        Header.size(
            for: headerData,
            width: collectionView.frame.width - collectionView.contentInset.left - collectionView.contentInset.right
        )
    }
    
    func footerSize(for collectionView: UICollectionView) -> CGSize {
        Footer.size(
            for: footerData,
            width: collectionView.frame.width - collectionView.contentInset.left - collectionView.contentInset.right
        )
    }
    
    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 size(for data: String?, width: CGFloat) -> CGSize {
        .zero
    }
}

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

    static func size(for data: String?, width: CGFloat) -> CGSize {
        .zero
    }
}

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 size(for data: ColorCollectionCellData, width: CGFloat) -> CGSize {
        .init(width: width / 2, height: width / 2)
    }
}

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

class ViewController: UIViewController {
    
    let sectionView = SectionView()
    
    override func loadView() {
        view = sectionView
    }

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

        sectionView.set(
            sections: [
                SectionWithoutHeaderFooter<ColorCollectionCell>(
                    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)
                        }
                    ]
                )
            ],
            append: false
        )
    }
}

In total, in just a few lines of code, we implemented 5 multi-colored cells with a fixed size.

Let’s try to use a similar approach for dynamically sized cells. For this flight, we will write the successor of SectionCell:

protocol DynamicSectionCell: SectionCell {
    
}

extension DynamicSectionCell {
    static func size(for data: CellData, width: CGFloat) -> CGSize {
        let cell = Self().setup(with: data)
        let size = cell.contentView.systemLayoutSizeFitting(
            .init(width: width, height: .greatestFiniteMagnitude),
            withHorizontalFittingPriority: .required,
            verticalFittingPriority: .fittingSizeLevel
        )
        
        return .init(width: width, height: size.height)
    }
}

The method used here is the Prototype design pattern. To calculate the cell size, we create a cell, fill it with data, and calculate its size based on the data.

Using this approach, the implementation of the cell will look like this:

class DynamicCollectionCell: UICollectionViewCell, DynamicSectionCell {
    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
    }
}

class DynamicCollectionCellData: SectionCellData {
    let text: String
    var onSelect: VoidClosure?
    
    init(text: String) {
        self.text = text
    }
}

class ViewController: UIViewController {
    
    let sectionView = SectionView()
    
    override func loadView() {
        view = sectionView
    }

    override func viewDidLoad() {
        super.viewDidLoad()
        sectionView.backgroundColor = .white
        sectionView.set(itemSpacing: 16)
        sectionView.set(lineSpacing: 16)
        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.")
                    ]
                ),
                SectionWithoutHeaderFooter<ColorCollectionCell>(
                    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)
                        }
                    ]
                )
            ],
            append: false
        )
    }
}

Despite the need to calculate the size of the UICollectionView elements, we were able to get rid of the template code and taught how to calculate the size dynamically.

The source code can be viewed here, and in the next article I will show how to use a similar approach to work with UICollectionViewCompositionalFlowLayout.

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


Written by bugorbn | Co-founder of VideoEditor: Reels & Stories | Founder of Flow: Diary, Calendar, Gallery | #Swift | #UIKit | #SwiftUI
Published by HackerNoon on 2024/02/12