Taking UICollectionView to SwiftUI
#iOS App Development
Why the heck do we still need UICollectionView in SwiftUI?
It's not the time to write off the good old UICollectionView due to several reasons.
Performance
First of all, it's performance.
If the collection that we need to present is too large, we would better instantiate views only for visible items and reuse them, other than creating views for all the items at once. That's what UICollectionView does while VStack and HStack don't.
LazyVStack and LazyHStack have something to suggest for performance improvement for lengthy collections. But they are available only since iOS14.
SwiftUI List looks like a good candidate for large vertical lists but is not always suitable due to limitations in its appearance configuration.
Legacy
We might already have a fancy set of item views that we don't want to re-implement.
We may also have powerful custom collection view layouts that we want to reuse in our brand new SwiftUI-first app.
Did I miss any other reasons for UICollectionView to be used in SwiftUI?
UICollectionView is Powerful
To be honest, UICollectionView is damn powerful. Prefetching, Drag'nDrop, Custom and Compositional layouts, custom collection update animations, 8+ years in production.
UICollectionView is so powerful that it is often used to implement all screens of the app, literally placing all the UI elements into sections and items and reusing UI components here and there.
You can even code a card game by simply using UICollectionView.
DataSource-backed UICollectionView
In order to make life much easier we would implement a data-driven CollectionView first. It would use a `UICollectionViewDiffableDataSource` under the hood and have a the similar API with CellProvider
and SupplementaryViewProvider
:
final class CollectionViewWithDataSource<SectionIdentifierType,
ItemIdentifierType>: UICollectionView
where
SectionIdentifierType: Hashable & Sendable,
ItemIdentifierType: Hashable & Sendable {
typealias DataSource = UICollectionViewDiffableDataSource<SectionIdentifierType, ItemIdentifierType>
typealias Snapshot = NSDiffableDataSourceSnapshot<SectionIdentifierType, ItemIdentifierType>
private let cellProvider: DataSource.CellProvider
private let updateQueue: DispatchQueue = DispatchQueue(
label: "com.collectionview.update",
qos: .userInteractive)
private lazy var collectionDataSource: DataSource = {
DataSource(
collectionView: self,
cellProvider: cellProvider
)
}()
init(frame: CGRect,
collectionViewLayout: UICollectionViewLayout,
collectionViewConfiguration: ((UICollectionView) -> Void),
cellProvider: @escaping DataSource.CellProvider,
supplementaryViewProvider: DataSource.SupplementaryViewProvider?) {
self.cellProvider = cellProvider
super.init(frame: frame, collectionViewLayout: collectionViewLayout)
collectionViewConfiguration(self)
collectionDataSource.supplementaryViewProvider = supplementaryViewProvider
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func apply(_ snapshot: Snapshot,
animatingDifferences: Bool = true,
completion: (() -> Void)? = nil) {
updateQueue.async { [weak self] in
self?.collectionDataSource.apply(
snapshot,
animatingDifferences: animatingDifferences,
completion: completion
)
}
}
}
Improtant to note: we would make it even more powerful by providing a serial update queue. It will allow to evaluate diff in the background offloading main thread queue.
According to official docs, it's 100% legal to use DiffableDataSource in that way till it's performed on the same queue every time:
It's safe to call this method from a background queue, but you must do so consistently in your app. Always call this method exclusively from the main queue or from a background queue.
Furthermore, when I didn't force collection view datasource updates to perform on a specific queue explicitly, I sometimes faced collection view inconsistent update crashes when using it embedded in a SwiftUI.
UIViewRepresentable CollectionView
Now let's implement a CollectionView
which would be a SwiftUI friendly wrap-around CollectionViewWithDataSource
It will get all the configuration required for the underlying CollectionViewWithDataSource
:
- Snapshot - used to define the state of UI.
- Configuration - additional UICollectionView configuration. Registering cells, whatever.
- Cell provider - a place with `dequeue cell for indexPath` routines.
- Supplementary view provider - a place with `dequeue supplementary view for indexPath` routines.
- CollectionViewLayout - collection view layout configuration
extension CollectionView {
typealias UIKitCollectionView = CollectionViewWithDataSource<SectionIdentifierType, ItemIdentifierType>
typealias DataSource = UICollectionViewDiffableDataSource<SectionIdentifierType, ItemIdentifierType>
typealias Snapshot = NSDiffableDataSourceSnapshot<SectionIdentifierType, ItemIdentifierType>
typealias UpdateCompletion = () -> Void
}
struct CollectionView<SectionIdentifierType, ItemIdentifierType>
where
SectionIdentifierType: Hashable & Sendable,
ItemIdentifierType: Hashable & Sendable {
private let snapshot: Snapshot
private let configuration: ((UICollectionView) -> Void)
private let cellProvider: DataSource.CellProvider
private let supplementaryViewProvider: DataSource.SupplementaryViewProvider?
private let collectionViewLayout: () -> UICollectionViewLayout
private(set) var collectionViewDelegate: (() -> UICollectionViewDelegate)?
private(set) var animatingDifferences: Bool = true
private(set) var updateCallBack: UpdateCompletion?
init(snapshot: Snapshot,
collectionViewLayout: @escaping () -> UICollectionViewLayout,
configuration: @escaping ((UICollectionView) -> Void) = { _ in },
cellProvider: @escaping DataSource.CellProvider,
supplementaryViewProvider: DataSource.SupplementaryViewProvider? = nil) {
self.snapshot = snapshot
self.configuration = configuration
self.cellProvider = cellProvider
self.supplementaryViewProvider = supplementaryViewProvider
self.collectionViewLayout = collectionViewLayout
}
}
UIViewRepresentable Implementation
extension CollectionView: UIViewRepresentable {
final class Coordinator {
var delegate: UICollectionViewDelegate?
init(delegate: UICollectionViewDelegate?) {
self.delegate = delegate
}
}
func makeCoordinator() -> Coordinator {
Coordinator(delegate: collectionViewDelegate?())
}
func makeUIView(context: Context) -> UIKitCollectionView {
let collectionView = UIKitCollectionView(
frame: .zero,
collectionViewLayout: collectionViewLayout(),
collectionViewConfiguration: configuration,
cellProvider: cellProvider,
supplementaryViewProvider: supplementaryViewProvider
)
collectionView.delegate = context.coordinator.delegate
return collectionView
}
func updateUIView(_ uiView: UIKitCollectionView,
context: Context) {
uiView.apply(
snapshot,
animatingDifferences: animatingDifferences,
completion: updateCallBack
)
}
}
Convenience Extensions
- Animate diff configuration allowing to turn off
UICollectionView
animated updates. - OnUpdate -
UICollectionView
update completion handler. - CollectionViewDelegate allows to set the underlying
UICollectionView
's delegate. According to current implementation, the object would be retained.
extension CollectionView {
func animateDifferences(_ animate: Bool) -> Self {
var selfCopy = self
selfCopy.animatingDifferences = animate
return selfCopy
}
func onUpdate(_ perform: (() -> Void)?) -> Self {
var selfCopy = self
selfCopy.updateCallBack = perform
return selfCopy
}
func collectionViewDelegate(_ makeDelegate: @escaping (() -> UICollectionViewDelegate)) -> Self {
var selfCopy = self
selfCopy.collectionViewDelegate = makeDelegate
return selfCopy
}
}
This can be used together with a proxy delegate like this:
final class CollectionViewDelegateProxy: NSObject, UICollectionViewDelegate {
let didScroll: (UIScrollView) -> Void
let didSelect: (UICollectionView, IndexPath) -> Void
init(didScroll: @escaping (UIScrollView) -> Void,
didSelect: @escaping (UICollectionView, IndexPath) -> Void) {
self.didScroll = didScroll
self.didSelect = didSelect
}
func scrollViewDidScroll(_ scrollView: UIScrollView) {
didScroll(scrollView)
}
func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
didSelect(collectionView, indexPath)
}
}
Usage Example
Cell Registration and Simple Provider
Here is a simple example of how to use the CollectionView
:
struct ContentView: View {
typealias Item = Int
typealias Section = Int
typealias Snapshot = NSDiffableDataSourceSnapshot<Section, Item>
@State var snapshot: Snapshot = {
var initialSnapshot = Snapshot()
initialSnapshot.appendSections([0])
return initialSnapshot
}()
var body: some View {
ZStack(alignment: .bottom) {
CollectionView(
snapshot: snapshot,
configuration: collectionViewConfiguration,
cellProvider: cellProvider,
supplementaryViewProvider: supplementaryProvider,
collectionViewLayout: collectionViewLayout
)
.padding()
Button(
action: {
let itemsCount = snapshot.numberOfItems(inSection: 0)
snapshot.appendItems([itemsCount + 1], toSection: 0)
}, label: {
Text("Add More Items")
}
)
}
}
}
extension ContentView {
func collectionViewLayout() -> UICollectionViewLayout {
UICollectionViewFlowLayout()
}
func collectionViewConfiguration(_ collectionView: UICollectionView) {
collectionView.register(
UICollectionViewCell.self,
forCellWithReuseIdentifier: "CellReuseId"
)
collectionView.register(
UICollectionReusableView.self,
forSupplementaryViewOfKind: "KindOfHeader",
withReuseIdentifier: "SupplementaryReuseId"
)
}
func cellProvider(_ collectionView: UICollectionView,
indexPath: IndexPath,
item: Item) -> UICollectionViewCell {
let cell = collectionView.dequeueReusableCell(
withReuseIdentifier: "CellReuseId",
for: indexPath
)
cell.backgroundColor = .red
return cell
}
func supplementaryProvider(_ collectionView: UICollectionView,
elementKind: String,
indexPath: IndexPath) -> UICollectionReusableView {
collectionView.dequeueReusableSupplementaryView(
ofKind: elementKind,
withReuseIdentifier: "SupplementaryReuseId",
for: indexPath
)
}
}
CellRegistration with UIHostingConfiguration
Update for iOS16: This was changed with a brand new UIHostingConfiguration introduced in iOS16. More details can be found here: Use SwiftUI With UIKit - WWDC2022
We can implement a convenient extension to CellRegistration
to host SwiftUI views with new UIContentConfiguration
and UIHostingConfiguration
API:
extension UICollectionView.CellRegistration {
static func hosting<Content: View, Item>(
content: @escaping (IndexPath, Item) -> Content) -> UICollectionView.CellRegistration<UICollectionViewCell, Item> {
UICollectionView.CellRegistration { cell, indexPath, item in
cell.contentConfiguration = UIHostingConfiguration {
content(indexPath, item)
}
}
}
}
Implement cell provider with registration in one shot:
extension ContentView {
func cellProviderWithRegistration(_ collectionView: UICollectionView,
indexPath: IndexPath,
item: Item) -> UICollectionViewCell {
collectionView.dequeueConfiguredReusableCell(
using: cellRegistration,
for: indexPath,
item: item
)
}
}
Update ContentView
to use it:
struct ContentView: View {
typealias Item = Int
typealias Section = Int
typealias Snapshot = NSDiffableDataSourceSnapshot<Section, Item>
@State var snapshot: Snapshot = {
var initialSnapshot = Snapshot()
initialSnapshot.appendSections([0])
return initialSnapshot
}()
var body: some View {
ZStack(alignment: .bottom) {
CollectionView(
snapshot: snapshot,
collectionViewLayout: collectionViewLayout,
cellProvider: cellProviderWithRegistration
)
.padding()
Button(
action: {
let itemsCount = snapshot.numberOfItems(inSection: 0)
snapshot.appendItems([itemsCount + 1], toSection: 0)
}, label: {
Text("Add More Items")
}
)
}
}
let cellRegistration: UICollectionView.CellRegistration = .hosting { (idx: IndexPath, item: Item) in
Text("\(item)")
}
}
Github Gist
The code above is available on Github
Comments