- 팀 구성원 : YesCoach(YesCoach), Jiss(hrjy6278), 산체스(sanches37)
- 프로젝트 기간 : 2021.10.25 ~ 2021.11.19 (4주)
- Swift UI 를 사용한 UI 구현
- MVVM 아키텍처 사용
- Core Data 를 활용한 로컬 캐시 구현
- Firebase FireStore 를 이용한 서버 DB 구현
- Repository 의 변경사항을 Delegate로 처리
-
선언형 UI인 SwiftUI를 사용하여 UI를 구현하였다. UIKit과는 다른, 새로운 SwiftUI를 공부하고, 적용하였다.
struct ProjectListView: View { @EnvironmentObject var projectListViewModel: ProjectListViewModel let type: ProjectStatus var body: some View { let projectList = projectListViewModel.filteredList(type: type) VStack { HStack { Text(type.description) .padding(.leading) Text(projectList.count.description) .foregroundColor(.white) .padding(5) .background(Circle()) Spacer() } .font(.title) .foregroundColor(.black) List { ForEach(projectList) { todo in ProjectRowView(viewModel: todo) } .onDelete { indexSet in projectListViewModel.action( .delete(indexSet: indexSet)) } } .listStyle(.plain) } .onAppear { projectListViewModel.onAppear() } } }
- SwiftUI가 MVVM과 잘 어울린다는 글을 보고 MVVM 패턴을 적용시켰다.
- View는 Model을 모르고, ViewModel에 View에 보여질 Model 데이터를 가지게 된다.
- View의 User Interaction(Input)은 열거형으로 정의하였다.
- Output은 메서드로 정의하였다.
- 각 List Row에 해당하는 ProjectRowViewModel을 구현하였다
//리스트의 뷰모델
final class ProjectListViewModel: ObservableObject{
//Input
enum Action {
case create(project: ProjectPlan)
case delete(indexSet: IndexSet)
case update(project: ProjectPlan)
}
@Published private var projectList: [ProjectRowViewModel] = []
//Repository
private let projectRepository = ProjectRepository()
func onAppear() {
projectRepository.setUp(delegate: self)
}
func action(_ action: Action) {
switch action {
case .create(let project):
projectRepository.addProject(project)
case .delete(let indexSet):
projectRepository.removeProject(indexSet: indexSet)
case .update(let project):
projectRepository.updateProject(project)
}
}
//Output
func selectedProject(from id: String?) -> ProjectRowViewModel? {
if let id = id, let index = projectList.firstIndex(where: { $0.id == id }) {
return projectList[index]
}
return nil
}
func filteredList(type: ProjectStatus) -> [ProjectRowViewModel] {
return projectList.filter {
$0.type == type
}
}
}
final class ProjectRowViewModel: Identifiable {
//Model
private var project: ProjectPlan
//Repository
private let repository = ProjectRepository()
weak var delegate: ProjectRowViewModelDelegate?
init(project: ProjectPlan) {
self.project = project
}
//Input
enum Action {
case changeType(type: ProjectStatus)
}
func action(_ action: Action) {
switch action {
case .changeType(let type):
project.type = type
repository.updateProject(project)
delegate?.updateViewModel()
}
}
//Output
var id: String {
return project.id
}
var title: String {
return project.title
}
var convertedDate: String {
return DateFormatter.convertDate(date: project.date)
}
var date: Date {
return project.date
}
var detail: String {
return project.detail
}
var type: ProjectStatus {
return project.type
}
var dateFontColor: Color {
let calendar = Calendar.current
switch project.type {
case .todo, .doing:
if calendar.compare(project.date, to: Date(), toGranularity: .day) == .orderedAscending {
return .red
} else {
return .black
}
case .done:
return .black
}
}
var transitionType: [ProjectStatus] {
return ProjectStatus.allCases.filter { $0 != project.type }
}
}
- 로컬 DB를 캐싱하고 관리하기 위해 Core Data를 프로젝트에 적용하였다.
- 코어 데이터로
Project
엔티티를 생성하고, 모델의 attribute들을 각각 추가하였다.- type 프로퍼티는 커스텀 타입이므로 이를 코어 데이터로 저장하기 위해 Integer16 타입으로 설정.
- 코어 데이터로
- 리모트 DB를 관리할 수 있도록 Firestore를 프로젝트에 적용하였다.
- 로컬 DB인지 리모트 DB인지와 관계 없이 동일한 인터페이스로 데이터에 접속할 수 있도록 하기 위해 레포지토리 패턴 도입.
- 어떤 데이터를 가져올지는 레포지토리의 몫이며 뷰 모델은 레포지토리가 보내주는 데이터만 받으면 된다.
protocol ProjectRepositoryDelegate: AnyObject {
func changeRepository(project: [ProjectPlan])
}
//레포지토리 클래스 생성
final class ProjectRepository {
weak var delegate: ProjectRepositoryDelegate?
private let firestore = FirestoreStorage()
private var projects: [ProjectPlan] = [] {
didSet {
delegate?.changeRepository(project: projects)
}
}
func setUp(delegate: ProjectRepositoryDelegate) {
self.delegate = delegate
self.projects = CoreDataStack.shared.fetch().map { project in
ProjectPlan(id: project.id, title: project.title, detail: project.detail, date: project.date, type: project.type)
}
}
func addProject(_ project: ProjectPlan) {
projects.append(project)
firestore.upload(project: project)
}
func removeProject(indexSet: IndexSet) {
let index = indexSet[indexSet.startIndex]
firestore.delete(id: projects[index].id)
projects.remove(atOffsets: indexSet)
}
func updateProject(_ project: ProjectPlan) {
projects.firstIndex { $0.id == project.id }.flatMap { projects[$0] = project }
firestore.upload(project: project)
}
}
//ViewModel
//ViewModel에서는 레포지토리를 소유하고 있다. CRUD의 기능은 레포지토리에게 시킨다.
final class ProjectListViewModel: ObservableObject{
@Published private(set) var projectList: [ProjectRowViewModel] = []
private let projectRepository = ProjectRepository()
func action(_ action: Action) {
switch action {
case .create(let project):
projectRepository.addProject(project)
case .delete(let indexSet):
projectRepository.removeProject(indexSet: indexSet)
case .update(let project):
projectRepository.updateProject(project)
}
}
-
Target Membership 관련 이슈
타겟 추가 후
SceneDelegate
에서ContentView
를 접근해야되는데 인식하지 못했음. (Can't not found ContentView)
→ View의 파일에서 Target Membership 체크하고 나서야 접근할 수 있었다.
-
네비게이션 좌측 Plus버튼을 눌러도 Sheet View 가 안뜨는 문제
// 수정 코드 .toolbar { ToolbarItem(placement: .navigationBarTrailing) { Button { isPresented.toggle() } label: { Image(systemName: "plus") }.sheet(isPresented: $isPresented) { NewTodoView() } } // isPresented 값이 true 로 바뀌고 적용을 하도록 수정함
// 이전 코드 .toolbar { ToolbarItem(placement: .navigationBarTrailing) { Button { isPresented.toggle() sheet(isPresented: $isPresented) { NewTodoView() } } label: { Image(systemName: "plus") } // 버튼 내부에서 sheet를 호출해서 문제가 됨
- Swift UI는 선언형 프로그래밍 방식이라, 모든 State에 따라 뷰가 그려져야한다. 우리의 사고방식은 기존 UIKit을 썼을때 처럼 Button 을 누르면 어떤 뷰를 띄워줘야한다는 생각에 Button의 Action안에 sheet View를 생성하였다. 이는 잘못된 방법이란걸 깨닫고 선언형 방법으로 코드를 수정하였다.
-
Published 프로퍼티가 동시에 변경되면서 뷰를 순환적으로 다시 그리는 이슈(CPU 오버헤딩)
final class ToDoListViewModel: ObservableObject{ @Published private(set) var toDoList: [Todo] = [] @Published private(set) var count: [SortType: Int] = [.toDo: 0, .doing: 0, .done: 0] func action(_ action: Action) { switch action { case .create(let todo): toDoList.append(todo) case .delete(let indexSet): toDoList.remove(atOffsets: indexSet) case .update(let todo): toDoList.firstIndex { $0.id == todo.id }.flatMap { toDoList[$0] = todo } case .changeType(let id, let type): toDoList.firstIndex { $0.id == id }.flatMap { toDoList[$0].type = type } } } func fetchList(type: SortType) -> [Todo] { let list = toDoList.filter { $0.type == type } **count[type] = list.count** return list }
→ 앱 실행시 화면이 계속 그려지는 (한무반복) 상황 발생 → CPU 100% 오버헤드 발생
Published 프로퍼티 사용에 유념. 한 메소드에서 복수의 Published 프로퍼티가 변경되면, 뷰는 계속 순환하여 그려진다.
-
모달뷰를 키보드가 가리는 문제
var body: some View { NavigationView { **GeometryReader { geo in** VStack { ScrollView { TextField("Title", text: $title) .textFieldStyle(.roundedBorder) DatePicker("Title", selection: $date, displayedComponents: .date) .datePickerStyle(.wheel) .labelsHidden() **TextEditor(text: $description)** .frame(width: geo.size.width, height: geo.size.height * 0.65, alignment: .center) } } }
- 모달뷰에 키보드 자판이 생겼을 때 TextEditor 를 가리는 문제가 발생했다.
- TextField, DatePicker, TextEditor 를 ScrollView 로 감싸서 스크롤이 되도록하려고 했는데, TextEditor 가 화면에 보이지 않았다.
- TextEditor 의 사이즈를 지정해주기 위해 GeometryReader 를 사용해서 상위뷰와 관련한 높이를 지정해줘서 스크롤시 문제가 되지 않게 하였다.
- 해결하지 못한 문제는 GeometryReader 를 ScrollView 에 넣었을 때는 높이값이 엄청 작게 나오는 문제가 발생했다.
-
Foreach는 식별된 데이터의 기본 컬렉션에서 요구에 따른 뷰를 계산하는 Structure.
foreach는 시퀀스의 각 요소에 대해 지정된 클로저를 for-in 반복문의 순서로 호출한다.
수정 전
수정 후
LongPressed 로 popover 를 띄울 때, enum ProjectStatus 타입의 todo, doing, done 중 filter 를 이용하여 LongPressed 되지 않은 타입들을 뽑아낸 뒤 forEach를 사용하여 moveButton을 호출하려고 하였는데 오류가 발생했다.
뷰를 그려주는 행위라 뷰를 그릴 때 사용하는 Foreach 를 사용하여 해결하였다.
-
Cell 클릭 후 내용 수정을 했을 때 수정이 되지 않은 이슈
struct ModalView: View { @EnvironmentObject var todoListViewModel: ProjectListViewModel @Binding var isDone: Bool ... let modalViewType: ModalType let currentProject: Project? // **#1** ..... } struct ProjectRowView: View { @EnvironmentObject var projectListViewModel: ProjectListViewModel @State private var isModalViewPresented: Bool = false @State private var isLongPressed: Bool = false **var project: Project** var body: some View { .... }.sheet(isPresented: $isModalViewPresented) { ModalView(isDone: $isModalViewPresented, modalViewType: .edit, **currentProject: project) // #2** } } -------------------------------------------------------------------------- // 수정 전 customTrailingButton extension ModalView { private var customTrailingButton: some View { Button { if modalViewType == .add { todoListViewModel.action( .create(project: Project(title: title, description: description, date: date, type: .todo))) } else if isEdit && modalViewType == .edit, let currentProject = currentProject { **todoListViewModel.action( .update(project: Project(project: currentProject))) // #3** } isDone = false } label: { Text("Done") } } } ----------------------------------------------------------------------- //수정 후 customTrailingButton extension ModalView { private var customTrailingButton: some View { Button { if modalViewType == .add { todoListViewModel.action( .create(project: Project(title: title, description: description, date: date, type: .todo))) } else if isEdit && modalViewType == .edit, let currentProject = currentProject { todoListViewModel.action( .update(project: Project(id: currentProject.id, title: title, description: description, date: date, type: currentProject.type))) // #4** } isDone = false } label: { Text("Done") } } } --------------------------------------------------------------------------- final class ProjectListViewModel: ObservableObject{ @Published private(set) var projectList: [Project] = [] func action(_ action: Action) { switch action { ...... case .update(let project): projectList.firstIndex { $0.id == project.id } .flatMap { projectList[$0] = project } // #5 }
-
ModalView 에서 Model 를 프로퍼티로 가지고 있어서 ModalView 를 호출 할 때
currentProject
를 초기화해야 된다. -
ProjectRowView 에서 ModalView 를 호출할 때
var project: Project
를 주입해주고 있기 때문에 클릭한 Cell 의 Project 를 ModalView가 알고 있다. -
수정 전 customTrailingButton 에서는 currentProject 를 viewModel 로 전달해서 값을 수정하려고 했는데 currentProject 값이 모달 화면이 뜰 때의 값이라 입력한 값을 전달해주기 위해 코드를 수정했다.
-
수정 후 title, description, date 의 값을 우리가 입력한 값으로 viewModel 에 넘겨주었다. 이때 우리가 원하는 배열의 index 를 찾기 위해
currentProject.id
를 viewModel 에 넘겨주었다. -
currentProject.id
로 index 를 찾아서 배열의 원하는 index 의 내용을 수정했다.
-
CoreData ManagedObject
을 프로젝트의 모델타입으로 사용했었는데 리모트 저장소의 데이터를 디코딩해 올 때마다 로컬 저장소에 여러번 저장되는 문제가 발생하여, 별개의 모델 타입을 생성하여 리모트저장소로부터 데이터를 받아오도록 수정하였다.이전
CoreData ManagedObject
을 사용한 코드import Foundation import CoreData @objc(Project) class Project: NSManagedObject, Codable { convenience init(id: String = UUID().uuidString, title: String, detail: String, date: Date, type: ProjectStatus) { self.init(context: CoreDataStack.shared.context) self.id = id self.title = title self.detail = detail self.date = date self.type = type } required convenience init(from decoder: Decoder) throws { self.init(context: CoreDataStack.shared.context) let container = try decoder.container(keyedBy: CodingKeys.self) self.id = try container.decode(String.self, forKey: .id) self.title = try container.decode(String.self, forKey: .title) self.detail = try container.decode(String.self, forKey: .detail) self.date = try container.decode(Date.self, forKey: .date) let status = try container.decode(Int16.self, forKey: .type) self.type = ProjectStatus(rawValue: status)! } func encode(to encoder: Encoder) throws { var container = encoder.container(keyedBy: CodingKeys.self) try container.encode(self.id, forKey: .id) try container.encode(self.title, forKey: .title) try container.encode(self.detail, forKey: .detail) try container.encode(self.date, forKey: .date) try container.encode(self.type.rawValue, forKey: .type) } enum CodingKeys: CodingKey { case id, title, detail, date, type } }
별도의 모델타입을 만든 코드
import Foundation struct ProjectPlan: Identifiable, Codable { let id: String var title: String var detail: String var date: Date var type: ProjectStatus init(id: String = UUID().uuidString, title: String, detail: String, date: Date, type: ProjectStatus) { self.id = id self.title = title self.detail = detail self.date = date self.type = type } init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) self.id = try container.decode(String.self, forKey: .id) self.title = try container.decode(String.self, forKey: .title) self.detail = try container.decode(String.self, forKey: .detail) self.date = try container.decode(Date.self, forKey: .date) let status = try container.decode(Int16.self, forKey: .type) self.type = ProjectStatus(rawValue: status)! } func encode(to encoder: Encoder) throws { var container = encoder.container(keyedBy: CodingKeys.self) try container.encode(self.id, forKey: .id) try container.encode(self.title, forKey: .title) try container.encode(self.detail, forKey: .detail) try container.encode(self.date, forKey: .date) try container.encode(self.type.rawValue, forKey: .type) } enum CodingKeys: CodingKey { case id, title, detail, date, type } } @objc public enum ProjectStatus: Int16, CustomStringConvertible, CaseIterable { case todo case doing case done public var description: String { switch self { case .todo: return "TODO" case .doing: return "DOING" case .done: return "DONE" } } }
-
로컬 과 리모트의 동기화 시점를 적용하는것에 대해 많은 시간이 있질 않아, 해보다가 포기하였다.
시퀀스 다이어 그램을 그려놓았으나, 실제로 해보니깐 이것저것 신경써야 될 게 많아 시간적 여유가 부족하였다.
-
List Cell 간격
Swift UI
의List View
를 사용했을때Cell
의 간격을 줄 수가 없었다. 이 부분을 해결하지 못했다.팀원들과 함께한 프로젝트
- 다른 캠퍼들의 얘기를 들어보면 List는 Cell간의 거리를 조절할 수 없다는 얘기가 많이 나왔다.
이에따라
Cell
간의 간격을 주려면LazyVStack
을 써볼 수 있을 것 같다.Stack
이기때문에Spacing
을 줄수 있다.
- 다른 캠퍼들의 얘기를 들어보면 List는 Cell간의 거리를 조절할 수 없다는 얘기가 많이 나왔다.
이에따라
-
할일 리스트의 갯수를 세는 부분에 연산과정이 많은 문제.
-
현재
View
에서ViewModel
에게 그릴Model
을 요청하게 된다.할일, 하는중, 완료 이 세가지
case
를 가진Enum
을 만들어 각각의List
를 뽑아 올 수 있게끔하였다.func filteredList(type: ProjectStatus) -> [Project] { return projectList.filter { $0.type == type } }
이때
filter
로 연산과정을 거쳐List
를 주게 된다.또 각각의
case
마다List
의 갯수를 알아야 해서 리스트의 갯수를 뽑아올 수 있도록 메서드를 만들었다.func todoCount(type: ProjectStatus) -> String { return projectList.filter { $0.type == type }.count.description }
이럴 경우 한
View
를 만드는데filter
과정이 두번 진행된다. 현재case
가 3개니 총filter
과정이 6번 진행된다. 연산과정이 불필요하게 많아진 느낌인데, 이를 더 줄일 수 있는 방법은 없을까하고 팀원들끼리 고민을 하였다.-
각각의
Case
마다 배열을 만들어List
마다 따로 관리한다.//각각 할일 리스트에 대한 배열을 생성함. var todoList = [Todo]() var doingList = [Todo]() var doneList = [Todo]() // CRUD를 진행할때마다 case를 비교를 해줘야함. // 효율적이라 생각하지 않음. private func createProject(_ project: Todo) { switch project.type { case .toDo: toDoList.append(project) case .doing: doingList.append(project) case .done: doneList.append(project) } }
- 이럴경우 현재 리스트의
CRUD
의 과정이 전부case
마다 비교를 해줘야 하기 때문에CRUD
코드를 전부 분기처리해야 된다는 점에서 비효율적이라 판단하여 진행하지 않았다.
- 이럴경우 현재 리스트의
-
filteredList(type:)
메서드를 사용할때 Count를 계산한다.// ViewModel final class ProjectListViewModel: ObservableObject{ @Published private(set) var projectList: [Project] = [] private(set) var count: [ProjectStatus: Int] = [.todo: 0, .doing: 0, .done: 0] ... func filteredList(type: ProjectStatus) -> [Project] { let list = projectList.filter { $0.type == type } **count[type] = list.count** return list } func todoCount(type: ProjectStatus) -> String { guard let count = count[type] else { return "0" } **return String(count)** } // View // 먼저 Count를 계산 Text(String(todoListViewModel.todoCount(type))) List { //현재 리스트의 Count를 계산하는 로직이 한템포 뒤에 있음. ForEach(todoListViewModel.fetchList(type: type)) { todo in TodoRowView(todo: todo) }
- 해당 방법이 좋은 걸로 판단되었으나 뷰에서 해당 메서드를 사용하기 전에
Count
를 계산해버리기 때문에, 갯수가 계속 한템포 느리게 반영됨.@Published
랩퍼를 이용하여View
를 바로 그리게끔 하였으나, 트러블 슈팅중 Published 프로퍼티가 동시에 변경되면서 뷰를 순환적으로 다시 그리는 이슈(CPU 오버헤딩)해당문제가 생김.
- 해당 방법이 좋은 걸로 판단되었으나 뷰에서 해당 메서드를 사용하기 전에
-
1. Swift UI
Lecture 1: Getting started with SwiftUI
SwiftUI: ToolBar Item & Toolbar Group (2021, Xcode 12, SwiftUI) - iOS Development for Beginners
2. Property Wrapper
5. MVVM
-
스위프트 UI를 사용하면 MVVM 디자인 패턴으로 앱을 제작하기 쉽다.
- 모델은 UI에 들어갈 데이터들을 담고 있다.
- 데이터를 구성하기 위해 필요한
Logic
도 담고 있다.
- 단지 화면을 나타내는
View
만 구성이 된다.import SwiftUI or UIKit
을 해서 쓰게된다. - 어떠한 경우에도
Data
에 대한 로직이 존재하면 안된다. State
도View
에 상태변화에 따라View
를 다르게 그려줄 뿐이지 이게Data
를 건드는 행위가 아니다.- 예시로 다양한
View
들의 테마가 있을때,State
를 활용한다. 해당State
에 따라View
가 바뀌게 된다. View
가 어떻게 보여야 되는지는 해당body var
만 알고 있다. 그리고 그렇게 해야된다.
- 예시로 다양한
- 모델에 변경사항이 있을때마다 해당 변경사항에 의존하는 모든
View
에Body var
를 요청해야된다. 매우 효율적인 시스템이 있어야한다.그렇지 않으면 UI전체를 지속적으로 그리게 된다.
View
를Model
에 바인딩(엮어서) 하여Model
의 변경 사항으로 인해View
가 반응하고 다시 그리도록 하는 것이다.View
와Model
사이에 인터프리터? 역활을 할 수 도 있다.Model
에서RestFul
을 요청하거나 데이터를 SQL 저장하거나 하는 로직은Model
이 복잡해지게 된다. 따라서ViewModel
은 모든 작업을 수행하고View
에게 가공된 데이터를 준다.ViewModel
은Model
의 게이트키퍼 (문지기) 역활도 하며 특히 모델을 변경할 때 모델에 대한 엑세스가 제대로 작동하는지 확인한다.
-
기본적인 데이터 흐름은 다음과 같다.
-
ViewModel은 Model의 변경사항을 지속적으로 알아 차린다. 변경 사항을 감지하면 즉시 변경된 사항 을 알리게 된다. (subscriber에게) 특정한 View를 알고 있는게 아니기 때문에 불특정 다수에게 알린다고 볼 수 있겠다. Model의 변경된 사항을 알고 싶은건 결국
subscriber(View)
가 해야 될 일이다.ViewModel
이Model
의 변경사항을Publishes
(게시) 하는걸 구독하는 건 결국View
다. 이렇게 되면Model
에서View
로 가는 데이터의 흐름은 얼추 알게 된다. -
자 그러면 View에서 발생하는 유저의 이벤트에 대해 Model이 바뀌어야 된다면 어떻게 해야 될까?
- 이벤트란 탭, 스와이프, UI탐색등등
해당 이벤트로 모델이 변경 될 것이다. 이때 ViewModel에 사용자의 이벤트를 처리하는 또 다른 책임이 추가되어 이를 처리하도록 지시한다.
- 위의 그림과 같이 흐름의 화살표를 보면 좀 더 이해가 쉽다.
-