할 일 목록을 추가하는 화면을 만들어보자.
홈 뷰컨과 같이 AddList 뷰컨도 ViewModel을 생성하고 이를 View에게 전달하는 작업과 화면 전환 로직만을 담당한다
class AddListViewController: UIViewController {
private var addListView: AddListView!
...
private function setupAddListView() {
let viewModel = AddListViewModel(tasksListService: TasksListService())
addListView = AddListView(viewmodel: viewmodel)
addListView.delegate = self
self.view = addListView
}
}
할일 추가 화면에 있는 UI 컴포넌트들은 다음과 같다.
bindViewToModel
메소드에서 위 컴포넌트들과 ViewModel을 바인딩해준다.
먼저 titleTextField
를 바인딩해보자.
titleTextfield
.rx.text
.map({ !($0?.isEmpty)! })
.bind(to: addListButton.rx.isEnabled)
.disposed(by: disposeBag)
titleTextfield.rx.text
.map({ $0! })
.bind(to: viewModel.input.title )
.disposed(by: disposeBag)
map
메소드를 사용해서 텍스트 필드에 값이 입력되어 있는지 여부를 방출한다.
또한 UIButton의 tap 이벤트도 ViewModel과 바인딩해준다.
addListButton.rx.tap
.bind(to: viewModel.input.addList)
.disposed(by: disposeBag)
backButton.rx.tap
.bind(to: viewModel.input.dismiss)
.disposed(by: disposeBag)
viewModel.output.dismiss
.drive(onNext: { [self] _ in
delegate?.navigateBack()
})
iconSelectorView
에는 선택한 아이콘의 이름을 방출하는 BehaviorRelay를 추가한다.
var selectedIcon = BehaviorRelay<String>(value: "checkmark.seal.fill")
그리고 selectedIcon
과 ViewModel을 바인딩해준다.
iconSelectorView.selectedIcon
.bind(to: viewModel.input.icon)
.disposed(by: disposeBag)
이 ViewModel에서는 Model과 상호작용 할 수 있게 TaskListService
에 레퍼런스를 전달하고 TaskListModel
객체를 초기화한다.
class AddListViewModel {
var output: Output!
var input: Input!
struct Input {
let icon: PublishRelay<String>
let title: PublishRelay<String>
let addList: PublishRelay<Void>
let dismiss: PublishRelay<Void>
}
struct Output {
let dismiss: Driver<Void>
}
...
}
class AddListViewModel {
...
private var tasksListService: TasksListServiceProtocol!
private(set) var list: TasksListModel!
private let dismiss = BehaviorRelay<Void>(value: ())
init(tasksListService: TasksListServiceProtocol) {
self.tasksListService = tasksListService
self.list = TasksListModel(id: ProcessInfo().globallyUniqueString, icon: "checkmark.seal.fill",
createdAt: Date())
// Inputs
let icon = PublishRelay<String>()
_ = icon.subscribe(onNext: { [self] newIcon in
list.icon = newIcon
})
let title = PublishRelay<String>()
_ = title.subscribe(onNext: { [self] newTitle in
list.title = newTitle
})
let addList = PublishRelay<Void>()
_ = addList.subscribe(onNext: { [self] _ in
tasksListService.saveTasksList(list)
dismiss.accept(())
})
let dismissView = PublishRelay<Void>()
_ = dismissView.subscribe(onNext: { [self] _ in
dismiss.accept(())
})
input = Input(icon: icon, title: title, addList: addList, dismiss: dismissView)
// Outputs
let backNavigation = dismiss.asDriver()
output = Output(dismiss: backNavigation)
...
}
}
이 화면은 목록 안에 있는 태스크를 보여주고, 완료 표시를 하고, 태스크를 추가하거나 삭제할 수 있는 화면이다.
이 뷰컨이 수행하는 역할은 다음과 같다.
class TaskListViewController: UIViewController {
private var taskListView: TaskListView!
private var tasksListModel: TasksListModel!
init(tasksListModel: TasksListModel) {
**self.tasksListModel = tasksListModel**
super.init(nibName: nil, bundle: nil)
}
...
private func setupTaskListView() {
**let viewModel = TaskListViewModel(tasksListModel: tasksListModel, taskService: TaskService(), tasksListService: TasksListService())
taskListView = TaskListView(viewModel: viewModel)
taskListView.delegate = self
self.view = taskListView**
}
}
extension TaskListViewController: TaskListViewControllerDelegate {
func addTask() {
let addTaskViewController = AddTaskViewController(tasksListModel: tasksListModel)
addTaskViewController.modalPresentationStyle = .pageSheet
present(addTaskViewController, animated: true)
}
}
extension TaskListViewController: BackButtonDelegate {
func navigateBack() {
navigationController?.popViewController(animated: true)
}
}
이 화면에서는 홈 화면에서 선택한 리스트에 있는 태스크들을 보여주기 때문에 초기화를 할 때 tasksListModel
을 전달해야 한다. 그리고 이 ViewModel은 다시 TaskListViewModel
에 전달된다.
TaskListView는 HomeView와 유사한 컴포넌트들을 가지고 있다.
그외 태스크를 수정하거나 상태를 변경할 수 있는 기능들이 포함될 수 있다.
이런 기능들을 고려하면서 먼저 UITableView와 ViewModel을 바인딩해보자!
tableView.rx
.setDelegate(self)
.disposed(by: disposeBag)
tableView.rx.itemDeleted
.bind(to: viewModel.input.deleteRow)
.disposed(by: disposeBag)
viewModel.output.tasks
.drive(tableView.rx.items(cellIdentifier: TaskCell.reuseId, cellType: TaskCell.self)) { (index, task, cell) in
cell.setParametersForTask(task, at: index)
cell.checkButton.rx.tap
.map({ IndexPath(row: cell.cellIndex, section: 0) })
.bind(to: viewModel.input.updateRow)
.disposed(by: cell.disposeBag)
}
.disposed(by: disposeBag)
addTaskButton.rx.tap
.asDriver()
.drive(onNext: { [self] in
delegate?.addTask()
})
.disposed(by: disposeBag)
backButton.rx.tap
.asDriver()
.drive(onNext: { [self] in
delegate?.navigateBack()
})
.disposed(by: disposeBag)
이 버튼들을 누르면 delegate를 통해 뷰컨의 메소드를 호출한다.
viewModel.output.pageTitle
.drive(pageTitle.rx.text)
.disposed(by: disposeBag)
viewModel.output.hideEmptyState
.drive(emptyState.rx.isHidden)
.disposed(by: disposeBag)
페이지 제목과 빈 화면 여부도 바인딩해준다.
viewModel.input.reload.accept(())
마지막으로 reload를 accept한다. accept하면 ViewModel이 데이터베이스를 호출해서 생성된 태스크 목록을 불러오고 이를 테이블 뷰에 보여준다.
class TaskListViewModel {
var output: Output!
var input: Input!
struct Input {
let reload: PublishRelay<Void>
let deleteRow: PublishRelay<IndexPath>
let updateRow: PublishRelay<IndexPath>
}
struct Output {
let hideEmptyState: Driver<Bool>
let tasks: Driver<[TaskModel]>
let pageTitle: Driver<String>
}
...
}
TaskListViewModel의 Input과 Output은 다음과 같다. 이제 이 속성들을 초기화해보자.
class TaskListViewModel {
...
init(tasksListModel: TasksListModel,
taskService: TaskServiceProtocol,
tasksListService: TasksListServiceProtocol) {
self.tasksListModel = tasksListModel
self.taskService = taskService
self.tasksListService = tasksListService
// Inputs
let reload = PublishRelay<Void>()
_ = reload.subscribe(onNext: { [self] _ in
fetchTasks()
})
let deleteRow = PublishRelay<IndexPath>()
_ = deleteRow.subscribe(onNext: { [self] indexPath in
deleteTaskAt(indexPath: indexPath)
})
let updateRow = PublishRelay<IndexPath>()
_ = updateRow.subscribe(onNext: { [self] indexPath in
updateTaskAt(indexPath: indexPath)
})
input = Input(reload: reload, deleteRow: deleteRow, updateRow: updateRow)
// Outputs
let items = tasks
.asDriver(onErrorJustReturn: [])
let hideEmptyState = tasks
.map({ items in
return !items.isEmpty
})
.asDriver(onErrorJustReturn: false)
let pageTitle = pageTitle
.asDriver(onErrorJustReturn: "")
output = Output(hideEmptyState: hideEmptyState, tasks: items, pageTitle: pageTitle)
...
}
...
}
개인적으로 ViewModel의 Output 역할이 항상 헷갈렸기 때문에 Output 속성들의 역할만 정리하자면
이제 데이터베이스 (Model)에 접근하는 메소드를 작성하자.
class TaskListViewModel {
...
private var tasksListModel: TasksListModel!
private var taskService: TaskServiceProtocol!
private var tasksListService: TasksListServiceProtocol!
let tasks = BehaviorRelay<[TaskModel]>(value: [])
let pageTitle = BehaviorRelay<String>(value: "")
init(tasksListModel: TasksListModel,
taskService: TaskServiceProtocol,
tasksListService: TasksListServiceProtocol) {
...
NotificationCenter.default.addObserver(self,
selector: #selector(contextObjectsDidChange),
name: NSNotification.Name.NSManagedObjectContextObjectsDidChange,
object: CoreDataManager.shared.mainContext)
}
@objc func contextObjectsDidChange() {
fetchTasks()
}
func fetchTasks() {
guard let list = tasksListService.fetchListWithId(tasksListModel.id) else { return }
let orderedTasks = list.tasks.sorted(by: { $0.createdAt.compare($1.createdAt) == .orderedDescending })
tasks.accept(orderedTasks)
pageTitle.accept(list.title)
}
func deleteTaskAt(indexPath: IndexPath) {
taskService.deleteTask(tasks.value[indexPath.row])
}
func updateTaskAt(indexPath: IndexPath) {
var taskToUpdate = tasks.value[indexPath.row]
taskToUpdate.done.toggle()
taskService.updateTask(taskToUpdate)
}
}
초기화될 때 tasksListModel
을 받고 AddTaskViewModel
을 생성한 후 이를 View에 전달한다.****
class AddTaskViewController: UIViewController {
private var addTaskView: AddTaskView!
private var tasksListModel: TasksListModel!
init(tasksListModel: TasksListModel) {
super.init(nibName: nil, bundle: nil)
self.tasksListModel = tasksListModel
}
...
private func setupAddTaskView() {
let viewModel = AddTaskViewModel(tasksListModel: tasksListModel, taskService: TaskService())
addTaskView = AddTaskView(viewModel: viewModel)
addTaskView.delegate = self
self.view = addTaskView
}
}
이 View의 코드는 AddListView
와 유사하다.
func bindViewToModel(_ viewModel: AddTaskViewModel) {
titleTextfield.rx.text
.map({!($0?.isEmpty)!})
.bind(to: addTaskButton.rx.isEnabled)
.disposed(by: disposeBag)
titleTextfield.rx.text
.map({ $0! })
.bind(to: viewModel.input.title )
.disposed(by: disposeBag)
addTaskButton.rx.tap
.bind(to: viewModel.input.addTask)
.disposed(by: disposeBag)
iconSelectorView.selectedIcon
.bind(to: viewModel.input.icon)
.disposed(by: disposeBag)
viewModel.output.dismiss
.skip(1)
.drive(onNext: { [self] in
delegate?.addedTask()
})
.disposed(by: disposeBag)
}
AddTaskViewModel
은 태스크를 생성한 태스크를 데이터베이스에 저장하는 역할을 담당한다.
class AddTaskViewModel {
var output: Output!
var input: Input!
struct Input {
let icon: PublishRelay<String>
let title: PublishRelay<String>
let addTask: PublishRelay<Void>
}
struct Output {
let dismiss: Driver<Void>
}
...
}
class AddTaskViewModel {
...
private var tasksListModel: TasksListModel!
private var taskService: TaskServiceProtocol!
private(set) var task: TaskModel!
let dismiss = BehaviorRelay<Void>(value: ())
init(tasksListModel: TasksListModel,
taskService: TaskServiceProtocol) {
self.tasksListModel = tasksListModel
self.taskService = taskService
self.task = TaskModel(id: ProcessInfo().globallyUniqueString,
icon: "checkmark.seal.fill",
done: false,
createdAt: Date())
// Inputs
let icon = PublishRelay<String>()
_ = icon.subscribe(onNext: { [self] newIcon in
task.icon = newIcon
})
let title = PublishRelay<String>()
_ = title.subscribe(onNext: { [self] newTitle in
task.title = newTitle
})
let addTask = PublishRelay<Void>()
_ = addTask.subscribe(onNext: { [self] _ in
taskService.saveTask(task, in: tasksListModel)
dismiss.accept(())
})
input = Input(icon: icon, title: title, addTask: addTask)
// Outputs
let dismissView = dismiss.asDriver()
output = Output(dismiss: dismissView)
}
}
input.addTask
는 데이터베이스에 새 태스크를 추가하는 메소드를 실행한 후 dismiss
메소드를 호출한다.
드디어 기능 개발 부분은 다 읽었다..!!!
여러 번 구현 과정을 보고, 회사에서도 개발을 하니까 확실히 혼자 공부할 때보다 이해가 훨씬 잘 된다!
다음은 이제 테스트 방법... 상반기 안에 이 책 꼭 완독해야지..👀