diff --git a/LeadKit.podspec b/LeadKit.podspec index 63b59d06..389b855d 100644 --- a/LeadKit.podspec +++ b/LeadKit.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |s| s.name = "LeadKit" - s.version = "0.5.4" + s.version = "0.5.5" 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" diff --git a/Sources/Classes/Pagination/PaginationTableViewWrapper.swift b/Sources/Classes/Pagination/PaginationTableViewWrapper.swift index a879914a..81f04a0a 100644 --- a/Sources/Classes/Pagination/PaginationTableViewWrapper.swift +++ b/Sources/Classes/Pagination/PaginationTableViewWrapper.swift @@ -142,6 +142,11 @@ where Delegate.Cursor == Cursor { paginationViewModel.load(.reload) } + /// Method acts like reload, but shows initial loading view after being invoked. + public func retry() { + paginationViewModel.load(.retry) + } + /// Method that enables placeholders animation due pull-to-refresh interaction. /// /// - Parameter scrollObservable: Observable that emits content offset as CGPoint. @@ -204,7 +209,9 @@ where Delegate.Cursor == Cursor { tableView.support.refreshControl?.endRefreshing() - addInfiniteScroll() + if !cursor.exhausted { + addInfiniteScroll() + } } else if case .loadingMore = afterState { delegate?.paginationWrapper(wrapper: self, didLoad: newItems, usingCursor: cursor) @@ -214,15 +221,15 @@ where Delegate.Cursor == Cursor { private func onErrorState(error: Error, afterState: PaginationViewModel.State) { if case .loading = afterState { - enterPlaceholderState() + defer { + tableView.support.refreshControl?.endRefreshing() + } guard let errorView = delegate?.errorPlaceholder(forPaginationWrapper: self, forError: error) else { return } - preparePlaceholderView(errorView) - - currentPlaceholderView = errorView + replacePlaceholderViewIfNeeded(with: errorView) } else if case .loadingMore = afterState { removeInfiniteScroll() @@ -244,15 +251,42 @@ where Delegate.Cursor == Cursor { } private func onEmptyState() { - enterPlaceholderState() - + defer { + tableView.support.refreshControl?.endRefreshing() + } guard let emptyView = delegate?.emptyPlaceholder(forPaginationWrapper: self) else { return } + replacePlaceholderViewIfNeeded(with: emptyView) + } - preparePlaceholderView(emptyView) + private func replacePlaceholderViewIfNeeded(with placeholderView: UIView) { + // don't update placeholder view if previous placeholder is the same one + if currentPlaceholderView === placeholderView { + return + } + tableView.isUserInteractionEnabled = true + removeCurrentPlaceholderView() - currentPlaceholderView = emptyView + 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 + + currentPlaceholderView = placeholderView } // MARK: - private stuff @@ -321,33 +355,6 @@ where Delegate.Cursor == Cursor { .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 } diff --git a/Sources/Classes/Pagination/PaginationViewModel.swift b/Sources/Classes/Pagination/PaginationViewModel.swift index 59d3fa03..75407802 100644 --- a/Sources/Classes/Pagination/PaginationViewModel.swift +++ b/Sources/Classes/Pagination/PaginationViewModel.swift @@ -61,9 +61,11 @@ public final class PaginationViewModel { /// /// - reload: reload all items and reset cursor to initial state. /// - next: load next batch of items. + /// - retry: reload to initial loading state public enum LoadType { case reload + case retry case next } @@ -94,10 +96,9 @@ public final class PaginationViewModel { public func load(_ loadType: LoadType) { switch loadType { case .reload: - currentRequest?.dispose() - cursor = cursor.reset() - - internalState.value = .loading(after: internalState.value) + reload() + case .retry: + reload(isRetry: true) case .next: if case .exhausted(_) = internalState.value { fatalError("You shouldn't call load(.next) after got .exhausted state!") @@ -118,6 +119,11 @@ public final class PaginationViewModel { } private func onGot(newItems: [C.Element], using cursor: C) { + if newItems.isEmpty { + internalState.value = .empty + return + } + internalState.value = .results(newItems: newItems, inCursor: cursor, after: internalState.value) if cursor.exhausted { @@ -138,4 +144,14 @@ public final class PaginationViewModel { } } + private func reload(isRetry: Bool = false) { + currentRequest?.dispose() + cursor = cursor.reset() + + if isRetry { + internalState.value = .initial + } + internalState.value = .loading(after: internalState.value) + } + } diff --git a/Sources/Protocols/LoadingIndicator.swift b/Sources/Protocols/LoadingIndicator.swift index d1735dc2..24cc2af5 100644 --- a/Sources/Protocols/LoadingIndicator.swift +++ b/Sources/Protocols/LoadingIndicator.swift @@ -22,6 +22,18 @@ import UIKit +/// Protocol that describes placeholder view, containing loading indicator. +public protocol LoadingIndicatorHolder: class { + var loadingIndicator: Animatable { get } + var indicatorOwner: UIView { get } +} + +public extension LoadingIndicatorHolder where Self: UIView { + public var indicatorOwner: UIView { + return self + } +} + /// Protocol that describes badic loading indicator. public protocol LoadingIndicator { diff --git a/Sources/Structures/Views/AnyLoadingIndicator.swift b/Sources/Structures/Views/AnyLoadingIndicator.swift index 3e5fbbcb..fd2b4a63 100644 --- a/Sources/Structures/Views/AnyLoadingIndicator.swift +++ b/Sources/Structures/Views/AnyLoadingIndicator.swift @@ -25,20 +25,28 @@ import UIKit /// Type that performs some kind of type erasure for LoadingIndicator. public struct AnyLoadingIndicator: Animatable { - private let internalView: UIView + private let backgroundView: 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.backgroundView = base.view self.animatableView = base.view } - /// The indicator view. + /// Initializer with placeholder view, that wraps indicator. + /// + /// - Parameter loadingIndicatorHolder: placeholder view, containing indicator. + public init(loadingIndicatorHolder: LoadingIndicatorHolder) { + self.backgroundView = loadingIndicatorHolder.indicatorOwner + self.animatableView = loadingIndicatorHolder.loadingIndicator + } + + /// The background view. var view: UIView { - return internalView + return backgroundView } public func startAnimating() { diff --git a/Tests/CursorTests.swift b/Tests/CursorTests.swift index fdb6e08c..cba3e0bd 100644 --- a/Tests/CursorTests.swift +++ b/Tests/CursorTests.swift @@ -94,9 +94,9 @@ class CursorTests: XCTestCase { XCTAssertEqual(loadedItems.count, 40) cursorExpectation.fulfill() - }) { error in + }, onError: { error in XCTFail(error.localizedDescription) - } + }) .addDisposableTo(disposeBag) waitForExpectations(timeout: 10, handler: nil)