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 daeaec34..775e04b5 100644 --- a/LeadKit/LeadKit.xcodeproj/project.pbxproj +++ b/LeadKit/LeadKit.xcodeproj/project.pbxproj @@ -9,11 +9,25 @@ /* 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 */; }; + 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 */; }; + 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 */; }; 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 */; }; 78011AB31D48B53600EA16A2 /* ApiRequestParameters.swift in Sources */ = {isa = PBXBuildFile; fileRef = 78011AB21D48B53600EA16A2 /* ApiRequestParameters.swift */; }; 780D23431DA412470084620D /* CGImage+Alpha.swift in Sources */ = {isa = PBXBuildFile; fileRef = 780D23421DA412470084620D /* CGImage+Alpha.swift */; }; @@ -43,7 +57,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 */; }; @@ -60,7 +74,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 */; }; @@ -78,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 */ @@ -95,13 +109,28 @@ /* 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 = ""; }; + 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 = ""; }; + 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 = ""; }; 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 = ""; }; 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 = ""; }; @@ -131,7 +160,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 = ""; }; @@ -151,7 +180,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 = ""; }; @@ -172,7 +201,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 */ @@ -206,6 +235,23 @@ path = Models; sourceTree = ""; }; + 675D24B01E9234A400E92D1F /* Pagination */ = { + isa = PBXGroup; + children = ( + 675D24B11E9234BB00E92D1F /* PaginationViewModel.swift */, + 678A20291E93C1A900787562 /* PaginationTableViewWrapper.swift */, + ); + path = Pagination; + sourceTree = ""; + }; + 675FB4231EA779650075BF3D /* Concurrency */ = { + isa = PBXGroup; + children = ( + 675FB4241EA7797C0075BF3D /* Mutex.swift */, + ); + path = Concurrency; + sourceTree = ""; + }; 67788F9D1E6965F800484DEE /* CGFloat */ = { isa = PBXGroup; children = ( @@ -214,6 +260,14 @@ path = CGFloat; sourceTree = ""; }; + 679DE4921E9613ED006F25FE /* Support */ = { + isa = PBXGroup; + children = ( + 679DE4931E9613ED006F25FE /* UIScrollView+Support.swift */, + ); + path = Support; + sourceTree = ""; + }; 67B305791E8A8727008169CA /* Views */ = { isa = PBXGroup; children = ( @@ -231,6 +285,38 @@ 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 = ( + 67EF144B1E8BEACB00D6E0DD /* StubCursor.swift */, + ); + path = Cursors; + sourceTree = ""; + }; 78011A651D47AF3000EA16A2 /* Enums */ = { isa = PBXGroup; children = ( @@ -243,6 +329,7 @@ 78011AAE1D48B46100EA16A2 /* Structures */ = { isa = PBXGroup; children = ( + 67DC650A1E979BFD002F2FFF /* Views */, 78011AB11D48B53600EA16A2 /* Api */, ); path = Structures; @@ -379,6 +466,8 @@ 78B0FC7B1C6B2BAE00358B64 /* Logging */, 78753E2A1DE58BED006BC0FB /* Cursors */, 67B305801E8A92B6008169CA /* Views */, + 675D24B01E9234A400E92D1F /* Pagination */, + 675FB4231EA779650075BF3D /* Concurrency */, ); path = Classes; sourceTree = ""; @@ -440,6 +529,7 @@ 78D4B54B1DA650FC005B0764 /* Functions */, 78CFEE2D1C5C456B00F50370 /* LeadKit.h */, 78CFEE2F1C5C456B00F50370 /* Info.plist */, + 671FF1611EAA264B001B882C /* iOS.playground */, ); path = LeadKit; sourceTree = ""; @@ -449,9 +539,12 @@ children = ( 67B305791E8A8727008169CA /* Views */, 6727419E1E65BF3C0075836A /* Models */, + 67EF144A1E8BEA9C00D6E0DD /* Cursors */, 78CFEE3B1C5C456B00F50370 /* Info.plist */, 6727419C1E65B99E0075836A /* MappableUserDefaultsTests.swift */, 67B3057E1E8A8804008169CA /* LoadFromNibTests.swift */, + 67EF144D1E8BED4E00D6E0DD /* CursorTests.swift */, + 674743931E929A5A00B47671 /* PaginationViewModelTests.swift */, ); path = LeadKitTests; sourceTree = ""; @@ -468,11 +561,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 */, @@ -497,12 +593,15 @@ 780F56CB1E0D7ACA004530B6 /* ObservableMappable.swift */, 78CFEE4B1C5C45E500F50370 /* ReuseIdentifierProtocol.swift */, 787682F91CAD40C200532AB3 /* StaticEstimatedViewHeightProtocol.swift */, - 78CFEE4C1C5C45E500F50370 /* StaticNibNameProtocol.swift */, + 78CFEE4C1C5C45E500F50370 /* XibNameProtocol.swift */, 78CFEE4D1C5C45E500F50370 /* StaticViewHeightProtocol.swift */, 78CFEE4E1C5C45E500F50370 /* StoryboardIdentifierProtocol.swift */, 783423691DB8D0E100A79643 /* StoryboardProtocol.swift */, 78CFEE4F1C5C45E500F50370 /* ViewHeightProtocol.swift */, 78CFEE501C5C45E500F50370 /* ViewModelProtocol.swift */, + 67B856E21E923BE600F54304 /* ResettableType.swift */, + 679DE48F1E9588B6006F25FE /* SupportProtocol.swift */, + 67DC65031E979B34002F2FFF /* LoadingIndicatorProtocol.swift */, ); path = Protocols; sourceTree = ""; @@ -512,7 +611,7 @@ children = ( 78D4B5451DA64D49005B0764 /* UIViewController+DefaultStoryboardIdentifier.swift */, 78C54AFC1E432EEF0051EFBA /* UIViewController+TopVisibleViewController.swift */, - EDF3DE3E1EA4F2E80016F729 /* UIViewController+XibName.swift */, + EDF3DE3E1EA4F2E80016F729 /* UIViewController+DefaultXibName.swift */, ); path = UIViewController; sourceTree = ""; @@ -529,10 +628,11 @@ 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 */, + 67DC65051E979B70002F2FFF /* UIView+LoadingIndicator.swift */, ); path = UIView; sourceTree = ""; @@ -849,22 +949,28 @@ 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 */, 7873D1511E112B0D001816EB /* Any+Cast.swift in Sources */, 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 */, 78B0FC811C6B2CD500358B64 /* App.swift in Sources */, 78B036491DA562C30021D5CC /* CGImage+Template.swift in Sources */, 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 */, + 67DC65061E979B70002F2FFF /* UIView+LoadingIndicator.swift in Sources */, 787609221E1403830093CE36 /* Observable+DeferredJust.swift in Sources */, 67B305841E8A92E8008169CA /* XibView.swift in Sources */, 78C54AFD1E432EEF0051EFBA /* UIViewController+TopVisibleViewController.swift in Sources */, @@ -886,18 +992,23 @@ 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 */, 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 */, + 675FB4251EA7797C0075BF3D /* Mutex.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 */, + EDF3DE3F1EA4F2E80016F729 /* UIViewController+DefaultXibName.swift in Sources */, 78B036451DA561D00021D5CC /* CGImage+Utils.swift in Sources */, 78CFEE591C5C45E500F50370 /* StoryboardIdentifierProtocol.swift in Sources */, 78011AB31D48B53600EA16A2 /* ApiRequestParameters.swift in Sources */, @@ -910,7 +1021,10 @@ buildActionMask = 2147483647; files = ( 67B3057D1E8A8735008169CA /* TestView.swift in Sources */, + 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 */, ); @@ -1030,12 +1144,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; @@ -1056,12 +1164,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; 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 ca64e3de..b7e2f407 100644 --- a/LeadKit/LeadKit/Classes/Cursors/FixedPageCursor.swift +++ b/LeadKit/LeadKit/Classes/Cursors/FixedPageCursor.swift @@ -23,13 +23,11 @@ import RxSwift /// Paging cursor implementation with enclosed cursor for fetching results -public class FixedPageCursor: CursorType where Cursor.LoadResultType == CountableRange { +public class FixedPageCursor: CursorType { - public typealias LoadResultType = CountableRange + fileprivate let cursor: Cursor - private let cursor: Cursor - - private let pageSize: Int + fileprivate let pageSize: Int /// Initializer with enclosed cursor /// @@ -51,7 +49,7 @@ public class FixedPageCursor: CursorType where Cursor.LoadRe return cursor[index] } - public func loadNextBatch() -> Observable { + public func loadNextBatch() -> Observable<[Cursor.Element]> { return Observable.deferred { if self.exhausted { throw CursorError.exhausted @@ -63,12 +61,27 @@ public class FixedPageCursor: CursorType where Cursor.LoadRe let startIndex = self.count self.count += min(restOfLoaded, self.pageSize) - return Observable.just(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.reset(), pageSize: other.pageSize) + } + +} diff --git a/LeadKit/LeadKit/Classes/Cursors/MapCursor.swift b/LeadKit/LeadKit/Classes/Cursors/MapCursor.swift index 4a994c0c..1feda515 100644 --- a/LeadKit/LeadKit/Classes/Cursors/MapCursor.swift +++ b/LeadKit/LeadKit/Classes/Cursors/MapCursor.swift @@ -22,30 +22,36 @@ import RxSwift -public typealias MapCursorLoadResultType = CountableRange - -public extension CursorType where Self.LoadResultType == MapCursorLoadResultType { +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 -public class MapCursor: CursorType where Cursor.LoadResultType == MapCursorLoadResultType { - - public typealias LoadResultType = Cursor.LoadResultType +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] = [] @@ -71,13 +77,26 @@ public class MapCursor: CursorType where Cursor.LoadResul return elements[index] } - public func loadNextBatch() -> Observable { - return cursor.loadNextBatch().map { loadedRange in - let startIndex = self.elements.count - self.elements += self.cursor[loadedRange].flatMap(self.transform) + public func loadNextBatch() -> Observable<[T]> { + return cursor.loadNextBatch().map { newItems in + let transformedNewItems = newItems.flatMap(self.transform) + self.elements += transformedNewItems - return startIndex..: 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.reset(), transform: other.transform) + } + +} diff --git a/LeadKit/LeadKit/Classes/Cursors/StaticCursor.swift b/LeadKit/LeadKit/Classes/Cursors/StaticCursor.swift index 06868dd6..9bf9eac0 100644 --- a/LeadKit/LeadKit/Classes/Cursors/StaticCursor.swift +++ b/LeadKit/LeadKit/Classes/Cursors/StaticCursor.swift @@ -23,9 +23,7 @@ import RxSwift /// Stub cursor implementation for array content type -public class StaticCursor: CursorType { - - public typealias LoadResultType = CountableRange +public class StaticCursor: ResettableCursorType { private let content: [Element] @@ -36,6 +34,10 @@ public class StaticCursor: CursorType { self.content = content } + public required init(initialFrom other: StaticCursor) { + self.content = other.content + } + public private(set) var exhausted = false public private(set) var count = 0 @@ -44,7 +46,7 @@ public class StaticCursor: CursorType { return content[index] } - public func loadNextBatch() -> Observable { + public func loadNextBatch() -> Observable<[Element]> { return Observable.deferred { if self.exhausted { throw CursorError.exhausted @@ -54,7 +56,7 @@ public class StaticCursor: CursorType { self.exhausted = true - return Observable.just(0.., + 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], + usingCursor cursor: Cursor) + + /// 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 + +} + +/// Class that connects PaginationViewModel with UITableView. It handles all non-visual and visual states. +final public class PaginationTableViewWrapper +where Delegate.Cursor == Cursor { + + private let tableView: UITableView + 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; + public var infiniteScrollTriggerOffset: CGFloat { + get { + return tableView.infiniteScrollTriggerOffset + } + set { + tableView.infiniteScrollTriggerOffset = newValue + } + } + + private let disposeBag = DisposeBag() + + private var currentPlaceholderView: UIView? + private var currentPlaceholderViewTopConstraint: NSLayoutConstraint? + + private let applicationCurrentyActive = Variable(true) + + /// Initializer with table view, placeholders container view, cusor and delegate parameters. + /// + /// - Parameters: + /// - tableView: UITableView instance to work with. + /// - cursor: Cursor object that acts as data source. + /// - delegate: Delegate object for data loading events handling and UI customization. + public init(tableView: UITableView, cursor: Cursor, delegate: Delegate) { + self.tableView = tableView + self.paginationViewModel = PaginationViewModel(cursor: cursor) + self.delegate = delegate + + bindViewModelStates() + + createRefreshControl() + + 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 + self?.currentPlaceholderViewTopConstraint?.constant = -offset.y + }) + .addDisposableTo(disposeBag) + } + + // MARK: States handling + + private func onInitialState() { + // + } + + private func onLoadingState(afterState: PaginationViewModel.State) { + if case .initial = afterState { + tableView.isUserInteractionEnabled = false + + removeCurrentPlaceholderView() + + guard let loadingIndicator = delegate?.initialLoadingIndicator(forPaginationWrapper: self) else { + return + } + + let loadingIndicatorView = loadingIndicator.view + + loadingIndicatorView.translatesAutoresizingMaskIntoConstraints = true + + tableView.backgroundView = loadingIndicatorView + + loadingIndicator.startAnimating() + + currentPlaceholderView = loadingIndicatorView + } else { + tableView.finishInfiniteScroll() + tableView.tableFooterView = nil + } + } + + private func onLoadingMoreState(afterState: PaginationViewModel.State) { + if case .error = afterState { // user tap retry button in table footer + tableView.tableFooterView = nil + addInfiniteScroll() + tableView.beginInfiniteScroll(true) + } + } + + 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) + + removeCurrentPlaceholderView() + + tableView.support.refreshControl?.endRefreshing() + + addInfiniteScroll() + } else if case .loadingMore = afterState { + delegate?.paginationWrapper(wrapper: self, didLoad: newItems, usingCursor: cursor) + + tableView.finishInfiniteScroll() + } + } + + 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() + + 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) + } + .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() + } + + private func addInfiniteScroll() { + tableView.addInfiniteScroll { [weak paginationViewModel] _ in + paginationViewModel?.load(.next) + } + + 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() { + typealias State = PaginationViewModel.State + + paginationViewModel.state.flatMapLatest { [applicationCurrentyActive] state -> Driver in + if applicationCurrentyActive.value { + return .just(state) + } else { + return applicationCurrentyActive + .asObservable() + .filter { $0 } + .delay(0.5, scheduler: MainScheduler.instance) + .asDriver(onErrorJustReturn: true) + .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) + } + + private func enterPlaceholderState() { + tableView.support.refreshControl?.endRefreshing() + tableView.isUserInteractionEnabled = true + + removeCurrentPlaceholderView() + } + + private func preparePlaceholderView(_ placeholderView: UIView) { + placeholderView.translatesAutoresizingMaskIntoConstraints = false + placeholderView.isHidden = false + + // I was unable to add pull-to-refresh placeholder scroll behaviour without this trick + let wrapperView = UIView() + wrapperView.addSubview(placeholderView) + + 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() { + 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) + } + +} diff --git a/LeadKit/LeadKit/Classes/Pagination/PaginationViewModel.swift b/LeadKit/LeadKit/Classes/Pagination/PaginationViewModel.swift new file mode 100644 index 00000000..3efb4ea5 --- /dev/null +++ b/LeadKit/LeadKit/Classes/Pagination/PaginationViewModel.swift @@ -0,0 +1,141 @@ +// +// 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 + +/// 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, 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. + /// - 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) + case loadingMore(after: State) + case results(newItems: [C.Element], inCursor: C, 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 + case next + + } + + private var cursor: C + + private let internalState = Variable(.initial) + + private var currentRequest: Disposable? + + 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: + currentRequest?.dispose() + cursor = cursor.reset() + + internalState.value = .loading(after: internalState.value) + case .next: + if case .exhausted(_) = internalState.value { + fatalError("You shouldn't call load(.next) after got .exhausted state!") + } + + internalState.value = .loadingMore(after: internalState.value) + } + + let currentCursor = cursor + + currentRequest = currentCursor.loadNextBatch() + .subscribeOn(internalScheduler) + .subscribe(onNext: { [weak self] newItems in + self?.onGot(newItems: newItems, using: currentCursor) + }, onError: { [weak self] error in + self?.onGot(error: error) + }) + } + + 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 + } + } + + private func onGot(error: Error) { + 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/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() { 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/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() 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/Extensions/PaginationTableViewWrapperDelegate/PaginationTableViewWrapperDelegate+DefaultImplementation.swift b/LeadKit/LeadKit/Extensions/PaginationTableViewWrapperDelegate/PaginationTableViewWrapperDelegate+DefaultImplementation.swift new file mode 100644 index 00000000..7dd721ad --- /dev/null +++ b/LeadKit/LeadKit/Extensions/PaginationTableViewWrapperDelegate/PaginationTableViewWrapperDelegate+DefaultImplementation.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 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) + } + + 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/Extensions/Support/UIScrollView+Support.swift b/LeadKit/LeadKit/Extensions/Support/UIScrollView+Support.swift new file mode 100644 index 00000000..aac2cdc3 --- /dev/null +++ b/LeadKit/LeadKit/Extensions/Support/UIScrollView+Support.swift @@ -0,0 +1,48 @@ +// +// 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 newControl = newRefreshControl { + refreshControl?.removeFromSuperview() + base.addSubview(newControl) + } else { + refreshControl?.removeFromSuperview() + } + } + } + +} 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/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 86% rename from LeadKit/LeadKit/Extensions/UIView/UIView+DefaultNibName.swift rename to LeadKit/LeadKit/Extensions/UIView/UIView+DefaultXibName.swift index 11a7c61f..a63aac9b 100644 --- a/LeadKit/LeadKit/Extensions/UIView/UIView+DefaultNibName.swift +++ b/LeadKit/LeadKit/Extensions/UIView/UIView+DefaultXibName.swift @@ -22,14 +22,9 @@ 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..9b763267 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 @@ -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 diff --git a/LeadKit/LeadKit/Extensions/UIView/UIView+LoadingIndicator.swift b/LeadKit/LeadKit/Extensions/UIView/UIView+LoadingIndicator.swift new file mode 100644 index 00000000..6c0a33ac --- /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 UIKit + +extension LoadingIndicator where Self: UIView { + + public var view: Self { + return self + } + +} 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 92% rename from LeadKit/LeadKit/Extensions/UIViewController/UIViewController+XibName.swift rename to LeadKit/LeadKit/Extensions/UIViewController/UIViewController+DefaultXibName.swift index 4a41f581..8f000930 100644 --- a/LeadKit/LeadKit/Extensions/UIViewController/UIViewController+XibName.swift +++ b/LeadKit/LeadKit/Extensions/UIViewController/UIViewController+DefaultXibName.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/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 { }) } } - + } 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/LoadingIndicatorProtocol.swift b/LeadKit/LeadKit/Protocols/LoadingIndicatorProtocol.swift new file mode 100644 index 00000000..c88b835c --- /dev/null +++ b/LeadKit/LeadKit/Protocols/LoadingIndicatorProtocol.swift @@ -0,0 +1,44 @@ +// +// 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 + +/// 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 new file mode 100644 index 00000000..c100df98 --- /dev/null +++ b/LeadKit/LeadKit/Protocols/ResettableType.swift @@ -0,0 +1,44 @@ +// +// 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 + +/// 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) + } + +} 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/LeadKit/Protocols/StaticNibNameProtocol.swift b/LeadKit/LeadKit/Protocols/XibNameProtocol.swift similarity index 84% rename from LeadKit/LeadKit/Protocols/StaticNibNameProtocol.swift rename to LeadKit/LeadKit/Protocols/XibNameProtocol.swift index 40140d39..4926f678 100644 --- a/LeadKit/LeadKit/Protocols/StaticNibNameProtocol.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 - */ -public protocol StaticNibNameProtocol { - /** - - returns: nib name string - */ - static var nibName: String { get } +/// Protocol that ensures that specific type can return it's xib name +public protocol XibNameProtocol { + + /// Name of related xib + static var xibName: String { get } + } diff --git a/LeadKit/LeadKit/Structures/Views/AnyLoadingIndicator.swift b/LeadKit/LeadKit/Structures/Views/AnyLoadingIndicator.swift new file mode 100644 index 00000000..067892f9 --- /dev/null +++ b/LeadKit/LeadKit/Structures/Views/AnyLoadingIndicator.swift @@ -0,0 +1,52 @@ +// +// 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 + +/// 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 + } + + public func startAnimating() { + animatableView.startAnimating() + } + + public func stopAnimating() { + animatableView.stopAnimating() + } + +} 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 diff --git a/LeadKit/LeadKitTests/CursorTests.swift b/LeadKit/LeadKitTests/CursorTests.swift new file mode 100644 index 00000000..e03519b2 --- /dev/null +++ b/LeadKit/LeadKitTests/CursorTests.swift @@ -0,0 +1,102 @@ +// +// 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 CursorTests: XCTestCase { + + let disposeBag = DisposeBag() + + override func setUp() { + super.setUp() + } + + override func tearDown() { + super.tearDown() + } + + func testStubCursor() { + let entityCursor = StubCursor() + + let cursorExpectation = expectation(description: "Stub cursor expectation") + + entityCursor.loadNextBatch() + .subscribe(onNext: { _ in + cursorExpectation.fulfill() + }) + .addDisposableTo(disposeBag) + + waitForExpectations(timeout: 10, handler: nil) + } + + func testFixedPageCursor() { + let stubCursor = StubCursor(maxItemsCount: 15, requestDelay: .milliseconds(100)) + let fixedPageCursor = FixedPageCursor(cursor: stubCursor, pageSize: 16) + + let cursorExpectation = expectation(description: "Fixed page cursor expectation") + let cursorExpectationError = expectation(description: "Fixed page cursor error expectation") + + fixedPageCursor.loadNextBatch() + .subscribe(onNext: { loadedItems in + XCTAssertEqual(loadedItems.count, 15) + + cursorExpectation.fulfill() + }) + .addDisposableTo(disposeBag) + + fixedPageCursor.loadNextBatch() + .subscribe(onNext: { _ in + XCTFail("Cursor should be exhausted!") + }, onError: { error in + switch try? cast(error) as CursorError { + case .exhausted?: + cursorExpectationError.fulfill() + default: + XCTFail("Cursor should be exhausted!") + } + }) + .addDisposableTo(disposeBag) + + waitForExpectations(timeout: 10, handler: nil) + } + + func testMapCursorWithFixedPageCursor() { + let stubCursor = StubCursor(maxItemsCount: 16, requestDelay: .milliseconds(100)) + let mapCursor = stubCursor.flatMap { $0.userId > 1 ? nil : $0 } + let fixedPageCursor = FixedPageCursor(cursor: mapCursor, pageSize: 20) + + let cursorExpectation = expectation(description: "Fixed page cursor expectation") + + fixedPageCursor.loadNextBatch() + .subscribe(onNext: { loadedItems in + XCTAssertEqual(loadedItems.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..3a8241ca --- /dev/null +++ b/LeadKit/LeadKitTests/Cursors/StubCursor.swift @@ -0,0 +1,81 @@ +// +// 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: ResettableCursorType { + + 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 + } + + required init(initialFrom other: StubCursor) { + self.maxItemsCount = other.maxItemsCount + self.requestDelay = other.requestDelay + } + + func loadNextBatch() -> Observable<[Post]> { + 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: "")] + } + +} diff --git a/LeadKit/LeadKitTests/PaginationViewModelTests.swift b/LeadKit/LeadKitTests/PaginationViewModelTests.swift new file mode 100644 index 00000000..23949ef9 --- /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) + } + +} 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