LoginSignup
3
1

MVVMをさらっとアウトプットしてみた(NotificationCenter編)

Last updated at Posted at 2023-02-19

はじめに

設計パターンの一つであるMVVMをアウトプットする。
また、本来であればRxSwiftなどライブラリを使用して通知や監視を行うのがオーソドックスだが、今回は学習の一環(分かりやすい)としてNotificationCenterを使用したMVVMである。

iOSにおけるMVVM

今回作るサンプルアプリについて

  • 画面上にはIDとパスワードの2つを入力するテキストフィールドが存在する

    • IDとパスワードの両方に少なくとも1文字以上入っていなければならない
    • どちらか一方でも文字入力がない場合、画面上にエラー文字列を表示する
  • 上記を踏まえて、IDとパスワードフィールドへの入力に応じてバリデーションを行い、満たされていないときはエラーを返却します。エラーの有無に応じてラベルの文字列と色が変化させる

  • イメージなどはこのサイトで見れます
    スクリーンショット 2023-02-19 22.18.29.png

ソースコード

  • Model
Swift
import Foundation

//アーキテクチャを学習する際は(おそらく)Modelからコードを定義するのが良い
//Model自身は他のコンポーネントに依存しない = ViewやViewModelがなくても「ビルド可能である」ことを表しているため

//UIに関係しないドメインロジックやそのデータを持つ(MVVMはGUIのアーキテクチャに属するため、Modelの詳細な責務分けをあまりしない)
//Modelが扱う領域の具体例
//WebAPI, データベースへのアクセス, BLEデバイス制御, 会員ステータスごとの商品の割引率の計算など多岐にわたる。

//役割
//①(今回のサンプルアプリに関して言うと)文字列のバリデーションを判定する処理を担っている

//Model
enum Result<T> {
    case success(T)
    case failure(Error)
}

enum ModelError: Error {
    case invalidId
    case invalidPassword
    case invalidIdAndPassword
}

protocol ModelProtocol {
    func validDate(idText: String?, passwordText: String?) -> Result<Void>
}

final class Model: ModelProtocol {
    func validDate(idText: String?, passwordText: String?) -> Result<Void> {
        switch (idText, passwordText) {
        case (.none, .none):
            return .failure(ModelError.invalidIdAndPassword)
        case (.none, .some):
            return .failure(ModelError.invalidId)
        case (.some, .none):
            return .failure(ModelError.invalidPassword)
        
        //オプショナルバインディング
        case (let idText?, let passwordText?):
            switch (idText.isEmpty, passwordText.isEmpty) {
            case (true, true):
                return .failure(ModelError.invalidIdAndPassword)
            case (false, false):
                return .success(())
            case (true, false):
                return .failure(ModelError.invalidId)
            case (false, true):
                return .failure(ModelError.invalidPassword)
            }
        }
    }
}
  • ViewModel
Swift
import UIKit

//Viewを持たなくともNotificationCenterでViewModelとそのロジックが独立して存在できることがポイント

//役割
//①Viewに表示するためのデータを保持する(今回のサンプルコードにはない)
//②Viewからイベントを受け取り、Modelの処理を呼び出す
//③Viewからイベントを受け取り、加工して値を更新する

//ViewModel
final class ViewModel {
    let changeText = Notification.Name("changeText")
    let changeColor = Notification.Name("changeColor")
    
    //post用(監視に通知を送る側)
    private let notificationCenter: NotificationCenter
    private let model: ModelProtocol
    
    init(notificationCenter: NotificationCenter, model: ModelProtocol = Model()) {
        self.notificationCenter = notificationCenter
        self.model = model
    }
    
    //Viewからイベントを受け取り(ViewがこのisPasswordChangeを呼ぶということ)、Modelの処理を呼び出す関数
    func idPasswordChanged(id: String?, password: String?) {
        //ViewからidPasswordChanged()が呼ばれて、modelの処理を呼び出している
        let result = model.validDate(idText: id, passwordText: password)
        
        //Modelから取得した値を元にデータを加工する
        switch result {
            //加工した結果の文字列と色はpostしてViewに通知する(それによって監視しているViewに伝わる >>> Viewの結果が更新される)
        case .success:
            notificationCenter.post(name: changeText, object: "OK")
            notificationCenter.post(name: changeColor, object: UIColor.green)
        case .failure(let error as ModelError):
            notificationCenter.post(name: changeText, object: error.errorText)
        case _:
            fatalError("Unexpected pattern")
        }
    }
}

private extension ModelError {
    var errorText: String {
        switch self {
        case .invalidIdAndPassword:
            return "IDとPasswordが未入力です"
        case .invalidId:
            return "IDが未入力です"
        case .invalidPassword:
            return "Passwordが未入力です"
        }
    }
}
  • View
Swift
import UIKit

//MVVMではViewControllerがView

//役割
//①ユーザー入力(View)からViewModelへ伝播する
//②自身(View)の状態とViewModelの状態をデータバインディングする
//③ViewModelから返されるイベント(Notificationなど)を元に描画処理を実行する

//View
class MVVMViewController: UIViewController {
    @IBOutlet private weak var idTextField: UITextField!
    @IBOutlet private weak var passwordTextFiled: UITextField!
    @IBOutlet private weak var validationLabel: UILabel!
    
    //addObserver用(受け取って処理を実行する側)
    private let notificationCenter = NotificationCenter()
    private lazy var viewModel = ViewModel(notificationCenter: notificationCenter)
    
    override func viewDidLoad() {
        super.viewDidLoad()
        idTextField.addTarget(self, action: #selector(textFieldEditingChanged), for: .editingChanged)
        passwordTextFiled.addTarget(self, action: #selector(textFieldEditingChanged), for: .editingChanged)
        notificationCenter.addObserver(self, selector: #selector(updateValidationText), name: viewModel.changeText, object: nil)
        notificationCenter.addObserver(self, selector: #selector(uodateValidationColor), name: viewModel.changeColor, object: nil)
    }
}

extension MVVMViewController {
    @objc func textFieldEditingChanged(sender: UITextField) {
        //テキストフィールドが選択されたとき、viewがViewModelの処理を呼び出す
        viewModel.idPasswordChanged(id: idTextField.text, password: passwordTextFiled.text)
    }
    
    @objc func updateValidationText(notification: Notification) {
        guard let text = notification.object as? String else { return }
        validationLabel.text = text
    }
    
    @objc func uodateValidationColor(notification: Notification) {
        guard let color = notification.object as? UIColor else { return }
        validationLabel.textColor = color
    }
}

おわりに

間違い等ございましたらコメント欄にてご指摘ください。

参考記事

開発環境

  • Xcode-13.4.1
  • Swift version 5.7
3
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
3
1