From 25262baa2ebc97c6253469c564169933d90b07bc Mon Sep 17 00:00:00 2001 From: Marin Todorov Date: Sun, 25 Aug 2019 22:27:21 +0200 Subject: [PATCH 1/8] batched data source draft --- Example/Example.xcodeproj/project.pbxproj | 16 ++ Example/Example/Base.lproj/Main.storyboard | 120 ++++++++- Example/Example/BatchesViewController.swift | 67 +++++ .../Example/CollectionViewController.swift | 4 + Example/Example/MenuTableViewController.swift | 6 +- Example/Example/ViewController.swift | 8 +- Example/Example/etc/SampleData.swift | 24 ++ .../BatchesDataSource/BatchesDataSource.swift | 246 ++++++++++++++++++ .../CollectionViewItemsController.swift | 0 .../UICollectionView+Subscribers.swift | 0 .../TableViewBatchesController.swift | 127 +++++++++ .../TableViewItemsController.swift | 12 + .../UITableView+Subscribers.swift | 0 .../Publisher+SubscribeRetaining.swift | 0 .../{ => etc}/Section.swift | 0 15 files changed, 612 insertions(+), 18 deletions(-) create mode 100644 Example/Example/BatchesViewController.swift create mode 100644 Example/Example/etc/SampleData.swift create mode 100644 Sources/CombineDataSources/BatchesDataSource/BatchesDataSource.swift rename Sources/CombineDataSources/{ => CollectionView}/CollectionViewItemsController.swift (100%) rename Sources/CombineDataSources/{ => CollectionView}/UICollectionView+Subscribers.swift (100%) create mode 100644 Sources/CombineDataSources/TableView/TableViewBatchesController.swift rename Sources/CombineDataSources/{ => TableView}/TableViewItemsController.swift (92%) rename Sources/CombineDataSources/{ => TableView}/UITableView+Subscribers.swift (100%) rename Sources/CombineDataSources/{ => etc}/Publisher+SubscribeRetaining.swift (100%) rename Sources/CombineDataSources/{ => etc}/Section.swift (100%) diff --git a/Example/Example.xcodeproj/project.pbxproj b/Example/Example.xcodeproj/project.pbxproj index 11a19d0..44a23fe 100644 --- a/Example/Example.xcodeproj/project.pbxproj +++ b/Example/Example.xcodeproj/project.pbxproj @@ -9,6 +9,8 @@ /* 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 */; }; + 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 +24,8 @@ /* 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 = ""; }; + 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 +50,14 @@ /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ + 9CA01D1A2312F4C700666EDE /* etc */ = { + isa = PBXGroup; + children = ( + 9CA01D1B2312F4CF00666EDE /* SampleData.swift */, + ); + path = etc; + sourceTree = ""; + }; 9CB630BD22FD510000368A0D = { isa = PBXGroup; children = ( @@ -67,12 +79,14 @@ 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 */, 9CB630CF22FD510000368A0D /* Main.storyboard */, 9CB630D222FD510100368A0D /* Assets.xcassets */, 9CB630D422FD510100368A0D /* LaunchScreen.storyboard */, @@ -165,10 +179,12 @@ 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 */, + 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..43c5ab9 100644 --- a/Example/Example/Base.lproj/Main.storyboard +++ b/Example/Example/Base.lproj/Main.storyboard @@ -1,8 +1,8 @@ - + - + @@ -100,16 +100,36 @@ - + - + + + + + + + + + + + + + + + - - + + + + + + + + + + + + + + + + + + + @@ -127,7 +171,7 @@ - + @@ -147,7 +191,7 @@ - + @@ -167,7 +211,7 @@ - + @@ -279,7 +323,7 @@ - + @@ -336,7 +380,7 @@ - + @@ -399,13 +443,14 @@ + - + @@ -425,8 +470,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/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..f6b8b04 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 diff --git a/Example/Example/etc/SampleData.swift b/Example/Example/etc/SampleData.swift new file mode 100644 index 0000000..7b0d5cd --- /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) -> 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..<20).map { _ in UUID().uuidString }))) + + case .batches: + promise(.success(BatchesDataSource.LoadResult.itemsToken((0..<20).map { _ in UUID().uuidString }, + nextToken: "nextTokenFromServer".data(using: .utf8)!) + )) + } + } + }.eraseToAnyPublisher() +} diff --git a/Sources/CombineDataSources/BatchesDataSource/BatchesDataSource.swift b/Sources/CombineDataSources/BatchesDataSource/BatchesDataSource.swift new file mode 100644 index 0000000..1133c0c --- /dev/null +++ b/Sources/CombineDataSources/BatchesDataSource/BatchesDataSource.swift @@ -0,0 +1,246 @@ +// +// For credits and licence check the LICENSE file included in this package. +// (c) CombineOpenSource, Created by Marin Todorov. +// + +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. + 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 + } + + /// Initialiazes 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) { + let items = CurrentValueSubject<[Element], Never>(items) + let token = CurrentValueSubject(initialToken) + + self.input = input + let output = self.output + + let reload = input.reload + .map { initialToken } + + let loadNext = input.loadNext + .map { token.value } + + let batchRequest = loadNext.merge(with: reload) + .share() + .prepend(initialToken) + + let batchResponse = batchRequest + .flatMap { token in + return loadItemsWithToken(token) + .handleEvents(receiveOutput: { _ in + output.error = nil + }, + receiveCompletion: { completion in + if case Subscribers.Completion.failure(let error) = completion { + output.error = error + } else { + output.error = nil + } + }) + .catch { _ in + return Empty() + } + .map { (token: token, result: $0) } + } + .eraseToAnyPublisher() + .share() + + // Bind `Output.isLoading` + Publishers.Merge(batchRequest.map { _ in true }, batchResponse.map { _ in false }) + .assign(to: \Output.isLoading, on: output) + .store(in: &subscriptions) + + // Bind `Output.isCompleted` + batchResponse + .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 = batchResponse + .compactMap { tuple -> (token: Data?, items: [Element], nextToken: Data?)? in + switch tuple.result { + case .completed: return nil + case .itemsToken(let elements, let nextToken): return (token: tuple.token, items: elements, nextToken: nextToken) + default: fatalError() + } + } + .share() + + // Bind `token` + result + .map { $0.nextToken } + .subscribe(token) + .store(in: &subscriptions) + + // Bind `items` + result + .map { + let currentItems = items.value + return $0.token == initialToken ? $0.items : currentItems + $0.items + } + .subscribe(items) + .store(in: &subscriptions) + + // Bind `Output.items` + items + .assign(to: \Output.items, on: output) + .store(in: &subscriptions) + } + + /// 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) { + let items = CurrentValueSubject<[Element], Never>(items) + let currentPage = CurrentValueSubject(initialPage) + + self.input = input + let output = self.output + + let reload = input.reload + .map { -1 } + + let loadNext = input.loadNext + .map { currentPage.value + 1 } + + let pageRequest = loadNext.merge(with: reload) + .share() + .prepend(1) + + // Bind `Output.isLoading = true` + pageRequest + .map { _ in true } + .assign(to: \Output.isLoading, on: output) + .store(in: &subscriptions) + + let pageResponse = pageRequest + .flatMap { page in + return loadPage(page == -1 ? 1 : page) + .handleEvents(receiveOutput: { _ in + output.error = nil + }, + receiveCompletion: { completion in + if case Subscribers.Completion.failure(let error) = completion { + output.error = error + } else { + output.error = nil + } + }) + .catch { _ in + return Empty() + } + .map { (pageNumber: page, result: $0) } + } + .eraseToAnyPublisher() + .share() + + // Bind `Output.isLoading = false` + Publishers.Merge(pageRequest.map { _ in true }, pageResponse.map { _ in false }) + .assign(to: \Output.isLoading, on: output) + .store(in: &subscriptions) + + // Bind `Output.isCompleted` + pageResponse + .map { tuple -> Bool in + switch tuple.result { + case .completed: return true + default: return false + } + } + .assign(to: \Output.isCompleted, on: output) + .store(in: &subscriptions) + + // Bind `items` + pageResponse + .compactMap { tuple -> (pageNumber: Int, items: [Element])? in + switch tuple.result { + case .completed: return nil + case .items(let elements): return (pageNumber: tuple.pageNumber, items: elements) + default: fatalError() + } + } + .map { + let currentItems = items.value + return $0.pageNumber == -1 ? $0.items : currentItems + $0.items + } + .subscribe(items) + .store(in: &subscriptions) + + // Bind `currentPage` + pageResponse + .map { $0.pageNumber } + .subscribe(currentPage) + .store(in: &subscriptions) + + // Bind `Output.items` + items + .assign(to: \Output.items, on: output) + .store(in: &subscriptions) + } +} + 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/TableView/TableViewBatchesController.swift b/Sources/CombineDataSources/TableView/TableViewBatchesController.swift new file mode 100644 index 0000000..926010e --- /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) + .subscribe(retaining: 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.. Date: Mon, 26 Aug 2019 13:35:46 +0200 Subject: [PATCH 2/8] added new demo for custom batcher --- Example/Example.xcodeproj/project.pbxproj | 4 + Example/Example/Base.lproj/Main.storyboard | 127 +++++++++++++++- .../Example/CustomBatchesViewController.swift | 138 ++++++++++++++++++ Example/Example/etc/SampleData.swift | 6 +- README.md | 11 ++ .../BatchesDataSource/BatchesDataSource.swift | 94 ++++++++---- 6 files changed, 340 insertions(+), 40 deletions(-) create mode 100644 Example/Example/CustomBatchesViewController.swift diff --git a/Example/Example.xcodeproj/project.pbxproj b/Example/Example.xcodeproj/project.pbxproj index 44a23fe..019c80a 100644 --- a/Example/Example.xcodeproj/project.pbxproj +++ b/Example/Example.xcodeproj/project.pbxproj @@ -9,6 +9,7 @@ /* 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 */; }; @@ -24,6 +25,7 @@ /* 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 = ""; }; @@ -87,6 +89,7 @@ 9C326988230B2DDE00E93F9C /* CollectionViewController.swift */, 9CA4B70C23048B470041CBA4 /* GitHubSearchViewController.swift */, 9CA01D182312EF7C00666EDE /* BatchesViewController.swift */, + 9C7E190A2313E02D00518E33 /* CustomBatchesViewController.swift */, 9CB630CF22FD510000368A0D /* Main.storyboard */, 9CB630D222FD510100368A0D /* Assets.xcassets */, 9CB630D422FD510100368A0D /* LaunchScreen.storyboard */, @@ -184,6 +187,7 @@ 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 43c5ab9..398b625 100644 --- a/Example/Example/Base.lproj/Main.storyboard +++ b/Example/Example/Base.lproj/Main.storyboard @@ -1,8 +1,8 @@ - + - + @@ -142,12 +142,32 @@ + + + + + + + + + + + + + + - + @@ -171,7 +191,7 @@ - + @@ -191,7 +211,7 @@ - + @@ -211,7 +231,7 @@ - + @@ -244,6 +264,101 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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/etc/SampleData.swift b/Example/Example/etc/SampleData.swift index 7b0d5cd..eb7a517 100644 --- a/Example/Example/etc/SampleData.swift +++ b/Example/Example/etc/SampleData.swift @@ -7,15 +7,15 @@ enum SampleDataType { case pages, batches } -func sampleData(_ type: SampleDataType) -> AnyPublisher.LoadResult, Error> { +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..<20).map { _ in UUID().uuidString }))) + promise(.success(BatchesDataSource.LoadResult.items((0.. { case completed } + enum ResponseResult { + case result((token: Data?, result: BatchesDataSource.LoadResult)) + case error(Error) + } + /// Initialiazes 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. @@ -65,50 +70,68 @@ public struct BatchesDataSource { /// 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) { - let items = CurrentValueSubject<[Element], Never>(items) + let itemsSubject = CurrentValueSubject<[Element], Never>(items) let token = CurrentValueSubject(initialToken) self.input = input let output = self.output let reload = input.reload - .map { initialToken } + .share() + reload + .map { _ in + return items + } + .subscribe(itemsSubject) + .store(in: &subscriptions) + let loadNext = input.loadNext .map { token.value } - let batchRequest = loadNext.merge(with: reload) + let batchRequest = loadNext.merge(with: reload.map { initialToken }) .share() .prepend(initialToken) - + let batchResponse = batchRequest .flatMap { token in return loadItemsWithToken(token) - .handleEvents(receiveOutput: { _ in - output.error = nil - }, - receiveCompletion: { completion in - if case Subscribers.Completion.failure(let error) = completion { - output.error = error - } else { - output.error = nil - } - }) - .catch { _ in - return Empty() + .map { result -> ResponseResult in + return .result((token: token, result: result)) + } + .catch { error in + Just(ResponseResult.error(error)) } - .map { (token: token, result: $0) } } .eraseToAnyPublisher() .share() - + + batchResponse + .compactMap { 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: Data?, result: BatchesDataSource.LoadResult)? in + switch result { + case .result(let result): return result + default: return nil + } + } + .share() + // Bind `Output.isCompleted` - batchResponse + successResponse .map { tuple -> Bool in switch tuple.result { case .completed: return true @@ -118,7 +141,7 @@ public struct BatchesDataSource { .assign(to: \Output.isCompleted, on: output) .store(in: &subscriptions) - let result = batchResponse + let result = successResponse .compactMap { tuple -> (token: Data?, items: [Element], nextToken: Data?)? in switch tuple.result { case .completed: return nil @@ -137,14 +160,14 @@ public struct BatchesDataSource { // Bind `items` result .map { - let currentItems = items.value - return $0.token == initialToken ? $0.items : currentItems + $0.items + let currentItems = itemsSubject.value + return currentItems + $0.items } - .subscribe(items) + .subscribe(itemsSubject) .store(in: &subscriptions) // Bind `Output.items` - items + itemsSubject .assign(to: \Output.items, on: output) .store(in: &subscriptions) } @@ -156,22 +179,31 @@ public struct BatchesDataSource { /// - 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) { - let items = CurrentValueSubject<[Element], Never>(items) + let itemsSubject = CurrentValueSubject<[Element], Never>(items) let currentPage = CurrentValueSubject(initialPage) self.input = input let output = self.output let reload = input.reload - .map { -1 } + .share() + + reload + .map { _ in + return items + } + .subscribe(itemsSubject) + .store(in: &subscriptions) let loadNext = input.loadNext .map { currentPage.value + 1 } - let pageRequest = loadNext.merge(with: reload) + let pageRequest = loadNext.merge(with: reload.map { -1 }) .share() .prepend(1) + // TODO: Add the response error handling like for batches + // Bind `Output.isLoading = true` pageRequest .map { _ in true } @@ -225,10 +257,10 @@ public struct BatchesDataSource { } } .map { - let currentItems = items.value - return $0.pageNumber == -1 ? $0.items : currentItems + $0.items + let currentItems = itemsSubject.value + return currentItems + $0.items } - .subscribe(items) + .subscribe(itemsSubject) .store(in: &subscriptions) // Bind `currentPage` @@ -238,7 +270,7 @@ public struct BatchesDataSource { .store(in: &subscriptions) // Bind `Output.items` - items + itemsSubject .assign(to: \Output.items, on: output) .store(in: &subscriptions) } From 99d53d4809d90a9f187308810b0c30d020c51fb9 Mon Sep 17 00:00:00 2001 From: Marin Todorov Date: Tue, 27 Aug 2019 20:40:57 +0200 Subject: [PATCH 3/8] adds readme todo --- README.md | 6 ++++++ .../BatchesDataSource/BatchesDataSource.swift | 2 +- Tests/CombineDataSourcesTests/TestFixtures.swift | 2 +- 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 7e1c333..4eb691d 100644 --- a/README.md +++ b/README.md @@ -110,7 +110,13 @@ A common pattern in list based views is to load a very long list of elements in **CombineDataSources** includes a data source allowing you to easily implement the batched list pattern called `BatchesDataSource`. +## Todo +- [ ] 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 diff --git a/Sources/CombineDataSources/BatchesDataSource/BatchesDataSource.swift b/Sources/CombineDataSources/BatchesDataSource/BatchesDataSource.swift index c8ad3c6..373e4bd 100644 --- a/Sources/CombineDataSources/BatchesDataSource/BatchesDataSource.swift +++ b/Sources/CombineDataSources/BatchesDataSource/BatchesDataSource.swift @@ -62,7 +62,7 @@ public struct BatchesDataSource { case error(Error) } - /// Initialiazes a list data source using a token to fetch batches of items. + /// 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. diff --git a/Tests/CombineDataSourcesTests/TestFixtures.swift b/Tests/CombineDataSourcesTests/TestFixtures.swift index d442fde..970d09b 100644 --- a/Tests/CombineDataSourcesTests/TestFixtures.swift +++ b/Tests/CombineDataSourcesTests/TestFixtures.swift @@ -9,7 +9,7 @@ import XCTest import CombineDataSources import UIKit -struct Model: Equatable { +struct Model: Hashable { var text: String } From 57351b524075936097b4b3249e1e2a90f337cc1f Mon Sep 17 00:00:00 2001 From: Marin Todorov Date: Thu, 29 Aug 2019 14:35:25 +0200 Subject: [PATCH 4/8] more tests added to batches data source --- Assets/BatchesDataSource.monopic | Bin 0 -> 3416 bytes Assets/BatchesDataSource.png | Bin 0 -> 75603 bytes Example/Example/AppDelegate.swift | 22 ++ README.md | 1 + .../BatchesDataSource/BatchesDataSource.swift | 255 ++++++++-------- .../BatchesDataSourceTests.swift | 286 ++++++++++++++++++ .../CollectionViewItemsControllerTests.swift | 0 .../UICollectionView+SubscribersTests.swift | 0 .../TableViewItemsControllerTests.swift | 0 .../UITableView+SubscribersTests.swift | 0 .../{ => data}/TestFixtures.swift | 7 + 11 files changed, 436 insertions(+), 135 deletions(-) create mode 100644 Assets/BatchesDataSource.monopic create mode 100644 Assets/BatchesDataSource.png create mode 100644 Tests/CombineDataSourcesTests/BatchesDataSource/BatchesDataSourceTests.swift rename Tests/CombineDataSourcesTests/{ => CollectionView}/CollectionViewItemsControllerTests.swift (100%) rename Tests/CombineDataSourcesTests/{ => CollectionView}/UICollectionView+SubscribersTests.swift (100%) rename Tests/CombineDataSourcesTests/{ => TableView}/TableViewItemsControllerTests.swift (100%) rename Tests/CombineDataSourcesTests/{ => TableView}/UITableView+SubscribersTests.swift (100%) rename Tests/CombineDataSourcesTests/{ => data}/TestFixtures.swift (88%) diff --git a/Assets/BatchesDataSource.monopic b/Assets/BatchesDataSource.monopic new file mode 100644 index 0000000000000000000000000000000000000000..3d88ac7f6506ebc88f1ac191e68de339c71fae73 GIT binary patch literal 3416 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 literal 0 HcmV?d00001 diff --git a/Assets/BatchesDataSource.png b/Assets/BatchesDataSource.png new file mode 100644 index 0000000000000000000000000000000000000000..658902a88cb6fbaf267d4dbbaf4346f466f3006e GIT binary patch literal 75603 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 literal 0 HcmV?d00001 diff --git a/Example/Example/AppDelegate.swift b/Example/Example/AppDelegate.swift index cbe5264..ec91d5d 100644 --- a/Example/Example/AppDelegate.swift +++ b/Example/Example/AppDelegate.swift @@ -4,10 +4,32 @@ // import UIKit +import Combine @UIApplicationMain class AppDelegate: UIResponder, UIApplicationDelegate { + var subscriptions = [AnyCancellable]() + func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { + let publisher = Future { promise in + print("request network data") + + DispatchQueue.main.async { + promise(.success("JSON")) + } + } + .eraseToAnyPublisher() + .assertMaxSubscriptions(1) + .share() + + publisher + .sink { print($0) } + .store(in: &subscriptions) + + publisher + .sink { print($0) } + .store(in: &subscriptions) + return true } diff --git a/README.md b/README.md index 4eb691d..9fba753 100644 --- a/README.md +++ b/README.md @@ -112,6 +112,7 @@ A common pattern in list based views is to load a very long list of elements in ## Todo +- [ ] 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 diff --git a/Sources/CombineDataSources/BatchesDataSource/BatchesDataSource.swift b/Sources/CombineDataSources/BatchesDataSource/BatchesDataSource.swift index 373e4bd..1af6e4a 100644 --- a/Sources/CombineDataSources/BatchesDataSource/BatchesDataSource.swift +++ b/Sources/CombineDataSources/BatchesDataSource/BatchesDataSource.swift @@ -3,6 +3,39 @@ // (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 @@ -46,83 +79,67 @@ public struct BatchesDataSource { /// The result of loading of a batch of items. public enum LoadResult { - /// A batch of `Element` items. + /// 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) + case itemsToken([Element], nextToken: Data?) /// No more items available to fetch. case completed } enum ResponseResult { - case result((token: Data?, result: BatchesDataSource.LoadResult)) + case result((token: Token, result: BatchesDataSource.LoadResult)) case error(Error) } - /// 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) { + 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(initialToken) + let token = CurrentValueSubject(initial) self.input = input let output = self.output - let reload = input.reload - .share() - - reload - .map { _ in - return items - } + 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: reload.map { initialToken }) - .share() - .prepend(initialToken) - - let batchResponse = batchRequest - .flatMap { token in - return loadItemsWithToken(token) - .map { result -> ResponseResult in - return .result((token: token, result: result)) - } - .catch { error in - Just(ResponseResult.error(error)) - } - } + let batchRequest = loadNext + .merge(with: input.reload.prepend(()).map { initial }) .eraseToAnyPublisher() - .share() - + + // TODO: avoid having extra subject when `shareReplay()` is introduced. + let batchResponse = PassthroughSubject() + batchResponse - .compactMap { result -> Error? in + .map { result -> Error? in switch result { case .error(let error): return error default: return nil } } - .assign(to: \Output.error, on: output) - .store(in: &subscriptions) - + .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: Data?, result: BatchesDataSource.LoadResult)? in + .compactMap { result -> (token: Token, result: BatchesDataSource.LoadResult)? in switch result { case .result(let result): return result default: return nil @@ -142,11 +159,16 @@ public struct BatchesDataSource { .store(in: &subscriptions) let result = successResponse - .compactMap { tuple -> (token: Data?, items: [Element], nextToken: Data?)? in + .compactMap { tuple -> (token: Token, items: [Element], nextToken: Token)? in switch tuple.result { - case .completed: return nil - case .itemsToken(let elements, let nextToken): return (token: tuple.token, items: elements, nextToken: nextToken) - default: fatalError() + 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() @@ -160,6 +182,7 @@ public struct BatchesDataSource { // Bind `items` result .map { + // TODO: Solve for `withLatestFrom(_)` let currentItems = itemsSubject.value return currentItems + $0.items } @@ -170,6 +193,39 @@ public struct BatchesDataSource { itemsSubject .assign(to: \Output.items, on: output) .store(in: &subscriptions) + + batchRequest + .assertMaxSubscriptions(1) + .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. @@ -179,100 +235,29 @@ public struct BatchesDataSource { /// - 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) { - let itemsSubject = CurrentValueSubject<[Element], Never>(items) - let currentPage = CurrentValueSubject(initialPage) - - self.input = input - let output = self.output - - let reload = input.reload - .share() - - reload - .map { _ in - return items - } - .subscribe(itemsSubject) - .store(in: &subscriptions) - - let loadNext = input.loadNext - .map { currentPage.value + 1 } - - let pageRequest = loadNext.merge(with: reload.map { -1 }) - .share() - .prepend(1) - - // TODO: Add the response error handling like for batches - - // Bind `Output.isLoading = true` - pageRequest - .map { _ in true } - .assign(to: \Output.isLoading, on: output) - .store(in: &subscriptions) - - let pageResponse = pageRequest - .flatMap { page in - return loadPage(page == -1 ? 1 : page) - .handleEvents(receiveOutput: { _ in - output.error = nil - }, - receiveCompletion: { completion in - if case Subscribers.Completion.failure(let error) = completion { - output.error = error - } else { - output.error = nil - } - }) - .catch { _ in - return Empty() - } - .map { (pageNumber: page, result: $0) } + 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() } - .eraseToAnyPublisher() - .share() + }) + } +} - // Bind `Output.isLoading = false` - Publishers.Merge(pageRequest.map { _ in true }, pageResponse.map { _ in false }) - .assign(to: \Output.isLoading, on: output) - .store(in: &subscriptions) +fileprivate var uuids = [String: Int]() - // Bind `Output.isCompleted` - pageResponse - .map { tuple -> Bool in - switch tuple.result { - case .completed: return true - default: return false - } - } - .assign(to: \Output.isCompleted, on: output) - .store(in: &subscriptions) +extension Publisher { + public func assertMaxSubscriptions(_ max: Int, file: StaticString = #file, line: UInt = #line) -> AnyPublisher { + let uuid = "\(file):\(line)" - // Bind `items` - pageResponse - .compactMap { tuple -> (pageNumber: Int, items: [Element])? in - switch tuple.result { - case .completed: return nil - case .items(let elements): return (pageNumber: tuple.pageNumber, items: elements) - default: fatalError() - } - } - .map { - let currentItems = itemsSubject.value - return currentItems + $0.items + return handleEvents(receiveSubscription: { _ in + let count = uuids[uuid] ?? 0 + guard count < max else { + assert(false, "Publisher subscribed more than \(max) times.") + return } - .subscribe(itemsSubject) - .store(in: &subscriptions) - - // Bind `currentPage` - pageResponse - .map { $0.pageNumber } - .subscribe(currentPage) - .store(in: &subscriptions) - - // Bind `Output.items` - itemsSubject - .assign(to: \Output.items, on: output) - .store(in: &subscriptions) + uuids[uuid] = count + 1 + }).eraseToAnyPublisher() } } - 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/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 88% rename from Tests/CombineDataSourcesTests/TestFixtures.swift rename to Tests/CombineDataSourcesTests/data/TestFixtures.swift index 970d09b..29e728c 100644 --- a/Tests/CombineDataSourcesTests/TestFixtures.swift +++ b/Tests/CombineDataSourcesTests/data/TestFixtures.swift @@ -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 +} From 500fe19e8754deb02bed57e7e2fa3ec85be6b5a6 Mon Sep 17 00:00:00 2001 From: Marin Todorov Date: Thu, 29 Aug 2019 23:39:06 +0200 Subject: [PATCH 5/8] clean up a bit of the readme and the extra code --- Example/Example/AppDelegate.swift | 22 ------- Example/Example/ViewController.swift | 13 ++-- README.md | 61 +++++++++++++++++-- .../BatchesDataSource/BatchesDataSource.swift | 1 - 4 files changed, 66 insertions(+), 31 deletions(-) diff --git a/Example/Example/AppDelegate.swift b/Example/Example/AppDelegate.swift index ec91d5d..cbe5264 100644 --- a/Example/Example/AppDelegate.swift +++ b/Example/Example/AppDelegate.swift @@ -4,32 +4,10 @@ // import UIKit -import Combine @UIApplicationMain class AppDelegate: UIResponder, UIApplicationDelegate { - var subscriptions = [AnyCancellable]() - func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { - let publisher = Future { promise in - print("request network data") - - DispatchQueue.main.async { - promise(.success("JSON")) - } - } - .eraseToAnyPublisher() - .assertMaxSubscriptions(1) - .share() - - publisher - .sink { print($0) } - .store(in: &subscriptions) - - publisher - .sink { print($0) } - .store(in: &subscriptions) - return true } diff --git a/Example/Example/ViewController.swift b/Example/Example/ViewController.swift index f6b8b04..cb6d94c 100644 --- a/Example/Example/ViewController.swift +++ b/Example/Example/ViewController.swift @@ -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 @@ -58,16 +59,18 @@ class ViewController: UIViewController { // 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 + .subscribe(retaining: 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 + .subscribe(retaining: 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 +80,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 + .subscribe(retaining: 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 +93,8 @@ class ViewController: UIViewController { controller.animated = false data - .subscribe(tableView.sectionsSubscriber(controller)) + .subscribe(retaining: tableView.sectionsSubscriber(controller)) + .store(in: &subscriptions) } reload() diff --git a/README.md b/README.md index 9fba753..2725140 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ 1.3 [Subscribing a completing publisher](#subscribing-a-completing-publisher) -1.4 Batched/Paged list of elements +1.4 [List loaded in batches](#list-loaded-in-batches) 2. [**Installation**](#installation) @@ -104,14 +104,67 @@ Just([Person(name: "test"]) This will keep the subscriber and the data source alive until you cancel the subscription manually or it is released from memory. -#### Batched/Paged list of elements +#### List loaded in batches -A common pattern in list based views is to load a very long list of elements in "batches" or "pages". (The distinction being that pages imply ordered, equal-length 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.) -**CombineDataSources** includes a data source allowing you to easily implement the batched list pattern called `BatchesDataSource`. +**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 case you want to implement your own custom logic, you can use directly the data source type: + +```swift +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) + } +) +``` + +`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 diff --git a/Sources/CombineDataSources/BatchesDataSource/BatchesDataSource.swift b/Sources/CombineDataSources/BatchesDataSource/BatchesDataSource.swift index 1af6e4a..87db099 100644 --- a/Sources/CombineDataSources/BatchesDataSource/BatchesDataSource.swift +++ b/Sources/CombineDataSources/BatchesDataSource/BatchesDataSource.swift @@ -195,7 +195,6 @@ public struct BatchesDataSource { .store(in: &subscriptions) batchRequest - .assertMaxSubscriptions(1) .flatMap { token in return loadNextCallback(token) .map { result -> ResponseResult in From 80837f4a77bca251880afc946e55e1e0bf8df839 Mon Sep 17 00:00:00 2001 From: Marin Todorov Date: Fri, 30 Aug 2019 11:29:26 +0200 Subject: [PATCH 6/8] subscribe() is out, bind() is in --- Assets/slack.png | Bin 0 -> 2063 bytes Example/Example/Base.lproj/Main.storyboard | 16 ++++---- .../Example/GitHubSearchViewController.swift | 2 +- Example/Example/ViewController.swift | 11 +++-- README.md | 38 ++++++------------ .../TableViewBatchesController.swift | 2 +- .../etc/Publisher+SubscribeRetaining.swift | 19 +++++---- .../MemoryManagementTests.swift | 32 +++++++++++++++ .../TableViewBatchesControllerTests.swift | 15 +++++++ 9 files changed, 86 insertions(+), 49 deletions(-) create mode 100644 Assets/slack.png create mode 100644 Tests/CombineDataSourcesTests/TableView/TableViewBatchesControllerTests.swift diff --git a/Assets/slack.png b/Assets/slack.png new file mode 100644 index 0000000000000000000000000000000000000000..046c935d287a45fe959ce20716fbc2142a816ff9 GIT binary patch literal 2063 zcmY*ac|6oz7azq`mQ<+0U>cPtGCw4mVUQV&XDGuE9?8yN7GvzhBU^cGk4hMXCL}n+M7gYy7L6AEDu13$&cUh_f5o39|KY*9`@7OfGzN)Efo<_RyHEodXgGXJ==b{BCxhhiUnPqFud(<8 z0$VqLIzkQj&CM^>-O8fQ&`Ium9ACJ%(h6gIbY=ot%YAYZl3~n~zv7Hjs+hb*I zIInAaSZ@svco$HT)WPQ(8@za{vF)l@Vpd6*Ize(4Xk4$uxylk{{H5tAtqG{bA$Yb4gV72(T(&$L?9JN)Z z@b58~idBum5YO#a3bL}tKc*em)(%RQZ#1IbbU_zLgRhEaUoj}hcTZf1p-d?K-`FVKx{W}1e}@2#Af1JS1)6M6 z4m@q@opC+ero=!~IcGkB_44x)8v0tpAT-X}I(>1q6GO(OD4I8Yxw(@5;?RfIH|1g? z1au5`0%QBv#vZ%6zI)$PvMhBf3bQap((#HitQEaEH`(3H3~_~z7H(ZVCHnZ?8wNUY zXE8Mn_Ob8!)1Cr18hvDy%}mQkkg)V7bkhD%MFp7E1;MdCRb1-dps14?B7;NYiZ^x| zl6a(Ve?D)1HHnI=N&o)rV$DB!+j(j>mB$CJeQY;49IsU!y%v^>NnhQjrTDK_+_NT- zW^N*kOjc5B!sfRHY`+XQl<%#uyaV%4lMb%gXMPUXR*=wPM(o_TY~xvu>=W&tG7Q-O zb-q1duw9_Cu_<#{Xoh>N;~;y5oL(z1mj2}xcy(;SVL3rY{;}uiQJJC7Mr^bkq@T?6 z_hxHnd(FrhUu|Dle#kzzgPDy|KA<1HWFAM1k#0WLdX@@`mp=r%G@@qE1#s;kg;N%laD4DPNA;dQ7HKKmPTOzs5$rK`u9S1DE<{L)?LF9zP?>J-cPbEH z>ok54Z@t(%We2YFXMlX_byD99B|76z8E%SF&q3bJR)7~Cgow*+x%j)K`>(g z6+2v))(tq+lt<8LvxlE*CzZ>)jJ8+>*G}DLs8A8_Aq~;>Nj5c=HMG8-fainC#jzgt z`)8!2N;_w!M;bR50y=L5;)S5-P-5~?NMZ&cM(jW-+>t{E6-MkiIEhHNm+bQ#@qsx@# zh9yKJ%dE>Z2V85+8DEhjD<)3w;R;+GKfz8)Nm03b_b!*mWJtuUhaqE{hH9C#u7&=A&8>%Xu^Z)<= literal 0 HcmV?d00001 diff --git a/Example/Example/Base.lproj/Main.storyboard b/Example/Example/Base.lproj/Main.storyboard index 398b625..759d981 100644 --- a/Example/Example/Base.lproj/Main.storyboard +++ b/Example/Example/Base.lproj/Main.storyboard @@ -1,8 +1,8 @@ - + - + @@ -25,7 +25,7 @@ -