일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
1 | 2 | |||||
3 | 4 | 5 | 6 | 7 | 8 | 9 |
10 | 11 | 12 | 13 | 14 | 15 | 16 |
17 | 18 | 19 | 20 | 21 | 22 | 23 |
24 | 25 | 26 | 27 | 28 | 29 | 30 |
- 영문 개인정보처리방침
- weak var
- 러닝기록앱
- dispatchsource
- 러닝타이머
- UICollectionViewFlowLayout
- Timer
- 한국어 개인정보처리방침
- font book
- weatherKit
- UIAlertAction
- addannotation
- SwiftUI Boolean 값
- 단일 책임원칙
- CoreLocation
- RunningTimer
- AnyObject
- swift
- Protocol
- App Store Connect
- 서체관리자
- MKMapViewDelegate
- CLLocationManagerDelegate
- 클로저의 캡슐화
- MKMapItem
- Required Reason API
- Xcode
- WeatherManager
- Startign Assignments
- xcode로 날씨앱 만들기
- Today
- Total
VesselWheel
MapKit으로 출발지, 도착지 그리고 경로 구현하기(.feat CLLocationManagerDelegate, MKMapViewDelegate) 본문
MapKit으로 출발지, 도착지 그리고 경로 구현하기(.feat CLLocationManagerDelegate, MKMapViewDelegate)
JasonYang 2024. 3. 5. 21:52MapKit에서 경로를 구현하기 위해서는
첫번째로 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)
}
}
}
'Xcode Study' 카테고리의 다른 글
Apple Developer 등록하기 및 협업하기 (0) | 2024.03.11 |
---|---|
MapKit에서 실시간 사용자 위치 추적 및 경로 표시하기 (1) | 2024.03.08 |
Mapkit의 지도에 MKLocalSearch활용하여 검색된 annotation 노출하기 (0) | 2024.03.04 |
러닝기록 타이머를 정지하며 코어데이터에 저장하기(작성중) (0) | 2024.03.04 |
SceneDeleagte(for 러닝기록 타이머) (0) | 2024.02.29 |