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 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
As we did previously, we will solve this problem in 4 stages:
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.
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.
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.
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
Don’t hesitate to contact me on