Merge master
This commit is contained in:
parent
29de30cd37
commit
7d96e07b4e
12
CHANGELOG.md
12
CHANGELOG.md
|
|
@ -1,4 +1,16 @@
|
|||
# Changelog
|
||||
### 0.9.9
|
||||
- **Add**: `BaseSearchViewController` class that that allows to enter text for search and then displays search results in table view.
|
||||
- **Add**: `BaseSearchViewModel` class that loads data from a given data source and performs search among the results.
|
||||
- **Add**: `SearchResultsController` protocol that represent a controller able to display search results.
|
||||
- **Add**: `SearchResultsControllerState` enum that represents `SearchResultsController` state.
|
||||
|
||||
### 0.9.8
|
||||
- **Add**: `rxDataRequest` method to `NetworkService` class, that performs reactive request to get data and http response.
|
||||
- **Add**: `responseData` method to `SessionManager` extension, that executes request and returns data.
|
||||
|
||||
### 0.9.7
|
||||
- **Add**: Carthage support.
|
||||
|
||||
### 0.9.11
|
||||
- **[Breaking change]**: Renamed `NumberFormat`'s `allOptions` to `allCases`
|
||||
|
|
|
|||
2
Cartfile
2
Cartfile
|
|
@ -3,4 +3,4 @@ binary "https://raw.github.com/TouchInstinct/CarthageBinaries/master/Alamofire/A
|
|||
binary "https://raw.github.com/TouchInstinct/CarthageBinaries/master/RxAlamofire/RxAlamofire.json"
|
||||
github "ReactiveX/RxSwift"
|
||||
github "maxsokolov/TableKit"
|
||||
github "pronebird/UIScrollView-InfiniteScroll"
|
||||
github "pronebird/UIScrollView-InfiniteScroll"
|
||||
|
|
|
|||
|
|
@ -34,6 +34,7 @@ Pod::Spec.new do |s|
|
|||
"Sources/Classes/Views/CollectionViewWrapperView/*",
|
||||
"Sources/Classes/Views/TableViewWrapperView/*",
|
||||
"Sources/Classes/Views/BasePlaceholderView/*",
|
||||
"Sources/Classes/Search/*",
|
||||
"Sources/Extensions/CABasicAnimation/*",
|
||||
"Sources/Extensions/CGFloat/CGFloat+Pixels.swift",
|
||||
"Sources/Extensions/NetworkService/NetworkService+RxLoadImage.swift",
|
||||
|
|
@ -69,6 +70,7 @@ Pod::Spec.new do |s|
|
|||
"Sources/Classes/Views/SeparatorCell/*",
|
||||
"Sources/Classes/Views/EmptyCell/*",
|
||||
"Sources/Classes/DataLoading/PaginationDataLoading/PaginationWrapper.swift",
|
||||
"Sources/Classes/Search/*",
|
||||
"Sources/Structures/Drawing/CALayerDrawingOperation.swift",
|
||||
"Sources/Extensions/DataLoading/PaginationDataLoading/*",
|
||||
"Sources/Extensions/Support/UIScrollView+Support.swift",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,198 @@
|
|||
//
|
||||
// Copyright (c) 2019 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 TableKit
|
||||
import RxSwift
|
||||
import UIKit
|
||||
|
||||
public typealias SearchResultsController = UIViewController & SearchResultsViewController
|
||||
|
||||
/// Class that allows to enter text for search and then displays search results in table view
|
||||
open class BaseSearchViewController<Item,
|
||||
ItemViewModel,
|
||||
ViewModel,
|
||||
CustomView: UIView & TableViewHolder>: BaseCustomViewController<ViewModel, CustomView>
|
||||
where ViewModel: BaseSearchViewModel<Item, ItemViewModel> {
|
||||
|
||||
// MARK: - Properties
|
||||
|
||||
private let disposeBag = DisposeBag()
|
||||
private let searchResultsController: SearchResultsController
|
||||
private let searchController: UISearchController
|
||||
private var didEnterText = false
|
||||
|
||||
// MARK: - Initialization
|
||||
|
||||
public init(viewModel: ViewModel, searchResultsController: SearchResultsController) {
|
||||
self.searchResultsController = searchResultsController
|
||||
self.searchController = UISearchController(searchResultsController: searchResultsController)
|
||||
super.init(viewModel: viewModel)
|
||||
}
|
||||
|
||||
required public init?(coder aDecoder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
// MARK: - Configurable Controller
|
||||
|
||||
open override func bindViews() {
|
||||
super.bindViews()
|
||||
|
||||
viewModel.itemsViewModelsDriver
|
||||
.drive(onNext: { [weak self] viewModels in
|
||||
self?.handle(itemViewModels: viewModels)
|
||||
})
|
||||
.disposed(by: disposeBag)
|
||||
|
||||
Observable.merge(searchResults, resetResults)
|
||||
.subscribe(onNext: { [weak self] state in
|
||||
self?.handle(searchResultsState: state)
|
||||
})
|
||||
.disposed(by: disposeBag)
|
||||
|
||||
let searchText = searchController.searchBar.rx.text
|
||||
.changed
|
||||
.do(onNext: { [weak self] text in
|
||||
self?.handle(searchText: text)
|
||||
})
|
||||
.map { $0 ?? "" }
|
||||
|
||||
viewModel.bind(searchText: searchText)
|
||||
.disposed(by: disposeBag)
|
||||
}
|
||||
|
||||
open override func addViews() {
|
||||
super.addViews()
|
||||
|
||||
if #available(iOS 11.0, *) {
|
||||
navigationItem.searchController = searchController
|
||||
} else {
|
||||
customView.tableView.tableHeaderView = searchController.searchBar
|
||||
}
|
||||
searchController.view.addSubview(statusBarView)
|
||||
}
|
||||
|
||||
open override func configureAppearance() {
|
||||
super.configureAppearance()
|
||||
|
||||
definesPresentationContext = true
|
||||
customView.tableView.tableHeaderView?.backgroundColor = searchBarColor
|
||||
}
|
||||
|
||||
open override func localize() {
|
||||
super.localize()
|
||||
|
||||
searchController.searchBar.placeholder = searchBarPlaceholder
|
||||
}
|
||||
|
||||
// MARK: - Search Controller Functionality
|
||||
|
||||
open func createRows(from itemsViewModels: [ItemViewModel]) -> [Row] {
|
||||
assertionFailure("createRows(from:) has not been implemented")
|
||||
return []
|
||||
}
|
||||
|
||||
open var searchBarPlaceholder: String {
|
||||
return ""
|
||||
}
|
||||
|
||||
open var searchBarColor: UIColor {
|
||||
return .gray
|
||||
}
|
||||
|
||||
open var statusBarView: UIView {
|
||||
let statusBarSize = statusBarFrame().size
|
||||
let statusBarView = UIView(frame: CGRect(x: 0,
|
||||
y: 0,
|
||||
width: statusBarSize.width,
|
||||
height: statusBarSize.height))
|
||||
statusBarView.backgroundColor = statusBarColor
|
||||
|
||||
return statusBarView
|
||||
}
|
||||
|
||||
open var statusBarColor: UIColor {
|
||||
return .black
|
||||
}
|
||||
|
||||
open func updateContent(with viewModels: [ItemViewModel]) {
|
||||
// override in subclass
|
||||
}
|
||||
|
||||
open func stateForUpdate(with viewModels: [ItemViewModel]) -> SearchResultsViewControllerState {
|
||||
let rows = createRows(from: viewModels)
|
||||
return .rowsContent(rows: rows)
|
||||
}
|
||||
|
||||
open var resetResults: Observable<SearchResultsViewControllerState> {
|
||||
return searchController.rx.willPresent
|
||||
.map { SearchResultsViewControllerState.initial }
|
||||
}
|
||||
|
||||
open var searchResults: Observable<SearchResultsViewControllerState> {
|
||||
return viewModel.searchResultsDriver
|
||||
.asObservable()
|
||||
.map { [weak self] viewModels -> SearchResultsViewControllerState in
|
||||
self?.stateForUpdate(with: viewModels) ?? .rowsContent(rows: [])
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
open func handle(itemViewModels viewModels: [ItemViewModel]) {
|
||||
updateContent(with: viewModels)
|
||||
}
|
||||
|
||||
open func handle(searchResultsState state: SearchResultsViewControllerState) {
|
||||
searchResultsController.update(for: state)
|
||||
}
|
||||
|
||||
open func handle(searchText: String?) {
|
||||
setTableViewInsets()
|
||||
}
|
||||
|
||||
private func setTableViewInsets() {
|
||||
guard !didEnterText else {
|
||||
return
|
||||
}
|
||||
didEnterText = true
|
||||
searchResultsController.searchResultsView.tableView.contentInset = tableViewInsets
|
||||
searchResultsController.searchResultsView.tableView.scrollIndicatorInsets = tableViewInsets
|
||||
}
|
||||
|
||||
open func statusBarFrame() -> CGRect {
|
||||
/// override in subclass
|
||||
return .zero
|
||||
}
|
||||
}
|
||||
|
||||
extension BaseSearchViewController {
|
||||
open var tableViewInsets: UIEdgeInsets {
|
||||
let searchBarHeight = searchController.searchBar.frame.height
|
||||
let statusBarHeight = statusBarFrame().height
|
||||
|
||||
return UIEdgeInsets(top: searchBarHeight + statusBarHeight,
|
||||
left: 0,
|
||||
bottom: 0,
|
||||
right: 0)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,100 @@
|
|||
//
|
||||
// Copyright (c) 2019 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 RxSwift
|
||||
import RxCocoa
|
||||
|
||||
/// ViewModel that loads data from a given data source and performs search among results
|
||||
open class BaseSearchViewModel<Item, ItemViewModel>: GeneralDataLoadingViewModel<[Item]> {
|
||||
|
||||
public typealias ItemsList = [Item]
|
||||
|
||||
private let searchTextRelay = BehaviorRelay(value: "")
|
||||
|
||||
public init(dataSource: Single<ItemsList>) {
|
||||
super.init(dataSource: dataSource, emptyResultChecker: { $0.isEmpty })
|
||||
}
|
||||
|
||||
open var itemsViewModelsDriver: Driver<[ItemViewModel]> {
|
||||
return loadingResultObservable
|
||||
.map { [weak self] items in
|
||||
self?.viewModels(from: items)
|
||||
}
|
||||
.filterNil()
|
||||
.share(replay: 1, scope: .forever)
|
||||
.asDriver(onErrorDriveWith: .empty())
|
||||
}
|
||||
|
||||
open var searchResultsDriver: Driver<[ItemViewModel]> {
|
||||
return searchTextRelay.throttle(1, scheduler: MainScheduler.instance)
|
||||
.withLatestFrom(loadingResultObservable) { ($0, $1) }
|
||||
.flatMapLatest { [weak self] searchText, items -> Observable<ItemsList> in
|
||||
self?.search(by: searchText, from: items).asObservable() ?? .empty()
|
||||
}
|
||||
.map { [weak self] items in
|
||||
self?.viewModels(from: items)
|
||||
}
|
||||
.filterNil()
|
||||
.share(replay: 1, scope: .forever)
|
||||
.asDriver(onErrorDriveWith: .empty())
|
||||
}
|
||||
|
||||
open func viewModel(from item: Item) -> ItemViewModel {
|
||||
fatalError("viewModel(from:) has not been implemented")
|
||||
}
|
||||
|
||||
open func search(by searchString: String, from items: ItemsList) -> Single<ItemsList> {
|
||||
fatalError("searchEngine(for:) has not been implemented")
|
||||
}
|
||||
|
||||
open func bind(searchText: Observable<String>) -> Disposable {
|
||||
return searchText.bind(to: searchTextRelay)
|
||||
}
|
||||
|
||||
open func onDidSelect(item: Item) {
|
||||
// override in subclass
|
||||
}
|
||||
|
||||
private func viewModels(from items: ItemsList) -> [ItemViewModel] {
|
||||
return items.map { self.viewModel(from: $0) }
|
||||
}
|
||||
|
||||
open var loadingResultObservable: Observable<ResultType> {
|
||||
return loadingStateDriver
|
||||
.asObservable()
|
||||
.map { $0.result }
|
||||
.filterNil()
|
||||
}
|
||||
|
||||
open var loadingErrorObservable: Observable<Error> {
|
||||
return loadingStateDriver
|
||||
.asObservable()
|
||||
.map { $0.error }
|
||||
.filterNil()
|
||||
}
|
||||
|
||||
open var firstLoadingResultObservable: Single<ResultType> {
|
||||
return loadingResultObservable
|
||||
.take(1)
|
||||
.asSingle()
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
//
|
||||
// Copyright (c) 2019 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 TableKit
|
||||
|
||||
public enum SearchResultsViewControllerState {
|
||||
case initial
|
||||
case rowsContent(rows: [Row])
|
||||
case sectionsContent(sections: [TableSection])
|
||||
}
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
//
|
||||
// Copyright (c) 2019 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.
|
||||
//
|
||||
|
||||
/// Protocol that represents a ViewController able to present search results
|
||||
public protocol SearchResultsViewController {
|
||||
|
||||
var searchResultsView: TableViewHolder { get set }
|
||||
|
||||
func update(for state: SearchResultsViewControllerState)
|
||||
}
|
||||
|
|
@ -0,0 +1,42 @@
|
|||
//
|
||||
// Copyright (c) 2019 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 RxSwift
|
||||
|
||||
/// Protocol that represents optional type. This is required for Observable's filterNil() function
|
||||
protocol OptionalType {
|
||||
associatedtype Wrapped
|
||||
|
||||
var optional: Wrapped? { get }
|
||||
}
|
||||
|
||||
extension Optional: OptionalType {
|
||||
public var optional: Wrapped? { return self }
|
||||
}
|
||||
|
||||
extension Observable where Element: OptionalType {
|
||||
func filterNil() -> Observable<Element.Wrapped> {
|
||||
return flatMap { value -> Observable<Element.Wrapped> in
|
||||
value.optional.map { .just($0) } ?? .empty()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1 +1 @@
|
|||
Subproject commit 54935bbe26063cdf04e72b8cb76d61c727ff99a7
|
||||
Subproject commit 8a74be38b9aa3f940503ad09127c28feee022983
|
||||
Loading…
Reference in New Issue