AndroidのEditTextとTextInputLayoutを使ってエラーメッセージとポップアップメッセージなしのエラーアイコンを表示する

# 概要
EditTextにはsetErrorメソッドが用意されており、自分でTextViewを用意してVisible/Goneをしなくてもエラーメッセージやアイコンを表示できます。
以下のMaterialDesignのサイトにあるようなエラー挙動が標準で提供されているメソッドで簡単に実現できないか調査したのですが、微妙に無理だったので色々試しました。

<img width="763" alt="01_material_design.png" src="https://qiita-image-store.s3.amazonaws.com/0/38780/f8379a1d-ac36-1e81-43a4-87758fc0a22b.png">

https://material.io/design/components/text-fields.html#anatomy

# この記事でやりたいこと
EditTextとTextInputLayoutを使ってエラーメッセージを下に表示し、ポップアップメッセージなしのエラーアイコンを右に表示する実装をします。
以下、gifのイメージです。

![01_purpose.gif](https://qiita-image-store.s3.amazonaws.com/0/38780/585f1ede-c6a3-26bd-8402-d33df03bbc73.gif)

# 環境
  - API Level 21〜 ※1
  - SupportLibrary: ver27.1.1
  - rxbinding:2.1.1（RxJavaやRxKotlinもついでに使っています）

（※1 API Level 19でも動くには動いたのですがLogCatには例外が吐かれます。理由は一番下の補足に書きました。）

# はじめに
目的の挙動をするEditTextの話をする前にEditTextやTextInputLayoutのエラー挙動を簡単に説明します。
また、この記事だけだと全体像が見えづらいので、GitHubに作成したコードもあげておきます。  
https://github.com/hotdrop/edittextsample

## 1. EditText
EditTextが提供しているsetErrorメソッドを使うとエラーのアイコンとポップアップメッセージを表示させることができます。
なお、サンプルコードはKotlinで書いているのでsetErrorがerrorになっています。

```Kotlin
// エラーメッセージ設定
editText.error = getString(R.string.error_message_is_empty)
// エラーメッセージ解除
editText.error = null
```
エラー時の画像
<img width="255" alt="02_normal_error.png" src="https://qiita-image-store.s3.amazonaws.com/0/38780/fc912784-f5d4-98d5-8402-e47b2e77f9c5.png">

setErrorメソッドはアイコンも指定できるオーバーロードされたメソッドも提供されており、任意のアイコンをエラー時に表示することもできます。  

## 2. TextInputLayout
EditTextの上に被せてFloatingラベル、文字カウンター、エラーメッセージなど表現できるLayoutです。
レイアウトxmlファイルへの実装は以下のような感じで行います。

```xml
<android.support.design.widget.TextInputLayout
    android:layout_width="0dp"
    android:layout_height="wrap_content"
    android:layout_marginEnd="8dp"
    app:counterEnabled="true"
    app:counterMaxLength="@integer/edit_text_max_length">

    <EditText
        android:id="@+id/editText"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"/>

</android.support.design.widget.TextInputLayout>
```
このTextInputLayoutにもsetErrorメソッドが提供されており、エラーメッセージをEditTextの下部に表示できます。

```Kotlin
// エラーメッセージ設定
textInputLayout.error = getString(R.string.error_message_is_empty)
// エラーメッセージ解除
textInputLayout.error = null
```
エラー時の画像
<img width="244" alt="03_edit_field_error.png" src="https://qiita-image-store.s3.amazonaws.com/0/38780/fd22ea5e-19a5-0db9-7a4b-98c48b843735.png">

  TextInputLayoutではアイコンを出すことはできませんが、EditTextのerrorと組み合わせれば目的のものができそうです。

## 3. EditTextとTextInputLayoutのsetErrorを組み合わせる
両方を組み合わせるのでこうなります。解除はこれまで通りnull入れるだけなので省略しています。

```Kotlin
// エラーメッセージ設定
editText.error = getString(R.string.error_message_is_empty)
textInputLayout.error = getString(R.string.error_message_is_empty)
```
エラー時の画像
<img width="253" alt="04_edit_field_and_icon_error.png" src="https://qiita-image-store.s3.amazonaws.com/0/38780/6db2bdcc-1229-b760-4c03-61c2c5a03897.png">

組み合わせただけなのでEditTextのアイコンがメッセージをポップアップ表示してしまいます。
私はこれが邪魔で消したかったのですがどうも調べた限りでは引数や用意されているメソッドでは解決できそうにありませんでした。

## 4. EditTextのエラーアイコンのポップアップメッセージを消す
StackOverflowで同じことをしたい人たちの質問がいくつかあったためそれを参考に考えましたが、結局、EditTextをextendsした自作EditTextを作成するしかなさそうです。  
実装したコードは以下の通りです。

```Kotlin
class ExEditText constructor(
      context: Context,
      attributeSet: AttributeSet
): EditText(context, attributeSet) {

  override fun setError(error: CharSequence?, icon: Drawable?) {

      // これerrorをwhenで評価してもよかったのですが、真ん中にContextCompat.getDrawableの処理がくるのがちょっと気持ち悪かったのでifにしています。
      // いや、でも記事見直しててwhenでいい気もしてきたな・・
      if (error == null) {
          super.setError(error, icon)
          setCompoundDrawables(null, null, null, null)
      }

      if (error != ONLY_ERROR_ICON) {
          super.setError(error, icon)
          return
      }

      ContextCompat.getDrawable(context, Resources.getSystem().getIdentifier("indicator_input_error", "drawable", "android"))?.let {
          it.setBounds(0, 0, it.intrinsicWidth, it.intrinsicHeight)
          setCompoundDrawables(null, null, it, null)
      } ?: setError(null, null)
  }

  companion object {
      const val ONLY_ERROR_ICON = "ONLY_ERROR_ICON"
  }
}
```
短いので処理のまとまりでコードを解説します。

  1. 最初のnull判定処理

    ```java
    if (error == null) {
        super.setError(error, icon)
        setCompoundDrawables(null, null, null, null)
    }
    ```
    errorは画面に表示されるエラーメッセージ文字列です。元のEditTextのsetErrorメソッドの挙動を極力崩さないようerrorにnullが入ってきた場合はエラーメッセージとアイコン表示を解除しています。
    setCompoundDrawablesは第一引数からleft, top, right, bottomとなっており指定した位置にアイコンを設定します。全部nullを指定して解除します。
    また、super.setErrorのiconをnullにすればsetCompoundDrawablesでわざわざnull初期化する必要ないのでは？と最初思ったのですが自分でsetCompoundDrawablesを設定しているので解除時も自分でsetCompoundDrawablesにnullを設定しないと解除できませんでした。  
  2. 次のONLY_ERROR_ICON判定処理

    ```java
    if (error != ONLY_ERROR_ICON) {
        super.setError(error, icon)
        return
    }
    ```
    ここがちょっと迷った処理です。エラーアイコンを表示するにはnull以外のエラーメッセージを指定する必要があります。
    アイコンしか表示しないためメッセージは表示に使わず、null以外であればなんでも良いのです。
    なんでも良いとはいえ、空(Empty)や使わないメッセージをわざわざ設定するもの嫌だったし、万が一アイコンのポップアップ表示を使いたい要望が出てこないとも限りません。
    そのため、指定した文字列が設定された場合に限りアイコンのみ表示するようconstでその文字列を指定し、それ以外のエラーメッセージがきた場合は通常のsetErrorの挙動になるようにしました。  
  3. 最後のDrawable処理

    ```Kotlin
      ContextCompat.getDrawable(context, Resources.getSystem().getIdentifier("indicator_input_error", "drawable", "android"))?.let {
          it.setBounds(0, 0, it.intrinsicWidth, it.intrinsicHeight)
          setCompoundDrawables(null, null, it, null)
      } ?: setError(null, null)
    ```
    表示するアイコンの描画処理です。自身で用意したアイコンを表示しても問題ありませんが、`Material Icons`でic_errorをインポートして色指定処理をするのが面倒だったので「EditTextで表示されるのだからどっかに同じアイコンが用意されているはず」と考えsetErrorのコードを追ったところ`TextView.setError(CharSequence error)`にアイコンを指定している処理があったのでそれを真似ました。
    ただ、TextViewで使われているDrawableResは`com.android.internal`なので外側からは指定できません。
    そのため`indicator_input_error`を無理やり取っています。internalは意味があってinternalにしているはずなのでこのやり方はいいとはいえない気がします。もし自分で同じようなのが用意できるならそっちの方が安全だと思います。
  4. 拡張EditTextクラスを使ってEditTextを実装
    この拡張EditTextクラスが用意できたらlayoutに指定して処理を書くだけです。

    ```xml
    <android.support.design.widget.TextInputLayout
          android:id="@+id/textInputLayout"
          android:layout_width="0dp"
          android:layout_height="wrap_content">

          <jp.hotdrop.edittextsample.component.ExEditText
              android:id="@+id/exEditText"
              android:layout_width="match_parent"
              android:layout_height="wrap_content"/>

    </android.support.design.widget.TextInputLayout>
    ```

    ```Kotlin
    // エラーメッセージ設定
    textInputLayout.error = getString(resId)
    editText.setError(ExEditText.ONLY_ERROR_ICON, null)
    ```
    エラーの画像
    こんな感じにポップアップ表示がなくなりました。
    <img width="252" alt="05_ex_edit_error.png" src="https://qiita-image-store.s3.amazonaws.com/0/38780/cbcf5d39-0e74-f28f-2e3d-714993a75812.png">

## 5. RxBindingでユーザーの入力を常に観測する
この記事はEditTextのエラーアイコンとメッセージが主題なので簡単な説明だけにします。
今回は入力値を即座にエラー表示するためRxBindingを使用しました。別にRxである必要はなくTextWatcherなどでも同じことができます。
入力値の判定が1つだけのサンプルは結構あるので複数のチェックをやってみます。
GitHubにあげたコードはもっと具体的なもので、ここでは説明のため省略している箇所が多々あります。

```kotlin
RxTextView.textChangeEvents(editText).
        skip(1).
        map { it.text().toString()) }.
        distinctUntilChanged().
        subscribeBy(
                onNext = {
                      // エラーメッセージ表示or解除処理
                    }
                }
        ).addTo(compositeDisposable)
```
いくつかポイントがあるので解説します。

  1. skip(1)  
    これは流れてきたデータの最初のN個をスキップする指定です。ここでは1を指定しているので1つだけスキップします。  
    画面表示時のEditTextView描画処理で1回データが流れてくるのでその対策です。  
    skipがないとsubscribeした瞬間にデータが流れてきて例えば必須チェックを行なっていたりすると  
    「XXを入力してください」  
    と画面表示された瞬間にエラーメッセージが表示されてしまいます。  
  2. distinctUntilChanged()  
    ユーザーが何か入力するたびにデータが流れてくるのは非効率な場合があります。  
    その場合、一度状態が変化したら再び別の状態に変更されないうちはデータを流さないようにする指定がこれです。  
    distinctとあるように一度状態が変化した後、その状態が続く限りはデータが流れてこないという表現の方がしっくりくるかもしれません。  
    ただ、これを闇雲につけるのはやめた方がいいです。  
    例えば通常単体で入力チェックを行うことは稀で「複数の入力フィールドを監視して全部チェックが通ったら次処理をするためのボタンがenableになる」といった挙動を実現したい場合が多いと思います。  
    そういう場合、複数の入力フィールドのObserverを生成しcombineLatestなどで結合すると思うので変化状態を常に流さないといけないところ、`distinctUntilChanged`をうっかりつけたままにして思う挙動をしない、なんてことがよくあるので注意する必要があります。

# まとめ
今回はTextInputLayoutとEditTextの両Error機能を使ってエラーメッセージの表示とアイコンのみ表示を実現しました。
RxBindingと併用すると簡単に文字入力時の即時エラー表示が可能になりとても便利です。
最初にも貼りましたが、最終的なコードと挙動はGithubにあるので参考にしていただけると嬉しいです。  

https://github.com/hotdrop/edittextsample  

<img width="398" alt="06_sample.png" src="https://qiita-image-store.s3.amazonaws.com/0/38780/41da4776-3446-53e2-9bc2-3b7adecdacb0.png">

# 補足
実は最初`minSdkVersion 19`にしてこのアプリを作成していました。実際、コード自体はAndroid4.4でも動きます。
動かしている間は分からなかったのですが、Logcatに例外が吐かれていることに気づきました。
原因はTextInputLayoutのsetErrorメソッドにメッセージ文字列を設定する処理で、例外の内容
`UnsupportedOperationException: Can't convert to color: type=0x2`
です。スタックトレースをたどると`AppCompatTextHelper`クラスの`onSetTextAppearance`メソッドの中で`getColorStateList`を呼んでおりこいつがエラーになっていました。
その付近のコードは`Build.VERSION.SDK_INT < 23`の分岐になっていてご丁寧にコメントが書いてありました。  
  > If we're running on < API 23, the text color may contain theme references so let's re-set using our own inflater

とあることから、Themeをカスタマイズすれば回避できそうです。ちょっとこの先は詳しく調べていないのでここまでにします。  

# おまけ
[MaterialDesignのtextInputLayoutのところ](https://material.io/develop/android/components/text-input-layout/)にあるSupportLibrary28から導入された`MaterialComponents`を使うと今まで微妙だったTextLayoutがいい感じになりそうです。
特に枠を作ってくれるStyleのOutlinedBoxは使ってみたいのですがまだbetaなので今後に期待です。