【RxSwift】TextFieldの初期値設定やバリデーションチェック(Scanオペレータ)をViewModelから行う【iOS/Swift】

2021年11月21日

概要

この記事では、MVVMアーキテクチャでiOSアプリを作る場合において、テキストフィールドを使う際に個人的によく使うユースケースをまとめています。

まず、テキストフィールドを使用する時に必要そうな要件は、ざっくりと以下のような感じだと思います。

  • テキストフィールドの初期値を設定
  • 入力文字列のバリデーションチェックを行い、問題なければそのまま入力を許可、異常値であれば拒否(例えば数値のみ、アルファベットのみ、10文字以下、などの入力制御)
  • ViewModel側でテキストフィールドに入力された文字列を保持する

図で表すとこんな感じです。

図:テキストフィールドの状態(View <-> ViewModel)

順番に実装方法を見ていきたいと思います。

ViewModelからViewにテキストフィールドの初期値を渡す

ここでは双方向バインディングを使用しています。理由は以下の通りです。

  • View の入力値を ViewModel 側に渡したい(通常のデータバインディング)
  • ViewModel 側で設定した初期値を View 側に渡したい

双方向バインディングを実現するためには、RxSwift の公式サンプルコードに「<->」という演算子が定義されているのでこれを使います。
このコードが書かれたファイルをそのままプロジェクトにインポートすることで使用できるようになります。

https://github.com/ReactiveX/RxSwift/blob/main/RxExample/RxExample/Operators.swift

次に、シンプルなViewとViewModelを定義して、View側のテキストフィールドとViewModel側のRelayを双方向バインディングします。

View:


class ViewController: UIViewController {

    @IBOutlet weak var textField: UITextField!
    
    private let viewModel = MainViewModel()
    private let disposeBag = DisposeBag()
    
    override func viewDidLoad() {
        super.viewDidLoad()
        // TextField と ViewModel を双方向バインディング
        // ・初期値をViewModel側から設定する
        // ・入力値のストリームをViewModel側に伝搬する
        (textField.rx.text <-> viewModel.textRelay).disposed(by: disposeBag)
    }
}


ViewModel:


class MainViewModel{
    // TextField に入力された文字列を受け取るためのRelay
    let textRelay = BehaviorRelay<String?>(value: nil)
    private let disposeBag = DisposeBag()
    
    init(){
        // 何らかの初期値を設定する
        textRelay.accept("init value")
        // このRelayを通してテキストフィールドに入力されている文字列を取得できる
        textRelay.asDriver().drive(onNext: {text in
            print(text)
        }).disposed(by: disposeBag)
    }
}

なお、UI周りのデータバインディングを実施するため、エラーを流さないように Subject ではなく Relayを使用しています。
(万が一UI周りのストリームでエラーを流してしまうと以降操作不能になることが懸念されるため)

テキストフィールドのバリデーションチェック

テキストフィールドに文字が入力されるたびに、文字列全体の正当性を判定して入力制御を実施します。
ユースケースとしては以下のような感じです。

  • 入力文字数を制御したい(例:10文字以内で入力させたい)
  • 英数字のみ入力させたい

このようなロジックはViewModel側で持たせて、View側はそのバリデーション結果に応じて「新しい文字列」or「前の文字列」のどちらかをテキストフィールドにセットする、という責務分割ができていることが理想だと考えます。

ソースコードは以下の通りです。

View:


class ViewController: UIViewController {

    @IBOutlet weak var textField: UITextField!
    
    private let viewModel = MainViewModel()
    private let disposeBag = DisposeBag()
    
    override func viewDidLoad() {
        super.viewDidLoad()
        // 双方向バインディング
        // ・初期値をViewModel側から設定する
        // ・入力値のストリームをViewModel側に伝搬する
        (textField.rx.text <-> viewModel.textRelay).disposed(by: disposeBag)
        
        // バリデーションチェック用
        textField.rx.text.orEmpty
            .scan("") { (previous, newText) -> String in
                // 新しく入力された文字列をViewModelに渡してバリデーションチェックを実施し、
                // その結果に応じて「新しい文字列(newText)」or 「前の文字列(previous)」を返してTextFieldにセット
                self.viewModel.validation(text: newText) ? newText : previous
            }
            .bind(to: textField.rx.text)
            .disposed(by: disposeBag)
    }
}

Scan オペレータの概要は以下に記載されています。
Scan

ここでは応用的な使い方をしていて、「previous」に前回入力されたテキスト、「newText」に最新テキストが格納されています。
newText の方を「ViewModel.validation」メソッドに渡してバリデーションチェックをしています。


ViewModel:


class MainViewModel{
    // TextField に入力された文字列を受け取る・初期値を送信するためのRelay
    let textRelay = BehaviorRelay<String?>(value: nil)
    private let disposeBag = DisposeBag()
    
    init(){
        // 何らかの初期値を設定する
        textRelay.accept("init value")
        // このRelayを通してテキストフィールドに入力されている文字列を取得できる
        textRelay.asDriver().drive(onNext: {text in
            print(text)
        }).disposed(by: disposeBag)
    }
    
    /// 入力値のバリデーションチェック
    func validation(text: String) -> Bool{
        // ここでは20文字以上は入力できないように制御している
        if (text.count >= 20){ return false }
        else { return true }
    }
}

参考文献

RxSwift Reverse observable aka two way binding

How to implement shouldChangeTextIn the RxSwift way