diff --git a/LeadKit/LeadKit/Classes/Pagination/PaginationTableViewWrapper.swift b/LeadKit/LeadKit/Classes/Pagination/PaginationTableViewWrapper.swift index a070dbeb..18986cc6 100644 --- a/LeadKit/LeadKit/Classes/Pagination/PaginationTableViewWrapper.swift +++ b/LeadKit/LeadKit/Classes/Pagination/PaginationTableViewWrapper.swift @@ -24,57 +24,81 @@ import UIKit import RxSwift import UIScrollView_InfiniteScroll +/// PaginationTableViewWrapper delegate used for pagination results handling and +/// customization of bound states (loading, empty, error, etc.). public protocol PaginationTableViewWrapperDelegate: class { associatedtype Cursor: ResettableCursorType + /// Delegate method that handles loading new chunk of data. + /// + /// - Parameters: + /// - wrapper: Wrapper object that loaded new items. + /// - newItems: New items. func paginationWrapper(wrapper: PaginationTableViewWrapper, didLoad newItems: [Cursor.Element]) + /// Delegate method that handles reloading or initial loading of data. + /// + /// - Parameters: + /// - wrapper: Wrapper object that reload items. + /// - allItems: New items. func paginationWrapper(wrapper: PaginationTableViewWrapper, didReload allItems: [Cursor.Element]) + /// Delegate method that returns placeholder view for empty state. + /// + /// - Parameter wrapper: Wrapper object that requests empty placeholder view. + /// - Returns: Configured instace of UIView. func emptyPlaceholder(forPaginationWrapper wrapper: PaginationTableViewWrapper) -> UIView + /// Delegate method that returns placeholder view for error state. + /// + /// - Parameters: + /// - wrapper: Wrapper object that requests error placeholder view. + /// - error: Error that occured due data loading. + /// - Returns: Configured instace of UIView. func errorPlaceholder(forPaginationWrapper wrapper: PaginationTableViewWrapper, forError error: Error) -> UIView + /// Delegate method that returns loading idicator for initial loading state. + /// This indicator will appear at center of the placeholders container. + /// + /// - Parameter wrapper: Wrapper object that requests loading indicator + /// - Returns: Configured instace of AnyLoadingIndicator. func initialLoadingIndicator(forPaginationWrapper wrapper: PaginationTableViewWrapper) -> AnyLoadingIndicator + /// Delegate method that returns loading idicator for initial loading state. + /// + /// - Parameter wrapper: Wrapper object that requests loading indicator. + /// - Returns: Configured instace of AnyLoadingIndicator. func loadingMoreIndicator(forPaginationWrapper wrapper: PaginationTableViewWrapper) -> AnyLoadingIndicator + /// Delegate method that returns instance of UIButton for "retry load more" action. + /// + /// - Parameter wrapper: Wrapper object that requests button for "retry load more" action. + /// - Returns: Configured instace of AnyLoadingIndicator. + func retryLoadMoreButton(forPaginationWrapper wrapper: PaginationTableViewWrapper) -> UIButton + + /// Delegate method that returns preferred height for "retry load more" button. + /// + /// - Parameter wrapper: Wrapper object that requests height "retry load more" button. + /// - Returns: Preferred height of "retry load more" button. + func retryLoadMoreButtonHeight(forPaginationWrapper wrapper: PaginationTableViewWrapper) -> CGFloat + } -public class PaginationTableViewWrapper +/// Class that connects PaginationViewModel with UITableView. It handles all non-visual and visual states. +final public class PaginationTableViewWrapper where D.Cursor == C { - public typealias PlaceholderTransform = (UIView, CGPoint) -> Void - private let tableView: UITableView private let placeholdersContainerView: UIView private let paginationViewModel: PaginationViewModel private weak var delegate: D? - public var placeholderTransformOnScroll: PlaceholderTransform = { view, offset in - var newFrame = view.frame - newFrame.origin.y = -offset.y - - view.frame = newFrame - } - - public var scrollObservable: Observable? { - didSet { - scrollObservable?.subscribe(onNext: { [weak self] offset in - guard let placeholder = self?.currentPlaceholderView else { - return - } - - self?.placeholderTransformOnScroll(placeholder, offset) - }) - .addDisposableTo(disposeBag) - } - } - + /// Sets the offset between the real end of the scroll view content and the scroll position, + /// so the handler can be triggered before reaching end. Defaults to 0.0; public var infiniteScrollTriggerOffset: CGFloat { get { return tableView.infiniteScrollTriggerOffset @@ -89,47 +113,52 @@ where D.Cursor == C { private var currentPlaceholderView: UIView? + private let applicationCurrentyActive = Variable(false) + + private var waitingOperations: [() -> Void] = [] + + /// Initializer with table view, placeholders container view, cusor and delegate parameters. + /// + /// - Parameters: + /// - tableView: UITableView instance to work with. + /// - placeholdersContainer: UIView container to be used for placeholders. + /// - cursor: Cursor object that acts as data source. + /// - delegate: Delegate object for data loading events handling and UI customization. public init(tableView: UITableView, placeholdersContainer: UIView, cursor: C, delegate: D) { self.tableView = tableView self.placeholdersContainerView = placeholdersContainer self.paginationViewModel = PaginationViewModel(cursor: cursor) self.delegate = delegate - paginationViewModel.state.drive(onNext: { [weak self] state in - print(state) - switch state { - case .initial: - self?.onInitialState() - case .loading(let after): - self?.onLoadingState(afterState: after) - case .loadingMore(let after): - self?.onLoadingMoreState(afterState: after) - case .results(let newItems, let after): - self?.onResultsState(newItems: newItems, afterState: after) - case .error(let error, let after): - self?.onErrorState(error: error, afterState: after) - case .empty: - self?.onEmptyState() - case .exhausted: - self?.onExhaustedState() - } - }) - .addDisposableTo(disposeBag) + bindViewModelStates() - let refreshControl = UIRefreshControl() - refreshControl.rx.controlEvent(.valueChanged) - .bindNext { [weak self] in - self?.reload() - } - .addDisposableTo(disposeBag) + createRefreshControl() - tableView.support.setRefreshControl(refreshControl) + bindAppStateNotifications() } + /// Method that reload all data in internal view model. public func reload() { paginationViewModel.load(.reload) } + /// Method that enables placeholders animation due pull-to-refresh interaction. + /// + /// - Parameter scrollObservable: Observable that emits content offset as CGPoint. + public func setScrollObservable(_ scrollObservable: Observable) { + scrollObservable.subscribe(onNext: { [weak self] offset in + guard let placeholder = self?.currentPlaceholderView else { + return + } + + var newFrame = placeholder.frame + newFrame.origin.y = -offset.y + + placeholder.frame = newFrame + }) + .addDisposableTo(disposeBag) + } + // MARK: States handling private func onInitialState() { @@ -165,7 +194,7 @@ where D.Cursor == C { } private func onLoadingMoreState(afterState: PaginationViewModel.State) { - if case .error = afterState { + if case .error = afterState { // user tap retry button in table footer tableView.tableFooterView = nil addInfiniteScroll() tableView.beginInfiniteScroll(true) @@ -206,10 +235,13 @@ where D.Cursor == C { tableView.removeInfiniteScroll() - let retryButton = UIButton(type: .custom) - retryButton.backgroundColor = .lightGray - retryButton.frame = CGRect(x: 0, y: 0, width: tableView.bounds.width, height: 44) - retryButton.setTitle("Retry load more", for: .normal) + guard let retryButton = delegate?.retryLoadMoreButton(forPaginationWrapper: self), + let retryButtonHeigth = delegate?.retryLoadMoreButtonHeight(forPaginationWrapper: self) else { + return + } + + retryButton.frame = CGRect(x: 0, y: 0, width: tableView.bounds.width, height: retryButtonHeigth) + retryButton.rx.controlEvent(.touchUpInside) .bindNext { [weak self] in self?.paginationViewModel.load(.next) @@ -246,6 +278,51 @@ where D.Cursor == C { tableView.infiniteScrollIndicatorView = delegate?.loadingMoreIndicator(forPaginationWrapper: self).view } + private func createRefreshControl() { + let refreshControl = UIRefreshControl() + refreshControl.rx.controlEvent(.valueChanged) + .bindNext { [weak self] in + self?.reload() + } + .addDisposableTo(disposeBag) + + tableView.support.setRefreshControl(refreshControl) + } + + private func bindViewModelStates() { + paginationViewModel.state.drive(onNext: { [weak self] state in + let stateHandling = { [weak self] in + switch state { + case .initial: + self?.onInitialState() + case .loading(let after): + self?.onLoadingState(afterState: after) + case .loadingMore(let after): + self?.onLoadingMoreState(afterState: after) + case .results(let newItems, let after): + self?.onResultsState(newItems: newItems, afterState: after) + case .error(let error, let after): + self?.onErrorState(error: error, afterState: after) + case .empty: + self?.onEmptyState() + case .exhausted: + self?.onExhaustedState() + } + } + + guard let strongSelf = self else { + return + } + + if strongSelf.applicationCurrentyActive.value { + stateHandling() + } else { + strongSelf.waitingOperations.append(stateHandling) + } + }) + .addDisposableTo(disposeBag) + } + private func enterPlaceholderState() { tableView.support.refreshControl?.endRefreshing() tableView.isUserInteractionEnabled = true @@ -260,6 +337,32 @@ where D.Cursor == C { placeholderView.anchorConstrainst(to: placeholdersContainerView).forEach { $0.isActive = true } } + + private func bindAppStateNotifications() { + let notificationCenter = NotificationCenter.default.rx + + notificationCenter.notification(.UIApplicationWillResignActive) + .subscribe(onNext: { [weak self] _ in + self?.applicationCurrentyActive.value = false + }) + .addDisposableTo(disposeBag) + + notificationCenter.notification(.UIApplicationDidBecomeActive) + .subscribe(onNext: { [weak self] _ in + self?.applicationCurrentyActive.value = true + }) + .addDisposableTo(disposeBag) + + applicationCurrentyActive.asDriver() + .drive(onNext: { [weak self] appActive in + if appActive { + self?.waitingOperations.forEach { $0() } + self?.waitingOperations = [] + } + }) + .addDisposableTo(disposeBag) + } + } private extension UIView { diff --git a/LeadKit/LeadKit/Classes/Pagination/PaginationViewModel.swift b/LeadKit/LeadKit/Classes/Pagination/PaginationViewModel.swift index ca1a251e..741428a1 100644 --- a/LeadKit/LeadKit/Classes/Pagination/PaginationViewModel.swift +++ b/LeadKit/LeadKit/Classes/Pagination/PaginationViewModel.swift @@ -23,22 +23,44 @@ import RxSwift import RxCocoa +/// Cursor type which can be resetted public typealias ResettableCursorType = CursorType & ResettableType +/// Class that encapsulate all pagination logic public final class PaginationViewModel { + /// Enum contains all possible states for PaginationViewModel class. + /// + /// - initial: initial state of view model. + /// Can occur only once after initial binding. + /// - loading: loading state of view model. Contains previous state of view model. + /// Can occur after any state. + /// - loadingMore: loading more items state of view model. Contains previous state of view model. + /// Can occur after error or results state. + /// - results: results state of view model. Contains loaded items and previous state of view model. + /// Can occur after loading or loadingMore state. + /// - error: error state of view model. Contains received error and previous state of view model. + /// Can occur after loading or loadingMore state. + /// - empty: empty state of view model. + /// Can occur after loading or loadingMore state when we got empty result (zero items). + /// - exhausted: exhausted state of view model. + /// Can occur after results state or after initial->loading state when cursor reports that it's exhausted. public indirect enum State { case initial - case loading(after: State) // can be after any state - case loadingMore(after: State) // can be after error or results - case results(newItems: [C.Element], after: State) // can be after loading or loadingMore - case error(error: Error, after: State) // can be after loading or loadingMore - case empty // can be after loading or loadingMore - case exhausted // can be after results + case loading(after: State) + case loadingMore(after: State) + case results(newItems: [C.Element], after: State) + case error(error: Error, after: State) + case empty + case exhausted } + /// Enum represents possible load types for PaginationViewModel class + /// + /// - reload: reload all items and reset cursor to initial state. + /// - next: load next batch of items. public enum LoadType { case reload @@ -54,14 +76,21 @@ public final class PaginationViewModel { private let internalScheduler = SerialDispatchQueueScheduler(qos: .default) + /// Current PaginationViewModel state Driver public var state: Driver { return internalState.asDriver() } + /// Initializer with enclosed cursor + /// + /// - Parameter cursor: cursor to use for pagination public init(cursor: C) { self.cursor = cursor } + /// Mathod which triggers loading of items. + /// + /// - Parameter loadType: type of loading. See LoadType enum. public func load(_ loadType: LoadType) { switch loadType { case .reload: @@ -71,7 +100,7 @@ public final class PaginationViewModel { internalState.value = .loading(after: internalState.value) case .next: if case .exhausted(_) = internalState.value { - preconditionFailure("You shouldn't call load(.next) after got .exhausted state!") + fatalError("You shouldn't call load(.next) after got .exhausted state!") } internalState.value = .loadingMore(after: internalState.value) diff --git a/LeadKit/LeadKit/Extensions/PaginationTableViewWrapperDelegate/PaginationTableViewWrapperDelegate+DefaultImplementation.swift b/LeadKit/LeadKit/Extensions/PaginationTableViewWrapperDelegate/PaginationTableViewWrapperDelegate+DefaultImplementation.swift index 1f762288..7dd721ad 100644 --- a/LeadKit/LeadKit/Extensions/PaginationTableViewWrapperDelegate/PaginationTableViewWrapperDelegate+DefaultImplementation.swift +++ b/LeadKit/LeadKit/Extensions/PaginationTableViewWrapperDelegate/PaginationTableViewWrapperDelegate+DefaultImplementation.swift @@ -71,4 +71,16 @@ public extension PaginationTableViewWrapperDelegate { return AnyLoadingIndicator(indicator) } + func retryLoadMoreButton(forPaginationWrapper wrapper: PaginationTableViewWrapper) -> UIButton { + let retryButton = UIButton(type: .custom) + retryButton.backgroundColor = .lightGray + retryButton.setTitle("Retry load more", for: .normal) + + return retryButton + } + + func retryLoadMoreButtonHeight(forPaginationWrapper wrapper: PaginationTableViewWrapper) -> CGFloat { + return 44 + } + } diff --git a/LeadKit/LeadKit/Protocols/LoadingIndicatorProtocol.swift b/LeadKit/LeadKit/Protocols/LoadingIndicatorProtocol.swift index ccfb3d85..c88b835c 100644 --- a/LeadKit/LeadKit/Protocols/LoadingIndicatorProtocol.swift +++ b/LeadKit/LeadKit/Protocols/LoadingIndicatorProtocol.swift @@ -22,17 +22,23 @@ import UIKit +/// Protocol that ensures that specific type support basic animation actions. public protocol Animatable { + /// Method that starts animation. func startAnimating() + /// Method that stops animation. func stopAnimating() } +/// Protocol that describes badic loading indicator. public protocol LoadingIndicator { + /// Type of view. Should be instance of UIView with basic animation actions. associatedtype View: UIView, Animatable + /// The underlying view. var view: View { get } } diff --git a/LeadKit/LeadKit/Protocols/ResettableType.swift b/LeadKit/LeadKit/Protocols/ResettableType.swift index fefe1b2b..c100df98 100644 --- a/LeadKit/LeadKit/Protocols/ResettableType.swift +++ b/LeadKit/LeadKit/Protocols/ResettableType.swift @@ -22,14 +22,21 @@ import Foundation +/// Protocol that ensures that specific type can init new resetted instance from another instance. public protocol ResettableType { + /// Initializer with other instance parameter. + /// + /// - Parameter other: Other instance of specific type. init(initialFrom other: Self) } public extension ResettableType { + /// Method that creates new resseted instance of self + /// + /// - Returns: resseted instance of self func reset() -> Self { return Self(initialFrom: self) }