【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:
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 }
}
}







