From 7d96e07b4ea35436415ab7ea6dc446fe7445fa8e Mon Sep 17 00:00:00 2001 From: Artur Date: Tue, 26 Mar 2019 20:22:04 +0300 Subject: [PATCH] Merge master --- CHANGELOG.md | 12 ++ Cartfile | 2 +- LeadKit.podspec | 2 + Podfile | 0 Podfile.lock | 0 .../Search/BaseSearchViewController.swift | 198 ++++++++++++++++++ .../Classes/Search/BaseSearchViewModel.swift | 100 +++++++++ .../SearchResultsViewControllerState.swift | 29 +++ .../SearchResultsViewController.swift | 29 +++ Sources/Protocols/OptionalType.swift | 42 ++++ build-scripts | 2 +- 11 files changed, 414 insertions(+), 2 deletions(-) create mode 100644 Podfile create mode 100644 Podfile.lock create mode 100644 Sources/Classes/Search/BaseSearchViewController.swift create mode 100644 Sources/Classes/Search/BaseSearchViewModel.swift create mode 100644 Sources/Enums/Search/SearchResultsViewControllerState.swift create mode 100644 Sources/Protocols/Controllers/SearchResultsViewController.swift create mode 100644 Sources/Protocols/OptionalType.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index fb7ec37b..1686b760 100644 --- a/CHANGELOG.md +++ b/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` diff --git a/Cartfile b/Cartfile index a662e7b8..16dc9a2b 100644 --- a/Cartfile +++ b/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" \ No newline at end of file +github "pronebird/UIScrollView-InfiniteScroll" diff --git a/LeadKit.podspec b/LeadKit.podspec index b9217b30..5d7ec659 100644 --- a/LeadKit.podspec +++ b/LeadKit.podspec @@ -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", diff --git a/Podfile b/Podfile new file mode 100644 index 00000000..e69de29b diff --git a/Podfile.lock b/Podfile.lock new file mode 100644 index 00000000..e69de29b diff --git a/Sources/Classes/Search/BaseSearchViewController.swift b/Sources/Classes/Search/BaseSearchViewController.swift new file mode 100644 index 00000000..e9b96d5f --- /dev/null +++ b/Sources/Classes/Search/BaseSearchViewController.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: BaseCustomViewController +where ViewModel: BaseSearchViewModel { + + // 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 { + return searchController.rx.willPresent + .map { SearchResultsViewControllerState.initial } + } + + open var searchResults: Observable { + 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) + } +} diff --git a/Sources/Classes/Search/BaseSearchViewModel.swift b/Sources/Classes/Search/BaseSearchViewModel.swift new file mode 100644 index 00000000..e9e5cdc3 --- /dev/null +++ b/Sources/Classes/Search/BaseSearchViewModel.swift @@ -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: GeneralDataLoadingViewModel<[Item]> { + + public typealias ItemsList = [Item] + + private let searchTextRelay = BehaviorRelay(value: "") + + public init(dataSource: Single) { + 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 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 { + fatalError("searchEngine(for:) has not been implemented") + } + + open func bind(searchText: Observable) -> 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 { + return loadingStateDriver + .asObservable() + .map { $0.result } + .filterNil() + } + + open var loadingErrorObservable: Observable { + return loadingStateDriver + .asObservable() + .map { $0.error } + .filterNil() + } + + open var firstLoadingResultObservable: Single { + return loadingResultObservable + .take(1) + .asSingle() + } +} diff --git a/Sources/Enums/Search/SearchResultsViewControllerState.swift b/Sources/Enums/Search/SearchResultsViewControllerState.swift new file mode 100644 index 00000000..783ab93a --- /dev/null +++ b/Sources/Enums/Search/SearchResultsViewControllerState.swift @@ -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]) +} diff --git a/Sources/Protocols/Controllers/SearchResultsViewController.swift b/Sources/Protocols/Controllers/SearchResultsViewController.swift new file mode 100644 index 00000000..c29e5611 --- /dev/null +++ b/Sources/Protocols/Controllers/SearchResultsViewController.swift @@ -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) +} diff --git a/Sources/Protocols/OptionalType.swift b/Sources/Protocols/OptionalType.swift new file mode 100644 index 00000000..ed36ea85 --- /dev/null +++ b/Sources/Protocols/OptionalType.swift @@ -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 { + return flatMap { value -> Observable in + value.optional.map { .just($0) } ?? .empty() + } + } +} diff --git a/build-scripts b/build-scripts index 54935bbe..8a74be38 160000 --- a/build-scripts +++ b/build-scripts @@ -1 +1 @@ -Subproject commit 54935bbe26063cdf04e72b8cb76d61c727ff99a7 +Subproject commit 8a74be38b9aa3f940503ad09127c28feee022983