initial pagination view model

This commit is contained in:
Ivan Smolin 2017-04-04 14:04:53 +03:00
parent 908ea53d3e
commit f9fbfb2735
7 changed files with 247 additions and 7 deletions

View File

@ -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 = "<group>"; };
6727419F1E65C1E00075836A /* Post.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Post.swift; sourceTree = "<group>"; };
674743931E929A5A00B47671 /* PaginationViewModelTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PaginationViewModelTests.swift; sourceTree = "<group>"; };
675D24B11E9234BB00E92D1F /* PaginationViewModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PaginationViewModel.swift; sourceTree = "<group>"; };
67788F9E1E69661800484DEE /* CGFloat+Pixels.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "CGFloat+Pixels.swift"; sourceTree = "<group>"; };
67B3057A1E8A8727008169CA /* TestView.xib */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.xib; path = TestView.xib; sourceTree = "<group>"; };
67B3057C1E8A8735008169CA /* TestView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TestView.swift; sourceTree = "<group>"; };
67B3057E1E8A8804008169CA /* LoadFromNibTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LoadFromNibTests.swift; sourceTree = "<group>"; };
67B305831E8A92E8008169CA /* XibView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = XibView.swift; sourceTree = "<group>"; };
67B856E21E923BE600F54304 /* ResettableCursorType.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ResettableCursorType.swift; sourceTree = "<group>"; };
67EF144B1E8BEACB00D6E0DD /* StubCursor.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = StubCursor.swift; sourceTree = "<group>"; };
67EF144D1E8BED4E00D6E0DD /* CursorTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CursorTests.swift; sourceTree = "<group>"; };
78011A631D47ABC500EA16A2 /* UIView+DefaultReuseIdentifier.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "UIView+DefaultReuseIdentifier.swift"; sourceTree = "<group>"; };
@ -210,6 +216,14 @@
path = Models;
sourceTree = "<group>";
};
675D24B01E9234A400E92D1F /* Pagination */ = {
isa = PBXGroup;
children = (
675D24B11E9234BB00E92D1F /* PaginationViewModel.swift */,
);
path = Pagination;
sourceTree = "<group>";
};
67788F9D1E6965F800484DEE /* CGFloat */ = {
isa = PBXGroup;
children = (
@ -391,6 +405,7 @@
78B0FC7B1C6B2BAE00358B64 /* Logging */,
78753E2A1DE58BED006BC0FB /* Cursors */,
67B305801E8A92B6008169CA /* Views */,
675D24B01E9234A400E92D1F /* Pagination */,
);
path = Classes;
sourceTree = "<group>";
@ -466,6 +481,7 @@
6727419C1E65B99E0075836A /* MappableUserDefaultsTests.swift */,
67B3057E1E8A8804008169CA /* LoadFromNibTests.swift */,
67EF144D1E8BED4E00D6E0DD /* CursorTests.swift */,
674743931E929A5A00B47671 /* PaginationViewModelTests.swift */,
);
path = LeadKitTests;
sourceTree = "<group>";
@ -517,6 +533,7 @@
783423691DB8D0E100A79643 /* StoryboardProtocol.swift */,
78CFEE4F1C5C45E500F50370 /* ViewHeightProtocol.swift */,
78CFEE501C5C45E500F50370 /* ViewModelProtocol.swift */,
67B856E21E923BE600F54304 /* ResettableCursorType.swift */,
);
path = Protocols;
sourceTree = "<group>";
@ -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 */,
);

View File

@ -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<C: CursorType>
where C: ResettableCursorType, C.LoadResultType == CountableRange<Int> {
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<State>(.initial)
private var currentRequest: Disposable?
public var state: Driver<State> {
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)
}
}

View File

@ -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
}

View File

@ -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)
}
}

View File

@ -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

View File

@ -23,7 +23,7 @@
import LeadKit
import RxSwift
class StubCursor: CursorType {
class StubCursor: CursorType, ResettableCursorType {
typealias LoadResultType = CountableRange<Int>
@ -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<CountableRange<Int>> {
return Observable.create { observer -> Disposable in
if self.exhausted {

View File

@ -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)
}
}