Advanced Collection View Layout in iOS 13 - Supplementary Views

Today we're going to see the advances in Collection view layout as introduced in iOS 13. This post is based on WWDC 2019 Session 215 - Advances in Collection View Layout. Many examples are derived from this presentation, but some of them have been modified or extended to provide extended learning not mentioned in the video.

iOS 13 allows two ways of creating UICollectionViewLayout and they are as follows:

  1. Manual creation
private func createLayout() -> UICollectionViewLayout {
    let item = NSCollectionLayoutItem(layoutSize: ...)

    let group = NSCollectionLayoutGroup.horizontal(layoutSize: .., subitems: [item])

    let section = NSCollectionLayoutSection(group: group)

    let layout = UICollectionViewCompositionalLayout(section: section)

    return layout
}

2. Using UICollectionViewCompositionalLayout

private func createLayout() -> UICollectionViewLayout {
        let layout = UICollectionViewCompositionalLayout { (sectionIndex: Int, layoutEnvironment: NSCollectionLayoutEnvironment) -> NSCollectionLayoutSection? in
        let item = NSCollectionLayoutItem(layoutSize: ...)

        let group = NSCollectionLayoutGroup.horizontal(layoutSize: .., subitems: [item])

       let section = NSCollectionLayoutSection(group: group)
       return section
    }
    
    return layout
}

For consistency purpose, we are going to use the latter way to create and return UICollectionViewLayout. There is no standard set which one to use and depends on the style and consistency you wish to incorporate in your code.

A. Adding supplementary views - Badges, Header, and Footers

  1. We will start by creating custom views for these supplementary views.
  2. Since we want to support multiline text for header and footer views, we are going to add appropriate constraints to enable it to grow and shrink as content changes
  3. We will add a constant and static reuseIdentifier which will allow us to register it with collection view with unique identifier
  4. Since it's a collection view supplementary view, we will subclass all these custom views from UICollectionReusableView

BadgeSupplementaryView


class BadgeSupplementaryView: UICollectionReusableView {

    let label: UILabel

    static let reuseIdentifier = "supplementary-view-reuse-identifier"

    override init(frame: CGRect) {
        label = UILabel(frame: .zero)
        super.init(frame: .zero)
        configure()
    }

    func configure() {
        label.numberOfLines = 0
        label.translatesAutoresizingMaskIntoConstraints = false
        addSubview(label)
        NSLayoutConstraint.activate([
            label.centerXAnchor.constraint(equalTo: centerXAnchor),
            label.centerYAnchor.constraint(equalTo: centerYAnchor)
            ])
        backgroundColor = .red
        label.textColor = .white
        label.textAlignment = .center
        label.font = UIFont.systemFont(ofSize: 11.0)

        layer.cornerRadius = 10.0
        clipsToBounds = true
        layer.borderWidth = 1.0
        layer.borderColor = UIColor.black.cgColor
    }
}

SupplementaryHeaderView


class SupplementaryHeaderView: UICollectionReusableView {

    static let reuseIdentifier = "supplementary-header-reusable-view"
    let label: UILabel

    enum Constants {
        static let padding: CGFloat = 20.0
    }

    override init(frame: CGRect) {
        label = UILabel()
        super.init(frame: .zero)
        backgroundColor = .red
        label.numberOfLines = 0
        addSubview(label)
        label.translatesAutoresizingMaskIntoConstraints = false

        NSLayoutConstraint.activate([
            label.leadingAnchor.constraint(equalTo: leadingAnchor, constant: Constants.padding),
            label.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -Constants.padding),
            label.topAnchor.constraint(equalTo: topAnchor, constant: Constants.padding),
            label.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -Constants.padding),
            ])
    }

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

SupplementaryFooterView

class SupplementaryFooterView: UICollectionReusableView {

    static let reuseIdentifier = "supplementary-footer-reusable-view"

    let label: UILabel

    enum Constants {
        static let padding: CGFloat = 10.0
    }

    override init(frame: CGRect) {
        label = UILabel()
        super.init(frame: .zero)
        backgroundColor = .purple
        label.numberOfLines = 0
        addSubview(label)
        label.translatesAutoresizingMaskIntoConstraints = false

        NSLayoutConstraint.activate([
            label.leadingAnchor.constraint(equalTo: leadingAnchor, constant: Constants.padding),
            label.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -Constants.padding),
            label.topAnchor.constraint(equalTo: topAnchor, constant: Constants.padding),
            label.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -Constants.padding),
            ])
    }

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

Please refer to my previous post on Diffable data source for collection views. I am making certain assumptions in next section which can be better understood by going through how diffable data sources exactly work

CollectionViewController.swift

final class CollectionViewController: UIViewController {
    ...
    ...
    ...
}

First, we will create an enum corresponding to 4 sections in our UICollectionView

enum Section: Int, CaseIterable {
    case main
    case header
    case body
    case innerBody

    func columnCount() -> Int {
        switch self {
        case .main:
            return 1
        case .header:
            return 3
        case .body:
            return 5
        case .innerBody:
            return 6
        }
    }
}

We need more than just 1 section to demonstrate the proper use of supplementary views. Please note - we also have an utility method columnCount() which we will use later to get specific number of columns in given section.

Next, we will create an enum to store the elements-kind associated with all 3 supplementary views.

enum Constants {
    static let badgeElementKind = "badge-element-kind"
    static let headerElementKind = "header-element-kind"
    static let footerElementKind = "footer-element-kind"
}

Next, we will define dataSource on the file level which will be used for collection view data and supplementary items

@IBOutlet weak var collectionView: UICollectionView!
var dataSource: UICollectionViewDiffableDataSource<Section, Int>!

We will also create an additional array structures to hold data in each section,

var sequence: [Int] = []
var sequenceHeader: [Int] = []
var sequenceBody: [Int] = []
var sequenceInnerBody: [Int] = []

Now, once all the variables have been declared, we will start writing additional code in our viewDidLoad method

override func viewDidLoad() {
    super.viewDidLoad()
    ....
    .....
}

We need to register all the supplementary views to collection view with following information

  1. viewClass
  2. supplementaryViewKind
  3. reuseIdentifier
Please note we are not registering regular collection view cell here. We will assume that is done elsewhere in the code or directly in the storyboard
collectionView.register(BadgeSupplementaryView.self, forSupplementaryViewOfKind: Constants.badgeElementKind, withReuseIdentifier: BadgeSupplementaryView.reuseIdentifier)

collectionView.register(SupplementaryHeaderView.self, forSupplementaryViewOfKind: Constants.headerElementKind, withReuseIdentifier: SupplementaryHeaderView.reuseIdentifier)

collectionView.register(SupplementaryFooterView.self, forSupplementaryViewOfKind: Constants.footerElementKind, withReuseIdentifier: SupplementaryFooterView.reuseIdentifier)

Please note SupplementaryViewKinds and ReuseIdentifiers have already been defined elsewhere.

Now we will populate our arrays with some sequential numbers to be used to display on collection view,

for i in 1...10 {
    sequence.append(i)
}

for i in 11...30 {
    sequenceHeader.append(i)
}

for i in 31...50 {
    sequenceBody.append(i)
}

for i in 51...75 {
    sequenceInnerBody.append(i)
}

Now we will set up our regular and supplementary data source to be able to populate views with data received from sequence arrays,

dataSource = UICollectionViewDiffableDataSource
    <Section, Int>(collectionView: collectionView) {
        (collectionView: UICollectionView, indexPath: IndexPath,
        number: Int) -> UICollectionViewCell? in
        guard let cell = collectionView.dequeueReusableCell(
            withReuseIdentifier: "cell", for: indexPath) as? MyCellCollectionViewCell else {
                fatalError("Cannot create new cell") }
        cell.headerLabel.text = nil
        cell.descriptionLabel.text = "\(number)"
        return cell
}
More on above code in this post

And set up the supplementaryViewProvider associated with dataSource

dataSource.supplementaryViewProvider = { [weak self] (collectionView: UICollectionView, kind: String, indexPath: IndexPath) -> UICollectionReusableView? in

if kind == Constants.headerElementKind {
    if let headerView = collectionView.dequeueReusableSupplementaryView(ofKind: kind, withReuseIdentifier: SupplementaryHeaderView.reuseIdentifier, for: indexPath) as? SupplementaryHeaderView {
        headerView.label.text = "Header\nHeader\nHeader\nHeader"
        return headerView
    }
}

if kind == Constants.footerElementKind {
    if let footerView = collectionView.dequeueReusableSupplementaryView(ofKind: kind, withReuseIdentifier: SupplementaryFooterView.reuseIdentifier, for: indexPath) as? SupplementaryFooterView {
        footerView.label.text = "Footer\nFooter\nFooter\nFooter"
        return footerView
    }
}

guard let strongSelf = self, let sequence = strongSelf.dataSource.itemIdentifier(for: indexPath) else { return nil }
if let badgeView = collectionView.dequeueReusableSupplementaryView(ofKind: kind, withReuseIdentifier: BadgeSupplementaryView.reuseIdentifier, for: indexPath) as? BadgeSupplementaryView {

    let badgeCount = sequence
    badgeView.label.text = "\(badgeCount)"
    badgeView.isHidden = false
    return badgeView
}

fatalError("Failed to get expected supplementary reusable view from collection view. Stopping the program execution")
}

Additional details and comments concerning above code,

  1. No matter whether badge, header, or footer - We are always returning the type which is subclass of UICollectionReusableView
  2. In order to dequeue desired ReusableView, we first check the kind of supplementary view as passed by the closure. Based on this value we dequeue appropriate type of UICollectionReusableView passing,

  A. viewClass

      B. supplementaryViewKind

        C. reuseIdentifier

3. Since header and footer views are assigned to each section, we assign a random string of long text to each of them

4. Since there is 1:1 mapping between each collection view cell and badge view, we use passed indexPath in order to retrieve the sequence to associate with that badge,

guard let sequence = self.dataSource.itemIdentifier(for: indexPath) else { return nil }

Once we have concerned sequence, dequeue the BadgeSupplementaryView from collection view and assign that badge count to the label

if let badgeView = collectionView.dequeueReusableSupplementaryView(ofKind: kind, withReuseIdentifier: BadgeSupplementaryView.reuseIdentifier, for: indexPath) as? BadgeSupplementaryView {

    let badgeCount = sequence
    badgeView.label.text = "\(badgeCount)"
    badgeView.isHidden = false
    return badgeView
}

Since we are using only 3 kinds of supplementary views, if our flow cannot find any of them and moves past them we will abort the program execution with fatalError

fatalError("Invalid state reached. Stopping the program execution")

Once all the data has been taken care of, we will move on to creating a layout

private func createLayout() -> UICollectionViewLayout {
    let layout = UICollectionViewCompositionalLayout { (sectionIndex: Int, layoutEnvironment: NSCollectionLayoutEnvironment) -> NSCollectionLayoutSection? in
        
        // 1-2
        guard let sectionKind = Section(rawValue: sectionIndex) else { return nil }
        let columns = sectionKind.columnCount()

        // 3-5
        let badgeAnchor = NSCollectionLayoutAnchor(edges: [.top, .trailing], fractionalOffset: CGPoint(x: 0.3, y: -0.3))
        let badgeSize = NSCollectionLayoutSize(widthDimension: .absolute(20.0), heightDimension: .absolute(20.0))
        let badge = NSCollectionLayoutSupplementaryItem(layoutSize: badgeSize, elementKind: Constants.badgeElementKind, containerAnchor: badgeAnchor)

        // 6-8
        let itemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .fractionalHeight(1.0))
        let item = NSCollectionLayoutItem(layoutSize: itemSize, supplementaryItems: [badge])
        item.contentInsets = NSDirectionalEdgeInsets(top: 2.0, leading: 2.0, bottom: 2.0, trailing: 2.0)

        // 9-11
        let groupHeight: NSCollectionLayoutDimension = columns == 1 ? .absolute(74) : .fractionalWidth(0.2)
        let groupSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: groupHeight)
        let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, subitem: item, count: columns)

        // 12
        let headerFooterSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .estimated(44.0))
        
        // 13
        let header = NSCollectionLayoutBoundarySupplementaryItem(layoutSize: headerFooterSize, elementKind: Constants.headerElementKind, alignment: .top)
        header.pinToVisibleBounds = true

        // 14
        let footer = NSCollectionLayoutBoundarySupplementaryItem(layoutSize: headerFooterSize, elementKind: Constants.footerElementKind, alignment: .bottom)

        // 15-18
        let section = NSCollectionLayoutSection(group: group)
        section.boundarySupplementaryItems = [header, footer]
        section.contentInsets = NSDirectionalEdgeInsets(top: 10.0, leading: 10.0, bottom: 10.0, trailing: 10.0)
        return section
    }

    return layout
}

Here's the summary of what we're doing in above snippet,

  1. Lines 1-2 - Get the struct of type Section from the passed sectionIndex. Type Section will let us call the columnCount method which will return the number of columns in each section
  2. Lines 3-5 - Here we create a badge element by passing badgeAnchor, elementKind, and badgeSize
  3. Lines 6-8 - We create individual item by passing itemSize and corresponding badge to attach to that element on collectionView
  4. Lines 9-11 - Horizontal group is created by passing item and specifying how many items we want to show in each group. The number of items in each group is decided by the section number
  5. Line 12 - We create a generic headerFooterSize. Note that it is configured to stretch the full width and height is set to  .estimated(44.0) so that it can be stretched as content grows
  6. Line 13 - We create the header of type NSCollectionLayoutBoundarySupplementaryItem  by specifying alignment to top and setting the property pinToVisibleBounds to true so that it sticks to the top during scrolling
  7. Line 14 - Footer of type NSCollectionLayoutBoundarySupplementaryItem is created by specifying bottom alignment
  8. Lines 15-18 - A Section is created by passing the previously created Group object. For each section, header and footer are added by specifying them in boundarySupplementaryItems property associated with each section. We also specify contentInsets to leave extra space around sections

Once our layout function is ready we set this layout to the collection view by adding following line at the end of viewDidLoad method

collectionView.collectionViewLayout = createLayout()

But this is all useless if we don't have data. Yes, we haven't yet populated and reloaded the collection view with solid data. This is what we're going to do next. We will create a new function loadData which will create individual sections, append items and apply those changes collection view through our dataSource

loadData method will be called from viewDidLayoutSubviews

override func viewDidLayoutSubviews() {
    super.viewDidLayoutSubviews()
    loadData()
}

func loadData() {
    let snapshot = NSDiffableDataSourceSnapshot<Section, Int>()
    snapshot.appendSections([.main, .header, .body, .innerBody])
    snapshot.appendItems(sequence, toSection: .main)
    snapshot.appendItems(sequenceHeader, toSection: .header)
    snapshot.appendItems(sequenceBody, toSection: .body)
    snapshot.appendItems(sequenceInnerBody, toSection: .innerBody)
    dataSource.apply(snapshot, animatingDifferences: true)
}

However, there is still one small thing we need to fix. If you run the code as is, you will see that all badge items are overlapping with header giving appearance line this,

This overlapping happens because the badge has zIndex of 1 which causes it to overlap with header (Which has zIndex of 0). Badge having zIndex of 1 makes sense since we want it to appear on the top of single item, but it's also the source of bug when it appears on the top of the header view. Solution is simple - All you have to do is to increase the zIndex of header to 2 so that the badge with zIndex of 1 will appear behind it.

header.zIndex = 2

This is how the view looks like after this fix,

And here is the video of full demo application,

Hope this blog post was useful to get yourself acquainted with new collection view APIs in iOS 13. The full source code is available in Github repository on collection-view-supplementary-view-blog-post branch. Feel free to download, fork and play with it. Feel free to make a pull request if you think there is a bug or scope for an improvement.

Thanks for reading, and as usual feel free to contact me if you have any further questions or comments on this article.

References: