我如何将 Combine 与 @resultbuilder 结合使用来构建动态 collectionview 列表?

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

我想使用 @resultbuilderCombine 在 UIKit 中创建我自己的反应式和声明式 UICollectionView 列表,类似于我们在 SwiftUI 中使用 List {} 得到的。

为此,我正在使用 resultbuilder 创建这样的快照:

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

我还需要使用以下扩展将 ListItemGroup 传递给 SnapshotBuilder 并获取 [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] }
}

我的 List Class 看起来像这样:

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
        }
    }
}

我在我的 ViewControllers 中使用它是这样的:

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)")
    }
}

如您所见,我使用 @Published var testItems 变量初始化 List。在我的 init() 函数中,我设置了一个订阅者并将它们存储在 cancellables 中,这样我就可以对更改做出反应。

如果我将一个项目添加到 testItems 数组,将执行 sink 回调以创建新快照并将它们应用到 data。它有效,但我需要点击导航按钮两次,才能看到列表中的项目。两个问题:

  1. 为什么会这样,我该如何解决? (所以我只需要点击一次按钮就可以看到列表中的变化)
  2. 以及如何改进我的代码? (目前我总是创建一个新快照而不是扩展已经创建的快照)

让我通过回答你的第二个问题来回答这两个问题。

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

我对您对 @resultBuilder 的使用感到有点困惑。通常,人们会使用结果生成器来创建领域特定语言 (DSL)。在这种情况下,您可以创建一个用于构造 ListItems 的 DSL,但这意味着您的意思是在编译时填充一个列表,这里的大部分代码似乎都集中在运行时动态更新列表。所以使用结果生成器似乎过于复杂。

在这种情况下,您还使用了 Publisher,您可以通过在控制器的 属性 上使用简单的 didSet 来获得。但是,作为控制器试图与其视图协调的更复杂模型的一部分,发布者将是一个非常好的主意。我有一个你的代码版本,我用 didSet 替换了 Publisher,但再看一眼 - 想象更复杂的模型案例,我把发布者放回去了。

你的发布者的管道都在你的结果构建器中纠缠不清——这很奇怪,因为发布者再次对运行时的变化做出动态反应,而结果构建器是为编译的语法加糖制作好的 DSL时间码。

所以我退出了 DSL,并建立了一个丰富的管道,充分利用了发布者。

此外,在使用 Combine 发布者时,通常使用类型擦除来使发布者的实际性质更加匿名。所以在我的返工中,我使用 eraseToAnyPublisher 以便 List 可以从任何人那里获取它的值,而不仅仅是 @Published 字符串列表。

所以List变成:

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([=10=]) }}
            .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
        }
    }
}

请注意为 itemPublisher 设置的丰富处理管道,它以 AnyPublisher<[String], Never> 的形式进入 class。

然后你的 DeclarativeViewController 变成:

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)")
    }
}

testItems 模型的发布者被删除到任何发布者。

在我的代码中 ListItem 保持不变,但是与 @resultBuiler 相关的所有内容都消失了。如果您想创建一个函数来为 table 中的初始项目集构建一组 ListItems(或具有静态内容的 table),也许您可​​以使用它但是这里似乎没有必要。