iOS - Observable을 이용한 MVVM

이한솔·2023년 10월 8일
0

iOS 앱개발 🍏

목록 보기
22/49
post-thumbnail

Observable을 이용한 MVVM

Clock 만들기

Model

class Clock {
    static var currentTime: (() -> String) = {
        let today = Date()
        
        let hours = Calendar.current.component(.hour, from: today)
        let minutes = Calendar.current.component(.minute, from: today)
        let minStr = String(format: "%02d", minutes)
        let seconds = Calendar.current.component(.second, from: today)
        let secStr = String(format: "%02d", seconds)
        return "\(hours):\(minStr):\(secStr)"
    }
}

View

class ViewController: UIViewController {
    
    // MARK: - UI Component
    let titleLabel: UILabel = {
        let label = UILabel()
        label.text = "Clock"
        label.font = UIFont.systemFont(ofSize: 20, weight: .bold)
        label.translatesAutoresizingMaskIntoConstraints = false
        return label
    }()
    
    let closureLabel: UILabel = {
        let label = UILabel()
        label.text = "closure"
        label.translatesAutoresizingMaskIntoConstraints = false
        return label
    }()
    
    let observableLabel: UILabel = {
        let label = UILabel()
        label.text = "observable"
        label.translatesAutoresizingMaskIntoConstraints = false
        return label
    }()
    
    
    // MARK: - Properties
    // 옵저버를 이용한 바인딩 1. viewModel 생성 viewDidLoad()전이라서 메모리에만 올라간 상태
    private let observableVM = ObservableViewModel()
    
    // MARK: - Life Cycle
    override func viewDidLoad() {
        super.viewDidLoad()
        setUI()
        // 2. 바인딩 및 타이머 실행
        setBindings()
        startTimer()
    }
    
    
    // MARK: - Method
    func setUI(){
        view.backgroundColor = .systemBackground
        view.addSubview(titleLabel)
        view.addSubview(closureLabel)
        view.addSubview(observableLabel)
        
        NSLayoutConstraint.activate([
            titleLabel.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 50),
            titleLabel.centerXAnchor.constraint(equalTo: view.centerXAnchor),
            closureLabel.topAnchor.constraint(equalTo: titleLabel.bottomAnchor, constant: 50),
            closureLabel.centerXAnchor.constraint(equalTo: view.centerXAnchor),
            observableLabel.topAnchor.constraint(equalTo: closureLabel.bottomAnchor, constant: 50),
            observableLabel.centerXAnchor.constraint(equalTo: view.centerXAnchor)
        ])
    }
    
    // 4. viewModel의 checkTime함수 호출됨
    // 7. 1초마다 값이 바뀌고 해당 내용 똑같이 진행됨
    func startTimer() {
        Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { [weak self] _ in
            self?.observableVM.checkTime()
        }
    }
    
    // 3-1. 초기값으로 observableLabel.text 세팅, bind함수를 통해 listner 클로저 정의
    func setBindings() {
        observableVM.observableTime.bind { [weak self] time in
            self?.observableLabel.text = time
        }
        
    }
  
}

ViewModel

class ObservableViewModel {
    
    // 만들어 놓은 Observable 객체를 원하는 초기 값으로 생성
    var observableTime: Observable<String> = Observable(value: "Observable")
    
    // 5. Observable타입의 observableTime.value가 현재시간으로 바뀜
    func checkTime() {
        observableTime.value = Clock.currentTime()
    }
    
}

Observer

class Observable<T> {
    // 6. value값이 바뀌면 listner 클로저가 실행됨
    var value: T? {
        didSet {
            self.listner?(value)
        }
    }
    
    init(value: T?) {
        self.value = value
    }
    
    var listner: ((T?) -> Void)?
    
    
    // 메서드(bind)대신 위의 클로저(listener)를 사용해도 되지만, 코드 정리를 위해 bind란 메서드를 만들어 줌, 보통 이 형태로 사용하는게 일반적
    // 3-2. bind 실행 시, 클로저 안쪽의 동작들을 listner에 저장한다.
    func bind(_ listener: @escaping (T?) -> Void) {
        listener(value) // 생략 가능하지만 초기값을 갖기 위해 설정해줌
        self.listner = listener
    }
    
}

참고: https://ios-daniel-yang.tistory.com/59#article-2-2--closure%EB%A5%BC-%EC%9D%B4%EC%9A%A9%ED%95%98%EB%8A%94-%EB%B0%A9%EB%B2%95



TodoList 만들기

TodoModel

struct TodoModel {
    let description: String
    var isCompleted: Bool
}

MainVC

class MainViewController: UIViewController {
    
    // MARK: - Properties
    private let mainView = MainView()
    private let observableVM = ObservableViewModel()
    
    
    // MARK: - Life Cycle
    override func loadView() {
        view = mainView
    }
    
    override func viewDidLoad() {
        super.viewDidLoad()
        setAddtarget()
    }
    
    
    // MARK: - Method
    private func setAddtarget() {
        mainView.goTodoButton.addTarget(self, action: #selector(goTodoButtonTapped), for: .touchUpInside)
        mainView.goDoneButton.addTarget(self, action: #selector(goDoneButtonTapped), for: .touchUpInside)
    }
    
    
    // MARK: - @objc
    // 1-1. 뷰컨 생성 시 프로토콜 타입 뷰모델 의존성 주입
    @objc func goTodoButtonTapped() {
        let todoVC = TodoViewController(observableVM: observableVM) 
        self.navigationController?.pushViewController(todoVC, animated: true)
    }
    
    @objc func goDoneButtonTapped() {
        let doneVC = DoneViewController(viewModel: observableVM)
        self.navigationController?.pushViewController(doneVC, animated: true)
    }
    
}

TodoVC

class TodoViewController: UIViewController {
    
    // MARK: - Properties
    private let todoView = TodoView()
    private let viewModel: ObservableVMProtocol
    
    // 1-2. 의존성 주입을 통해 ClosureViewModel 전달 (의존성 제거)
    init(observableVM: ObservableVMProtocol) {
        self.viewModel = observableVM
        super.init(nibName: nil, bundle: nil)
    }
    
    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
    
    override func loadView() {
        view = todoView
    }
    
    // MARK: - Life Cycle
    override func viewDidLoad() {
        super.viewDidLoad()
        setAddTarget()
        setTableView()
        setBindings()
    }
    
    
    // MARK: - Method
    private func setAddTarget() {
        todoView.addButton.addTarget(self, action: #selector(addButtonTapped), for: .touchUpInside)
    }
    
    private func setTableView() {
        todoView.tableView.delegate = self
        todoView.tableView.dataSource = self
        todoView.tableView.register(TableViewCell.self, forCellReuseIdentifier: "customCell")
    }
    
    // 2. 뷰모델의 observableTodo.bind 정의
    // 6. Observable.value값이 변하면 실행됨
    private func setBindings(){
        viewModel.observableTodo.bind { [weak self] todo in
            self?.todoView.tableView.reloadData()
        }
    }
    
    
    // MARK: - @objc
    // 옵저버이용 3. addButton을 누르면 뷰모델의 addTodo() 호출
    @objc func addButtonTapped() {
        let alert = UIAlertController(title: "Add Todo", message: "Enter a new todo item", preferredStyle: .alert)
        
        alert.addTextField { textField in
            textField.placeholder = "Todo item"
        }
        
        let addAction = UIAlertAction(title: "Add", style: .default) { [weak self] _ in
            if let newTodo = alert.textFields?.first?.text {
                self?.viewModel.addTodo(description: newTodo, isCompleted: false)
            }
        }
        
        let cancelAction = UIAlertAction(title: "Cancel", style: .cancel, handler: nil)
        
        alert.addAction(addAction)
        alert.addAction(cancelAction)
        
        present(alert, animated: true, completion: nil)
    }
    
}

// MARK: - UITableViewDelegate
extension TodoViewController: UITableViewDelegate {
    
    func tableView(_ tableView: UITableView, commit editingStyle: UITableViewCell.EditingStyle, forRowAt indexPath: IndexPath) {
        if editingStyle == .delete {
            viewModel.removeTodo(at: indexPath.row)
        }
    }
    
}


// MARK: - UITableViewDelegate
extension TodoViewController: UITableViewDataSource {
    
    func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        return viewModel.todoCount
    }
    
    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        guard let cell = tableView.dequeueReusableCell(withIdentifier: "customCell", for: indexPath) as? TableViewCell else {
            return UITableViewCell()
        }
        
        cell.callBackMethod = { [weak self] in
            self?.viewModel.toggleTodo(at: indexPath.row)
        }
        
        cell.todoLabel.text = viewModel.todoDescription(indexPath.row)
        cell.checkButton.setImage(UIImage(systemName: viewModel.todoIsCompleted(indexPath.row)), for: .normal)
        
        return cell
    }
    
}

DoneVC

class DoneViewController: UIViewController {
    
    // MARK: - Properties
    private let doneView = DoneView()
    private let viewModel: ObservableVMProtocol
    
    init(viewModel: ObservableVMProtocol) {
        self.viewModel = viewModel
        super.init(nibName: nil, bundle: nil)
    }
    
    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
    
    
    // MARK: - Life Cycle
    override func loadView() {
        view = doneView
    }
    
    override func viewDidLoad() {
        super.viewDidLoad()
        setTableView()
        setBindings()
    }
    
    
    // MARK: - Method
    private func setTableView() {
        doneView.tableView.dataSource = self
        doneView.tableView.register(TableViewCell.self, forCellReuseIdentifier: "customCell")
    }
    
    // doneList가 변하면 didSet호출, 실행 될 함수 정의
    private func setBindings(){
        viewModel.observableDone.bind { [weak self] done in
            self?.doneView.tableView.reloadData()
        }
    }
    
}


// MARK: - UITableViewDataSource
extension DoneViewController: UITableViewDataSource {
    
    func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        return viewModel.doneCount
    }
    
    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        guard let cell = tableView.dequeueReusableCell(withIdentifier: "customCell", for: indexPath) as? TableViewCell else {
            return UITableViewCell()
        }
        
        let description = viewModel.doneDescription(indexPath.row)
        
        cell.callBackMethod = { [weak self] in
            self?.viewModel.removeDone(description: description!)
        }
        
        cell.todoLabel.text = description
        cell.checkButton.setImage(UIImage(systemName: viewModel.doneIsCompleted(indexPath.row)), for: .normal)
    
        return cell
    }
    
    
}

ObservableViewModel

// MARK: - ObservableVMProtocol
protocol ObservableVMProtocol {
    var observableTodo: Observable<[TodoModel]> { get }
    var observableDone: Observable<[TodoModel]> { get }
    var todoCount: Int { get }
    var todoDescription: (Int) -> String? { get }
    var todoIsCompleted: (Int) -> String { get }
    var doneCount: Int { get }
    var doneDescription: (Int) -> String? { get }
    var doneIsCompleted: (Int) -> String { get }
    func addTodo(description: String, isCompleted: Bool)
    func removeTodo(at index: Int)
    func toggleTodo(at index: Int)
    func removeDone(description: String)
}


// MARK: - ObservableViewModel
class ObservableViewModel: ObservableVMProtocol {
    
    var observableTodo: Observable<[TodoModel]> = Observable([])
    
    var observableDone: Observable<[TodoModel]> {
        return Observable(observableTodo.value.filter { $0.isCompleted })
    }
    
    // Todo 개수
    var todoCount: Int {
        return observableTodo.value.count
    }
    
    // Todo 내용
    var todoDescription: (Int) -> String? {
        return { [weak self] index in
            return self?.todoDescription(at: index)
        }
    }
    
    // Todo isCompleted 상태
    var todoIsCompleted: (Int) -> String {
        return { [weak self] index in
            return self?.todoCompleted(at: index) ?? "defaultImageName"
        }
    }
    
    var doneCount: Int {
        return observableDone.value.count
    }
    
    var doneDescription: (Int) -> String? {
        return { [weak self] index in
            return self?.doneDescription(at:index)}
    }
    
    var doneIsCompleted: (Int) -> String {
        return { [weak self] index in
            return self?.doneCompleted(at: index) ?? "defaultImageName"
        }
    }
    
    // 4. Observable의 value에 추가됨
    // Todo 추가
    func addTodo(description: String, isCompleted: Bool) {
        let newTodo = TodoModel(description: description, isCompleted: isCompleted)
        observableTodo.value.append(newTodo)
    }
    
    // Todo 삭제
    func removeTodo(at index: Int) {
        observableTodo.value.remove(at: index)
    }
    
    // Todo 토글
    func toggleTodo(at index: Int) {
        guard index >= 0, index < observableTodo.value.count else {
            return
        }
        observableTodo.value[index].isCompleted.toggle()
    }
    
    // DoneTodo 삭제
    func removeDone(description: String) {
        if let index = observableTodo.value.firstIndex(where: { $0.description == description && $0.isCompleted }) {
            observableTodo.value[index].isCompleted = false
        }
    }
    
    // Todo 내용
    func todoDescription(at index: Int) -> String? {
        guard index >= 0, index < observableTodo.value.count else {
            return nil
        }
        return observableTodo.value[index].description
    }
    
    // Todo isCompleted 상태
    func todoCompleted(at index: Int) -> String {
        guard index >= 0, index < observableTodo.value.count else {
            return "defaultImageName"
        }
        
        let isCompleted = observableTodo.value[index].isCompleted
        return isCompleted ? "chevron.down.circle.fill" : "chevron.down.circle"
    }
    
    // Done 내용
    func doneDescription(at index: Int) -> String? {
        guard index >= 0, index < observableDone.value.count else {
            return nil
        }
        return observableDone.value[index].description
    }
    
    // Done isCompleted 상태
    func doneCompleted(at index: Int) -> String {
        guard index >= 0, index < observableDone.value.count else {
            return "defaultImageName"
        }
        
        let isCompleted = observableDone.value[index].isCompleted
        return isCompleted ? "chevron.down.circle.fill" : "chevron.down.circle"
    }
    
    
}

Observer

class Observable<T> {

    // 5. listner 호출됨
    var value: T {
        didSet {
            self.listener?(value)
        }
    }
    
    init(_ value: T) {
        self.value = value
    }
    
    var listener: ((T) -> Void)?
    
    func bind(_ listener: @escaping (T) -> Void) {
        listener(value)
        self.listener = listener
    }
    
}
  1. TodoVC todo 추가 시
    viewModel의 addTodo() 실행 -> observer의 value가 변함 -> listen() 실행 -> TodoVC tableView reload

  2. TodoVC에서 todo 삭제 시
    viewModel의 removeTodo() 실행 -> observableTodo value 변경됨 (observableDone에도 있었다면 observableDone의 value도 변경됨) -> 각각 listen() 실행 -> 각각 tableView reload

  3. TodoVC에서 버튼 토글시
    viewModel의 toggleTodo() 실행 -> observableTodo value & observableDone의 value 변함 -> 각각 listen()실행 -> 각각 tableView reload

  4. doneVC에서 버튼 토글시
    viewModel의 removeDone() 실행 -> observableDone value 변경됨 -> listen() 실행 -> DoneVC tableView reload

0개의 댓글