본문 바로가기

개발/iOS

[iOS] GoogleMap 마커, 경로 사용하기

반응형

 

1 ) GoogleMap 추가하기

https://developers.google.com/maps/documentation/ios-sdk/overview?hl=ko

GoogleMap을 사용하기 위해선 우선 API 키를 발급 받아야합니다.
위 링크에서 API키를 발급 받았다면, AppDelegate didFinishLaunchingWithOptions 함수에 구글맵을 연동하는 코드를 작성합니다.

func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
    // Override point for customization after application launch.
    
    GMSServices.provideAPIKey(PrefixManager.googleMapKey)
    
    return true
}

 

구글맵을 연동하는 가이드는 위 링크에 설명이 잘 되어있어서 바로 기능 사용으로 넘어가겠습니다.

 

MapViewController (구글 맵뷰를 가진 VC)

mapView를 가진 VC를 생성합니다.
mapView의 설정은 필요한 것과 사용해보면 좋을 것을 구분해서 추가합니다.

final class MapViewController: UIViewController {
    
    // MARK: - Views
    
    private lazy var camera = GMSCameraPosition(latitude: 47.603, longitude: -122.331, zoom: 14)
    
    lazy var mapView: GMSMapView = {
        let map = GMSMapView()
        map.accessibilityElementsHidden = false // 접근성 (오버레이 객체 - 마커, 정보창, 폴리곤 등)
        map.isMyLocationEnabled = true // 내 위치, 나침반 방향
        map.isBuildingsEnabled = false // 3D 빌딩 표시 여부
        map.delegate = self
        map.selectedMarker = nil // 마커의 정보창 숨기기
        
        map.settings.compassButton = true
        map.settings.myLocationButton = true
        map.settings.indoorPicker = true
//        map.padding = .init
        return map
    }()
    
    ...  
}

extension MapViewController: GMSMapViewDelegate {
    
    /// 관심 장소를 탭하는 사용자
    func mapView(_ mapView: GMSMapView, didTapPOIWithPlaceID placeID: String, name: String, location: CLLocationCoordinate2D) {
        print("You tapped \(name): \(placeID), \(location.latitude)/\(location.longitude)")
    }
    
    /// 마커를 클릭했을 때
    func mapView(_ mapView: GMSMapView, didTap marker: GMSMarker) -> Bool {
        return true
    }
    
    /// 마커가 아닌 특정 좌표를 클릭했을 때
    func mapView(_ mapView: GMSMapView, didTapAt coordinate: CLLocationCoordinate2D) {
        
    }
    
}

...

 

 

2 ) Camera

mapView 는 기본 카메라를 제공합니다. 하나의 시점 외의 또 다른 시점을 저장하며 스위치 작업을 진행하고 싶다면, GMSCameraPosition을 추가하여 카메라를 직접 추가할 수 있습니다. 두 개의 시점을 가지는 경우는 미비하여 기본 카메라를 사용하는 것을 추천 드립니다

/// 직접 카메라 생성하기
let customCamera = GMSCameraPosition(latitude: _l, longitude: _lon, zoom: 14)
mapView.camera = customCamera

ㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡ

/// 기본 카메라 사용하기
mapView.camera = .camera(withLatitude: _lat, longitude: _lon, zoom: 14)

 

 

3 ) Manager 추가

 

제가 지도에서 사용하려는 기능은 크게 4개입니다.

1. 마커
2. 경로 표시
3. 다각형 표시
4. 카메라 줌인 레벨 조절

지도 핸들링은 독립적으로 움직일 수 있습니다.
저는 지도와 관련된 함수, 변수가 VM와 VC에 들어가는 것을 원치 않았고 지도 기능을 담당하는 Manager을 만들었습니다.
각 화면은 자신이 표시하고 싶은 마커, 경로, 다각형을 Manager에게 요청을 날려야합니다.

이 방법의 장점은 VC가 지도와 관련된 메소드를 최소화하여 가질 수 있다는 점이고, 기능을 분리했기 때문에 유지보수 시 편리한 이점을 가져갈 수 있는 점입니다.

이어 저는 코드의 복잡성을 줄이기 위해, 지도의 모든 요소를 하나의 매니저에서 관리하는게 아닌 각자의 매니저에서 관리하도록 했습니다.
관제 센터처럼 중앙 집중화된 코드는 편리하지만 추후 기능이 추가되거나 수정될 경우는 견고한 코드를 건들기 쉽지 않아 이 방법을 선택했습니다. (코드 취향!)

 

3-1 ) Marker Manager (마커)

지도 상의 마커를 생성하고 지우는 것을 담당합니다.

 

import GoogleMaps

protocol MarkerManagerDelegate: AnyObject {
    var mapView: GMSMapView { get }
}

final class MarkerManager {
    
    // MARK: - Static Properties
    
    static let shard = MarkerManager()
    
    // MARK: - Properties
    
    private var markerList: [GMSMarker] = []
    weak var delegate: MarkerManagerDelegate?
    
    // MARK: - LifeCycle
    
    private init() { }
    
    deinit {
        delegate = nil
        removeAllMarker()
    }
    
    // MARK: - Helpers
    
    func deleteMarker(marker: GMSMarker) {
        if let _removeMarker = markerList.first(where: { $0 == marker }) {
            _removeMarker.map = nil
            markerList.removeAll(where: { $0 == _removeMarker })
        }
    }
    
    /// 카테고리 또는 지정 마커 생성
    func createMarker(markerType: MarkerType, title: String?, lat: Double, lon: Double) {
        let newMarker = setMarker(title: title, lat: lat, lon: lon)
        newMarker.icon = markerType.image
        markerList.append(newMarker)
    }
    
    /// 기본 마커 생성 (색상 변경 가능)
    func createDefaultMarker(color: UIColor?, title: String?, lat: Double, lon: Double) {
        let newMarker = setMarker(title: title, lat: lat, lon: lon)
        if let _color = color {
            newMarker.icon = GMSMarker.markerImage(with: _color)
        }
        markerList.append(newMarker)
    }
    
    /// 지도를 바라보는 시점을 바꿨을 시, 해당 각도만큼 값을 넣어 마커를 회전시킴
    func applySpinToAllMarker(rotationDegree: Double) {
        markerList.forEach { marker in
            marker.groundAnchor = CGPoint(x: 0.5, y: 0.5) // 중심을 기준으로 회전
            marker.rotation = rotationDegree
        }
    }
    
    /// 마커 전체 삭제
    func removeAllMarker() {
        markerList.forEach { $0.map = nil }
        markerList.removeAll()
    }
    
    private func setMarker(title: String?, lat: Double, lon: Double) -> GMSMarker {
        let newMarker = GMSMarker(position: .init(latitude: lat, longitude: lon))
        newMarker.title = title
        newMarker.isFlat = true
        newMarker.tracksViewChanges = false
        newMarker.map = delegate?.mapView
        return newMarker
    }

}

 

구글맵에서는 지도에 마커, 노선, 도형을 그렸을 때 mapView.clear 함수를 제공합니다.

하지만 기본으로 제공하는 함수는 지도에 추가한 모든 사항을 지워버리기 때문에 직접 마커, 노선, 도형 변수를 지정하여 삭제해야합니다. MarkerManager는 마커 생성, 삭제 시 연결된 구글맵에 자동으로 반영되는 것을 구현하기 위해 delegate를 추가했습니다.

 

(선택) Marker Manager - Marker Image

저는 각 마커의 타입을 지정해 카테고리를 만들었습니다.
마커에 들어가는 이미지도 마커 매니저에서 담당하도록 구현했습니다.
원하는 마커를 지정하여 마커를 생성하면 됩니다.

 

// MARK: - Enums

extension MarkerManager {
    
    enum MarkerType {
        case normal
        case busStation
        case landmark
        case smarthub
        case storage
        case source
        case destination
        case bicycle
        case mobility
        case bus
        
        var image: UIImage? {
            switch self {
            case .normal:           return nil
            case .busStation:       return .ic_busstation_marker
            case .landmark:         return .ic_landmark_marker
            case .smarthub:         return .ic_smarthub_marker
            case .storage:          return .ic_storage_marker
            case .source:           return .ic_startbox_marker
            case .destination:      return .ic_arrivalbox_marker
            case .bicycle:          return createCircleView(borderColor: .blue_bright).asImage()
            case .mobility:         return createCircleView(borderColor: .cyan_dark).asImage()
            case .bus:              return createCircleView(borderColor: .blue_dark).asImage()
            }
        }
        
        private func createCircleView(borderColor: UIColor) -> UIView {
            let circleView = UIView(frame: .init(x: 0, y: 0, width: 10, height: 10))
            circleView.backgroundColor = .white
            circleView.layer.cornerRadius = 5.0
            circleView.layer.borderWidth = 2.0
            circleView.layer.borderColor = borderColor.cgColor
            return circleView
        }
    }
    
}

 

 

이제 Marker Manager 과 VC를 연결해보겠습니다.
Marker Manager의 delegate를 연결합니다. 

 

extension MapViewController: MarkerManagerDelegate, PolylineManagerDelegate {
    
    private func setManager() {
        markerManager.delegate = self
        poligonManager.delegate = self
    }
    
    private func clearManager() {
        markerManager.delegate = nil
        poligonManager.delegate = nil
    }
    
}

 

MapViewController에서 마커를 그릴 시 아래처럼 호출하면 됩니다.

 

/// MapViewController 에서 호출하는 코드

let _lat = 37.5289716
let _lon = 126.9216007

markerManager.createMarker(markerType: .source, title: nil, lat: _lat, lon: _lon)
markerManager.createMarker(markerType: .busTakingOff, title: nil, lat: _lat, lon: _lon)

사진이 나란히 올라가지 않는게 참 불편하네요..

 

3-2 ) Polyline Manager (노선, 다각형)

 

Polyline Manager은 노선, 다각형을 담당합니다.
노선으로 경로를 표현할 수 있고, 다각형으로 영역을 표시할 수 있습니다.

 

import GoogleMaps

protocol PolylineManagerDelegate: AnyObject {
    var mapView: GMSMapView { get }
}

final class PolylineManager {
    
    // MARK: - Static Properties
    
    static let shared = PolylineManager()
    
    // MARK: - Properties
    
    private var polygon: GMSPolygon?
    private var polyDictionary: [Int : PolyRoutePathItem] = [:]
    weak var delegate: PolylineManagerDelegate?
    
    // MARK: - LifeCycle
    
    private init() { }
    
    deinit {
        removePolyDictionary()
        removePolygon()
        delegate = nil
    }
    
    // MARK: - Helpers
    
    /// 노선 그리기 (도보, 버스 등 종합적인 노선 처리)
    func createRoutePathByInfo(actionList: [RouteActionItem]?) {
        guard let _actionList = actionList else { return }
        
        for index in 0..<_actionList.count {
            let item = _actionList[index]
            let routeType = PolylineRouteType.create(acitonType: item.actionType)
            let drivingType = routeType == .driving ? PolyLineDrivingType.create(mobilityType: item.mobilityType) : .none
         
            if let _path = createPathList(by: item.route) {
                let line = createPolyLine(routeType: routeType, path: _path, drivingType: drivingType)
                polyDictionary.updateValue(.init(path: _path, line: line), forKey: index)
            }
        }
    }
    
    /// 다각형 그리기 (스마트허브 영역 띄우기)
    func createSmartHubArea(pathList: [CLLocationCoordinate2D]?) {
        guard let _pathList = pathList, _pathList.count > 0 else { return }
        removePolygon()
        
        let area = GMSMutablePath()
        _pathList.forEach { area.add($0) }
        
        let polygon = GMSPolygon(path: area)
        polygon.fillColor = .cyan_moderate.withAlphaComponent(0.4)
        polygon.map = delegate?.mapView
    }
    
    /// 노선 지우기
    func removePolyDictionary() {
        for (_, value) in polyDictionary {
            value.line.map = nil
            value.path.removeAllCoordinates()
        }
        polyDictionary.removeAll()
    }
    
    /// 다각형 지우기
    func removePolygon() {
        polygon?.layer.removeFromSuperlayer()
        polygon?.map = nil
        polygon = nil
    }
    
    /// 경로 데이터 변환
    private func createPathList(by routeList: [[Double]]?) -> GMSMutablePath? {
        guard let _routeList = routeList else { return nil }
        let routePath = GMSMutablePath()
        
        _routeList.forEach { route in
            if let _lat = route.last,
               let _lon = route.first {
                routePath.addLatitude(_lat, longitude: _lon)
            }
        }
        
        return routePath
    }
    
    /// 점선 데이터 변환
    private func createPolyLine(routeType: PolylineRouteType, path: GMSMutablePath, drivingType: PolyLineDrivingType) -> GMSPolyline {
        let polyline = GMSPolyline(path: path)
        switch routeType {
        case .walk:
            let style = [GMSStrokeStyle.solidColor(.black), GMSStrokeStyle.solidColor(.clear)]
            polyline.spans = GMSStyleSpans(path, style, [10, 5], .rhumb)
        case .driving:
            polyline.strokeColor = drivingType.routeColor
        default:
            polyline.strokeColor = routeType.routeColor
        }
        polyline.strokeWidth = 6.0
        polyline.geodesic = true
        polyline.map = delegate?.mapView
        
        return polyline
    }
    
}

// MARK: - Enums

extension PolylineManager {
        
    struct PolyRoutePathItem {
        let path: GMSMutablePath
        let line: GMSPolyline
    }
    
    enum PolylineRouteType: String {
        case walk = "walking"
        case driving = "mobility_driving"
        case busMoving = "bus_moving"
        case none = ""
        
        static func create(acitonType: String?) -> PolylineRouteType {
            return .init(rawValue: acitonType ?? "") ?? .none
        }
        
        var routeColor: UIColor {
            switch self {
            case .walk:         return .black
            case .busMoving:    return .blue_persian
            default:            return .clear
            }
        }
    }
    
    enum PolyLineDrivingType: String {
        /// 킥보드
        case kickboard          = "ev_kickboard"
        /// 전기 자전거
        case electronicBicycle  = "ev_bicycle"
        /// 공공 자전거
        case publicBicycle      = "public_bicycle"
        /// 전기 오토바이
        case motorcycle         = "ev_motorcycle"
        case none = ""
        
        static func create(mobilityType: String?) -> PolyLineDrivingType {
            return .init(rawValue: mobilityType ?? "") ?? .none
        }
        
        var routeColor: UIColor {
            switch self {
            case .publicBicycle:    return .blue_bright
            case .none:             return .clear
            default:                return .cyan_dark
            }
        }
    }
    
}

 

 

모빌리티 타입에 따른 색상을 별도로 지정해두었습니다.
이 테스트 코드에서는 필요한 값만을 넣어 구현하였기에 독립적인 개체로 추가했습니다.
스타일은 편하게 바꾸셔도 됩니다.

MapViewController에서 노선과 다각형을 그릴 시 아래처럼 호출하면 됩니다.

 

/// MapViewController 에서 호출하는 코드

 let testGeofence: [CLLocationCoordinate2D] = [
        .init(latitude: 37.5289716, longitude: 126.9216007),
        .init(latitude: 37.5246671, longitude: 126.9255057),
        .init(latitude: 37.5276075, longitude: 126.9303555),
        .init(latitude: 37.5318616, longitude: 126.9265208),
        .init(latitude: 37.5289716, longitude: 126.9216007)
    ]
    
// 노선을 그리는 코드
poligonManager.createRoutePathByInfo(actionList: res.actionItemList)
// 다각형을 그리는 코드    
poligonManager.createSmartHubArea(pathList: testGeofence)

 

참고하실 때 출처를 표기해주시는 매너 부탁드립니다. 🙂
감사합니다. 

반응형

'개발 > iOS' 카테고리의 다른 글

[iOS] YoutubePlayer 사용하기  (0) 2022.07.15
[iOS] 기기 별 지원 정보 (OS, Face ID, Touch ID)  (0) 2021.10.18
[iOS] iOS의 4가지 층  (0) 2021.03.24