feat: add realisation of paginating items from a data source
This commit is contained in:
parent
954080891d
commit
49e6172edf
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
]
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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<Data?, URLError>? {
|
||||
|
||||
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<MockCursor>(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() // Первоначальная загрузка данных
|
||||
}
|
||||
|
||||
```
|
||||
|
|
@ -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 {}
|
||||
|
|
@ -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<Cursor: PaginatorCursorType>: PaginatorUIDelegate {
|
||||
|
||||
public typealias ViewSetter = ParameterClosure<UIView?>
|
||||
|
||||
// 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?()
|
||||
}
|
||||
}
|
||||
|
|
@ -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<Cursor: PaginatorCursorType,
|
||||
Delegate: PaginatorDelegate,
|
||||
UIDelegate: PaginatorUIDelegate>
|
||||
where Cursor.Page == Delegate.Page, UIDelegate.ErrorType == Cursor.Failure {
|
||||
|
||||
|
||||
private typealias State = PaginatorState<Cursor>
|
||||
private typealias FinishInfiniteScrollCompletion = ParameterClosure<UIScrollView>
|
||||
|
||||
private let dataLoadingModel: PaginatorDataLoadingModel<Cursor>
|
||||
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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<Cursor: PaginatorCursorType> {
|
||||
|
||||
public typealias State = PaginatorState<Cursor>
|
||||
|
||||
// MARK: - Public Properties
|
||||
|
||||
private var cursor: Cursor
|
||||
|
||||
private(set) var state: PaginatorState<Cursor> {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
|
@ -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<Cursor: CursorType> {
|
||||
|
||||
/// 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)
|
||||
}
|
||||
|
|
@ -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<UITableView>)
|
||||
func removeInfiniteScroll()
|
||||
func finishInfiniteScroll(completion handler: ParameterClosure<UITableView>?)
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -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
|
||||
Loading…
Reference in New Issue