![](https://hackmd.io/_uploads/B1tsbuqka.png) # 🗂️ 프로젝트 매니저 _ 🌳🐿️ - 프로젝트 팀원: [Dasan🌳](https://github.com/DasanKim), [Mary🐿️](https://github.com/MaryJo-github) - 프로젝트 리뷰어: [Green🍏](https://github.com/GREENOVER) --- ## 📖 목차 - 🍀 [소개](#소개) </br> - 💻 [실행 화면](#실행_화면) </br> - 🛠️ [핵심 경험](#핵심_경험) </br> - 🧨 [트러블 슈팅](#트러블_슈팅) </br> - 📚 [참고 링크](#참고_링크) </br> - 👩‍👧‍👧 [about TEAM](#about_TEAM) </br> </br> ## 🍀 소개<a id="소개"></a> 프로젝트의 할일들을 Todo, Doing, Done 3가지 상태로 관리할 수 있는 iPad용 앱입니다. </br> ## 💻 실행 화면<a id="실행_화면"></a> | 새 할 일 작성 | 메모 확인 | | :--------: | :--------: | | | | | 저장된 메모 수정 | 저장된 메모 삭제 | | :--------: | :--------: | | | | | 카테고리 변경 | Localization | | :--------: | :--------: | | | | </br> ## 🛠️ 핵심 경험<a id="핵심_경험"></a> - [StateObject와 ObservedObject](https://github.com/MaryJo-github/ios-project-manager/wiki/StateObject%EC%99%80-ObservedObject) - [SwiftUI를 통한 UI 구현](https://github.com/MaryJo-github/ios-project-manager/wiki/SwiftUI%EB%A5%BC-%ED%86%B5%ED%95%9C-UI-%EA%B5%AC%ED%98%84) - 리스트에서 스와이프를 통한 삭제 구현 - Date Picker를 통한 날짜 입력 - 날짜 Localization - Custom Modify 적용 - ZStack과 Overlay - Popover와 ContextMenu - [MVVM 구조 채택](https://github.com/MaryJo-github/ios-project-manager/wiki/MVVM-%EA%B5%AC%EC%A1%B0-%EC%B1%84%ED%83%9D) </br> ## 🧨 트러블 슈팅<a id="트러블_슈팅"></a> ### 1️⃣ 맨 마지막 Row의 메모만 보여지는 오류 🚨 **문제점** <br> - List의 Row를 선택하면 해당 sheet가 보여져야 했습니다. - 사용자의 tapGesture에 반응하기위하여 State 속성의 `isDisplaySheet` 프로퍼티를 만들고, 해당 값이 `true`일 때 sheet를 띄우도록 하였습니다. - 하지만 실행해보니, 선택한 Row가 아닌 맨 마지막 Row의 메모만 보여졌습니다. <img src="https://github.com/yagom-academy/ios-project-manager/assets/106504779/4d1616fb-34aa-4514-bbf1-1e551969d1ad" width="500"> <details> <summary> 상세 코드 </summary> ```swift // MemoListView.swift ... @State private var isDisplaySheet: Bool = false var body: some View { List { Section { ForEach(memos) { memo in VStack { MemoCellView() ... } .onTapGesture { isPresented.toggle() } .sheet(isPresented: $isDisplaySheet) { SheetView(memo: memo) } } } } } ``` </details> <br> 💡 **해결방법** <br> - 해당 오류는 tapGesture가 인식되었을 때 showDetail이 true로 변경되면서 선택한 메모 뿐만 아니라 모든 메모의 Sheet가 활성화되면서 나타난 문제점으로 파악하였습니다. - 저희는 두 가지 해결방법을 찾았습니다. 1. currentMemo 프로퍼티를 생성하여 tapGesture가 인식되었을 때 선택한 Row의 메모를 담고, 해당 메모를 띄우는 방법 <details> <summary> 상세 코드 </summary> ```swift // MemoListView.swift ... @State private var currentMemo: Memo? = nil var body: some View { List { Section { ForEach(memos) { memo in VStack { MemoCellView() ... } .onTapGesture { currentMemo = memo } .sheet(item: $currentMemo) { SheetView(memo: memo) } } } } } ``` </details> 2. MemoListView가 아닌 MemoCellView에서 State 속성의 `isDisplaySheet` 프로퍼티를 만들고, 해당 값이 `true`일 때 sheet를 띄우는 방법 <details> <summary> 상세 코드 </summary> ```swift // MemoCellView ... @State var isDisplaySheet: Bool = false fileprivate var body: some View { VStack(alignment: .leading, spacing: 2) { ... } .onTapGesture { isDisplaySheet.toggle() } .sheet(isPresented: $isDisplaySheet) { SheetView( sheetViewModel: .init( isEditMode: false, memo: memo) ) } ... } ``` </details> - 두 가지 해결방법 중 ForEach 내에서 sheet사용하기보다 가장 하위 계층에 있는 MemoCellView에서 sheet를 사용하는 것이 안전할 뿐만아니라, 역할적으로도 MemoCell에서 담당하는 것이 자연스럽다고 판단하여 두 번째 방법을 선택하여 진행하였습니다. <img src="https://github.com/yagom-academy/ios-project-manager/assets/106504779/4733ef6a-c6c1-4894-a35b-53d20a313221" width="500"> <br> ### 2️⃣ List Row의 터치영역 🚨 **문제점** <br> - List의 Row를 터치하여 detail view를 띄울 때, Content(text) 부분에서만 터치 이벤트가 인식되고 그 외의 빈공간에서는 인식되지 않았습니다. <img src="https://github.com/yagom-academy/ios-project-manager/assets/106504779/d9487254-393d-4acf-bcf6-d582dd3aafef" width="500"> 💡 **해결방법** <br> - .contentShape(Rectangle())을 추가해줌으로써 전체 스택을 터치할 수 있도록 수정하였습니다. - `contentShape`는 hit testing용 content 모양을 정의합니다. - hit testing이란 터치나 드래그 같은 이벤트를 받는 것을 말합니다. <img src="https://github.com/yagom-academy/ios-project-manager/assets/106504779/075f9dc2-3a98-45fd-accf-6357f4a100b4" width="500"> <br> ### 3️⃣ 뷰 합치기 🚨 **문제점** <br> - 새로운 Sheet View인 `NewMemo`View와 Row를 선택했을 때 띄워지는 `MemoDetail`View는 형태가 비슷하여 중복 코드가 많았습니다. <table> <tr> <td> NewMemo </td> <td> MemoDetail </td> </tr> <tr> <td> ```swift struct NewMemo: View { @EnvironmentObject private var modelData: ModelData @State private var memo: Memo = Memo.newMemo var body: some View { NavigationView { VStack { TitleTextField(content: $memo.title) DeadlinePicker(date: $memo.deadline) BodyTextField(content: $memo.body) } .sheetBackground() .navigationTitle(memo.category.description) .navigationBarTitleDisplayMode(.inline) .navigationBarItems( leading: Button { } label: { Text("Cancel") }, trailing: Button { } label: { Text("Done") } ) } .navigationViewStyle(.stack) } } ``` </td> <td> ```swift struct MemoDetail: View { @EnvironmentObject private var modelData: ModelData private let memo: Memo ... var body: some View { NavigationView { VStack { TitleTextField(content: $modelData.memos[memoIndex].title) DeadlinePicker(date: $modelData.memos[memoIndex].deadline) BodyTextField(content: $modelData.memos[memoIndex].body) } .sheetBackground() .navigationTitle(memo.category.description) .navigationBarTitleDisplayMode(.inline) .navigationBarItems( leading: Button { } label: { Text("Edit") }, trailing: Button { } label: { Text("Done") } ) } .navigationViewStyle(.stack) } ``` </td> </tr> </table> 💡 **해결방법** <br> - 이에 해당 뷰들을 SheetView라는 뷰 하나로 통합하여 **코드 재사용성을 높여보고자** 하였습니다. - View마다 달랐던 전달인자 - 각 View가 가지고 있던 memo 프로퍼티를 ViewModel로 이동, View 마다 달랐던 (@Published) memo 프로퍼티 초기화 방식을 ViewModel의 init으로 주입받는 형식으로 통일해주었습니다. - 또한 각 View 마다 달랐던 TextField 및 DatePicker의 전달인자를 ViewModel의 `@Published memo`로 통일해주었습니다. ```swift final class SheetViewModel: ObservableObject { @Published var isEditMode: Bool @Published var memo: Memo init(isEditMode: Bool = true, memo: Memo = .init(title: "", body: "", deadline: .now, category: .toDo) ) { self.isEditMode = isEditMode self.memo = memo } func navigationLeftBtnTapped() { isEditMode.toggle() } } ``` - View마다 달랐던 Button - 각 View에 따라 nagigationBar의 Button 모양이 달랐는데, 이는 ViewModel의 `@Published isEditMode` 프로퍼티를 통하여 View의 `상태`에 따라 Button 모양을 변경하도록 수정해주었습니다. ```swift // SheetView.swift ... private var leftButton: some View { Button( action: { if sheetViewModel.isEditMode { dismiss() } sheetViewModel.navigationLeftBtnTapped() }, label: { Text(sheetViewModel.isEditMode ? "Cancel" : "Edit") } ) } ... ``` <br> ### 4️⃣ 하위 뷰에서 메모 수정 및 뷰 업데이트 🚨 **문제점** <br> - MemoListView는 Category 별 ListView를 그리는 뷰입니다. 따라서 MemoBoardView에서 MemoListView를 그릴 때 전체 메모에서 `Category 별로 분류한 데이터`를 MemoListViewModel에 넘겨주고 있습니다. ```swift // MemoBoardView.swit struct MemoBoardView: View { @EnvironmentObject private var memoBoardViewModel: MemoBoardViewModel var body: some View { HStack(spacing: 4) { ForEach(memoBoardViewModel.categories, id: \.description) { category in MemoListView( memoListViewModel: MemoListViewModel( memos: memoBoardViewModel.filter(by: category), category: category ) ) } } } } ``` ```swift // MemoListView.swit struct MemoListView: View { @StateObject var memoListViewModel: MemoListViewModel var body: some View { VStack(spacing: 0) { TitleView(memoListViewModel: memoListViewModel) MemoListContentView(memoListViewModel: memoListViewModel) .background(ColorSet.background) .listStyle(.plain) } } } ``` - 하지만 MemoListView 또는 그 하위 뷰에서 메모의 삭제나 category 이동이 일어났을 때, MemoBoardViewModel의 memos에 반영이 되었지만 변경된 카테고리가 보이는 화면에 반영되지 않는 문제점이 발생하였습니다. ![projectManager_updateTrouble](https://hackmd.io/_uploads/SySBL2DlA.gif) <img src="https://github.com/yagom-academy/ios-project-manager/assets/106504779/4d1616fb-34aa-4514-bbf1-1e551969d1ad" width="500"> 💡 **해결방법** <br> - 해당 오류는 @StateObject로 정의한 memoListViewModel이 한 번만 초기화되기 때문에 발생하는 문제점으로 파악하였습니다. - [@StateObject는 선언하는 컨테이너의 Lifetime동안 새 인스턴스를 한 번만 생성합니다.](https://developer.apple.com/documentation/swiftui/stateobject#overview) - 즉, MemoBoardViewModel의 memos가 변경됨에 따라 memoListViewModel의 인스턴스를 새로 만들어서MemoListView를 그리는 줄 알았으나, memoListViewModel은 초기에 한 번만 생성되고 변경되지 않은 것이었습니다. - 저희는 두 가지의 해결방법을 찾았습니다. 1. memoListViewModel을 @ObservedObject로 변경 @ObservedObject는 매 번 새로운 인스턴스를 만들기 때문에 memos가 변경됨에 따라 뷰도 같이 업데이트됩니다. ```swift // MemoListView.swit struct MemoListView: View { @ObservedObject var memoListViewModel: MemoListViewModel var body: some View { ... } } ``` 2. [onChange(of:initial:\_:)](https://developer.apple.com/documentation/swiftui/view/onchange(of:initial:_:)-8wgw9) 메서드 활용 메모의 변경이 일어나는 MemoListView의 하위 뷰에서 onChange 메서드를 통해 memoBoardViewModel의 memos의 변경사항을 감지하여, memoListViewModel의 memos를 category에 따라 다시 분류한 memos로 업데이트하였습니다. ```swift private struct MemoListContentView: View { @ObservedObject private var memoListViewModel: MemoListViewModel @EnvironmentObject private var memoBoardViewModel: MemoBoardViewModel fileprivate init(memoListViewModel: MemoListViewModel) { self.memoListViewModel = memoListViewModel } fileprivate var body: some View { List { ForEach(memoListViewModel.memos) { memo in MemoCellView( memoListViewModel: memoListViewModel, memo: memo ) .swipeActions(edge: .trailing, allowsFullSwipe: true) { Button(role: .destructive) { memoBoardViewModel.delete(memo) } label: { Text("Delete") } } } } .onChange(of: memoBoardViewModel.memos) { memos in memoListViewModel.memos = memos.filter { $0.category == memoListViewModel.category } } } } ``` - 두 가지 해결방법 중 @ObservedObject로 변경하는 방법은 뷰가 렌더링 될 때 마다 인스턴스를 새로 생성하므로 비효율적이라 판단하여 두 번째 방법을 선택하였습니다. <br> ## 📚 참고 링크<a id="참고_링크"></a> - [🍎Apple: swiftUI 튜토리얼](https://developer.apple.com/tutorials/swiftui) - [🍎Apple Docs: StateObject](https://developer.apple.com/documentation/swiftui/stateobject) - [🍎Apple Docs: ObservedObject](https://developer.apple.com/documentation/swiftui/observedobject) - [🍎Apple Docs: swipeActions(edge:allowsFullSwipe:content:)](https://developer.apple.com/documentation/swiftui/view/swipeactions(edge:allowsfullswipe:content:)) - [🍎Apple Docs: DatePicker](https://developer.apple.com/documentation/swiftui/datepicker) - [🍎Apple Docs: ViewModifier](https://developer.apple.com/documentation/swiftui/viewmodifier) - [🍎Apple Docs: ZStack](https://developer.apple.com/documentation/swiftui/zstack) - [🍎Apple Docs: overlay(alignment:content:) ](https://developer.apple.com/documentation/swiftui/view/overlay(alignment:content:)) - [🍎Apple HIG: Popovers](https://developer.apple.com/design/human-interface-guidelines/popovers) - [🍎Apple HIG: Context menus ](https://developer.apple.com/design/human-interface-guidelines/context-menus) - [🍏WWDC: Data Flow Through SwiftUI](https://developer.apple.com/videos/play/wwdc2019/226/) - [🌐blog: Localization 정리 & 선택된 App Language 알아내기](https://jooeungen.tistory.com/entry/iOSSwift-Localization-%EC%A0%95%EB%A6%AC-%EC%84%A0%ED%83%9D%EB%90%9C-App-Language-%EC%95%8C%EC%95%84%EB%82%B4%EA%B8%B0) <br> --- ## 👩‍👧‍👧 about TEAM<a id="about_TEAM"></a> | <Img src = "https://user-images.githubusercontent.com/106504779/253477235-ca103b42-8938-447f-9381-29d0bcf55cac.jpeg" width="100"> | 🌳Dasan🌳 | https://github.com/DasanKim | | :--------: | :--------: | :--------: | | <Img src = "https://hackmd.io/_uploads/r1rHg7JC3.jpg" width="100"> | **🐿️Mary🐿️** | **https://github.com/MaryJo-github** | </br> ---- # StateObject와 ObservedObject ## 사건의 발단 - MemoListView는 Category 별 ListView를 그리는 뷰입니다. 따라서 MemoBoardView에서 MemoListView를 그릴 때 전체 메모에서 `Category 별로 분류한 데이터`를 MemoListViewModel에 넘겨주고 있습니다. ```swift struct MemoBoardView: View { @EnvironmentObject private var memoBoardViewModel: MemoBoardViewModel var body: some View { HStack(spacing: 4) { ForEach(memoBoardViewModel.categories, id: \.description) { category in MemoListView( memoListViewModel: MemoListViewModel( memos: memoBoardViewModel.filter(by: category), category: category ) ) } } } } ``` - 하지만 MemoListView 또는 그 하위 뷰에서 메모의 삭제나 category 이동이 일어났을 때, MemoBoardViewModel의 memos에 반영이 되었지만 변경된 카테고리가 보이는 화면에 반영되지 않는 문제점이 발생하였습니다. ## 해결책 찾기 - MemoBoardViewModel의 memos가 변경되었음에도 MemoListView가 자동으로 업데이트 되지 않은 원인을 MemoListViewModel에 있는 memos가 바뀌지 않아서라고 판단하였습니다. - 따라서 onChange 메서드를 통해 memoBoardViewModel의 memos의 변경사항을 감지하여, memoListViewModel의 memos를 category에 따라 다시 분류한 memos로 업데이트하였더니 뷰가 다시 그려졌습니다. ```swift .onChange(of: memoBoardViewModel.memos) { memos in memoListViewModel.memos = memos.filter { $0.category == memoListViewModel.category } } ``` ## 풀리지 않은 의문 문제는 해결하였지만, 여전히 이해가 되지 않은 부분이 있었습니다. 간략한 구조를 보여주고 있는 그림에서 볼 수 있듯이 MemoBoardView에서도, MemoListView 하위 뷰들에서도 MemoBoardViewModel를 EnvironmentObject 프로퍼티로 가지고 있는데 왜 자동으로 View가 업데이트 되지 않는 것인지 의문이 풀리지 않았습니다. 이에 몇 가지 가설을 세우고 실험(?)을 진행하였습니다. ### 1) MemoBoardViewModel의 memos가 바뀌었는데, MemoBoardView에서 카테고리 별로 분류를 안해주나(MemoBoardView가 불리지 않는가)? - 실험 - MemoBoardView에서 ForEach로 memo를 분류하는 부분에 break point 찍고 실행 후, 스와이프로 memo 셀을 삭제해보았다. - 결과 - break point가 있는 곳에서 멈추었다. - MemoBoardView에서 MemoListView를 다시 그리는게 맞는데??? -> 메리 뇌 멈춤 ### 2) MemoBoardViewModel를 EnvironmentObject 프로퍼티로 가지고 있는 뷰가 모두 업데이트 되는게 아니었나? - 실험 - 각 View init에 print메서드로 View 이름을 찍어보았다. <details> <summary> View가 호출되는 순서 </summary> ``` // 어플 처음 실행시켰을 때(상위->하위) 메모 보드 뷰 메모 리스트 뷰 메모 리스트 뷰 메모 리스트 뷰 메모 리스트 뷰 타이틀 뷰 리스트 컨텐츠 뷰 메모 리스트 뷰 타이틀 뷰 리스트 컨텐츠 뷰 메모 리스트 뷰 타이틀 뷰 리스트 컨텐츠 뷰 메모 셀 뷰 메모 셀 뷰 메모 셀 뷰 메모 셀 뷰 메모 셀 뷰 메모 셀 뷰 메모 셀 뷰 메모 셀 뷰 메모 셀 뷰 메모 셀 뷰 메모 셀 뷰 메모 셀 뷰 ``` ``` // onChange를 적용하지 않았을 때(하위->상위) 메모 리스트 뷰 //todo 타이틀 뷰 리스트 컨텐츠 뷰 메모 셀 뷰 메모 셀 뷰 메모 셀 뷰 메모 리스트 뷰 //doing 타이틀 뷰 리스트 컨텐츠 뷰 메모 셀 뷰 메모 리스트 뷰 //done 타이틀 뷰 리스트 컨텐츠 뷰 메모 셀 뷰 메모 셀 뷰 메모 보드 뷰 // ``` ``` // onChange를 적용했을 때(하위->상위) 메모 리스트 뷰 //todo 타이틀 뷰 리스트 컨텐츠 뷰 메모 셀 뷰 메모 셀 뷰 메모 셀 뷰 메모 리스트 뷰 //doing 타이틀 뷰 리스트 컨텐츠 뷰 메모 셀 뷰 메모 리스트 뷰 //done 타이틀 뷰 리스트 컨텐츠 뷰 메모 셀 뷰 메모 셀 뷰 메모 보드 뷰 // 타이틀 뷰 // 여기서부터 MemoListViewModel의 memos가 업데이트되어 다시 그려짐 리스트 컨텐츠 뷰 메모 셀 뷰 메모 셀 뷰 타이틀 뷰 리스트 컨텐츠 뷰 메모 셀 뷰 타이틀 뷰 리스트 컨텐츠 뷰 메모 셀 뷰 메모 셀 뷰 ``` </details> - 결과 - MemoBoardViewModel를 EnvironmentObject 프로퍼티로 가지고 있는 뷰들 중 현재 보여지는 뷰들은 모두 다시 그려짐 ### 3) 뷰가 무효화되고 다시 그려질 때, 하위부터 상위뷰 순서로 그려져서 카테고리 분류한 것이 반영이 안되나? - 실험 - MemoBoardView에서 MemoListView를 생성할 때 MemoListViewModel도 생성하면서 주입시켜주는데, 이때 어떤 memoListViewModel을 넘겨주는지 break point를 찍어 추적함 - 결과 - 뷰가 무효화되어 다시 그려질 때마다 MemoListViewModel의 주소값을 추적하였더니 처음 생성되었을 때의 주소값과 같았다. - 즉, MemoBoardView가 다시 그려지더라도 StateObject로 선언된 MemoListViewModel 생성자는 처음 생성될 때만 호출되고 그 이후에 호출되는 것은 이미 만들어진 하나의 인스턴스가 불리므로 주소가 같은 것 - 바로 MemoListView에 StateObject로 선언되어있던 MemoListViewModel을 ObservedObject으로 변경해서 실행시켜보았다. - 매번 새로운 MemoListViewModel생성 되어 모두 다른 주소값을 가지고 있는 것을 확인할 수 있었다. - onChange를 사용하지 않아도 원하는 대로 뷰가 업데이트 되는 것을 확인할 수 있었다. <details> <summary> memoListViewModel이 StateObject로 선언되어있을 때 주소값 </summary> ``` // 초기 생성 및 뷰 무효화 이후에도 계속 같은 주소값을 가지고 있었다. // todo (lldb) po memoListViewModel <MemoListViewModel: 0x6000019cc1e0> // doing (lldb) po memoListViewModel <MemoListViewModel: 0x6000019d55a0> // done (lldb) po memoListViewModel <MemoListViewModel: 0x6000019d6ac0> ``` </details> <details> <summary> memoListViewModel이 ObservedObject로 선언되어있을 때 주소값 </summary> ``` // 초기 생성시 주소값 (lldb) po memoListViewModel <MemoListViewModel: 0x60000186f820> (lldb) po memoListViewModel <MemoListViewModel: 0x60000185d4a0> (lldb) po memoListViewModel <MemoListViewModel: 0x60000183fb00> // todo (lldb) po memoListViewModel <MemoListViewModel: 0x6000018357a0> // doing (lldb) po memoListViewModel <MemoListViewModel: 0x6000018547a0> // done (lldb) po memoListViewModel <MemoListViewModel: 0x600001808900> // 뷰 무효화 이후의 주소값 // todo (lldb) po memoListViewModel <MemoListViewModel: 0x6000018b4e20> // doing (lldb) po memoListViewModel <MemoListViewModel: 0x6000018368c0> // done (lldb) po memoListViewModel <MemoListViewModel: 0x600001854da0> ``` </details> ## 결론 - EnvironmentObject 관련 이슈인 줄알고 그 부분에 몰두하고 있었는데, StateObject와 ObservedObject의 차이 때문이었습니다. - StateObject: 처음 뷰를 그릴 때 인스턴스를 만들어놓고, 저장해놓고 있다가 이후 렌더링 시 재사용(초기화를 하지 않는다.) - ObservedObject: 뷰를 렌더링 할 때 마다 인스턴스를 새로 생성 - memoListViewModel을 ObservedObject로 선언하면 코드가 간결해지는 장점이 있었으나, 뷰가 업데이트될 때마다 memoListViewModel이 초기화되는 것은 비효율적이라고 생각하였습니다. 따라서 memoListViewModel를 StateObejct으로 선언하고 onChange 메서드로 문제점을 해결해주었습니다. # SwiftUI를 통한 UI 구현 ## List에서 스와이프를 통한 삭제 구현 SwiftUI의 List에서 제공해주는 `swipeActions`를 통해 스와이프를 통한 삭제를 간단하게 구현할 수 있었습니다. ```swift private struct MemoListContentView: View { @ObservedObject private var memoListViewModel: MemoListViewModel @EnvironmentObject private var memoBoardViewModel: MemoBoardViewModel ... fileprivate var body: some View { List { ForEach(memoListViewModel.memos) { memo in MemoCellView(...) .swipeActions(edge: .trailing, allowsFullSwipe: true) { Button(role: .destructive) { memoBoardViewModel.delete(memo) } label: { Text("Delete") } } } } ... } } ``` ## Date Picker를 통한 날짜 입력 ### ViewModel과 DatePicker 연결하기 DatePicker를 통해 선택된 날짜 date를 @Binding으로 ViewModel의 memo와 **양방향 연결**하여 날짜를 입력 받습니다. - ObservableObject는 $ 접두사를 통해 포함된 모든 프로퍼티에 대한 바인딩을 자동으로 제공합니다.([참고](https://forums.developer.apple.com/forums/thread/126796)) <details> <summary> View 코드 </summary> ```swift // DeadlinePicker.swift struct DeadlinePicker: View { @Binding var date: Date var body: some View { DatePicker("deadline", selection: $date, displayedComponents: .date) .labelsHidden() .datePickerStyle(.wheel) .environment(\.locale, Locale(identifier: NSLocale.preferredLanguages.first ?? "ko_KR")) } } // SheetView.swift struct SheetView: View { @Environment(\.dismiss) private var dismiss @EnvironmentObject private var memoBoardViewModel: MemoBoardViewModel @StateObject var sheetViewModel: SheetViewModel var body: some View { NavigationView { VStack { TitleTextField(content: $sheetViewModel.memo.title) DeadlinePicker(date: $sheetViewModel.memo.deadline) BodyTextField(content: $sheetViewModel.memo.body) } .sheetBackground() .disabled(!sheetViewModel.isEditMode) .navigationTitle(sheetViewModel.memo.category.description) .navigationBarTitleDisplayMode(.inline) .navigationBarItems( leading: leftButton, trailing: rightButton ) } .navigationViewStyle(.stack) } ``` </details> <details> <summary> ViewModel 코드 </summary> ```swift final class SheetViewModel: ObservableObject { @Published var isEditMode: Bool @Published var memo: Memo init( isEditMode: Bool = true, memo: Memo = .init( title: "", body: "", deadline: .now, category: .toDo ) ) { self.isEditMode = isEditMode self.memo = memo } func navigationLeftBtnTapped() { isEditMode.toggle() } } ``` </details> ### DatePicker label에 대한 고찰 |1. label 값만 줬을 때| |:--:| |<img src="https://github.com/yagom-academy/ios-project-manager/assets/106504779/2f929e77-4196-431c-a55a-6e3ab9723b96" width ="300">| |**2. label에 빈 문자열을 줬을 때**| |<img src="https://github.com/yagom-academy/ios-project-manager/assets/106504779/b8ad5958-ce8c-4012-8926-e70da0bf0d63" width ="300">| |**3. labelsHidden()를 적용했을 때**| |<img src="https://github.com/yagom-academy/ios-project-manager/assets/106504779/a1a223cb-cf29-48cf-9363-4293cd201e85" width ="300">| 1. DatePicker에 label(title)이 들어가면, title과 DatePicker가 각각 leading, trailing으로 정렬되는 것을 확인할 수 있었습니다. 2. label에 빈 문자열이 들어가더라도 label에 값이 있다고 판단하여 이 역시 DatePicker가 trailing으로 정렬되었습니다. 따라서 저희가 원하는 `center` 정렬을 위해서 `.labelsHidden()를` 통해 label를 숨기되, [SwiftUI에서는 accessibility 등 다른 용도로 레이블을 사용하므로 레이블을 숨기는 경우에도 항상 컨트롤에 대한 레이블을 제공해야 하므로](https://developer.apple.com/documentation/swiftui/view/labelshidden()) 레이블에 값을 넣어줌으로써 `accessibility` 등 다른 용도로도 사용될 수 있도록 하였습니다. ## 날짜 Localization - 앱 특성상 날짜가 자주 쓰이기 때문에 `날짜`에 대한 localization을 제공하기로 하였습니다. - memo에 저장되는 Date값과 DatePicker의 Date에 locale 적용 - 사용자 입장에서 익숙한 경험을 유지할 수 있도록 하기 위하여 사용자가 있는 지역(region)보다 사용자가 설정해둔 **선호하는 언어**의 날짜 포맷으로 localization을 해주었습니다. - 선호하는 언어 목록을 가져오기 위하여 NSLocale의 `preferredLanguages` 프로퍼티를 사용 ```swift extension Date { var formattedDate: String { let myFormat = Date.FormatStyle() .year() .day() .month() .locale(Locale(identifier: NSLocale.preferredLanguages.first ?? "ko_KR")) return self.formatted(myFormat) } } ``` ```swift struct DeadlinePicker: View { @Binding var date: Date var body: some View { DatePicker("deadline", selection: $date, displayedComponents: .date) .labelsHidden() .datePickerStyle(.wheel) .environment(\.locale, Locale(identifier: NSLocale.preferredLanguages.first ?? "ko_KR")) } } ``` ## Custom View Modifier 적용 코드의 재사용성을 높이기 위해 Custom View Modifier를 적용해보았습니다. 1. ViewModifier 정의 및 body 구현 ```swift struct SheetBackground: ViewModifier { func body(content: Content) -> some View { ZStack { ColorSet.navigationBarBackground.edgesIgnoringSafeArea(.all) Color.white content } } } ``` 2. View extension에 추가 ```swift extension View { func sheetBackground() -> some View { modifier(SheetBackground()) } } ``` 3. ViewModifier 적용 ```swift struct SheetView: View { ... var body: some View { NavigationView { VStack { ... } .sheetBackground() ... } .navigationViewStyle(.stack) } } ``` ## ZStack과 Overlay ZStack과 Overlay의 차이점을 알고 필요한 곳에 적절히 적용하였습니다. ### ZStack > A view that overlays its subviews, aligning them in both axes. (하위 뷰를 오버레이하여, 양쪽 축에 정렬합니다.) - ZStack은 연속되는 각 하위 뷰에 이전 뷰보다 더 높은 **z축** 값을 할당하므로, 이후 하위 뷰가 이전 뷰의 위에 표시됩니다.(zIndex로 우선순위를 바꿀 수 있음) - ZStack의 자식뷰들은 서로에 대해 독립적입니다. - ZStack에 frame을 따로 주지 않은 이상 크기가 가장 큰 자식뷰를 기준으로 ZStack의 fit이 결정됩니다. ### Overlay > Layers the views that you specify in front of this view. (이 뷰 앞에 지정한 뷰를 계층화합니다.) - overlay는 하나 이상의 뷰를 다른 뷰 **앞에** 배치할 수 있습니다. - 즉, z축과 관계 없이 위로만 오버레이되어 기본 뷰 위에 다른 요소를 추가하는 역할을 합니다. - 콘텐츠 클로저에 둘 이상의 뷰를 지정하는 경우 Modifier는 클로저의 모든 뷰를 암시적 ZStack으로 수집하여 뒤쪽에서 앞쪽 순서로 가져옵니다. - overlay되는 view는 부모뷰에 종속됩니다. 오버레이된 뷰는 항상 부모뷰의 위에 위치하고 부모뷰를 기준으로 fit이 결정됩니다. ## Popover와 ContextMenu List의 한 셀을 **long Press(hold gesture)** 했을 때, 해당 셀을 다른 category로 이동하는 메뉴를 보여주기 위한 방법으로 `Popover`와 `ContextMenu` 중에 고민하였습니다. |Popover|<img src="https://hackmd.io/_uploads/Hkv_U5IeR.png" width="500">| |:--:|:--:| |**ContextMenu**|<img src="https://hackmd.io/_uploads/HyKw8qUx0.png" width="500">| ### Popover `Popover`는 사람들이 컨트롤이나 대화형 영역을 클릭하거나 탭할 때 **다른 콘텐츠 위에** 표시되는 일반적인 뷰입니다. [H.I.G](https://developer.apple.com/design/human-interface-guidelines/popovers)에 따르면 - 소량의 정보나 기능을 노출할 때, 혹은 콘텐츠를 위한 공간이 일시적으로 필요한 경우 사용됩니다. - 또한 compact views에서는 popovers를 사용을 피하고 wide views에서 사용하라고 권장합니다. <details> <summary> Popover으로 구현한 카테고리 이동 버튼 </summary> ```swift private struct MemoCellView: View { @ObservedObject private var memoListViewModel: MemoListViewModel @EnvironmentObject private var memoBoardViewModel: MemoBoardViewModel var memo: Memo @State var isDisplayPopover: Bool = false ... fileprivate var body: some View { VStack(alignment: .leading, spacing: 2) {...} .onLongPressGesture { isDisplayPopover.toggle() } .popover(isPresented: $isDisplayPopover, attachmentAnchor: .point(.center) ) { VStack { Button { memoBoardViewModel.move(memo, destination: memoBoardViewModel.getFirstDestination(from: memo.category)) } label: { Text("\(memoBoardViewModel.getFirstDestination(from: memo.category).description)으로 이동") ... } Button { memoBoardViewModel.move(memo, destination: memoBoardViewModel.getSecondDestination(from: memo.category)) } label: { Text("\(memoBoardViewModel.getSecondDestination(from: memo.category).description)으로 이동") ... } } ... } } } ``` </details> ### ContextMenu `ContextMenu`는 인터페이스를 복잡하게 하지 않고도 **항목과 직접 관련된 기능에 접근**할 수 있게합니다. [H.I.G](https://developer.apple.com/design/human-interface-guidelines/context-menus)에 따르면 - 현재 상황에서 가장 필요할 가능성이 높은 명령에 빠르게 접근할 수 있도록 도움을 줄 때 사용됩니다. - ContextMenu는 자주 사용하는 항목에 편리하게 접근할 수 있지만 기본적으로 숨겨져 있어 사람들이 메뉴가 있다는 사실을 모를 수 있어, 기본 인터페이스에서도 ContextMene 항목을 사용할 수 있어야한다고 권장합니다. - iOS, iPadOS에서는 항목의 preview를 제공할 수 있습니다. <details> <summary> ContextMenu로 구현한 카테고리 이동 버튼 </summary> ```swift private struct MemoCellView: View { @ObservedObject private var memoListViewModel: MemoListViewModel @EnvironmentObject private var memoBoardViewModel: MemoBoardViewModel var memo: Memo ... fileprivate var body: some View { VStack(alignment: .leading, spacing: 2) {...} .contextMenu { Button { memoBoardViewModel.move(memo, destination: memoBoardViewModel.getFirstDestination(from: memo.category)) } label: { Text("\(memoBoardViewModel.getFirstDestination(from: memo.category).description)으로 이동") } Button { memoBoardViewModel.move(memo, destination: memoBoardViewModel.getSecondDestination(from: memo.category)) } label: { Text("\(memoBoardViewModel.getSecondDestination(from: memo.category).description)으로 이동") } } } } ``` </details> ### 최종 선택 - ContextMenu 아래와 같은 이유로 Popover 대신 ContextMemo를 선택하였습니다. 1. 카테고리를 이동하기 위한 메뉴를 보여주는 것은 ContextMenu의 목적인 **항목과 직접 관련된 기능에 접근**하는 것에 부합 2. 셀을 선택했을 때 **어떤 셀을 선택하였는지 명확하게 보여주는 점**이 사용자에게 더 좋은 경험을 제공 3. ContextMenu가 preview 기능을 제공하고 있어 추후 내용 미리보기 등 **기능을 확장하기에 용이** # MVVM 구조 채택 - 처음에는 MV 구조로 구현해보았으나, 비즈니스로직 및 Model 변수들이 View에 있어 **Model과 View의 의존성**이 매우 높았습니다. 이러한 문제를 해결하기 위해 MVVM 패턴을 적용해보고자 하였습니다. - 일반적인 MVVM 구조에서 View와 ViewModel의 역할은 다음과 같습니다. - View - 사용자의 interation 받기 - 상태값을 화면에 나타내기 - ViewModel - 상태값을 변경하는 비즈니스 로직 ex. 버튼이 눌렸을 때 어떤 상태값을 어떻게 변경할지 - 상태값을 바인딩하는 역할 ex. 상태값이 변경되었을 때 뷰 업데이트 - 그러나 SwiftUI에서 View는 자체적으로 Data Binding이 가능한 Property Wrapper를 지원하기 때문에 저희는 ViewModel에서 비즈니스 로직에만 집중하기로 하였습니다. - 다음과 같이 각 View에 ViewModel을 생성하여 비즈니스로직을 분리하였습니다. <Img src = "https://hackmd.io/_uploads/rJJJZF8gC.png" width="200"> ```swift // MemoHomeViewModel.swift final class MemoHomeViewModel: ObservableObject { @Published var isDisplaySheet: Bool = false func plusBtnTapped() { isDisplaySheet.toggle() } } ``` /* ```swift struct MemoListView: View { @State private var currentMemo: Memo? = nil //@State private var showingDetail = false var memos: [Memo] var body: some View { // apple은 이 뷰를 만들었을까... 왜(why) 문제의도 파악! // data의 무결성을 막기위해. 데이터 드리븐으로 가즈아 // memo, category // enum-case VStack { HStack { Text(memos.first?.category.description ?? "") // first를 쓰는 경우 불안하므로 현업에서 안씀 Image(systemName: "\(memos.count).circle.fill") Spacer() } .font(.largeTitle) .foregroundColor(.primary) List { //VStack ForEach(memos) { memo in VStack(alignment: .leading, spacing: 2) { //VStack은 항상 spacing 2씩 떨어져있음 MemoRow(memo: memo) // .sheet(isPresented: $showingDetail) { // MemoDetail(memo: memo) // } .swipeActions { Button { // } label: { Text("Delete") } } .frame(maxWidth: .infinity, alignment: .leading) //.listRowSeparator(.hidden) if memos.last != memo { Color.gray .frame(height: 8) } } .listRowSeparator(.hidden) .listRowInsets(EdgeInsets()) .contentShape(Rectangle()) .onTapGesture { //showingDetail.toggle() currentMemo = memo } //.frame(maxWidth: .infinity, alignment: .leading) } .sheet(item: $currentMemo) { memo in MemoDetail(memo: memo) } } .listStyle(.grouped) Spacer() } } } struct MemoView_Previews: PreviewProvider { static var previews: some View { MemoListView(memos: ModelData().memos) } } ``` */ /* // memoListView.swift var body: some View { VStack { HStack { Text(category.description) Image(systemName: "\(memos.count).circle.fill") Spacer() } .font(.largeTitle) .foregroundColor(.primary) List { ForEach(memos) { memo in VStack(alignment: .leading, spacing: 2) { MemoRow(memo: memo) .swipeActions { Button { // } label: { Text("Delete") } } .frame(maxWidth: .infinity, alignment: .leading) if memos.last != memo { Color.gray .frame(height: 8) } } .listRowSeparator(.hidden) .listRowInsets(EdgeInsets()) .contentShape(Rectangle()) .onTapGesture { currentMemo = memo } } .sheet(item: $currentMemo) { memo in MemoDetail(memo: memo) } } .listStyle(.grouped) Spacer() } } */ https://developer.apple.com/documentation/swiftui/view/contentshape(_:eofill:)