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:
- 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
- We will start by creating custom views for these supplementary views.
- 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
- We will add a constant and static
reuseIdentifier
which will allow us to register it with collection view with unique identifier - 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
viewClass
supplementaryViewKind
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,
- No matter whether badge, header, or footer - We are always returning the type which is subclass of
UICollectionReusableView
- 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 ofUICollectionReusableView
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,
- Lines 1-2 - Get the
struct
of typeSection
from the passedsectionIndex
. TypeSection
will let us call thecolumnCount
method which will return the number of columns in each section - Lines 3-5 - Here we create a
badge
element by passingbadgeAnchor
,elementKind
, andbadgeSize
- Lines 6-8 - We create individual item by passing
itemSize
and correspondingbadge
to attach to that element oncollectionView
- Lines 9-11 - Horizontal group is created by passing
item
and specifying how manyitems
we want to show in each group. The number of items in each group is decided by the section number - 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 - Line 13 - We create the header of type
NSCollectionLayoutBoundarySupplementaryItem
by specifyingalignment
totop
and setting the propertypinToVisibleBounds
totrue
so that it sticks to the top during scrolling - Line 14 - Footer of type
NSCollectionLayoutBoundarySupplementaryItem
is created by specifyingbottom
alignment
- Lines 15-18 - A
Section
is created by passing the previously createdGroup
object. For each section,header
andfooter
are added by specifying them inboundarySupplementaryItems
property associated with each section. We also specifycontentInsets
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: