MapKit (26)
iOS 15 Update
이 강의가 오래된 강의였다보니 강의를 보면서 코드를 치더라도 꽤나 많은것들이 이미 Deprecated가 되어 있었다.
Deprecated Modifiers Docs여기보면 상당히 많으니 참고.
먼저 iOS 15부터 해본다.
아마 내용 전개는 Deprecated된 코드를 before & after 로 수정하면서 가지 않을까 생각
ProfileModalView
.animation Modifier가 Deprecated인 상태이다.
보통 이런건 Docs를 보게되면 어떤걸 대체해서 쓰라고 하는지 나와 있기에, 그대로 해주면 십중팔구 해결이 된다.
하지만 animation 이거는 조금 다르게 봐야하는게
우선 withAnimation을 사용하려면 View보단 modal이 어디서 트리거 되는지를 봐야한다.
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
// DetailVM
func show(_ profile: DDGProfile, in sizeCategory: ContentSizeCategory) {
selectedProfile = profile
if sizeCategory >= .accessibilityMedium {
isShowingProfileSheet = true
} else {
isShowingProfileModal = true
}
}
// DetailView
fileprivate struct AvatarGridView: View {
// 생략
var body: some View {
ZStack {
if viewModel.checkedInProfiles.isEmpty {
GridEmptyStateTextView()
} else {
ScrollView {
LazyVGrid(columns: viewModel.determineColumns(for: sizeCategory), content: {
ForEach(viewModel.checkedInProfiles) { profile in
FirstNameAvatarView(profile: profile)
.onTapGesture {
withAnimation {
viewModel.show(profile, in: sizeCategory)
}
}
}
})
}
}
if viewModel.isLoading { LoadingView() }
}
}
}
여기서 아바타를 탭했을때 show가 작동되면서 true가 트리거 되고 Modal이 보여지는 구조이기때문이다.
즉, LocationDetailView를 보게되면
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
if viewModel.isShowingProfileModal {
// 생략
ProfileModalView(isShowingProfileModal: $viewModel.isShowingProfileModal,
profile: viewModel.selectedProfile!)
}
.sheet(isPresented: $viewModel.isShowingProfileSheet, content: {
NavigationView {
ProfileSheetView(profile: viewModel.selectedProfile!)
.toolbar {
Button("Dismiss", action: {
viewModel.isShowingProfileSheet = false
})
}
}
.accentColor(.brandPrimary)
})
이런식으로 profile에 관해 어떤 View가 보여질지 전부 세팅이 되어있기때문.
그러므로 프로필과 관련된 ProfileModal, ProfileSheet가 전부 트리거가 되는 부분이 show인것이다.
만약 우리가 이전에 Text에 따라 profile을 다르게 보이도록 설정을 안했다면
1
2
3
4
5
6
// DetailView
.onAppear(perform: {
viewModel.getCheckedInProfiles()
viewModel.getCheckedInStatus()
})
.animation(.easeOut, value: viewModel.isShowingProfileModal)
DetailView에 animation Modifier를 써서 사용했으면 되었다.
하지만 지금은 그게 아니기에 withAnimation이 옳은 방법.
하지만 animation Modifer가 Deprecated 되면서
그냥 순수하게 animation Modifer 그 자체로만 본다면
기존에는 View가 보여지는쪽에 modifier 사용 현재는 Animation과 관련된 View를 호출하는쪽에 Modifier 사용 및 어떤 값에 변화에 따라 그 View가 나오는지에 대한 Value입력 필요
이걸 표로 다시 정리하면
| 구분 | 과거 방식 (.animation(_:)) | 현재 방식 (.animation(_:value:)) |
|---|---|---|
| 개념 | 뷰의 모든 변화에 자동 반응 | 특정 값(Value)이 변경될 때만 제한적으로 반응 |
| Modifier 적용 위치 | 애니메이션 효과가 “보여지는 뷰” 자체에 직접 사용 (예: ProfileModalView().animation(.easeOut)) | 애니메이션 대상 뷰를 “호출하고 그리는” 부모 컨테이너에 사용 (예: ZStack { ... }.animation(.easeOut, value: ...)) |
| 트리거 감시 방식 | 감시 대상 없음 (해당 뷰와 하위 뷰의 모든 상태 변화를 무조건 애니메이션화) | value: 파라미터에 명시된 상태 변수(Equatable)의 변화만 추적하여 트리거 |
| 애플의 설계 의도 | 선언하기는 쉬우나, 의도치 않은 자잘한 뷰 버그나 성능 저하를 유발함 | 애니메이션이 일어나는 시점과 대상을 개발자가 명확히 통제하도록 강제함 |
💡 정리 흐름 매칭
- 기존: “나(모달 뷰) 나타날 때 애니메이션 줘!” ➡️ 뷰 자체에 수정자 적용
- 현재: “내가 이 값(Value)을 바꿀 테니, 나 때문에 변화가 생기는 자식 뷰(호출되는 뷰)들을 애니메이션으로 그려줘!” ➡️ 호출하는 부모 뷰에 수정자 및 가치(Value) 입력
결론
1. 동적 로직에 따른 트리거 분기 (withAnimation의 승리)
show함수는 실행되는 순간의sizeCategory를 판단하여Modal을 켤지Sheet를 켤지 그때그때 결정한다.- withAnimation: “어떤 변수가 바뀌든 상관없어. 이 함수 안에서 일어나는 모든 상태 변경을 애니메이션으로 묶어줘!”라는 명령이므로, 분기 로직(if/else)과 완벽하게 맞아떨어진다.
2. 고정된 감시 대상 (.animation(_:value:)의 한계)
- animation(value:): 꼬리표처럼 붙는 수정자이므로, 컴파일 시점에 감시 대상(
isShowingProfileModal)이 딱 정해져 있어야 한다. - 만약 카테고리가 바뀌어서
isShowingProfileSheet가 true가 되면, 모달만 쳐다보고 있던.animation(value: isShowingProfileModal)은 아무런 반응도 하지 않는다. - 결국 이 방식을 쓰려면
ZStack하단에 모달용, 시트용 감시자를 덕지덕지 붙여야 하는 지저분한 코드가 된다.
💡 핵심 포인트
- 값이 고정적으로 변할 때는 뷰의 꼬리표(
modifier) 방식이 좋다. - 로직에 따라 변하는 값이 달라질 때는 동작(
action) 기반의withAnimation방식이 압도적으로 유리하다.
AccentColor
이것도 Deprecated가 되어서 tint를 사용하라고 하는데
여기선 보통 brandPrimary 색상을 사용했기에 그냥 Asset에서 통일화한다.
이렇게 만들어 주면 된다.
이후 검색을 통해
.accentColor(.brandPrimary)가 있는부분을 모두 지워주도록 한다.
이전글에서 tint로 미리 사용을 했었는데, 이부분도 그냥 지워주자.
그리고 LocationMapview에 이전에 이미
.tint(Color.pink)로 사용을 했어서 그냥 알아두기만 하자.
그리고 강의에선 Dismiss Button과 유저의 현위치를 알려주는 icon의 색이 전부 BrandPrimary로 나오지만 지금 기준에서는 색이 적용이 되지않아서
이건 나중에 별도로 해보는걸로 한다.
ContentSizeCategory
이건 Docs를 보면 DynamicTypeSize를 사용하라고한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// Before
@ViewBuilder func createLocationDetailView(for location: DDGLocation, in sizeCategory: ContentSizeCategory) -> some View {
if sizeCategory >= .accessibilityMedium {
LocationDetailView(viewModel: LocationDetailViewModel(location: location)).embedInScrollView()
} else {
LocationDetailView(viewModel: LocationDetailViewModel(location: location))
}
}
// After
@ViewBuilder func createLocationDetailView(for location: DDGLocation, in dynamicTypeSize: DynamicTypeSize) -> some View {
if dynamicTypeSize >= .accessibility3 {
LocationDetailView(viewModel: LocationDetailViewModel(location: location)).embedInScrollView()
} else {
LocationDetailView(viewModel: LocationDetailViewModel(location: location))
}
}
Command + Shift + F로 sizeCategory를 검색해서 전부 dynamicTypeSize로 바꿔준다.
그리고 확인을 했을때 문제없으면 성공 (사진은 생략)
PresentationMode
이것 역시도 Deprecated
현재는 isPresented or dismiss 이렇게 사용하라고한다.
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
// OnboardView
// Before
@Environment(\.presentationMode) var presentationMode
var body: some View {
VStack {
HStack {
Spacer()
Button {
presentationMode.wrappedValue.dismiss()
} label: {
XDismissButton()
}
.padding()
}
// 생략
}
}
// After
@Environment(\.dismiss) var dismiss
var body: some View {
VStack {
HStack {
Spacer()
Button {
dismiss()
}
}
}
}
이렇게 dismiss로 바꿔주었다.
Overlay
overlay(alignment:content:) 이렇게 사용하라고 한다.
기존과 큰 변화는 없다. 단지 파라미터를 사용해서 더 가독성을 높이려고 한게 아닐까 싶다.
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
// ProfileModalView
// Before
.overlay (
Button {
withAnimation { isShowingProfileModal = false }
} label: {
XDismissButton()
}, alignment: .topTrailing
)
// After
.overlay(alignment: .topTrailing) {
Button {
withAnimation { isShowingProfileModal = false }
} label: {
XDismissButton()
}
}
// BioTextEditor
// Before
.overlay(RoundedRectangle(cornerRadius: 8).stroke(Color.secondary, lineWidth: 1))
// After
.overlay(content: {
RoundedRectangle(cornerRadius: 8).stroke(Color.secondary, lineWidth: 1)
})
Alignment가 생략 가능하므로 content만 추가해주면 된다.
Alert
alert(_:isPresented:presenting:actions:message:) 를 사용하라고 한다.
1
2
3
4
5
6
7
8
// ProfileView
// Before
.alert(
item: $viewModel.alertItem, content: {
$0.alert
})
// After
강의에선 지금당장 바꿀 필요를 못느낀다고해서 패스 했는데 내가 해보려고한다. (아마 이후 강의에선 바꿨을지도?)
listStyle
LocationDetailView에 listSytle Modifer를 추가해주었는데,
Sample확인은 여기서
1
2
.navigationTitle("Grub Spots")
.listStyle(.plain)
그냥 이렇게 추가해주면 된다. 사진은 생략.
LocationButton
영상을 한번 봐도 좋을듯?
WWDC21도 읽어보면 좋을듯 하다.
우선 사용하기위해선 CoreLocationUI를 import 해주어야 한다.
1
2
3
4
5
6
7
8
9
10
11
12
// LocationMapView
.overlay(alignment: .bottomLeading, content: {
LocationButton(.currentLocation) {
// button tapped
}
.foregroundStyle(.white)
.symbolVariant(.fill)
.tint(.grubRed)
.labelStyle(.iconOnly)
.clipShape(Circle())
.padding(EdgeInsets(top: 0, leading: 20, bottom: 40, trailing: 0))
})
Overlay를 통해 만들어 주었다.
아직은 버튼을 눌렀을때 작동하는걸 만들어두지는 않은 상태
AppTabVM
우선 checkIfLocationServicesIsEnabled, checkLocationAuthorization, locationManagerDidChangeAuthorization 이 3개의 함수가 필요없으므로 전부 지운다.
여기서 관리를 하지 않을것이기 때문
즉 TabVM에는
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
extension AppTabView {
final class AppTabViewModel: ObservableObject {
@Published var isShowingOnboardView = false
@AppStorage("hasSeenOnboardView") var hasSeenOnboardView = false {
didSet {
isShowingOnboardView = hasSeenOnboardView
}
}
let kHasSeenOnboardView = "hasSeenOnboardView"
func checkIfHasSeenOnboard() {
if !hasSeenOnboardView {
hasSeenOnboardView = true
}
}
}
}
이렇게만 남겨두었다.
이후 함수를 삭제함으로써 발생하는 에러들을 전부 처리해준다. (그냥 그 부분을 지워주면 됨)
1
2
3
4
5
6
7
8
// AppTabView
.onAppear {
CloudKitManager.shared.getUserRecord()
viewModel.checkIfHasSeenOnboard()
}
.sheet(isPresented: $viewModel.isShowingOnboardView) {
OnboardView()
}
LocationMapVM
이전에 LocationMapVM에 했다가 AppTabVM으로 옮겼던건데, 강의에선 다시 여기로 옮겨서 관리를 하는걸로 바꾸었다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
let deviceLocationManager = CLLocationManager()
override init() {
super.init()
deviceLocationManager.delegate = self
}
func requestAllowOnceLocationPermission() {
deviceLocationManager.requestLocation()
}
func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
guard let currentLocation = locations.last else { return }
withAnimation {
region = MKCoordinateRegion(center: currentLocation.coordinate, span: MKCoordinateSpan(latitudeDelta: 0.01, longitudeDelta: 0.01))
}
}
func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) {
print("Did Fail With Error")
}
이부분을 추가해주었다.
그리고 다시 LocationButton의 빈부분을 채워준다.
1
2
3
LocationButton(.currentLocation) {
viewModel.requestAllowOnceLocationPermission()
}
그리고 먼저
이렇게 위치를 사용안함으로 바꾸고 앱을 실행하면
이렇게 현위치가 보이지 않는데, 눌러도 작동하지 않는다.
Console에서는 우리가 만들어둔 Error만 출력.
아마도 지금이랑 이전이랑 달라서 그런듯.
plist이전에 해놨기에 문제가 정확하게 어떤지 몰라 Error부분을 디테일하게 고쳐본다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) {
if let clError = error as? CLError {
switch clError.code {
case .denied:
print("❌ 위치 권한이 거부되었습니다. (설정 앱 확인 필요)")
case .locationUnknown:
print("❌ 시뮬레이터가 현재 위치를 잡지 못하고 있습니다. (Features -> Location 설정 필요)")
default:
print("❌ CoreLocation 에러 코드: \(clError.code.rawValue) - \(error.localizedDescription)")
}
} else {
print("❌ 일반 에러: \(error.localizedDescription)")
}
}
콘솔에서 ❌ 위치 권한이 거부되었습니다. (설정 앱 확인 필요)이게 뜨지만
설정이나 이런걸 확인해도 이상이 없다.
그리고 해당코드로 바꾸고나서 앱을 재설치해도 plist에 권한 허가가 뜨지 않는다.
우선 이부분은 패스하고 나중에 정리를 해야겠다.
Github: Dub-Dub-Grub Repository