From 5db61f049863a74f53afb08fccfb8690774c766e Mon Sep 17 00:00:00 2001 From: Kiryl Kaveryn Date: Wed, 5 Feb 2025 17:15:20 +0400 Subject: [PATCH] [ios] implement modal search screen SearchOnMap Signed-off-by: Kiryl Kaveryn --- iphone/Maps/Core/Search/MWMSearch.h | 15 +- iphone/Maps/Core/Search/MWMSearch.mm | 6 +- iphone/Maps/Maps.xcodeproj/project.pbxproj | 92 ++++- .../SearchOnMapTests/SearchOnMapTests.swift | 262 +++++++++++++ .../Search/SearchOnMap/PlaceholderView.swift | 122 ++++++ .../Presentation/MapPassthroughView.swift | 30 ++ .../ModalScreenPresentationStep.swift | 94 +++++ ...archOnMapModalPresentationController.swift | 226 +++++++++++ .../SearchOnMapModalTransitionManager.swift | 21 + .../SideMenuDismissalAnimator.swift | 22 ++ .../SideMenuPresentationAnimator.swift | 25 ++ .../SearchOnMap/SearchOnMapHeaderView.swift | 113 ++++++ .../SearchOnMap/SearchOnMapInteractor.swift | 165 ++++++++ .../SearchOnMap/SearchOnMapManager.swift | 96 +++++ .../SearchOnMap/SearchOnMapModels.swift | 94 +++++ .../SearchOnMap/SearchOnMapPresenter.swift | 117 ++++++ .../SearchOnMapViewController.swift | 362 ++++++++++++++++++ .../SearchCategoriesViewController.swift | 13 +- .../SearchHistoryViewController.swift | 112 ++++-- .../SearchHistoryViewController.xib | 56 --- .../Search/Tabs/SearchTabViewController.swift | 18 +- 21 files changed, 1956 insertions(+), 105 deletions(-) create mode 100644 iphone/Maps/Tests/UI/SearchOnMapTests/SearchOnMapTests.swift create mode 100644 iphone/Maps/UI/Search/SearchOnMap/PlaceholderView.swift create mode 100644 iphone/Maps/UI/Search/SearchOnMap/Presentation/MapPassthroughView.swift create mode 100644 iphone/Maps/UI/Search/SearchOnMap/Presentation/ModalScreenPresentationStep.swift create mode 100644 iphone/Maps/UI/Search/SearchOnMap/Presentation/SearchOnMapModalPresentationController.swift create mode 100644 iphone/Maps/UI/Search/SearchOnMap/Presentation/SearchOnMapModalTransitionManager.swift create mode 100644 iphone/Maps/UI/Search/SearchOnMap/Presentation/SideMenuDismissalAnimator.swift create mode 100644 iphone/Maps/UI/Search/SearchOnMap/Presentation/SideMenuPresentationAnimator.swift create mode 100644 iphone/Maps/UI/Search/SearchOnMap/SearchOnMapHeaderView.swift create mode 100644 iphone/Maps/UI/Search/SearchOnMap/SearchOnMapInteractor.swift create mode 100644 iphone/Maps/UI/Search/SearchOnMap/SearchOnMapManager.swift create mode 100644 iphone/Maps/UI/Search/SearchOnMap/SearchOnMapModels.swift create mode 100644 iphone/Maps/UI/Search/SearchOnMap/SearchOnMapPresenter.swift create mode 100644 iphone/Maps/UI/Search/SearchOnMap/SearchOnMapViewController.swift delete mode 100644 iphone/Maps/UI/Search/Tabs/HistoryTab/SearchHistoryViewController.xib diff --git a/iphone/Maps/Core/Search/MWMSearch.h b/iphone/Maps/Core/Search/MWMSearch.h index 225a5446ff..130d0c4b24 100644 --- a/iphone/Maps/Core/Search/MWMSearch.h +++ b/iphone/Maps/Core/Search/MWMSearch.h @@ -5,9 +5,7 @@ NS_ASSUME_NONNULL_BEGIN @class SearchResult; -NS_SWIFT_NAME(Search) -@interface MWMSearch : NSObject - +@protocol SearchManager + (void)addObserver:(id)observer; + (void)removeObserver:(id)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 *)getResults; + (void)clear; +@end + +NS_SWIFT_NAME(Search) +@interface MWMSearch : NSObject + ++ (SearchItemType)resultTypeWithRow:(NSUInteger)row; ++ (NSUInteger)containerIndexWithRow:(NSUInteger)row; ++ (SearchResult *)resultWithContainerIndex:(NSUInteger)index; + (void)setSearchOnMap:(BOOL)searchOnMap; diff --git a/iphone/Maps/Core/Search/MWMSearch.mm b/iphone/Maps/Core/Search/MWMSearch.mm index d4709d1401..88dca14f27 100644 --- a/iphone/Maps/Core/Search/MWMSearch.mm +++ b/iphone/Maps/Core/Search/MWMSearch.mm @@ -176,15 +176,15 @@ using Observers = NSHashTable; } + (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 *)getResults { diff --git a/iphone/Maps/Maps.xcodeproj/project.pbxproj b/iphone/Maps/Maps.xcodeproj/project.pbxproj index ec78d74d37..bfc090f5f6 100644 --- a/iphone/Maps/Maps.xcodeproj/project.pbxproj +++ b/iphone/Maps/Maps.xcodeproj/project.pbxproj @@ -493,8 +493,21 @@ ED4DC7772CAEDECC0029B338 /* ProductsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = ED4DC7752CAEDECC0029B338 /* ProductsViewModel.swift */; }; ED4DC7782CAEDECC0029B338 /* ProductButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = ED4DC7732CAEDECC0029B338 /* ProductButton.swift */; }; ED4DC7792CAEDECC0029B338 /* ProductsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = ED4DC7742CAEDECC0029B338 /* ProductsViewController.swift */; }; + ED5BAF4B2D688F5B0088D7B1 /* SearchOnMapHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = ED5BAF4A2D688F5A0088D7B1 /* SearchOnMapHeaderView.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 */; }; @@ -506,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 */; }; ED83880F2D54DEB3002A0536 /* UIImage+FilledWithColor.swift in Sources */ = {isa = PBXBuildFile; fileRef = ED83880E2D54DEA4002A0536 /* UIImage+FilledWithColor.swift */; }; ED914AB22D35063A00973C45 /* TextColorStyleSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = ED914AB12D35063A00973C45 /* TextColorStyleSheet.swift */; }; @@ -523,7 +537,6 @@ EDC4E3612C5E2576009286A2 /* RecentlyDeletedCategoriesViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = EDC4E3412C5D1BD3009286A2 /* RecentlyDeletedCategoriesViewModelTests.swift */; }; EDC4E3692C5E6F5B009286A2 /* MockRecentlyDeletedCategoriesManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = EDC4E3402C5D1BD3009286A2 /* MockRecentlyDeletedCategoriesManager.swift */; }; EDCA7CDF2D317DF9003366CE /* StyleSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = EDCA7CDE2D317DF9003366CE /* StyleSheet.swift */; }; - EDDE060E2D6CAEAF000C328A /* SearchHistoryViewController.xib in Resources */ = {isa = PBXBuildFile; fileRef = EDDE060D2D6CAEAF000C328A /* SearchHistoryViewController.xib */; }; EDE243DD2B6D2E640057369B /* AboutController.swift in Sources */ = {isa = PBXBuildFile; fileRef = EDE243D52B6CF3980057369B /* AboutController.swift */; }; EDE243E52B6D3F400057369B /* OSMView.swift in Sources */ = {isa = PBXBuildFile; fileRef = EDE243E42B6D3F400057369B /* OSMView.swift */; }; EDE243E72B6D55610057369B /* InfoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = EDE243E02B6D3EA00057369B /* InfoView.swift */; }; @@ -1455,11 +1468,24 @@ ED4DC7732CAEDECC0029B338 /* ProductButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProductButton.swift; sourceTree = ""; }; ED4DC7742CAEDECC0029B338 /* ProductsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProductsViewController.swift; sourceTree = ""; }; ED4DC7752CAEDECC0029B338 /* ProductsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProductsViewModel.swift; sourceTree = ""; }; + ED5BAF4A2D688F5A0088D7B1 /* SearchOnMapHeaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchOnMapHeaderView.swift; sourceTree = ""; }; ED63CEB62BDF8F9C006155C4 /* SettingsTableViewiCloudSwitchCell.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SettingsTableViewiCloudSwitchCell.swift; sourceTree = ""; }; ED70D5582D5396F300738C1E /* SearchItemType.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = SearchItemType.h; sourceTree = ""; }; ED70D5592D5396F300738C1E /* SearchResult.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = SearchResult.h; sourceTree = ""; }; ED70D55A2D5396F300738C1E /* SearchResult.mm */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.objcpp; path = SearchResult.mm; sourceTree = ""; }; ED70D55B2D5396F300738C1E /* SearchResult+Core.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "SearchResult+Core.h"; sourceTree = ""; }; + ED70D57B2D539A2500738C1E /* MapPassthroughView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MapPassthroughView.swift; sourceTree = ""; }; + ED70D57C2D539A2500738C1E /* ModalScreenPresentationStep.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ModalScreenPresentationStep.swift; sourceTree = ""; }; + ED70D57D2D539A2500738C1E /* SearchOnMapModalPresentationController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchOnMapModalPresentationController.swift; sourceTree = ""; }; + ED70D57E2D539A2500738C1E /* SearchOnMapModalTransitionManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchOnMapModalTransitionManager.swift; sourceTree = ""; }; + ED70D57F2D539A2500738C1E /* SideMenuDismissalAnimator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SideMenuDismissalAnimator.swift; sourceTree = ""; }; + ED70D5802D539A2500738C1E /* SideMenuPresentationAnimator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SideMenuPresentationAnimator.swift; sourceTree = ""; }; + ED70D5822D539A2500738C1E /* PlaceholderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlaceholderView.swift; sourceTree = ""; }; + ED70D5832D539A2500738C1E /* SearchOnMapInteractor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchOnMapInteractor.swift; sourceTree = ""; }; + ED70D5842D539A2500738C1E /* SearchOnMapManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchOnMapManager.swift; sourceTree = ""; }; + ED70D5852D539A2500738C1E /* SearchOnMapModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchOnMapModels.swift; sourceTree = ""; }; + ED70D5862D539A2500738C1E /* SearchOnMapPresenter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchOnMapPresenter.swift; sourceTree = ""; }; + ED70D5872D539A2500738C1E /* SearchOnMapViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchOnMapViewController.swift; sourceTree = ""; }; ED77556D2C2C490B0051E656 /* UIAlertController+openInAppActionSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIAlertController+openInAppActionSheet.swift"; sourceTree = ""; }; ED79A5AA2BD7AA9C00952D1F /* LoadingOverlayViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoadingOverlayViewController.swift; sourceTree = ""; }; ED79A5AC2BD7BA0F00952D1F /* UIApplication+LoadingOverlay.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIApplication+LoadingOverlay.swift"; sourceTree = ""; }; @@ -1471,6 +1497,7 @@ ED79A5D02BDF8D6100952D1F /* LocalDirectoryMonitor.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LocalDirectoryMonitor.swift; sourceTree = ""; }; ED7CCC4E2C1362E300E2A737 /* FileType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileType.swift; sourceTree = ""; }; ED808D0E2C38407800D52585 /* CircleImageButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CircleImageButton.swift; sourceTree = ""; }; + ED810EC42D566E9B00ECDE2C /* SearchOnMapTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchOnMapTests.swift; sourceTree = ""; }; ED8270EF2C2071A3005966DA /* SettingsTableViewDetailedSwitchCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsTableViewDetailedSwitchCell.swift; sourceTree = ""; }; ED83880E2D54DEA4002A0536 /* UIImage+FilledWithColor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIImage+FilledWithColor.swift"; sourceTree = ""; }; ED914AB12D35063A00973C45 /* TextColorStyleSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TextColorStyleSheet.swift; sourceTree = ""; }; @@ -1488,7 +1515,6 @@ EDC4E3482C5D1BEF009286A2 /* RecentlyDeletedCategoriesViewModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RecentlyDeletedCategoriesViewModel.swift; sourceTree = ""; }; EDC4E3492C5D1BEF009286A2 /* RecentlyDeletedTableViewCell.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RecentlyDeletedTableViewCell.swift; sourceTree = ""; }; EDCA7CDE2D317DF9003366CE /* StyleSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StyleSheet.swift; sourceTree = ""; }; - EDDE060D2D6CAEAF000C328A /* SearchHistoryViewController.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = SearchHistoryViewController.xib; sourceTree = ""; }; EDE243D52B6CF3980057369B /* AboutController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AboutController.swift; sourceTree = ""; }; EDE243E02B6D3EA00057369B /* InfoView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InfoView.swift; sourceTree = ""; }; EDE243E42B6D3F400057369B /* OSMView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSMView.swift; sourceTree = ""; }; @@ -3180,6 +3206,7 @@ ED1ADA312BC6B19E0029209F /* Tests */ = { isa = PBXGroup; children = ( + ED810EC02D566E6F00ECDE2C /* UI */, EDC4E3442C5D1BD3009286A2 /* Bookmarks */, 4B4153B82BF970B800EE4B02 /* Classes */, 4B4153B62BF9709100EE4B02 /* Core */, @@ -3231,6 +3258,34 @@ path = Products; sourceTree = ""; }; + 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 = ""; + }; + 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 */, + ED5BAF4A2D688F5A0088D7B1 /* SearchOnMapHeaderView.swift */, + ); + path = SearchOnMap; + sourceTree = ""; + }; ED79A5A92BD7AA7500952D1F /* LoadingOverlay */ = { isa = PBXGroup; children = ( @@ -3253,6 +3308,22 @@ path = iCloud; sourceTree = ""; }; + ED810EC02D566E6F00ECDE2C /* UI */ = { + isa = PBXGroup; + children = ( + ED810EC32D566E7600ECDE2C /* SearchOnMapTests */, + ); + path = UI; + sourceTree = ""; + }; + ED810EC32D566E7600ECDE2C /* SearchOnMapTests */ = { + isa = PBXGroup; + children = ( + ED810EC42D566E9B00ECDE2C /* SearchOnMapTests.swift */, + ); + path = SearchOnMapTests; + sourceTree = ""; + }; ED9857022C4ECFFC00694F6C /* MailComposer */ = { isa = PBXGroup; children = ( @@ -3822,6 +3893,7 @@ F6E2FCE11E097B9F0083EBEC /* Search */ = { isa = PBXGroup; children = ( + ED70D5882D539A2500738C1E /* SearchOnMap */, F6E2FCF41E097B9F0083EBEC /* MWMSearchContentView.h */, F6E2FCF51E097B9F0083EBEC /* MWMSearchContentView.m */, F6E2FCF81E097B9F0083EBEC /* MWMSearchManager+Layout.h */, @@ -3868,7 +3940,6 @@ F6E2FD0F1E097B9F0083EBEC /* HistoryTab */ = { isa = PBXGroup; children = ( - EDDE060D2D6CAEAF000C328A /* SearchHistoryViewController.xib */, 337F98B721D3D67E00C8AC27 /* SearchHistoryCell.swift */, 337F98B321D3C9F200C8AC27 /* SearchHistoryViewController.swift */, ); @@ -4266,7 +4337,6 @@ 6741A9991BF340DE002C974C /* MWMAlertViewController.xib in Resources */, 4501B1942077C35A001B9173 /* resources-xxxhdpi_light in Resources */, 3467CEB7202C6FA900D3C670 /* BMCNotificationsCell.xib in Resources */, - EDDE060E2D6CAEAF000C328A /* SearchHistoryViewController.xib in Resources */, 4761BE2B252D3DB900EE2DE4 /* SubgroupCell.xib in Resources */, 99F9A0E72462CA1700AE21E0 /* DownloadAllView.xib in Resources */, 349D1AD51E2E325B004A2006 /* BottomMenuItemCell.xib in Resources */, @@ -4556,6 +4626,7 @@ ED43B8BD2C12063500D07BAA /* DocumentPicker.swift in Sources */, 470E1674252AD7F2002D201A /* BookmarksListInfoViewController.swift in Sources */, 47B9065521C7FA400079C85E /* NSString+MD5.m in Sources */, + ED5BAF4B2D688F5B0088D7B1 /* SearchOnMapHeaderView.swift in Sources */, CDB4D5022231412900104869 /* SettingsTemplateBuilder.swift in Sources */, 993DF10A23F6BDB100AC231A /* UISwitchRenderer.swift in Sources */, 99C9642C2428C0F700E41723 /* PlacePageHeaderBuilder.swift in Sources */, @@ -4735,6 +4806,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 */, @@ -4881,6 +4964,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 */, diff --git a/iphone/Maps/Tests/UI/SearchOnMapTests/SearchOnMapTests.swift b/iphone/Maps/Tests/UI/SearchOnMapTests/SearchOnMapTests.swift new file mode 100644 index 0000000000..1b23176dea --- /dev/null +++ b/iphone/Maps/Tests/UI/SearchOnMapTests/SearchOnMapTests.swift @@ -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() + 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() + ]) + } +} diff --git a/iphone/Maps/UI/Search/SearchOnMap/PlaceholderView.swift b/iphone/Maps/UI/Search/SearchOnMap/PlaceholderView.swift new file mode 100644 index 0000000000..9bf57b97c3 --- /dev/null +++ b/iphone/Maps/UI/Search/SearchOnMap/PlaceholderView.swift @@ -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() + } +} diff --git a/iphone/Maps/UI/Search/SearchOnMap/Presentation/MapPassthroughView.swift b/iphone/Maps/UI/Search/SearchOnMap/Presentation/MapPassthroughView.swift new file mode 100644 index 0000000000..7d60beafb6 --- /dev/null +++ b/iphone/Maps/UI/Search/SearchOnMap/Presentation/MapPassthroughView.swift @@ -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 + } +} diff --git a/iphone/Maps/UI/Search/SearchOnMap/Presentation/ModalScreenPresentationStep.swift b/iphone/Maps/UI/Search/SearchOnMap/Presentation/ModalScreenPresentationStep.swift new file mode 100644 index 0000000000..e6196ec034 --- /dev/null +++ b/iphone/Maps/UI/Search/SearchOnMap/Presentation/ModalScreenPresentationStep.swift @@ -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 + } +} diff --git a/iphone/Maps/UI/Search/SearchOnMap/Presentation/SearchOnMapModalPresentationController.swift b/iphone/Maps/UI/Search/SearchOnMap/Presentation/SearchOnMapModalPresentationController.swift new file mode 100644 index 0000000000..c459f8bca3 --- /dev/null +++ b/iphone/Maps/UI/Search/SearchOnMap/Presentation/SearchOnMapModalPresentationController.swift @@ -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 + } +} diff --git a/iphone/Maps/UI/Search/SearchOnMap/Presentation/SearchOnMapModalTransitionManager.swift b/iphone/Maps/UI/Search/SearchOnMap/Presentation/SearchOnMapModalTransitionManager.swift new file mode 100644 index 0000000000..335b7d4d76 --- /dev/null +++ b/iphone/Maps/UI/Search/SearchOnMap/Presentation/SearchOnMapModalTransitionManager.swift @@ -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 + } +} diff --git a/iphone/Maps/UI/Search/SearchOnMap/Presentation/SideMenuDismissalAnimator.swift b/iphone/Maps/UI/Search/SearchOnMap/Presentation/SideMenuDismissalAnimator.swift new file mode 100644 index 0000000000..35515734d7 --- /dev/null +++ b/iphone/Maps/UI/Search/SearchOnMap/Presentation/SideMenuDismissalAnimator.swift @@ -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) + }) + } +} diff --git a/iphone/Maps/UI/Search/SearchOnMap/Presentation/SideMenuPresentationAnimator.swift b/iphone/Maps/UI/Search/SearchOnMap/Presentation/SideMenuPresentationAnimator.swift new file mode 100644 index 0000000000..0bc83210f7 --- /dev/null +++ b/iphone/Maps/UI/Search/SearchOnMap/Presentation/SideMenuPresentationAnimator.swift @@ -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) + }) + } +} diff --git a/iphone/Maps/UI/Search/SearchOnMap/SearchOnMapHeaderView.swift b/iphone/Maps/UI/Search/SearchOnMap/SearchOnMapHeaderView.swift new file mode 100644 index 0000000000..1dd937c832 --- /dev/null +++ b/iphone/Maps/UI/Search/SearchOnMap/SearchOnMapHeaderView.swift @@ -0,0 +1,113 @@ +protocol SearchOnMapHeaderViewDelegate: UISearchBarDelegate { + func cancelButtonDidTap() +} + +final class SearchOnMapHeaderView: UIView { + weak var delegate: SearchOnMapHeaderViewDelegate? { + didSet { + searchBar.delegate = delegate + } + } + + private enum Constants { + 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: 8) + } + + private let grabberView = UIView() + private let searchBar = UISearchBar() + private let cancelButton = UIButton() + + 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(.searchHeader) + 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 setupSearchBar() { + searchBar.placeholder = L("search") + searchBar.showsCancelButton = false + if #available(iOS 13.0, *) { + searchBar.searchTextField.clearButtonMode = .always + searchBar.returnKeyType = .search + searchBar.searchTextField.enablesReturnKeyAutomatically = true + } + } + + private func setupCancelButton() { + cancelButton.tintColor = .whitePrimaryText() + cancelButton.setStyle(.clearBackground) + cancelButton.setTitle(L("cancel"), for: .normal) + cancelButton.addTarget(self, action: #selector(cancelButtonTapped), for: .touchUpInside) + } + + private func layoutView() { + addSubview(grabberView) + addSubview(searchBar) + addSubview(cancelButton) + + grabberView.translatesAutoresizingMaskIntoConstraints = false + searchBar.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), + + searchBar.topAnchor.constraint(equalTo: grabberView.bottomAnchor), + searchBar.leadingAnchor.constraint(equalTo: leadingAnchor), + searchBar.trailingAnchor.constraint(equalTo: cancelButton.leadingAnchor, constant: -Constants.cancelButtonInsets.left), + + cancelButton.centerYAnchor.constraint(equalTo: searchBar.centerYAnchor), + cancelButton.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -Constants.cancelButtonInsets.right), + + bottomAnchor.constraint(equalTo: searchBar.bottomAnchor) + ]) + } + + @objc private func cancelButtonTapped() { + delegate?.cancelButtonDidTap() + } + + func setSearchText(_ text: String) { + searchBar.text = text + } + + func setIsSearching(_ isSearching: Bool) { + if isSearching { + searchBar.becomeFirstResponder() + } else if searchBar.isFirstResponder { + searchBar.resignFirstResponder() + } + } + + var searchText: SearchOnMap.SearchText { + SearchOnMap.SearchText(searchBar.text ?? "", locale: searchBar.textInputMode?.primaryLanguage) + } +} diff --git a/iphone/Maps/UI/Search/SearchOnMap/SearchOnMapInteractor.swift b/iphone/Maps/UI/Search/SearchOnMap/SearchOnMapInteractor.swift new file mode 100644 index 0000000000..3bbedc1dbc --- /dev/null +++ b/iphone/Maps/UI/Search/SearchOnMap/SearchOnMapInteractor.swift @@ -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)) + } +} diff --git a/iphone/Maps/UI/Search/SearchOnMap/SearchOnMapManager.swift b/iphone/Maps/UI/Search/SearchOnMap/SearchOnMapManager.swift new file mode 100644 index 0000000000..982e7555c3 --- /dev/null +++ b/iphone/Maps/UI/Search/SearchOnMap/SearchOnMapManager.swift @@ -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() + + // 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 + } +} diff --git a/iphone/Maps/UI/Search/SearchOnMap/SearchOnMapModels.swift b/iphone/Maps/UI/Search/SearchOnMap/SearchOnMapModels.swift new file mode 100644 index 0000000000..2f9db11c18 --- /dev/null +++ b/iphone/Maps/UI/Search/SearchOnMap/SearchOnMapModels.swift @@ -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) +} diff --git a/iphone/Maps/UI/Search/SearchOnMap/SearchOnMapPresenter.swift b/iphone/Maps/UI/Search/SearchOnMap/SearchOnMapPresenter.swift new file mode 100644 index 0000000000..aa422b027d --- /dev/null +++ b/iphone/Maps/UI/Search/SearchOnMap/SearchOnMapPresenter.swift @@ -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 + } + } +} diff --git a/iphone/Maps/UI/Search/SearchOnMap/SearchOnMapViewController.swift b/iphone/Maps/UI/Search/SearchOnMap/SearchOnMapViewController.swift new file mode 100644 index 0000000000..0886e5126d --- /dev/null +++ b/iphone/Maps/UI/Search/SearchOnMap/SearchOnMapViewController.swift @@ -0,0 +1,362 @@ +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 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 + private let headerView = SearchOnMapHeaderView() + private let containerView = 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) + headerView.setIsSearching(false) + } + + // MARK: - Private methods + private func setupViews() { + view.setStyle(.background) + 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.delegate = self + } + + 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 + } + + // TODO: (KK) Implement filters collection viewe + 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), + ]) + + layoutResultsView() + layoutHistoryAndCategoryTabView() + layoutSearchNoResultsView() + layoutSearchingView() + } + + 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 handleTapOutside(_ gesture: UITapGestureRecognizer) { + let location = gesture.location(in: view) + if resultsTableView.frame.contains(location) && searchResults.isEmpty { + headerView.setIsSearching(false) + } + } + + // 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) { + headerView.setIsSearching(isSearching) + } + + private func replaceSearchText(with text: String) { + headerView.setSearchText(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: headerView.searchText)) + 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: - 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(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))) + } + + func cancelButtonDidTap() { + interactor.handle(.closeSearch) + } +} + +// MARK: - SearchTabViewControllerDelegate +extension SearchOnMapViewController: SearchTabViewControllerDelegate { + func searchTabController(_ viewController: SearchTabViewController, didSearch text: String, withCategory: Bool) { + interactor.handle(.didSelectText(SearchText(text, locale: nil), isCategory: withCategory)) + } +} + diff --git a/iphone/Maps/UI/Search/Tabs/CategoriesTab/SearchCategoriesViewController.swift b/iphone/Maps/UI/Search/Tabs/CategoriesTab/SearchCategoriesViewController.swift index e9ab1f5998..745d81a5bb 100644 --- a/iphone/Maps/UI/Search/Tabs/CategoriesTab/SearchCategoriesViewController.swift +++ b/iphone/Maps/UI/Search/Tabs/CategoriesTab/SearchCategoriesViewController.swift @@ -1,4 +1,4 @@ -protocol SearchCategoriesViewControllerDelegate: AnyObject { +protocol SearchCategoriesViewControllerDelegate: SearchOnMapScrollViewDelegate { func categoriesViewController(_ viewController: SearchCategoriesViewController, didSelect category: String) } @@ -41,8 +41,19 @@ final class SearchCategoriesViewController: MWMTableViewController { tableView.deselectRow(at: indexPath, animated: true) } + override func scrollViewDidScroll(_ scrollView: UIScrollView) { + delegate?.scrollViewDidScroll(scrollView) + } + func category(at indexPath: IndexPath) -> String { let index = indexPath.row return categories[index] } } + +extension SearchCategoriesViewController: ModallyPresentedViewController { + func translationYDidUpdate(_ translationY: CGFloat) { + guard isViewLoaded else { return } + tableView.contentInset.bottom = translationY + view.safeAreaInsets.bottom + } +} diff --git a/iphone/Maps/UI/Search/Tabs/HistoryTab/SearchHistoryViewController.swift b/iphone/Maps/UI/Search/Tabs/HistoryTab/SearchHistoryViewController.swift index 01eba9fcd2..be08ea1c38 100644 --- a/iphone/Maps/UI/Search/Tabs/HistoryTab/SearchHistoryViewController.swift +++ b/iphone/Maps/UI/Search/Tabs/HistoryTab/SearchHistoryViewController.swift @@ -1,59 +1,97 @@ -protocol SearchHistoryViewControllerDelegate: AnyObject { +protocol SearchHistoryViewControllerDelegate: SearchOnMapScrollViewDelegate { func searchHistoryViewController(_ viewController: SearchHistoryViewController, didSelect query: String) } final class SearchHistoryViewController: MWMViewController { private weak var delegate: SearchHistoryViewControllerDelegate? - private var lastQueries: [String] + private var lastQueries: [String] = [] private let frameworkHelper: MWMSearchFrameworkHelper - private static let clearCellIdentifier = "SearchHistoryViewController_clearCellIdentifier" - - @IBOutlet private var tableView: UITableView! - @IBOutlet private weak var noResultsViewContainer: UIView! - + private let emptyHistoryView = PlaceholderView(title: L("search_history_title"), + subtitle: L("search_history_text")) + + private let tableView = UITableView() + + // MARK: - Init init(frameworkHelper: MWMSearchFrameworkHelper, delegate: SearchHistoryViewControllerDelegate?) { self.delegate = delegate - self.lastQueries = frameworkHelper.lastSearchQueries() 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() - - if frameworkHelper.isSearchHistoryEmpty() { - showNoResultsView() - } else { - tableView.register(cell: SearchHistoryCell.self) - } + 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 + tableView.tableFooterView = UIView(frame: CGRect(x: 0, y: 0, width: 400, height: 1)) + + 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) + ]) } - - func showNoResultsView() { - guard let noResultsView = MWMSearchNoResults.view(with: nil, - title: L("search_history_title"), - text: L("search_history_text")) else { - assertionFailure() - return + + 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 } - noResultsViewContainer.addSubview(noResultsView) - tableView.isHidden = true } - func clearSearchHistory() { + private func clearSearchHistory() { frameworkHelper.clearSearchHistory() - lastQueries = [] + 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 frameworkHelper.isSearchHistoryEmpty() ? 0 : lastQueries.count + 1 + return lastQueries.isEmpty ? 0 : lastQueries.count + 1 } func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { @@ -71,16 +109,22 @@ extension SearchHistoryViewController: UITableViewDelegate { func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { if indexPath.row == lastQueries.count { clearSearchHistory() - UIView.animate(withDuration: kDefaultAnimationDuration, - animations: { - tableView.alpha = 0.0 - }) { _ in - self.showNoResultsView() - } } 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 translationYDidUpdate(_ translationY: CGFloat) { + guard isViewLoaded else { return } + tableView.contentInset.bottom = translationY + emptyHistoryView.translationYDidUpdate(translationY) + } } diff --git a/iphone/Maps/UI/Search/Tabs/HistoryTab/SearchHistoryViewController.xib b/iphone/Maps/UI/Search/Tabs/HistoryTab/SearchHistoryViewController.xib deleted file mode 100644 index 952a4e3cdb..0000000000 --- a/iphone/Maps/UI/Search/Tabs/HistoryTab/SearchHistoryViewController.xib +++ /dev/null @@ -1,56 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/iphone/Maps/UI/Search/Tabs/SearchTabViewController.swift b/iphone/Maps/UI/Search/Tabs/SearchTabViewController.swift index 205d0a793a..e8ef5bc720 100644 --- a/iphone/Maps/UI/Search/Tabs/SearchTabViewController.swift +++ b/iphone/Maps/UI/Search/Tabs/SearchTabViewController.swift @@ -1,5 +1,5 @@ @objc(MWMSearchTabViewControllerDelegate) -protocol SearchTabViewControllerDelegate: AnyObject { +protocol SearchTabViewControllerDelegate: SearchOnMapScrollViewDelegate { func searchTabController(_ viewController: SearchTabViewController, didSearch: String, withCategory: Bool) } @@ -47,6 +47,22 @@ final class SearchTabViewController: TabViewController { super.viewDidDisappear(animated) activeTab = SearchActiveTab.init(rawValue: tabView.selectedIndex ?? 0) ?? .categories } + + func reloadSearchHistory() { + (viewControllers[SearchActiveTab.history.rawValue] as? SearchHistoryViewController)?.reload() + } +} + +extension SearchTabViewController: ModallyPresentedViewController { + func translationYDidUpdate(_ translationY: CGFloat) { + viewControllers.forEach { ($0 as? ModallyPresentedViewController)?.translationYDidUpdate(translationY) } + } +} + +extension SearchTabViewController: SearchOnMapScrollViewDelegate { + func scrollViewDidScroll(_ scrollView: UIScrollView) { + delegate?.scrollViewDidScroll(scrollView) + } } extension SearchTabViewController: SearchCategoriesViewControllerDelegate {