포스트

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

Image

이게 MapMarker가 제공하는 기본 Annotation 이다.

MapAnnotation을 사용하여 DDGAnnotation을 적용하도록 한다. (현재 MapAnnotation은 Deprecated 되어있는 상태)

1
2
3
MapAnnotation(coordinate: location.location.coordinate) {
    DDGAnnotation(location: location)
}

Image

적용하면 이렇게 된다

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 이다.

그렇다면 어떤 차이가 있을까?

Image

이 사진을 보면 위치가 달라진걸 알 수 있다.

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)’는 완전히 다르다.

  1. 데이터 전달과 의존성: 현재 로직은 유저의 탭 액션에 따라 부모 뷰(LocationMapView)에서 locationManager.selectedLocation이라는 특정 장소 데이터를 자식 뷰(LocationDetailView)로 전달(Passing)하여 화면을 그리는 구조다.
  2. @StateObject가 부적합한 이유: @StateObject는 뷰와 상관없이 뷰 모델이 독립적인 생명주기를 가지고 데이터를 직접 소유하겠다는 선언이다. 부모가 넘겨주는 데이터에 전적으로 의존하여 매번 새로운 장소를 렌더링해야 하는 현재 상황과는 설계 의도부터가 맞지 않는다.
  3. @ObservedObject의 당위성: 이전 뷰로부터 데이터를 전달받아 사용하는 개념이므로, 뷰가 새롭게 렌더링될 때 ViewModel 역시 함께 리셋되고, 전달된 location 값을 새롭게 주입(Inject)받아 렌더링에 반영하는 @ObservedObject가 구조적으로나 논리적으로 완벽한 정답이다.

최종 결론: 부모 뷰로부터 데이터를 주입받아 새롭게 렌더링되는 뷰라면, ViewModel 역시 독립적으로 존재하기보다 뷰의 렌더링 주기에 맞춰 함께 생성되고 주입받는 것이 맞다.


그렇다면 @StateObject 실전 활용 예시

@StateObject는 뷰의 렌더링과 무관하게 “데이터의 소유권과 생명주기를 독립적으로 방어”해야 할 때 사용한다.

  1. 🛒 쇼핑앱 장바구니 (Cart Manager)
    • 부모 뷰(리스트)가 리렌더링되어도 사용자가 담아둔 상품 내역은 초기화 없이 유지되어야 할 때.
  2. ⏱️ 타이머 / 스톱워치
    • 화면 갱신이나 레이아웃 변경이 발생해도, 측정 중인 시간 데이터는 끊김 없이 계속 흘러가야 할 때.
  3. 📝 다단계 입력 폼 (회원가입 / 글쓰기)
    • 여러 화면에 걸쳐 정보를 입력하는 도중 뷰가 닫히거나 재생성되어도 기존 입력 데이터가 날아가지 않게 보호할 때.
  4. 📡 최상위 데이터 로더 (Root Data Fetcher)
    • 앱 구동 시 서버에서 최초로 데이터를 받아와 객체를 처음 생성하고 관리하는 ‘주인’ 역할을 할 때.
📊 SwiftUI 프로퍼티 래퍼 비교: 객체의 주인은 누구인가?
구분@StateObject@ObservedObject
소유권 (Ownership)뷰가 직접 소유 (내가 만들었음)외부에서 주입 (빌려온 것임)
생명주기 (Lifecycle)뷰가 재생성되어도 상태 유지부모 뷰가 재생성되면 함께 초기화될 수 있음
주요 용도데이터의 최초 생성 및 저장소부모로부터 데이터를 전달받아 표시
강사의 한마디“완전히 새로운 것을 초기화할 때”“이전 화면에서 정보를 전달받을 때”
나의 결론“독립적으로 데이터를 지켜야 할 때”“부모에 의존해 새로운 값을 보여줘야 할 때”

Image

현재 실행하고서 가게를 탭하면 이렇게 나오는데 가게 이름이 상단에 표시되면 좋을 것 같아서 NavigationView를 추가해주도록 한다.

1
2
3
NavigationView {
    LocationDetailView(viewModel: LocationDetailViewModel(location: locationManager.selectedLocation!))
}

Image

그럼 이렇게 상단에 가게명이 보이는 걸 알 수 있다.

하지만 창을 내리려면 (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

이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.