UICollectionView Ergonomics
One of my favorite things in UIKit is UICollectionView
. I love how versatile it is. With iOS 13 it got even better with the diffable data source and compositional layout APIs.
However, when dealing with collection views there’s a lot of boiler plate one has to take care of. In this post I want to share some of the code I’ve written to make working with collection views a bit nicer.
Reusability and Configuration
The central part of collection views are its cells of course. In collection views cells has to be instantiated by the dequeueReusableCell
method. And to be dequeued they first have to be registered. With the power of Swift’s type system we can create a couple of helper protocols to make this process simpler and perhaps even more concise and clear.
First we create a protocol to define what it means to be a reusable cell. All we really need is a reuse identifier:
public protocol ReusableCell: UICollectionViewCell {
static var reuseIdentifier: String { get }
}
Then we can write an extension on that protocol to provide a default implementation for all cells that conform to the protocol:
public extension ReusableCell {
// 1.
static var reuseIdentifier: String { String(describing: Self.self) }
// 2.
static func register(with collectionView: UICollectionView) {
collectionView.register(Self.self, forCellWithReuseIdentifier: Self.reuseIdentifier)
}
// 3.
static func dequeue(from collectionView: UICollectionView, at indexPath: IndexPath) -> Self {
collectionView.dequeueReusableCell(
withReuseIdentifier: Self.reuseIdentifier,
for: indexPath) as! Self
}
}
- We can use the
String(describing:)
1 initializer to get the name of the implementing type which will result in a unique identifier for each class. - With this method we can call
OurCellClass.register(with: collectionView)
which reads a bit nicer. - This makes it so that we don’t have to do the
as? OurCellClass
dance in thecellForItemAtIndexPath
method. The force casting should be safe to do in this scenario as long as we use theregister(with:)
method.
With this code we can create a new cell class:
public final class OurCellClass: UICollectionViewCell, ReusableCell { ... }
And then if we subclass UICollectionViewController
:
public final class OurCollectionViewController: UICollectionViewController {
public override func viewDidLoad() {
super.viewDidLoad()
OurCellClass.register(with: collectionView)
}
}
Or if we just create a UICollectionViewController
without subclassing we can of course just do:
let viewController = UICollectionViewController(layout: someLayout)
OurCellClass.register(with: viewController.collectionView)
Supplementary Views
We can do the same for supplementary views:
public protocol ReusableCollectionReusableView: UICollectionReusableView {
static var elementKind: String { get }
static var reuseIdentifier: String { get }
}
public extension ReusableCollectionReusableView {
static var elementKind: String { String(describing: Self.self) + "-element-kind" }
static var reuseIdentifier: String { String(describing: Self.self) }
static func register(with collectionView: UICollectionView) {
collectionView.register(
Self.self,
forSupplementaryViewOfKind: Self.elementKind,
withReuseIdentifier: Self.reuseIdentifier)
}
static func dequeue(from collectionView: UICollectionView, at indexPath: IndexPath) -> Self {
collectionView.dequeueReusableSupplementaryView(
ofKind: Self.elementKind,
withReuseIdentifier: Self.reuseIdentifier,
for: indexPath) as! Self
}
}
The only real difference is that we also need the elementKind
.
Configuring Cells
When working with generic data sources it can be handy to also make cell configuration generic. You might for example have some data source class that is generic over the model type it operates on. In such cases you could also provide a ConfigurableCell
protocol:
public protocol ConfigurableCell: ReusableCell {
associatedtype Item
func configure(for item: Item)
}
It then becomes trivial to create a data source that is generic over both a cell type and a model and constrain the generics so that the model and Cell.Item
is the same type. I’m currently not doing that in any of my projects though, so that exercise is left to the reader. This has been written about by many already.
Regardless of whether you have a generic data source or not, it might be nice to use this API for all cells anyway. Since it creates a unified way of handling configuration across all your cells.
Getting Rid of the Cell All-together
I have often found myself wanting to use a cell outside of the context of a collection view. Ie. as a plain view. Maybe in a contextual menu preview for example. While UICollectionViewCell
is a subclass of UIView
this is technically possible, but it does feel a little weird.
That’s why I’ve created the ViewHostingCollectionViewCell
. It’s a generic cell type that has one job: wrap a UIView
subclass in a collection view cell and forward important cell updates to the view.
The first part is a protocol that defines a couple of properties and methods a view confined in a cell needs to have.
public protocol CellConfinable: UIView {
associatedtype Item
var isSelected: Bool { get set }
var isHighlighted: Bool { get set }
func prepareForReuse()
func configure(for item: Item)
}
This will allow the view to behave pretty much like any collection view cell.
Then we have the actual cell implementation:
public final class ViewHostingCollectionViewCell<View: CellConfinable, Item>:
UICollectionViewCell,
ConfigurableCell where Item == View.Item {
// MARK: Properties
/// The reuse identifier, made unique by using the type of the wrapped view.
public static var reuseIdentifier: String { return "hosted-\(String(describing: View.self))"}
/// The hosted view.
public let hostedView: View
/// The selection state of the cell.
public override var isSelected: Bool {
didSet {
hostedView.isSelected = isSelected
}
}
/// The highlight state of the cell.
public override var isHighlighted: Bool {
didSet {
hostedView.isHighlighted = isHighlighted
}
}
// MARK: Initialization
public override init(frame: CGRect) {
hostedView = View(frame: frame)
super.init(frame: frame)
contentView.addSubview(hostedView, constraints: [
equal(\.leadingAnchor),
equal(\.trailingAnchor)
])
contentView.topAnchor.constraint(equalTo: hostedView.topAnchor).isActive = true
contentView.bottomAnchor.constraint(equalTo: hostedView.bottomAnchor).isActive = true
}
public required init?(coder: NSCoder) { Abort.because(.shutUpXcode) }
// MARK: Cell Configuration
public override func prepareForReuse() {
super.prepareForReuse()
hostedView.prepareForReuse()
}
public func configure(for item: Item) {
hostedView.configure(for: item)
}
}
It’s fairly simple and straight forward. It’s generic over View
which must be a UIView
that conforms to the CellConfinable
protocol.
When a cell is dequeued its init(frame:)
method is called. Since we know that View
is a UIView
we can instantiate one the same way.2 3
Wrapping Up
With all of this in place we can now create a custom view:
public final class OurView: CellConfinable {
public init(frame: CGRect) {
super.init(frame: frame)
// add subviews and configure layout
}
public func configure(for item: OurModel) {
// configure the view for the model
}
}
And when we create our collection view:
let viewController = UICollectionView(layout: someLayout)
ViewHostingCollectionViewCell<OurView>.register(with: viewController.collectionView)
And in our data source, using a diffable data source:
let dataSource = UICollectionViewDiffableDataSource<SectionIdentifier, OurModelIdentifier>(collectionView: viewController.collectionView) { collectionView, indexPath, item in
let cell = ViewHostingCollectionViewCell<OurView>.dequeue(from: collectionView, at: indexPath)
cell.configure(for: item)
return cell
}
Isn’t that nice?
-
There is some contention regarding the use of
String(describing:)
for use cases like this. I stumbled upon this post by @skagedal on the Swift forums. But as in our case we only need a runtime unique identifier, we’re not relying on it to load stuff from our bundle, I think we’re fine. ↩︎ -
The Auto Layout code I’m using here is inspired by the Auto Layout with Key Paths episode of Swift Talk. ↩︎
-
I’m using some code inspired by @davedelong here. Specifically
Abort.because(.shutUpXcode))
. Dave’s “SyzygyCore” is a treasure trove of nice little helper methods, extensions and classes. I encourage you to browse it! ↩︎