Repo created

This commit is contained in:
Fr4nz D13trich 2025-11-22 13:58:55 +01:00
parent 4af19165ec
commit 68073add76
12458 changed files with 12350765 additions and 2 deletions

View file

@ -0,0 +1,121 @@
final class PlaceholderView: UIView {
private let activityIndicator: UIActivityIndicatorView?
private let titleLabel = UILabel()
private let subtitleLabel = UILabel()
private let stackView = UIStackView()
private var keyboardHeight: CGFloat = 0
private var centerYConstraint: NSLayoutConstraint!
private var containerModalYTranslation: CGFloat = 0
private let minOffsetFromTheKeyboardTop: CGFloat = 20
private let maxOffsetFromTheTop: CGFloat = 100
init(title: String? = nil, subtitle: String? = nil, hasActivityIndicator: Bool = false) {
self.activityIndicator = hasActivityIndicator ? UIActivityIndicatorView() : nil
super.init(frame: .zero)
setupView(title: title, subtitle: subtitle)
layoutView()
setupKeyboardObservers()
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
deinit {
NotificationCenter.default.removeObserver(self)
}
private func setupKeyboardObservers() {
NotificationCenter.default.addObserver(self,
selector: #selector(keyboardWillShow(_:)),
name: UIResponder.keyboardWillShowNotification,
object: nil)
NotificationCenter.default.addObserver(self,
selector: #selector(keyboardWillHide(_:)),
name: UIResponder.keyboardWillHideNotification,
object: nil)
}
@objc private func keyboardWillShow(_ notification: Notification) {
if let keyboardFrame = notification.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect {
keyboardHeight = keyboardFrame.height
reloadConstraints()
}
}
@objc private func keyboardWillHide(_ notification: Notification) {
keyboardHeight = 0
reloadConstraints()
}
override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
super.traitCollectionDidChange(previousTraitCollection)
if traitCollection.verticalSizeClass != previousTraitCollection?.verticalSizeClass {
reloadConstraints()
}
}
private func setupView(title: String?, subtitle: String?) {
if let activityIndicator = activityIndicator {
activityIndicator.hidesWhenStopped = true
activityIndicator.startAnimating()
activityIndicator.style = .medium
}
titleLabel.text = title
titleLabel.setFontStyle(.medium16, color: .blackPrimary)
titleLabel.textAlignment = .center
titleLabel.numberOfLines = 0
subtitleLabel.text = subtitle
subtitleLabel.setFontStyle(.regular14, color: .blackSecondary)
subtitleLabel.textAlignment = .center
subtitleLabel.isHidden = subtitle == nil
subtitleLabel.numberOfLines = 0
stackView.axis = .vertical
stackView.alignment = .fill
stackView.spacing = 8
}
private func layoutView() {
if let activityIndicator = activityIndicator {
stackView.addArrangedSubview(activityIndicator)
}
if let title = titleLabel.text, !title.isEmpty {
stackView.addArrangedSubview(titleLabel)
}
if let subtitle = subtitleLabel.text, !subtitle.isEmpty {
stackView.addArrangedSubview(subtitleLabel)
}
addSubview(stackView)
stackView.translatesAutoresizingMaskIntoConstraints = false
centerYConstraint = stackView.centerYAnchor.constraint(equalTo: centerYAnchor)
NSLayoutConstraint.activate([
stackView.centerXAnchor.constraint(equalTo: centerXAnchor),
stackView.widthAnchor.constraint(lessThanOrEqualTo: widthAnchor, multiplier: 0.8),
centerYConstraint
])
}
private func reloadConstraints() {
let offset = keyboardHeight > 0 ? max(bounds.height / 2 - keyboardHeight, minOffsetFromTheKeyboardTop + stackView.frame.height) : containerModalYTranslation / 2
let maxOffset = bounds.height / 2 - maxOffsetFromTheTop
centerYConstraint.constant = -min(offset, maxOffset)
UIView.animate(withDuration: kDefaultAnimationDuration, delay: .zero, options: [.beginFromCurrentState, .curveEaseOut]) {
self.layoutIfNeeded()
}
}
}
// MARK: - ModallyPresentedViewController
extension PlaceholderView: ModallyPresentedViewController {
func presentationFrameDidChange(_ frame: CGRect) {
self.containerModalYTranslation = frame.origin.y
reloadConstraints()
}
}

View file

@ -0,0 +1,49 @@
enum PresentationStepChangeAnimation {
case none
case slide
case slideAndBounce
}
final class ModalPresentationAnimator {
private enum Constants {
static let animationDuration: TimeInterval = kDefaultAnimationDuration
static let springDamping: CGFloat = 0.8
static let springVelocity: CGFloat = 0.2
static let controlPoint1: CGPoint = CGPoint(x: 0.25, y: 0.1)
static let controlPoint2: CGPoint = CGPoint(x: 0.15, y: 1.0)
}
static func animate(with stepAnimation: PresentationStepChangeAnimation = .slide,
animations: @escaping (() -> Void),
completion: ((Bool) -> Void)?) {
switch stepAnimation {
case .none:
animations()
completion?(true)
case .slide:
let timing = UICubicTimingParameters(controlPoint1: Constants.controlPoint1,
controlPoint2: Constants.controlPoint2)
let animator = UIViewPropertyAnimator(duration: Constants.animationDuration,
timingParameters: timing)
animator.addAnimations(animations)
animator.addCompletion { position in
completion?(position == .end)
}
animator.startAnimation()
case .slideAndBounce:
let velocity = CGVector(dx: Constants.springVelocity, dy: Constants.springVelocity)
let timing = UISpringTimingParameters(dampingRatio: Constants.springDamping,
initialVelocity: velocity)
let animator = UIViewPropertyAnimator(duration: Constants.animationDuration,
timingParameters: timing)
animator.addAnimations(animations)
animator.addCompletion { position in
completion?(position == .end)
}
animator.startAnimation()
}
}
}

View file

@ -0,0 +1,97 @@
enum ModalPresentationStep: Int, CaseIterable {
case fullScreen
case halfScreen
case compact
case hidden
}
extension ModalPresentationStep {
private enum Constants {
static let iPadWidth: CGFloat = 350
static let compactHeightOffset: CGFloat = 120
static let halfScreenHeightFactorPortrait: CGFloat = 0.55
static let topInset: CGFloat = 8
}
var upper: ModalPresentationStep {
switch self {
case .fullScreen:
return .fullScreen
case .halfScreen:
return .fullScreen
case .compact:
return .halfScreen
case .hidden:
return .compact
}
}
var lower: ModalPresentationStep {
switch self {
case .fullScreen:
return .halfScreen
case .halfScreen:
return .compact
case .compact:
return .compact
case .hidden:
return .hidden
}
}
var first: ModalPresentationStep {
.fullScreen
}
var last: ModalPresentationStep {
.compact
}
func frame(for presentedView: UIView, in containerViewController: UIViewController) -> CGRect {
let isIPad = UIDevice.current.userInterfaceIdiom == .pad
var containerSize = containerViewController.view.bounds.size
if containerSize == .zero {
containerSize = UIScreen.main.bounds.size
}
let safeAreaInsets = containerViewController.view.safeAreaInsets
let traitCollection = containerViewController.traitCollection
var frame = CGRect(origin: .zero, size: containerSize)
if isIPad {
frame.size.width = Constants.iPadWidth
switch self {
case .hidden:
frame.origin.x = -Constants.iPadWidth
default:
frame.origin.x = .zero
}
return frame
}
let isPortraitOrientation = traitCollection.verticalSizeClass == .regular
if isPortraitOrientation {
switch self {
case .fullScreen:
frame.origin.y = safeAreaInsets.top + Constants.topInset
case .halfScreen:
frame.origin.y = containerSize.height * Constants.halfScreenHeightFactorPortrait
case .compact:
frame.origin.y = containerSize.height - Constants.compactHeightOffset
case .hidden:
frame.origin.y = containerSize.height
}
} else {
frame.size.width = Constants.iPadWidth
frame.origin.x = safeAreaInsets.left
switch self {
case .fullScreen:
frame.origin.y = Constants.topInset
case .halfScreen, .compact:
frame.origin.y = containerSize.height - Constants.compactHeightOffset
case .hidden:
frame.origin.y = containerSize.height
}
}
return frame
}
}

View file

@ -0,0 +1,114 @@
final class ModalPresentationStepsController {
enum StepUpdate {
case didClose
case didUpdateFrame(CGRect)
case didUpdateStep(ModalPresentationStep)
}
fileprivate enum Constants {
static let slowSwipeVelocity: CGFloat = 500
static let fastSwipeDownVelocity: CGFloat = 4000
static let fastSwipeUpVelocity: CGFloat = 3000
static let translationThreshold: CGFloat = 50
}
private weak var presentedView: UIView?
private weak var containerViewController: UIViewController?
private var initialTranslationY: CGFloat = .zero
private(set) var currentStep: ModalPresentationStep = .fullScreen
private(set) var maxAvailableFrame: CGRect = .zero
var currentFrame: CGRect { frame(for: currentStep) }
var hiddenFrame: CGRect { frame(for: .hidden) }
var didUpdateHandler: ((StepUpdate) -> Void)?
func set(presentedView: UIView, containerViewController: UIViewController) {
self.presentedView = presentedView
self.containerViewController = containerViewController
}
func setInitialState() {
setStep(.hidden, animation: .none)
}
func close(completion: (() -> Void)? = nil) {
setStep(.hidden, animation: .slide, completion: completion)
}
func updateMaxAvailableFrame() {
maxAvailableFrame = frame(for: .fullScreen)
}
func handlePan(_ gesture: UIPanGestureRecognizer) {
guard let presentedView else { return }
let translation = gesture.translation(in: presentedView)
let velocity = gesture.velocity(in: presentedView)
var currentFrame = presentedView.frame
switch gesture.state {
case .began:
initialTranslationY = presentedView.frame.origin.y
case .changed:
let newY = max(max(initialTranslationY + translation.y, 0), maxAvailableFrame.origin.y)
currentFrame.origin.y = newY
presentedView.frame = currentFrame
didUpdateHandler?(.didUpdateFrame(currentFrame))
case .ended:
let nextStep: ModalPresentationStep
if velocity.y > Constants.fastSwipeDownVelocity {
didUpdateHandler?(.didClose)
return
} else if velocity.y < -Constants.fastSwipeUpVelocity {
nextStep = .fullScreen
} else if velocity.y > Constants.slowSwipeVelocity || translation.y > Constants.translationThreshold {
if currentStep == .compact {
didUpdateHandler?(.didClose)
return
}
nextStep = currentStep.lower
} else if velocity.y < -Constants.slowSwipeVelocity || translation.y < -Constants.translationThreshold {
nextStep = currentStep.upper
} else {
nextStep = currentStep
}
let animation: PresentationStepChangeAnimation = abs(velocity.y) > Constants.slowSwipeVelocity ? .slideAndBounce : .slide
setStep(nextStep, animation: animation)
default:
break
}
}
func setStep(_ step: ModalPresentationStep,
completion: (() -> Void)? = nil) {
guard currentStep != step else { return }
setStep(step, animation: .slide, completion: completion)
}
private func setStep(_ step: ModalPresentationStep,
animation: PresentationStepChangeAnimation,
completion: (() -> Void)? = nil) {
guard let presentedView else { return }
currentStep = step
updateMaxAvailableFrame()
let frame = frame(for: step)
didUpdateHandler?(.didUpdateStep(step))
didUpdateHandler?(.didUpdateFrame(frame))
ModalPresentationAnimator.animate(with: animation) {
presentedView.frame = frame
} completion: { _ in
completion?()
}
}
private func frame(for step: ModalPresentationStep) -> CGRect {
guard let presentedView, let containerViewController else { return .zero }
return step.frame(for: presentedView, in: containerViewController)
}
}

View file

@ -0,0 +1,9 @@
final class SearchOnMapAreaView: UIView {
override var sideButtonsAreaAffectDirections: MWMAvailableAreaAffectDirections {
alternative(iPhone: .bottom, iPad: [])
}
override var trafficButtonAreaAffectDirections: MWMAvailableAreaAffectDirections {
alternative(iPhone: .bottom, iPad: [])
}
}

View file

@ -0,0 +1,151 @@
protocol SearchOnMapHeaderViewDelegate: UISearchBarDelegate {
func cancelButtonDidTap()
func grabberDidTap()
}
final class SearchOnMapHeaderView: UIView {
weak var delegate: SearchOnMapHeaderViewDelegate? {
didSet {
searchBar.delegate = delegate
}
}
private enum Constants {
static let searchBarHeight: CGFloat = 36
static let searchBarInsets: UIEdgeInsets = UIEdgeInsets(top: 8, left: 10, bottom: 10, right: 0)
static let grabberHeight: CGFloat = 5
static let grabberWidth: CGFloat = 36
static let grabberTopMargin: CGFloat = 5
static let cancelButtonInsets: UIEdgeInsets = UIEdgeInsets(top: 0, left: 6, bottom: 0, right: 16)
}
private let grabberView = UIView()
private let grabberTapHandlerView = UIView()
private let searchBar = UISearchBar()
private let cancelButton = UIButton()
private let cancelContainer = UIView()
private var separator: UIView?
override init(frame: CGRect) {
super.init(frame: frame)
setupView()
layoutView()
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
private func setupView() {
setStyle(.background)
setupGrabberView()
setupGrabberTapHandlerView()
setupSearchBar()
setupCancelButton()
}
private func setupGrabberView() {
grabberView.setStyle(.grabber)
iPadSpecific { [weak self] in
self?.grabberView.isHidden = true
}
}
private func setupGrabberTapHandlerView() {
grabberTapHandlerView.backgroundColor = .clear
iPhoneSpecific {
let tapGesture = UITapGestureRecognizer(target: self, action: #selector(grabberDidTap))
grabberTapHandlerView.addGestureRecognizer(tapGesture)
}
}
private func setupSearchBar() {
searchBar.setStyle(.defaultSearchBar)
searchBar.placeholder = L("search")
searchBar.showsCancelButton = false
searchBar.searchTextField.clearButtonMode = .always
searchBar.returnKeyType = .search
searchBar.searchTextField.enablesReturnKeyAutomatically = true
searchBar.backgroundImage = UIImage()
}
private func setupCancelButton() {
cancelContainer.setStyle(.background)
cancelButton.setStyle(.searchCancelButton)
cancelButton.setTitle(L("cancel"), for: .normal)
cancelButton.addTarget(self, action: #selector(cancelButtonDidTap), for: .touchUpInside)
}
private func layoutView() {
addSubview(grabberView)
addSubview(grabberTapHandlerView)
addSubview(searchBar)
addSubview(cancelContainer)
cancelContainer.addSubview(cancelButton)
separator = addSeparator(.bottom)
grabberView.translatesAutoresizingMaskIntoConstraints = false
grabberTapHandlerView.translatesAutoresizingMaskIntoConstraints = false
grabberTapHandlerView.setContentHuggingPriority(.defaultLow, for: .vertical)
searchBar.translatesAutoresizingMaskIntoConstraints = false
cancelContainer.translatesAutoresizingMaskIntoConstraints = false
cancelButton.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
grabberView.topAnchor.constraint(equalTo: topAnchor, constant: Constants.grabberTopMargin),
grabberView.centerXAnchor.constraint(equalTo: centerXAnchor),
grabberView.widthAnchor.constraint(equalToConstant: Constants.grabberWidth),
grabberView.heightAnchor.constraint(equalToConstant: Constants.grabberHeight),
grabberTapHandlerView.topAnchor.constraint(equalTo: grabberView.bottomAnchor),
grabberTapHandlerView.leadingAnchor.constraint(equalTo: leadingAnchor),
grabberTapHandlerView.trailingAnchor.constraint(equalTo: trailingAnchor),
grabberTapHandlerView.bottomAnchor.constraint(equalTo: searchBar.topAnchor),
searchBar.topAnchor.constraint(equalTo: grabberView.bottomAnchor, constant: Constants.searchBarInsets.top),
searchBar.leadingAnchor.constraint(equalTo: leadingAnchor, constant: Constants.searchBarInsets.left),
searchBar.trailingAnchor.constraint(equalTo: cancelContainer.leadingAnchor),
searchBar.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -Constants.searchBarInsets.bottom),
searchBar.heightAnchor.constraint(equalToConstant: Constants.searchBarHeight),
cancelContainer.trailingAnchor.constraint(equalTo: trailingAnchor),
cancelContainer.topAnchor.constraint(equalTo: searchBar.topAnchor),
cancelContainer.bottomAnchor.constraint(equalTo: searchBar.bottomAnchor),
cancelButton.topAnchor.constraint(equalTo: cancelContainer.topAnchor),
cancelButton.leadingAnchor.constraint(equalTo: cancelContainer.leadingAnchor, constant: Constants.cancelButtonInsets.left),
cancelButton.trailingAnchor.constraint(equalTo: cancelContainer.trailingAnchor, constant: -Constants.cancelButtonInsets.right),
cancelButton.bottomAnchor.constraint(equalTo: cancelContainer.bottomAnchor),
])
}
@objc private func grabberDidTap() {
delegate?.grabberDidTap()
}
@objc private func cancelButtonDidTap() {
delegate?.cancelButtonDidTap()
}
func setSearchText(_ text: String) {
searchBar.text = text
}
func setIsSearching(_ isSearching: Bool) {
if isSearching {
searchBar.becomeFirstResponder()
} else if searchBar.isFirstResponder {
searchBar.resignFirstResponder()
}
}
var searchQuery: SearchQuery {
SearchQuery(searchBar.text ?? "", locale: searchBar.textInputMode?.primaryLanguage, source: .typedText)
}
func setSeparatorHidden(_ hidden: Bool) {
separator?.isHidden = hidden
}
}

View file

@ -0,0 +1,176 @@
final class SearchOnMapInteractor: NSObject {
private let presenter: SearchOnMapPresenter
private let searchManager: SearchManager.Type
private let routeManager: MWMRouter.Type
private var isUpdatesDisabled = false
var routingTooltipSearch: SearchOnMapRoutingTooltipSearch = .none
init(presenter: SearchOnMapPresenter,
searchManager: SearchManager.Type = Search.self,
routeManager: MWMRouter.Type = MWMRouter.self) {
self.presenter = presenter
self.searchManager = searchManager
self.routeManager = routeManager
super.init()
searchManager.add(self)
}
deinit {
searchManager.remove(self)
}
func handle(_ event: SearchOnMap.Request) {
let response = resolve(event)
presenter.process(response)
}
private func resolve(_ event: SearchOnMap.Request) -> SearchOnMap.Response {
switch event {
case .openSearch:
return .showHistoryAndCategory
case .hideSearch:
return .setSearchScreenHidden(true)
case .didStartDraggingSearch:
return .setIsTyping(false)
case .didStartTyping:
return .setIsTyping(true)
case .didType(let searchText):
return processTypedText(searchText)
case .clearButtonDidTap:
return processClearButtonDidTap()
case .didSelect(let searchText):
return processSelectedText(searchText)
case .searchButtonDidTap(let searchText):
return processSearchButtonDidTap(searchText)
case .didSelectResult(let result, let query):
return processSelectedResult(result, query: query)
case .didSelectPlaceOnMap:
return isiPad ? .none : .setSearchScreenHidden(true)
case .didDeselectPlaceOnMap:
return deselectPlaceOnMap()
case .didStartDraggingMap:
return .setSearchScreenCompact
case .didUpdatePresentationStep(let step):
searchManager.setSearchMode(searchModeForPresentationStep(step))
return .updatePresentationStep(step)
case .closeSearch:
return closeSearch()
}
}
private func processClearButtonDidTap() -> SearchOnMap.Response {
isUpdatesDisabled = true
searchManager.clear()
return .clearSearch
}
private func processSearchButtonDidTap(_ query: SearchQuery) -> SearchOnMap.Response {
searchManager.save(query)
return .showOnTheMap
}
private func processTypedText(_ query: SearchQuery) -> SearchOnMap.Response {
isUpdatesDisabled = false
searchManager.searchQuery(query)
return .startSearching
}
private func processSelectedText(_ query: SearchQuery) -> SearchOnMap.Response {
isUpdatesDisabled = false
if query.source != .history {
searchManager.save(query)
}
searchManager.searchQuery(query)
return .selectQuery(query)
}
private func processSelectedResult(_ result: SearchResult, query: SearchQuery) -> SearchOnMap.Response {
switch result.itemType {
case .regular:
searchManager.save(query)
switch routingTooltipSearch {
case .none:
searchManager.showResult(at: result.index)
case .start:
let point = MWMRoutePoint(cgPoint: result.point,
title: result.titleText,
subtitle: result.addressText,
type: .start,
intermediateIndex: 0)
routeManager.build(from: point, bestRouter: false)
case .finish:
let point = MWMRoutePoint(cgPoint: result.point,
title: result.titleText,
subtitle: result.addressText,
type: .finish,
intermediateIndex: 0)
routeManager.build(to: point, bestRouter: false)
@unknown default:
fatalError("Unsupported routingTooltipSearch")
}
return isiPad ? .none : .setSearchScreenHidden(true)
case .suggestion:
let suggestionQuery = SearchQuery(result.suggestion,
locale: query.locale,
source: result.isPureSuggest ? .suggestion : .typedText)
searchManager.searchQuery(suggestionQuery)
return .selectQuery(suggestionQuery)
@unknown default:
fatalError("Unsupported result type")
}
}
private func deselectPlaceOnMap() -> SearchOnMap.Response {
routingTooltipSearch = .none
return .setSearchScreenHidden(false)
}
private func closeSearch() -> SearchOnMap.Response {
routingTooltipSearch = .none
isUpdatesDisabled = true
searchManager.clear()
return .close
}
private func searchModeForPresentationStep(_ step: ModalPresentationStep) -> SearchMode {
switch step {
case .fullScreen:
return isiPad ? .everywhereAndViewport : .everywhere
case .halfScreen, .compact:
return .everywhereAndViewport
case .hidden:
return .viewport
}
}
}
// MARK: - MWMSearchObserver
extension SearchOnMapInteractor: MWMSearchObserver {
func onSearchCompleted() {
guard !isUpdatesDisabled, searchManager.searchMode() != .viewport else { return }
let results = searchManager.getResults()
presenter.process(.showResults(SearchOnMap.SearchResults(results), isSearchCompleted: true))
}
func onSearchResultsUpdated() {
guard !isUpdatesDisabled, searchManager.searchMode() != .viewport else { return }
let results = searchManager.getResults()
guard !results.isEmpty else { return }
presenter.process(.showResults(SearchOnMap.SearchResults(results), isSearchCompleted: false))
}
}

View file

@ -0,0 +1,92 @@
@objc
enum SearchOnMapState: Int {
case searching
case hidden
case closed
}
@objc
enum SearchOnMapRoutingTooltipSearch: Int {
case none
case start
case finish
}
@objc
protocol SearchOnMapManagerObserver: AnyObject {
func searchManager(didChangeState state: SearchOnMapState)
}
@objcMembers
final class SearchOnMapManager: NSObject {
private var interactor: SearchOnMapInteractor? { viewController?.interactor }
private let observers = ListenerContainer<SearchOnMapManagerObserver>()
weak var viewController: SearchOnMapViewController?
var isSearching: Bool { viewController != nil }
override init() {
super.init()
}
// MARK: - Public methods
func startSearching(isRouting: Bool) {
if viewController != nil {
interactor?.handle(.openSearch)
return
}
FrameworkHelper.deactivateMapSelection()
let viewController = SearchOnMapViewControllerBuilder.build(isRouting: isRouting,
didChangeState: notifyObservers)
self.viewController = viewController
}
func hide() {
interactor?.handle(.hideSearch)
}
func close() {
interactor?.handle(.closeSearch)
}
func setRoutingTooltip(_ tooltip: SearchOnMapRoutingTooltipSearch) {
interactor?.routingTooltipSearch = tooltip
}
func setPlaceOnMapSelected(_ isSelected: Bool) {
interactor?.handle(isSelected ? .didSelectPlaceOnMap : .didDeselectPlaceOnMap)
}
func setMapIsDragging() {
interactor?.handle(.didStartDraggingMap)
}
func searchText(_ searchText: SearchQuery) {
interactor?.handle(.didSelect(searchText))
}
func addObserver(_ observer: SearchOnMapManagerObserver) {
observers.addListener(observer)
}
func removeObserver(_ observer: SearchOnMapManagerObserver) {
observers.removeListener(observer)
}
private func notifyObservers(_ state: SearchOnMapState) {
observers.forEach { observer in observer.searchManager(didChangeState: state) }
}
}
private struct SearchOnMapViewControllerBuilder {
static func build(isRouting: Bool, didChangeState: @escaping ((SearchOnMapState) -> Void)) -> SearchOnMapViewController {
let viewController = SearchOnMapViewController()
let presenter = SearchOnMapPresenter(isRouting: isRouting,
didChangeState: didChangeState)
let interactor = SearchOnMapInteractor(presenter: presenter)
presenter.view = viewController
viewController.interactor = interactor
viewController.show()
return viewController
}
}

View file

@ -0,0 +1,97 @@
@objcMembers
final class SearchQuery: NSObject {
let text: String
let locale: String
let source: SearchTextSource
init(_ text: String, locale: String? = nil, source: SearchTextSource) {
self.text = text
self.locale = locale ?? AppInfo.shared().languageId
self.source = source
}
}
enum SearchOnMap {
struct ViewModel: Equatable {
enum Content: Equatable {
case historyAndCategory
case results(SearchResults)
case noResults
case searching
}
var isTyping: Bool
var skipSuggestions: Bool
var searchingText: String?
var contentState: Content
var presentationStep: ModalPresentationStep
}
struct SearchResults: Equatable {
let results: [SearchResult]
let hasPartialMatch: Bool
let isEmpty: Bool
let count: Int
let suggestionsCount: Int
init(_ results: [SearchResult]) {
self.results = results
self.hasPartialMatch = !results.allSatisfy { $0.highlightRanges.isEmpty }
self.isEmpty = results.isEmpty
self.count = results.count
self.suggestionsCount = results.filter { $0.itemType == .suggestion }.count
}
}
enum Request {
case openSearch
case hideSearch
case closeSearch
case didStartDraggingSearch
case didStartDraggingMap
case didStartTyping
case didType(SearchQuery)
case didSelect(SearchQuery)
case didSelectResult(SearchResult, withQuery: SearchQuery)
case searchButtonDidTap(SearchQuery)
case clearButtonDidTap
case didSelectPlaceOnMap
case didDeselectPlaceOnMap
case didUpdatePresentationStep(ModalPresentationStep)
}
enum Response: Equatable {
case startSearching
case showOnTheMap
case setIsTyping(Bool)
case showHistoryAndCategory
case showResults(SearchResults, isSearchCompleted: Bool = false)
case selectQuery(SearchQuery)
case clearSearch
case setSearchScreenHidden(Bool)
case setSearchScreenCompact
case updatePresentationStep(ModalPresentationStep)
case close
case none
}
}
extension SearchOnMap.SearchResults {
static let empty = SearchOnMap.SearchResults([])
subscript(index: Int) -> SearchResult {
results[index]
}
mutating func skipSuggestions() {
self = SearchOnMap.SearchResults(results.filter { $0.itemType != .suggestion })
}
}
extension SearchOnMap.ViewModel {
static let initial = SearchOnMap.ViewModel(isTyping: false,
skipSuggestions: false,
searchingText: nil,
contentState: .historyAndCategory,
presentationStep: .fullScreen)
}

View file

@ -0,0 +1,124 @@
final class SearchOnMapPresenter {
typealias Response = SearchOnMap.Response
typealias ViewModel = SearchOnMap.ViewModel
weak var view: SearchOnMapView?
private var searchState: SearchOnMapState = .searching {
didSet {
guard searchState != oldValue else { return }
didChangeState?(searchState)
}
}
private var viewModel: ViewModel = .initial
private var isRouting: Bool
private var didChangeState: ((SearchOnMapState) -> Void)?
init(isRouting: Bool, didChangeState: ((SearchOnMapState) -> Void)?) {
self.isRouting = isRouting
self.didChangeState = didChangeState
didChangeState?(searchState)
}
func process(_ response: SearchOnMap.Response) {
guard response != .none else { return }
if response == .close {
view?.close()
searchState = .closed
return
}
let showSearch = response == .setSearchScreenHidden(false) || response == .showHistoryAndCategory
guard viewModel.presentationStep != .hidden || showSearch else {
return
}
let newViewModel = resolve(action: response, with: viewModel)
if viewModel != newViewModel {
viewModel = newViewModel
view?.render(newViewModel)
searchState = newViewModel.presentationStep.searchState
}
}
private func resolve(action: Response, with previousViewModel: ViewModel) -> ViewModel {
var viewModel = previousViewModel
viewModel.searchingText = nil // should not be nil only when the text is passed to the search field
switch action {
case .startSearching:
viewModel.isTyping = true
viewModel.skipSuggestions = false
viewModel.contentState = .searching
case .showOnTheMap:
viewModel.isTyping = false
viewModel.skipSuggestions = true
viewModel.presentationStep = isRouting ? .hidden : .halfScreen
if case .results(var results) = viewModel.contentState, !results.isEmpty {
results.skipSuggestions()
viewModel.contentState = .results(results)
}
case .setIsTyping(let isSearching):
viewModel.isTyping = isSearching
if isSearching {
viewModel.presentationStep = .fullScreen
}
case .showHistoryAndCategory:
viewModel.isTyping = true
viewModel.contentState = .historyAndCategory
viewModel.presentationStep = .fullScreen
case .showResults(var searchResults, let isSearchCompleted):
if (viewModel.skipSuggestions) {
searchResults.skipSuggestions()
}
viewModel.contentState = searchResults.isEmpty && isSearchCompleted ? .noResults : .results(searchResults)
case .selectQuery(let query):
viewModel.skipSuggestions = false
viewModel.searchingText = query.text
viewModel.contentState = .searching
switch query.source {
case .typedText, .suggestion:
viewModel.isTyping = true
case .category, .history, .deeplink:
viewModel.isTyping = false
viewModel.presentationStep = isRouting ? .hidden : .halfScreen
@unknown default:
fatalError("Unknown search text source")
}
case .clearSearch:
viewModel.searchingText = ""
viewModel.isTyping = true
viewModel.skipSuggestions = false
viewModel.contentState = .historyAndCategory
viewModel.presentationStep = .fullScreen
case .setSearchScreenHidden(let isHidden):
viewModel.isTyping = false
viewModel.presentationStep = isHidden ? .hidden : (isRouting ? .fullScreen : .halfScreen)
case .setSearchScreenCompact:
viewModel.isTyping = false
viewModel.presentationStep = .compact
case .updatePresentationStep(let step):
if step == .hidden {
viewModel.isTyping = false
}
viewModel.presentationStep = step
case .close, .none:
break
}
return viewModel
}
}
private extension ModalPresentationStep {
var searchState: SearchOnMapState {
switch self {
case .fullScreen, .halfScreen, .compact:
return .searching
case .hidden:
return .hidden
}
}
}

View file

@ -0,0 +1,498 @@
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
}
}
}