Getting Started with UICollectionViewCompositionalLayout iOS 01.06.2021

The new collection view compositional layout available in iOS 13 and up represents significant shift from the old way of creating complex collection views layouts. It is a declarative approach to building complex layouts with UICollectionView.

Compositional layouts are a declarative kind of API that allows us to build large layouts by stitching together smaller layout groups. Compositional layouts have a hierarchy that consists of Item, Group, Sections, and Layout.

ios_compositional_layout.png

To build any compositional layout, the following four classes need to be implemented:

  • UICollectionViewCompositionalLayout. At the top sits the UICollectionViewCompositionalLayout which can be set on UICollectionView with the setCollectionViewLayout method. UICollectionViewCompositionalLayout has two main initializers. One takes parameter section: which is of type NSCollectionLayoutSection and the other takes sectionProvider: which is basically a closure that lets us provide different sections based on the indexPath.
  • NSCollectionLayoutSection. When we want to create compositional layout, we need to provide NSCollectionLayoutSection either directly or via the section provider. This represents the standard section we know from table views and collection views. This class has a number of important properties to customize how the section looks like and behaves. Sections eventually compose the compositional layouts. A single section can contain multiple groups.
  • NSCollectionLayoutGroup. Groups are used to define how the items in particular sections are arranged. While the basic group is basically just a wrapper around individual layout items, it can get pretty complicated. You can also nest groups and this is the way to create complex layout. There are two main methods to create NSCollectionLayoutGroup available on the class itself and these are .horizontal, .vertical and .custom. Groups are considered to be the biggest workhorse of a compositional layout. You will typically do most configuration work on groups.
  • NSCollectionLayoutItem. NSCollectionLayoutItem is the basic building block of compositional layout. NSCollectionLayoutItems map directly to the data source items. The item expects instance of NSCollectionLayoutSize, which is also used to configure sizes of the groups. There are three ways to configure sizes. We have .estimated, .absolute and .fractional(Width|Height).
  • NSCollectionLayoutSize. The width and height dimensions are of the type NSCollectionLayoutDimension which can be defined by setting the fraction width/height of the layout (percentage relative to its container), or by setting the absolute or estimated sizes.

In this post, I have used the UICollectionViewCompositionalLayout with UICollectionViewDiffableDataSource.

First, create one method returning UICollectionViewCompositionalLayout. You have to assign that method to collectionViewLayout in the initialization method.

func createLayout() -> UICollectionViewLayout {        
    let itemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(0.5), heightDimension: .fractionalWidth(1.0))
    let item = NSCollectionLayoutItem(layoutSize: itemSize)

    let groupSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .fractionalWidth(0.5))
    let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, subitems: [item])

    let section = NSCollectionLayoutSection(group: group)

    let layout = UICollectionViewCompositionalLayout(section: section)
    return layout
}

Here we have used NSCollectionLayoutGroup.horizontal to create the layout group. By making the layout group horizontal all items added to the group will be positioned horizontally. This means that the group will position items on the horizontal axis until it has filled up its width, and then it starts positioning items on the next "line" until that contains as many items as can be fit in the group’s width and so forth.

Here we have used fraction width and height for NSCollectionLayoutSize, which means it will fractionally set width and height according to device width and height. We can also use absolute and estimated height and width as well. Absolute will provide an exact height and width. By using estimated will be determined when the content is rendered. There is one restriction for estimated is that you must not set contentInsets for element otherwise, Layout will warn you and won’t draw respectively.

There are two possible ways to give the space to your collection view cells.

  1. Assign edge insets on the items, groups, or sections.
  2. Assign a spacing between individual items and groups.

By using edge insets you can apply like below.

item.contentInsets = NSDirectionalEdgeInsets(top: 8, leading: 8, bottom: 8, trailing: 8)

You can also provide spacing using the following code.

group.interItemSpacing = .fixed(15)

Example. List view

import UIKit
import SnapKit

struct Movie: Hashable {
    let id = UUID()
    let title: String

    func hash(into hasher: inout Hasher) {
        hasher.combine(id)
    }

    static func ==(lhs: Movie, rhs: Movie) -> Bool {
        return lhs.id == rhs.id
    }

    static func items() -> [Movie] {
        var movies: [Movie] = []
        for i in 1...7 {
            movies.append(Movie(title: "Movie \(i)"))
        }
        return movies
    }
}

class MovieCell: UICollectionViewCell {
    static let reuseIdentifier = "cellId"

    let titleLabel: UILabel = {
        let v = UILabel()
        v.font = UIFont.systemFont(ofSize: 14)
        v.textColor = .darkGray
        return v
    }()

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

    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }

    func setupView(){
        backgroundColor = .white
        addSubview(titleLabel)
        titleLabel.snp.makeConstraints { (make) in
            make.edges.equalToSuperview()
        }
    }
}

class ViewController: UIViewController, UICollectionViewDelegate {
    var items: [Movie] = []

    enum Section: CaseIterable {
        case main
    }

    lazy var dataSource: UICollectionViewDiffableDataSource<Section, Movie> = {
        let dataSource = UICollectionViewDiffableDataSource <Section, Movie>(collectionView: collectionView) {
            (collectionView: UICollectionView, indexPath: IndexPath, movie: Movie) -> UICollectionViewCell? in
            let cell = collectionView.dequeueReusableCell(withReuseIdentifier: MovieCell.reuseIdentifier, for: indexPath) as! MovieCell
            cell.titleLabel.text = movie.title
            return cell
        }
        return dataSource
    }()

    lazy var collectionView: UICollectionView = {
        let v = UICollectionView(frame: .zero, collectionViewLayout: createLayout())
        v.backgroundColor = .white
        v.register(MovieCell.self, forCellWithReuseIdentifier: MovieCell.reuseIdentifier)
        return v
    }()

    override func viewDidLoad() {
        super.viewDidLoad()

        setupView()
        loadData()
        setupTimer()
    }

    func setupView() {
        view.addSubview(collectionView)
        collectionView.snp.makeConstraints { (make) in
            make.edges.equalToSuperview()
        }
    }

    func loadData() {
        if items.isEmpty {
            items = Movie.items()
        } else {
            let number = Int.random(in: 0...100)
            let indx = Int.random(in: 0..<items.count)
            items.insert(Movie(title: "Movie \(number)"), at: indx)
        }

        var snapshot = NSDiffableDataSourceSnapshot<Section, Movie>()
        snapshot.appendSections([.main])
        snapshot.appendItems(items, toSection: .main)
        dataSource.apply(snapshot, animatingDifferences: true)
    }

    func setupTimer() {
        Timer.scheduledTimer(withTimeInterval: 3, repeats: true) { timer in
            self.loadData()
        }
    }

    private func createLayout() -> UICollectionViewLayout {
        let itemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0),
                                              heightDimension: .fractionalHeight(1.0))
        let item = NSCollectionLayoutItem(layoutSize: itemSize)
        let groupSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0),
                                               heightDimension: .absolute(44))
        let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, subitems: [item])
        let section = NSCollectionLayoutSection(group: group)
        let layout = UICollectionViewCompositionalLayout(section: section)
        return layout
    }
}

Example. Grid View

private func createLayout() -> UICollectionViewLayout {
    let itemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(0.2),
                                         heightDimension: .fractionalHeight(1.0))
    let item = NSCollectionLayoutItem(layoutSize: itemSize)
    item.contentInsets = NSDirectionalEdgeInsets(top: 5, leading: 5, bottom: 5, trailing: 5)

    let groupSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0),
                                          heightDimension: .fractionalWidth(0.2))
    let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize,
                                                     subitems: [item])

    let section = NSCollectionLayoutSection(group: group)

    let layout = UICollectionViewCompositionalLayout(section: section)
    return layout
}

Example. Two Column View

private func createLayout() -> UICollectionViewLayout {
    let itemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0),
                                          heightDimension: .fractionalHeight(1.0))
    let item = NSCollectionLayoutItem(layoutSize: itemSize)

    let groupSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0),
                                           heightDimension: .absolute(44))
    let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, subitem: item, count: 2)
    let spacing = CGFloat(10)
    group.interItemSpacing = .fixed(spacing)

    let section = NSCollectionLayoutSection(group: group)
    section.interGroupSpacing = spacing

    section.contentInsets = NSDirectionalEdgeInsets(top: 0, leading: 10, bottom: 0, trailing: 10)

    let layout = UICollectionViewCompositionalLayout(section: section)
    return layout
}

Example. Two rows

Two rows. First one with one item, second one with 3 items.

private func createLayout() -> UICollectionViewLayout {

    let inset: CGFloat = 5

    // Large item on top
    let topItemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .fractionalWidth(0.4))
    let topItem = NSCollectionLayoutItem(layoutSize: topItemSize)
    topItem.contentInsets = NSDirectionalEdgeInsets(top: inset, leading: inset, bottom: inset, trailing: inset)

    // Bottom item
    let bottomItemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(0.5), heightDimension: .fractionalHeight(1.0))
    let bottomItem = NSCollectionLayoutItem(layoutSize: bottomItemSize)
    bottomItem.contentInsets = NSDirectionalEdgeInsets(top: inset, leading: inset, bottom: inset, trailing: inset)

    // Group for bottom item, it repeats the bottom item twice
    let bottomGroupSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .fractionalWidth(0.3))
    let bottomGroup = NSCollectionLayoutGroup.horizontal(layoutSize: bottomGroupSize, subitem: bottomItem, count: 3)

    // Combine the top item and bottom group
    let fullGroupSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .fractionalWidth(0.4 + 0.3))
    let nestedGroup = NSCollectionLayoutGroup.vertical(layoutSize: fullGroupSize, subitems: [topItem, bottomGroup])

    let section = NSCollectionLayoutSection(group: nestedGroup)

    let layout = UICollectionViewCompositionalLayout(section: section)

    return layout
}