MapKit (16)
Annotaion 적용하기
기존에 LocationMapView를 보게되면 기본적으로 자체 제공하는 MapMarker를 사용했었다.
1
2
3
Map(coordinateRegion: $viewModel.region, showsUserLocation: true, annotationItems: locationManager.locations) { location in
MapMarker(coordinate: location.location.coordinate, tint: .brandPrimary)
}
이게 MapMarker가 제공하는 기본 Annotation 이다.
MapAnnotation을 사용하여 DDGAnnotation을 적용하도록 한다. (현재 MapAnnotation은 Deprecated 되어있는 상태)
1
2
3
MapAnnotation(coordinate: location.location.coordinate) {
DDGAnnotation(location: location)
}
적용하면 이렇게 된다
99인 이유는 이전에 99로 하드코딩을 해둔 상태이기 때문.
1
2
3
MapAnnotation(coordinate: location.location.coordinate, anchorPoint: CGPoint(x: 0.5, y: 0.75)) {
DDGAnnotation(location: location)
}
그리고 anchorPoint를 좀 수정하려고 한다. 일반적으로 우리가 anchorPoint를 조정 하지 않는 이상 default는 x: 0.5, y: 0.5 이다.
그렇다면 어떤 차이가 있을까?
이 사진을 보면 위치가 달라진걸 알 수 있다.
Annotaion 상호작용 하기
보통 지도같은 어플을 사용하면 Annotation을 터치했을때 해당 가게 관련 정보들이 나오는걸 볼 수 있다.
이처럼 onTapGesture를 사용하여 유져들과 상호작용 할 수 있도록 만들어 본다.
1
2
3
4
5
6
7
8
9
10
// LocationManager
var selectedLocation: DDGLocation?
// LocationMapView
MapAnnotation(coordinate: location.location.coordinate, anchorPoint: CGPoint(x: 0.5, y: 0.75)) {
DDGAnnotation(location: location)
.onTapGesture {
locationManager.selectedLocation = location
}
}
AppTabViewModel 만들기
사실 어떻게 보면 AppTabView가 RootView 이기때문에, 굳이 LocationMapVM에서 처리 할 필요없이 정말 위치를 가져오는 작업빼고는 나머지는 AppTabView에서 하는게 맞기 때문에 VM을 만들고 코드들을 옮기는 작업을 진행한다.
먼저 기존 LocationMapVM에 있던 코드들을 AppTabVM에 옮겨준다. (GetLocation 빼고)
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
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
import CoreLocation
final class AppTabViewModel: NSObject, ObservableObject {
@Published var isShowingOnboardView = false
@Published var alertItem: AlertItem?
var deviceLocationManager: CLLocationManager?
let kHasSeenOnboardView = "hasSeenOnboardView"
var hasSeenOnboardView: Bool {
return UserDefaults.standard.bool(forKey: kHasSeenOnboardView)
}
func runStartupChecks() {
if !hasSeenOnboardView {
isShowingOnboardView = true
UserDefaults.standard.set(true, forKey: kHasSeenOnboardView)
} else {
checkIfLocationServicesIsEnabled()
}
}
func checkIfLocationServicesIsEnabled() {
if CLLocationManager.locationServicesEnabled() {
deviceLocationManager = CLLocationManager()
deviceLocationManager!.delegate = self
} else {
alertItem = AlertContext.locationDisabled
}
}
private func checkLocationAuthorization() {
guard let deviceLocationManager = deviceLocationManager else { return }
switch deviceLocationManager.authorizationStatus {
case .notDetermined:
deviceLocationManager.requestWhenInUseAuthorization()
case .restricted:
alertItem = AlertContext.locationRestricted
case .denied:
alertItem = AlertContext.locationDenied
case .authorizedAlways, .authorizedWhenInUse:
break
@unknown default:
break
}
}
}
extension AppTabViewModel: CLLocationManagerDelegate {
func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) {
checkLocationAuthorization()
}
}
아마도 여기에 작성할 코드가 꽤나 길어질것 같다.
기존에 LocationMapView에서 하던 runStartupChecks 역시
AppTabView에서 하도록 바꿔준다.
1
2
3
4
.onAppear {
CloudKitManager.shared.getUserRecord()
viewModel.runStartupChecks() // moved
}
LocationMapVM에 있던 대다수의 기능들이 AppTabViewVM으로 옮겨 갔기에, 빌드를 해서 에러나는 부분들은 전부 LocationMapView ➡ AppTabView로 코드가 이관된다고 생각하면 될 것 같다.
그래도 내가 기억을 하기위해서는 가독성이 좀 떨어지더라도 옮긴 코드들도 여기에 전부 적어보도록 한다.
이럴때 Ctrl + Command + T 를 하면 xcode 2분할이 되기때문에 코드 옮기기가 쉬워지니 참고.
1
2
3
4
5
// AppTabView
.tint(.brandPrimary)
.sheet(isPresented: $viewModel.isShowingOnboardView, onDismiss: viewModel.checkIfLocationServicesIsEnabled) {
OnboardView(isShowingOnboardView: $viewModel.isShowingOnboardView)
}
그리고 앱을 삭제하고 다시 실행했을때 OnboardingView가 나온다면 성공.
Annotation 클릭시 LocationDetailView 보이게 하기
우선 Mapview로 가서
1
2
3
4
5
6
7
8
9
10
11
12
// LocationMapView
.onTapGesture {
locationManager.selectedLocation = location
viewModel.isShowingDetailView = true
}
.sheet(isPresented: $viewModel.isShowingDetailView) {
LocationDetailView(viewModel: LocationDetailViewModel(location: locationManager.selectedLocation!))
}
// LocationMapViewModel
@Published var isShowingDetailView = false
이때 selectedLocation 옵셔널로 설정을 해두어서 지금은 !로 강제로 언래핑을 해두었는데, 지금은 일단 이렇게 해두었는데 뭐 다들 알겠지만 강제로 언래핑을 하는건 좋지 않다.
여러 옵셔널 바인딩이 있기때문에 이건 뭐 나중에 해도 될 듯.
그리고 하나 더 짚어 본다면, 현재 LocationDetailView를 sheet를 통해 새롭게 화면을 띄우게 되는데, 이때 다시 Initializing을 하게되는데, ViewModel을 우리가 @ObservedObject로 만들어 두었다.
즉 해당 ViewModel은 새롭게 init이 되어도 이전으로 부터 데이터를 제공 받게 된다.
@ObservedObject vs @StateObject: 데이터 주입과 설계 ‘의도’의 차이
현재 LocationMapView에서 LocationDetailView로 전환되는 과정에서 ViewModel을 어떻게 선언할 것인가에 대한 고찰.
강사의 핵심 정의
“Remember, state object, if you’re initializing a brand new one, observed object if you’re passing information in from a previous screen.” (완전히 새로운 것을 초기화할 땐 StateObject, 이전 화면에서 정보를 전달받을 땐 ObservedObject를 기억하라.)
💡 로직 중심의 요약 및 나의 결론
단순히 화면에 데이터가 정상적으로 출력된다는 ‘결과’만 놓고 보면 두 프로퍼티 래퍼(Wrapper) 모두 동작할 수는 있다. 하지만 앱 아키텍처 관점에서 둘의 ‘설계 의도(Intent)’는 완전히 다르다.
- 데이터 전달과 의존성: 현재 로직은 유저의 탭 액션에 따라 부모 뷰(
LocationMapView)에서locationManager.selectedLocation이라는 특정 장소 데이터를 자식 뷰(LocationDetailView)로 전달(Passing)하여 화면을 그리는 구조다. - @StateObject가 부적합한 이유:
@StateObject는 뷰와 상관없이 뷰 모델이 독립적인 생명주기를 가지고 데이터를 직접 소유하겠다는 선언이다. 부모가 넘겨주는 데이터에 전적으로 의존하여 매번 새로운 장소를 렌더링해야 하는 현재 상황과는 설계 의도부터가 맞지 않는다. - @ObservedObject의 당위성: 이전 뷰로부터 데이터를 전달받아 사용하는 개념이므로, 뷰가 새롭게 렌더링될 때 ViewModel 역시 함께 리셋되고, 전달된
location값을 새롭게 주입(Inject)받아 렌더링에 반영하는@ObservedObject가 구조적으로나 논리적으로 완벽한 정답이다.
최종 결론: 부모 뷰로부터 데이터를 주입받아 새롭게 렌더링되는 뷰라면, ViewModel 역시 독립적으로 존재하기보다 뷰의 렌더링 주기에 맞춰 함께 생성되고 주입받는 것이 맞다.
그렇다면 @StateObject 실전 활용 예시
@StateObject는 뷰의 렌더링과 무관하게 “데이터의 소유권과 생명주기를 독립적으로 방어”해야 할 때 사용한다.
- 🛒 쇼핑앱 장바구니 (Cart Manager)
- 부모 뷰(리스트)가 리렌더링되어도 사용자가 담아둔 상품 내역은 초기화 없이 유지되어야 할 때.
- ⏱️ 타이머 / 스톱워치
- 화면 갱신이나 레이아웃 변경이 발생해도, 측정 중인 시간 데이터는 끊김 없이 계속 흘러가야 할 때.
- 📝 다단계 입력 폼 (회원가입 / 글쓰기)
- 여러 화면에 걸쳐 정보를 입력하는 도중 뷰가 닫히거나 재생성되어도 기존 입력 데이터가 날아가지 않게 보호할 때.
- 📡 최상위 데이터 로더 (Root Data Fetcher)
- 앱 구동 시 서버에서 최초로 데이터를 받아와 객체를 처음 생성하고 관리하는 ‘주인’ 역할을 할 때.
📊 SwiftUI 프로퍼티 래퍼 비교: 객체의 주인은 누구인가?
| 구분 | @StateObject | @ObservedObject |
|---|---|---|
| 소유권 (Ownership) | 뷰가 직접 소유 (내가 만들었음) | 외부에서 주입 (빌려온 것임) |
| 생명주기 (Lifecycle) | 뷰가 재생성되어도 상태 유지 | 부모 뷰가 재생성되면 함께 초기화될 수 있음 |
| 주요 용도 | 데이터의 최초 생성 및 저장소 | 부모로부터 데이터를 전달받아 표시 |
| 강사의 한마디 | “완전히 새로운 것을 초기화할 때” | “이전 화면에서 정보를 전달받을 때” |
| 나의 결론 | “독립적으로 데이터를 지켜야 할 때” | “부모에 의존해 새로운 값을 보여줘야 할 때” |
현재 실행하고서 가게를 탭하면 이렇게 나오는데 가게 이름이 상단에 표시되면 좋을 것 같아서 NavigationView를 추가해주도록 한다.
1
2
3
NavigationView {
LocationDetailView(viewModel: LocationDetailViewModel(location: locationManager.selectedLocation!))
}
그럼 이렇게 상단에 가게명이 보이는 걸 알 수 있다.
하지만 창을 내리려면 (Dismiss) 드래그를 통해서만 가능하므로 버튼을 추가해본다.
1
2
3
4
5
6
7
NavigationView {
LocationDetailView(viewModel: LocationDetailViewModel(location: locationManager.selectedLocation!))
.toolbar {
Button("Dismiss", action: { viewModel.isShowingDetailView = false })
}
}
.accentColor(.brandPrimary)
toolbar를 사용하여 NavigationView쪽에 버튼을 추가해주었다. 하지만 NavigationView 역시 현재는 Deprecated 되어있는 상태이다.
사진은 패스
Github: Dub-Dub-Grub Repository