From 49e6172edfddf1433ebf89f4a2292a07341928c5 Mon Sep 17 00:00:00 2001 From: Vlad Suhomlinov <> Date: Thu, 17 Jun 2021 18:04:17 +0300 Subject: [PATCH] feat: add realisation of paginating items from a data source --- Package.resolved | 9 + Package.swift | 30 ++- README.md | 3 +- TIPagination/README.md | 203 ++++++++++++++++ .../Protocols/PaginatorCursorType.swift | 25 ++ .../Default/DefaultPaginatorUIDelegate.swift | 219 ++++++++++++++++++ TIPagination/Sources/Paginator.swift | 205 ++++++++++++++++ .../Sources/PaginatorDataLoadingModel.swift | 127 ++++++++++ TIPagination/Sources/PaginatorState.swift | 47 ++++ .../Protocols/InfiniteScrollDelegate.swift | 32 +++ .../Sources/Protocols/PaginatorDelegate.swift | 42 ++++ .../Protocols/PaginatorUIDelegate.swift | 39 ++++ TIPagination/TIPagination.podspec | 18 ++ 13 files changed, 991 insertions(+), 8 deletions(-) create mode 100644 TIPagination/README.md create mode 100644 TIPagination/Sources/Cursors/Protocols/PaginatorCursorType.swift create mode 100644 TIPagination/Sources/Default/DefaultPaginatorUIDelegate.swift create mode 100644 TIPagination/Sources/Paginator.swift create mode 100644 TIPagination/Sources/PaginatorDataLoadingModel.swift create mode 100644 TIPagination/Sources/PaginatorState.swift create mode 100644 TIPagination/Sources/Protocols/InfiniteScrollDelegate.swift create mode 100644 TIPagination/Sources/Protocols/PaginatorDelegate.swift create mode 100644 TIPagination/Sources/Protocols/PaginatorUIDelegate.swift create mode 100644 TIPagination/TIPagination.podspec diff --git a/Package.resolved b/Package.resolved index 65ac8579..3dfc4c9c 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,6 +1,15 @@ { "object": { "pins": [ + { + "package": "Cursors", + "repositoryURL": "https://github.com/petropavel13/Cursors", + "state": { + "branch": null, + "revision": "a1561869135e72832eff3b1e729075c56c2eebf6", + "version": "0.5.1" + } + }, { "package": "KeychainAccess", "repositoryURL": "https://github.com/kishikawakatsumi/KeychainAccess.git", diff --git a/Package.swift b/Package.swift index ef8ed654..cc2dca77 100644 --- a/Package.swift +++ b/Package.swift @@ -7,27 +7,43 @@ let package = Package( .iOS(.v11) ], products: [ - .library(name: "TITransitions", targets: ["TITransitions"]), + + // MARK: - UIKit .library(name: "TIUIKitCore", targets: ["TIUIKitCore"]), + .library(name: "TIUIElements", targets: ["TIUIElements"]), + + // MARK: - Utils .library(name: "TISwiftUtils", targets: ["TISwiftUtils"]), .library(name: "TIFoundationUtils", targets: ["TIFoundationUtils"]), .library(name: "TIKeychainUtils", targets: ["TIKeychainUtils"]), - .library(name: "TIUIElements", targets: ["TIUIElements"]), .library(name: "TITableKitUtils", targets: ["TITableKitUtils"]), - .library(name: "OTPSwiftView", targets: ["OTPSwiftView"]) + + // MARK: - Elements + .library(name: "OTPSwiftView", targets: ["OTPSwiftView"]), + .library(name: "TITransitions", targets: ["TITransitions"]), + .library(name: "TIPagination", targets: ["TIPagination"]), ], dependencies: [ .package(url: "https://github.com/maxsokolov/TableKit.git", from: "2.11.0"), - .package(url: "https://github.com/kishikawakatsumi/KeychainAccess.git", from: "4.2.2") + .package(url: "https://github.com/kishikawakatsumi/KeychainAccess.git", from: "4.2.2"), + .package(url: "https://github.com/petropavel13/Cursors", from: "0.5.1") ], targets: [ - .target(name: "TITransitions", path: "TITransitions/Sources"), + + // MARK: - UIKit .target(name: "TIUIKitCore", path: "TIUIKitCore/Sources"), + .target(name: "TIUIElements", dependencies: ["TIUIKitCore", "TISwiftUtils"], path: "TIUIElements/Sources"), + + // MARK: - Utils .target(name: "TISwiftUtils", path: "TISwiftUtils/Sources"), .target(name: "TIFoundationUtils", dependencies: ["TISwiftUtils"], path: "TIFoundationUtils/Sources"), .target(name: "TIKeychainUtils", dependencies: ["TIFoundationUtils", "KeychainAccess"], path: "TIKeychainUtils/Sources"), - .target(name: "TIUIElements", dependencies: ["TIUIKitCore", "TISwiftUtils"], path: "TIUIElements/Sources"), .target(name: "TITableKitUtils", dependencies: ["TIUIElements", "TableKit"], path: "TITableKitUtils/Sources"), - .target(name: "OTPSwiftView", dependencies: ["TIUIElements"], path: "OTPSwiftView/Sources") + + // MARK: - Elements + + .target(name: "OTPSwiftView", dependencies: ["TIUIElements"], path: "OTPSwiftView/Sources"), + .target(name: "TITransitions", path: "TITransitions/Sources"), + .target(name: "TIPagination", dependencies: ["Cursors", "TISwiftUtils"], path: "TIPagination/Sources"), ] ) diff --git a/README.md b/README.md index 1c080af5..fd45bb6e 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,8 @@ This repository contains the following additional frameworks: - [TITableKitUtils](TITableKitUtils) - Set of helpers for TableKit classes. - [TIFoundationUtils](TIFoundationUtils) - Set of helpers for Foundation framework classes. - [TIKeychainUtils](TIKeychainUtils) - Set of helpers for Keychain classes. - +- [TIPagination](TIPagination) - realisation of paginating items from a data source. + Useful docs: - [Semantic Commit Messages](docs/semantic-commit-messages.md) - commit message codestyle. - [Snippets](docs/snippets.md) - useful commands and scripts for development. diff --git a/TIPagination/README.md b/TIPagination/README.md new file mode 100644 index 00000000..58e155dc --- /dev/null +++ b/TIPagination/README.md @@ -0,0 +1,203 @@ +# TIPagination + +Компонент “Пагинация” предоставляет реализацию: +* Пагинации элементов из источника данных (API, DB, etc.) +* Обработки состояний в ходе получения данных (загрузка, ошибка, подгрузка, ошибка подгрузки, etc.) +* Делегирование отображения состояний внешнему коду + +## Пример реализации + +### Создание источника данных +Источником данных служит абстрактный `Cursor`. Он может как выполнять запросы к серверу на получение новых элементов, так и получать данные из локального хранилища. + +Например, существует API, который возвращает постраничный список банков, в которых у пользователя есть счёт. Также в каждом ответе указывается банк пользователя по умолчанию, в который должны приходить платежи. + +```swift +/// Модель одного банка +struct Bank: Codable { + let name: String + let primaryColor: UIColor +} + +/// Модель страницы банков, приходящая с сервера +struct BanksPage: PageType, Codable { + + let pagesRemaining: Int // Количество оставшихся страниц + + let pageItems: [Bank] + let defaultBank: Bank? // Банк по умолчанию. Может изменяться при получении новых страниц. + + init(items: [Bank], defaultBank: Bank?, pagesRemaining: Int) { + self.pageItems = items + self.defaultBank = defaultBank + self.pagesRemaining = pagesRemaining + } + + init(copy ancestor: Self, pageItems: [Bank]) { + self.pagesRemaining = ancestor.pagesRemaining + self.pageItems = pageItems + self.defaultBank = ancestor.defaultBank + } +} +``` + +После создания модели данных необходимо создать курсор, который будет отвечать за загрузку данных с сервера. Для этого удобно использовать Combine: + +```swift +import Combine + +... + +final class BankListCursor: PaginatorCursorType { + + enum BankListCursorError: CursorErrorType { + + case exhausted + case url + + public var isExhausted: Bool { + self == .exhausted + } + + public static var exhaustedError: BankListCursorError { + .exhausted + } + } + + typealias Page = BanksPage + typealias Failure = BankListCursorError + + // MARK: - Private Properties + + let urlSession = URLSession(configuration: .default) + + private var page: Int + private var requestCancellable: AnyCancellable? + + // MARK: - Public Initializers + + init() { + page = 1 + } + + init(withInitialStateFrom other: BankListCursor) { + page = 1 + } + + // MARK: - Public Methods + + func cancel() { + requestCancellable?.cancel() + } + + func loadNextPage(completion: @escaping ResultCompletion) { + + guard let publisher = publisherForPage(page) else { + completion(.failure(.url)) + return + } + + requestCancellable = publisher + .catch { _ in + Just(nil) + } + .sink { [weak self] result in + + guard result != nil else { + completion(.failure(.network)) + return + } + + self?.page += 1 + + completion(.success( (page: result, exhausted: result.pagesRemaining < 1) )) + } + } + + // MARK: - Private Methods + + private func publisherForPage(_ page: Int) -> AnyPublisher? { + + guard let url = URL(string: "https://some-bank-api.com/user_banks?page=\(page)") else { + return nil + } + + return urlSession.dataTaskPublisher(for: url) + .map { $0.data } + .decode(type: BanksPage.self, decoder: decoder) + .eraseToAnyPublisher() + } +} +``` + +### Поддержка делегата данных + +`PaginatorDelegate` представляет из себя протокол, который сигнализирует об изменении состоянии данных в источнике. Пример реализации с использованием TableKit и TableDirector: + +```swift +extension MyViewController: PaginatorDelegate { + + func paginator(didLoad newPage: MockCursor.Page) { + updateDefaultBank(with: newPage.defaultBank) // Обновление банка, установленного у пользователя по умолчанию + + let rows = newPage.pageItems.map { /* Create table cell rows */ } + tableDirector.append(section: .init(onlyRows: rows)).reload() + } + + func paginator(didReloadWith page: MockCursor.Page) { + updateDefaultBank(with: newPage.defaultBank) // Обновление банка, установленного у пользователя по умолчанию + + let rows = page.pageItems.map { /* Create table cell rows */ } + tableDirector.clear().append(section: .init(onlyRows: rows)).reload() + } + + func clearContent() { + tableDirector.clear().reload() + } +} +``` + +### Поддержка UI-делегатов + +`PaginatorUIDelegate` используется для управления UI. Он содержит набор методов, которые вызываются после перехода модели данных в то или иное состояние. В них можно показать ActivityIndicator, плейсхолдер для ошибки или для пустого состояния. Большинство работы берет на себя стандартная реализация этого протокола – `DefaultPaginatorUIDelegate`. Работать с ней очень просто: + +```swift +... +private lazy var paginatorUiDelegate = DefaultPaginatorUIDelegate(tableView) +... +``` + +Вторым UI-делегатом является `InfiniteScrollDelegate`, который необходим для поддержания совместимости с фреймворком **UIScrollView_InfiniteScroll**. Делегат обязан выполнять проксирование методов в UIScrollView, который используется для пагинации. В качестве делегата можно также использовать UITableView из коробки: + +```swift +import UIScrollView_InfiniteScroll + +... + +extension UITableView: InfiniteScrollDelegate {} +``` + +### Создание Paginator + +После того, как источник данных и все делегаты установлены, можно приступать к созданию объекта `Paginator`. Этот объект является ответственным за управление состоянием пагинации извне (загрузка, перезагрузка, повтор загрузки данных после ошибок). Для его создания потребуются все ранее определенные составляющие: + +```swift +... +private lazy var paginator = Paginator(cursor: mockCursor, + delegate: self, + infiniteScrollDelegate: tableView, + uiDelegate: paginatorUiDelegate) +... + +override func viewDidLoad() { + super.viewDidLoad() + + // Callback у DefaultPaginatorUIDelegate, срабатывает при нажатии на кнопку "Retry Loading" + paginatorUiDelegate.onRetry = { [weak self] in + self?.paginator.retry() + } + + paginator.reload() // Первоначальная загрузка данных +} + +``` diff --git a/TIPagination/Sources/Cursors/Protocols/PaginatorCursorType.swift b/TIPagination/Sources/Cursors/Protocols/PaginatorCursorType.swift new file mode 100644 index 00000000..53793688 --- /dev/null +++ b/TIPagination/Sources/Cursors/Protocols/PaginatorCursorType.swift @@ -0,0 +1,25 @@ +// +// Copyright (c) 2020 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 Cursors + +public protocol PaginatorCursorType: CancelableCursorType & ResettableType {} diff --git a/TIPagination/Sources/Default/DefaultPaginatorUIDelegate.swift b/TIPagination/Sources/Default/DefaultPaginatorUIDelegate.swift new file mode 100644 index 00000000..714e6b89 --- /dev/null +++ b/TIPagination/Sources/Default/DefaultPaginatorUIDelegate.swift @@ -0,0 +1,219 @@ +// +// Copyright (c) 2020 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 +import TISwiftUtils + +open class DefaultPaginatorUIDelegate: PaginatorUIDelegate { + + public typealias ViewSetter = ParameterClosure + + // MARK: - Private Properties + + private let scrollView: UIScrollView + private let backgroundViewSetter: ViewSetter + private let footerViewSetter: ViewSetter + + private var currentPlaceholderView: UIView? + + // MARK: - Public Properties + + /// Called when default retry button is pressed + public var onRetry: VoidClosure? + + // MARK: - Public Initializers + + public convenience init(_ tableView: UITableView) { + self.init(scrollView: tableView, + backgroundViewSetter: { [weak tableView] in + tableView?.backgroundView = $0 + }, footerViewSetter: { [weak tableView] in + tableView?.tableFooterView = $0 + }) + } + + public convenience init(_ collectionView: UICollectionView) { + self.init(scrollView: collectionView, + backgroundViewSetter: { [weak collectionView] in + collectionView?.backgroundView = $0 + }, footerViewSetter: { _ in + // No footer in UICollectionView + }) + } + + public init(scrollView: UIScrollView, + backgroundViewSetter: @escaping ViewSetter, + footerViewSetter: @escaping ViewSetter) { + + self.scrollView = scrollView + self.backgroundViewSetter = backgroundViewSetter + self.footerViewSetter = footerViewSetter + } + + // MARK: - UI Setup + + open func footerRetryView() -> UIView? { + let retryButton = UIButton(type: .custom) + retryButton.backgroundColor = .lightGray + retryButton.setTitle("Retry load more", for: .normal) + + retryButton.addTarget(self, action: #selector(retryAction), for: .touchUpInside) + + return retryButton + } + + open func footerRetryViewHeight() -> CGFloat { + 44 + } + + open func emptyPlaceholder() -> UIView? { + let label = UILabel() + label.text = "Empty" + + return label + } + + open func errorPlaceholder(for error: Cursor.Failure) -> UIView? { + let label = UILabel() + label.text = error.localizedDescription + + return label + } + + // MARK: - PaginatorUIDelegate + + open func onInitialLoading() { + scrollView.isUserInteractionEnabled = false + removeAllPlaceholders() + + let loadingIndicatorView = UIActivityIndicatorView() + loadingIndicatorView.translatesAutoresizingMaskIntoConstraints = true + + backgroundViewSetter(loadingIndicatorView) + loadingIndicatorView.startAnimating() + + currentPlaceholderView = loadingIndicatorView + } + + open func onReloading() { + footerViewSetter(nil) + } + + open func onLoadingMore() { + footerViewSetter(nil) + } + + open func onLoadingError(_ error: Cursor.Failure) { + guard let errorView = errorPlaceholder(for: error) else { + return + } + + replacePlaceholderViewIfNeeded(with: errorView) + scrollView.refreshControl?.endRefreshing() + } + + open func onLoadingMoreError(_ error: Cursor.Failure) { + guard let retryView = footerRetryView() else { + return + } + + let retryViewHeight = footerRetryViewHeight() + + retryView.frame = CGRect(x: 0, + y: 0, + width: scrollView.bounds.width, + height: retryViewHeight) + + footerViewSetter(retryView) + + let contentOffsetWithRetryView = scrollView.contentOffset.y + retryViewHeight + let invisibleContentHeight = scrollView.contentSize.height - scrollView.frame.size.height + + let shouldUpdateContentOffset = contentOffsetWithRetryView >= invisibleContentHeight + + if shouldUpdateContentOffset { + scrollView.setContentOffset(CGPoint(x: 0, y: contentOffsetWithRetryView), + animated: true) + } + } + + open func onSuccessfulLoad() { + scrollView.isUserInteractionEnabled = true + removeAllPlaceholders() + scrollView.refreshControl?.endRefreshing() + } + + open func onEmptyState() { + guard let emptyView = emptyPlaceholder() else { + return + } + + replacePlaceholderViewIfNeeded(with: emptyView) + scrollView.refreshControl?.endRefreshing() + } + + open func onExhaustedState() { + removeAllPlaceholders() + } + + open func onAddInfiniteScroll() { + // empty + } + + // MARK: - Private Methods + + private func replacePlaceholderViewIfNeeded(with placeholderView: UIView) { + scrollView.isUserInteractionEnabled = true + removeAllPlaceholders() + + placeholderView.translatesAutoresizingMaskIntoConstraints = false + placeholderView.isHidden = false + + // I was unable to add pull-to-refresh placeholder scroll behaviour without this trick + let placeholderWrapperView = UIView() + placeholderWrapperView.addSubview(placeholderView) + + let leadingConstraint = placeholderView.leadingAnchor.constraint(equalTo: placeholderWrapperView.leadingAnchor) + let trailingConstraint = placeholderView.trailingAnchor.constraint(equalTo: placeholderWrapperView.trailingAnchor) + let topConstraint = placeholderView.topAnchor.constraint(equalTo: placeholderWrapperView.topAnchor) + let bottomConstraint = placeholderView.bottomAnchor.constraint(equalTo: placeholderWrapperView.bottomAnchor) + + NSLayoutConstraint.activate([ + leadingConstraint, + trailingConstraint, + topConstraint, + bottomConstraint + ]) + + backgroundViewSetter(placeholderWrapperView) + currentPlaceholderView = placeholderView + } + + private func removeAllPlaceholders() { + backgroundViewSetter(nil) + footerViewSetter(nil) + } + + @objc private func retryAction() { + onRetry?() + } +} diff --git a/TIPagination/Sources/Paginator.swift b/TIPagination/Sources/Paginator.swift new file mode 100644 index 00000000..1a27035b --- /dev/null +++ b/TIPagination/Sources/Paginator.swift @@ -0,0 +1,205 @@ +// +// Copyright (c) 2020 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 +import Cursors +import TISwiftUtils + +/// Class that connects PaginationDataLoadingModel with UIScrollView. It handles all non-visual and visual states. +final public class Paginator +where Cursor.Page == Delegate.Page, UIDelegate.ErrorType == Cursor.Failure { + + + private typealias State = PaginatorState + private typealias FinishInfiniteScrollCompletion = ParameterClosure + + private let dataLoadingModel: PaginatorDataLoadingModel + + private weak var delegate: Delegate? + private weak var infiniteScrollDelegate: InfiniteScrollDelegate? + private weak var uiDelegate: UIDelegate? + + /// Initializer with table cursor, data delegate and UI delegates. + /// + /// - Parameters: + /// - cursor: Cursor object that acts as data source. + /// - delegate: Delegate object for data loading events handling. + /// - infiniteScrollDelegate: Delegate object for handling infinite scroll. + /// - uiDelegate: Delegate object for UI customization. + public init(cursor: Cursor, + delegate: Delegate, + infiniteScrollDelegate: InfiniteScrollDelegate, + uiDelegate: UIDelegate?) { + + self.delegate = delegate + self.infiniteScrollDelegate = infiniteScrollDelegate + self.uiDelegate = uiDelegate + + self.dataLoadingModel = PaginatorDataLoadingModel(cursor: cursor) + + bindToDataLoadingModel() + } + + /// Method that reloads all data in internal view model. + public func reload() { + dataLoadingModel.reload() + } + + /// Retry loading depending on previous error state. + public func retry() { + + switch dataLoadingModel.state { + case .loadingError, .empty: + dataLoadingModel.reload() + case .loadingMoreError: + dataLoadingModel.loadMore() + default: + assertionFailure("Retry was used without any error.") + } + } + + /// Add Infinite scroll to footer of tableView + public func addInfiniteScroll(withHandler: Bool) { + if withHandler { + infiniteScrollDelegate?.addInfiniteScroll { [weak dataLoadingModel] _ in + dataLoadingModel?.loadMore() + } + } else { + infiniteScrollDelegate?.addInfiniteScroll { _ in } + } + + uiDelegate?.onAddInfiniteScroll() + } + + // MARK: - State handling + + private func onStateChanged(from old: State, to new: State) { + + switch new { + case .loading: + onLoadingState(afterState: old) + + case let .loadingError(error): + onErrorState(error: error, afterState: old) + + case .loadingMore: + onLoadingMoreState(afterState: old) + + case let .content(page): + onContentState(newPage: page, afterState: old) + + case let .loadingMoreError(error): + onErrorState(error: error, afterState: old) + + case .empty: + onEmptyState() + + case .exhausted: + onExhaustedState() + } + } + + private func onLoadingState(afterState: State) { + + if dataLoadingModel.isInitialState(afterState) { + uiDelegate?.onInitialLoading() + } else { + removeInfiniteScroll() + uiDelegate?.onReloading() + } + } + + private func onLoadingMoreState(afterState: State) { + uiDelegate?.onLoadingMore() + + if case .loadingMoreError = afterState { + addInfiniteScroll(withHandler: false) + infiniteScrollDelegate?.beginInfiniteScroll(true) + } + } + + private func onContentState(newPage: Cursor.Page, + afterState: State) { + + uiDelegate?.onSuccessfulLoad() + + switch afterState { + case .loading: + delegate?.paginator(didReloadWith: newPage) + addInfiniteScroll(withHandler: true) + + case .loadingMore: + delegate?.paginator(didLoad: newPage) + readdInfiniteScrollWithHandler() + + default: + assertionFailure("Content arrived without loading first.") + } + } + + private func onErrorState(error: Cursor.Failure, afterState: State) { + + switch afterState { + case .loading: + uiDelegate?.onLoadingError(error) + + case .loadingMore: + removeInfiniteScroll() + uiDelegate?.onLoadingMoreError(error) + + default: + assertionFailure("Error happened without loading.") + } + } + + private func onEmptyState() { + delegate?.clearContent() + uiDelegate?.onEmptyState() + } + + private func onExhaustedState() { + removeInfiniteScroll() + uiDelegate?.onExhaustedState() + } + + private func bindToDataLoadingModel() { + dataLoadingModel.onStateChanged = { [weak self] state in + DispatchQueue.main.async { + self?.onStateChanged(from: state.old, to: state.new) + } + } + } + + // MARK: - InfiniteScroll management + + private func readdInfiniteScrollWithHandler() { + removeInfiniteScroll() + addInfiniteScroll(withHandler: true) + } + + private func removeInfiniteScroll(with completion: FinishInfiniteScrollCompletion? = nil) { + infiniteScrollDelegate?.finishInfiniteScroll(completion: completion) + infiniteScrollDelegate?.removeInfiniteScroll() + } +} diff --git a/TIPagination/Sources/PaginatorDataLoadingModel.swift b/TIPagination/Sources/PaginatorDataLoadingModel.swift new file mode 100644 index 00000000..57f7fe39 --- /dev/null +++ b/TIPagination/Sources/PaginatorDataLoadingModel.swift @@ -0,0 +1,127 @@ +// +// Copyright (c) 2020 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 Cursors +import TISwiftUtils + +open class PaginatorDataLoadingModel { + + public typealias State = PaginatorState + + // MARK: - Public Properties + + private var cursor: Cursor + + private(set) var state: PaginatorState { + didSet { + onStateChanged?((old: oldValue, new: state)) + } + } + + /// Handler for observing state changes + var onStateChanged: ParameterClosure<(old: State, new: State)>? + + // MARK: - Public Initializers + + init(cursor: Cursor) { + self.cursor = cursor + state = .loading + } + + // MARK: - Public Methods + + /// First data loading or reloading + open func reload() { + state = .loading + cursor = cursor.reset() + commonLoad() + } + + /// Load more content + open func loadMore() { + state = .loadingMore + commonLoad() + } + + public func isInitialState(_ state: State) -> Bool { + if case .loading = state { + return true + } else { + return false + } + } + + // MARK: - Private Methods + + private func commonLoad() { + + cursor.cancel() + cursor.loadNextPage { [weak self] in + switch $0 { + case let .success(response): + self?.onSuccess(response) + case let .failure(error): + self?.onGot(error: error) + } + } + } + + open func onSuccess(_ response: Cursor.SuccessResult) { + + switch state { + case .loading where response.page.isEmptyPage: + state = .empty + default: + state = .content(response.page) + + if response.exhausted { + state = .exhausted + } + } + } + + open func onGot(error: Cursor.Failure) { + + guard !error.isExhausted else { + state = .exhausted + return + } + + switch state { + case .loading: + state = .loadingError(error) + case .loadingMore: + state = .loadingMoreError(error) + default: + assertionFailure("Error may occur only after loading.") + } + } +} + +// MARK: - PageType + isEmpty + +extension PageType { + + var isEmptyPage: Bool { + pageItems.isEmpty + } +} diff --git a/TIPagination/Sources/PaginatorState.swift b/TIPagination/Sources/PaginatorState.swift new file mode 100644 index 00000000..5bc148db --- /dev/null +++ b/TIPagination/Sources/PaginatorState.swift @@ -0,0 +1,47 @@ +// +// Copyright (c) 2020 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 Cursors + +public enum PaginatorState { + + /// Initial loading or reloading + case loading + + /// Loading or reloading error + case loadingError(Cursor.Failure) + + /// No content at all + case empty + + /// There will be no more content + case exhausted + + /// Next content batch + case content(Cursor.Page) + + /// Loading more content + case loadingMore + + /// Error while loading more content + case loadingMoreError(Cursor.Failure) +} diff --git a/TIPagination/Sources/Protocols/InfiniteScrollDelegate.swift b/TIPagination/Sources/Protocols/InfiniteScrollDelegate.swift new file mode 100644 index 00000000..07fb6838 --- /dev/null +++ b/TIPagination/Sources/Protocols/InfiniteScrollDelegate.swift @@ -0,0 +1,32 @@ +// +// Copyright (c) 2020 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 +import TISwiftUtils + +public protocol InfiniteScrollDelegate: class { + + func beginInfiniteScroll(_ forceScroll: Bool) + func addInfiniteScroll(handler: @escaping ParameterClosure) + func removeInfiniteScroll() + func finishInfiniteScroll(completion handler: ParameterClosure?) +} diff --git a/TIPagination/Sources/Protocols/PaginatorDelegate.swift b/TIPagination/Sources/Protocols/PaginatorDelegate.swift new file mode 100644 index 00000000..1c21d41a --- /dev/null +++ b/TIPagination/Sources/Protocols/PaginatorDelegate.swift @@ -0,0 +1,42 @@ +// +// Copyright (c) 2020 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. +// + +/// PaginationWrapper delegate used for pagination results handling +public protocol PaginatorDelegate: class { + + associatedtype Page + + /// Handles loading new chunk of data. + /// + /// - Parameters: + /// - newPage: New page. + func paginator(didLoad newPage: Page) + + /// Handles reloading or initial loading of data. + /// + /// - Parameters: + /// - page: New page. + func paginator(didReloadWith page: Page) + + /// Clear presented data. + func clearContent() +} diff --git a/TIPagination/Sources/Protocols/PaginatorUIDelegate.swift b/TIPagination/Sources/Protocols/PaginatorUIDelegate.swift new file mode 100644 index 00000000..d5ae6811 --- /dev/null +++ b/TIPagination/Sources/Protocols/PaginatorUIDelegate.swift @@ -0,0 +1,39 @@ +// +// Copyright (c) 2020 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. +// + +public protocol PaginatorUIDelegate: class { + + associatedtype ErrorType + + func onInitialLoading() + func onReloading() + func onLoadingMore() + + func onLoadingError(_ error: ErrorType) + func onLoadingMoreError(_ error: ErrorType) + + func onSuccessfulLoad() + func onEmptyState() + func onExhaustedState() + + func onAddInfiniteScroll() +} diff --git a/TIPagination/TIPagination.podspec b/TIPagination/TIPagination.podspec new file mode 100644 index 00000000..7718d072 --- /dev/null +++ b/TIPagination/TIPagination.podspec @@ -0,0 +1,18 @@ +Pod::Spec.new do |s| + s.name = 'TIPagination' + s.version = '1.2.0' + s.summary = 'Set of helpers for Foundation framework classes.' + s.homepage = 'https://github.com/TouchInstinct/LeadKit/tree/' + s.version.to_s + '/' + s.name + s.license = 'Apache License, Version 2.0' + s.author = 'Touch Instinct' + s.source = { :git => 'https://github.com/TouchInstinct/LeadKit.git', :tag => s.version.to_s } + + s.ios.deployment_target = '11.0' + s.swift_versions = ['5.3'] + + s.source_files = s.name + '/Sources/**/*' + + s.dependency 'TISwiftUtils', s.version.to_s + s.dependency 'Cursors', s.version.to_s + s.framework = 'UIKit' +end