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