Angular4 + Cordova な iOS アプリでテキストボックスの入力時に Angular のイベントが発火しない件
タイトルで全部説明しきれなかった。
- Angular + Cordova で iOS アプリを作っている。
- Angular の ReactiveForms を使って、FormControl として定義したテキストボックスに文字を入力させたい。
- 文字の入力中にリアルタイムでバリデーションチェックを行い、連動して画面にエラーメッセージを出したりしたい。
- テキストボックスに入力された文字をフォーム送信したい。
という状況で、以下のような不具合が起きた。
- iOS の「かな入力」モードのソフトウェアキーボードを使っている時に、
- テキストボックスに入力した文字を確定する前にフォーカスアウトしてしまうと、
- FormControl が入力中の文字列を認識できなくなる。
確定せずにフォーカスアウトすると、テキストボックスには文字列が入っているのに、FormControl の value としては取得できない、という状態になるのだ。ユーザとしては正しい値を入れたのに、確定キーを押さずにフォーカスアウトしただけで「何も入れていません」的な扱いになるので、これは直したい。
まずはテキストボックスの実装
まずは問題が発生する画面のサンプルを。
ReactiveForms を使ったテキストボックスがある画面は、以下のような実装になっている。
@Component({ /* 省略 */})
export class MyComponent {
/** フォーム */
public myForm: FormGroup;
/** コンストラクタでフォームを生成する */
constructor(private formBuilder: FormBuilder) {
this.myForm = this.formBuilder.group({
userId: ['', [Validators.required, Validators.pattern('^[0-9]+$')]]
});
}
}
HTML 側はこんな感じ。
<form [formGroup]="myForm" novalidate>
<p>ユーザ ID を入力してください。</p>
<p><input type="text" formControlName="userId"></p>
<!-- 入力値のフィードバックメッセージをリアルタイムに表示したい -->
<p>{{ myForm.get('userId').touched && myForm.get('userId').errors ? '入力エラーがあります' : '正常値です' }}</p>
<!-- あとは送信ボタンがあったり… -->
このままだと上述の不具合が起こる。
原因は…
普通に PC のブラウザで触っている時は、未変換の文字を残してフォーカスアウトしたりしても問題ないのだが、どうも iOS の「かな入力」キーボードの時の IME の挙動が Angular のイベント管理部分と相性が悪いようだ。iOS の「かな入力」時の IME まではさすがに Angular も対応していなかったようで、Cordova アプリ化してみて初めて遭遇する問題だったようだ。残念ながら「Angular + Cordova + iOS + かな入力キーボード」という環境を満たす不具合に遭遇した例がなく、自分で直すしかなかった。
色々要素の状態を調べてみると、実際の DOM 要素の value 属性値には、フォーカスアウト時に未確定だった文字列も入力されていた。つまり Angular の中の世界でだけ値が取得できていなかったワケである。そこで、生の DOM 要素から value 属性値を取得して、FormControl に強制的に再設定させれば、Angular の世界でもきちんと反映されるはずと考え、以下のようなディレクティブを作った。
FormControl 値強制再反映ディレクティブ
import { Directive, ElementRef, HostListener, Input } from '@angular/core';
import { FormControl } from '@angular/forms';
/**
* FormControl 値強制再反映ディレクティブ
*/
@Directive({
selector: '[forceUpdateFormControl]'
})
export class ForceUpdateFormControlDirective {
/** 操作対象の FormControl を指定する */
@Input()
target: FormControl;
/**
* コンストラクタ
*
* @param el 実際の DOM 要素を参照する
*/
constructor(protected el: ElementRef) {}
/** blur イベント時の処理 */
@HostListener('blur')
onBlur(): void {
this.forceUpdateValue();
}
/** keyup イベント時の処理 */
@HostListener('keyup')
onKeyup(): void {
this.forceUpdateValue();
}
/** 実際の DOM 要素から取得した value 属性値を FormControl に設定する */
private forceUpdateValue(): void {
// 対象の FormControl が指定されており DOM 要素に value プロパティが存在する場合のみ実行する
if(this.targetFormControl && this.el.nativeElement.value !== undefined) {
// DOM 要素の value 属性値を FormControl に設定する
this.targetFormControl.setValue(this.el.nativeElement.value);
// setValue() だけでは状態が変化しないので、フォームを操作したマークを付ける
this.targetFormControl.markAsDirty();
}
}
}
あとはこのディレクティブを input[type="text"]
な要素に設定する。
<p>
<input type="text" formControlName="userId"
forceUpdateFormControl [target]="myForm.get('userId')">
</p>
これで上手く行った。