Simplify collection view compositional layout with a DSL

At the WWDC19 Apple introduced a class UICollectionViewCompositionalLayout. This new API allows us to create a custom layout in a declarative and composable way. It is available from iOS 13, tvOS 13 and macOS 10.15.

Here are some examples of compositional layout from Apple sample code Implementing Modern Collection Views:

Layout with nested groups with orthogonal scrolling
Layout with nested groups
List layout
List layout
Orthogonal scrolling example
Orthogonal scrolling

A compositional layout is composed of sections containing groups, and the groups contain items. There are also supplementary items and boundary supplementary items for headers, footers or any custom supplementary view like badges. One of the best feature of compositonal layouts is the ability to have multi-dimensional browsing with the orthogonal scrolling behavior (see gif above).

Here is for example the code for the layout with nested groups and orthogonal scrolling:

let leadingItem = NSCollectionLayoutItem(
    layoutSize: NSCollectionLayoutSize(widthDimension: .fractionalWidth(0.7),
                                       heightDimension: .fractionalHeight(1.0)))
leadingItem.contentInsets = NSDirectionalEdgeInsets(top: 10, leading: 10, bottom: 10, trailing: 10)

let trailingItem = NSCollectionLayoutItem(
    layoutSize: NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0),
                                       heightDimension: .fractionalHeight(0.3)))
trailingItem.contentInsets = NSDirectionalEdgeInsets(top: 10, leading: 10, bottom: 10, trailing: 10)
let trailingGroup = NSCollectionLayoutGroup.vertical(
    layoutSize: NSCollectionLayoutSize(widthDimension: .fractionalWidth(0.3),
                                       heightDimension: .fractionalHeight(1.0)),
    subitem: trailingItem, count: 2)

let containerGroup = NSCollectionLayoutGroup.horizontal(
    layoutSize: NSCollectionLayoutSize(widthDimension: .fractionalWidth(0.85),
                                       heightDimension: .fractionalHeight(0.4)),
    subitems: [leadingItem, trailingGroup])
let section = NSCollectionLayoutSection(group: containerGroup)
section.orthogonalScrollingBehavior = .continuous
let layout = UICollectionViewCompositionalLayout(section: section)

This code works well, but it has some issues:

Let’s see in the following section how we can tackle these problems.

Domain-specific language for UICollectionViewCompositionalLayout

With the Swift 5.1 @_functionBuilder (now @resultBuilder in Swift 5.4) and SwiftUI being popular, a domain-specific language (DSL) can be a great way to improve the UICollectionViewCompositionalLayout API.

By looking at the SwiftUI DSL created by Apple, we can model a similar syntax to rewrite the previous example into something like that:

let layout = LayoutBuilder {
    Section {
        HGroup {
            Item()
                .width(.fractionalWidth(0.7))
                .contentInsets(value: 10)
            VGroup(count: 2) {
                Item().contentInsets(value: 10)
            }
            .width(.fractionalWidth(0.3))
        }
        .height(.fractionalHeight(0.4))
        .width(.fractionalWidth(0.85))
    }
    .orthogonalScrollingBehavior(.continuous)
}

This code has several advantages compared to the UIKit API:


Here is another example, where we focus on a simple list layout:

Custom list layout with pinned headers
Custom list layout with pinned headers

With UIKit, we would implement this layout like this:

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)
section.interGroupSpacing = 5
section.contentInsets = NSDirectionalEdgeInsets(top: 0, leading: 10, bottom: 0, trailing: 10)

let sectionHeader = NSCollectionLayoutBoundarySupplementaryItem(
    layoutSize: NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0),
                                       heightDimension: .estimated(44)),
    elementKind: PinnedSectionHeaderFooterViewController.sectionHeaderElementKind,
    alignment: .top)
let sectionFooter = NSCollectionLayoutBoundarySupplementaryItem(
    layoutSize: NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0),
                                       heightDimension: .estimated(44)),
    elementKind: PinnedSectionHeaderFooterViewController.sectionFooterElementKind,
    alignment: .bottom)
sectionHeader.pinToVisibleBounds = true
sectionHeader.zIndex = 2
section.boundarySupplementaryItems = [sectionHeader, sectionFooter]
let layout = UICollectionViewCompositionalLayout(section: section)

The same layout with the DSL becomes:

let layout = LayoutBuilder {
    Section {
        HGroup {
            Item()
        }
        .height(.absolute(44))
    }
    .boundarySupplementaryItems {
        BoundarySupplementaryItem(
            elementKind: PinnedSectionHeaderFooterViewController.sectionHeaderElementKind
        )
        .height(.estimated(44))
        .alignment(.top)
        .pinToVisibleBounds(true)
        .zIndex(zIndex: 2)
        BoundarySupplementaryItem(
            elementKind: PinnedSectionHeaderFooterViewController.sectionFooterElementKind
        )
        .height(.estimated(44))
        .alignment(.bottom)
    }
}

The CompositionalLayoutDSL library

If you liked the proposed DSL syntax, here’s great news:
The previous examples of code using the DSL can actually be used right now with our new library CompositionalLayoutDSL!

This library gives you the DSL, and it also gives you the ability to compose your layout in a SwiftUI manner, making it all the easier to reuse custom layouts, as well as improving readability.

Let’s take the following layout as an example:

Custom compositional layout
Custom compositional layout

You can create your own structs to represent custom groups, making them reusable in your project:

struct TiledGroup: LayoutGroup {
    let width: CGFloat
    let inset: CGFloat

    var layoutGroup: LayoutGroup {
        VGroup {
            bigCellTrailing
            threeSmallCells
            bigCellLeading
            threeSmallCells
        }
        .height(.absolute(totalHeight))
        .interItemSpacing(.fixed(inset))
    }

    // Computed values
    private var smallCellSize: CGFloat {
        return (width - inset * 2) / 3
    }

    private var bigCellSize: CGFloat {
        return smallCellSize * 2 + inset
    }

    private var totalHeight: CGFloat {
        return (bigCellSize + inset + smallCellSize) * 2 + inset
    }

    // Groups
    private var bigCellTrailing: LayoutGroup {
        HGroup {
            VGroup(count: 2) { Item() }
                .width(.absolute(smallCellSize))
                .interItemSpacing(.fixed(inset))
            Item()
                .width(.absolute(bigCellSize))
        }
        .height(.absolute(bigCellSize))
        .interItemSpacing(.fixed(inset))
    }

    private var bigCellLeading: LayoutGroup {
        HGroup {
            Item()
                .width(.absolute(bigCellSize))
            VGroup(count: 2) { Item() }
                .width(.absolute(smallCellSize))
                .interItemSpacing(.fixed(inset))
        }
        .height(.absolute(bigCellSize))
        .interItemSpacing(.fixed(inset))
    }

    private var threeSmallCells: LayoutGroup {
        HGroup(count: 3) { Item() }
            .height(.absolute(smallCellSize))
            .interItemSpacing(.fixed(inset))
    }
}

Creating your own structs to represent your objects is possible for all components of a compositional layout. Here is an example that conforms to LayoutSection:

struct TiledSection: LayoutSection {
    private let inset: CGFloat = 8

    let environment: NSCollectionLayoutEnvironment

    var layoutSection: LayoutSection {
        Section {
            TiledGroup(
            	width: environment.container.effectiveContentSize.width,
            	inset: inset
            )
        }
        .interGroupSpacing(inset)
    }
}

And when you want to use the custom section you configure your collection view like that:

// To apply the layout to the collection view
let compositionalLayout = CompositionalLayout { section, environment in
    TiledSection(environment: environment)
}
.interSectionSpacing(8)
collectionView.setCollectionViewLayout(compositionalLayout, animated: false)

All the other compositional layouts availables in the Apple sample code have also been adapted to use the DSL, you can find the changes in this repository.
If you want to see more of this library in action you can have a look at the example app.

Conclusion

Compositional layout has been introduced by Apple in 2019 and gives you the power of creating great layouts easily. While declarative, the UIKit API is quite verbose and therefore hard to read and visualize.
To solve those issues we have created a new library CompositionalLayoutDSL which provides a DSL that makes compositional layout code shorter, more readable and reusable.

CompositionalLayoutDSL requires at least Swift 5.1, and is available on the platforms supporting the compositional layout API (iOS 13+, tvOS 13+ and macOS 10.15+). You can use this library in your project using the Swift Package Manager, CocoaPods or Carthage.