diff --git a/.gitignore b/.gitignore index 3dd82707..03a865bc 100644 --- a/.gitignore +++ b/.gitignore @@ -41,3 +41,4 @@ fastlane/report.xml fastlane/Preview.html fastlane/screenshots/**/*.png fastlane/test_output +Poppool/Poppool/Infrastructure/*.mobileprovision diff --git a/Poppool/Poppool.xcodeproj/project.pbxproj b/Poppool/Poppool.xcodeproj/project.pbxproj index f8e92c85..7d676841 100644 --- a/Poppool/Poppool.xcodeproj/project.pbxproj +++ b/Poppool/Poppool.xcodeproj/project.pbxproj @@ -425,6 +425,11 @@ 4EA9989D2D21C404009DC30B /* RxDataSources in Frameworks */ = {isa = PBXBuildFile; productRef = 4EA9989C2D21C404009DC30B /* RxDataSources */; }; 4EAB809D2D3F78AA0041AF30 /* NMFMapViewDelegateProxy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EAB809C2D3F78AA0041AF30 /* NMFMapViewDelegateProxy.swift */; }; 4EAB809F2D3F8EF50041AF30 /* ViewportBounds.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EAB809E2D3F8EF50041AF30 /* ViewportBounds.swift */; }; + 4EC63FB22DB2147C0053B12D /* MapUIHandling.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EC63FB12DB2147C0053B12D /* MapUIHandling.swift */; }; + 4EC63FB72DB214840053B12D /* MapViewController+InteractionHandling.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EC63FB42DB214840053B12D /* MapViewController+InteractionHandling.swift */; }; + 4EC63FB82DB214840053B12D /* MapViewController+UIHandling.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EC63FB52DB214840053B12D /* MapViewController+UIHandling.swift */; }; + 4EC63FBA2DB21B930053B12D /* MapInteractionHandling.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EC63FB92DB21B930053B12D /* MapInteractionHandling.swift */; }; + 4EC63FBC2DB222F60053B12D /* MarkerStyling.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EC63FBB2DB222F60053B12D /* MarkerStyling.swift */; }; 4EDDEFB42D2D285900CFAFA5 /* DateTimePickerManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EDDEFB32D2D285900CFAFA5 /* DateTimePickerManager.swift */; }; 4EDE57032D5E70650014D924 /* LocationPermissionBottomSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EDE57022D5E70650014D924 /* LocationPermissionBottomSheet.swift */; }; 4EE360FD2D91876300D2441D /* NMapsMap in Frameworks */ = {isa = PBXBuildFile; productRef = 4EE360FC2D91876300D2441D /* NMapsMap */; }; @@ -888,6 +893,11 @@ 4EA998992D21C2FC009DC30B /* StoreListSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoreListSection.swift; sourceTree = ""; }; 4EAB809C2D3F78AA0041AF30 /* NMFMapViewDelegateProxy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NMFMapViewDelegateProxy.swift; sourceTree = ""; }; 4EAB809E2D3F8EF50041AF30 /* ViewportBounds.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewportBounds.swift; sourceTree = ""; }; + 4EC63FB12DB2147C0053B12D /* MapUIHandling.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MapUIHandling.swift; sourceTree = ""; }; + 4EC63FB42DB214840053B12D /* MapViewController+InteractionHandling.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MapViewController+InteractionHandling.swift"; sourceTree = ""; }; + 4EC63FB52DB214840053B12D /* MapViewController+UIHandling.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MapViewController+UIHandling.swift"; sourceTree = ""; }; + 4EC63FB92DB21B930053B12D /* MapInteractionHandling.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MapInteractionHandling.swift; sourceTree = ""; }; + 4EC63FBB2DB222F60053B12D /* MarkerStyling.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarkerStyling.swift; sourceTree = ""; }; 4EDDEFB32D2D285900CFAFA5 /* DateTimePickerManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DateTimePickerManager.swift; sourceTree = ""; }; 4EDE57022D5E70650014D924 /* LocationPermissionBottomSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocationPermissionBottomSheet.swift; sourceTree = ""; }; 4EE5A3D22D40E4A600A2469A /* MapGuideReactor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MapGuideReactor.swift; sourceTree = ""; }; @@ -2764,6 +2774,11 @@ 4E685EC72D12CEB6001EF91C /* MapMarker.swift */, 4E685EC82D12CEB6001EF91C /* MapReactor.swift */, 4E685EC92D12CEB6001EF91C /* MapSearchInput.swift */, + 4EC63FB42DB214840053B12D /* MapViewController+InteractionHandling.swift */, + 4EC63FBB2DB222F60053B12D /* MarkerStyling.swift */, + 4EC63FB52DB214840053B12D /* MapViewController+UIHandling.swift */, + 4EC63FB92DB21B930053B12D /* MapInteractionHandling.swift */, + 4EC63FB12DB2147C0053B12D /* MapUIHandling.swift */, 4E685ECB2D12CEB6001EF91C /* MapView.swift */, 4E685ECC2D12CEB6001EF91C /* MapViewController.swift */, ); @@ -3115,6 +3130,7 @@ 083C86202D087445003F441C /* GetPopUpDetailResponse.swift in Sources */, 4EA2C9432D424DF900F4D97C /* FindDirectionEndPoint.swift in Sources */, 081898FD2D33D9ED0067BF01 /* GetBlockUserListResponse.swift in Sources */, + 4EC63FB22DB2147C0053B12D /* MapUIHandling.swift in Sources */, 083A25B22CF362670099B58E /* NetworkError.swift in Sources */, 081899522D363E640067BF01 /* BookMarkPopUpViewTypeModalController.swift in Sources */, BD9103692CF6149D00BBCCAE /* HomeAPIRepository.swift in Sources */, @@ -3241,6 +3257,7 @@ 082197B22D4E4E200054094A /* NormalCommentEditController.swift in Sources */, 081899502D363E5C0067BF01 /* BookMarkPopUpViewTypeModalView.swift in Sources */, 081899452D35FEA10067BF01 /* RecentPopUpSection.swift in Sources */, + 4EC63FBA2DB21B930053B12D /* MapInteractionHandling.swift in Sources */, 083A259A2CF362090099B58E /* SectionDecorationItem.swift in Sources */, BD9103892CF614A900BBCCAE /* PopUpStoreResponse.swift in Sources */, 086F89C72D1E348400CA4FC9 /* CommentUserInfoReactor.swift in Sources */, @@ -3271,6 +3288,8 @@ BD9103642CF6149D00BBCCAE /* GetHomeInfoResponseDTO.swift in Sources */, 081898E22D338FA40067BF01 /* ListCountButtonSectionCell.swift in Sources */, 08A2E47D2D1B06B000102313 /* ImageDetailReactor.swift in Sources */, + 4EC63FB72DB214840053B12D /* MapViewController+InteractionHandling.swift in Sources */, + 4EC63FB82DB214840053B12D /* MapViewController+UIHandling.swift in Sources */, 08B191B42CF609260057BC04 /* KakaoLoginService.swift in Sources */, BDCA41C12CF35AC0005EECF6 /* AppDelegate.swift in Sources */, 083C863A2D0C7F0A003F441C /* CommentSelectedReactor.swift in Sources */, @@ -3346,6 +3365,7 @@ 081898B02D2CFCA40067BF01 /* GetWithdrawlListResponse.swift in Sources */, 083C86692D0ECB47003F441C /* InstaGuideSection.swift in Sources */, 081899392D35F11F0067BF01 /* MyPageRecentView.swift in Sources */, + 4EC63FBC2DB222F60053B12D /* MarkerStyling.swift in Sources */, 088DE24C2D12F33B0030FA9E /* DetailInfoSectionCell.swift in Sources */, 083C86762D0EE2CF003F441C /* PostCommentRequestDTO.swift in Sources */, 083C861E2D08737F003F441C /* GetPopUpDetailResponseDTO.swift in Sources */, diff --git a/Poppool/Poppool/Domain/Entities/MapPopUpStore.swift b/Poppool/Poppool/Domain/Entities/MapPopUpStore.swift index 2be1694e..3a1ee534 100644 --- a/Poppool/Poppool/Domain/Entities/MapPopUpStore.swift +++ b/Poppool/Poppool/Domain/Entities/MapPopUpStore.swift @@ -1,6 +1,11 @@ import Foundation import NMapsMap +// TODO: 프레젠테이션 + +// MARK: - ddd + +// FIXME: 엔티티 struct MapPopUpStore: Equatable { let id: Int64 let category: String @@ -15,6 +20,7 @@ struct MapPopUpStore: Equatable { let markerSnippet: String let mainImageUrl: String? + // TODO: var nmgCoordinate: NMGLatLng { NMGLatLng(lat: latitude, lng: longitude) } diff --git a/Poppool/Poppool/Domain/Repository/MapDirectionRepository.swift b/Poppool/Poppool/Domain/Repository/MapDirectionRepository.swift index fcb8e508..2d2dd1a8 100644 --- a/Poppool/Poppool/Domain/Repository/MapDirectionRepository.swift +++ b/Poppool/Poppool/Domain/Repository/MapDirectionRepository.swift @@ -1,10 +1,3 @@ -// -// MapDirectionRepository.swift -// Poppool -// -// Created by 김기현 on 1/23/25. -// - import Foundation import RxSwift diff --git a/Poppool/Poppool/Domain/UseCase/MapUseCase.swift b/Poppool/Poppool/Domain/UseCase/MapUseCase.swift index 660ee9d0..75aa2e85 100644 --- a/Poppool/Poppool/Domain/UseCase/MapUseCase.swift +++ b/Poppool/Poppool/Domain/UseCase/MapUseCase.swift @@ -44,7 +44,7 @@ class DefaultMapUseCase: MapUseCase { northEastLon: northEastLon, southWestLat: southWestLat, southWestLon: southWestLon, - categories: categories // ← 그대로 넘긴다 + categories: categories ) .map { $0.map { $0.toDomain() } } } diff --git a/Poppool/Poppool/Presentation/Scene/Admin/AdminBottomSheet/AdminBottomSheetView.swift b/Poppool/Poppool/Presentation/Scene/Admin/AdminBottomSheet/AdminBottomSheetView.swift index b23047dc..0db59dc6 100644 --- a/Poppool/Poppool/Presentation/Scene/Admin/AdminBottomSheet/AdminBottomSheetView.swift +++ b/Poppool/Poppool/Presentation/Scene/Admin/AdminBottomSheet/AdminBottomSheetView.swift @@ -209,7 +209,6 @@ final class AdminBottomSheetView: UIView { // MARK: - Public Methods func updateContentVisibility(isCategorySelected: Bool) { - Logger.log(message: "높이 변경 시작: \(isCategorySelected ? "카테고리" : "상태값")", category: .debug) let newHeight: CGFloat = isCategorySelected ? 200 : 160 @@ -220,6 +219,5 @@ final class AdminBottomSheetView: UIView { setNeedsLayout() layoutIfNeeded() - Logger.log(message: "높이 변경 완료", category: .debug) } } diff --git a/Poppool/Poppool/Presentation/Scene/Admin/AdminRegister/PopUpStoreRegisterViewController.swift b/Poppool/Poppool/Presentation/Scene/Admin/AdminRegister/PopUpStoreRegisterViewController.swift index 15e6855b..1bb25375 100644 --- a/Poppool/Poppool/Presentation/Scene/Admin/AdminRegister/PopUpStoreRegisterViewController.swift +++ b/Poppool/Poppool/Presentation/Scene/Admin/AdminRegister/PopUpStoreRegisterViewController.swift @@ -282,15 +282,6 @@ extension PopUpStoreRegisterViewController { self.mainView.timeButton.setTitle("\(startString) ~ \(endString)", for: .normal) } - private func showLoadingIndicator() { - // 로딩 인디케이터 표시 로직 구현 - // 예: Activity Indicator 또는 커스텀 로딩 뷰 표시 - } - - private func hideLoadingIndicator() { - // 로딩 인디케이터 숨김 로직 구현 - } - private func showSuccessAlert(isUpdate: Bool) { let message = isUpdate ? "팝업스토어가 성공적으로 수정되었습니다." : "팝업스토어가 성공적으로 등록되었습니다." let alert = UIAlertController( @@ -322,7 +313,6 @@ extension PopUpStoreRegisterViewController: View { func bind(reactor: Reactor) { // MARK: - Input (View -> Reactor) - // 텍스트 필드 바인딩 self.mainView.nameField.rx.text.orEmpty .distinctUntilChanged() .map { Reactor.Action.updateName($0) } @@ -372,18 +362,6 @@ extension PopUpStoreRegisterViewController: View { }) .disposed(by: self.disposeBag) - // 로딩 상태 - reactor.state.map { $0.isLoading } - .distinctUntilChanged() - .bind(onNext: { [weak self] isLoading in - if isLoading { - self?.showLoadingIndicator() - } else { - self?.hideLoadingIndicator() - } - }) - .disposed(by: self.disposeBag) - // 에러 메시지 reactor.state.map { $0.errorMessage } .distinctUntilChanged() diff --git a/Poppool/Poppool/Presentation/Scene/Admin/AdminStoreCell.swift b/Poppool/Poppool/Presentation/Scene/Admin/AdminStoreCell.swift index 5ab3d4c4..ef61f033 100644 --- a/Poppool/Poppool/Presentation/Scene/Admin/AdminStoreCell.swift +++ b/Poppool/Poppool/Presentation/Scene/Admin/AdminStoreCell.swift @@ -76,15 +76,10 @@ final class AdminStoreCell: UITableViewCell { // MARK: - Configure func configure(with store: GetAdminPopUpStoreListResponseDTO.PopUpStore) { - Logger.log(message: "셀 데이터 바인딩: \(store)", category: .debug) - titleLabel.text = store.name categoryLabel.text = store.categoryName statusChip.text = "운영" - - // mainImageUrl에서 baseURL 부분 제거 let imagePath = store.mainImageUrl.replacingOccurrences(of: KeyPath.popPoolS3BaseURL, with: "") - Logger.log(message: "이미지 경로: \(imagePath)", category: .debug) storeImageView.setPPImage(path: imagePath) } } diff --git a/Poppool/Poppool/Presentation/Scene/Map/FindMap/MapGuideView/FullScreenMapViewController.swift b/Poppool/Poppool/Presentation/Scene/Map/FindMap/MapGuideView/FullScreenMapViewController.swift index 82393654..b3215ed6 100644 --- a/Poppool/Poppool/Presentation/Scene/Map/FindMap/MapGuideView/FullScreenMapViewController.swift +++ b/Poppool/Poppool/Presentation/Scene/Map/FindMap/MapGuideView/FullScreenMapViewController.swift @@ -26,6 +26,8 @@ class FullScreenMapViewController: MapViewController { // MARK: - Lifecycle override func viewDidLoad() { super.viewDidLoad() + // use full‑screen styler for marker appearance + self.markerStyler = FullScreenMarkerStyler() setupFullScreenUI() setupNavigation() // configureInitialMapPosition() @@ -107,12 +109,10 @@ class FullScreenMapViewController: MapViewController { mainView.mapView.moveCamera(cameraUpdate) if let existingMarker = initialMarker { - // 기존 마커가 맵뷰에 설정되어 있지 않으면 설정 if existingMarker.mapView == nil { existingMarker.mapView = mainView.mapView } - // 명시적으로 TapMarker 스타일 적용 (selected 매개변수는 무시됨) existingMarker.iconImage = NMFOverlayImage(name: "TapMarker") existingMarker.width = 44 existingMarker.height = 44 @@ -120,7 +120,6 @@ class FullScreenMapViewController: MapViewController { currentMarker = existingMarker } else { - // 새 마커 생성 시에도 TapMarker 적용 let marker = NMFMarker() marker.position = position marker.iconImage = NMFOverlayImage(name: "TapMarker") @@ -158,65 +157,6 @@ class FullScreenMapViewController: MapViewController { .disposed(by: disposeBag) } - // 마커 스타일 업데이트 함수 - 항상 TapMarker로만 설정하도록 수정 - private func fullScreenUpdateMarkerStyle(marker: NMFMarker, selected: Bool) { - // 선택 여부와 상관없이 항상 TapMarker - marker.width = 44 - marker.height = 44 - marker.iconImage = NMFOverlayImage(name: "TapMarker") - marker.anchor = CGPoint(x: 0.5, y: 1.0) - } - - override func updateMarkerStyle(marker: NMFMarker, selected: Bool, isCluster: Bool, count: Int = 1, regionName: String = "") { - // 풀스크린 모드에서는 항상 TapMarker 스타일 적용 - if isFullScreenMode && markerLocked { - marker.width = 44 - marker.height = 44 - marker.iconImage = NMFOverlayImage(name: "TapMarker") - marker.anchor = CGPoint(x: 0.5, y: 1.0) - - if count > 1 { - marker.captionText = "\(count)" - } else { - marker.captionText = "" - } - return - } - - super.updateMarkerStyle(marker: marker, selected: selected, isCluster: isCluster, count: count, regionName: regionName) - } - - override func handleSingleStoreTap(_ marker: NMFMarker, store: MapPopUpStore) -> Bool { - isMovingToMarker = true - markerLocked = true - - if let previousMarker = currentMarker, previousMarker != marker { - fullScreenUpdateMarkerStyle(marker: previousMarker, selected: false) - } - - marker.iconImage = NMFOverlayImage(name: "TapMarker") - marker.width = 44 - marker.height = 44 - fullScreenUpdateMarkerStyle(marker: marker, selected: true) - currentMarker = marker - - currentCarouselStores = [store] - carouselView.updateCards([store]) - carouselView.isHidden = false - mainView.setStoreCardHidden(false, animated: true) - - let cameraUpdate = NMFCameraUpdate(scrollTo: marker.position, zoomTo: 15.0) - cameraUpdate.animation = .easeIn - cameraUpdate.animationDuration = 0.3 - mainView.mapView.moveCamera(cameraUpdate) - - DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in - self?.isMovingToMarker = false - } - - return true - } - // 맵뷰 탭 처리 오버라이드 override func mapView(_ mapView: NMFMapView, didTapMap latlng: NMGLatLng, point: CGPoint) { return @@ -238,12 +178,4 @@ class FullScreenMapViewController: MapViewController { } super.mapView(mapView, cameraIsChangingByReason: reason) } - - override func handleRegionalClusterTap(_ marker: NMFMarker, clusterData: ClusterMarkerData) -> Bool { - return false - } - - override func handleMicroClusterTap(_ marker: NMFMarker, storeArray: [MapPopUpStore]) -> Bool { - return false - } } diff --git a/Poppool/Poppool/Presentation/Scene/Map/MapView/.swift b/Poppool/Poppool/Presentation/Scene/Map/MapView/.swift new file mode 100644 index 00000000..be06270d --- /dev/null +++ b/Poppool/Poppool/Presentation/Scene/Map/MapView/.swift @@ -0,0 +1,344 @@ +// import UIKit +// import NMapsMap +// import ReactorKit +// +// extension MapViewController { +// +// // MARK: - Marker Style Handler +// func updateMarkerStyle(marker: NMFMarker, selected: Bool, isCluster: Bool, count: Int = 1, regionName: String = "") { +// if selected { +// marker.width = 44 +// marker.height = 44 +// marker.iconImage = NMFOverlayImage(name: "TapMarker") +// } else if isCluster { +// marker.width = 36 +// marker.height = 36 +// marker.iconImage = NMFOverlayImage(name: "cluster_marker") +// } else { +// marker.width = 32 +// marker.height = 32 +// marker.iconImage = NMFOverlayImage(name: "Marker") +// } +// +// marker.captionText = (count > 1) ? "\(count)" : "" +// marker.anchor = CGPoint(x: 0.5, y: 1.0) +// } +// +// // MARK: - Map Tap Handler +// @objc func handleMapViewTap(_ gesture: UITapGestureRecognizer) { +// // 리스트 뷰가 보이는 상태가 아닌 경우에만 처리 +// guard !isMovingToMarker else { return } +// +// // 선택된 마커 해제 +// if let currentMarker = self.currentMarker { +// updateMarkerStyle(marker: currentMarker, selected: false, isCluster: false) +// self.currentMarker = nil +// } +// +// // 툴팁 제거 및 관련 상태 초기화 +// currentTooltipView?.removeFromSuperview() +// currentTooltipView = nil +// currentTooltipStores = [] +// currentTooltipCoordinate = nil +// +// // 캐러셀 및 스토어 카드 숨김 처리 +// carouselView.isHidden = true +// carouselView.updateCards([]) +// currentCarouselStores = [] +// mainView.setStoreCardHidden(true, animated: true) +// +// // 클러스터 업데이트 (필요 시 재설정) +// updateMapWithClustering() +// } +// +// // MARK: - Pan Gesture Handler +// @objc func handlePanGesture(_ gesture: UIPanGestureRecognizer) { +// let translation = gesture.translation(in: view) +// let velocity = gesture.velocity(in: view) +// +// switch gesture.state { +// case .changed: +// if let constraint = listViewTopConstraint { +// let currentOffset = constraint.layoutConstraints.first?.constant ?? 0 +// let newOffset = currentOffset + translation.y +// let minOffset: CGFloat = filterContainerBottomY // 필터 컨테이너 바닥 높이 +// let maxOffset: CGFloat = view.frame.height // 최하단 위치 +// let clampedOffset = min(max(newOffset, minOffset), maxOffset) +// +// constraint.update(offset: clampedOffset) +// gesture.setTranslation(.zero, in: view) +// +// if modalState == .top { +// adjustMapViewAlpha(for: clampedOffset, minOffset: minOffset, maxOffset: maxOffset) +// } +// } +// case .ended: +// if let constraint = listViewTopConstraint { +// let currentOffset = constraint.layoutConstraints.first?.constant ?? 0 +// let middleY = view.frame.height * 0.3 +// let targetState: ModalState +// +// // 속도와 현재 오프셋에 따른 상태 결정 +// if velocity.y > 500 { +// targetState = .bottom +// } else if velocity.y < -500 { +// targetState = .top +// } else if currentOffset < middleY * 0.7 { +// targetState = .top +// } else if currentOffset < view.frame.height * 0.7 { +// targetState = .middle +// } else { +// targetState = .bottom +// } +// +// animateToState(targetState) +// } +// default: +// break +// } +// } +// +// // MARK: - Map Alpha Handlers +// func adjustMapViewAlpha(for offset: CGFloat, minOffset: CGFloat, maxOffset: CGFloat) { +// let middleOffset = view.frame.height * 0.3 +// if offset <= minOffset { +// mainView.mapView.alpha = 0 // 완전히 숨김 +// } else if offset >= maxOffset { +// mainView.mapView.alpha = 1 // 완전히 보임 +// } else if offset <= middleOffset { +// let progress = (offset - minOffset) / (middleOffset - minOffset) +// mainView.mapView.alpha = progress +// } else { +// mainView.mapView.alpha = 1 +// } +// } +// +// func updateMapViewAlpha(for offset: CGFloat, minOffset: CGFloat, maxOffset: CGFloat) { +// let progress = (maxOffset - offset) / (maxOffset - minOffset) +// mainView.mapView.alpha = max(0, min(progress, 1)) +// } +// +// // MARK: - Modal Animation Handler +// func animateToState(_ state: ModalState) { +// guard modalState != state else { return } +// self.view.layoutIfNeeded() +// UIView.animate(withDuration: 0.3, animations: { +// switch state { +// case .top: +// let filterChipsFrame = self.mainView.filterChips.convert(self.mainView.filterChips.bounds, to: self.view) +// self.mainView.mapView.alpha = 0 +// self.storeListViewController.setGrabberHandleVisible(false) +// self.listViewTopConstraint?.update(offset: filterChipsFrame.maxY) +// self.mainView.searchInput.setBackgroundColor(.g50) +// +// case .middle: +// self.storeListViewController.setGrabberHandleVisible(true) +// let offset = max(self.view.frame.height * 0.3, self.filterContainerBottomY) +// self.listViewTopConstraint?.update(offset: offset) +// self.storeListViewController.mainView.layer.cornerRadius = 20 +// self.storeListViewController.mainView.layer.maskedCorners = [.layerMinXMinYCorner, .layerMaxXMinYCorner] +// self.mainView.mapView.alpha = 1 +// self.mainView.mapView.isHidden = false +// self.mainView.searchInput.setBackgroundColor(.white) +// +// if let reactor = self.reactor { +// reactor.action.onNext(.fetchAllStores) +// reactor.state +// .map { $0.viewportStores } +// .distinctUntilChanged() +// .filter { !$0.isEmpty } +// .take(1) +// .observe(on: MainScheduler.instance) +// .subscribe(onNext: { [weak self] stores in +// self?.fetchStoreDetails(for: stores) +// }) +// .disposed(by: self.disposeBag) +// } +// +// case .bottom: +// self.storeListViewController.setGrabberHandleVisible(true) +// self.listViewTopConstraint?.update(offset: self.view.frame.height) +// self.mainView.mapView.alpha = 1 +// self.mainView.mapView.isHidden = false +// self.mainView.searchInput.setBackgroundColor(.white) +// } +// self.view.layoutIfNeeded() +// }) { _ in +// self.modalState = state +// } +// } +// +// // MARK: - Tooltip & Marker Handlers +// +// func configureTooltip(for marker: NMFMarker, stores: [MapPopUpStore]) { +// // 기존 툴팁 제거 +// currentTooltipView?.removeFromSuperview() +// +// let tooltipView = MarkerTooltipView() +// tooltipView.configure(with: stores) +// tooltipView.selectStore(at: 0) +// +// let markerPoint = mainView.mapView.projection.point(from: marker.position) +// let markerHeight: CGFloat = 32 +// +// tooltipView.frame = CGRect( +// x: markerPoint.x, +// y: markerPoint.y - markerHeight - tooltipView.frame.height - 14, +// width: tooltipView.frame.width, +// height: tooltipView.frame.height +// ) +// +// mainView.addSubview(tooltipView) +// currentTooltipView = tooltipView +// currentTooltipStores = stores +// currentTooltipCoordinate = marker.position +// } +// +// func updateTooltipPosition() { +// guard let marker = currentMarker, let tooltip = currentTooltipView else { return } +// let markerPoint = mainView.mapView.projection.point(from: marker.position) +// var markerCenter = markerPoint +// markerCenter.y -= 20 +// let offsetX: CGFloat = -10 +// let offsetY: CGFloat = -10 +// +// tooltip.frame.origin = CGPoint( +// x: markerCenter.x + offsetX, +// y: markerCenter.y - tooltip.frame.height - offsetY +// ) +// } +// +// // MARK: - Store Selection Handlers +// func handleSingleStoreTap(_ marker: NMFMarker, store: MapPopUpStore) -> Bool { +// isMovingToMarker = true +// +// if let previousMarker = currentMarker { +// updateMarkerStyle(marker: previousMarker, selected: false, isCluster: false) +// } +// +// updateMarkerStyle(marker: marker, selected: true, isCluster: false) +// currentMarker = marker +// +// if currentCarouselStores.isEmpty || !currentCarouselStores.contains(where: { $0.id == store.id }) { +// let bounds = getVisibleBounds() +// let visibleStores = currentStores.filter { store in +// let storePosition = NMGLatLng(lat: store.latitude, lng: store.longitude) +// return NMGLatLngBounds(southWest: bounds.southWest, northEast: bounds.northEast).contains(storePosition) +// } +// +// if !visibleStores.isEmpty { +// currentCarouselStores = visibleStores +// carouselView.updateCards(visibleStores) +// if let index = visibleStores.firstIndex(where: { $0.id == store.id }) { +// carouselView.scrollToCard(index: index) +// } +// } else { +// currentCarouselStores = [store] +// carouselView.updateCards([store]) +// } +// } else { +// if let index = currentCarouselStores.firstIndex(where: { $0.id == store.id }) { +// carouselView.scrollToCard(index: index) +// } +// } +// +// carouselView.isHidden = false +// mainView.setStoreCardHidden(false, animated: true) +// +// if let storeArray = marker.userInfo["storeData"] as? [MapPopUpStore], storeArray.count > 1 { +// configureTooltip(for: marker, stores: storeArray) +// if let index = storeArray.firstIndex(where: { $0.id == store.id }) { +// (currentTooltipView as? MarkerTooltipView)?.selectStore(at: index) +// } +// } else { +// currentTooltipView?.removeFromSuperview() +// currentTooltipView = nil +// } +// +// isMovingToMarker = false +// return true +// } +// +// func handleRegionalClusterTap(_ marker: NMFMarker, clusterData: ClusterMarkerData) -> Bool { +// let currentZoom = mainView.mapView.zoomLevel +// let currentLevel = MapZoomLevel.getLevel(from: Float(currentZoom)) +// +// switch currentLevel { +// case .city: +// let districtZoomLevel: Double = 10.0 +// let cameraUpdate = NMFCameraUpdate(scrollTo: marker.position, zoomTo: districtZoomLevel) +// cameraUpdate.animation = .easeIn +// cameraUpdate.animationDuration = 0.3 +// mainView.mapView.moveCamera(cameraUpdate) +// case .district: +// let detailedZoomLevel: Double = 12.0 +// let cameraUpdate = NMFCameraUpdate(scrollTo: marker.position, zoomTo: detailedZoomLevel) +// cameraUpdate.animation = .easeIn +// cameraUpdate.animationDuration = 0.3 +// mainView.mapView.moveCamera(cameraUpdate) +// default: +// break +// } +// +// updateMarkersForCluster(stores: clusterData.cluster.stores) +// carouselView.updateCards(clusterData.cluster.stores) +// carouselView.isHidden = false +// currentCarouselStores = clusterData.cluster.stores +// return true +// } +// +// +// func handleMicroClusterTap(_ marker: NMFMarker, storeArray: [MapPopUpStore]) -> Bool { +// if currentMarker == marker { +// currentTooltipView?.removeFromSuperview() +// currentTooltipView = nil +// currentTooltipStores = [] +// currentTooltipCoordinate = nil +// carouselView.isHidden = true +// carouselView.updateCards([]) +// currentCarouselStores = [] +// updateMarkerStyle(marker: marker, selected: false, isCluster: false, count: storeArray.count) +// currentMarker = nil +// isMovingToMarker = false +// return false +// } +// +// isMovingToMarker = true +// currentTooltipView?.removeFromSuperview() +// currentTooltipView = nil +// +// if let previousMarker = currentMarker { +// updateMarkerStyle(marker: previousMarker, selected: false, isCluster: false) +// } +// +// updateMarkerStyle(marker: marker, selected: true, isCluster: false, count: storeArray.count) +// currentMarker = marker +// +// currentCarouselStores = storeArray +// carouselView.updateCards(storeArray) +// carouselView.isHidden = false +// carouselView.scrollToCard(index: 0) +// +// mainView.setStoreCardHidden(false, animated: true) +// +// let cameraUpdate = NMFCameraUpdate(scrollTo: marker.position) +// cameraUpdate.animation = .easeIn +// cameraUpdate.animationDuration = 0.3 +// mainView.mapView.moveCamera(cameraUpdate) +// +// 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 +// } +// } +// +// return true +// } +// +// // MARK: - Toast Helper +// private func showNoMarkersToast() { +// Logger.log(message: "현재 지도 영역에 표시할 마커가 없습니다", category: .debug) +// } +// } diff --git a/Poppool/Poppool/Presentation/Scene/Map/MapView/MapInteractionHandling.swift b/Poppool/Poppool/Presentation/Scene/Map/MapView/MapInteractionHandling.swift new file mode 100644 index 00000000..2c77c464 --- /dev/null +++ b/Poppool/Poppool/Presentation/Scene/Map/MapView/MapInteractionHandling.swift @@ -0,0 +1,38 @@ +import NMapsMap +import SnapKit +import UIKit + +protocol MapInteractionHandling: AnyObject { + var mainView: MapView { get } + var currentMarker: NMFMarker? { get set } + var currentStores: [MapPopUpStore] { get set } + var currentCarouselStores: [MapPopUpStore] { get set } + var isMovingToMarker: Bool { get set } + var currentTooltipView: UIView? { get set } + var currentTooltipStores: [MapPopUpStore] { get set } + var currentTooltipCoordinate: NMGLatLng? { get set } + var individualMarkerDictionary: [Int64: NMFMarker] { get set } + var clusterMarkerDictionary: [String: NMFMarker] { get set } + var clusteringManager: ClusteringManager { get } + + func updateMarkerStyle(marker: NMFMarker, selected: Bool, isCluster: Bool, count: Int, regionName: String) + func handleSingleStoreTap(_ marker: NMFMarker, store: MapPopUpStore) -> Bool + func handleRegionalClusterTap(_ marker: NMFMarker, clusterData: ClusterMarkerData) -> Bool + func handleMicroClusterTap(_ marker: NMFMarker, storeArray: [MapPopUpStore]) -> Bool + + // 제스처 관련 메서드 + func handleMapViewTap(_ gesture: UITapGestureRecognizer) + func handlePanGesture(_ gesture: UIPanGestureRecognizer) + + // 툴팁 관련 메서드 + func configureTooltip(for marker: NMFMarker, stores: [MapPopUpStore]) + func updateTooltipPosition() + + // 헬퍼 메서드 + func getVisibleBounds() -> (northEast: NMGLatLng, southWest: NMGLatLng) + func updateMapWithClustering() + + func groupStoresByExactLocation(_ stores: [MapPopUpStore]) -> [CoordinateKey: [MapPopUpStore]] + func createClusterMarkerImage(regionName: String, count: Int) -> UIImage? + +} diff --git a/Poppool/Poppool/Presentation/Scene/Map/MapView/MapReactor.swift b/Poppool/Poppool/Presentation/Scene/Map/MapView/MapReactor.swift index b0e82322..fdbde5eb 100644 --- a/Poppool/Poppool/Presentation/Scene/Map/MapView/MapReactor.swift +++ b/Poppool/Poppool/Presentation/Scene/Map/MapView/MapReactor.swift @@ -350,15 +350,6 @@ final class MapReactor: Reactor { updatedStores.insert(selectedStore, at: 0) // 🔥 선택된 마커를 캐러셀의 첫 번째로 설정 } - Logger.log( - message: """ - Updated viewport stores: - - Total: \(updatedStores.count) - - Selected Store: \(state.selectedStore?.name ?? "None") - """, - category: .debug - ) - newState.viewportStores = updatedStores case let .setSelectedStore(store): diff --git a/Poppool/Poppool/Presentation/Scene/Map/MapView/MapSearchInput.swift b/Poppool/Poppool/Presentation/Scene/Map/MapView/MapSearchInput.swift index e5532286..6e91abe5 100644 --- a/Poppool/Poppool/Presentation/Scene/Map/MapView/MapSearchInput.swift +++ b/Poppool/Poppool/Presentation/Scene/Map/MapView/MapSearchInput.swift @@ -71,14 +71,12 @@ final class MapSearchInput: UIView, View { } func bind(reactor: MapReactor) { - // 텍스트필드 입력 로그 (필요 시 확인) - searchTextField.rx.text.orEmpty - .subscribe(onNext: { text in - print("[DEBUG] TextField Input: \(text)") - }) - .disposed(by: disposeBag) + /// TODO : 검색 재활성화시 사용 +// searchTextField.rx.text.orEmpty +// .subscribe(onNext: { _ in +// }) +// .disposed(by: disposeBag) - // Reactor의 선택된 위치 필터를 텍스트필드에 바인딩 (없으면 기본 문구) reactor.state .map { $0.selectedLocationFilters.first ?? "팝업스토어명을 입력해보세요" } .distinctUntilChanged() @@ -92,14 +90,12 @@ final class MapSearchInput: UIView, View { // MARK: - Gesture Setup private func setupGesture() { - // containerView에 탭 제스처를 추가하여 화면 전환 트리거 let tapGesture = UITapGestureRecognizer(target: self, action: #selector(handleTapGesture)) containerView.addGestureRecognizer(tapGesture) containerView.isUserInteractionEnabled = true } @objc private func handleTapGesture() { - // onSearch 클로저를 호출해서 화면 전환을 진행 onSearch?(searchTextField.text ?? "") } } diff --git a/Poppool/Poppool/Presentation/Scene/Map/MapView/MapUIHandling.swift b/Poppool/Poppool/Presentation/Scene/Map/MapView/MapUIHandling.swift new file mode 100644 index 00000000..fd7ab4a9 --- /dev/null +++ b/Poppool/Poppool/Presentation/Scene/Map/MapView/MapUIHandling.swift @@ -0,0 +1,26 @@ +import NMapsMap +import SnapKit +import UIKit + +/// 지도 UI 상태 및 애니메이션을 처리하는 프로토콜 +protocol MapUIHandling: AnyObject { + + var mainView: MapView { get } + var carouselView: MapPopupCarouselView { get } + var modalState: ModalState { get set } + var listViewTopConstraint: Constraint? { get } + var filterContainerBottomY: CGFloat { get } + + func animateToState(_ state: ModalState) + func adjustMapViewAlpha(for offset: CGFloat, minOffset: CGFloat, maxOffset: CGFloat) + func updateMapViewAlpha(for offset: CGFloat, minOffset: CGFloat, maxOffset: CGFloat) + func showNoMarkersToast() + func setStoreCardHidden(_ hidden: Bool, animated: Bool) + func updateMarkersForCluster(stores: [MapPopUpStore]) +} + +enum ModalState { + case top + case middle + case bottom +} diff --git a/Poppool/Poppool/Presentation/Scene/Map/MapView/MapViewController+InteractionHandling.swift b/Poppool/Poppool/Presentation/Scene/Map/MapView/MapViewController+InteractionHandling.swift new file mode 100644 index 00000000..333ab897 --- /dev/null +++ b/Poppool/Poppool/Presentation/Scene/Map/MapView/MapViewController+InteractionHandling.swift @@ -0,0 +1,426 @@ +import NMapsMap +import ReactorKit +import UIKit + +extension MapViewController: MapInteractionHandling { + + // MARK: - Marker Style Handler + func updateMarkerStyle( + marker: NMFMarker, + selected: Bool, + isCluster: Bool, + count: Int = 1, + regionName: String = "" + ) { + if selected { + marker.width = 44 + marker.height = 44 + marker.iconImage = NMFOverlayImage(name: "TapMarker") + } else if isCluster { + marker.width = 36 + marker.height = 36 + marker.iconImage = NMFOverlayImage(name: "cluster_marker") + } else { + marker.width = 32 + marker.height = 32 + marker.iconImage = NMFOverlayImage(name: "Marker") + } + + marker.captionText = "" + + marker.anchor = CGPoint(x: 0.5, y: 1.0) + + } + @objc func handleMapViewTap(_ gesture: UITapGestureRecognizer) { + guard !isMovingToMarker else { return } + + if let currentMarker = self.currentMarker { + updateMarkerStyle(marker: currentMarker, selected: false, isCluster: false) + self.currentMarker = nil + } + + currentTooltipView?.removeFromSuperview() + currentTooltipView = nil + currentTooltipStores = [] + currentTooltipCoordinate = nil + + // 캐러셀 및 스토어 카드 숨김 처리 + carouselView.isHidden = true + carouselView.updateCards([]) + currentCarouselStores = [] + mainView.setStoreCardHidden(true, animated: true) + + // 클러스터 업데이트 (필요 시 재설정) + updateMapWithClustering() + } + + // MARK: - Pan Gesture Handler + @objc func handlePanGesture(_ gesture: UIPanGestureRecognizer) { + let translation = gesture.translation(in: view) + let velocity = gesture.velocity(in: view) + + switch gesture.state { + case .changed: + if let constraint = listViewTopConstraint { + let currentOffset = constraint.layoutConstraints.first?.constant ?? 0 + let newOffset = currentOffset + translation.y + let minOffset: CGFloat = filterContainerBottomY // 필터 컨테이너 바닥 높이 + let maxOffset: CGFloat = view.frame.height // 최하단 위치 + let clampedOffset = min(max(newOffset, minOffset), maxOffset) + + constraint.update(offset: clampedOffset) + gesture.setTranslation(.zero, in: view) + + if modalState == .top { + adjustMapViewAlpha(for: clampedOffset, minOffset: minOffset, maxOffset: maxOffset) + } + } + case .ended: + if let constraint = listViewTopConstraint { + let currentOffset = constraint.layoutConstraints.first?.constant ?? 0 + let middleY = view.frame.height * 0.3 + let targetState: ModalState + + // 속도와 현재 오프셋에 따른 상태 결정 + if velocity.y > 500 { + targetState = .bottom + } else if velocity.y < -500 { + targetState = .top + } else if currentOffset < middleY * 0.7 { + targetState = .top + } else if currentOffset < view.frame.height * 0.7 { + targetState = .middle + } else { + targetState = .bottom + } + + animateToState(targetState) + } + default: + break + } + } + + // MARK: - Tooltip Handlers + func configureTooltip(for marker: NMFMarker, stores: [MapPopUpStore]) { + // 기존 툴팁 제거 + currentTooltipView?.removeFromSuperview() + + let tooltipView = MarkerTooltipView() + tooltipView.configure(with: stores) + tooltipView.selectStore(at: 0) + + let markerPoint = mainView.mapView.projection.point(from: marker.position) + let markerHeight: CGFloat = 32 + + tooltipView.frame = CGRect( + x: markerPoint.x, + y: markerPoint.y - markerHeight - tooltipView.frame.height - 14, + width: tooltipView.frame.width, + height: tooltipView.frame.height + ) + + tooltipView.onStoreSelected = { [weak self] index in + guard let self = self, index < stores.count else { return } + self.currentCarouselStores = stores + self.carouselView.updateCards(stores) + self.carouselView.scrollToCard(index: index) + + self.updateMarkerStyle(marker: marker, selected: true, isCluster: false, count: stores.count) + tooltipView.selectStore(at: index) + } + + mainView.addSubview(tooltipView) + currentTooltipView = tooltipView + currentTooltipStores = stores + currentTooltipCoordinate = marker.position + } + + func updateTooltipPosition() { + guard let marker = currentMarker, let tooltip = currentTooltipView else { return } + let markerPoint = mainView.mapView.projection.point(from: marker.position) + var markerCenter = markerPoint + markerCenter.y -= 20 + let offsetX: CGFloat = -10 + let offsetY: CGFloat = -10 + + tooltip.frame.origin = CGPoint( + x: markerCenter.x + offsetX, + y: markerCenter.y - tooltip.frame.height - offsetY + ) + } + + // MARK: - Store Selection Handlers + func handleSingleStoreTap(_ marker: NMFMarker, store: MapPopUpStore) -> Bool { + isMovingToMarker = true + + if let previousMarker = currentMarker { + updateMarkerStyle(marker: previousMarker, selected: false, isCluster: false) + } + + updateMarkerStyle(marker: marker, selected: true, isCluster: false) + currentMarker = marker + + if currentCarouselStores.isEmpty || !currentCarouselStores.contains(where: { $0.id == store.id }) { + let bounds = getVisibleBounds() + let visibleStores = currentStores.filter { store in + let storePosition = NMGLatLng(lat: store.latitude, lng: store.longitude) + return NMGLatLngBounds(southWest: bounds.southWest, northEast: bounds.northEast).contains(storePosition) + } + + if !visibleStores.isEmpty { + currentCarouselStores = visibleStores + carouselView.updateCards(visibleStores) + if let index = visibleStores.firstIndex(where: { $0.id == store.id }) { + carouselView.scrollToCard(index: index) + } + } else { + currentCarouselStores = [store] + carouselView.updateCards([store]) + } + } else { + if let index = currentCarouselStores.firstIndex(where: { $0.id == store.id }) { + carouselView.scrollToCard(index: index) + } + } + + carouselView.isHidden = false + mainView.setStoreCardHidden(false, animated: true) + + if let storeArray = marker.userInfo["storeData"] as? [MapPopUpStore], storeArray.count > 1 { + configureTooltip(for: marker, stores: storeArray) + if let index = storeArray.firstIndex(where: { $0.id == store.id }) { + (currentTooltipView as? MarkerTooltipView)?.selectStore(at: index) + } + } else { + currentTooltipView?.removeFromSuperview() + currentTooltipView = nil + } + + isMovingToMarker = false + return true + } + + func handleRegionalClusterTap(_ marker: NMFMarker, clusterData: ClusterMarkerData) -> Bool { + let currentZoom = mainView.mapView.zoomLevel + let currentLevel = MapZoomLevel.getLevel(from: Float(currentZoom)) + + switch currentLevel { + case .city: + let districtZoomLevel: Double = 10.0 + let cameraUpdate = NMFCameraUpdate(scrollTo: marker.position, zoomTo: districtZoomLevel) + cameraUpdate.animation = .easeIn + cameraUpdate.animationDuration = 0.3 + mainView.mapView.moveCamera(cameraUpdate) + case .district: + let detailedZoomLevel: Double = 12.0 + let cameraUpdate = NMFCameraUpdate(scrollTo: marker.position, zoomTo: detailedZoomLevel) + cameraUpdate.animation = .easeIn + cameraUpdate.animationDuration = 0.3 + mainView.mapView.moveCamera(cameraUpdate) + default: + break + } + + updateMarkersForCluster(stores: clusterData.cluster.stores) + carouselView.updateCards(clusterData.cluster.stores) + carouselView.isHidden = false + currentCarouselStores = clusterData.cluster.stores + return true + } + + func handleMicroClusterTap(_ marker: NMFMarker, storeArray: [MapPopUpStore]) -> Bool { + if currentMarker == marker { + currentTooltipView?.removeFromSuperview() + currentTooltipView = nil + currentTooltipStores = [] + currentTooltipCoordinate = nil + carouselView.isHidden = true + carouselView.updateCards([]) + currentCarouselStores = [] + updateMarkerStyle(marker: marker, selected: false, isCluster: false, count: storeArray.count) + currentMarker = nil + isMovingToMarker = false + return false + } + + isMovingToMarker = true + currentTooltipView?.removeFromSuperview() + currentTooltipView = nil + + if let previousMarker = currentMarker { + updateMarkerStyle(marker: previousMarker, selected: false, isCluster: false) + } + + updateMarkerStyle(marker: marker, selected: true, isCluster: false, count: storeArray.count) + currentMarker = marker + + currentCarouselStores = storeArray + carouselView.updateCards(storeArray) + carouselView.isHidden = false + carouselView.scrollToCard(index: 0) + + mainView.setStoreCardHidden(false, animated: true) + + let cameraUpdate = NMFCameraUpdate(scrollTo: marker.position) + cameraUpdate.animation = .easeIn + cameraUpdate.animationDuration = 0.3 + mainView.mapView.moveCamera(cameraUpdate) + + 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 + } + } + + return true + } + + func getVisibleBounds() -> (northEast: NMGLatLng, southWest: NMGLatLng) { + let mapBounds = mainView.mapView.contentBounds + let northEast = NMGLatLng(lat: mapBounds.northEastLat, lng: mapBounds.northEastLng) + let southWest = NMGLatLng(lat: mapBounds.southWestLat, lng: mapBounds.southWestLng) + return (northEast: northEast, southWest: southWest) + } + + func updateMapWithClustering() { + let currentZoom = mainView.mapView.zoomLevel + let level = MapZoomLevel.getLevel(from: Float(currentZoom)) + + CATransaction.begin() + CATransaction.setDisableActions(true) + + switch level { + case .detailed: + // 상세 레벨에서는 개별 마커를 사용합니다. + let newStoreIds = Set(currentStores.map { $0.id }) + let groupedDict = groupStoresByExactLocation(currentStores) + + // 클러스터 마커 제거 + clusterMarkerDictionary.values.forEach { $0.mapView = nil } + clusterMarkerDictionary.removeAll() + + // 그룹별로 개별 마커 생성/업데이트 + for (coordinate, storeGroup) in groupedDict { + if storeGroup.count == 1, let store = storeGroup.first { + if let existingMarker = individualMarkerDictionary[store.id] { + if existingMarker.position.lat != store.latitude || + existingMarker.position.lng != store.longitude { + existingMarker.position = NMGLatLng(lat: store.latitude, lng: store.longitude) + } + let isSelected = (existingMarker == currentMarker) + updateMarkerStyle(marker: existingMarker, selected: isSelected, isCluster: false) + } else { + let marker = NMFMarker() + marker.position = NMGLatLng(lat: store.latitude, lng: store.longitude) + marker.userInfo = ["storeData": store] + marker.anchor = CGPoint(x: 0.5, y: 1.0) + updateMarkerStyle(marker: marker, selected: false, isCluster: false) + + // 직접 터치 핸들러 추가 + marker.touchHandler = { [weak self] overlay in + guard let self = self, let tappedMarker = overlay as? NMFMarker else { return false } + return self.handleSingleStoreTap(tappedMarker, store: store) + } + + marker.mapView = mainView.mapView + individualMarkerDictionary[store.id] = marker + } + } else { + // 여러 스토어가 동일 위치에 있으면 단일 마커로 표시하면서 count 갱신 + guard let firstStore = storeGroup.first else { continue } + let markerKey = firstStore.id + if let existingMarker = individualMarkerDictionary[markerKey] { + existingMarker.userInfo = ["storeData": storeGroup] + let isSelected = (existingMarker == currentMarker) + updateMarkerStyle(marker: existingMarker, selected: isSelected, isCluster: false, count: storeGroup.count) + } else { + let marker = NMFMarker() + marker.position = NMGLatLng(lat: firstStore.latitude, lng: firstStore.longitude) + marker.userInfo = ["storeData": storeGroup] + marker.anchor = CGPoint(x: 0.5, y: 1.0) + updateMarkerStyle(marker: marker, selected: false, isCluster: false, count: storeGroup.count) + + // 직접 터치 핸들러 추가 + marker.touchHandler = { [weak self] overlay in + guard let self = self, let tappedMarker = overlay as? NMFMarker else { return false } + return self.handleMicroClusterTap(tappedMarker, storeArray: storeGroup) + } + + marker.mapView = mainView.mapView + individualMarkerDictionary[markerKey] = marker + } + } + } + + // 기존에 보이지 않는 개별 마커 제거 + individualMarkerDictionary = individualMarkerDictionary.filter { id, marker in + if newStoreIds.contains(id) { + return true + } else { + marker.mapView = nil + return false + } + } + + case .district, .city, .country: + // 개별 마커 숨기기 + individualMarkerDictionary.values.forEach { $0.mapView = nil } + individualMarkerDictionary.removeAll() + + // 클러스터 생성 + let clusters = clusteringManager.clusterStores(currentStores, at: Float(currentZoom)) + let activeClusterKeys = Set(clusters.map { $0.cluster.name }) + + for cluster in clusters { + let clusterKey = cluster.cluster.name + var marker: NMFMarker + if let existingMarker = clusterMarkerDictionary[clusterKey] { + marker = existingMarker + if marker.position.lat != cluster.cluster.coordinate.lat || + marker.position.lng != cluster.cluster.coordinate.lng { + marker.position = NMGLatLng(lat: cluster.cluster.coordinate.lat, lng: cluster.cluster.coordinate.lng) + } + } else { + marker = NMFMarker() + clusterMarkerDictionary[clusterKey] = marker + } + + marker.position = NMGLatLng(lat: cluster.cluster.coordinate.lat, lng: cluster.cluster.coordinate.lng) + marker.userInfo = ["clusterData": cluster] + + if let clusterImage = createClusterMarkerImage(regionName: cluster.cluster.name, count: cluster.storeCount) { + marker.iconImage = NMFOverlayImage(image: clusterImage) + } else { + marker.iconImage = NMFOverlayImage(name: "cluster_marker") + } + + marker.touchHandler = { [weak self] (overlay) -> Bool in + guard let self = self, + let tappedMarker = overlay as? NMFMarker, + let clusterData = tappedMarker.userInfo["clusterData"] as? ClusterMarkerData else { + return false + } + + return self.handleRegionalClusterTap(tappedMarker, clusterData: clusterData) + } + + marker.captionText = "" + marker.anchor = CGPoint(x: 0.5, y: 0.5) + marker.mapView = mainView.mapView + } + + // 활성 클러스터가 아닌 마커 제거 + for (key, marker) in clusterMarkerDictionary { + if !activeClusterKeys.contains(key) { + marker.mapView = nil + clusterMarkerDictionary.removeValue(forKey: key) + } + } + } + + CATransaction.commit() + } +} diff --git a/Poppool/Poppool/Presentation/Scene/Map/MapView/MapViewController+UIHandling.swift b/Poppool/Poppool/Presentation/Scene/Map/MapView/MapViewController+UIHandling.swift new file mode 100644 index 00000000..01a00f81 --- /dev/null +++ b/Poppool/Poppool/Presentation/Scene/Map/MapView/MapViewController+UIHandling.swift @@ -0,0 +1,118 @@ +import NMapsMap +import ReactorKit +import UIKit + +extension MapViewController { + + // MARK: - Map Alpha Handlers + func adjustMapViewAlpha(for offset: CGFloat, minOffset: CGFloat, maxOffset: CGFloat) { + let middleOffset = view.frame.height * 0.3 + if offset <= minOffset { + mainView.mapView.alpha = 0 // 완전히 숨김 + } else if offset >= maxOffset { + mainView.mapView.alpha = 1 // 완전히 보임 + } else if offset <= middleOffset { + let progress = (offset - minOffset) / (middleOffset - minOffset) + mainView.mapView.alpha = progress + } else { + mainView.mapView.alpha = 1 + } + } + + func updateMapViewAlpha(for offset: CGFloat, minOffset: CGFloat, maxOffset: CGFloat) { + let progress = (maxOffset - offset) / (maxOffset - minOffset) + mainView.mapView.alpha = max(0, min(progress, 1)) + } + + // MARK: - Modal Animation Handler + func animateToState(_ state: ModalState) { + guard modalState != state else { return } + self.view.layoutIfNeeded() + UIView.animate(withDuration: 0.3, animations: { + switch state { + case .top: + let filterChipsFrame = self.mainView.filterChips.convert(self.mainView.filterChips.bounds, to: self.view) + self.mainView.mapView.alpha = 0 + self.storeListViewController.setGrabberHandleVisible(false) + self.listViewTopConstraint?.update(offset: filterChipsFrame.maxY) + self.mainView.searchInput.setBackgroundColor(.g50) + + case .middle: + self.storeListViewController.setGrabberHandleVisible(true) + let offset = max(self.view.frame.height * 0.3, self.filterContainerBottomY) + self.listViewTopConstraint?.update(offset: offset) + self.storeListViewController.mainView.layer.cornerRadius = 20 + self.storeListViewController.mainView.layer.maskedCorners = [.layerMinXMinYCorner, .layerMaxXMinYCorner] + self.mainView.mapView.alpha = 1 + self.mainView.mapView.isHidden = false + self.mainView.searchInput.setBackgroundColor(.white) + + if let reactor = self.reactor { + reactor.action.onNext(.fetchAllStores) + reactor.state + .map { $0.viewportStores } + .distinctUntilChanged() + .filter { !$0.isEmpty } + .take(1) + .observe(on: MainScheduler.instance) + .subscribe(onNext: { [weak self] stores in + self?.fetchStoreDetails(for: stores) + }) + .disposed(by: self.disposeBag) + } + + case .bottom: + self.storeListViewController.setGrabberHandleVisible(true) + self.listViewTopConstraint?.update(offset: self.view.frame.height) + self.mainView.mapView.alpha = 1 + self.mainView.mapView.isHidden = false + self.mainView.searchInput.setBackgroundColor(.white) + } + self.view.layoutIfNeeded() + }) { _ in + self.modalState = state + } + } + + // MARK: - Helper Methods + func setStoreCardHidden(_ hidden: Bool, animated: Bool) { + mainView.setStoreCardHidden(hidden, animated: animated) + } + + func updateMarkersForCluster(stores: [MapPopUpStore]) { + for marker in individualMarkerDictionary.values { + marker.mapView = nil + } + individualMarkerDictionary.removeAll() + + for marker in clusterMarkerDictionary.values { + marker.mapView = nil + } + clusterMarkerDictionary.removeAll() + + for store in stores { + let marker = NMFMarker() + marker.position = NMGLatLng(lat: store.latitude, lng: store.longitude) + marker.userInfo = ["storeData": store] + marker.anchor = CGPoint(x: 0.5, y: 1.0) + + updateMarkerStyle(marker: marker, selected: false, isCluster: false) + + marker.touchHandler = { [weak self] overlay in + guard let self = self, + let tappedMarker = overlay as? NMFMarker, + let storeData = tappedMarker.userInfo["storeData"] as? MapPopUpStore + else { return false } + return self.handleSingleStoreTap(tappedMarker, store: storeData) + } + + marker.mapView = mainView.mapView + individualMarkerDictionary[store.id] = marker + } + } + + // MARK: - Toast Helper + func showNoMarkersToast() { + Logger.log(message: "현재 지도 영역에 표시할 마커가 없습니다", category: .debug) + } +} diff --git a/Poppool/Poppool/Presentation/Scene/Map/MapView/MapViewController.swift b/Poppool/Poppool/Presentation/Scene/Map/MapView/MapViewController.swift index e327b463..6ac48c13 100644 --- a/Poppool/Poppool/Presentation/Scene/Map/MapView/MapViewController.swift +++ b/Poppool/Poppool/Presentation/Scene/Map/MapView/MapViewController.swift @@ -8,47 +8,52 @@ import RxSwift import SnapKit import UIKit -class MapViewController: BaseViewController, View, CLLocationManagerDelegate, NMFMapViewTouchDelegate, NMFMapViewCameraDelegate, UIGestureRecognizerDelegate { - typealias Reactor = MapReactor - - fileprivate struct CoordinateKey: Hashable { - let lat: Int - let lng: Int +struct CoordinateKey: Hashable { + let lat: Int + let lng: Int - init(latitude: Double, longitude: Double) { - self.lat = Int(latitude * 1_000_00) - self.lng = Int(longitude * 1_000_00) - } + init(latitude: Double, longitude: Double) { + self.lat = Int(latitude * 1_000_00) + self.lng = Int(longitude * 1_000_00) } +} + +class MapViewController: BaseViewController, View, + CLLocationManagerDelegate, + NMFMapViewTouchDelegate, + NMFMapViewCameraDelegate, + UIGestureRecognizerDelegate { + typealias Reactor = MapReactor var currentTooltipView: UIView? var currentTooltipStores: [MapPopUpStore] = [] var currentTooltipCoordinate: NMGLatLng? // MARK: - Properties + var markerStyler: MarkerStyling = DefaultMarkerStyler() private var storeDetailsCache: [Int64: StoreItem] = [:] var isMovingToMarker = false var currentCarouselStores: [MapPopUpStore] = [] - private var markerDictionary: [Int64: NMFMarker] = [:] - private var individualMarkerDictionary: [Int64: NMFMarker] = [:] - private var clusterMarkerDictionary: [String: NMFMarker] = [:] + var markerDictionary: [Int64: NMFMarker] = [:] + var individualMarkerDictionary: [Int64: NMFMarker] = [:] + var clusterMarkerDictionary: [String: NMFMarker] = [:] private let popUpAPIUseCase = PopUpAPIUseCaseImpl( repository: PopUpAPIRepositoryImpl(provider: ProviderImpl())) - private let clusteringManager = ClusteringManager() + var clusteringManager = ClusteringManager() var currentStores: [MapPopUpStore] = [] var disposeBag = DisposeBag() let mainView = MapView() let carouselView = MapPopupCarouselView() private let locationManager = CLLocationManager() var currentMarker: NMFMarker? - private let storeListReactor = StoreListReactor() - private let storeListViewController = StoreListViewController(reactor: StoreListReactor()) - private var listViewTopConstraint: Constraint? +// private let storeListReactor = StoreListReactor() + var storeListViewController = StoreListViewController(reactor: StoreListReactor()) + var listViewTopConstraint: Constraint? private var currentFilterBottomSheet: FilterBottomSheetViewController? private var filterChipsTopY: CGFloat = 0 - private var filterContainerBottomY: CGFloat { + var filterContainerBottomY: CGFloat { let frameInView = mainView.filterChips.convert(mainView.filterChips.bounds, to: view) - return frameInView.maxY // 필터 컨테이너의 바닥 높이 + return frameInView.maxY } enum ModalState { @@ -57,7 +62,7 @@ class MapViewController: BaseViewController, View, CLLocationManagerDelegate, NM case bottom } - private var modalState: ModalState = .bottom + var modalState: ModalState = .bottom private let idleSubject = PublishSubject() // MARK: - Lifecycle @@ -89,7 +94,6 @@ class MapViewController: BaseViewController, View, CLLocationManagerDelegate, NM if let reactor = self.reactor { reactor.action.onNext(.fetchCategories) - // 한국 전체 영역에 대한 경계값 설정 let koreaRegion = ( northEast: NMGLatLng(lat: 38.0, lng: 132.0), southWest: NMGLatLng(lat: 33.0, lng: 124.0) @@ -193,53 +197,6 @@ class MapViewController: BaseViewController, View, CLLocationManagerDelegate, NM .disposed(by: disposeBag) } - private func configureTooltip(for marker: NMFMarker, stores: [MapPopUpStore]) { - Logger.log(message: """ - 툴팁 설정: - - 현재 캐러셀 스토어: \(currentCarouselStores.map { $0.name }) - - 마커 스토어: \(stores.map { $0.name }) - """, category: .debug) - - // 기존 툴팁 제거 - self.currentTooltipView?.removeFromSuperview() - - let tooltipView = MarkerTooltipView() - tooltipView.configure(with: stores) - - tooltipView.selectStore(at: 0) - - tooltipView.onStoreSelected = { [weak self] index in - guard let self = self, index < stores.count else { return } - self.currentCarouselStores = stores - self.carouselView.updateCards(stores) - self.carouselView.scrollToCard(index: index) - - self.updateMarkerStyle(marker: marker, selected: true, isCluster: false, count: stores.count) - tooltipView.selectStore(at: index) - - Logger.log(message: """ - 툴팁 선택: - - 선택된 스토어: \(stores[index].name) - - 툴팁 인덱스: \(index) - """, category: .debug) - } - - let markerPoint = self.mainView.mapView.projection.point(from: marker.position) - let markerHeight: CGFloat = 32 - - tooltipView.frame = CGRect( - x: markerPoint.x, - y: markerPoint.y - markerHeight - tooltipView.frame.height - 14, - width: tooltipView.frame.width, - height: tooltipView.frame.height - ) - - self.mainView.addSubview(tooltipView) - self.currentTooltipView = tooltipView - self.currentTooltipStores = stores - self.currentTooltipCoordinate = marker.position - } - // MARK: - Setup private func setUp() { view.addSubview(mainView) @@ -272,8 +229,7 @@ class MapViewController: BaseViewController, View, CLLocationManagerDelegate, NM setupPanAndSwipeGestures() let mapViewTapGesture = UITapGestureRecognizer(target: self, action: #selector(handleMapViewTap(_:))) -// mapViewTapGesture.cancelsTouchesInView = false // 중요: 다른 터치 이벤트를 방해하지 않음 - mapViewTapGesture.delaysTouchesBegan = false // 터치 지연 없음 + mapViewTapGesture.delaysTouchesBegan = false mainView.mapView.addGestureRecognizer(mapViewTapGesture) mapViewTapGesture.delegate = self } @@ -284,7 +240,6 @@ class MapViewController: BaseViewController, View, CLLocationManagerDelegate, NM .skip(1) .withUnretained(self) .subscribe { owner, _ in - Logger.log(message: "⬆️ 위로 스와이프 감지", category: .debug) switch owner.modalState { case .bottom: owner.animateToState(.middle) @@ -300,7 +255,6 @@ class MapViewController: BaseViewController, View, CLLocationManagerDelegate, NM .skip(1) .withUnretained(self) .subscribe { owner, _ in - Logger.log(message: "⬇️ 아래로 스와이프 감지됨", category: .debug) switch owner.modalState { case .top: owner.animateToState(.middle) @@ -330,7 +284,7 @@ class MapViewController: BaseViewController, View, CLLocationManagerDelegate, NM mainView.listButton.rx.tap .withUnretained(self) .subscribe { owner, _ in - owner.animateToState(.middle) // 버튼 눌렀을 때 상태를 middle로 변경 + owner.animateToState(.middle) } .disposed(by: disposeBag) @@ -382,7 +336,6 @@ class MapViewController: BaseViewController, View, CLLocationManagerDelegate, NM // 필터 제거 액션 self.reactor?.action.onNext(.clearFilters(.category)) - // 현재 뷰포트의 바운드로 마커 업데이트 요청 let bounds = self.getVisibleBounds() self.reactor?.action.onNext(.viewportChanged( northEastLat: bounds.northEast.lat, @@ -402,7 +355,6 @@ class MapViewController: BaseViewController, View, CLLocationManagerDelegate, NM reactor.state.map { $0.selectedLocationFilters }.distinctUntilChanged(), reactor.state.map { $0.selectedCategoryFilters }.distinctUntilChanged() ) { locationFilters, categoryFilters -> (String, String) in - // 지역 필터 텍스트 포맷팅 let locationText: String if locationFilters.isEmpty { locationText = "지역선택" @@ -411,8 +363,6 @@ class MapViewController: BaseViewController, View, CLLocationManagerDelegate, NM } else { locationText = locationFilters[0] } - - // 카테고리 필터 텍스트 포맷팅 let categoryText: String if categoryFilters.isEmpty { categoryText = "카테고리" @@ -482,14 +432,12 @@ class MapViewController: BaseViewController, View, CLLocationManagerDelegate, NM .bind { [weak self] results in guard let self = self else { return } - // 이전 선택된 마커, 툴팁, 캐러셀 초기화 self.clearAllMarkers() self.storeListViewController.reactor?.action.onNext(.setStores([])) self.carouselView.updateCards([]) self.carouselView.isHidden = true self.resetSelectedMarker() // 추가된 부분 - // 결과가 없으면 스토어 카드 숨김 후 종료 if results.isEmpty { self.mainView.setStoreCardHidden(true, animated: true) return @@ -543,184 +491,19 @@ class MapViewController: BaseViewController, View, CLLocationManagerDelegate, NM // 마커 스타일 설정 updateMarkerStyle(marker: marker, selected: false, isCluster: false, count: 1) - // 중요: 마커에 직접 터치 핸들러 추가 - marker.touchHandler = { [weak self] (_) -> Bool in - guard let self = self else { return false } - - Logger.log(message: "마커 터치됨! 위치: \(marker.position), 스토어: \(store.name)", category: .debug) - - // 단일 스토어 마커 처리 - return self.handleSingleStoreTap(marker, store: store) + // 수정 + marker.touchHandler = { [weak self] overlay in + guard let self = self, + let tappedMarker = overlay as? NMFMarker, + let storeData = tappedMarker.userInfo["storeData"] as? MapPopUpStore + else { return false } + return self.handleSingleStoreTap(tappedMarker, store: storeData) } marker.mapView = mainView.mapView markerDictionary[store.id] = marker } - func updateMarkerStyle(marker: NMFMarker, selected: Bool, isCluster: Bool, count: Int = 1, regionName: String = "") { - if selected { - marker.width = 44 - marker.height = 44 - marker.iconImage = NMFOverlayImage(name: "TapMarker") - } else if isCluster { - marker.width = 36 - marker.height = 36 - marker.iconImage = NMFOverlayImage(name: "cluster_marker") - } else { - marker.width = 32 - marker.height = 32 - marker.iconImage = NMFOverlayImage(name: "Marker") - } - - if count > 1 { - marker.captionText = "\(count)" - } else { - marker.captionText = "" - } - - marker.anchor = CGPoint(x: 0.5, y: 1.0) - } - - @objc private func handleMapViewTap(_ gesture: UITapGestureRecognizer) { - // 리스트뷰가 현재 보이는 상태(중간 또는 상단)일 때만 내림 - if modalState == .middle || modalState == .top { - Logger.log(message: "맵뷰 탭 감지: 리스트뷰 내림", category: .debug) - animateToState(.bottom) - } - } - - @objc private func handlePanGesture(_ gesture: UIPanGestureRecognizer) { - let translation = gesture.translation(in: view) - let velocity = gesture.velocity(in: view) - - switch gesture.state { - case .changed: - if let constraint = listViewTopConstraint { - let currentOffset = constraint.layoutConstraints.first?.constant ?? 0 - let newOffset = currentOffset + translation.y - - // 오프셋 제한 범위 설정 - let minOffset: CGFloat = filterContainerBottomY // 필터 컨테이너 바닥 제한 - let maxOffset: CGFloat = view.frame.height // 최하단 제한 - let clampedOffset = min(max(newOffset, minOffset), maxOffset) - - constraint.update(offset: clampedOffset) - gesture.setTranslation(.zero, in: view) - - if modalState == .top { - adjustMapViewAlpha(for: clampedOffset, minOffset: minOffset, maxOffset: maxOffset) - } - } - - case .ended: - if let constraint = listViewTopConstraint { - let currentOffset = constraint.layoutConstraints.first?.constant ?? 0 - let middleY = view.frame.height * 0.3 // 중간 지점 기준 높이 - let targetState: ModalState - - // 속도와 위치를 기반으로 상태 결정 - if velocity.y > 500 { // 아래로 빠르게 드래그 - targetState = .bottom - } else if velocity.y < -500 { // 위로 빠르게 드래그 - targetState = .top - } else if currentOffset < middleY * 0.7 { - targetState = .top - } else if currentOffset < view.frame.height * 0.7 { - targetState = .middle - } else { - targetState = .bottom - } - - animateToState(targetState) - } - - default: - break - } - } - - private func adjustMapViewAlpha(for offset: CGFloat, minOffset: CGFloat, maxOffset: CGFloat) { - let middleOffset = view.frame.height * 0.3 - - if offset <= minOffset { - mainView.mapView.alpha = 0 // 탑에서는 완전히 숨김 - } else if offset >= maxOffset { - mainView.mapView.alpha = 1 // 바텀에서는 완전히 보임 - } else if offset <= middleOffset { - let progress = (offset - minOffset) / (middleOffset - minOffset) - mainView.mapView.alpha = progress - } else { - mainView.mapView.alpha = 1 - } - } - - private func updateMapViewAlpha(for offset: CGFloat, minOffset: CGFloat, maxOffset: CGFloat) { - let progress = (maxOffset - offset) / (maxOffset - minOffset) - mainView.mapView.alpha = max(0, min(progress, 1)) - } - - private func animateToState(_ state: ModalState) { - guard modalState != state else { return } - self.view.layoutIfNeeded() - - UIView.animate(withDuration: 0.3, animations: { - switch state { - case .top: - let filterChipsFrame = self.mainView.filterChips.convert( - self.mainView.filterChips.bounds, - to: self.view - ) - self.mainView.mapView.alpha = 0 // 탑 상태에서는 숨김 - self.storeListViewController.setGrabberHandleVisible(false) - self.listViewTopConstraint?.update(offset: filterChipsFrame.maxY) - self.mainView.searchInput.setBackgroundColor(.g50) - - case .middle: - self.storeListViewController.setGrabberHandleVisible(true) - let offset = max(self.view.frame.height * 0.3, self.filterContainerBottomY) - self.listViewTopConstraint?.update(offset: offset) - self.storeListViewController.mainView.layer.cornerRadius = 20 - self.storeListViewController.mainView.layer.maskedCorners = [.layerMinXMinYCorner, .layerMaxXMinYCorner] - self.mainView.mapView.alpha = 1 - self.mainView.mapView.isHidden = false - self.mainView.searchInput.setBackgroundColor(.white) - - if let reactor = self.reactor { - reactor.action.onNext(.fetchAllStores) - - reactor.state - .map { $0.viewportStores } - .distinctUntilChanged() - .filter { !$0.isEmpty } - .take(1) - .observe(on: MainScheduler.instance) - .subscribe(onNext: { [weak self] stores in - guard let self = self else { return } - self.fetchStoreDetails(for: stores) - - Logger.log( - message: "✅ 전체 스토어 목록으로 리스트뷰 업데이트: \(stores.count)개", - category: .debug - ) - }) - .disposed(by: self.disposeBag) - } - - case .bottom: - self.storeListViewController.setGrabberHandleVisible(true) - self.listViewTopConstraint?.update(offset: self.view.frame.height) - self.mainView.mapView.alpha = 1 - self.mainView.mapView.isHidden = false - self.mainView.searchInput.setBackgroundColor(.white) - } - - self.view.layoutIfNeeded() - }) { _ in - self.modalState = state - Logger.log(message: ". 현재 상태: \(state)", category: .debug) - } - } - func imageFromView(_ view: UIView) -> UIImage? { UIGraphicsBeginImageContextWithOptions(view.bounds.size, false, UIScreen.main.scale) defer { UIGraphicsEndImageContext() } @@ -733,170 +516,19 @@ class MapViewController: BaseViewController, View, CLLocationManagerDelegate, NM // MARK: - Helper: 클러스터용 커스텀 마커 이미지 생성 (MapMarker를 사용) func createClusterMarkerImage(regionName: String, count: Int) -> UIImage? { - // MapMarker의 입력값에 클러스터 상태를 전달합니다. - let markerView = MapMarker() // 기존 커스텀 뷰, 네이버맵용으로도 사용 가능하도록 구현됨. + let markerView = MapMarker() let input = MapMarker.Input(isSelected: false, isCluster: true, regionName: regionName, count: count, isMultiMarker: false) markerView.injection(with: input) - // 프레임이 설정되어 있지 않다면 적당한 크기로 지정 (예: 80x32) if markerView.frame == .zero { markerView.frame = CGRect(x: 0, y: 0, width: 80, height: 32) } return imageFromView(markerView) } // MARK: - Clustering - private func updateMapWithClustering() { - let currentZoom = mainView.mapView.zoomLevel - let level = MapZoomLevel.getLevel(from: Float(currentZoom)) - // 클러스터 처리 시 현재 스토어 목록(currentStores)을 사용 - Logger.log(message: "현재 줌 레벨: \(currentZoom), 모드: \(level), 스토어 수: \(currentStores.count)", category: .debug) - - CATransaction.begin() - CATransaction.setDisableActions(true) - - switch level { - case .detailed: - // 상세 레벨에서는 개별 마커를 사용합니다. - let newStoreIds = Set(currentStores.map { $0.id }) - let groupedDict = groupStoresByExactLocation(currentStores) - - // 클러스터 마커 제거 - clusterMarkerDictionary.values.forEach { $0.mapView = nil } - clusterMarkerDictionary.removeAll() - - // 그룹별로 개별 마커 생성/업데이트 - for (coordinate, storeGroup) in groupedDict { - if storeGroup.count == 1, let store = storeGroup.first { - if let existingMarker = individualMarkerDictionary[store.id] { - if existingMarker.position.lat != store.latitude || - existingMarker.position.lng != store.longitude { - existingMarker.position = NMGLatLng(lat: store.latitude, lng: store.longitude) - } - let isSelected = (existingMarker == currentMarker) - updateMarkerStyle(marker: existingMarker, selected: isSelected, isCluster: false) - } else { - let marker = NMFMarker() - marker.position = NMGLatLng(lat: store.latitude, lng: store.longitude) - marker.userInfo = ["storeData": store] - marker.anchor = CGPoint(x: 0.5, y: 1.0) - updateMarkerStyle(marker: marker, selected: false, isCluster: false) - - // 직접 터치 핸들러 추가 - marker.touchHandler = { [weak self] (_) -> Bool in - guard let self = self else { return false } - - print("개별 마커 터치됨! 스토어: \(store.name)") - return self.handleSingleStoreTap(marker, store: store) - } - - marker.mapView = mainView.mapView - individualMarkerDictionary[store.id] = marker - } - } else { - // 여러 스토어가 동일 위치에 있으면 단일 마커로 표시하면서 count 갱신 - guard let firstStore = storeGroup.first else { continue } - let markerKey = firstStore.id - if let existingMarker = individualMarkerDictionary[markerKey] { - existingMarker.userInfo = ["storeData": storeGroup] - let isSelected = (existingMarker == currentMarker) - updateMarkerStyle(marker: existingMarker, selected: isSelected, isCluster: false, count: storeGroup.count) - } else { - let marker = NMFMarker() - marker.position = NMGLatLng(lat: firstStore.latitude, lng: firstStore.longitude) - marker.userInfo = ["storeData": storeGroup] - marker.anchor = CGPoint(x: 0.5, y: 1.0) - updateMarkerStyle(marker: marker, selected: false, isCluster: false, count: storeGroup.count) - - // 직접 터치 핸들러 추가 - marker.touchHandler = { [weak self] (_) -> Bool in - guard let self = self else { return false } - - print("마이크로 클러스터 마커 터치됨! 스토어 수: \(storeGroup.count)개") - return self.handleMicroClusterTap(marker, storeArray: storeGroup) - } - - marker.mapView = mainView.mapView - individualMarkerDictionary[markerKey] = marker - } - } - } - - // 기존에 보이지 않는 개별 마커 제거 - individualMarkerDictionary = individualMarkerDictionary.filter { id, marker in - if newStoreIds.contains(id) { - return true - } else { - marker.mapView = nil - return false - } - } - - case .district, .city, .country: - // 개별 마커 숨기기 - individualMarkerDictionary.values.forEach { $0.mapView = nil } - individualMarkerDictionary.removeAll() - - // 클러스터 생성 - let clusters = clusteringManager.clusterStores(currentStores, at: Float(currentZoom)) - let activeClusterKeys = Set(clusters.map { $0.cluster.name }) - - for cluster in clusters { - let clusterKey = cluster.cluster.name - var marker: NMFMarker - if let existingMarker = clusterMarkerDictionary[clusterKey] { - marker = existingMarker - // 위치 업데이트가 필요하면 수정 - if marker.position.lat != cluster.cluster.coordinate.lat || - marker.position.lng != cluster.cluster.coordinate.lng { - marker.position = NMGLatLng(lat: cluster.cluster.coordinate.lat, lng: cluster.cluster.coordinate.lng) - } - } else { - marker = NMFMarker() - clusterMarkerDictionary[clusterKey] = marker - } - - marker.position = NMGLatLng(lat: cluster.cluster.coordinate.lat, lng: cluster.cluster.coordinate.lng) - marker.userInfo = ["clusterData": cluster] // 중요: userInfo에 cluster 객체를 직접 저장 - - // 여기서 커스텀 클러스터 마커 뷰를 이미지로 변환하여 적용합니다. - if let clusterImage = createClusterMarkerImage(regionName: cluster.cluster.name, count: cluster.storeCount) { - marker.iconImage = NMFOverlayImage(image: clusterImage) - } else { - // 기본 에셋 fallback (원하는 경우) - marker.iconImage = NMFOverlayImage(name: "cluster_marker") - } - - // 터치 핸들러 추가 - userInfo에서 클러스터 데이터를 직접 가져오기 - marker.touchHandler = { [weak self] (overlay) -> Bool in - guard let self = self, - let tappedMarker = overlay as? NMFMarker, - let clusterData = tappedMarker.userInfo["clusterData"] as? ClusterMarkerData else { - return false - } - - return self.handleRegionalClusterTap(tappedMarker, clusterData: clusterData) - } - - marker.captionText = "" - marker.anchor = CGPoint(x: 0.5, y: 0.5) - marker.mapView = mainView.mapView - } - - // 활성 클러스터가 아닌 마커 제거 - for (key, marker) in clusterMarkerDictionary { - if !activeClusterKeys.contains(key) { - marker.mapView = nil - clusterMarkerDictionary.removeValue(forKey: key) - } - } - } - - CATransaction.commit() - } - private func clearAllMarkers() { individualMarkerDictionary.values.forEach { $0.mapView = nil } individualMarkerDictionary.removeAll() @@ -908,7 +540,7 @@ class MapViewController: BaseViewController, View, CLLocationManagerDelegate, NM markerDictionary.removeAll() } - private func groupStoresByExactLocation(_ stores: [MapPopUpStore]) -> [CoordinateKey: [MapPopUpStore]] { + func groupStoresByExactLocation(_ stores: [MapPopUpStore]) -> [CoordinateKey: [MapPopUpStore]] { var dict = [CoordinateKey: [MapPopUpStore]]() for store in stores { let key = CoordinateKey(latitude: store.latitude, longitude: store.longitude) @@ -1018,7 +650,6 @@ class MapViewController: BaseViewController, View, CLLocationManagerDelegate, NM currentFilterBottomSheet = nil } - // 기본 마커 private func addMarkers(for stores: [MapPopUpStore]) { markerDictionary.values.forEach { $0.mapView = nil } markerDictionary.removeAll() @@ -1029,21 +660,18 @@ class MapViewController: BaseViewController, View, CLLocationManagerDelegate, NM marker.userInfo = ["storeData": store] updateMarkerStyle(marker: marker, selected: false, isCluster: false) - - // 직접 터치 핸들러 추가 - marker.touchHandler = { [weak self] (_) -> Bool in - guard let self = self else { return false } - - print("검색 결과 마커 터치됨! 스토어: \(store.name)") - return self.handleSingleStoreTap(marker, store: store) + marker.touchHandler = { [weak self] overlay in + guard let self = self, + let tappedMarker = overlay as? NMFMarker, + let storeData = tappedMarker.userInfo["storeData"] as? MapPopUpStore + else { return false } + return self.handleSingleStoreTap(tappedMarker, store: storeData) } - marker.mapView = mainView.mapView markerDictionary[store.id] = marker } } private func updateListView(with results: [MapPopUpStore]) { - // MapPopUpStore 배열을 StoreItem 배열로 변환 let storeItems = results.map { $0.toStoreItem() } storeListViewController.reactor?.action.onNext(.setStores(storeItems)) } @@ -1079,16 +707,6 @@ class MapViewController: BaseViewController, View, CLLocationManagerDelegate, NM ) } - // 현재 보이는 지도 영역의 경계를 가져오는 함수 - private func getVisibleBounds() -> (northEast: NMGLatLng, southWest: NMGLatLng) { - let mapBounds = mainView.mapView.contentBounds - - let northEast = NMGLatLng(lat: mapBounds.northEastLat, lng: mapBounds.northEastLng) - let southWest = NMGLatLng(lat: mapBounds.southWestLat, lng: mapBounds.southWestLng) - - return (northEast: northEast, southWest: southWest) - } - // MARK: - Location private func checkLocationAuthorization() { switch locationManager.authorizationStatus { @@ -1096,45 +714,24 @@ class MapViewController: BaseViewController, View, CLLocationManagerDelegate, NM locationManager.requestWhenInUseAuthorization() case .authorizedWhenInUse, .authorizedAlways: locationManager.startUpdatingLocation() - mainView.mapView.positionMode = .direction // 내 위치 트래킹 모드 활성화 + mainView.mapView.positionMode = .direction case .denied, .restricted: Logger.log( message: "위치 서비스가 비활성화되었습니다. 설정에서 권한을 확인해주세요.", category: .error ) - mainView.mapView.positionMode = .disabled // 내 위치 트래킹 모드 비활성화 + mainView.mapView.positionMode = .disabled @unknown default: break } } - private func updateTooltipPosition() { - guard let marker = currentMarker, let tooltip = currentTooltipView else { return } - - // 마커 위치를 화면 좌표로 변환 - let markerPoint = mainView.mapView.projection.point(from: marker.position) - var markerCenter = markerPoint - - // 마커 높이 고려 (네이버 마커는 크기가 다를 수 있음) - markerCenter.y = markerPoint.y - 20 // 마커 이미지 높이의 절반 정도 - - // 오프셋 값 (디자인에 맞게 조정) - let offsetX: CGFloat = -10 - let offsetY: CGFloat = -10 - - tooltip.frame.origin = CGPoint( - x: markerCenter.x + offsetX, - y: markerCenter.y - tooltip.frame.height - offsetY - ) - } - private func resetSelectedMarker() { if let currentMarker = currentMarker { // 마커 스타일 업데이트 updateMarkerStyle(marker: currentMarker, selected: false, isCluster: false) } - // 툴팁 제거 currentTooltipView?.removeFromSuperview() currentTooltipView = nil currentTooltipStores = [] @@ -1147,42 +744,7 @@ class MapViewController: BaseViewController, View, CLLocationManagerDelegate, NM self.currentMarker = nil } - private func updateMarkersForCluster(stores: [MapPopUpStore]) { - // 전체 개별 및 클러스터 마커 제거 - for marker in individualMarkerDictionary.values { - marker.mapView = nil - } - individualMarkerDictionary.removeAll() - - for marker in clusterMarkerDictionary.values { - marker.mapView = nil - } - clusterMarkerDictionary.removeAll() - - // 클러스터에 포함된 스토어들만 새 마커 추가 - for store in stores { - let marker = NMFMarker() - marker.position = NMGLatLng(lat: store.latitude, lng: store.longitude) - marker.userInfo = ["storeData": store] - marker.anchor = CGPoint(x: 0.5, y: 1.0) - - updateMarkerStyle(marker: marker, selected: false, isCluster: false) - - // 직접 터치 핸들러 추가 - marker.touchHandler = { [weak self] (_) -> Bool in - guard let self = self else { return false } - - print("클러스터 내 마커 터치됨! 스토어: \(store.name)") - return self.handleSingleStoreTap(marker, store: store) - } - - marker.mapView = mainView.mapView - individualMarkerDictionary[store.id] = marker - } - } - private func findMarkerForStore(for store: MapPopUpStore) -> NMFMarker? { - // individualMarkerDictionary에 저장된 모든 마커를 순회 for marker in individualMarkerDictionary.values { if let singleStore = marker.userInfo["storeData"] as? MapPopUpStore, singleStore.id == store.id { return marker @@ -1202,8 +764,7 @@ class MapViewController: BaseViewController, View, CLLocationManagerDelegate, NM return nil } - private func fetchStoreDetails(for stores: [MapPopUpStore]) { - // 빈 목록이면 처리하지 않음 + func fetchStoreDetails(for stores: [MapPopUpStore]) { guard !stores.isEmpty else { return } // 먼저 기본 정보로 StoreItem 생성하여 순서 유지 @@ -1222,7 +783,6 @@ class MapViewController: BaseViewController, View, CLLocationManagerDelegate, NM // 리스트에는 모든 스토어 정보 표시 (필터링된 모든 스토어) self.storeListViewController.reactor?.action.onNext(.setStores(initialStoreItems)) - // 각 스토어의 상세 정보를 병렬로 가져와서 업데이트 (북마크 정보 등) stores.forEach { store in self.popUpAPIUseCase.getPopUpDetail( commentType: "NORMAL", @@ -1241,12 +801,7 @@ class MapViewController: BaseViewController, View, CLLocationManagerDelegate, NM } func bindViewport(reactor: MapReactor) { - // 카메라 이동 완료 시 이벤트 발생되는 Subject let cameraObservable = PublishSubject() - - // NMFMapViewCameraDelegate 메서드에서 호출할 수 있도록 설정 - - // 카메라 변경 감지해서 액션 전달 cameraObservable .throttle(.milliseconds(200), scheduler: MainScheduler.instance) .map { [unowned self] _ -> MapReactor.Action in @@ -1261,7 +816,6 @@ class MapViewController: BaseViewController, View, CLLocationManagerDelegate, NM .bind(to: reactor.action) .disposed(by: disposeBag) - // 현재 뷰포트 내의 스토어 업데이트 - 초기 1회 reactor.state .map { $0.viewportStores } .distinctUntilChanged() @@ -1373,7 +927,6 @@ class MapViewController: BaseViewController, View, CLLocationManagerDelegate, NM private func findAndShowNearestStore(from location: CLLocation) { guard !currentStores.isEmpty else { - Logger.log(message: "현재위치 표기할 스토어가 없습니다", category: .debug) return } @@ -1386,7 +939,6 @@ class MapViewController: BaseViewController, View, CLLocationManagerDelegate, NM } if let store = nearestStore, let marker = findMarkerForStore(for: store) { - // 카메라 이동 없이 선택된 마커만 업데이트 _ = handleSingleStoreTap(marker, store: store) } // 마커가 없다면 새로 생성 @@ -1409,176 +961,6 @@ class MapViewController: BaseViewController, View, CLLocationManagerDelegate, NM } } - // MARK: - Marker Handling - func handleSingleStoreTap(_ marker: NMFMarker, store: MapPopUpStore) -> Bool { - isMovingToMarker = true - - // 이전 마커 선택 상태 해제 - if let previousMarker = currentMarker { - updateMarkerStyle(marker: previousMarker, selected: false, isCluster: false) - } - - // 새 마커 선택 상태로 설정 - updateMarkerStyle(marker: marker, selected: true, isCluster: false) - currentMarker = marker - - // 캐러셀에 표시할 스토어 확인 - if currentCarouselStores.isEmpty || !currentCarouselStores.contains(where: { $0.id == store.id }) { - // 현재 뷰포트의 모든 스토어를 가져오기 - let bounds = getVisibleBounds() - - let visibleStores = currentStores.filter { store in - let storePosition = NMGLatLng(lat: store.latitude, lng: store.longitude) - return NMGLatLngBounds(southWest: bounds.southWest, northEast: bounds.northEast).contains(storePosition) - } - - if !visibleStores.isEmpty { - // 뷰포트의 모든 스토어를 캐러셀에 표시 - currentCarouselStores = visibleStores - carouselView.updateCards(visibleStores) - - // 선택한 스토어의 인덱스를 찾아 스크롤 - if let index = visibleStores.firstIndex(where: { $0.id == store.id }) { - carouselView.scrollToCard(index: index) - } - } else { - // 뷰포트에 다른 스토어가 없는 경우, 선택한 스토어만 표시 - currentCarouselStores = [store] - carouselView.updateCards([store]) - } - } else { - // 캐러셀에 이미 해당 스토어가 있는 경우, 해당 위치로 스크롤 - if let index = currentCarouselStores.firstIndex(where: { $0.id == store.id }) { - carouselView.scrollToCard(index: index) - } - } - - carouselView.isHidden = false - mainView.setStoreCardHidden(false, animated: true) - - // 툴팁 처리 - if let storeArray = marker.userInfo["storeData"] as? [MapPopUpStore], storeArray.count > 1 { - // 마이크로 클러스터인 경우 툴팁 표시 - configureTooltip(for: marker, stores: storeArray) - // 해당 스토어의 툴팁 인덱스 선택 - if let index = storeArray.firstIndex(where: { $0.id == store.id }) { - (currentTooltipView as? MarkerTooltipView)?.selectStore(at: index) - } - } else { - // 단일 마커인 경우 툴팁 제거 - currentTooltipView?.removeFromSuperview() - currentTooltipView = nil - } - - isMovingToMarker = false - return true - } - - // 리전 클러스터 탭 처리 - func handleRegionalClusterTap(_ marker: NMFMarker, clusterData: ClusterMarkerData) -> Bool { - print("handleRegionalClusterTap 함수 호출됨") - - let currentZoom = mainView.mapView.zoomLevel - let currentLevel = MapZoomLevel.getLevel(from: Float(currentZoom)) - - // 디버깅 - print("현재 줌 레벨: \(currentZoom), 모드: \(currentLevel)") - print("클러스터 정보: \(clusterData.cluster.name), 스토어 수: \(clusterData.storeCount)") - - switch currentLevel { - case .city: // 시 단위 클러스터 - print("시 단위 클러스터 처리") - let districtZoomLevel: Double = 10.0 - let cameraUpdate = NMFCameraUpdate(scrollTo: marker.position, zoomTo: districtZoomLevel) - cameraUpdate.animation = .easeIn - cameraUpdate.animationDuration = 0.3 - mainView.mapView.moveCamera(cameraUpdate) - - case .district: // 구 단위 클러스터 - print("구 단위 클러스터 처리") - let detailedZoomLevel: Double = 12.0 - let cameraUpdate = NMFCameraUpdate(scrollTo: marker.position, zoomTo: detailedZoomLevel) - cameraUpdate.animation = .easeIn - cameraUpdate.animationDuration = 0.3 - mainView.mapView.moveCamera(cameraUpdate) - default: - print("기타 레벨 클러스터 처리") - } - - // 클러스터에 포함된 스토어들만 표시하도록 마커 업데이트 - updateMarkersForCluster(stores: clusterData.cluster.stores) - - // 캐러셀 업데이트 - carouselView.updateCards(clusterData.cluster.stores) - carouselView.isHidden = false - self.currentCarouselStores = clusterData.cluster.stores - - return true - } - - // 마이크로 클러스터 탭 처리 - func handleMicroClusterTap(_ marker: NMFMarker, storeArray: [MapPopUpStore]) -> Bool { - // 이미 선택된 마커를 다시 탭할 때 - if currentMarker == marker { - // 툴팁과 캐러셀만 숨기고, 마커의 선택 상태는 유지 - currentTooltipView?.removeFromSuperview() - currentTooltipView = nil - currentTooltipStores = [] - currentTooltipCoordinate = nil - - carouselView.isHidden = true - carouselView.updateCards([]) - currentCarouselStores = [] - - // 마커 상태 업데이트 - updateMarkerStyle(marker: marker, selected: false, isCluster: false, count: storeArray.count) - - currentMarker = nil - isMovingToMarker = false - return false - } - - isMovingToMarker = true - - currentTooltipView?.removeFromSuperview() - currentTooltipView = nil - - if let previousMarker = currentMarker { - updateMarkerStyle(marker: previousMarker, selected: false, isCluster: false) - } - - updateMarkerStyle(marker: marker, selected: true, isCluster: false, count: storeArray.count) - currentMarker = marker - - currentCarouselStores = storeArray - carouselView.updateCards(storeArray) - carouselView.isHidden = false - carouselView.scrollToCard(index: 0) - - mainView.setStoreCardHidden(false, animated: true) - - // 지도 이동 - let cameraUpdate = NMFCameraUpdate(scrollTo: marker.position) - cameraUpdate.animation = .easeIn - cameraUpdate.animationDuration = 0.3 - mainView.mapView.moveCamera(cameraUpdate) - - // 툴팁 생성 - 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 - } - } - - return true - } - - private func showNoMarkersToast() { - // 디자인 예정이므로 임시 구현 - Logger.log(message: "현재 지도 영역에 표시할 마커가 없습니다", category: .debug) - } } // MARK: - CLLocationManagerDelegate @@ -1604,33 +986,26 @@ class MapViewController: BaseViewController, View, CLLocationManagerDelegate, NM // MARK: - NMFMapViewTouchDelegate extension MapViewController { - // 마커 탭 이벤트 처리 // 마커 탭 이벤트 처리 func mapView(_ mapView: NMFMapView, didTap marker: NMFMarker) -> Bool { - Logger.log(message: "didTapMarker 호출됨: \(marker.position), userInfo: \(marker.userInfo)", category: .debug) // 클러스터 마커 확인 if let clusterData = marker.userInfo["clusterData"] as? ClusterMarkerData { - Logger.log(message: "클러스터 데이터 감지: \(clusterData.cluster.name), 스토어 수: \(clusterData.storeCount)", category: .debug) return handleRegionalClusterTap(marker, clusterData: clusterData) } // 마이크로 클러스터 또는 단일 스토어 마커 확인 else if let storeArray = marker.userInfo["storeData"] as? [MapPopUpStore] { if storeArray.count > 1 { - Logger.log(message: "마이크로 클러스터 감지: \(storeArray.count)개 스토어", category: .debug) return handleMicroClusterTap(marker, storeArray: storeArray) } else if let singleStore = storeArray.first { - Logger.log(message: "단일 스토어 감지: \(singleStore.name)", category: .debug) return handleSingleStoreTap(marker, store: singleStore) } } // 단일 스토어 마커 (배열이 아닌 경우) 확인 else if let singleStore = marker.userInfo["storeData"] as? MapPopUpStore { - Logger.log(message: "단일 스토어 감지: \(singleStore.name)", category: .debug) return handleSingleStoreTap(marker, store: singleStore) } - Logger.log(message: "인식할 수 없는 마커 타입", category: .error) return false } @@ -1717,7 +1092,6 @@ class MapViewController: BaseViewController, View, CLLocationManagerDelegate, NM // MARK: - UIGestureRecognizerDelegate extension MapViewController { - // 맵뷰의 다른 제스처와 충돌하지 않도록 함 func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool { // 맵의 내장 제스처와 동시 인식 허용 return true @@ -1725,7 +1099,6 @@ class MapViewController: BaseViewController, View, CLLocationManagerDelegate, NM // 리스트뷰가 보일 때만 커스텀 탭 제스처 허용 func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldReceive touch: UITouch) -> Bool { - // 터치가 리스트뷰 영역에 있으면 커스텀 제스처 트리거하지 않음 let touchPoint = touch.location(in: view) // 리스트뷰가 보이고 터치가 리스트뷰 위에 있으면 탭 처리하지 않음 diff --git a/Poppool/Poppool/Presentation/Scene/Map/MapView/MarkerStyling.swift b/Poppool/Poppool/Presentation/Scene/Map/MapView/MarkerStyling.swift new file mode 100644 index 00000000..3ab6c0d8 --- /dev/null +++ b/Poppool/Poppool/Presentation/Scene/Map/MapView/MarkerStyling.swift @@ -0,0 +1,123 @@ +import NMapsMap +import UIKit + +protocol MarkerStyling { + func applyStyle( + to marker: NMFMarker, + selected: Bool, + isCluster: Bool, + count: Int, + regionName: String + ) +} + +struct DefaultMarkerStyler: MarkerStyling { + func applyStyle( + to marker: NMFMarker, + selected: Bool, + isCluster: Bool, + count: Int, + regionName: String + ) { + // MapMarker 인스턴스 생성 + let markerView = MapMarker() + + // 마커 뷰에 속성 주입 + markerView.injection(with: MapMarker.Input( + isSelected: selected, + isCluster: isCluster, + regionName: regionName, + count: count, + isMultiMarker: count > 1 + )) + + markerView.backgroundColor = .clear + + // 레이아웃 업데이트 + markerView.layoutIfNeeded() + let fittingSize = markerView.systemLayoutSizeFitting(UIView.layoutFittingCompressedSize) + markerView.frame = CGRect(origin: .zero, size: fittingSize) + + if let markerImage = convertViewToImage(markerView) { + marker.iconImage = NMFOverlayImage(image: markerImage) + + marker.width = fittingSize.width + marker.height = fittingSize.height + } else { + if selected { + marker.iconImage = NMFOverlayImage(name: "TapMarker") + marker.width = 44 + marker.height = 44 + } else if isCluster { + marker.iconImage = NMFOverlayImage(name: "cluster_marker") + marker.width = 36 + marker.height = 36 + } else { + marker.iconImage = NMFOverlayImage(name: "Marker") + marker.width = 32 + marker.height = 32 + } + } + + marker.captionText = "" + + // 마커 앵커 설정 + marker.anchor = CGPoint(x: 0.5, y: 1.0) + } + + private func convertViewToImage(_ view: UIView) -> UIImage? { + UIGraphicsBeginImageContextWithOptions(view.bounds.size, false, UIScreen.main.scale) + defer { UIGraphicsEndImageContext() } + + guard let context = UIGraphicsGetCurrentContext() else { return nil } + view.layer.render(in: context) + return UIGraphicsGetImageFromCurrentImageContext() + } +} + +struct FullScreenMarkerStyler: MarkerStyling { + func applyStyle( + to marker: NMFMarker, + selected: Bool, + isCluster: Bool, + count: Int, + regionName: String + ) { + let markerView = MapMarker() + markerView.injection(with: MapMarker.Input( + isSelected: true, + isCluster: isCluster, + regionName: regionName, + count: count, + isMultiMarker: count > 1 + )) + + markerView.backgroundColor = .clear + let fittingSize = markerView.systemLayoutSizeFitting(UIView.layoutFittingExpandedSize) + let scale: CGFloat = 1.2 + markerView.frame = CGRect(origin: .zero, size: CGSize(width: fittingSize.width * scale, height: fittingSize.height * scale)) + markerView.layoutIfNeeded() + + if let markerImage = convertViewToImage(markerView) { + marker.iconImage = NMFOverlayImage(image: markerImage) + marker.width = fittingSize.width * scale + marker.height = fittingSize.height * scale + } else { + marker.iconImage = NMFOverlayImage(name: "TapMarker") + marker.width = 44 + marker.height = 44 + } + + marker.captionText = "" + marker.anchor = CGPoint(x: 0.5, y: 1.0) + } + + private func convertViewToImage(_ view: UIView) -> UIImage? { + UIGraphicsBeginImageContextWithOptions(view.bounds.size, false, UIScreen.main.scale) + defer { UIGraphicsEndImageContext() } + + guard let context = UIGraphicsGetCurrentContext() else { return nil } + view.layer.render(in: context) + return UIGraphicsGetImageFromCurrentImageContext() + } +} diff --git a/Poppool/Poppool/Presentation/Scene/Map/MapView/MarkerTooltipView.swift b/Poppool/Poppool/Presentation/Scene/Map/MapView/MarkerTooltipView.swift index a1c2bffe..f53405ef 100644 --- a/Poppool/Poppool/Presentation/Scene/Map/MapView/MarkerTooltipView.swift +++ b/Poppool/Poppool/Presentation/Scene/Map/MapView/MarkerTooltipView.swift @@ -30,7 +30,7 @@ final class MarkerTooltipView: UIView, UIGestureRecognizerDelegate { // MARK: - Initialization override init(frame: CGRect) { super.init(frame: frame) - self.frame.size = CGSize(width: 200, height: 100) // 임시 높이로 시작 + self.frame.size = CGSize(width: 200, height: 100) setupLayout() // setupGestures() } @@ -71,15 +71,10 @@ final class MarkerTooltipView: UIView, UIGestureRecognizerDelegate { // 기존 뷰 제거 stackView.arrangedSubviews.forEach { $0.removeFromSuperview() } - print("🗨️ 툴팁 구성") - print("📋 입력받은 스토어: \(stores.map { $0.name })") - - // stores 배열 순서대로 처리 for (index, store) in stores.enumerated() { let rowContainer = createRow(for: store, at: index) stackView.addArrangedSubview(rowContainer) - // 구분선 추가 (마지막 아이템 제외) if index < stores.count - 1 { let separator = createSeparator() stackView.addArrangedSubview(separator) @@ -89,7 +84,6 @@ final class MarkerTooltipView: UIView, UIGestureRecognizerDelegate { // 레이아웃 업데이트 layoutIfNeeded() - // 컨텐츠 크기에 맞게 높이 조정 let height = stackView.systemLayoutSizeFitting( CGSize(width: 200, height: UIView.layoutFittingCompressedSize.height) ).height + 24 @@ -156,10 +150,6 @@ final class MarkerTooltipView: UIView, UIGestureRecognizerDelegate { @objc private func handleRowTap(_ gesture: UITapGestureRecognizer) { guard let row = gesture.view else { return } let index = row.tag - - print("🗨️ 툴팁 탭") - print("👆 탭된 인덱스: \(index)") - gesture.cancelsTouchesInView = true selectStore(at: index) onStoreSelected?(index) @@ -186,11 +176,9 @@ final class MarkerTooltipView: UIView, UIGestureRecognizerDelegate { else { continue } if row.tag == index { - // 선택된 행 label.font = .boldSystemFont(ofSize: 12) bulletView.backgroundColor = .jd500 } else { - // 선택되지 않은 행 label.font = .systemFont(ofSize: 12) bulletView.backgroundColor = .clear } diff --git a/Poppool/Poppool/Presentation/Scene/Map/StoreListView/StoreListReactor.swift b/Poppool/Poppool/Presentation/Scene/Map/StoreListView/StoreListReactor.swift index 21e54ec8..aeb6b948 100644 --- a/Poppool/Poppool/Presentation/Scene/Map/StoreListView/StoreListReactor.swift +++ b/Poppool/Poppool/Presentation/Scene/Map/StoreListView/StoreListReactor.swift @@ -9,10 +9,6 @@ final class StoreListReactor: Reactor { private let popUpAPIUseCase: PopUpAPIUseCaseImpl private let bookmarkStateRelay = PublishRelay<(Int64, Bool)>() -// private var currentPage = 0 -// private let pageSize = 10 -// private var hasMorePages = true - enum Action { case syncBookmarkStatus(storeId: Int64, isBookmarked: Bool) case didSelectItem(Int) @@ -76,13 +72,12 @@ final class StoreListReactor: Reactor { // Int64 → Int32 변환 필요 guard let idInt32 = Int32(exactly: store.id) else { - Logger.log(message: "ID 값이 Int32 범위를 초과했습니다: \(store.id)", category: .error) return .empty() } return popUpAPIUseCase.getPopUpDetail( commentType: "NORMAL", - popUpStoredId: Int64(idInt32) // Int32 → Int64 변환 + popUpStoredId: Int64(idInt32) ) .flatMap { detail -> Observable in if detail.bookmarkYn != store.isBookmarked { diff --git a/Poppool/Poppool/Presentation/Utills/Common/FilterType.swift b/Poppool/Poppool/Presentation/Utills/Common/FilterType.swift index b19e7c64..3825b486 100644 --- a/Poppool/Poppool/Presentation/Utills/Common/FilterType.swift +++ b/Poppool/Poppool/Presentation/Utills/Common/FilterType.swift @@ -1,7 +1,6 @@ import Foundation import UIKit -/// 맵과 리스트에서 공통으로 사용하는 필터 타입 enum FilterType { case location case category diff --git a/Poppool/Poppool/Presentation/Utills/Common/MapFilterChips.swift b/Poppool/Poppool/Presentation/Utills/Common/MapFilterChips.swift index 8006ce31..7322aa76 100644 --- a/Poppool/Poppool/Presentation/Utills/Common/MapFilterChips.swift +++ b/Poppool/Poppool/Presentation/Utills/Common/MapFilterChips.swift @@ -54,8 +54,6 @@ class MapFilterChips: UIView { // MARK: - Update State func update(locationText: String?, categoryText: String?) { - print("Updating chips - locationText: \(String(describing: locationText)), categoryText: \(String(describing: categoryText))") - updateChip(button: locationChip, text: locationText, placeholder: "지역선택", onClear: onRemoveLocation) updateChip(button: categoryChip, text: categoryText, placeholder: "카테고리", onClear: onRemoveCategory) }