diff --git a/LeaveGo/LeaveGo/ContentView.swift b/LeaveGo/LeaveGo/ContentView.swift index 7acc765..29191c0 100644 --- a/LeaveGo/LeaveGo/ContentView.swift +++ b/LeaveGo/LeaveGo/ContentView.swift @@ -10,8 +10,6 @@ import CoreData struct ContentView: View { - @State private var mapViewModel = MapViewModel() - var body: some View { TabView { DummyView() @@ -21,7 +19,6 @@ struct ContentView: View { } MapView() - .environment(mapViewModel) .tabItem { Image(systemName: "map.fill") Text("장소") diff --git a/LeaveGo/LeaveGo/Design/Extensions/View+asUIImage.swift b/LeaveGo/LeaveGo/Design/Extensions/View+asUIImage.swift new file mode 100644 index 0000000..836d1d4 --- /dev/null +++ b/LeaveGo/LeaveGo/Design/Extensions/View+asUIImage.swift @@ -0,0 +1,22 @@ +// +// View+asUIImage.swift +// LeaveGo +// +// Created by 이치훈 on 12/30/25. +// + +import SwiftUI + +extension View { + func asMarkerImage(size: CGSize) -> UIImage? { + let controller = UIHostingController(rootView: self) + controller.view.bounds = CGRect(origin: .zero, size: size) + controller.view.backgroundColor = .clear + controller.view.layoutIfNeeded() + + let renderer = UIGraphicsImageRenderer(size: size) + return renderer.image { _ in + controller.view.drawHierarchy(in: controller.view.bounds, afterScreenUpdates: true) + } + } +} diff --git a/LeaveGo/LeaveGo/Features/PlaceMap/Components/PlaceMarkerView.swift b/LeaveGo/LeaveGo/Features/PlaceMap/Components/PlaceMarkerView.swift new file mode 100644 index 0000000..91abd5b --- /dev/null +++ b/LeaveGo/LeaveGo/Features/PlaceMap/Components/PlaceMarkerView.swift @@ -0,0 +1,55 @@ +// +// PlaceMarker.swift +// LeaveGo +// +// Created by 이치훈 on 12/30/25. +// + +import SwiftUI + +struct PlaceMarkerView: View { + + let isSelected: Bool + let thumbnail: UIImage? + + init(isSelected: Bool = false, thumbnail: UIImage? = nil) { + self.isSelected = isSelected + self.thumbnail = thumbnail + } + + var body: some View { + ZStack { +#if DEBUG + Rectangle() + .fill(.red.opacity(0.3)) +#endif + + Circle() + .fill(isSelected ? .lgAccent : .lgBackgroundAccent) + .frame(width: isSelected ? 48 : 40, + height: isSelected ? 48 : 40) + .shadow(.medium) + + if let thumbnail { + thumbnailImage(Image(uiImage: thumbnail)) + } else { + Circle() + .fill(isSelected ? .lgSubAccent : .clear) + .frame(width: 40, + height: 40) + + thumbnailImage(Image("img_logoWithNoBg")) + } + } + } + + @ViewBuilder + private func thumbnailImage(_ image: Image) -> some View { + image + .resizable() + .scaledToFill() + .frame(width: isSelected ? 40 : 32, + height: isSelected ? 40 : 32) + .clipShape(Circle()) + } +} diff --git a/LeaveGo/LeaveGo/Features/PlaceMap/Coordinator/NaverMapViewCoordinator.swift b/LeaveGo/LeaveGo/Features/PlaceMap/Coordinator/NaverMapViewCoordinator.swift index 7006898..8579579 100644 --- a/LeaveGo/LeaveGo/Features/PlaceMap/Coordinator/NaverMapViewCoordinator.swift +++ b/LeaveGo/LeaveGo/Features/PlaceMap/Coordinator/NaverMapViewCoordinator.swift @@ -34,14 +34,44 @@ class NaverMapViewCoordinator: NSObject { // 마커 캐시 private var currentMarkers: [String: NMFMarker] = [:] - private var cachedPlaceIds: Set = [] + private var cachedPlaceIDs: Set = [] // 상태 플래그 var hasMovedToUserLocation = false - // 공유 아이콘 (메모리 최적화) - private let defaultIcon = "img_logoWithNoBg" - private let selectedIcon = "img_userAnnotationPlaceholder" + /// 마지막으로 이동한 카메라 위치 (중복 이동 방지용) + var lastTargetCameraLocation: CLLocationCoordinate2D? + + // MARK: 마커 이미지 캐시 + + /// 장소별 마커 이미지 캐시 + private var markerImageCache: [String: NMFOverlayImage] = [:] + private var selectedMarkerImageCache: [String: NMFOverlayImage] = [:] + + private let imageRepository = ImageRepository.shared + + private let defaultMarkerSize = CGSize(width: 50, height: 100) + private let selectedMarkerSize = CGSize(width: 60, height: 120) + + /// 선택되지 않은 마커의 이미지 + private lazy var defaultMarkerImage: NMFOverlayImage? = { + let markerView = PlaceMarkerView(isSelected: false) + guard let uiImage = markerView.asMarkerImage(size: defaultMarkerSize) else { + return nil + } + return NMFOverlayImage(image: uiImage) + }() + + /// 선택된 마커의 이미지 + private lazy var selectedMarkerImage: NMFOverlayImage? = { + let markerView = PlaceMarkerView(isSelected: true) + guard let uiImage = markerView.asMarkerImage(size: selectedMarkerSize) else { + return nil + } + return NMFOverlayImage(image: uiImage) + }() + + private var markerUpdateTask: Task? init(viewModel: MapViewModel) { naverMapViewDelegate = viewModel @@ -67,26 +97,26 @@ class NaverMapViewCoordinator: NSObject { /// 6. 캐시 업데이트 /// ``` public func updateMarkers(on mapView: NMFMapView, with placeList: [PlaceDTO]) { - let newIds = Set(placeList.map { $0.id }) + let newIDs = Set(placeList.map { $0.id }) // 변경 없으면 스킵 - guard cachedPlaceIds != newIds else { return } + guard cachedPlaceIDs != newIDs else { return } // 1. 삭제: 새 데이터에 없는 기존 마커 제거 - let toRemove = cachedPlaceIds.subtracting(newIds) + let toRemove = cachedPlaceIDs.subtracting(newIDs) for id in toRemove { removeMarker(id: id) } // 2. 추가: 기존에 없는 새 마커 생성 - let toAdd = newIds.subtracting(cachedPlaceIds) + let toAdd = newIDs.subtracting(cachedPlaceIDs) for place in placeList where toAdd.contains(place.id) { let marker = createMarker(from: place) marker.mapView = mapView currentMarkers[place.id] = marker } - cachedPlaceIds = newIds + cachedPlaceIDs = newIDs } // MARK: createMarker @@ -104,11 +134,10 @@ class NaverMapViewCoordinator: NSObject { marker.position = NMGLatLng(lat: lat, lng: lng) } - // 스타일 설정 - marker.iconImage = NMFOverlayImage(name: defaultIcon) - marker.iconTintColor = .systemBlue - marker.width = 24 - marker.height = 32 + // 스타일 설정 - 캐시된 기본 이미지 사용 + marker.iconImage = defaultMarkerImage ?? NMFOverlayImage() + marker.width = CGFloat(NMF_MARKER_SIZE_AUTO) + marker.height = CGFloat(NMF_MARKER_SIZE_AUTO) // 캡션 설정 marker.captionText = place.title @@ -122,16 +151,24 @@ class NaverMapViewCoordinator: NSObject { // 탭 핸들러: 오버레이가 터치될 경우 호출되는 콜백 블록 marker.touchHandler = { [weak self] overlay -> Bool in - guard let placeId = overlay.userInfo["placeId"] as? String else { + guard let placeID = overlay.userInfo["placeId"] as? String else { return true } Task { @MainActor in - await self?.naverMapViewDelegate?.setSelectedPlaceId(id: placeId) + await self?.naverMapViewDelegate?.setSelectedPlaceID(id: placeID) } return true } + // 장소별 썸네일 마크를 미리 생성 및 캐싱 + if let thumbnailURLString = place.thumbnailImage, + let thumbnailURL = URL(string: thumbnailURLString) { + loadThumbnailAndUpdateMarker(placeID: place.id, + url: thumbnailURL, + marker: marker) + } + return marker } @@ -144,31 +181,107 @@ class NaverMapViewCoordinator: NSObject { marker.touchHandler = nil marker.mapView = nil currentMarkers.removeValue(forKey: id) + + // 마커 썸네일 이미지 캐시 정리 + markerImageCache.removeValue(forKey: id) + selectedMarkerImageCache.removeValue(forKey: id) } // MARK: updateSelectedMarker + /// 선택된 마커를 최적화된 방식으로 업데이트합니다. /// /// 이전 선택과 현재 선택 마커 **최대 2개만** 업데이트하여 성능을 극대화합니다. /// 모든 마커를 순회하는 기존 방식(O(n))과 달리, 변경된 마커만 직접 접근하여 /// 업데이트하므로 O(1) 복잡도를 달성합니다. - public func updateSelectedMarkerOptimized(selectedId: String?, previousSelectedId: String?) { - // 1. 이전 선택 마커를 기본 스타일로 복원 - if let prevId = previousSelectedId, - let prevMarker = currentMarkers[prevId] { - prevMarker.iconTintColor = .systemBlue - prevMarker.width = 24 - prevMarker.height = 32 - prevMarker.zIndex = 0 + /// + /// - Parameters: + /// - selectedID: 현재 선택된 마커의 ID + /// - previousSelectedID: 이전에 선택되었던 마커의 ID + /// + /// - Note: 캐시된 이미지를 재사용하므로 이미지 재생성 비용이 발생하지 않습니다. + public func updateSelectedMarker(selectedID: String?, previousSelectedID: String?) { + + markerUpdateTask?.cancel() + + if let prevID = previousSelectedID, + let prevMarker = currentMarkers[prevID] { + prevMarker.hidden = true + } + + if let newID = selectedID, + let newMarker = currentMarkers[newID] { + newMarker.hidden = true } - // 2. 새로운 선택 마커를 강조 스타일로 변경 - if let newId = selectedId, - let newMarker = currentMarkers[newId] { - newMarker.iconTintColor = .systemOrange - newMarker.width = 32 - newMarker.height = 42 - newMarker.zIndex = 1 + markerUpdateTask = Task { @MainActor in + try? await Task.sleep(for: .milliseconds(50)) + + guard !Task.isCancelled else { return } + + // 1. 이전 선택 마커를 기본 스타일(deselected)로 복원 + if let prevID = previousSelectedID, + let prevMarker = self.currentMarkers[prevID] { + prevMarker.iconImage = self.markerImageCache[prevID] ?? self.defaultMarkerImage ?? NMFOverlayImage() + prevMarker.width = CGFloat(NMF_MARKER_SIZE_AUTO) + prevMarker.height = CGFloat(NMF_MARKER_SIZE_AUTO) + prevMarker.zIndex = 0 + prevMarker.hidden = false + } + + // 2. 새로 선택된 마커를 강조 스타일(selected)로 변경 + if let newID = selectedID, + let newMarker = self.currentMarkers[newID] { + newMarker.iconImage = selectedMarkerImageCache[newID] ?? selectedMarkerImage ?? NMFOverlayImage() + newMarker.width = CGFloat(NMF_MARKER_SIZE_AUTO) + newMarker.height = CGFloat(NMF_MARKER_SIZE_AUTO) + newMarker.zIndex = 1 + newMarker.hidden = false + } + } + } + + // MARK: loadThumbnailAndUpdateMarker + + /// 썸네일 이미지가 그려진 장소 marker를 생성해 캐싱합니다. + /// + /// - Parameters: + /// - placeID: 작업을 진행할 장소의 ID + /// - url: 장소 썸네일 이미지의 url + /// - marker: 다시 그릴 Marker의 class 참조 + /// + /// - Note: 초기 시점엔 썸네일 이미지가 없는 'defaultMarkerImage' 프로퍼티를 올려놓습니다. 이후 장소 썸네일 이미지 Load가 끝나면 이 함수가 실행되어 썸네일이 포함된 마커 이미지로 다시 그립니다. + private func loadThumbnailAndUpdateMarker(placeID: String, + url: URL, + marker: NMFMarker) { + if let cachedImage = markerImageCache[placeID] { + marker.iconImage = cachedImage + return + } + + Task { @MainActor in + guard let thumbnailImage = await imageRepository.loadImage(from: url) else { + return + } + + // deselected 버전 marker image + let markerView = PlaceMarkerView(isSelected: false, thumbnail: thumbnailImage) + guard let uiImage = markerView.asMarkerImage(size: defaultMarkerSize) else { + return + } + + let overlayImage = NMFOverlayImage(image: uiImage) + self.markerImageCache[placeID] = overlayImage + + // selected 버전 marker image + let selectedMarkerView = PlaceMarkerView(isSelected: true, thumbnail: thumbnailImage) + if let selectedUIImage = selectedMarkerView.asMarkerImage(size: selectedMarkerSize) { + self.selectedMarkerImageCache[placeID] = NMFOverlayImage(image: selectedUIImage) + } + + if marker.mapView != nil { + marker.iconImage = overlayImage + } } } @@ -180,6 +293,9 @@ class NaverMapViewCoordinator: NSObject { marker.mapView = nil } currentMarkers.removeAll() + markerImageCache.removeAll() + selectedMarkerImageCache.removeAll() + markerUpdateTask?.cancel() } } @@ -190,7 +306,7 @@ extension NaverMapViewCoordinator: NMFMapViewTouchDelegate { func mapView(_ mapView: NMFMapView, didTapMap latlng: NMGLatLng, point: CGPoint) { // 지도 빈 영역 탭 시 선택 해제 Task { @MainActor in - await naverMapViewDelegate?.setSelectedPlaceId(id: nil) + await naverMapViewDelegate?.setSelectedPlaceID(id: nil) } } diff --git a/LeaveGo/LeaveGo/Features/PlaceMap/MapView.swift b/LeaveGo/LeaveGo/Features/PlaceMap/MapView.swift index 036e2af..86bffbc 100644 --- a/LeaveGo/LeaveGo/Features/PlaceMap/MapView.swift +++ b/LeaveGo/LeaveGo/Features/PlaceMap/MapView.swift @@ -9,14 +9,34 @@ import SwiftUI struct MapView: View { - @Environment(MapViewModel.self) private var viewModel + @State private var viewModel = MapViewModel() +// @State private var selectedPlace: PlaceDTO? + + private var selectedPlaceBinding: Binding { + Binding( + get: { viewModel.selectedPlace }, + set: { newValue in + // nil이 전달되면 명시적으로 선택 해제 + Task { @MainActor in + await viewModel.setSelectedPlaceID(id: newValue?.id) + } + } + ) + } var body: some View { - @Bindable var viewModel = viewModel ZStack { NaverMapView() .environment(viewModel) + .sheet(item: selectedPlaceBinding) { place in + PlaceDetailSheetView(place: place, buttonTitle: "경로 찾기") + .presentationDetents([.medium, .large]) + .onAppear { + // Sheet가 나타날 때 해당 마커 위치로 카메라 이동 + viewModel.moveCameraToSelectedPlace() + } + } // Map Launch Screen if !viewModel.isLocationLoaded { diff --git a/LeaveGo/LeaveGo/Features/PlaceMap/NaverMapView.swift b/LeaveGo/LeaveGo/Features/PlaceMap/NaverMapView.swift index 2df78ac..b475e9e 100644 --- a/LeaveGo/LeaveGo/Features/PlaceMap/NaverMapView.swift +++ b/LeaveGo/LeaveGo/Features/PlaceMap/NaverMapView.swift @@ -12,6 +12,7 @@ struct NaverMapView: UIViewRepresentable { @Environment(MapViewModel.self) private var viewModel + // MARK: makeUIView /// Naver 지도의 UIKit 뷰를 생성하고 초기 설정을 수행합니다. /// /// 이 메서드는 UIViewRepresentable 프로토콜의 필수 구현으로, @@ -30,11 +31,12 @@ struct NaverMapView: UIViewRepresentable { let view = NMFNaverMapView() // 기본 설정 - view.showZoomControls = false view.mapView.positionMode = .direction view.mapView.zoomLevel = 15 view.mapView.isIndoorMapEnabled = true - view.showLocationButton = true + view.showZoomControls = false + view.showLocationButton = false + // TODO: custom zoom controller 도입 예정 view.showCompass = true // 지도 터치 델리게이트 설정 @@ -55,6 +57,7 @@ struct NaverMapView: UIViewRepresentable { return view } + // MARK: updateUIView /// 이 메서드는 UIViewRepresentable 프로토콜의 필수 구현으로, /// SwiftUI의 상태(@State, @Binding, @Environment 등)가 변경될 때마다 자동으로 호출됩니다. /// ViewModel의 변경사항을 UIKit 기반 지도 뷰에 반영하는 역할을 합니다. @@ -77,6 +80,15 @@ struct NaverMapView: UIViewRepresentable { func updateUIView(_ uiView: NMFNaverMapView, context: Context) { let coordinator = context.coordinator + /// marker가 PlaceDetailSheetView에 가려지지 않도록 contentInset을 sheet 높이 만큼 보정합니다. + if viewModel.selectedPlaceID != nil { + let sheetHeight = UIScreen.main.bounds.height * 0.4 + uiView.mapView.contentInset = UIEdgeInsets(top: 0, left: 0, bottom: sheetHeight, right: 0) + } else { + uiView.mapView.contentInset = .zero + } + + // 사용자 위치 이동 처리 if let location = viewModel.userLocation, !coordinator.hasMovedToUserLocation { let userCoord = NMGLatLng(lat: location.latitude, @@ -92,13 +104,27 @@ struct NaverMapView: UIViewRepresentable { coordinator.hasMovedToUserLocation = true } + // 카메라 이동 처리 + if let targetLocation = viewModel.targetCameraLocation, + coordinator.lastTargetCameraLocation != targetLocation { + let targetCoord = NMGLatLng(lat: targetLocation.latitude, + lng: targetLocation.longitude) + let cameraUpdate = NMFCameraUpdate(scrollTo: targetCoord) + cameraUpdate.animation = .easeIn + cameraUpdate.animationDuration = 0.3 + uiView.mapView.moveCamera(cameraUpdate) + + coordinator.lastTargetCameraLocation = targetLocation + } + coordinator.updateMarkers(on: uiView.mapView, with: viewModel.placeList) - coordinator.updateSelectedMarkerOptimized( - selectedId: viewModel.selectedPlaceId, - previousSelectedId: viewModel.getPreviousSelectedPlaceId()) + coordinator.updateSelectedMarker( + selectedID: viewModel.selectedPlaceID, + previousSelectedID: viewModel.getPreviousSelectedPlaceID()) } + // MARK: makeCoordinator /// Coordinator 생성 func makeCoordinator() -> NaverMapViewCoordinator { NaverMapViewCoordinator(viewModel: viewModel) diff --git a/LeaveGo/LeaveGo/Features/PlaceMap/ViewModels/MapViewModel.swift b/LeaveGo/LeaveGo/Features/PlaceMap/ViewModels/MapViewModel.swift index d677df9..e8ae63b 100644 --- a/LeaveGo/LeaveGo/Features/PlaceMap/ViewModels/MapViewModel.swift +++ b/LeaveGo/LeaveGo/Features/PlaceMap/ViewModels/MapViewModel.swift @@ -11,7 +11,7 @@ import NMapsMap // MARK: NaverMapViewDelegate Protocol protocol NaverMapViewDelegate: AnyObject { - func setSelectedPlaceId(id : String?) async + func setSelectedPlaceID(id: String?) async } @MainActor @@ -21,11 +21,14 @@ final class MapViewModel { // MARK: - Properties public var userLocation: CLLocationCoordinate2D? public var placeList: [PlaceDTO] = [] - public var selectedPlaceId: String? - private var previousSelectedPlaceId: String? + public var selectedPlaceID: String? + private var previousSelectedPlaceID: String? + + /// 카메라를 이동시킬 목표 좌표 (설정하면 지도가 해당 위치로 이동) + public var targetCameraLocation: CLLocationCoordinate2D? public var selectedPlace: PlaceDTO? { - placeList.first { $0.id == selectedPlaceId } + placeList.first { $0.id == selectedPlaceID } } /// 여행지 API 요청을 처리하는 리포지토리 @@ -44,8 +47,21 @@ final class MapViewModel { // MARK: Method - func getPreviousSelectedPlaceId() -> String? { - return previousSelectedPlaceId + func getPreviousSelectedPlaceID() -> String? { + return previousSelectedPlaceID + } + + /// 선택된 장소의 위치로 카메라를 이동시킵니다 + func moveCameraToSelectedPlace() { + guard let place = selectedPlace, + let latStr = place.mapY, + let lngStr = place.mapX, + let lat = Double(latStr), + let lng = Double(lngStr) else { + return + } + + targetCameraLocation = CLLocationCoordinate2D(latitude: lat, longitude: lng) } // MARK: - LocationManager @@ -116,13 +132,13 @@ final class MapViewModel { // MARK: - NaverMapViewDelegate extension MapViewModel: NaverMapViewDelegate { - func setSelectedPlaceId(id: String?) async { - guard selectedPlaceId != id else { + func setSelectedPlaceID(id: String?) async { + guard selectedPlaceID != id else { return } - previousSelectedPlaceId = selectedPlaceId + previousSelectedPlaceID = selectedPlaceID - selectedPlaceId = id + selectedPlaceID = id } } diff --git a/LeaveGo/LeaveGo/Shared/APIKeys.swift b/LeaveGo/LeaveGo/Shared/APIKeys.swift index cebae41..e9b9032 100644 --- a/LeaveGo/LeaveGo/Shared/APIKeys.swift +++ b/LeaveGo/LeaveGo/Shared/APIKeys.swift @@ -9,9 +9,9 @@ import Foundation struct APIKeys { static var naverMapClientId: String { - if let clientId = Bundle.main.object(forInfoDictionaryKey: "NMFClientId") as? String, !clientId.isEmpty, - clientId != "NAVER_MAP_CLIENT_ID" { - return clientId + if let clientID = Bundle.main.object(forInfoDictionaryKey: "NMFClientId") as? String, !clientID.isEmpty, + clientID != "NAVER_MAP_CLIENT_ID" { + return clientID } #if DEBUG diff --git a/LeaveGo/LeaveGo/Shared/Extension/CLLocationCoordinate2D+Extension.swift b/LeaveGo/LeaveGo/Shared/Extension/CLLocationCoordinate2D+Extension.swift new file mode 100644 index 0000000..5495b46 --- /dev/null +++ b/LeaveGo/LeaveGo/Shared/Extension/CLLocationCoordinate2D+Extension.swift @@ -0,0 +1,15 @@ +// +// CLLocation+Extension.swift +// LeaveGo +// +// Created by 이치훈 on 1/1/26. +// + +import CoreLocation + +// CLLocationCoordinate2D를 Equatable로 확장 (카메라 위치 비교용) +extension CLLocationCoordinate2D: @retroactive Equatable { + public static func == (lhs: CLLocationCoordinate2D, rhs: CLLocationCoordinate2D) -> Bool { + return lhs.latitude == rhs.latitude && lhs.longitude == rhs.longitude + } +} diff --git a/LeaveGo/Resource/Assets.xcassets/Color/lgSubAccentColor.colorset/Contents.json b/LeaveGo/Resource/Assets.xcassets/Color/lgSubAccentColor.colorset/Contents.json new file mode 100644 index 0000000..b551710 --- /dev/null +++ b/LeaveGo/Resource/Assets.xcassets/Color/lgSubAccentColor.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "display-p3", + "components" : { + "alpha" : "1.000", + "blue" : "111", + "green" : "186", + "red" : "244" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "display-p3", + "components" : { + "alpha" : "1.000", + "blue" : "111", + "green" : "186", + "red" : "244" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +}