VesselWheel

MapKit으로 출발지, 도착지 그리고 경로 구현하기(.feat CLLocationManagerDelegate, MKMapViewDelegate) 본문

Xcode Study

MapKit으로 출발지, 도착지 그리고 경로 구현하기(.feat CLLocationManagerDelegate, MKMapViewDelegate)

JasonYang 2024. 3. 5. 21:52

MapKit에서 경로를 구현하기 위해서는

첫번째로 CLLocationManagerDelegate를 활용하여 위치서비스 권한요청을 실시한다.

-> 사용자의 현재 위치나 사용자의 위치를 활용한 기능을 구현할 때 필요한 프로토콜이다. 

러닝기록앱으로서 사용자의 위치를 추적하고, 경로를 이동할 때 사용될 예정이다. 

싱글톤 패턴으로 CLLocationManagerDelegate을 통해 정의한 위치서비스 권한 요청, 위치업데이트 매소드를 호출한다. 

 

위치 권한 관련된 매소드가 locationManagerDidChangeAuthorization이 있다. 

locationManagerDidChangeAuthorization 매소드는 locationManager가 위치서비스를 요청할 때 내부적으로 사용되는 매소드이다. 

viewController에서 위치서비스 요청을 하기 위해서는 아래의 매소드를 호출하는 커스텀 매소드를 만들어야 한다. 

(아래의 didChangeAuthroization은 depreacated 되었다. )

    //위치서비스의 권한 상태가 변경될 때 호출되는 매서드
    func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) {
        switch manager.authorizationStatus {
        case .authorizedAlways, .authorizedWhenInUse:
            self.locationManager.startUpdatingLocation()
            print("GPS 권한 설정됨")
        case .denied, .restricted:
            DispatchQueue.main.async {
                self.locationManager.requestWhenInUseAuthorization()
            }
            print("GPS 권한 요청함")
        case .notDetermined:
            //결정이 안되었을 경우 권한 요청
            DispatchQueue.main.async {
                self.locationManager.requestWhenInUseAuthorization()
            }
            print("GPS 권한 요청함")
        default:
            return
        }
    }

-> 위치권한을 switch문으로 사용자의 위치권한 선택에 따라 위치권환 요청을 요구하는 매소드를 호출한다. 

-> 위치권한이 허용된 .authorizedAlways, .authorizedWhenInUse:의 경우에는 바로 위치업데이트를 실시하고,

=> .startUpdatingLocation()

-> .denied, .restricted:와 .notDetermined: 경우에는 위치권한요청을 요구한다. 

=> requestWhenInUseAuthorization()

상기 매소드는 Info.plist에서 권한요청에 따른 메세지를 설정해주어야한다. 

 

위치서비스 권한 요청을 위해 ViewController에서 사용되는 매소드

    //위치사용 권한 요청
    public func getLocationUsagePermission() {
        self.locationManager.requestWhenInUseAuthorization()
    }
    
    // Call this method to start updating the location
    public func startUpdatingLocation() {
        locationManager.startUpdatingLocation()
    }
    
    // Call this method to stop updating the location
    public func stopUpdatingLocation() {
        locationManager.stopUpdatingLocation()
    }

 

 

위치서비스 요청 간 위치서비스와 관련된 오류처리를 하는 매소드

    // 오류 처리
    func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) {
        print("Location Error: \(error)")
    }

 

러닝타이머와 관련되어 위치정보를 업데이트하여 이전 위치와 현재 위치의 거리를 계산해주는 매소드

    // CLLocationManagerDelegate 메서드
    func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
        guard let location = locations.last else { return }

        // 현재 위치 정보 출력
        print("Current location: \(location.coordinate.latitude), \(location.coordinate.longitude)")

        if let previousLocation = self.previousLocation {
            // 이전 위치와 현재 위치 사이의 거리 계산
            let distance = previousLocation.distance(from: location)
            totalDistance += distance

            // 계산된 거리 정보 출력
            print("Distance from previous location: \(distance) meters")
            print("Total distance: \(totalDistance) meters")
        } else {
            // 이전 위치 정보가 없을 경우, 처음 위치 업데이트를 받은 것임
            print("Starting location updates...")
        }

        // 새 위치를 이전 위치로 업데이트
        self.previousLocation = location

        // 위치 업데이트 클로저 호출 (새 위치 데이터 전달)
        updateLocationClosure?(location)
    }

 

💡 전체코드 참조

더보기
//
//  RunningTimerManager.swift
//  Run-It
//
//  Created by Jason Yang on 2/25/24.
//

import Foundation
import CoreLocation


// 위치 권한 요청, 사용자 위치를 계속 요청하므로 추적 배터리 소모 고려 조정 필요
class RunningTimerLocationManager: NSObject, CLLocationManagerDelegate {
    
    static let shared = RunningTimerLocationManager()
    
    var locationManager: CLLocationManager = CLLocationManager()
    var previousLocation: CLLocation?
    var totalDistance: Double = 0
    var startTime: Date?
    var pace: Double = 0
    var updateLocationClosure: ((CLLocation) -> Void)?
    
    override init() {
        super.init()
        locationManager.delegate = self
        locationManager.allowsBackgroundLocationUpdates = true
        locationManager.desiredAccuracy = kCLLocationAccuracyBest
    }
    
    //위치사용 권한 요청
    func getLocationUsagePermission() {
        self.locationManager.requestWhenInUseAuthorization()
    }
    
    // Call this method to start updating the location
    func startUpdatingLocation() {
        locationManager.startUpdatingLocation()
    }
    
    // Call this method to stop updating the location
    func stopUpdatingLocation() {
        locationManager.stopUpdatingLocation()
    }
    
    //위치서비스의 권한 상태가 변경될 때 호출되는 매서드
    func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) {
        switch manager.authorizationStatus {
        case .authorizedAlways, .authorizedWhenInUse:
            self.locationManager.startUpdatingLocation()
            print("GPS 권한 설정됨")
        case .denied, .restricted:
            DispatchQueue.main.async {
                self.locationManager.requestWhenInUseAuthorization()
            }
            print("GPS 권한 요청함")
        case .notDetermined:
            //결정이 안되었을 경우 권한 요청
            DispatchQueue.main.async {
                self.locationManager.requestWhenInUseAuthorization()
            }
            print("GPS 권한 요청함")
        default:
            return
        }
    }
    
    // 오류 처리
    func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) {
        print("Location Error: \(error)")
    }
    
    // CLLocationManagerDelegate 메서드
    func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
        guard let location = locations.last else { return }

        // 현재 위치 정보 출력
        print("Current location: \(location.coordinate.latitude), \(location.coordinate.longitude)")

        if let previousLocation = self.previousLocation {
            // 이전 위치와 현재 위치 사이의 거리 계산
            let distance = previousLocation.distance(from: location)
            totalDistance += distance

            // 계산된 거리 정보 출력
            print("Distance from previous location: \(distance) meters")
            print("Total distance: \(totalDistance) meters")
        } else {
            // 이전 위치 정보가 없을 경우, 처음 위치 업데이트를 받은 것임
            print("Starting location updates...")
        }

        // 새 위치를 이전 위치로 업데이트
        self.previousLocation = location

        // 위치 업데이트 클로저 호출 (새 위치 데이터 전달)
        updateLocationClosure?(location)
    }

}

두번째로 MKMapViewDelegate를 활용하여 annotation과 경로 등에 대한 세부 사항을 지정해준다. 

더보기
//
//  RunningViewController.swift
//  Running&Eat
//
//  Created by Jason Yang on 2/21/24.
//

import UIKit
import SnapKit
import MapKit
import CoreLocation

class RunningMapViewController: UIViewController, MKMapViewDelegate {
    
    //MARK: - UI Properties
    lazy var locationManager: CLLocationManager = {
        let manager = CLLocationManager()
        manager.desiredAccuracy = kCLLocationAccuracyBest
        manager.startUpdatingLocation() // startUpdate를 해야 didUpdateLocation 메서드가 호출됨.
        manager.delegate = self
        manager.allowsBackgroundLocationUpdates = true
        manager.showsBackgroundLocationIndicator = true
        return manager
    }()
    
    lazy var mapView: MKMapView = {
        let mapView = MKMapView()
        mapView.showsUserLocation = true
        mapView.isZoomEnabled = true
        mapView.isRotateEnabled = true
        mapView.mapType = MKMapType.standard
        mapView.showsCompass = false
        
        return mapView
    }()
    
    lazy var compassButton: MKCompassButton = {
        let compassButton = MKCompassButton(mapView: self.mapView)
        compassButton.compassVisibility = .visible
        var config = UIButton.Configuration.filled()
        config.cornerStyle = .capsule
        compassButton.frame = CGRect(x: 0, y: 0, width: 50, height: 50)
        return compassButton
    }()
    
    lazy var currentLocationButton: UIButton = {
        let button = UIButton()
        var config = UIButton.Configuration.filled()
        config.baseBackgroundColor = .systemCyan
        config.cornerStyle = .capsule
        config.image = UIImage(systemName: "location")?.withConfiguration(UIImage.SymbolConfiguration(pointSize: 20, weight: .medium))
        button.configuration = config
        button.layer.cornerRadius = 25
        button.addTarget(self, action: #selector(currentLocationButtonAction), for: .touchUpInside)
        return button
    }()
    
    lazy var storeListButton: UIButton = {
        let button = UIButton()
        var config = UIButton.Configuration.filled()
        config.baseBackgroundColor = .systemIndigo
        config.cornerStyle = .capsule
        config.image = UIImage(systemName: "plus")?.withConfiguration(UIImage.SymbolConfiguration(pointSize: 20, weight: .medium))
        button.configuration = config
        button.layer.cornerRadius = 25
        button.layer.shadowRadius = 10
        button.layer.shadowOpacity = 0.3
        button.addTarget(self, action: #selector(TappedstoreListButton), for: .touchUpInside)
        return button
    }()
    
    lazy var convenienceStoreButton: UIButton = {
        let button = UIButton()
        var config = UIButton.Configuration.filled()
        config.baseBackgroundColor = .systemPink
        config.cornerStyle = .capsule
        config.image = UIImage(systemName: "storefront")?.withConfiguration(UIImage.SymbolConfiguration(pointSize: 20, weight: .medium))
        button.configuration = config
        button.layer.shadowRadius = 10
        button.layer.shadowOpacity = 0.3
        button.alpha = 0.0
        button.addTarget(self, action: #selector(presentStoreAnnotationButton), for: .touchUpInside)
        return button
    }()
    
    lazy var cafeButton: UIButton = {
        let button = UIButton()
        var config = UIButton.Configuration.filled()
        config.baseBackgroundColor = .systemPink
        config.cornerStyle = .capsule
        config.image = UIImage(systemName: "cup.and.saucer")?.withConfiguration(UIImage.SymbolConfiguration(pointSize: 20, weight: .medium))
        button.configuration = config
        button.layer.shadowRadius = 10
        button.layer.shadowOpacity = 0.3
        button.alpha = 0.0
        button.addTarget(self, action: #selector(presentStoreAnnotationButton), for: .touchUpInside)
        return button
    }()
    
    private var isActive: Bool = false {
        didSet {
            showActionButtons()
        }
    }
    
    private var animation: UIViewPropertyAnimator? //회전 버튼 프로퍼티
    
    lazy var startRunningButton: UIButton = {
        let button = UIButton()
        button.frame = CGRect(x: 0, y: 0, width: 100, height: 100)
        button.tintColor = .white
        let configuration = UIImage.SymbolConfiguration(pointSize: 50)
        if let image = UIImage(systemName: "figure.run", withConfiguration: configuration) {
            button.setImage(image, for: .normal)
        }
        button.backgroundColor = .systemIndigo
        button.layer.cornerRadius = 45
        button.clipsToBounds = true
        
        button.addTarget(self, action: #selector(TappedstartRunningButton), for: .touchUpInside)
        return button
    }()
    
    //MARK: - 전역 변수 선언
    var routeLine = MKPolyline()
    var currentCircle: MKCircle?
    var currentLocation: CLLocation?
    var destination: CLLocationCoordinate2D?
    
    var startPin: MKPointAnnotation?
    var endPin: MKPointAnnotation?
    
    
    //MARK: - LifeCycle
    override func viewDidLoad() {
        super.viewDidLoad()
        view.backgroundColor = .white
        mapView.delegate = self
        addSubview()
        setLayout()
        RunningTimerLocationManager.shared.getLocationUsagePermission() //viewDidLoad 되었을 때 권한요청을 할 것인지, 현재 위치를 눌렀을 때 권한요청을 할 것인지
        
    }
    
    override func viewWillDisappear(_ animated: Bool) {
        RunningTimerLocationManager.shared.stopUpdatingLocation()  // 러닝 중에 지도가 보인다면, viewWillDisappear 할 때 stopUpdatingLocation()가 호출되면 안됨.
    }
    
    //MARK: - @objc functions
    @objc private func TappedstartRunningButton() {
        print("TappedstartRunningButton()")
        let startRunningViewController =  StartRunningViewController()
        startRunningViewController.modalPresentationStyle = .fullScreen
        self.present(startRunningViewController, animated: true)
        
    }
    
    @objc func currentLocationButtonAction() {
        //        RunningTimerLocationManager.shared.getLocationUsagePermission()  //viewDidLoad 되었을 때 권한요청을 할 것인지, 현재 위치를 눌렀을 때 권한요청을 할 것인지
        mapView.showsUserLocation = true
        mapView.setUserTrackingMode(.follow, animated: true)
        print("확인")
    }
    
    @objc private func TappedstoreListButton() {
        isActive.toggle()
    }
    
    @objc func presentStoreAnnotationButton() {
        getAnnotationLocation()
        //        if mapView.annotations.count > 0 {
        //            removeAnnotationsFromMap()
        //
        //        } else {
        //            getAnnotationLocation()
        //        }
        //        let storeViewController = StoreViewController()
        //        showMyViewControllerInACustomizedSheet(storeViewController)
    }
    
}

//MARK: - Annotation Setup
extension RunningMapViewController {
    
    private func getAnnotationLocation() {
        guard let currentLocation = self.mapView.userLocation.location else {
            print("Failed to get user location")
            return
        }
        
        let Searchrequest = MKLocalSearch.Request()
        Searchrequest.naturalLanguageQuery = "GS25" // 원하는 POI 유형을 검색어로 지정
        //        request.region = mapView.region // 검색 범위를 지정
        Searchrequest.region = MKCoordinateRegion(center: currentLocation.coordinate, latitudinalMeters: 150, longitudinalMeters: 150) // 150m 범위를 지정
        
        let search = MKLocalSearch(request: Searchrequest)
        search.start { (response, error) in
            guard let response = response else {
                print("Search error: \(error?.localizedDescription ?? "Unknown error")")
                return
            }
            
            for item in response.mapItems {
                // 검색한 POI를 지도에 추가
                let annotation = MKPointAnnotation()
                annotation.coordinate = item.placemark.coordinate
                annotation.title = item.name
                self.mapView.addAnnotation(annotation)
            }
        }
    }
    
    private func removeAnnotationsFromMap() {
        // mapView.annotations 배열에서 MKUserLocation 인스턴스를 제외하고 모두 제거
        let annotationsToRemove = mapView.annotations.filter { $0 !== mapView.userLocation }
        mapView.removeAnnotations(annotationsToRemove)
        
        // mapView.overlays 배열에서 currentCircle를 제외하고 모두 제거
        let overlaysToRemove = mapView.overlays.filter { $0 !== currentCircle }
        mapView.removeOverlays(overlaysToRemove)
    }
    
} //extension

//MARK: - CLLocationManagerDelegate
extension RunningMapViewController: CLLocationManagerDelegate {
    
    func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
        if let location = locations.last {
            self.currentLocation = location
            
            // 이전에 추가된 원을 제거
            if let currentCircle = currentCircle {
                mapView.removeOverlay(currentCircle)
            }
            
            // 새로운 원을 추가
            let circle = MKCircle(center: location.coordinate, radius: 150)
            mapView.addOverlay(circle)
            self.currentCircle = circle
            
            if let destination = destination {
                let userLocation = location.coordinate  // 사용자의 위치에 기기의 마지막 위경도를 주입
                
                calculateAndShowRoute(from: userLocation, to: destination)
            }
        }
    }
    
    // 사용자의 현재 위치를 기반으로 경로를 계산하고 지도에 표시하는 메서드
    private func calculateAndShowRoute(from userLocation: CLLocationCoordinate2D, to destination: CLLocationCoordinate2D) {
        
        mapView.removeOverlays(mapView.overlays)
        
        if let startPin = startPin, let annotationToRemove = mapView.annotations.first(where: { $0.coordinate.latitude == startPin.coordinate.latitude && $0.coordinate.longitude == startPin.coordinate.longitude }) {
            mapView.removeAnnotation(annotationToRemove)
        }
        if let endPin = endPin, let annotationToRemove = mapView.annotations.first(where: { $0.coordinate.latitude == endPin.coordinate.latitude && $0.coordinate.longitude == endPin.coordinate.longitude }) {
            mapView.removeAnnotation(annotationToRemove)
        }
        
        // MKDirectionsRequest를 사용하여 경로를 요청
        let request = MKDirections.Request()
        request.source = MKMapItem(placemark: MKPlacemark(coordinate: userLocation))
        request.destination = MKMapItem(placemark: MKPlacemark(coordinate: destination))
        request.transportType = .walking // 적절한 이동 수단을 선택
        
        // MKDirections를 사용하여 경로를 추적
        let directions = MKDirections(request: request)
        directions.calculate { response, error in
            guard let route = response?.routes.first else {
                if let error = error {
                    print("Error calculating route: \(error.localizedDescription)")
                }
                return
            }
            
            // 경로를 지도에 추가
            self.mapView.addOverlay(route.polyline)
            
            // 경로가 모두 표시되도록 지도를 조정
            let region = MKCoordinateRegion(route.polyline.boundingMapRect)
            self.mapView.setRegion(region, animated: true)
            
            // 출발점과 목적지에 커스텀 애노테이션을 추가
            self.addCustomPins(userLocation: userLocation, destination: destination)
        }
    }
    
    private func addCustomPins(userLocation: CLLocationCoordinate2D, destination: CLLocationCoordinate2D) {
        startPin = MKPointAnnotation()
        endPin = MKPointAnnotation()
        
        if let startPin = startPin {
            startPin.title = "start"
            startPin.coordinate = userLocation
            mapView.addAnnotation(startPin)
        }
        
        if let endPin = endPin {
            endPin.title = "end"
            endPin.coordinate = destination
            mapView.addAnnotation(endPin)
        }
    }
    
    func mapView(_ mapView: MKMapView, didSelect view: MKAnnotationView) {
        
        if let annotation = view.annotation {
            
            // 사용자의 현재 위치에서 선택한 애노테이션까지의 경로를 계산하고 보여줌
            calculateAndShowRoute(from: mapView.userLocation.coordinate, to: annotation.coordinate)
        }
    }
    
    func mapView(_ mapView: MKMapView, didDeselect view: MKAnnotationView) {
        
    }
    
    
    // 지도에 경로 및 주변원을 표시하기 위해 MKMapViewDelegate에서 MKPolylineRenderer를 설정
    func mapView(_ mapView: MKMapView, rendererFor overlay: MKOverlay) -> MKOverlayRenderer {
        if overlay is MKPolyline {
            let renderer = MKPolylineRenderer(overlay: overlay)
            renderer.strokeColor = UIColor.systemIndigo // 경로의 색상을 설정
            renderer.lineCap = .round
            renderer.lineWidth = 5.0 // 경로의 두께를 설정합니다.
            return renderer
            // 지도에 서클을 표시하기 위해 MKCircle를 활용
        } else if let circleOverlay = overlay as? MKCircle {
            let renderer = MKCircleRenderer(circle: circleOverlay)
            renderer.fillColor = UIColor.systemIndigo.withAlphaComponent(0.2)
            return renderer
        }
        return MKOverlayRenderer(overlay: overlay)
    }
    
    //지도에 표시될 annotation 아이콘 설정 매소드
    func mapView(_ mapView: MKMapView, viewFor annotation: MKAnnotation) -> MKAnnotationView? {
        if annotation is MKUserLocation {
            return nil
        }
        
        switch annotation.title {
        case "end":
            var annotationView = mapView.dequeueReusableAnnotationView(withIdentifier: "endPin")
            if annotationView == nil {
                annotationView = MKAnnotationView(annotation: annotation, reuseIdentifier: "endPin")
                annotationView?.image = UIImage(systemName: "figure.run")
            } else {
                annotationView?.annotation = annotation
            }
            return annotationView
        case "start":
            var annotationView = mapView.dequeueReusableAnnotationView(withIdentifier: "startPin")
            if annotationView == nil {
                annotationView = MKAnnotationView(annotation: annotation, reuseIdentifier: "startPin")
                annotationView?.image = UIImage(systemName: "figure.stand")
            } else {
                annotationView?.annotation = annotation
            }
            return annotationView
        default:
            return nil
        }
    }
    
}
//MARK: - PopButton setup
extension RunningMapViewController {
    private func showActionButtons() {
        popButtons()
        rotateFloatingButton()
    }
    
    private func popButtons() {
        if isActive {
            convenienceStoreButton.layer.transform = CATransform3DMakeScale(0.4, 0.4, 1)
            UIView.animate(withDuration: 0.3, delay: 0.2, usingSpringWithDamping: 0.55, initialSpringVelocity: 0.3, options: [.curveEaseInOut], animations: { [weak self] in
                guard let self = self else { return }
                self.convenienceStoreButton.layer.transform = CATransform3DIdentity
                self.convenienceStoreButton.alpha = 1.0
            })
        } else {
            UIView.animate(withDuration: 0.15, delay: 0.2, options: []) { [weak self] in
                guard let self = self else { return }
                self.convenienceStoreButton.layer.transform = CATransform3DMakeScale(0.4, 0.4, 0.1)
                self.convenienceStoreButton.alpha = 0.0
                removeAnnotationsFromMap() // 지도에 표시된 MapItem을 삭제(사용자 위치 제외)
                
            }
        }
    }
    
    private func rotateFloatingButton() {
        let animation = CABasicAnimation(keyPath: "transform.rotation.z")
        let fromValue = isActive ? 0 : CGFloat.pi / 4
        let toValue = isActive ? CGFloat.pi / 4 : 0
        animation.fromValue = fromValue
        animation.toValue = toValue
        animation.duration = 0.3
        animation.fillMode = .forwards
        animation.isRemovedOnCompletion = false
        storeListButton.layer.add(animation, forKey: nil)
    }
}
//MARK: - Layout setup
extension RunningMapViewController {
    private func addSubview() {
        view.addSubview(mapView)
        mapView.addOverlay(routeLine)
        view.addSubview(compassButton)
        view.addSubview(startRunningButton)
        view.addSubview(currentLocationButton)
        view.addSubview(storeListButton)
        view.addSubview(convenienceStoreButton)
    }
    
    private func setLayout() {
        mapView.snp.makeConstraints {
            $0.top.leading.trailing.equalToSuperview()
            $0.bottom.equalTo(view.safeAreaLayoutGuide.snp.bottom)
        }
        
        compassButton.snp.makeConstraints {
            $0.width.height.equalTo(50)
            $0.trailing.equalToSuperview().offset(-20)
            $0.top.equalToSuperview().offset(120)
        }
        
        currentLocationButton.snp.makeConstraints {
            $0.width.height.equalTo(50)
            $0.trailing.equalToSuperview().offset(-20)
            $0.top.equalTo(compassButton.snp.bottom).offset(20)
        }
        storeListButton.snp.makeConstraints {
            $0.width.height.equalTo(50)
            $0.trailing.equalToSuperview().offset(-20)
            $0.top.equalTo(currentLocationButton.snp.bottom).offset(20)
        }
        
        convenienceStoreButton.snp.makeConstraints {
            $0.top.equalTo(storeListButton.snp.bottom).offset(20)
            $0.centerX.equalTo(storeListButton)
        }
        
        startRunningButton.snp.makeConstraints {
            $0.width.height.equalTo(90)
            $0.centerX.equalToSuperview()
            $0.bottom.equalToSuperview().offset(-120)
        }
    }
    
    
}