diff --git a/iphone/Maps/Classes/MapViewController.mm b/iphone/Maps/Classes/MapViewController.mm index 72fdb5c4cf..e6e4f02a01 100644 --- a/iphone/Maps/Classes/MapViewController.mm +++ b/iphone/Maps/Classes/MapViewController.mm @@ -177,16 +177,19 @@ NSString *const kSettingsSegue = @"Map2Settings"; } - (void)setupSearchContainer { + if (self.searchContainer != nil) + return; self.searchContainer = [[TouchTransparentView alloc] initWithFrame:self.view.bounds]; [self.view addSubview:self.searchContainer]; [self.view bringSubviewToFront:self.searchContainer]; + self.searchContainer.autoresizingMask = UIViewAutoresizingFlexibleWidth | UIViewAutoresizingFlexibleHeight; } - (void)updatePlacePageContainerConstraints { const BOOL isLimitedWidth = IPAD || self.traitCollection.verticalSizeClass == UIUserInterfaceSizeClassCompact; - if (IPAD && self.searchView != nil) { - NSLayoutConstraint * leadingToSearchConstraint = [self.placePageContainer.leadingAnchor constraintEqualToAnchor:self.searchView.trailingAnchor constant:kPlacePageLeadingOffset]; + if (IPAD && self.searchViewAvailableArea != nil) { + NSLayoutConstraint * leadingToSearchConstraint = [self.placePageContainer.leadingAnchor constraintEqualToAnchor:self.searchViewAvailableArea.trailingAnchor constant:kPlacePageLeadingOffset]; leadingToSearchConstraint.priority = UILayoutPriorityDefaultHigh; leadingToSearchConstraint.active = isLimitedWidth; } @@ -281,8 +284,8 @@ NSString *const kSettingsSegue = @"Map2Settings"; UITouch *touch = [allTouches objectAtIndex:0]; CGPoint const pt = [touch locationInView:v]; - // **Check if the tap is inside searchView** - if (self.searchManager.isSearching && type == df::TouchEvent::TOUCH_MOVE && !CGRectContainsPoint(self.searchView.frame, pt)) + // Check if the tap is inside searchView) + if (self.searchManager.isSearching && type == df::TouchEvent::TOUCH_MOVE && !CGRectContainsPoint(self.searchViewAvailableArea.frame, pt)) [self.searchManager setMapIsDragging]; e.SetTouchType(type); @@ -743,8 +746,8 @@ NSString *const kSettingsSegue = @"Map2Settings"; return _searchManager; } -- (UIView * _Nullable)searchView { - return self.searchManager.viewController.view; +- (UIView * _Nullable)searchViewAvailableArea { + return self.searchManager.viewController.availableAreaView; } - (BOOL)hasNavigationBar { diff --git a/iphone/Maps/Core/Theme/GlobalStyleSheet.swift b/iphone/Maps/Core/Theme/GlobalStyleSheet.swift index 0b6ab0297e..b8aa9db785 100644 --- a/iphone/Maps/Core/Theme/GlobalStyleSheet.swift +++ b/iphone/Maps/Core/Theme/GlobalStyleSheet.swift @@ -433,7 +433,7 @@ extension GlobalStyleSheet: IStyleSheet { s.coloring = MWMButtonColoring.blue } case .grabber: - return .addFrom(Self.pressBackground) { s in + return .addFrom(Self.background) { s in s.cornerRadius = .grabber } case .modalSheetBackground: diff --git a/iphone/Maps/Maps.xcodeproj/project.pbxproj b/iphone/Maps/Maps.xcodeproj/project.pbxproj index 0167018217..57f2e409ca 100644 --- a/iphone/Maps/Maps.xcodeproj/project.pbxproj +++ b/iphone/Maps/Maps.xcodeproj/project.pbxproj @@ -491,12 +491,13 @@ 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 */; }; + ED5E02142D8B17B600A5CC7B /* ModalPresentationStepsController.swift in Sources */ = {isa = PBXBuildFile; fileRef = ED5E02132D8B17B600A5CC7B /* ModalPresentationStepsController.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 */; }; ED70D58C2D539A2500738C1E /* SearchOnMapPresenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = ED70D5862D539A2500738C1E /* SearchOnMapPresenter.swift */; }; - ED70D58D2D539A2500738C1E /* ModalScreenPresentationStep.swift in Sources */ = {isa = PBXBuildFile; fileRef = ED70D57C2D539A2500738C1E /* ModalScreenPresentationStep.swift */; }; + ED70D58D2D539A2500738C1E /* ModalPresentationStep.swift in Sources */ = {isa = PBXBuildFile; fileRef = ED70D57C2D539A2500738C1E /* ModalPresentationStep.swift */; }; ED70D58F2D539A2500738C1E /* SearchOnMapInteractor.swift in Sources */ = {isa = PBXBuildFile; fileRef = ED70D5832D539A2500738C1E /* SearchOnMapInteractor.swift */; }; ED70D5922D539A2500738C1E /* PlaceholderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = ED70D5822D539A2500738C1E /* PlaceholderView.swift */; }; ED70D5932D539A2500738C1E /* SearchOnMapManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = ED70D5842D539A2500738C1E /* SearchOnMapManager.swift */; }; @@ -521,8 +522,9 @@ ED9857082C4ED02D00694F6C /* MailComposer.swift in Sources */ = {isa = PBXBuildFile; fileRef = ED9857072C4ED02D00694F6C /* MailComposer.swift */; }; ED9966802B94FBC20083CE55 /* ColorPicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = ED99667D2B94FBC20083CE55 /* ColorPicker.swift */; }; EDA1EAA42CC7ECAD00DBDCAA /* ElevationProfileFormatter.swift in Sources */ = {isa = PBXBuildFile; fileRef = EDA1EAA32CC7ECAD00DBDCAA /* ElevationProfileFormatter.swift */; }; - EDB71D5C2D82B4F8004A6A7F /* SearchOnMapPresentationController.swift in Sources */ = {isa = PBXBuildFile; fileRef = EDB71D5B2D82B4F8004A6A7F /* SearchOnMapPresentationController.swift */; }; EDB71D8C2D8474A0004A6A7F /* CornerRadius.swift in Sources */ = {isa = PBXBuildFile; fileRef = EDB71D8B2D8474A0004A6A7F /* CornerRadius.swift */; }; + EDB71E002D8B0338004A6A7F /* ModalPresentationAnimator.swift in Sources */ = {isa = PBXBuildFile; fileRef = EDB71DFF2D8B0338004A6A7F /* ModalPresentationAnimator.swift */; }; + EDB71E042D8B0943004A6A7F /* SearchOnMapAreaView.swift in Sources */ = {isa = PBXBuildFile; fileRef = EDB71E032D8B0943004A6A7F /* SearchOnMapAreaView.swift */; }; EDBD68072B625724005DD151 /* LocationServicesDisabledAlert.xib in Resources */ = {isa = PBXBuildFile; fileRef = EDBD68062B625724005DD151 /* LocationServicesDisabledAlert.xib */; }; EDBD680B2B62572E005DD151 /* LocationServicesDisabledAlert.swift in Sources */ = {isa = PBXBuildFile; fileRef = EDBD680A2B62572E005DD151 /* LocationServicesDisabledAlert.swift */; }; EDC3573B2B7B5029001AE9CA /* CALayer+SetCorner.swift in Sources */ = {isa = PBXBuildFile; fileRef = EDC3573A2B7B5029001AE9CA /* CALayer+SetCorner.swift */; }; @@ -1464,12 +1466,13 @@ 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 = ""; }; + ED5E02132D8B17B600A5CC7B /* ModalPresentationStepsController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ModalPresentationStepsController.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 = ""; }; - ED70D57C2D539A2500738C1E /* ModalScreenPresentationStep.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ModalScreenPresentationStep.swift; sourceTree = ""; }; + ED70D57C2D539A2500738C1E /* ModalPresentationStep.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ModalPresentationStep.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 = ""; }; @@ -1540,8 +1543,9 @@ ED9857072C4ED02D00694F6C /* MailComposer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MailComposer.swift; sourceTree = ""; }; ED99667D2B94FBC20083CE55 /* ColorPicker.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ColorPicker.swift; sourceTree = ""; }; EDA1EAA32CC7ECAD00DBDCAA /* ElevationProfileFormatter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ElevationProfileFormatter.swift; sourceTree = ""; }; - EDB71D5B2D82B4F8004A6A7F /* SearchOnMapPresentationController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchOnMapPresentationController.swift; sourceTree = ""; }; EDB71D8B2D8474A0004A6A7F /* CornerRadius.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CornerRadius.swift; sourceTree = ""; }; + EDB71DFF2D8B0338004A6A7F /* ModalPresentationAnimator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ModalPresentationAnimator.swift; sourceTree = ""; }; + EDB71E032D8B0943004A6A7F /* SearchOnMapAreaView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchOnMapAreaView.swift; sourceTree = ""; }; EDBD68062B625724005DD151 /* LocationServicesDisabledAlert.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = LocationServicesDisabledAlert.xib; sourceTree = ""; }; EDBD680A2B62572E005DD151 /* LocationServicesDisabledAlert.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocationServicesDisabledAlert.swift; sourceTree = ""; }; EDC3573A2B7B5029001AE9CA /* CALayer+SetCorner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CALayer+SetCorner.swift"; sourceTree = ""; }; @@ -3286,8 +3290,9 @@ ED70D5812D539A2500738C1E /* Presentation */ = { isa = PBXGroup; children = ( - ED70D57C2D539A2500738C1E /* ModalScreenPresentationStep.swift */, - EDB71D5B2D82B4F8004A6A7F /* SearchOnMapPresentationController.swift */, + ED5E02132D8B17B600A5CC7B /* ModalPresentationStepsController.swift */, + EDB71DFF2D8B0338004A6A7F /* ModalPresentationAnimator.swift */, + ED70D57C2D539A2500738C1E /* ModalPresentationStep.swift */, ); path = Presentation; sourceTree = ""; @@ -3303,6 +3308,7 @@ ED70D5862D539A2500738C1E /* SearchOnMapPresenter.swift */, ED70D5872D539A2500738C1E /* SearchOnMapViewController.swift */, ED5BAF4A2D688F5A0088D7B1 /* SearchOnMapHeaderView.swift */, + EDB71E032D8B0943004A6A7F /* SearchOnMapAreaView.swift */, ); path = SearchOnMap; sourceTree = ""; @@ -4484,7 +4490,6 @@ 99012853244732DB00C72B10 /* BottomTabBarInteractor.swift in Sources */, 6741A9A31BF340DE002C974C /* main.mm in Sources */, 34D3B04F1E38A20C004100F9 /* Bundle+Init.swift in Sources */, - EDB71D5C2D82B4F8004A6A7F /* SearchOnMapPresentationController.swift in Sources */, 34AB666E1FC5AA330078E451 /* TransportTransitStepsCollectionView.swift in Sources */, 993DF11E23F6BDB100AC231A /* UITextViewRenderer.swift in Sources */, F6E2FF5A1E097BA00083EBEC /* MWMNightModeController.m in Sources */, @@ -4577,6 +4582,7 @@ F653CE191C71F62700A453F1 /* MWMAddPlaceNavigationBar.mm in Sources */, 340475621E081A4600C92850 /* MWMNetworkPolicy+UI.m in Sources */, F6E2FEE51E097BA00083EBEC /* MWMSearchNoResults.m in Sources */, + ED5E02142D8B17B600A5CC7B /* ModalPresentationStepsController.swift in Sources */, F6E2FF631E097BA00083EBEC /* MWMTTSLanguageViewController.mm in Sources */, 4715273524907F8200E91BBA /* BookmarkColorViewController.swift in Sources */, 47E3C7292111E614008B3B27 /* FadeInAnimatedTransitioning.swift in Sources */, @@ -4656,6 +4662,7 @@ 99AAEA74244DA5ED0039D110 /* BottomMenuPresentationController.swift in Sources */, 99514BB823E82B450085D3A7 /* ElevationProfilePresenter.swift in Sources */, 34C9BD031C6DB693000DC38D /* MWMTableViewController.m in Sources */, + EDB71E002D8B0338004A6A7F /* ModalPresentationAnimator.swift in Sources */, F6E2FD8C1E097BA00083EBEC /* MWMNoMapsView.m in Sources */, 34D3B0361E389D05004100F9 /* MWMEditorSelectTableViewCell.m in Sources */, 990128562449A82500C72B10 /* BottomTabBarView.swift in Sources */, @@ -4811,7 +4818,7 @@ ED70D5892D539A2500738C1E /* SearchOnMapViewController.swift in Sources */, ED70D58A2D539A2500738C1E /* SearchOnMapModels.swift in Sources */, ED70D58C2D539A2500738C1E /* SearchOnMapPresenter.swift in Sources */, - ED70D58D2D539A2500738C1E /* ModalScreenPresentationStep.swift in Sources */, + ED70D58D2D539A2500738C1E /* ModalPresentationStep.swift in Sources */, ED70D58F2D539A2500738C1E /* SearchOnMapInteractor.swift in Sources */, ED70D5922D539A2500738C1E /* PlaceholderView.swift in Sources */, ED70D5932D539A2500738C1E /* SearchOnMapManager.swift in Sources */, @@ -4834,6 +4841,7 @@ 34AB66381FC5AA330078E451 /* RouteManagerCell.swift in Sources */, ED1263AB2B6F99F900AD99F3 /* UIView+AddSeparator.swift in Sources */, CD4A1F132305872700F2A6B6 /* PromoBookingPresentationController.swift in Sources */, + EDB71E042D8B0943004A6A7F /* SearchOnMapAreaView.swift in Sources */, 3472B5D3200F501500DC6CD5 /* BackgroundFetchTaskFrameworkType.swift in Sources */, 47E460AD240D737D00385B45 /* OpeinigHoursLocalization.swift in Sources */, 99F9A0E52462CA0E00AE21E0 /* DownloadAllView.swift in Sources */, diff --git a/iphone/Maps/Tests/UI/SearchOnMapTests/SearchOnMapTests.swift b/iphone/Maps/Tests/UI/SearchOnMapTests/SearchOnMapTests.swift index e5ac427eee..b8f3423efc 100644 --- a/iphone/Maps/Tests/UI/SearchOnMapTests/SearchOnMapTests.swift +++ b/iphone/Maps/Tests/UI/SearchOnMapTests/SearchOnMapTests.swift @@ -130,8 +130,13 @@ final class SearchOnMapTests: XCTestCase { searchManager.results = results interactor.handle(.didSelectResult(results[0], withSearchText: searchText)) - XCTAssertEqual(currentState, .hidden) - XCTAssertEqual(view.viewModel.presentationStep, .hidden) + if isIPad { + XCTAssertEqual(currentState, .searching) + XCTAssertEqual(view.viewModel.presentationStep, .fullScreen) + } else { + XCTAssertEqual(currentState, .hidden) + XCTAssertEqual(view.viewModel.presentationStep, .hidden) + } } func test_GivenSearchIsActive_WhenSelectPlaceOnMap_ThenHideSearch() { @@ -158,8 +163,13 @@ final class SearchOnMapTests: XCTestCase { searchManager.results = results interactor.handle(.didSelectResult(results[0], withSearchText: searchText)) - XCTAssertEqual(currentState, .hidden) - XCTAssertEqual(view.viewModel.presentationStep, .hidden) + if isIPad { + XCTAssertEqual(currentState, .searching) + XCTAssertEqual(view.viewModel.presentationStep, .fullScreen) + } else { + XCTAssertEqual(currentState, .hidden) + XCTAssertEqual(view.viewModel.presentationStep, .hidden) + } interactor.handle(.didDeselectPlaceOnMap) XCTAssertEqual(currentState, .searching) @@ -216,7 +226,6 @@ final class SearchOnMapTests: XCTestCase { // MARK: - Mocks private class SearchOnMapViewMock: SearchOnMapView { - var viewModel: SearchOnMap.ViewModel = .initial var scrollViewDelegate: (any SearchOnMapScrollViewDelegate)? func render(_ viewModel: SearchOnMap.ViewModel) { @@ -224,6 +233,8 @@ private class SearchOnMapViewMock: SearchOnMapView { } func close() { } + func show() { + } } private class SearchManagerMock: SearchManager { diff --git a/iphone/Maps/UI/Search/SearchOnMap/PlaceholderView.swift b/iphone/Maps/UI/Search/SearchOnMap/PlaceholderView.swift index 9bf57b97c3..b86db4e6cf 100644 --- a/iphone/Maps/UI/Search/SearchOnMap/PlaceholderView.swift +++ b/iphone/Maps/UI/Search/SearchOnMap/PlaceholderView.swift @@ -115,8 +115,8 @@ final class PlaceholderView: UIView { // MARK: - ModallyPresentedViewController extension PlaceholderView: ModallyPresentedViewController { - func translationYDidUpdate(_ translationY: CGFloat) { - self.containerModalYTranslation = translationY + func presentationFrameDidChange(_ frame: CGRect) { + self.containerModalYTranslation = frame.origin.y reloadConstraints() } } diff --git a/iphone/Maps/UI/Search/SearchOnMap/Presentation/ModalPresentationAnimator.swift b/iphone/Maps/UI/Search/SearchOnMap/Presentation/ModalPresentationAnimator.swift new file mode 100644 index 0000000000..6af87627bf --- /dev/null +++ b/iphone/Maps/UI/Search/SearchOnMap/Presentation/ModalPresentationAnimator.swift @@ -0,0 +1,38 @@ +enum PresentationStepChangeAnimation { + case none + case slide + case slideAndBounce +} + +final class ModalPresentationAnimator { + + private enum Constants { + static let animationDuration: TimeInterval = kDefaultAnimationDuration + static let springDamping: CGFloat = 0.85 + static let springVelocity: CGFloat = 0.2 + } + + static func animate(with stepAnimation: PresentationStepChangeAnimation = .slide, + animations: @escaping (() -> Void), + completion: ((Bool) -> Void)?) { + switch stepAnimation { + case .none: + animations() + completion?(true) + case .slide: + UIView.animate(withDuration: Constants.animationDuration, + delay: 0, + options: .curveEaseOut, + animations: animations, + completion: completion) + case .slideAndBounce: + UIView.animate(withDuration: Constants.animationDuration, + delay: 0, + usingSpringWithDamping: Constants.springDamping, + initialSpringVelocity: Constants.springVelocity, + options: .curveLinear, + animations: animations, + completion: completion) + } + } +} diff --git a/iphone/Maps/UI/Search/SearchOnMap/Presentation/ModalScreenPresentationStep.swift b/iphone/Maps/UI/Search/SearchOnMap/Presentation/ModalPresentationStep.swift similarity index 77% rename from iphone/Maps/UI/Search/SearchOnMap/Presentation/ModalScreenPresentationStep.swift rename to iphone/Maps/UI/Search/SearchOnMap/Presentation/ModalPresentationStep.swift index c2aac32531..09afc18399 100644 --- a/iphone/Maps/UI/Search/SearchOnMap/Presentation/ModalScreenPresentationStep.swift +++ b/iphone/Maps/UI/Search/SearchOnMap/Presentation/ModalPresentationStep.swift @@ -1,11 +1,11 @@ -enum ModalScreenPresentationStep { +enum ModalPresentationStep: Int, CaseIterable { case fullScreen case halfScreen case compact case hidden } -extension ModalScreenPresentationStep { +extension ModalPresentationStep { private enum Constants { static let iPadWidth: CGFloat = 350 static let compactHeightOffset: CGFloat = 120 @@ -14,7 +14,7 @@ extension ModalScreenPresentationStep { static let landscapeTopInset: CGFloat = 10 } - var upper: ModalScreenPresentationStep { + var upper: ModalPresentationStep { switch self { case .fullScreen: return .fullScreen @@ -27,7 +27,7 @@ extension ModalScreenPresentationStep { } } - var lower: ModalScreenPresentationStep { + var lower: ModalPresentationStep { switch self { case .fullScreen: return .halfScreen @@ -40,19 +40,22 @@ extension ModalScreenPresentationStep { } } - var first: ModalScreenPresentationStep { + var first: ModalPresentationStep { .fullScreen } - var last: ModalScreenPresentationStep { + var last: ModalPresentationStep { .compact } - func frame() -> CGRect { + func frame(for presentedView: UIView, in containerViewController: UIViewController) -> CGRect { let isIPad = UIDevice.current.userInterfaceIdiom == .pad - let containerSize = UIScreen.main.bounds.size - let safeAreaInsets = UIApplication.shared.keyWindow?.safeAreaInsets ?? .zero - let traitCollection = UIScreen.main.traitCollection + var containerSize = containerViewController.view.bounds.size + if containerSize == .zero { + containerSize = UIScreen.main.bounds.size + } + let safeAreaInsets = containerViewController.view.safeAreaInsets + let traitCollection = containerViewController.traitCollection var frame = CGRect(origin: .zero, size: containerSize) if isIPad { diff --git a/iphone/Maps/UI/Search/SearchOnMap/Presentation/ModalPresentationStepsController.swift b/iphone/Maps/UI/Search/SearchOnMap/Presentation/ModalPresentationStepsController.swift new file mode 100644 index 0000000000..f0c74c35e8 --- /dev/null +++ b/iphone/Maps/UI/Search/SearchOnMap/Presentation/ModalPresentationStepsController.swift @@ -0,0 +1,118 @@ +final class ModalPresentationStepsController { + + enum StepUpdate { + case didClose + case didUpdateFrame(CGRect) + case didUpdateStep(ModalPresentationStep) + } + + fileprivate enum Constants { + static let slowSwipeVelocity: CGFloat = 500 + static let fastSwipeDownVelocity: CGFloat = 4000 + static let fastSwipeUpVelocity: CGFloat = 3000 + static let translationThreshold: CGFloat = 50 + } + + private weak var presentedView: UIView? + private weak var containerViewController: UIViewController? + + private var initialTranslationY: CGFloat = .zero + + private(set) var currentStep: ModalPresentationStep = .fullScreen + private(set) var maxAvailableFrame: CGRect = .zero + + var currentFrame: CGRect { frame(for: currentStep) } + var hiddenFrame: CGRect { frame(for: .hidden) } + + var didUpdateHandler: ((StepUpdate) -> Void)? + + func set(presentedView: UIView, containerViewController: UIViewController) { + self.presentedView = presentedView + self.containerViewController = containerViewController + } + + func setInitialState() { + setStep(.hidden, animation: .none) + } + + func close(completion: (() -> Void)? = nil) { + setStep(.hidden, animation: .slide, completion: completion) + } + + func updateMaxAvailableFrame() { + maxAvailableFrame = frame(for: .fullScreen) + } + + func handlePan(_ gesture: UIPanGestureRecognizer) { + guard let presentedView else { return } + let translation = gesture.translation(in: presentedView) + let velocity = gesture.velocity(in: presentedView) + var currentFrame = presentedView.frame + + switch gesture.state { + case .began: + initialTranslationY = presentedView.frame.origin.y + case .changed: + let newY = max(max(initialTranslationY + translation.y, 0), maxAvailableFrame.origin.y) + currentFrame.origin.y = newY + presentedView.frame = currentFrame + didUpdateHandler?(.didUpdateFrame(currentFrame)) + case .ended: + let nextStep: ModalPresentationStep + if velocity.y > Constants.fastSwipeDownVelocity { + didUpdateHandler?(.didClose) + return + } else if velocity.y < -Constants.fastSwipeUpVelocity { + nextStep = .fullScreen + } else if velocity.y > Constants.slowSwipeVelocity || translation.y > Constants.translationThreshold { + if currentStep == .compact { + didUpdateHandler?(.didClose) + return + } + nextStep = currentStep.lower + } else if velocity.y < -Constants.slowSwipeVelocity || translation.y < -Constants.translationThreshold { + nextStep = currentStep.upper + } else { + nextStep = currentStep + } + + let animation: PresentationStepChangeAnimation = abs(velocity.y) > Constants.slowSwipeVelocity ? .slideAndBounce : .slide + setStep(nextStep, animation: animation, notifyAboutStepUpdate: true) + default: + break + } + } + + func setStep(_ step: ModalPresentationStep, + completion: (() -> Void)? = nil) { + guard currentStep != step else { return } + setStep(step, animation: .slide, notifyAboutStepUpdate: false, completion: completion) + } + + private func setStep(_ step: ModalPresentationStep, + animation: PresentationStepChangeAnimation, + notifyAboutStepUpdate: Bool = true, + completion: (() -> Void)? = nil) { + guard let presentedView else { return } + currentStep = step + updateMaxAvailableFrame() + + let frame = frame(for: step) + didUpdateHandler?(.didUpdateFrame(frame)) + + ModalPresentationAnimator.animate(with: animation) { + presentedView.frame = frame + } completion: { [weak self] _ in + guard let self else { return } + if notifyAboutStepUpdate { + self.didUpdateHandler?(.didUpdateStep(step)) + } + completion?() + } + } + + private func frame(for step: ModalPresentationStep) -> CGRect { + guard let presentedView, let containerViewController else { return .zero } + return step.frame(for: presentedView, in: containerViewController) + } +} diff --git a/iphone/Maps/UI/Search/SearchOnMap/Presentation/SearchOnMapPresentationController.swift b/iphone/Maps/UI/Search/SearchOnMap/Presentation/SearchOnMapPresentationController.swift deleted file mode 100644 index f4ea79b98b..0000000000 --- a/iphone/Maps/UI/Search/SearchOnMap/Presentation/SearchOnMapPresentationController.swift +++ /dev/null @@ -1,213 +0,0 @@ -protocol ModallyPresentedViewController { - func translationYDidUpdate(_ translationY: CGFloat) -} - -final class SearchOnMapPresentationController: NSObject { - - private enum StepChangeAnimation { - case none - 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 = .zero - private weak var interactor: SearchOnMapInteractor? { presentedViewController?.interactor } - // TODO: (KK) replace with set of steps passed from the outside - private var presentationStep: ModalScreenPresentationStep = .fullScreen - private var internalScrollViewContentOffset: CGFloat = .zero - private var maxAvailableFrameOfPresentedView: CGRect = .zero - - private weak var presentedViewController: SearchOnMapViewController? - private weak var parentViewController: UIViewController? - private weak var containerView: UIView? - private let affectedAreas: [Weak] - - init(parentViewController: UIViewController, - containerView: UIView, - affectedAreas: Set = []) { - self.parentViewController = parentViewController - self.containerView = containerView - self.affectedAreas = affectedAreas.map { Weak(value: $0) } - } - - func setViewController(_ viewController: SearchOnMapViewController) { - self.presentedViewController = viewController - guard let containerView, let parentViewController else { return } - containerView.addSubview(viewController.view) - parentViewController.addChild(viewController) - viewController.view.frame = frameOfPresentedViewInContainerView - viewController.didMove(toParent: parentViewController) - viewController.view.setStyleAndApply(.modalSheetBackground) - - affectedAreas.forEach { $0.value?.addAffectingView(viewController.view) } - - iPhoneSpecific { - let panGestureRecognizer = UIPanGestureRecognizer(target: self, action: #selector(handlePan(_:))) - panGestureRecognizer.delegate = self - viewController.view.addGestureRecognizer(panGestureRecognizer) - viewController.scrollViewDelegate = self - } - animateTo(.hidden, animation: .none) - } - - func show() { - interactor?.handle(.openSearch) - } - - func close() { - guard let presentedViewController else { return } - presentedViewController.willMove(toParent: nil) - animateTo(.hidden) { - presentedViewController.view.removeFromSuperview() - presentedViewController.removeFromParent() - } - } - - func setPresentationStep(_ step: ModalScreenPresentationStep) { - guard presentationStep != step else { return } - animateTo(step) - } - - // MARK: - Layout - func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { - presentedViewController?.view.frame = frameOfPresentedViewInContainerView - presentedViewController?.view.layoutIfNeeded() - } - - private var frameOfPresentedViewInContainerView: CGRect { - updateMaxAvailableFrameOfPresentedView() - let frame = presentationStep.frame() - return frame - } - - private func updateMaxAvailableFrameOfPresentedView() { - maxAvailableFrameOfPresentedView = ModalScreenPresentationStep.fullScreen.frame() - } - - // MARK: - Pan gesture handling - @objc private func handlePan(_ gesture: UIPanGestureRecognizer) { - guard let presentedViewController, let presentedView = presentedViewController.view 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 - 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, completion: (() -> Void)? = nil) { - guard let presentedViewController, let presentedView = presentedViewController.view else { return } - self.presentationStep = presentationStep - interactor?.handle(.didUpdatePresentationStep(presentationStep)) - - let updatedFrame = presentationStep.frame() - let targetYTranslation = updatedFrame.origin.y - - switch animation { - case .none: - presentedView.frame = updatedFrame - translationYDidUpdate(targetYTranslation) - completion?() - case .slide: - UIView.animate(withDuration: Constants.animationDuration, - delay: 0, - options: .curveEaseOut, - animations: { [weak self] in - presentedView.frame = updatedFrame - self?.translationYDidUpdate(targetYTranslation) - }) { _ in - completion?() - } - 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) - }) { _ in - completion?() - } - } - } -} - -// MARK: - ModallyPresentedViewController -extension SearchOnMapPresentationController: ModallyPresentedViewController { - func translationYDidUpdate(_ translationY: CGFloat) { - iPhoneSpecific { - presentedViewController?.translationYDidUpdate(translationY) - } - } -} - -// MARK: - UIGestureRecognizerDelegate -extension SearchOnMapPresentationController: 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 SearchOnMapPresentationController: SearchOnMapScrollViewDelegate { - func scrollViewDidScroll(_ scrollView: UIScrollView) { - guard let presentedViewController, let presentedView = presentedViewController.view 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/SearchOnMapAreaView.swift b/iphone/Maps/UI/Search/SearchOnMap/SearchOnMapAreaView.swift new file mode 100644 index 0000000000..fbe222d35b --- /dev/null +++ b/iphone/Maps/UI/Search/SearchOnMap/SearchOnMapAreaView.swift @@ -0,0 +1,9 @@ +final class SearchOnMapAreaView: UIView { + override var sideButtonsAreaAffectDirections: MWMAvailableAreaAffectDirections { + alternative(iPhone: .bottom, iPad: []) + } + + override var trafficButtonAreaAffectDirections: MWMAvailableAreaAffectDirections { + alternative(iPhone: .bottom, iPad: []) + } +} diff --git a/iphone/Maps/UI/Search/SearchOnMap/SearchOnMapHeaderView.swift b/iphone/Maps/UI/Search/SearchOnMap/SearchOnMapHeaderView.swift index 272672be17..aaa5303bf2 100644 --- a/iphone/Maps/UI/Search/SearchOnMap/SearchOnMapHeaderView.swift +++ b/iphone/Maps/UI/Search/SearchOnMap/SearchOnMapHeaderView.swift @@ -10,10 +10,12 @@ final class SearchOnMapHeaderView: UIView { } private enum Constants { + static let searchBarHeight: CGFloat = 36 + static let searchBarInsets: UIEdgeInsets = UIEdgeInsets(top: 8, left: 10, bottom: 10, right: 0) static let grabberHeight: CGFloat = 5 static let grabberWidth: CGFloat = 36 static let grabberTopMargin: CGFloat = 5 - static let cancelButtonInsets: UIEdgeInsets = UIEdgeInsets(top: 0, left: 6, bottom: 0, right: 8) + static let cancelButtonInsets: UIEdgeInsets = UIEdgeInsets(top: 0, left: 6, bottom: 0, right: 16) } private let grabberView = UIView() @@ -81,9 +83,11 @@ final class SearchOnMapHeaderView: UIView { grabberView.widthAnchor.constraint(equalToConstant: Constants.grabberWidth), grabberView.heightAnchor.constraint(equalToConstant: Constants.grabberHeight), - searchBar.topAnchor.constraint(equalTo: grabberView.bottomAnchor), - searchBar.leadingAnchor.constraint(equalTo: leadingAnchor), + searchBar.topAnchor.constraint(equalTo: grabberView.bottomAnchor, constant: Constants.searchBarInsets.top), + searchBar.leadingAnchor.constraint(equalTo: leadingAnchor, constant: Constants.searchBarInsets.left), searchBar.trailingAnchor.constraint(equalTo: cancelContainer.leadingAnchor), + searchBar.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -Constants.searchBarInsets.bottom), + searchBar.heightAnchor.constraint(equalToConstant: Constants.searchBarHeight), cancelContainer.trailingAnchor.constraint(equalTo: trailingAnchor), cancelContainer.topAnchor.constraint(equalTo: searchBar.topAnchor), @@ -93,8 +97,6 @@ final class SearchOnMapHeaderView: UIView { cancelButton.leadingAnchor.constraint(equalTo: cancelContainer.leadingAnchor, constant: Constants.cancelButtonInsets.left), cancelButton.trailingAnchor.constraint(equalTo: cancelContainer.trailingAnchor, constant: -Constants.cancelButtonInsets.right), cancelButton.bottomAnchor.constraint(equalTo: cancelContainer.bottomAnchor), - - bottomAnchor.constraint(equalTo: searchBar.bottomAnchor) ]) } diff --git a/iphone/Maps/UI/Search/SearchOnMap/SearchOnMapManager.swift b/iphone/Maps/UI/Search/SearchOnMap/SearchOnMapManager.swift index b436555e1e..c1291d2667 100644 --- a/iphone/Maps/UI/Search/SearchOnMap/SearchOnMapManager.swift +++ b/iphone/Maps/UI/Search/SearchOnMap/SearchOnMapManager.swift @@ -81,20 +81,13 @@ final class SearchOnMapManager: NSObject { private struct SearchOnMapViewControllerBuilder { static func build(isRouting: Bool, didChangeState: @escaping ((SearchOnMapState) -> Void)) -> SearchOnMapViewController { - let mapViewController = MapViewController.shared()! - let presentationController = SearchOnMapPresentationController(parentViewController: mapViewController, - containerView: mapViewController.searchContainer, - affectedAreas: [ - mapViewController.sideButtonsArea, - mapViewController.trafficButtonArea, - ]) - let viewController = SearchOnMapViewController(presentationController: presentationController) + let viewController = SearchOnMapViewController() let presenter = SearchOnMapPresenter(isRouting: isRouting, didChangeState: didChangeState) let interactor = SearchOnMapInteractor(presenter: presenter) presenter.view = viewController viewController.interactor = interactor - presentationController.show() + viewController.show() return viewController } } diff --git a/iphone/Maps/UI/Search/SearchOnMap/SearchOnMapModels.swift b/iphone/Maps/UI/Search/SearchOnMap/SearchOnMapModels.swift index 8147e534f3..2634571f84 100644 --- a/iphone/Maps/UI/Search/SearchOnMap/SearchOnMapModels.swift +++ b/iphone/Maps/UI/Search/SearchOnMap/SearchOnMapModels.swift @@ -1,6 +1,6 @@ enum SearchOnMap { struct ViewModel: Equatable { - enum ContentState: Equatable { + enum Content: Equatable { case historyAndCategory case results(SearchResults) case noResults @@ -10,8 +10,8 @@ enum SearchOnMap { var isTyping: Bool var skipSuggestions: Bool var searchingText: String? - var contentState: ContentState - var presentationStep: ModalScreenPresentationStep + var contentState: Content + var presentationStep: ModalPresentationStep } struct SearchResults: Equatable { @@ -54,7 +54,7 @@ enum SearchOnMap { case clearButtonDidTap case didSelectPlaceOnMap case didDeselectPlaceOnMap - case didUpdatePresentationStep(ModalScreenPresentationStep) + case didUpdatePresentationStep(ModalPresentationStep) } enum Response: Equatable { @@ -67,7 +67,7 @@ enum SearchOnMap { case clearSearch case setSearchScreenHidden(Bool) case setSearchScreenCompact - case updatePresentationStep(ModalScreenPresentationStep) + case updatePresentationStep(ModalPresentationStep) case close case none } diff --git a/iphone/Maps/UI/Search/SearchOnMap/SearchOnMapPresenter.swift b/iphone/Maps/UI/Search/SearchOnMap/SearchOnMapPresenter.swift index 9005716260..74c669f5c5 100644 --- a/iphone/Maps/UI/Search/SearchOnMap/SearchOnMapPresenter.swift +++ b/iphone/Maps/UI/Search/SearchOnMap/SearchOnMapPresenter.swift @@ -104,7 +104,7 @@ final class SearchOnMapPresenter { } } -private extension ModalScreenPresentationStep { +private extension ModalPresentationStep { var searchState: SearchOnMapState { switch self { case .fullScreen, .halfScreen, .compact: diff --git a/iphone/Maps/UI/Search/SearchOnMap/SearchOnMapViewController.swift b/iphone/Maps/UI/Search/SearchOnMap/SearchOnMapViewController.swift index 31919ff9e6..84d2f9bf15 100644 --- a/iphone/Maps/UI/Search/SearchOnMap/SearchOnMapViewController.swift +++ b/iphone/Maps/UI/Search/SearchOnMap/SearchOnMapViewController.swift @@ -1,7 +1,6 @@ protocol SearchOnMapView: AnyObject { - var scrollViewDelegate: SearchOnMapScrollViewDelegate? { get set } - func render(_ viewModel: SearchOnMap.ViewModel) + func show() func close() } @@ -10,37 +9,61 @@ protocol SearchOnMapScrollViewDelegate: AnyObject { func scrollViewDidScroll(_ scrollView: UIScrollView) } +@objc +protocol ModallyPresentedViewController: AnyObject { + @objc func presentationFrameDidChange(_ frame: CGRect) +} + final class SearchOnMapViewController: UIViewController { typealias ViewModel = SearchOnMap.ViewModel - typealias ContentState = SearchOnMap.ViewModel.ContentState + typealias Content = SearchOnMap.ViewModel.Content typealias SearchText = SearchOnMap.SearchText fileprivate enum Constants { static let estimatedRowHeight: CGFloat = 80 + static let panGestureThreshold: CGFloat = 5 } var interactor: SearchOnMapInteractor? - var modalPresentationController: SearchOnMapPresentationController? - weak var scrollViewDelegate: SearchOnMapScrollViewDelegate? - - private var searchResults = SearchOnMap.SearchResults([]) - // MARK: - UI Elements - let contentView = UIView() + @objc let availableAreaView = SearchOnMapAreaView() + private let contentView = UIView() private let headerView = SearchOnMapHeaderView() private let searchResultsView = UIView() private let resultsTableView = UITableView() private let historyAndCategoryTabViewController = SearchTabViewController() private var searchingActivityView = PlaceholderView(hasActivityIndicator: true) - private var containerModalYTranslation: CGFloat = 0 private var searchNoResultsView = PlaceholderView(title: L("search_not_found"), subtitle: L("search_not_found_query")) + private var internalScrollViewContentOffset: CGFloat = .zero + private let presentationStepsController = ModalPresentationStepsController() + private var searchResults = SearchOnMap.SearchResults([]) + // MARK: - Init - init(presentationController: SearchOnMapPresentationController?) { - self.modalPresentationController = presentationController + init() { super.init(nibName: nil, bundle: nil) - modalPresentationController?.setViewController(self) + configureModalPresentation() + } + + private func configureModalPresentation() { + guard let mapViewController = MapViewController.shared() else { + fatalError("MapViewController is not available") + } + presentationStepsController.set(presentedView: availableAreaView, containerViewController: self) + presentationStepsController.didUpdateHandler = presentationUpdateHandler + + mapViewController.searchContainer.addSubview(view) + mapViewController.addChild(self) + view.frame = mapViewController.searchContainer.bounds + view.autoresizingMask = [.flexibleWidth, .flexibleHeight] + didMove(toParent: mapViewController) + + let affectedAreaViews = [ + mapViewController.sideButtonsArea, + mapViewController.trafficButtonArea, + ] + affectedAreaViews.forEach { $0?.addAffectingView(availableAreaView) } } @available(*, unavailable) @@ -50,14 +73,14 @@ final class SearchOnMapViewController: UIViewController { // MARK: - Lifecycle override func loadView() { - view = SearchOnMapAreaView() + view = TouchTransparentView() } override func viewDidLoad() { super.viewDidLoad() setupViews() layoutViews() - modalPresentationController?.show() + presentationStepsController.setInitialState() } override func viewWillDisappear(_ animated: Bool) { @@ -67,31 +90,45 @@ final class SearchOnMapViewController: UIViewController { override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { super.traitCollectionDidChange(previousTraitCollection) - modalPresentationController?.traitCollectionDidChange(traitCollection) + updateFrameOfPresentedViewInContainerView() + updateDimView(for: availableAreaView.frame) + } + + override func viewWillTransition(to size: CGSize, with coordinator: any UIViewControllerTransitionCoordinator) { + super.viewWillTransition(to: size, with: coordinator) + if #available(iOS 14.0, *), ProcessInfo.processInfo.isiOSAppOnMac { + updateFrameOfPresentedViewInContainerView() + } } // MARK: - Private methods private func setupViews() { - contentView.setStyle(.modalSheetContent) - setupTapGestureRecognizer() + availableAreaView.setStyleAndApply(.modalSheetBackground) + contentView.setStyleAndApply(.modalSheetContent) + setupGestureRecognizers() setupHeaderView() - setupContainerView() + setupSearchResultsView() setupResultsTableView() setupHistoryAndCategoryTabView() - setupResultsTableView() } - private func setupTapGestureRecognizer() { + private func setupGestureRecognizers() { let tapGesture = UITapGestureRecognizer(target: self, action: #selector(handleTapOutside)) tapGesture.cancelsTouchesInView = false - view.addGestureRecognizer(tapGesture) + contentView.addGestureRecognizer(tapGesture) + + iPhoneSpecific { + let panGestureRecognizer = UIPanGestureRecognizer(target: self, action: #selector(handlePan(_:))) + panGestureRecognizer.delegate = self + contentView.addGestureRecognizer(panGestureRecognizer) + } } private func setupHeaderView() { headerView.delegate = self } - private func setupContainerView() { + private func setupSearchResultsView() { searchResultsView.setStyle(.background) } @@ -111,7 +148,8 @@ final class SearchOnMapViewController: UIViewController { } private func layoutViews() { - view.addSubview(contentView) + view.addSubview(availableAreaView) + availableAreaView.addSubview(contentView) contentView.addSubview(headerView) contentView.addSubview(searchResultsView) @@ -120,10 +158,10 @@ final class SearchOnMapViewController: UIViewController { searchResultsView.translatesAutoresizingMaskIntoConstraints = false NSLayoutConstraint.activate([ - contentView.topAnchor.constraint(equalTo: view.topAnchor), - contentView.leadingAnchor.constraint(equalTo: view.leadingAnchor), - contentView.trailingAnchor.constraint(equalTo: view.trailingAnchor), - contentView.bottomAnchor.constraint(equalTo: view.bottomAnchor), + contentView.topAnchor.constraint(equalTo: availableAreaView.topAnchor), + contentView.leadingAnchor.constraint(equalTo: availableAreaView.leadingAnchor), + contentView.trailingAnchor.constraint(equalTo: availableAreaView.trailingAnchor), + contentView.bottomAnchor.constraint(equalTo: availableAreaView.bottomAnchor), headerView.topAnchor.constraint(equalTo: contentView.topAnchor), headerView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor), @@ -139,6 +177,7 @@ final class SearchOnMapViewController: UIViewController { layoutHistoryAndCategoryTabView() layoutSearchNoResultsView() layoutSearchingView() + updateFrameOfPresentedViewInContainerView() } private func layoutResultsView() { @@ -187,16 +226,44 @@ final class SearchOnMapViewController: UIViewController { ]) } - // MARK: - Handle Button Actions - @objc private func handleTapOutside(_ gesture: UITapGestureRecognizer) { + // MARK: - Handle Presentation Steps + private func updateFrameOfPresentedViewInContainerView() { + presentationStepsController.updateMaxAvailableFrame() + availableAreaView.frame = presentationStepsController.currentFrame + view.layoutIfNeeded() + } + + @objc + private func handleTapOutside(_ gesture: UITapGestureRecognizer) { let location = gesture.location(in: view) if resultsTableView.frame.contains(location) && searchResults.isEmpty { headerView.setIsSearching(false) } } - // MARK: - Handle State Updates - private func setContent(_ content: ContentState) { + @objc + private func handlePan(_ gesture: UIPanGestureRecognizer) { + interactor?.handle(.didStartDraggingSearch) + presentationStepsController.handlePan(gesture) + } + + private var presentationUpdateHandler: (ModalPresentationStepsController.StepUpdate) -> Void { + { [weak self] update in + guard let self else { return } + switch update { + case .didClose: + self.interactor?.handle(.closeSearch) + case .didUpdateFrame(let frame): + self.presentationFrameDidChange(frame) + self.updateDimView(for: frame) + case .didUpdateStep(let step): + self.interactor?.handle(.didUpdatePresentationStep(step)) + } + } + } + + // MARK: - Handle Content Updates + private func setContent(_ content: Content) { switch content { case .historyAndCategory: historyAndCategoryTabViewController.reloadSearchHistory() @@ -214,7 +281,7 @@ final class SearchOnMapViewController: UIViewController { showView(viewToShow(for: content)) } - private func viewToShow(for content: ContentState) -> UIView { + private func viewToShow(for content: Content) -> UIView { switch content { case .historyAndCategory: return historyAndCategoryTabViewController.view @@ -232,24 +299,26 @@ final class SearchOnMapViewController: UIViewController { historyAndCategoryTabViewController.view, searchNoResultsView, searchingActivityView].filter { $0 != view } - UIView.transition(with: searchResultsView, - duration: kDefaultAnimationDuration / 2, - options: [.transitionCrossDissolve, .curveEaseInOut], animations: { - viewsToHide.forEach { viewToHide in - view.isHidden = false - view.alpha = 1 - viewToHide.isHidden = true - viewToHide.alpha = 0 - } - }) + UIView.animate(withDuration: kDefaultAnimationDuration / 2, + delay: 0, + options: .curveEaseInOut, + animations: { + viewsToHide.forEach { $0.alpha = 0 } + view.alpha = 1 + }) { _ in + viewsToHide.forEach { $0.isHidden = true } + view.isHidden = false + } } private func setIsSearching(_ isSearching: Bool) { headerView.setIsSearching(isSearching) } - private func replaceSearchText(with text: String) { - headerView.setSearchText(text) + private func setSearchText(_ text: String?) { + if let text { + headerView.setSearchText(text) + } } } @@ -258,30 +327,32 @@ extension SearchOnMapViewController: SearchOnMapView { func render(_ viewModel: ViewModel) { setContent(viewModel.contentState) setIsSearching(viewModel.isTyping) - if let searchingText = viewModel.searchingText { - replaceSearchText(with: searchingText) - } - modalPresentationController?.setPresentationStep(viewModel.presentationStep) + setSearchText(viewModel.searchingText) + presentationStepsController.setStep(viewModel.presentationStep) + } + + func show() { + interactor?.handle(.openSearch) } func close() { headerView.setIsSearching(false) - guard let modalPresentationController else { - dismiss(animated: true) - return + willMove(toParent: nil) + presentationStepsController.close { [weak self] in + self?.view.removeFromSuperview() + self?.removeFromParent() } - modalPresentationController.close() } } // MARK: - ModallyPresentedViewController extension SearchOnMapViewController: ModallyPresentedViewController { - func translationYDidUpdate(_ translationY: CGFloat) { - self.containerModalYTranslation = translationY + func presentationFrameDidChange(_ frame: CGRect) { + let translationY = frame.origin.y resultsTableView.contentInset.bottom = translationY - historyAndCategoryTabViewController.translationYDidUpdate(translationY) - searchNoResultsView.translationYDidUpdate(translationY) - searchingActivityView.translationYDidUpdate(translationY) + historyAndCategoryTabViewController.presentationFrameDidChange(frame) + searchNoResultsView.presentationFrameDidChange(frame) + searchingActivityView.presentationFrameDidChange(frame) } } @@ -320,23 +391,6 @@ extension SearchOnMapViewController: UITableViewDelegate { 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 @@ -370,12 +424,28 @@ extension SearchOnMapViewController: SearchTabViewControllerDelegate { } } -private class SearchOnMapAreaView: UIView { - override var sideButtonsAreaAffectDirections: MWMAvailableAreaAffectDirections { - alternative(iPhone: .bottom, iPad: []) +// MARK: - UIGestureRecognizerDelegate +extension SearchOnMapViewController: UIGestureRecognizerDelegate { + func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool { + true } - override var trafficButtonAreaAffectDirections: MWMAvailableAreaAffectDirections { - alternative(iPhone: .bottom, iPad: []) + 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 SearchOnMapViewController: SearchOnMapScrollViewDelegate { + func scrollViewDidScroll(_ scrollView: UIScrollView) { + let hasReachedTheTop = Int(availableAreaView.frame.origin.y) > Int(presentationStepsController.maxAvailableFrame.origin.y) + let hasZeroContentOffset = internalScrollViewContentOffset == 0 + if hasReachedTheTop && hasZeroContentOffset { + // prevent the internal scroll view scrolling + scrollView.contentOffset.y = internalScrollViewContentOffset + return + } + internalScrollViewContentOffset = scrollView.contentOffset.y } } diff --git a/iphone/Maps/UI/Search/Tabs/CategoriesTab/SearchCategoriesViewController.swift b/iphone/Maps/UI/Search/Tabs/CategoriesTab/SearchCategoriesViewController.swift index 745d81a5bb..4ce8ae6453 100644 --- a/iphone/Maps/UI/Search/Tabs/CategoriesTab/SearchCategoriesViewController.swift +++ b/iphone/Maps/UI/Search/Tabs/CategoriesTab/SearchCategoriesViewController.swift @@ -52,8 +52,8 @@ final class SearchCategoriesViewController: MWMTableViewController { } extension SearchCategoriesViewController: ModallyPresentedViewController { - func translationYDidUpdate(_ translationY: CGFloat) { + func presentationFrameDidChange(_ frame: CGRect) { guard isViewLoaded else { return } - tableView.contentInset.bottom = translationY + view.safeAreaInsets.bottom + tableView.contentInset.bottom = frame.origin.y + view.safeAreaInsets.bottom } } diff --git a/iphone/Maps/UI/Search/Tabs/HistoryTab/SearchHistoryViewController.swift b/iphone/Maps/UI/Search/Tabs/HistoryTab/SearchHistoryViewController.swift index be08ea1c38..85f63dc556 100644 --- a/iphone/Maps/UI/Search/Tabs/HistoryTab/SearchHistoryViewController.swift +++ b/iphone/Maps/UI/Search/Tabs/HistoryTab/SearchHistoryViewController.swift @@ -122,9 +122,9 @@ extension SearchHistoryViewController: UITableViewDelegate { } extension SearchHistoryViewController: ModallyPresentedViewController { - func translationYDidUpdate(_ translationY: CGFloat) { + func presentationFrameDidChange(_ frame: CGRect) { guard isViewLoaded else { return } - tableView.contentInset.bottom = translationY - emptyHistoryView.translationYDidUpdate(translationY) + tableView.contentInset.bottom = frame.origin.y + emptyHistoryView.presentationFrameDidChange(frame) } } diff --git a/iphone/Maps/UI/Search/Tabs/SearchTabViewController.swift b/iphone/Maps/UI/Search/Tabs/SearchTabViewController.swift index e8ef5bc720..b770bf063d 100644 --- a/iphone/Maps/UI/Search/Tabs/SearchTabViewController.swift +++ b/iphone/Maps/UI/Search/Tabs/SearchTabViewController.swift @@ -54,8 +54,8 @@ final class SearchTabViewController: TabViewController { } extension SearchTabViewController: ModallyPresentedViewController { - func translationYDidUpdate(_ translationY: CGFloat) { - viewControllers.forEach { ($0 as? ModallyPresentedViewController)?.translationYDidUpdate(translationY) } + func presentationFrameDidChange(_ frame: CGRect) { + viewControllers.forEach { ($0 as? ModallyPresentedViewController)?.presentationFrameDidChange(frame) } } }