diff --git a/Assets/BatchesDataSource.monopic b/Assets/BatchesDataSource.monopic new file mode 100644 index 0000000..3d88ac7 Binary files /dev/null and b/Assets/BatchesDataSource.monopic differ diff --git a/Assets/BatchesDataSource.png b/Assets/BatchesDataSource.png new file mode 100644 index 0000000..658902a Binary files /dev/null and b/Assets/BatchesDataSource.png differ diff --git a/Assets/slack.png b/Assets/slack.png new file mode 100644 index 0000000..f6bf31d Binary files /dev/null and b/Assets/slack.png differ diff --git a/Example/Example.xcodeproj/project.pbxproj b/Example/Example.xcodeproj/project.pbxproj index 11a19d0..019c80a 100644 --- a/Example/Example.xcodeproj/project.pbxproj +++ b/Example/Example.xcodeproj/project.pbxproj @@ -9,6 +9,9 @@ /* Begin PBXBuildFile section */ 9C326989230B2DDE00E93F9C /* CollectionViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9C326988230B2DDE00E93F9C /* CollectionViewController.swift */; }; 9C371EE7230356CE00617B57 /* MenuTableViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9C371EE6230356CE00617B57 /* MenuTableViewController.swift */; }; + 9C7E190B2313E02D00518E33 /* CustomBatchesViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9C7E190A2313E02D00518E33 /* CustomBatchesViewController.swift */; }; + 9CA01D192312EF7C00666EDE /* BatchesViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9CA01D182312EF7C00666EDE /* BatchesViewController.swift */; }; + 9CA01D1C2312F4CF00666EDE /* SampleData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9CA01D1B2312F4CF00666EDE /* SampleData.swift */; }; 9CA4B70D23048B470041CBA4 /* GitHubSearchViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9CA4B70C23048B470041CBA4 /* GitHubSearchViewController.swift */; }; 9CB630CA22FD510000368A0D /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9CB630C922FD510000368A0D /* AppDelegate.swift */; }; 9CB630CC22FD510000368A0D /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9CB630CB22FD510000368A0D /* SceneDelegate.swift */; }; @@ -22,6 +25,9 @@ /* Begin PBXFileReference section */ 9C326988230B2DDE00E93F9C /* CollectionViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CollectionViewController.swift; sourceTree = ""; }; 9C371EE6230356CE00617B57 /* MenuTableViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MenuTableViewController.swift; sourceTree = ""; }; + 9C7E190A2313E02D00518E33 /* CustomBatchesViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomBatchesViewController.swift; sourceTree = ""; }; + 9CA01D182312EF7C00666EDE /* BatchesViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BatchesViewController.swift; sourceTree = ""; }; + 9CA01D1B2312F4CF00666EDE /* SampleData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SampleData.swift; sourceTree = ""; }; 9CA4B70C23048B470041CBA4 /* GitHubSearchViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GitHubSearchViewController.swift; sourceTree = ""; }; 9CB630C622FD510000368A0D /* Example.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Example.app; sourceTree = BUILT_PRODUCTS_DIR; }; 9CB630C922FD510000368A0D /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; @@ -46,6 +52,14 @@ /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ + 9CA01D1A2312F4C700666EDE /* etc */ = { + isa = PBXGroup; + children = ( + 9CA01D1B2312F4CF00666EDE /* SampleData.swift */, + ); + path = etc; + sourceTree = ""; + }; 9CB630BD22FD510000368A0D = { isa = PBXGroup; children = ( @@ -67,12 +81,15 @@ 9CB630C822FD510000368A0D /* Example */ = { isa = PBXGroup; children = ( + 9CA01D1A2312F4C700666EDE /* etc */, 9CB630C922FD510000368A0D /* AppDelegate.swift */, 9CB630CB22FD510000368A0D /* SceneDelegate.swift */, 9C371EE6230356CE00617B57 /* MenuTableViewController.swift */, 9CB630CD22FD510000368A0D /* ViewController.swift */, 9C326988230B2DDE00E93F9C /* CollectionViewController.swift */, 9CA4B70C23048B470041CBA4 /* GitHubSearchViewController.swift */, + 9CA01D182312EF7C00666EDE /* BatchesViewController.swift */, + 9C7E190A2313E02D00518E33 /* CustomBatchesViewController.swift */, 9CB630CF22FD510000368A0D /* Main.storyboard */, 9CB630D222FD510100368A0D /* Assets.xcassets */, 9CB630D422FD510100368A0D /* LaunchScreen.storyboard */, @@ -165,10 +182,13 @@ files = ( 9C326989230B2DDE00E93F9C /* CollectionViewController.swift in Sources */, 9C371EE7230356CE00617B57 /* MenuTableViewController.swift in Sources */, + 9CA01D192312EF7C00666EDE /* BatchesViewController.swift in Sources */, 9CB630CE22FD510000368A0D /* ViewController.swift in Sources */, 9CA4B70D23048B470041CBA4 /* GitHubSearchViewController.swift in Sources */, 9CB630CA22FD510000368A0D /* AppDelegate.swift in Sources */, 9CB630CC22FD510000368A0D /* SceneDelegate.swift in Sources */, + 9C7E190B2313E02D00518E33 /* CustomBatchesViewController.swift in Sources */, + 9CA01D1C2312F4CF00666EDE /* SampleData.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/Example/Example/Base.lproj/Main.storyboard b/Example/Example/Base.lproj/Main.storyboard index 5a7f3fa..759d981 100644 --- a/Example/Example/Base.lproj/Main.storyboard +++ b/Example/Example/Base.lproj/Main.storyboard @@ -1,8 +1,8 @@ - + - + @@ -25,7 +25,7 @@ - - + @@ -279,7 +438,7 @@ - + @@ -302,7 +461,7 @@ @@ -336,7 +495,7 @@ - + @@ -399,13 +558,14 @@ + - + @@ -425,8 +585,59 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Example/Example/BatchesViewController.swift b/Example/Example/BatchesViewController.swift new file mode 100644 index 0000000..d8f786a --- /dev/null +++ b/Example/Example/BatchesViewController.swift @@ -0,0 +1,67 @@ +// +// For credits and licence check the LICENSE file included in this package. +// (c) CombineOpenSource, Created by Marin Todorov. +// + +import UIKit +import Combine +import CombineDataSources + +struct MockAPI { + static func requestPage(pageNumber: Int) -> AnyPublisher.LoadResult, Error> { + // Do your network request or otherwise fetch items here. + return sampleData(.pages) + } + + static func requestBatch(token: Data?) -> AnyPublisher.LoadResult, Error> { + // Do your network request or otherwise fetch items here. + return sampleData(.batches) + } +} + +class BatchesViewController: UIViewController { + @IBOutlet var tableView: UITableView! + + enum Demo: Int, RawRepresentable { + case pages, batchesWithToken + } + + var demo: Demo! + var controller: TableViewBatchesController! + + override func viewDidLoad() { + super.viewDidLoad() + + // Create a plain table data source. + let itemsController = TableViewItemsController<[[String]]>(cellIdentifier: "Cell", cellType: UITableViewCell.self, cellConfig: { cell, indexPath, text in + cell.textLabel!.text = "\(indexPath.row+1). \(text)" + }) + + switch demo { + case .batchesWithToken: + + // Bind a batched data source to table view. + controller = TableViewBatchesController( + tableView: tableView, + itemsController: itemsController, + initialToken: nil, + loadItemsWithToken: { nextToken in + MockAPI.requestBatch(token: nextToken) + } + ) + + case .pages: + + // Bind a paged data source to table view. + controller = TableViewBatchesController( + tableView: tableView, + itemsController: itemsController, + loadPage: { nextPage in + return MockAPI.requestPage(pageNumber: nextPage) + } + ) + + default: break + } + } +} diff --git a/Example/Example/CollectionViewController.swift b/Example/Example/CollectionViewController.swift index 1843276..5248fd0 100644 --- a/Example/Example/CollectionViewController.swift +++ b/Example/Example/CollectionViewController.swift @@ -25,6 +25,10 @@ class PersonCollectionCell: UICollectionViewCell { } class CollectionViewController: UIViewController { + enum Demo: Int, RawRepresentable { + case plain, multiple, sections, noAnimations + } + @IBOutlet var collectionView: UICollectionView! // The kind of demo to show diff --git a/Example/Example/CustomBatchesViewController.swift b/Example/Example/CustomBatchesViewController.swift new file mode 100644 index 0000000..2120bc7 --- /dev/null +++ b/Example/Example/CustomBatchesViewController.swift @@ -0,0 +1,138 @@ + +import UIKit +import Combine +import CombineDataSources + +// An example custom token type. +struct ServerToken: Codable { + let id: UUID + let count: Int +} + +enum APIError: LocalizedError { + case test + var errorDescription: String? { + return "Request failed, try again." + } +} + +var requestsCounter = 0 + +extension MockAPI { + // An example of some custom token logic - for this demo we use a JSON struct that holds + // a custom UUID and the count of elements to fetch in the current batch. + static func requestBatchCustomToken(_ token: Data?) -> AnyPublisher.LoadResult, Error> { + let serverToken: ServerToken? = token.map { try! JSONDecoder().decode(ServerToken.self, from: $0) } + // Do network request, database lookup, etc. here + return Future.LoadResult, Error> { promise in + DispatchQueue.main.asyncAfter(deadline: .now() + 2) { + let currentBatchCount = serverToken?.count ?? 2 + let nextToken = ServerToken(id: UUID(), count: currentBatchCount * 2) + let items = (0.. 0 else { + // Return a test error + promise(.failure(APIError.test)) + return + } + + guard currentBatchCount < 50 else { + // No more items to fetch + promise(.success(.completed)) + return + } + + // Return the current batch items + the token to fetch the next batch. + promise(.success(.itemsToken(items, nextToken: try! JSONEncoder().encode(nextToken)))) + } + }.eraseToAnyPublisher() + } +} + +class CustomBatchesViewController: UIViewController { + @IBOutlet var itemsLabel: UILabel! + @IBOutlet var statusLabel: UILabel! + @IBOutlet var loadNextButton: UIButton! + @IBOutlet var resetButton: UIButton! + + var batcher: BatchesDataSource! + var subscriptions = [AnyCancellable]() + + let loadNextSubject = PassthroughSubject() + let resetSubject = PassthroughSubject() + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + + let input = BatchesInput( + reload: resetSubject.eraseToAnyPublisher(), + loadNext: loadNextSubject.eraseToAnyPublisher() + ) + + batcher = BatchesDataSource( + items: ["Initial Element"], + input: input, + initialToken: nil, + loadItemsWithToken: { token in + return MockAPI.requestBatchCustomToken(token) + }) + + // Bind Items label + batcher.output.$items + .map { "\($0.count) items fetched" } + .assign(to: \.text, on: itemsLabel) + .store(in: &subscriptions) + + // Bind Status label + Publishers.MergeMany([ + // Status: is loading + batcher.output.$isLoading.filter { $0 } + .map { _ in "Loading batch..." }.eraseToAnyPublisher(), + + // Status: is completed + batcher.output.$isCompleted.filter { $0 } + .map { _ in "Fetched all items available" }.eraseToAnyPublisher(), + + // Status: successfull fetch + Publishers.CombineLatest3(batcher.output.$isLoading, batcher.output.$isCompleted, batcher.output.$error) + .filter { !$0 && !$1 && $2 == nil} + .map { _ in "Fetched succcessfully" } + .eraseToAnyPublisher(), + + // Status: error + batcher.output.$error + .filter { $0 != nil } + .map { $0?.localizedDescription } + .eraseToAnyPublisher() + ]) + .assign(to: \.text, on: statusLabel) + .store(in: &subscriptions) + + // Bind Load next button alpha + Publishers.CombineLatest(batcher.output.$isLoading, batcher.output.$isCompleted) + .map { $0 || $1 ? 0.5 : 1.0 } + .assign(to: \.alpha, on: loadNextButton) + .store(in: &subscriptions) + + // Bind Load next is enabled + Publishers.CombineLatest(batcher.output.$isLoading, batcher.output.$isCompleted) + .map { !($0 || $1) } + .assign(to: \.isEnabled, on: loadNextButton) + .store(in: &subscriptions) + + // Bind Reset button + batcher.output.$isLoading + .map { !$0 } + .assign(to: \.isEnabled, on: resetButton) + .store(in: &subscriptions) + } + + @IBAction func loadNext() { + loadNextSubject.send() + } + + @IBAction func reset() { + resetSubject.send() + } +} diff --git a/Example/Example/GitHubSearchViewController.swift b/Example/Example/GitHubSearchViewController.swift index 4de475d..6acb4fd 100644 --- a/Example/Example/GitHubSearchViewController.swift +++ b/Example/Example/GitHubSearchViewController.swift @@ -30,7 +30,7 @@ class GitHubSearchViewController: UIViewController, UISearchBarDelegate { .map { $0.items } .replaceError(with: []) .receive(on: DispatchQueue.main) - .subscribe(retaining: tableView.rowsSubscriber(cellIdentifier: "Cell", cellType: UITableViewCell.self, cellConfig: { (cell, ip, repo) in + .bind(subscriber: tableView.rowsSubscriber(cellIdentifier: "Cell", cellType: UITableViewCell.self, cellConfig: { (cell, ip, repo) in cell.textLabel!.text = repo.name cell.detailTextLabel!.text = repo.description })) diff --git a/Example/Example/MenuTableViewController.swift b/Example/Example/MenuTableViewController.swift index fcaa994..9e2cdd0 100644 --- a/Example/Example/MenuTableViewController.swift +++ b/Example/Example/MenuTableViewController.swift @@ -8,7 +8,9 @@ import UIKit class MenuTableViewController: UITableViewController { override func prepare(for segue: UIStoryboardSegue, sender: Any?) { let rowIndex = (sender as! UITableViewCell).tag - (segue.destination as? ViewController)?.demo = Demo(rawValue: rowIndex)! - (segue.destination as? CollectionViewController)?.demo = Demo(rawValue: rowIndex)! + + (segue.destination as? ViewController)?.demo = ViewController.Demo(rawValue: rowIndex)! + (segue.destination as? CollectionViewController)?.demo = CollectionViewController.Demo(rawValue: rowIndex)! + (segue.destination as? BatchesViewController)?.demo = BatchesViewController.Demo(rawValue: rowIndex)! } } diff --git a/Example/Example/ViewController.swift b/Example/Example/ViewController.swift index 171c4a2..da4735f 100644 --- a/Example/Example/ViewController.swift +++ b/Example/Example/ViewController.swift @@ -15,11 +15,11 @@ class PersonCell: UITableViewCell { @IBOutlet var nameLabel: UILabel! } -enum Demo: Int, RawRepresentable { - case plain, multiple, sections, noAnimations -} - class ViewController: UIViewController { + enum Demo: Int, RawRepresentable { + case plain, multiple, sections, noAnimations + } + @IBOutlet var tableView: UITableView! // The kind of demo to show @@ -37,6 +37,7 @@ class ViewController: UIViewController { // Publisher to emit data to the table var data = PassthroughSubject<[[Person]], Never>() + var subscriptions = [AnyCancellable]() private var flag = false @@ -56,18 +57,19 @@ class ViewController: UIViewController { switch demo { case .plain: // A plain list with a single section -> Publisher<[Person], Never> - data - .map { $0[0] } - .subscribe(tableView.rowsSubscriber(cellIdentifier: "Cell", cellType: PersonCell.self, cellConfig: { cell, indexPath, model in + first.publisher + .bind(subscriber: tableView.rowsSubscriber(cellIdentifier: "Cell", cellType: PersonCell.self, cellConfig: { cell, indexPath, model in cell.nameLabel.text = "\(indexPath.section+1).\(indexPath.row+1) \(model.name)" })) + .store(in: &subscriptions) case .multiple: // Table with sections -> Publisher<[[Person]], Never> data - .subscribe(tableView.sectionsSubscriber(cellIdentifier: "Cell", cellType: PersonCell.self, cellConfig: { cell, indexPath, model in + .bind(subscriber: tableView.sectionsSubscriber(cellIdentifier: "Cell", cellType: PersonCell.self, cellConfig: { cell, indexPath, model in cell.nameLabel.text = "\(indexPath.section+1).\(indexPath.row+1) \(model.name)" })) + .store(in: &subscriptions) case .sections: // Table with section driven by `Section` models -> Publisher<[Section], Never> @@ -77,9 +79,10 @@ class ViewController: UIViewController { return Section(header: "Header", items: persons, footer: "Footer") } } - .subscribe(tableView.sectionsSubscriber(cellIdentifier: "Cell", cellType: PersonCell.self, cellConfig: { cell, indexPath, model in + .bind(subscriber: tableView.sectionsSubscriber(cellIdentifier: "Cell", cellType: PersonCell.self, cellConfig: { cell, indexPath, model in cell.nameLabel.text = "\(indexPath.section+1).\(indexPath.row+1) \(model.name)" })) + .store(in: &subscriptions) case .noAnimations: // Use custom controller to disable animations @@ -89,7 +92,8 @@ class ViewController: UIViewController { controller.animated = false data - .subscribe(tableView.sectionsSubscriber(controller)) + .bind(subscriber: tableView.sectionsSubscriber(controller)) + .store(in: &subscriptions) } reload() diff --git a/Example/Example/etc/SampleData.swift b/Example/Example/etc/SampleData.swift new file mode 100644 index 0000000..eb7a517 --- /dev/null +++ b/Example/Example/etc/SampleData.swift @@ -0,0 +1,24 @@ + +import Foundation +import Combine +import CombineDataSources + +enum SampleDataType { + case pages, batches +} + +func sampleData(_ type: SampleDataType, count: Int = 20) -> AnyPublisher.LoadResult, Error> { + return Future.LoadResult, Error> { promise in + DispatchQueue.main.asyncAfter(deadline: .now() + 2) { + switch type { + case .pages: + promise(.success(BatchesDataSource.LoadResult.items((0..() data - .subscribe(subscriber: tableView.rowsSubscriber(cellIdentifier: "Cell", cellType: PersonCell.self, cellConfig: { cell, indexPath, model in + .bind(subscriber: tableView.rowsSubscriber(cellIdentifier: "Cell", cellType: PersonCell.self, cellConfig: { cell, indexPath, model in cell.nameLabel.text = model.name })) + .store(in: &subscriptions) ``` ![Plain list updates with CombineDataSources](https://github.com/combineopensource/CombineDataSources/raw/master/Assets/plain-list.gif) @@ -47,10 +50,11 @@ Respectively for a collection view: ```swift data - .subscribe(collectionView.itemsSubscriber(cellIdentifier: "Cell", cellType: PersonCollectionCell.self, cellConfig: { cell, indexPath, model in + .bind(subscriber: collectionView.itemsSubscriber(cellIdentifier: "Cell", cellType: PersonCollectionCell.self, cellConfig: { cell, indexPath, model in cell.nameLabel.text = model.name cell.imageURL = URL(string: "https://api.adorable.io/avatars/100/\(model.name)")! })) + .store(in: &subscriptions) ``` ![Plain list updates for a collection view](https://github.com/combineopensource/CombineDataSources/raw/master/Assets/plain-collection.gif) @@ -61,9 +65,10 @@ data var data = PassthroughSubject<[Section], Never>() data - .subscribe(subscriber: tableView.sectionsSubscriber(cellIdentifier: "Cell", cellType: PersonCell.self, cellConfig: { cell, indexPath, model in + .bind(subscriber: tableView.sectionsSubscriber(cellIdentifier: "Cell", cellType: PersonCell.self, cellConfig: { cell, indexPath, model in cell.nameLabel.text = model.name })) + .store(in: &subscriptions) ``` ![Sectioned list updates with CombineDataSources](https://github.com/combineopensource/CombineDataSources/raw/master/Assets/sections-list.gif) @@ -81,26 +86,77 @@ controller.animated = false // More custom controller configuration ... data - .subscribe(subscriber: tableView.sectionsSubscriber(controller)) + .subscribe(bind: tableView.sectionsSubscriber(controller)) + .store(in: &subscriptions) ``` -#### Subscribing a completing publisher +#### List loaded in batches + +A common pattern for list views is to load a very long list of elements in "batches" or "pages". (The distinction being that pages imply ordered, equal-length batches.) -Sometimes you'll bind a publisher to your table or collection view and it will complete at a point. When you use `subscribe(_)` the completion event will release the CombineDataSource subscriber as well and that will likely render the table/collection empty. +**CombineDataSources** includes a data source allowing you to easily implement the batched list pattern called `BatchesDataSource` and a table view controller `TableViewBatchesController` which wraps loading items in batches via the said data source and managing your UI. -In such case you can use the custom operator included in **CombineDataSources** `subscribe(retaining:)` that will give you an `AnyCancellable` to retain the subscriber, like so: +In case you want to implement your own custom logic, you can use directly the data source type: ```swift -var subscriptions = [AnyCancellable]() -... -Just([Person(name: "test"]) - .subscribe(retaining: tableView.rowsSubscriber(cellIdentifier: "Cell", cellType: UITableViewCell.self, cellConfig: { (cell, ip, person) in - cell.textLabel!.text = person.name - })) - .store(in: &subscriptions) +let input = BatchesInput( + reload: resetSubject.eraseToAnyPublisher(), + loadNext: loadNextSubject.eraseToAnyPublisher() +) + +let dataSource = BatchesDataSource( + items: ["Initial Element"], + input: input, + initialToken: nil, + loadItemsWithToken: { token in + return MockAPI.requestBatchCustomToken(token) + }) +``` + +`dataSource` is controlled via the two inputs: + +- `input.reload` (to reload the very first batch) and + +- `loadNext` (to load each next batch) + + The data source has four outputs: + +- `output.$items` is the current list of elements, + +- `output.$isLoading` whether it's currently fetching a batch of elements, + +- `output.$isCompleted` whether the data source fetched all available elements, and + +- `output.$error` which is a stream of `Error?` elements where errors by the loading closure will bubble up. + +In case you'd like to use the provided controller the code is fairly simple as well. You use the standard table view items controller and `TableViewBatchesController` like so: + +```swift +let itemsController = TableViewItemsController<[[String]]>(cellIdentifier: "Cell", cellType: UITableViewCell.self, cellConfig: { cell, indexPath, text in + cell.textLabel!.text = "\(indexPath.row+1). \(text)" +}) + +let tableController = TableViewBatchesController( + tableView: tableView, + itemsController: itemsController, + initialToken: nil, + loadItemsWithToken: { nextToken in + MockAPI.requestBatch(token: nextToken) + } +) ``` -This will keep the subscriber and the data source alive until you cancel the subscription manually or it is released from memory. +`tableController` will set the table view data source, fetch items, and display cells with the proper animations. + +## Todo + +- [ ] much better README, pls +- [ ] use a @Published for the time being instead of withLatestFrom +- [ ] make the batches data source prepend or append the new batch (e.g. new items come from the top or at the bottom) +- [ ] cover every API with tests +- [ ] make the default batches view controller neater +- [ ] add AppKit version of the data sources +- [ ] support Cocoapods ## Installation @@ -111,10 +167,19 @@ Add the following dependency to your **Package.swift** file: ```swift .package(url: "https://github.com/combineopensource/CombineDataSources, from: "0.2") ``` + ## License CombineOpenSource is available under the MIT license. See the LICENSE file for more info. +## Combine Open Source + +![Combine Slack channel](Assets/slack.png) + +CombineOpenSource Slack channel: [https://combineopensource.slack.com](https://combineopensource.slack.com). + +[Sign up here](https://join.slack.com/t/combineopensource/shared_invite/enQtNzQ1MzYyMTMxOTkxLWJkZmNkZDU4MTE4NmU2MjBhYzM5NzI1NTRlNWNhODFiMDEyMjVjOWZmZWI2NmViMzU3ZjZhYjc0YTExOGZmMDM) + ## Credits Created by Marin Todorov for [CombineOpenSource](https://github.com/combineopensource). diff --git a/Sources/CombineDataSources/BatchesDataSource/BatchesDataSource.swift b/Sources/CombineDataSources/BatchesDataSource/BatchesDataSource.swift new file mode 100644 index 0000000..87db099 --- /dev/null +++ b/Sources/CombineDataSources/BatchesDataSource/BatchesDataSource.swift @@ -0,0 +1,262 @@ +// +// For credits and licence check the LICENSE file included in this package. +// (c) CombineOpenSource, Created by Marin Todorov. +// + +/* + Data flow in BatchesDataSource: + Dashed boxes represent the inputs provided to `BatchesDataSource.init(...)`. + Single line boxes are the intermediate publishers. + Double line boxes are the published outputs. + + ┌──────────────────────┐ ╔════════════════════╗ + ┌──────────────────────▶│ itemsSubject │──────────────────▶║ Output.$items ║◀───┐ + │ └──────────────────────┘ ╚════════════════════╝ │ + │ ╔════════════════════╗ │ + │ ┌──────────────────────┬──────────────────▶║ Output.$isLoading ║ │ + │ │ │ ╚════════════════════╝ │ + │ │ │ │ + │ ┌───────────────────┐ ┌───────────────────┐ ┌───────────────────┐ ┌───────────────────┐ + ┌──────────────┐ │ │ │ │ │ │ │ │ + ┌─┬──▶│ reload │──┬──▶│ batchRequest │─▶│ batchResponse │─▶│ successResponse │─▶│ result │ + │ │ └──────────────┘ │ │ │ │ │ │ │ │ │ + │ │ │ └───────────────────┘ └───────────────────┘ └───────────────────┘ └───────────────────┘ + │ │ ┌──────────────┐ ▲ │ │ │ + │ │ │ loadNext │ └───────┐ │ │ │ + │ │ └──────────────┘ │ │ ┌─────┘ │ + │ │ ▲ │ │ │ │ + │ │ │ ┌ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ │ │ ╔════════════════════╗ │ + │ │ ┌ ─ ─ ─ ─ ─ ─ ─ │ loadNextBatch() │ │ └─▶║Output.$isCompleted ║ │ + │ └── initialToken │ │ └ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ │ ╚════════════════════╝ │ + │ └ ─ ─ ─ ─ ─ ─ ─ │ │ ╔════════════════════╗ │ + │ │ └──────────────────▶║ Output.$error ║ │ + │ ┌ ─ ─ ─ ─ ─ ─ ─ │ ╚════════════════════╝ │ + └── items │ │ ┌──────────────────┐ │ + └ ─ ─ ─ ─ ─ ─ ─ └─────────────────────────────│ token │◀────────────────────────────────┘ + └──────────────────┘ + */ + +import Foundation +import Combine + +/// Batches source input. Provides two publishers to control requesting the next batch +/// of items and resetting the items collection. +public struct BatchesInput { + public init(reload: AnyPublisher? = nil, loadNext: AnyPublisher) { + self.reload = reload ?? Empty().eraseToAnyPublisher() + self.loadNext = loadNext + } + + /// Resets the list and loads the initial list of items. + public let reload: AnyPublisher + + /// Loads the next batch of items. + public let loadNext: AnyPublisher +} + +/// Manages a list of items in batches or pages. +public struct BatchesDataSource { + internal let input: BatchesInput + + public class Output { + /// Is the data source currently fetching a batch of items. + @Published public var isLoading = false + + /// Is the data source loaded all available items. + @Published public var isCompleted = false + + /// The list of items fetched so far. + @Published public var items = [Element]() + + /// The last error while fetching a batch of items. + @Published public var error: Error? = nil + } + + /// The current output of the data source. + public let output = Output() + + private var subscriptions = [AnyCancellable]() + + /// The result of loading of a batch of items. + public enum LoadResult { + /// A batch of `Element` items to use with pages. + case items([Element]) + + /// A batch of `Element` items and a token to provide + /// to the loader in order to fetch the next batch. + case itemsToken([Element], nextToken: Data?) + + /// No more items available to fetch. + case completed + } + + enum ResponseResult { + case result((token: Token, result: BatchesDataSource.LoadResult)) + case error(Error) + } + + enum Token { + case int(Int) + case data(Data?) + } + + private init(items: [Element] = [], input: BatchesInput, initial: Token, loadNextCallback: @escaping (Token) -> AnyPublisher) { + let itemsSubject = CurrentValueSubject<[Element], Never>(items) + let token = CurrentValueSubject(initial) + + self.input = input + let output = self.output + + input.reload + .map { _ in items } + .append(Empty(completeImmediately: false)) + .subscribe(itemsSubject) + .store(in: &subscriptions) + + let loadNext = input.loadNext + .map { token.value } + + let batchRequest = loadNext + .merge(with: input.reload.prepend(()).map { initial }) + .eraseToAnyPublisher() + + // TODO: avoid having extra subject when `shareReplay()` is introduced. + let batchResponse = PassthroughSubject() + + batchResponse + .map { result -> Error? in + switch result { + case .error(let error): return error + default: return nil + } + } + .assign(to: \Output.error, on: output) + .store(in: &subscriptions) + + // Bind `Output.isLoading` + Publishers.Merge(batchRequest.map { _ in true }, batchResponse.map { _ in false }) + .assign(to: \Output.isLoading, on: output) + .store(in: &subscriptions) + + let successResponse = batchResponse + .compactMap { result -> (token: Token, result: BatchesDataSource.LoadResult)? in + switch result { + case .result(let result): return result + default: return nil + } + } + .share() + + // Bind `Output.isCompleted` + successResponse + .map { tuple -> Bool in + switch tuple.result { + case .completed: return true + default: return false + } + } + .assign(to: \Output.isCompleted, on: output) + .store(in: &subscriptions) + + let result = successResponse + .compactMap { tuple -> (token: Token, items: [Element], nextToken: Token)? in + switch tuple.result { + case .completed: + return nil + case .items(let elements): + // Fix incremeneting page number + guard case Token.int(let currentPage) = tuple.token else { fatalError() } + return (token: tuple.token, items: elements, nextToken: .int(currentPage+1)) + case .itemsToken(let elements, let nextToken): + return (token: tuple.token, items: elements, nextToken: .data(nextToken)) + } + } + .share() + + // Bind `token` + result + .map { $0.nextToken } + .subscribe(token) + .store(in: &subscriptions) + + // Bind `items` + result + .map { + // TODO: Solve for `withLatestFrom(_)` + let currentItems = itemsSubject.value + return currentItems + $0.items + } + .subscribe(itemsSubject) + .store(in: &subscriptions) + + // Bind `Output.items` + itemsSubject + .assign(to: \Output.items, on: output) + .store(in: &subscriptions) + + batchRequest + .flatMap { token in + return loadNextCallback(token) + .map { result -> ResponseResult in + return .result((token: token, result: result)) + } + .catch { error in + Just(ResponseResult.error(error)) + } + .append(Empty(completeImmediately: true)) + } + .sink(receiveValue: batchResponse.send) + .store(in: &subscriptions) + + } + + /// Initializes a list data source using a token to fetch batches of items. + /// - Parameter items: initial list of items. + /// - Parameter input: the input to control the data source. + /// - Parameter initialToken: the token to use to fetch the first batch. + /// - Parameter loadItemsWithToken: a `(Data?) -> (Publisher)` closure that fetches a batch of items and returns the items fetched + /// plus a token to use for the next batch. The token can be an alphanumerical id, a URL, or another type of token. + /// - Todo: if `withLatestFrom` is introduced, use it instead of grabbing the latest value unsafely. + public init(items: [Element] = [], input: BatchesInput, initialToken: Data?, loadItemsWithToken: @escaping (Data?) -> AnyPublisher) { + self.init(items: items, input: input, initial: Token.data(initialToken), loadNextCallback: { token -> AnyPublisher in + switch token { + case .data(let data): + return loadItemsWithToken(data) + default: fatalError() + } + }) + } + + /// Initialiazes a list data source of items batched in numbered pages. + /// - Parameter items: initial list of items. + /// - Parameter input: the input to control the data source. + /// - Parameter initialPage: the page number to use for the first load of items. + /// - Parameter loadPage: a `(Int) -> (Publisher)` closure that fetches a batch of items. + /// - Todo: if `withLatestFrom` is introduced, use it instead of grabbing the latest value unsafely. + public init(items: [Element] = [], input: BatchesInput, initialPage: Int = 0, loadPage: @escaping (Int) -> AnyPublisher) { + self.init(items: items, input: input, initial: Token.int(initialPage), loadNextCallback: { page -> AnyPublisher in + switch page { + case .int(let page): + return loadPage(page) + default: fatalError() + } + }) + } +} + +fileprivate var uuids = [String: Int]() + +extension Publisher { + public func assertMaxSubscriptions(_ max: Int, file: StaticString = #file, line: UInt = #line) -> AnyPublisher { + let uuid = "\(file):\(line)" + + return handleEvents(receiveSubscription: { _ in + let count = uuids[uuid] ?? 0 + guard count < max else { + assert(false, "Publisher subscribed more than \(max) times.") + return + } + uuids[uuid] = count + 1 + }).eraseToAnyPublisher() + } +} diff --git a/Sources/CombineDataSources/CollectionViewItemsController.swift b/Sources/CombineDataSources/CollectionView/CollectionViewItemsController.swift similarity index 100% rename from Sources/CombineDataSources/CollectionViewItemsController.swift rename to Sources/CombineDataSources/CollectionView/CollectionViewItemsController.swift diff --git a/Sources/CombineDataSources/UICollectionView+Subscribers.swift b/Sources/CombineDataSources/CollectionView/UICollectionView+Subscribers.swift similarity index 100% rename from Sources/CombineDataSources/UICollectionView+Subscribers.swift rename to Sources/CombineDataSources/CollectionView/UICollectionView+Subscribers.swift diff --git a/Sources/CombineDataSources/Publisher+SubscribeRetaining.swift b/Sources/CombineDataSources/Publisher+SubscribeRetaining.swift deleted file mode 100644 index 9d799cf..0000000 --- a/Sources/CombineDataSources/Publisher+SubscribeRetaining.swift +++ /dev/null @@ -1,19 +0,0 @@ -// -// For credits and licence check the LICENSE file included in this package. -// (c) CombineOpenSource, Created by Marin Todorov. -// - -import Foundation -import Combine - -public extension Publisher where Failure == Never { - func subscribe(retaining subscriber: S) -> AnyCancellable - where S.Failure == Never, S.Input == Output { - - sink(receiveCompletion: { (completion) in - subscriber.receive(completion: completion) - }) { (value) in - _ = subscriber.receive(value) - } - } -} diff --git a/Sources/CombineDataSources/TableView/TableViewBatchesController.swift b/Sources/CombineDataSources/TableView/TableViewBatchesController.swift new file mode 100644 index 0000000..2201ae6 --- /dev/null +++ b/Sources/CombineDataSources/TableView/TableViewBatchesController.swift @@ -0,0 +1,127 @@ +// +// For credits and licence check the LICENSE file included in this package. +// (c) CombineOpenSource, Created by Marin Todorov. +// + +import UIKit +import Combine + +public class TableViewBatchesController { + // Input + public let reload = PassthroughSubject() + public let loadNext = PassthroughSubject() + + // Output + public let loadError = CurrentValueSubject(nil) + + // Private user interface + private let tableView: UITableView + private var batchesDataSource: BatchesDataSource! + private var spin: UIActivityIndicatorView = { + let spin = UIActivityIndicatorView(style: .large) + spin.tintColor = .systemGray + spin.startAnimating() + spin.alpha = 0 + return spin + }() + + private var itemsController: TableViewItemsController<[[Element]]>! + private var subscriptions = [AnyCancellable]() + + public convenience init(tableView: UITableView, itemsController: TableViewItemsController<[[Element]]>, initialToken: Data?, loadItemsWithToken: @escaping (Data?) -> AnyPublisher.LoadResult, Error>) { + self.init(tableView: tableView) + + // Create a token-based batched data source. + batchesDataSource = BatchesDataSource( + input: BatchesInput(reload: reload.eraseToAnyPublisher(), loadNext: loadNext.eraseToAnyPublisher()), + initialToken: initialToken, + loadItemsWithToken: loadItemsWithToken + ) + + self.itemsController = itemsController + + bind() + } + + public convenience init(tableView: UITableView, itemsController: TableViewItemsController<[[Element]]>, loadPage: @escaping (Int) -> AnyPublisher.LoadResult, Error>) { + self.init(tableView: tableView) + + // Create a paged data source. + self.batchesDataSource = BatchesDataSource( + input: BatchesInput(reload: reload.eraseToAnyPublisher(), loadNext: loadNext.eraseToAnyPublisher()), + loadPage: loadPage + ) + + self.itemsController = itemsController + + bind() + } + + private init(tableView: UITableView) { + self.tableView = tableView + + // Add bottom offset. + var newInsets = tableView.contentInset + newInsets.bottom += 60 + tableView.contentInset = newInsets + + // Add spinner. + tableView.addSubview(spin) + } + + private func bind() { + // Display items in table view. + batchesDataSource.output.$items + .receive(on: DispatchQueue.main) + .bind(subscriber: tableView.rowsSubscriber(itemsController)) + .store(in: &subscriptions) + + // Show/hide spinner. + batchesDataSource.output.$isLoading + .receive(on: DispatchQueue.main) + .sink { [weak self] isLoading in + guard let self = self else { return } + if isLoading { + self.spin.center = CGPoint(x: self.tableView.frame.width/2, y: self.tableView.contentSize.height + 30) + self.spin.alpha = 1 + self.tableView.scrollRectToVisible(CGRect(x: 0, y: self.tableView.contentOffset.y + self.tableView.frame.height, width: 10, height: 10), animated: true) + } else { + self.spin.alpha = 0 + } + } + .store(in: &subscriptions) + + // Bind errors. + batchesDataSource.output.$error + .subscribe(loadError) + .store(in: &subscriptions) + + // Observe for table dragging. + let didDrag = Publishers.CombineLatest(Just(tableView), tableView.publisher(for: \.contentOffset)) + .map { $0.0.isDragging } + .scan((from: false, to: false)) { result, value -> (from: Bool, to: Bool) in + return (from: result.to, to: value) + } + .filter { tuple -> Bool in + tuple == (from: true, to: false) + } + + // Observe table offset and trigger loading next page at bottom + Publishers.CombineLatest(Just(tableView), didDrag) + .map { $0.0 } + .filter { table -> Bool in + return isAtBottom(of: table) + } + .sink { [weak self] _ in + self?.loadNext.send() + } + .store(in: &subscriptions) + } +} + +fileprivate func isAtBottom(of tableView: UITableView) -> Bool { + let height = tableView.frame.size.height + let contentYoffset = tableView.contentOffset.y + let distanceFromBottom = tableView.contentSize.height - contentYoffset + return distanceFromBottom <= height +} diff --git a/Sources/CombineDataSources/TableViewItemsController.swift b/Sources/CombineDataSources/TableView/TableViewItemsController.swift similarity index 92% rename from Sources/CombineDataSources/TableViewItemsController.swift rename to Sources/CombineDataSources/TableView/TableViewItemsController.swift index d4e55c5..5bff65c 100644 --- a/Sources/CombineDataSources/TableViewItemsController.swift +++ b/Sources/CombineDataSources/TableView/TableViewItemsController.swift @@ -60,6 +60,18 @@ public class TableViewItemsController: NSObject, UITableViewData private let fromRow = {(section: Int) in return {(row: Int) in return IndexPath(row: row, section: section)}} func updateCollection(_ items: CollectionType) { + // Initial collection + if collection == nil, animated { + tableView.beginUpdates() + tableView.insertSections(IndexSet(integersIn: 0..(subscriber: B) -> AnyCancellable + where B.Failure == Never, B.Input == Output { + + handleEvents(receiveSubscription: { subscription in + subscriber.receive(subscription: subscription) + }) + .sink { value in + _ = subscriber.receive(value) + } + } +} diff --git a/Sources/CombineDataSources/Section.swift b/Sources/CombineDataSources/etc/Section.swift similarity index 100% rename from Sources/CombineDataSources/Section.swift rename to Sources/CombineDataSources/etc/Section.swift diff --git a/Tests/CombineDataSourcesTests/BatchesDataSource/BatchesDataSourceTests.swift b/Tests/CombineDataSourcesTests/BatchesDataSource/BatchesDataSourceTests.swift new file mode 100644 index 0000000..c21e08b --- /dev/null +++ b/Tests/CombineDataSourcesTests/BatchesDataSource/BatchesDataSourceTests.swift @@ -0,0 +1,286 @@ +import Foundation +import Combine +import XCTest + +@testable import CombineDataSources + +final class BatchesDataSourceTests: XCTestCase { + var input: BatchesInput { + BatchesInput(loadNext: PassthroughSubject().eraseToAnyPublisher()) + } + + var inputControls: (input: BatchesInput, reload: PassthroughSubject, loadNext: PassthroughSubject) { + let reload = PassthroughSubject() + let loadNext = PassthroughSubject() + let input = BatchesInput(reload: reload.eraseToAnyPublisher(), loadNext: loadNext.eraseToAnyPublisher()) + return (input: input, reload: reload, loadNext: loadNext) + } + + func testInitialState() { + let batcher = BatchesDataSource(input: input) { page in + return Empty.LoadResult, Error>().eraseToAnyPublisher() + } + + XCTAssertEqual(batcher.output.isLoading, true) + XCTAssertEqual(batcher.output.isCompleted, false) + XCTAssertTrue(batcher.output.items.isEmpty) + XCTAssertNil(batcher.output.error) + } + + func testInitialItems() { + let testStrings = ["test1", "test2"] + let batcher = BatchesDataSource(items: testStrings, input: input) { page in + return Empty.LoadResult, Error>().eraseToAnyPublisher() + } + + XCTAssertEqual(batcher.output.items, testStrings) + } + + func testInitialLoadSynchronous() { + let testStrings = ["test1", "test2"] + var subscriptions = [AnyCancellable]() + + let batcher = BatchesDataSource(items: testStrings, input: input) { page in + return Just.LoadResult>(.items(["test3"])) + .setFailureType(to: Error.self) + .eraseToAnyPublisher() + } + + let controlEvent = expectation(description: "Wait for control event") + + batcher.output.$items + .prefix(1) + .collect() + .sink(receiveCompletion: { _ in + controlEvent.fulfill() + }) { values in + XCTAssertEqual([testStrings + ["test3"]], values) + } + .store(in: &subscriptions) + + wait(for: [controlEvent], timeout: 1) + } + + func testInitialLoadAsynchronous() { + let testStrings = ["test1", "test2"] + var subscriptions = [AnyCancellable]() + + let batcher = BatchesDataSource(items: testStrings, input: input) { page in + return Future.LoadResult, Error> { promise in + DispatchQueue.main.async { + promise(.success(.items(["test3"]))) + } + }.eraseToAnyPublisher() + } + + let controlEvent = expectation(description: "Wait for control event") + + batcher.output.$items + .prefix(2) + .collect() + .sink(receiveCompletion: { _ in + controlEvent.fulfill() + }) { values in + XCTAssertEqual([testStrings, testStrings + ["test3"]], values) + } + .store(in: &subscriptions) + + wait(for: [controlEvent], timeout: 1) + } + + func testLoadNext() { + let testStrings = ["test1", "test2"] + var subscriptions = [AnyCancellable]() + + let inputControls = self.inputControls + + let batcher = BatchesDataSource(items: testStrings, input: inputControls.input) { page in + return Future.LoadResult, Error> { promise in + DispatchQueue.main.async { + promise(.success(.items(["test3"]))) + } + }.eraseToAnyPublisher() + } + + let controlEvent = expectation(description: "Wait for control event") + + batcher.output.$items + .dropFirst(2) + .prefix(2) + .collect() + .sink(receiveCompletion: { _ in + controlEvent.fulfill() + }) { values in + XCTAssertEqual([ + testStrings + ["test3", "test3"], + testStrings + ["test3", "test3", "test3"] + ], values) + } + .store(in: &subscriptions) + + DispatchQueue.global().async { + inputControls.loadNext.send() + } + DispatchQueue.global().async { + inputControls.loadNext.send() + } + + wait(for: [controlEvent], timeout: 1) + } + + func testReload() { + let testStrings = ["test1", "test2"] + var subscriptions = [AnyCancellable]() + + let inputControls = self.inputControls + + let batcher = BatchesDataSource(items: testStrings, input: inputControls.input) { page in + return Future.LoadResult, Error> { promise in + DispatchQueue.main.async { + promise(.success(.items(["test3"]))) + } + }.eraseToAnyPublisher() + } + + let controlEvent = expectation(description: "Wait for control event") + + batcher.output.$items + .dropFirst(2) + .prefix(2) + .collect() + .sink(receiveCompletion: { _ in + controlEvent.fulfill() + }) { values in + XCTAssertEqual([ + testStrings + ["test3"], + testStrings + ["test3", "test3"] + ], values) + } + .store(in: &subscriptions) + + DispatchQueue.global().async { + inputControls.reload.send() + inputControls.loadNext.send() + } + + wait(for: [controlEvent], timeout: 1) + } + + func testIsCompleted() { + var subscriptions = [AnyCancellable]() + let inputControls = self.inputControls + + var shouldComplete = false + + let batcher = BatchesDataSource(input: inputControls.input) { page in + return Future.LoadResult, Error> { promise in + DispatchQueue.main.async { + if shouldComplete { + promise(.success(.items(["test3"]))) + } else { + promise(.success(.completed)) + } + shouldComplete.toggle() + } + }.eraseToAnyPublisher() + } + + let controlEvent = expectation(description: "Wait for control event") + + batcher.output.$isCompleted + .prefix(3) + .collect() + .sink(receiveCompletion: { _ in + controlEvent.fulfill() + }) { values in + XCTAssertEqual([ + false, true, false + ], values) + } + .store(in: &subscriptions) + + DispatchQueue.global().async { + inputControls.loadNext.send() + inputControls.reload.send() + } + + wait(for: [controlEvent], timeout: 1) + } + + func testIsLoading() { + var subscriptions = [AnyCancellable]() + let inputControls = self.inputControls + + let batcher = BatchesDataSource(input: inputControls.input) { page in + return Future.LoadResult, Error> { promise in + DispatchQueue.main.async { + promise(.success(.items(["test3"]))) + } + }.eraseToAnyPublisher() + } + + let controlEvent = expectation(description: "Wait for control event") + + batcher.output.$isLoading + .prefix(4) + .collect() + .sink(receiveCompletion: { _ in + controlEvent.fulfill() + }) { values in + XCTAssertEqual([ + true, false, true, false + ], values) + } + .store(in: &subscriptions) + + DispatchQueue.global().asyncAfter(deadline: .now() + 0.25) { + inputControls.loadNext.send() + } + + wait(for: [controlEvent], timeout: 1) + } + + func testError() { + var subscriptions = [AnyCancellable]() + let inputControls = self.inputControls + + var shouldError = false + + let batcher = BatchesDataSource(input: inputControls.input) { page in + return Future.LoadResult, Error> { promise in + DispatchQueue.main.async { + if shouldError { + promise(.success(.items(["test3"]))) + } else { + promise(.failure(TestError.test)) + } + shouldError.toggle() + } + }.eraseToAnyPublisher() + } + + let controlEvent = expectation(description: "Wait for control event") + + batcher.output.$error + .prefix(4) + .collect() + .sink(receiveCompletion: { _ in + controlEvent.fulfill() + }) { values in + XCTAssertNil(values[0]) + XCTAssertNotNil(values[1] as? TestError) + XCTAssertNil(values[2]) + XCTAssertNotNil(values[3] as? TestError) + } + .store(in: &subscriptions) + + DispatchQueue.global().asyncAfter(deadline: .now() + 0.1) { + inputControls.loadNext.send() + } + DispatchQueue.global().asyncAfter(deadline: .now() + 0.2) { + inputControls.loadNext.send() + } + + wait(for: [controlEvent], timeout: 1) + } +} diff --git a/Tests/CombineDataSourcesTests/CollectionViewItemsControllerTests.swift b/Tests/CombineDataSourcesTests/CollectionView/CollectionViewItemsControllerTests.swift similarity index 100% rename from Tests/CombineDataSourcesTests/CollectionViewItemsControllerTests.swift rename to Tests/CombineDataSourcesTests/CollectionView/CollectionViewItemsControllerTests.swift diff --git a/Tests/CombineDataSourcesTests/UICollectionView+SubscribersTests.swift b/Tests/CombineDataSourcesTests/CollectionView/UICollectionView+SubscribersTests.swift similarity index 100% rename from Tests/CombineDataSourcesTests/UICollectionView+SubscribersTests.swift rename to Tests/CombineDataSourcesTests/CollectionView/UICollectionView+SubscribersTests.swift diff --git a/Tests/CombineDataSourcesTests/MemoryManagementTests.swift b/Tests/CombineDataSourcesTests/MemoryManagementTests.swift index 6b04ef0..fc197d7 100644 --- a/Tests/CombineDataSourcesTests/MemoryManagementTests.swift +++ b/Tests/CombineDataSourcesTests/MemoryManagementTests.swift @@ -1,5 +1,6 @@ import XCTest import UIKit +import Combine @testable import CombineDataSources final class MemoryManagementTests: XCTestCase { @@ -23,4 +24,35 @@ final class MemoryManagementTests: XCTestCase { dataSource = nil XCTAssertNotNil(ctr!.dataSource) } + + func testBind() { + let expectation1 = expectation(description: "subscribed") + let expectation2 = expectation(description: "value") + + var subscriptions = [AnyCancellable]() + var sub: AnySubscriber? + + sub = AnySubscriber( + receiveSubscription: { sub in + expectation1.fulfill() + }, + receiveValue: { value -> Subscribers.Demand in + expectation2.fulfill() + return .unlimited + }) + { (completion) in + XCTFail("Binding sent completion event") + } + + DispatchQueue.main.async { + let data = PassthroughSubject() + data + .bind(subscriber: sub!) + .store(in: &subscriptions) + + data.send("asdasd") // will be passed on + data.send(completion: .finished) // will be filtered + } + wait(for: [expectation1, expectation2], timeout: 1) + } } diff --git a/Tests/CombineDataSourcesTests/TableView/TableViewBatchesControllerTests.swift b/Tests/CombineDataSourcesTests/TableView/TableViewBatchesControllerTests.swift new file mode 100644 index 0000000..684cfd5 --- /dev/null +++ b/Tests/CombineDataSourcesTests/TableView/TableViewBatchesControllerTests.swift @@ -0,0 +1,15 @@ +// +// File.swift +// +// +// Created by Marin Todorov on 8/30/19. +// + +import UIKit +import Combine +import XCTest + +final class TableViewBatchesControllerTests: XCTestCase { + // TODO: add + +} diff --git a/Tests/CombineDataSourcesTests/TableViewItemsControllerTests.swift b/Tests/CombineDataSourcesTests/TableView/TableViewItemsControllerTests.swift similarity index 100% rename from Tests/CombineDataSourcesTests/TableViewItemsControllerTests.swift rename to Tests/CombineDataSourcesTests/TableView/TableViewItemsControllerTests.swift diff --git a/Tests/CombineDataSourcesTests/UITableView+SubscribersTests.swift b/Tests/CombineDataSourcesTests/TableView/UITableView+SubscribersTests.swift similarity index 100% rename from Tests/CombineDataSourcesTests/UITableView+SubscribersTests.swift rename to Tests/CombineDataSourcesTests/TableView/UITableView+SubscribersTests.swift diff --git a/Tests/CombineDataSourcesTests/TestFixtures.swift b/Tests/CombineDataSourcesTests/data/TestFixtures.swift similarity index 85% rename from Tests/CombineDataSourcesTests/TestFixtures.swift rename to Tests/CombineDataSourcesTests/data/TestFixtures.swift index d442fde..29e728c 100644 --- a/Tests/CombineDataSourcesTests/TestFixtures.swift +++ b/Tests/CombineDataSourcesTests/data/TestFixtures.swift @@ -9,7 +9,7 @@ import XCTest import CombineDataSources import UIKit -struct Model: Equatable { +struct Model: Hashable { var text: String } @@ -20,6 +20,10 @@ let dataSet2 = [ Section(header: "section header", items: [Model(text: "test model")], footer: "section footer") ] +func batch(of count: Int) -> [Model] { + (0.. Int { @@ -38,3 +42,6 @@ class TestDataSource: NSObject, UITableViewDataSource { } } +enum TestError: Error { + case test +}