diff --git a/Check/modal/infobox/ico/line.png b/Check/modal/infobox/ico/line.png new file mode 100644 index 00000000..421badb4 Binary files /dev/null and b/Check/modal/infobox/ico/line.png differ diff --git a/Marker/UnTapMarker/search/ico/solid.png b/Marker/UnTapMarker/search/ico/solid.png new file mode 100644 index 00000000..10ce11cd Binary files /dev/null and b/Marker/UnTapMarker/search/ico/solid.png differ diff --git a/Marker/search/ico/solid.png b/Marker/search/ico/solid.png new file mode 100644 index 00000000..39f13b31 Binary files /dev/null and b/Marker/search/ico/solid.png differ diff --git a/Poppool/Poppool.xcodeproj/project.pbxproj b/Poppool/Poppool.xcodeproj/project.pbxproj index 87241957..966a72dc 100644 --- a/Poppool/Poppool.xcodeproj/project.pbxproj +++ b/Poppool/Poppool.xcodeproj/project.pbxproj @@ -435,8 +435,6 @@ 4EE5A3D32D40E4A600A2469A /* MapGuideReactor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EE5A3D22D40E4A600A2469A /* MapGuideReactor.swift */; }; 4EEA1D8F2D352012003E7DE9 /* ImageCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EEA1D8E2D352012003E7DE9 /* ImageCell.swift */; }; 4EEA1D912D352027003E7DE9 /* ExtendedImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EEA1D902D352027003E7DE9 /* ExtendedImage.swift */; }; - 4EEA1D952D358B23003E7DE9 /* PopUpStoreRegisterView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EEA1D942D358B23003E7DE9 /* PopUpStoreRegisterView.swift */; }; - 4EECA3932D5676C900A07CCA /* MapGuideViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E9790C42D40E13500210499 /* MapGuideViewController.swift */; }; 4EECA3942D56770B00A07CCA /* MapPopUpStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E685EBB2D12CEB6001EF91C /* MapPopUpStore.swift */; }; 4EED9BAC2D22730400B288E7 /* FilterType.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EED9BAB2D22730400B288E7 /* FilterType.swift */; }; BD226D512CF6DB290038C984 /* PPReturnHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD226D502CF6DB290038C984 /* PPReturnHeaderView.swift */; }; @@ -921,8 +919,6 @@ 4EE5A3D22D40E4A600A2469A /* MapGuideReactor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MapGuideReactor.swift; sourceTree = ""; }; 4EEA1D8E2D352012003E7DE9 /* ImageCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageCell.swift; sourceTree = ""; }; 4EEA1D902D352027003E7DE9 /* ExtendedImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExtendedImage.swift; sourceTree = ""; }; - 4EEA1D922D358839003E7DE9 /* PopUpStoreRegisterReactor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PopUpStoreRegisterReactor.swift; sourceTree = ""; }; - 4EEA1D942D358B23003E7DE9 /* PopUpStoreRegisterView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PopUpStoreRegisterView.swift; sourceTree = ""; }; 4EED9BAB2D22730400B288E7 /* FilterType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FilterType.swift; sourceTree = ""; }; BD226D502CF6DB290038C984 /* PPReturnHeaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PPReturnHeaderView.swift; sourceTree = ""; }; BD91034E2CF6149D00BBCCAE /* AuthAPIEndPoint.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AuthAPIEndPoint.swift; sourceTree = ""; }; @@ -2711,7 +2707,7 @@ path = UseCase; sourceTree = ""; }; - 4E685EBD2D12CEB6001EF91C /* MapAPI */ = { + 4E685EBD2D12CEB6001EF91C /* MapDomain */ = { isa = PBXGroup; children = ( 4E685EB72D12CEB6001EF91C /* Repository */, @@ -2720,7 +2716,7 @@ 4E685EBB2D12CEB6001EF91C /* MapPopUpStore.swift */, 4E685EBC2D12CEB6001EF91C /* MapPopUpStoreDTO.swift */, ); - path = MapAPI; + path = MapDomain; sourceTree = ""; }; 4E685EC02D12CEB6001EF91C /* MapPopupCardView */ = { @@ -2747,22 +2743,10 @@ 4E685ECD2D12CEB6001EF91C /* Map */ = { isa = PBXGroup; children = ( - 4EED9BAA2D2272F500B288E7 /* Common */, + 4EC23F432D6F7E6D00558673 /* MapView */, 4E685EB52D12CEB6001EF91C /* FillterSheetView */, - 4E685EBD2D12CEB6001EF91C /* MapAPI */, - 4E685EC02D12CEB6001EF91C /* MapPopupCardView */, 4E685EC52D12CEB6001EF91C /* StoreListView */, - 4E685EC62D12CEB6001EF91C /* MapFilterChips.swift */, - 4E8AA29C2D59A2340029DF75 /* MarkerTooltipView.swift */, - 4E685EC72D12CEB6001EF91C /* MapMarker.swift */, - 4E685EC82D12CEB6001EF91C /* MapReactor.swift */, - 4E685EC92D12CEB6001EF91C /* MapSearchInput.swift */, - 4E685ECB2D12CEB6001EF91C /* MapView.swift */, - 4E685ECC2D12CEB6001EF91C /* MapViewController.swift */, - 4E6C07052D4B6E56008A962A /* RegionDefinitions.swift */, - 4E6C07072D4B6E74008A962A /* ClusteringModels.swift */, - 4E9A465F2D55D1270010578A /* MapUtilities.swift */, - 4E6C07092D4B6E81008A962A /* ClusteringManager.swift */, + 4EED9BAA2D2272F500B288E7 /* Common */, 4EE5A3D12D40E3B100A2469A /* FindMap */, ); path = Map; @@ -2777,21 +2761,36 @@ 4E755B242D2B9C6C00ADFB21 /* AdminView.swift */, 4E755B262D2B9C7C00ADFB21 /* AdminStoreCell.swift */, 4E755B282D2BA65A00ADFB21 /* AdminReactor.swift */, + 4E9405302D6F7C790002B590 /* AdminRegister */, + 4E94052F2D6F7C670002B590 /* AdminBottomSheet */, + 4EEA1D8E2D352012003E7DE9 /* ImageCell.swift */, + 4EDDEFB22D2D284B00CFAFA5 /* Common */, + ); + path = Admin; + sourceTree = ""; + }; + 4E94052F2D6F7C670002B590 /* AdminBottomSheet */ = { + isa = PBXGroup; + children = ( 4E9C12772D2BC7A0006744D6 /* AdminBottomSheetView.swift */, 4E9C12792D2BC811006744D6 /* AdminBottomSheetViewController.swift */, 4E6CA4842D34D6ED0034D09A /* AdminBottomSheetReactor.swift */, + ); + path = AdminBottomSheet; + sourceTree = ""; + }; + 4E9405302D6F7C790002B590 /* AdminRegister */ = { + isa = PBXGroup; + children = ( 4E9C12802D2BE0A6006744D6 /* PopUpStoreRegisterViewController.swift */, - 4EEA1D942D358B23003E7DE9 /* PopUpStoreRegisterView.swift */, - 4EEA1D922D358839003E7DE9 /* PopUpStoreRegisterReactor.swift */, - 4EEA1D8E2D352012003E7DE9 /* ImageCell.swift */, - 4EDDEFB22D2D284B00CFAFA5 /* Common */, ); - path = Admin; + path = AdminRegister; sourceTree = ""; }; 4E9C127B2D2BCFE4006744D6 /* Data */ = { isa = PBXGroup; children = ( + 4E685EBD2D12CEB6001EF91C /* MapDomain */, 4E9C127E2D2BD012006744D6 /* Remote */, 4E9C127D2D2BD007006744D6 /* Repository */, 4E9C127C2D2BCFF1006744D6 /* DTO */, @@ -2832,16 +2831,31 @@ path = Domain; sourceTree = ""; }; - 4EA2C93B2D424D2600F4D97C /* New Group */ = { + 4EA2C93B2D424D2600F4D97C /* MapGuideView */ = { isa = PBXGroup; children = ( 4E6A066F2D42A96100B2A658 /* FullScreenMapViewController.swift */, 4EA2C93C2D424D3300F4D97C /* MapDirectionRepository.swift */, 4EA2C93E2D424D7400F4D97C /* MapDirectionUseCase.swift */, 4EA2C9402D424D8400F4D97C /* GetPopUpDirectionResponseDTO.swift */, + 4E9790C42D40E13500210499 /* MapGuideViewController.swift */, + 4EE5A3D22D40E4A600A2469A /* MapGuideReactor.swift */, 4EA2C9422D424DF900F4D97C /* FindDirectionEndPoint.swift */, ); - path = "New Group"; + path = MapGuideView; + sourceTree = ""; + }; + 4EC23F432D6F7E6D00558673 /* MapView */ = { + isa = PBXGroup; + children = ( + 4E8AA29C2D59A2340029DF75 /* MarkerTooltipView.swift */, + 4E685EC72D12CEB6001EF91C /* MapMarker.swift */, + 4E685EC82D12CEB6001EF91C /* MapReactor.swift */, + 4E685EC92D12CEB6001EF91C /* MapSearchInput.swift */, + 4E685ECB2D12CEB6001EF91C /* MapView.swift */, + 4E685ECC2D12CEB6001EF91C /* MapViewController.swift */, + ); + path = MapView; sourceTree = ""; }; 4EDDEFB22D2D284B00CFAFA5 /* Common */ = { @@ -2856,9 +2870,7 @@ 4EE5A3D12D40E3B100A2469A /* FindMap */ = { isa = PBXGroup; children = ( - 4EA2C93B2D424D2600F4D97C /* New Group */, - 4E9790C42D40E13500210499 /* MapGuideViewController.swift */, - 4EE5A3D22D40E4A600A2469A /* MapGuideReactor.swift */, + 4EA2C93B2D424D2600F4D97C /* MapGuideView */, ); path = FindMap; sourceTree = ""; @@ -2869,7 +2881,13 @@ 4EAB809C2D3F78AA0041AF30 /* GMSMapViewDelegateProxy.swift */, 4EAB809E2D3F8EF50041AF30 /* ViewportBounds.swift */, 4EED9BAB2D22730400B288E7 /* FilterType.swift */, + 4E6C07052D4B6E56008A962A /* RegionDefinitions.swift */, + 4E685EC62D12CEB6001EF91C /* MapFilterChips.swift */, + 4E9A465F2D55D1270010578A /* MapUtilities.swift */, + 4E6C07092D4B6E81008A962A /* ClusteringManager.swift */, + 4E6C07072D4B6E74008A962A /* ClusteringModels.swift */, 4EDE57022D5E70650014D924 /* LocationPermissionBottomSheet.swift */, + 4E685EC02D12CEB6001EF91C /* MapPopupCardView */, ); path = Common; sourceTree = ""; @@ -3321,7 +3339,6 @@ 08B191B82CF6092F0057BC04 /* AuthServiceable.swift in Sources */, 0841BAB82CFAC41300049E31 /* SectionBackGroundDecorationView.swift in Sources */, 086F89F52D2269E300CA4FC9 /* MyPageReactor.swift in Sources */, - 4EEA1D952D358B23003E7DE9 /* PopUpStoreRegisterView.swift in Sources */, 089952682D046CD80022AEF9 /* SearchResultReactor.swift in Sources */, BDCA41C52CF35AC0005EECF6 /* TestViewController.swift in Sources */, 086DD9402D01EEEB00B97D3B /* SearchCountTitleSection.swift in Sources */, @@ -3669,7 +3686,6 @@ files = ( BDCA41E42CF35AC1005EECF6 /* PoppoolUITestsLaunchTests.swift in Sources */, 4E9A465E2D50B2DB0010578A /* AdminStoreCell.swift in Sources */, - 4EECA3932D5676C900A07CCA /* MapGuideViewController.swift in Sources */, BDCA41E22CF35AC1005EECF6 /* PoppoolUITests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; diff --git a/Poppool/Poppool/Presentation/Admin/AdminBottomSheetReactor.swift b/Poppool/Poppool/Presentation/Admin/AdminBottomSheet/AdminBottomSheetReactor.swift similarity index 100% rename from Poppool/Poppool/Presentation/Admin/AdminBottomSheetReactor.swift rename to Poppool/Poppool/Presentation/Admin/AdminBottomSheet/AdminBottomSheetReactor.swift diff --git a/Poppool/Poppool/Presentation/Admin/AdminBottomSheetView.swift b/Poppool/Poppool/Presentation/Admin/AdminBottomSheet/AdminBottomSheetView.swift similarity index 100% rename from Poppool/Poppool/Presentation/Admin/AdminBottomSheetView.swift rename to Poppool/Poppool/Presentation/Admin/AdminBottomSheet/AdminBottomSheetView.swift diff --git a/Poppool/Poppool/Presentation/Admin/AdminBottomSheetViewController.swift b/Poppool/Poppool/Presentation/Admin/AdminBottomSheet/AdminBottomSheetViewController.swift similarity index 100% rename from Poppool/Poppool/Presentation/Admin/AdminBottomSheetViewController.swift rename to Poppool/Poppool/Presentation/Admin/AdminBottomSheet/AdminBottomSheetViewController.swift diff --git a/Poppool/Poppool/Presentation/Admin/PopUpStoreRegisterViewController.swift b/Poppool/Poppool/Presentation/Admin/AdminRegister/PopUpStoreRegisterViewController.swift similarity index 100% rename from Poppool/Poppool/Presentation/Admin/PopUpStoreRegisterViewController.swift rename to Poppool/Poppool/Presentation/Admin/AdminRegister/PopUpStoreRegisterViewController.swift diff --git a/Poppool/Poppool/Presentation/Map/MapAPI/MapAPIEndpoint.swift b/Poppool/Poppool/Presentation/Admin/Data/MapDomain/MapAPIEndpoint.swift similarity index 100% rename from Poppool/Poppool/Presentation/Map/MapAPI/MapAPIEndpoint.swift rename to Poppool/Poppool/Presentation/Admin/Data/MapDomain/MapAPIEndpoint.swift diff --git a/Poppool/Poppool/Presentation/Map/MapAPI/MapPopUpStore.swift b/Poppool/Poppool/Presentation/Admin/Data/MapDomain/MapPopUpStore.swift similarity index 100% rename from Poppool/Poppool/Presentation/Map/MapAPI/MapPopUpStore.swift rename to Poppool/Poppool/Presentation/Admin/Data/MapDomain/MapPopUpStore.swift diff --git a/Poppool/Poppool/Presentation/Map/MapAPI/MapPopUpStoreDTO.swift b/Poppool/Poppool/Presentation/Admin/Data/MapDomain/MapPopUpStoreDTO.swift similarity index 100% rename from Poppool/Poppool/Presentation/Map/MapAPI/MapPopUpStoreDTO.swift rename to Poppool/Poppool/Presentation/Admin/Data/MapDomain/MapPopUpStoreDTO.swift diff --git a/Poppool/Poppool/Presentation/Map/MapAPI/Repository/MapRepository.swift b/Poppool/Poppool/Presentation/Admin/Data/MapDomain/Repository/MapRepository.swift similarity index 100% rename from Poppool/Poppool/Presentation/Map/MapAPI/Repository/MapRepository.swift rename to Poppool/Poppool/Presentation/Admin/Data/MapDomain/Repository/MapRepository.swift diff --git a/Poppool/Poppool/Presentation/Map/MapAPI/UseCase/MapUseCase.swift b/Poppool/Poppool/Presentation/Admin/Data/MapDomain/UseCase/MapUseCase.swift similarity index 100% rename from Poppool/Poppool/Presentation/Map/MapAPI/UseCase/MapUseCase.swift rename to Poppool/Poppool/Presentation/Admin/Data/MapDomain/UseCase/MapUseCase.swift diff --git a/Poppool/Poppool/Presentation/Map/ClusteringManager.swift b/Poppool/Poppool/Presentation/Map/Common/ClusteringManager.swift similarity index 100% rename from Poppool/Poppool/Presentation/Map/ClusteringManager.swift rename to Poppool/Poppool/Presentation/Map/Common/ClusteringManager.swift diff --git a/Poppool/Poppool/Presentation/Map/ClusteringModels.swift b/Poppool/Poppool/Presentation/Map/Common/ClusteringModels.swift similarity index 85% rename from Poppool/Poppool/Presentation/Map/ClusteringModels.swift rename to Poppool/Poppool/Presentation/Map/Common/ClusteringModels.swift index d521266e..9e8dc1ce 100644 --- a/Poppool/Poppool/Presentation/Map/ClusteringModels.swift +++ b/Poppool/Poppool/Presentation/Map/Common/ClusteringModels.swift @@ -11,9 +11,9 @@ enum MapZoomLevel { case ..<7: return .country case 7..<10: - return .city - case 10..<11: - return .district + return .city + case 10..<12: // 구 레벨 범위 확장 (10~12) + return .district default: return .detailed } diff --git a/Poppool/Poppool/Presentation/Map/MapFilterChips.swift b/Poppool/Poppool/Presentation/Map/Common/MapFilterChips.swift similarity index 100% rename from Poppool/Poppool/Presentation/Map/MapFilterChips.swift rename to Poppool/Poppool/Presentation/Map/Common/MapFilterChips.swift diff --git a/Poppool/Poppool/Presentation/Map/MapPopupCardView/MapPopupCarouselView.swift b/Poppool/Poppool/Presentation/Map/Common/MapPopupCardView/MapPopupCarouselView.swift similarity index 91% rename from Poppool/Poppool/Presentation/Map/MapPopupCardView/MapPopupCarouselView.swift rename to Poppool/Poppool/Presentation/Map/Common/MapPopupCardView/MapPopupCarouselView.swift index bc0dcdac..cd217101 100644 --- a/Poppool/Poppool/Presentation/Map/MapPopupCardView/MapPopupCarouselView.swift +++ b/Poppool/Poppool/Presentation/Map/Common/MapPopupCardView/MapPopupCarouselView.swift @@ -9,6 +9,21 @@ final class MapPopupCarouselView: UICollectionView { private var popupCards: [MapPopUpStore] = [] private var currentIndex: Int = 0 + var currentVisibleIndex: Int { + + let centerX = self.contentOffset.x + self.bounds.width / 2 + + for i in 0.. GMSMarker? { diff --git a/Poppool/Poppool/Presentation/Map/FindMap/New Group/GetPopUpDirectionResponseDTO.swift b/Poppool/Poppool/Presentation/Map/FindMap/MapGuideView/GetPopUpDirectionResponseDTO.swift similarity index 100% rename from Poppool/Poppool/Presentation/Map/FindMap/New Group/GetPopUpDirectionResponseDTO.swift rename to Poppool/Poppool/Presentation/Map/FindMap/MapGuideView/GetPopUpDirectionResponseDTO.swift diff --git a/Poppool/Poppool/Presentation/Map/FindMap/New Group/MapDirectionRepository.swift b/Poppool/Poppool/Presentation/Map/FindMap/MapGuideView/MapDirectionRepository.swift similarity index 100% rename from Poppool/Poppool/Presentation/Map/FindMap/New Group/MapDirectionRepository.swift rename to Poppool/Poppool/Presentation/Map/FindMap/MapGuideView/MapDirectionRepository.swift diff --git a/Poppool/Poppool/Presentation/Map/FindMap/New Group/MapDirectionUseCase.swift b/Poppool/Poppool/Presentation/Map/FindMap/MapGuideView/MapDirectionUseCase.swift similarity index 100% rename from Poppool/Poppool/Presentation/Map/FindMap/New Group/MapDirectionUseCase.swift rename to Poppool/Poppool/Presentation/Map/FindMap/MapGuideView/MapDirectionUseCase.swift diff --git a/Poppool/Poppool/Presentation/Map/FindMap/MapGuideReactor.swift b/Poppool/Poppool/Presentation/Map/FindMap/MapGuideView/MapGuideReactor.swift similarity index 100% rename from Poppool/Poppool/Presentation/Map/FindMap/MapGuideReactor.swift rename to Poppool/Poppool/Presentation/Map/FindMap/MapGuideView/MapGuideReactor.swift diff --git a/Poppool/Poppool/Presentation/Map/FindMap/MapGuideViewController.swift b/Poppool/Poppool/Presentation/Map/FindMap/MapGuideView/MapGuideViewController.swift similarity index 100% rename from Poppool/Poppool/Presentation/Map/FindMap/MapGuideViewController.swift rename to Poppool/Poppool/Presentation/Map/FindMap/MapGuideView/MapGuideViewController.swift diff --git a/Poppool/Poppool/Presentation/Map/MapMarker.swift b/Poppool/Poppool/Presentation/Map/MapView/MapMarker.swift similarity index 100% rename from Poppool/Poppool/Presentation/Map/MapMarker.swift rename to Poppool/Poppool/Presentation/Map/MapView/MapMarker.swift diff --git a/Poppool/Poppool/Presentation/Map/MapReactor.swift b/Poppool/Poppool/Presentation/Map/MapView/MapReactor.swift similarity index 100% rename from Poppool/Poppool/Presentation/Map/MapReactor.swift rename to Poppool/Poppool/Presentation/Map/MapView/MapReactor.swift diff --git a/Poppool/Poppool/Presentation/Map/MapSearchInput.swift b/Poppool/Poppool/Presentation/Map/MapView/MapSearchInput.swift similarity index 100% rename from Poppool/Poppool/Presentation/Map/MapSearchInput.swift rename to Poppool/Poppool/Presentation/Map/MapView/MapSearchInput.swift diff --git a/Poppool/Poppool/Presentation/Map/MapView.swift b/Poppool/Poppool/Presentation/Map/MapView/MapView.swift similarity index 72% rename from Poppool/Poppool/Presentation/Map/MapView.swift rename to Poppool/Poppool/Presentation/Map/MapView/MapView.swift index 6e098ee3..c3f8c972 100644 --- a/Poppool/Poppool/Presentation/Map/MapView.swift +++ b/Poppool/Poppool/Presentation/Map/MapView/MapView.swift @@ -67,11 +67,11 @@ final class MapView: UIView { fatalError("init(coder:) has not been implemented") } - // MARK: - Helper Method: 스토어카드 보임/숨김에 따른 레이아웃 업데이트 - /// 스토어카드의 표시 상태를 변경하면서 버튼 레이아웃을 업데이트합니다. + // MARK: - Helper Method func setStoreCardHidden(_ hidden: Bool, animated: Bool = true) { guard storeCard.isHidden != hidden else { return } storeCard.isHidden = hidden + if animated { UIView.animate(withDuration: 0.3) { self.updateButtonLayout() @@ -87,25 +87,28 @@ final class MapView: UIView { // MARK: - SetUp private extension MapView { func setUpConstraints() { + // 1. MapView 설정 addSubview(mapView) mapView.snp.makeConstraints { make in make.edges.equalToSuperview() } + // 2. Search Filter Container 설정 addSubview(searchFilterContainer) searchFilterContainer.snp.makeConstraints { make in - make.top.equalToSuperview().offset(60) + make.top.equalToSuperview().offset(56) make.leading.trailing.equalToSuperview() } + // 3. Search Input 설정 searchFilterContainer.addSubview(searchInput) searchInput.snp.makeConstraints { make in make.top.equalToSuperview() - make.leading.equalToSuperview().inset(20) - make.trailing.equalToSuperview().inset(16) + make.leading.trailing.equalToSuperview().inset(20) // 수정된 부분 make.height.equalTo(37) } + // 4. Filter Chips 설정 searchFilterContainer.addSubview(filterChips) filterChips.snp.makeConstraints { make in make.top.equalTo(searchInput.snp.bottom).offset(7) @@ -114,27 +117,19 @@ private extension MapView { make.bottom.equalToSuperview() } - addSubview(locationButton) - addSubview(listButton) + // 5. Store Card 설정 addSubview(storeCard) - - locationButton.snp.makeConstraints { make in - make.trailing.equalToSuperview().inset(16) - make.bottom.equalTo(storeCard.snp.top).offset(-40) - make.size.equalTo(44) - } - - listButton.snp.makeConstraints { make in - make.trailing.equalToSuperview().inset(16) - make.bottom.equalTo(locationButton.snp.top).offset(-12) - make.size.equalTo(44) - } - storeCard.snp.makeConstraints { make in make.leading.trailing.equalToSuperview().inset(30) make.height.equalTo(137) - make.bottom.equalTo(safeAreaLayoutGuide) + make.bottom.equalTo(safeAreaLayoutGuide).offset(-24) // 수정된 부분 } + + // 6. Buttons 설정 + addSubview(locationButton) + addSubview(listButton) + + // 초기 버튼 레이아웃은 updateButtonLayout()에서 설정됨 } func configureUI() { @@ -144,30 +139,22 @@ private extension MapView { } func updateButtonLayout() { - if storeCard.isHidden { - locationButton.snp.remakeConstraints { make in - make.trailing.equalToSuperview().inset(16) - make.bottom.equalTo(safeAreaLayoutGuide).inset(40) - make.size.equalTo(44) - } - listButton.snp.remakeConstraints { make in - make.trailing.equalToSuperview().inset(16) - make.bottom.equalTo(locationButton.snp.top).offset(-8) // 예시로 8pt 간격 - make.size.equalTo(44) - } - } else { - locationButton.snp.remakeConstraints { make in - make.trailing.equalToSuperview().inset(16) - make.bottom.equalTo(storeCard.snp.top).offset(-50) - make.size.equalTo(44) - } - listButton.snp.remakeConstraints { make in - make.trailing.equalToSuperview().inset(16) - make.bottom.equalTo(locationButton.snp.top).offset(-12) - make.size.equalTo(44) + locationButton.snp.remakeConstraints { make in + make.trailing.equalToSuperview().inset(16) + make.size.equalTo(44) + + if storeCard.isHidden { + make.bottom.equalTo(safeAreaLayoutGuide).offset(-40) + } else { + make.bottom.equalTo(storeCard.snp.top).offset(-40) } } - layoutIfNeeded() + + listButton.snp.remakeConstraints { make in + make.trailing.equalToSuperview().inset(16) + make.bottom.equalTo(locationButton.snp.top).offset(-12) + make.size.equalTo(44) + } } } diff --git a/Poppool/Poppool/Presentation/Map/MapViewController.swift b/Poppool/Poppool/Presentation/Map/MapView/MapViewController.swift similarity index 69% rename from Poppool/Poppool/Presentation/Map/MapViewController.swift rename to Poppool/Poppool/Presentation/Map/MapView/MapViewController.swift index 59f20bfa..6be0b434 100644 --- a/Poppool/Poppool/Presentation/Map/MapViewController.swift +++ b/Poppool/Poppool/Presentation/Map/MapView/MapViewController.swift @@ -32,6 +32,11 @@ class MapViewController: BaseViewController, View { // MARK: - Properties private var storeDetailsCache: [Int64: StoreItem] = [:] private var isMovingToMarker = false + private var isUserInteraction = false + private var isMapMoving = false + private var shouldUpdateCarouselAfterMoving = false + + var currentCarouselStores: [MapPopUpStore] = [] private var markerDictionary: [Int64: GMSMarker] = [:] private var individualMarkerDictionary: [Int64: GMSMarker] = [:] @@ -103,33 +108,19 @@ class MapViewController: BaseViewController, View { southWestLat: koreaRegion.southWest.latitude, southWestLon: koreaRegion.southWest.longitude )) -// reactor.state -// .map { $0.viewportStores } -// .distinctUntilChanged() -// .filter { !$0.isEmpty } -// .take(1) -// .compactMap { [weak self] stores -> (stores: [MapPopUpStore], location: CLLocation)? in -// guard let self = self, -// let location = self.locationManager.location else { return nil } -// return (stores: stores, location: location) -// } -// .subscribe(onNext: { [weak self] result in -// self?.findAndShowNearestStore(from: result.location) -// }) -// .disposed(by: disposeBag) - - } carouselView.rx.observe(Bool.self, "hidden") - .distinctUntilChanged() - .subscribe(onNext: { [weak self] isHidden in - guard let self = self, let isHidden = isHidden else { return } - self.mainView.setStoreCardHidden(isHidden, animated: true) - }) - .disposed(by: disposeBag) + .distinctUntilChanged() + .subscribe(onNext: { [weak self] isHidden in + guard let self = self, let isHidden = isHidden else { return } + + // 캐러셀 상태 변경 시 마커 처리 + self.hideMarkersUnderCarousel() + }) + .disposed(by: disposeBag) carouselView.onCardTapped = { [weak self] store in let detailController = DetailController() @@ -143,24 +134,59 @@ class MapViewController: BaseViewController, View { mainView.mapView.rx.idleAtPosition .observe(on: MainScheduler.instance) + .debounce(.milliseconds(300), scheduler: MainScheduler.instance) .subscribe(onNext: { [weak self] in guard let self = self else { return } - if let marker = self.currentMarker, - let storeArray = marker.userData as? [MapPopUpStore] { - // tooltip이 없으면 생성, 있으면 위치 업데이트 - if self.currentTooltipView == nil { - self.configureTooltip(for: marker, stores: storeArray) - } else { - self.updateTooltipPosition() + + // 지도 움직임 상태 초기화 + self.isMapMoving = false + + // 현재 줌 레벨 확인 + let currentZoom = self.mainView.mapView.camera.zoom + let level = MapZoomLevel.getLevel(from: currentZoom) + + // 개별 마커 레벨에서만 캐러셀 업데이트 + if level == .detailed { + // 마커 이동 중이 아닐 때만 뷰포트 기반 캐러셀 업데이트 + if !self.isMovingToMarker { + // 현재 뷰포트 내 스토어 필터링 + self.updateCarouselWithVisibleStores() } + + // 툴팁 위치 업데이트 - 다중 마커인 경우에만 + if let marker = self.currentMarker, + let storeArray = marker.userData as? [MapPopUpStore], + storeArray.count > 1 { + if self.currentTooltipView == nil { + self.configureTooltip(for: marker, stores: storeArray) + } else { + self.updateTooltipPosition() + } + } + } else { + // 클러스터 레벨에서는 캐러셀과 툴팁 숨김 + self.carouselView.isHidden = true + self.carouselView.updateCards([]) + self.currentCarouselStores = [] + self.mainView.setStoreCardHidden(true, animated: true) + + self.currentTooltipView?.removeFromSuperview() + self.currentTooltipView = nil + self.currentTooltipStores = [] } + self.isMovingToMarker = false + self.isUserInteraction = false + + // 클러스터링 업데이트 + self.updateMapWithClustering() + self.hideMarkersUnderCarousel() + }) .disposed(by: disposeBag) - carouselView.onCardScrolled = { [weak self] pageIndex in guard let self = self, pageIndex >= 0, @@ -174,38 +200,56 @@ class MapViewController: BaseViewController, View { - 선택된 스토어: \(store.name) """, category: .debug) - if let existingMarker = self.currentMarker, - let markerStores = existingMarker.userData as? [MapPopUpStore] { + // 해당 스토어의 마커 찾기 + if let marker = self.findMarkerForStore(for: store) { + // 이전 마커 선택 해제 + if let previousMarker = self.currentMarker, + previousMarker != marker, + let previousMarkerView = previousMarker.iconView as? MapMarker { + previousMarkerView.injection(with: .init( + isSelected: false, + isCluster: false, + count: (previousMarker.userData as? [MapPopUpStore])?.count ?? 1 + )) + } - // 1. 마커 뷰 업데이트 - if let currentMarkerView = existingMarker.iconView as? MapMarker { - currentMarkerView.injection(with: .init( + // 새 마커 선택 + if let markerView = marker.iconView as? MapMarker { + markerView.injection(with: .init( isSelected: true, isCluster: false, - count: markerStores.count + count: (marker.userData as? [MapPopUpStore])?.count ?? 1 )) } - // 2. 툴팁 업데이트 - if markerStores.count > 1 { + // 현재 마커 업데이트 + self.currentMarker = marker + + // 지도 이동 코드 제거 - 캐러셀 스와이프 시 지도가 움직이지 않도록 함 + // self.mainView.mapView.animate(toLocation: marker.position) + + // 툴팁 처리 - 다중 마커인 경우에만 + if let storeArray = marker.userData as? [MapPopUpStore], storeArray.count > 1 { + // 툴팁 업데이트 또는 생성 if self.currentTooltipView == nil { - self.configureTooltip(for: existingMarker, stores: markerStores) + self.configureTooltip(for: marker, stores: storeArray) } - // 현재 캐러셀의 스토어에 해당하는 툴팁 인덱스 찾기 - if let tooltipIndex = markerStores.firstIndex(where: { $0.id == store.id }) { -// Logger.log(message: """ -// 툴팁 업데이트: -// - 선택된 스토어: \(store.name) -// - 툴팁 인덱스: \(tooltipIndex) -// """, category: .debug) + // 현재 선택된 스토어에 맞게 툴팁 선택 + if let tooltipIndex = storeArray.firstIndex(where: { $0.id == store.id }) { (self.currentTooltipView as? MarkerTooltipView)?.selectStore(at: tooltipIndex) } + } else { + // 단일 마커면 툴팁 제거 + self.currentTooltipView?.removeFromSuperview() + self.currentTooltipView = nil + self.currentTooltipStores = [] } } } + if let reactor = self.reactor { bindViewport(reactor: reactor) reactor.action.onNext(.fetchCategories) @@ -232,8 +276,17 @@ class MapViewController: BaseViewController, View { // onStoreSelected 클로저 설정 tooltipView.onStoreSelected = { [weak self] index in guard let self = self, index < stores.count else { return } - self.currentCarouselStores = stores - self.carouselView.updateCards(stores) + + // 툴팁에서 선택한 스토어 + let selectedStore = stores[index] + + // 캐러셀의 현재 내용이 이 다중 마커의 스토어들과 다르면 업데이트 + if self.currentCarouselStores != stores { + self.currentCarouselStores = stores + self.carouselView.updateCards(stores) + } + + // 캐러셀에서 해당 스토어의 위치로 스크롤 self.carouselView.scrollToCard(index: index) // 선택된 상태로 업데이트 @@ -244,7 +297,9 @@ class MapViewController: BaseViewController, View { count: stores.count )) } + tooltipView.selectStore(at: index) + Logger.log(message: """ 툴팁 선택: - 선택된 스토어: \(stores[index].name) @@ -252,6 +307,7 @@ class MapViewController: BaseViewController, View { """, category: .debug) } + // 툴팁 위치 설정 (예시: 마커 우측에 위치) let markerPoint = self.mainView.mapView.projection.point(for: marker.position) let markerHeight = (marker.iconView as? MapMarker)?.imageView.frame.height ?? 32 @@ -755,6 +811,33 @@ class MapViewController: BaseViewController, View { CATransaction.begin() CATransaction.setDisableActions(true) + // 캐러셀 표시 여부를 줌 레벨에 따라 결정 + let shouldShowCarousel = (level == .detailed) + + // 캐러셀을 줌 레벨에 따라 표시/숨김 + if !shouldShowCarousel { + carouselView.isHidden = true + carouselView.updateCards([]) + currentCarouselStores = [] + mainView.setStoreCardHidden(true, animated: true) + + // 현재 툴팁도 해제 + currentTooltipView?.removeFromSuperview() + currentTooltipView = nil + currentTooltipStores = [] + + // 선택된 마커도 해제 + if let currentMarker = currentMarker, + let markerView = currentMarker.iconView as? MapMarker { + markerView.injection(with: .init( + isSelected: false, + isCluster: false, + count: (currentMarker.userData as? [MapPopUpStore])?.count ?? 1 + )) + } + currentMarker = nil + } + switch level { case .detailed: // 현재 표시되어야 할 마커의 키 집합 생성 @@ -837,6 +920,7 @@ class MapViewController: BaseViewController, View { } } + // 더 이상 필요없는 마커 제거 individualMarkerDictionary = individualMarkerDictionary.filter { id, marker in if newStoreIds.contains(id) { return true @@ -846,7 +930,20 @@ class MapViewController: BaseViewController, View { } } + // 개별 마커 레벨에서만 캐러셀 업데이트 + if shouldShowCarousel { + // 선택된 마커가 없거나 이미 선택된 마커가 있지만 개별 마커인 경우만 캐러셀 업데이트 + if currentMarker == nil { + updateCarouselWithVisibleStores() + } else if let userData = currentMarker?.userData { + if userData is MapPopUpStore || (userData as? [MapPopUpStore])?.count == 1 { + updateCarouselWithVisibleStores() + } + } + } + case .district, .city, .country: + // 개별 마커 제거 individualMarkerDictionary.values.forEach { $0.map = nil } individualMarkerDictionary.removeAll() @@ -906,19 +1003,112 @@ class MapViewController: BaseViewController, View { } CATransaction.commit() + hideMarkersUnderCarousel() + } + func hideMarkersUnderCarousel() { + // 캐러셀이 숨겨져 있으면 모든 마커 표시 + if carouselView.isHidden { + showAllMarkers() + return + } + // 캐러셀의 지도 상 위치 계산 (상단 Y 좌표) + let carouselTopY = view.frame.height - carouselView.frame.height - view.safeAreaInsets.bottom + + // 개별 마커 딕셔너리 처리 + for (id, marker) in individualMarkerDictionary { + let markerPoint = mainView.mapView.projection.point(for: marker.position) + let isUnderCarousel = markerPoint.y >= carouselTopY + + if isUnderCarousel { + // 캐러셀 아래의 마커는 지도에서 완전히 제거 + marker.map = nil + + // 현재 선택된 마커가 캐러셀 아래에 있으면 툴팁도 제거 + if marker == currentMarker { + currentTooltipView?.removeFromSuperview() + currentTooltipView = nil + currentMarker = nil + } + } else { + // 캐러셀 위의 마커는 표시 + if marker.map == nil { + marker.map = mainView.mapView + } + } + } + + // 클러스터 마커 딕셔너리 처리 + for (key, marker) in clusterMarkerDictionary { + let markerPoint = mainView.mapView.projection.point(for: marker.position) + if markerPoint.y >= carouselTopY { + marker.map = nil + } else if marker.map == nil { + marker.map = mainView.mapView + } + } + + // 일반 마커 딕셔너리 처리 + for (id, marker) in markerDictionary { + let markerPoint = mainView.mapView.projection.point(for: marker.position) + if markerPoint.y >= carouselTopY { + marker.map = nil + } else if marker.map == nil { + marker.map = mainView.mapView + } + } + + // 툴팁 위치 확인 및 관리 + if let tooltipCoord = currentTooltipCoordinate { + let tooltipMarkerPoint = mainView.mapView.projection.point(for: tooltipCoord) + if tooltipMarkerPoint.y >= carouselTopY { + currentTooltipView?.removeFromSuperview() + currentTooltipView = nil + currentTooltipCoordinate = nil + } + } + } + + + // 모든 마커를 표시하는 함수 + func showAllMarkers() { + for (_, marker) in individualMarkerDictionary { + marker.opacity = 1.0 + } + + for (_, marker) in clusterMarkerDictionary { + marker.opacity = 1.0 + } + + for (_, marker) in markerDictionary { + marker.opacity = 1.0 + } + } private func clearAllMarkers() { + // 개별 마커 제거 individualMarkerDictionary.values.forEach { $0.map = nil } individualMarkerDictionary.removeAll() + // 클러스터 마커 제거 clusterMarkerDictionary.values.forEach { $0.map = nil } clusterMarkerDictionary.removeAll() + // 일반 마커 제거 markerDictionary.values.forEach { $0.map = nil } markerDictionary.removeAll() + + // 툴팁 제거 + currentTooltipView?.removeFromSuperview() + currentTooltipView = nil + currentTooltipStores = [] + currentTooltipCoordinate = nil + + // 선택된 마커 초기화 + currentMarker = nil } + private func groupStoresByExactLocation(_ stores: [MapPopUpStore]) -> [CoordinateKey: [MapPopUpStore]] { var dict = [CoordinateKey: [MapPopUpStore]]() for store in stores { @@ -1094,11 +1284,10 @@ extension MapViewController: CLLocationManagerDelegate { func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) { guard let location = locations.last else { return } - currentMarker?.map = nil - currentMarker = nil - carouselView.isHidden = true - currentCarouselStores = [] + // 이전 선택된 마커 초기화 + resetSelectedMarker() + // 현재 위치로 카메라 이동 let camera = GMSCameraPosition.camera( withLatitude: location.coordinate.latitude, longitude: location.coordinate.longitude, @@ -1106,12 +1295,14 @@ extension MapViewController: CLLocationManagerDelegate { ) mainView.mapView.animate(to: camera) - // 카메라 이동이 완료된 후 가장 가까운 스토어 찾기 + // 카메라 이동이 완료된 후 뷰포트 내 스토어만 확인 mainView.mapView.rx.idleAtPosition .take(1) .subscribe(onNext: { [weak self] _ in guard let self = self else { return } - self.findAndShowNearestStore(from: location) + + // 뷰포트 내 스토어만 표시 + self.updateCarouselWithVisibleStores() }) .disposed(by: disposeBag) @@ -1119,49 +1310,53 @@ extension MapViewController: CLLocationManagerDelegate { } + private func findAndShowNearestStore(from location: CLLocation) { guard !currentStores.isEmpty else { Logger.log(message: "현재위치 표기할 스토어가 없습니다", category: .debug) return } - resetSelectedMarker() + // 현재 뷰포트 내에 있는 스토어만 필터링 + let visibleRegion = mainView.mapView.projection.visibleRegion() + let bounds = GMSCoordinateBounds(region: visibleRegion) - let nearestStore = currentStores.min { store1, store2 in - let location1 = CLLocation(latitude: store1.latitude, longitude: store1.longitude) - let location2 = CLLocation(latitude: store2.latitude, longitude: store2.longitude) - return location.distance(from: location1) < location.distance(from: location2) + let visibleStores = currentStores.filter { store in + bounds.contains(CLLocationCoordinate2D( + latitude: store.latitude, + longitude: store.longitude + )) } - if let store = nearestStore { - if let marker = findMarkerForStore(for: store) { - _ = handleSingleStoreTap(marker, store: store) - } else { - let marker = GMSMarker() - marker.position = store.coordinate - marker.userData = store - marker.groundAnchor = CGPoint(x: 0.5, y: 1.0) + // 뷰포트 내에 스토어가 있으면 뷰포트 내 스토어 표시 + if !visibleStores.isEmpty { + // 뷰포트 내에서 가장 가까운 스토어 찾기 + let nearestVisibleStore = visibleStores.min { store1, store2 in + let location1 = CLLocation(latitude: store1.latitude, longitude: store1.longitude) + let location2 = CLLocation(latitude: store2.latitude, longitude: store2.longitude) + return location.distance(from: location1) < location.distance(from: location2) + } - let markerView = MapMarker() - markerView.injection(with: .init( - isSelected: true, - isCluster: false, - count: 1 - )) - marker.iconView = markerView - marker.map = mainView.mapView + if let store = nearestVisibleStore, + let marker = findMarkerForStore(for: store) { + // 현재 뷰포트 내 가장 가까운 스토어의 마커 선택 + _ = handleSingleStoreTap(marker, store: store) + } + } else { + // 뷰포트 내에 스토어가 없으면 토스트 메시지 표시 + showToast(message: "이 지역에는 팝업 스토어가 없어요") - // 마커를 individualMarkerDictionary에 추가 - individualMarkerDictionary[store.id] = marker + // 캐러셀 숨김 + carouselView.isHidden = true + carouselView.updateCards([]) + currentCarouselStores = [] + mainView.setStoreCardHidden(true, animated: true) - currentMarker = marker - carouselView.updateCards([store]) - currentCarouselStores = [store] - carouselView.scrollToCard(index: 0) - mainView.setStoreCardHidden(false, animated: true) - } + // 선택된 마커 초기화 + resetSelectedMarker() } } + } @@ -1204,26 +1399,59 @@ extension MapViewController: GMSMapViewDelegate { func mapView(_ mapView: GMSMapView, didChange position: GMSCameraPosition) { - if !isMovingToMarker { - currentTooltipView?.removeFromSuperview() - currentTooltipView = nil - currentTooltipStores = [] - updateMapWithClustering() + // 현재 줌 레벨 확인 + let currentZoomLevel = MapZoomLevel.getLevel(from: position.zoom) + + // 사용자 상호작용으로 인한 이동이고 마커 이동 중이 아닌 경우에만 처리 + if isUserInteraction && !isMovingToMarker { + // 줌 레벨이 detailed가 아니면 캐러셀 숨김 + if currentZoomLevel != .detailed { + carouselView.isHidden = true + carouselView.updateCards([]) + currentCarouselStores = [] + mainView.setStoreCardHidden(true, animated: true) + hideMarkersUnderCarousel() + + + // 툴팁도 숨김 + currentTooltipView?.removeFromSuperview() + currentTooltipView = nil + currentTooltipStores = [] + } - // 캐러셀 초기화 - carouselView.isHidden = true - carouselView.updateCards([]) - currentCarouselStores = [] + // 툴팁 위치 업데이트 + updateTooltipPosition() } + + // 지도 움직임 상태 업데이트 + isMapMoving = true } + + + func mapView(_ mapView: GMSMapView, willMove gesture: Bool) { - if gesture && !isMovingToMarker { - resetSelectedMarker() + // 사용자 제스처로 인한 이동인 경우만 처리 + if gesture { + isUserInteraction = true + isMapMoving = true + + // 현재 줌 레벨이 개별 마커 레벨이 아니면 캐러셀 숨김 + let currentZoom = mapView.camera.zoom + let level = MapZoomLevel.getLevel(from: currentZoom) + + if level != .detailed { + carouselView.isHidden = true + currentCarouselStores = [] + mainView.setStoreCardHidden(true, animated: true) + } } } + + + /// 지도 빈 공간 탭 → 기존 마커/캐러셀 해제 func mapView(_ mapView: GMSMapView, didTapAt coordinate: CLLocationCoordinate2D) { guard !isMovingToMarker else { return } @@ -1261,7 +1489,7 @@ extension MapViewController: GMSMapViewDelegate { // MARK: - Helper for single marker tap - func handleSingleStoreTap(_ marker: GMSMarker, store: MapPopUpStore) -> Bool { + func handleSingleStoreTap(_ marker: GMSMarker, store: MapPopUpStore) -> Bool { if currentMarker == marker { resetSelectedMarker() return false @@ -1292,72 +1520,83 @@ extension MapViewController: GMSMapViewDelegate { currentMarker = marker - // 캐러셀 업데이트 - carouselView.updateCards([store]) - carouselView.isHidden = false - currentCarouselStores = [store] - carouselView.scrollToCard(index: 0) - mainView.setStoreCardHidden(false, animated: true) + // 캐러셀 업데이트 - 뷰포트 내 모든 마커의 정보 표시 + updateCarouselWithVisibleStores() + + // 현재 선택된 스토어로 스크롤 + if let index = currentCarouselStores.firstIndex(where: { $0.id == store.id }) { + carouselView.scrollToCard(index: index) + } mainView.mapView.animate(toLocation: marker.position) - + return true } - - - func handleRegionalClusterTap(_ marker: GMSMarker, clusterData: ClusterMarkerData) -> Bool { + func handleRegionalClusterTap(_ marker: GMSMarker, clusterData: ClusterMarkerData) -> Bool { let currentZoom = mainView.mapView.camera.zoom let currentLevel = MapZoomLevel.getLevel(from: currentZoom) + isMovingToMarker = true + + // 줌 레벨에 따른 처리 switch currentLevel { - case .city: // 시 단위 클러스터 - let districtZoomLevel: Float = 10.0 + case .country: + // 국가 레벨에서 시 레벨로 + let cityZoomLevel: Float = 8.5 + let camera = GMSCameraPosition(target: marker.position, zoom: cityZoomLevel) + mainView.mapView.animate(to: camera) + + case .city: + // 시 레벨에서 구 레벨로 + let districtZoomLevel: Float = 10.5 let camera = GMSCameraPosition(target: marker.position, zoom: districtZoomLevel) mainView.mapView.animate(to: camera) - case .district: // 구 단위 클러스터 - let detailedZoomLevel: Float = 12.0 + case .district: + // 구 레벨에서 상세 레벨로 (해당 구의 스토어만 표시) + let detailedZoomLevel: Float = 13.0 let camera = GMSCameraPosition(target: marker.position, zoom: detailedZoomLevel) - mainView.mapView.animate(to: camera) - default: - break - } + // 선택된 클러스터의 구 이름 가져오기 + let selectedDistrictName = clusterData.cluster.name + Logger.log(message: "선택된 구: \(selectedDistrictName), 스토어 수: \(clusterData.storeCount), 상세 줌으로 확대", category: .debug) - // 캐러셀 업데이트는 공통 - carouselView.updateCards(clusterData.cluster.stores) - carouselView.isHidden = false - self.currentCarouselStores = clusterData.cluster.stores + // 구 클러스터의 스토어들 저장 + let districtStores = clusterData.cluster.stores - return true - } + // 1. 기존 마커 초기화 + clearAllMarkers() + // 2. 현재 스토어를 해당 구의 스토어로만 설정 + currentStores = districtStores - func handleMicroClusterTap(_ marker: GMSMarker, storeArray: [MapPopUpStore]) -> Bool { - // 이미 선택된 마커를 다시 탭할 때 - if currentMarker == marker { - // 툴팁과 캐러셀만 숨기고, 마커의 선택 상태는 유지 - currentTooltipView?.removeFromSuperview() - currentTooltipView = nil - currentTooltipStores = [] - currentTooltipCoordinate = nil + // 3. 상세 줌 레벨로 애니메이션하며 이동 + mainView.mapView.animate(to: camera) + + // 4. 애니메이션이 완료되면 updateMapWithClustering이 호출되어 + // 스토어들이 개별 마커로 표시될 것임 + + case .detailed: + // 이미 상세 레벨인 경우, 아무 작업 없음 + break + } + // 클러스터 레벨에서는 캐러셀 표시하지 않음 + if currentLevel != .detailed { carouselView.isHidden = true carouselView.updateCards([]) currentCarouselStores = [] + mainView.setStoreCardHidden(true, animated: true) + } - // 마커 상태 업데이트 - if let markerView = marker.iconView as? MapMarker { - markerView.injection(with: .init( - isSelected: false, - isCluster: false, - count: storeArray.count - )) - } + return true + } - currentMarker = nil - isMovingToMarker = false // 여기서 false로 설정 + func handleMicroClusterTap(_ marker: GMSMarker, storeArray: [MapPopUpStore]) -> Bool { + // 이미 선택된 마커를 다시 탭할 때 + if currentMarker == marker { + resetSelectedMarker() return false } @@ -1384,28 +1623,27 @@ extension MapViewController: GMSMapViewDelegate { } currentMarker = marker + // 다중 마커의 경우 해당 마커의 스토어들만 표시 - 중요: isHidden을 false로 설정 currentCarouselStores = storeArray carouselView.updateCards(storeArray) - carouselView.isHidden = false + carouselView.isHidden = false // 명시적으로 표시 설정 carouselView.scrollToCard(index: 0) - mainView.setStoreCardHidden(false, animated: true) // 지도 이동 및 툴팁 생성 mainView.mapView.animate(toLocation: marker.position) - // 툴팁 생성을 idleAtPosition 이벤트까지 기다리지 않고 직접 호출 - if storeArray.count > 1 { - DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { [weak self] in - guard let self = self else { return } - self.configureTooltip(for: marker, stores: storeArray) - self.isMovingToMarker = false - } + // 툴팁 생성 - 약간의 지연을 두어 애니메이션이 완료된 후 표시 + DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { [weak self] in + guard let self = self else { return } + self.configureTooltip(for: marker, stores: storeArray) + self.isMovingToMarker = false } return true } + private func updateTooltipPosition() { guard let marker = currentMarker, let tooltip = currentTooltipView else { return } @@ -1448,14 +1686,13 @@ extension MapViewController: GMSMapViewDelegate { currentTooltipView = nil currentTooltipStores = [] currentTooltipCoordinate = nil - carouselView.isHidden = true - carouselView.updateCards([]) - currentCarouselStores = [] // 현재 마커 참조 제거 self.currentMarker = nil - } + // 캐러셀 초기화 대신 뷰포트에 보이는 마커들로 업데이트 + updateCarouselWithVisibleStores() + } } @@ -1491,6 +1728,22 @@ extension MapViewController { } .bind(to: reactor.action) .disposed(by: disposeBag) + reactor.state + .map { $0.viewportStores } + .distinctUntilChanged() + .filter { !$0.isEmpty } + .observe(on: MainScheduler.instance) + .subscribe(onNext: { [weak self] stores in + guard let self = self else { return } + + // 현재 스토어 목록 업데이트 및 클러스터링 + self.currentStores = stores + self.updateMapWithClustering() + + // 캐러셀 업데이트 + self.updateCarouselWithVisibleStores() + }) + .disposed(by: disposeBag) // 현재 뷰포트 내의 스토어 업데이트 - 마커만 업데이트 reactor.state @@ -1518,36 +1771,50 @@ extension MapViewController { reactor.state - .map { $0.viewportStores } - .distinctUntilChanged() - .debounce(.milliseconds(300), scheduler: MainScheduler.instance) - .observe(on: MainScheduler.instance) - .map { [unowned self] stores -> [MapPopUpStore] in - let visibleRegion = self.mainView.mapView.projection.visibleRegion() - let bounds = GMSCoordinateBounds(region: visibleRegion) - - let filteredStores = stores.filter { store in - bounds.contains(CLLocationCoordinate2D( - latitude: store.latitude, - longitude: store.longitude - )) - } - - if self.currentMarker == nil, - let location = self.locationManager.location, - self is FullScreenMapViewController { - (self as! FullScreenMapViewController).findAndShowNearestStore(from: location) - } - - return filteredStores - } - .do(onNext: { [weak self] stores in - self?.currentStores = stores - self?.updateMapWithClustering() - }) - .subscribe() - .disposed(by: disposeBag) + .map { $0.viewportStores } + .distinctUntilChanged() + .debounce(.milliseconds(300), scheduler: MainScheduler.instance) + .observe(on: MainScheduler.instance) + .map { [unowned self] stores -> [MapPopUpStore] in + let visibleRegion = self.mainView.mapView.projection.visibleRegion() + let bounds = GMSCoordinateBounds(region: visibleRegion) + + // 캐러셀 영역 계산 + let carouselTopY = self.view.frame.height - self.carouselView.frame.height - self.view.safeAreaInsets.bottom + + // 뷰포트 내에 있고 캐러셀 영역에 닿지 않는 스토어만 필터링 + let filteredStores = stores.filter { store in + // 뷰포트 내에 있는지 확인 + let isInBounds = bounds.contains(CLLocationCoordinate2D( + latitude: store.latitude, + longitude: store.longitude + )) + // 캐러셀 아래에 있는지 확인 + let markerPoint = self.mainView.mapView.projection.point(for: CLLocationCoordinate2D( + latitude: store.latitude, + longitude: store.longitude + )) + let isUnderCarousel = markerPoint.y >= carouselTopY + + // 뷰포트 내에 있고 캐러셀 아래에 있지 않은 스토어만 반환 + return isInBounds && !isUnderCarousel + } + + if self.currentMarker == nil, + let location = self.locationManager.location, + self is FullScreenMapViewController { + (self as! FullScreenMapViewController).findAndShowNearestStore(from: location) + } + + return filteredStores + } + .do(onNext: { [weak self] stores in + self?.currentStores = stores + self?.updateMapWithClustering() + }) + .subscribe() + .disposed(by: disposeBag) } private func fetchStoreDetails(for stores: [MapPopUpStore]) { guard !stores.isEmpty else { return } @@ -1585,22 +1852,188 @@ extension MapViewController { .disposed(by: disposeBag) } } - private func findMarkerForStore(for store: MapPopUpStore) -> GMSMarker? { + // 1. 개별 마커 딕셔너리에서 검색 if let marker = individualMarkerDictionary[store.id] { return marker } - for marker in clusterMarkerDictionary.values { - if let stores = (marker.userData as? [MapPopUpStore]), - stores.contains(where: { $0.id == store.id }) { + + // 2. 일반 마커 딕셔너리에서 검색 (추가됨) + if let marker = markerDictionary[store.id] { + return marker + } + + // 3. 모든 마커 딕셔너리에서 다중 스토어로 저장된 마커 검색 (추가됨) + for (_, marker) in individualMarkerDictionary { + if let storeArray = marker.userData as? [MapPopUpStore], + storeArray.contains(where: { $0.id == store.id }) { + return marker + } + } + + // 4. 클러스터 마커에서 검색 + for (_, marker) in clusterMarkerDictionary { + if let clusterData = marker.userData as? ClusterMarkerData, + clusterData.cluster.stores.contains(where: { $0.id == store.id }) { return marker } } return nil } + // 뷰포트 변경 시 캐러셀 업데이트 + func updateCarouselWithVisibleStores() { + // 현재 줌 레벨 확인 + let currentZoom = mainView.mapView.camera.zoom + let level = MapZoomLevel.getLevel(from: currentZoom) - + // 개별 마커 레벨이 아니면 캐러셀 숨김 + if level != .detailed { + carouselView.isHidden = true + carouselView.updateCards([]) + currentCarouselStores = [] + mainView.setStoreCardHidden(true, animated: true) + return + } + + // 현재 다중 마커가 선택된 상태이고 그 마커가 개별 마커 모음이면 + // 해당 마커의 스토어들만 표시 (단, 캐러셀 영역에 닿지 않는 스토어만) + if let currentMarker = currentMarker, + let storeArray = currentMarker.userData as? [MapPopUpStore], + storeArray.count > 1 { + // 캐러셀 영역 계산 + let carouselTopY = view.frame.height - carouselView.frame.height - view.safeAreaInsets.bottom + + // 캐러셀 영역에 닿지 않는 스토어만 필터링 + let visibleStores = storeArray.filter { store in + let markerPoint = mainView.mapView.projection.point(for: CLLocationCoordinate2D( + latitude: store.latitude, + longitude: store.longitude + )) + return markerPoint.y < carouselTopY + } + + if !visibleStores.isEmpty { + currentCarouselStores = visibleStores + carouselView.updateCards(visibleStores) + carouselView.isHidden = false + mainView.setStoreCardHidden(false, animated: true) + } else { + carouselView.isHidden = true + carouselView.updateCards([]) + currentCarouselStores = [] + mainView.setStoreCardHidden(true, animated: true) + } + return + } + + // 그 외에는 뷰포트에 보이는 모든 스토어 표시 (캐러셀 영역 제외) + let visibleRegion = mainView.mapView.projection.visibleRegion() + let bounds = GMSCoordinateBounds(region: visibleRegion) + + // 캐러셀 영역 계산 + let carouselTopY = view.frame.height - carouselView.frame.height - view.safeAreaInsets.bottom + + // 캐러셀 아래에 있지 않은 스토어만 필터링 + let visibleStores = currentStores.filter { store in + // 바운드 내에 있는지 확인 + let isInBounds = bounds.contains(CLLocationCoordinate2D( + latitude: store.latitude, + longitude: store.longitude + )) + + // 캐러셀 아래에 있는지 확인 + let markerPoint = mainView.mapView.projection.point(for: + CLLocationCoordinate2D(latitude: store.latitude, longitude: store.longitude)) + let isUnderCarousel = markerPoint.y >= carouselTopY + + // 바운드 내에 있고 캐러셀 아래에 있지 않은 스토어만 반환 + return isInBounds && !isUnderCarousel + } + + if !visibleStores.isEmpty { + // 캐러셀 업데이트 + currentCarouselStores = visibleStores + carouselView.updateCards(visibleStores) + carouselView.isHidden = false + mainView.setStoreCardHidden(false, animated: true) + + // 현재 선택된 마커가 있으면 해당 카드로 스크롤 + if let currentMarker = currentMarker { + // 마커에서 스토어 정보 추출 + var selectedStore: MapPopUpStore? = nil + + if let store = currentMarker.userData as? MapPopUpStore { + selectedStore = store + } else if let storeArray = currentMarker.userData as? [MapPopUpStore], !storeArray.isEmpty { + // 다중 마커의 경우 첫 번째 스토어 사용 + selectedStore = storeArray.first + } + + if let selectedStore = selectedStore, + let index = visibleStores.firstIndex(where: { $0.id == selectedStore.id }) { + carouselView.scrollToCard(index: index) + } + } + } else { + // 보이는 스토어가 없으면 캐러셀 숨김 및 토스트 메시지 표시 + carouselView.isHidden = true + carouselView.updateCards([]) + currentCarouselStores = [] + mainView.setStoreCardHidden(true, animated: true) + + // 마커 이동 중이 아닐 때만 토스트 표시 + if !isMovingToMarker { + showToast(message: "이 지역에는 팝업 스토어가 없어요") + } + } + } + + private func showToast(message: String) { + // 이미 표시 중인 토스트가 있으면 제거 + view.subviews.forEach { subview in + if let label = subview as? UILabel, label.tag == 9999 { + label.removeFromSuperview() + } + } + + let toastLabel = UILabel() + toastLabel.tag = 9999 // 식별용 태그 + toastLabel.backgroundColor = UIColor.black.withAlphaComponent(0.7) + toastLabel.textColor = UIColor.white + toastLabel.textAlignment = .center + toastLabel.font = UIFont.systemFont(ofSize: 14) + toastLabel.text = message + toastLabel.alpha = 1.0 + toastLabel.layer.cornerRadius = 18 + toastLabel.clipsToBounds = true + + view.addSubview(toastLabel) + toastLabel.snp.makeConstraints { make in + make.centerX.equalToSuperview() + make.bottom.equalTo(view.safeAreaLayoutGuide).offset(-80) + make.width.lessThanOrEqualTo(280) + make.height.equalTo(36) + } + + UIView.animate(withDuration: 2.0, delay: 0.5, options: .curveEaseOut, animations: { + toastLabel.alpha = 0.0 + }, completion: { _ in + toastLabel.removeFromSuperview() + }) + } + + // 마커에서 스토어 정보 추출 헬퍼 함수 + private func getStoreFromMarker(_ marker: GMSMarker) -> MapPopUpStore? { + if let store = marker.userData as? MapPopUpStore { + return store + } else if let storeArray = marker.userData as? [MapPopUpStore], !storeArray.isEmpty { + return storeArray.first + } else if let clusterData = marker.userData as? ClusterMarkerData, !clusterData.cluster.stores.isEmpty { + return clusterData.cluster.stores.first + } + return nil + } private func handleMarkerTap(_ marker: GMSMarker) -> Bool { isMovingToMarker = true diff --git a/Poppool/Poppool/Presentation/Map/MarkerTooltipView.swift b/Poppool/Poppool/Presentation/Map/MapView/MarkerTooltipView.swift similarity index 98% rename from Poppool/Poppool/Presentation/Map/MarkerTooltipView.swift rename to Poppool/Poppool/Presentation/Map/MapView/MarkerTooltipView.swift index 88ba1492..936b8848 100644 --- a/Poppool/Poppool/Presentation/Map/MarkerTooltipView.swift +++ b/Poppool/Poppool/Presentation/Map/MapView/MarkerTooltipView.swift @@ -25,6 +25,7 @@ final class MarkerTooltipView: UIView, UIGestureRecognizerDelegate { }() var onStoreSelected: ((Int) -> Void)? + var selectedIndex: Int = -1 // MARK: - Initialization override init(frame: CGRect) { @@ -176,7 +177,7 @@ final class MarkerTooltipView: UIView, UIGestureRecognizerDelegate { // MARK: - Store Selection func selectStore(at index: Int) { - // 모든 행을 순회하면서 해당 인덱스의 행만 선택 상태로 변경 + selectedIndex = index for case let row as UIView in stackView.arrangedSubviews { guard let horizontalStack = row.subviews.first as? UIStackView, horizontalStack.arrangedSubviews.count >= 2, diff --git a/Poppool/Poppool/Resource/Assets.xcassets/Icon/icon_check/icon_check_white.imageset/line.png b/Poppool/Poppool/Resource/Assets.xcassets/Icon/icon_check/icon_check_white.imageset/line.png index 1a066fd5..421badb4 100644 Binary files a/Poppool/Poppool/Resource/Assets.xcassets/Icon/icon_check/icon_check_white.imageset/line.png and b/Poppool/Poppool/Resource/Assets.xcassets/Icon/icon_check/icon_check_white.imageset/line.png differ diff --git a/Poppool/Poppool/Resource/Assets.xcassets/Marker/Marker.imageset/Contents.json b/Poppool/Poppool/Resource/Assets.xcassets/Marker/Marker.imageset/Contents.json index 4c3c14a6..fb8ebfdd 100644 --- a/Poppool/Poppool/Resource/Assets.xcassets/Marker/Marker.imageset/Contents.json +++ b/Poppool/Poppool/Resource/Assets.xcassets/Marker/Marker.imageset/Contents.json @@ -1,7 +1,7 @@ { "images" : [ { - "filename" : "Marker.png", + "filename" : "solid.png", "idiom" : "universal", "scale" : "1x" }, diff --git a/Poppool/Poppool/Resource/Assets.xcassets/Marker/Marker.imageset/Marker.png b/Poppool/Poppool/Resource/Assets.xcassets/Marker/Marker.imageset/Marker.png deleted file mode 100644 index 4d73de52..00000000 Binary files a/Poppool/Poppool/Resource/Assets.xcassets/Marker/Marker.imageset/Marker.png and /dev/null differ diff --git a/Poppool/Poppool/Resource/Assets.xcassets/Marker/Marker.imageset/solid.png b/Poppool/Poppool/Resource/Assets.xcassets/Marker/Marker.imageset/solid.png new file mode 100644 index 00000000..10ce11cd Binary files /dev/null and b/Poppool/Poppool/Resource/Assets.xcassets/Marker/Marker.imageset/solid.png differ diff --git a/Poppool/Poppool/Resource/Assets.xcassets/Marker/TapMarker.imageset/Contents.json b/Poppool/Poppool/Resource/Assets.xcassets/Marker/TapMarker.imageset/Contents.json index 496c274f..fb8ebfdd 100644 --- a/Poppool/Poppool/Resource/Assets.xcassets/Marker/TapMarker.imageset/Contents.json +++ b/Poppool/Poppool/Resource/Assets.xcassets/Marker/TapMarker.imageset/Contents.json @@ -1,7 +1,7 @@ { "images" : [ { - "filename" : "TapMarker.png", + "filename" : "solid.png", "idiom" : "universal", "scale" : "1x" }, diff --git a/Poppool/Poppool/Resource/Assets.xcassets/Marker/TapMarker.imageset/TapMarker.png b/Poppool/Poppool/Resource/Assets.xcassets/Marker/TapMarker.imageset/TapMarker.png deleted file mode 100644 index 1459c59e..00000000 Binary files a/Poppool/Poppool/Resource/Assets.xcassets/Marker/TapMarker.imageset/TapMarker.png and /dev/null differ diff --git a/Poppool/Poppool/Resource/Assets.xcassets/Marker/TapMarker.imageset/solid.png b/Poppool/Poppool/Resource/Assets.xcassets/Marker/TapMarker.imageset/solid.png new file mode 100644 index 00000000..39f13b31 Binary files /dev/null and b/Poppool/Poppool/Resource/Assets.xcassets/Marker/TapMarker.imageset/solid.png differ