Skip to content

Commit b2d13b2

Browse files
committed
adding collection view support
1 parent 7756f2f commit b2d13b2

File tree

9 files changed

+588
-12
lines changed

9 files changed

+588
-12
lines changed

Example/Example.xcodeproj/project.pbxproj

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
objects = {
88

99
/* Begin PBXBuildFile section */
10+
9C326989230B2DDE00E93F9C /* CollectionViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9C326988230B2DDE00E93F9C /* CollectionViewController.swift */; };
1011
9C371EE7230356CE00617B57 /* MenuTableViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9C371EE6230356CE00617B57 /* MenuTableViewController.swift */; };
1112
9CA4B70D23048B470041CBA4 /* GitHubSearchViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9CA4B70C23048B470041CBA4 /* GitHubSearchViewController.swift */; };
1213
9CB630CA22FD510000368A0D /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9CB630C922FD510000368A0D /* AppDelegate.swift */; };
@@ -19,6 +20,7 @@
1920
/* End PBXBuildFile section */
2021

2122
/* Begin PBXFileReference section */
23+
9C326988230B2DDE00E93F9C /* CollectionViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CollectionViewController.swift; sourceTree = "<group>"; };
2224
9C371EE6230356CE00617B57 /* MenuTableViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MenuTableViewController.swift; sourceTree = "<group>"; };
2325
9CA4B70C23048B470041CBA4 /* GitHubSearchViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GitHubSearchViewController.swift; sourceTree = "<group>"; };
2426
9CB630C622FD510000368A0D /* Example.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Example.app; sourceTree = BUILT_PRODUCTS_DIR; };
@@ -69,6 +71,7 @@
6971
9CB630CB22FD510000368A0D /* SceneDelegate.swift */,
7072
9C371EE6230356CE00617B57 /* MenuTableViewController.swift */,
7173
9CB630CD22FD510000368A0D /* ViewController.swift */,
74+
9C326988230B2DDE00E93F9C /* CollectionViewController.swift */,
7275
9CA4B70C23048B470041CBA4 /* GitHubSearchViewController.swift */,
7376
9CB630CF22FD510000368A0D /* Main.storyboard */,
7477
9CB630D222FD510100368A0D /* Assets.xcassets */,
@@ -160,6 +163,7 @@
160163
isa = PBXSourcesBuildPhase;
161164
buildActionMask = 2147483647;
162165
files = (
166+
9C326989230B2DDE00E93F9C /* CollectionViewController.swift in Sources */,
163167
9C371EE7230356CE00617B57 /* MenuTableViewController.swift in Sources */,
164168
9CB630CE22FD510000368A0D /* ViewController.swift in Sources */,
165169
9CA4B70D23048B470041CBA4 /* GitHubSearchViewController.swift in Sources */,

Example/Example/Base.lproj/Main.storyboard

Lines changed: 159 additions & 12 deletions
Large diffs are not rendered by default.
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
//
2+
// For credits and licence check the LICENSE file included in this package.
3+
// (c) CombineOpenSource, Created by Marin Todorov.
4+
//
5+
6+
import UIKit
7+
import Combine
8+
import CombineDataSources
9+
10+
class PersonCollectionCell: UICollectionViewCell {
11+
@IBOutlet var nameLabel: UILabel!
12+
@IBOutlet var image: UIImageView!
13+
private var subscriptions = [AnyCancellable]()
14+
15+
var imageURL: URL! {
16+
didSet {
17+
URLSession.shared.dataTaskPublisher(for: imageURL)
18+
.compactMap { UIImage(data: $0.data) }
19+
.replaceError(with: UIImage())
20+
.receive(on: DispatchQueue.main)
21+
.assign(to: \.image, on: image)
22+
.store(in: &subscriptions)
23+
}
24+
}
25+
}
26+
27+
class CollectionViewController: UIViewController {
28+
@IBOutlet var collectionView: UICollectionView!
29+
30+
// The kind of demo to show
31+
var demo: Demo = .plain
32+
33+
// Test data set to use
34+
let first = [
35+
[Person(name: "Julia"), Person(name: "Vicki"), Person(name: "Pete")],
36+
[Person(name: "Jim"), Person(name: "Jane")],
37+
]
38+
let second = [
39+
[Person(name: "Pete")],
40+
[Person(name: "Jim")],
41+
]
42+
43+
// Publisher to emit data to the table
44+
var data = PassthroughSubject<[[Person]], Never>()
45+
46+
private var flag = false
47+
48+
// Emits values out of `data`
49+
func reload() {
50+
data.send(flag ? first : second)
51+
flag.toggle()
52+
53+
DispatchQueue.main.asyncAfter(deadline: .now() + 1) { [weak self] in
54+
self?.reload()
55+
}
56+
}
57+
58+
override func viewDidLoad() {
59+
super.viewDidLoad()
60+
61+
switch demo {
62+
case .plain:
63+
// A plain list with a single section -> Publisher<[Person], Never>
64+
data
65+
.map { $0[0] }
66+
.subscribe(collectionView.itemsSubscriber(cellIdentifier: "Cell", cellType: PersonCollectionCell.self, cellConfig: { cell, indexPath, model in
67+
cell.nameLabel.text = model.name
68+
cell.imageURL = URL(string: "https://api.adorable.io/avatars/100/\(model.name)")!
69+
}))
70+
71+
case .multiple:
72+
// Table with sections -> Publisher<[[Person]], Never>
73+
data
74+
.subscribe(collectionView.sectionsSubscriber(cellIdentifier: "Cell", cellType: PersonCollectionCell.self, cellConfig: { cell, indexPath, model in
75+
cell.nameLabel.text = model.name
76+
cell.imageURL = URL(string: "https://api.adorable.io/avatars/100/\(model.name)")!
77+
}))
78+
79+
case .sections:
80+
// Table with section driven by `Section` models -> Publisher<[Section<Person>], Never>
81+
data
82+
.map { sections in
83+
return sections.map { persons -> Section<Person> in
84+
return Section(items: persons)
85+
}
86+
}
87+
.subscribe(collectionView.sectionsSubscriber(cellIdentifier: "Cell", cellType: PersonCollectionCell.self, cellConfig: { cell, indexPath, model in
88+
cell.nameLabel.text = model.name
89+
cell.imageURL = URL(string: "https://api.adorable.io/avatars/100/\(model.name)")!
90+
}))
91+
92+
case .noAnimations:
93+
// Use custom controller to disable animations
94+
let controller = CollectionViewItemsController<[[Person]]>(cellIdentifier: "Cell", cellType: PersonCollectionCell.self) { cell, indexPath, person in
95+
cell.nameLabel.text = person.name
96+
cell.imageURL = URL(string: "https://api.adorable.io/avatars/100/\(person.name)")!
97+
}
98+
controller.animated = false
99+
100+
data
101+
.subscribe(collectionView.sectionsSubscriber(controller))
102+
}
103+
104+
reload()
105+
}
106+
}

Example/Example/MenuTableViewController.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,5 +9,6 @@ class MenuTableViewController: UITableViewController {
99
override func prepare(for segue: UIStoryboardSegue, sender: Any?) {
1010
let rowIndex = (sender as! UITableViewCell).tag
1111
(segue.destination as? ViewController)?.demo = Demo(rawValue: rowIndex)!
12+
(segue.destination as? CollectionViewController)?.demo = Demo(rawValue: rowIndex)!
1213
}
1314
}

README.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,18 @@ data
4343

4444
![Plain list updates with CombineDataSources](https://github.com/combineopensource/CombineDataSources/raw/master/Assets/plain-list.gif)
4545

46+
Respectively for a collection view:
47+
48+
```swift
49+
data
50+
.subscribe(collectionView.itemsSubscriber(cellIdentifier: "Cell", cellType: PersonCollectionCell.self, cellConfig: { cell, indexPath, model in
51+
cell.nameLabel.text = model.name
52+
cell.imageURL = URL(string: "https://api.adorable.io/avatars/100/\(model.name)")!
53+
}))
54+
```
55+
56+
![Plain list updates for a collection view](https://github.com/combineopensource/CombineDataSources/raw/master/Assets/plain-collection.gif)
57+
4658
#### Bind a list of Section models
4759

4860
```swift
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
//
2+
// For credits and licence check the LICENSE file included in this package.
3+
// (c) CombineOpenSource, Created by Marin Todorov.
4+
//
5+
6+
import UIKit
7+
import Combine
8+
9+
/// A collection view controller acting as data source.
10+
/// `CollectionType` needs to be a collection of collections to represent sections containing rows.
11+
public class CollectionViewItemsController<CollectionType>: NSObject, UICollectionViewDataSource
12+
where CollectionType: RandomAccessCollection,
13+
CollectionType.Index == Int,
14+
CollectionType.Element: Equatable,
15+
CollectionType.Element: RandomAccessCollection,
16+
CollectionType.Element.Index == Int,
17+
CollectionType.Element.Element: Equatable {
18+
19+
public typealias Element = CollectionType.Element.Element
20+
public typealias CellFactory<Element: Equatable> = (CollectionViewItemsController<CollectionType>, UICollectionView, IndexPath, Element) -> UICollectionViewCell
21+
public typealias CellConfig<Element, Cell> = (Cell, IndexPath, Element) -> Void
22+
23+
private let cellFactory: CellFactory<Element>
24+
private var collection: CollectionType!
25+
26+
/// Should the table updates be animated or static.
27+
public var animated = true
28+
29+
/// The collection view for the data source
30+
var collectionView: UICollectionView!
31+
32+
/// A fallback data source to implement custom logic like indexes, dragging, etc.
33+
public var dataSource: UICollectionViewDataSource?
34+
35+
// MARK: - Init
36+
public init<CellType>(cellIdentifier: String, cellType: CellType.Type, cellConfig: @escaping CellConfig<Element, CellType>) where CellType: UICollectionViewCell {
37+
cellFactory = { dataSource, collectionView, indexPath, value in
38+
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: cellIdentifier, for: indexPath) as! CellType
39+
cellConfig(cell, indexPath, value)
40+
return cell
41+
}
42+
}
43+
44+
private init(cellFactory: @escaping CellFactory<Element>) {
45+
self.cellFactory = cellFactory
46+
}
47+
48+
deinit {
49+
debugPrint("Controller is released")
50+
}
51+
52+
// MARK: - Update collection
53+
private let fromRow = {(section: Int) in return {(row: Int) in return IndexPath(row: row, section: section)}}
54+
55+
func updateCollection(_ items: CollectionType) {
56+
// If the changes are not animatable, reload the table
57+
guard animated, collection != nil, items.count == collection.count else {
58+
collection = items
59+
collectionView.reloadData()
60+
return
61+
}
62+
63+
// Commit the changes to the collection view sections
64+
collectionView.performBatchUpdates({[unowned self] in
65+
for sectionIndex in 0..<items.count {
66+
let changes = delta(newList: items[sectionIndex], oldList: collection[sectionIndex])
67+
collectionView.deleteItems(at: changes.removals.map(self.fromRow(sectionIndex)))
68+
collectionView.insertItems(at: changes.insertions.map(self.fromRow(sectionIndex)))
69+
}
70+
collection = items
71+
}, completion: nil)
72+
}
73+
74+
private func delta<T>(newList: T, oldList: T) -> (insertions: [Int], removals: [Int])
75+
where T: RandomAccessCollection, T.Element: Equatable {
76+
77+
let changes = newList.difference(from: oldList)
78+
79+
let insertIndexes = changes.compactMap { change -> Int? in
80+
guard case CollectionDifference<T.Element>.Change.insert(let offset, _, _) = change else {
81+
return nil
82+
}
83+
return offset
84+
}
85+
let deleteIndexes = changes.compactMap { change -> Int? in
86+
guard case CollectionDifference<T.Element>.Change.remove(let offset, _, _) = change else {
87+
return nil
88+
}
89+
return offset
90+
}
91+
92+
return (insertions: insertIndexes, removals: deleteIndexes)
93+
}
94+
95+
// MARK: - UITableViewDataSource protocol
96+
public func numberOfSections(in collectionView: UICollectionView) -> Int {
97+
guard collection != nil else { return 0 }
98+
return collection.count
99+
}
100+
101+
public func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
102+
return collection[section].count
103+
}
104+
105+
public func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
106+
cellFactory(self, collectionView, indexPath, collection[indexPath.section][indexPath.row])
107+
}
108+
109+
// MARK: - Fallback data source object
110+
override public func forwardingTarget(for aSelector: Selector!) -> Any? {
111+
return dataSource
112+
}
113+
}
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
//
2+
// For credits and licence check the LICENSE file included in this package.
3+
// (c) CombineOpenSource, Created by Marin Todorov.
4+
//
5+
6+
import UIKit
7+
import Combine
8+
9+
extension UICollectionView {
10+
11+
/// A collection view specific `Subscriber` that receives `[[Element]]` input and updates a sectioned collection view.
12+
/// - Parameter cellIdentifier: The Cell ID to use for dequeueing table cells.
13+
/// - Parameter cellType: The required cell type for table rows.
14+
/// - Parameter cellConfig: A closure that receives an initialized cell and a collection element
15+
/// and configures the cell for displaying in its containing table view.
16+
public func sectionsSubscriber<CellType, Items>(cellIdentifier: String, cellType: CellType.Type, cellConfig: @escaping CollectionViewItemsController<Items>.CellConfig<Items.Element.Element, CellType>)
17+
-> AnySubscriber<Items, Never> where CellType: UICollectionViewCell,
18+
Items: RandomAccessCollection,
19+
Items.Element: RandomAccessCollection,
20+
Items.Element: Equatable {
21+
22+
return sectionsSubscriber(.init(cellIdentifier: cellIdentifier, cellType: cellType, cellConfig: cellConfig))
23+
}
24+
25+
/// A table view specific `Subscriber` that receives `[[Element]]` input and updates a sectioned table view.
26+
/// - Parameter source: A configured `CollectionViewItemsController<Items>` instance.
27+
public func sectionsSubscriber<Items>(_ source: CollectionViewItemsController<Items>)
28+
-> AnySubscriber<Items, Never> where
29+
Items: RandomAccessCollection,
30+
Items.Element: RandomAccessCollection,
31+
Items.Element: Equatable {
32+
33+
source.collectionView = self
34+
dataSource = source
35+
36+
return AnySubscriber<Items, Never>(receiveSubscription: { subscription in
37+
subscription.request(.unlimited)
38+
}, receiveValue: { [weak self] items -> Subscribers.Demand in
39+
guard let self = self else { return .none }
40+
41+
if self.dataSource == nil {
42+
self.dataSource = source
43+
}
44+
45+
source.updateCollection(items)
46+
return .unlimited
47+
}) { _ in }
48+
}
49+
50+
/// A table view specific `Subscriber` that receives `[Element]` input and updates a single section table view.
51+
/// - Parameter cellIdentifier: The Cell ID to use for dequeueing table cells.
52+
/// - Parameter cellType: The required cell type for table rows.
53+
/// - Parameter cellConfig: A closure that receives an initialized cell and a collection element
54+
/// and configures the cell for displaying in its containing table view.
55+
public func itemsSubscriber<CellType, Items>(cellIdentifier: String, cellType: CellType.Type, cellConfig: @escaping CollectionViewItemsController<[Items]>.CellConfig<Items.Element, CellType>)
56+
-> AnySubscriber<Items, Never> where CellType: UICollectionViewCell,
57+
Items: RandomAccessCollection,
58+
Items: Equatable {
59+
60+
return itemsSubscriber(.init(cellIdentifier: cellIdentifier, cellType: cellType, cellConfig: cellConfig))
61+
}
62+
63+
/// A table view specific `Subscriber` that receives `[Element]` input and updates a single section table view.
64+
/// - Parameter source: A configured `CollectionViewItemsController<Items>` instance.
65+
public func itemsSubscriber<Items>(_ source: CollectionViewItemsController<[Items]>)
66+
-> AnySubscriber<Items, Never> where
67+
Items: RandomAccessCollection,
68+
Items: Equatable {
69+
70+
source.collectionView = self
71+
dataSource = source
72+
73+
return AnySubscriber<Items, Never>(receiveSubscription: { subscription in
74+
subscription.request(.unlimited)
75+
}, receiveValue: { [weak self] items -> Subscribers.Demand in
76+
guard let self = self else { return .none }
77+
78+
if self.dataSource == nil {
79+
self.dataSource = source
80+
}
81+
82+
source.updateCollection([items])
83+
return .unlimited
84+
}) { _ in }
85+
}
86+
}
87+

0 commit comments

Comments
 (0)