VesselWheel

MapKit에서 실시간 사용자 위치 추적 및 경로 표시하기 본문

Xcode Study

MapKit에서 실시간 사용자 위치 추적 및 경로 표시하기

JasonYang 2024. 3. 8. 09:52

Mapkit을 사용하기 위해서는 첫번째로 

CLLocationManager과 MKMapView의 객체를 정의해야한다. 

더보기
    lazy var locationManager: CLLocationManager = {
        let manager = CLLocationManager()
        manager.desiredAccuracy = kCLLocationAccuracyBest
        manager.startUpdatingLocation() // startUpdate를 해야 didUpdateLocation 메서드가 호출됨.
        manager.delegate = self
        manager.pausesLocationUpdatesAutomatically = false
        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 = .white
        config.cornerStyle = .capsule
        config.baseForegroundColor = .black
        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
    }()

 

 

 

-> CLLocationManager는 NSObject로 CLLocationManager을 사용하고자 하는 곳에서 객체를 생성하고, CLLocationManager에서 지원하는 매소드, 프로퍼티의 상속 및 참조 기능을 제공한다. 

 

 

 

    lazy var locationManager: CLLocationManager = {
        let manager = CLLocationManager()
        manager.desiredAccuracy = kCLLocationAccuracyBest
        manager.startUpdatingLocation() // startUpdate를 해야 didUpdateLocation 메서드가 호출됨.
        manager.delegate = self
        manager.pausesLocationUpdatesAutomatically = false
        manager.allowsBackgroundLocationUpdates = true
        manager.showsBackgroundLocationIndicator = true
        return manager
    }()

-> manager.desiredAccuracy = kCLLocationAccuracyBest : 위치 정확도를 최고로 지정

-> startUpdatingLocation() : 객체 생성과 함께 위치 업데이트 시작

-> delegate = self : delegate 지정은 viewDidLoad에서 설정해줘야 한다. 

manager.pausesLocationUpdatesAutomatically = false
manager.allowsBackgroundLocationUpdates = true
manager.showsBackgroundLocationIndicator = true

-> 러닝타이머와 mapView는 사용자가 화면을 끄고 이동하더라고 지원해야하기 때문에, 

-> pausesLocationUpdatesAutomatically : 메모리와 배터리 성능을 고려하여 애플은 위치업데이트를 자동 종결시키는 것을 디폴트로 하고 있기 때문에, 이 사항은 false로 해줘야한다. 

-> allowsBackgroundLocationUpdates : 마찬가지로 앱화면이 백그라운드, 즉 다른 앱을 사용하더라도 위치업데이트는 진행되어야 한다. 

-> showsBackgroundLocationIndicator : 백그라운드에서 위치업데이트가 되고 있다는 것을 사용자에게 알려야한다. (배터리 소모, 메모리 사용은 사용자 권한)

-> allowsBackgroundLocationUpdates 를 위해서는 앱 - TARGETS + Capability에서 [Background Modes]를 추가하고, Location updates를 체크해줘야 정상 작동한다. 

 

    override func viewDidLoad() {
        super.viewDidLoad()
        view.backgroundColor = .white
        addSubview()
        setLayout()
        mapView.delegate = self
        locationManager.delegate = self
        RunningTimerLocationManager.shared.getLocationUsagePermission() //viewDidLoad 되었을 때 권한요청을 할 것인지, 현재 위치를 눌렀을 때 권한요청을 할 것인지
        
    }
    
    override func viewWillDisappear(_ animated: Bool) {
//        RunningTimerLocationManager.shared.stopUpdatingLocation()  // 러닝 중에 지도가 보인다면, viewWillDisappear 할 때 stopUpdatingLocation()가 호출되면 안됨.
    }

-> 뷰가 로드되었을 때, 

mapView.delegate = self
locationManager.delegate = self

mapView와 locationManager의 위임자를 해당 ViewController로 지정해줘야, 상기 기능을 사용할 수 있다. 

(이 상관관계를 잘 몰라서, 아래에 구현한 매소드를 정의했지만, 해당 ViewController는 상속을 선언하지 않았기 때문에 사용할 수 없었다.)


RunningTimerLocationManager.shared.getLocationUsagePermission()

//viewDidLoad 되었을 때 권한요청을 할 것인지, 현재 위치를 눌렀을 때 권한요청

-> 싱글톤 패턴으로 RunningTimerLocationManager을 정의하고 해당 클래스에서 권한요청 관련 사항을 정의했다. (이전 글 참조)

 

-> viewWillDisappear에서 stopUpdatingLocation()을 하면 화면을 다른 뷰로 넘기면 위치서비스 업데이트가 중지된다. (선택사항)

더보기
//
//  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.startUpdatingLocation()
        locationManager.pausesLocationUpdatesAutomatically = false
        locationManager.allowsBackgroundLocationUpdates = true
        locationManager.showsBackgroundLocationIndicator = 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)
    }

}

맵뷰로 잠시 넘어가서 이어가자면, 

    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
    }()

MKMapView는 기본적으로 UIView, NSCoding을 따르고, 

delegate로 MKMapViewDelegate을 통해 해당되는 프로퍼티와 매소드를 사용할 수 있다. 

https://developer.apple.com/documentation/mapkit/mkmapviewdelegate

 

MKMapViewDelegate | Apple Developer Documentation

Optional methods that you use to receive map-related update messages.

developer.apple.com

 

MKMapViewDelegate에서는 지도이동, 맵데이터 로딩, 사용자 추적, 맵주석 뷰, 맵주석의 이동 시, 주석 선택 혹은 미선택 시, 맵 층의 관리에 관한 다양한 매소드가 있다. 

더보기
    //MARK: - MKMapViewDelegate
    
    func mapView(_ mapView: MKMapView, didSelect view: MKAnnotationView) {
        guard let customAnnotation = view.annotation as? CustomAnnotation, let mapItem = customAnnotation.mapItem else { return }
        mapView.removeOverlays(mapView.overlays)
        let annotation = CustomAnnotation()
        
        let name = mapItem.name ?? "Unknown"
        let address = mapItem.placemark.title ?? "No Address"
        let category = "편의점" // 확장 필요

        // 어노테이션까지 거리 계산
        let userLocation = CLLocation(latitude: mapView.userLocation.coordinate.latitude, longitude: mapView.userLocation.coordinate.longitude)
        let annotationLocation = CLLocation(latitude: customAnnotation.coordinate.latitude, longitude: customAnnotation.coordinate.longitude)
        let distance = userLocation.distance(from: annotationLocation)
        
        let url = mapItem.url?.absoluteString ?? "No URL"
        
        let isFavorite = favoritesViewModel.isFavorite(storeName: annotation.title ?? "", latitude: annotation.coordinate.latitude, longitude: annotation.coordinate.longitude)

        // 예시 정보를 `AnnotationInfo`로 생성
        let info = AnnotationInfo(
            name: name,
            category: category,
            address: address,
            url: url,
            latitude: customAnnotation.coordinate.latitude,
            longitude: customAnnotation.coordinate.longitude,
            isOpenNow: true, // 로직 수정 필요
            distance: Int(distance), // 계산된 거리 정보 사용
            isFavorite: isFavorite
        )
        
        // 사용자 현재 위치와 선택한 어노테이션 위치 사이의 경로 계산 및 표시
        let sourceCoordinate = mapView.userLocation.coordinate
        let destinationCoordinate = customAnnotation.coordinate

        let sourcePlacemark = MKPlacemark(coordinate: sourceCoordinate)
        let destinationPlacemark = MKPlacemark(coordinate: destinationCoordinate)

        let sourceItem = MKMapItem(placemark: sourcePlacemark)
        let destinationItem = MKMapItem(placemark: destinationPlacemark)

        let directionRequest = MKDirections.Request()
        directionRequest.source = sourceItem
        directionRequest.destination = destinationItem
        directionRequest.transportType = .walking // 또는 .automobile

        let directions = MKDirections(request: directionRequest)
        directions.calculate { (response, error) in
            guard let response = response else {
                if let error = error {
                    print("Error: \(error)")
                }
                return
            }
            
            let route = response.routes[0]
            mapView.addOverlay(route.polyline, level: .aboveRoads)
            
            let rect = route.polyline.boundingMapRect
            mapView.setRegion(MKCoordinateRegion(rect), animated: true)
        }
        
        // StoreViewController에 정보 전달 및 표시
        let storeVC = StoreViewController()
        storeVC.stores = [info] // 단일 어노테이션 정보 전달
        storeVC.modalPresentationStyle = .formSheet
        storeVC.modalTransitionStyle = .coverVertical
        storeVC.view.backgroundColor = UIColor.systemBackground
        
        self.present(storeVC, animated: true, completion: nil)
    }

    
    func mapView(_ mapView: MKMapView, didDeselect view: MKAnnotationView) {
        
    }
    
    
    // 지도에 경로 및 주변원을 표시하기 위한 MKMapViewDelegate에서 MKPolylineRenderer, MKCircleRenderer를 설정
    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(named: "DestinationIcon")
            } 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
        }
    }

-> func mapView(_ mapView: MKMapView, didSelect view: MKAnnotationView) 는 맵 주석이 선택되었을 때 호출되는 매소드이다. 

-> mapView(_ mapView: MKMapView, rendererFor overlay: MKOverlay) -> MKOverlayRendere 는 맵 계층의 경로나, 서클 등을 추가할 때 정의하는 매소드이다. 

-> func mapView(_ mapView: MKMapView, viewFor annotation: MKAnnotation) -> MKAnnotationView? 는 애플에서 제공하는 기본 주석에 커스텀하거나 정의하는 매소드이다.