498 lines
18 KiB
Swift
498 lines
18 KiB
Swift
protocol SearchOnMapView: AnyObject {
|
|
func render(_ viewModel: SearchOnMap.ViewModel)
|
|
func show()
|
|
func close()
|
|
}
|
|
|
|
@objc
|
|
protocol SearchOnMapScrollViewDelegate: AnyObject {
|
|
func scrollViewDidScroll(_ scrollView: UIScrollView)
|
|
func scrollViewWillEndDragging(_ scrollView: UIScrollView, withVelocity velocity: CGPoint, targetContentOffset: UnsafeMutablePointer<CGPoint>)
|
|
}
|
|
|
|
@objc
|
|
protocol ModallyPresentedViewController: AnyObject {
|
|
@objc func presentationFrameDidChange(_ frame: CGRect)
|
|
}
|
|
|
|
final class SearchOnMapViewController: UIViewController {
|
|
typealias ViewModel = SearchOnMap.ViewModel
|
|
typealias Content = SearchOnMap.ViewModel.Content
|
|
|
|
fileprivate enum Constants {
|
|
static let estimatedRowHeight: CGFloat = 80
|
|
static let panGestureThreshold: CGFloat = 5
|
|
static let dimAlpha: CGFloat = 0.3
|
|
static let dimViewThreshold: CGFloat = 50
|
|
}
|
|
|
|
var interactor: SearchOnMapInteractor?
|
|
|
|
@objc let availableAreaView = SearchOnMapAreaView()
|
|
private let contentView = UIView()
|
|
private let headerView = SearchOnMapHeaderView()
|
|
private let searchResultsView = UIView()
|
|
private let resultsTableView = UITableView()
|
|
private let historyAndCategoryTabViewController = SearchTabViewController()
|
|
private var searchingActivityView = PlaceholderView(hasActivityIndicator: true)
|
|
private var searchNoResultsView = PlaceholderView(title: L("search_not_found"),
|
|
subtitle: L("search_not_found_query"))
|
|
private var dimView: UIView?
|
|
|
|
private var internalScrollViewContentOffset: CGFloat = .zero
|
|
private let presentationStepsController = ModalPresentationStepsController()
|
|
private var searchResults = SearchOnMap.SearchResults([])
|
|
|
|
// MARK: - Init
|
|
init() {
|
|
super.init(nibName: nil, bundle: nil)
|
|
configureModalPresentation()
|
|
}
|
|
|
|
private func configureModalPresentation() {
|
|
guard let mapViewController = MapViewController.shared() else {
|
|
fatalError("MapViewController is not available")
|
|
}
|
|
presentationStepsController.set(presentedView: availableAreaView, containerViewController: self)
|
|
presentationStepsController.didUpdateHandler = presentationUpdateHandler
|
|
|
|
mapViewController.searchContainer.addSubview(view)
|
|
mapViewController.addChild(self)
|
|
view.frame = mapViewController.searchContainer.bounds
|
|
view.autoresizingMask = [.flexibleWidth, .flexibleHeight]
|
|
didMove(toParent: mapViewController)
|
|
|
|
let affectedAreaViews = [
|
|
mapViewController.sideButtonsArea,
|
|
mapViewController.trafficButtonArea,
|
|
]
|
|
affectedAreaViews.forEach { $0?.addAffectingView(availableAreaView) }
|
|
}
|
|
|
|
@available(*, unavailable)
|
|
required init?(coder: NSCoder) {
|
|
fatalError("init(coder:) has not been implemented")
|
|
}
|
|
|
|
// MARK: - Lifecycle
|
|
override func loadView() {
|
|
view = TouchTransparentView()
|
|
}
|
|
|
|
override func viewDidLoad() {
|
|
super.viewDidLoad()
|
|
setupViews()
|
|
layoutViews()
|
|
presentationStepsController.setInitialState()
|
|
}
|
|
|
|
override func viewWillDisappear(_ animated: Bool) {
|
|
super.viewWillDisappear(animated)
|
|
headerView.setIsSearching(false)
|
|
}
|
|
|
|
override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
|
|
super.traitCollectionDidChange(previousTraitCollection)
|
|
updateFrameOfPresentedViewInContainerView()
|
|
updateDimView(for: availableAreaView.frame)
|
|
}
|
|
|
|
override func viewWillTransition(to size: CGSize, with coordinator: any UIViewControllerTransitionCoordinator) {
|
|
super.viewWillTransition(to: size, with: coordinator)
|
|
if ProcessInfo.processInfo.isiOSAppOnMac {
|
|
updateFrameOfPresentedViewInContainerView()
|
|
}
|
|
}
|
|
|
|
// MARK: - Private methods
|
|
private func setupViews() {
|
|
availableAreaView.setStyleAndApply(.modalSheetBackground)
|
|
contentView.setStyleAndApply(.modalSheetContent)
|
|
|
|
setupGestureRecognizers()
|
|
setupDimView()
|
|
setupHeaderView()
|
|
setupSearchResultsView()
|
|
setupResultsTableView()
|
|
setupHistoryAndCategoryTabView()
|
|
}
|
|
|
|
private func setupDimView() {
|
|
iPhoneSpecific {
|
|
dimView = UIView()
|
|
dimView?.backgroundColor = .black
|
|
dimView?.frame = view.bounds
|
|
}
|
|
}
|
|
|
|
private func setupGestureRecognizers() {
|
|
let tapGesture = UITapGestureRecognizer(target: self, action: #selector(handleTapOutside))
|
|
tapGesture.cancelsTouchesInView = false
|
|
contentView.addGestureRecognizer(tapGesture)
|
|
|
|
iPhoneSpecific {
|
|
let panGestureRecognizer = UIPanGestureRecognizer(target: self, action: #selector(handlePan(_:)))
|
|
panGestureRecognizer.delegate = self
|
|
contentView.addGestureRecognizer(panGestureRecognizer)
|
|
}
|
|
}
|
|
|
|
private func setupHeaderView() {
|
|
headerView.delegate = self
|
|
}
|
|
|
|
private func setupSearchResultsView() {
|
|
searchResultsView.setStyle(.background)
|
|
}
|
|
|
|
private func setupResultsTableView() {
|
|
resultsTableView.setStyle(.background)
|
|
resultsTableView.estimatedRowHeight = Constants.estimatedRowHeight
|
|
resultsTableView.rowHeight = UITableView.automaticDimension
|
|
resultsTableView.registerNib(cellClass: SearchSuggestionCell.self)
|
|
resultsTableView.registerNib(cellClass: SearchCommonCell.self)
|
|
resultsTableView.dataSource = self
|
|
resultsTableView.delegate = self
|
|
resultsTableView.keyboardDismissMode = .onDrag
|
|
}
|
|
|
|
private func setupHistoryAndCategoryTabView() {
|
|
historyAndCategoryTabViewController.delegate = self
|
|
}
|
|
|
|
private func layoutViews() {
|
|
if let dimView {
|
|
view.addSubview(dimView)
|
|
dimView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
|
|
}
|
|
view.addSubview(availableAreaView)
|
|
availableAreaView.addSubview(contentView)
|
|
contentView.addSubview(searchResultsView)
|
|
contentView.addSubview(headerView)
|
|
|
|
contentView.translatesAutoresizingMaskIntoConstraints = false
|
|
headerView.translatesAutoresizingMaskIntoConstraints = false
|
|
searchResultsView.translatesAutoresizingMaskIntoConstraints = false
|
|
|
|
NSLayoutConstraint.activate([
|
|
contentView.topAnchor.constraint(equalTo: availableAreaView.topAnchor),
|
|
contentView.leadingAnchor.constraint(equalTo: availableAreaView.leadingAnchor),
|
|
contentView.trailingAnchor.constraint(equalTo: availableAreaView.trailingAnchor),
|
|
contentView.bottomAnchor.constraint(equalTo: availableAreaView.bottomAnchor),
|
|
|
|
headerView.topAnchor.constraint(equalTo: contentView.topAnchor),
|
|
headerView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor),
|
|
headerView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor),
|
|
|
|
searchResultsView.topAnchor.constraint(equalTo: headerView.bottomAnchor),
|
|
searchResultsView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor),
|
|
searchResultsView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor),
|
|
searchResultsView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor),
|
|
])
|
|
|
|
layoutResultsView()
|
|
layoutHistoryAndCategoryTabView()
|
|
layoutSearchNoResultsView()
|
|
layoutSearchingView()
|
|
updateFrameOfPresentedViewInContainerView()
|
|
}
|
|
|
|
private func layoutResultsView() {
|
|
searchResultsView.addSubview(resultsTableView)
|
|
resultsTableView.translatesAutoresizingMaskIntoConstraints = false
|
|
|
|
NSLayoutConstraint.activate([
|
|
resultsTableView.topAnchor.constraint(equalTo: searchResultsView.topAnchor),
|
|
resultsTableView.leadingAnchor.constraint(equalTo: searchResultsView.leadingAnchor),
|
|
resultsTableView.trailingAnchor.constraint(equalTo: searchResultsView.trailingAnchor),
|
|
resultsTableView.bottomAnchor.constraint(equalTo: searchResultsView.bottomAnchor)
|
|
])
|
|
}
|
|
|
|
private func layoutHistoryAndCategoryTabView() {
|
|
searchResultsView.addSubview(historyAndCategoryTabViewController.view)
|
|
historyAndCategoryTabViewController.view.translatesAutoresizingMaskIntoConstraints = false
|
|
|
|
NSLayoutConstraint.activate([
|
|
historyAndCategoryTabViewController.view.topAnchor.constraint(equalTo: searchResultsView.topAnchor),
|
|
historyAndCategoryTabViewController.view.leadingAnchor.constraint(equalTo: searchResultsView.leadingAnchor),
|
|
historyAndCategoryTabViewController.view.trailingAnchor.constraint(equalTo: searchResultsView.trailingAnchor),
|
|
historyAndCategoryTabViewController.view.bottomAnchor.constraint(equalTo: searchResultsView.bottomAnchor)
|
|
])
|
|
}
|
|
|
|
private func layoutSearchNoResultsView() {
|
|
searchNoResultsView.translatesAutoresizingMaskIntoConstraints = false
|
|
searchResultsView.addSubview(searchNoResultsView)
|
|
NSLayoutConstraint.activate([
|
|
searchNoResultsView.topAnchor.constraint(equalTo: searchResultsView.topAnchor),
|
|
searchNoResultsView.leadingAnchor.constraint(equalTo: searchResultsView.leadingAnchor),
|
|
searchNoResultsView.trailingAnchor.constraint(equalTo: searchResultsView.trailingAnchor),
|
|
searchNoResultsView.bottomAnchor.constraint(equalTo: searchResultsView.bottomAnchor)
|
|
])
|
|
}
|
|
|
|
private func layoutSearchingView() {
|
|
searchResultsView.insertSubview(searchingActivityView, at: 0)
|
|
searchingActivityView.translatesAutoresizingMaskIntoConstraints = false
|
|
NSLayoutConstraint.activate([
|
|
searchingActivityView.leadingAnchor.constraint(equalTo: searchResultsView.leadingAnchor),
|
|
searchingActivityView.trailingAnchor.constraint(equalTo: searchResultsView.trailingAnchor),
|
|
searchingActivityView.topAnchor.constraint(equalTo: searchResultsView.topAnchor),
|
|
searchingActivityView.bottomAnchor.constraint(equalTo: searchResultsView.bottomAnchor)
|
|
])
|
|
}
|
|
|
|
// MARK: - Handle Presentation Steps
|
|
private func updateFrameOfPresentedViewInContainerView() {
|
|
presentationStepsController.updateMaxAvailableFrame()
|
|
availableAreaView.frame = presentationStepsController.currentFrame
|
|
view.layoutIfNeeded()
|
|
}
|
|
|
|
@objc
|
|
private func handleTapOutside(_ gesture: UITapGestureRecognizer) {
|
|
let location = gesture.location(in: view)
|
|
if resultsTableView.frame.contains(location) && searchResults.isEmpty {
|
|
headerView.setIsSearching(false)
|
|
}
|
|
}
|
|
|
|
@objc
|
|
private func handlePan(_ gesture: UIPanGestureRecognizer) {
|
|
interactor?.handle(.didStartDraggingSearch)
|
|
presentationStepsController.handlePan(gesture)
|
|
}
|
|
|
|
private var presentationUpdateHandler: (ModalPresentationStepsController.StepUpdate) -> Void {
|
|
{ [weak self] update in
|
|
guard let self else { return }
|
|
switch update {
|
|
case .didClose:
|
|
self.interactor?.handle(.closeSearch)
|
|
case .didUpdateFrame(let frame):
|
|
self.presentationFrameDidChange(frame)
|
|
self.updateDimView(for: frame)
|
|
case .didUpdateStep(let step):
|
|
self.interactor?.handle(.didUpdatePresentationStep(step))
|
|
}
|
|
}
|
|
}
|
|
|
|
private func updateDimView(for frame: CGRect) {
|
|
guard let dimView else { return }
|
|
let currentTop = frame.origin.y
|
|
let maxTop = presentationStepsController.maxAvailableFrame.origin.y
|
|
let alpha = (1 - (currentTop - maxTop) / Constants.dimViewThreshold) * Constants.dimAlpha
|
|
let isCloseToTop = currentTop - maxTop < Constants.dimViewThreshold
|
|
let isPortrait = UIApplication.shared.statusBarOrientation.isPortrait
|
|
let shouldDim = isCloseToTop && isPortrait
|
|
UIView.animate(withDuration: kDefaultAnimationDuration / 2) {
|
|
dimView.alpha = shouldDim ? alpha : 0
|
|
dimView.isHidden = !shouldDim
|
|
}
|
|
}
|
|
|
|
// MARK: - Handle Content Updates
|
|
private func setContent(_ content: Content) {
|
|
switch content {
|
|
case .historyAndCategory:
|
|
historyAndCategoryTabViewController.reloadSearchHistory()
|
|
case let .results(results):
|
|
if searchResults != results {
|
|
searchResults = results
|
|
resultsTableView.reloadData()
|
|
}
|
|
case .noResults:
|
|
searchResults = .empty
|
|
resultsTableView.reloadData()
|
|
case .searching:
|
|
break
|
|
}
|
|
headerView.setSeparatorHidden(content == .historyAndCategory)
|
|
showView(viewToShow(for: content))
|
|
}
|
|
|
|
private func viewToShow(for content: Content) -> UIView {
|
|
switch content {
|
|
case .historyAndCategory:
|
|
return historyAndCategoryTabViewController.view
|
|
case .results:
|
|
return resultsTableView
|
|
case .noResults:
|
|
return searchNoResultsView
|
|
case .searching:
|
|
return searchingActivityView
|
|
}
|
|
}
|
|
|
|
private func showView(_ view: UIView) {
|
|
let viewsToHide: [UIView] = [resultsTableView,
|
|
historyAndCategoryTabViewController.view,
|
|
searchNoResultsView,
|
|
searchingActivityView].filter { $0 != view }
|
|
UIView.animate(withDuration: kDefaultAnimationDuration / 2,
|
|
delay: 0,
|
|
options: .curveEaseInOut,
|
|
animations: {
|
|
viewsToHide.forEach { $0.alpha = 0 }
|
|
view.alpha = 1
|
|
}) { _ in
|
|
viewsToHide.forEach { $0.isHidden = true }
|
|
view.isHidden = false
|
|
}
|
|
}
|
|
|
|
private func setIsSearching(_ isSearching: Bool) {
|
|
headerView.setIsSearching(isSearching)
|
|
}
|
|
|
|
private func setSearchText(_ text: String?) {
|
|
if let text {
|
|
headerView.setSearchText(text)
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Public methods
|
|
extension SearchOnMapViewController: SearchOnMapView {
|
|
func render(_ viewModel: ViewModel) {
|
|
setContent(viewModel.contentState)
|
|
setIsSearching(viewModel.isTyping)
|
|
setSearchText(viewModel.searchingText)
|
|
presentationStepsController.setStep(viewModel.presentationStep)
|
|
}
|
|
|
|
func show() {
|
|
interactor?.handle(.openSearch)
|
|
}
|
|
|
|
func close() {
|
|
headerView.setIsSearching(false)
|
|
updateDimView(for: presentationStepsController.hiddenFrame)
|
|
willMove(toParent: nil)
|
|
presentationStepsController.close { [weak self] in
|
|
self?.view.removeFromSuperview()
|
|
self?.removeFromParent()
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - ModallyPresentedViewController
|
|
extension SearchOnMapViewController: ModallyPresentedViewController {
|
|
func presentationFrameDidChange(_ frame: CGRect) {
|
|
let translationY = frame.origin.y
|
|
resultsTableView.contentInset.bottom = translationY
|
|
historyAndCategoryTabViewController.presentationFrameDidChange(frame)
|
|
searchNoResultsView.presentationFrameDidChange(frame)
|
|
searchingActivityView.presentationFrameDidChange(frame)
|
|
}
|
|
}
|
|
|
|
// MARK: - UITableViewDataSource
|
|
extension SearchOnMapViewController: UITableViewDataSource {
|
|
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
|
|
searchResults.count
|
|
}
|
|
|
|
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
|
|
let result = searchResults[indexPath.row]
|
|
switch result.itemType {
|
|
case .regular:
|
|
let cell = tableView.dequeueReusableCell(cell: SearchCommonCell.self, indexPath: indexPath)
|
|
cell.configure(with: result, isPartialMatching: searchResults.hasPartialMatch)
|
|
return cell
|
|
case .suggestion:
|
|
let cell = tableView.dequeueReusableCell(cell: SearchSuggestionCell.self, indexPath: indexPath)
|
|
cell.configure(with: result, isPartialMatching: true)
|
|
cell.isLastCell = indexPath.row == searchResults.suggestionsCount - 1
|
|
return cell
|
|
@unknown default:
|
|
fatalError("Unknown item type")
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - UITableViewDelegate
|
|
extension SearchOnMapViewController: UITableViewDelegate {
|
|
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
|
|
let result = searchResults[indexPath.row]
|
|
interactor?.handle(.didSelectResult(result, withQuery: headerView.searchQuery))
|
|
tableView.deselectRow(at: indexPath, animated: true)
|
|
}
|
|
|
|
func scrollViewWillBeginDragging(_ scrollView: UIScrollView) {
|
|
interactor?.handle(.didStartDraggingSearch)
|
|
}
|
|
}
|
|
|
|
// MARK: - SearchOnMapHeaderViewDelegate
|
|
extension SearchOnMapViewController: SearchOnMapHeaderViewDelegate {
|
|
func searchBarTextDidBeginEditing(_ searchBar: UISearchBar) {
|
|
interactor?.handle(.didStartTyping)
|
|
}
|
|
|
|
func searchBar(_ searchBar: UISearchBar, textDidChange searchText: String) {
|
|
guard !searchText.isEmpty else {
|
|
interactor?.handle(.clearButtonDidTap)
|
|
return
|
|
}
|
|
interactor?.handle(.didType(SearchQuery(searchText, locale: searchBar.textInputMode?.primaryLanguage, source: .typedText)))
|
|
}
|
|
|
|
func searchBarSearchButtonClicked(_ searchBar: UISearchBar) {
|
|
guard let searchText = searchBar.text, !searchText.isEmpty else { return }
|
|
interactor?.handle(.searchButtonDidTap(SearchQuery(searchText, locale: searchBar.textInputMode?.primaryLanguage, source: .typedText)))
|
|
}
|
|
|
|
func cancelButtonDidTap() {
|
|
interactor?.handle(.closeSearch)
|
|
}
|
|
|
|
func grabberDidTap() {
|
|
interactor?.handle(.didUpdatePresentationStep(.fullScreen))
|
|
}
|
|
}
|
|
|
|
// MARK: - SearchTabViewControllerDelegate
|
|
extension SearchOnMapViewController: SearchTabViewControllerDelegate {
|
|
func searchTabController(_ viewController: SearchTabViewController, didSearch query: SearchQuery) {
|
|
interactor?.handle(.didSelect(query))
|
|
}
|
|
}
|
|
|
|
// MARK: - UIGestureRecognizerDelegate
|
|
extension SearchOnMapViewController: UIGestureRecognizerDelegate {
|
|
func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
|
|
true
|
|
}
|
|
|
|
func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool {
|
|
if gestureRecognizer is UIPanGestureRecognizer && otherGestureRecognizer is UIPanGestureRecognizer {
|
|
// threshold is used to soften transition from the internal scroll zero content offset
|
|
return internalScrollViewContentOffset < Constants.panGestureThreshold
|
|
}
|
|
return false
|
|
}
|
|
}
|
|
|
|
// MARK: - SearchOnMapScrollViewDelegate
|
|
extension SearchOnMapViewController: SearchOnMapScrollViewDelegate {
|
|
func scrollViewDidScroll(_ scrollView: UIScrollView) {
|
|
let hasReachedTheTop = Int(availableAreaView.frame.origin.y) > Int(presentationStepsController.maxAvailableFrame.origin.y)
|
|
let hasZeroContentOffset = internalScrollViewContentOffset == 0
|
|
if hasReachedTheTop && hasZeroContentOffset {
|
|
// prevent the internal scroll view scrolling
|
|
scrollView.contentOffset.y = internalScrollViewContentOffset
|
|
return
|
|
}
|
|
internalScrollViewContentOffset = scrollView.contentOffset.y
|
|
}
|
|
|
|
func scrollViewWillEndDragging(_ scrollView: UIScrollView, withVelocity velocity: CGPoint, targetContentOffset: UnsafeMutablePointer<CGPoint>) {
|
|
// lock internal scroll view when the user fast scrolls screen to the top
|
|
if internalScrollViewContentOffset == 0 {
|
|
targetContentOffset.pointee = .zero
|
|
}
|
|
}
|
|
}
|