3
Help us understand the problem. What are the problem?

More than 3 years have passed since last update.

posted at

updated at

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

はじめに

AndroidのTabLayout + ViewPager で作られた画面上で、EditText を使った際、
EditText の文字終端部分でソフトウェアキーボードの移動キー(left/right arrow)を押すと
タブが切り替わるという現象に出会いました。
(↓見辛いですがこんな感じ)

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

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

動作環境

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

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

ベースコード

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

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)
    }
}
TabFragment.kt
class TabFragment : Fragment() {
    override fun onCreateView(inflater: LayoutInflater?, container: ViewGroup?,
                              savedInstanceState: Bundle?): View? {
        return inflater?.inflate(R.layout.fragment_tab, container, false)
    }
}
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>
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>

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

一体何が

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

まず、EditTextTextView を継承しています。( AppCompatEditTextTintableBackgroundView を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

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

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 までイベントが到達してしまった結果、
意図しない場面でタブ切り替えが発生します。
( ´ー`)フゥー...

対応版コード

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

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を返すようにしました。

この結果が以下ですが、文字を打っている最中にタブが不意に切り替わってしまうことを防げていそうです。
(画像見づらくて大変申し訳ありません)

おわりに

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

Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Sign upLogin
3
Help us understand the problem. What are the problem?