From f9fbfb2735c31d4210001885acc718eae5250951 Mon Sep 17 00:00:00 2001 From: Ivan Smolin Date: Tue, 4 Apr 2017 14:04:53 +0300 Subject: [PATCH] initial pagination view model --- LeadKit/LeadKit.xcodeproj/project.pbxproj | 20 ++++ .../Pagination/PaginationViewModel.swift | 103 ++++++++++++++++++ LeadKit/LeadKit/Enums/CursorError.swift | 2 - .../Protocols/ResettableCursorType.swift | 37 +++++++ LeadKit/LeadKitTests/CursorTests.swift | 22 +++- LeadKit/LeadKitTests/Cursors/StubCursor.swift | 7 +- .../PaginationViewModelTests.swift | 63 +++++++++++ 7 files changed, 247 insertions(+), 7 deletions(-) create mode 100644 LeadKit/LeadKit/Classes/Pagination/PaginationViewModel.swift create mode 100644 LeadKit/LeadKit/Protocols/ResettableCursorType.swift create mode 100644 LeadKit/LeadKitTests/PaginationViewModelTests.swift diff --git a/LeadKit/LeadKit.xcodeproj/project.pbxproj b/LeadKit/LeadKit.xcodeproj/project.pbxproj index 89f526b3..72d94e62 100644 --- a/LeadKit/LeadKit.xcodeproj/project.pbxproj +++ b/LeadKit/LeadKit.xcodeproj/project.pbxproj @@ -9,11 +9,14 @@ /* Begin PBXBuildFile section */ 6727419D1E65B99E0075836A /* MappableUserDefaultsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6727419C1E65B99E0075836A /* MappableUserDefaultsTests.swift */; }; 672741A01E65C1E00075836A /* Post.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6727419F1E65C1E00075836A /* Post.swift */; }; + 674743941E929A5A00B47671 /* PaginationViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 674743931E929A5A00B47671 /* PaginationViewModelTests.swift */; }; + 675D24B21E9234BB00E92D1F /* PaginationViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 675D24B11E9234BB00E92D1F /* PaginationViewModel.swift */; }; 67788F9F1E69661800484DEE /* CGFloat+Pixels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 67788F9E1E69661800484DEE /* CGFloat+Pixels.swift */; }; 67B3057B1E8A8727008169CA /* TestView.xib in Resources */ = {isa = PBXBuildFile; fileRef = 67B3057A1E8A8727008169CA /* TestView.xib */; }; 67B3057D1E8A8735008169CA /* TestView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 67B3057C1E8A8735008169CA /* TestView.swift */; }; 67B3057F1E8A8804008169CA /* LoadFromNibTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 67B3057E1E8A8804008169CA /* LoadFromNibTests.swift */; }; 67B305841E8A92E8008169CA /* XibView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 67B305831E8A92E8008169CA /* XibView.swift */; }; + 67B856E31E923BE600F54304 /* ResettableCursorType.swift in Sources */ = {isa = PBXBuildFile; fileRef = 67B856E21E923BE600F54304 /* ResettableCursorType.swift */; }; 67EF144C1E8BEACB00D6E0DD /* StubCursor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 67EF144B1E8BEACB00D6E0DD /* StubCursor.swift */; }; 67EF144E1E8BED4E00D6E0DD /* CursorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 67EF144D1E8BED4E00D6E0DD /* CursorTests.swift */; }; 78011A641D47ABC500EA16A2 /* UIView+DefaultReuseIdentifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 78011A631D47ABC500EA16A2 /* UIView+DefaultReuseIdentifier.swift */; }; @@ -99,11 +102,14 @@ 12F36034A5278991B658B53E /* Pods_LeadKit.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_LeadKit.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 6727419C1E65B99E0075836A /* MappableUserDefaultsTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MappableUserDefaultsTests.swift; sourceTree = ""; }; 6727419F1E65C1E00075836A /* Post.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Post.swift; sourceTree = ""; }; + 674743931E929A5A00B47671 /* PaginationViewModelTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PaginationViewModelTests.swift; sourceTree = ""; }; + 675D24B11E9234BB00E92D1F /* PaginationViewModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PaginationViewModel.swift; sourceTree = ""; }; 67788F9E1E69661800484DEE /* CGFloat+Pixels.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "CGFloat+Pixels.swift"; sourceTree = ""; }; 67B3057A1E8A8727008169CA /* TestView.xib */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.xib; path = TestView.xib; sourceTree = ""; }; 67B3057C1E8A8735008169CA /* TestView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TestView.swift; sourceTree = ""; }; 67B3057E1E8A8804008169CA /* LoadFromNibTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LoadFromNibTests.swift; sourceTree = ""; }; 67B305831E8A92E8008169CA /* XibView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = XibView.swift; sourceTree = ""; }; + 67B856E21E923BE600F54304 /* ResettableCursorType.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ResettableCursorType.swift; sourceTree = ""; }; 67EF144B1E8BEACB00D6E0DD /* StubCursor.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = StubCursor.swift; sourceTree = ""; }; 67EF144D1E8BED4E00D6E0DD /* CursorTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CursorTests.swift; sourceTree = ""; }; 78011A631D47ABC500EA16A2 /* UIView+DefaultReuseIdentifier.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "UIView+DefaultReuseIdentifier.swift"; sourceTree = ""; }; @@ -210,6 +216,14 @@ path = Models; sourceTree = ""; }; + 675D24B01E9234A400E92D1F /* Pagination */ = { + isa = PBXGroup; + children = ( + 675D24B11E9234BB00E92D1F /* PaginationViewModel.swift */, + ); + path = Pagination; + sourceTree = ""; + }; 67788F9D1E6965F800484DEE /* CGFloat */ = { isa = PBXGroup; children = ( @@ -391,6 +405,7 @@ 78B0FC7B1C6B2BAE00358B64 /* Logging */, 78753E2A1DE58BED006BC0FB /* Cursors */, 67B305801E8A92B6008169CA /* Views */, + 675D24B01E9234A400E92D1F /* Pagination */, ); path = Classes; sourceTree = ""; @@ -466,6 +481,7 @@ 6727419C1E65B99E0075836A /* MappableUserDefaultsTests.swift */, 67B3057E1E8A8804008169CA /* LoadFromNibTests.swift */, 67EF144D1E8BED4E00D6E0DD /* CursorTests.swift */, + 674743931E929A5A00B47671 /* PaginationViewModelTests.swift */, ); path = LeadKitTests; sourceTree = ""; @@ -517,6 +533,7 @@ 783423691DB8D0E100A79643 /* StoryboardProtocol.swift */, 78CFEE4F1C5C45E500F50370 /* ViewHeightProtocol.swift */, 78CFEE501C5C45E500F50370 /* ViewModelProtocol.swift */, + 67B856E21E923BE600F54304 /* ResettableCursorType.swift */, ); path = Protocols; sourceTree = ""; @@ -871,6 +888,7 @@ 78011A641D47ABC500EA16A2 /* UIView+DefaultReuseIdentifier.swift in Sources */, 786D78EC1D53C46E006B2CEA /* AlamofireManager+Extensions.swift in Sources */, CAA707D51E2E614E0022D732 /* ModuleConfigurator.swift in Sources */, + 675D24B21E9234BB00E92D1F /* PaginationViewModel.swift in Sources */, 78B0FC811C6B2CD500358B64 /* App.swift in Sources */, 78B036491DA562C30021D5CC /* CGImage+Template.swift in Sources */, CAE698C61E96775F000394B0 /* String+Extensions.swift in Sources */, @@ -907,6 +925,7 @@ CAA707D71E2E616D0022D732 /* BaseViewModel.swift in Sources */, E126CBB31DB68DDA00E1B2F8 /* UICollectionView+CellRegistration.swift in Sources */, 78CFEE581C5C45E500F50370 /* StaticViewHeightProtocol.swift in Sources */, + 67B856E31E923BE600F54304 /* ResettableCursorType.swift in Sources */, 787783631CA03CA0001CDC9B /* IndexPath+ImmutableIndexPath.swift in Sources */, 78B036471DA5624D0021D5CC /* CGImage+Creation.swift in Sources */, 789CC6081DE5835600F789D3 /* CursorType.swift in Sources */, @@ -927,6 +946,7 @@ 67EF144E1E8BED4E00D6E0DD /* CursorTests.swift in Sources */, 67EF144C1E8BEACB00D6E0DD /* StubCursor.swift in Sources */, 67B3057F1E8A8804008169CA /* LoadFromNibTests.swift in Sources */, + 674743941E929A5A00B47671 /* PaginationViewModelTests.swift in Sources */, 6727419D1E65B99E0075836A /* MappableUserDefaultsTests.swift in Sources */, 672741A01E65C1E00075836A /* Post.swift in Sources */, ); diff --git a/LeadKit/LeadKit/Classes/Pagination/PaginationViewModel.swift b/LeadKit/LeadKit/Classes/Pagination/PaginationViewModel.swift new file mode 100644 index 00000000..5fc3fe56 --- /dev/null +++ b/LeadKit/LeadKit/Classes/Pagination/PaginationViewModel.swift @@ -0,0 +1,103 @@ +// +// Copyright (c) 2017 Touch Instinct +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the Software), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED AS IS, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. +// + +import RxSwift +import RxCocoa + +public class PaginationViewModel +where C: ResettableCursorType, C.LoadResultType == CountableRange { + + public indirect enum State { + + case initial + case loading // can be after any state + case loadingMore // can be after results + case results(newItems: [C.Element], after: State) // can be after loading or loadingMore + case error(error: Error, after: State) // can be after loading or loadingMore + case empty // can be after loading or loadingMore + case exhausted // can be after results + + } + + public enum LoadType { + + case reload + case next + + } + + private var cursor: C + + private let internalState = Variable(.initial) + + private var currentRequest: Disposable? + + public var state: Driver { + return internalState.asDriver() + } + + public init(cursor: C) { + self.cursor = cursor + } + + public func load(_ loadType: LoadType) { + switch loadType { + case .reload: + currentRequest?.dispose() + cursor = cursor.reset() + + internalState.value = .loading + case .next: + if case .exhausted(_) = internalState.value { + preconditionFailure("You shouldn't call load(.next) after got .exhausted state!") + } + + internalState.value = .loadingMore + } + + currentRequest = cursor.loadNextBatch() + .subscribe(onNext: { [weak self] loadedRange in + self?.onGot(cursorLoadResult: loadedRange) + }, onError: { [weak self] error in + self?.onGot(error: error) + }) + } + + private func onGot(cursorLoadResult: C.LoadResultType) { + let newItems = cursor[cursorLoadResult] + + if newItems.count > 0 { + internalState.value = .results(newItems: newItems, after: internalState.value) + } else { + internalState.value = .empty + } + + if cursor.exhausted { + internalState.value = .exhausted + } + } + + private func onGot(error: Error) { + internalState.value = .error(error: error, after: internalState.value) + } + +} diff --git a/LeadKit/LeadKit/Enums/CursorError.swift b/LeadKit/LeadKit/Enums/CursorError.swift index 6045a6c3..40f8f18d 100644 --- a/LeadKit/LeadKit/Enums/CursorError.swift +++ b/LeadKit/LeadKit/Enums/CursorError.swift @@ -24,11 +24,9 @@ import Foundation /// A type representing an possible errors that can be thrown during working with cursor object /// -/// - busy: cursor is currently processing another request /// - exhausted: cursor did load all available results public enum CursorError: Error { - case busy case exhausted } diff --git a/LeadKit/LeadKit/Protocols/ResettableCursorType.swift b/LeadKit/LeadKit/Protocols/ResettableCursorType.swift new file mode 100644 index 00000000..939e7ee9 --- /dev/null +++ b/LeadKit/LeadKit/Protocols/ResettableCursorType.swift @@ -0,0 +1,37 @@ +// +// Copyright (c) 2017 Touch Instinct +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the Software), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED AS IS, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. +// + +import Foundation + +public protocol ResettableCursorType { + + init(initialFrom other: Self) + +} + +public extension ResettableCursorType { + + func reset() -> Self { + return Self(initialFrom: self) + } + +} diff --git a/LeadKit/LeadKitTests/CursorTests.swift b/LeadKit/LeadKitTests/CursorTests.swift index 72d09d2e..39b2f59c 100644 --- a/LeadKit/LeadKitTests/CursorTests.swift +++ b/LeadKit/LeadKitTests/CursorTests.swift @@ -1,9 +1,23 @@ // -// CursorTests.swift -// LeadKit +// Copyright (c) 2017 Touch Instinct // -// Created by Ivan Smolin on 29/03/2017. -// Copyright © 2017 Touch Instinct. All rights reserved. +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the Software), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED AS IS, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. // import XCTest diff --git a/LeadKit/LeadKitTests/Cursors/StubCursor.swift b/LeadKit/LeadKitTests/Cursors/StubCursor.swift index c0f28b13..1fe4f17e 100644 --- a/LeadKit/LeadKitTests/Cursors/StubCursor.swift +++ b/LeadKit/LeadKitTests/Cursors/StubCursor.swift @@ -23,7 +23,7 @@ import LeadKit import RxSwift -class StubCursor: CursorType { +class StubCursor: CursorType, ResettableCursorType { typealias LoadResultType = CountableRange @@ -52,6 +52,11 @@ class StubCursor: CursorType { self.requestDelay = requestDelay } + required init(initialFrom other: StubCursor) { + self.maxItemsCount = other.maxItemsCount + self.requestDelay = other.requestDelay + } + func loadNextBatch() -> Observable> { return Observable.create { observer -> Disposable in if self.exhausted { diff --git a/LeadKit/LeadKitTests/PaginationViewModelTests.swift b/LeadKit/LeadKitTests/PaginationViewModelTests.swift new file mode 100644 index 00000000..d8b5e57d --- /dev/null +++ b/LeadKit/LeadKitTests/PaginationViewModelTests.swift @@ -0,0 +1,63 @@ +// +// Copyright (c) 2017 Touch Instinct +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the Software), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED AS IS, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. +// + +import XCTest +import LeadKit +import RxSwift + +class PaginationViewModelTests: XCTestCase { + + let disposeBag = DisposeBag() + + override func setUp() { + super.setUp() + } + + override func tearDown() { + super.tearDown() + } + + func testExample() { + let cursor = StubCursor(maxItemsCount: 36, requestDelay: .seconds(1)) + let viewModel = PaginationViewModel(cursor: cursor) + + let paginationExpectation = expectation(description: "Pagination expectation") + + viewModel.state.drive(onNext: { state in + switch state { + case .initial, .loadingMore, .loading: + print("PageViewModel state changed to \(state)") + case .results(let newItems, _): + print(newItems.count) + paginationExpectation.fulfill() + default: + XCTFail("Unexpected state: \(state)") + } + }) + .addDisposableTo(disposeBag) + + viewModel.load(.reload) + + waitForExpectations(timeout: 10, handler: nil) + } + +}