【RxSwift】TextFieldの初期値設定やバリデーションチェック(Scanオペレータ)をViewModelから行う【iOS/Swift】
概要
この記事では、MVVMアーキテクチャでiOSアプリを作る場合において、テキストフィールドを使う際に個人的によく使うユースケースをまとめています。
まず、テキストフィールドを使用する時に必要そうな要件は、ざっくりと以下のような感じだと思います。
- テキストフィールドの初期値を設定
- 入力文字列のバリデーションチェックを行い、問題なければそのまま入力を許可、異常値であれば拒否(例えば数値のみ、アルファベットのみ、10文字以下、などの入力制御)
- 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:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
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:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
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:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 |
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:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
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 } } } |