3. MVVM: Model-View-ViewModel (4)

Seoyoung Lee·2024년 2월 3일
0

Add List Screen

할 일 목록을 추가하는 화면을 만들어보자.

AddListViewController

홈 뷰컨과 같이 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
      }
}

AddListView

할일 추가 화면에 있는 UI 컴포넌트들은 다음과 같다.

  • UITextField (제목 입력)
  • UIButton (추가 버튼, 뒤로 가기 버튼)
  • IconSelector

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)

AddListViewModel

이 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)
       ...
    }
}

Tasks List Screen

이 화면은 목록 안에 있는 태스크를 보여주고, 완료 표시를 하고, 태스크를 추가하거나 삭제할 수 있는 화면이다.

TaskListViewController

이 뷰컨이 수행하는 역할은 다음과 같다.

  • TaskListViewModel 인스턴스 초기화
  • View에게 ViewModel 넘겨주기
  • 태스크 추가 화면/홈 화면으로의 내비게이션 관리
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

TaskListView는 HomeView와 유사한 컴포넌트들을 가지고 있다.

  • 태스크들의 목록을 보여주는 UITableView
  • 태스크를 삭제/추가하는 버튼
  • 태스크 목록 제목
  • 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이 데이터베이스를 호출해서 생성된 태스크 목록을 불러오고 이를 테이블 뷰에 보여준다.

TasksListViewModel

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 속성들의 역할만 정리하자면

  • hideEmptyState: 태스크들이 존재하는지 여부를 Bool값으로 리턴한다.
  • tasks: 태스크들이 담긴 배열을 방출한다. 이 태스크의 타입은 TaskModel이고 데이터베이스에서 받아오는 값이다.
  • pageTitle: 태스크 목록의 제목

이제 데이터베이스 (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)
    }
}

Add Task Screen

AddTaskViewController

초기화될 때 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
    }
}

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

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 메소드를 호출한다.


드디어 기능 개발 부분은 다 읽었다..!!!
여러 번 구현 과정을 보고, 회사에서도 개발을 하니까 확실히 혼자 공부할 때보다 이해가 훨씬 잘 된다!
다음은 이제 테스트 방법... 상반기 안에 이 책 꼭 완독해야지..👀

profile
나의 내일은 파래 🐳

0개의 댓글