[ios] implement modal search screen SearchOnMap
Signed-off-by: Kiryl Kaveryn <kirylkaveryn@gmail.com>
This commit is contained in:
parent
a8ce059fcc
commit
3229ca602d
17 changed files with 1808 additions and 9 deletions
|
@ -5,9 +5,7 @@ NS_ASSUME_NONNULL_BEGIN
|
|||
|
||||
@class SearchResult;
|
||||
|
||||
NS_SWIFT_NAME(Search)
|
||||
@interface MWMSearch : NSObject
|
||||
|
||||
@protocol SearchManager
|
||||
+ (void)addObserver:(id<MWMSearchObserver>)observer;
|
||||
+ (void)removeObserver:(id<MWMSearchObserver>)observer;
|
||||
|
||||
|
@ -18,12 +16,17 @@ NS_SWIFT_NAME(Search)
|
|||
+ (void)showEverywhereSearchResultsOnMap;
|
||||
+ (void)showViewportSearchResultsOnMap;
|
||||
|
||||
+ (SearchItemType)resultTypeWithRow:(NSUInteger)row;
|
||||
+ (NSUInteger)containerIndexWithRow:(NSUInteger)row;
|
||||
+ (SearchResult *)resultWithContainerIndex:(NSUInteger)index;
|
||||
+ (NSArray<SearchResult *> *)getResults;
|
||||
|
||||
+ (void)clear;
|
||||
@end
|
||||
|
||||
NS_SWIFT_NAME(Search)
|
||||
@interface MWMSearch : NSObject<SearchManager>
|
||||
|
||||
+ (SearchItemType)resultTypeWithRow:(NSUInteger)row;
|
||||
+ (NSUInteger)containerIndexWithRow:(NSUInteger)row;
|
||||
+ (SearchResult *)resultWithContainerIndex:(NSUInteger)index;
|
||||
|
||||
+ (void)setSearchOnMap:(BOOL)searchOnMap;
|
||||
|
||||
|
|
|
@ -176,15 +176,15 @@ using Observers = NSHashTable<Observer>;
|
|||
}
|
||||
|
||||
+ (void)showEverywhereSearchResultsOnMap {
|
||||
MWMSearch *manager = [MWMSearch manager];
|
||||
MWMSearch * manager = [MWMSearch manager];
|
||||
if (![MWMRouter isRoutingActive])
|
||||
GetFramework().ShowSearchResults(manager->m_everywhereResults);
|
||||
}
|
||||
|
||||
+ (void)showViewportSearchResultsOnMap {
|
||||
MWMSearch *manager = [MWMSearch manager];
|
||||
MWMSearch * manager = [MWMSearch manager];
|
||||
if (![MWMRouter isRoutingActive])
|
||||
GetFramework().ShowSearchResults(manager->m_viewportResults);
|
||||
[manager processViewportChangedEvent];
|
||||
}
|
||||
|
||||
+ (NSArray<SearchResult *> *)getResults {
|
||||
|
|
|
@ -2,6 +2,7 @@ enum GlobalStyleSheet: String, CaseIterable {
|
|||
case tableView = "TableView"
|
||||
case tableCell = "TableCell"
|
||||
case tableViewCell = "MWMTableViewCell"
|
||||
case defaultTableViewCell
|
||||
case tableViewHeaderFooterView = "TableViewHeaderFooterView"
|
||||
case searchBar = "SearchBar"
|
||||
case navigationBar = "NavigationBar"
|
||||
|
@ -81,6 +82,10 @@ extension GlobalStyleSheet: IStyleSheet {
|
|||
case .tableViewCell:
|
||||
return .addFrom(Self.tableCell) { s in
|
||||
}
|
||||
case .defaultTableViewCell:
|
||||
return .add { s in
|
||||
s.backgroundColor = colors.white
|
||||
}
|
||||
case .tableViewHeaderFooterView:
|
||||
return .add { s in
|
||||
s.font = fonts.medium14
|
||||
|
|
|
@ -496,6 +496,18 @@
|
|||
ED4DC7792CAEDECC0029B338 /* ProductsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = ED4DC7742CAEDECC0029B338 /* ProductsViewController.swift */; };
|
||||
ED63CEB92BDF8F9D006155C4 /* SettingsTableViewiCloudSwitchCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = ED63CEB62BDF8F9C006155C4 /* SettingsTableViewiCloudSwitchCell.swift */; };
|
||||
ED70D55C2D5396F300738C1E /* SearchResult.mm in Sources */ = {isa = PBXBuildFile; fileRef = ED70D55A2D5396F300738C1E /* SearchResult.mm */; };
|
||||
ED70D5892D539A2500738C1E /* SearchOnMapViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = ED70D5872D539A2500738C1E /* SearchOnMapViewController.swift */; };
|
||||
ED70D58A2D539A2500738C1E /* SearchOnMapModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = ED70D5852D539A2500738C1E /* SearchOnMapModels.swift */; };
|
||||
ED70D58B2D539A2500738C1E /* SearchOnMapModalTransitionManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = ED70D57E2D539A2500738C1E /* SearchOnMapModalTransitionManager.swift */; };
|
||||
ED70D58C2D539A2500738C1E /* SearchOnMapPresenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = ED70D5862D539A2500738C1E /* SearchOnMapPresenter.swift */; };
|
||||
ED70D58D2D539A2500738C1E /* ModalScreenPresentationStep.swift in Sources */ = {isa = PBXBuildFile; fileRef = ED70D57C2D539A2500738C1E /* ModalScreenPresentationStep.swift */; };
|
||||
ED70D58E2D539A2500738C1E /* SideMenuPresentationAnimator.swift in Sources */ = {isa = PBXBuildFile; fileRef = ED70D5802D539A2500738C1E /* SideMenuPresentationAnimator.swift */; };
|
||||
ED70D58F2D539A2500738C1E /* SearchOnMapInteractor.swift in Sources */ = {isa = PBXBuildFile; fileRef = ED70D5832D539A2500738C1E /* SearchOnMapInteractor.swift */; };
|
||||
ED70D5902D539A2500738C1E /* SearchOnMapModalPresentationController.swift in Sources */ = {isa = PBXBuildFile; fileRef = ED70D57D2D539A2500738C1E /* SearchOnMapModalPresentationController.swift */; };
|
||||
ED70D5912D539A2500738C1E /* MapPassthroughView.swift in Sources */ = {isa = PBXBuildFile; fileRef = ED70D57B2D539A2500738C1E /* MapPassthroughView.swift */; };
|
||||
ED70D5922D539A2500738C1E /* PlaceholderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = ED70D5822D539A2500738C1E /* PlaceholderView.swift */; };
|
||||
ED70D5932D539A2500738C1E /* SearchOnMapManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = ED70D5842D539A2500738C1E /* SearchOnMapManager.swift */; };
|
||||
ED70D5942D539A2500738C1E /* SideMenuDismissalAnimator.swift in Sources */ = {isa = PBXBuildFile; fileRef = ED70D57F2D539A2500738C1E /* SideMenuDismissalAnimator.swift */; };
|
||||
ED77556E2C2C490B0051E656 /* UIAlertController+openInAppActionSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = ED77556D2C2C490B0051E656 /* UIAlertController+openInAppActionSheet.swift */; };
|
||||
ED79A5AB2BD7AA9C00952D1F /* LoadingOverlayViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = ED79A5AA2BD7AA9C00952D1F /* LoadingOverlayViewController.swift */; };
|
||||
ED79A5AD2BD7BA0F00952D1F /* UIApplication+LoadingOverlay.swift in Sources */ = {isa = PBXBuildFile; fileRef = ED79A5AC2BD7BA0F00952D1F /* UIApplication+LoadingOverlay.swift */; };
|
||||
|
@ -507,6 +519,7 @@
|
|||
ED79A5D82BDF8D6100952D1F /* LocalDirectoryMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = ED79A5D02BDF8D6100952D1F /* LocalDirectoryMonitor.swift */; };
|
||||
ED7CCC4F2C1362E300E2A737 /* FileType.swift in Sources */ = {isa = PBXBuildFile; fileRef = ED7CCC4E2C1362E300E2A737 /* FileType.swift */; };
|
||||
ED808D0F2C38407800D52585 /* CircleImageButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = ED808D0E2C38407800D52585 /* CircleImageButton.swift */; };
|
||||
ED810EC52D566E9B00ECDE2C /* SearchOnMapTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = ED810EC42D566E9B00ECDE2C /* SearchOnMapTests.swift */; };
|
||||
ED8270F02C2071A3005966DA /* SettingsTableViewDetailedSwitchCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = ED8270EF2C2071A3005966DA /* SettingsTableViewDetailedSwitchCell.swift */; };
|
||||
ED914AB22D35063A00973C45 /* TextColorStyleSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = ED914AB12D35063A00973C45 /* TextColorStyleSheet.swift */; };
|
||||
ED914AB82D351DF000973C45 /* StyleApplicable.swift in Sources */ = {isa = PBXBuildFile; fileRef = ED914AB72D351DF000973C45 /* StyleApplicable.swift */; };
|
||||
|
@ -1463,6 +1476,18 @@
|
|||
ED70D5592D5396F300738C1E /* SearchResult.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = SearchResult.h; sourceTree = "<group>"; };
|
||||
ED70D55A2D5396F300738C1E /* SearchResult.mm */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.objcpp; path = SearchResult.mm; sourceTree = "<group>"; };
|
||||
ED70D55B2D5396F300738C1E /* SearchResult+Core.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "SearchResult+Core.h"; sourceTree = "<group>"; };
|
||||
ED70D57B2D539A2500738C1E /* MapPassthroughView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MapPassthroughView.swift; sourceTree = "<group>"; };
|
||||
ED70D57C2D539A2500738C1E /* ModalScreenPresentationStep.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ModalScreenPresentationStep.swift; sourceTree = "<group>"; };
|
||||
ED70D57D2D539A2500738C1E /* SearchOnMapModalPresentationController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchOnMapModalPresentationController.swift; sourceTree = "<group>"; };
|
||||
ED70D57E2D539A2500738C1E /* SearchOnMapModalTransitionManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchOnMapModalTransitionManager.swift; sourceTree = "<group>"; };
|
||||
ED70D57F2D539A2500738C1E /* SideMenuDismissalAnimator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SideMenuDismissalAnimator.swift; sourceTree = "<group>"; };
|
||||
ED70D5802D539A2500738C1E /* SideMenuPresentationAnimator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SideMenuPresentationAnimator.swift; sourceTree = "<group>"; };
|
||||
ED70D5822D539A2500738C1E /* PlaceholderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlaceholderView.swift; sourceTree = "<group>"; };
|
||||
ED70D5832D539A2500738C1E /* SearchOnMapInteractor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchOnMapInteractor.swift; sourceTree = "<group>"; };
|
||||
ED70D5842D539A2500738C1E /* SearchOnMapManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchOnMapManager.swift; sourceTree = "<group>"; };
|
||||
ED70D5852D539A2500738C1E /* SearchOnMapModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchOnMapModels.swift; sourceTree = "<group>"; };
|
||||
ED70D5862D539A2500738C1E /* SearchOnMapPresenter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchOnMapPresenter.swift; sourceTree = "<group>"; };
|
||||
ED70D5872D539A2500738C1E /* SearchOnMapViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchOnMapViewController.swift; sourceTree = "<group>"; };
|
||||
ED77556D2C2C490B0051E656 /* UIAlertController+openInAppActionSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIAlertController+openInAppActionSheet.swift"; sourceTree = "<group>"; };
|
||||
ED79A5AA2BD7AA9C00952D1F /* LoadingOverlayViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoadingOverlayViewController.swift; sourceTree = "<group>"; };
|
||||
ED79A5AC2BD7BA0F00952D1F /* UIApplication+LoadingOverlay.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIApplication+LoadingOverlay.swift"; sourceTree = "<group>"; };
|
||||
|
@ -1474,6 +1499,7 @@
|
|||
ED79A5D02BDF8D6100952D1F /* LocalDirectoryMonitor.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LocalDirectoryMonitor.swift; sourceTree = "<group>"; };
|
||||
ED7CCC4E2C1362E300E2A737 /* FileType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileType.swift; sourceTree = "<group>"; };
|
||||
ED808D0E2C38407800D52585 /* CircleImageButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CircleImageButton.swift; sourceTree = "<group>"; };
|
||||
ED810EC42D566E9B00ECDE2C /* SearchOnMapTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchOnMapTests.swift; sourceTree = "<group>"; };
|
||||
ED8270EF2C2071A3005966DA /* SettingsTableViewDetailedSwitchCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsTableViewDetailedSwitchCell.swift; sourceTree = "<group>"; };
|
||||
ED914AB12D35063A00973C45 /* TextColorStyleSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TextColorStyleSheet.swift; sourceTree = "<group>"; };
|
||||
ED914AB72D351DF000973C45 /* StyleApplicable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StyleApplicable.swift; sourceTree = "<group>"; };
|
||||
|
@ -3183,6 +3209,7 @@
|
|||
ED1ADA312BC6B19E0029209F /* Tests */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
ED810EC02D566E6F00ECDE2C /* UI */,
|
||||
EDC4E3442C5D1BD3009286A2 /* Bookmarks */,
|
||||
4B4153B82BF970B800EE4B02 /* Classes */,
|
||||
4B4153B62BF9709100EE4B02 /* Core */,
|
||||
|
@ -3234,6 +3261,33 @@
|
|||
path = Products;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
ED70D5812D539A2500738C1E /* Presentation */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
ED70D57B2D539A2500738C1E /* MapPassthroughView.swift */,
|
||||
ED70D57C2D539A2500738C1E /* ModalScreenPresentationStep.swift */,
|
||||
ED70D57D2D539A2500738C1E /* SearchOnMapModalPresentationController.swift */,
|
||||
ED70D57E2D539A2500738C1E /* SearchOnMapModalTransitionManager.swift */,
|
||||
ED70D57F2D539A2500738C1E /* SideMenuDismissalAnimator.swift */,
|
||||
ED70D5802D539A2500738C1E /* SideMenuPresentationAnimator.swift */,
|
||||
);
|
||||
path = Presentation;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
ED70D5882D539A2500738C1E /* SearchOnMap */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
ED70D5812D539A2500738C1E /* Presentation */,
|
||||
ED70D5822D539A2500738C1E /* PlaceholderView.swift */,
|
||||
ED70D5832D539A2500738C1E /* SearchOnMapInteractor.swift */,
|
||||
ED70D5842D539A2500738C1E /* SearchOnMapManager.swift */,
|
||||
ED70D5852D539A2500738C1E /* SearchOnMapModels.swift */,
|
||||
ED70D5862D539A2500738C1E /* SearchOnMapPresenter.swift */,
|
||||
ED70D5872D539A2500738C1E /* SearchOnMapViewController.swift */,
|
||||
);
|
||||
path = SearchOnMap;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
ED79A5A92BD7AA7500952D1F /* LoadingOverlay */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
|
@ -3256,6 +3310,22 @@
|
|||
path = iCloud;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
ED810EC02D566E6F00ECDE2C /* UI */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
ED810EC32D566E7600ECDE2C /* SearchOnMapTests */,
|
||||
);
|
||||
path = UI;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
ED810EC32D566E7600ECDE2C /* SearchOnMapTests */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
ED810EC42D566E9B00ECDE2C /* SearchOnMapTests.swift */,
|
||||
);
|
||||
path = SearchOnMapTests;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
ED9857022C4ECFFC00694F6C /* MailComposer */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
|
@ -3825,6 +3895,7 @@
|
|||
F6E2FCE11E097B9F0083EBEC /* Search */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
ED70D5882D539A2500738C1E /* SearchOnMap */,
|
||||
F6E2FCF41E097B9F0083EBEC /* MWMSearchContentView.h */,
|
||||
F6E2FCF51E097B9F0083EBEC /* MWMSearchContentView.m */,
|
||||
F6E2FCF81E097B9F0083EBEC /* MWMSearchManager+Layout.h */,
|
||||
|
@ -4743,6 +4814,18 @@
|
|||
CD9AD96C2281B56900EC174A /* CPViewPortState.swift in Sources */,
|
||||
EDE243DD2B6D2E640057369B /* AboutController.swift in Sources */,
|
||||
3404755C1E081A4600C92850 /* MWMLocationManager.mm in Sources */,
|
||||
ED70D5892D539A2500738C1E /* SearchOnMapViewController.swift in Sources */,
|
||||
ED70D58A2D539A2500738C1E /* SearchOnMapModels.swift in Sources */,
|
||||
ED70D58B2D539A2500738C1E /* SearchOnMapModalTransitionManager.swift in Sources */,
|
||||
ED70D58C2D539A2500738C1E /* SearchOnMapPresenter.swift in Sources */,
|
||||
ED70D58D2D539A2500738C1E /* ModalScreenPresentationStep.swift in Sources */,
|
||||
ED70D58E2D539A2500738C1E /* SideMenuPresentationAnimator.swift in Sources */,
|
||||
ED70D58F2D539A2500738C1E /* SearchOnMapInteractor.swift in Sources */,
|
||||
ED70D5902D539A2500738C1E /* SearchOnMapModalPresentationController.swift in Sources */,
|
||||
ED70D5912D539A2500738C1E /* MapPassthroughView.swift in Sources */,
|
||||
ED70D5922D539A2500738C1E /* PlaceholderView.swift in Sources */,
|
||||
ED70D5932D539A2500738C1E /* SearchOnMapManager.swift in Sources */,
|
||||
ED70D5942D539A2500738C1E /* SideMenuDismissalAnimator.swift in Sources */,
|
||||
3454D7BC1E07F045004AF2AD /* CLLocation+Mercator.mm in Sources */,
|
||||
47E3C7272111E5A8008B3B27 /* AlertPresentationController.swift in Sources */,
|
||||
CDCA27812243F59800167D87 /* CarPlayRouter.swift in Sources */,
|
||||
|
@ -4889,6 +4972,7 @@
|
|||
EDF838BE2C00B9D0007E4E67 /* LocalDirectoryMonitorDelegateMock.swift in Sources */,
|
||||
EDC4E3692C5E6F5B009286A2 /* MockRecentlyDeletedCategoriesManager.swift in Sources */,
|
||||
EDF838BF2C00B9D0007E4E67 /* SynchronizationStateManagerTests.swift in Sources */,
|
||||
ED810EC52D566E9B00ECDE2C /* SearchOnMapTests.swift in Sources */,
|
||||
4B83AE4B2C2E642100B0C3BC /* TTSTesterTest.m in Sources */,
|
||||
EDF838C22C00B9D6007E4E67 /* MetadataItemStubs.swift in Sources */,
|
||||
EDC4E3612C5E2576009286A2 /* RecentlyDeletedCategoriesViewModelTests.swift in Sources */,
|
||||
|
|
262
iphone/Maps/Tests/UI/SearchOnMapTests/SearchOnMapTests.swift
Normal file
262
iphone/Maps/Tests/UI/SearchOnMapTests/SearchOnMapTests.swift
Normal file
|
@ -0,0 +1,262 @@
|
|||
import XCTest
|
||||
@testable import Organic_Maps__Debug_
|
||||
|
||||
final class SearchOnMapTests: XCTestCase {
|
||||
|
||||
private var presenter: SearchOnMapPresenter!
|
||||
private var interactor: SearchOnMapInteractor!
|
||||
private var view: SearchOnMapViewMock!
|
||||
private var searchManager: SearchManagerMock.Type!
|
||||
private var currentState: SearchOnMapState = .searching
|
||||
|
||||
override func setUp() {
|
||||
super.setUp()
|
||||
searchManager = SearchManagerMock.self
|
||||
presenter = SearchOnMapPresenter(transitionManager: SearchOnMapModalTransitionManager(),
|
||||
isRouting: false,
|
||||
didChangeState: { [weak self] in self?.currentState = $0 })
|
||||
interactor = SearchOnMapInteractor(presenter: presenter, searchManager: searchManager)
|
||||
view = SearchOnMapViewMock()
|
||||
presenter.view = view
|
||||
}
|
||||
|
||||
override func tearDown() {
|
||||
presenter = nil
|
||||
interactor = nil
|
||||
view = nil
|
||||
searchManager.results = .empty
|
||||
searchManager = nil
|
||||
super.tearDown()
|
||||
}
|
||||
|
||||
func test_GivenViewIsLoading_WhenViewLoads_ThenShowsHistoryAndCategory() {
|
||||
interactor.handle(.openSearch)
|
||||
|
||||
XCTAssertEqual(currentState, .searching)
|
||||
XCTAssertEqual(view.viewModel.presentationStep, .fullScreen)
|
||||
XCTAssertEqual(view.viewModel.contentState, .historyAndCategory)
|
||||
XCTAssertEqual(view.viewModel.searchingText, nil)
|
||||
XCTAssertEqual(view.viewModel.isTyping, true)
|
||||
}
|
||||
|
||||
func test_GivenInitialState_WhenSelectCategory_ThenUpdateSearchResultsAndShowMap() {
|
||||
interactor.handle(.openSearch)
|
||||
|
||||
let searchText = SearchOnMap.SearchText("category")
|
||||
interactor.handle(.didSelectText(searchText, isCategory: true))
|
||||
|
||||
XCTAssertEqual(view.viewModel.presentationStep, .halfScreen)
|
||||
XCTAssertEqual(view.viewModel.contentState, .searching)
|
||||
XCTAssertEqual(view.viewModel.searchingText, searchText.text)
|
||||
XCTAssertEqual(view.viewModel.isTyping, false)
|
||||
|
||||
let results = SearchResult.stubResults()
|
||||
searchManager.results = results
|
||||
|
||||
XCTAssertEqual(currentState, .searching)
|
||||
XCTAssertEqual(view.viewModel.presentationStep, .halfScreen)
|
||||
XCTAssertEqual(view.viewModel.contentState, .results(results))
|
||||
XCTAssertEqual(view.viewModel.searchingText, nil)
|
||||
XCTAssertEqual(view.viewModel.isTyping, false)
|
||||
}
|
||||
|
||||
func test_GivenInitialState_WhenTypeText_ThenUpdateSearchResults() {
|
||||
interactor.handle(.openSearch)
|
||||
|
||||
let searchText = SearchOnMap.SearchText("text")
|
||||
interactor.handle(.didType(searchText))
|
||||
|
||||
XCTAssertEqual(view.viewModel.presentationStep, .fullScreen)
|
||||
XCTAssertEqual(view.viewModel.contentState, .searching)
|
||||
XCTAssertEqual(view.viewModel.searchingText, nil)
|
||||
XCTAssertEqual(view.viewModel.isTyping, true)
|
||||
|
||||
let results = SearchResult.stubResults()
|
||||
searchManager.results = results
|
||||
|
||||
XCTAssertEqual(currentState, .searching)
|
||||
XCTAssertEqual(view.viewModel.presentationStep, .fullScreen)
|
||||
XCTAssertEqual(view.viewModel.contentState, .results(results))
|
||||
XCTAssertEqual(view.viewModel.searchingText, nil)
|
||||
XCTAssertEqual(view.viewModel.isTyping, true)
|
||||
}
|
||||
|
||||
func test_GivenInitialState_WhenTapSearch_ThenUpdateSearchResultsAndShowMap() {
|
||||
interactor.handle(.openSearch)
|
||||
|
||||
let searchText = SearchOnMap.SearchText("text")
|
||||
interactor.handle(.didType(searchText))
|
||||
|
||||
let results = SearchResult.stubResults()
|
||||
searchManager.results = results
|
||||
|
||||
XCTAssertEqual(view.viewModel.presentationStep, .fullScreen)
|
||||
XCTAssertEqual(view.viewModel.contentState, .results(results))
|
||||
XCTAssertEqual(view.viewModel.searchingText, nil)
|
||||
XCTAssertEqual(view.viewModel.isTyping, true)
|
||||
|
||||
interactor.handle(.searchButtonDidTap(searchText))
|
||||
|
||||
XCTAssertEqual(currentState, .searching)
|
||||
XCTAssertEqual(view.viewModel.presentationStep, .halfScreen)
|
||||
XCTAssertEqual(view.viewModel.contentState, .results(results))
|
||||
XCTAssertEqual(view.viewModel.searchingText, nil)
|
||||
XCTAssertEqual(view.viewModel.isTyping, false)
|
||||
}
|
||||
|
||||
func test_GivenSearchIsOpened_WhenMapIsDragged_ThenCollapseSearchScreen() {
|
||||
interactor.handle(.openSearch)
|
||||
XCTAssertEqual(view.viewModel.presentationStep, .fullScreen)
|
||||
|
||||
interactor.handle(.didStartDraggingMap)
|
||||
XCTAssertEqual(view.viewModel.presentationStep, .compact)
|
||||
}
|
||||
|
||||
func test_GivenSearchIsOpened_WhenModalPresentationScreenIsDragged_ThenDisableTyping() {
|
||||
interactor.handle(.openSearch)
|
||||
XCTAssertEqual(view.viewModel.isTyping, true)
|
||||
|
||||
interactor.handle(.didStartDraggingSearch)
|
||||
XCTAssertEqual(view.viewModel.isTyping, false)
|
||||
}
|
||||
|
||||
func test_GivenResultsOnScreen_WhenSelectResult_ThenHideSearch() {
|
||||
interactor.handle(.openSearch)
|
||||
XCTAssertEqual(view.viewModel.isTyping, true)
|
||||
|
||||
let searchText = SearchOnMap.SearchText("text")
|
||||
interactor.handle(.didType(searchText))
|
||||
|
||||
let results = SearchResult.stubResults()
|
||||
searchManager.results = results
|
||||
|
||||
interactor.handle(.didSelectResult(results[0], atIndex: 0, withSearchText: searchText))
|
||||
XCTAssertEqual(currentState, .hidden)
|
||||
XCTAssertEqual(view.viewModel.presentationStep, .hidden)
|
||||
}
|
||||
|
||||
func test_GivenSearchIsActive_WhenSelectPlaceOnMap_ThenHideSearch() {
|
||||
interactor.handle(.openSearch)
|
||||
XCTAssertEqual(view.viewModel.presentationStep, .fullScreen)
|
||||
|
||||
interactor.handle(.didSelectPlaceOnMap)
|
||||
|
||||
if isIPad {
|
||||
XCTAssertNotEqual(view.viewModel.presentationStep, .hidden)
|
||||
} else {
|
||||
XCTAssertEqual(view.viewModel.presentationStep, .hidden)
|
||||
}
|
||||
}
|
||||
|
||||
func test_GivenSearchIsHidden_WhenPPDeselected_ThenShowSearch() {
|
||||
interactor.handle(.openSearch)
|
||||
XCTAssertEqual(view.viewModel.isTyping, true)
|
||||
|
||||
let searchText = SearchOnMap.SearchText("text")
|
||||
interactor.handle(.didType(searchText))
|
||||
|
||||
let results = SearchResult.stubResults()
|
||||
searchManager.results = results
|
||||
|
||||
interactor.handle(.didSelectResult(results[0], atIndex: 0, withSearchText: searchText))
|
||||
XCTAssertEqual(currentState, .hidden)
|
||||
XCTAssertEqual(view.viewModel.presentationStep, .hidden)
|
||||
|
||||
interactor.handle(.didDeselectPlaceOnMap)
|
||||
XCTAssertEqual(currentState, .searching)
|
||||
XCTAssertEqual(view.viewModel.presentationStep, .halfScreen)
|
||||
}
|
||||
|
||||
func test_GivenSearchIsOpen_WhenCloseSearch_ThenHideSearch() {
|
||||
interactor.handle(.openSearch)
|
||||
XCTAssertEqual(view.viewModel.presentationStep, .fullScreen)
|
||||
|
||||
interactor.handle(.closeSearch)
|
||||
XCTAssertEqual(currentState, .closed)
|
||||
}
|
||||
|
||||
func test_GivenSearchHasText_WhenClearSearch_ThenShowHistoryAndCategory() {
|
||||
interactor.handle(.openSearch)
|
||||
|
||||
let searchText = SearchOnMap.SearchText("text")
|
||||
interactor.handle(.didType(searchText))
|
||||
|
||||
interactor.handle(.clearButtonDidTap)
|
||||
XCTAssertEqual(view.viewModel.presentationStep, .fullScreen)
|
||||
XCTAssertEqual(view.viewModel.contentState, .historyAndCategory)
|
||||
XCTAssertEqual(view.viewModel.searchingText, "")
|
||||
XCTAssertEqual(view.viewModel.isTyping, true)
|
||||
}
|
||||
|
||||
func test_GivenSearchExecuted_WhenNoResults_ThenShowNoResults() {
|
||||
interactor.handle(.openSearch)
|
||||
|
||||
let searchText = SearchOnMap.SearchText("text")
|
||||
interactor.handle(.didType(searchText))
|
||||
|
||||
searchManager.results = SearchOnMap.SearchResults([])
|
||||
interactor.onSearchCompleted()
|
||||
|
||||
XCTAssertEqual(view.viewModel.contentState, .noResults)
|
||||
}
|
||||
|
||||
func test_GivenSearchIsActive_WhenSelectSuggestion_ThenSearchAgain() {
|
||||
interactor.handle(.openSearch)
|
||||
|
||||
let searchText = SearchOnMap.SearchText("old search")
|
||||
interactor.handle(.didType(searchText))
|
||||
|
||||
let suggestion = SearchResult(titleText: "", type: .suggestion, suggestion: "suggestion")
|
||||
interactor.handle(.didSelectResult(suggestion, atIndex: 0, withSearchText: searchText))
|
||||
|
||||
XCTAssertEqual(view.viewModel.searchingText, "suggestion")
|
||||
XCTAssertEqual(view.viewModel.contentState, .searching)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Mocks
|
||||
|
||||
private class SearchOnMapViewMock: SearchOnMapView {
|
||||
var viewModel: SearchOnMap.ViewModel = .initial
|
||||
var scrollViewDelegate: (any SearchOnMapScrollViewDelegate)?
|
||||
func render(_ viewModel: SearchOnMap.ViewModel) {
|
||||
self.viewModel = viewModel
|
||||
}
|
||||
}
|
||||
|
||||
private class SearchManagerMock: SearchManager {
|
||||
static var observers = ListenerContainer<MWMSearchObserver>()
|
||||
static var results = SearchOnMap.SearchResults.empty {
|
||||
didSet {
|
||||
observers.forEach { observer in
|
||||
observer.onSearchCompleted?()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static func add(_ observer: any MWMSearchObserver) {
|
||||
self.observers.addListener(observer)
|
||||
}
|
||||
|
||||
static func remove(_ observer: any MWMSearchObserver) {
|
||||
self.observers.removeListener(observer)
|
||||
}
|
||||
|
||||
static func saveQuery(_ query: String, forInputLocale inputLocale: String) {}
|
||||
static func searchQuery(_ query: String, forInputLocale inputLocale: String, withCategory isCategory: Bool) {}
|
||||
static func showResult(at index: UInt) {}
|
||||
static func showEverywhereSearchResultsOnMap() {}
|
||||
static func showViewportSearchResultsOnMap() {}
|
||||
static func clear() {}
|
||||
static func getResults() -> [SearchResult] { results.results }
|
||||
}
|
||||
|
||||
private extension SearchResult {
|
||||
static func stubResults() -> SearchOnMap.SearchResults {
|
||||
SearchOnMap.SearchResults([
|
||||
SearchResult(),
|
||||
SearchResult(),
|
||||
SearchResult()
|
||||
])
|
||||
}
|
||||
}
|
122
iphone/Maps/UI/Search/SearchOnMap/PlaceholderView.swift
Normal file
122
iphone/Maps/UI/Search/SearchOnMap/PlaceholderView.swift
Normal file
|
@ -0,0 +1,122 @@
|
|||
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()
|
||||
if #available(iOS 13.0, *) {
|
||||
activityIndicator.style = .medium
|
||||
} else {
|
||||
activityIndicator.style = .gray
|
||||
}
|
||||
}
|
||||
|
||||
titleLabel.text = title
|
||||
titleLabel.setFontStyle(.medium16, color: .blackPrimary)
|
||||
titleLabel.textAlignment = .center
|
||||
|
||||
subtitleLabel.text = subtitle
|
||||
subtitleLabel.setFontStyle(.regular14, color: .blackSecondary)
|
||||
subtitleLabel.textAlignment = .center
|
||||
subtitleLabel.isHidden = subtitle == nil
|
||||
subtitleLabel.numberOfLines = 2
|
||||
|
||||
stackView.axis = .vertical
|
||||
stackView.alignment = .center
|
||||
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)
|
||||
layoutIfNeeded()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - ModallyPresentedViewController
|
||||
extension PlaceholderView: ModallyPresentedViewController {
|
||||
func translationYDidUpdate(_ translationY: CGFloat) {
|
||||
self.containerModalYTranslation = translationY
|
||||
reloadConstraints()
|
||||
}
|
||||
}
|
|
@ -0,0 +1,30 @@
|
|||
/// A transparent view that allows touch events to pass through to the MapViewController's view.
|
||||
///
|
||||
/// This view is used to enable interaction with the underlying map while still maintaining a
|
||||
/// transparent overlay. It does not block touch events but forwards them to the specified `passingView`.
|
||||
|
||||
final class MapPassthroughView: UIView {
|
||||
private weak var passingView: UIView?
|
||||
|
||||
init(passingView: UIView) {
|
||||
self.passingView = passingView
|
||||
super.init(frame: passingView.bounds)
|
||||
self.autoresizingMask = [.flexibleWidth, .flexibleHeight]
|
||||
self.alpha = 0
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
|
||||
guard let passingView else { return nil }
|
||||
let pointInPassthroughView = passingView.convert(point, from: self)
|
||||
super.hitTest(point, with: event)
|
||||
if passingView.bounds.contains(pointInPassthroughView) {
|
||||
return MapViewController.shared()?.view.hitTest(point, with: event)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
|
@ -0,0 +1,94 @@
|
|||
enum ModalScreenPresentationStep {
|
||||
case fullScreen
|
||||
case halfScreen
|
||||
case compact
|
||||
case hidden
|
||||
}
|
||||
|
||||
extension ModalScreenPresentationStep {
|
||||
private enum Constants {
|
||||
static let iPadWidth: CGFloat = 350
|
||||
static let compactHeightOffset: CGFloat = 120
|
||||
static let fullScreenHeightFactorPortrait: CGFloat = 0.1
|
||||
static let halfScreenHeightFactorPortrait: CGFloat = 0.55
|
||||
static let landscapeTopInset: CGFloat = 10
|
||||
}
|
||||
|
||||
var upper: ModalScreenPresentationStep {
|
||||
switch self {
|
||||
case .fullScreen:
|
||||
return .fullScreen
|
||||
case .halfScreen:
|
||||
return .fullScreen
|
||||
case .compact:
|
||||
return .halfScreen
|
||||
case .hidden:
|
||||
return .compact
|
||||
}
|
||||
}
|
||||
|
||||
var lower: ModalScreenPresentationStep {
|
||||
switch self {
|
||||
case .fullScreen:
|
||||
return .halfScreen
|
||||
case .halfScreen:
|
||||
return .compact
|
||||
case .compact:
|
||||
return .compact
|
||||
case .hidden:
|
||||
return .hidden
|
||||
}
|
||||
}
|
||||
|
||||
var first: ModalScreenPresentationStep {
|
||||
.fullScreen
|
||||
}
|
||||
|
||||
var last: ModalScreenPresentationStep {
|
||||
.compact
|
||||
}
|
||||
|
||||
func frame(for viewController: UIViewController, in containerView: UIView) -> CGRect {
|
||||
let isIPad = UIDevice.current.userInterfaceIdiom == .pad
|
||||
let containerSize = containerView.bounds.size
|
||||
let safeAreaInsets = containerView.safeAreaInsets
|
||||
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 = viewController.traitCollection.verticalSizeClass == .regular
|
||||
if isPortraitOrientation {
|
||||
switch self {
|
||||
case .fullScreen:
|
||||
frame.origin.y = containerSize.height * Constants.fullScreenHeightFactorPortrait
|
||||
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.landscapeTopInset
|
||||
case .halfScreen, .compact:
|
||||
frame.origin.y = containerSize.height - Constants.compactHeightOffset
|
||||
case .hidden:
|
||||
frame.origin.y = containerSize.height
|
||||
}
|
||||
}
|
||||
return frame
|
||||
}
|
||||
}
|
|
@ -0,0 +1,226 @@
|
|||
protocol ModallyPresentedViewController {
|
||||
func translationYDidUpdate(_ translationY: CGFloat)
|
||||
}
|
||||
|
||||
protocol SearchOnMapModalPresentationView: AnyObject {
|
||||
func setPresentationStep(_ step: ModalScreenPresentationStep)
|
||||
func close()
|
||||
}
|
||||
|
||||
final class SearchOnMapModalPresentationController: UIPresentationController {
|
||||
|
||||
private enum StepChangeAnimation {
|
||||
case slide
|
||||
case slideAndBounce
|
||||
}
|
||||
|
||||
private enum Constants {
|
||||
static let animationDuration: TimeInterval = kDefaultAnimationDuration
|
||||
static let springDamping: CGFloat = 0.85
|
||||
static let springVelocity: CGFloat = 0.2
|
||||
static let iPhoneCornerRadius: CGFloat = 10
|
||||
static let slowSwipeVelocity: CGFloat = 500
|
||||
static let fastSwipeDownVelocity: CGFloat = 4000
|
||||
static let fastSwipeUpVelocity: CGFloat = 3000
|
||||
static let translationThreshold: CGFloat = 50
|
||||
static let panGestureThreshold: CGFloat = 5
|
||||
}
|
||||
|
||||
private var initialTranslationY: CGFloat = 0
|
||||
private weak var interactor: SearchOnMapInteractor? {
|
||||
(presentedViewController as? SearchOnMapViewController)?.interactor
|
||||
}
|
||||
// TODO: replace with set of steps passed from the outside
|
||||
private var presentationStep: ModalScreenPresentationStep = .fullScreen
|
||||
private var internalScrollViewContentOffset: CGFloat = 0
|
||||
private var maxAvailableFrameOfPresentedView: CGRect = .zero
|
||||
|
||||
// MARK: - Init
|
||||
override init(presentedViewController: UIViewController, presenting presentingViewController: UIViewController?) {
|
||||
super.init(presentedViewController: presentedViewController, presenting: presentingViewController)
|
||||
|
||||
iPhoneSpecific {
|
||||
let panGestureRecognizer = UIPanGestureRecognizer(target: self, action: #selector(handlePan(_:)))
|
||||
panGestureRecognizer.delegate = self
|
||||
presentedViewController.view.addGestureRecognizer(panGestureRecognizer)
|
||||
if let presentedViewController = presentedViewController as? SearchOnMapView {
|
||||
presentedViewController.scrollViewDelegate = self
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Lifecycle
|
||||
override func containerViewWillLayoutSubviews() {
|
||||
super.containerViewWillLayoutSubviews()
|
||||
presentedView?.frame = frameOfPresentedViewInContainerView
|
||||
}
|
||||
|
||||
override func presentationTransitionWillBegin() {
|
||||
guard let containerView else { return }
|
||||
containerView.backgroundColor = .clear
|
||||
let passThroughView = MapPassthroughView(passingView: containerView)
|
||||
containerView.addSubview(passThroughView)
|
||||
}
|
||||
|
||||
override func presentationTransitionDidEnd(_ completed: Bool) {
|
||||
translationYDidUpdate(presentedView?.frame.origin.y ?? 0)
|
||||
}
|
||||
|
||||
override func dismissalTransitionDidEnd(_ completed: Bool) {
|
||||
super.dismissalTransitionDidEnd(completed)
|
||||
if completed {
|
||||
presentedView?.removeFromSuperview()
|
||||
}
|
||||
}
|
||||
|
||||
override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
|
||||
super.traitCollectionDidChange(previousTraitCollection)
|
||||
updateMaxAvailableFrameOfPresentedView()
|
||||
}
|
||||
|
||||
// MARK: - Layout
|
||||
override var frameOfPresentedViewInContainerView: CGRect {
|
||||
guard let containerView else { return .zero }
|
||||
let frame = presentationStep.frame(for: presentedViewController, in: containerView)
|
||||
updateMaxAvailableFrameOfPresentedView()
|
||||
return frame
|
||||
}
|
||||
|
||||
private func updateMaxAvailableFrameOfPresentedView() {
|
||||
guard let containerView else { return }
|
||||
maxAvailableFrameOfPresentedView = ModalScreenPresentationStep.fullScreen.frame(for: presentedViewController, in: containerView)
|
||||
}
|
||||
|
||||
private func updateSideButtonsAvailableArea(_ newY: CGFloat) {
|
||||
iPhoneSpecific {
|
||||
guard presentedViewController.traitCollection.verticalSizeClass != .compact else { return }
|
||||
var sideButtonsAvailableArea = MWMSideButtons.getAvailableArea()
|
||||
sideButtonsAvailableArea.size.height = newY - sideButtonsAvailableArea.origin.y
|
||||
MWMSideButtons.updateAvailableArea(sideButtonsAvailableArea)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Pan gesture handling
|
||||
@objc private func handlePan(_ gesture: UIPanGestureRecognizer) {
|
||||
guard let presentedView, maxAvailableFrameOfPresentedView != .zero else { return }
|
||||
interactor?.handle(.didStartDraggingSearch)
|
||||
|
||||
let translation = gesture.translation(in: presentedView)
|
||||
let velocity = gesture.velocity(in: presentedView)
|
||||
|
||||
switch gesture.state {
|
||||
case .began:
|
||||
initialTranslationY = presentedView.frame.origin.y
|
||||
case .changed:
|
||||
let newY = max(max(initialTranslationY + translation.y, 0), maxAvailableFrameOfPresentedView.origin.y)
|
||||
presentedView.frame.origin.y = newY
|
||||
updateSideButtonsAvailableArea(newY)
|
||||
translationYDidUpdate(newY)
|
||||
case .ended:
|
||||
let nextStep: ModalScreenPresentationStep
|
||||
if velocity.y > Constants.fastSwipeDownVelocity {
|
||||
interactor?.handle(.closeSearch)
|
||||
return
|
||||
} else if velocity.y < -Constants.fastSwipeUpVelocity {
|
||||
nextStep = .fullScreen // fast swipe up
|
||||
} else if velocity.y > Constants.slowSwipeVelocity || translation.y > Constants.translationThreshold {
|
||||
if presentationStep == .compact {
|
||||
interactor?.handle(.closeSearch)
|
||||
return
|
||||
}
|
||||
nextStep = presentationStep.lower // regular swipe down
|
||||
} else if velocity.y < -Constants.slowSwipeVelocity || translation.y < -Constants.translationThreshold {
|
||||
nextStep = presentationStep.upper // regular swipe up
|
||||
} else {
|
||||
// TODO: swipe to closest step on the big translation
|
||||
nextStep = presentationStep
|
||||
}
|
||||
let animation: StepChangeAnimation = abs(velocity.y) > Constants.slowSwipeVelocity ? .slideAndBounce : .slide
|
||||
animateTo(nextStep, animation: animation)
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
private func animateTo(_ presentationStep: ModalScreenPresentationStep, animation: StepChangeAnimation = .slide) {
|
||||
guard let presentedView, let containerView else { return }
|
||||
self.presentationStep = presentationStep
|
||||
interactor?.handle(.didUpdatePresentationStep(presentationStep))
|
||||
|
||||
let updatedFrame = presentationStep.frame(for: presentedViewController, in: containerView)
|
||||
let targetYTranslation = updatedFrame.origin.y
|
||||
|
||||
switch animation {
|
||||
case .slide:
|
||||
UIView.animate(withDuration: Constants.animationDuration,
|
||||
delay: 0,
|
||||
options: .curveEaseOut,
|
||||
animations: { [weak self] in
|
||||
presentedView.frame = updatedFrame
|
||||
self?.translationYDidUpdate(targetYTranslation)
|
||||
self?.updateSideButtonsAvailableArea(targetYTranslation)
|
||||
})
|
||||
case .slideAndBounce:
|
||||
UIView.animate(withDuration: Constants.animationDuration,
|
||||
delay: 0,
|
||||
usingSpringWithDamping: Constants.springDamping,
|
||||
initialSpringVelocity: Constants.springVelocity,
|
||||
options: .curveLinear,
|
||||
animations: { [weak self] in
|
||||
presentedView.frame = updatedFrame
|
||||
self?.translationYDidUpdate(targetYTranslation)
|
||||
self?.updateSideButtonsAvailableArea(targetYTranslation)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - SearchOnMapModalPresentationView
|
||||
extension SearchOnMapModalPresentationController: SearchOnMapModalPresentationView {
|
||||
func setPresentationStep(_ step: ModalScreenPresentationStep) {
|
||||
guard presentationStep != step else { return }
|
||||
animateTo(step)
|
||||
}
|
||||
|
||||
func close() {
|
||||
guard let containerView else { return }
|
||||
updateSideButtonsAvailableArea(containerView.frame.height)
|
||||
presentedViewController.dismiss(animated: true)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - ModallyPresentedViewController
|
||||
extension SearchOnMapModalPresentationController: ModallyPresentedViewController {
|
||||
func translationYDidUpdate(_ translationY: CGFloat) {
|
||||
iPhoneSpecific {
|
||||
(presentedViewController as? SearchOnMapViewController)?.translationYDidUpdate(translationY)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - UIGestureRecognizerDelegate
|
||||
extension SearchOnMapModalPresentationController: UIGestureRecognizerDelegate {
|
||||
func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
|
||||
true
|
||||
}
|
||||
|
||||
func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool {
|
||||
// threshold is used to soften transition from the internal scroll zero content offset
|
||||
internalScrollViewContentOffset < Constants.panGestureThreshold
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - SearchOnMapScrollViewDelegate
|
||||
extension SearchOnMapModalPresentationController: SearchOnMapScrollViewDelegate {
|
||||
func scrollViewDidScroll(_ scrollView: UIScrollView) {
|
||||
guard let presentedView else { return }
|
||||
let hasReachedTheTop = Int(presentedView.frame.origin.y) > Int(maxAvailableFrameOfPresentedView.origin.y)
|
||||
let hasZeroContentOffset = internalScrollViewContentOffset == 0
|
||||
if hasReachedTheTop && hasZeroContentOffset {
|
||||
// prevent the internal scroll view scrolling
|
||||
scrollView.contentOffset.y = internalScrollViewContentOffset
|
||||
return
|
||||
}
|
||||
internalScrollViewContentOffset = scrollView.contentOffset.y
|
||||
}
|
||||
}
|
|
@ -0,0 +1,21 @@
|
|||
@objc
|
||||
final class SearchOnMapModalTransitionManager: NSObject, UIViewControllerTransitioningDelegate {
|
||||
|
||||
weak var presentationController: SearchOnMapModalPresentationView?
|
||||
|
||||
func animationController(forPresented presented: UIViewController, presenting: UIViewController, source: UIViewController) -> (any UIViewControllerAnimatedTransitioning)? {
|
||||
isIPad ? SideMenuPresentationAnimator() : nil
|
||||
}
|
||||
|
||||
func animationController(forDismissed dismissed: UIViewController) -> (any UIViewControllerAnimatedTransitioning)? {
|
||||
isIPad ? SideMenuDismissalAnimator() : nil
|
||||
}
|
||||
|
||||
func presentationController(forPresented presented: UIViewController,
|
||||
presenting: UIViewController?,
|
||||
source: UIViewController) -> UIPresentationController? {
|
||||
let presentationController = SearchOnMapModalPresentationController(presentedViewController: presented, presenting: presenting)
|
||||
self.presentationController = presentationController
|
||||
return presentationController
|
||||
}
|
||||
}
|
|
@ -0,0 +1,22 @@
|
|||
final class SideMenuDismissalAnimator: NSObject, UIViewControllerAnimatedTransitioning {
|
||||
func transitionDuration(using transitionContext: UIViewControllerContextTransitioning?) -> TimeInterval {
|
||||
return kDefaultAnimationDuration / 2
|
||||
}
|
||||
|
||||
func animateTransition(using transitionContext: UIViewControllerContextTransitioning) {
|
||||
guard let fromVC = transitionContext.viewController(forKey: .from) else { return }
|
||||
let initialFrame = transitionContext.initialFrame(for: fromVC)
|
||||
let targetFrame = initialFrame.offsetBy(dx: -initialFrame.width, dy: 0)
|
||||
|
||||
UIView.animate(withDuration: transitionDuration(using: transitionContext),
|
||||
delay: .zero,
|
||||
options: .curveEaseIn,
|
||||
animations: {
|
||||
fromVC.view.frame = targetFrame
|
||||
},
|
||||
completion: {
|
||||
fromVC.view.removeFromSuperview()
|
||||
transitionContext.completeTransition($0)
|
||||
})
|
||||
}
|
||||
}
|
|
@ -0,0 +1,25 @@
|
|||
final class SideMenuPresentationAnimator: NSObject, UIViewControllerAnimatedTransitioning {
|
||||
func transitionDuration(using transitionContext: UIViewControllerContextTransitioning?) -> TimeInterval {
|
||||
return kDefaultAnimationDuration / 2
|
||||
}
|
||||
|
||||
func animateTransition(using transitionContext: UIViewControllerContextTransitioning) {
|
||||
guard let toVC = transitionContext.viewController(forKey: .to) else { return }
|
||||
let containerView = transitionContext.containerView
|
||||
let finalFrame = transitionContext.finalFrame(for: toVC)
|
||||
let originFrame = finalFrame.offsetBy(dx: -finalFrame.width, dy: 0)
|
||||
containerView.addSubview(toVC.view)
|
||||
toVC.view.frame = originFrame
|
||||
toVC.view.autoresizingMask = [.flexibleWidth, .flexibleHeight]
|
||||
|
||||
UIView.animate(withDuration: transitionDuration(using: transitionContext),
|
||||
delay: .zero,
|
||||
options: .curveEaseOut,
|
||||
animations: {
|
||||
toVC.view.frame = finalFrame
|
||||
},
|
||||
completion: {
|
||||
transitionContext.completeTransition($0)
|
||||
})
|
||||
}
|
||||
}
|
165
iphone/Maps/UI/Search/SearchOnMap/SearchOnMapInteractor.swift
Normal file
165
iphone/Maps/UI/Search/SearchOnMap/SearchOnMapInteractor.swift
Normal file
|
@ -0,0 +1,165 @@
|
|||
final class SearchOnMapInteractor: NSObject {
|
||||
|
||||
private let presenter: SearchOnMapPresenter
|
||||
private let searchManager: SearchManager.Type
|
||||
private let routeManager: MWMRouter.Type
|
||||
private var isUpdatesDisabled = false
|
||||
private var showResultsOnMap: Bool = 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 .didSelectText(let searchText, let isCategory):
|
||||
return processSelectedText(searchText, isCategory: isCategory)
|
||||
case .searchButtonDidTap(let searchText):
|
||||
return processSearchButtonDidTap(searchText)
|
||||
case .didSelectResult(let result, let index, let searchText):
|
||||
return processSelectedResult(result, index: index, searchText: searchText)
|
||||
case .didSelectPlaceOnMap:
|
||||
return isIPad ? .none : .setSearchScreenHidden(true)
|
||||
case .didDeselectPlaceOnMap:
|
||||
return deselectPlaceOnMap()
|
||||
case .didStartDraggingMap:
|
||||
return .setSearchScreenCompact
|
||||
case .didUpdatePresentationStep(let step):
|
||||
return .updatePresentationStep(step)
|
||||
case .closeSearch:
|
||||
return closeSearch()
|
||||
}
|
||||
}
|
||||
|
||||
private func processClearButtonDidTap() -> SearchOnMap.Response {
|
||||
isUpdatesDisabled = true
|
||||
searchManager.clear()
|
||||
return .clearSearch
|
||||
}
|
||||
|
||||
private func processSearchButtonDidTap(_ searchText: SearchOnMap.SearchText) -> SearchOnMap.Response {
|
||||
searchManager.saveQuery(searchText.text,
|
||||
forInputLocale: searchText.locale)
|
||||
showResultsOnMap = true
|
||||
return .showOnTheMap
|
||||
}
|
||||
|
||||
private func processTypedText(_ searchText: SearchOnMap.SearchText) -> SearchOnMap.Response {
|
||||
isUpdatesDisabled = false
|
||||
showResultsOnMap = true
|
||||
searchManager.searchQuery(searchText.text,
|
||||
forInputLocale: searchText.locale,
|
||||
withCategory: false)
|
||||
return .startSearching
|
||||
}
|
||||
|
||||
private func processSelectedText(_ searchText: SearchOnMap.SearchText, isCategory: Bool) -> SearchOnMap.Response {
|
||||
isUpdatesDisabled = false
|
||||
searchManager.saveQuery(searchText.text,
|
||||
forInputLocale: searchText.locale)
|
||||
searchManager.searchQuery(searchText.text,
|
||||
forInputLocale: searchText.locale,
|
||||
withCategory: isCategory)
|
||||
showResultsOnMap = true
|
||||
return .selectText(searchText.text)
|
||||
}
|
||||
|
||||
private func processSelectedResult(_ result: SearchResult, index: Int, searchText: SearchOnMap.SearchText) -> SearchOnMap.Response {
|
||||
switch result.itemType {
|
||||
case .regular:
|
||||
searchManager.saveQuery(searchText.text,
|
||||
forInputLocale:searchText.locale)
|
||||
switch routingTooltipSearch {
|
||||
case .none:
|
||||
searchManager.showResult(at: UInt(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:
|
||||
searchManager.searchQuery(result.suggestion,
|
||||
forInputLocale: searchText.locale,
|
||||
withCategory: result.isPureSuggest)
|
||||
return .selectText(result.suggestion)
|
||||
@unknown default:
|
||||
fatalError("Unsupported result type")
|
||||
}
|
||||
}
|
||||
|
||||
private func deselectPlaceOnMap() -> SearchOnMap.Response {
|
||||
routingTooltipSearch = .none
|
||||
searchManager.showViewportSearchResultsOnMap()
|
||||
return .setSearchScreenHidden(false)
|
||||
}
|
||||
|
||||
private func closeSearch() -> SearchOnMap.Response {
|
||||
routingTooltipSearch = .none
|
||||
isUpdatesDisabled = true
|
||||
showResultsOnMap = false
|
||||
searchManager.clear()
|
||||
return .close
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - MWMSearchObserver
|
||||
extension SearchOnMapInteractor: MWMSearchObserver {
|
||||
func onSearchCompleted() {
|
||||
guard !isUpdatesDisabled else { return }
|
||||
let results = searchManager.getResults()
|
||||
if showResultsOnMap && !results.isEmpty {
|
||||
searchManager.showEverywhereSearchResultsOnMap()
|
||||
showResultsOnMap = false
|
||||
}
|
||||
presenter.process(.showResults(SearchOnMap.SearchResults(results), isSearchCompleted: true))
|
||||
}
|
||||
|
||||
func onSearchResultsUpdated() {
|
||||
guard !isUpdatesDisabled else { return }
|
||||
let results = searchManager.getResults()
|
||||
guard !results.isEmpty else { return }
|
||||
presenter.process(.showResults(SearchOnMap.SearchResults(results), isSearchCompleted: false))
|
||||
}
|
||||
}
|
96
iphone/Maps/UI/Search/SearchOnMap/SearchOnMapManager.swift
Normal file
96
iphone/Maps/UI/Search/SearchOnMap/SearchOnMapManager.swift
Normal file
|
@ -0,0 +1,96 @@
|
|||
@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 let navigationController: UINavigationController
|
||||
private weak var interactor: SearchOnMapInteractor?
|
||||
private let observers = ListenerContainer<SearchOnMapManagerObserver>()
|
||||
|
||||
// MARK: - Public properties
|
||||
weak var viewController: UIViewController?
|
||||
var isSearching: Bool { viewController != nil }
|
||||
|
||||
init(navigationController: UINavigationController = MapViewController.shared()!.navigationController!) {
|
||||
self.navigationController = navigationController
|
||||
}
|
||||
|
||||
// MARK: - Public methods
|
||||
func startSearching(isRouting: Bool) {
|
||||
if viewController != nil {
|
||||
interactor?.handle(.openSearch)
|
||||
return
|
||||
}
|
||||
FrameworkHelper.deactivateMapSelection()
|
||||
let viewController = buildViewController(isRouting: isRouting)
|
||||
self.viewController = viewController
|
||||
self.interactor = viewController.interactor
|
||||
navigationController.present(viewController, animated: true)
|
||||
}
|
||||
|
||||
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(_ text: String, locale: String, isCategory: Bool) {
|
||||
let searchText = SearchOnMap.SearchText(text, locale: locale)
|
||||
interactor?.handle(.didSelectText(searchText, isCategory: isCategory))
|
||||
}
|
||||
|
||||
func addObserver(_ observer: SearchOnMapManagerObserver) {
|
||||
observers.addListener(observer)
|
||||
}
|
||||
|
||||
func removeObserver(_ observer: SearchOnMapManagerObserver) {
|
||||
observers.removeListener(observer)
|
||||
}
|
||||
|
||||
// MARK: - Private methods
|
||||
private func buildViewController(isRouting: Bool) -> SearchOnMapViewController {
|
||||
let transitioningManager = SearchOnMapModalTransitionManager()
|
||||
let presenter = SearchOnMapPresenter(transitionManager: transitioningManager,
|
||||
isRouting: isRouting,
|
||||
didChangeState: { [weak self] state in
|
||||
guard let self else { return }
|
||||
self.observers.forEach { observer in observer.searchManager(didChangeState: state) }
|
||||
})
|
||||
let interactor = SearchOnMapInteractor(presenter: presenter)
|
||||
let viewController = SearchOnMapViewController(interactor: interactor)
|
||||
presenter.view = viewController
|
||||
viewController.modalPresentationStyle = .custom
|
||||
viewController.transitioningDelegate = transitioningManager
|
||||
return viewController
|
||||
}
|
||||
}
|
94
iphone/Maps/UI/Search/SearchOnMap/SearchOnMapModels.swift
Normal file
94
iphone/Maps/UI/Search/SearchOnMap/SearchOnMapModels.swift
Normal file
|
@ -0,0 +1,94 @@
|
|||
enum SearchOnMap {
|
||||
struct ViewModel: Equatable {
|
||||
enum ContentState: Equatable {
|
||||
case historyAndCategory
|
||||
case results(SearchResults)
|
||||
case noResults
|
||||
case searching
|
||||
}
|
||||
|
||||
var isTyping: Bool
|
||||
var skipSuggestions: Bool
|
||||
var searchingText: String?
|
||||
var contentState: ContentState
|
||||
var presentationStep: ModalScreenPresentationStep
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
struct SearchText {
|
||||
let text: String
|
||||
let locale: String
|
||||
|
||||
init(_ text: String, locale: String? = nil) {
|
||||
self.text = text
|
||||
self.locale = locale ?? AppInfo.shared().languageId
|
||||
}
|
||||
}
|
||||
|
||||
enum Request {
|
||||
case openSearch
|
||||
case hideSearch
|
||||
case closeSearch
|
||||
case didStartDraggingSearch
|
||||
case didStartDraggingMap
|
||||
case didStartTyping
|
||||
case didType(SearchText)
|
||||
case didSelectText(SearchText, isCategory: Bool)
|
||||
case didSelectResult(SearchResult, atIndex: Int, withSearchText: SearchText)
|
||||
case searchButtonDidTap(SearchText)
|
||||
case clearButtonDidTap
|
||||
case didSelectPlaceOnMap
|
||||
case didDeselectPlaceOnMap
|
||||
case didUpdatePresentationStep(ModalScreenPresentationStep)
|
||||
}
|
||||
|
||||
enum Response: Equatable {
|
||||
case startSearching
|
||||
case showOnTheMap
|
||||
case setIsTyping(Bool)
|
||||
case showHistoryAndCategory
|
||||
case showResults(SearchResults, isSearchCompleted: Bool = false)
|
||||
case selectText(String?)
|
||||
case clearSearch
|
||||
case setSearchScreenHidden(Bool)
|
||||
case setSearchScreenCompact
|
||||
case updatePresentationStep(ModalScreenPresentationStep)
|
||||
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)
|
||||
}
|
117
iphone/Maps/UI/Search/SearchOnMap/SearchOnMapPresenter.swift
Normal file
117
iphone/Maps/UI/Search/SearchOnMap/SearchOnMapPresenter.swift
Normal file
|
@ -0,0 +1,117 @@
|
|||
final class SearchOnMapPresenter {
|
||||
typealias Response = SearchOnMap.Response
|
||||
typealias ViewModel = SearchOnMap.ViewModel
|
||||
|
||||
weak var view: SearchOnMapView?
|
||||
weak var presentationView: SearchOnMapModalPresentationView? { transitionManager.presentationController }
|
||||
|
||||
private var searchState: SearchOnMapState = .searching {
|
||||
didSet {
|
||||
guard searchState != oldValue else { return }
|
||||
didChangeState?(searchState)
|
||||
}
|
||||
}
|
||||
|
||||
private let transitionManager: SearchOnMapModalTransitionManager
|
||||
private var viewModel: ViewModel = .initial
|
||||
private var isRouting: Bool
|
||||
private var didChangeState: ((SearchOnMapState) -> Void)?
|
||||
|
||||
init(transitionManager: SearchOnMapModalTransitionManager, isRouting: Bool, didChangeState: ((SearchOnMapState) -> Void)?) {
|
||||
self.transitionManager = transitionManager
|
||||
self.isRouting = isRouting
|
||||
self.didChangeState = didChangeState
|
||||
didChangeState?(searchState)
|
||||
}
|
||||
|
||||
func process(_ response: SearchOnMap.Response) {
|
||||
guard response != .none else { return }
|
||||
|
||||
if response == .close {
|
||||
searchState = .closed
|
||||
presentationView?.close()
|
||||
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
|
||||
presentationView?.setPresentationStep(newViewModel.presentationStep)
|
||||
}
|
||||
}
|
||||
|
||||
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 .selectText(let text):
|
||||
viewModel.isTyping = false
|
||||
viewModel.skipSuggestions = false
|
||||
viewModel.searchingText = text
|
||||
viewModel.contentState = .searching
|
||||
viewModel.presentationStep = isRouting ? .hidden : .halfScreen
|
||||
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):
|
||||
viewModel.presentationStep = step
|
||||
case .close, .none:
|
||||
break
|
||||
}
|
||||
return viewModel
|
||||
}
|
||||
}
|
||||
|
||||
private extension ModalScreenPresentationStep {
|
||||
var searchState: SearchOnMapState {
|
||||
switch self {
|
||||
case .fullScreen, .halfScreen, .compact:
|
||||
return .searching
|
||||
case .hidden:
|
||||
return .hidden
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,433 @@
|
|||
import UIKit
|
||||
|
||||
protocol SearchOnMapView: AnyObject {
|
||||
var scrollViewDelegate: SearchOnMapScrollViewDelegate? { get set }
|
||||
|
||||
func render(_ viewModel: SearchOnMap.ViewModel)
|
||||
}
|
||||
|
||||
@objc
|
||||
protocol SearchOnMapScrollViewDelegate: AnyObject {
|
||||
func scrollViewDidScroll(_ scrollView: UIScrollView)
|
||||
}
|
||||
|
||||
final class SearchOnMapViewController: UIViewController {
|
||||
typealias ViewModel = SearchOnMap.ViewModel
|
||||
typealias ContentState = SearchOnMap.ViewModel.ContentState
|
||||
typealias SearchText = SearchOnMap.SearchText
|
||||
|
||||
fileprivate enum Constants {
|
||||
static let grabberHeight: CGFloat = 5
|
||||
static let grabberWidth: CGFloat = 36
|
||||
static let grabberTopMargin: CGFloat = 5
|
||||
static let categoriesHeight: CGFloat = 100
|
||||
static let filtersHeight: CGFloat = 50
|
||||
static let keyboardAnimationDuration: CGFloat = 0.3
|
||||
static let cancelButtonInsets: UIEdgeInsets = UIEdgeInsets(top: 0, left: 6, bottom: 0, right: 8)
|
||||
static let estimatedRowHeight: CGFloat = 80
|
||||
}
|
||||
|
||||
let interactor: SearchOnMapInteractor
|
||||
weak var scrollViewDelegate: SearchOnMapScrollViewDelegate?
|
||||
|
||||
private var searchResults = SearchOnMap.SearchResults([])
|
||||
|
||||
// MARK: - UI Elements
|
||||
// TODO: move the header into the separate class
|
||||
private let headerView = UIView()
|
||||
private let searchBar = UISearchBar()
|
||||
private let cancelButton = UIButton()
|
||||
private let containerView = UIView()
|
||||
private let grabberView = UIView()
|
||||
private let resultsTableView = UITableView()
|
||||
private let historyAndCategoryTabViewController = SearchTabViewController()
|
||||
// TODO: implement filters
|
||||
private let filtersCollectionView: UICollectionView = {
|
||||
let layout = UICollectionViewFlowLayout()
|
||||
layout.scrollDirection = .horizontal
|
||||
return UICollectionView(frame: .zero, collectionViewLayout: layout)
|
||||
}()
|
||||
private var searchingActivityView = PlaceholderView(hasActivityIndicator: true)
|
||||
private var containerModalYTranslation: CGFloat = 0
|
||||
private var searchNoResultsView = PlaceholderView(title: L("search_not_found"),
|
||||
subtitle: L("search_not_found_query"))
|
||||
|
||||
// MARK: - Init
|
||||
init(interactor: SearchOnMapInteractor) {
|
||||
self.interactor = interactor
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
deinit {
|
||||
NotificationCenter.default.removeObserver(self)
|
||||
}
|
||||
|
||||
// MARK: - Lifecycle
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
setupViews()
|
||||
layoutViews()
|
||||
interactor.handle(.openSearch)
|
||||
}
|
||||
|
||||
override func viewWillDisappear(_ animated: Bool) {
|
||||
super.viewWillDisappear(animated)
|
||||
searchBar.resignFirstResponder()
|
||||
}
|
||||
|
||||
// MARK: - Private methods
|
||||
private func setupViews() {
|
||||
setupTapGestureRecognizer()
|
||||
setupHeaderView()
|
||||
setupContainerView()
|
||||
setupResultsTableView()
|
||||
setupHistoryAndCategoryTabView()
|
||||
setupResultsTableView()
|
||||
setupFiltersCollectionView()
|
||||
}
|
||||
|
||||
private func setupTapGestureRecognizer() {
|
||||
let tapGesture = UITapGestureRecognizer(target: self, action: #selector(handleTapOutside))
|
||||
tapGesture.cancelsTouchesInView = false
|
||||
view.addGestureRecognizer(tapGesture)
|
||||
}
|
||||
|
||||
private func setupHeaderView() {
|
||||
headerView.setStyle(.searchHeader)
|
||||
headerView.layer.maskedCorners = [.layerMinXMinYCorner, .layerMaxXMinYCorner]
|
||||
setupGrabberView()
|
||||
setupSearchBar()
|
||||
setupCancelButton()
|
||||
}
|
||||
|
||||
private func setupGrabberView() {
|
||||
grabberView.setStyle(.background)
|
||||
grabberView.layer.setCorner(radius: Constants.grabberHeight / 2)
|
||||
iPadSpecific { [weak self] in
|
||||
self?.grabberView.isHidden = true
|
||||
}
|
||||
}
|
||||
|
||||
private func setupCancelButton() {
|
||||
cancelButton.tintColor = .whitePrimaryText()
|
||||
cancelButton.setStyle(.clearBackground)
|
||||
cancelButton.setTitle(L("cancel"), for: .normal)
|
||||
cancelButton.addTarget(self, action: #selector(cancelButtonDidTap), for: .touchUpInside)
|
||||
}
|
||||
|
||||
private func setupSearchBar() {
|
||||
searchBar.placeholder = L("search")
|
||||
searchBar.delegate = self
|
||||
searchBar.showsCancelButton = false
|
||||
if #available(iOS 13.0, *) {
|
||||
searchBar.searchTextField.clearButtonMode = .always
|
||||
searchBar.returnKeyType = .search
|
||||
searchBar.searchTextField.enablesReturnKeyAutomatically = true
|
||||
}
|
||||
}
|
||||
|
||||
private func setupContainerView() {
|
||||
containerView.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 setupFiltersCollectionView() {
|
||||
filtersCollectionView.register(UICollectionViewCell.self, forCellWithReuseIdentifier: "FilterCell")
|
||||
filtersCollectionView.dataSource = self
|
||||
}
|
||||
|
||||
private func layoutViews() {
|
||||
view.addSubview(headerView)
|
||||
view.addSubview(containerView)
|
||||
headerView.translatesAutoresizingMaskIntoConstraints = false
|
||||
containerView.translatesAutoresizingMaskIntoConstraints = false
|
||||
|
||||
NSLayoutConstraint.activate([
|
||||
headerView.topAnchor.constraint(equalTo: view.topAnchor),
|
||||
headerView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
||||
headerView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
|
||||
|
||||
containerView.topAnchor.constraint(equalTo: headerView.bottomAnchor),
|
||||
containerView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
||||
containerView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
|
||||
containerView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
|
||||
])
|
||||
|
||||
layoutHeaderView()
|
||||
layoutResultsView()
|
||||
layoutHistoryAndCategoryTabView()
|
||||
layoutSearchNoResultsView()
|
||||
layoutSearchingView()
|
||||
}
|
||||
|
||||
private func layoutHeaderView() {
|
||||
headerView.addSubview(grabberView)
|
||||
headerView.addSubview(searchBar)
|
||||
headerView.addSubview(cancelButton)
|
||||
|
||||
grabberView.translatesAutoresizingMaskIntoConstraints = false
|
||||
searchBar.translatesAutoresizingMaskIntoConstraints = false
|
||||
cancelButton.translatesAutoresizingMaskIntoConstraints = false
|
||||
|
||||
searchBar.setContentHuggingPriority(.defaultHigh, for: .vertical)
|
||||
headerView.setContentHuggingPriority(.defaultHigh, for: .vertical)
|
||||
cancelButton.setContentCompressionResistancePriority(.defaultHigh, for: .horizontal)
|
||||
NSLayoutConstraint.activate([
|
||||
grabberView.topAnchor.constraint(equalTo: headerView.topAnchor, constant: Constants.grabberTopMargin),
|
||||
grabberView.centerXAnchor.constraint(equalTo: headerView.centerXAnchor),
|
||||
grabberView.widthAnchor.constraint(equalToConstant: Constants.grabberWidth),
|
||||
grabberView.heightAnchor.constraint(equalToConstant: Constants.grabberHeight),
|
||||
|
||||
searchBar.topAnchor.constraint(equalTo: grabberView.bottomAnchor),
|
||||
searchBar.leadingAnchor.constraint(equalTo: headerView.leadingAnchor),
|
||||
searchBar.trailingAnchor.constraint(equalTo: cancelButton.leadingAnchor, constant: -Constants.cancelButtonInsets.left),
|
||||
|
||||
cancelButton.centerYAnchor.constraint(equalTo: searchBar.centerYAnchor),
|
||||
cancelButton.trailingAnchor.constraint(equalTo: headerView.trailingAnchor, constant: -Constants.cancelButtonInsets.right),
|
||||
|
||||
headerView.bottomAnchor.constraint(equalTo: searchBar.bottomAnchor)
|
||||
])
|
||||
}
|
||||
|
||||
private func layoutResultsView() {
|
||||
containerView.addSubview(resultsTableView)
|
||||
resultsTableView.translatesAutoresizingMaskIntoConstraints = false
|
||||
|
||||
NSLayoutConstraint.activate([
|
||||
resultsTableView.topAnchor.constraint(equalTo: containerView.topAnchor),
|
||||
resultsTableView.leadingAnchor.constraint(equalTo: containerView.leadingAnchor),
|
||||
resultsTableView.trailingAnchor.constraint(equalTo: containerView.trailingAnchor),
|
||||
resultsTableView.bottomAnchor.constraint(equalTo: containerView.bottomAnchor)
|
||||
])
|
||||
}
|
||||
|
||||
private func layoutHistoryAndCategoryTabView() {
|
||||
containerView.addSubview(historyAndCategoryTabViewController.view)
|
||||
historyAndCategoryTabViewController.view.translatesAutoresizingMaskIntoConstraints = false
|
||||
|
||||
NSLayoutConstraint.activate([
|
||||
historyAndCategoryTabViewController.view.topAnchor.constraint(equalTo: containerView.topAnchor),
|
||||
historyAndCategoryTabViewController.view.leadingAnchor.constraint(equalTo: containerView.leadingAnchor),
|
||||
historyAndCategoryTabViewController.view.trailingAnchor.constraint(equalTo: containerView.trailingAnchor),
|
||||
historyAndCategoryTabViewController.view.bottomAnchor.constraint(equalTo: containerView.bottomAnchor)
|
||||
])
|
||||
}
|
||||
|
||||
private func layoutSearchNoResultsView() {
|
||||
searchNoResultsView.translatesAutoresizingMaskIntoConstraints = false
|
||||
containerView.addSubview(searchNoResultsView)
|
||||
NSLayoutConstraint.activate([
|
||||
searchNoResultsView.topAnchor.constraint(equalTo: containerView.topAnchor),
|
||||
searchNoResultsView.leadingAnchor.constraint(equalTo: containerView.leadingAnchor),
|
||||
searchNoResultsView.trailingAnchor.constraint(equalTo: containerView.trailingAnchor),
|
||||
searchNoResultsView.bottomAnchor.constraint(equalTo: containerView.bottomAnchor)
|
||||
])
|
||||
}
|
||||
|
||||
private func layoutSearchingView() {
|
||||
containerView.insertSubview(searchingActivityView, at: 0)
|
||||
searchingActivityView.translatesAutoresizingMaskIntoConstraints = false
|
||||
NSLayoutConstraint.activate([
|
||||
searchingActivityView.leadingAnchor.constraint(equalTo: containerView.leadingAnchor),
|
||||
searchingActivityView.trailingAnchor.constraint(equalTo: containerView.trailingAnchor),
|
||||
searchingActivityView.topAnchor.constraint(equalTo: containerView.topAnchor),
|
||||
searchingActivityView.bottomAnchor.constraint(equalTo: containerView.bottomAnchor)
|
||||
])
|
||||
}
|
||||
|
||||
// MARK: - Handle Button Actions
|
||||
@objc private func cancelButtonDidTap() {
|
||||
interactor.handle(.closeSearch)
|
||||
}
|
||||
|
||||
@objc private func handleTapOutside(_ gesture: UITapGestureRecognizer) {
|
||||
let location = gesture.location(in: view)
|
||||
if resultsTableView.frame.contains(location) && searchResults.isEmpty {
|
||||
searchBar.resignFirstResponder()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Handle State Updates
|
||||
private func setContent(_ content: ContentState) {
|
||||
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
|
||||
}
|
||||
showView(viewToShow(for: content))
|
||||
}
|
||||
|
||||
private func viewToShow(for content: ContentState) -> 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.transition(with: containerView,
|
||||
duration: kDefaultAnimationDuration / 2,
|
||||
options: [.transitionCrossDissolve, .curveEaseInOut], animations: {
|
||||
viewsToHide.forEach { viewToHide in
|
||||
view.isHidden = false
|
||||
view.alpha = 1
|
||||
viewToHide.isHidden = true
|
||||
viewToHide.alpha = 0
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private func setIsSearching(_ isSearching: Bool) {
|
||||
if isSearching {
|
||||
searchBar.becomeFirstResponder()
|
||||
} else if searchBar.isFirstResponder {
|
||||
searchBar.resignFirstResponder()
|
||||
}
|
||||
}
|
||||
|
||||
private func replaceSearchText(with text: String) {
|
||||
searchBar.text = text
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Public methods
|
||||
extension SearchOnMapViewController: SearchOnMapView {
|
||||
func render(_ viewModel: ViewModel) {
|
||||
setContent(viewModel.contentState)
|
||||
setIsSearching(viewModel.isTyping)
|
||||
if let searchingText = viewModel.searchingText {
|
||||
replaceSearchText(with: searchingText)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - ModallyPresentedViewController
|
||||
extension SearchOnMapViewController: ModallyPresentedViewController {
|
||||
func translationYDidUpdate(_ translationY: CGFloat) {
|
||||
self.containerModalYTranslation = translationY
|
||||
resultsTableView.contentInset.bottom = translationY
|
||||
historyAndCategoryTabViewController.translationYDidUpdate(translationY)
|
||||
searchNoResultsView.translationYDidUpdate(translationY)
|
||||
searchingActivityView.translationYDidUpdate(translationY)
|
||||
}
|
||||
}
|
||||
|
||||
// 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, atIndex: indexPath.row, withSearchText: SearchText(searchBar.text ?? "", locale: searchBar.textInputMode?.primaryLanguage)))
|
||||
tableView.deselectRow(at: indexPath, animated: true)
|
||||
}
|
||||
|
||||
func scrollViewWillBeginDragging(_ scrollView: UIScrollView) {
|
||||
interactor.handle(.didStartDraggingSearch)
|
||||
}
|
||||
|
||||
func scrollViewDidScroll(_ scrollView: UIScrollView) {
|
||||
scrollViewDelegate?.scrollViewDidScroll(scrollView)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - UICollectionViewDataSource
|
||||
extension SearchOnMapViewController: UICollectionViewDataSource {
|
||||
func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
|
||||
// TODO: remove search from here
|
||||
Int(Search.resultsCount())
|
||||
}
|
||||
|
||||
func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
|
||||
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "FilterCell", for: indexPath)
|
||||
return cell
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - UISearchBarDelegate
|
||||
extension SearchOnMapViewController: UISearchBarDelegate {
|
||||
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(SearchText(searchText, locale: searchBar.textInputMode?.primaryLanguage)))
|
||||
}
|
||||
|
||||
func searchBarSearchButtonClicked(_ searchBar: UISearchBar) {
|
||||
guard let searchText = searchBar.text, !searchText.isEmpty else { return }
|
||||
interactor.handle(.searchButtonDidTap(SearchText(searchText, locale: searchBar.textInputMode?.primaryLanguage)))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - SearchTabViewControllerDelegate
|
||||
extension SearchOnMapViewController: SearchTabViewControllerDelegate {
|
||||
func searchTabController(_ viewController: SearchTabViewController, didSearch text: String, withCategory: Bool) {
|
||||
interactor.handle(.didSelectText(SearchText(text, locale: nil), isCategory: withCategory))
|
||||
}
|
||||
}
|
||||
|
Reference in a new issue