23.01.13 - 앱을 처음 설치했을 때 현재 위치로 이동하지 않는 오류 해결
지난 번 프로토타입에 이어 이번 주부터는 MVP를 진행하고 있다.
기존에 더미 데이터로 구현했던 것들을 실제 FireStore와 연동하고 구현하지 못했던 부분들을 구현하는 것을 목표로 잡았다.
이번 주에 구현하려는 기능은 아래와 같다.
1. 사용자가 지도를 움직이면 움직인 좌표에 대한 도로명 주소를 실시간을 가져옴
2. 사용자가 지도를 움직이면 마커가 살짝 위로 올라가고, 움직임이 멈추면 마커가 다시 내려옴
3. 사용자의 현재 위치를 가져오고, 버튼을 클릭하면 현재 위치로 지도의 Focus를 변경함
하나씩 살펴보도록 하자.
1. 사용자가 지도를 움직이면 움직인 좌표에 대한 도로명 주소를 실시간으로 가져오기
말이 좀 길다.
간단하게 설명하자면 지도를 움직였을 때 도로명 주소 가져온다고 생각하면 된다.
이 기능은 지난 번 프로토타입 때는 구현하지 못한 기능이었지만, 공식문서를 하나 하나 살펴보면서 결국에는 해결했다.
우선 기존 구현 방식을 살펴보면,
CLLocationManagerDelgate를 채택하고 mapViewDidChangeVisibleRegion(_ mapView: MKMapView) 메서드로 지도가 움직일 때 위치 정보를 가져오려고 했었다. 해당 메서드는 지도에서 보이는 영역이 변경되면 호출되는 메서드이다. 따라서 사용자가 지도를 움직이면 이 메서드가 호출되고, 이 메서드 내부에서 위, 경도를 도로명 주소로 변환해주는 ReverseGeocoding을 진행하면 될 것 같았다.
사용자가 지도를 움직일 때, 즉각적으로 위치 정보는 잘 가져왔다.
하지만 이를 도로명 주소로 변환하기 위해서 reverseGeocoding을 적용하면 문제가 발생했다.
에러를 읽어보면 API 호출을 너무 자주해주고 있다는 것
이 문제를 해결하기 위해선 가장 먼저 떠오른 방법은
위, 경도가 바뀔 때마다 호출하는 것이 아니라, TimeInterval을 줘서 특정 시간에 한 번씩만 호출되도록 하는 방법이었다.
검색해보니 어떤 분이 이 방식으로 같은 문제를 해결하고 있었다.
[ios - Swift] MapView 사용하기 (MKMapView 2 / 2)
이번 글에서는 지난 글에서 얘기한 것과 같이 지역 내 검색 및 좌표에 대한 정보를 추출하는 방법을 알아보도록 하겠습니다. 위 gif를 살펴보면 맵 뷰의 현재 위치, 중앙 위치의 주소, 주변 가게
poky-develop.tistory.com
하지만 뭔가 다른 방법으로 접근하고 싶었다.
전에 플러터로 GoogleMaps을 사용할 때 비슷한 기능을 제공하는 메서드가 있었고,
MapKit에도 있을 것이라고 생각하고 공식문서를 찾아봤다.
역시나 Mapkit에서도 제공하고 있었다.
각각에 대해서 간단하게 설명하자면
mapView(_:regionWillChangeAnimated:) 는 Position이 변경되기 직전에
mapViewDidChangeVisibleRegion 는 Position이 변경될 때
mapView(MKMapView, regionDidChangeAnimated:) 는 Position 변경이 종료된 후에 호출된다.
그 중에서 나는 세 번째 메서드를 통해 ReverseGeocoding을 진행했고,
이해를 돕기 위해 해당 메서드 내부에 print문을 찍어봤다.
이렇게 사용자가 지도를 움직이고,
위도, 경도가 조금이라도 바뀌면 mapViewDidChangeVisibleRegion 메서드가 호출되면서
func convertCLLocationToAddress(location: CLLocation) {
let geocoder = CLGeocoder()
// Location To Address
geocoder.reverseGeocodeLocation(location) { placemarks, error in
if error != nil {
return
}
guard let placemark = placemarks?.first else { return }
self.startPlace = "\(placemark.country ?? "") \(placemark.locality ?? "") \(placemark.name ?? "")"
}
}
위에 구현한 convertCLLocationToAddress 메서드가 호출되도록 구현했다.
convertCLLocationToAddress은 Swift에서 자체적으로 제공하는 reverseGeocodeLocation을 활용했다.
이렇게 위, 경도를 도로명 주소로 변환하는 기능은 구현했다.
2. 사용자가 지도를 움직이면 마커가 살짝 위로 올라가고, 움직임이 멈추면 마커가 다시 내려옴
다음은 두 번째, 역시 말이 길다.
사용자가 지도를 움직이면 마커에 특정 효과를 주고 싶었다.
이 기능이 왜 필요하냐고 물어본다면...
사용자 인터랙션을 좋아하기에...
배민이랑 쏘카에서도 이 기능을 찾아볼 수 있다.
아무튼 이 기능이 좋아서 구현을 하려고 했고, 역시 위에서 언급한 메서드를 사용했다.
사용자가 지도를 움직이고 있다는 상태를 저장하기 위한 프로퍼티를 정의해서 구현했다.
@Published var isChanging: Bool = false // 지도의 움직임 여부를 저장하는 프로퍼티
// ... 생략
// MARK: - MapView에서 화면이 이동하면 호출되는 메서드
func mapViewDidChangeVisibleRegion(_ mapView: MKMapView) {
DispatchQueue.main.async {
self.isChanging = true
}
}
// MARK: - MapView에서 화면 이동이 종료되면 호출되는 메서드
func mapView(_ mapView: MKMapView, regionDidChangeAnimated: Bool) {
let location: CLLocation = CLLocation(latitude: mapView.centerCoordinate.latitude, longitude: mapView.centerCoordinate.longitude)
self.convertLocationToAddress(location: location)
DispatchQueue.main.async {
self.isChanging = false
}
}
// ... 생략
// MARK: - location을 도로명 주소로 변환해주는 메서드
func convertLocationToAddress(location: CLLocation) {
let geocoder = CLGeocoder()
geocoder.reverseGeocodeLocation(location) { placemarks, error in
if error != nil {
return
}
guard let placemark = placemarks?.first else { return }
self.startPlace = "\(placemark.country ?? "") \(placemark.locality ?? "") \(placemark.name ?? "")"
}
}
3. 사용자의 현재 위치를 가져오고, 버튼을 클릭하면 현재 위치로 지도의 Focus를 변경함
마지막 세 번째는 사용자의 현재 위치를 가져오고, 버튼을 클릭하면 사용자의 현재 위치로 이동하는 기능이다.
우선은 사용자의 위치를 가져와야 하기에, 위치 권한을 요청하는 작업을 먼저 진행한다.
info 파일에 아래처럼 Key를 추가하고
사용자가 위치 권한을 설정했는지 확인하고, 위치 권한을 요청하는 메서드를 구현했다.
// MARK: - 사용자의 위치 권한 여부를 확인하고 요청하거나 현재 위치 MapView를 이동하는 메서드
func configureLocationManager() {
mapView.delegate = self
manager.delegate = self
let status = manager.authorizationStatus
if status == .notDetermined {
manager.requestAlwaysAuthorization()
} else if status == .authorizedAlways || status == .authorizedWhenInUse {
self.moveFocusOnUserLocation()
}
}
// MARK: - 사용자의 현재 위치로 MapView를 이동하는 메서드
func moveFocusOnUserLocation() {
mapView.showsUserLocation = true
mapView.setUserTrackingMode(.follow, animated: true)
}
또한 사용자가 위치 권한을 변경하면 호출되는 메서드에서도 moveFocusOnUserLocation을 호출했다.
공식문서를 보다가 기존에 사용한 메서드는 Deprecated 된 메서드여서 새로운 메서드로 변경했다.
// MARK: - 사용자에게 위치 권한이 변경되면 호출되는 메서드
// Deprecated
// func locationManager(_ manager: CLLocationManager, didChangeAuthorization status: CLAuthorizationStatus) {
//
// if status == .authorizedAlways || status == .authorizedWhenInUse {
// self.moveFocusOnUserLocation()
// }
// }
func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) {
if manager.authorizationStatus == .authorizedAlways || manager.authorizationStatus == .authorizedWhenInUse {
self.moveFocusOnUserLocation()
}
}
사실 locationManagerDidChangeAuthorization 메서드를 사용하지 않아도 앱은 구현하려는 기능은 정상적으로 작동한다.
그 이유는 해당 메서드는 사용자가 위치 권한을 변경했을 때 호출되는 메서드이기 때문이다.
구현한 코드 상에서는 LocationManager가 EnvironmentObject로 생성되어 있고,
따라서 앱이 실행될 때 LocationManager의 인스턴스를 생성한다.
그 이후에 configureLocationManger 메서드가 호출되는 방식이다.
따라서 사용자가 위치 권한은 변경했는지와 관계 없이 위치 권한이 허용 상태이면,
사용자의 현재 위치로 이동하게 된다.
Apple Developer Documentation
developer.apple.com
그리고 공식문서를 읽어보면 알 수 있는데,
해당 메서드는 LocationManager의 인스턴스가 생성되거나 위치 권한이 변경될 때 호출된다고 나와있다.
실제로 앱이 실행될 때 EnvironmentObject로 LocationManager의 인스턴스를 생성하므로 앱의 완성도를 좀 더 높힌다면?
configureLocationManager에서 moveFocusOnUserLocation를 호출하지 않고,
locationManagerDidChangeAuthorization에서 호출하는 것이 더 적절하다고 생각한다.
아무튼 여기까지 해서 버그 해결~ 😎
(23.01.13 - 앱을 처음 설치했을 때 현재 위치로 이동하지 않는 오류 해결)
우선, 프로젝트에 대해 간단하게 설명하자면 간단한 일기 앱이고, 일기를 작성할 때 사용자의 위치를 등록할 수 있도록 구현할 계획이었다.
또한 일기 작성 페이지에서 MapView를 .onSheet(또는 .FullScreen)로 적용하고 있었다.
이 프로젝트에 기존 코드를 그대로 적용을 했는데,
앱을 처음 실행했을 때 위치 권한을 부여하고, 이후에 MapView가 정상적으로 작동하지 않는 오류가 있었다.
오류를 살펴보면,
1. 사용자의 현재 위치를 가져오지 못함.
2. 다른 기능(화면을 움직일 때 마커가 위, 아래로 움직이는 기능, 실시간으로 도로명 주소를 가져오는 기능)이 정상적으로 작동하지 않음.
코드를 다시 하나 하나 살펴보면서 내린 결론은 LocationManager 인스턴스의 생성 시기였다.
기존 코드는 MapView에서 LocationManager의 인스턴스를 생성하고 있었다.
이를 MapView가 보여지는 페이지가 아닌 이전 페이지(일기를 작성하는 페이지)에서 생성하고 파라미터로 넘겨주는 방식으로 변경했고,
오류를 해결할 수 있었다.
오류를 해결하면서 불필요한 코드를 제거하고, 위에서 언급한 moveFocusOnUserLocation의 역할을
locationManagerDidChangeAuthorization에서 처리하도록 변경했다.
또한 didUpdateLocation 메서드의 경우 특정 조건에서만 실행되고, 이 메서드를 호출하지 않아도 기능을 정상적으로 구현할 수 있기에 내부의 코드를 지워줬다.
전체 코드
// ... 생략
// MARK: - MapView 커스텀
struct MapViewCoordinator: UIViewRepresentable {
@ObservedObject var locationManager: LocationManager
func makeUIView(context: Context) -> some UIView {
return locationManager.mapView
}
func updateUIView(_ uiView: UIViewType, context: Context) { }
}
//
// LocationManager.swift
//
// Created by 고도 on 2022/12/20.
//
import Foundation
import MapKit
class LocationManager: NSObject, ObservableObject, MKMapViewDelegate, CLLocationManagerDelegate {
@Published var mapView: MKMapView = .init()
@Published var isChanging: Bool = false // 지도의 움직임 여부를 저장하는 프로퍼티
@Published var currentPlace: String = "" // 현재 위치의 도로명 주소를 저장하는 프로퍼티
private var manager: CLLocationManager = .init()
private var currentGeoPoint: CLLocationCoordinate2D? // 현재 위치를 저장하는 프로퍼티
override init() {
super.init()
self.configureLocationManager()
}
// MARK: - 사용자의 위치 권한 여부를 확인하고 요청하거나 현재 위치 MapView를 이동하는 메서드
func configureLocationManager() {
mapView.delegate = self
manager.delegate = self
let status = manager.authorizationStatus
if status == .notDetermined {
manager.requestAlwaysAuthorization()
} else if status == .authorizedAlways || status == .authorizedWhenInUse {
mapView.showsUserLocation = true // 사용자의 현재 위치를 확인할 수 있도록
}
}
// MARK: - MapView에서 화면이 이동하면 호출되는 메서드
func mapViewDidChangeVisibleRegion(_ mapView: MKMapView) {
DispatchQueue.main.async {
self.isChanging = true
}
}
// MARK: - MapView에서 화면 이동이 종료되면 호출되는 메서드
func mapView(_ mapView: MKMapView, regionDidChangeAnimated: Bool) {
let location: CLLocation = CLLocation(latitude: mapView.centerCoordinate.latitude, longitude: mapView.centerCoordinate.longitude)
self.convertLocationToAddress(location: location)
DispatchQueue.main.async {
self.isChanging = false
}
}
// MARK: - 특정 위치로 MapView의 Focus를 이동하는 메서드
func mapViewFocusChange() {
print("[SUCCESS] Map Focus Changed")
let span = MKCoordinateSpan(latitudeDelta: 0.005, longitudeDelta: 0.005)
let region = MKCoordinateRegion(center: self.currentGeoPoint ?? CLLocationCoordinate2D(latitude: 37.394776, longitude: 127.11116), span: span)
mapView.setRegion(region, animated: true)
}
// MARK: - 사용자에게 위치 권한이 변경되면 호출되는 메서드 (LocationManager 인스턴스가 생성될 때도 호출)
func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) {
if manager.authorizationStatus == .authorizedAlways || manager.authorizationStatus == .authorizedWhenInUse {
guard let location = manager.location else {
print("[ERROR] No Location")
return
}
self.currentGeoPoint = location.coordinate // 현재 위치를 저장하고
self.mapViewFocusChange() // 현재 위치로 MapView를 이동
self.convertLocationToAddress(location: location)
}
}
// MARK: - 사용자의 위치가 변경되면 호출되는 메서드
/// startUpdatingLocation 메서드 또는 requestLocation 메서드를 호출했을 때에만 이 메서드가 호출
func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
print("[SUCCESS] Did Update Locations")
}
// MARK: - 사용자의 현재 위치를 가져오는 것을 실패했을 때 호출되는 메서드
func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) {
print(error)
}
// MARK: - location을 도로명 주소로 변환해주는 메서드
func convertLocationToAddress(location: CLLocation) {
let geocoder = CLGeocoder()
geocoder.reverseGeocodeLocation(location) { placemarks, error in
if error != nil {
return
}
guard let placemark = placemarks?.first else { return }
self.startPlace = "\(placemark.country ?? "") \(placemark.locality ?? "") \(placemark.name ?? "")"
}
}
}
다음 게시물은 네이버 검색 API와 Katech 좌표를 WGS 좌표로 변환하는 네이버 Maps API을 활용해서
목적지를 지도 상에 표시해주는 기능에 대한 게시물로!
'💻 개발 > iOS' 카테고리의 다른 글
[iOS / SwiftUI] View Memory Graph Hierarchy를 활용해보자 (0) | 2023.03.14 |
---|---|
[iOS / SwiftUI] OnAppear, OnDisappear는 언제 호출될까? (1) | 2022.12.01 |
[iOS / SwiftUI] 스크롤, 무한으로 즐겨요~ (LazyVStack으로 무한 스크롤 구현하기) (0) | 2022.11.28 |
[iOS / Swift] Swift 문자열 정복하기 (aka 'Character') (0) | 2022.11.03 |
[iOS / SwiftUI] 다양한 상태 프로퍼티들을 알아보자! (0) | 2022.10.24 |