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 god 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.

Embedding UICollectionView in SwiftUI

Wrapping up Sections

We know nothing about how the section will look like. But what we know for sure is that it will have items.

public protocol SectionProtocol: Hashable {
    associatedtype Item: Hashable
    
    var items: [Item] { get }
}

Hashable implementation of the sections in future will be the most crucial part of any Section. As it will define entirely how the diff will work.

Implementing CollectionView

public struct CollectionView<Section, Item>
    where
    Section: SectionProtocol,
    Section.Item == Item {

    private let collectionViewProvider: CollectionViewProvider
    private let cellProvider: CellProvider
    private let supplementaryViewProvider: SupplementaryViewProvider?
    private let updateCompleteHandler: CollectionViewUpdateCompleteHandler?

    private var animateCollectionUpdates: Bool = true

    private let sections: [Section]

    public init(sections: [Section],
                collectionViewProvider: @escaping  CollectionViewProvider,
                cellProvider: @escaping CellProvider,
                supplementaryViewProvider: SupplementaryViewProvider? = nil,
                updateCompleteHandler: CollectionViewUpdateCompleteHandler? = nil) {

        self.collectionViewProvider = collectionViewProvider
        self.cellProvider = cellProvider
        self.sections = sections
        self.supplementaryViewProvider = supplementaryViewProvider
        self.updateCompleteHandler = updateCompleteHandler
    }
}

With diff animation modifier:


public extension CollectionView {
    func animatingDifferences(_ animated: Bool) -> Self {
        var selfCopy = self
        selfCopy.animateCollectionUpdates = animated
        return selfCopy
    }
}

and UIViewRepresentable conformance

extension CollectionView: UIViewRepresentable {
    public func makeCoordinator() -> Coordinator {
        Coordinator()
    }

    public func makeUIView(context: UIViewRepresentableContext<CollectionView>) -> UICollectionView {
        let collectionView = collectionViewProvider()
        let datasource = DataSource(collectionView: collectionView,
                                    cellProvider: cellProvider)
        datasource.setSupplementaryViewProvider(with: supplementaryViewProvider)
        context.coordinator.datasource = datasource
        return collectionView
    }

    public func updateUIView(_ uiView: UICollectionView,
                             context: UIViewRepresentableContext<CollectionView>) {

        context.coordinator.applySnapshotInBackground(sections: sections,
                                                      animated: animateCollectionUpdates) {
            updateCompleteHandler?(uiView)
        }
    }
}

UICollectionViewDiffableDataSource convenience extension

I use a slightly improved version of SupplementaryViewProvider closure that comes with direct access to DataSource snapshot, allowing to configure Headers and Footers more conveniently.

extension UICollectionViewDiffableDataSource where SectionIdentifierType: SectionProtocol,
                                                   SectionIdentifierType.Item == ItemIdentifierType {
    func setSupplementaryViewProvider(
        with provider: CollectionView<SectionIdentifierType, ItemIdentifierType>.SupplementaryViewProvider?) {

        guard let provider = provider else {
            supplementaryViewProvider = nil
            return
        }

        supplementaryViewProvider = { [weak self] (collecion, kind, idx) in
            guard let self = self else {
                return nil
            }

            return provider(self.snapshot(), collecion, kind, idx)
        }
    }
}

Implementing Coordinator

The coordinator I usually use is rather tricky.

It uses serial background queue to prepare a snapshot of the data and perform diff against the old snapshot.

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.


extension CollectionView {
    public class Coordinator: NSObject {
        var datasource: DataSource?

        private let updateQueue = DispatchQueue(
            label: "CollectionView.Coordinator.Update.Queue",
            qos: .userInteractive)

        func applySnapshotInBackground(sections: [Section],
                                       animated: Bool,
                                       complete: @escaping () -> Void) {

            updateQueue.async { [weak self] in
                guard let self = self else {
                    DispatchQueue.main.async {
                        complete()
                    }
                    return
                }

                self.applySnapshot(sections: sections, animated: animated) {
                    DispatchQueue.main.async {
                        complete()
                    }
                }
            }
        }
    }
}


extension CollectionView.Coordinator {
    private func applySnapshot(sections: [Section],
                               animated: Bool,
                               complete: @escaping () -> Void) {

        guard let datasource = self.datasource else {
            complete()
            return
        }

        var snapshot = CollectionView.SnapShot()

        snapshot.appendSections(sections)
        sections.forEach {
            snapshot.appendItems($0.items, toSection: $0)
        }

        datasource.apply(snapshot, animatingDifferences: animated) {
            complete()
        }
    }
}

Why not just use SwiftUI views as cells

There is a way to use SwiftUI views as cells for our embedded collection view.
Each cell may act as a hosting ViewController for SwiftUI view.

It's easy to find implementations of that thing, like the one below, but I personally do not recommend using it.


public class HostingCollectionViewCell<Content: View>: UICollectionViewCell
    where Content: ReusableView {

    private var hostingController: UIHostingController<Content>?
    
    override init(frame: CGRect) {
        super.init(frame: frame)
    }

    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
    
    public static var reuseIdentifier: String {
        String(describing: Content.reuseIdentifier)
    }
    
    public func set(rootView: Content) {
        guard let host = hostingController else {
            let host = UIHostingController(rootView: rootView)

            let parentController = resolveParentViewController()
            parentController?.addChild(host)

            addSubview(host.view)
            host.view.translatesAutoresizingMaskIntoConstraints = false

            NSLayoutConstraint.activate([
                leadingAnchor.constraint(equalTo: host.view.leadingAnchor),
                trailingAnchor.constraint(equalTo: host.view.trailingAnchor),
                topAnchor.constraint(equalTo: host.view.topAnchor),
                bottomAnchor.constraint(equalTo: host.view.bottomAnchor)
            ])

            parentController.map { host.didMove(toParent: $0) }
            return
        }

        host.rootView = rootView
        host.view.invalidateIntrinsicContentSize()
    }

    deinit {
        hostingController?.willMove(toParent: nil)
        hostingController?.view.removeFromSuperview()
        hostingController?.removeFromParent()
        hostingController = nil
    }
}

fileprivate extension UIView {
    func resolveParentViewController() -> UIViewController? {
        var parentResponder: UIResponder? = self
        while parentResponder != nil {
            parentResponder = parentResponder?.next
            if let viewController = parentResponder as? UIViewController {
                return viewController
            }
        }
        return nil
    }
}


public protocol ReusableView {

}

public extension ReusableView {
    static var reuseIdentifier: String {
       "\(self)"
    }
}

Performance

First of all, I've got concerns about performance. It doesn't look cheap to allocate a view controller for every collection view cell.

Layout issues

Another problem is endless issues with cell's view frames.

I've tried it with simple layouts and it worked well, but when I tried it with a more complicated compositional layout, especially with orthogonal scroll behavior. it got completely broken.

I had problems even with simple cells with a fixed size and only Tim Cook knows what may happen to self-sizing ones.

There is an issue related to keyboard avoidance that interferes with the layout, even though it must be easily fixed by the ingoreSafeArea modifier.

Another one is the safe area issue that folks are trying to fix on SO

In my opinion, the issues above are a strong signal that this time we are doing something really wrong when trying to make a sandwich of SwiftUI - UICollectionView - SwiftUI.


Nevertheless, ping me if you find a working solution for the sandwich, lol.

The code above is available on github.