TabLayout + ViewPager + EditTextでソフトウェアキーボードからのタブ切り替わりを防ぐ

# はじめに
Androidの`TabLayout` + `ViewPager` で作られた画面上で、`EditText` を使った際、
`EditText` の文字終端部分でソフトウェアキーボードの移動キー(left/right arrow)を押すと
タブが切り替わるという現象に出会いました。
(↓見辛いですがこんな感じ)
<img src="https://qiita-image-store.s3.amazonaws.com/0/161173/656603d4-1c5e-0168-ce5a-1fc1f187a511.gif" width=200px>

- `EditText` 内は自由にカーソル移動できてほしい
- タブは切り替わってほしくない

という2つを満たす方法を今回は探してみました。

## 動作環境
この記事での動作環境および開発環境は以下となります。

- Android Studio 3.0.1
- Nexus 5X
- Android O (8.1.0)

# ベースコード
問題切り分けのために、まずは最低限のコードを用意しました。
3つのタブにそれぞれ同じレイアウト( `EditText` のみを設置)の `Fragment` を詰めます。

```kotlin:MainActivity.kt
class MainActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        val viewPager = this.findViewById(R.id.viewPager) as ViewPager
        viewPager.adapter = object : FragmentPagerAdapter(this.supportFragmentManager) {
            override fun getCount(): Int = 3

            override fun getItem(position: Int): Fragment = TabFragment()

            override fun getPageTitle(position: Int): CharSequence = "tab ${position + 1}"
        }
        val tabLayout = this.findViewById(R.id.tabLayout) as TabLayout
        tabLayout.setupWithViewPager(viewPager)
    }
}
```

```kotlin:TabFragment.kt
class TabFragment : Fragment() {
    override fun onCreateView(inflater: LayoutInflater?, container: ViewGroup?,
                              savedInstanceState: Bundle?): View? {
        return inflater?.inflate(R.layout.fragment_tab, container, false)
    }
}
```

```xml:activity_main.xml
<?xml version="1.0" encoding="utf-8"?>
<android.support.constraint.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context="com.example.takefumiota.tablayouttest.MainActivity">

    <android.support.design.widget.TabLayout
        android:id="@+id/tabLayout"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent" />

    <android.support.v4.view.ViewPager
        android:id="@+id/viewPager"
        android:layout_width="match_parent"
        android:layout_height="0dp"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toBottomOf="@+id/tabLayout" />
</android.support.constraint.ConstraintLayout>
```

```xml:fragment_tab.xml
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:orientation="vertical">

    <EditText
        android:id="@+id/editText"
        android:hint="@string/app_name"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:maxLines="3" />
</LinearLayout>
```

これを動かすと、冒頭にあったように、ソフトウェアキーボードでカーソルを移動していると、
文字終端(空文字の場合は常に)で意図せずタブが切り替わる場面に遭遇します。

# 一体何が
最初は何が起こったのか分からず震えるだけでしたが、心を落ち着けて少し調べてみました。

まず、`EditText` は `TextView` を継承しています。( `AppCompatEditText` も `TintableBackgroundView` をimplementした `EditText` なので同様)
`TextView` のKeyDownイベントの挙動として以下のようになっています。
`doKeyDown` 関数にで `KEY_EVENT_NOT_HANDLED` でなければキーイベントが回収されたことになることがわかるかと思います。
http://tools.oesf.biz/android-8.1.0_r1.0/xref/frameworks/base/core/java/android/widget/TextView.java#7093

```java:TextView.java#7093-7101
    @Override
    public boolean onKeyDown(int keyCode, KeyEvent event) {
       final int which = doKeyDown(keyCode, event, null);
       if (which == KEY_EVENT_NOT_HANDLED) {
           return super.onKeyDown(keyCode, event);
       }

       return true;
   }
```

さらに、上記で実行されている `doKeyDown` 関数は以下となります。(長いので超一部抜粋)
http://tools.oesf.biz/android-8.1.0_r1.0/xref/frameworks/base/core/java/android/widget/TextView.java#7201

```java:TextView.java#7201-7363
    private int doKeyDown(int keyCode, KeyEvent event, KeyEvent otherEvent) {
        ...
        if (mMovement != null && mLayout != null) {
            boolean doDown = true;
            if (otherEvent != null) {
                try {
                    boolean handled = mMovement.onKeyOther(this, (Spannable) mText, otherEvent); // ★☆★☆★
                    doDown = false;
                    if (handled) {
                        return KEY_EVENT_HANDLED;
                    }
                } catch (AbstractMethodError e) {
                    // onKeyOther was added after 1.0, so if it isn't
                    // implemented we need to try to dispatch as a regular down.
                }
            }
            if (doDown) {
                if (mMovement.onKeyDown(this, (Spannable) mText, keyCode, event)) { // ★☆★☆★
                    if (event.getRepeatCount() == 0 && !KeyEvent.isModifierKey(keyCode)) {
                        mPreventDefaultMovement = true;
                    }
                    return KEY_DOWN_HANDLED_BY_MOVEMENT_METHOD;
                }
            }
            ...
        }
        return mPreventDefaultMovement && !KeyEvent.isModifierKey(keyCode)
            ? KEY_EVENT_HANDLED : KEY_EVENT_NOT_HANDLED;
    }
```

`★☆★☆★` がついているところに注目すると、 `mMovement` の関数(`mMovement.onKeyOther` , `mMovement.onKeyDown`)がtrueのとき、この関数は `KEY_EVENT_NOT_HANDLED` 以外を返しています。
また、移動キーでこのイベントが発火し、上記に当てはまらない場合、関数の最後で `KEY_EVENT_NOT_HANDLED` が返却され、結果さらに上位のViewにイベントが引き渡されていきます。
**要はコレが原因** なわけです。

さて、この `mMovement` ですが、 `EditText` では `ArrowKeyMovementMethod` が設定されます。
http://tools.oesf.biz/android-8.1.0_r1.0/xref/frameworks/base/core/java/android/text/method/ArrowKeyMovementMethod.java

ここから先は細かい話になりますのでソースを読んでいただければと思いますが、端折りますと以下のようになります。
1. `EditText` にてカーソル移動を `ArrowKeyMovementMethod(mMovement)` に要求
2. `BaseMovementMethod.onKeyOther(...)`, `BaseMovementMethod.onKeyDown(...)` 内にて、 `BaseMovementMethod.handleMovementKey(...)` を実行
3. 各移動イベントに応じた実行関数をコール
4. 移動に成功すればtrue, 失敗すればfalseを返却

4番の成功/失敗ですが、カーソルがそれ以上要求された方向に移動できないときはfalseを返すようになっているようでした。
e.g.) http://tools.oesf.biz/android-8.1.0_r1.0/xref/frameworks/base/core/java/android/text/Selection.java#213

この結果がずーーーーーっと上まで波及していき、 `EditText` を突き抜けて `ViewPager` までイベントが到達してしまった結果、
意図しない場面でタブ切り替えが発生します。
( ´ー｀)ﾌｩｰ．．．

# 対応版コード
では不要なタブ切り替えイベントを抑制するように修正してみます。(

```kotlin:TabFragment.kt
class TabFragment : Fragment() {
    override fun onCreateView(inflater: LayoutInflater?, container: ViewGroup?,
                              savedInstanceState: Bundle?): View? {
      - return inflater?.inflate(R.layout.fragment_tab, container, false)
      + return inflater?.inflate(R.layout.fragment_tab, container, false)?.apply {
      +     (this.findViewById(R.id.editText) as EditText).movementMethod = object : ArrowKeyMovementMethod() {
      +         override fun handleMovementKey(widget: TextView?, buffer: Spannable?, keyCode: Int, movementMetaState: Int, event: KeyEvent?): Boolean {
      +             val handled = super.handleMovementKey(widget, buffer, keyCode, movementMetaState, event)
      +             return when (keyCode) {
      +                 KeyEvent.KEYCODE_DPAD_LEFT -> true
      +                 KeyEvent.KEYCODE_DPAD_RIGHT -> true
      +                 else -> handled
      +             }
      +         }
      +     }
      + }
    }
}
```

以上です。
問題だったのは、キーイベントが右移動または左移動のときに処理したことにならず、
親のViewまでイベントが到達してしまうことでしたので、
それらのキーが入力された際は、今までの処理を行ったあとに常にtrueを返すようにしました。

この結果が以下ですが、文字を打っている最中にタブが不意に切り替わってしまうことを防げていそうです。
(**画像見づらくて大変申し訳ありません**)
<img src="https://qiita-image-store.s3.amazonaws.com/0/161173/c8240dd4-44b9-af59-1eb0-b77c69a2a98b.gif" width=200px>

# おわりに
データ入力画面を作るときに困っていたのでなんとかしたいと思いなんとか漕ぎ着けましたが、
もっとスマートな方法がある気しかしません。
知見ございましたら優しいツッコミいただければとお思います。