From 908ea53d3e3fa608977c554e26372e81354775e0 Mon Sep 17 00:00:00 2001 From: Ivan Smolin Date: Thu, 30 Mar 2017 18:13:42 +0300 Subject: [PATCH 01/28] make cursors thread safe and add some tests --- LeadKit/LeadKit.xcodeproj/project.pbxproj | 16 ++++ .../Classes/Cursors/FixedPageCursor.swift | 17 +++- .../LeadKit/Classes/Cursors/MapCursor.swift | 19 +++- .../Classes/Cursors/StaticCursor.swift | 9 ++ LeadKit/LeadKitTests/CursorTests.swift | 88 +++++++++++++++++++ LeadKit/LeadKitTests/Cursors/StubCursor.swift | 78 ++++++++++++++++ .../MappableUserDefaultsTests.swift | 7 +- LeadKit/LeadKitTests/Models/Post.swift | 11 +++ 8 files changed, 234 insertions(+), 11 deletions(-) create mode 100644 LeadKit/LeadKitTests/CursorTests.swift create mode 100644 LeadKit/LeadKitTests/Cursors/StubCursor.swift diff --git a/LeadKit/LeadKit.xcodeproj/project.pbxproj b/LeadKit/LeadKit.xcodeproj/project.pbxproj index daeaec34..89f526b3 100644 --- a/LeadKit/LeadKit.xcodeproj/project.pbxproj +++ b/LeadKit/LeadKit.xcodeproj/project.pbxproj @@ -14,6 +14,8 @@ 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 */; }; + 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 */; }; 78011AB31D48B53600EA16A2 /* ApiRequestParameters.swift in Sources */ = {isa = PBXBuildFile; fileRef = 78011AB21D48B53600EA16A2 /* ApiRequestParameters.swift */; }; 780D23431DA412470084620D /* CGImage+Alpha.swift in Sources */ = {isa = PBXBuildFile; fileRef = 780D23421DA412470084620D /* CGImage+Alpha.swift */; }; @@ -102,6 +104,8 @@ 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 = ""; }; + 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 = ""; }; 78011AB21D48B53600EA16A2 /* ApiRequestParameters.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ApiRequestParameters.swift; sourceTree = ""; }; 780D23421DA412470084620D /* CGImage+Alpha.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "CGImage+Alpha.swift"; sourceTree = ""; }; @@ -231,6 +235,14 @@ path = Views; sourceTree = ""; }; + 67EF144A1E8BEA9C00D6E0DD /* Cursors */ = { + isa = PBXGroup; + children = ( + 67EF144B1E8BEACB00D6E0DD /* StubCursor.swift */, + ); + path = Cursors; + sourceTree = ""; + }; 78011A651D47AF3000EA16A2 /* Enums */ = { isa = PBXGroup; children = ( @@ -449,9 +461,11 @@ children = ( 67B305791E8A8727008169CA /* Views */, 6727419E1E65BF3C0075836A /* Models */, + 67EF144A1E8BEA9C00D6E0DD /* Cursors */, 78CFEE3B1C5C456B00F50370 /* Info.plist */, 6727419C1E65B99E0075836A /* MappableUserDefaultsTests.swift */, 67B3057E1E8A8804008169CA /* LoadFromNibTests.swift */, + 67EF144D1E8BED4E00D6E0DD /* CursorTests.swift */, ); path = LeadKitTests; sourceTree = ""; @@ -910,6 +924,8 @@ buildActionMask = 2147483647; files = ( 67B3057D1E8A8735008169CA /* TestView.swift in Sources */, + 67EF144E1E8BED4E00D6E0DD /* CursorTests.swift in Sources */, + 67EF144C1E8BEACB00D6E0DD /* StubCursor.swift in Sources */, 67B3057F1E8A8804008169CA /* LoadFromNibTests.swift in Sources */, 6727419D1E65B99E0075836A /* MappableUserDefaultsTests.swift in Sources */, 672741A01E65C1E00075836A /* Post.swift in Sources */, diff --git a/LeadKit/LeadKit/Classes/Cursors/FixedPageCursor.swift b/LeadKit/LeadKit/Classes/Cursors/FixedPageCursor.swift index ca64e3de..28025a37 100644 --- a/LeadKit/LeadKit/Classes/Cursors/FixedPageCursor.swift +++ b/LeadKit/LeadKit/Classes/Cursors/FixedPageCursor.swift @@ -31,6 +31,8 @@ public class FixedPageCursor: CursorType where Cursor.LoadRe private let pageSize: Int + private let semaphore = DispatchSemaphore(value: 1) + /// Initializer with enclosed cursor /// /// - Parameters: @@ -52,7 +54,13 @@ public class FixedPageCursor: CursorType where Cursor.LoadRe } public func loadNextBatch() -> Observable { + return loadNextBatch(withSemaphore: semaphore) + } + + private func loadNextBatch(withSemaphore semaphore: DispatchSemaphore?) -> Observable { return Observable.deferred { + semaphore?.wait() + if self.exhausted { throw CursorError.exhausted } @@ -67,8 +75,15 @@ public class FixedPageCursor: CursorType where Cursor.LoadRe } return self.cursor.loadNextBatch() - .flatMap { _ in self.loadNextBatch() } + .flatMap { _ in + self.loadNextBatch(withSemaphore: nil) + } } + .do(onNext: { [weak semaphore] _ in + semaphore?.signal() + }, onError: { [weak semaphore] _ in + semaphore?.signal() + }) } } diff --git a/LeadKit/LeadKit/Classes/Cursors/MapCursor.swift b/LeadKit/LeadKit/Classes/Cursors/MapCursor.swift index 4a994c0c..1f8277b7 100644 --- a/LeadKit/LeadKit/Classes/Cursors/MapCursor.swift +++ b/LeadKit/LeadKit/Classes/Cursors/MapCursor.swift @@ -49,6 +49,8 @@ public class MapCursor: CursorType where Cursor.LoadResul private var elements: [T] = [] + private let semaphore = DispatchSemaphore(value: 1) + /// Initializer with enclosed cursor /// /// - Parameters: @@ -72,12 +74,21 @@ public class MapCursor: CursorType where Cursor.LoadResul } public func loadNextBatch() -> Observable { - return cursor.loadNextBatch().map { loadedRange in - let startIndex = self.elements.count - self.elements += self.cursor[loadedRange].flatMap(self.transform) + return Observable.deferred { + self.semaphore.wait() - return startIndex..: CursorType { private let content: [Element] + private let semaphore = DispatchSemaphore(value: 1) + /// Initializer for array content type /// /// - Parameter content: array with elements of Elemet type @@ -46,6 +48,8 @@ public class StaticCursor: CursorType { public func loadNextBatch() -> Observable { return Observable.deferred { + self.semaphore.wait() + if self.exhausted { throw CursorError.exhausted } @@ -56,6 +60,11 @@ public class StaticCursor: CursorType { return Observable.just(0.. 1 ? nil : $0 } + let fixedPageCursor = FixedPageCursor(cursor: mapCursor, pageSize: 20) + + let cursorExpectation = expectation(description: "Fixed page cursor expectation") + + fixedPageCursor.loadNextBatch() + .subscribe(onNext: { loadedRange in + XCTAssertEqual(fixedPageCursor[loadedRange].count, 8) + + cursorExpectation.fulfill() + }) + .addDisposableTo(disposeBag) + + waitForExpectations(timeout: 10, handler: nil) + } + +} diff --git a/LeadKit/LeadKitTests/Cursors/StubCursor.swift b/LeadKit/LeadKitTests/Cursors/StubCursor.swift new file mode 100644 index 00000000..c0f28b13 --- /dev/null +++ b/LeadKit/LeadKitTests/Cursors/StubCursor.swift @@ -0,0 +1,78 @@ +// +// 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 LeadKit +import RxSwift + +class StubCursor: CursorType { + + typealias LoadResultType = CountableRange + + typealias Element = Post + + private var posts: [Post] = [] + + private let maxItemsCount: Int + + private let requestDelay: DispatchTimeInterval + + var count: Int { + return posts.count + } + + var exhausted: Bool { + return count >= maxItemsCount + } + + subscript(index: Int) -> Post { + return posts[index] + } + + init(maxItemsCount: Int = 12, requestDelay: DispatchTimeInterval = .seconds(2)) { + self.maxItemsCount = maxItemsCount + self.requestDelay = requestDelay + } + + func loadNextBatch() -> Observable> { + return Observable.create { observer -> Disposable in + if self.exhausted { + observer.onError(CursorError.exhausted) + } else { + DispatchQueue.global().asyncAfter(deadline: .now() + self.requestDelay, execute: { + let countBefore = self.count + + let newPosts = Post.generate() + + let maxNewPosts = min(self.maxItemsCount, countBefore + newPosts.count) + + self.posts = Array((self.posts + newPosts)[0.. [Post] { + return [Post(userId: 1, postId: 1, title: "First post", body: ""), + Post(userId: 1, postId: 2, title: "Second post", body: ""), + Post(userId: 2, postId: 3, title: "Third post", body: ""), + Post(userId: 2, postId: 4, title: "Forth post", body: "")] + } + +} From f9fbfb2735c31d4210001885acc718eae5250951 Mon Sep 17 00:00:00 2001 From: Ivan Smolin Date: Tue, 4 Apr 2017 14:04:53 +0300 Subject: [PATCH 02/28] 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) + } + +} From ded59ff2d96c286f00a86fc8fc0ceaa43105365f Mon Sep 17 00:00:00 2001 From: Ivan Smolin Date: Tue, 4 Apr 2017 16:25:00 +0300 Subject: [PATCH 03/28] initial PaginationTableViewWrapper. api breaking changes in CursorType protocol --- LeadKit/LeadKit.xcodeproj/project.pbxproj | 12 +++-- .../Classes/Cursors/FixedPageCursor.swift | 10 ++-- .../LeadKit/Classes/Cursors/MapCursor.swift | 18 +++---- .../Classes/Cursors/StaticCursor.swift | 6 +-- .../PaginationTableViewWrapper.swift | 53 +++++++++++++++++++ .../Pagination/PaginationViewModel.swift | 16 +++--- .../CursorType/CursorType+Slice.swift | 20 +++---- LeadKit/LeadKit/Protocols/CursorType.swift | 4 +- ...eCursorType.swift => ResettableType.swift} | 4 +- LeadKit/LeadKitTests/CursorTests.swift | 8 +-- LeadKit/LeadKitTests/Cursors/StubCursor.swift | 6 +-- 11 files changed, 99 insertions(+), 58 deletions(-) create mode 100644 LeadKit/LeadKit/Classes/Pagination/PaginationTableViewWrapper.swift rename LeadKit/LeadKit/Protocols/{ResettableCursorType.swift => ResettableType.swift} (94%) diff --git a/LeadKit/LeadKit.xcodeproj/project.pbxproj b/LeadKit/LeadKit.xcodeproj/project.pbxproj index 72d94e62..bff7ba8c 100644 --- a/LeadKit/LeadKit.xcodeproj/project.pbxproj +++ b/LeadKit/LeadKit.xcodeproj/project.pbxproj @@ -12,11 +12,12 @@ 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 */; }; + 678A202A1E93C1A900787562 /* PaginationTableViewWrapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 678A20291E93C1A900787562 /* PaginationTableViewWrapper.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 */; }; + 67B856E31E923BE600F54304 /* ResettableType.swift in Sources */ = {isa = PBXBuildFile; fileRef = 67B856E21E923BE600F54304 /* ResettableType.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 */; }; @@ -105,11 +106,12 @@ 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 = ""; }; + 678A20291E93C1A900787562 /* PaginationTableViewWrapper.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PaginationTableViewWrapper.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 = ""; }; + 67B856E21E923BE600F54304 /* ResettableType.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ResettableType.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 = ""; }; @@ -220,6 +222,7 @@ isa = PBXGroup; children = ( 675D24B11E9234BB00E92D1F /* PaginationViewModel.swift */, + 678A20291E93C1A900787562 /* PaginationTableViewWrapper.swift */, ); path = Pagination; sourceTree = ""; @@ -533,7 +536,7 @@ 783423691DB8D0E100A79643 /* StoryboardProtocol.swift */, 78CFEE4F1C5C45E500F50370 /* ViewHeightProtocol.swift */, 78CFEE501C5C45E500F50370 /* ViewModelProtocol.swift */, - 67B856E21E923BE600F54304 /* ResettableCursorType.swift */, + 67B856E21E923BE600F54304 /* ResettableType.swift */, ); path = Protocols; sourceTree = ""; @@ -886,6 +889,7 @@ 7873D1511E112B0D001816EB /* Any+Cast.swift in Sources */, 78B036431DA4FEC90021D5CC /* CGImage+Transform.swift in Sources */, 78011A641D47ABC500EA16A2 /* UIView+DefaultReuseIdentifier.swift in Sources */, + 678A202A1E93C1A900787562 /* PaginationTableViewWrapper.swift in Sources */, 786D78EC1D53C46E006B2CEA /* AlamofireManager+Extensions.swift in Sources */, CAA707D51E2E614E0022D732 /* ModuleConfigurator.swift in Sources */, 675D24B21E9234BB00E92D1F /* PaginationViewModel.swift in Sources */, @@ -925,7 +929,7 @@ CAA707D71E2E616D0022D732 /* BaseViewModel.swift in Sources */, E126CBB31DB68DDA00E1B2F8 /* UICollectionView+CellRegistration.swift in Sources */, 78CFEE581C5C45E500F50370 /* StaticViewHeightProtocol.swift in Sources */, - 67B856E31E923BE600F54304 /* ResettableCursorType.swift in Sources */, + 67B856E31E923BE600F54304 /* ResettableType.swift in Sources */, 787783631CA03CA0001CDC9B /* IndexPath+ImmutableIndexPath.swift in Sources */, 78B036471DA5624D0021D5CC /* CGImage+Creation.swift in Sources */, 789CC6081DE5835600F789D3 /* CursorType.swift in Sources */, diff --git a/LeadKit/LeadKit/Classes/Cursors/FixedPageCursor.swift b/LeadKit/LeadKit/Classes/Cursors/FixedPageCursor.swift index 28025a37..bdbf4829 100644 --- a/LeadKit/LeadKit/Classes/Cursors/FixedPageCursor.swift +++ b/LeadKit/LeadKit/Classes/Cursors/FixedPageCursor.swift @@ -23,9 +23,7 @@ import RxSwift /// Paging cursor implementation with enclosed cursor for fetching results -public class FixedPageCursor: CursorType where Cursor.LoadResultType == CountableRange { - - public typealias LoadResultType = CountableRange +public class FixedPageCursor: CursorType { private let cursor: Cursor @@ -53,11 +51,11 @@ public class FixedPageCursor: CursorType where Cursor.LoadRe return cursor[index] } - public func loadNextBatch() -> Observable { + public func loadNextBatch() -> Observable<[Cursor.Element]> { return loadNextBatch(withSemaphore: semaphore) } - private func loadNextBatch(withSemaphore semaphore: DispatchSemaphore?) -> Observable { + private func loadNextBatch(withSemaphore semaphore: DispatchSemaphore?) -> Observable<[Cursor.Element]> { return Observable.deferred { semaphore?.wait() @@ -71,7 +69,7 @@ public class FixedPageCursor: CursorType where Cursor.LoadRe let startIndex = self.count self.count += min(restOfLoaded, self.pageSize) - return Observable.just(startIndex.. - -public extension CursorType where Self.LoadResultType == MapCursorLoadResultType { +public extension CursorType { /// Creates MapCursor with current cursor /// @@ -37,9 +35,7 @@ public extension CursorType where Self.LoadResultType == MapCursorLoadResultType } /// Map cursor implementation with enclosed cursor for fetching results -public class MapCursor: CursorType where Cursor.LoadResultType == MapCursorLoadResultType { - - public typealias LoadResultType = Cursor.LoadResultType +public class MapCursor: CursorType { public typealias Transform = (Cursor.Element) -> T? @@ -73,15 +69,15 @@ public class MapCursor: CursorType where Cursor.LoadResul return elements[index] } - public func loadNextBatch() -> Observable { + public func loadNextBatch() -> Observable<[T]> { return Observable.deferred { self.semaphore.wait() - return self.cursor.loadNextBatch().map { loadedRange in - let startIndex = self.elements.count - self.elements += self.cursor[loadedRange].flatMap(self.transform) + return self.cursor.loadNextBatch().map { newItems in + let transformedNewItems = newItems.flatMap(self.transform) + self.elements += transformedNewItems - return startIndex..: CursorType { - public typealias LoadResultType = CountableRange - private let content: [Element] private let semaphore = DispatchSemaphore(value: 1) @@ -46,7 +44,7 @@ public class StaticCursor: CursorType { return content[index] } - public func loadNextBatch() -> Observable { + public func loadNextBatch() -> Observable<[Element]> { return Observable.deferred { self.semaphore.wait() @@ -58,7 +56,7 @@ public class StaticCursor: CursorType { self.exhausted = true - return Observable.just(0.., + didLoad newItems: [Cursor.Element], + itemsBefore: [Cursor.Element]) + + func paginationWrapper(wrapper: PaginationTableViewWrapper, + didReload allItems: [Cursor.Element]) + +} + +public class PaginationTableViewWrapper +where D.Cursor == C { + + private let tableView: UITableView + + private let paginationViewModel: PaginationViewModel + + private weak var delegate: D? + + public init(tableView: UITableView, cursor: C, delegate: D) { + self.tableView = tableView + self.paginationViewModel = PaginationViewModel(cursor: cursor) + self.delegate = delegate + } + +} diff --git a/LeadKit/LeadKit/Classes/Pagination/PaginationViewModel.swift b/LeadKit/LeadKit/Classes/Pagination/PaginationViewModel.swift index 5fc3fe56..ca66af7e 100644 --- a/LeadKit/LeadKit/Classes/Pagination/PaginationViewModel.swift +++ b/LeadKit/LeadKit/Classes/Pagination/PaginationViewModel.swift @@ -23,8 +23,9 @@ import RxSwift import RxCocoa -public class PaginationViewModel -where C: ResettableCursorType, C.LoadResultType == CountableRange { +public typealias ResettableCursorType = CursorType & ResettableType + +public final class PaginationViewModel { public indirect enum State { @@ -51,6 +52,8 @@ where C: ResettableCursorType, C.LoadResultType == CountableRange { private var currentRequest: Disposable? + private let internalScheduler = SerialDispatchQueueScheduler(qos: .default) + public var state: Driver { return internalState.asDriver() } @@ -75,16 +78,15 @@ where C: ResettableCursorType, C.LoadResultType == CountableRange { } currentRequest = cursor.loadNextBatch() - .subscribe(onNext: { [weak self] loadedRange in - self?.onGot(cursorLoadResult: loadedRange) + .subscribeOn(internalScheduler) + .subscribe(onNext: { [weak self] newItems in + self?.onGot(newItems: newItems) }, onError: { [weak self] error in self?.onGot(error: error) }) } - private func onGot(cursorLoadResult: C.LoadResultType) { - let newItems = cursor[cursorLoadResult] - + private func onGot(newItems: [C.Element]) { if newItems.count > 0 { internalState.value = .results(newItems: newItems, after: internalState.value) } else { diff --git a/LeadKit/LeadKit/Extensions/CursorType/CursorType+Slice.swift b/LeadKit/LeadKit/Extensions/CursorType/CursorType+Slice.swift index 7c403514..dcc7d94d 100644 --- a/LeadKit/LeadKit/Extensions/CursorType/CursorType+Slice.swift +++ b/LeadKit/LeadKit/Extensions/CursorType/CursorType+Slice.swift @@ -22,9 +22,13 @@ import Foundation -public extension CursorType where LoadResultType == CountableRange { +public extension CursorType { - subscript(range: LoadResultType) -> [Self.Element] { + subscript(range: CountableRange) -> [Self.Element] { + return range.map { self[$0] } + } + + subscript(range: CountableClosedRange) -> [Self.Element] { return range.map { self[$0] } } @@ -33,15 +37,3 @@ public extension CursorType where LoadResultType == CountableRange { } } - -public extension CursorType where LoadResultType == CountableClosedRange { - - subscript(range: LoadResultType) -> [Self.Element] { - return range.map { self[$0] } - } - - var loadedElements: [Self.Element] { - return self[0...count - 1] - } - -} diff --git a/LeadKit/LeadKit/Protocols/CursorType.swift b/LeadKit/LeadKit/Protocols/CursorType.swift index 554ebfc8..3de50906 100644 --- a/LeadKit/LeadKit/Protocols/CursorType.swift +++ b/LeadKit/LeadKit/Protocols/CursorType.swift @@ -27,8 +27,6 @@ public protocol CursorType { associatedtype Element - associatedtype LoadResultType - /// Indicates that cursor load all available results var exhausted: Bool { get } @@ -40,6 +38,6 @@ public protocol CursorType { /// Loads next batch of results /// /// - Returns: Observable of LoadResultType - func loadNextBatch() -> Observable + func loadNextBatch() -> Observable<[Element]> } diff --git a/LeadKit/LeadKit/Protocols/ResettableCursorType.swift b/LeadKit/LeadKit/Protocols/ResettableType.swift similarity index 94% rename from LeadKit/LeadKit/Protocols/ResettableCursorType.swift rename to LeadKit/LeadKit/Protocols/ResettableType.swift index 939e7ee9..fefe1b2b 100644 --- a/LeadKit/LeadKit/Protocols/ResettableCursorType.swift +++ b/LeadKit/LeadKit/Protocols/ResettableType.swift @@ -22,13 +22,13 @@ import Foundation -public protocol ResettableCursorType { +public protocol ResettableType { init(initialFrom other: Self) } -public extension ResettableCursorType { +public extension ResettableType { func reset() -> Self { return Self(initialFrom: self) diff --git a/LeadKit/LeadKitTests/CursorTests.swift b/LeadKit/LeadKitTests/CursorTests.swift index 39b2f59c..e03519b2 100644 --- a/LeadKit/LeadKitTests/CursorTests.swift +++ b/LeadKit/LeadKitTests/CursorTests.swift @@ -58,8 +58,8 @@ class CursorTests: XCTestCase { let cursorExpectationError = expectation(description: "Fixed page cursor error expectation") fixedPageCursor.loadNextBatch() - .subscribe(onNext: { loadedRange in - XCTAssertEqual(fixedPageCursor[loadedRange].count, 15) + .subscribe(onNext: { loadedItems in + XCTAssertEqual(loadedItems.count, 15) cursorExpectation.fulfill() }) @@ -89,8 +89,8 @@ class CursorTests: XCTestCase { let cursorExpectation = expectation(description: "Fixed page cursor expectation") fixedPageCursor.loadNextBatch() - .subscribe(onNext: { loadedRange in - XCTAssertEqual(fixedPageCursor[loadedRange].count, 8) + .subscribe(onNext: { loadedItems in + XCTAssertEqual(loadedItems.count, 8) cursorExpectation.fulfill() }) diff --git a/LeadKit/LeadKitTests/Cursors/StubCursor.swift b/LeadKit/LeadKitTests/Cursors/StubCursor.swift index 1fe4f17e..4d391635 100644 --- a/LeadKit/LeadKitTests/Cursors/StubCursor.swift +++ b/LeadKit/LeadKitTests/Cursors/StubCursor.swift @@ -23,7 +23,7 @@ import LeadKit import RxSwift -class StubCursor: CursorType, ResettableCursorType { +class StubCursor: ResettableCursorType { typealias LoadResultType = CountableRange @@ -57,7 +57,7 @@ class StubCursor: CursorType, ResettableCursorType { self.requestDelay = other.requestDelay } - func loadNextBatch() -> Observable> { + func loadNextBatch() -> Observable<[Post]> { return Observable.create { observer -> Disposable in if self.exhausted { observer.onError(CursorError.exhausted) @@ -71,7 +71,7 @@ class StubCursor: CursorType, ResettableCursorType { self.posts = Array((self.posts + newPosts)[0.. Date: Wed, 5 Apr 2017 15:55:50 +0300 Subject: [PATCH 04/28] initial placeholders support in pagination wrapper. binding to view model in pagination wrapper --- .../PaginationTableViewWrapper.swift | 109 +++++++++++++++++- 1 file changed, 106 insertions(+), 3 deletions(-) diff --git a/LeadKit/LeadKit/Classes/Pagination/PaginationTableViewWrapper.swift b/LeadKit/LeadKit/Classes/Pagination/PaginationTableViewWrapper.swift index b524076c..b4da58a5 100644 --- a/LeadKit/LeadKit/Classes/Pagination/PaginationTableViewWrapper.swift +++ b/LeadKit/LeadKit/Classes/Pagination/PaginationTableViewWrapper.swift @@ -21,6 +21,7 @@ // import UIKit +import RxSwift public protocol PaginationTableViewWrapperDelegate: class { @@ -33,21 +34,123 @@ public protocol PaginationTableViewWrapperDelegate: class { func paginationWrapper(wrapper: PaginationTableViewWrapper, didReload allItems: [Cursor.Element]) + func emptyPlaceholder(forPaginationWrapper wrapper: PaginationTableViewWrapper) -> UIView + + func errorPlaceholder(forPaginationWrapper wrapper: PaginationTableViewWrapper, + forError error: Error) -> UIView + +} + +public extension PaginationTableViewWrapperDelegate { + + func emptyPlaceholder(forPaginationWrapper wrapper: PaginationTableViewWrapper) -> UIView { + let placeholder = UIView() + + let label = UILabel() + label.translatesAutoresizingMaskIntoConstraints = false + label.text = "There is nothing here" + + placeholder.addSubview(label) + label.centerXAnchor.constraint(equalTo: placeholder.centerXAnchor).isActive = true + label.centerYAnchor.constraint(equalTo: placeholder.centerYAnchor).isActive = true + + return placeholder + } + + func errorPlaceholder(forPaginationWrapper wrapper: PaginationTableViewWrapper, + forError error: Error) -> UIView { + + let placeholder = UIView() + + let label = UILabel() + label.translatesAutoresizingMaskIntoConstraints = false + label.text = "An error has occurred" + + placeholder.addSubview(label) + label.centerXAnchor.constraint(equalTo: placeholder.centerXAnchor).isActive = true + label.centerYAnchor.constraint(equalTo: placeholder.centerYAnchor).isActive = true + + return placeholder + } + } public class PaginationTableViewWrapper where D.Cursor == C { + public typealias PlaceholderTransform = (UIView, CGPoint) -> Void + private let tableView: UITableView - + private let placeholdersContainerView: UIView private let paginationViewModel: PaginationViewModel - private weak var delegate: D? - public init(tableView: UITableView, cursor: C, delegate: D) { + public var placeholderTransformOnScroll: PlaceholderTransform = { view, offset in + var newFrame = view.frame + newFrame.origin.y = -offset.y + + view.frame = newFrame + } + + private let disposeBag = DisposeBag() + + public init(tableView: UITableView, placeholdersContainer: UIView, cursor: C, delegate: D) { self.tableView = tableView + self.placeholdersContainerView = placeholdersContainer self.paginationViewModel = PaginationViewModel(cursor: cursor) self.delegate = delegate + + paginationViewModel.state.drive(onNext: { [weak self] state in + switch state { + case .initial: + self?.onInitialState() + case .loading: + self?.onLoadingState() + case .loadingMore: + self?.onLoadingMoreState() + case .results(let newItems, let after): + self?.onResultsState(newItems: newItems, afterState: after) + case .error(let error, let after): + self?.onErrorState(error: error, afterState: after) + case .empty: + self?.onEmptyState() + case .exhausted: + self?.onExhaustedState() + } + }) + .addDisposableTo(disposeBag) + } + + public func reload() { + paginationViewModel.load(.reload) + } + + private func onInitialState() { + // + } + + private func onLoadingState() { + // + } + + private func onLoadingMoreState() { + // + } + + private func onResultsState(newItems: [C.Element], afterState: PaginationViewModel.State) { + // + } + + private func onErrorState(error: Error, afterState: PaginationViewModel.State) { + // + } + + private func onEmptyState() { + // + } + + private func onExhaustedState() { + // } } From b1c657dd827b03922ea9410690221aeab8f907d2 Mon Sep 17 00:00:00 2001 From: Ivan Smolin Date: Thu, 6 Apr 2017 09:19:17 +0300 Subject: [PATCH 05/28] Support protocol (Reactive analogue). Initial infinite scroll support --- LeadKit.podspec | 3 +- LeadKit/LeadKit.xcodeproj/project.pbxproj | 16 ++++ .../PaginationTableViewWrapper.swift | 38 +++++++- .../Support/UIScrollView+Support.swift | 47 ++++++++++ .../LeadKit/Protocols/SupportProtocol.swift | 86 +++++++++++++++++++ LeadKit/LeadKitTests/Cursors/StubCursor.swift | 2 - LeadKit/Podfile | 3 +- LeadKit/Podfile.lock | 5 +- 8 files changed, 191 insertions(+), 9 deletions(-) create mode 100644 LeadKit/LeadKit/Extensions/Support/UIScrollView+Support.swift create mode 100644 LeadKit/LeadKit/Protocols/SupportProtocol.swift diff --git a/LeadKit.podspec b/LeadKit.podspec index 9462fd5b..87391000 100644 --- a/LeadKit.podspec +++ b/LeadKit.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |s| s.name = "LeadKit" - s.version = "0.4.9" + s.version = "0.5.0" s.summary = "iOS framework with a bunch of tools for rapid development" s.homepage = "https://github.com/TouchInstinct/LeadKit" s.license = "Apache License, Version 2.0" @@ -16,4 +16,5 @@ Pod::Spec.new do |s| s.dependency "ObjectMapper", '~> 2.1' s.dependency "Toast-Swift", '~> 2.0.0' s.dependency "TableKit", '~> 2.3.1' + s.dependency "UIScrollView-InfiniteScroll", '~> 1.0.0' end diff --git a/LeadKit/LeadKit.xcodeproj/project.pbxproj b/LeadKit/LeadKit.xcodeproj/project.pbxproj index bff7ba8c..778f2d33 100644 --- a/LeadKit/LeadKit.xcodeproj/project.pbxproj +++ b/LeadKit/LeadKit.xcodeproj/project.pbxproj @@ -13,6 +13,8 @@ 675D24B21E9234BB00E92D1F /* PaginationViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 675D24B11E9234BB00E92D1F /* PaginationViewModel.swift */; }; 67788F9F1E69661800484DEE /* CGFloat+Pixels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 67788F9E1E69661800484DEE /* CGFloat+Pixels.swift */; }; 678A202A1E93C1A900787562 /* PaginationTableViewWrapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 678A20291E93C1A900787562 /* PaginationTableViewWrapper.swift */; }; + 679DE4901E9588B6006F25FE /* SupportProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 679DE48F1E9588B6006F25FE /* SupportProtocol.swift */; }; + 679DE4941E9613ED006F25FE /* UIScrollView+Support.swift in Sources */ = {isa = PBXBuildFile; fileRef = 679DE4931E9613ED006F25FE /* UIScrollView+Support.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 */; }; @@ -107,6 +109,8 @@ 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 = ""; }; 678A20291E93C1A900787562 /* PaginationTableViewWrapper.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PaginationTableViewWrapper.swift; sourceTree = ""; }; + 679DE48F1E9588B6006F25FE /* SupportProtocol.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SupportProtocol.swift; sourceTree = ""; }; + 679DE4931E9613ED006F25FE /* UIScrollView+Support.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "UIScrollView+Support.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 = ""; }; @@ -235,6 +239,14 @@ path = CGFloat; sourceTree = ""; }; + 679DE4921E9613ED006F25FE /* Support */ = { + isa = PBXGroup; + children = ( + 679DE4931E9613ED006F25FE /* UIScrollView+Support.swift */, + ); + path = Support; + sourceTree = ""; + }; 67B305791E8A8727008169CA /* Views */ = { isa = PBXGroup; children = ( @@ -514,6 +526,7 @@ 78E59B2C1C786CD500C6BFE9 /* UIView */, 78D4B5441DA64D31005B0764 /* UIViewController */, CA6196DE1E9BDB3900C5889F /* UIWindow */, + 679DE4921E9613ED006F25FE /* Support */, 7884DB9A1DC1432B00E52A63 /* UserDefaults */, ); path = Extensions; @@ -537,6 +550,7 @@ 78CFEE4F1C5C45E500F50370 /* ViewHeightProtocol.swift */, 78CFEE501C5C45E500F50370 /* ViewModelProtocol.swift */, 67B856E21E923BE600F54304 /* ResettableType.swift */, + 679DE48F1E9588B6006F25FE /* SupportProtocol.swift */, ); path = Protocols; sourceTree = ""; @@ -890,6 +904,7 @@ 78B036431DA4FEC90021D5CC /* CGImage+Transform.swift in Sources */, 78011A641D47ABC500EA16A2 /* UIView+DefaultReuseIdentifier.swift in Sources */, 678A202A1E93C1A900787562 /* PaginationTableViewWrapper.swift in Sources */, + 679DE4901E9588B6006F25FE /* SupportProtocol.swift in Sources */, 786D78EC1D53C46E006B2CEA /* AlamofireManager+Extensions.swift in Sources */, CAA707D51E2E614E0022D732 /* ModuleConfigurator.swift in Sources */, 675D24B21E9234BB00E92D1F /* PaginationViewModel.swift in Sources */, @@ -898,6 +913,7 @@ CAE698C61E96775F000394B0 /* String+Extensions.swift in Sources */, 7873D14F1E1127BC001816EB /* LeadKitError.swift in Sources */, 78753E301DE594B4006BC0FB /* MapCursor.swift in Sources */, + 679DE4941E9613ED006F25FE /* UIScrollView+Support.swift in Sources */, 780D23461DA416F80084620D /* CGContext+Initializers.swift in Sources */, 95B39A861D9D51250057BD54 /* String+Localization.swift in Sources */, 78C36F7E1D801E3E00E7EBEA /* Double+Rounding.swift in Sources */, diff --git a/LeadKit/LeadKit/Classes/Pagination/PaginationTableViewWrapper.swift b/LeadKit/LeadKit/Classes/Pagination/PaginationTableViewWrapper.swift index b4da58a5..8f725ec1 100644 --- a/LeadKit/LeadKit/Classes/Pagination/PaginationTableViewWrapper.swift +++ b/LeadKit/LeadKit/Classes/Pagination/PaginationTableViewWrapper.swift @@ -22,14 +22,14 @@ import UIKit import RxSwift +import UIScrollView_InfiniteScroll public protocol PaginationTableViewWrapperDelegate: class { associatedtype Cursor: ResettableCursorType func paginationWrapper(wrapper: PaginationTableViewWrapper, - didLoad newItems: [Cursor.Element], - itemsBefore: [Cursor.Element]) + didLoad newItems: [Cursor.Element]) func paginationWrapper(wrapper: PaginationTableViewWrapper, didReload allItems: [Cursor.Element]) @@ -39,6 +39,8 @@ public protocol PaginationTableViewWrapperDelegate: class { func errorPlaceholder(forPaginationWrapper wrapper: PaginationTableViewWrapper, forError error: Error) -> UIView + func loadingIndicator(forPaginationWrapper wrapper: PaginationTableViewWrapper) -> UIView + } public extension PaginationTableViewWrapperDelegate { @@ -73,6 +75,10 @@ public extension PaginationTableViewWrapperDelegate { return placeholder } + func loadingIndicator(forPaginationWrapper wrapper: PaginationTableViewWrapper) -> UIView { + return UIActivityIndicatorView(activityIndicatorStyle: .gray) + } + } public class PaginationTableViewWrapper @@ -119,6 +125,15 @@ where D.Cursor == C { } }) .addDisposableTo(disposeBag) + + let refreshControl = UIRefreshControl() + refreshControl.rx.controlEvent(.valueChanged) + .bindNext { [weak self] _ in + self?.reload() + } + .addDisposableTo(disposeBag) + + tableView.support.setRefreshControl(refreshControl) } public func reload() { @@ -138,7 +153,17 @@ where D.Cursor == C { } private func onResultsState(newItems: [C.Element], afterState: PaginationViewModel.State) { - // + if case .loading = afterState { + delegate?.paginationWrapper(wrapper: self, didReload: newItems) + + tableView.support.refreshControl?.endRefreshing() + + addInfiniteScroll() + } else if case .loadingMore = afterState { + delegate?.paginationWrapper(wrapper: self, didLoad: newItems) + + tableView.finishInfiniteScroll() + } } private func onErrorState(error: Error, afterState: PaginationViewModel.State) { @@ -150,7 +175,12 @@ where D.Cursor == C { } private func onExhaustedState() { - // + tableView.removeInfiniteScroll() } + private func addInfiniteScroll() { + tableView.addInfiniteScroll { [weak paginationViewModel] _ in + paginationViewModel?.load(.next) + } + } } diff --git a/LeadKit/LeadKit/Extensions/Support/UIScrollView+Support.swift b/LeadKit/LeadKit/Extensions/Support/UIScrollView+Support.swift new file mode 100644 index 00000000..ccd40e7b --- /dev/null +++ b/LeadKit/LeadKit/Extensions/Support/UIScrollView+Support.swift @@ -0,0 +1,47 @@ +// +// 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 UIKit + +public extension Support where Base: UIScrollView { + + public var refreshControl: UIRefreshControl? { + if #available(iOS 10.0, *) { + return base.refreshControl + } else { + return base.subviews.first { $0 is UIRefreshControl } as? UIRefreshControl + } + } + + public func setRefreshControl(_ newRefreshControl: UIRefreshControl?) { + if #available(iOS 10.0, *) { + base.refreshControl = newRefreshControl + } else { + if let control = newRefreshControl { + base.addSubview(control) + } else { + refreshControl?.removeFromSuperview() + } + } + } + +} diff --git a/LeadKit/LeadKit/Protocols/SupportProtocol.swift b/LeadKit/LeadKit/Protocols/SupportProtocol.swift new file mode 100644 index 00000000..364b493b --- /dev/null +++ b/LeadKit/LeadKit/Protocols/SupportProtocol.swift @@ -0,0 +1,86 @@ +// +// 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 + +/** + Use `Support` proxy as customization point for constrained protocol extensions. + + General pattern would be: + + // 1. Extend Support protocol with constrain on Base + // Read as: Support Extension where Base is a SomeType + extension Support where Base: SomeType { + // 2. Put any specific reactive extension for SomeType here + } + + With this approach we can have more specialized methods and properties using + `Base` and not just specialized on common base type. + + */ +public struct Support { + /// Base object to extend. + public let base: Base + + /// Creates extensions with base object. + /// + /// - parameter base: Base object. + public init(_ base: Base) { + self.base = base + } +} + +/// A type that has reactive extensions. +public protocol SupportCompatible { + /// Extended type + associatedtype CompatibleType + + /// Support extensions. + static var support: Support.Type { get set } + + /// Support extensions. + var support: Support { get set } +} + +public extension SupportCompatible { + /// Support extensions. + public static var support: Support.Type { + get { + return Support.self + } + set { + // this enables using Support to "mutate" base type + } + } + + /// Support extensions. + public var support: Support { + get { + return Support(self) + } + set { + // this enables using Support to "mutate" base object + } + } +} + +extension NSObject: SupportCompatible {} diff --git a/LeadKit/LeadKitTests/Cursors/StubCursor.swift b/LeadKit/LeadKitTests/Cursors/StubCursor.swift index 4d391635..3a8241ca 100644 --- a/LeadKit/LeadKitTests/Cursors/StubCursor.swift +++ b/LeadKit/LeadKitTests/Cursors/StubCursor.swift @@ -25,8 +25,6 @@ import RxSwift class StubCursor: ResettableCursorType { - typealias LoadResultType = CountableRange - typealias Element = Post private var posts: [Post] = [] diff --git a/LeadKit/Podfile b/LeadKit/Podfile index 76cddc6c..8fd231e6 100644 --- a/LeadKit/Podfile +++ b/LeadKit/Podfile @@ -11,7 +11,8 @@ target 'LeadKit' do pod "ObjectMapper", '~> 2.1' pod "Toast-Swift", '~> 2.0.0' pod "TableKit", '~> 2.3.1' - + pod "UIScrollView-InfiniteScroll", '~> 1.0.0' + target 'LeadKitTests' do inherit! :search_paths # Pods for testing diff --git a/LeadKit/Podfile.lock b/LeadKit/Podfile.lock index d02ce890..d7f9f13c 100644 --- a/LeadKit/Podfile.lock +++ b/LeadKit/Podfile.lock @@ -14,6 +14,7 @@ PODS: - RxSwift (3.2.0) - TableKit (2.3.1) - Toast-Swift (2.0.0) + - UIScrollView-InfiniteScroll (1.0.0) DEPENDENCIES: - CocoaLumberjack/Swift (~> 3.1.0) @@ -23,6 +24,7 @@ DEPENDENCIES: - RxSwift (= 3.2.0) - TableKit (~> 2.3.1) - Toast-Swift (~> 2.0.0) + - UIScrollView-InfiniteScroll (~> 1.0.0) SPEC CHECKSUMS: Alamofire: dc44b1600b800eb63da6a19039a0083d62a6a62d @@ -33,7 +35,8 @@ SPEC CHECKSUMS: RxSwift: 46574f70d416b7923c237195939cc488a7fbf3a0 TableKit: 02e041b443f75fa3e9f1ee6024d4b256305bd904 Toast-Swift: 5b2f8f720f7e78e48511f693df1f9c9a6e38a25a + UIScrollView-InfiniteScroll: d26885be71caca7485cdb37eab513a8f89036bb0 -PODFILE CHECKSUM: c85ee069c7ba0c53c44a486be3daf0ff5ed4e9fa +PODFILE CHECKSUM: 0e4c2bc8339733ce0009cdae7684c9cdf03a9be0 COCOAPODS: 1.2.0 From 5db2c2bd5e261a8378d33010a8c520f0592b106b Mon Sep 17 00:00:00 2001 From: Ivan Smolin Date: Fri, 7 Apr 2017 15:14:13 +0500 Subject: [PATCH 06/28] placeholders and loading indicators implementation --- LeadKit/LeadKit.xcodeproj/project.pbxproj | 46 ++++- .../PaginationTableViewWrapper.swift | 188 +++++++++++++----- .../Pagination/PaginationViewModel.swift | 19 +- ...rapperDelegate+DefaultImplementation.swift | 74 +++++++ ...UIActivityIndicator+LoadingIndicator.swift | 27 +++ .../UIView/UIView+LoadingIndicator.swift | 31 +++ .../Protocols/LoadingIndicatorProtocol.swift | 38 ++++ .../Views/AnyLoadingIndicator.swift | 47 +++++ 8 files changed, 415 insertions(+), 55 deletions(-) create mode 100644 LeadKit/LeadKit/Extensions/PaginationTableViewWrapperDelegate/PaginationTableViewWrapperDelegate+DefaultImplementation.swift create mode 100644 LeadKit/LeadKit/Extensions/UIActivityIndicator/UIActivityIndicator+LoadingIndicator.swift create mode 100644 LeadKit/LeadKit/Extensions/UIView/UIView+LoadingIndicator.swift create mode 100644 LeadKit/LeadKit/Protocols/LoadingIndicatorProtocol.swift create mode 100644 LeadKit/LeadKit/Structures/Views/AnyLoadingIndicator.swift diff --git a/LeadKit/LeadKit.xcodeproj/project.pbxproj b/LeadKit/LeadKit.xcodeproj/project.pbxproj index 778f2d33..8f9a115c 100644 --- a/LeadKit/LeadKit.xcodeproj/project.pbxproj +++ b/LeadKit/LeadKit.xcodeproj/project.pbxproj @@ -20,6 +20,11 @@ 67B3057F1E8A8804008169CA /* LoadFromNibTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 67B3057E1E8A8804008169CA /* LoadFromNibTests.swift */; }; 67B305841E8A92E8008169CA /* XibView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 67B305831E8A92E8008169CA /* XibView.swift */; }; 67B856E31E923BE600F54304 /* ResettableType.swift in Sources */ = {isa = PBXBuildFile; fileRef = 67B856E21E923BE600F54304 /* ResettableType.swift */; }; + 67DC65041E979B34002F2FFF /* LoadingIndicatorProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 67DC65031E979B34002F2FFF /* LoadingIndicatorProtocol.swift */; }; + 67DC65061E979B70002F2FFF /* UIView+LoadingIndicator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 67DC65051E979B70002F2FFF /* UIView+LoadingIndicator.swift */; }; + 67DC65091E979BB8002F2FFF /* UIActivityIndicator+LoadingIndicator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 67DC65081E979BB8002F2FFF /* UIActivityIndicator+LoadingIndicator.swift */; }; + 67DC650C1E979C0A002F2FFF /* AnyLoadingIndicator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 67DC650B1E979C0A002F2FFF /* AnyLoadingIndicator.swift */; }; + 67DC650F1E979D0C002F2FFF /* PaginationTableViewWrapperDelegate+DefaultImplementation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 67DC650E1E979D0C002F2FFF /* PaginationTableViewWrapperDelegate+DefaultImplementation.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 */; }; @@ -116,6 +121,11 @@ 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 /* ResettableType.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ResettableType.swift; sourceTree = ""; }; + 67DC65031E979B34002F2FFF /* LoadingIndicatorProtocol.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LoadingIndicatorProtocol.swift; sourceTree = ""; }; + 67DC65051E979B70002F2FFF /* UIView+LoadingIndicator.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "UIView+LoadingIndicator.swift"; sourceTree = ""; }; + 67DC65081E979BB8002F2FFF /* UIActivityIndicator+LoadingIndicator.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "UIActivityIndicator+LoadingIndicator.swift"; sourceTree = ""; }; + 67DC650B1E979C0A002F2FFF /* AnyLoadingIndicator.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AnyLoadingIndicator.swift; sourceTree = ""; }; + 67DC650E1E979D0C002F2FFF /* PaginationTableViewWrapperDelegate+DefaultImplementation.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "PaginationTableViewWrapperDelegate+DefaultImplementation.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 = ""; }; @@ -264,6 +274,30 @@ path = Views; sourceTree = ""; }; + 67DC65071E979BA9002F2FFF /* UIActivityIndicator */ = { + isa = PBXGroup; + children = ( + 67DC65081E979BB8002F2FFF /* UIActivityIndicator+LoadingIndicator.swift */, + ); + path = UIActivityIndicator; + sourceTree = ""; + }; + 67DC650A1E979BFD002F2FFF /* Views */ = { + isa = PBXGroup; + children = ( + 67DC650B1E979C0A002F2FFF /* AnyLoadingIndicator.swift */, + ); + path = Views; + sourceTree = ""; + }; + 67DC650D1E979CF7002F2FFF /* PaginationTableViewWrapperDelegate */ = { + isa = PBXGroup; + children = ( + 67DC650E1E979D0C002F2FFF /* PaginationTableViewWrapperDelegate+DefaultImplementation.swift */, + ); + path = PaginationTableViewWrapperDelegate; + sourceTree = ""; + }; 67EF144A1E8BEA9C00D6E0DD /* Cursors */ = { isa = PBXGroup; children = ( @@ -284,6 +318,7 @@ 78011AAE1D48B46100EA16A2 /* Structures */ = { isa = PBXGroup; children = ( + 67DC650A1E979BFD002F2FFF /* Views */, 78011AB11D48B53600EA16A2 /* Api */, ); path = Structures; @@ -513,11 +548,14 @@ 787783611CA03C84001CDC9B /* IndexPath */, 787D87481E10E19000D6015C /* ObjectMapper */, 787609201E1403460093CE36 /* Observable */, + 67DC650D1E979CF7002F2FFF /* PaginationTableViewWrapperDelegate */, 780F56C81E0D76A5004530B6 /* Sequence */, 78A0FCC41DC366A10070B5E1 /* StoryboardProtocol */, 787783651CA04D14001CDC9B /* String */, CAE698BF1E965AE9000394B0 /* TableDirector */, + 679DE4921E9613ED006F25FE /* Support */, EF2921A41E16595100E8F43B /* TimeInterval */, + 67DC65071E979BA9002F2FFF /* UIActivityIndicator */, E126CBB11DB68D9A00E1B2F8 /* UICollectionView */, 78C36F7F1D8021D100E7EBEA /* UIColor */, CA1FE7071E27D79C00968901 /* UIDevice */, @@ -526,7 +564,6 @@ 78E59B2C1C786CD500C6BFE9 /* UIView */, 78D4B5441DA64D31005B0764 /* UIViewController */, CA6196DE1E9BDB3900C5889F /* UIWindow */, - 679DE4921E9613ED006F25FE /* Support */, 7884DB9A1DC1432B00E52A63 /* UserDefaults */, ); path = Extensions; @@ -551,6 +588,7 @@ 78CFEE501C5C45E500F50370 /* ViewModelProtocol.swift */, 67B856E21E923BE600F54304 /* ResettableType.swift */, 679DE48F1E9588B6006F25FE /* SupportProtocol.swift */, + 67DC65031E979B34002F2FFF /* LoadingIndicatorProtocol.swift */, ); path = Protocols; sourceTree = ""; @@ -581,6 +619,7 @@ 78011A631D47ABC500EA16A2 /* UIView+DefaultReuseIdentifier.swift */, 78CFEE481C5C45E500F50370 /* UIView+LoadFromNib.swift */, EF5FB5681E0141610030E4BE /* UIView+Rotation.swift */, + 67DC65051E979B70002F2FFF /* UIView+LoadingIndicator.swift */, ); path = UIView; sourceTree = ""; @@ -898,6 +937,7 @@ 78753E2C1DE58BF9006BC0FB /* StaticCursor.swift in Sources */, 78D4B54A1DA64EAB005B0764 /* Any+TypeName.swift in Sources */, 78CFEE571C5C45E500F50370 /* StaticNibNameProtocol.swift in Sources */, + 67DC650C1E979C0A002F2FFF /* AnyLoadingIndicator.swift in Sources */, 788EC15A1CF64528009CFB6B /* UIStoryboard+InstantiateViewController.swift in Sources */, 787783671CA04D4A001CDC9B /* String+SizeCalculation.swift in Sources */, 7873D1511E112B0D001816EB /* Any+Cast.swift in Sources */, @@ -917,6 +957,7 @@ 780D23461DA416F80084620D /* CGContext+Initializers.swift in Sources */, 95B39A861D9D51250057BD54 /* String+Localization.swift in Sources */, 78C36F7E1D801E3E00E7EBEA /* Double+Rounding.swift in Sources */, + 67DC65061E979B70002F2FFF /* UIView+LoadingIndicator.swift in Sources */, 787609221E1403830093CE36 /* Observable+DeferredJust.swift in Sources */, 67B305841E8A92E8008169CA /* XibView.swift in Sources */, 78C54AFD1E432EEF0051EFBA /* UIViewController+TopVisibleViewController.swift in Sources */, @@ -945,10 +986,13 @@ CAA707D71E2E616D0022D732 /* BaseViewModel.swift in Sources */, E126CBB31DB68DDA00E1B2F8 /* UICollectionView+CellRegistration.swift in Sources */, 78CFEE581C5C45E500F50370 /* StaticViewHeightProtocol.swift in Sources */, + 67DC65091E979BB8002F2FFF /* UIActivityIndicator+LoadingIndicator.swift in Sources */, 67B856E31E923BE600F54304 /* ResettableType.swift in Sources */, 787783631CA03CA0001CDC9B /* IndexPath+ImmutableIndexPath.swift in Sources */, 78B036471DA5624D0021D5CC /* CGImage+Creation.swift in Sources */, 789CC6081DE5835600F789D3 /* CursorType.swift in Sources */, + 67DC650F1E979D0C002F2FFF /* PaginationTableViewWrapperDelegate+DefaultImplementation.swift in Sources */, + 67DC65041E979B34002F2FFF /* LoadingIndicatorProtocol.swift in Sources */, 78B0364B1DA61EDE0021D5CC /* CGImage+Crop.swift in Sources */, EDF3DE3F1EA4F2E80016F729 /* UIViewController+XibName.swift in Sources */, 78B036451DA561D00021D5CC /* CGImage+Utils.swift in Sources */, diff --git a/LeadKit/LeadKit/Classes/Pagination/PaginationTableViewWrapper.swift b/LeadKit/LeadKit/Classes/Pagination/PaginationTableViewWrapper.swift index 8f725ec1..a070dbeb 100644 --- a/LeadKit/LeadKit/Classes/Pagination/PaginationTableViewWrapper.swift +++ b/LeadKit/LeadKit/Classes/Pagination/PaginationTableViewWrapper.swift @@ -39,45 +39,9 @@ public protocol PaginationTableViewWrapperDelegate: class { func errorPlaceholder(forPaginationWrapper wrapper: PaginationTableViewWrapper, forError error: Error) -> UIView - func loadingIndicator(forPaginationWrapper wrapper: PaginationTableViewWrapper) -> UIView + func initialLoadingIndicator(forPaginationWrapper wrapper: PaginationTableViewWrapper) -> AnyLoadingIndicator -} - -public extension PaginationTableViewWrapperDelegate { - - func emptyPlaceholder(forPaginationWrapper wrapper: PaginationTableViewWrapper) -> UIView { - let placeholder = UIView() - - let label = UILabel() - label.translatesAutoresizingMaskIntoConstraints = false - label.text = "There is nothing here" - - placeholder.addSubview(label) - label.centerXAnchor.constraint(equalTo: placeholder.centerXAnchor).isActive = true - label.centerYAnchor.constraint(equalTo: placeholder.centerYAnchor).isActive = true - - return placeholder - } - - func errorPlaceholder(forPaginationWrapper wrapper: PaginationTableViewWrapper, - forError error: Error) -> UIView { - - let placeholder = UIView() - - let label = UILabel() - label.translatesAutoresizingMaskIntoConstraints = false - label.text = "An error has occurred" - - placeholder.addSubview(label) - label.centerXAnchor.constraint(equalTo: placeholder.centerXAnchor).isActive = true - label.centerYAnchor.constraint(equalTo: placeholder.centerYAnchor).isActive = true - - return placeholder - } - - func loadingIndicator(forPaginationWrapper wrapper: PaginationTableViewWrapper) -> UIView { - return UIActivityIndicatorView(activityIndicatorStyle: .gray) - } + func loadingMoreIndicator(forPaginationWrapper wrapper: PaginationTableViewWrapper) -> AnyLoadingIndicator } @@ -98,8 +62,33 @@ where D.Cursor == C { view.frame = newFrame } + public var scrollObservable: Observable? { + didSet { + scrollObservable?.subscribe(onNext: { [weak self] offset in + guard let placeholder = self?.currentPlaceholderView else { + return + } + + self?.placeholderTransformOnScroll(placeholder, offset) + }) + .addDisposableTo(disposeBag) + } + } + + public var infiniteScrollTriggerOffset: CGFloat { + get { + return tableView.infiniteScrollTriggerOffset + } + + set { + tableView.infiniteScrollTriggerOffset = newValue + } + } + private let disposeBag = DisposeBag() + private var currentPlaceholderView: UIView? + public init(tableView: UITableView, placeholdersContainer: UIView, cursor: C, delegate: D) { self.tableView = tableView self.placeholdersContainerView = placeholdersContainer @@ -107,13 +96,14 @@ where D.Cursor == C { self.delegate = delegate paginationViewModel.state.drive(onNext: { [weak self] state in + print(state) switch state { case .initial: self?.onInitialState() - case .loading: - self?.onLoadingState() - case .loadingMore: - self?.onLoadingMoreState() + case .loading(let after): + self?.onLoadingState(afterState: after) + case .loadingMore(let after): + self?.onLoadingMoreState(afterState: after) case .results(let newItems, let after): self?.onResultsState(newItems: newItems, afterState: after) case .error(let error, let after): @@ -128,7 +118,7 @@ where D.Cursor == C { let refreshControl = UIRefreshControl() refreshControl.rx.controlEvent(.valueChanged) - .bindNext { [weak self] _ in + .bindNext { [weak self] in self?.reload() } .addDisposableTo(disposeBag) @@ -140,22 +130,56 @@ where D.Cursor == C { paginationViewModel.load(.reload) } + // MARK: States handling + private func onInitialState() { // } - private func onLoadingState() { - // + private func onLoadingState(afterState: PaginationViewModel.State) { + if case .initial = afterState { + tableView.isUserInteractionEnabled = false + + currentPlaceholderView?.removeFromSuperview() + + guard let loadingIndicator = delegate?.initialLoadingIndicator(forPaginationWrapper: self) else { + return + } + + let loadingIndicatorView = loadingIndicator.view + + loadingIndicatorView.translatesAutoresizingMaskIntoConstraints = false + + placeholdersContainerView.insertSubview(loadingIndicatorView, aboveSubview: tableView) + + loadingIndicatorView.centerXAnchor.constraint(equalTo: placeholdersContainerView.centerXAnchor).isActive = true + loadingIndicatorView.centerYAnchor.constraint(equalTo: placeholdersContainerView.centerYAnchor).isActive = true + + loadingIndicator.startAnimating() + + currentPlaceholderView = loadingIndicatorView + } else { + tableView.finishInfiniteScroll() + tableView.tableFooterView = nil + } } - private func onLoadingMoreState() { - // + private func onLoadingMoreState(afterState: PaginationViewModel.State) { + if case .error = afterState { + tableView.tableFooterView = nil + addInfiniteScroll() + tableView.beginInfiniteScroll(true) + } } private func onResultsState(newItems: [C.Element], afterState: PaginationViewModel.State) { + tableView.isUserInteractionEnabled = true + if case .loading = afterState { delegate?.paginationWrapper(wrapper: self, didReload: newItems) + currentPlaceholderView?.removeFromSuperview() + tableView.support.refreshControl?.endRefreshing() addInfiniteScroll() @@ -167,13 +191,49 @@ where D.Cursor == C { } private func onErrorState(error: Error, afterState: PaginationViewModel.State) { - // + if case .loading = afterState { + enterPlaceholderState() + + guard let errorView = delegate?.errorPlaceholder(forPaginationWrapper: self, forError: error) else { + return + } + + preparePlaceholderView(errorView) + + currentPlaceholderView = errorView + } else if case .loadingMore = afterState { + tableView.finishInfiniteScroll() + + tableView.removeInfiniteScroll() + + let retryButton = UIButton(type: .custom) + retryButton.backgroundColor = .lightGray + retryButton.frame = CGRect(x: 0, y: 0, width: tableView.bounds.width, height: 44) + retryButton.setTitle("Retry load more", for: .normal) + retryButton.rx.controlEvent(.touchUpInside) + .bindNext { [weak self] in + self?.paginationViewModel.load(.next) + } + .addDisposableTo(disposeBag) + + tableView.tableFooterView = retryButton + } } private func onEmptyState() { - // + enterPlaceholderState() + + guard let emptyView = delegate?.emptyPlaceholder(forPaginationWrapper: self) else { + return + } + + preparePlaceholderView(emptyView) + + currentPlaceholderView = emptyView } + // MARK: private stuff + private func onExhaustedState() { tableView.removeInfiniteScroll() } @@ -182,5 +242,35 @@ where D.Cursor == C { tableView.addInfiniteScroll { [weak paginationViewModel] _ in paginationViewModel?.load(.next) } + + tableView.infiniteScrollIndicatorView = delegate?.loadingMoreIndicator(forPaginationWrapper: self).view + } + + private func enterPlaceholderState() { + tableView.support.refreshControl?.endRefreshing() + tableView.isUserInteractionEnabled = true + + currentPlaceholderView?.removeFromSuperview() + } + + private func preparePlaceholderView(_ placeholderView: UIView) { + placeholderView.translatesAutoresizingMaskIntoConstraints = false + + placeholdersContainerView.insertSubview(placeholderView, belowSubview: tableView) + + placeholderView.anchorConstrainst(to: placeholdersContainerView).forEach { $0.isActive = true } } } + +private extension UIView { + + func anchorConstrainst(to view: UIView) -> [NSLayoutConstraint] { + return [ + leadingAnchor.constraint(equalTo: view.leadingAnchor), + trailingAnchor.constraint(equalTo: view.trailingAnchor), + topAnchor.constraint(equalTo: view.topAnchor), + bottomAnchor.constraint(equalTo: view.bottomAnchor) + ] + } + +} diff --git a/LeadKit/LeadKit/Classes/Pagination/PaginationViewModel.swift b/LeadKit/LeadKit/Classes/Pagination/PaginationViewModel.swift index ca66af7e..ca1a251e 100644 --- a/LeadKit/LeadKit/Classes/Pagination/PaginationViewModel.swift +++ b/LeadKit/LeadKit/Classes/Pagination/PaginationViewModel.swift @@ -30,8 +30,8 @@ public final class PaginationViewModel { public indirect enum State { case initial - case loading // can be after any state - case loadingMore // can be after results + case loading(after: State) // can be after any state + case loadingMore(after: State) // can be after error or 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 @@ -68,13 +68,13 @@ public final class PaginationViewModel { currentRequest?.dispose() cursor = cursor.reset() - internalState.value = .loading + internalState.value = .loading(after: internalState.value) case .next: if case .exhausted(_) = internalState.value { preconditionFailure("You shouldn't call load(.next) after got .exhausted state!") } - internalState.value = .loadingMore + internalState.value = .loadingMore(after: internalState.value) } currentRequest = cursor.loadNextBatch() @@ -99,7 +99,16 @@ public final class PaginationViewModel { } private func onGot(error: Error) { - internalState.value = .error(error: error, after: internalState.value) + if case .exhausted? = error as? CursorError, case .loading(let after) = internalState.value { + switch after { + case .initial, .empty: // cursor exhausted after creation + internalState.value = .empty + default: + internalState.value = .error(error: error, after: internalState.value) + } + } else { + internalState.value = .error(error: error, after: internalState.value) + } } } diff --git a/LeadKit/LeadKit/Extensions/PaginationTableViewWrapperDelegate/PaginationTableViewWrapperDelegate+DefaultImplementation.swift b/LeadKit/LeadKit/Extensions/PaginationTableViewWrapperDelegate/PaginationTableViewWrapperDelegate+DefaultImplementation.swift new file mode 100644 index 00000000..a4df2491 --- /dev/null +++ b/LeadKit/LeadKit/Extensions/PaginationTableViewWrapperDelegate/PaginationTableViewWrapperDelegate+DefaultImplementation.swift @@ -0,0 +1,74 @@ +// +// 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 UIKit + +public extension PaginationTableViewWrapperDelegate { + + func emptyPlaceholder(forPaginationWrapper wrapper: PaginationTableViewWrapper) -> UIView { + let placeholder = UIView() + + let label = UILabel() + label.translatesAutoresizingMaskIntoConstraints = false + label.text = "There is nothing here" + + placeholder.addSubview(label) + label.centerXAnchor.constraint(equalTo: placeholder.centerXAnchor).isActive = true + label.centerYAnchor.constraint(equalTo: placeholder.centerYAnchor).isActive = true + + return placeholder + } + + func errorPlaceholder(forPaginationWrapper wrapper: PaginationTableViewWrapper, + forError error: Error) -> UIView { + + let placeholder = UIView() + + let label = UILabel() + label.translatesAutoresizingMaskIntoConstraints = false + label.text = "An error has occurred" + + placeholder.addSubview(label) + label.centerXAnchor.constraint(equalTo: placeholder.centerXAnchor).isActive = true + label.centerYAnchor.constraint(equalTo: placeholder.centerYAnchor).isActive = true + + return placeholder + } + + func initialLoadingIndicator(forPaginationWrapper wrapper: PaginationTableViewWrapper) + -> AnyLoadingIndicator { + + let indicator = UIActivityIndicatorView(activityIndicatorStyle: .whiteLarge) + indicator.color = .gray + + return AnyLoadingIndicator(indicator) + } + + func loadingMoreIndicator(forPaginationWrapper wrapper: PaginationTableViewWrapper) + -> AnyLoadingIndicator { + + let indicator = UIActivityIndicatorView(activityIndicatorStyle: .gray) + + return AnyLoadingIndicator(indicator) + } + +} diff --git a/LeadKit/LeadKit/Extensions/UIActivityIndicator/UIActivityIndicator+LoadingIndicator.swift b/LeadKit/LeadKit/Extensions/UIActivityIndicator/UIActivityIndicator+LoadingIndicator.swift new file mode 100644 index 00000000..f752a5da --- /dev/null +++ b/LeadKit/LeadKit/Extensions/UIActivityIndicator/UIActivityIndicator+LoadingIndicator.swift @@ -0,0 +1,27 @@ +// +// 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 UIKit + +extension UIActivityIndicatorView: Animatable {} + +extension UIActivityIndicatorView: LoadingIndicator {} diff --git a/LeadKit/LeadKit/Extensions/UIView/UIView+LoadingIndicator.swift b/LeadKit/LeadKit/Extensions/UIView/UIView+LoadingIndicator.swift new file mode 100644 index 00000000..2ae4b192 --- /dev/null +++ b/LeadKit/LeadKit/Extensions/UIView/UIView+LoadingIndicator.swift @@ -0,0 +1,31 @@ +// +// 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 + +extension LoadingIndicator where Self: UIView { + + public var view: Self { + return self + } + +} diff --git a/LeadKit/LeadKit/Protocols/LoadingIndicatorProtocol.swift b/LeadKit/LeadKit/Protocols/LoadingIndicatorProtocol.swift new file mode 100644 index 00000000..ccfb3d85 --- /dev/null +++ b/LeadKit/LeadKit/Protocols/LoadingIndicatorProtocol.swift @@ -0,0 +1,38 @@ +// +// 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 UIKit + +public protocol Animatable { + + func startAnimating() + func stopAnimating() + +} + +public protocol LoadingIndicator { + + associatedtype View: UIView, Animatable + + var view: View { get } + +} diff --git a/LeadKit/LeadKit/Structures/Views/AnyLoadingIndicator.swift b/LeadKit/LeadKit/Structures/Views/AnyLoadingIndicator.swift new file mode 100644 index 00000000..5e3386cd --- /dev/null +++ b/LeadKit/LeadKit/Structures/Views/AnyLoadingIndicator.swift @@ -0,0 +1,47 @@ +// +// 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 UIKit + +public struct AnyLoadingIndicator: Animatable { + + private let internalView: UIView + private let animatableView: Animatable + + public init(_ base: Indicator) where Indicator: LoadingIndicator { + self.internalView = base.view + self.animatableView = base.view + } + + var view: UIView { + return internalView + } + + public func startAnimating() { + animatableView.startAnimating() + } + + public func stopAnimating() { + animatableView.stopAnimating() + } + +} From 7355ebc8585fb7e0883e86189dc84b57aa88d24a Mon Sep 17 00:00:00 2001 From: Ivan Smolin Date: Fri, 7 Apr 2017 15:14:59 +0500 Subject: [PATCH 07/28] remove whitespace --- ...nationTableViewWrapperDelegate+DefaultImplementation.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/LeadKit/LeadKit/Extensions/PaginationTableViewWrapperDelegate/PaginationTableViewWrapperDelegate+DefaultImplementation.swift b/LeadKit/LeadKit/Extensions/PaginationTableViewWrapperDelegate/PaginationTableViewWrapperDelegate+DefaultImplementation.swift index a4df2491..1f762288 100644 --- a/LeadKit/LeadKit/Extensions/PaginationTableViewWrapperDelegate/PaginationTableViewWrapperDelegate+DefaultImplementation.swift +++ b/LeadKit/LeadKit/Extensions/PaginationTableViewWrapperDelegate/PaginationTableViewWrapperDelegate+DefaultImplementation.swift @@ -67,8 +67,8 @@ public extension PaginationTableViewWrapperDelegate { -> AnyLoadingIndicator { let indicator = UIActivityIndicatorView(activityIndicatorStyle: .gray) - + return AnyLoadingIndicator(indicator) } - + } From b3dface0c8c10b7a65cfb2a8d298ff1df3b1294b Mon Sep 17 00:00:00 2001 From: Ivan Smolin Date: Tue, 11 Apr 2017 13:12:19 +0300 Subject: [PATCH 08/28] finish retry load more customization, add UIApplication states handling and cover public methods with documentation --- .../PaginationTableViewWrapper.swift | 213 +++++++++++++----- .../Pagination/PaginationViewModel.swift | 43 +++- ...rapperDelegate+DefaultImplementation.swift | 12 + .../Protocols/LoadingIndicatorProtocol.swift | 6 + .../LeadKit/Protocols/ResettableType.swift | 7 + 5 files changed, 219 insertions(+), 62 deletions(-) diff --git a/LeadKit/LeadKit/Classes/Pagination/PaginationTableViewWrapper.swift b/LeadKit/LeadKit/Classes/Pagination/PaginationTableViewWrapper.swift index a070dbeb..18986cc6 100644 --- a/LeadKit/LeadKit/Classes/Pagination/PaginationTableViewWrapper.swift +++ b/LeadKit/LeadKit/Classes/Pagination/PaginationTableViewWrapper.swift @@ -24,57 +24,81 @@ import UIKit import RxSwift import UIScrollView_InfiniteScroll +/// PaginationTableViewWrapper delegate used for pagination results handling and +/// customization of bound states (loading, empty, error, etc.). public protocol PaginationTableViewWrapperDelegate: class { associatedtype Cursor: ResettableCursorType + /// Delegate method that handles loading new chunk of data. + /// + /// - Parameters: + /// - wrapper: Wrapper object that loaded new items. + /// - newItems: New items. func paginationWrapper(wrapper: PaginationTableViewWrapper, didLoad newItems: [Cursor.Element]) + /// Delegate method that handles reloading or initial loading of data. + /// + /// - Parameters: + /// - wrapper: Wrapper object that reload items. + /// - allItems: New items. func paginationWrapper(wrapper: PaginationTableViewWrapper, didReload allItems: [Cursor.Element]) + /// Delegate method that returns placeholder view for empty state. + /// + /// - Parameter wrapper: Wrapper object that requests empty placeholder view. + /// - Returns: Configured instace of UIView. func emptyPlaceholder(forPaginationWrapper wrapper: PaginationTableViewWrapper) -> UIView + /// Delegate method that returns placeholder view for error state. + /// + /// - Parameters: + /// - wrapper: Wrapper object that requests error placeholder view. + /// - error: Error that occured due data loading. + /// - Returns: Configured instace of UIView. func errorPlaceholder(forPaginationWrapper wrapper: PaginationTableViewWrapper, forError error: Error) -> UIView + /// Delegate method that returns loading idicator for initial loading state. + /// This indicator will appear at center of the placeholders container. + /// + /// - Parameter wrapper: Wrapper object that requests loading indicator + /// - Returns: Configured instace of AnyLoadingIndicator. func initialLoadingIndicator(forPaginationWrapper wrapper: PaginationTableViewWrapper) -> AnyLoadingIndicator + /// Delegate method that returns loading idicator for initial loading state. + /// + /// - Parameter wrapper: Wrapper object that requests loading indicator. + /// - Returns: Configured instace of AnyLoadingIndicator. func loadingMoreIndicator(forPaginationWrapper wrapper: PaginationTableViewWrapper) -> AnyLoadingIndicator + /// Delegate method that returns instance of UIButton for "retry load more" action. + /// + /// - Parameter wrapper: Wrapper object that requests button for "retry load more" action. + /// - Returns: Configured instace of AnyLoadingIndicator. + func retryLoadMoreButton(forPaginationWrapper wrapper: PaginationTableViewWrapper) -> UIButton + + /// Delegate method that returns preferred height for "retry load more" button. + /// + /// - Parameter wrapper: Wrapper object that requests height "retry load more" button. + /// - Returns: Preferred height of "retry load more" button. + func retryLoadMoreButtonHeight(forPaginationWrapper wrapper: PaginationTableViewWrapper) -> CGFloat + } -public class PaginationTableViewWrapper +/// Class that connects PaginationViewModel with UITableView. It handles all non-visual and visual states. +final public class PaginationTableViewWrapper where D.Cursor == C { - public typealias PlaceholderTransform = (UIView, CGPoint) -> Void - private let tableView: UITableView private let placeholdersContainerView: UIView private let paginationViewModel: PaginationViewModel private weak var delegate: D? - public var placeholderTransformOnScroll: PlaceholderTransform = { view, offset in - var newFrame = view.frame - newFrame.origin.y = -offset.y - - view.frame = newFrame - } - - public var scrollObservable: Observable? { - didSet { - scrollObservable?.subscribe(onNext: { [weak self] offset in - guard let placeholder = self?.currentPlaceholderView else { - return - } - - self?.placeholderTransformOnScroll(placeholder, offset) - }) - .addDisposableTo(disposeBag) - } - } - + /// Sets the offset between the real end of the scroll view content and the scroll position, + /// so the handler can be triggered before reaching end. Defaults to 0.0; public var infiniteScrollTriggerOffset: CGFloat { get { return tableView.infiniteScrollTriggerOffset @@ -89,47 +113,52 @@ where D.Cursor == C { private var currentPlaceholderView: UIView? + private let applicationCurrentyActive = Variable(false) + + private var waitingOperations: [() -> Void] = [] + + /// Initializer with table view, placeholders container view, cusor and delegate parameters. + /// + /// - Parameters: + /// - tableView: UITableView instance to work with. + /// - placeholdersContainer: UIView container to be used for placeholders. + /// - cursor: Cursor object that acts as data source. + /// - delegate: Delegate object for data loading events handling and UI customization. public init(tableView: UITableView, placeholdersContainer: UIView, cursor: C, delegate: D) { self.tableView = tableView self.placeholdersContainerView = placeholdersContainer self.paginationViewModel = PaginationViewModel(cursor: cursor) self.delegate = delegate - paginationViewModel.state.drive(onNext: { [weak self] state in - print(state) - switch state { - case .initial: - self?.onInitialState() - case .loading(let after): - self?.onLoadingState(afterState: after) - case .loadingMore(let after): - self?.onLoadingMoreState(afterState: after) - case .results(let newItems, let after): - self?.onResultsState(newItems: newItems, afterState: after) - case .error(let error, let after): - self?.onErrorState(error: error, afterState: after) - case .empty: - self?.onEmptyState() - case .exhausted: - self?.onExhaustedState() - } - }) - .addDisposableTo(disposeBag) + bindViewModelStates() - let refreshControl = UIRefreshControl() - refreshControl.rx.controlEvent(.valueChanged) - .bindNext { [weak self] in - self?.reload() - } - .addDisposableTo(disposeBag) + createRefreshControl() - tableView.support.setRefreshControl(refreshControl) + bindAppStateNotifications() } + /// Method that reload all data in internal view model. public func reload() { paginationViewModel.load(.reload) } + /// Method that enables placeholders animation due pull-to-refresh interaction. + /// + /// - Parameter scrollObservable: Observable that emits content offset as CGPoint. + public func setScrollObservable(_ scrollObservable: Observable) { + scrollObservable.subscribe(onNext: { [weak self] offset in + guard let placeholder = self?.currentPlaceholderView else { + return + } + + var newFrame = placeholder.frame + newFrame.origin.y = -offset.y + + placeholder.frame = newFrame + }) + .addDisposableTo(disposeBag) + } + // MARK: States handling private func onInitialState() { @@ -165,7 +194,7 @@ where D.Cursor == C { } private func onLoadingMoreState(afterState: PaginationViewModel.State) { - if case .error = afterState { + if case .error = afterState { // user tap retry button in table footer tableView.tableFooterView = nil addInfiniteScroll() tableView.beginInfiniteScroll(true) @@ -206,10 +235,13 @@ where D.Cursor == C { tableView.removeInfiniteScroll() - let retryButton = UIButton(type: .custom) - retryButton.backgroundColor = .lightGray - retryButton.frame = CGRect(x: 0, y: 0, width: tableView.bounds.width, height: 44) - retryButton.setTitle("Retry load more", for: .normal) + guard let retryButton = delegate?.retryLoadMoreButton(forPaginationWrapper: self), + let retryButtonHeigth = delegate?.retryLoadMoreButtonHeight(forPaginationWrapper: self) else { + return + } + + retryButton.frame = CGRect(x: 0, y: 0, width: tableView.bounds.width, height: retryButtonHeigth) + retryButton.rx.controlEvent(.touchUpInside) .bindNext { [weak self] in self?.paginationViewModel.load(.next) @@ -246,6 +278,51 @@ where D.Cursor == C { tableView.infiniteScrollIndicatorView = delegate?.loadingMoreIndicator(forPaginationWrapper: self).view } + private func createRefreshControl() { + let refreshControl = UIRefreshControl() + refreshControl.rx.controlEvent(.valueChanged) + .bindNext { [weak self] in + self?.reload() + } + .addDisposableTo(disposeBag) + + tableView.support.setRefreshControl(refreshControl) + } + + private func bindViewModelStates() { + paginationViewModel.state.drive(onNext: { [weak self] state in + let stateHandling = { [weak self] in + switch state { + case .initial: + self?.onInitialState() + case .loading(let after): + self?.onLoadingState(afterState: after) + case .loadingMore(let after): + self?.onLoadingMoreState(afterState: after) + case .results(let newItems, let after): + self?.onResultsState(newItems: newItems, afterState: after) + case .error(let error, let after): + self?.onErrorState(error: error, afterState: after) + case .empty: + self?.onEmptyState() + case .exhausted: + self?.onExhaustedState() + } + } + + guard let strongSelf = self else { + return + } + + if strongSelf.applicationCurrentyActive.value { + stateHandling() + } else { + strongSelf.waitingOperations.append(stateHandling) + } + }) + .addDisposableTo(disposeBag) + } + private func enterPlaceholderState() { tableView.support.refreshControl?.endRefreshing() tableView.isUserInteractionEnabled = true @@ -260,6 +337,32 @@ where D.Cursor == C { placeholderView.anchorConstrainst(to: placeholdersContainerView).forEach { $0.isActive = true } } + + private func bindAppStateNotifications() { + let notificationCenter = NotificationCenter.default.rx + + notificationCenter.notification(.UIApplicationWillResignActive) + .subscribe(onNext: { [weak self] _ in + self?.applicationCurrentyActive.value = false + }) + .addDisposableTo(disposeBag) + + notificationCenter.notification(.UIApplicationDidBecomeActive) + .subscribe(onNext: { [weak self] _ in + self?.applicationCurrentyActive.value = true + }) + .addDisposableTo(disposeBag) + + applicationCurrentyActive.asDriver() + .drive(onNext: { [weak self] appActive in + if appActive { + self?.waitingOperations.forEach { $0() } + self?.waitingOperations = [] + } + }) + .addDisposableTo(disposeBag) + } + } private extension UIView { diff --git a/LeadKit/LeadKit/Classes/Pagination/PaginationViewModel.swift b/LeadKit/LeadKit/Classes/Pagination/PaginationViewModel.swift index ca1a251e..741428a1 100644 --- a/LeadKit/LeadKit/Classes/Pagination/PaginationViewModel.swift +++ b/LeadKit/LeadKit/Classes/Pagination/PaginationViewModel.swift @@ -23,22 +23,44 @@ import RxSwift import RxCocoa +/// Cursor type which can be resetted public typealias ResettableCursorType = CursorType & ResettableType +/// Class that encapsulate all pagination logic public final class PaginationViewModel { + /// Enum contains all possible states for PaginationViewModel class. + /// + /// - initial: initial state of view model. + /// Can occur only once after initial binding. + /// - loading: loading state of view model. Contains previous state of view model. + /// Can occur after any state. + /// - loadingMore: loading more items state of view model. Contains previous state of view model. + /// Can occur after error or results state. + /// - results: results state of view model. Contains loaded items and previous state of view model. + /// Can occur after loading or loadingMore state. + /// - error: error state of view model. Contains received error and previous state of view model. + /// Can occur after loading or loadingMore state. + /// - empty: empty state of view model. + /// Can occur after loading or loadingMore state when we got empty result (zero items). + /// - exhausted: exhausted state of view model. + /// Can occur after results state or after initial->loading state when cursor reports that it's exhausted. public indirect enum State { case initial - case loading(after: State) // can be after any state - case loadingMore(after: State) // can be after error or 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 + case loading(after: State) + case loadingMore(after: State) + case results(newItems: [C.Element], after: State) + case error(error: Error, after: State) + case empty + case exhausted } + /// Enum represents possible load types for PaginationViewModel class + /// + /// - reload: reload all items and reset cursor to initial state. + /// - next: load next batch of items. public enum LoadType { case reload @@ -54,14 +76,21 @@ public final class PaginationViewModel { private let internalScheduler = SerialDispatchQueueScheduler(qos: .default) + /// Current PaginationViewModel state Driver public var state: Driver { return internalState.asDriver() } + /// Initializer with enclosed cursor + /// + /// - Parameter cursor: cursor to use for pagination public init(cursor: C) { self.cursor = cursor } + /// Mathod which triggers loading of items. + /// + /// - Parameter loadType: type of loading. See LoadType enum. public func load(_ loadType: LoadType) { switch loadType { case .reload: @@ -71,7 +100,7 @@ public final class PaginationViewModel { internalState.value = .loading(after: internalState.value) case .next: if case .exhausted(_) = internalState.value { - preconditionFailure("You shouldn't call load(.next) after got .exhausted state!") + fatalError("You shouldn't call load(.next) after got .exhausted state!") } internalState.value = .loadingMore(after: internalState.value) diff --git a/LeadKit/LeadKit/Extensions/PaginationTableViewWrapperDelegate/PaginationTableViewWrapperDelegate+DefaultImplementation.swift b/LeadKit/LeadKit/Extensions/PaginationTableViewWrapperDelegate/PaginationTableViewWrapperDelegate+DefaultImplementation.swift index 1f762288..7dd721ad 100644 --- a/LeadKit/LeadKit/Extensions/PaginationTableViewWrapperDelegate/PaginationTableViewWrapperDelegate+DefaultImplementation.swift +++ b/LeadKit/LeadKit/Extensions/PaginationTableViewWrapperDelegate/PaginationTableViewWrapperDelegate+DefaultImplementation.swift @@ -71,4 +71,16 @@ public extension PaginationTableViewWrapperDelegate { return AnyLoadingIndicator(indicator) } + func retryLoadMoreButton(forPaginationWrapper wrapper: PaginationTableViewWrapper) -> UIButton { + let retryButton = UIButton(type: .custom) + retryButton.backgroundColor = .lightGray + retryButton.setTitle("Retry load more", for: .normal) + + return retryButton + } + + func retryLoadMoreButtonHeight(forPaginationWrapper wrapper: PaginationTableViewWrapper) -> CGFloat { + return 44 + } + } diff --git a/LeadKit/LeadKit/Protocols/LoadingIndicatorProtocol.swift b/LeadKit/LeadKit/Protocols/LoadingIndicatorProtocol.swift index ccfb3d85..c88b835c 100644 --- a/LeadKit/LeadKit/Protocols/LoadingIndicatorProtocol.swift +++ b/LeadKit/LeadKit/Protocols/LoadingIndicatorProtocol.swift @@ -22,17 +22,23 @@ import UIKit +/// Protocol that ensures that specific type support basic animation actions. public protocol Animatable { + /// Method that starts animation. func startAnimating() + /// Method that stops animation. func stopAnimating() } +/// Protocol that describes badic loading indicator. public protocol LoadingIndicator { + /// Type of view. Should be instance of UIView with basic animation actions. associatedtype View: UIView, Animatable + /// The underlying view. var view: View { get } } diff --git a/LeadKit/LeadKit/Protocols/ResettableType.swift b/LeadKit/LeadKit/Protocols/ResettableType.swift index fefe1b2b..c100df98 100644 --- a/LeadKit/LeadKit/Protocols/ResettableType.swift +++ b/LeadKit/LeadKit/Protocols/ResettableType.swift @@ -22,14 +22,21 @@ import Foundation +/// Protocol that ensures that specific type can init new resetted instance from another instance. public protocol ResettableType { + /// Initializer with other instance parameter. + /// + /// - Parameter other: Other instance of specific type. init(initialFrom other: Self) } public extension ResettableType { + /// Method that creates new resseted instance of self + /// + /// - Returns: resseted instance of self func reset() -> Self { return Self(initialFrom: self) } From f0d4fd9505fff90856e54e05e31db76efbac0c71 Mon Sep 17 00:00:00 2001 From: Ivan Smolin Date: Tue, 11 Apr 2017 13:34:00 +0300 Subject: [PATCH 09/28] Add documentation for AnyLoadingIndicator --- LeadKit/LeadKit/Structures/Views/AnyLoadingIndicator.swift | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/LeadKit/LeadKit/Structures/Views/AnyLoadingIndicator.swift b/LeadKit/LeadKit/Structures/Views/AnyLoadingIndicator.swift index 5e3386cd..067892f9 100644 --- a/LeadKit/LeadKit/Structures/Views/AnyLoadingIndicator.swift +++ b/LeadKit/LeadKit/Structures/Views/AnyLoadingIndicator.swift @@ -22,16 +22,21 @@ import UIKit +/// Type that performs some kind of type erasure for LoadingIndicator. public struct AnyLoadingIndicator: Animatable { private let internalView: UIView private let animatableView: Animatable + /// Initializer with indicator that should be wrapped. + /// + /// - Parameter _: indicator for wrapping. public init(_ base: Indicator) where Indicator: LoadingIndicator { self.internalView = base.view self.animatableView = base.view } + /// The indicator view. var view: UIView { return internalView } From fd8239801d1ae221f28a27e9825d54240199e125 Mon Sep 17 00:00:00 2001 From: Ivan Smolin Date: Tue, 11 Apr 2017 13:43:38 +0300 Subject: [PATCH 10/28] remove whitespace --- LeadKit/LeadKit/Extensions/UIWindow/UIWindow+Extensions.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/LeadKit/LeadKit/Extensions/UIWindow/UIWindow+Extensions.swift b/LeadKit/LeadKit/Extensions/UIWindow/UIWindow+Extensions.swift index bd137155..d91625dc 100644 --- a/LeadKit/LeadKit/Extensions/UIWindow/UIWindow+Extensions.swift +++ b/LeadKit/LeadKit/Extensions/UIWindow/UIWindow+Extensions.swift @@ -63,5 +63,5 @@ public extension UIWindow { }) } } - + } From 470ef0a8809cbf5751231897bbd5f4c88f1b2843 Mon Sep 17 00:00:00 2001 From: Ivan Smolin Date: Tue, 11 Apr 2017 13:43:56 +0300 Subject: [PATCH 11/28] remove carthage stuff --- LeadKit/LeadKit.xcodeproj/project.pbxproj | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/LeadKit/LeadKit.xcodeproj/project.pbxproj b/LeadKit/LeadKit.xcodeproj/project.pbxproj index 8f9a115c..ff9c54e5 100644 --- a/LeadKit/LeadKit.xcodeproj/project.pbxproj +++ b/LeadKit/LeadKit.xcodeproj/project.pbxproj @@ -1130,12 +1130,6 @@ DYLIB_COMPATIBILITY_VERSION = 1; DYLIB_CURRENT_VERSION = 1; DYLIB_INSTALL_NAME_BASE = "@rpath"; - FRAMEWORK_SEARCH_PATHS = ( - "$(inherited)", - "$(PROJECT_DIR)/LeadKit", - "$(PROJECT_DIR)/LeadKit/Frameworks", - "$(PROJECT_DIR)/../Carthage/Build/iOS", - ); INFOPLIST_FILE = LeadKit/Info.plist; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; IPHONEOS_DEPLOYMENT_TARGET = 9.0; @@ -1156,12 +1150,6 @@ DYLIB_COMPATIBILITY_VERSION = 1; DYLIB_CURRENT_VERSION = 1; DYLIB_INSTALL_NAME_BASE = "@rpath"; - FRAMEWORK_SEARCH_PATHS = ( - "$(inherited)", - "$(PROJECT_DIR)/LeadKit", - "$(PROJECT_DIR)/LeadKit/Frameworks", - "$(PROJECT_DIR)/../Carthage/Build/iOS", - ); INFOPLIST_FILE = LeadKit/Info.plist; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; IPHONEOS_DEPLOYMENT_TARGET = 9.0; From 5030a9b5fa5757aff0583ff1176df5e81a7816d9 Mon Sep 17 00:00:00 2001 From: Ivan Smolin Date: Thu, 13 Apr 2017 20:25:22 +0300 Subject: [PATCH 12/28] resettable map cursor + pass cursor in results callback --- .../LeadKit/Classes/Cursors/MapCursor.swift | 28 +++++++++++++++++-- .../PaginationTableViewWrapper.swift | 22 +++++++++------ .../Pagination/PaginationViewModel.swift | 18 ++++++------ 3 files changed, 46 insertions(+), 22 deletions(-) diff --git a/LeadKit/LeadKit/Classes/Cursors/MapCursor.swift b/LeadKit/LeadKit/Classes/Cursors/MapCursor.swift index 652efcdc..12282a4e 100644 --- a/LeadKit/LeadKit/Classes/Cursors/MapCursor.swift +++ b/LeadKit/LeadKit/Classes/Cursors/MapCursor.swift @@ -27,11 +27,21 @@ public extension CursorType { /// Creates MapCursor with current cursor /// /// - Parameter transform: closure to transform elements - /// - Returns: new MapCursorInstance + /// - Returns: new MapCursor instance func flatMap(transform: @escaping MapCursor.Transform) -> MapCursor { return MapCursor(cursor: self, transform: transform) } + /// Creates ResettableMapCursor with current cursor + /// + /// - Parameter transform: closure to transform elements + /// - Returns: new ResettableMapCursor instance + func flatMap(transform: @escaping ResettableMapCursor.Transform) + -> ResettableMapCursor where Self: ResettableCursorType { + + return ResettableMapCursor(cursor: self, transform: transform) + } + } /// Map cursor implementation with enclosed cursor for fetching results @@ -39,9 +49,9 @@ public class MapCursor: CursorType { public typealias Transform = (Cursor.Element) -> T? - private let cursor: Cursor + fileprivate let cursor: Cursor - private let transform: Transform + fileprivate let transform: Transform private var elements: [T] = [] @@ -88,3 +98,15 @@ public class MapCursor: CursorType { } } + +public class ResettableMapCursor: MapCursor, ResettableType { + + public override init(cursor: Cursor, transform: @escaping Transform) { + super.init(cursor: cursor, transform: transform) + } + + public required init(initialFrom other: ResettableMapCursor) { + super.init(cursor: other.cursor, transform: other.transform) + } + +} diff --git a/LeadKit/LeadKit/Classes/Pagination/PaginationTableViewWrapper.swift b/LeadKit/LeadKit/Classes/Pagination/PaginationTableViewWrapper.swift index 18986cc6..2d8bb191 100644 --- a/LeadKit/LeadKit/Classes/Pagination/PaginationTableViewWrapper.swift +++ b/LeadKit/LeadKit/Classes/Pagination/PaginationTableViewWrapper.swift @@ -35,16 +35,20 @@ public protocol PaginationTableViewWrapperDelegate: class { /// - Parameters: /// - wrapper: Wrapper object that loaded new items. /// - newItems: New items. + /// - cursor: Cursor used to load items func paginationWrapper(wrapper: PaginationTableViewWrapper, - didLoad newItems: [Cursor.Element]) + didLoad newItems: [Cursor.Element], + usingCursor cursor: Cursor) /// Delegate method that handles reloading or initial loading of data. /// /// - Parameters: /// - wrapper: Wrapper object that reload items. /// - allItems: New items. + /// - cursor: Cursor used to load items func paginationWrapper(wrapper: PaginationTableViewWrapper, - didReload allItems: [Cursor.Element]) + didReload allItems: [Cursor.Element], + usingCursor cursor: Cursor) /// Delegate method that returns placeholder view for empty state. /// @@ -113,7 +117,7 @@ where D.Cursor == C { private var currentPlaceholderView: UIView? - private let applicationCurrentyActive = Variable(false) + private let applicationCurrentyActive = Variable(true) private var waitingOperations: [() -> Void] = [] @@ -201,11 +205,11 @@ where D.Cursor == C { } } - private func onResultsState(newItems: [C.Element], afterState: PaginationViewModel.State) { + private func onResultsState(newItems: [C.Element], inCursor cursor: C, afterState: PaginationViewModel.State) { tableView.isUserInteractionEnabled = true if case .loading = afterState { - delegate?.paginationWrapper(wrapper: self, didReload: newItems) + delegate?.paginationWrapper(wrapper: self, didReload: newItems, usingCursor: cursor) currentPlaceholderView?.removeFromSuperview() @@ -213,7 +217,7 @@ where D.Cursor == C { addInfiniteScroll() } else if case .loadingMore = afterState { - delegate?.paginationWrapper(wrapper: self, didLoad: newItems) + delegate?.paginationWrapper(wrapper: self, didLoad: newItems, usingCursor: cursor) tableView.finishInfiniteScroll() } @@ -299,8 +303,8 @@ where D.Cursor == C { self?.onLoadingState(afterState: after) case .loadingMore(let after): self?.onLoadingMoreState(afterState: after) - case .results(let newItems, let after): - self?.onResultsState(newItems: newItems, afterState: after) + case .results(let newItems, let cursor, let after): + self?.onResultsState(newItems: newItems, inCursor: cursor, afterState: after) case .error(let error, let after): self?.onErrorState(error: error, afterState: after) case .empty: @@ -335,7 +339,7 @@ where D.Cursor == C { placeholdersContainerView.insertSubview(placeholderView, belowSubview: tableView) - placeholderView.anchorConstrainst(to: placeholdersContainerView).forEach { $0.isActive = true } + placeholdersContainerView.addConstraints(placeholderView.anchorConstrainst(to: placeholdersContainerView)) } private func bindAppStateNotifications() { diff --git a/LeadKit/LeadKit/Classes/Pagination/PaginationViewModel.swift b/LeadKit/LeadKit/Classes/Pagination/PaginationViewModel.swift index 741428a1..3efb4ea5 100644 --- a/LeadKit/LeadKit/Classes/Pagination/PaginationViewModel.swift +++ b/LeadKit/LeadKit/Classes/Pagination/PaginationViewModel.swift @@ -37,7 +37,7 @@ public final class PaginationViewModel { /// Can occur after any state. /// - loadingMore: loading more items state of view model. Contains previous state of view model. /// Can occur after error or results state. - /// - results: results state of view model. Contains loaded items and previous state of view model. + /// - results: results state of view model. Contains loaded items, cursor and previous state of view model. /// Can occur after loading or loadingMore state. /// - error: error state of view model. Contains received error and previous state of view model. /// Can occur after loading or loadingMore state. @@ -50,7 +50,7 @@ public final class PaginationViewModel { case initial case loading(after: State) case loadingMore(after: State) - case results(newItems: [C.Element], after: State) + case results(newItems: [C.Element], inCursor: C, after: State) case error(error: Error, after: State) case empty case exhausted @@ -106,21 +106,19 @@ public final class PaginationViewModel { internalState.value = .loadingMore(after: internalState.value) } - currentRequest = cursor.loadNextBatch() + let currentCursor = cursor + + currentRequest = currentCursor.loadNextBatch() .subscribeOn(internalScheduler) .subscribe(onNext: { [weak self] newItems in - self?.onGot(newItems: newItems) + self?.onGot(newItems: newItems, using: currentCursor) }, onError: { [weak self] error in self?.onGot(error: error) }) } - private func onGot(newItems: [C.Element]) { - if newItems.count > 0 { - internalState.value = .results(newItems: newItems, after: internalState.value) - } else { - internalState.value = .empty - } + private func onGot(newItems: [C.Element], using cursor: C) { + internalState.value = .results(newItems: newItems, inCursor: cursor, after: internalState.value) if cursor.exhausted { internalState.value = .exhausted From d99dc6ce86464b6e560d04808b5b62bf26713139 Mon Sep 17 00:00:00 2001 From: Ivan Smolin Date: Fri, 14 Apr 2017 16:25:53 +0300 Subject: [PATCH 13/28] set placeholder visible by default --- .../LeadKit/Classes/Pagination/PaginationTableViewWrapper.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/LeadKit/LeadKit/Classes/Pagination/PaginationTableViewWrapper.swift b/LeadKit/LeadKit/Classes/Pagination/PaginationTableViewWrapper.swift index 2d8bb191..4d2372ad 100644 --- a/LeadKit/LeadKit/Classes/Pagination/PaginationTableViewWrapper.swift +++ b/LeadKit/LeadKit/Classes/Pagination/PaginationTableViewWrapper.swift @@ -336,6 +336,7 @@ where D.Cursor == C { private func preparePlaceholderView(_ placeholderView: UIView) { placeholderView.translatesAutoresizingMaskIntoConstraints = false + placeholderView.isHidden = false placeholdersContainerView.insertSubview(placeholderView, belowSubview: tableView) From 14d7eb3be5905091f97fa21e98aaf1f830f81638 Mon Sep 17 00:00:00 2001 From: Ivan Smolin Date: Tue, 18 Apr 2017 19:58:01 +0300 Subject: [PATCH 14/28] StaticNibNameProtocol -> XibNameProtocol --- LeadKit/LeadKit.xcodeproj/project.pbxproj | 16 ++++++++-------- .../UICollectionView+CellRegistration.swift | 4 ++-- ...NibName.swift => UIView+DefaultXibName.swift} | 4 ++-- .../Extensions/UIView/UIView+LoadFromNib.swift | 4 ++-- ...bNameProtocol.swift => XibNameProtocol.swift} | 4 ++-- 5 files changed, 16 insertions(+), 16 deletions(-) rename LeadKit/LeadKit/Extensions/UIView/{UIView+DefaultNibName.swift => UIView+DefaultXibName.swift} (94%) rename LeadKit/LeadKit/Protocols/{StaticNibNameProtocol.swift => XibNameProtocol.swift} (94%) diff --git a/LeadKit/LeadKit.xcodeproj/project.pbxproj b/LeadKit/LeadKit.xcodeproj/project.pbxproj index ff9c54e5..6255ecc8 100644 --- a/LeadKit/LeadKit.xcodeproj/project.pbxproj +++ b/LeadKit/LeadKit.xcodeproj/project.pbxproj @@ -56,7 +56,7 @@ 789CC60B1DE584F800F789D3 /* CursorType+Slice.swift in Sources */ = {isa = PBXBuildFile; fileRef = 789CC60A1DE584F800F789D3 /* CursorType+Slice.swift */; }; 78A0FCC71DC366A10070B5E1 /* StoryboardProtocol+DefaultBundle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 78A0FCC51DC366A10070B5E1 /* StoryboardProtocol+DefaultBundle.swift */; }; 78A0FCC81DC366A10070B5E1 /* StoryboardProtocol+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 78A0FCC61DC366A10070B5E1 /* StoryboardProtocol+Extensions.swift */; }; - 78A74EA91C6B373700FE9724 /* UIView+DefaultNibName.swift in Sources */ = {isa = PBXBuildFile; fileRef = 78A74EA81C6B373700FE9724 /* UIView+DefaultNibName.swift */; }; + 78A74EA91C6B373700FE9724 /* UIView+DefaultXibName.swift in Sources */ = {isa = PBXBuildFile; fileRef = 78A74EA81C6B373700FE9724 /* UIView+DefaultXibName.swift */; }; 78B036411DA4D7060021D5CC /* UIImage+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 78B036401DA4D7060021D5CC /* UIImage+Extensions.swift */; }; 78B036431DA4FEC90021D5CC /* CGImage+Transform.swift in Sources */ = {isa = PBXBuildFile; fileRef = 78B036421DA4FEC90021D5CC /* CGImage+Transform.swift */; }; 78B036451DA561D00021D5CC /* CGImage+Utils.swift in Sources */ = {isa = PBXBuildFile; fileRef = 78B036441DA561D00021D5CC /* CGImage+Utils.swift */; }; @@ -73,7 +73,7 @@ 78CFEE351C5C456B00F50370 /* LeadKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 78CFEE2A1C5C456B00F50370 /* LeadKit.framework */; }; 78CFEE541C5C45E500F50370 /* UIView+LoadFromNib.swift in Sources */ = {isa = PBXBuildFile; fileRef = 78CFEE481C5C45E500F50370 /* UIView+LoadFromNib.swift */; }; 78CFEE561C5C45E500F50370 /* ReuseIdentifierProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 78CFEE4B1C5C45E500F50370 /* ReuseIdentifierProtocol.swift */; }; - 78CFEE571C5C45E500F50370 /* StaticNibNameProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 78CFEE4C1C5C45E500F50370 /* StaticNibNameProtocol.swift */; }; + 78CFEE571C5C45E500F50370 /* XibNameProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 78CFEE4C1C5C45E500F50370 /* XibNameProtocol.swift */; }; 78CFEE581C5C45E500F50370 /* StaticViewHeightProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 78CFEE4D1C5C45E500F50370 /* StaticViewHeightProtocol.swift */; }; 78CFEE591C5C45E500F50370 /* StoryboardIdentifierProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 78CFEE4E1C5C45E500F50370 /* StoryboardIdentifierProtocol.swift */; }; 78CFEE5A1C5C45E500F50370 /* ViewHeightProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 78CFEE4F1C5C45E500F50370 /* ViewHeightProtocol.swift */; }; @@ -157,7 +157,7 @@ 789CC60A1DE584F800F789D3 /* CursorType+Slice.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "CursorType+Slice.swift"; sourceTree = ""; }; 78A0FCC51DC366A10070B5E1 /* StoryboardProtocol+DefaultBundle.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "StoryboardProtocol+DefaultBundle.swift"; sourceTree = ""; }; 78A0FCC61DC366A10070B5E1 /* StoryboardProtocol+Extensions.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "StoryboardProtocol+Extensions.swift"; sourceTree = ""; }; - 78A74EA81C6B373700FE9724 /* UIView+DefaultNibName.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = "UIView+DefaultNibName.swift"; path = "LeadKit/Extensions/UIView/UIView+DefaultNibName.swift"; sourceTree = SOURCE_ROOT; }; + 78A74EA81C6B373700FE9724 /* UIView+DefaultXibName.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = "UIView+DefaultXibName.swift"; path = "LeadKit/Extensions/UIView/UIView+DefaultXibName.swift"; sourceTree = SOURCE_ROOT; }; 78B036401DA4D7060021D5CC /* UIImage+Extensions.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "UIImage+Extensions.swift"; sourceTree = ""; }; 78B036421DA4FEC90021D5CC /* CGImage+Transform.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "CGImage+Transform.swift"; sourceTree = ""; }; 78B036441DA561D00021D5CC /* CGImage+Utils.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "CGImage+Utils.swift"; sourceTree = ""; }; @@ -177,7 +177,7 @@ 78CFEE3B1C5C456B00F50370 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 78CFEE481C5C45E500F50370 /* UIView+LoadFromNib.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = "UIView+LoadFromNib.swift"; path = "LeadKit/Extensions/UIView/UIView+LoadFromNib.swift"; sourceTree = SOURCE_ROOT; }; 78CFEE4B1C5C45E500F50370 /* ReuseIdentifierProtocol.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ReuseIdentifierProtocol.swift; sourceTree = ""; }; - 78CFEE4C1C5C45E500F50370 /* StaticNibNameProtocol.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = StaticNibNameProtocol.swift; sourceTree = ""; }; + 78CFEE4C1C5C45E500F50370 /* XibNameProtocol.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = XibNameProtocol.swift; sourceTree = ""; }; 78CFEE4D1C5C45E500F50370 /* StaticViewHeightProtocol.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = StaticViewHeightProtocol.swift; sourceTree = ""; }; 78CFEE4E1C5C45E500F50370 /* StoryboardIdentifierProtocol.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = StoryboardIdentifierProtocol.swift; sourceTree = ""; }; 78CFEE4F1C5C45E500F50370 /* ViewHeightProtocol.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ViewHeightProtocol.swift; sourceTree = ""; }; @@ -580,7 +580,7 @@ 780F56CB1E0D7ACA004530B6 /* ObservableMappable.swift */, 78CFEE4B1C5C45E500F50370 /* ReuseIdentifierProtocol.swift */, 787682F91CAD40C200532AB3 /* StaticEstimatedViewHeightProtocol.swift */, - 78CFEE4C1C5C45E500F50370 /* StaticNibNameProtocol.swift */, + 78CFEE4C1C5C45E500F50370 /* XibNameProtocol.swift */, 78CFEE4D1C5C45E500F50370 /* StaticViewHeightProtocol.swift */, 78CFEE4E1C5C45E500F50370 /* StoryboardIdentifierProtocol.swift */, 783423691DB8D0E100A79643 /* StoryboardProtocol.swift */, @@ -615,7 +615,7 @@ 78E59B2C1C786CD500C6BFE9 /* UIView */ = { isa = PBXGroup; children = ( - 78A74EA81C6B373700FE9724 /* UIView+DefaultNibName.swift */, + 78A74EA81C6B373700FE9724 /* UIView+DefaultXibName.swift */, 78011A631D47ABC500EA16A2 /* UIView+DefaultReuseIdentifier.swift */, 78CFEE481C5C45E500F50370 /* UIView+LoadFromNib.swift */, EF5FB5681E0141610030E4BE /* UIView+Rotation.swift */, @@ -936,7 +936,7 @@ 789CC60B1DE584F800F789D3 /* CursorType+Slice.swift in Sources */, 78753E2C1DE58BF9006BC0FB /* StaticCursor.swift in Sources */, 78D4B54A1DA64EAB005B0764 /* Any+TypeName.swift in Sources */, - 78CFEE571C5C45E500F50370 /* StaticNibNameProtocol.swift in Sources */, + 78CFEE571C5C45E500F50370 /* XibNameProtocol.swift in Sources */, 67DC650C1E979C0A002F2FFF /* AnyLoadingIndicator.swift in Sources */, 788EC15A1CF64528009CFB6B /* UIStoryboard+InstantiateViewController.swift in Sources */, 787783671CA04D4A001CDC9B /* String+SizeCalculation.swift in Sources */, @@ -979,7 +979,7 @@ 78CFEE5A1C5C45E500F50370 /* ViewHeightProtocol.swift in Sources */, 787682FA1CAD40C300532AB3 /* StaticEstimatedViewHeightProtocol.swift in Sources */, CA1FE7091E27D7DE00968901 /* UIDevice+Extensions.swift in Sources */, - 78A74EA91C6B373700FE9724 /* UIView+DefaultNibName.swift in Sources */, + 78A74EA91C6B373700FE9724 /* UIView+DefaultXibName.swift in Sources */, CAE698C21E965B47000394B0 /* TableDirector+Extensions.swift in Sources */, CAA707D91E2E61A50022D732 /* ConfigurableController.swift in Sources */, 7884DB9C1DC1439200E52A63 /* UserDefaults+MappableDataTypes.swift in Sources */, diff --git a/LeadKit/LeadKit/Extensions/UICollectionView/UICollectionView+CellRegistration.swift b/LeadKit/LeadKit/Extensions/UICollectionView/UICollectionView+CellRegistration.swift index a6022f67..0ff6ecb1 100644 --- a/LeadKit/LeadKit/Extensions/UICollectionView/UICollectionView+CellRegistration.swift +++ b/LeadKit/LeadKit/Extensions/UICollectionView/UICollectionView+CellRegistration.swift @@ -34,9 +34,9 @@ public extension UICollectionView { /// - bundle: The bundle in which to search for the nib file. /// If you specify nil, this method looks for the nib file in the main bundle. public func registerNib(forCellClass cellClass: T.Type, bundle: Bundle? = nil) - where T: ReuseIdentifierProtocol, T: UICollectionViewCell, T: StaticNibNameProtocol { + where T: ReuseIdentifierProtocol, T: UICollectionViewCell, T: XibNameProtocol { - register(UINib(nibName: T.nibName, bundle: bundle), forCellWithReuseIdentifier: T.reuseIdentifier) + register(UINib(nibName: T.xibName, bundle: bundle), forCellWithReuseIdentifier: T.reuseIdentifier) } } diff --git a/LeadKit/LeadKit/Extensions/UIView/UIView+DefaultNibName.swift b/LeadKit/LeadKit/Extensions/UIView/UIView+DefaultXibName.swift similarity index 94% rename from LeadKit/LeadKit/Extensions/UIView/UIView+DefaultNibName.swift rename to LeadKit/LeadKit/Extensions/UIView/UIView+DefaultXibName.swift index 11a7c61f..55f06fd3 100644 --- a/LeadKit/LeadKit/Extensions/UIView/UIView+DefaultNibName.swift +++ b/LeadKit/LeadKit/Extensions/UIView/UIView+DefaultXibName.swift @@ -22,14 +22,14 @@ import UIKit -extension UIView: StaticNibNameProtocol { +extension UIView: XibNameProtocol { /** default implementation of StaticNibNameProtocol - returns: class name string */ - open class var nibName: String { + open class var xibName: String { return className(of: self) } diff --git a/LeadKit/LeadKit/Extensions/UIView/UIView+LoadFromNib.swift b/LeadKit/LeadKit/Extensions/UIView/UIView+LoadFromNib.swift index 72bbc6b2..087edbce 100644 --- a/LeadKit/LeadKit/Extensions/UIView/UIView+LoadFromNib.swift +++ b/LeadKit/LeadKit/Extensions/UIView/UIView+LoadFromNib.swift @@ -30,8 +30,8 @@ public extension UIView { /// - Parameter bundle: The bundle in which to search for the nib file. /// If you specify nil, this method looks for the nib file in the main bundle. /// - Returns: UIView or UIView subclass instance - public static func loadFromNib(bundle: Bundle? = nil) -> T where T: StaticNibNameProtocol, T: UIView { - return loadFromNib(named: T.nibName, bundle: bundle) + public static func loadFromNib(bundle: Bundle? = nil) -> T where T: XibNameProtocol, T: UIView { + return loadFromNib(named: T.xibName, bundle: bundle) } /// Method which loads UIView (or subclass) instance from nib using given nib name parameter diff --git a/LeadKit/LeadKit/Protocols/StaticNibNameProtocol.swift b/LeadKit/LeadKit/Protocols/XibNameProtocol.swift similarity index 94% rename from LeadKit/LeadKit/Protocols/StaticNibNameProtocol.swift rename to LeadKit/LeadKit/Protocols/XibNameProtocol.swift index 40140d39..f288d321 100644 --- a/LeadKit/LeadKit/Protocols/StaticNibNameProtocol.swift +++ b/LeadKit/LeadKit/Protocols/XibNameProtocol.swift @@ -25,9 +25,9 @@ import Foundation /** * protocol which ensures that specific type can return nib name of view */ -public protocol StaticNibNameProtocol { +public protocol XibNameProtocol { /** - returns: nib name string */ - static var nibName: String { get } + static var xibName: String { get } } From 673db680b715e1e0169a16a4a0f069c09724f43f Mon Sep 17 00:00:00 2001 From: Ivan Smolin Date: Tue, 18 Apr 2017 20:07:39 +0300 Subject: [PATCH 15/28] use table view background view for placeholders instead of container view --- .../PaginationTableViewWrapper.swift | 77 ++++++++----------- 1 file changed, 33 insertions(+), 44 deletions(-) diff --git a/LeadKit/LeadKit/Classes/Pagination/PaginationTableViewWrapper.swift b/LeadKit/LeadKit/Classes/Pagination/PaginationTableViewWrapper.swift index 4d2372ad..fc669efc 100644 --- a/LeadKit/LeadKit/Classes/Pagination/PaginationTableViewWrapper.swift +++ b/LeadKit/LeadKit/Classes/Pagination/PaginationTableViewWrapper.swift @@ -93,13 +93,12 @@ public protocol PaginationTableViewWrapperDelegate: class { } /// Class that connects PaginationViewModel with UITableView. It handles all non-visual and visual states. -final public class PaginationTableViewWrapper -where D.Cursor == C { +final public class PaginationTableViewWrapper +where Delegate.Cursor == Cursor { private let tableView: UITableView - private let placeholdersContainerView: UIView - private let paginationViewModel: PaginationViewModel - private weak var delegate: D? + private let paginationViewModel: PaginationViewModel + private weak var delegate: Delegate? /// Sets the offset between the real end of the scroll view content and the scroll position, /// so the handler can be triggered before reaching end. Defaults to 0.0; @@ -107,7 +106,6 @@ where D.Cursor == C { get { return tableView.infiniteScrollTriggerOffset } - set { tableView.infiniteScrollTriggerOffset = newValue } @@ -116,6 +114,7 @@ where D.Cursor == C { private let disposeBag = DisposeBag() private var currentPlaceholderView: UIView? + private var currentPlaceholderViewTopConstraint: NSLayoutConstraint? private let applicationCurrentyActive = Variable(true) @@ -125,12 +124,10 @@ where D.Cursor == C { /// /// - Parameters: /// - tableView: UITableView instance to work with. - /// - placeholdersContainer: UIView container to be used for placeholders. /// - cursor: Cursor object that acts as data source. /// - delegate: Delegate object for data loading events handling and UI customization. - public init(tableView: UITableView, placeholdersContainer: UIView, cursor: C, delegate: D) { + public init(tableView: UITableView, cursor: Cursor, delegate: Delegate) { self.tableView = tableView - self.placeholdersContainerView = placeholdersContainer self.paginationViewModel = PaginationViewModel(cursor: cursor) self.delegate = delegate @@ -151,14 +148,7 @@ where D.Cursor == C { /// - Parameter scrollObservable: Observable that emits content offset as CGPoint. public func setScrollObservable(_ scrollObservable: Observable) { scrollObservable.subscribe(onNext: { [weak self] offset in - guard let placeholder = self?.currentPlaceholderView else { - return - } - - var newFrame = placeholder.frame - newFrame.origin.y = -offset.y - - placeholder.frame = newFrame + self?.currentPlaceholderViewTopConstraint?.constant = -offset.y }) .addDisposableTo(disposeBag) } @@ -169,11 +159,11 @@ where D.Cursor == C { // } - private func onLoadingState(afterState: PaginationViewModel.State) { + private func onLoadingState(afterState: PaginationViewModel.State) { if case .initial = afterState { tableView.isUserInteractionEnabled = false - currentPlaceholderView?.removeFromSuperview() + removeCurrentPlaceholderView() guard let loadingIndicator = delegate?.initialLoadingIndicator(forPaginationWrapper: self) else { return @@ -181,12 +171,9 @@ where D.Cursor == C { let loadingIndicatorView = loadingIndicator.view - loadingIndicatorView.translatesAutoresizingMaskIntoConstraints = false + loadingIndicatorView.translatesAutoresizingMaskIntoConstraints = true - placeholdersContainerView.insertSubview(loadingIndicatorView, aboveSubview: tableView) - - loadingIndicatorView.centerXAnchor.constraint(equalTo: placeholdersContainerView.centerXAnchor).isActive = true - loadingIndicatorView.centerYAnchor.constraint(equalTo: placeholdersContainerView.centerYAnchor).isActive = true + tableView.backgroundView = loadingIndicatorView loadingIndicator.startAnimating() @@ -197,7 +184,7 @@ where D.Cursor == C { } } - private func onLoadingMoreState(afterState: PaginationViewModel.State) { + private func onLoadingMoreState(afterState: PaginationViewModel.State) { if case .error = afterState { // user tap retry button in table footer tableView.tableFooterView = nil addInfiniteScroll() @@ -205,13 +192,13 @@ where D.Cursor == C { } } - private func onResultsState(newItems: [C.Element], inCursor cursor: C, afterState: PaginationViewModel.State) { + private func onResultsState(newItems: [Cursor.Element], inCursor cursor: Cursor, afterState: PaginationViewModel.State) { tableView.isUserInteractionEnabled = true if case .loading = afterState { delegate?.paginationWrapper(wrapper: self, didReload: newItems, usingCursor: cursor) - currentPlaceholderView?.removeFromSuperview() + removeCurrentPlaceholderView() tableView.support.refreshControl?.endRefreshing() @@ -223,7 +210,7 @@ where D.Cursor == C { } } - private func onErrorState(error: Error, afterState: PaginationViewModel.State) { + private func onErrorState(error: Error, afterState: PaginationViewModel.State) { if case .loading = afterState { enterPlaceholderState() @@ -331,16 +318,31 @@ where D.Cursor == C { tableView.support.refreshControl?.endRefreshing() tableView.isUserInteractionEnabled = true - currentPlaceholderView?.removeFromSuperview() + removeCurrentPlaceholderView() } private func preparePlaceholderView(_ placeholderView: UIView) { placeholderView.translatesAutoresizingMaskIntoConstraints = false placeholderView.isHidden = false - placeholdersContainerView.insertSubview(placeholderView, belowSubview: tableView) + // I was unable to add pull-to-refresh placeholder scroll behaviour without this trick + let wrapperView = UIView() + wrapperView.addSubview(placeholderView) - placeholdersContainerView.addConstraints(placeholderView.anchorConstrainst(to: placeholdersContainerView)) + let leadingConstraint = placeholderView.leadingAnchor.constraint(equalTo: wrapperView.leadingAnchor) + let trailingConstraint = placeholderView.trailingAnchor.constraint(equalTo: wrapperView.trailingAnchor) + let topConstraint = placeholderView.topAnchor.constraint(equalTo: wrapperView.topAnchor) + let bottomConstraint = placeholderView.bottomAnchor.constraint(equalTo: wrapperView.bottomAnchor) + + wrapperView.addConstraints([leadingConstraint, trailingConstraint, topConstraint, bottomConstraint]) + + currentPlaceholderViewTopConstraint = topConstraint + + tableView.backgroundView = wrapperView + } + + private func removeCurrentPlaceholderView() { + tableView.backgroundView = nil } private func bindAppStateNotifications() { @@ -369,16 +371,3 @@ where D.Cursor == C { } } - -private extension UIView { - - func anchorConstrainst(to view: UIView) -> [NSLayoutConstraint] { - return [ - leadingAnchor.constraint(equalTo: view.leadingAnchor), - trailingAnchor.constraint(equalTo: view.trailingAnchor), - topAnchor.constraint(equalTo: view.topAnchor), - bottomAnchor.constraint(equalTo: view.bottomAnchor) - ] - } - -} From a86ae5aed32903de44588b09acdb60395c766dc9 Mon Sep 17 00:00:00 2001 From: Ivan Smolin Date: Wed, 19 Apr 2017 14:07:40 +0300 Subject: [PATCH 16/28] full thread safety --- LeadKit/LeadKit.xcodeproj/project.pbxproj | 12 +++ .../LeadKit/Classes/Concurrency/Mutex.swift | 92 +++++++++++++++++++ .../Classes/Cursors/FixedPageCursor.swift | 59 ++++++++---- .../LeadKit/Classes/Cursors/MapCursor.swift | 19 ++-- .../Classes/Cursors/StaticCursor.swift | 35 ++++--- .../PaginationTableViewWrapper.swift | 5 +- .../PaginationViewModelTests.swift | 2 +- 7 files changed, 182 insertions(+), 42 deletions(-) create mode 100644 LeadKit/LeadKit/Classes/Concurrency/Mutex.swift diff --git a/LeadKit/LeadKit.xcodeproj/project.pbxproj b/LeadKit/LeadKit.xcodeproj/project.pbxproj index 6255ecc8..7b187dad 100644 --- a/LeadKit/LeadKit.xcodeproj/project.pbxproj +++ b/LeadKit/LeadKit.xcodeproj/project.pbxproj @@ -11,6 +11,7 @@ 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 */; }; + 675FB4251EA7797C0075BF3D /* Mutex.swift in Sources */ = {isa = PBXBuildFile; fileRef = 675FB4241EA7797C0075BF3D /* Mutex.swift */; }; 67788F9F1E69661800484DEE /* CGFloat+Pixels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 67788F9E1E69661800484DEE /* CGFloat+Pixels.swift */; }; 678A202A1E93C1A900787562 /* PaginationTableViewWrapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 678A20291E93C1A900787562 /* PaginationTableViewWrapper.swift */; }; 679DE4901E9588B6006F25FE /* SupportProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 679DE48F1E9588B6006F25FE /* SupportProtocol.swift */; }; @@ -112,6 +113,7 @@ 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 = ""; }; + 675FB4241EA7797C0075BF3D /* Mutex.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Mutex.swift; sourceTree = ""; }; 67788F9E1E69661800484DEE /* CGFloat+Pixels.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "CGFloat+Pixels.swift"; sourceTree = ""; }; 678A20291E93C1A900787562 /* PaginationTableViewWrapper.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PaginationTableViewWrapper.swift; sourceTree = ""; }; 679DE48F1E9588B6006F25FE /* SupportProtocol.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SupportProtocol.swift; sourceTree = ""; }; @@ -241,6 +243,14 @@ path = Pagination; sourceTree = ""; }; + 675FB4231EA779650075BF3D /* Concurrency */ = { + isa = PBXGroup; + children = ( + 675FB4241EA7797C0075BF3D /* Mutex.swift */, + ); + path = Concurrency; + sourceTree = ""; + }; 67788F9D1E6965F800484DEE /* CGFloat */ = { isa = PBXGroup; children = ( @@ -456,6 +466,7 @@ 78753E2A1DE58BED006BC0FB /* Cursors */, 67B305801E8A92B6008169CA /* Views */, 675D24B01E9234A400E92D1F /* Pagination */, + 675FB4231EA779650075BF3D /* Concurrency */, ); path = Classes; sourceTree = ""; @@ -989,6 +1000,7 @@ 67DC65091E979BB8002F2FFF /* UIActivityIndicator+LoadingIndicator.swift in Sources */, 67B856E31E923BE600F54304 /* ResettableType.swift in Sources */, 787783631CA03CA0001CDC9B /* IndexPath+ImmutableIndexPath.swift in Sources */, + 675FB4251EA7797C0075BF3D /* Mutex.swift in Sources */, 78B036471DA5624D0021D5CC /* CGImage+Creation.swift in Sources */, 789CC6081DE5835600F789D3 /* CursorType.swift in Sources */, 67DC650F1E979D0C002F2FFF /* PaginationTableViewWrapperDelegate+DefaultImplementation.swift in Sources */, diff --git a/LeadKit/LeadKit/Classes/Concurrency/Mutex.swift b/LeadKit/LeadKit/Classes/Concurrency/Mutex.swift new file mode 100644 index 00000000..11b3560b --- /dev/null +++ b/LeadKit/LeadKit/Classes/Concurrency/Mutex.swift @@ -0,0 +1,92 @@ +// +// 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. +// + +// Source https://github.com/mattgallagher/CwlUtils/blob/master/Sources/CwlUtils/CwlMutex.swift +final class Mutex { + + // Non-recursive "PTHREAD_MUTEX_NORMAL" and recursive "PTHREAD_MUTEX_RECURSIVE" mutex types. + enum MutexType { + case normal + case recursive + } + + private var mutex = pthread_mutex_t() + + /// Default constructs as ".Normal" or ".Recursive" on request. + init(type: MutexType = .normal) { + var attr = pthread_mutexattr_t() + + guard pthread_mutexattr_init(&attr) == 0 else { + preconditionFailure("Failed to init mutex attributes!") + } + + switch type { + case .normal: + pthread_mutexattr_settype(&attr, PTHREAD_MUTEX_NORMAL) + case .recursive: + pthread_mutexattr_settype(&attr, PTHREAD_MUTEX_RECURSIVE) + } + + guard pthread_mutex_init(&mutex, &attr) == 0 else { + preconditionFailure("Failed to init mutex!") + } + } + + deinit { + pthread_mutex_destroy(&mutex) + } + + func sync(execute work: () throws -> R) rethrows -> R { + unbalancedLock() + + defer { + unbalancedUnlock() + } + + return try work() + } + + func trySync(execute work: () throws -> R) rethrows -> R? { + guard unbalancedTryLock() else { + return nil + } + + defer { + unbalancedUnlock() + } + + return try work() + } + + func unbalancedLock() { + pthread_mutex_lock(&mutex) + } + + func unbalancedTryLock() -> Bool { + return pthread_mutex_trylock(&mutex) == 0 + } + + func unbalancedUnlock() { + pthread_mutex_unlock(&mutex) + } + +} diff --git a/LeadKit/LeadKit/Classes/Cursors/FixedPageCursor.swift b/LeadKit/LeadKit/Classes/Cursors/FixedPageCursor.swift index bdbf4829..25956707 100644 --- a/LeadKit/LeadKit/Classes/Cursors/FixedPageCursor.swift +++ b/LeadKit/LeadKit/Classes/Cursors/FixedPageCursor.swift @@ -25,11 +25,17 @@ import RxSwift /// Paging cursor implementation with enclosed cursor for fetching results public class FixedPageCursor: CursorType { - private let cursor: Cursor + fileprivate let cursor: Cursor - private let pageSize: Int + fileprivate let pageSize: Int - private let semaphore = DispatchSemaphore(value: 1) + private var internalCount = 0 + + private var internalExhausted: Bool { + return cursor.exhausted && cursor.count == internalCount + } + + private let mutex = Mutex() /// Initializer with enclosed cursor /// @@ -42,46 +48,61 @@ public class FixedPageCursor: CursorType { } public var exhausted: Bool { - return cursor.exhausted && cursor.count == count + return mutex.sync { internalExhausted } } - public private(set) var count: Int = 0 + public var count: Int { + return mutex.sync { internalCount } + } public subscript(index: Int) -> Cursor.Element { - return cursor[index] + return mutex.sync { cursor[index] } } public func loadNextBatch() -> Observable<[Cursor.Element]> { - return loadNextBatch(withSemaphore: semaphore) + return loadNextBatch(usingMutex: mutex) } - private func loadNextBatch(withSemaphore semaphore: DispatchSemaphore?) -> Observable<[Cursor.Element]> { + private func loadNextBatch(usingMutex mutex: Mutex?) -> Observable<[Cursor.Element]> { return Observable.deferred { - semaphore?.wait() + mutex?.unbalancedLock() - if self.exhausted { + if self.internalExhausted { throw CursorError.exhausted } - let restOfLoaded = self.cursor.count - self.count + let restOfLoaded = self.cursor.count - self.internalCount if restOfLoaded >= self.pageSize || self.cursor.exhausted { - let startIndex = self.count - self.count += min(restOfLoaded, self.pageSize) + let startIndex = self.internalCount + self.internalCount += min(restOfLoaded, self.pageSize) - return .just(self.cursor[startIndex..: FixedPageCursor, ResettableType { + + public override init(cursor: Cursor, pageSize: Int) { + super.init(cursor: cursor, pageSize: pageSize) + } + + public required init(initialFrom other: ResettableFixedPageCursor) { + super.init(cursor: other.cursor, pageSize: other.pageSize) + } + +} diff --git a/LeadKit/LeadKit/Classes/Cursors/MapCursor.swift b/LeadKit/LeadKit/Classes/Cursors/MapCursor.swift index 12282a4e..5b9b8ed5 100644 --- a/LeadKit/LeadKit/Classes/Cursors/MapCursor.swift +++ b/LeadKit/LeadKit/Classes/Cursors/MapCursor.swift @@ -55,7 +55,7 @@ public class MapCursor: CursorType { private var elements: [T] = [] - private let semaphore = DispatchSemaphore(value: 1) + private let mutex = Mutex() /// Initializer with enclosed cursor /// @@ -68,20 +68,20 @@ public class MapCursor: CursorType { } public var exhausted: Bool { - return cursor.exhausted + return mutex.sync { cursor.exhausted } } public var count: Int { - return elements.count + return mutex.sync { elements.count } } public subscript(index: Int) -> T { - return elements[index] + return mutex.sync { elements[index] } } public func loadNextBatch() -> Observable<[T]> { return Observable.deferred { - self.semaphore.wait() + self.mutex.unbalancedLock() return self.cursor.loadNextBatch().map { newItems in let transformedNewItems = newItems.flatMap(self.transform) @@ -90,15 +90,16 @@ public class MapCursor: CursorType { return transformedNewItems } } - .do(onNext: { [weak semaphore] _ in - semaphore?.signal() - }, onError: { [weak semaphore] _ in - semaphore?.signal() + .do(onNext: { _ in + self.mutex.unbalancedUnlock() + }, onError: { _ in + self.mutex.unbalancedUnlock() }) } } +/// MapCursor subclass with implementation of ResettableType public class ResettableMapCursor: MapCursor, ResettableType { public override init(cursor: Cursor, transform: @escaping Transform) { diff --git a/LeadKit/LeadKit/Classes/Cursors/StaticCursor.swift b/LeadKit/LeadKit/Classes/Cursors/StaticCursor.swift index 87bbcd70..fef46bba 100644 --- a/LeadKit/LeadKit/Classes/Cursors/StaticCursor.swift +++ b/LeadKit/LeadKit/Classes/Cursors/StaticCursor.swift @@ -23,11 +23,14 @@ import RxSwift /// Stub cursor implementation for array content type -public class StaticCursor: CursorType { +public class StaticCursor: ResettableCursorType { private let content: [Element] - private let semaphore = DispatchSemaphore(value: 1) + private var internalExhausted = false + private var internalCount = 0 + + private let mutex = Mutex() /// Initializer for array content type /// @@ -36,32 +39,40 @@ public class StaticCursor: CursorType { self.content = content } - public private(set) var exhausted = false + public required init(initialFrom other: StaticCursor) { + self.content = other.content + } - public private(set) var count = 0 + public var exhausted: Bool { + return mutex.sync { internalExhausted } + } + + public var count: Int { + return mutex.sync { internalCount } + } public subscript(index: Int) -> Element { - return content[index] + return mutex.sync { content[index] } } public func loadNextBatch() -> Observable<[Element]> { return Observable.deferred { - self.semaphore.wait() + self.mutex.unbalancedLock() if self.exhausted { throw CursorError.exhausted } - self.count = self.content.count + self.internalCount = self.content.count - self.exhausted = true + self.internalExhausted = true return .just(self.content) } - .do(onNext: { [weak semaphore] _ in - semaphore?.signal() - }, onError: { [weak semaphore] _ in - semaphore?.signal() + .do(onNext: { _ in + self.mutex.unbalancedUnlock() + }, onError: { _ in + self.mutex.unbalancedUnlock() }) } diff --git a/LeadKit/LeadKit/Classes/Pagination/PaginationTableViewWrapper.swift b/LeadKit/LeadKit/Classes/Pagination/PaginationTableViewWrapper.swift index fc669efc..1db9036e 100644 --- a/LeadKit/LeadKit/Classes/Pagination/PaginationTableViewWrapper.swift +++ b/LeadKit/LeadKit/Classes/Pagination/PaginationTableViewWrapper.swift @@ -192,7 +192,10 @@ where Delegate.Cursor == Cursor { } } - private func onResultsState(newItems: [Cursor.Element], inCursor cursor: Cursor, afterState: PaginationViewModel.State) { + private func onResultsState(newItems: [Cursor.Element], + inCursor cursor: Cursor, + afterState: PaginationViewModel.State) { + tableView.isUserInteractionEnabled = true if case .loading = afterState { diff --git a/LeadKit/LeadKitTests/PaginationViewModelTests.swift b/LeadKit/LeadKitTests/PaginationViewModelTests.swift index d8b5e57d..23949ef9 100644 --- a/LeadKit/LeadKitTests/PaginationViewModelTests.swift +++ b/LeadKit/LeadKitTests/PaginationViewModelTests.swift @@ -46,7 +46,7 @@ class PaginationViewModelTests: XCTestCase { switch state { case .initial, .loadingMore, .loading: print("PageViewModel state changed to \(state)") - case .results(let newItems, _): + case .results(let newItems, _, _): print(newItems.count) paginationExpectation.fulfill() default: From 844a7d1c677de0385b213556d837debd8dc21dd0 Mon Sep 17 00:00:00 2001 From: Ivan Smolin Date: Wed, 19 Apr 2017 14:14:35 +0300 Subject: [PATCH 17/28] xib name docs update --- .../Extensions/UIView/UIView+DefaultXibName.swift | 5 ----- .../UIViewController/UIViewController+XibName.swift | 7 +------ LeadKit/LeadKit/Protocols/XibNameProtocol.swift | 10 ++++------ 3 files changed, 5 insertions(+), 17 deletions(-) diff --git a/LeadKit/LeadKit/Extensions/UIView/UIView+DefaultXibName.swift b/LeadKit/LeadKit/Extensions/UIView/UIView+DefaultXibName.swift index 55f06fd3..a63aac9b 100644 --- a/LeadKit/LeadKit/Extensions/UIView/UIView+DefaultXibName.swift +++ b/LeadKit/LeadKit/Extensions/UIView/UIView+DefaultXibName.swift @@ -24,11 +24,6 @@ import UIKit extension UIView: XibNameProtocol { - /** - default implementation of StaticNibNameProtocol - - - returns: class name string - */ open class var xibName: String { return className(of: self) } diff --git a/LeadKit/LeadKit/Extensions/UIViewController/UIViewController+XibName.swift b/LeadKit/LeadKit/Extensions/UIViewController/UIViewController+XibName.swift index 4a41f581..8f000930 100644 --- a/LeadKit/LeadKit/Extensions/UIViewController/UIViewController+XibName.swift +++ b/LeadKit/LeadKit/Extensions/UIViewController/UIViewController+XibName.swift @@ -22,13 +22,8 @@ import UIKit -extension UIViewController { +extension UIViewController: XibNameProtocol { - /** - Name of related xib - - - returns: type name string - */ open class var xibName: String { return typeName(of: self) } diff --git a/LeadKit/LeadKit/Protocols/XibNameProtocol.swift b/LeadKit/LeadKit/Protocols/XibNameProtocol.swift index f288d321..4926f678 100644 --- a/LeadKit/LeadKit/Protocols/XibNameProtocol.swift +++ b/LeadKit/LeadKit/Protocols/XibNameProtocol.swift @@ -22,12 +22,10 @@ import Foundation -/** - * protocol which ensures that specific type can return nib name of view - */ +/// Protocol that ensures that specific type can return it's xib name public protocol XibNameProtocol { - /** - - returns: nib name string - */ + + /// Name of related xib static var xibName: String { get } + } From ab9921da537c64d04b746e1ddf8c7f7096919bfb Mon Sep 17 00:00:00 2001 From: Ivan Smolin Date: Wed, 19 Apr 2017 14:17:24 +0300 Subject: [PATCH 18/28] small fixes --- LeadKit/LeadKit.xcodeproj/project.pbxproj | 8 ++++---- .../Extensions/UIView/UIView+LoadingIndicator.swift | 2 +- LeadKit/LeadKit/Extensions/UIView/UIView+Rotation.swift | 4 ++-- ...ibName.swift => UIViewController+DefaultXibName.swift} | 0 4 files changed, 7 insertions(+), 7 deletions(-) rename LeadKit/LeadKit/Extensions/UIViewController/{UIViewController+XibName.swift => UIViewController+DefaultXibName.swift} (100%) diff --git a/LeadKit/LeadKit.xcodeproj/project.pbxproj b/LeadKit/LeadKit.xcodeproj/project.pbxproj index 7b187dad..65413cfb 100644 --- a/LeadKit/LeadKit.xcodeproj/project.pbxproj +++ b/LeadKit/LeadKit.xcodeproj/project.pbxproj @@ -92,7 +92,7 @@ CAE698C21E965B47000394B0 /* TableDirector+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAE698C01E965B47000394B0 /* TableDirector+Extensions.swift */; }; CAE698C61E96775F000394B0 /* String+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAE698C41E96775F000394B0 /* String+Extensions.swift */; }; E126CBB31DB68DDA00E1B2F8 /* UICollectionView+CellRegistration.swift in Sources */ = {isa = PBXBuildFile; fileRef = E126CBB21DB68DDA00E1B2F8 /* UICollectionView+CellRegistration.swift */; }; - EDF3DE3F1EA4F2E80016F729 /* UIViewController+XibName.swift in Sources */ = {isa = PBXBuildFile; fileRef = EDF3DE3E1EA4F2E80016F729 /* UIViewController+XibName.swift */; }; + EDF3DE3F1EA4F2E80016F729 /* UIViewController+DefaultXibName.swift in Sources */ = {isa = PBXBuildFile; fileRef = EDF3DE3E1EA4F2E80016F729 /* UIViewController+DefaultXibName.swift */; }; EF2921A61E165DF400E8F43B /* TimeInterval+DateComponents.swift in Sources */ = {isa = PBXBuildFile; fileRef = EF2921A51E165DF400E8F43B /* TimeInterval+DateComponents.swift */; }; EF5FB5691E0141610030E4BE /* UIView+Rotation.swift in Sources */ = {isa = PBXBuildFile; fileRef = EF5FB5681E0141610030E4BE /* UIView+Rotation.swift */; }; /* End PBXBuildFile section */ @@ -200,7 +200,7 @@ CAE698C41E96775F000394B0 /* String+Extensions.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "String+Extensions.swift"; sourceTree = ""; }; CC832342120EAD568C9F7FC3 /* Pods-LeadKitTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-LeadKitTests.debug.xcconfig"; path = "Pods/Target Support Files/Pods-LeadKitTests/Pods-LeadKitTests.debug.xcconfig"; sourceTree = ""; }; E126CBB21DB68DDA00E1B2F8 /* UICollectionView+CellRegistration.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = "UICollectionView+CellRegistration.swift"; path = "UICollectionView/UICollectionView+CellRegistration.swift"; sourceTree = ""; }; - EDF3DE3E1EA4F2E80016F729 /* UIViewController+XibName.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "UIViewController+XibName.swift"; sourceTree = ""; }; + EDF3DE3E1EA4F2E80016F729 /* UIViewController+DefaultXibName.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "UIViewController+DefaultXibName.swift"; sourceTree = ""; }; EF2921A51E165DF400E8F43B /* TimeInterval+DateComponents.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "TimeInterval+DateComponents.swift"; sourceTree = ""; }; EF5FB5681E0141610030E4BE /* UIView+Rotation.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "UIView+Rotation.swift"; sourceTree = ""; }; /* End PBXFileReference section */ @@ -609,7 +609,7 @@ children = ( 78D4B5451DA64D49005B0764 /* UIViewController+DefaultStoryboardIdentifier.swift */, 78C54AFC1E432EEF0051EFBA /* UIViewController+TopVisibleViewController.swift */, - EDF3DE3E1EA4F2E80016F729 /* UIViewController+XibName.swift */, + EDF3DE3E1EA4F2E80016F729 /* UIViewController+DefaultXibName.swift */, ); path = UIViewController; sourceTree = ""; @@ -1006,7 +1006,7 @@ 67DC650F1E979D0C002F2FFF /* PaginationTableViewWrapperDelegate+DefaultImplementation.swift in Sources */, 67DC65041E979B34002F2FFF /* LoadingIndicatorProtocol.swift in Sources */, 78B0364B1DA61EDE0021D5CC /* CGImage+Crop.swift in Sources */, - EDF3DE3F1EA4F2E80016F729 /* UIViewController+XibName.swift in Sources */, + EDF3DE3F1EA4F2E80016F729 /* UIViewController+DefaultXibName.swift in Sources */, 78B036451DA561D00021D5CC /* CGImage+Utils.swift in Sources */, 78CFEE591C5C45E500F50370 /* StoryboardIdentifierProtocol.swift in Sources */, 78011AB31D48B53600EA16A2 /* ApiRequestParameters.swift in Sources */, diff --git a/LeadKit/LeadKit/Extensions/UIView/UIView+LoadingIndicator.swift b/LeadKit/LeadKit/Extensions/UIView/UIView+LoadingIndicator.swift index 2ae4b192..6c0a33ac 100644 --- a/LeadKit/LeadKit/Extensions/UIView/UIView+LoadingIndicator.swift +++ b/LeadKit/LeadKit/Extensions/UIView/UIView+LoadingIndicator.swift @@ -20,7 +20,7 @@ // THE SOFTWARE. // -import Foundation +import UIKit extension LoadingIndicator where Self: UIView { diff --git a/LeadKit/LeadKit/Extensions/UIView/UIView+Rotation.swift b/LeadKit/LeadKit/Extensions/UIView/UIView+Rotation.swift index b59f2530..1613eb18 100644 --- a/LeadKit/LeadKit/Extensions/UIView/UIView+Rotation.swift +++ b/LeadKit/LeadKit/Extensions/UIView/UIView+Rotation.swift @@ -20,9 +20,9 @@ // THE SOFTWARE. // -import Foundation +import UIKit -extension UIView { +public extension UIView { private static let rotationKeyPath = "transform.rotation.z" diff --git a/LeadKit/LeadKit/Extensions/UIViewController/UIViewController+XibName.swift b/LeadKit/LeadKit/Extensions/UIViewController/UIViewController+DefaultXibName.swift similarity index 100% rename from LeadKit/LeadKit/Extensions/UIViewController/UIViewController+XibName.swift rename to LeadKit/LeadKit/Extensions/UIViewController/UIViewController+DefaultXibName.swift From 94969f65f782076fd50a3016a1d9d14d193f1ed6 Mon Sep 17 00:00:00 2001 From: Ivan Smolin Date: Fri, 21 Apr 2017 15:55:20 +0300 Subject: [PATCH 19/28] use xibName for innerViewNibName of XibView --- LeadKit/LeadKit/Classes/Views/XibView.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/LeadKit/LeadKit/Classes/Views/XibView.swift b/LeadKit/LeadKit/Classes/Views/XibView.swift index b94db6fe..083210dc 100644 --- a/LeadKit/LeadKit/Classes/Views/XibView.swift +++ b/LeadKit/LeadKit/Classes/Views/XibView.swift @@ -27,7 +27,7 @@ open class XibView: UIView { /// Nib name used to instantiate inner view open var innerViewNibName: String { - return className(of: self) + return type(of: self).xibName } public convenience init() { From 4aaf6dc0483fbec63feb4171a42521901d0c905f Mon Sep 17 00:00:00 2001 From: Ivan Smolin Date: Fri, 21 Apr 2017 15:55:47 +0300 Subject: [PATCH 20/28] flip UIImage when creating from UIView --- LeadKit/LeadKit/Extensions/CGImage/CGImage+Creation.swift | 3 +++ 1 file changed, 3 insertions(+) diff --git a/LeadKit/LeadKit/Extensions/CGImage/CGImage+Creation.swift b/LeadKit/LeadKit/Extensions/CGImage/CGImage+Creation.swift index 15e1ce94..3273da34 100644 --- a/LeadKit/LeadKit/Extensions/CGImage/CGImage+Creation.swift +++ b/LeadKit/LeadKit/Extensions/CGImage/CGImage+Creation.swift @@ -70,6 +70,9 @@ public extension CGImage { return nil } + ctx.translateBy(x: 0, y: size.height) + ctx.scaleBy(x: 1.0, y: -1.0) + view.layer.render(in: ctx) return ctx.makeImage() From 1387f0074461d1838a1057af04808c6cc04c74bb Mon Sep 17 00:00:00 2001 From: Ivan Smolin Date: Fri, 21 Apr 2017 15:57:51 +0300 Subject: [PATCH 21/28] add iOS playground --- LeadKit/LeadKit.xcodeproj/project.pbxproj | 2 ++ LeadKit/LeadKit/iOS.playground/Contents.swift | 3 +++ LeadKit/LeadKit/iOS.playground/contents.xcplayground | 4 ++++ 3 files changed, 9 insertions(+) create mode 100644 LeadKit/LeadKit/iOS.playground/Contents.swift create mode 100644 LeadKit/LeadKit/iOS.playground/contents.xcplayground diff --git a/LeadKit/LeadKit.xcodeproj/project.pbxproj b/LeadKit/LeadKit.xcodeproj/project.pbxproj index 65413cfb..775e04b5 100644 --- a/LeadKit/LeadKit.xcodeproj/project.pbxproj +++ b/LeadKit/LeadKit.xcodeproj/project.pbxproj @@ -109,6 +109,7 @@ /* Begin PBXFileReference section */ 12F36034A5278991B658B53E /* Pods_LeadKit.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_LeadKit.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 671FF1611EAA264B001B882C /* iOS.playground */ = {isa = PBXFileReference; lastKnownFileType = file.playground; path = iOS.playground; sourceTree = ""; }; 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 = ""; }; @@ -528,6 +529,7 @@ 78D4B54B1DA650FC005B0764 /* Functions */, 78CFEE2D1C5C456B00F50370 /* LeadKit.h */, 78CFEE2F1C5C456B00F50370 /* Info.plist */, + 671FF1611EAA264B001B882C /* iOS.playground */, ); path = LeadKit; sourceTree = ""; diff --git a/LeadKit/LeadKit/iOS.playground/Contents.swift b/LeadKit/LeadKit/iOS.playground/Contents.swift new file mode 100644 index 00000000..0fa4d197 --- /dev/null +++ b/LeadKit/LeadKit/iOS.playground/Contents.swift @@ -0,0 +1,3 @@ +import LeadKit + +let str = "Hello, LeadKit playground" \ No newline at end of file diff --git a/LeadKit/LeadKit/iOS.playground/contents.xcplayground b/LeadKit/LeadKit/iOS.playground/contents.xcplayground new file mode 100644 index 00000000..5da2641c --- /dev/null +++ b/LeadKit/LeadKit/iOS.playground/contents.xcplayground @@ -0,0 +1,4 @@ + + + + \ No newline at end of file From bebef20e54da64cf4715ef1f7eb57043df799873 Mon Sep 17 00:00:00 2001 From: Ivan Smolin Date: Mon, 24 Apr 2017 14:11:36 +0300 Subject: [PATCH 22/28] resettable map and fixed page cursors also reset inner cursor --- LeadKit/LeadKit/Classes/Cursors/FixedPageCursor.swift | 2 +- LeadKit/LeadKit/Classes/Cursors/MapCursor.swift | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/LeadKit/LeadKit/Classes/Cursors/FixedPageCursor.swift b/LeadKit/LeadKit/Classes/Cursors/FixedPageCursor.swift index 25956707..2826c531 100644 --- a/LeadKit/LeadKit/Classes/Cursors/FixedPageCursor.swift +++ b/LeadKit/LeadKit/Classes/Cursors/FixedPageCursor.swift @@ -102,7 +102,7 @@ public class ResettableFixedPageCursor: FixedPageC } public required init(initialFrom other: ResettableFixedPageCursor) { - super.init(cursor: other.cursor, pageSize: other.pageSize) + super.init(cursor: other.cursor.reset(), pageSize: other.pageSize) } } diff --git a/LeadKit/LeadKit/Classes/Cursors/MapCursor.swift b/LeadKit/LeadKit/Classes/Cursors/MapCursor.swift index 5b9b8ed5..8a5a1222 100644 --- a/LeadKit/LeadKit/Classes/Cursors/MapCursor.swift +++ b/LeadKit/LeadKit/Classes/Cursors/MapCursor.swift @@ -107,7 +107,7 @@ public class ResettableMapCursor: MapCursor Date: Thu, 27 Apr 2017 09:47:15 +0300 Subject: [PATCH 23/28] typo --- LeadKit/LeadKit/Extensions/UIView/UIView+LoadFromNib.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/LeadKit/LeadKit/Extensions/UIView/UIView+LoadFromNib.swift b/LeadKit/LeadKit/Extensions/UIView/UIView+LoadFromNib.swift index 087edbce..9b763267 100644 --- a/LeadKit/LeadKit/Extensions/UIView/UIView+LoadFromNib.swift +++ b/LeadKit/LeadKit/Extensions/UIView/UIView+LoadFromNib.swift @@ -46,7 +46,7 @@ public extension UIView { let nib = UINib(nibName: nibName, bundle: bundle) guard let nibView = nib.instantiate(withOwner: owner, options: nil).first as? T else { - fatalError("Can't nstantiate nib view with type \(T.self)") + fatalError("Can't instantiate nib view with type \(T.self)") } return nibView From 2ddad9ee0831906257cd709c97e26893ff3c9497 Mon Sep 17 00:00:00 2001 From: Ivan Smolin Date: Fri, 28 Apr 2017 17:43:55 +0300 Subject: [PATCH 24/28] cursors not thread safe anymore --- .../Classes/Cursors/FixedPageCursor.swift | 41 +++++-------------- .../LeadKit/Classes/Cursors/MapCursor.swift | 25 ++++------- .../Classes/Cursors/StaticCursor.swift | 26 +++--------- 3 files changed, 22 insertions(+), 70 deletions(-) diff --git a/LeadKit/LeadKit/Classes/Cursors/FixedPageCursor.swift b/LeadKit/LeadKit/Classes/Cursors/FixedPageCursor.swift index 2826c531..b7e2f407 100644 --- a/LeadKit/LeadKit/Classes/Cursors/FixedPageCursor.swift +++ b/LeadKit/LeadKit/Classes/Cursors/FixedPageCursor.swift @@ -29,14 +29,6 @@ public class FixedPageCursor: CursorType { fileprivate let pageSize: Int - private var internalCount = 0 - - private var internalExhausted: Bool { - return cursor.exhausted && cursor.count == internalCount - } - - private let mutex = Mutex() - /// Initializer with enclosed cursor /// /// - Parameters: @@ -48,48 +40,35 @@ public class FixedPageCursor: CursorType { } public var exhausted: Bool { - return mutex.sync { internalExhausted } + return cursor.exhausted && cursor.count == count } - public var count: Int { - return mutex.sync { internalCount } - } + public private(set) var count: Int = 0 public subscript(index: Int) -> Cursor.Element { - return mutex.sync { cursor[index] } + return cursor[index] } public func loadNextBatch() -> Observable<[Cursor.Element]> { - return loadNextBatch(usingMutex: mutex) - } - - private func loadNextBatch(usingMutex mutex: Mutex?) -> Observable<[Cursor.Element]> { return Observable.deferred { - mutex?.unbalancedLock() - - if self.internalExhausted { + if self.exhausted { throw CursorError.exhausted } - let restOfLoaded = self.cursor.count - self.internalCount + let restOfLoaded = self.cursor.count - self.count if restOfLoaded >= self.pageSize || self.cursor.exhausted { - let startIndex = self.internalCount - self.internalCount += min(restOfLoaded, self.pageSize) + let startIndex = self.count + self.count += min(restOfLoaded, self.pageSize) - return .just(self.cursor[startIndex..: CursorType { private var elements: [T] = [] - private let mutex = Mutex() - /// Initializer with enclosed cursor /// /// - Parameters: @@ -68,33 +66,24 @@ public class MapCursor: CursorType { } public var exhausted: Bool { - return mutex.sync { cursor.exhausted } + return cursor.exhausted } public var count: Int { - return mutex.sync { elements.count } + return elements.count } public subscript(index: Int) -> T { - return mutex.sync { elements[index] } + return elements[index] } public func loadNextBatch() -> Observable<[T]> { - return Observable.deferred { - self.mutex.unbalancedLock() + return cursor.loadNextBatch().map { newItems in + let transformedNewItems = newItems.flatMap(self.transform) + self.elements += transformedNewItems - return self.cursor.loadNextBatch().map { newItems in - let transformedNewItems = newItems.flatMap(self.transform) - self.elements += transformedNewItems - - return transformedNewItems - } + return transformedNewItems } - .do(onNext: { _ in - self.mutex.unbalancedUnlock() - }, onError: { _ in - self.mutex.unbalancedUnlock() - }) } } diff --git a/LeadKit/LeadKit/Classes/Cursors/StaticCursor.swift b/LeadKit/LeadKit/Classes/Cursors/StaticCursor.swift index fef46bba..9bf9eac0 100644 --- a/LeadKit/LeadKit/Classes/Cursors/StaticCursor.swift +++ b/LeadKit/LeadKit/Classes/Cursors/StaticCursor.swift @@ -27,11 +27,6 @@ public class StaticCursor: ResettableCursorType { private let content: [Element] - private var internalExhausted = false - private var internalCount = 0 - - private let mutex = Mutex() - /// Initializer for array content type /// /// - Parameter content: array with elements of Elemet type @@ -43,37 +38,26 @@ public class StaticCursor: ResettableCursorType { self.content = other.content } - public var exhausted: Bool { - return mutex.sync { internalExhausted } - } + public private(set) var exhausted = false - public var count: Int { - return mutex.sync { internalCount } - } + public private(set) var count = 0 public subscript(index: Int) -> Element { - return mutex.sync { content[index] } + return content[index] } public func loadNextBatch() -> Observable<[Element]> { return Observable.deferred { - self.mutex.unbalancedLock() - if self.exhausted { throw CursorError.exhausted } - self.internalCount = self.content.count + self.count = self.content.count - self.internalExhausted = true + self.exhausted = true return .just(self.content) } - .do(onNext: { _ in - self.mutex.unbalancedUnlock() - }, onError: { _ in - self.mutex.unbalancedUnlock() - }) } } From 267f7c6b4ca603fb873092a8540bd850a2aac703 Mon Sep 17 00:00:00 2001 From: Ivan Smolin Date: Tue, 2 May 2017 10:57:10 +0300 Subject: [PATCH 25/28] remove previous refresh control before add new one --- .../LeadKit/Extensions/Support/UIScrollView+Support.swift | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/LeadKit/LeadKit/Extensions/Support/UIScrollView+Support.swift b/LeadKit/LeadKit/Extensions/Support/UIScrollView+Support.swift index ccd40e7b..aac2cdc3 100644 --- a/LeadKit/LeadKit/Extensions/Support/UIScrollView+Support.swift +++ b/LeadKit/LeadKit/Extensions/Support/UIScrollView+Support.swift @@ -36,8 +36,9 @@ public extension Support where Base: UIScrollView { if #available(iOS 10.0, *) { base.refreshControl = newRefreshControl } else { - if let control = newRefreshControl { - base.addSubview(control) + if let newControl = newRefreshControl { + refreshControl?.removeFromSuperview() + base.addSubview(newControl) } else { refreshControl?.removeFromSuperview() } From 75fbeda92afbd8eaac0cb2a7f3f582999cdfea91 Mon Sep 17 00:00:00 2001 From: Ivan Smolin Date: Tue, 2 May 2017 14:13:42 +0300 Subject: [PATCH 26/28] use rx instead of waiting operations blocks --- .../PaginationTableViewWrapper.swift | 61 +++++++------------ 1 file changed, 22 insertions(+), 39 deletions(-) diff --git a/LeadKit/LeadKit/Classes/Pagination/PaginationTableViewWrapper.swift b/LeadKit/LeadKit/Classes/Pagination/PaginationTableViewWrapper.swift index 1db9036e..f39530d3 100644 --- a/LeadKit/LeadKit/Classes/Pagination/PaginationTableViewWrapper.swift +++ b/LeadKit/LeadKit/Classes/Pagination/PaginationTableViewWrapper.swift @@ -118,8 +118,6 @@ where Delegate.Cursor == Cursor { private let applicationCurrentyActive = Variable(true) - private var waitingOperations: [() -> Void] = [] - /// Initializer with table view, placeholders container view, cusor and delegate parameters. /// /// - Parameters: @@ -284,34 +282,28 @@ where Delegate.Cursor == Cursor { } private func bindViewModelStates() { - paginationViewModel.state.drive(onNext: { [weak self] state in - let stateHandling = { [weak self] in - switch state { - case .initial: - self?.onInitialState() - case .loading(let after): - self?.onLoadingState(afterState: after) - case .loadingMore(let after): - self?.onLoadingMoreState(afterState: after) - case .results(let newItems, let cursor, let after): - self?.onResultsState(newItems: newItems, inCursor: cursor, afterState: after) - case .error(let error, let after): - self?.onErrorState(error: error, afterState: after) - case .empty: - self?.onEmptyState() - case .exhausted: - self?.onExhaustedState() - } - } - - guard let strongSelf = self else { - return - } - - if strongSelf.applicationCurrentyActive.value { - stateHandling() - } else { - strongSelf.waitingOperations.append(stateHandling) + paginationViewModel.state.flatMapLatest { [applicationCurrentyActive] state in + return applicationCurrentyActive + .asDriver() + .filter { $0 } + .map { _ in state } + } + .drive(onNext: { [weak self] state in + switch state { + case .initial: + self?.onInitialState() + case .loading(let after): + self?.onLoadingState(afterState: after) + case .loadingMore(let after): + self?.onLoadingMoreState(afterState: after) + case .results(let newItems, let cursor, let after): + self?.onResultsState(newItems: newItems, inCursor: cursor, afterState: after) + case .error(let error, let after): + self?.onErrorState(error: error, afterState: after) + case .empty: + self?.onEmptyState() + case .exhausted: + self?.onExhaustedState() } }) .addDisposableTo(disposeBag) @@ -362,15 +354,6 @@ where Delegate.Cursor == Cursor { self?.applicationCurrentyActive.value = true }) .addDisposableTo(disposeBag) - - applicationCurrentyActive.asDriver() - .drive(onNext: { [weak self] appActive in - if appActive { - self?.waitingOperations.forEach { $0() } - self?.waitingOperations = [] - } - }) - .addDisposableTo(disposeBag) } } From 29b3bb3215cc0729a08c2007081e51528350871b Mon Sep 17 00:00:00 2001 From: Ivan Smolin Date: Tue, 2 May 2017 14:30:34 +0300 Subject: [PATCH 27/28] add small delay for inactive -> active state changes --- .../PaginationTableViewWrapper.swift | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/LeadKit/LeadKit/Classes/Pagination/PaginationTableViewWrapper.swift b/LeadKit/LeadKit/Classes/Pagination/PaginationTableViewWrapper.swift index f39530d3..4a3d54f8 100644 --- a/LeadKit/LeadKit/Classes/Pagination/PaginationTableViewWrapper.swift +++ b/LeadKit/LeadKit/Classes/Pagination/PaginationTableViewWrapper.swift @@ -22,6 +22,7 @@ import UIKit import RxSwift +import RxCocoa import UIScrollView_InfiniteScroll /// PaginationTableViewWrapper delegate used for pagination results handling and @@ -282,11 +283,19 @@ where Delegate.Cursor == Cursor { } private func bindViewModelStates() { - paginationViewModel.state.flatMapLatest { [applicationCurrentyActive] state in - return applicationCurrentyActive - .asDriver() - .filter { $0 } - .map { _ in state } + typealias State = PaginationViewModel.State + + paginationViewModel.state.flatMapLatest { [applicationCurrentyActive] state -> Driver in + if applicationCurrentyActive.value { + return .just(state) + } else { + return applicationCurrentyActive + .asObservable() + .delay(0.5, scheduler: MainScheduler.instance) + .filter { $0 } + .asDriver(onErrorJustReturn: true) + .map { _ in state } + } } .drive(onNext: { [weak self] state in switch state { From e509f2d4a720df1acf6bf63267990bd061c94f0d Mon Sep 17 00:00:00 2001 From: Ivan Smolin Date: Tue, 2 May 2017 14:35:53 +0300 Subject: [PATCH 28/28] move delay after filter --- .../LeadKit/Classes/Pagination/PaginationTableViewWrapper.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/LeadKit/LeadKit/Classes/Pagination/PaginationTableViewWrapper.swift b/LeadKit/LeadKit/Classes/Pagination/PaginationTableViewWrapper.swift index 4a3d54f8..7a55ab28 100644 --- a/LeadKit/LeadKit/Classes/Pagination/PaginationTableViewWrapper.swift +++ b/LeadKit/LeadKit/Classes/Pagination/PaginationTableViewWrapper.swift @@ -291,8 +291,8 @@ where Delegate.Cursor == Cursor { } else { return applicationCurrentyActive .asObservable() - .delay(0.5, scheduler: MainScheduler.instance) .filter { $0 } + .delay(0.5, scheduler: MainScheduler.instance) .asDriver(onErrorJustReturn: true) .map { _ in state } }