1. 현재 위치와 함께 MapView 표시하기
1️⃣ 위치 권한 요청 후, 현재 위치 받아오기
Info.plist
<key>NSLocationAlwaysUsageDescription</key>
<string>위치 기반 맛집 검색을 위해 위치 권한 허용이 필요합니다.</string>
<key>NSLocationWhenInUseUsageDescription</key>
<string>위치 기반 맛집 검색을 위해 위치 권한 허용이 필요합니다.</string>
<key>NSLocationAlwaysAndWhenInUseUsageDescription</key>
<string>위치 기반 맛집 검색을 위해 위치 권한 허용이 필요합니다.</string>
LocationService
- 위치 권한 여부를 체크하고, 허용받았을 때 사용자의 위치를 가져오는 Service Class이다.
import CoreLocation
class LocationService: NSObject {
static let shared = LocationService()
@Published var userLocation: (lat: Double, lng: Double) = (0.0, 0.0)
var locationManager = CLLocationManager()
func checkLocationAuthAndGetUserLocation() {
switch locationManager.authorizationStatus {
case .notDetermined:
locationManager.requestWhenInUseAuthorization()
case .restricted:
// 위치 정보 접근 제한 - 관련 액션
case .denied:
// 위치 정보 접근 거부 - 관련 액션 (사용자에게 설정 창 안내해준다든지)
case .authorizedAlways, .authorizedWhenInUse:
fetchUserLocation()
default:
break
}
}
func fetchUserLocation() {
let lat = locationManager.location?.coordinate.latitude
let lng = locationManager.location?.coordinate.longitude
userLocation = (Double(lat ?? 0.0), Double(lng ?? 0.0))
}
}
2️⃣ NaverMapView 가져오기 + 현재 사용자의 위치 오버레이 찍기
NaverMap은 UIKit 기반이기 때문에 SwiftUI에서 사용하려면 UIViewRepresentable 프로토콜을 채택해야한다.
- makeUIView: UIView 객체를 상성하여 반환
- coordinator: UIView에서 delegate 등 복잡한 이벤트 처리가 필요할 때, SwiftUI와 UIKit 간 연결을 도와주는 매개체 역할을 한다.
네이버 지도에 관한 세세한 내용은 공식문서를 첨부해두도록 하고, 간단하게 주석을 통해 설명을 달아두었다.
https://navermaps.github.io/ios-map-sdk/guide-ko/1.html
여기서는 크게 지도와 좌표, 위치 오버레이, 마커, 카메라에 대한 항목을 잘 따라가면서 구현하면 된다.
import SwiftUI
import NMapsMap
import Combine
struct MapView: UIViewRepresentable {
func makeUIView(context: Context) -> NMFNaverMapView {
context.coordinator.getNaverMapView()
}
func updateUIView(_ uiView: NMFNaverMapView, context: Context) {}
func makeCoordinator() -> Coordinator {
Coordinator.shared
}
class Coordinator: NSObject, ObservableObject,
NMFMapViewCameraDelegate,
NMFMapViewTouchDelegate {
static let shared = Coordinator()
let view = NMFNaverMapView(frame: .zero)
private var cancellables = Set<AnyCancellable>()
override init() {
super.init()
initMap()
bindLocationUpdates()
}
private func initMap() {
// 네이버 지도에 관한 기본 정보 세팅하기
view.mapView.positionMode = .direction
view.mapView.isNightModeEnabled = true
view.mapView.zoomLevel = 15
view.mapView.minZoomLevel = 1
view.mapView.maxZoomLevel = 17
view.showLocationButton = true
view.showZoomControls = true
view.showCompass = true
view.showScaleBar = true
// 카메라: 사용자가 지도를 바라보는 틀같은 친구
view.mapView.addCameraDelegate(delegate: self)
view.mapView.touchDelegate = self
}
private func bindLocationUpdates() {
// 위에서 받아오는 userLocation에 바인딩하여, 값이 변경될때마다 특정행동을 할 수 있도록 한다.
LocationService.shared.$userLocation
.receive(on: RunLoop.main)
.sink { [weak self] location in
guard let self = self else { return }
self.updateMapLocation(lat: location.lat, lng: location.lng)
}
.store(in: &cancellables)
}
private func updateMapLocation(lat: Double, lng: Double) {
let cameraUpdate = NMFCameraUpdate(scrollTo: NMGLatLng(lat: lat, lng: lng), zoomTo: 15)
cameraUpdate.animationDuration = 1 // 카메라 이동을 몇초동안 할 것인가?
// locationOverlay: 사용자의 현재 위치를 나타내는 오버레이
// NMGLatLng으로 변환하여 사용자의 현재 위치를 지정해줍니다.
view.mapView.locationOverlay.location = NMGLatLng(lat: lat, lng: lng)
view.mapView.locationOverlay.hidden = false
// 카메라 이동 -> 현재 사용자의 위치로 이동하기
view.mapView.moveCamera(cameraUpdate)
}
func getNaverMapView() -> NMFNaverMapView {
return view
}
}
}
3️⃣ 카카오맵 API를 이용하여 주변 음식점 마커 찍기
사이드 프로젝트에서 사용자의 현재 위치를 받아 그 주변의 음식점들을 받아와야했는데, API 레퍼런스를 찾아봤을 때 카카오맵 API가 적절하다고 생각했다.
하단 카테고리로 로컬 검색 API를 사용했다.
https://developers.kakao.com/docs/latest/ko/local/dev-guide#search-by-category
import SwiftUI
import NMapsMap
import Combine
struct MapView: UIViewRepresentable {
// ... 중략
class Coordinator: NSObject, ObservableObject,
NMFMapViewCameraDelegate,
NMFMapViewTouchDelegate {
static let shared = Coordinator()
@Published var places: [PlaceInfo] = []
private var markers: [NMFMarker] = [] // 지도 위의 마커들을 담을 배열
let infoWindow = NMFInfoWindow() // 지도의 정보창 (음식점 이름 위에 띄울 창)을 컨트롤하는 클래스 객체
let dataSource = NMFInfoWindowDefaultTextSource.data() // 지도의 정보창의 datasource
let view = NMFNaverMapView(frame: .zero)
private var cancellables = Set<AnyCancellable>()
override init() {
super.init()
// 생략
bindLocationUpdates()
}
// 정보를 받아올때
private func bindLocationUpdates() {
LocationService.shared.$userLocation
.receive(on: RunLoop.main)
.sink { [weak self] location in
guard let self = self else { return }
// 사용자의 위치 오버레이를 찍음과 동시에
self.updateMapLocation(lat: location.lat, lng: location.lng)
// 위치 주변의 음식점들을 검색한다.
self.loadNearbyRestaurants(lat: location.lat, lng: location.lng)
}
.store(in: &cancellables)
}
private func loadNearbyRestaurants(lat: Double, lng: Double) {
// 카카오로컬 API - 요청 및 응답 포맷은 문서 참고
RestaurantSerachAPIService.shared.fetchNearbyRestaurants(lat: lat, lng: lng)
.receive(on: RunLoop.main)
.sink(
receiveCompletion: { completion in
if case let .failure(error) = completion {
print("🍽️ 식당 검색 실패: \(error.localizedDescription)")
}
},
receiveValue: { [weak self] places in
self?.addPlaceMarkers(places)
}
)
.store(in: &cancellables)
}
// ... 중략
private func addPlaceMarkers(_ places: [PlaceInfo]) {
// 위치가 바뀔때마다 지도 위 마커를 새롭게 갱신하기 위해, 이전 마커들을 제거한다.
markers.forEach { $0.mapView = nil }
markers.removeAll()
// 마커 클릭 시 액션에 관한 handler
let handler = { [weak self] (overlay: NMFOverlay) -> Bool in
if let marker = overlay as? NMFMarker {
// 정보 창이 열린 마커의 tag를 텍스트로 노출하도록 반환
self?.dataSource.title = marker.userInfo["tag"] as! String
// 마커를 터치할 때 정보창을 엶
self?.infoWindow.open(with: marker)
}
return true
};
for place in places {
guard let lat = Double(place.y), let lng = Double(place.x) else { continue }
let marker = NMFMarker()
marker.position = NMGLatLng(lat: lat, lng: lng)
// 마커를 기본 내장된 마커로 지정할 수도 있고, 커스텀 이미지로 지정할 수도 있다.
marker.iconImage = NMFOverlayImage(name: "chili-pepper")
marker.width = 30
marker.height = 40
marker.mapView = view.mapView
marker.userInfo = ["tag": place.placeName] // 각각의 마커에 정보를 만들어준다.
marker.touchHandler = handler // 클릭 시 핸들러 달기
markers.append(marker)
}
}
// 지도를 탭할 때, 모든 정보창들을 없앨 수 있도록 한다.
func mapView(_ mapView: NMFMapView, didTapMap latlng: NMGLatLng, point: CGPoint) {
infoWindow.close()
}
}
}
여기까지 진행하면 아래와 같이 나타난다. (오른쪽 주황색 버튼은 새로고침 버튼으로 필자가 커스텀한 버튼임)