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,5 @@
@interface MWMSearchNoResults : UIView
+ (instancetype)viewWithImage:(UIImage *)image title:(NSString *)title text:(NSString *)text;
@end

View file

@ -0,0 +1,37 @@
#import "MWMSearchNoResults.h"
static CGFloat const kCompactHeight = 216;
static CGFloat const kExtraCompactHeight = 52;
@interface MWMSearchNoResults ()
@property(weak, nonatomic) IBOutlet UILabel * title;
@property(weak, nonatomic) IBOutlet UILabel * text;
@property(weak, nonatomic) IBOutlet NSLayoutConstraint * textCenterY;
@end
@implementation MWMSearchNoResults
+ (instancetype)viewWithImage:(UIImage *)image title:(NSString *)title text:(NSString *)text {
MWMSearchNoResults * view =
[NSBundle.mainBundle loadNibNamed:[self className] owner:nil options:nil].firstObject;
if (title) {
view.title.text = title;
} else {
[view.title removeFromSuperview];
}
view.text.text = text;
return view;
}
- (void)layoutSubviews {
[super layoutSubviews];
self.frame = self.superview.bounds;
BOOL compact = self.height < kCompactHeight;
self.textCenterY.priority = compact ? UILayoutPriorityDefaultHigh : UILayoutPriorityFittingSizeLevel;
BOOL extraCompact = self.height < kExtraCompactHeight;
self.title.hidden = extraCompact;
}
@end

View file

@ -0,0 +1,58 @@
<?xml version="1.0" encoding="UTF-8"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="18122" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" colorMatched="YES">
<device id="retina4_7" orientation="portrait" appearance="light"/>
<dependencies>
<deployment identifier="iOS"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="18093"/>
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
</dependencies>
<objects>
<placeholder placeholderIdentifier="IBFilesOwner" id="-1" userLabel="File's Owner"/>
<placeholder placeholderIdentifier="IBFirstResponder" id="-2" customClass="UIResponder"/>
<view contentMode="scaleToFill" id="iN0-l3-epB" customClass="MWMSearchNoResults" propertyAccessControl="none">
<rect key="frame" x="0.0" y="0.0" width="320" height="250"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<subviews>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" horizontalCompressionResistancePriority="1000" verticalCompressionResistancePriority="1000" text="Label" textAlignment="center" lineBreakMode="tailTruncation" numberOfLines="0" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" preferredMaxLayoutWidth="288" translatesAutoresizingMaskIntoConstraints="NO" id="vGi-FH-krh" propertyAccessControl="none">
<rect key="frame" x="16" y="82.5" width="288" height="20.5"/>
<fontDescription key="fontDescription" type="system" pointSize="17"/>
<color key="textColor" systemColor="darkTextColor"/>
<nil key="highlightedColor"/>
<userDefinedRuntimeAttributes>
<userDefinedRuntimeAttribute type="string" keyPath="styleName" value="blackPrimaryText:medium18"/>
</userDefinedRuntimeAttributes>
</label>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" horizontalCompressionResistancePriority="1000" verticalCompressionResistancePriority="1000" text="Label" textAlignment="center" lineBreakMode="tailTruncation" numberOfLines="0" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" preferredMaxLayoutWidth="288" translatesAutoresizingMaskIntoConstraints="NO" id="2Z7-NG-6sZ" propertyAccessControl="none">
<rect key="frame" x="16" y="115" width="288" height="20.5"/>
<fontDescription key="fontDescription" type="system" pointSize="17"/>
<color key="textColor" systemColor="darkTextColor"/>
<nil key="highlightedColor"/>
<userDefinedRuntimeAttributes>
<userDefinedRuntimeAttribute type="string" keyPath="styleName" value="regular14:blackSecondaryText"/>
</userDefinedRuntimeAttributes>
</label>
</subviews>
<color key="backgroundColor" red="0.0" green="0.0" blue="0.0" alpha="0.0" colorSpace="custom" customColorSpace="sRGB"/>
<constraints>
<constraint firstAttribute="trailing" secondItem="vGi-FH-krh" secondAttribute="trailing" constant="16" id="1Ye-aw-qVm"/>
<constraint firstItem="2Z7-NG-6sZ" firstAttribute="centerY" secondItem="iN0-l3-epB" secondAttribute="centerY" priority="250" id="AK9-c1-mte"/>
<constraint firstItem="2Z7-NG-6sZ" firstAttribute="leading" secondItem="iN0-l3-epB" secondAttribute="leading" constant="16" id="Ucg-Kh-I5L"/>
<constraint firstItem="2Z7-NG-6sZ" firstAttribute="top" secondItem="vGi-FH-krh" secondAttribute="bottom" constant="12" id="XYx-js-SkS" userLabel="Text.top = centerY"/>
<constraint firstAttribute="trailing" secondItem="2Z7-NG-6sZ" secondAttribute="trailing" constant="16" id="ZH9-Do-kc8"/>
<constraint firstItem="vGi-FH-krh" firstAttribute="leading" secondItem="iN0-l3-epB" secondAttribute="leading" constant="16" id="lDj-iy-gS7"/>
</constraints>
<freeformSimulatedSizeMetrics key="simulatedDestinationMetrics"/>
<connections>
<outlet property="text" destination="2Z7-NG-6sZ" id="eaY-75-TDT"/>
<outlet property="textCenterY" destination="AK9-c1-mte" id="XbC-PV-37Y"/>
<outlet property="title" destination="vGi-FH-krh" id="11v-W7-Rpl"/>
</connections>
<point key="canvasLocation" x="139" y="154"/>
</view>
</objects>
<resources>
<systemColor name="darkTextColor">
<color white="0.0" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
</systemColor>
</resources>
</document>

View file

@ -0,0 +1,29 @@
final class SearchNoResultsViewController: MWMViewController {
static var controller: SearchNoResultsViewController {
let storyboard = UIStoryboard.instance(.main)
return storyboard.instantiateViewController(withIdentifier: toString(self)) as! SearchNoResultsViewController
}
@IBOutlet private weak var container: UIView!
@IBOutlet fileprivate weak var containerBottomOffset: NSLayoutConstraint!
override func viewDidLoad() {
super.viewDidLoad()
container.addSubview(MWMSearchNoResults.view(with: nil,
title: L("search_not_found"),
text: L("search_not_found_query")))
MWMKeyboard.add(self)
onKeyboardAnimation()
}
}
extension SearchNoResultsViewController: MWMKeyboardObserver {
func onKeyboardAnimation() {
containerBottomOffset.constant = MWMKeyboard.keyboardHeight()
view.layoutIfNeeded()
}
}

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
}
}
}

View file

@ -0,0 +1,11 @@
#import "MWMTableViewCell.h"
@class SearchResult;
static CGFloat const kSearchCellSeparatorInset = 48;
@interface MWMSearchCell : MWMTableViewCell
- (void)configureWith:(SearchResult * _Nonnull)result isPartialMatching:(BOOL)isPartialMatching;
@end

View file

@ -0,0 +1,68 @@
#import "MWMSearchCell.h"
#import "SearchResult.h"
@interface MWMSearchCell ()
@property (weak, nonatomic) IBOutlet UILabel * titleLabel;
@end
@implementation MWMSearchCell
- (void)configureWith:(SearchResult * _Nonnull)result isPartialMatching:(BOOL)isPartialMatching {
NSString * title = result.titleText;
if (title.length == 0)
{
self.titleLabel.text = @"";
return;
}
bool hasBranchName = (result.branchText && result.branchText.length > 0 && ![title containsString:result.branchText]);
NSDictionary * selectedTitleAttributes = [self selectedTitleAttributes];
NSDictionary * unselectedTitleAttributes = [self unselectedTitleAttributes];
if ((!selectedTitleAttributes || !unselectedTitleAttributes) && !hasBranchName)
{
self.titleLabel.text = title;
return;
}
NSMutableAttributedString * attributedTitle =
[[NSMutableAttributedString alloc] initWithString:title];
NSArray<NSValue *> *highlightRanges = result.highlightRanges;
[attributedTitle addAttributes:unselectedTitleAttributes range:NSMakeRange(0, title.length)];
// Add branch with thinner font weight if present and not already in title
if (hasBranchName) {
NSMutableDictionary * branchAttributes = [unselectedTitleAttributes mutableCopy];
[branchAttributes setValue:[UIFont regular17] forKey:NSFontAttributeName];
NSAttributedString * branchString = [[NSAttributedString alloc] initWithString:[NSString stringWithFormat:@" %@", result.branchText] attributes:branchAttributes];
[attributedTitle appendAttributedString:branchString];
}
for (NSValue *rangeValue in highlightRanges) {
NSRange range = [rangeValue rangeValue];
if (NSMaxRange(range) <= attributedTitle.string.length) {
[attributedTitle setAttributes:selectedTitleAttributes range:range];
} else {
NSLog(@"Incorrect range: %@ for string: %@", NSStringFromRange(range), attributedTitle.string);
}
}
self.titleLabel.attributedText = attributedTitle;
[self.titleLabel sizeToFit];
}
- (NSDictionary *)selectedTitleAttributes
{
return nil;
}
- (NSDictionary *)unselectedTitleAttributes
{
return nil;
}
@end

View file

@ -0,0 +1,10 @@
#import "MWMSearchCell.h"
@class SearchResult;
NS_SWIFT_NAME(SearchCommonCell)
@interface MWMSearchCommonCell : MWMSearchCell
- (void)configureWith:(SearchResult * _Nonnull)result isPartialMatching:(BOOL)isPartialMatching;
@end

View file

@ -0,0 +1,59 @@
#import "MWMSearchCommonCell.h"
#import "CLLocation+Mercator.h"
#import "MWMLocationManager.h"
#import "SwiftBridge.h"
#import "SearchResult.h"
@interface MWMSearchCommonCell ()
@property(weak, nonatomic) IBOutlet UILabel * distanceLabel;
@property(weak, nonatomic) IBOutlet UILabel * infoLabel;
@property(weak, nonatomic) IBOutlet UILabel * locationLabel;
@property(weak, nonatomic) IBOutlet UILabel * openLabel;
@property(weak, nonatomic) IBOutlet UIView * popularView;
@property(weak, nonatomic) IBOutlet UIImageView * iconImageView;
@end
@implementation MWMSearchCommonCell
- (void)configureWith:(SearchResult * _Nonnull)result isPartialMatching:(BOOL)isPartialMatching {
[super configureWith:result isPartialMatching:isPartialMatching];
self.locationLabel.text = result.addressText;
[self.locationLabel sizeToFit];
self.infoLabel.text = result.infoText;
self.distanceLabel.text = result.distanceText;
self.distanceLabel.textColor = [UIColor.labelColor colorWithAlphaComponent:0.7];
self.popularView.hidden = YES;
self.openLabel.text = result.openStatusText;
self.openLabel.textColor = result.openStatusColor;
[self.openLabel setHidden:result.openStatusText.length == 0];
[self setStyleNameAndApply:@"Background"];
if (result.iconImageName != nil) {
self.iconImageView.image = [[UIImage imageNamed:result.iconImageName] imageWithTintColor:UIColor.white];
}
self.iconImageView.backgroundColor = [UIColor colorNamed:@"Base Colors/Blue Color"];
self.separatorInset = UIEdgeInsetsMake(0, kSearchCellSeparatorInset, 0, 0);
}
- (void)layoutSubviews {
[super layoutSubviews];
self.iconImageView.image = [self.iconImageView.image imageWithTintColor:UIColor.white];
[self.iconImageView.layer setCornerRadius:self.iconImageView.height / 2];
}
- (NSDictionary *)selectedTitleAttributes {
return @{
NSForegroundColorAttributeName : [UIColor black],
NSFontAttributeName : [UIFont bold17]
};
}
- (NSDictionary *)unselectedTitleAttributes {
return @{
NSForegroundColorAttributeName : [UIColor blackPrimaryText],
NSFontAttributeName : [UIFont medium17]
};
}
@end

View file

@ -0,0 +1,145 @@
<?xml version="1.0" encoding="UTF-8"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="23727" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" colorMatched="YES">
<device id="retina6_1" orientation="portrait" appearance="light"/>
<dependencies>
<deployment identifier="iOS"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="23721"/>
<capability name="System colors in document resources" minToolsVersion="11.0"/>
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
</dependencies>
<objects>
<placeholder placeholderIdentifier="IBFilesOwner" id="-1" userLabel="File's Owner"/>
<placeholder placeholderIdentifier="IBFirstResponder" id="-2" customClass="UIResponder"/>
<tableViewCell contentMode="scaleToFill" selectionStyle="default" indentationWidth="10" reuseIdentifier="MWMSearchCommonCell" rowHeight="85" id="KGk-i7-Jjw" customClass="MWMSearchCommonCell">
<rect key="frame" x="0.0" y="0.0" width="321" height="85"/>
<autoresizingMask key="autoresizingMask" flexibleMaxX="YES" flexibleMaxY="YES"/>
<tableViewCellContentView key="contentView" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" tableViewCell="KGk-i7-Jjw" id="H2p-sc-9uM">
<rect key="frame" x="0.0" y="0.0" width="321" height="85"/>
<autoresizingMask key="autoresizingMask"/>
<subviews>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="252" verticalCompressionResistancePriority="752" text="New York Cafe" textAlignment="natural" lineBreakMode="wordWrap" numberOfLines="0" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="4FD-RE-ffF">
<rect key="frame" x="56" y="12" width="181" height="20.5"/>
<accessibility key="accessibilityConfiguration" identifier="searchTitle"/>
<fontDescription key="fontDescription" type="system" weight="medium" pointSize="17"/>
<color key="textColor" red="0.12941176470588234" green="0.12941176470588234" blue="0.12941176470588234" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
<nil key="highlightedColor"/>
<userDefinedRuntimeAttributes>
<userDefinedRuntimeAttribute type="string" keyPath="styleName" value="blackPrimaryText"/>
</userDefinedRuntimeAttributes>
</label>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="" textAlignment="right" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="P8X-Xp-AaE">
<rect key="frame" x="237" y="57" width="68" height="0.0"/>
<fontDescription key="fontDescription" type="system" weight="medium" pointSize="13"/>
<color key="textColor" red="0.12549019607843137" green="0.58823529411764708" blue="0.95294117647058818" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
<nil key="highlightedColor"/>
</label>
<view hidden="YES" contentMode="scaleToFill" ambiguous="YES" translatesAutoresizingMaskIntoConstraints="NO" id="uWz-7m-GUu">
<rect key="frame" x="262.5" y="16" width="41.5" height="20"/>
<subviews>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="TOP" textAlignment="center" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="yjT-ah-SWQ">
<rect key="frame" x="8" y="0.0" width="25.5" height="20"/>
<fontDescription key="fontDescription" type="boldSystem" pointSize="12"/>
<color key="textColor" red="0.1176470588" green="0.58823529409999997" blue="0.93983289930000002" alpha="1" colorSpace="calibratedRGB"/>
<nil key="highlightedColor"/>
<userDefinedRuntimeAttributes>
<userDefinedRuntimeAttribute type="string" keyPath="styleName" value="bold12:linkBlueText"/>
<userDefinedRuntimeAttribute type="string" keyPath="localizedText" value="popular_place"/>
</userDefinedRuntimeAttributes>
</label>
</subviews>
<color key="backgroundColor" red="0.14117647059999999" green="0.61176470589999998" blue="0.94901960780000005" alpha="0.16424978600000001" colorSpace="calibratedRGB"/>
<constraints>
<constraint firstAttribute="height" constant="20" id="g2X-lA-DE3"/>
<constraint firstAttribute="bottom" secondItem="yjT-ah-SWQ" secondAttribute="bottom" id="ghe-TD-Ni1"/>
<constraint firstItem="yjT-ah-SWQ" firstAttribute="top" secondItem="uWz-7m-GUu" secondAttribute="top" id="rK0-T0-I3J"/>
<constraint firstItem="yjT-ah-SWQ" firstAttribute="leading" secondItem="uWz-7m-GUu" secondAttribute="leading" constant="8" id="ucR-R6-Fyw"/>
<constraint firstAttribute="trailing" secondItem="yjT-ah-SWQ" secondAttribute="trailing" constant="8" id="vYZ-sk-v7u"/>
</constraints>
<userDefinedRuntimeAttributes>
<userDefinedRuntimeAttribute type="string" keyPath="styleName" value="SearchPopularView"/>
</userDefinedRuntimeAttributes>
</view>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Сafe" textAlignment="natural" lineBreakMode="tailTruncation" numberOfLines="0" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="5UO-MD-Hgx">
<rect key="frame" x="56" y="36.5" width="29" height="1"/>
<accessibility key="accessibilityConfiguration" identifier="searchType"/>
<constraints>
<constraint firstAttribute="width" relation="greaterThanOrEqual" constant="26" id="O31-Vq-Bsz"/>
</constraints>
<fontDescription key="fontDescription" type="system" weight="medium" pointSize="13"/>
<color key="textColor" red="0.0" green="0.0" blue="0.0" alpha="0.54000000000000004" colorSpace="custom" customColorSpace="sRGB"/>
<nil key="highlightedColor"/>
<userDefinedRuntimeAttributes>
<userDefinedRuntimeAttribute type="string" keyPath="styleName" value="medium13:blackSecondaryText"/>
</userDefinedRuntimeAttributes>
</label>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalCompressionResistancePriority="751" verticalCompressionResistancePriority="751" text="Open" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="SDd-3c-YeL">
<rect key="frame" x="271.5" y="21.5" width="33.5" height="16"/>
<accessibility key="accessibilityConfiguration" identifier="searchType"/>
<constraints>
<constraint firstAttribute="width" relation="greaterThanOrEqual" constant="26" id="tqr-8N-JwN"/>
</constraints>
<fontDescription key="fontDescription" type="system" weight="medium" pointSize="13"/>
<color key="textColor" red="0.0" green="0.0" blue="0.0" alpha="0.54000000000000004" colorSpace="custom" customColorSpace="sRGB"/>
<nil key="highlightedColor"/>
</label>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" verticalCompressionResistancePriority="751" text="Russia, Moscow &amp; Central, Moscow" textAlignment="natural" lineBreakMode="tailTruncation" numberOfLines="0" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="6pc-4s-GyP">
<rect key="frame" x="56" y="41.5" width="181" height="31.5"/>
<accessibility key="accessibilityConfiguration" identifier="searchSubTitle"/>
<fontDescription key="fontDescription" type="system" pointSize="13"/>
<color key="textColor" red="0.0" green="0.0" blue="0.0" alpha="0.54000000000000004" colorSpace="custom" customColorSpace="sRGB"/>
<nil key="highlightedColor"/>
<userDefinedRuntimeAttributes>
<userDefinedRuntimeAttribute type="string" keyPath="styleName" value="regular13:blackSecondaryText"/>
</userDefinedRuntimeAttributes>
</label>
<imageView clipsSubviews="YES" userInteractionEnabled="NO" contentMode="center" horizontalHuggingPriority="251" verticalHuggingPriority="251" translatesAutoresizingMaskIntoConstraints="NO" id="YIa-Hb-PGV" userLabel="Icon ImageView">
<rect key="frame" x="16" y="28.5" width="28" height="28"/>
<color key="backgroundColor" systemColor="linkColor"/>
<constraints>
<constraint firstAttribute="height" constant="28" id="fPn-mC-8Je"/>
<constraint firstAttribute="width" constant="28" id="sJ9-6S-sMG"/>
</constraints>
</imageView>
</subviews>
<color key="backgroundColor" white="0.0" alpha="0.0" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
<constraints>
<constraint firstItem="P8X-Xp-AaE" firstAttribute="leading" secondItem="6pc-4s-GyP" secondAttribute="trailing" id="0hr-QT-t0D"/>
<constraint firstItem="5UO-MD-Hgx" firstAttribute="top" secondItem="4FD-RE-ffF" secondAttribute="bottom" constant="4" id="5dn-ca-dCn"/>
<constraint firstItem="6pc-4s-GyP" firstAttribute="top" secondItem="5UO-MD-Hgx" secondAttribute="bottom" constant="4" id="7pm-XZ-vLK"/>
<constraint firstAttribute="bottom" secondItem="6pc-4s-GyP" secondAttribute="bottom" constant="12" id="HWe-cz-8hm"/>
<constraint firstItem="5UO-MD-Hgx" firstAttribute="trailing" relation="lessThanOrEqual" secondItem="SDd-3c-YeL" secondAttribute="leading" id="SJj-b5-T2k"/>
<constraint firstAttribute="trailing" secondItem="4FD-RE-ffF" secondAttribute="trailing" constant="84" id="Ugu-lP-b9G"/>
<constraint firstAttribute="trailing" secondItem="P8X-Xp-AaE" secondAttribute="trailing" constant="16" id="VJE-wo-TBb"/>
<constraint firstItem="YIa-Hb-PGV" firstAttribute="leading" secondItem="H2p-sc-9uM" secondAttribute="leading" constant="16" id="WRp-Ac-GmE"/>
<constraint firstItem="YIa-Hb-PGV" firstAttribute="centerY" secondItem="H2p-sc-9uM" secondAttribute="centerY" id="g6c-sx-War"/>
<constraint firstItem="5UO-MD-Hgx" firstAttribute="leading" secondItem="4FD-RE-ffF" secondAttribute="leading" id="gNK-ED-O8W"/>
<constraint firstItem="4FD-RE-ffF" firstAttribute="top" secondItem="H2p-sc-9uM" secondAttribute="top" constant="12" id="hM6-br-iKE"/>
<constraint firstItem="6pc-4s-GyP" firstAttribute="leading" secondItem="5UO-MD-Hgx" secondAttribute="leading" id="jvQ-jd-XUJ"/>
<constraint firstItem="4FD-RE-ffF" firstAttribute="leading" secondItem="YIa-Hb-PGV" secondAttribute="trailing" constant="12" id="nKc-RK-2zd"/>
<constraint firstAttribute="trailing" secondItem="6pc-4s-GyP" secondAttribute="trailing" constant="84" id="nfE-NI-LX9"/>
<constraint firstItem="6pc-4s-GyP" firstAttribute="baseline" secondItem="P8X-Xp-AaE" secondAttribute="firstBaseline" id="q7E-Jg-MYT"/>
<constraint firstAttribute="trailing" secondItem="SDd-3c-YeL" secondAttribute="trailing" constant="16" id="vay-ux-6dA"/>
<constraint firstItem="SDd-3c-YeL" firstAttribute="bottom" secondItem="5UO-MD-Hgx" secondAttribute="bottom" id="wXg-ce-SnG"/>
</constraints>
</tableViewCellContentView>
<userDefinedRuntimeAttributes>
<userDefinedRuntimeAttribute type="string" keyPath="styleName" value="Background"/>
</userDefinedRuntimeAttributes>
<connections>
<outlet property="distanceLabel" destination="P8X-Xp-AaE" id="Kaw-aR-8uJ"/>
<outlet property="iconImageView" destination="YIa-Hb-PGV" id="oQP-F5-9hw"/>
<outlet property="infoLabel" destination="5UO-MD-Hgx" id="lgJ-zE-omX"/>
<outlet property="locationLabel" destination="6pc-4s-GyP" id="Te0-y3-sVQ"/>
<outlet property="openLabel" destination="SDd-3c-YeL" id="5Rv-fO-g4x"/>
<outlet property="popularView" destination="uWz-7m-GUu" id="LAK-NA-Fea"/>
<outlet property="titleLabel" destination="4FD-RE-ffF" id="OQm-o8-LUd"/>
</connections>
<point key="canvasLocation" x="315.21739130434787" y="296.98660714285711"/>
</tableViewCell>
</objects>
<resources>
<systemColor name="linkColor">
<color red="0.0" green="0.47843137250000001" blue="1" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
</systemColor>
</resources>
</document>

View file

@ -0,0 +1,8 @@
#import "MWMSearchCell.h"
NS_SWIFT_NAME(SearchSuggestionCell)
@interface MWMSearchSuggestionCell : MWMSearchCell
@property (nonatomic) BOOL isLastCell;
@end

View file

@ -0,0 +1,36 @@
#import "MWMSearchSuggestionCell.h"
@interface MWMSearchSuggestionCell ()
@property (weak, nonatomic) IBOutlet UIImageView * icon;
@end
@implementation MWMSearchSuggestionCell
- (void)awakeFromNib
{
[super awakeFromNib];
if (IPAD)
self.contentView.backgroundColor = [UIColor white];
}
- (NSDictionary *)selectedTitleAttributes
{
return @{NSForegroundColorAttributeName : UIColor.linkBlue, NSFontAttributeName : UIFont.bold16};
}
- (NSDictionary *)unselectedTitleAttributes
{
return @{NSForegroundColorAttributeName : UIColor.linkBlue, NSFontAttributeName : UIFont.regular16};
}
#pragma mark - Properties
- (void)setIsLastCell:(BOOL)isLastCell
{
_isLastCell = isLastCell;
self.separatorInset = UIEdgeInsetsMake(0, isLastCell ? 0 : kSearchCellSeparatorInset, 0, 0);
}
@end

View file

@ -0,0 +1,58 @@
<?xml version="1.0" encoding="UTF-8"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="23504" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" colorMatched="YES">
<device id="retina4_7" orientation="portrait" appearance="light"/>
<dependencies>
<deployment identifier="iOS"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="23506"/>
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
</dependencies>
<objects>
<placeholder placeholderIdentifier="IBFilesOwner" id="-1" userLabel="File's Owner"/>
<placeholder placeholderIdentifier="IBFirstResponder" id="-2" customClass="UIResponder"/>
<tableViewCell contentMode="scaleToFill" selectionStyle="default" indentationWidth="10" id="KGk-i7-Jjw" customClass="MWMSearchSuggestionCell" propertyAccessControl="none">
<rect key="frame" x="0.0" y="0.0" width="320" height="44"/>
<autoresizingMask key="autoresizingMask" flexibleMaxX="YES" flexibleMaxY="YES"/>
<tableViewCellContentView key="contentView" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" ambiguous="YES" tableViewCell="KGk-i7-Jjw" id="H2p-sc-9uM">
<rect key="frame" x="0.0" y="0.0" width="320" height="44"/>
<autoresizingMask key="autoresizingMask"/>
<subviews>
<imageView userInteractionEnabled="NO" contentMode="center" horizontalHuggingPriority="251" verticalHuggingPriority="251" image="ic_search" translatesAutoresizingMaskIntoConstraints="NO" id="1IA-T9-KOb">
<rect key="frame" x="16" y="8" width="24" height="24"/>
<constraints>
<constraint firstAttribute="width" constant="24" id="TPv-Js-EXq"/>
<constraint firstAttribute="height" constant="24" id="bse-BN-jT9"/>
</constraints>
<userDefinedRuntimeAttributes>
<userDefinedRuntimeAttribute type="string" keyPath="styleName" value="MWMBlue"/>
</userDefinedRuntimeAttributes>
</imageView>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="New Arbat Avenue" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="gWP-Zj-GCt">
<rect key="frame" x="52" y="9.5" width="252" height="21"/>
<fontDescription key="fontDescription" type="system" weight="medium" pointSize="17"/>
<color key="textColor" red="0.12549019610000001" green="0.58823529409999997" blue="0.95294117649999999" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
<nil key="highlightedColor"/>
</label>
</subviews>
<constraints>
<constraint firstAttribute="bottom" secondItem="1IA-T9-KOb" secondAttribute="bottom" constant="8" id="0He-dX-gNg"/>
<constraint firstAttribute="trailing" secondItem="gWP-Zj-GCt" secondAttribute="trailing" constant="16" id="c1H-CR-1P7"/>
<constraint firstItem="gWP-Zj-GCt" firstAttribute="centerY" secondItem="1IA-T9-KOb" secondAttribute="centerY" id="iJZ-pk-VCW"/>
<constraint firstItem="1IA-T9-KOb" firstAttribute="leading" secondItem="H2p-sc-9uM" secondAttribute="leading" constant="16" id="l2F-lx-j3x"/>
<constraint firstItem="gWP-Zj-GCt" firstAttribute="leading" secondItem="1IA-T9-KOb" secondAttribute="trailing" constant="12" id="xfc-Vx-bth"/>
<constraint firstItem="1IA-T9-KOb" firstAttribute="top" secondItem="H2p-sc-9uM" secondAttribute="top" constant="8" id="y24-rC-y5X"/>
</constraints>
</tableViewCellContentView>
<userDefinedRuntimeAttributes>
<userDefinedRuntimeAttribute type="string" keyPath="styleName" value="Background"/>
</userDefinedRuntimeAttributes>
<connections>
<outlet property="icon" destination="1IA-T9-KOb" id="eBB-Wj-yU5"/>
<outlet property="titleLabel" destination="gWP-Zj-GCt" id="P6N-C2-ce6"/>
</connections>
<point key="canvasLocation" x="139" y="154"/>
</tableViewCell>
</objects>
<resources>
<image name="ic_search" width="28" height="28"/>
</resources>
</document>

View file

@ -0,0 +1,65 @@
protocol SearchCategoriesViewControllerDelegate: SearchOnMapScrollViewDelegate {
func categoriesViewController(_ viewController: SearchCategoriesViewController,
didSelect category: String)
}
final class SearchCategoriesViewController: MWMTableViewController {
private weak var delegate: SearchCategoriesViewControllerDelegate?
private let categories: [String]
init(frameworkHelper: MWMSearchFrameworkHelper.Type, delegate: SearchCategoriesViewControllerDelegate?) {
self.delegate = delegate
categories = frameworkHelper.searchCategories()
super.init(nibName: nil, bundle: nil)
}
@available(*, unavailable)
required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func viewDidLoad() {
super.viewDidLoad()
tableView.setStyle(.background)
tableView.register(cell: SearchCategoryCell.self)
tableView.keyboardDismissMode = .onDrag
let footerHeight = (UIApplication.shared.connectedScenes.filter { $0.activationState == .foregroundActive }.first(where: { $0 is UIWindowScene }) as? UIWindowScene)?.keyWindow?.safeAreaInsets.bottom ?? 1
tableView.tableFooterView = UIView(frame: CGRect(x: 0, y: 0, width: 400, height: footerHeight))
}
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
return categories.count
}
override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCell(cell: SearchCategoryCell.self, indexPath: indexPath)
cell.configure(with: category(at: indexPath))
return cell
}
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
let selectedCategory = category(at: indexPath)
delegate?.categoriesViewController(self, didSelect: selectedCategory)
tableView.deselectRow(at: indexPath, animated: true)
}
override func scrollViewDidScroll(_ scrollView: UIScrollView) {
delegate?.scrollViewDidScroll(scrollView)
}
override func scrollViewWillEndDragging(_ scrollView: UIScrollView, withVelocity velocity: CGPoint, targetContentOffset: UnsafeMutablePointer<CGPoint>) {
delegate?.scrollViewWillEndDragging(scrollView, withVelocity: velocity, targetContentOffset: targetContentOffset)
}
func category(at indexPath: IndexPath) -> String {
let index = indexPath.row
return categories[index]
}
}
extension SearchCategoriesViewController: ModallyPresentedViewController {
func presentationFrameDidChange(_ frame: CGRect) {
guard isViewLoaded else { return }
tableView.contentInset.bottom = frame.origin.y + view.safeAreaInsets.bottom
}
}

View file

@ -0,0 +1,25 @@
final class SearchCategoryCell: UITableViewCell {
private var categoryName: String = ""
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
super.init(style: .default, reuseIdentifier: reuseIdentifier)
setStyle(.defaultTableViewCell)
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func configure(with categoryName: String) {
self.categoryName = categoryName
textLabel?.text = L(categoryName)
imageView?.image = UIImage(named: String(format: "Search/Categories/%@", categoryName.replacingOccurrences(of: "category_", with: "")))
}
override func applyTheme() {
super.applyTheme()
imageView?.image = UIImage(named: String(format: "Search/Categories/%@", categoryName.replacingOccurrences(of: "category_", with: "")))
}
}

View file

@ -0,0 +1,34 @@
final class SearchHistoryCell: MWMTableViewCell {
enum Content {
case query(String)
case clear
}
static private let placeholderImage = UIImage.filled(with: .clear, size: CGSize(width: 28, height: 28))
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
super.init(style: .default, reuseIdentifier: reuseIdentifier)
setStyle(.defaultTableViewCell)
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func configure(for content: Content) {
switch content {
case .query(let query):
textLabel?.text = query
textLabel?.setFontStyleAndApply(.regular17, color: .blackSecondary)
imageView?.image = UIImage(resource: .icSearch)
imageView?.setStyleAndApply(.black)
isSeparatorHidden = false
case .clear:
textLabel?.text = L("clear_search")
textLabel?.setFontStyleAndApply(.regular14, color: .linkBlue)
imageView?.image = Self.placeholderImage
isSeparatorHidden = true
}
}
}

View file

@ -0,0 +1,131 @@
protocol SearchHistoryViewControllerDelegate: SearchOnMapScrollViewDelegate {
func searchHistoryViewController(_ viewController: SearchHistoryViewController,
didSelect query: String)
}
final class SearchHistoryViewController: MWMViewController {
private weak var delegate: SearchHistoryViewControllerDelegate?
private var lastQueries: [String] = []
private let frameworkHelper: MWMSearchFrameworkHelper.Type
private let emptyHistoryView = PlaceholderView(title: L("search_history_title"),
subtitle: L("search_history_text"))
private let tableView = UITableView()
// MARK: - Init
init(frameworkHelper: MWMSearchFrameworkHelper.Type, delegate: SearchHistoryViewControllerDelegate?) {
self.delegate = delegate
self.frameworkHelper = frameworkHelper
super.init(nibName: nil, bundle: nil)
}
@available(*, unavailable)
required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
// MARK: - Lifecycle
override func viewDidLoad() {
super.viewDidLoad()
setupTableView()
setupNoResultsView()
}
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
reload()
}
// MARK: - Private methods
private func setupTableView() {
tableView.setStyle(.background)
tableView.register(cell: SearchHistoryCell.self)
tableView.keyboardDismissMode = .onDrag
tableView.delegate = self
tableView.dataSource = self
let footerHeight = (UIApplication.shared.connectedScenes.filter { $0.activationState == .foregroundActive }.first(where: { $0 is UIWindowScene }) as? UIWindowScene)?.keyWindow?.safeAreaInsets.bottom ?? 1
tableView.tableFooterView = UIView(frame: CGRect(x: 0, y: 0, width: 400, height: footerHeight))
view.addSubview(tableView)
tableView.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
tableView.topAnchor.constraint(equalTo: view.topAnchor),
tableView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
tableView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
tableView.bottomAnchor.constraint(equalTo: view.bottomAnchor)
])
}
private func setupNoResultsView() {
view.addSubview(emptyHistoryView)
emptyHistoryView.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
emptyHistoryView.topAnchor.constraint(equalTo: view.topAnchor),
emptyHistoryView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
emptyHistoryView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
emptyHistoryView.bottomAnchor.constraint(equalTo: view.bottomAnchor)
])
}
private func showEmptyHistoryView(_ isVisible: Bool = true, animated: Bool = true) {
UIView.transition(with: emptyHistoryView,
duration: animated ? kDefaultAnimationDuration : 0,
options: [.transitionCrossDissolve, .curveEaseInOut]) {
self.emptyHistoryView.alpha = isVisible ? 1.0 : 0.0
self.emptyHistoryView.isHidden = !isVisible
}
}
private func clearSearchHistory() {
frameworkHelper.clearSearchHistory()
reload()
}
// MARK: - Public methods
func reload() {
guard isViewLoaded else { return }
lastQueries = frameworkHelper.lastSearchQueries()
showEmptyHistoryView(lastQueries.isEmpty ? true : false)
tableView.reloadData()
}
}
extension SearchHistoryViewController: UITableViewDataSource {
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
return lastQueries.isEmpty ? 0 : lastQueries.count + 1
}
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCell(cell: SearchHistoryCell.self, indexPath: indexPath)
if indexPath.row == lastQueries.count {
cell.configure(for: .clear)
} else {
cell.configure(for: .query(lastQueries[indexPath.row]))
}
return cell
}
}
extension SearchHistoryViewController: UITableViewDelegate {
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
if indexPath.row == lastQueries.count {
clearSearchHistory()
} else {
let query = lastQueries[indexPath.row]
delegate?.searchHistoryViewController(self, didSelect: query)
}
tableView.deselectRow(at: indexPath, animated: true)
}
func scrollViewDidScroll(_ scrollView: UIScrollView) {
delegate?.scrollViewDidScroll(scrollView)
}
}
extension SearchHistoryViewController: ModallyPresentedViewController {
func presentationFrameDidChange(_ frame: CGRect) {
guard isViewLoaded else { return }
tableView.contentInset.bottom = frame.origin.y
emptyHistoryView.presentationFrameDidChange(frame)
}
}

View file

@ -0,0 +1,87 @@
@objc(MWMSearchTabViewControllerDelegate)
protocol SearchTabViewControllerDelegate: SearchOnMapScrollViewDelegate {
func searchTabController(_ viewController: SearchTabViewController, didSearch: SearchQuery)
}
@objc(MWMSearchTabViewController)
final class SearchTabViewController: TabViewController {
private enum SearchActiveTab: Int {
case history = 0
case categories
}
private static let selectedIndexKey = "SearchTabViewController_selectedIndexKey"
@objc weak var delegate: SearchTabViewControllerDelegate?
private var frameworkHelper = MWMSearchFrameworkHelper.self
private var activeTab: SearchActiveTab = SearchActiveTab.init(rawValue:
UserDefaults.standard.integer(forKey: SearchTabViewController.selectedIndexKey)) ?? .categories {
didSet {
UserDefaults.standard.set(activeTab.rawValue, forKey: SearchTabViewController.selectedIndexKey)
}
}
override func viewDidLoad() {
super.viewDidLoad()
let history = SearchHistoryViewController(frameworkHelper: frameworkHelper,
delegate: self)
history.title = L("history")
let categories = SearchCategoriesViewController(frameworkHelper: frameworkHelper,
delegate: self)
categories.title = L("categories")
viewControllers = [history, categories]
if frameworkHelper.isSearchHistoryEmpty() {
tabView.selectedIndex = SearchActiveTab.categories.rawValue
} else {
tabView.selectedIndex = activeTab.rawValue
}
}
override func viewDidDisappear(_ animated: Bool) {
super.viewDidDisappear(animated)
activeTab = SearchActiveTab.init(rawValue: tabView.selectedIndex ?? 0) ?? .categories
}
func reloadSearchHistory() {
(viewControllers[SearchActiveTab.history.rawValue] as? SearchHistoryViewController)?.reload()
}
}
extension SearchTabViewController: ModallyPresentedViewController {
func presentationFrameDidChange(_ frame: CGRect) {
viewControllers.forEach { ($0 as? ModallyPresentedViewController)?.presentationFrameDidChange(frame) }
}
}
extension SearchTabViewController: SearchOnMapScrollViewDelegate {
func scrollViewDidScroll(_ scrollView: UIScrollView) {
delegate?.scrollViewDidScroll(scrollView)
}
func scrollViewWillEndDragging(_ scrollView: UIScrollView, withVelocity velocity: CGPoint, targetContentOffset: UnsafeMutablePointer<CGPoint>) {
delegate?.scrollViewWillEndDragging(scrollView, withVelocity: velocity, targetContentOffset: targetContentOffset)
}
}
extension SearchTabViewController: SearchCategoriesViewControllerDelegate {
func categoriesViewController(_ viewController: SearchCategoriesViewController,
didSelect category: String) {
let preferredLang = AppInfo.shared().languageId
let supportedBySearchLang = MWMSearchFrameworkHelper.isLanguageSupported(preferredLang) ? preferredLang : "en"
let searchText = L(category, languageCode: supportedBySearchLang) + " "
let query = SearchQuery(searchText, locale: supportedBySearchLang, source: .category)
delegate?.searchTabController(self, didSearch: query)
}
}
extension SearchTabViewController: SearchHistoryViewControllerDelegate {
func searchHistoryViewController(_ viewController: SearchHistoryViewController,
didSelect query: String) {
let query = SearchQuery(query.trimmingCharacters(in: .whitespacesAndNewlines) + " ", source: .history)
delegate?.searchTabController(self, didSearch: query)
}
}

View file

@ -0,0 +1,84 @@
final class ValueStepperView: UIControl {
var minValue = 0 {
didSet {
guard minValue <= maxValue else { fatalError() }
value = max(value, minValue)
}
}
var maxValue = 100 {
didSet {
guard maxValue >= minValue else { fatalError() }
value = min(value, maxValue)
}
}
var value = 0 {
didSet {
guard value <= maxValue && value >= minValue else { fatalError() }
minusButton.isEnabled = value > minValue
plusButton.isEnabled = value < maxValue
valueLabel.text = "\(value)"
}
}
let minusButton = MWMButton(type: .custom)
let plusButton = MWMButton(type: .custom)
let valueLabel = UILabel()
private var viewConstraints: [NSLayoutConstraint]!
override init(frame: CGRect) {
super.init(frame: frame)
configure()
}
required init?(coder: NSCoder) {
super.init(coder: coder)
configure()
}
private func configure() {
addSubview(minusButton)
addSubview(valueLabel)
addSubview(plusButton)
minusButton.translatesAutoresizingMaskIntoConstraints = false
valueLabel.translatesAutoresizingMaskIntoConstraints = false
plusButton.translatesAutoresizingMaskIntoConstraints = false
valueLabel.textAlignment = .center
minusButton.isEnabled = false
minusButton.setImage(UIImage(named: "ic_booking_minus"), for: .normal)
plusButton.setImage(UIImage(named: "ic_booking_plus"), for: .normal)
valueLabel.text = "\(value)"
minusButton.addTarget(self, action: #selector(onMinus(_:)), for: .touchUpInside)
plusButton.addTarget(self, action: #selector(onPlus(_:)), for: .touchUpInside)
NSLayoutConstraint.activate([
minusButton.leadingAnchor.constraint(equalTo: leadingAnchor),
valueLabel.leadingAnchor.constraint(equalTo: minusButton.trailingAnchor),
plusButton.leadingAnchor.constraint(equalTo: valueLabel.trailingAnchor),
plusButton.trailingAnchor.constraint(equalTo: trailingAnchor),
valueLabel.topAnchor.constraint(equalTo: topAnchor),
valueLabel.bottomAnchor.constraint(equalTo: bottomAnchor),
minusButton.topAnchor.constraint(equalTo: topAnchor),
minusButton.bottomAnchor.constraint(equalTo: bottomAnchor),
plusButton.topAnchor.constraint(equalTo: topAnchor),
plusButton.bottomAnchor.constraint(equalTo: bottomAnchor),
minusButton.widthAnchor.constraint(equalTo: minusButton.heightAnchor, multiplier: 1),
plusButton.widthAnchor.constraint(equalTo: plusButton.heightAnchor, multiplier: 1)
])
}
@objc func onMinus(_ sender: UIButton) {
value -= 1
sendActions(for: .valueChanged)
}
@objc func onPlus(_ sender: UIButton) {
value += 1
sendActions(for: .valueChanged)
}
}