iOS - Closure를 이용한 MVVM

이한솔·2023년 10월 6일
1

iOS 앱개발 🍏

목록 보기
21/49

MVVM 동작 흐름

  1. 사용자의 동작을 Input 전달받음
  2. Command Pattern으로 ViewModel에 명령
  3. ViewModel에서 Model에게 데이터를 요청 및 응답
  4. 응답받은 데이터를 ViewModel에서 가공 및 저장
  5. Data Binding을 통해 ViewModel 값이 변하면, View도 자동으로 업데이트

Data Binding

View -> ViewModel 바인딩과 ViewModel -> View 바인딩을 통해 쌍방향으로 소통이 가능하다.



Clock 만들기

ViewModel -> View 바인딩을 해보자!

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 closureVM = ClosureViewModel()
    
    // 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함수 호출됨
    // 6. 1초마다 값이 바뀌고 해당 내용 똑같이 진행됨
    func startTimer() {
        Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { [weak self] _ in
            self?.closureVM.checkTime()
        }
    }
    
    // 3. 초기값으로 closureLabel.text 세팅, viewModel의 didChangeTime() 정의
    func setBindings() {
        closureVM.didChangeClosureTime = { [weak self] viewModel in
            self?.closureLabel.text = viewModel.closureTime
        }
    }
    
}

ViewModel

class ClosureViewModel {
    var didChangeClosureTime: ((ClosureViewModel) -> Void)?
    
    var closureTime: String {
        didSet {
            didChangeClosureTime?(self)
        }
    }
    
    init() {
        closureTime = Clock.currentTime()
    }
    
    // 5. closureTime 값이 달라지고 didSet의 didChangeTime() 호출됨
    func checkTime() {
        closureTime = Clock.currentTime()
    }
    
    
}

참고: 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 만들기

SceneDelegate

class SceneDelegate: UIResponder, UIWindowSceneDelegate {
    
    var window: UIWindow?
    
    func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
        
        guard let windowScene = (scene as? UIWindowScene) else { return }
        window = UIWindow(frame: windowScene.coordinateSpace.bounds)
        window?.windowScene = windowScene
        
        // 의존성 주입
        let closureViewModel = ClosureViewModel()
        let viewController = ViewController(closureVM: closureViewModel)
        
        window?.rootViewController = viewController
        window?.makeKeyAndVisible()
    }
    
}

Model

struct TodoModel {
    let description: String
}

View

class ViewController: UIViewController {
    
    // MARK: - UI Component
    private let tableView: UITableView = {
        let tableView = UITableView()
        tableView.translatesAutoresizingMaskIntoConstraints = false
        return tableView
    }()
    
    private let closureButton: UIButton = {
        let button = UIButton(type: .system)
        button.setTitle("투두추가(클로저)", for: .normal)
        button.titleLabel?.font = UIFont.systemFont(ofSize: 18)
        button.translatesAutoresizingMaskIntoConstraints = false
        button.addTarget(self, action: #selector(closureButtonTapped), for: .touchUpInside)
        return button
    }()
    
    
    // MARK: - Properties
    var closureViewModel: ClosureViewModelProtocol
    
    // 0. 의존성 주입을 통해 ClosureViewModel 전달 (의존성 제거)
    init(closureVM: ClosureViewModelProtocol) {
        self.closureViewModel = closureVM
        super.init(nibName: nil, bundle: nil)
    }
    
    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
    
    // MARK: - Life Cycle
    override func viewDidLoad() {
        super.viewDidLoad()
        setUI()
        setTableView()
        setBindings()
    }
    
    // MARK: - Method
    func setUI(){
        view.backgroundColor = .systemBackground
        
        view.addSubview(tableView)
        view.addSubview(closureButton)
        
        NSLayoutConstraint.activate([
            tableView.topAnchor.constraint(equalTo: view.topAnchor),
            tableView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
            tableView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
            tableView.bottomAnchor.constraint(equalTo: view.bottomAnchor, constant: -80),
            
            closureButton.topAnchor.constraint(equalTo: tableView.bottomAnchor, constant: 8),
            closureButton.centerXAnchor.constraint(equalTo: view.centerXAnchor)
        ])
        
    }
    
    func setTableView(){
        tableView.dataSource = self
        tableView.register(UITableViewCell.self, forCellReuseIdentifier: "Cell")
    }
    
    
    // MARK: - @objc
    // 클로저이용 1. addButton을 누르면 뷰모델의 addTodo() 호출
    @objc func closureButtonTapped() {
        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?.closureViewModel.addTodo(description: newTodo)
            }
        }
        
        let cancelAction = UIAlertAction(title: "Cancel", style: .cancel, handler: nil)
        
        alert.addAction(addAction)
        alert.addAction(cancelAction)
        
        present(alert, animated: true, completion: nil)
    }
    
    
    // 4. 뷰모델의 didChangedClosureViewModel 정의
    func setBindings(){
        closureViewModel.didChangedClosureTodo = { [weak self] viewModel in
            self?.tableView.reloadData()
        }
    }
    
}


// MARK: - UITableViewDelegate
extension ViewController: UITableViewDelegate {
   // 삭제도 똑같이 진행됨
    func tableView(_ tableView: UITableView, commit editingStyle: UITableViewCell.EditingStyle, forRowAt indexPath: IndexPath) {
        if editingStyle == .delete {
            closureViewModel.removeTodo(at: indexPath.row)
        }
    }
    
}

// MARK: - UITableViewDelegate
extension ViewController: UITableViewDataSource {
    func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        return closureViewModel.todoCount
    }
    
    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
          let cell = tableView.dequeueReusableCell(withIdentifier: "Cell", for: indexPath)
          
          // 5. viewModel에서 바인딩 된 description을 textLabel의 text로 설정
          cell.textLabel?.text = closureViewModel.todoDescription(indexPath.row)

          return cell
      }
    
}

ViewModel

protocol ClosureViewModelProtocol {
    var didChangedClosureTodo: ((ClosureViewModel) -> Void)? { get set }
    var todo: [TodoModel] { get set }
    var todoCount: Int { get }
    var todoDescription: (Int) -> String? { get }
    func addTodo(description: String)
    func removeTodo(at index: Int)
    func todoDescription(at index: Int) -> String?
}

class ClosureViewModel: ClosureViewModelProtocol {
    var didChangedClosureTodo: ((ClosureViewModel) -> Void)?
    
    // 3. todo가 변하면 didChangedClosureViewModel()호출
    var todo: [TodoModel] = [] {
        didSet {
            didChangedClosureTodo?(self)
        }
    }
    
    var todoCount: Int {
        return todo.count
    }
    
    var todoDescription: (Int) -> String? {
        return { [weak self] index in
            return self?.todoDescription(at: index)
        }
    }
    
    // 2. addTodo가 호출되면 뷰모델의 todo가 변함
    func addTodo(description: String){
        todo.append(TodoModel(description: description))
    }
    
    func removeTodo(at index: Int) {
        todo.remove(at: index)
    }
    
    func todoDescription(at index: Int) -> String? {
        guard index >= 0, index < todo.count else {
            return nil
        }
        return todo[index].description
    }
    
}

0개의 댓글