5 min read

Taking UICollectionView to SwiftUI

Why the heck do we still need UICollectionView in SwiftUI? (Updated on 23.03.2023)
Taking UICollectionView to SwiftUI
Photo by Christin Hume / Unsplash

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.

Perfomance

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 {
    func makeUIView(context: Context) -> UIKitCollectionView {
        let collectionView = UIKitCollectionView(
            frame: .zero,
            collectionViewLayout: collectionViewLayout(),
            collectionViewConfiguration: configuration,
            cellProvider: cellProvider,
            supplementaryViewProvider: supplementaryViewProvider
        )

        let delegate = collectionViewDelegate?()
        collectionView.delegate = 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 self
    }

    func onUpdate(_ perform: (() -> Void)?) -> Self {
        var selfCopy = self
        selfCopy.updateCallBack = perform
        return self
    }

    func collectionViewDelegate(_ makeDelegate: @escaping (() -> UICollectionViewDelegate)) -> Self {
        var selfCopy = self
        selfCopy.collectionViewDelegate = makeDelegate
        return self
    }
}


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 UIHostingConfigurationAPI:


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