MapKit (21)
Dynamic Type
이어서 하도록 한다.
이젠 Modal에 관해서 해본다.
이렇게 Font 확대시 Modal이 깨지고 있는데 이부분을 고쳐보도록 한다.
우선 ProfileSheetView를 하나 만들어 주었다. 이건 폰트 사이즈가 커졌을때 쓰일 별도의 View이다.
ProfileModalView를 복사해서 조금 변형을 해주면 된다. (코드는 생략)
그리고 폰트 사이즈에 따라 별도의 sheet를 보여줄지 Modal을 보여줄지를 정하는 함수를 LocationDetailVM에 추가해준다.
1
2
3
4
5
6
7
8
9
10
11
12
var selectedProfile: DDGProfile?
@Published var isShowingProfileSheet = false
func show(profile: DDGProfile, in sizeCategory: ContentSizeCategory) {
selectedProfile = profile
if sizeCategory >= .accessibilityMedium {
isShowingProfileSheet = true
} else {
isShowingProfileModal = true
}
}
이때 selectedProfile도 고쳐주었는데, 이전에는
1
2
3
4
5
6
7
// before
var selectedProfile: DDGProfile? {
didSet { isShowingProfileModal = true }
}
// after
var selectedProfile: DDGProfile?
해당 변수가 만들어질때 바로 modal을 true해서 활성화 했다면 이제는 show를 통해 활성화 하므로 변수를 그냥 옵셔널로만 바꿔준다.
그리고 onTapGesture 부분을 고쳐주고, 새로만든 sheet가 활성화 되게 하기위해 sheet Modifier를 통해 호출해주도록 한다.
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
ScrollView {
LazyVGrid(columns: viewModel.determineColumns(for: sizeCategory)) {
ForEach(viewModel.checkedInProfiles) { profile in
FirstNameAvatarView(profile: profile)
// 생략
.onTapGesture {
viewModel.show(profile: profile, in: sizeCategory)
}
}
}
}
.onAppear(perform: {
// 생략
})
.sheet(isPresented: $viewModel.isShowingProfileSheet, content: {
NavigationView {
ProfileSheetView(profile: viewModel.selectedProfile!)
.toolbar {
Button("Dismiss", action: {
viewModel.isShowingProfileSheet = false
})
}
}
.accentColor(.brandPrimary)
})
실행하면 아래와 같이 되는걸 알 수 있다.
OnBoardingView & AppTabView
구조적 그룹화와 코드 격리 전략
현재 mapview면 mapvm 이런식으로 각 뷰에 맞게 필요한 기능들을 vm에 매칭시켜서 관리를 하고 있는데 다른 view에서 해당 vm에 접근이 가능하다.
이때
1
2
3
4
5
extension LocationMapView {
final class LocationMapViewModel: NSObject, ObservableObject {
// 생략
}
}
extension을 사용해 해당 view에만 적용을 하게되면, vm은 그 view에서만 사용이 가능하다, 즉 다른 view에서 접근이 불가.
이거랑 유사한 방법으로는 fileprivate가 있는데 이건 같은 source code file 에서만 사용 가능.
1
2
3
fileprivate struct OnboardInfoView: View {
// 생략
}
OnboardInfoView의 경우 OnBoardView파일에서만 사용하므로 이런식으로 해준다.
| 구분 | Extension 네임스페이스 방식 | fileprivate 방식 |
|---|---|---|
| 핵심 로직 | View.ViewModel 구조로 소속 계층화 | 동일한 .swift 파일 내로 접근 제한 |
| 주요 목적 | 이름 중복 방지 및 논리적 그룹화 | 파일 단위 보안 및 외부 간섭 차단 |
| 가독성 | LocationMapView.LocationMapViewModel로 출처 명확 | 파일 내부 전용임을 코드 수준에서 강제 |
| 접근 제어 | 기본은 internal (외부 파일에서 View.VM으로 접근 가능) | 해당 파일 외부에서는 존재 자체를 모름 |
| 추천 상황 | 프로젝트가 커서 VM 이름이 겹칠 우려가 있을 때 | View와 VM을 한 파일에 두고 캡슐화하고 싶을 때 |
반복되는 코드 정리하기
현재
1
Alert(title: alertItem.title, message: alertItem.message, dismissButton: alertItem.dismissButton)
이런 코드가 반복적으로 사용이 되고있다. 흔히 이런걸 통틀어 Boiler Plate Code라고 하는데, 이부분을 좀 정리를 하려고 한다.
1
2
3
4
5
6
struct AlertItem: Identifiable {
// 생략
var alert: Alert {
Alert(title: title, message: message, dismissButton: dismissButton)
}
}
기존에 계속해서 생성하던걸 이렇게 하나의 변수로 정립을 해주었다.
이걸 실제로 적용을 해보면
1
2
3
4
5
6
7
8
9
10
// LocationMapView
// before
.alert(item: $viewModel.alertItem, content: { alertItem in
Alert(title: alertItem.title, message: alertItem.message, dismissButton: alertItem.dismissButton)
})
// after
.alert(item: $viewModel.alertItem, content: {
$0.alert
})
혹시라도 이해가 안될수도 있으니 간략하게 정리
- 현상: 알림창을 호출할 때마다
Alert(title: message: dismissButton:)를 반복해서 작성하는 보일러플레이트 코드가 발생한다. - 해결:
AlertItem구조체 내부에Alert객체를 반환하는 연산 프로퍼티를 생성하여 뷰의 책임을 모델로 옮긴다. - 로직 및 필요성:
- 뷰에서 ViewModel의 함수를 호출할 때, 내부적으로
switch-case를 통해 다양한 실패 상황을 분류한다. - 이때 각 케이스별로 상황에 맞는 AlertContext(데이터)를 끌어다 사용하게 되는데, 이 데이터 안에 이미 출력될 제목과 메시지가 정의되어 있다.
- 뷰에서 ViewModel의 함수를 호출할 때, 내부적으로
- 결과: 뷰는 구체적인 상황(case)을 알 필요 없이, ViewModel이 던져준
$0.alert를 화면에 그리기만 하면 된다. - 결론: 비즈니스 로직(ViewModel)과 데이터 정의(AlertContext)가 이미 상황 처리를 마쳤기 때문에, 뷰 코드는 단순화된 인터페이스로 가독성을 유지할 수 있다.
OnBoardingView 코드 다듬기
@UserDefaults에서 @AppStorage로의 전환
Before & After
기존 UserDefaults를 직접 호출하던 로직을 SwiftUI 전용 프로퍼티 래퍼인 @AppStorage로 교체해주었다.
Before UserDefaults.standard를 사용하여 직접 Getter와 Setter를 구현한 방식이다
1
2
3
4
5
6
7
8
9
10
11
12
13
14
let kHasSeenOnboardView = "hasSeenOnboardView"
var hasSeenOnboardView: Bool {
return UserDefaults.standard.bool(forKey: kHasSeenOnboardView)
}
func runStartupChecks() {
if !hasSeenOnboardView {
isShowingOnboardView = true // UI 상태 변경
UserDefaults.standard.set(true, forKey: kHasSeenOnboardView) // 직접 저장
} else {
checkIfLocationServicesIsEnabled()
}
}
After @AppStorage를 사용해 특정 키값과 변수를 연결한다.
1
2
3
4
5
6
7
8
9
10
11
12
@AppStorage("hasSeenOnboardView") var hasSeenOnboardView = false {
didSet { isShowingOnboardView = hasSeenOnboardView }
}
func runStartupChecks() {
if !hasSeenOnboardView {
// 값 변경 시 저장소에 자동 반영 및 위 didSet 로직 실행
hasSeenOnboardView = true
} else {
checkIfLocationServicesIsEnabled()
}
}
주요 변경 사항
- 선언부의 간소화
- Before: 별도의 상수(
kHasSeenOnboardView)를 선언하고 연산 프로퍼티를 통해 값을 수동으로 반환함. - After: 프로퍼티 래퍼를 사용해 선언과 동시에 저장소 연결 및 기본값 설정을 마침.
- Before: 별도의 상수(
- 데이터 흐름의 자동화
- Before:
runStartupChecks내부에서 UI 상태 변경과 데이터 저장을 각각 수행해야 했음. - After:
hasSeenOnboardView의 값이 변경되면didSet이 트리거되어 UI 상태 변수(isShowingOnboardView)가 자동으로 동기화됨.
- Before:
- 기술적 인과관계 및 이점
- 상태 불일치 방지: 데이터 저장과 화면 업데이트가 하나의 흐름(
didSet)으로 묶여 로직 누락에 의한 버그를 방지함. - 유지보수성 향상: 코드 가독성이 좋아지고 SwiftUI 프레임워크가 지향하는 데이터 흐름 방식에 부합함.
- 상태 불일치 방지: 데이터 저장과 화면 업데이트가 하나의 흐름(
@Binding에서 @Environment로의 전환
화면을 닫는 로직을 부모 뷰로부터 전달받은 @Binding 변수 제어 방식에서, SwiftUI 프레임워크가 제공하는 @Environment 환경 변수 방식으로 교체함. 이를 통해 뷰 간의 의존성을 줄이고 재사용성을 높임.
Before & After
Before (@Binding 방식) 부모 뷰의 상태 변수를 직접 참조하여 화면의 표시 여부를 결정함. 뷰를 생성할 때 반드시 바인딩 값을 넘겨줘야 하므로 의존성이 강한 방식임.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 부모로부터 상태를 전달받아야 함
@Binding var isShowingOnboardView: Bool
Button {
// 부모의 변수값을 직접 변경하여 화면을 닫음
isShowingOnboardView = false
} label: {
XDismissButton()
}
// AppTabView
.sheet(isPresented: $viewModel.isShowingOnboardView, onDismiss: viewModel.checkIfLocationServicesIsEnabled) {
OnboardView(isShowingOnboardView: $viewModel.isShowingOnboardView)
}
After (@Environment 방식)
시스템 환경 변수인 presentationMode를 사용하여 현재 뷰 스스로가 자신을 해제(dismiss)함. 부모 뷰가 누구인지, 어떤 상태 변수를 쓰는지 알 필요가 없어 독립적인 사용이 가능함.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 시스템 환경 변수를 사용하여 현재 화면 상태를 가져옴
@Environment(\.presentationMode) var presentationMode
Button {
// 현재 뷰 스스로를 화면에서 제거(dismiss)함
presentationMode.wrappedValue.dismiss()
} label: {
XDismissButton()
}
// AppTabview
.sheet(isPresented: $viewModel.isShowingOnboardView, onDismiss: viewModel.checkIfLocationServicesIsEnabled) {
OnboardView()
}
주요 변경 사항
- 상태 관리 주체의 변화
- Before:
@Binding var isShowingOnboardView를 통해 외부에서 주입된 상태를 변경함. - After:
@Environment(\.presentationMode)를 통해 SwiftUI 시스템 로직으로 화면을 닫음.
- Before:
- 프리뷰 및 생성자 간소화
- Before: 프리뷰나 실제 코드에서 뷰를 호출할 때
.constant(true)와 같은 바인딩 인자를 반드시 전달해야 함. - After: 인자 없이
OnboardView()호출만으로 생성이 가능해져 테스트와 미리보기가 훨씬 간편해짐.
- Before: 프리뷰나 실제 코드에서 뷰를 호출할 때
- 기술적 인과관계 및 이점
- 디커플링(Decoupling): OnboardView가 특정 변수 이름에 종속되지 않으므로, 앱 내 어디서든 Modal이나 Sheet로 띄웠을 때 동일한 방식으로 동작함.
- 유지보수 향상: 뷰의 호출부와 선언부 모두 코드가 간결해지며, SwiftUI 프레임워크의 표준 관습을 따르게 됨.
alert 빠졌던 부분 보완
이전에
1
2
3
4
5
6
7
8
9
10
11
12
13
func getCheckedInCounts() {
CloudKitManager.shared.getCheckedInProfilesCount { result in
DispatchQueue.main.async {
switch result {
case .success(let checkedInProfiles):
self.checkedInProfiles = checkedInProfiles
case .failure(_):
// Alert
break
}
}
}
}
이부분을 주석만 잠고 넘어갔었는데 마무리를 지어보려 한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// AlertItem
static let checkedInCount = AlertItem(title: Text("Server Error"),
message: Text("Unable to get the number of people checked into each location. Please check your internet connection and try again."),
dismissButton: .default(Text("Ok")))
// LocationMapVM
func getCheckedInCounts() {
CloudKitManager.shared.getCheckedInProfilesCount { result in
DispatchQueue.main.async { [self] in
switch result {
case .success(let checkedInProfiles):
self.checkedInProfiles = checkedInProfiles
case .failure(_):
alertItem = AlertContext.checkedInCount
break
}
}
}
}
해주면 끝.
Github: Dub-Dub-Grub Repository