【Android】ListViewにEditTextとButtonを配置した時の値取得やクリックイベント周りの開発Tips【Kotlin】

Android 10月 10, 2016

最近Kotlinに色んな意味でハマっているんですが、その中でも一番ハマったListViewにEditTextとButtonを配置したときの実装について、備忘録を載せたいと思います。

というのも、おそらくこの実装にチャレンジした人はわかって頂けると思うのですが、様々な落とし穴があります。

  • ListViewにButtonを含むとOnItemClickListenerと競合する
  • EditTextを変更してもListView側のリストに反映されない
  • AddTextChangedListenerを設定すると、入力するたびにフォーカスが外れる

などなど、細かいものを挙げればキリがないのですが、今回はこれらの問題をおおよそ解決出来るような実装を紹介します。いやあ、ListView(Adapter)の闇は深い。

環境

  • Gradle 2.2.0
  • Kotlin 1.0.4
  • DataBinding

サンプルは、ダイアログ上に編集可能なリストを表示するアプリです。雰囲気は以下のような感じ。

ListView + EditText + Buttonサンプル

ダイアログの実装まで書くと長くなるため、Adapter側の実装をメインに書きます。

CustomAdapter

ListItem(EditText + Button)を持つアダプタ。今回はこのアダプタをダイアログ上で表示する。

アイテムのView

list_item.xml

<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android">

    <data>
        <variable name="position" type="String" />
        <variable name="content" type="String" />
    </data>

    <RelativeLayout
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:padding="10dp"
        android:orientation="vertical">

        <TextView
            android:layout_width="0px"
            android:layout_height="0px"
            android:text="@{position}" />

        <EditText
            android:id="@+id/contentEdit"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:layout_alignParentStart="true"
            android:layout_alignParentLeft="true"
            android:text="@={content}"
            android:textAppearance="?android:attr/textAppearanceLarge" />

        <Button
            android:id="@+id/addButton"
            android:layout_width="100px"
            android:layout_height="wrap_content"
            android:layout_toLeftOf="@+id/removeButton"
            android:text="+"/>
        <Button
            android:id="@+id/removeButton"
            android:layout_width="100px"
            android:layout_height="wrap_content"
            android:layout_alignParentEnd="true"
            android:layout_alignParentRight="true"
            android:text="-"/>
    </RelativeLayout>
</layout>

画面上に表示していないpositionを取るTextViewが存在するが、これがミソ。ちなみに型をIntで取ろうとしたら、XMLは文字だけじゃコラと怒られてしまう(自分だけ?)。

わざわざ表示しないpositionの値をここで書くのは、後述のバインディングの際に値を保持するためで、xml上の何処かで使用していないとコンパイル時に最適化されて(?)エラーが出るため。

CustomAdapter.kt

class CustomAdapter(context: Context, items: List<String>, val textViewResourceId: Int = R.layout.list_item) :
        ArrayAdapter<String>(context, textViewResourceId, items) {

    private val inflater: LayoutInflater = context.getSystemService(Context.LAYOUT_INFLATER_SERVICE) as LayoutInflater

    private val items: MutableList<String> = mutableListOf()

    init {
        this.items.addAll(items)
    }

    override fun getView(position: Int, convertView: View?, parent: ViewGroup?): View? {
        val view: View
        val binding: ListItemBinding
        if (null == convertView) {
            binding = DataBindingUtil.inflate(inflater, textViewResourceId, parent, false)
            view = binding.root
            view.tag = binding
            binding.contentEdit.addTextChangedListener(TextWatcherCustom(binding))
        } else {
            binding = convertView.tag as ListItemBinding
            view = convertView
        }
        binding.position = position.toString()
        binding.content = getItem(position)
        binding.addButton.setOnClickListener { v ->
            items.add(position + 1, "")
            reload()
        }
        binding.removeButton.setOnClickListener { v ->
            items.removeAt(position)
            reload()
        }
        return view
    }

    /**
     * 画面遷移時にも呼び出す
     */
    fun reload() {
        clear()
        addAll(items)
        notifyDataSetChanged()
    }

    private inner class TextWatcherCustom(var binding: GenreItemInfoContentListItemBinding) : TextWatcher {
        var size = items.size

        override fun afterTextChanged(s: Editable) {}

        override fun onTextChanged(s: CharSequence, start: Int, before: Int, count: Int) {
            val position = binding.position.toInt()
            if (items[position] != s.toString() && size == items.size) {
                items[position] = s.toString()
                size = items.size
            }
        }

        override fun beforeTextChanged(s: CharSequence, start: Int, count: Int, after: Int) {}
    }
}

Adapterのitemsはprivateのため、直接いじるにはちょっと面倒。そのため、初期化で新しいitemsをMutableListで保持し、これを元にAdapterの値を変更していこうという発想。

override fun getView(position: Int, convertView: View?, parent: ViewGroup?): View? {
    val view: View
    val binding: GenreItemInfoContentListItemBinding
    if (null == convertView) {
        binding = DataBindingUtil.inflate(inflater, textViewResourceId, parent, false)
        view = binding.root
        view.tag = binding
        binding.contentEdit.addTextChangedListener(TextWatcherCustom(binding))
    } else {
        binding = convertView.tag as GenreItemInfoContentListItemBinding
        view = convertView
    }
    ...

getView内の実装はよくある感じになっている。気を付けたいのは、getViewは初期化の際や更新時に何度も呼ばれるため、addTextChangedListenerは上記のタイミングで設定するようにしないと、重複して何度も登録されてしまうという現象が起こって大変なことになる。

binding.position = position.toString()
binding.content = getItem(position)

contentへのバインディングはよく知られている通り。positionへの設定は少しテクニカルかもしれない。これはEditTextを用いる上で重要な設定で、色々試行錯誤した結果、ここに設定して保持するのがスマートかと感じた。

binding.addButton.setOnClickListener { v ->
    items.add(position + 1, "")
    reload()
}
binding.removeButton.setOnClickListener { v ->
    items.removeAt(position)
    reload()
}

ListItemの追加・削除ボタンにリスナーを登録。後述のreload()で更新を反映。

fun reload() {
    clear()
    addAll(items)
    notifyDataSetChanged()
}

Viewへの反映としてreload()を用意している。これは、現在のitemsの状態をAdapterのitemsに反映させる。

private inner class TextWatcherCustom(var binding: GenreItemInfoContentListItemBinding) : TextWatcher {
    var size = items.size

    override fun afterTextChanged(s: Editable) {}

    override fun onTextChanged(s: CharSequence, start: Int, before: Int, count: Int) {
        val position = binding.position.toInt()
        if (items[position] != s.toString() && size == items.size) {
            items[position] = s.toString()
            size = items.size
        }
    }

    override fun beforeTextChanged(s: CharSequence, start: Int, count: Int, after: Int) {}
}

この内部クラスではBindingを引数に取って保持することで、EditTextの編集文字列とitemsの対応を可能にする。binding.contentで現在のListItemの値が取得可能なら、同じように設定すればbinding.positionも取得可能なはずだ。

onTextChangeで変更直後に即座にitemsに反映という実装はよく紹介されているが、それらのレイでは直接Adapter#insert,addなどを用いて操作するため、フォーカスが外れるという現象が起こってしまう。

そこで、入力文字列の変更に関しては、CustomAdapterのitemsプロパティへの反映までで留めておき、ListItemの追加・削除か、画面を抜けたときに明示的に呼ぶreload()のタイミングでAdapterに反映させれば良いと考えた。

また、ListItemの追加・削除の際、onTextChangedメソッドが呼ばれるのだが、この時何故かs: CharSequenceに編集前の古い値が入ってきて上書きされるという現象が起こってしまっていた。そのためsizeをプロパティとして持ち、ListItem数の変更の際はこれを飛ばす処理のために用いている。

ContentDialogFragment

ダイアログはDialogFragmentを用いる。ざっくり、以下のように設定すれば良いと思う。

dialog_content.xml

<?xml version="1.0" encoding="utf-8"?>
<layout
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto">

    <data>
        <import type="java.util.List" />
        <variable name="name" type="String" />
        <variable name="contentList" type="List&lt;String>" />
    </data>

    <LinearLayout
        android:id="@+id/dialog"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:orientation="vertical"
        android:padding="10dp">

        <EditText
            android:id="@+id/name"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:text="@={name}"/>

        <ListView
            android:id="@+id/contentListView"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:inputType="text"
            app:contentList="@{contentList}"/>

        <!-- アイテムがない場合の表示 -->
        <RelativeLayout
            android:id="@+id/emptyListView"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:orientation="vertical"
            android:padding="10dp">

            <EditText
                android:layout_width="match_parent"
                android:layout_height="wrap_content"
                android:hint="please input"
                android:textAppearance="?android:attr/textAppearanceMedium"/>

            <Button
                android:layout_width="100px"
                android:layout_height="wrap_content"
                android:layout_alignParentEnd="true"
                android:layout_alignParentRight="true"
                android:text="+"/>
        </RelativeLayout>
    </LinearLayout>
</layout>

emptyListは参考までに。

ContentDialogFragment.kt

class ContentDialogFragment : DialogFragment() {

    private lateinit var binding: DialogContentBinding

    ...

    override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
        super.onCreateDialog(savedInstanceState)

        val inflater = LayoutInflater.from(activity)
        val dialog = inflater.inflate(R.layout.dialog_content, null) as LinearLayout
        binding = DialogContentBinding.bind(dialog)
        binding.contentList = mContentList
        binding.contentListView.emptyView = binding.emptyListView
        val builder = AlertDialog.Builder(activity)
        return builder.setView(dialog)
                .setPositiveButton("ok") { dialog, which -> hoge }
                .setNegativeButton("cancel") { dialog, which -> fuga }
                .setCancelable(true)
                .create()
    }

    fun notifyDataSetChanged() {
        val adapter = ((binding.contentListView as? ListView)?.adapter as? CustomAdapter)
        adapter?.reload()
        adapter?.notifyDataSetChanged()
    }

    companion object {
        fun newInstance(): ContentDialogFragment {
            val fragment = ContentDialogFragment()
            val args = Bundle()
            args.putSerializable("", ???)
            fragment.arguments = args
            return fragment
        }
    }

    object CustomSetter {
        @JvmStatic
        @BindingAdapter("contentList")
        fun setContentList(listView: ListView, contentList: List<String>) {
            val adapter = CustomAdapter(listView.context, contentList)
            listView.adapter = adapter
        }
    }
}

onCreateDialogに関してはまあこんな雰囲気という感じ。
今回のダイアログは別のActivityから呼ばれることを想定しているため、先のCustomAdapterの変更を通知するためのnotifyDataSetChanged()を設定し、その中でCustomAdapter#reloadCustomAdapter#notifyDataSetChangedを呼ぶ。reloadを呼ばないと、CustomAdapter#itemsの最終状態がAdapter#itemsに反映されない。

後はOKボタンが押されたらデータを保存する処理を書けば良い。

まとめ

ListViewでEditTextやButtonを使う実装は中々難しいのか、有用な情報があまりなかったのですが、今回やっと思った通りの動きが実現できたので良かったです。

もっと良い実装はまだあるかと思うので、知ってる方がいれば是非教えていただきたいと思います。

slont

金融ベンチャーでWebエンジニア。美と酒とTechで生きてる。Vue.jsが至高。Elixir好き。個人事業とWebアプリ案件もやってます。 アプリ→https://app.cullet.me Android→https://play.google.com/store/apps/details?id=net.maytry.cullet.android

Great! You've successfully subscribed.
Great! Next, complete checkout for full access.
Welcome back! You've successfully signed in.
Success! Your account is fully activated, you now have access to all content.