Angular4 + Cordova な iOS アプリでテキストボックスの入力時に Angular のイベントが発火しない件

タイトルで全部説明しきれなかった。

という状況で、以下のような不具合が起きた。

確定せずにフォーカスアウトすると、テキストボックスには文字列が入っているのに、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>

これで上手く行った。