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 Sponsored I want a Press KitWhen 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:
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
}
}