The power of Compositional Layout

Photo by Raagesh C / Unsplash

With iOS 13 Apple introduced a new and very powerful way of creating layouts for UICollectionView - UICollectionViewCompositionalLayout.

The compositional layout uses three types of "building blocks":
- item - describes properties of the actual cell that will be displayed
- group - group can have one or more subitems - groups or items. This is the most powerful thing about the compositional layout! No more calculating which element should be bigger, smaller, full width, or half-width. You describe all of this info in the group!
- section - As the name suggests, this describes the section. The section has one group inside and can have supplementary items, like footers and headers.

Before we dive in - let's prepare the easy model for the collection view to display. We will use an array of random colors:

extension UIColor {
    static var random: UIColor {
        return UIColor(
            red: .random(in: 0...1),
            green: .random(in: 0...1),
            blue: .random(in: 0...1),
            alpha: 1.0
        )
    }
}

final class ColorRepository {
    lazy var colors: [UIColor] = {
        (0...100).map { _ in
            UIColor.random
        }
    }()
}

Now, the layout. For the first iteration I want to have a simple grid of cells, as squares, the full width of the screen:

enum GridCompositionalLayout {
     static func generateLayout() -> UICollectionViewCompositionalLayout {

         let config = UICollectionViewCompositionalLayoutConfiguration()

         return UICollectionViewCompositionalLayout(
             section: makeSection(),
             configuration: config
         )
     }

     private static func makeItem() -> NSCollectionLayoutItem {
         let item = NSCollectionLayoutItem(
             layoutSize: .init(
                 widthDimension: .fractionalWidth(1),
                 heightDimension: .fractionalWidth(1)
             )
         )
         item.contentInsets = .init(top: 0, leading: 4, bottom: 4, trailing: 4)

         return item
     }

     private static func makeGroup() -> NSCollectionLayoutGroup {
         return NSCollectionLayoutGroup.horizontal(
             layoutSize: .init(
                 widthDimension: .fractionalWidth(1),
                 heightDimension: .fractionalWidth(1)
             ),
             subitems: [makeItem()]
         )
     }

     private static func makeSection() -> NSCollectionLayoutSection {
         let section = NSCollectionLayoutSection(group: makeGroup())
         section.contentInsets = .init(
             top: 16,
             leading: 0,
             bottom: 0, trailing: 0
         )
         return section
     }

 }

What is happening:

private static func makeItem() - describes item size and its padding. fractionalWidth(1) means, that item should occupy the whole width of the group. I'm using the width as the height dimension, so elements are squared. Neat trick, isn't it?

private static func makeGroup() -> NSCollectionLayoutGroup - group definition is almost the same as items - the group has one square element.

private static func makeSection() - Section doesn't have any decorators or supplementary items, just our one group.

static func generateLayout() - generates our simple layout, it has one section.

The grid layout is ready! We can implement the view controller:

class ViewController: UIViewController {
    
    private lazy var collectionView = UICollectionView(
        frame: .zero,
        collectionViewLayout: GridCompositionalLayout.generateLayout()
    )
    
    private lazy var model = ColorRepository()

    override func viewDidLoad() {
        super.viewDidLoad()
        prepareCollectionView()
    }
    
    private func prepareCollectionView() {
        collectionView.dataSource = self
        collectionView.translatesAutoresizingMaskIntoConstraints = false
        
        collectionView.register(UICollectionViewCell.self, forCellWithReuseIdentifier: "cell")
        
        view.addSubview(collectionView)
        
        NSLayoutConstraint.activate(
            [
                collectionView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor),
                collectionView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
                collectionView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
                collectionView.trailingAnchor.constraint(equalTo: view.trailingAnchor)
            ]
        )
    }
}

extension ViewController: UICollectionViewDataSource {
    func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
        return model.colors.count
    }
    
    func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
        let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "cell", for: indexPath)
        cell.backgroundColor = model.colors[indexPath.row]
        return cell
    }
}

Nothing fancy here, simplest implementation for view controller with a collection view. And the result:

Ok, not bad, we have squares! I'm not pleased with the result. I want to have a grid with two elements in a row. To make that happen we just have to change two values in our layout:

private static func makeItem() -> NSCollectionLayoutItem {
        let item = NSCollectionLayoutItem(
            layoutSize: .init(
                widthDimension: .fractionalWidth(0.5),
                heightDimension: .fractionalWidth(0.5)
            )
        )
        item.contentInsets = .init(top: 0, leading: 2, bottom: 0, trailing: 2)
        
        return item
    }
    
    private static func makeGroup() -> NSCollectionLayoutGroup {
        let group =  NSCollectionLayoutGroup.horizontal(
            layoutSize: .init(
                widthDimension: .fractionalWidth(1),
                heightDimension: .fractionalWidth(0.5)
            ),
            subitems: [makeItem()]
        )
            
        group.contentInsets = .init(top: 0, leading: 2, bottom: 4, trailing: 2)
            return group
    }

As you can see, the item definition now uses.fractionalWidth(0.5) so each item will occupy just half of the width of the group. Group now has heightDimension: .fractionalWidth(0.5) so it gives less height for its items, half of the width of the screen. With these simple changes, we have a new layout:

Yay! three lines of code changed and we have a nice, even, perfect grid layout! I don't want to stop here. I want to have two squares on top followed by one bigger square - 2 + 1 grid layout. To do this we will change our main group to accommodate subgroup and item:

enum GridCompositionalLayout {
    static func generateLayout() -> UICollectionViewCompositionalLayout {
        
        let config = UICollectionViewCompositionalLayoutConfiguration()
        
        return UICollectionViewCompositionalLayout(
            section: makeSection(),
            configuration: config
        )
    }
    
    private static func makeItem() -> NSCollectionLayoutItem {
        let item = NSCollectionLayoutItem(
            layoutSize: .init(
                widthDimension: .fractionalWidth(0.5),
                heightDimension: .fractionalWidth(0.5)
            )
        )
        item.contentInsets = .init(top: 0, leading: 2, bottom: 2, trailing: 2)
        
        return item
    }
    
    private static func makeBigItem() -> NSCollectionLayoutItem {
        let item = NSCollectionLayoutItem(
            layoutSize: .init(
                widthDimension: .fractionalWidth(1),
                heightDimension: .fractionalWidth(1)
            )
        )
        item.contentInsets = .init(top: 0, leading: 2, bottom: 0, trailing: 2)
        
        return item
    }
    
    private static func makeGroup() -> NSCollectionLayoutGroup {
        let group =  NSCollectionLayoutGroup.horizontal(
            layoutSize: .init(
                widthDimension: .fractionalWidth(1),
                heightDimension: .fractionalWidth(0.5)
            ),
            subitems: [makeItem()]
        )
        
        group.contentInsets = .init(top: 0, leading: 2, bottom: 4, trailing: 2)
        
        let groupWithBigElement = NSCollectionLayoutGroup.horizontal(
            layoutSize: .init(
                widthDimension: .fractionalWidth(1),
                heightDimension: .fractionalWidth(1)
            ),
            subitems: [makeBigItem()]
        )
        
        groupWithBigElement.contentInsets = .init(top: 0, leading: 2, bottom: 4, trailing: 2)
        
        
        let compositionalGroup = NSCollectionLayoutGroup.vertical(
            layoutSize: .init(
                widthDimension: .fractionalWidth(1),
                heightDimension: .fractionalWidth(1.5)
            ),
            subitems: [group, groupWithBigElement]
        )
        
        return compositionalGroup
    }
    
    private static func makeSection() -> NSCollectionLayoutSection {
        let section = NSCollectionLayoutSection(group: makeGroup())
        section.contentInsets = .init(
            top: 16,
            leading: 0,
            bottom: 0, trailing: 0
        )
        return section
    }
}

The biggest change is in private static func makeGroup() -> NSCollectionLayoutGroup -  We compose two groups into one vertical group to make our unique layout:

That simple change makes a nice 2+1 layout similar to the Photos app. No extra changes are needed.

The power of compositional layout doesn't end here. We can add supplementary views like headers and footers. What is cool about it, we can add more than one header to one section!

To demonstrate that, we will add a collection header and two section headers - one with a title and one just as a spacer.

We have to modify the layout a bit:

import UIKit

enum SupplementaryElements {
    static let collectionHeader = "collection-header"
    static let sectionHeader = "section-header"
    static let sectionSpacer = "sectionSpacer"
}

enum GridCompositionalLayout {
    static func generateLayout() -> UICollectionViewCompositionalLayout {
        
        let config = UICollectionViewCompositionalLayoutConfiguration()
        config.boundarySupplementaryItems = [makeCollectionHeader()]
        
        return UICollectionViewCompositionalLayout(
            section: makeSection(),
            configuration: config
        )
    }
    
    private static func makeItem() -> NSCollectionLayoutItem {
        let item = NSCollectionLayoutItem(
            layoutSize: .init(
                widthDimension: .fractionalWidth(0.5),
                heightDimension: .fractionalWidth(0.5)
            )
        )
        item.contentInsets = .init(top: 0, leading: 2, bottom: 2, trailing: 2)
        
        return item
    }
    
    private static func makeBigItem() -> NSCollectionLayoutItem {
        let item = NSCollectionLayoutItem(
            layoutSize: .init(
                widthDimension: .fractionalWidth(1),
                heightDimension: .fractionalWidth(1)
            )
        )
        item.contentInsets = .init(top: 0, leading: 2, bottom: 0, trailing: 2)
        
        return item
    }
    
    private static func makeGroup() -> NSCollectionLayoutGroup {
        let group =  NSCollectionLayoutGroup.horizontal(
            layoutSize: .init(
                widthDimension: .fractionalWidth(1),
                heightDimension: .fractionalWidth(0.5)
            ),
            subitems: [makeItem()]
        )
        
        group.contentInsets = .init(top: 0, leading: 2, bottom: 4, trailing: 2)
        
        let groupWithBigElement = NSCollectionLayoutGroup.horizontal(
            layoutSize: .init(
                widthDimension: .fractionalWidth(1),
                heightDimension: .fractionalWidth(1)
            ),
            subitems: [makeBigItem()]
        )
        
        groupWithBigElement.contentInsets = .init(top: 0, leading: 2, bottom: 4, trailing: 2)
        
        
        let compositionalGroup = NSCollectionLayoutGroup.vertical(
            layoutSize: .init(
                widthDimension: .fractionalWidth(1),
                heightDimension: .fractionalWidth(1.5)
            ),
            subitems: [group, groupWithBigElement]
        )
        
        return compositionalGroup
    }
    
    private static func makeSectionHeader() -> NSCollectionLayoutBoundarySupplementaryItem {
        return NSCollectionLayoutBoundarySupplementaryItem(
            layoutSize: .init(
                widthDimension: .fractionalWidth(1),
                heightDimension: .absolute(32)
            ),
            elementKind: SupplementaryElements.sectionHeader,
            alignment: .top
        )
    }
    
    private static func makeCollectionHeader() -> NSCollectionLayoutBoundarySupplementaryItem {
        return NSCollectionLayoutBoundarySupplementaryItem(
            layoutSize: .init(
                widthDimension: .fractionalWidth(1),
                heightDimension: .absolute(32)
            ),
            elementKind: SupplementaryElements.collectionHeader,
            alignment: .top
        )
    }
    
    private static func makeSpacer() -> NSCollectionLayoutBoundarySupplementaryItem {
        return NSCollectionLayoutBoundarySupplementaryItem(
            layoutSize: .init(
                widthDimension: .fractionalWidth(1),
                heightDimension: .absolute(60)
            ),
            elementKind: SupplementaryElements.sectionSpacer,
            alignment: .top
        )
    }
    
    private static func makeSection() -> NSCollectionLayoutSection {
        let section = NSCollectionLayoutSection(group: makeGroup())
        section.contentInsets = .init(
            top: 16,
            leading: 0,
            bottom: 0, trailing: 0
        )
        section.boundarySupplementaryItems = [makeSpacer(), makeSectionHeader()]
        
        return section
    }
    
}

We have added private static func makeCollectionHeader(), private static func makeSectionHeader(), private static func makeSpacer(). Supplementary items have nearly identical initializers as items, we specify dimensions and identifiers. Of course, we need to implement these views:

import UIKit

final class HeaderView: UICollectionReusableView {
    private(set) lazy var label = UILabel()
    
    required init?(coder: NSCoder) {
        super.init(coder: coder)
    }
    
    override init(frame: CGRect) {
        super.init(frame: frame)
        label.translatesAutoresizingMaskIntoConstraints = false
        addSubview(label)
        
        NSLayoutConstraint.activate(
            [
                label.centerXAnchor.constraint(equalTo: self.centerXAnchor),
                label.centerYAnchor.constraint(equalTo: self.centerYAnchor)
            ]
        )
    }
}


final class Spacer: UICollectionReusableView {
    required init?(coder: NSCoder) {
        super.init(coder: coder)
    }
    
    override init(frame: CGRect) {
        super.init(frame: frame)
        backgroundColor = .clear
    }
}

Also, our view controller needs to register new views, and the data source has to provide them:

private func prepareCollectionView() {
        collectionView.dataSource = self
        collectionView.translatesAutoresizingMaskIntoConstraints = false
        
        collectionView.register(UICollectionViewCell.self, forCellWithReuseIdentifier: "cell")
        
        collectionView.register(
            HeaderView.self,
            forSupplementaryViewOfKind: SupplementaryElements.collectionHeader,
            withReuseIdentifier: SupplementaryElements.collectionHeader
        )
        
        collectionView.register(
            HeaderView.self,
            forSupplementaryViewOfKind: SupplementaryElements.sectionHeader,
            withReuseIdentifier: SupplementaryElements.sectionHeader
        )

        collectionView.register(
            Spacer.self,
            forSupplementaryViewOfKind: SupplementaryElements.sectionSpacer,
            withReuseIdentifier: SupplementaryElements.sectionSpacer
        )

        
        view.addSubview(collectionView)
        
        NSLayoutConstraint.activate(
            [
                collectionView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor),
                collectionView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
                collectionView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
                collectionView.trailingAnchor.constraint(equalTo: view.trailingAnchor)
            ]
        )
    }
func collectionView(_ collectionView: UICollectionView, viewForSupplementaryElementOfKind kind: String, at indexPath: IndexPath) -> UICollectionReusableView {
        let cell = collectionView.dequeueReusableSupplementaryView(
            ofKind: kind,
            withReuseIdentifier: kind,
            for: indexPath
        )
        
        if let header = cell as? HeaderView {
            header.label.text = kind
        }
        
        return cell
    }

And the final effect:

Conclusions

Collection view compositional layout should be with us from the beginning. It provides a declarative way for creating rich and complicated layouts for your app. No more complicated size calculation, fighting with insets, header sizes.

Complete code with step-by-step commits is available here.

Artur Gruchała

Artur Gruchała

I started learning iOS development when Swift was introduced. Since then I've tried Xamarin, Flutter, and React Native. Nothing is better than native code:)
Poland