# ๐Ÿ—‚๏ธ ํ”„๋กœ์ ํŠธ ๋งค๋‹ˆ์ € > ์‚ฌ์šฉ์ž์˜ ์ผ์ •์„ ํ‘œ์‹œํ•˜๋Š” iPad ์•ฑ์ž…๋‹ˆ๋‹ค. > * ์ฃผ์š” ๊ฐœ๋…: `MVVM`, `Combine` > > ํ”„๋กœ์ ํŠธ ๊ธฐ๊ฐ„: 2023.05.15 ~ 2023.06.02 ### ๐Ÿ’ป ๊ฐœ๋ฐœํ™˜๊ฒฝ ๋ฐ ๋ผ์ด๋ธŒ๋Ÿฌ๋ฆฌ <img src = "https://img.shields.io/badge/swift-5.8-orange"> <img src = "https://img.shields.io/badge/Minimum%20Diployment%20Target-13.0-blue"> <img src = "https://img.shields.io/badge/CocoaPods-1.11.3-brightgreen"> <img src = "https://img.shields.io/badge/Firebase-10.9.0-red"> ## โญ๏ธ ํŒ€์› | Rowan | Brody | | :--------: | :--------: | | <Img src = "https://i.imgur.com/S1hlffJ.jpg" height="200"/> |<img height="200" src="https://avatars.githubusercontent.com/u/70146658?v=4" width="200">|<img src="https://github.com/Andrew-0411/ios-diary/assets/45560895/2872b119-d22b-46a7-85c4-d9e0c3dd6da8"> | [Github Profile](https://github.com/Kyeongjun2) |[Github Profile](https://github.com/seunghyunCheon) | </br> ## ๐Ÿ“ ๋ชฉ์ฐจ 1. [ํƒ€์ž„๋ผ์ธ](#-ํƒ€์ž„๋ผ์ธ) 2. [ํ”„๋กœ์ ํŠธ ๊ตฌ์กฐ](#-ํ”„๋กœ์ ํŠธ-๊ตฌ์กฐ) 3. [์‹คํ–‰ํ™”๋ฉด](#-์‹คํ–‰ํ™”๋ฉด) 4. [ํŠธ๋Ÿฌ๋ธ” ์ŠˆํŒ…](#-ํŠธ๋Ÿฌ๋ธ”-์ŠˆํŒ…) 5. [ํ•ต์‹ฌ๊ฒฝํ—˜](#-ํ•ต์‹ฌ๊ฒฝํ—˜) 6. [์ฐธ๊ณ  ๋งํฌ](#-์ฐธ๊ณ -๋งํฌ) </br> # ๐Ÿ“† ํƒ€์ž„๋ผ์ธ - 2023.05.15: ๊ธฐ์ˆ ์Šคํƒ, ์ €์žฅ์†Œ ์„ ํƒ. - 2023.05.16: MVVM ์•„ํ‚คํ…์ฒ˜ ํ•™์Šต. - 2023.05.17: ๋ฉ”์ธํ™”๋ฉด, TodoListViewModel, TodoViewModel ๊ตฌ์„ฑ. - 2023.05.18: Combine๊ฐœ๋… ํ•™์Šต. - 2023.05.19: ํ•  ์ผ ์ถ”๊ฐ€ํ™”๋ฉด ํ”Œ๋กœ์šฐ์ฐจํŠธ, UML, README ์ž‘์„ฑ. - 2023.05.22: Detailํ™”๋ฉด ๊ตฌํ˜„ ๋ฐ Combine์ ์šฉ. - 2023.05.23: Task์ถ”๊ฐ€ ์‹œ ๋ฉ”์ธํ™”๋ฉด์— ์ ์šฉ. - 2023.05.24: ๋ฉ”์ธ ํ™”๋ฉด ๋‚ด์˜ ํ•˜๋‚˜์˜ ์ปฌ๋ ‰์…˜๋ทฐ์—์„œ 3๊ฐœ์˜ ์ปฌ๋ ‰์…˜ ๋ทฐ ์‚ฌ์šฉํ•˜๋„๋ก ๋ณ€๊ฒฝ. - 2023.05.25: CollectionViewModel ์ถ”์ƒํ™”ํ•˜์—ฌ ๋ณด์ผ๋Ÿฌ ํ”Œ๋ ˆ์ดํŠธ ์ฝ”๋“œ ์‚ญ์ œ. - 2023.05.26: ๋ทฐ๋ชจ๋ธ์—์„œ ๋ทฐ๋ฅผ ์ œ์™ธํ•œ UML ์žฌ์„ค๊ณ„, README ์ž‘์„ฑ. - 2023.05.29: ๋ทฐ - ๋ทฐ๋ชจ๋ธ ๋ฐ”์ธ๋”ฉ ๋ฆฌํŒฉํ† ๋ง, popOverStyle modal view ์ •์˜ - 2023.05.30: ๊ฐ์ฒด ๊ฐ„ ์˜์กด์„ฑ ์ฃผ์ž… ๊ตฌํ˜„, ์‹œ๋ฎฌ๋ ˆ์ดํ„ฐ ์ž‘๋™ ์‹œ ๋ฐœ์ƒํ•œ ์˜ค๋ฅ˜ ์ˆ˜์ • - 2023.05.31: ์š”๊ตฌ์‚ฌํ•ญ์— ๋งž๋Š” UI ๊ตฌํ˜„, ์ŠคํŠธ๋ฆผ์—์„œ ์˜ค๋ฅ˜๋ฅผ ๋˜์งˆ ์ˆ˜ ์žˆ๋„๋ก ๋ฆฌํŒฉํ† ๋ง - 2023.06.01: ๊ฐ์ฒด ๊ด€๊ณ„ ์žฌ์„ค๊ณ„ ๋ฐ UML ์ˆ˜์ • ๋ฐ ๋ฆฌํŒฉํ† ๋ง, ์ปฌ๋ ‰์…˜๋ทฐ ์Šค๋ƒ…์ƒท ๋ฆฌ๋กœ๋“œ ๊ธฐ๋Šฅ ๊ฐœ์„  - 2023.06.02: ViewModel ๋‚ด๋ถ€ ์ด๋ฒคํŠธ ๋ฐ”์ธ๋”ฉ์„ ์œ„ํ•œ Input / Output Nested Type ์ถ”๊ฐ€, MainViewModel ๋ฆฌํŒฉํ† ๋ง ๋ฐ README ์ž‘์„ฑ </br> # ๐ŸŒณ ํ”„๋กœ์ ํŠธ ๊ตฌ์กฐ ## UML Class Diagram ![](https://hackmd.io/_uploads/BkUD2NvLn.jpg) ![](https://hackmd.io/_uploads/rkvX34PI3.jpg) ![](https://hackmd.io/_uploads/BJFSpXDI3.jpg) </br> ## File Tree ```swift โ””โ”€โ”€ Diary โ”œโ”€โ”€ AppDelegate.swift โ”œโ”€โ”€ SceneDelegate.swift โ”œโ”€โ”€ Model โ”‚ โ”œโ”€โ”€ Plan.swift โ”‚ โ”œโ”€โ”€ WorkState.swift โ”‚ โ””โ”€โ”€ Service โ”‚ โ””โ”€โ”€ PlanStorageService.swift โ”œโ”€โ”€ MainView โ”‚ โ”œโ”€โ”€ MainViewController.swift โ”‚ โ”œโ”€โ”€ MainViewModel.swift โ”‚ โ”œโ”€โ”€ PlanCollectionView โ”‚ โ”‚ โ”œโ”€โ”€ PlanCollectionViewController.swift โ”‚ โ”‚ โ”œโ”€โ”€ TodoViewModel.swift โ”‚ โ”‚ โ”œโ”€โ”€ DoingViewModel.swift โ”‚ โ”‚ โ”œโ”€โ”€ DoneViewModel.swift โ”‚ โ”‚ โ””โ”€โ”€ PlanListViewModel.swift โ”‚ โ”œโ”€โ”€ PlanCell โ”‚ โ”‚ โ”œโ”€โ”€ PlanCell.swift โ”‚ โ”‚ โ””โ”€โ”€ PlanCellViewModel.swift โ”‚ โ””โ”€โ”€ HeaderView โ”‚ โ”œโ”€โ”€ HeaderView.swift โ”‚ โ””โ”€โ”€ HeaderViewModel.swift โ”œโ”€โ”€ DetailView โ”‚ โ”œโ”€โ”€ DetailViewController.swift โ”‚ โ””โ”€โ”€ DetailViewModel.swift โ”œโ”€โ”€ Protocols โ”‚ โ””โ”€โ”€ ReuseableIdentifier.swift โ”œโ”€โ”€ Extensions โ”‚ โ”œโ”€โ”€ Combine โ”‚ โ”‚ โ”œโ”€โ”€ UIControl+Combine.swift โ”‚ โ”‚ โ”œโ”€โ”€ UITextView+Combine.swift โ”‚ โ”‚ โ”œโ”€โ”€ UITextField+Combine.swift โ”‚ โ”‚ โ””โ”€โ”€ UIDatePicker+Combine.swift โ”‚ โ”œโ”€โ”€ Array.swift โ”‚ โ””โ”€โ”€ CALayer.swift โ”œโ”€โ”€ Assets โ”œโ”€โ”€ LaunchScreen โ”œโ”€โ”€ Info.plist โ””โ”€โ”€ GoogleService-Info.plist ``` </br> # ๐Ÿ“ฑ ์‹คํ–‰ํ™”๋ฉด |ํ•  ์ผ ์ถ”๊ฐ€|ํ•  ์ผ ์ˆ˜์ •| |:--:|:--:| |<img src="https://github.com/seunghyunCheon/Diary/assets/70146658/c186b2c4-ef79-4806-8e4b-2f5c1bef4644" width="700">|<img src="https://github.com/seunghyunCheon/Diary/assets/70146658/b95556ae-4a13-4fa4-9a50-5012de76c84c" width="700">| |ํ•  ์ผ ์‚ญ์ œ|ํ•  ์ผ ์ด๋™| |:--:|:--:| |<img src="https://github.com/seunghyunCheon/Diary/assets/70146658/4720d776-3fdb-44a3-8756-95eae80eee44" width="700">|<img src="https://github.com/seunghyunCheon/Diary/assets/70146658/1bd44cd4-1a2c-4660-bbb0-4ba5c98dc440" width="700">| |๊ธฐํ•œ์ง€๋‚œ ํ•  ์ผ|ํ™•์ธ ๋ฒ„ํŠผ ํ™œ์„ฑํ™”| |:--:|:--:| |<img src="https://github.com/seunghyunCheon/Diary/assets/70146658/645b2a79-1b05-4fd9-b707-fa6d5396b0e0" width="700"/>|<img src="https://github.com/seunghyunCheon/Diary/assets/70146658/64a465e1-95f4-4307-9d01-ced02aa163f9" width="700"/>| </br> # ๐Ÿš€ ํŠธ๋Ÿฌ๋ธ” ์ŠˆํŒ… ## 1๏ธโƒฃ MVVM ์•„ํ‚คํ…์ฒ˜ ์„ค๊ณ„ ### ๐Ÿ” ๋ฌธ์ œ์  ![](https://hackmd.io/_uploads/B17nL66Hn.png) ์ดˆ๊ธฐ ๊ฐ์ฒด ๊ด€๊ณ„๋ฅผ ์„ค๊ณ„๋„๋ฅผ ์ฐธ๊ณ ํ•˜์—ฌ ์ฝ”๋“œ๋ฅผ ์ž‘์„ฑํ•œ ๊ฒฐ๊ณผ ๋ฐœ์ƒํ•œ ๋ฌธ์ œ๋Š” ์•„๋ž˜์™€ ๊ฐ™์Šต๋‹ˆ๋‹ค. 1. ๋ณด์ผ๋Ÿฌ ํ”Œ๋ ˆ์ดํŠธ ์ฝ”๋“œ๊ฐ€ ๋งŽ์Œ(Todo / Doing / Done List๋ฅผ ๊ด€๋ฆฌํ•˜๋Š” ViewController) 2. ViewModel๊ณผ ViewController์˜ ์—ญํ• ์˜ ๋ถ€์ ์ ˆํ•œ ์„ค๊ณ„ * DiffableDataSource, Snapshot์˜ ๊ด€๋ฆฌ๋ฅผ ViewModel์ด ํ•˜๊ณ  ์žˆ๋˜ ๋ฌธ์ œ * Todo/Doing/Done List ๊ฐ๊ฐ์˜ ํ™•์žฅ์„ฑ์„ ๊ณ ๋ คํ•˜์ง€ ์•Š์•˜๋˜ ๋ฌธ์ œ 3. Protocol์„ ํ†ตํ•œ ์ถ”์ƒํ™”์— ๋”ฐ๋ผ ์ฝ”๋“œ๊ฐ€ ๋” ๋ณต์žกํ•œ depth / ๊ด€๊ณ„๋ฅผ ๊ฐ€์ ธ ๊ฐ€๋…์„ฑ / ๊ฐ์ฒด๊ด€๊ณ„ ํŒŒ์•…์ด ์–ด๋ ค์›€ 4. List๋ฅผ ๊ตฌํ˜„ํ•  ๋ทฐ๋ฅผ TableView์—์„œ CollectionView๋กœ ์„ค๊ณ„ ์ˆ˜์ • ์—†์ด ๋ณ€๊ฒฝํ•œ ๋ฌธ์ œ </br> ### โš’๏ธ ํ•ด๊ฒฐ๋ฐฉ์•ˆ * MVVM ์•„ํ‚คํ…์ฒ˜์— ๋Œ€ํ•œ ์ถ”๊ฐ€ ์ž๋ฃŒ ์กฐ์‚ฌ ๋ฐ ํ•™์Šต * Class Diagram ์žฌ์„ค๊ณ„ ![](https://hackmd.io/_uploads/BJNgOhaH2.jpg) ![](https://hackmd.io/_uploads/HJA763TS2.jpg) **์ ์šฉ ๊ฒฐ๊ณผ** 1. CollectionView๋ฅผ ๊ด€๋ฆฌํ•˜๋Š” ๋ทฐ์ปจํŠธ๋กค๋Ÿฌ๋ฅผ ํ•˜๋‚˜์˜ ํƒ€์ž…(TaskCollectionViewController)์œผ๋กœ ๊ฐœ์„ ํ•˜์—ฌ ํ•ด๊ฒฐ 2. DiffableDataSource์™€ Snapshot ๊ฐ์ฒด์˜ ์œ„์น˜๋ฅผ TaskCollectionViewController ๋‚ด๋ถ€๋กœ ๋ณ€๊ฒฝ 3. ๋ณต์žกํ•œ ๊ด€๊ณ„์„ฑ์„ ์ตœ์†Œํ™”ํ•˜๋Š” ๋ฐฉํ–ฅ์œผ๋กœ Protocol ์ถ”์ƒํ™” ์„ค๊ณ„(TaskListViewModel Protocol ์ •์˜, ์ ์ ˆํ•œ Delegation ํŒจํ„ด์„ ์œ„ํ•œ Protocol ์ •์˜) 4. UML ์žฌ์„ค๊ณ„ ํ›„ CollectionView ์‚ฌ์šฉ์„ ๋ช…์‹œ </br> ### โš’๏ธ ์ถ”๊ฐ€ ๊ฐœ์„ ์•ˆ ![](https://hackmd.io/_uploads/BJFSpXDI3.jpg) ์ด๋ฒคํŠธ ์ŠคํŠธ๋ฆผ์„ ๊ด€๋ฆฌ๊ฐ€ ํ•„์š”ํ•œ ๋ทฐ ๋ชจ๋ธ์— Input / Output์„ ์ •์˜ํ•˜์—ฌ ViewController์—์„œ๋งŒ ๊ตฌ๋…์ด ์ผ์–ด๋‚˜๋„๋ก ์„ค๊ณ„ **์ ์šฉ ๊ฒฐ๊ณผ** 1. `@Published` attribute๋ฅผ ์ œ๊ฑฐํ•˜์—ฌ planList fetch ์ดํ›„ ๋ถˆํ•„์š”ํ•œ filter-distribute ๊ณผ์ •์ด ์—†๋„๋ก ๊ฐœ์„  2. view model์˜ `transform(input:)` ๋ฉ”์„œ๋“œ๋ฅผ ํ™œ์šฉํ•˜์—ฌ ViewController์—์„œ๋งŒ ๊ตฌ๋… ์ง„ํ–‰ 3. DiffableDataSource์— Snapshot apply ์‹œ, ๋ถˆํ•„์š”ํ•œ reload๊ฐ€ ์—†๋„๋ก ๊ฐœ์„  </br> ## 2๏ธโƒฃ ์š”๊ตฌ์‚ฌํ•ญ์— ๋งž๋Š” ๋ ˆ์ด์•„์›ƒ ์„ค๊ณ„. ํ•œ ํ™”๋ฉด์— 3๊ฐœ์˜ ํ…Œ์ด๋ธ” ๋ทฐ๊ฐ€ ๋ณด์—ฌ์ง€๋Š” ํ™”๋ฉด์„ ๊ตฌํ˜„ํ•˜๋Š” ์š”๊ตฌ์‚ฌํ•ญ์ด ์กด์žฌํ–ˆ์Šต๋‹ˆ๋‹ค. ์ดˆ๊ธฐ์—๋Š” ํ•˜๋‚˜์˜ ์ปฌ๋ ‰์…˜ ๋ทฐ์—์„œ ์ปดํฌ์ง€์…”๋„ ๋ ˆ์ด์•„์›ƒ์„ ์‚ฌ์šฉํ•œ๋‹ค๋ฉด ๊ฐ๊ฐ์˜ ํ™”๋ฉด์— ์ ‘๊ทผ์ด ์‰ฌ์›Œ์ง„๋‹ค๊ณ  ์ƒ๊ฐํ•˜์—ฌ ์ด ๋ฐฉ๋ฒ•์„ ์ฑ„ํƒํ–ˆ์Šต๋‹ˆ๋‹ค. </br> ### ๐Ÿ” ๋ฌธ์ œ์  ํ•˜์ง€๋งŒ ์ด๋ฅผ ๊ตฌํ˜„ํ•˜๋ฉด์„œ ๋‹ค์Œ์˜ ๋ฌธ์ œ๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค. - section header๊ฐ€ ์…€ ๋‚ด์šฉ๊ณผ ๊ฒน์ณ๋ณด์ด๋Š” ๋ฌธ์ œ. - ๊ฐ๊ฐ์˜ ์„น์…˜์„ listConfiguration์„ ์‚ฌ์šฉํ•˜์—ฌ ๊ตฌ์„ฑํ–ˆ์„ ๋•Œ section์˜ ํฌ๊ธฐ๋ฅผ ์žก์ง€ ๋ชปํ•˜๋Š” ๋ฌธ์ œ. - ์ด๋ฅผ ํ•ด๊ฒฐํ•˜๊ธฐ ์œ„ํ•ด ๊ฐ๊ฐ์˜ ์„น์…˜์„ listํ˜•์‹์ด ์•„๋‹ˆ๋ผ compositionalLayout์œผ๋กœ ๊ตฌ์„ฑํ–ˆ์ง€๋งŒ trailingSwipeActionsConfigurationProvider์„ ์ถ”๊ฐ€ํ•˜์ง€ ๋ชปํ•˜๋Š” ๋ฌธ์ œ๊ฐ€ ๋˜ ๋ฐœ์ƒํ•จ. - deleteํ•˜๋Š” ๊ฒƒ์ฒ˜๋Ÿผ ๋ณด์—ฌ์ง€๋Š” ์Šคํฌ๋กค๋ทฐ๋ฅผ ๋งŒ๋“ค ์ˆ˜๋Š” ์žˆ์œผ๋‚˜ ์• ํ”Œ์—์„œ ์ง€์›ํ•˜๋Š” SwipeAction๊ธฐ๋Šฅ์„ ์‚ฌ์šฉํ•˜์ง€ ์•Š๊ณ  ์žˆ๊ธฐ์— ์ด๋•Œ๋ถ€ํ„ฐ ์„ค๊ณ„๊ฐ€ ์ž˜๋ชป๋˜์—ˆ๋‹ค๊ณ  ์ƒ๊ฐ. - ํ•˜๋‚˜์˜ ๋ทฐ๋ชจ๋ธ์—์„œ ๋ชจ๋“  ๊ธฐ๋Šฅ์„ ๊ด€๋ฆฌํ•˜๊ธฐ์— ํ™•์žฅ์„ฑ์ด ๋‚ฎ์•„์ง€๋Š” ๋ฌธ์ œ. <br/> ```swift private func collectionViewLayout() -> UICollectionViewLayout { let layoutConfiguration = UICollectionViewCompositionalLayoutConfiguration() layoutConfiguration.scrollDirection = .horizontal let layout = UICollectionViewCompositionalLayout(sectionProvider: { sectionIndex, _ in let itemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .fractionalWidth(0.2)) let item = NSCollectionLayoutItem(layoutSize: itemSize) let groupSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1/3), heightDimension: .fractionalHeight(1)) let group = NSCollectionLayoutGroup.vertical(layoutSize: groupSize, subitems: [item]) group.edgeSpacing = NSCollectionLayoutEdgeSpacing(leading: nil, top: .flexible(50), trailing: nil, bottom: nil) let section = NSCollectionLayoutSection(group: group) section.orthogonalScrollingBehavior = .continuous // ํ—ค๋” ์„ค์ •. // deleteAction ์ปค์Šคํ…€ ๊ตฌํ˜„ํ•ด์•ผ ํ•จ. return section }, configuration: layoutConfiguration) return layout } ``` </br> ### โš’๏ธ ํ•ด๊ฒฐ๋ฐฉ์•ˆ ์œ„ ๋ฌธ์ œ๋“ค์„ ๊ณ ๋ คํ–ˆ์„ ๋•Œ ํ•˜๋‚˜์˜ ์ปฌ๋ ‰์…˜๋ทฐ์—์„œ ๊ด€๋ฆฌํ•˜๊ธฐ ๋ณด๋‹ค๋Š” 3๊ฐœ์˜ ์ปฌ๋ ‰์…˜ ๋ทฐ๋ฅผ ๋งŒ๋“œ๋Š” ๊ฒƒ์ด ์ข‹๋‹ค๊ณ  ์ƒ๊ฐํ•˜์—ฌ ์ด ๋ฐฉ๋ฒ•์œผ๋กœ ๋ณ€๊ฒฝํ–ˆ์Šต๋‹ˆ๋‹ค. ๋ณ€๊ฒฝ์ดํ›„ ๋‹ค์Œ์˜ ์žฅ์ ์ด ์žˆ์—ˆ์Šต๋‹ˆ๋‹ค. - ํ™•์žฅ์„ฑ์„ ์ฆ๊ฐ€์‹œํ‚ค๊ณ  ์œ ์ง€๋ณด์ˆ˜ ์šฉ์ดํ•˜๊ฒŒ ๋งŒ๋“ค์–ด ๊ฐœ๋ฐฉํ์‡„์›์น™ ์ค€์ˆ˜. - ๊ฐ๊ฐ์˜ ๋ทฐ๊ฐ€ ๋ทฐ๋ชจ๋ธ์— 1:1๊ด€๊ณ„๋ฅผ ๋งบ๊ณ ์žˆ๊ธฐ ๋•Œ๋ฌธ์— ๋‹จ์ผ์ฑ…์ž„์›์น™์„ ์ค€์ˆ˜. - ๋ณต์žกํ•œ 1๊ฐœ์˜ ๋ ˆ์ด์•„์›ƒ์—์„œ ๊ฐ„๋‹จํ•œ 3๊ฐœ์˜ ๋ ˆ์ด์•„์›ƒ ๊ตฌ์„ฑํ•˜์—ฌ ๊ฐ€๋…์„ฑ ์ฆ๊ฐ€. <br/> ```swift private func collectionViewLayout() -> UICollectionViewLayout { let layout = UICollectionViewCompositionalLayout { _, layoutEnvironment in var config = UICollectionLayoutListConfiguration(appearance: .grouped) // ํ—ค๋” ์„ค์ • // ์• ํ”Œ์ด ์ ์šฉํ•˜๋Š” UIContextualAction์‚ฌ์šฉํ•˜์—ฌ Delete ๊ตฌํ˜„. let section = NSCollectionLayoutSection.list(using: config, layoutEnvironment: layoutEnvironment) section.interGroupSpacing = 10 return section } return layout } ``` </br> ## 3๏ธโƒฃ ์—ฌ๋Ÿฌ๊ฐœ์˜ ์•„์ดํ…œ์ด ๋ฆฌ๋กœ๋“œ ๋˜๋Š” ๋ฌธ์ œ ### ๐Ÿ” ๋ฌธ์ œ์  ์ปฌ๋ ‰์…˜ ๋ทฐ์˜ ๋ฐ์ดํ„ฐ์†Œ์Šค๋กœ ์กด์žฌํ•˜๋Š” ๋ฐฐ์—ด์ด ๋ณ€๊ฒฝ๋  ๋•Œ๋งˆ๋‹ค ์Šค๋ƒ…์ƒท์—์„œ ๊ทธ ๋ฐฐ์—ด์˜ ์ „์ฒด id๋ฅผ ๊ฐ€์ ธ์™€ `reloadItems`๋ฅผ ์‚ฌ์šฉํ•˜์—ฌ ์—…๋ฐ์ดํŠธ ํ–ˆ์Šต๋‹ˆ๋‹ค. ```swift func bind() { viewModel.$planList .sink { isUpdating, planList in if isUpdating { self.updateSnapshot(planList) } } } func updateSnapshot(planList: [Plan]) { let planIDList = planList.map { $0.id } let snapshot = Snapshot() snapshot.appendSection([.main]) snapshot.reloadItems([planIDList]) dataSource.apply(snapshot) } ``` ํ•˜์ง€๋งŒ ์œ„ ๋ฐฉ๋ฒ•์€ ์Šค๋ƒ…์ƒท์—์„œ `hashable`ํ•œ ๋ชจ๋ธ ์ž์ฒด๋ฅผ ๋“ค๊ณ ์žˆ๋Š” ๊ฒƒ์ด ์•„๋‹ˆ๋ผ `id`๋งŒ์„ ๋“ค๊ณ  ์žˆ๊ธฐ ๋•Œ๋ฌธ์— `id`์— ํ•ด๋‹นํ•˜๋Š” ๋‚ด์šฉ์ด ๋ณ€๊ฒฝ๋˜๋”๋ผ๋„ `id`๋Š” ๋ณ€๊ฒฝ๋˜์ง€ ์•Š๊ธฐ ๋•Œ๋ฌธ์— ๋ฆฌ๋กœ๋“œ๋˜์ง€ ์•Š๋Š” ๋ฌธ์ œ๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค. ๊ทธ๋ž˜์„œ ๋‹ค์Œ๊ณผ ๊ฐ™์ด ๋ณ€๊ฒฝํ–ˆ์Šต๋‹ˆ๋‹ค. ```swift func updateSnapshot(planList: [Plan]) { let snapshot = dataSource.snapshot() let planIDList = planList.map { $0.id } snapshot.reloadSection([.main]) snapshot.reloadItems([planIDList]) dataSource.apply(snapshot) } ``` ํ•˜์ง€๋งŒ ์œ„ ์ฝ”๋“œ๋Š” ์…€์˜ ์—…๋ฐ์ดํŠธ๊ฐ€ ๋ฐœ์ƒํ–ˆ์„ ๋•Œ ์ „์ฒด๋ฅผ ๋ฆฌ๋กœ๋“œํ•˜๋Š” ๋ฌธ์ œ๊ฐ€ ์กด์žฌํ–ˆ๊ณ  ์‚ญ์ œ๊ธฐ๋Šฅ ๋˜ํ•œ ๋งˆ์ฐฌ๊ฐ€์ง€์˜€์Šต๋‹ˆ๋‹ค. </br> ### โš’๏ธ ํ•ด๊ฒฐ๋ฐฉ์•ˆ ### ์ƒ์„ฑ, ์ˆ˜์ •, ์‚ญ์ œ์— ๋Œ€ํ•œ ์Šค๋ƒ…์ƒท ํ•จ์ˆ˜๋ฅผ ๋ถ„๋ฆฌํ•˜์—ฌ ๋ฌธ์ œ๋ฅผ ํ•ด๊ฒฐํ–ˆ์Šต๋‹ˆ๋‹ค. `taskList`์ž์ฒด๋ฅผ ๊ตฌ๋…ํ•˜๊ณ  ์žˆ๋‹ค๋ฉด ๋””ํ…Œ์ผํ•œ ๊ธฐ๋Šฅ์„ ์ •์˜ ํ•  ์ˆ˜ ์—†์—ˆ๊ธฐ ๋•Œ๋ฌธ์— `taskList`๋ฅผ `Publisher`๋กœ ๋‘๊ณ ์žˆ๋˜ ์ฝ”๋“œ๋ฅผ ์ œ๊ฑฐํ–ˆ์Šต๋‹ˆ๋‹ค. ๋งŒ์•ฝ ์Šค๋ƒ…์ƒท์—์„œ ๊ด€๋ฆฌํ•˜๊ณ  ์žˆ๋Š” ๊ฒƒ์ด `id`๊ฐ€ ์•„๋‹Œ `Hashable`ํ•œ ๋ชจ๋ธ์ด๋ผ๋ฉด `dataSource`์— ์ „๋ถ€ ๊ฐ€์ ธ์™€์„œ `apply`๋งŒ ํ˜ธ์ถœํ–ˆ์„๋•Œ `DiffableDatasource`๊ฐ€ ์ž์ฒด์ ์œผ๋กœ ์ฐจ์ด๋ฅผ ๋น„๊ตํ•˜์—ฌ ์—…๋ฐ์ดํŠธ๋ฅผ ํ•  ๊ฒƒ์ด๊ธฐ ๋•Œ๋ฌธ์— ๋””ํ…Œ์ผํ•œ ๊ธฐ๋Šฅ์„ ์ •์˜ํ•  ํ•„์š”๊ฐ€ ์—†์„ ๊ฒƒ์ž…๋‹ˆ๋‹ค. ํ•˜์ง€๋งŒ ํ˜„์žฌ `DiffableDataSource`์—๋Š” `Hashable`ํ•œ ๋ชจ๋ธ์„ ๋“ค๊ณ ๋‹ค๋‹ˆ๋Š” ๊ฒƒ์ด ์•„๋‹Œ `UUID`๋ฅผ ๋“ค๊ณ ๋‹ค๋‹ˆ๊ธฐ ๋•Œ๋ฌธ์— ๋””ํ…Œ์ผํ•œ ๊ธฐ๋Šฅ์ •์˜๊ฐ€ ํ•„์š”ํ–ˆ๊ธฐ ๋•Œ๋ฌธ์— `taskList`์˜ `Publisher`๋ฅผ ์ œ๊ฑฐํ–ˆ์Šต๋‹ˆ๋‹ค. ๊ทธ๋ฆฌ๊ณ  ์ƒ์„ฑ, ์‚ญ์ œ, ์ˆ˜์ • ์ด๋ฒคํŠธ์— ๋Œ€ํ•œ `Publisher`๋ฅผ ์ •์˜ํ–ˆ์Šต๋‹ˆ๋‹ค. ```swift protocol PlanListViewModel: AnyObject { var planList: [Plan] { get set } var planCountChanged: PassthroughSubject<Int, Never> { get } var planCreated: PassthroughSubject<Int, Never> { get } var planUpdated: PassthroughSubject<UUID, Never> { get } var planDeleted: PassthroughSubject<(Int, UUID), Never> { get } // ... } ``` ์ƒ์„ฑ, ์‚ญ์ œ, ์ˆ˜์ •์ด ์ผ์–ด๋‚ฌ์„ ๋•Œ ๊ฐ๊ฐ์˜ ์ด๋ฒคํŠธ ํผ๋ธ”๋ฆฌ์…”์— `send`๋ฅผ ํ†ตํ•ด ์—ฐ๊ด€๋œ ๊ฐ’(ex. ์‚ญ์ œ๋  id, ์—…๋ฐ์ดํŠธ ๋  id ๋“ฑ)์„ ๋ฐœํ–‰ํ•จ์œผ๋กœ์จ ์—ฐ๊ด€๋œ ์ž‘์—…์— ๋งž๊ฒŒ ๋ฐ์ดํ„ฐ์†Œ์Šค๊ฐ€ ๋ณ€๊ฒฝ๋  ์ˆ˜ ์žˆ๋„๋ก ํ–ˆ์Šต๋‹ˆ๋‹ค. ```swift // PlanCollectionViewController.swift private func bindState() { viewModel.planCreated .sink { [weak self] planListCount in self?.applyLatestSnapshot() self?.viewModel.planCountChanged.send(planListCount) } .store(in: &cancellables) viewModel.planUpdated .sink { [weak self] planID in self?.reloadItems(id: planID) } .store(in: &cancellables) viewModel.planDeleted .sink { [weak self] (planListCount, planID) in self?.deleteItems(id: planID) self?.viewModel.planCountChanged.send(planListCount) } .store(in: &cancellables) } private func applyLatestSnapshot() { let planIDList = viewModel.planList.map { $0.id } var snapshot = Snapshot() snapshot.appendSections([.main]) snapshot.appendItems(planIDList) dataSource?.apply(snapshot) } private func reloadItems(id: UUID) { guard var snapshot = dataSource?.snapshot() else { return } snapshot.reloadItems([id]) dataSource?.apply(snapshot) } private func deleteItems(id: UUID) { guard var snapshot = dataSource?.snapshot() else { return } snapshot.deleteItems([id]) dataSource?.apply(snapshot) } ``` </br> ## 4๏ธโƒฃ Event Stream์ด ๋Š์–ด์ง€๋Š” ๋ฌธ์ œ ### ๐Ÿ” ๋ฌธ์ œ์  Combine์„ ํ™œ์šฉํ•˜์—ฌ ๋ทฐ์—์„œ ๋ฐœ์ƒํ•œ Event์˜ Publisher๋ฅผ ๊ตฌ๋…ํ•  ๋•Œ, Stream์ด ViewModel ๋‚ด๋ถ€์—์„œ ๊ตฌ๋…์ด ์ด๋ฃจ์–ด์ ธ ๋‹ค์Œ๊ณผ ๊ฐ™์€ ๋ฌธ์ œ๊ฐ€ ์žˆ์—ˆ์Šต๋‹ˆ๋‹ค. 1. ๊ตฌ๋…์ด ViewController์™€ ViewModel์—์„œ ๋ชจ๋‘ ์ด๋ฃจ์–ด์ง€๊ณ  ์žˆ์–ด ์ด๋ฒคํŠธ ์ฒ˜๋ฆฌ ๊ณผ์ •์˜ ํŒŒ์•…์ด ์–ด๋ ต๋‹ค. 2. ๊ตฌ๋…์˜ ์ˆ˜๊ฐ€ ๋Š˜์–ด๋‚˜๋ฉด ์ €์žฅ๋˜๋Š” cancellable์˜ ์ธ์Šคํ„ด์Šค๊ฐ€ ๋Š˜์–ด๋‚˜ ๋ฉ”๋ชจ๋ฆฌ ์‚ฌ์šฉ๋Ÿ‰์ด ๋Š˜์–ด๋‚  ๊ฒƒ์ด๋‹ค. ```swift // ViewModel ๊ตฌํ˜„๋ถ€... struct Input { let titleTextEvent: AnyPublisher<String?, Never> let bodyTextEvent: AnyPublisher<String?, Never> } struct Output { let isEditingDone = PassthroughSubject<Bool, Error>() } private var cancellables = Set<AnyCancellable>() func transform(input: Input) -> Output { let output = Output() input.titleTextEvent .sink(receiveValue: { [weak self] title in guard let self else { return } self.title = title output.isEditingDone.send(true) }) .store(in: &cancellables) input.bodyTextEvent .sink(receiveValue: { [weak self] body in guard let self else { return } self.body = body output.isEditingDone.send(true) }) .store(in: &cancellables) return output } ``` </br> ### โš’๏ธ ํ•ด๊ฒฐ๋ฐฉ์•ˆ ViewModel์—์„œ cancellables๋ฅผ ์ œ๊ฑฐํ•˜๊ณ , operator๋ฅผ ํ†ตํ•ด ์ด๋ฒคํŠธ๋ฅผ ํ†ตํ•ด ๋ฐ›์€ publisher๋ฅผ ๊ฐ€๊ณตํ•˜์—ฌ ๊ด€๋ จ๋œ publisher๋ฅผ ํ•˜๋‚˜์˜ ์ŠคํŠธ๋ฆผ์œผ๋กœ mergeํ–ˆ์Šต๋‹ˆ๋‹ค. merge๋œ publisher๋ฅผ Output์œผ๋กœ ์ „๋‹ฌํ•ด ViewController์—์„œ๋งŒ ๊ตฌ๋…์ด 1ํšŒ๋งŒ ์ผ์–ด๋‚˜๊ฒŒ ๋˜์–ด ์—ฐ๊ฒฐ๋œ ์ŠคํŠธ๋ฆผ์„ ๊ฐ€์งˆ ์ˆ˜ ์žˆ๋„๋ก ํ•ด๊ฒฐํ–ˆ์Šต๋‹ˆ๋‹ค. ```swift struct Input { let titleTextEvent: AnyPublisher<String?, Never> let bodyTextEvent: AnyPublisher<String?, Never> let datePickerEvent: AnyPublisher<Date, Never> } struct Output { let isEditingDone: AnyPublisher<Bool, Error> } func transform(input: Input) -> Output { let titlePublisher = input.titleTextEvent .tryMap { [weak self] title in guard let title else { throw EditError.nilText } guard let self else { throw EditError.nilViewModel } self.title = title return true } let bodyPublisher = input.bodyTextEvent .tryMap { [weak self] body in guard let body else { throw EditError.nilText } guard let self else { throw EditError.nilViewModel } self.body = body return true } let datePublisher = input.datePickerEvent .tryMap { [weak self] date in guard let self else { throw EditError.nilViewModel} self.date = date return true } let isEditingDone = titlePublisher .merge(with: bodyPublisher, datePublisher) .eraseToAnyPublisher() return Output(isEditingDone: isEditingDone) } ``` # โœจ ํ•ต์‹ฌ๊ฒฝํ—˜ <details> <summary><big>โœ… Combine</big></summary> </br> ## Protocol ### Publisher ์ฑ„ํƒํ•œ ํƒ€์ž…์ด ์‹œ๊ฐ„์ด ์ง€๋‚จ์— ๋”ฐ๋ผ ์ผ๋ จ์˜ ๊ฐ’์„ ์ „์†กํ•  ์ˆ˜ ์žˆ์Œ์„ ์„ ์–ธํ•˜๋Š” ํ”„๋กœํ† ์ฝœ. ์—ฌ๋Ÿฌ๊ฐ€์ง€ ๊ตฌ๋… ๋ฉ”์„œ๋“œ๋ฅผ ํ†ตํ•ด Subscriber์— ์ž์‹ ์˜ ๋ณ€๊ฒฝ์‚ฌํ•ญ์„ ์•Œ๋ฆฐ๋‹ค. **Creating Your Own Publishers** Publisher ํ”„๋กœํ† ์ฝœ์„ ์ง์ ‘ ๊ตฌํ˜„ํ•˜๋Š” ๋Œ€์‹  Combine ํ”„๋ ˆ์ž„์›Œํฌ์—์„œ ์ œ๊ณตํ•˜๋Š” ์—ฌ๋Ÿฌ ํƒ€์ž… ์ค‘ ํ•˜๋‚˜๋ฅผ ์‚ฌ์šฉํ•˜์—ฌ ๊ณ ์œ ํ•œ Publisher๋ฅผ ๋งŒ๋“ค ์ˆ˜ ์žˆ๋‹ค. * `PassthroughSubject`์™€ ๊ฐ™์€ `Subject`์˜ ๊ตฌ์ฒด์ ์ธ ํ•˜์œ„ ํด๋ž˜์Šค๋ฅผ ์‚ฌ์šฉํ•˜์—ฌ `send(_:)` ๋ฉ”์„œ๋“œ๋ฅผ ํ˜ธ์ถœํ•ด ํ•„์š”์— ๋”ฐ๋ผ ๊ฐ’์„ ๊ฒŒ์‹œ. * `CurrentValueSubject`๋ฅผ ์‚ฌ์šฉํ•˜์—ฌ subject์˜ ๊ธฐ๋ณธ ๊ฐ’์„ ์—…๋ฐ์ดํŠธํ•  ๋•Œ๋งˆ๋‹ค ๊ฐ’์„ ๊ฒŒ์‹œ. * ์ปค์Šคํ…€ ํƒ€์ž…์˜ ์†์„ฑ์— @Published attribute๋ฅผ ์ถ”๊ฐ€ํ•˜์—ฌ ํ”„๋กœํผํ‹ฐ๋ฅผ ๊ฒŒ์‹œ. ### Subscriber Publisher๋กœ๋ถ€ํ„ฐ input์„ ์ „๋‹ฌ ๋ฐ›์„ ์ˆ˜ ์žˆ๋Š” ํƒ€์ž…์„ ์„ ์–ธํ•˜๋Š” ํ”„๋กœํ† ์ฝœ. Subscriber ์ธ์Šคํ„ด์Šค๋Š” Publisher๋กœ๋ถ€ํ„ฐ ์ŠคํŠธ๋ฆผ์˜ ์š”์†Œ๋ฅผ ์ „๋‹ฌ ๋ฐ›๋Š”๋‹ค. Subscriber ์ธ์Šคํ„ด์Šค๋Š” ๊ด€๊ณ„ ๋ณ€๊ฒฝ์„ ์„ค๋ช…ํ•˜๋Š” life cycle event์™€ ํ•จ๊ป˜ publisher๋กœ๋ถ€ํ„ฐ ์ŠคํŠธ๋ฆผ์˜ element๋ฅผ ๋ฐ›๋Š”๋‹ค. Publisher์˜ `subscribe(_:)` ๋ฉ”์„œ๋“œ๋ฅผ ํ˜ธ์ถœํ•˜์—ฌ subscriber์™€ publisher๋ฅผ ์—ฐ๊ฒฐํ•œ๋‹ค. ํ•ด๋‹น ๋ฉ”์„œ๋“œ์˜ ํ˜ธ์ถœ ์ดํ›„ publisher๋Š” subscriber์˜ `reveive(subscription:)` ๋ฉ”์„œ๋“œ๋ฅผ ํ˜ธ์ถœํ•œ๋‹ค. ์ดํ›„ publisher์—๊ฒŒ element๋ฅผ ์š”์ฒญํ•˜๊ณ  ์„ ํƒ์ ์œผ๋กœ ๊ตฌ๋…์„ ์ทจ์†Œํ•˜๋Š” ๋ฐ ์‚ฌ์šฉํ•˜๋Š” subscription ์ธ์Šคํ„ด์Šค๊ฐ€ subscriber์—๊ฒŒ ์ œ๊ณต๋œ๋‹ค. Combine์€ publisher ํƒ€์ž…์— ๋Œ€ํ•œ ์—ฐ์‚ฐ์ž๋กœ ๋‹ค์Œ subscriber๋ฅผ ์ œ๊ณตํ•œ๋‹ค. * `sink(receiveCompletion:receiveValue:)`: sink๋Š” ์™„๋ฃŒ ์‹ ํ˜ธ๋ฅผ ์ˆ˜์‹ ํ•  ๋•Œ์™€ ์ƒˆ element๋ฅผ ์ˆ˜์‹ ํ•  ๋•Œ๋งˆ๋‹ค ์ œ๊ณต๋œ ํด๋กœ์ €๋ฅผ ์‹คํ–‰ํ•œ๋‹ค. * `assign(to:on:)`: assign์€ ์ƒˆ๋กœ ๋ฐ›์€ ๊ฐ element๋ฅผ ์ง€์ •๋œ ์ธ์Šคํ„ด์Šค์˜ key path๋กœ ์‹๋ณ„๋˜๋Š” ํ”„๋กœํผํ‹ฐ์— ํ• ๋‹นํ•œ๋‹ค. </br> ### Subject ์™ธ๋ถ€ ํ˜ธ์ถœ์ž๊ฐ€ ์š”์†Œ๋ฅผ ๊ฒŒ์‹œํ•  ์ˆ˜ ์žˆ๋Š” ๋ฉ”์„œ๋“œ๋ฅผ ๋…ธ์ถœํ•˜๋Š” Publisher. `send(_:)` ๋ฅผ ํ†ตํ•ด ์ŠคํŠธ๋ฆผ์— ์–ด๋–ค ๊ฐ’์„ ์ฃผ์ž…ํ•  ์ˆ˜ ์žˆ๋‹ค. </br> ## Property Wrapper ### @Published ํ•ด๋‹น ํ”„๋กœํผํ‹ฐ ๋ž˜ํผ attribute๋กœ ํ‘œ์‹œ๋œ ํ”„๋กœํผํ‹ฐ๋Š” ํƒ€์ž…์ด publishํ•˜๊ฒŒ ๋œ๋‹ค. * ๊ฒŒ์‹œ๋œ ํ”„๋กœํผํ‹ฐ์˜ publisher์—๋Š” ํ”„๋กœํผํ‹ฐ ์ด๋ฆ„ ์•ž์— $ํ‘œ์‹œ๋ฅผ ์ถ”๊ฐ€ํ•˜์—ฌ ์ ‘๊ทผํ•  ์ˆ˜ ์žˆ๋‹ค. * @Published ํ”„๋กœํผํ‹ฐ ๋ž˜ํผ๋Š” class ํ”„๋กœํผํ‹ฐ์—๋งŒ ์ ์šฉํ•  ์ˆ˜ ์žˆ๋‹ค. </br> </details> <details> <summary><big>โœ… MVVM ํ™œ์šฉ</big></summary> </br> ## Model * ๋น„์ฆˆ๋‹ˆ์Šค ๋ฐ์ดํ„ฐ๋ฅผ ๊ฐ€์ง€๊ณ  ์žˆ๋Š” ๊ณ„์ธต. * Repository์— ๋ฐ์ดํ„ฐ๋ฅผ CRUDํ•˜๋Š” ๋กœ์ง์ด ์กด์žฌ. ## View * ๋ทฐ๋ชจ๋ธ๊ณผ ์—ฐ๊ฒฐ๋˜๋Š” ๋ฐ”์ธ๋”ฉ์ด ์กด์žฌ. * ๊ทธ ์™ธ ๋ ˆ์ด์•„์›ƒ์„ ๊ทธ๋ฆฌ๋Š” ์ฝ”๋“œ๋งŒ ์กด์žฌ. ## ViewModel * ๋ฐ์ดํ„ฐ ๋ฐ”์ธ๋”ฉ ๋Œ€์ƒ์„ ์ œ๊ณต. ๋ชจ๋ธ์„ ์ง์ ‘ ๋…ธ์ถœํ•˜๊ฑฐ๋‚˜ ํŠน์ • ๋ชจ๋ธ ๋ฉค๋ฒ„๋ฅผ ๋ž˜ํ•‘ํ•˜๋Š” ๋ฉค๋ฒ„๋ฅผ ์ œ๊ณต. * UIKit๋ฅผ importํ•˜์ง€์•Š๊ณ  ๋ทฐ์—๊ฒŒ ๋ฐ”์ธ๋”ฉํ•ด์ฃผ๋Š” ๋ชจ๋ธ๊ณผ Presentation Logic๋งŒ ์กด์žฌ. ## MVVM + Combine ![](https://hackmd.io/_uploads/Bk0XDVD8n.jpg) Combine ๋ผ์ด๋ธŒ๋Ÿฌ๋ฆฌ๋Š” ์ด๋ฒคํŠธ ์ฒ˜๋ฆฌ ์—ฐ์‚ฐ์ž๋ฅผ ๊ฒฐํ•ฉํ•˜์—ฌ ๋น„๋™๊ธฐ ์ด๋ฒคํŠธ ์ฒ˜๋ฆฌ๋ฅผ ์ปค์Šคํ„ฐ๋งˆ์ด์ง•ํ•  ์ˆ˜ ์žˆ๋Š” ๊ธฐ๋Šฅ์„ ์ œ๊ณตํ•œ๋‹ค. RxSwift ๋ผ์ด๋ธŒ๋Ÿฌ๋ฆฌ์™€ ๋™์ผํ•œ ๊ธฐ๋Šฅ์„ ์ œ๊ณตํ•˜๊ธฐ ๋•Œ๋ฌธ์— ์ด๋ฒคํŠธ ์ŠคํŠธ๋ฆผ์— ๋Œ€ํ•œ ์ดํ•ด๋ฅผ ์œ„ํ•ด ์—ฌ๋Ÿฌ ์ž๋ฃŒ๋“ค์„ ์กฐ์‚ฌํ•ด๋ณด๊ณ  ๊ฐ€์žฅ ์ดํ•ด์— ๋„์›€์ด ๋˜์—ˆ๋˜ ์ด๋ฏธ์ง€๋ฅผ ์ฒจ๋ถ€ํ–ˆ๋‹ค. MVVM์—์„œ ์ค‘์š”ํ•œ ๊ฒƒ์€ View์™€ ViewModel์˜ ๋ฐ”์ธ๋”ฉ์ด๋ฏ€๋กœ ์ŠคํŠธ๋ฆผ์— ๋Œ€ํ•œ ์ดํ•ด๊ฐ€ ํ•„์š”ํ–ˆ๋‹ค. ํ•™์Šตํ–ˆ๋˜ ๋‚ด์šฉ์—์„œ ์ค‘์š”ํ•œ ์ ์€ Stream์˜ ๊ตฌ๋…์ด ViewController์—์„œ ์ผ์–ด๋‚˜์•ผ ํ•œ๋‹ค๋Š” ๊ฒƒ์ด์—ˆ๋‹ค. View์—์„œ ๋ฐœ์ƒํ•œ ์ด๋ฒคํŠธ๋ฅผ View Model ๋‚ด๋ถ€์— cancellable์„ ์ €์žฅํ•˜์—ฌ ๊ตฌ๋…ํ•˜๊ฒŒ ๋œ๋‹ค๋ฉด stream์ด ๋Š๊ฒจ ์–ด์ƒ‰ํ•œ ์ฝ”๋“œ๊ฐ€ ๋  ์ˆ˜ ์žˆ๋‹ค. </br> </details> <details> <summary><big>โœ… Compositional Layoutํ™œ์šฉ</big></summary> </br> ## sectionProvider * Section์•ˆ์˜ ์š”์†Œ ๊ฐ„ ๊ฐ„๊ฒฉ์„ ์ฃผ๊ธฐ ์œ„ํ•ด `UICollectionViewCompositionalLayout.list`์‚ฌ์šฉ์ด ์•„๋‹Œ `sectionProvider`์ ์šฉ. ## layout.scrollDirection * ์ปฌ๋ ‰์…˜ ๋ทฐ ๋ ˆ์ด์•„์›ƒ์ด ์Šคํฌ๋กค๋˜๋Š” ์ถ•์„ ๊ฒฐ์ •ํ•˜๋Š” ์†์„ฑ. ## section.orthogonalScrollingBehavior * ํ˜„์žฌ ๋ ˆ์ด์•„์›ƒ๋ฐฉํ–ฅ์˜ ์ˆ˜์ง๋ฐฉํ–ฅ์œผ๋กœ ์Šคํฌ๋กค ์Šคํƒ€์ผ์„ ์ฃผ๋Š” ์†์„ฑ. </details> --- </br> # ๐Ÿ“š ์ฐธ๊ณ  ๋งํฌ * [๐ŸŽ Apple Docs - Combine](https://developer.apple.com/documentation/combine) * [๐ŸŽ Apple Docs - UIContextualAction](https://developer.apple.com/documentation/uikit/uicontextualaction) * [๐ŸŽ Apple Docs - associatedtype](https://docs.swift.org/swift-book/documentation/the-swift-programming-language/generics/) * [๐ŸŽ WWDC - Combine](https://developer.apple.com/videos/play/wwdc2019/722/) * [๐ŸŽ WWDC - Combine in Practice](https://developer.apple.com/videos/play/wwdc2019/721) * [๊ณฐํŠ€๊น€ - MVVM](https://www.youtube.com/watch?v=M58LqynqQHc) * [Github - Combine-MVVM](https://github.com/mcichecki/combine-mvvm) * [Github - todolist-mvvm](https://github.com/jalehman/todolist-mvvm) * [Github - CombineCocoa](https://github.com/CombineCommunity/CombineCocoa) * [Rx-MVVM์˜ ์˜ฌ๋ฐ”๋ฅธ ์‚ฌ์šฉ๋ฒ• - saebyuck_choom](https://velog.io/@dawn_dancer/iOS-Rx-MVVM%EC%9D%98-%EC%98%AC%EB%B0%94%EB%A5%B8-%EC%82%AC%EC%9A%A9%EB%B2%95-saebyuckchoom)