MapKit을 사용하여 지도를 불러와 원하는 기능을 구현할 수 있습니다

 

간단하게 지도 사용해보기

MapKit 을 사용하기 위해서 가장 먼저 MapKit 을 불러와야 합니다.

import SwiftUI
import MapKit

 

지도를 불러오는 방법은 간단합니다. 다음과 같이 Map 만 호출하면 됩니다.

- Map 내부에는 Marker(마커) 또는 Annotation(주석) 을 꾸밀 수 있으며 coordinate 는 경도와 위도로 초기화하여 사용됩니다.

struct ContentView: View {
    var body: some View {
        Map {
            Marker("San Francisco City Hall", coordinate: cityHallLocation)
                .tint(.orange)
            Marker("San Francisco Public Library", coordinate: publicLibraryLocation)
                .tint(.blue)
            Annotation("Diller Civic Center Playground", coordinate: playgroundLocation) {
                ZStack {
                    RoundedRectangle(cornerRadius: 5)
                        .fill(Color.yellow)
                    Text("🛝")
                        .padding(5)
                }
            }
        }
        .mapControlVisibility(.hidden)
    }
}

 

나의 위치 파악하기

내 위치 정보 불러오기 위한 기본 설정

나의 현재 위치를 파악하기 위해서는 위치 권한을 얻은 후 LocationManager 를 사용해야 합니다.

더보기

CLLocationManager 객체는 앱의 위치 관련 동작을 관리하는 중앙 관리자입니다.

CLLoactionManager 를 사용하여 위치 서비스를 구성하고 시작 및 멈출 수 있습니다.

* 유저의 현재 위치를 파악할 수 있다.

* 주변 지역에 대한 정보 또는 그 지역에 누가 왔고 떠났는지를 모니터링할 수 있다.

* 주변에 있는 블루투스 비콘을 알아낼 수 있다.

 

1개 또는 여러개의 CLLoactionManager 만들어서 위치 데이터를 활용할 수 있습니다.

location-manager 객체를 만들면 Core Location이 위치가 변화를 감지했을때 어떻게 얼마나 자주 보고를 할 것인지를 설정해야합니다. 특히, 앱의 요구를 반영하는 값으로 distanceFilter desiredAccuracy 를 구성합니다.

distanceFilter: 사용자가 (최소 몇미터) 움직였을때 업데이트를 시켜야하는지

desiredAccuracy: 사용자가 값을 받고싶은 위치의 정확도

 

CLLoactionManager 객체는 모든 위치와 관련된 정보들을 CLLocationManagerDelegate 프로토콜을 준수하는 delegate 객체에 전달합니다.

location manager가 초기화를 마친 후 시스템이 앱의 권한 상태를 delegate의 locationManagerDidChangeAuthorization(_:) 메소드에 보고하기 때문에 location manager를 구성할 때 즉시 delegate를 할당해야 합니다.

CLLocationManager를 사용하는 스레드는 활성 상태인 RunLoop를 갖추어야 하며, 메인 스레드에서 작업하는 것이 일반적입니다.

코드를 보기 전에 우선 Info.plist 를 먼저 설정하여 사용자로부터 위치 권한을 요청합니다.

Location When In Use Usage Description: 앱을 실행 중일 때 위치 서비스를 사용할 권한을 요청합니다.

Location Always Usage Description: 앱이 백그라운드에서도 위치 서비스를 사용할 권한을 요청합니다.

 

이후 LocationManager 를 통해 위치 권한 여부를 확인한 후 권한이 없다면 요청합니다.

 

실수할 수 있는 점이 아래처럼 init() 으로 직접 권한을 요청하는 것이 아닌 Delegate 메서드를 통해 권한을 요청해야 합니다.

안되는 코드

@Observable
@MainActor class LocationManager {
    
    let manager: CLLocationManager // 이게 진짜 LoactionManager이고 위치 권한에 대한 것들을 담당한다.
    
    init() {
        self.manager = CLLocationManager()
        if self.manager.authorizationStatus == .notDetermined {
            // 만약 권한이 없다면
            self.manager.requestWhenInUseAuthorization()
        }
    }
}

 

⭕️ 동작하는 코드

func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) {
    switch manager.authorizationStatus {
    case .notDetermined:
        print("디버깅: notDetermined")
        manager.desiredAccuracy = kCLLocationAccuracyBest
        self.manager.requestWhenInUseAuthorization()
    case .denied:
        print("디버깅: denied")
    case .restricted:
        print("디버깅: restricted")
    case .authorizedWhenInUse:
        print("디버깅: authorizedWhenInUse")
        manager.startUpdatingLocation()
    default:
        print("디버깅: default")
    }
}

 

 

하지만 여기까지만 해서 다음과 같이 권한 요청이 나타나지 않는다면 다음과 같은 방법들을 시도해볼 수 있겠습니다

  1. info.plist 에 NSLocationWhenInUseUsageDescription 를 직접 넣기
    info.plist 에 NSLocationAlwaysUsageDescription 를 직접 넣기 

 

 

UserAnnotation 으로 내 위치 지도에 표시하기 

다음과 같이 지도에 표시하기 위해서는 UserAnnotation() 을 사용해야 합니다.

Map() {
    UserAnnotation()
}

내 위치로 카메라 이동

내 위치로 지도 뷰 이동시키기 위해서는 MapUserLocationButton 을 사용할 수 있겠습니다. 이 버튼은 MapKit 에서 기본적으로 제공하는 버튼으로 다음과 같이 사용될 수 있습니다.

 

아래 코드에서는 @Namespace 프로퍼티를 사용하여 Map의 scope 를 설정하였지만 이것은 Map 의 클로저 외부에서 버튼을 구현하고 싶을 때 Namespace 를 사용합니다.

더보기

@Namespace 는 애니메이션이나 뷰 간 상태 공유를 위해 사용되며 뷰들이 같은 @Namespace 를 공유할 때, 상태가 일관되게 유지하며, 뷰 간의 전환이나 상호작용을 보다 쉽게 관리할 수 있습니다. 
MapUserLocationButtonMapmapControl 내부에서 동작하면 Map과 상호작용이 가능하지만

만약 Map 외부에서 따로 동작을 하기 위해서는 MapUserLocationButton으로 사용자의 위치로 지도를 이동시키려 할 때,

Map 과 버튼이 같은 @Namespace 내에 있어야 올바르게 상호작용할 수 있습니다.

struct LocationButtonTestView: View {
    @Namespace var mapScope
    var body: some View {
        VStack {
            Map(scope: mapScope)
            MapUserLocationButton(scope: mapScope)
        }
        .mapScope(mapScope)
    }
}

🔴 mapScope 를 적용하지 않으면 생기는 문제

이때 mapScope 를 적용하지 않는다면 다음과 같이 UI 적으로도 기능적으로도 제대로 동작하지 않는 것을 확인할 수 있습니다.

 

 

 

만약 mapControls 내부에 구현을 하게 된다면 다음과 같이 Namespace 를 사용하지 않고 구현됩니다.

🔴 중요한 점은 아래처럼 mapControls 내부에 적용하게 된다면 우측 상단에 위치가 고정됩니다.
Map()
    .mapControls {
        MapUserLocationButton()
    }

 

변경되는 위치 불러오기

func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation])

이 메서드는 사용자의 위치가 변경되었을 때 호출되는 콜백 함수입니다. 변경된 위치를 locations 에 배열로 담고 있으며 가장 최신 변경된 위치는 locations 배열의 마지막 원소에 해당합니다.

 

위 콜백함수는 다음과 같이 사용될 수 있습니다.

MKCoordinateRegion 이란 동그란 지역을 나타냅니다.

파라미터로는 centerspan 이 존재하며 center원의 중앙 기준 span확대 정도를 의미합니다.

var region: MKCoordinateRegion = MKCoordinateRegion()

func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
    locations.last.map {
        region = MKCoordinateRegion(center: CLLocationCoordinate2D(latitude: $0.coordinate.latitude, longitude: $0.coordinate.longitude), span: MKCoordinateSpan(latitudeDelta: 0.5, longitudeDelta: 0.5))
    }
}

 

변경되는 위치 UI 에 적용하기

내 위치가 변경될 때마다 나의 위치에 맞게 지도가 움직일 수 있도록 다음과 같이 만들 수 있습니다.

Map(position: $position, scope: mapScope)
...
.onChange(of: locationManager.region, { oldValue, newValue in
    position = .region(newValue)
})

 

탭한 부분 위치 가져오기

지도에서 내가 탭한 부분의 위치를 가져오기 위해서는 MapReader를 사용해야 합니다.

MapReader의  MapProxy 인스턴스를 사용하여 탭한 특정 부분의 경도와 위도를 convert 메서드로 변환할 수 있습니다.

MapReader { reader in
    Map()
        .onTapGesture(perform: { screenCoord in
            if let pinLocation = reader.convert(screenCoord, from: .local) {
                print(pinLocation)
            }
        })
}

 

 

탭한 부분 핀으로 만들기

위에서 탭한 부분의 위치를 가져왔으면 해당 위치를 저장하여 해당 위치를 핀으로 만들 수 있습니다.

핀은 Annotation 을 사용하여 만들 수 있습니다, Annotation은 Map 의 클로저 내부에 들어가 생성됩니다.

init(coordinate: CLLocationCoordinate2D, anchor: UnitPoint, content: () -> Content, label: () -> Label)
: coordinates(좌표), anchor(Content의 위치 설정), content, label 파라미터들을 사용하여 만들 수 있습니다.

@State private var tapPosition: CLLocationCoordinate2D?

MapReader { reader in
    Map(position: $position, scope: mapScope) {
        if let position = tapPosition {
            Annotation(coordinate: position) {
                Text("📍")
            } label: {  }
        }
    }
    .onTapGesture(perform: { screenCoord in
        if let pinLocation = reader.convert(screenCoord, from: .local) {
            print(pinLocation)
            tapPosition = pinLocation
        }
    })
}

 

Marker 마커 만들기

Maker란 pin과는 다릅니다. pin은 사용자가 정의하는 위치 표시 느낌이지만  Marker는 MKMapItem 을 사용하여 장소를 표현합니다. MKMapItem 을 사용하기 때문에 해당 장소에 대한 정보를 바탕으로 아이콘과 색상으로 만들어진 마커를 생성합니다.

 

Marker는 MKMapItem을 사용하지 않는다고 해도 직접 커스텀화 가능합니다.

Marker("Custom Marker", coordinate: CLLocationCoordinate2D(latitude: 37, longitude: 127))
Marker("Custom Marker2", systemImage: "car.fill", coordinate: .init(latitude: 37.0001, longitude: 127.0001))
//Marker("Custom Marker3", Image: "image", coordinate: .init(latitude: 37.0001, longitude: 127.0001))

 

monogram으로 다음과 같이 꾸밀 수도 있습니다.

Marker("Monogram1", monogram: Text("M1"), coordinate: .init(latitude: 37, longitude: 127))
    .tint(.blue)
Marker("Monogram2", monogram: Text("M2"), coordinate: .init(latitude: 37.00001, longitude: 127.00004))

 

 

 

 

전화걸기

전화를 할 수 있는 기능은 다음 코드와 같이 구현됩니다.

func makeCall(phone: String) {
    if let url = URL(string: "tel://\(phone)") {
        if UIApplication.shared.canOpenURL(url) {
            UIApplication.shared.open(url) // 만약에 가능한 전화번호면 전화를 엽니다
        } else {
            print("Device can't make phone calls")
        }
    }
}

 

🟢 버튼으로 만들어 전화걸기

Button(action: {
    if let phone = mapItem.phoneNumber {
        let numericPhoneNumber = phone.components(separatedBy: CharacterSet.decimalDigits.inverted).joined() // 숫자만 표기하도록 설정
        makeCall(phone: numericPhoneNumber)
    }
}, label: {
    HStack {
        Image(systemName: "phone.fill")
        Text("Call")
    }
}).buttonStyle(.bordered)

MKMapItem 이란?

🟢 MKMapItem 은 장소를 표현하기 위한 위치 정보를 포함한 인스턴스

지도에 특정한 지점뿐만 아니라 자신의 위치 또한 MKMapItem 으로 표현할 수 있겠습니다.

지도에서 특정 위치에 정보를 나타내거나 특정 위치를 저장하고 싶을 때에는 openMaps(with: launchOptions:) 메서드를 호출합니다.

openMaps 메서드는 지도 앱을 실행시켜 해당 위치를 사용자가 사용자가 지정한 정보와 함께 보여줍니다.

 

🟢 MKMapItem 을 사용하여 길찾기도 구현 가능

MKMapItem 은 길찾기 앱을 구현할 때에도 마찬가지로 사용되며 2개의 MKMapItem 객체를 사용하여 출발지과 목적지를 지정할 수 있습니다. 이렇게 MKMapItem 을 사용한다면 길찾기를 구현할 수도 있습니다.

 

🔵 MKMapItem 은 다음과 같은 데이터들을 포함하고 있습니다.

 

forCurrentLocation(): 현재 위치를 MKMapItem 로 받을 수 있습니다.

let item = MKMapItem.forCurrentLocation() // 현재 위치를 반환하는 싱글톤 MapItem 객체를 생성합니다

 

placemark: 위치 정보를 담고 있는 MKPlacemark 객체를 반환합니다.

let latitude = mapItem.placemark.coordinate.latitude
let longitude = mapItem.placemark.coordinate.longitude

 

pointOfInterestCategory: 해당 위치에 카테고리를 담고 있습니다.

let category = mapItem.pointOfInterestCategory // 레스토랑, 주유소, 공원 등

 

그외에도 다음과 같은 속성값들이 존재합니다.

var isCurrentLocation (현재 위치와 동일한지) -> Bool
var name: 해당 장소와 연관된 이름
var phoneNumber: 연락처, 전화번호
var url: 해당 장소와 연관된 URL
var timeZone: 장소의 시간대 정보

 

지도 스타일 설정하기

지도에는 3가지 종류의 스타일이 존재합니다.

  • standard
  • hybrid
  • imagery

스타일에 사용되는 파라미터들

- elevation: 사물을 2D로 만들지 좀 더 현실적으로 만들지 결정할 수 있는 파라미터 입니다. (automatic, flat, realitic)
- pintsOfInterest: 지도 위에 표시할 관심 지점 카테고리를 정의할 수 있습니다.
- showsTraffic: 교통상황을 보여줄지를 결정하는 Bool 타입 옵션입니다

 

.standard(elevation: .automatic, pointsOfInterest: .some([.restaurant, .hospital]), showsTraffic: True)

1번 그림(standart), 2번 그림(hybrid), 3번 그림(imagery)

Map()
    .mapStyle(.hybrid(elevation: .realistic))

 

 

장소 검색하기

장소를 검색하기 위해서는 MKLocalSearch 클래스를 사용해야 합니다.

 MKLocalSearch 클래스를 사용하면 단일 검색 요청을 실행할 수 있으며 검색을 통해 주소를 얻으 수 있습니다. 검색이 완료되면 클래스 객체는 completion handler를 통해 검색 결과를 전달합니다.
MKLocationSearch 클래스는 MKLocalSearch.Request를 통해 검색을 하기 때문에 해당 Request를 만들어야 합니다.

 

MLLocalSearch.Request 만들기

naturalLanguageQuery: 검색하고자 하는 String 검색어

resultTypes: 검색 결과의 유형을 결정할 수 있으며 address(주소) 와 pointOfInterest(관심지점) 가 존재합니다.

region: 어떤 지역을 기준으로 검색을 할 것인지 설정할 수 있습니다.

 - center: 예를 들어 주차장 인근의 놀이터를 찾고 싶을 때 center에 값이 들어갑니다.

 - span: 어떤 지역의 범위를 설정합니다.

 

만든 Request로 검색하기

Request를 만든 후 검색을 하기 위해서는 간단하게 start() 메서드를 사용하면 됩니다.

 - 만일 검색을 취소하고 싶으면 cancle() 메서드를 호출하면 됩니다.

@Binding private var searchResults: [MKMapItem]

func search(for query: String) {
    let request = MKLocalSearch.Request()
    request.naturalLanguageQuery = query
    request.resultTypes = .pointOfInterest
    request.region = MKCoordinateRegion(
        center: tapPosition!, span: MKCoordinateSpan(latitudeDelta: 0.0125, longitudeDelta: 0.0125))

    Task {
        let search = MKLocalSearch(request: request)
        let response = try? await search.start()
        searchResults = response?.mapItems ?? []
    }
}

 

검색어 자동완성

검색창에서 연관 검색어 보이게하기 위해서는 MKLocalSearchCompleter를  을 사용해야 합니다.

MKLocalSearchCompleter 는 일부 String 으로 사용자가 원하는 특정 장소를 검색하는데 도와주는데 특정 장소들은 MKLocalSearchCompletion 값으로 반환됩니다.

인스턴스와 delegate 설정하기

가장 먼저 자동완성을 지원하는 completer 의 인스턴스를 만들어야 합니다.

이후 클래스를 delegate 로 만들어 completer 의 변화를 관찰할 수 있도록 합니다.

@Published var searchCompletions: [MKLocalSearchCompletion] = []
private var searchCompleter: MKLocalSearchCompleter
    
override init() {
    self.searchCompleter = MKLocalSearchCompleter()
    super.init()
    self.searchCompleter.delegate = self
}

 

검색할 단어 적용하기

검색할 단어를 적용하기 위해서는 completer 에 원하는 특정 조건을 넣어 값을 가져옵니다. 

아래에서는 String 을 조건으로 설정하였지만 그 외에도 아래와 같이 여러 타입을 조건문으로 사용할 수 있습니다.

더보기

var addressFilter: MKAddressFilter?

 - 특정 국가나 지역을 제외하거나 포함하는 등의 주소 필터링을 설정할 때 사용

var queryFragment: String

 - 자동완성 결과를 받을 String 의 검색어 

searchCompleter.queryFragment = "Starbucks"

var region: MKCoordinateRegion

 - 검색할 지역의 지리적 범위를 설정하는 속성

searchCompleter.region = MKCoordinateRegion(center: CLLocationCoordinate2D(latitude: 37.7749, longitude: -122.4194), span: MKCoordinateSpan(latitudeDelta: 0.05, longitudeDelta: 0.05))

var regionPriority: MKLocalSearchRegionPriority

 - 설정된 지역의 중요도를 지정하는 속성으로 지역의 priority 를 높이면 지역 내 결과가 우선 표시

   • default: 기본 우선순위

   • max: 해당 지역 범위를 강력하게 우선

searchCompleter.regionPriority = .max

var resultTypes: MKLocalSearchCompleter.ResultType

 - 검색 결과에 포함할 항목 유형을 정의하는 속성으로 특정한 유형의 결과만 검색하고자 할 때 사용.

  • address: 주소 관련 검색 결과

  • pointOfInterest: 명소 관련 검색 결과

  • query: 기존 검색 쿼리에서의 자동완성 결과

searchCompleter.resultTypes = [.address, .pointOfInterest]

var pointOfInterestFilter: MKPointOfInterestFilter?

 - 특정 명소 카테고리를 포함하거나 제외하는 필터를 설정하는 속성 

let filter = MKPointOfInterestFilter(including: [.cafe, .park])
searchCompleter.pointOfInterestFilter = filter
 
$text
    .debounce(for: .milliseconds(300), scheduler: RunLoop.main)
    .sink(receiveValue: { [weak self] query in
        self?.searchCompleter.queryFragment = query
    })
    .store(in: &searchCancellable)

 

자동완성된 값 불러오기

자동완성된 값은 delegate 콜백 함수를 통해 받아올 수 있습니다.

extension SearchViewModel: MKLocalSearchCompleterDelegate {
    func completerDidUpdateResults(_ completer: MKLocalSearchCompleter) {
        self.searchCompletions = completer.results
    }
}

 

 

카메라뷰 이동시키기

특정 동작을 한 후 자동으로 카메라 뷰를 이동시키고 싶을 때가 있습니다, 예를들어 지도에서 다른 곳을 보고 있다가 주변 카페를 검색했을 때 주변 카페를 기준으로 카메라를 이동시키고 싶은 상황이 있겠습니다.

 

다음과 같은 설정으로 검색 결과를 기준으로 위치를 지동으로 업데이트할 수 있습니다.

@State private var position: MapCameraPosition = .automatic

Map(position: $position)
    .onChange(of: searchResults) { oldValue, newValue in
        position = .automatic
    }

 

 

 

아니면 특정 위치를 설정하여 position 바인딩을 통해 원하는 위치로 이동시킬 수도 있습니다.

A뷰

@State private var position: MapCameraPosition = .automatic

var body: some View {
    // 검색을 위한 뷰
	SearchButton(position: $position, searchResults: $searchResults)
    // position을 사용하여 카메라 위치 조절 가능
	Map(position: $position, scope: mapScope)
}

 

B뷰

@Binding var position: MapCameraPosition

Button {
    position = .region(.init(center: .init(latitude: 127.1, longitude: 37.1), latitudinalMeters: .greatestFiniteMagnitude, longitudinalMeters: .greatestFiniteMagnitude))
} label: {
    Text("이동")
}

 

MapCamera

지도 앱에서 표시되는 것들은 모두 MapCamear 가 관리합니다.

MapCameraPosition 카메라 위치

카메라(MapCamara)는 특정한 거리를 둔 채 좌표를 계산하며 카메라의 방향(MapCameraPosition)이 지도에 보이는 것을 결정합니다.

MapCamera를 만들거나 구성하는 대신에 MapCameraPosition을 사용하면 표시해야 할 것만 따로 특정시킬 수 있습니다.

MapCameraPosition 은 어떤 지역이나 장소가 표시될지를 조절할 수 있습니다

 

MapCameraPosition 설정하기

다음은 MapCameraPosition 설정하는 방법들입니다

  • camera(MapCamera): MapCamera 에 따른 MapCameraPosition 를 생성합니다.
  • region(MKCoordinateRegion): MKCoordinateRegion 에 따른 MapCameraPosition 를 생성합니다.
  • rect(MKMapRect): MKMapRect 에 따른 MapCameraPosition 를 생성합니다.
  • item(MKMapItem): MKMapItem 에 따른 MapCameraPosition 를 생성합니다.
  • userLocation(): 사용자의 위치에 따른 MapCameraPosition 를 생성합니다

 

camera 로 MapCameraPosition 생성하기

distance: 카메라가 중심 좌표에서 떨어진 거리를 미터 단위로 설정

heading: 카메라가 바라보는 방향(헤딩)을 설정. 값은 degrees 로 지정되며 242는 대략 서남서 방향입니다.

pitch: 카메라의 각도(pitch) 를 설정합니다. 카메라가 땅을 향해 기울어진 각도를 나타냅니다.

position = .camera(
    MapCamera(centerCoordinate:
        CLLocationCoordinate2D(
            latitude: 42.360431,
            longitude: -71.055930
        ),
        distance: 980,
        heading: 242,
        pitch: 60
    )
)

 

이때 pitch(각도)를 잘 설정하면 아래처럼 3D 화면으 표현하기 좋습니다.

 

userLocation 사용하여 사용자 위치로 MapCameraPosition 설정하기

지도의 카메라 위치를 사용자 위치로 설정하는 것을 의미합니다.

fallback: automatic은 만약 사용자의 위치를 추적하지 못하거나 오류가 발생했을 경우, fallback 옵션으로 대체할 위치를 지정합니다.

position = .userLocation(fallback: .automatic)

 

userLocation으로 설정했을 때 변화하는 프로퍼티 값

 

카메라가 사용자의 위치를 따라오고 있는지 나타내는 followsUserLocation 은 true 로 변경됩니다.

 - 카메라의 위치 상태를 userLocation 으로 설정한다면 true 아니면 false 값을 갖게 됩니다.

position.followsUserLocation == true

 

만약 사용자가 화면을 이동하면 카메라는 더 이상 따라오지 않고 positionedByUser 는 true 로 변경됩니다.

 - 즉 사용자가 지도와 상호작용은 한다면 true 값으 갖게 됩니다.

position.positionedByUser == true

 

 

카메라가 이동된 후 지역 검색

여행을 했을 때 특정 위치의 카페를 찾고 싶을 수 있습니다. 예를들어 화면을 드래그하여 다른 지역으로 이동 후 이동된 해당 지역에서 검색을 해야하는 경우가 존재합니다. 이런 상황에서는 어떻게 해야하는지 다루겠습니다.

 

🟢 onMapCameraChange 메서드로 지도 이동을 감지합니다.

onMapCameraChange 메서드는 지도 카메라의 프레임이 변경을 감지하고 동작을 수행합니다.

frequency: 클로저의 동작이 언제 수행될 지 지정하는 파라미터 입니다.

 - continuous(상호작용 내내 수행)

 - onEnd(변경이 끝났을 때에 수행)

 

클로저로 받는 인자값(MapCameraUpdateContext)은 다음과 같이 사용됩니다.

우선 이동된 지역을 저장하기 위한 프로퍼티 visibleRegion 을 만들어줍니다.

이후 onMapCameraChange 메서드를 사용하면 클로저로 받는 인수값을 사용하여 이동된 카메라 기준의 지역을 저장합니다.

@State private var visibleRegion: MKCoordinateRegion?
// @State private var visibleCamera: MapCamera?
// @State private var visibleRect: MKMapRect?
Map()
    .onMapCameraChange { context in
        visibleRegion = context.region
        // visibleCamera = context.camera
        // visibleRect = context.rect
    }

 

🟢 변경된 region 프로퍼티 값을 사용하여 검색

 

이후 지도의 변경이 있다면 onMapCameraChange 로 인해 visibleRegion 으로 저장된 지역을 가지고 MKLocalSearch.Request() 에 사용하여 검색합니다.

var visibleRegion: MKCoordinateRegion?

func search(for query: String) {
    let request = MKLocalSearch.Request()
    request.naturalLanguageQuery = query
    request.resultTypes = .pointOfInterest
    request.region = visibleRegion ?? MKCoordinateRegion(
        center: locationManager.region.center, span: MKCoordinateSpan(latitudeDelta: 0.0125, longitudeDelta: 0.0125))

    Task {
        let search = MKLocalSearch(request: request)
        let response = try? await search.start()
        searchResults = response?.mapItems ?? []
        print(searchResults)
    }

    locationManager.requestLocation()
}

 

 

마커

마커 선택할 수 있게 만들기

마커를 선택할 수 있으려면 선택된 마커를 저장하기 위한 프로퍼티 래퍼를 만들어야 합니다.

@State private var selectedResult: MKMapItem?

 

Map 에 selection 파라미터에 바인딩 프로퍼티를 넣게 된다면 선택된 마커를 저장할 수 있을 뿐만 아니라 강조도 할 수 있게 됩니다.

Map(position: $position, selection: $selectedResult, scope: mapScope)

 

 

마커를 탭하게 된다면 다음과 같이 선택되었다고 알려 주는 애니메이션이 나옵니다.

 

tag 를 사용하여 마커 저장하기

tag 를 사용한다면 커스텀 마커를 따로 사용 및 focused 할 수도 있습니다.

@State private var selectedTag: Int?

var body: some View {

    Map(selection: $selectedTag) {
        Marker(item: mapItem)
            .tag(1)
            
        Marker("Not a MapItem", coordinate: .place)
            .tag(2)
    }
}

 

 

LookAroundPreview 로 장소 미리보기

LookAroundPreview란 아래 사진처럼 장소를 미리볼 수 있는 UI 를 제공하고 있습니다.

 

LookAroundPreview 사용하기

위에서 나온 것처럼 LookAroundPreview 는 다음과 같이 코드로 작성됩니다.

 

initialScene 메서드에 MKLookAroundScene 이 입력받아지면 해당 인스턴스 정보에 맞는 장소 미리보기가 보여집니다.

이후 overlay 혹은 다른 방법을 통해서 해당 뷰에 대한 정보나 걸리는 시간 등을 표현할 수 있습니다.

LookAroundPreview(initialScene: lookAroundScene)
    .overlay(alignment: .bottomTrailing) {
        HStack {
            Text("\(selectedResult.name ?? "")")
            if let travelTime {
                Text(travelTime)
            }
        }
        .font(.caption)
        .foregroundStyle(.white)
        .padding(10)
    }

 

 

🟢 MKLookAroundSceneRequestMKLookAroundScene 가져오기

MKLookAroundScene 데이터를 받기 위해서는 MKLookAroundSceneRequest 를 사용해야 합니다.

 

MKLookAroundSceneRequest 에 MKMapItem 인 selectedResult 를 담으면 MKLookAroundScene 를 반환하는 Request 객체를 만들 수 있으며 해당 객체의 scene 을 통해서 MKLookAroundScene 을 받아 LookAroundPreview에 사용할 수 있습니다.

func getLookAroundScene() {
    lookAroundScene = nil
    Task {
        let request = MKLookAroundSceneRequest(mapItem: selectedResult)
        lookAroundScene = try? await request.scene
    }
}

 

 

 

LookAroundPreview 전체 코드

struct LookAround: View {
    @State private var lookAroundScene: MKLookAroundScene?
    var selectedResult: MKMapItem
    var route: MKRoute?
    
    var body: some View {
        LookAroundPreview(initialScene: lookAroundScene)
            .overlay(alignment: .bottomTrailing) {
                HStack {
                    Text("\(selectedResult.name ?? "")")
                    if let travelTime {
                        Text(travelTime)
                    }
                }
                .font(.caption)
                .foregroundStyle(.white)
                .padding(10)
            }
            .onAppear {
                getLookAroundScene()
            }
            .onChange(of: selectedResult) { oldValue, newValue in
                getLookAroundScene()
            }
    }
    
    func getLookAroundScene() {
        lookAroundScene = nil
        Task {
            let request = MKLookAroundSceneRequest(mapItem: selectedResult)
            lookAroundScene = try? await request.scene
        }
    }
    
    private var travelTime: String? {
        guard let route else { return nil }
        let formatter = DateComponentsFormatter()
        formatter.unitsStyle = .abbreviated
        formatter.allowedUnits = [.hour, .minute]
        return formatter.string(from: route.expectedTravelTime)
    }
}

 

 

길찾기

MKDirection.Request 으로 경로 계산하기

🟢 MKDirection.Request 으로 경로 계산할 수 있습니다

길찾기 기능을 구현하기 위해서는 해당 경로를 계산 요청하기 위한 MKDirections.Request() 이 필요합니다.

MKDirection.Request 는 다음과 같은 요청 옵션으로 구성될 수 있습니다.

 

더보기

var source: MKMapItem?

        경로의 시작점을 지정할 수 있습니다.

 

var destination: MKMapItem?

        경로의 목적지를 지정할 수 있습니다.

 

var transportType: MKDirectionsTransportType

        교통수단의 타입을 지정할 수 있습니다. (automobile, walking, transit, any)

 

var highwayPreference: MKDirections.RoutePreference

        고속도로 선호 여부를 지정할 수 있습니다. (avoid, any)

 

var tollPreference: MKDirections.RoutePreference

        톨비 요금소 선호 여부를 지정할 수 있습니다.

 

var requestsAlternateRoutes: Bool

        여러개의 경로가 가능할 때 해당 경로들을 나타낼 지를 지정할 수 있습니다

 

var departureDate: Date? & var arrivalDate: Date?

        출발 시간과 도착 시간을 설정합니다

 

 

다음은 MKDirection.Request 를 사용하여 경로를 계산하는 함수를 구현한 코드입니다.

var route: MKRoute?

func getDirections() {
    route = nil
    guard let selectedResult else { return }

    let request = MKDirections.Request()
    request.source = MKMapItem(placemark: MKPlacemark(coordinate: .init(latitude: 36, longitude: 127)))
    request.destination = selectedResult

    Task {
        let directions = MKDirections(request: request)
        let response = try? await directions.calculate()
        route = response?.routes.first
    }
}

 

애플맵으로 길찾기

길을 찾기 위해서는 목적지를 정해야 합니다. 그렇기 위해서 지도에서 탭을 한 지점을 기준으로 길을 찾겠습니다.

원하는 목적지를 저장하기 위해서는 MKMapItem 타입으로 저장해야 합니다.

@State var mapItem: MKMapItem = .forCurrentLocation()

.onTapGesture(perform: { screenCoord in
    if let pinLocation = reader.convert(screenCoord, from: .local) {
        print(pinLocation)
        tapPosition = pinLocation
        mapItem = MKMapItem(placemark: MKPlacemark(coordinate: pinLocation))
    }
})

 

이후 저장된 목적지를 찾기 위해서는 다음 코드를 사용하면 됩니다. 아래 버튼을 누르게 되면 해당 위치의 길을 Apple Map 으로 길찾기가 실행됩니다.

let mapItem: MKMapItem

Button(action: {
    MKMapItem.openMaps(with: [mapItem]) // 애플맵을 열어서 경로를 안내한다
}, label: {
    HStack {
        Image(systemName: "car.circle.fill")
        Text("Take me there")
    }
}).buttonStyle(.bordered)
    .tint(.green)

 

MKMapItem 에서 launchOptions 사용

openMaps 에는 launchOptions 를 사용하면 애플 맵을 열 때 추가적인 동작을 수행하도록 만들 수 있습니다.

MKMapItem.openMaps(with:[MKMapItem], launchOptions: [String : Any]?)
let mapItem: MKMapItem
mapItem.openInMaps(launchOptions: [String : Any]? = nil) -> Bool

 

🔵 launchOptions 의 종류

더보기

MKLaunchOptionsDirectionsModeKey

설명: 지도가 열릴 때 사용자가 선택한 교통수단에 맞춰 경로를 제시하는 모드입니다.

  • MKLaunchOptionsDirectionsModeDriving (자동차 모드)
  • MKLaunchOptionsDirectionsModeWalking (도보 모드)
  • MKLaunchOptionsDirectionsModeTransit (대중교통 모드)
let launchOptions = [MKLaunchOptionsDirectionsModeKey: MKLaunchOptionsDirectionsModeDriving]
mapItem.openInMaps(launchOptions: launchOptions)

 

MKLaunchOptionsMapTypeKey

설명: 지도가 열릴 때 지도 유형을 설정할 수 있는 키입니다. 예를 들어, 기본 지도(standard), 위성 지도(satellite), 혼합 지도(hybrid) 중에서 선택할 수 있습니다.

 

  • MKMapType.standard.rawValue (기본 지도)
  • MKMapType.satellite.rawValue (위성 지도)
  • MKMapType.hybrid.rawValue (혼합 지도)
let launchOptions = [MKLaunchOptionsMapTypeKey: MKMapType.satellite.rawValue]
mapItem.openInMaps(launchOptions: launchOptions)​
 

 

MKLaunchOptionsMapCenterKey

설명: 지도를 열 때 중심으로 설정할 좌표를 지정하는 키입니다. 이 값을 사용하면 지도가 특정 좌표를 중심으로 표시됩니다.

let centerCoordinate = CLLocationCoordinate2D(latitude: 37.7749, longitude: -122.4194)
let launchOptions = [MKLaunchOptionsMapCenterKey: NSValue(mkCoordinate: centerCoordinate)]
mapItem.openInMaps(launchOptions: launchOptions)

 

MKLaunchOptionsMapSpanKey

설명: 지도가 열릴 때 어느 정도의 범위를 보여줄지를 지정할 수 있는 키입니다. 이 값은 위도와 경도의 범위를 정의하여 지도의 줌 수준을 설정합니다.

let span = MKCoordinateSpan(latitudeDelta: 0.05, longitudeDelta: 0.05)
let launchOptions = [MKLaunchOptionsMapSpanKey: NSValue(mkCoordinateSpan: span)]
mapItem.openInMaps(launchOptions: launchOptions)

 

MKLaunchOptionsShowsTrafficKey

설명: 지도에서 실시간 교통 정보를 표시할지 여부를 결정하는 키입니다. 이 값을 통해 교통 상황을 지도로 보여줄지 말지를 설정할 수 있습니다. (동작하는지 여부는 불확실)

let launchOptions = [MKLaunchOptionsShowsTrafficKey: true]
mapItem.openInMaps(launchOptions: launchOptions)

 

MKLaunchOptionsCameraKey

설명: 특정한 카메라 뷰로 지도를 보여줄 수 있는 키입니다. 이 값은 MKMapCamera 객체로, 사용자가 보는 각도나 방향을 제어할 수 있습니다.

let camera = MKMapCamera(lookingAtCenter: CLLocationCoordinate2D(latitude: 37.7749, longitude: -122.4194), fromDistance: 1000, pitch: 45, heading: 0)
let launchOptions = [MKLaunchOptionsCameraKey: camera]
mapItem.openInMaps(launchOptions: launchOptions)

 

 

 

 

계산된 경로 사용하기 MKRoute

🟢 반환된 경로 정보는 MKRoute 로 반환됩니다

MKRoute는 출발지와 도착지 사이의 계산된 경로에 대한 정보를 정의하는 객체입니다.

이 클래스는 MKDirections.Reponse 객체에 의해 생성된 경로이며 다음과 같은 프로퍼티와 메서드들이 존재합니다.

 

더보기

var polyline: MKPolyline

       경로의 세부적인 지리적 선형 정보를 나타냅니다.

 

var steps: [MKRoute.Step]

       경로의 각 단계를 나타내는 배열입니다. MKRoute.Step 객체로 경로를 나누어 단계별로 설명합니다.
       각각의 Step은 한 부분의 경로를 설명하며, 예를 들어 "오른쪽으로 500m 이동" 같은 지시사항이 포함될 수 있습니다.

 

var name: String

       경로에 할당된 이름입니다. 도로 이름이나 루트 이름이 포함됩니다.

 

var hasHighways: Bool

       경로에 고속도로가 포함되어 있는지 여부를 나타내는 Bool 값입니다.

 

var expectedTravelTime: TimeInterval

       경로의 소요 시간을 초 단위로 반환합니다.

 

var transportType: MKDirectionsTransportType

       전체적인 경로의 교통 수단을 반환합니다.

 

MapPolygon 으로 경로 선 그리기

🟢 경로 선으로 표시하기

계산된 MKRoute 를 사용하면 경로를 선으로 만들어낼 수 있습니다.

MapPolyline(route)
        .stroke(.blue, lineWidth: 5)

 

 

MKCoordinateRegion, CLLocation 으로 거리 구하기

🟢 두 좌표를 안다면 좌표 간의 거리를 계산할 수 있습니다.

  • MKCoordinateRegion 은 lat,long 정보를 포함하고 있는 center: CLLocationCoordinate2D 를 가지고 있으며 이를 CLLocation 으로 거리를 계산할 수 있습니다.
  • CLLocation 에서는 cooridnate (long, lat) 과 같은 위치 정보 뿐만 아니라 두 좌표의 거리를 m (미터) 단위로 계산해주는 메서드 distance(from: CLLocation) 가 존재합니다.

 

/// 두 좌표 간의 거리를 계산하는 함수
func calculateDistance(lat1: CLLocationDegrees, lon1: CLLocationDegrees,
                       lat2: CLLocationDegrees, lon2: CLLocationDegrees) -> CLLocationDistance {
    let loc1 = CLLocation(latitude: lat1, longitude: lon1)
    let loc2 = CLLocation(latitude: lat2, longitude: lon2)

    return loc1.distance(from: loc2) // 미터 단위로 반환
}
func checkNearMe(cameraRegion: MKCoordinateRegion) -> Bool {
    // 두 좌표 간의 거리를 계산
    manager.requestWhenInUseAuthorization()
    let distance = calculateDistance(lat1: region.center.latitude, lon1: region.center.longitude,
                                     lat2: cameraRegion.center.latitude, lon2: cameraRegion.center.longitude)
    
    if distance > 500 { // 카메라 뷰 중앙으로부터 500미터보다 멀리 떨어져있으면
        return false
    } else {
        return true
    }
}

MapCircle 사용해서 지도에 원 그리기

MapCircle 를 사용한다면 다음처럼 구현하여 원하는 위치에 원을 그릴 수 있습니다.

MapCircle(center: CLLocationCoordinate2D(latitude: 37.7749, longitude: -122.4194), radius: 1000)
        .stroke(Color.blue, lineWidth: 2)
        .foregroundStyle(Color.gray.opacity(0.3))
더보기
init(MKCircle)
 
init(center: CLLocationCoordinate2D, radius: CLLocationDistance)
 
init(mapRect: MKMapRect)

 

 

구글맵에서 제공하는 기본 Annotation 선택하기

🟢 구글 맵에 들어가면 기본적으로 제공되는 Annotation 들과 사용자가 상호작용이 가능합니다

애플 맵에서 기본적으로 제공하는 여러 Annotation들이 있습니다. WWDC22 발표 전까지는 개발자가 만든 Annotation 을 제외한 다른 지도의 Annotation 들과는 상호작용을 하지 못했지만 현재는 가능합니다.

 

Selectable Map Features API

선택을 하기 위해서는 Selectable Map Features API 을 사용해야 합니다.

Selectable Map Features API 에는 다음과 같은 옵션을 제공합니다.

 

  • POI(Points of Interest); 상점, 레스토랑, 랜드마크 등의 관심 지점
  • Territories: 도시, 주 등의 지형
  • Physical features:  산맥, 호수 등

API 를 사용하는 방법은 다음과 같습니다. 

1. 위에서 언급한 3가지 종류 (POI, Territories, Physical features) 중 하나를 선택합니다.

 

 

mapFeatureSelectionContent 로 지도 기본 마커 탭하기

SwiftUI 에서는 mapFeatureSelectionContent 를 사용하여 지도에 존재하는 마커를 탭하여 원하는 정보와 함께 띄우 수 있습니다.

@State private var mapFeature : MapFeature?
var body: some View {
    Map(selection:$mapFeature)
    .mapFeatureSelectionContent { feature in
        if let image = feature.image {
            Marker(feature.title ?? "", coordinate: feature.coordinate)
        }
    }
}

 

🔴 mapFeatureSelectionContent 의 문제점

 

mapFeatureSelectionContent 의 문제점은 MapFeatureMKMapItem동시에 selection 으로 사용될 수 없는 것입니다.

selection 으로는 하나의 바인딩 값만 받기에 지도에 존재하는 기본 마커는 탭이 가능하지만 검색 결과 또는 동적인 사용자 지정 마커 같은 경우에는 탭이 되지 않는다는 것입니다.

 

 

 

 

 

 

 

 

 

 

 

 

ytw_developer