Search code examples
swiftuicollectionviewuikitcombineswift5

How can i use Combine with @resultbuilder to build a dynamic collectionview list?


I want to use @resultbuilder and Combine to create my own reactive and declarative UICollectionView List in UIKit, similiar to what we get with List {} in SwiftUI.

For that, i am using a resultbuilder to create a Snapshot like this:

@resultBuilder
struct SnapshotBuilder {
    
    static func buildBlock(_ components: ListItemGroup...) -> [ListItem] {
        return components.flatMap { $0.items }
    }
    
    // Support `for-in` loop
    static func buildArray(_ components: [ListItemGroup]) -> [ListItem] {
        return components.flatMap { $0.items }
    }
    
    static func buildFinalResult(_ component: [ListItem]) -> NSDiffableDataSourceSectionSnapshot<ListItem> {
        var sectionSnapshot = NSDiffableDataSourceSectionSnapshot<ListItem>()
        sectionSnapshot.append(component)
        return sectionSnapshot
    }
}

I also need to use the following extensions to pass ListItemGroup to SnapshotBuilder and get [ListItem]

struct ListItem: Hashable {
    
    let title: String
    let image: UIImage?
    var children: [ListItem]
    
    init(_ title: String, children: [ListItem] = []) {
        self.title = title
        self.image = UIImage(systemName: title)
        self.children = children
    }
}

protocol ListItemGroup {
    var items: [ListItem] { get }
}

extension Array: ListItemGroup where Element == ListItem {
    var items: [ListItem] { self }
}

extension ListItem: ListItemGroup {
    var items: [ListItem] { [self] }
}

My List Class looks like this:

final class List: UICollectionView {
    
    enum Section {
        case main
    }
    
    var data: UICollectionViewDiffableDataSource<Section, ListItem>!
    private var cancellables = Set<AnyCancellable>()
    
    init(_ items: Published<[String]>.Publisher, style: UICollectionLayoutListConfiguration.Appearance = .insetGrouped, @SnapshotBuilder snapshot: @escaping () -> NSDiffableDataSourceSectionSnapshot<ListItem>) {
        super.init(frame: .zero, collectionViewLayout: List.createLayout(style))
    
        configureDataSource()
        data.apply(snapshot(), to: .main)
        
        items
            .sink { newValue in
                let newSnapshot = snapshot()
                self.data.apply(newSnapshot, to: .main, animatingDifferences: true)
            }
            .store(in: &cancellables)
    }
    
    required init(coder: NSCoder) {
        super.init(coder: coder)!
    }
    
    private static func createLayout(_ appearance: UICollectionLayoutListConfiguration.Appearance) -> UICollectionViewLayout {
        let layoutConfig = UICollectionLayoutListConfiguration(appearance: appearance)
        return UICollectionViewCompositionalLayout.list(using: layoutConfig)
    }
    
    private func configureDataSource() {
        let cellRegistration = UICollectionView.CellRegistration<UICollectionViewListCell, ListItem> {
            (cell, indexPath, item) in
            
            var content = cell.defaultContentConfiguration()
            content.image = item.image
            content.text = item.title
            cell.contentConfiguration = content
        }
        
        data = UICollectionViewDiffableDataSource<Section, ListItem>(collectionView: self) {
            (collectionView: UICollectionView, indexPath: IndexPath, identifier: ListItem) -> UICollectionViewCell? in
            
            let cell = collectionView.dequeueConfiguredReusableCell(using: cellRegistration, for: indexPath, item: identifier)
            cell.accessories = [.disclosureIndicator()]
            return cell
        }
    }
}

And i am using it in my ViewControllers like this:

class DeclarativeViewController: UIViewController {
    
    @Published var testItems: [String] = []
    
    var collectionView: List!
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        navigationController?.navigationBar.sizeToFit()
        title = "Settings"

        navigationItem.rightBarButtonItem = UIBarButtonItem(image: UIImage(systemName: "plus"), style: .plain, target: self, action: #selector(addItem))
        
        view.backgroundColor = .systemBackground
        
        collectionView = List($testItems) {
            for item in self.testItems {
                ListItem(item)
            }
        }
        
        collectionView.frame = view.bounds
        collectionView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
        view.addSubview(collectionView)
    }
    
    @objc func addItem() {
        testItems.append("Item \(testItems.count)")
    }
}

As you can see, i initialize my List with the @Published var testItems variable. In my init() func, i setup a subscriber and store them in cancellables, so i can react on changes.

If i add an item to testItems array, the sink callback is exectued to create a new snapshot and apply them to data. It works, but i need to tap the navigation button twice, to see an item on the list. Two questions:

  1. Why this is happen and how can i solve this? (so i only need to tap the button once to see changes in my list)
  2. and how can i improve my code? (currently I always create a new snapshot instead of extending the already created one)

Solution

  • Let me answer both questions by answering your second one.

    How can i improve my code? (currently I always create a new snapshot instead of extending the already created one)

    I'm a bit confused about your use of @resultBuilder. Typically one would use a result builder to create a Domain Specific Language (DSL). In this case you could create a DSL for constructing ListItems, but that would imply that you mean to populate a list at compile time, most of your code here seems to focus on updating the list, dynamically, a runtime. So using result builder seems overly complex.

    In this case, you're also using a Publisher where you could probably get by using a simple didSet on your controller's property. However, a Publisher would be a really good idea as part of a more complex Model that the Controller was trying to coordinate with its views. I had a version of your code where I replaced the Publisher with didSet but on second glance - imaging the more complex model case, I put the publisher back in.

    You've got your publisher's pipeline all tangled up in your result builder - which is odd because, again, publishers are about reacting dynamically to changes at runtime whereas result builders are about making nice DSLs for the syntax sugaring of compile time code.

    So I pulled out the DSL, and set up a rich pipeline that makes good use of having a publisher.

    Also, when using Combine publishers, it's common to use type erasure to make the actual nature of the publisher more anonymous. So in my rework, I use eraseToAnyPublisher so that List could take it's values from anyone, not just an @Published list of strings.

    So List becomes:

    final class List: UICollectionView {
        enum Section {
            case main
        }
    
        private var subscriptions = Set<AnyCancellable>()
        private var data: UICollectionViewDiffableDataSource<Section, ListItem>!
    
        init(itemPublisher: AnyPublisher<[String], Never>,
            style: UICollectionLayoutListConfiguration.Appearance = .insetGrouped) {
            super.init(frame: .zero, collectionViewLayout: List.createLayout(style))
            configureDataSource()
    
            itemPublisher
                .map{ items in  items.map { ListItem($0) }}
                .map{ listItems in
                    var newSnapshot = NSDiffableDataSourceSectionSnapshot<ListItem>()
                    newSnapshot.append(listItems)
                    return newSnapshot
                }
                .sink {
                    newSnapshot in
                    self.data?.apply(newSnapshot, to: .main, animatingDifferences: true)
                }
                .store(in: &subscriptions)
        }
    
        required init?(coder : NSCoder) {
            super.init(coder: coder)
        }
    
        private static func createLayout(_ appearance: UICollectionLayoutListConfiguration.Appearance) -> UICollectionViewLayout {
            let layoutConfig = UICollectionLayoutListConfiguration(appearance: appearance)
            return UICollectionViewCompositionalLayout.list(using: layoutConfig)
        }
    
        private func configureDataSource() {
            let cellRegistration = UICollectionView.CellRegistration<UICollectionViewListCell, ListItem> {
                (cell, indexPath, item) in
    
                var content = cell.defaultContentConfiguration()
                content.image = item.image
                content.text = item.title
                cell.contentConfiguration = content
            }
    
            data = UICollectionViewDiffableDataSource<Section, ListItem>(collectionView: self) {
                (collectionView: UICollectionView, indexPath: IndexPath, identifier: ListItem) -> UICollectionViewCell? in
    
                let cell = collectionView.dequeueConfiguredReusableCell(using: cellRegistration, for: indexPath, item: identifier)
                cell.accessories = [.disclosureIndicator()]
                return cell
            }
        }
    }
    

    Note the rich processing pipeline that is set up for itemPublisher and that it comes into the class as AnyPublisher<[String], Never>.

    Then your DeclarativeViewController becomes:

    class DeclarativeViewController: UIViewController {
        @Published var testItems: [String] = []
    
        var collectionView: List!
    
        override func viewDidLoad() {
            super.viewDidLoad()
    
            navigationController?.navigationBar.sizeToFit()
            title = "Settings"
    
            navigationItem.rightBarButtonItem = UIBarButtonItem(image: UIImage(systemName: "plus"), style: .plain, target: self, action: #selector(addItem))
    
            view.backgroundColor = .systemBackground
    
            collectionView = List(itemPublisher: $testItems.eraseToAnyPublisher())
            collectionView.frame = view.bounds
            collectionView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
            view.addSubview(collectionView)
        }
    
        @objc func addItem() {
            testItems.append("Item \(testItems.count)")
        }
    }
    

    where the testItems model's publisher get erased away to an any publisher.

    In my code ListItem stays the same, but all the stuff related to the @resultBuiler is gone. Maybe you could use it if you wanted to create a funciton to build a set of ListItems for the initial set of items in a table (or for a table that has static content) But it didn't seem necessary here.