Repo created
This commit is contained in:
parent
4af19165ec
commit
68073add76
12458 changed files with 12350765 additions and 2 deletions
121
iphone/Maps/UI/Search/SearchOnMap/PlaceholderView.swift
Normal file
121
iphone/Maps/UI/Search/SearchOnMap/PlaceholderView.swift
Normal 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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
final class SearchOnMapAreaView: UIView {
|
||||
override var sideButtonsAreaAffectDirections: MWMAvailableAreaAffectDirections {
|
||||
alternative(iPhone: .bottom, iPad: [])
|
||||
}
|
||||
|
||||
override var trafficButtonAreaAffectDirections: MWMAvailableAreaAffectDirections {
|
||||
alternative(iPhone: .bottom, iPad: [])
|
||||
}
|
||||
}
|
||||
151
iphone/Maps/UI/Search/SearchOnMap/SearchOnMapHeaderView.swift
Normal file
151
iphone/Maps/UI/Search/SearchOnMap/SearchOnMapHeaderView.swift
Normal 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
|
||||
}
|
||||
}
|
||||
176
iphone/Maps/UI/Search/SearchOnMap/SearchOnMapInteractor.swift
Normal file
176
iphone/Maps/UI/Search/SearchOnMap/SearchOnMapInteractor.swift
Normal 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))
|
||||
}
|
||||
}
|
||||
92
iphone/Maps/UI/Search/SearchOnMap/SearchOnMapManager.swift
Normal file
92
iphone/Maps/UI/Search/SearchOnMap/SearchOnMapManager.swift
Normal 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
|
||||
}
|
||||
}
|
||||
97
iphone/Maps/UI/Search/SearchOnMap/SearchOnMapModels.swift
Normal file
97
iphone/Maps/UI/Search/SearchOnMap/SearchOnMapModels.swift
Normal 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)
|
||||
}
|
||||
124
iphone/Maps/UI/Search/SearchOnMap/SearchOnMapPresenter.swift
Normal file
124
iphone/Maps/UI/Search/SearchOnMap/SearchOnMapPresenter.swift
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue