paint-brush
How to Create Scrollable Lists with Protocol-Oriented Design & UICollectionViewCompositionalLayoutby@bugorbn
717 reads
717 reads

How to Create Scrollable Lists with Protocol-Oriented Design & UICollectionViewCompositionalLayout

by Boris BugorJune 18th, 2024
Read on Terminal Reader
Read this story w/o Javascript

Too Long; Didn't Read

The motivation for this approach is very simple, we want to reduce the amount of boilerplate code by creating universal tools. We will solve this problem in 4 stages. Writing an abstraction of the data type of scrolling elements; writing a base class for scrollable elements; Writing an Implementation for Lists; and Writing an implementation for Lists.
featured image - How to Create Scrollable Lists with Protocol-Oriented Design & UICollectionViewCompositionalLayout
Boris Bugor HackerNoon profile picture

This article is a continuation of my series on using a protocol-oriented approach when scaling projects with a large code base.


If you have not read the previous article, I strongly recommend that you familiarize yourself with the approaches and conclusions made in it. Briefly, a case was considered with the creation of a universal class that would allow the creation of 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, supported by iOS 13+, and see what nuances this framework brings.


As we did previously, 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 of 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 also 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:

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 and 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, or 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.


Let’s add a section here 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-coloured 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.