Senの競技プログラミング備忘録

こけた問題を自分用の解説で載せる。けんちょんさんのブログを目指したい。質的にも量的にも。こけた問題だけに限定するけど

ListViewにButtonとか入れてるけどOnItemClickListenerが動かない

Androidアプリの開発で、ListViewにCheckBoxやButtonを入れて、OnItemClickListenerを設定したが働かないという問題にであいました。 結構な既出問題でしたが、日本語情報ではなかなか十全な記事がなかったです。そして、自分のFocus、TouchModeの理解などもかねてまとめることにしました。

問題

このようなListViewに、

<ListView
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:id="@+id/lv" />

このようなCheckBoxが1つだけ入っているLinearLayoutからなるものを入れました。(Buttonでも本質的に同じ事は起こりえると思います。)

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

    <CheckBox
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:id="@+id/cb"
        />

</LinearLayout>

やり方は、onCreate()内で動的にadapterを用いて設定しようとしました。

//onCreate内
val lst : MutableList<Map<String, Any>> = mutableListOf()
lst.add(mapOf("text" to "アメリカ"))
lst.add(mapOf("text" to "ドイツ"))
lst.add(mapOf("text" to "イギリス"))

findViewById<ListView>(R.id.lv).adapter = SimpleAdapter(this, lst, R.layout.item_checkbox, arrayOf("text"), intArrayOf(R.id.cb))

そして、ListViewなので、アイテムが押されたら○○はOnItemClickListenerで実装すると思い、そこでチェックボックスの挙動による設定を行った。

//onCreate()内
findViewById<ListView>(R.id.lv).onItemClickListener = lvItemListener()

//inner class
inner class lvItemListener : AdapterView.OnItemClickListener{
    override fun onItemClick(p0: AdapterView<*>?, p1: View?, p2: Int, p3: Long) {
        Log.d("てすと", "ListViewのリスナーが押された。" + p2 + "番目だ!")
        //実際はここでチェックボックスの押さたことによる処理を記述
    }
}

これをBuildして、実際にListViewにAdaptされた各チェックボックスを押しても、チェックがついたり消えたりはするがOnItemClickListenerが呼び出されないという状況が起きる。

前提知識

以下のフォーカスとタッチモードはこの公式記事のまとめです。

フォーカス

携帯端末でのiOSは基本的にスマホに照準しているので、ガラケーみたいな上下左右、テンキー、決定ボタンでの操作を想定していない。しかし、Androidは様々なハードで使われおり、その中ではガラケーみたいに上下左右、テンキー、決定ボタンで操作するようなモデルも存在する。

そこで、フォーカスという概念が登場する。フォーカスとは、Windowsでいうと以下のようなものである。(Androidでも同じようなものです)

f:id:Sen_comp:20210403194151p:plain

この画像のように、ボタンにフォーカスがあり、左右上下キーでフォーカスの変更、エンターキーでフォーカスされてるボタンの実行がなされる感じです。多くのアプリはそのようなガラケーモードともいえる者を想定したつくりではないのも事実ですが・・・

ちなみに、デフォルトデフォーカスされないWidgetも大量にあります。TextViewとかはただ文字を表示することしか想定されていないので、フォーカスがデフォルトされることはありません。

タッチモード

逆に、普通のスマホと言われているタッチパネル式の端末では、AndroidOSはタッチモードであると認識しています。 では、フォーカスはタッチモードでは存在しないものか?と言われるとそうではありません。タッチモードの時にもフォーカスは存在しており、EditTextが複数個並んでいるとき、1つ埋め終われば→ボタンを押せばシームレスに次のEditText入力に移れます。

clickable

Viewについては、ボタンやCheckBoxなどリスナーを設定しなくても、押されたことが明らかにわかるようなアニメーションをするWidgetがあります。それらが押された時にそもそもリスナー以前に、押された時のアニメーションを含む内部的な処理も全部カットしたい場合、

android:clickable="false"

と設定すればいいです。

先ほどの問題の何が悪さしているのか

まず、結論から言うと、「ListViewに含まれてるwidgetで、クリック可能(clickable)もしくはフォーカス可能のようなものが1つでも含まれたら、その行についてはOnItemClickListenerは呼び出されない」です。

Androidの内部処理では、ListViewの中身でフォーカスorクリックできるような者が存在している場合、クリックしたというイベントに対してそちらのonClickListenerが呼び出されるので、onItemClickListenerが呼ばれることはありません。

解決法

具体的な手法

この挙動が嫌な場合、次のように改善することができます。

  1. ListViewにAdaptするLayoutのルートタグ(一番外のもの)に、android:descendantFocusability="blocksDescendants"を挿入
  2. AdaptするLayoutの中で、
    1. タッチモードの時、フォーカスがあるWidgetandroid:focusableInTouchMode="false"を挿入
    2. テンキー操作モードの時、フォーカスがあるWidgetandroid:focusable="false"を挿入
    3. クリック可能であるWdigetは、android:clickable="false"を挿入

これをさきほどのCheckBoxに含ませたLayoutに適応させると、

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

    <CheckBox
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:id="@+id/cb"
        android:focusableInTouchMode="false"
        android:clickable="false"
        />

</LinearLayout>

CheckBoxのフォーカスについては何も記述していませんが、これはタッチモードの時、CheckBoxにはフォーカスはデフォルトではない。 が理由です。いちいち調べるのはおそらく面倒だと思いますので、以下のように何も考えずにすべてのAdaptしたいLayoutのすべての(○○Layoutではない)Widget

android:focusableInTouchMode="false"
android:focusable="false"
android:clickable="false"

を書いても問題がないと思います。

解説

android:descendantFocusability

公式ドキュメントによりますと、これの設定できる値は

  • beforeDescendants このViewがフォーカスされるのは、どの子Viewよりも
  • afterDescendants このViewがフォーカスされるのは、どの子Viewよりも
  • blocksDescendants たとえ子Viewがフォーカス可能でも、それらにフォーカスさせない

の3つです。今回の場合、子Viewがフォーカス可能というのがOnItemClickListenerの呼び出しの阻害になっていますので、blocksDescendantsを選んだわけです。

android:clickable

今回の問題解決のキー要素でもあります。OnItemClickListenerの呼び出しの阻害はフォーカスのみならず、クリック可能なWidgetでも代わりにそれのOnClickListenerが呼ばれることで起きます。 これはdescendantFocusabilityとは別に設定する必要があります。(EditTextなどはクリック可能ではないのでスルーしても大丈夫ですが、ボタンやチェックボックスではこれを書かないとOnItemClickListenerは呼ばれません!!!)

最後に

これでこの問題は解決できました。この記事を書きあげるまでにいろいろ実験や調査で3~4hは吹っ飛びました(調査力が低すぎる)が、なんとか解決できてよかったです。

余談

しかし、今回の場合これで解決をしますと、ListViewの中にあるチェックボックスやボタンの動きを封じることになります。つまり、チェックボックスを押してもチェックされません

結論:ListViewの中でButtonやCheckBoxのクリックを検知したいのならば、それらのViewに直接OnClickListenerをつけるべし

結論としては、RecyclerViewで必須なArrayAdapter<型>を継承し、getView()をoverrideしたカスタムAdapterを書くべき。

//onCreate内
findViewById<ListView>(R.id.lv).adapter = CheckBoxArrayAdapter(this, R.layout.item_checkbox, 
    listOf("アメリカ", "ドイツ", "フランス", "イギリス")
)

class CheckBoxArrayAdapter(context : Context, rid : Int, val dataList : List<String>) : ArrayAdapter<String>(context, rid, dataList){
    override fun getView(position: Int, convertView: View?, parent: ViewGroup): View {
        var view = convertView
        if(view == null)
            view = LayoutInflater.from(context).inflate(R.layout.item_checkbox, null)
        //viewがすでに存在している場合は、inflateせずに流用できる。inflateはコストが重い。

        view?.findViewById<CheckBox>(R.id.cb)?.text = dataList[position]
        view?.findViewById<CheckBox>(R.id.cb)?.setOnClickListener{
            Log.d("わい", "押された!")
        }
        return view!! //無理やりこう書いてるけど、view==nullならinflateで必ずnullではなくなるので、大丈夫
    }
}

ここを参考にしました