경로의 크기에 맞추어 지도 이미지 생성하기 (MKMapView, MKMapSnapshotter)

러닝앱에서 러닝을 끝내면 러닝 결과를 보여주는 화면이 나타난다. 이때 이 화면에서 지도와 함께 뛴 루트를 보여준다.

이때 지도를 넣고 오버레이를 그리는 방식으로 구현해도 되지만, 지도는 많은 자원을 사용한다. 단순히 경로를 보여주는 역할을 하기에는 자원 사용률이 높아서 과하다고 생각했고, 이를 이미지로 만들어서 보여줄 수 있는 방법이 없을까 했는데, MKMapSnapshotter라는게 있어서 사용해 보았다.

 

MKMapSnapshotter

지도와 지도의 컨텐츠를 이미지로 캡처해 주는 기능을 제공하는 클래스 입니다. 지도를 보여주지만, 상호작용이 필요하지 않을때 사용하는 것을 추천하고 있습니다. 지도를 캡처할 때, 오버레이나 annotation 까지 같이 표시해주지 않습니다. 따라서 캡처한 이미지 위에 오버레이를 표시하고 싶다면, 직접 그려야 합니다. 다행히도, 이를 위한 기능이 제공되며 이를 통해 쉽게 원하는 오버레이나 annotation을 그릴 수 있습니다. 참고로 지도를 캡처하는 작업은 비동기 입니다.

 

MKMapSnapshotter.Options

지도를 캡처 할 때, 옵션을 설정하여 원하는대로 캡처를 할 수 있습니다. MKPointOfInterestFilter를 통해 point of interest(마트, 식당, 카페 등등을 표시하는 글자들)를 본인이 원하는대로 보이게할수도, 안보이게 할 수 있으며, 건물을 보이지 않게 할수도 있습니다.

private func setSnapshotOptions(coordinates: [CLLocationCoordinate2D]) -> MKMapSnapshotter.Options {
        let options = MKMapSnapshotter.Options()
        let filter: MKPointOfInterestFilter = .excludingAll //모든 point of interest가 안보이도록
        
        options.pointOfInterestFilter = filter
        options.size = CGSize(width: 400, height: 400)
        options.showsBuildings = false // 건물 안보이게
        
        return options
    }

원하는 지도 지점 설정하기

MKMapView에서 원하는 지점을 보게하는 방법과 다르지 않습니다. MKCoordinateRegion을 생성해서 MKMapSnapshotter.Options에 넣어주면 됩니다. 그러나 경로 전체를 지도상에서 보여줘야 하기에, 경로의 좌표를 이용해 MKCoordinateRegion를 만들어줍니다. 1.5배를 한 이유는 지도 경계선에 경로의 끝 부분이 붙어있지 않게 하기 위함입니다.

// 러닝 경로 좌표 중심값
func getRunningRouteCenterCoordinate(coordinates: [CLLocationCoordinate2D]) -> CLLocationCoordinate2D? {
        guard coordinates.isEmpty == false || coordinates.count > 1 else { return nil }
        
        var centerLat: CLLocationDegrees = 0
        var centerLon: CLLocationDegrees = 0
        
        for coordinate in coordinates {
            centerLat += coordinate.latitude
            centerLon += coordinate.longitude
        }
        
        centerLat /= Double(coordinates.count)
        centerLon /= Double(coordinates.count)
        
        return CLLocationCoordinate2D(latitude: centerLat, longitude: centerLon)
    }
    
// 최대, 최소 위경도 구해서 MKCoordinateRegion 생성
func makeRouteSizeRegion(center: CLLocationCoordinate2D, coordinates: [CLLocationCoordinate2D]) -> MKCoordinateRegion {
		
        let minLatitude = coordinates.min(by: { $0.latitude < $1.latitude })?.latitude ?? 0
        let maxLatitude = coordinates.max(by: { $0.latitude < $1.latitude })?.latitude ?? 0
        let minLongitude = coordinates.min(by: { $0.longitude < $1.longitude })?.longitude ?? 0
        let maxLongitude = coordinates.max(by: { $0.longitude < $1.longitude })?.longitude ?? 0
        let span = MKCoordinateSpan(latitudeDelta: (maxLatitude - minLatitude) * 1.5, longitudeDelta: (maxLongitude - minLongitude) * 1.5)
        
        return MKCoordinateRegion(center: center, span: span)
    }
    
 //options에 적용
  options.region = makeRouteSizeRegion(...)

 

경로가 그려진 지도 이미지 생성

option의 설정이 모두완료 됬으니, 이제 이미지를 생성할 시간입니다. MKMapSnapshotter를 이용해서 지도 이미지를 생성합니다.

MKMapSnapshotter 객체를 options를 넣어 생성 한 후, completionHandler를 통해 지도 이미지를 가져옵니다. 

func setRouteImage(route coordinates: [CLLocationCoordinate2D]) {
        let options = setSnapshotOptions(coordinates: coordinates)
        let snapShotter = MKMapSnapshotter(options: options)
        snapShotter.start { snapshot, error in
            guard let snapshot = snapshot, error == nil else { fatalError() }
            let mapImage = snapshot.image
        }
    }

이제 지도 이미지에 경로를 그려야 합니다. 경로를 그리는 방법은 CAShapeLayer를 이용하여 그려도 되지만, UIGraphicsImageRenderer를 이용하면 경로가 그려진 이미지를 만들 수 있습니다.  snapshot.point(for:)은 지도의 좌표 값을 현재 이미지 사진의 CGPoint 값으로 변경해 주는 메소드입니다. 이를 이용해 경로 좌표값으로 지도 이미지에 경로를 정확히 그려 넣을 수 있습니다.

func setRouteImage(route coordinates: [CLLocationCoordinate2D]) {
        let options = setSnapshotOptions(coordinates: coordinates)
        let snapShotter = MKMapSnapshotter(options: options)
        snapShotter.start { snapshot, error in
            guard let snapshot = snapshot, error == nil else { fatalError() }
            let mapImage = snapshot.image
            let runningOverlayMapImage = UIGraphicsImageRenderer(size: mapImage.size).image { _ in
                mapImage.draw(at: .zero)
                let points = coordinates.map { snapshot.point(for: $0) }
                let path = UIBezierPath()
                path.move(to: points.first ?? CGPoint(x: 0, y: 0))
                
                for point in points.dropFirst() {
                    path.addLine(to: point)
                }
                
                path.lineWidth = 5
                UIColor.blue.setStroke()
                path.stroke()
            }
        }
    }

이렇게 하면 아래 사진처럼 지도그린 경로를 이미지로 표현 할 수 있습니다. 저는 UIImageView를 서브클래싱 해서 사용했습니다.

전체 코드

import UIKit
import CoreLocation
import MapKit

class CustomRouteMapImageView: UIImageView {
    
    func setRouteImage(route coordinates: [CLLocationCoordinate2D]) {
        let options = setSnapshotOptions(coordinates: coordinates)
        let snapShotter = MKMapSnapshotter(options: options)
        snapShotter.start { snapshot, error in
            guard let snapshot = snapshot, error == nil else { fatalError() }
            let mapImage = snapshot.image
            let runningOverlayMapImage = UIGraphicsImageRenderer(size: mapImage.size).image { _ in
                mapImage.draw(at: .zero)
                let points = coordinates.map { snapshot.point(for: $0) }
                let path = UIBezierPath()
                path.move(to: points.first ?? CGPoint(x: 0, y: 0))
                
                for point in points.dropFirst() {
                    path.addLine(to: point)
                }
                
                path.lineWidth = 5
                UIColor.blue.setStroke()
                path.stroke()
            }
            self.image = runningOverlayMapImage
        }
    }
    
    private func makeRouteSizeRegion(center: CLLocationCoordinate2D, minLatitude: CLLocationDegrees, maxLatitude: CLLocationDegrees, minLongitude: CLLocationDegrees, maxLongitude: CLLocationDegrees) -> MKCoordinateRegion {
        let span = MKCoordinateSpan(latitudeDelta: (maxLatitude - minLatitude) * 1.5, longitudeDelta: (maxLongitude - minLongitude) * 1.5)
        return MKCoordinateRegion(center: center, span: span)
    }
    
    private func getRunningRouteCenterCoordinate(coordinates: [CLLocationCoordinate2D]) -> CLLocationCoordinate2D? {
        guard coordinates.isEmpty == false || coordinates.count > 1 else { return nil }
        
        var centerLat: CLLocationDegrees = 0
        var centerLon: CLLocationDegrees = 0
        
        for coordinate in coordinates {
            centerLat += coordinate.latitude
            centerLon += coordinate.longitude
        }
        
        centerLat /= Double(coordinates.count)
        centerLon /= Double(coordinates.count)
        
        return CLLocationCoordinate2D(latitude: centerLat, longitude: centerLon)
    }
    
    private func setSnapshotOptions(coordinates: [CLLocationCoordinate2D]) -> MKMapSnapshotter.Options {
        let options = MKMapSnapshotter.Options()
        
        let minLatitude = coordinates.min(by: { $0.latitude < $1.latitude })?.latitude ?? 0
        let maxLatitude = coordinates.max(by: { $0.latitude < $1.latitude })?.latitude ?? 0
        let minLongitude = coordinates.min(by: { $0.longitude < $1.longitude })?.longitude ?? 0
        let maxLongitude = coordinates.max(by: { $0.longitude < $1.longitude })?.longitude ?? 0
        options.region = makeRouteSizeRegion(center: getRunningRouteCenterCoordinate(coordinates: coordinates) ?? CLLocationCoordinate2D(latitude: 0, longitude: 0), minLatitude: minLatitude, maxLatitude: maxLatitude, minLongitude: minLongitude, maxLongitude: maxLongitude)
        options.size = CGSize(width: 400, height: 400)
        options.showsBuildings = true
        let filter: MKPointOfInterestFilter = .excludingAll
        options.pointOfInterestFilter = filter
        
        return options
    }
    
}