[iOS] NaverMap API - Directions(네비게이션 루트 기능)

Directions이란?

 

  Direction API는 출발지와 도착지를 바탕으로 경로 데이터를 조회할 수 있는 API이다. 

Direction API를 활용하면 내비게이션과 같이 활용할 수가 있다.

옵션에 따라 실시간 빠른 길, 실시간 최적의 길, 무료 우선인 길 등에 대한 데이터를 받을 수 있으며,

예상 거리, 소요 시간, 택시 요금, 유류비 등의 데이터도 받을 수 있다.

(도큐먼트를 보고 본인이 필요한 데이터를 받아와서 사용하길 바란다!)

 

 

 

 

 

Directions API

 

Direction API 역시 다른 네이버맵 API와 마찬가지로 get 메서드이며 헤더에 키를 넣어줘야 한다.

 

 

 

 

 

 

네트워크 통신 코드

네트워크 통신에서는 쿼리 부분에 start, goal이라는 좌표값 쿼리를 필수로 넣어줘야 한다.

네트워크 통신 코드는 아래와 같다.

func directionCallRequest(start: String, goal: String, completion: @escaping (Data) -> Void) {
        
        guard var urlComponents = URLComponents(string: "https://naveropenapi.apigw.ntruss.com/map-direction/v1/driving") else {
            print("URL Components Error")
            return
        }
        
        let queryItemArray = [
            URLQueryItem(name: "start", value: start),
            URLQueryItem(name: "goal", value: goal),
            URLQueryItem(name: "option", value: "trafast")
        ]
        urlComponents.queryItems = queryItemArray
        
        guard let url = urlComponents.url else {
            print("URL Error")
            return
        }
        
        var urlRequest = URLRequest(url: url)
        
        urlRequest.addValue("클라이언트 Id", forHTTPHeaderField: "X-NCP-APIGW-API-KEY-ID")
        urlRequest.addValue("Secret 키", forHTTPHeaderField: "X-NCP-APIGW-API-KEY")
        
        URLSession.shared.dataTask(with: urlRequest) { data, response, error in
            
            guard let data = data, let response = response as? HTTPURLResponse, response.statusCode == 200 else {
                print("status code error")
                return
            }
            completion(data)
        }.resume()
    }

 

 

 

 

 

 

 

 

구현 예제

좌: SetCoordsViewController 우: NaviViewController

이제 위와 같이 동작하게 구현해 보자면,

이전 글에서 구현한 마커가 지도 객체 가운데에 고정되어 있어서

드래그가 끝날 때마다 좌표값을 반환하는 기능을 이용해 출발 좌표로 사용할 것이고,

위에서 사용한 Geocoding API를 활용해 도착지점 좌표값으로 활용할 것이다.

 

class SetCoordsViewController: UIViewController {
    
    let textField = UITextField()
    let map = NMFNaverMapView()
    let centerMarker = NMFMarker()
    var startPointCoords = ""
    var targetPointCoords = ""
    
    
    override func viewDidLoad() {
        super.viewDidLoad()
        // 뷰 객체 설정
        ...
        
        // 센터 마크 설정
        
        map.mapView.addCameraDelegate(delegate: self)
        
        }
        
    func geoCallRequest(query: String, completion: @escaping (Data) -> Void) {
    	// 텍스트 필드에 주소 입력 후 검색 버튼 눌렀을 때 네트워크 통신 코드
    }
    
    @objc func searchButtonTapped() {
        guard let query = textField.text, !query.isEmpty else {
            print("Query is empty")
            return
        }
        
        self.geoCallRequest(query: query) { data in
            do {
                let decodedData = try JSONDecoder().decode(Geocoding.self, from: data)
                print("Received Data: \(decodedData)")
                let lastData = decodedData.addresses.first
                self.targetPointCoords = (lastData?.x ?? "0") + "," + (lastData?.y ?? "0")
            } catch {
                print(error)
            }
        }
        
    }
}


extension SetCoordsViewController: NMFMapViewCameraDelegate {
    func mapView(_ mapView: NMFMapView, cameraIsChangingByReason reason: Int) {
        centerMarker.position = mapView.cameraPosition.target
    }
    
    func mapView(_ mapView: NMFMapView, cameraDidChangeByReason reason: Int, animated: Bool) {
        centerMarker.position = mapView.cameraPosition.target
    }
    
    func mapViewCameraIdle(_ mapView: NMFMapView) {
        print(mapView.cameraPosition.target)
        startPointCoords = "\(mapView.cameraPosition.target.lng)" + "," + "\(mapView.cameraPosition.target.lat)"
    }
}

 

mapViewCameraIdle 메서드를 통해 출발 지점 좌표를 얻고,

geoCallRequest 메서드를 통해 도착 지점 좌표를 얻는다. 

 

 

그리고 다음 뷰에서는 이 두 좌표 값을 받아서 direction API를 호출해 주면 된다.

 

class NaviViewController: UIViewController {
    
    var startPointCoords = ""
    var targetPointCoords = ""
    var map = NMFNaverMapView()
    
    init(startPointCoords: String, targetPointCoords: String) {
        self.startPointCoords = startPointCoords
        self.targetPointCoords = targetPointCoords
        super.init(nibName: nil, bundle: nil)
    }
    
    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
    
    override func viewDidLoad() {
        super.viewDidLoad()
        view.backgroundColor = .white
        
        map = NMFNaverMapView(frame: view.frame)
        view.addSubview(map)
        
        directionCallRequest(start: startPointCoords, goal: targetPointCoords) { data in
            do {
                let decodedData = try JSONDecoder().decode(DirectionModel.self, from: data)
                DispatchQueue.main.async {
                    self.updateMapRoute(item: decodedData)
                }
            } catch {
                print(error)
            }
        }
    }
    
	func directionCallRequest(start: String, goal: String, completion: @escaping (Data) -> Void) {
		//direction API call 메서드
	}
	
    // 화면에 오버레이를 추가하는 메서드
	func updateMapRoute(item: DirectionModel) {
        // 지도의 좌상단 부분과 우하단 부분을 설정하여 그 부분만 보여지게 하기
        let boundItems = item.route.trafast[0].summary.bbox
        let southWest = NMGLatLng(lat: boundItems[0][1], lng: boundItems[0][0])
        let northEast = NMGLatLng(lat: boundItems[1][1], lng: boundItems[1][0])
        let bounds = NMGLatLngBounds(southWest: southWest, northEast: northEast)
        map.mapView.extent = bounds
        // 루트의 가운데 지점을 중심으로 지도가 보여지게 하기
        let midIndex = item.route.trafast[0].path.count / 2
        let midPosition = item.route.trafast[0].path[midIndex]
        let cameraUpdate = NMFCameraUpdate(scrollTo: NMGLatLng(lat: midPosition[1], lng: midPosition[0]))
        map.mapView.moveCamera(cameraUpdate)
        map.mapView.zoomLevel = 10
        // API로 받아온 루트들을 오버레이로 그려주기
        let pathOverlay = NMFPath()
        var convertedPath: [NMGLatLng] = []
        for position in item.route.trafast[0].path {
            convertedPath.append(NMGLatLng(lat: position[1], lng: position[0]))
        }
        pathOverlay.path = NMGLineString(points: convertedPath)
        // 오버레이 설정
        pathOverlay.patternInterval = 20
        pathOverlay.width = 6
        pathOverlay.color = .green.withAlphaComponent(0.5)
        pathOverlay.mapView = map.mapView
    }
}

//모델들
...

 

 

이렇게 뷰를 구성하면 위와 같이 길을 지도 객체 위에 보여줄 수 있다.

 

 

 

이때 루트를 오버레이로 그려주는 메서드를 좀 더 보자면

 

    func updateMapRoute(item: DirectionModel) {
        
        let midIndex = item.route.trafast[0].path.count / 2
        let midPosition = item.route.trafast[0].path[midIndex]
        let cameraUpdate = NMFCameraUpdate(scrollTo: NMGLatLng(lat: midPosition[1], lng: midPosition[0]))
        map.mapView.moveCamera(cameraUpdate)
        map.mapView.zoomLevel = 10
        
        let pathOverlay = NMFPath()
        var convertedPath: [NMGLatLng] = []
        for position in item.route.trafast[0].path {
            convertedPath.append(NMGLatLng(lat: position[1], lng: position[0]))
        }
        pathOverlay.path = NMGLineString(points: convertedPath)
        // asset에 화살표 이미지를 추가하여 이동 방향을 표시
        let image = UIImage(named: "pathPattern")!
        pathOverlay.patternIcon = NMFOverlayImage(image: image)
        pathOverlay.patternInterval = 20
        pathOverlay.width = 6
        pathOverlay.color = .red.withAlphaComponent(0.5)
        pathOverlay.mapView = map.mapView
        // 시작지점을 마커로 표시
        let startMarker = NMFMarker()
        startMarker.position = NMGLatLng(lat: item.route.trafast[0].summary.start.location[1], lng: item.route.trafast[0].summary.start.location[0])
        // asset에 이미지 추가한 것을 가져옴 (naverMap에서 제공하는 오버레이 이미지를 사용해도 됨)
        startMarker.iconImage = NMFOverlayImage(name: "startPoint")
        startMarker.width = 20
        startMarker.height = 20
        startMarker.mapView = map.mapView
		// 도착지점을 마커로 표시
        let goalMarker = NMFMarker()
        goalMarker.position = NMGLatLng(lat: item.route.trafast[0].summary.goal.location[1], lng: item.route.trafast[0].summary.goal.location[0])
        // asset에 이미지 추가한 것을 가져옴 (naverMap에서 제공하는 오버레이 이미지를 사용해도 됨)
        goalMarker.iconImage = NMFOverlayImage(name: "destination")
        goalMarker.width = 20
        goalMarker.height = 26
        goalMarker.mapView = map.mapView
        
    }

 

위와 같이 코딩을 작성할 수 있다.

추가적으로 pathOverlay.patternIcon을 사용하면 이동 경로가 화살표로 표시가 되며,

시작지점과 도착지점을 구분할 수 있게 마커로 표시할 수도 있다.

 

 

 

SetCoordsViewController 전체 코드 보기

더보기
import UIKit
import NMapsMap

class SetCoordsViewController: UIViewController {
    
    let textField = UITextField()
    let map = NMFNaverMapView()
    let centerMarker = NMFMarker()
    var startPointCoords = ""
    var targetPointCoords = ""
    
    override func viewDidLoad() {
        super.viewDidLoad()
        view.backgroundColor = .white
        
        textField.placeholder = "주소를 입력해주세요"
        textField.borderStyle = .roundedRect
        textField.translatesAutoresizingMaskIntoConstraints = false
        view.addSubview(textField)
        
        let searchButton = UIButton(type: .system)
        searchButton.setTitle("검색", for: .normal)
        searchButton.translatesAutoresizingMaskIntoConstraints = false
        searchButton.addTarget(self, action: #selector(searchButtonTapped), for: .touchUpInside)
        view.addSubview(searchButton)
        
        map.translatesAutoresizingMaskIntoConstraints = false
        view.addSubview(map)
        
        let showNextPageButton = UIButton()
        showNextPageButton.setTitle("다음", for: .normal)
        showNextPageButton.backgroundColor = .green
        showNextPageButton.translatesAutoresizingMaskIntoConstraints = false
        showNextPageButton.addTarget(self, action: #selector(showNextPageButtonTapped), for: .touchUpInside)
        view.addSubview(showNextPageButton)
        
        let cameraUpdate = NMFCameraUpdate(scrollTo: NMGLatLng(lat: 37.479132, lng: 127.011770))
        map.mapView.moveCamera(cameraUpdate)
        
        centerMarker.position = map.mapView.cameraPosition.target
        centerMarker.captionText = "마커"
        centerMarker.captionAligns = [NMFAlignType.top]
        centerMarker.mapView = map.mapView
        
        map.mapView.addCameraDelegate(delegate: self)
        
        NSLayoutConstraint.activate([
            textField.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 20),
            textField.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 20),
            
            searchButton.centerYAnchor.constraint(equalTo: textField.centerYAnchor),
            searchButton.leadingAnchor.constraint(equalTo: textField.trailingAnchor, constant: 10),
            searchButton.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -20),
            
            textField.trailingAnchor.constraint(equalTo: searchButton.leadingAnchor, constant: -10),
            searchButton.widthAnchor.constraint(equalToConstant: 60),
            
            map.topAnchor.constraint(equalTo: textField.bottomAnchor, constant: 20),
            map.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 20),
            map.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -20),
            map.bottomAnchor.constraint(equalTo: showNextPageButton.topAnchor),
            
            showNextPageButton.bottomAnchor.constraint(equalTo: view.bottomAnchor, constant: -20),
            showNextPageButton.heightAnchor.constraint(equalToConstant: 50),
            showNextPageButton.widthAnchor.constraint(equalToConstant: 300),
            showNextPageButton.centerXAnchor.constraint(equalTo: view.centerXAnchor)
        ])
        
    }
    
    func geoCallRequest(query: String, completion: @escaping (Data) -> Void) {
        
        guard var urlComponents = URLComponents(string: "https://naveropenapi.apigw.ntruss.com/map-geocode/v2/geocode") else {
            print("URL Components Error")
            return
        }
        
        let queryItemArray = [
            URLQueryItem(name: "query", value: query)
        ]
        urlComponents.queryItems = queryItemArray
        
        guard let url = urlComponents.url else {
            print("URL Error")
            return
        }
        
        var urlRequest = URLRequest(url: url)
        
        urlRequest.addValue("client Id", forHTTPHeaderField: "X-NCP-APIGW-API-KEY-ID")
        urlRequest.addValue("Secret key", forHTTPHeaderField: "X-NCP-APIGW-API-KEY")
        
        URLSession.shared.dataTask(with: urlRequest) { data, response, error in
            
            guard let data = data, let response = response as? HTTPURLResponse, response.statusCode == 200 else {
                print("status code error")
                return
            }
            completion(data)
        }.resume()
    }
    
    
    
    @objc func searchButtonTapped() {
        guard let query = textField.text, !query.isEmpty else {
            print("Query is empty")
            return
        }
        
        self.geoCallRequest(query: query) { data in
            do {
                let decodedData = try JSONDecoder().decode(Geocoding.self, from: data)
                print("Received Data: \(decodedData)")
                let lastData = decodedData.addresses.first
                self.targetPointCoords = (lastData?.x ?? "0") + "," + (lastData?.y ?? "0")
            } catch {
                print(error)
            }
        }
        
    }
    
    @objc func showNextPageButtonTapped() {
        let vc = NaviViewController(startPointCoords: startPointCoords, targetPointCoords: targetPointCoords)
        navigationController?.pushViewController(vc, animated: true)
    }
    
}

extension SetCoordsViewController: NMFMapViewCameraDelegate {
    func mapView(_ mapView: NMFMapView, cameraIsChangingByReason reason: Int) {
        centerMarker.position = mapView.cameraPosition.target
    }
    
    func mapView(_ mapView: NMFMapView, cameraDidChangeByReason reason: Int, animated: Bool) {
        centerMarker.position = mapView.cameraPosition.target
    }
    
    func mapViewCameraIdle(_ mapView: NMFMapView) {
        print(mapView.cameraPosition.target)
        startPointCoords = "\(mapView.cameraPosition.target.lng)" + "," + "\(mapView.cameraPosition.target.lat)"
    }
}


struct Geocoding: Codable {
    let status: String
    let meta: Meta
    let addresses: [GeoAddress]
    let errorMessage: String
}

struct GeoAddress: Codable {
    let roadAddress, jibunAddress, englishAddress: String
    let addressElements: [AddressElement]
    let x, y: String
    let distance: Double
}

struct AddressElement: Codable {
    let types: [String]
    let longName, shortName, code: String
}

struct Meta: Codable {
    let totalCount, page, count: Int
}

 

NaviViewController 전체 코드 보기

더보기
import UIKit
import NMapsMap

class NaviViewController: UIViewController {
    
    var startPointCoords = ""
    var targetPointCoords = ""
    var map = NMFNaverMapView()
    
    init(startPointCoords: String, targetPointCoords: String) {
        self.startPointCoords = startPointCoords
        self.targetPointCoords = targetPointCoords
        super.init(nibName: nil, bundle: nil)
    }
    
    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
    
    override func viewDidLoad() {
        super.viewDidLoad()
        view.backgroundColor = .white
        
        map = NMFNaverMapView(frame: view.frame)
        view.addSubview(map)
        
        directionCallRequest(start: startPointCoords, goal: targetPointCoords) { data in
            do {
                let decodedData = try JSONDecoder().decode(DirectionModel.self, from: data)
                DispatchQueue.main.async {
                    self.updateMapRoute(item: decodedData)
                }
            } catch {
                print(error)
            }
        }
    }
    
    func directionCallRequest(start: String, goal: String, completion: @escaping (Data) -> Void) {
        
        guard var urlComponents = URLComponents(string: "https://naveropenapi.apigw.ntruss.com/map-direction/v1/driving") else {
            print("URL Components Error")
            return
        }
        
        let queryItemArray = [
            URLQueryItem(name: "start", value: start),
            URLQueryItem(name: "goal", value: goal),
            URLQueryItem(name: "option", value: "trafast")
        ]
        urlComponents.queryItems = queryItemArray
        
        guard let url = urlComponents.url else {
            print("URL Error")
            return
        }
        
        var urlRequest = URLRequest(url: url)
        
        urlRequest.addValue("클라이언트 Id", forHTTPHeaderField: "X-NCP-APIGW-API-KEY-ID")
        urlRequest.addValue("Secret 키", forHTTPHeaderField: "X-NCP-APIGW-API-KEY")
        
        URLSession.shared.dataTask(with: urlRequest) { data, response, error in
            
            guard let data = data, let response = response as? HTTPURLResponse, response.statusCode == 200 else {
                print("status code error")
                return
            }
            completion(data)
        }.resume()
    }
    
    func updateMapRoute(item: DirectionModel) {
        
        let boundItems = item.route.trafast[0].summary.bbox
        let southWest = NMGLatLng(lat: boundItems[0][1], lng: boundItems[0][0])
        let northEast = NMGLatLng(lat: boundItems[1][1], lng: boundItems[1][0])
        let bounds = NMGLatLngBounds(southWest: southWest, northEast: northEast)
        map.mapView.extent = bounds
        
        let midIndex = item.route.trafast[0].path.count / 2
        let midPosition = item.route.trafast[0].path[midIndex]
        let cameraUpdate = NMFCameraUpdate(scrollTo: NMGLatLng(lat: midPosition[1], lng: midPosition[0]))
        map.mapView.moveCamera(cameraUpdate)
        map.mapView.zoomLevel = 10
        
        let pathOverlay = NMFPath()
        var convertedPath: [NMGLatLng] = []
        for position in item.route.trafast[0].path {
            convertedPath.append(NMGLatLng(lat: position[1], lng: position[0]))
        }
        pathOverlay.path = NMGLineString(points: convertedPath)
        let image = UIImage(named: "pathPattern")!
        pathOverlay.patternIcon = NMFOverlayImage(image: image)
        pathOverlay.patternInterval = 20
        pathOverlay.width = 6
        pathOverlay.color = .red.withAlphaComponent(0.5)
        pathOverlay.mapView = map.mapView
        
        let startMarker = NMFMarker()
        startMarker.position = NMGLatLng(lat: item.route.trafast[0].summary.start.location[1], lng: item.route.trafast[0].summary.start.location[0])
        startMarker.iconImage = NMFOverlayImage(name: "startPoint")
        startMarker.width = 20
        startMarker.height = 20
        startMarker.mapView = map.mapView

        let goalMarker = NMFMarker()
        goalMarker.position = NMGLatLng(lat: item.route.trafast[0].summary.goal.location[1], lng: item.route.trafast[0].summary.goal.location[0])
        goalMarker.iconImage = NMFOverlayImage(name: "destination")
        goalMarker.width = 20
        goalMarker.height = 26
        goalMarker.mapView = map.mapView
        
    }

}

struct DirectionModel: Decodable {
    let message: String
    let route: Route
}

struct Route: Decodable {
    let trafast: [Trafast]
}

struct Trafast: Decodable {
    let summary: Summary
    let path: [[Double]]
}

struct Summary: Decodable {
    let start: Start
    let goal: Goal
    let distance: Int
    let duration: Int
    let bbox: [[Double]]
    let taxiFare: Int
}

struct Goal: Decodable {
    let location: [Double]
}

struct Start: Decodable {
    let location: [Double]
}

 

 

 

어떻게 사용할 수 있을까?

 

네이버에서 제공하는 API와 지도 객체를 활용한다면,

내비게이션 앱도 만들 수 있고,

특정 장소의 주소를 바로 얻을 수 있는 앱,

러닝을 할 때 지나온 곳을 기록하는 앱도 만들 수 있다.

 

또 개발자가 어떻게 구현하느냐에 따라 엄청 많은 앱이 개발될 수 있겠지만,

네이버 맵은 여러 요소가 개발되기 편하게 구현되어 있어서 좋았던 것 같다.

 

 

<택시팟>

 

 

작년에 포항에서 공부를 할 때 택시 가격이 부담되는 경험을 떠올려서 택시 합승 플랫폼인 <택시팟>을 만들었다.

(만약 이 앱의 회고가 궁금하다면 아래 게시글을...)

 

그 과정에서 네이버 맵과 API가 잘 구현되어 있어서 그나마 수월하게 구현할 수 있었던 것 같다.

또한 API를 무료로 호출할 수 있는 한도도 넉넉해서 개발에 부담이 되지 않았다.

아쉬운 점은 서버를 계속해서 사용할 수 없어서 출시를 하지 못하는 점이다...

 

 

[회고] 개인앱 프로젝트<택시팟> 회고

프로젝트 소개앱 소개 & 기획 택시 합승 플랫폼, 택시팟!  기획 계기 수도권에서는 대중교통이 잘 되어 있어서 택시를 굳이 안 이용해도 되는 경우가 많다. 하지만 지방은 수도권의 상황과 다

d0ngurrrrrrr.tistory.com