
# 🗂️ 프로젝트 매니저 _ 🌳🐿️
- 프로젝트 팀원: [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에 반영이 되었지만 변경된 카테고리가 보이는 화면에 반영되지 않는 문제점이 발생하였습니다.

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