---
# System prepended metadata

title: "README - 프로젝트 매니저\U0001F5C2️"

---

![](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:)