본문 바로가기
iOS/개발

[Swift] iOS 네이버 지도 SDK - 지도 뷰 커스텀

by 바등쪼 2023. 7. 12.

https://lsj8706.tistory.com/46

이전 글인 지도 마커(Marker) 커스텀에서 이어집니다.

 

기능 요구 사항

네이버 지도 SDK를 활용한 앱에서 지도를 커스텀한 과정을 공유해 보고자 합니다.

프로젝트의 주요 서비스는 달리기 코스를 직접 그리고 공유하는 기능이었습니다!

따라서 지도가 필요했고 저는 네이버 지도 SDK를 선택하여 사용했습니다.

 

당연하게도 디자이너분들이 SDK의 기본 UI가 아닌 저희 프로젝트만의 UI를 만들어 주셨고 저는 iOS 네이버 지도 SDK를 활용하여 요구사항에 맞는 지도 뷰를 구현해야 했습니다..!

 

순서대로 1, 2, 3번 뷰로 부르겠습니다.
순서대로 4, 5, 6번 뷰로 부르겠습니다.

 

위처럼 대충보기에는 비슷하지만 세부 기능과 형태가 다른 지도 뷰들이 요구사항으로 들어왔습니다.

마지막 5, 6번 뷰의 지도는 이미지 뷰입니다. 따라서 사용자가 그린 지도를 이미지로 변환하여 서버로 전송하고 받아오는 로직도 필요했습니다. (해당 로직은 이번 포스팅에 포함하지 않고 지도 뷰의 커스텀에 집중하겠습니다.)

 

요구 사항들을 정리하면 다음과 같습니다.

  1. 1번 뷰에 보이듯이 사용자의 현재 위치 오버레이가 존재합니다. (오버레이 이미지 지정 필요)
  2. 1번 뷰의 우측 하단에 있는 버튼을 클릭하면 지도가 보여주는 부분이 사용자의 위치로 이동해야 합니다. (Camera move)
  3. 2번 뷰는 러닝 코스를 그리는 뷰입니다. 사용자가 터치를 하면 마커가 생성되고 마커들끼리 경로 선이 이어져야 합니다. (우선 마커의 최대 개수는 20개로 제한합니다.)
  4. 각 마커들끼리의 거리를 더해 경로의 총 길이를 구해야 합니다. (km 단위)
  5. 러닝 코스의 출발지 마커는 고정이고 경유지 마커와 UI가 다릅니다. (이전 포스팅 참고)
  6. 3번 뷰는 완성한 러닝 코스를 달리고 있을 때 보여주는 화면입니다. 1번 뷰처럼 사용자의 위치 오버레이가 필요합니다. (이때는 지도를 클릭해도 마커 생성 X)
  7. 4번 뷰는 다른 사람들이 그리고 공유한 러닝 코스 또는 사용자게 이전에 그린 코스를 보는 화면입니다. (사용자는 터치하여 마커 생성 불가)
  8. 5, 6번 뷰는 이전에 그려진 코스를 이미지로 보여주는 화면입니다. (지도 뷰를 이미지로 변환해야 함)

 

이러한 요구 사항들을 만족하면서 여러 곳의 ViewController에서 사용할 Map 뷰가 필요했습니다.

각 VC마다 새로 네이버 지도 뷰를 생성해서 세팅을 하는 것은 매우 비효율적입니다.

 

따라서 요구 사항에 맞는 새로운 지도 객체를 생성하고 이 객체가 네이버 지도인 NMFNaverMapView를 감싸 추상화 하도록 구상했습니다.

 


 

구현

다양한 기능을 수행하기 때문에 코드의 길이가 짧지 않습니다.

조금씩 끊어서 기술하겠습니다!

 

모든 기능은 iOS 네이버 지도 SDK 공식 문서를 바탕으로 구현했습니다!!

Combine을 부분적으로 사용했습니다.

 

클래스 선언과 프로퍼티

import UIKit
import CoreLocation
import Combine

import NMapsMap
import SnapKit
import Then

final class RNMapView: UIView {
    
    // MARK: - Properties
    
    @Published var pathDistance: Double = 0
    @Published var markerCount = 0
    
    private let screenWidth = UIScreen.main.bounds.width
    private let screenHeight = UIScreen.main.bounds.height
    
    let pathImage = PassthroughSubject<UIImage?, Never>()
    private var cancelBag = Set<AnyCancellable>()
    
    private let locationManager = CLLocationManager()
    private var isDrawMode: Bool = false
    private var markers = [RNMarker]() {
        didSet {
            markerCount = markers.count + 1
            self.makePath()
        }
    }
    /// startMarker를 포함한 모든 마커들의 위치 정보
    private var markersLatLngs: [NMGLatLng] {
        [self.startMarker.position] + self.markers.map { $0.position }
    }
    private var bottomPadding: CGFloat = 0
    private let locationOverlayIcon = NMFOverlayImage(image: ImageLiterals.icLocationOverlay)   // 현재 위치 오버레이
    
    // MARK: - UI Components
    
    let map = NMFNaverMapView()
    private var startMarker = RNStartMarker()
    private let pathOverlay = NMFPath()
    private let moveToUserlocationButton = UIButton(type: .custom) // 현재 사용자 위치로 화면 이동하는 버튼
    
    // MARK: - initialization
    
    public init() {
        super.init(frame: .zero)
        self.mapInit()
    }
    
    private func mapInit() {
        setUI()
        setLayout()
        setDelegate()
        setMap()
        getLocationAuth()
        setPathOverlay()
        setLocationOverlay()
    }
    
    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
}

프로젝트의 이름인 Runnect에서 따와 RNMapView로 이름 지었습니다.

UIView를 상속받습니다.

 

프로퍼티

pathDistance는 경로의 길이를 담을 변수입니다. VC에서 바인딩하여 사용하기 위해 컴바인을 이용하여 @Published로 선언했습니다.

markerCount는 지도 객체에 생성된 마커의 개수를 나타내는 변수입니다. 이 역시 Publisher로 선언했습니다.

 

pathImage는 지도를 이미지로 전환한 것을 담고 있는 PassthroughSubject입니다.

사용자의 위치 정보를 받아와야 하기 때문에 CLLocationManager 인스턴스를 생성했습니다.

isDrawMode는 지도가 편집 모드인지를 나타냅니다. false로 설정하면 지도를 터치해도 아무런 반응이 발생하지 않습니다.

 

markers는 경유지 마커들을 담은 어레이입니다. didSet을 사용하여 이 어레이가 바뀌면 markerCount도 자동으로 바뀌도록 했습니다. 또한 makePath()를 호출하여 자동으로 마커들 사이에 경로선을 그리도록 했습니다.

markersLatLngs는 출발지 마커와 경유지 마커를 모두 포함한 위치 정보를 담는 어레이입니다. NMGLatLng 타입은 네이버 지도 SDK에 선언된 위치(좌표) 정보를 나타내는 클래스입니다.

 

bottomPadding은 버튼과 같은 기타 UI들의 하단 패딩을 조절하기 위한 값을 나타냅니다.

locationOverlayIcon은 현재 위치 오버레이를 위한 비트맵 이미지 객체입니다. UIImage로 생성하여 메모리를 관리했습니다.

 

 

UI Components

map은 네이버 지도 뷰 클래스입니다. 이 객체를 감싸서 추가적인 기능을 제공하는 것이 목표입니다.

startMarker는 출발지 마커입니다. 각 지도당 1개만 존재합니다.

pathOverlayNMFPath 객체이고 지도에 경로선을 나타내는 오버레이입니다. (참고)

moveToUserlocationButton는 현재 사용자 위치로 화면을 이동시키는 버튼입니다.

 

 

 

메서드

UI 및 레이아웃 세팅

// MARK: - UI & Layout

extension RNMapView {
    private func setUI() {
        self.backgroundColor = .white
        self.moveToUserlocationButton.setImage(ImageLiterals.icMapLocation, for: .normal)
        self.moveToUserlocationButton.isHidden = true
        self.moveToUserlocationButton.addTarget(self, action: #selector(locationButtonDidTap), for: .touchUpInside)
    }
    
    private func setLayout() {
        addSubviews(map, moveToUserlocationButton)
        
        map.snp.makeConstraints { make in
            make.edges.equalToSuperview()
        }
        
        moveToUserlocationButton.snp.makeConstraints { make in
            make.bottom.equalToSuperview().inset(88+bottomPadding)
            make.trailing.equalToSuperview().inset(12)
        }
    }
    
    private func updateSubviewsConstraints() {
        [moveToUserlocationButton].forEach { view in
            view.snp.updateConstraints { make in
                make.bottom.equalToSuperview().inset(98+bottomPadding)
            }
        }
    }
}

// MARK: - @objc Function

extension RNMapView {
    @objc func locationButtonDidTap() {
        self.setPositionMode(mode: .direction)
    }
}

UI 요소들의 색상과 이미지 등을 지정합니다.

SnapKit을 사용하여 레이아웃을 잡았습니다.

현재 사용자 위치로 화면을 이동시키는 moveToUserlocationButton의 레이아웃을 잡고 bottomPadding을 설정한다면

updateSubviewsConstraints를 호출하여 새로 패딩이 적용된 새로운 레이아웃으로 업데이트하도록 했습니다.

 

해당 버튼을 클릭하면 setPositionMode 함수가 실행되도록 했습니다. 이 함수의 설명도 밑에 이어집니다.

 

Naver 지도 관련 세팅

    private func setDelegate() {
        locationManager.delegate = self
        locationManager.desiredAccuracy = CLLocationAccuracy.greatestFiniteMagnitude
        locationManager.requestWhenInUseAuthorization()
        
        map.mapView.addCameraDelegate(delegate: self)
        map.mapView.touchDelegate = self
    }
    
    private func setPathOverlay() {
        pathOverlay.width = 4
        pathOverlay.outlineWidth = 0
        pathOverlay.color = .m1
    }
    
    private func setLocationOverlay() {
        let locationOverlay = map.mapView.locationOverlay
        locationOverlay.icon = locationOverlayIcon
    }

locationManager와 지도 관련 delegate를 설정합니다.

사용자의 위치 정보 권한을 요청합니다.

setPathOverlay는 경로선의 두께와 같은 UI를 설정합니다.

setLocationOverlay 함수는 사용자의 현재 위치 오버레이의 아이콘을 설정합니다.  

 

 

NMFMapViewCameraDelegate, NMFMapViewTouchDelegate

// MARK: - NMFMapViewCameraDelegate, NMFMapViewTouchDelegate

extension RNMapView: NMFMapViewCameraDelegate, NMFMapViewTouchDelegate {
    // 지도 탭 이벤트
    func mapView(_ mapView: NMFMapView, didTapMap latlng: NMGLatLng, point: CGPoint) {
        guard isDrawMode && markers.count < 20 else { return }
        self.makeMarker(at: latlng)
    }
    
    func mapView(_ mapView: NMFMapView, cameraDidChangeByReason reason: Int, animated: Bool) {
        let locationOverlay = map.mapView.locationOverlay
        if locationOverlay.icon != locationOverlayIcon {
            setLocationOverlay()
        }
    }
}

지도 카메라 이벤트와 터치 이벤트를 처리하기 위해 네이버 지도 SDK에서 제공하는 Delegate 프로토콜들을 채택했습니다.

 

didTapMap을 구현하면 지도에 터치 이벤트가 발생할 때 처리할 액션을 구현할 수 있습니다.

latlng 파라미터에 터치한 지점의 위치 정보가 담겨있습니다.

isDrawMode가 true 이고 생성된 마커가 20개 미만일 때 터치가 발생하면 터치한 위치에 새 마커를 생성하도록 했습니다.

 

cameraDidChangeByReason을 구현하면 카메라가 이동했을 때 처리할 액션을 구현할 수 있습니다.

사실 이 함수는 구현하지 않아도 정상적으로 동작할 것 같았는데 문제가 하나 있어서 추가했습니다.

사용자 위치 오버레이를 요구 사항에 의해 다음과 같이 커스텀한 아이콘으로 사용하고 있었는데요!

사용자의 현재 위치를 나타내는 오버레이

사용자가 지도 뷰를 드래그하여 카메라를 이동시키면 이 오버레이의 아이콘이 네이버 지도에서 제공하는 디폴트 아이콘으로 변경되는 문제가있었습니다.

 

따라서 cameraDidChangeByReason에서 카메라 이동이 발생할 때마다 다시 해당 오버레이의 아이콘을 커스텀 아이콘으로 재지정하도록 하여 문제를 해결했습니다.

 

깔끔한 정답은 아닌 것 같지만 다른 방법을 찾지 못했는데 혹시 저랑 같은 문제를 겪으셨는데 다른 방법으로 해결하신 경험이 있다면 알려주시면 정말 감사드리겠습니다!! 👍

 

 

지도 기능 구현 메서드

@discardableResult 어트리뷰트 키워드를 사용하여 VC에서 메서드 체이닝을 통해 선언적으로 지도 뷰를 구성할 수 있도록 했습니다.

/// isDrawMode (편집 모드) 설정
    @discardableResult
    func setDrawMode(to isDrawMode: Bool) -> Self {
        self.isDrawMode = isDrawMode
        return self
    }
    
    /// 카메라가 따라가는 mode 설정
    @discardableResult
    func setPositionMode(mode: NMFMyPositionMode) -> Self {
        map.mapView.positionMode = mode
        setLocationOverlay()
        return self
    }
    
    /// 지정 위치에 startMarker와 출발 infoWindow 생성 (기존의 startMarker는 제거)
    @discardableResult
    func makeStartMarker(at location: NMGLatLng, withCameraMove: Bool = false) -> Self {
        self.startMarker.position = location
        self.startMarker.mapView = self.map.mapView
        self.startMarker.showInfoWindow()
        if withCameraMove {
            moveToLocation(location: location)
        }
        markerCount = 1
        return self
    }
    
    /// 사용자 위치에 startMarker와 출발 infoWindow 생성 (기존의 startMarker는 제거)
    @discardableResult
    func makeStartMarkerAtUserLocation(withCameraMove: Bool = false) -> Self {
        self.startMarker.position = getUserLocation()
        self.startMarker.mapView = self.map.mapView
        self.startMarker.showInfoWindow()
        moveToUserLocation()
        markerCount = 1
        return self
    }

setDrawMode는 isDrawMode의 값을 변경합니다.

 

setPositionMode는 카메라가 따라가는 모드(위치 추적 모드)를 설정합니다.

NMFMyPositionMode 타입을 받아오는데 자세한 내용은 링크를 참고해주세요!

 

makeStartMarker는 위치 정보를 받아서 출발지 마커를 지도 뷰에 생성합니다. withCameraMove를 true로 하면 해당 출발지 위치로 카메라(화면)를 이동시킵니다.

makeStartMarkerAtUserLocation는 사용자가 위치한 곳에 출발지 마커를 생성합니다.

 

    /// 지정 위치에 마커 생성
    @discardableResult
    func makeMarker(at location: NMGLatLng) -> Self {
        let marker = RNMarker()
        marker.position = location
        marker.mapView = self.map.mapView
        addDistance(with: location)
        self.markers.append(marker)
        return self
    }
    
    /// NMGLatLng 어레이를 받아서 모든 위치에 마커 생성
    @discardableResult
    func makeMarkers(at locations: [NMGLatLng]) -> Self {
        locations.forEach { location in
            makeMarker(at: location)
        }
        return self
    }
    
    /// NMGLatLng 어레이를 받아서 첫 위치를 startMarker로 설정하고 나머지를 일반 마커로 생성
    @discardableResult
    func makeMarkersWithStartMarker(at locations: [NMGLatLng], moveCameraToStartMarker: Bool) -> Self {
        removeMarkers()
        if locations.count < 2 { return self }
        makeStartMarker(at: locations[0], withCameraMove: moveCameraToStartMarker)
        locations[1...].forEach { location in
            makeMarker(at: location)
        }
        return self
    }

makeMarker는 지정 위치에 일반 마커(경유지 마커)를 생성합니다.

또한 addDistance 함수를 호출하여 새로 추가된 마커까지의 거리를 pathDistance에 더합니다.

 

makeMarkers는 위치 정보 어레이를 받아서 모든 위치에 마커를 생성합니다.

 

makeMarkersWithStartMarker는 위치 정보 어레이를 받아서 첫 번째 위치를 출발지 마커로 생성하고 나머지를 일반 마커로 생성합니다.

 

 

    /// 사용자 위치로 카메라 이동
    @discardableResult
    func moveToUserLocation() -> Self {
        let userLatLng = getUserLocation()
        let cameraUpdate = NMFCameraUpdate(scrollTo: userLatLng)
        
        DispatchQueue.main.async { [weak self] in
            cameraUpdate.animation = .easeIn
            self?.map.mapView.moveCamera(cameraUpdate)
        }
        return self
    }
    
    /// 사용자 위치 가져오기
    func getUserLocation() -> NMGLatLng {
        let userLocation = locationManager.location?.coordinate
        let userLatLng = userLocation.toNMGLatLng()
        return userLatLng
    }
    
    /// 지정 위치로 카메라 이동
    @discardableResult
    func moveToLocation(location: NMGLatLng) -> Self {
        let cameraUpdate = NMFCameraUpdate(scrollTo: location)
        DispatchQueue.main.async { [weak self] in
            cameraUpdate.animation = .easeIn
            self?.map.mapView.moveCamera(cameraUpdate)
        }
        return self
    }

moveToUserLocation는 사용자 위치로 카메라를 이동시킵니다.

UI 작업이기에 메인 스레드에서 실행되도록 했습니다.

 

getUserLocation는 locationManager로부터 사용자의 현재 위치 정보를 가져옵니다.

iOS에서 제공하는 위치 정보 객체는 CLLocationCoordinate2D 인데 네이버 지도에서 사용하는 객체는 NMGLatLng 타입이기 때문에 이 타입으로 변환하는 함수인 toNMGLagLng를 추가했습니다.

 

extension CLLocationCoordinate2D {
    func toNMGLatLng() -> NMGLatLng {
        return NMGLatLng(lat: self.latitude, lng: self.longitude)
    }
}

 

moveToLocation는 지정 위치로 카메라를 이동시킵니다.

 

    /// 저장된 위치들로 경로선 그리기
    @discardableResult
    func makePath() -> Self {
        if self.markersLatLngs.count == 1 {
            self.pathOverlay.mapView = nil
            return self
        }
        pathOverlay.path = NMGLineString(points: self.markersLatLngs)
        pathOverlay.mapView = map.mapView
        return self
    }
    
    /// moveToUserlocationButton 설정
    @discardableResult
    func showMoveToUserLocationButton(toShow: Bool) -> Self {
        self.moveToUserlocationButton.isHidden = !toShow
        return self
    }
    
    /// 지도에 ContentPadding을 지정하여 중심 위치가 변경되게 설정
    @discardableResult
    func makeContentPadding(padding: UIEdgeInsets) -> Self {
        map.mapView.contentInset = padding
        self.bottomPadding = padding.bottom
        updateSubviewsConstraints()
        return self
    }

makePath는 지도 뷰에 올라와 있는 마커들끼리 연결하는 경로선을 그립니다.

NMGLineString 객체를 생성할 때 앞서 만든 markersLatLngs를 넣어서 좌표들을 주면 네이버 지도가 알아서 좌표간 경로선을 그려줍니다.

 

showMoveToUserLocationButton은 moveToUserlocationButton의 숨김 여부를 설정합니다.

 

makeContentPadding은 지도에 contentInset을 지정합니다.

해당 값을 변경하면 지도의 중심 위치가 바뀌게 됩니다. (링크의 콘텐츠 패딩 부분 참고)

 

 

/// 네이버 지도 로고 Margin 설정
    @discardableResult
    func makeNaverLogoMargin(inset: UIEdgeInsets) -> Self {
        map.mapView.logoMargin = inset
        return self
    }
    
    /// 현재 존재하는 Marker들 위치 리턴
    func getMarkersLatLng() -> [NMGLatLng] {
        return self.markersLatLngs
    }
    
    /// 경로 총 거리 가져오기
    func getPathDistance() -> Double {
        return pathDistance
    }

makeNaverLogoMargin는 네이버 로고의 위치를 변경합니다. (네이버 지도를 사용할 때는 반드시 해당 로고가 보여야 합니다.)

네이버 로고

getMarkersLatLng는 지도 뷰 위에 존재하는 마커들의 위치를 리턴합니다.

 

getPathDistance는 경로의 총 거리를 리턴합니다.

 

 

    /// 직전의 마커 생성을 취소하고 경로선도 제거
    func undo() {
        guard let lastMarker = self.markers.popLast() else { return }
        substractDistance(with: lastMarker.position)
        lastMarker.mapView = nil
    }
    
    /// 출발지 마커를 제외한 모든 마커 제거
    func removeMarkers() {
        while self.markers.count != 0 {
            undo()
        }
    }
    
    // 두 지점 사이의 거리(m) 추가
    private func addDistance(with newLocation: NMGLatLng) {
        let lastCLLoc = markersLatLngs.last?.toCLLocation()
        let newCLLoc = newLocation.toCLLocation()
        guard let distance = lastCLLoc?.distance(from: newCLLoc) else { return }
        pathDistance += distance
    }
    
    // 마지막 지점까지의 거리(m) 제거
    private func substractDistance(with targetLocation: NMGLatLng) {
        let lastCLLoc = markersLatLngs.last?.toCLLocation()
        let targetCLLoc = targetLocation.toCLLocation()
        guard let distance = lastCLLoc?.distance(from: targetCLLoc) else { return }
        pathDistance -= distance
        if pathDistance < 1 { pathDistance = 0 }
    }

undo는 가장 최근에 생성한 마커를 제거합니다. 해당 마커와 연결된 경로선 역시 제거합니다.

 

removeMarkers는 출발지 마커를 제외한 모든 마커를 제거합니다.

 

addDistance는 두 지점 사이의 거리를 pathDistance에 더하여 저장합니다. (미터 단위)

두 위치 사이의 거리는 CLLocation에서 제공하는 distance(from:) 메서드를 사용했습니다.

해당 함수를 사용하려면 위치 정보 타입이 CLLocation이어야 하기 때문에 NMGLatLng 을 CLLocation으로 변환하는 함수인 toCLLocation을 추가로 생성했습니다.

 

extension NMGLatLng {
    func toCLLocation() -> CLLocation {
        return CLLocation(latitude: lat, longitude: lng)
    }
}

 

substractDistance는 undo를 실행할 때 호출되는 함수입니다. pathDistance에 가장 최근에 추가한 마커까지의 거리를 빼서 저장합니다.

 

    private func setMap() {
        // 카메라 대상 지점을 한반도로 고정
        map.mapView.extent = NMGLatLngBounds(southWestLat: 31.43, southWestLng: 122.37, northEastLat: 44.35, northEastLng: 132)
        map.showLocationButton = false
        map.showScaleBar = false
        map.showZoomControls = false
        map.mapView.logoAlign = .rightTop
    }
    
   private func startUpdatingUserLocation() {
        DispatchQueue.global().async { [weak self] in
            if CLLocationManager.locationServicesEnabled() {
                print("위치 상태 On 상태")
                self?.locationManager.startUpdatingLocation()
            } else {
                print("위치 상태 Off 상태")
            }
        }
    }

setMap은 네이버 지도의 기본 UI의 설정을 담당합니다.

네이버 지도는 디폴트 UI 컴포넌트들을 제공하고 있습니다. 이 UI들이 필요하다면 커스텀 없이 사용하는 것도 효율적일 것입니다. (링크)

 

startUpdatingUserLocation는 사용자의 위치를 트래킹하기 시작합니다.

 

extension RNMapView {
    /// 현재 시점까지의 마커들을 캡쳐하여 pahImage에 send
    func capturePathImage() {
        makeCameraMoveForCapture(at: self.markersLatLngs)
    }
    
    /// 캡처를 위한 좌표 설정 및 카메라 이동
    private func makeCameraMoveForCapture(at locations: [NMGLatLng]) {
        map.mapView.contentInset = UIEdgeInsets(top: screenHeight/4, left: 0, bottom: screenHeight/4, right: 0)
        let bounds = makeMBR(at: locations)
        let cameraUpdate = NMFCameraUpdate(fit: bounds, padding: 100)
        cameraUpdate.animation = .none
        LoadingIndicator.showLoading()
        map.mapView.moveCamera(cameraUpdate) { isCancelled in
            if isCancelled {
                print("카메라 이동 취소")
                LoadingIndicator.hideLoading()
            } else {
                DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
                    self.makePathImage()
                    LoadingIndicator.hideLoading()
                }
            }
        }
    }
    
    /// 바운더리(MBR) 생성
    private func makeMBR(at locations: [NMGLatLng]) -> NMGLatLngBounds {
        return NMGLatLngBounds(latLngs: locations)
    }
    
    /// 지도 뷰를 UIImage로 변환하여 pathImage에 send
    private func makePathImage() {
        if let image = UIImage.imageFromView(view: map.mapView) {
            guard let newImage = self.cropImage(inputImage: image) else {
                print("이미지 생성 실패")
                return
            }
            self.pathImage.send(newImage)
        }
    }
    
    func cropImage(inputImage image: UIImage) -> UIImage? {
    	// 사이즈는 원하는 값으로 바꾸어서 사용 
        let y = screenHeight > 800 ? screenHeight/4 + 150 : screenHeight/4 - 40
        return UIImage.cropImage(image, toRect: CGRect(x: 0, y: y, width: screenWidth*2, height: 500.adjustedH), viewWidth: screenWidth, viewHeight: 400.adjustedH)
    }
}

capturePathImage는 지도 뷰를 캡쳐하여 이미지로 변환합니다.

makeCameraMoveForCapture를 호출합니다.

이 함수에서는 makeMBR함수를 호출하여 캡쳐할 좌표 바운더리를 생성합니다. (링크의 MBR 참고)

해당 바운더리로 카메라를 이동시키고 메인 스레드에서 makePathImage 함수를 실행하도록 합니다.

 

makePathImage는 map.mapView를 UIImage로 변환합니다.

이를 위해 UIImage를 확장하여 imageFromView 함수를 추가했습니다.

 

extension UIImage {
    static func imageFromView(view: UIView) -> UIImage? {
        UIGraphicsBeginImageContextWithOptions(view.bounds.size, view.isOpaque, 0.0)
        view.drawHierarchy(in: view.bounds, afterScreenUpdates: true)
        defer { UIGraphicsEndImageContext() }
        guard let context = UIGraphicsGetCurrentContext() else { return nil }
        view.layer.render(in: context)
        let image = UIGraphicsGetImageFromCurrentImageContext()
        return image
    }
}

이렇게 얻은 UIImage를 원하는 사이즈로 crop하고 pathImage Subject에 send하여 다른 객체에서 sink하여 사용할 수 있도록 했습니다.

 

extension UIImage {
    static func cropImage(_ inputImage: UIImage, toRect cropRect: CGRect, viewWidth: CGFloat, viewHeight: CGFloat) -> UIImage? {
        let imageViewScale = max(inputImage.size.width / viewWidth,
                                 inputImage.size.height / viewHeight)

        // Scale cropRect to handle images larger than shown-on-screen size
        let cropZone = CGRect(x: cropRect.origin.x * imageViewScale,
                              y: cropRect.origin.y * imageViewScale,
                              width: cropRect.size.width * imageViewScale,
                              height: cropRect.size.height * imageViewScale)
        
        // Perform cropping in Core Graphics
        guard let cutImageRef: CGImage = inputImage.cgImage?.cropping(to: cropZone)
        else {
            return nil
        }

        // Return image to UIImage
        let croppedImage: UIImage = UIImage(cgImage: cutImageRef)
        return croppedImage
    }
}

구글링을 통해 가져온 이미지 크롭 함수입니다.

 

 

 

사용 예시

지금까지 구현한 커스텀 지도 뷰를 활용하여 앞서 요구 사항에서 제시한 여러 화면에서의 지도 뷰 선언을 간편하게 할 수 있습니다.

이 화면의 지도 뷰를 VC에 추가할 때는 다음과 같이 간단하게 선언하여 사용하면 됩니다.

private lazy var mapView = RNMapView()
        .setPositionMode(mode: .normal)
        .makeContentPadding(padding: UIEdgeInsets(top: -calculateTopInset(), left: 0, bottom: tabBarHeight, right: 0))
        .moveToUserLocation()
        .showMoveToUserLocationButton(toShow: true)

 

    private let mapView = RNMapView()
        .makeNaverLogoMargin(inset: UIEdgeInsets(top: 52, left: 0, bottom: 0, right: 0))
        .setDrawMode(to: true)

 


 

마무리

다양한 기능이 필요한 지도 뷰를 커스텀 해보았습니다.

여러 VC에서 필요한 지도 뷰를 하나의 객체로 추상화하여 재사용성을 높이고자 했습니다.

 

아쉬운 점 또한 있습니다..!

  1. RNMapView가 너무 많은 기능을 구현하고 있어서 책임을 분산할 객체를 별도로 생성하고 합성을 통해 기능을 합치는 방식을 사용했으면 더 좋았을 것 같습니다. (리팩토링을 해볼까..?)
  2. 지도 캡쳐에서 crop하는 영역의 범위를 하드 코딩하고 있습니다. 기기마다 화면의 비율이 달라서 경로를 온전히 담은 이미지를 얻기 위해서는 정확한 범위를 crop해야 하는데 아직 그 부분을 계산하는 로직을 해결하지 못하여 우선 하드코딩으로 처리했습니다.

 

그래도 이번에 네이버 지도 SDK를 활용하여 다양한 기능을 커스텀 추가하면서 많은 것을 배웠습니다.

  1. 큰 라이브러리의 공식 문서를 꼼꼼하게 읽고 요구 사항에 필요한 기능들을 가져와 활용하고자 했습니다.
  2. 메서드 체이닝 형식으로 뷰를 선언할 수 있도록 하여 가독성을 높였습니다.
  3. 특정 UI에만 의존하지 않고 다양한 기능을 추가하여 다른 프로젝트에서 유사한 기능이 필요하다면 약간의 수정만 하여 곧바로 재사용할 수 있을 것 같습니다.

 

전체 코드 주소

https://github.com/lsj8706/Runnect-iOS/blob/develop/Runnect-iOS/Runnect-iOS/Global/UIComponents/MapView/RNMapView.swift

 

 

프로젝트 앱 스토어 주소

https://apps.apple.com/us/app/runnect/id1663884202

 

‎Runnect: 코스를 그리고 공유하는 데일리 러닝앱

‎데일리 러닝앱 Runnect은 런린이와 프로 러너에게 던진 물음에서 시작했습니다. Q. 런린이 여러분, 러닝을 떠올리면 어떤 생각이 드시나요? “멋진 이미지가 떠오르는데 시도하기는 어려워요”

apps.apple.com

 

댓글