Merge pull request #276 from TouchInstinct/feature/tipagination

feat: add realisation of paginating items from a data source
This commit is contained in:
Loupehope 2021-06-19 23:08:30 +03:00 committed by GitHub
commit cd91364c75
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
22 changed files with 1002 additions and 16 deletions

View File

@ -1,5 +1,8 @@
# Changelog
### 1.3.0
- **Add**: `TIPaginator` - realisation of paginating items from a data source.
### 1.2.0
- **Add**: `TIKeychainUtils` - Set of helpers for Keychain classes.

View File

@ -1,6 +1,6 @@
Pod::Spec.new do |s|
s.name = "LeadKit"
s.version = "1.2.0"
s.version = "1.3.0"
s.summary = "iOS framework with a bunch of tools for rapid development"
s.homepage = "https://github.com/TouchInstinct/LeadKit"
s.license = "Apache License, Version 2.0"

View File

@ -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",

View File

@ -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"),
]
)

View File

@ -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.

View File

@ -1,6 +1,6 @@
Pod::Spec.new do |s|
s.name = 'TIFoundationUtils'
s.version = '1.2.0'
s.version = '1.3.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 = { :type => 'MIT', :file => 'LICENSE' }

View File

@ -1,6 +1,6 @@
Pod::Spec.new do |s|
s.name = 'TIKeychainUtils'
s.version = '1.2.0'
s.version = '1.3.0'
s.summary = 'Set of helpers for Keychain classes.'
s.homepage = 'https://github.com/TouchInstinct/LeadKit/tree/' + s.version.to_s + '/' + s.name
s.license = { :type => 'MIT', :file => 'LICENSE' }

203
TIPagination/README.md Normal file
View File

@ -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() // Первоначальная загрузка данных
}
```

View File

@ -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 {}

View File

@ -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?()
}
}

View File

@ -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()
}
}

View File

@ -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
}
}

View File

@ -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)
}

View File

@ -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>?)
}

View File

@ -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()
}

View File

@ -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()
}

View File

@ -0,0 +1,18 @@
Pod::Spec.new do |s|
s.name = 'TIPagination'
s.version = '1.3.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

View File

@ -1,6 +1,6 @@
Pod::Spec.new do |s|
s.name = 'TISwiftUtils'
s.version = '1.2.0'
s.version = '1.3.0'
s.summary = 'Bunch of useful helpers for Swift development.'
s.homepage = 'https://github.com/TouchInstinct/LeadKit/tree/' + s.version.to_s + '/' + s.name
s.license = { :type => 'MIT', :file => 'LICENSE' }

View File

@ -1,6 +1,6 @@
Pod::Spec.new do |s|
s.name = 'TITableKitUtils'
s.version = '1.2.0'
s.version = '1.3.0'
s.summary = 'Set of helpers for TableKit classes.'
s.homepage = 'https://github.com/TouchInstinct/LeadKit/tree/' + s.version.to_s + '/' + s.name
s.license = { :type => 'MIT', :file => 'LICENSE' }

View File

@ -1,6 +1,6 @@
Pod::Spec.new do |s|
s.name = 'TITransitions'
s.version = '1.2.0'
s.version = '1.3.0'
s.summary = 'Set of custom transitions to present controller. '
s.homepage = 'https://github.com/TouchInstinct/LeadKit/tree/' + s.version.to_s + '/' + s.name
s.license = { :type => 'MIT', :file => 'LICENSE' }

View File

@ -1,6 +1,6 @@
Pod::Spec.new do |s|
s.name = 'TIUIElements'
s.version = '1.2.0'
s.version = '1.3.0'
s.summary = 'Bunch of useful protocols and views.'
s.homepage = 'https://github.com/TouchInstinct/LeadKit/tree/' + s.version.to_s + '/' + s.name
s.license = { :type => 'MIT', :file => 'LICENSE' }

View File

@ -1,6 +1,6 @@
Pod::Spec.new do |s|
s.name = 'TIUIKitCore'
s.version = '1.2.0'
s.version = '1.3.0'
s.summary = 'Core UI elements: protocols, views and helpers.'
s.homepage = 'https://github.com/TouchInstinct/LeadKit/tree/' + s.version.to_s + '/' + s.name
s.license = { :type => 'MIT', :file => 'LICENSE' }