How to show page indicator with Compositional Layout

Short guide how to do it with UIPageControl and Combine. Full source code available.

Published: Jan. 26, 2022
I want a Press Kit

When you have horizontal sections where items take the entire width of the Collection View, it makes sense to give the user some indication that more items are available. That's a great use case for UIPageControl aka the "dotted page indicator."

In this post, we'll implement this with Compositional Layout and Diffable Data Source.

There are a few distinct parts to this. You need to show the (usually) footer element with UIPageControl in your collection view, monitor user swipes/scrolling through the horizontal items, and update the page control.

tl;dr version

If you already have paging control prepared and have extensive experience with Compositional Layout, here is the most important part. When defining your NSCollectionLayoutSection, configure this handler:

section.visibleItemsInvalidationHandler = { [weak self] (items, offset, env) -> Void in
    guard let self = self else { return }
    let page = round(offset.x / self.view.bounds.width)
    // update your paging indicator with the `page` value
}

Here is the result:

UIPageIndicator with Compositional Layout example

Preparing paging control

This example will use PagingSectionFooterView to contain the UIPageControl. The entire source for this class is available at the end of the article, along with a link to an example project.

We need to register it with the collection view:

collectionView.register(PagingSectionFooterView.self, forSupplementaryViewOfKind: UICollectionView.elementKindSectionFooter, withReuseIdentifier: PagingSectionFooterView.reuseIdentifier)

I am using an extension to get the reuseIdentifier property for my reusable views and cells. You can find it in older post.

We will also need a piece of data to send with Combine. For that, we can use a simple struct:

struct PagingInfo: Equatable, Hashable {
    let sectionIndex: Int
    let currentPage: Int
}

Since my example has two sections with paging indicators, I need to know which page control to update - so I need to keep track of the sectionIndex.

With this, I have also defined this Combine subject:

private let pagingInfoSubject = PassthroughSubject<PagingInfo, Never>()

pagingInfoSubject lets us subscribe to it from the instances of PagingSectionFooterView. The Never here means that the subject never fails and always delivers the data.

Adding paging view to Compositional Layout

Next, we use NSCollectionLayoutBoundarySupplementaryItem to register our footer view with sections for which we want to show paging indicators.

let footerSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .absolute(20))

let pagingFooterElement = NSCollectionLayoutBoundarySupplementaryItem(layoutSize: footerSize, elementKind: UICollectionView.elementKindSectionFooter, alignment: .bottom)
section.boundarySupplementaryItems += [pagingFooterElement]

section.visibleItemsInvalidationHandler = { [weak self] (items, offset, env) -> Void in
    guard let self = self else { return }

    let page = round(offset.x / self.view.bounds.width)

    self.pagingInfoSubject.send(PagingInfo(sectionIndex: sectionIndex, currentPage: Int(page)))
}

Most of this is standard Compositional Layout code. What is new and essential is the visibleItemsInvalidationHandler. We can use this to monitor users' scrolling through the section and perhaps animate items coming into view.

We are interested in the offset, which allows us to calculate the current page for this section.

We then use our pagingInfoSubject to send this data forward.

The sectionIndex comes from the outside scope. We need to know the position of the section to be able to update the correct UIPageControl. If you use the approach defining your sections as enum, it would be cleaner to send the enum.

Dequeuing paging view

As the last step, we need to modify or implement the supplementaryViewProvider on our Diffable Data Source to return the PagingSectionFooterView.

Here is the relevant part of this method:

let pagingFooter = collectionView.dequeueReusableSupplementaryView(ofKind: UICollectionView.elementKindSectionFooter, withReuseIdentifier: PagingSectionFooterView.reuseIdentifier, for: indexPath) as! PagingSectionFooterView

let itemCount = self.datasource.snapshot().numberOfItems(inSection: indexPath.section)
pagingFooter.configure(with: itemCount)

pagingFooter.subscribeTo(subject: pagingInfoSubject, for: indexPath.section)

return pagingFooter

We get the number of items from the data source, configure the UIPageControl with it and subscribe this view to our pagingInfoSubject.

Once all this is wired up, paging indicators will come to life.

Subscribe method in detail

Before wrapping up, let's quickly look at the subscribeTo(subject: implementation.

private var pagingInfoToken: AnyCancellable?

func subscribeTo(subject: PassthroughSubject<PagingInfo, Never>, for section: Int) {
    pagingInfoToken = subject
        .filter { $0.sectionIndex == section }
        .receive(on: DispatchQueue.main)
        .sink { [weak self] pagingInfo in
            self?.pageControl.currentPage = pagingInfo.currentPage
        }
}

First, we use the filter to get only PagingInfo for the correct section, switch to DispatchQueue.main since we touch the UI, and finally update the UIPageControl.

PagingSectionFooterView source

The full example is available in my GitHub project implemented in the ViewController.swift and below is source code for the PagingSectionFooterView.

import UIKit
import Combine

class PagingSectionFooterView: UICollectionReusableView {
    private lazy var pageControl: UIPageControl = {
        let control = UIPageControl()
        control.translatesAutoresizingMaskIntoConstraints = false
        control.isUserInteractionEnabled = true
        control.currentPageIndicatorTintColor = .systemOrange
        control.pageIndicatorTintColor = .systemGray5
        return control
    }()

    private var pagingInfoToken: AnyCancellable?

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

    required init?(coder: NSCoder) {
        super.init(coder: coder)
        setupView()
    }

    func configure(with numberOfPages: Int) {
        pageControl.numberOfPages = numberOfPages
    }

    func subscribeTo(subject: PassthroughSubject<PagingInfo, Never>, for section: Int) {
        pagingInfoToken = subject
            .filter { $0.sectionIndex == section }
            .receive(on: DispatchQueue.main)
            .sink { [weak self] pagingInfo in
                self?.pageControl.currentPage = pagingInfo.currentPage
            }
    }

    private func setupView() {
        backgroundColor = .clear

        addSubview(pageControl)

        NSLayoutConstraint.activate([
            pageControl.centerXAnchor.constraint(equalTo: centerXAnchor),
            pageControl.centerYAnchor.constraint(equalTo: centerYAnchor)
        ])
    }

    override func prepareForReuse() {
        super.prepareForReuse()

        pagingInfoToken?.cancel()
        pagingInfoToken = nil
    }
}
Bluesky logo

Follow on Bluesky to not miss new posts

Filip Němeček profile photo

WRITTEN BY

Filip Němeček Mastodon

iOS blogger and developer with interest in Python/Django.

iOS blogger and developer with interest in Python/Django.