【Android】ListViewにEditTextとButtonを配置した時の値取得やクリックイベント周りの開発Tips【Kotlin】
最近Kotlinに色んな意味でハマっているんですが、その中でも一番ハマったListViewにEditTextとButtonを配置したときの実装について、備忘録を載せたいと思います。
というのも、おそらくこの実装にチャレンジした人はわかって頂けると思うのですが、様々な落とし穴があります。
- ListViewにButtonを含むとOnItemClickListenerと競合する
- EditTextを変更してもListView側のリストに反映されない
- AddTextChangedListenerを設定すると、入力するたびにフォーカスが外れる
などなど、細かいものを挙げればキリがないのですが、今回はこれらの問題をおおよそ解決出来るような実装を紹介します。いやあ、ListView(Adapter)の闇は深い。
環境
- Gradle 2.2.0
- Kotlin 1.0.4
- DataBinding
サンプルは、ダイアログ上に編集可能なリストを表示するアプリです。雰囲気は以下のような感じ。
ダイアログの実装まで書くと長くなるため、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<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#reload
とCustomAdapter#notifyDataSetChanged
を呼ぶ。reload
を呼ばないと、CustomAdapter#items
の最終状態がAdapter#items
に反映されない。
後はOKボタンが押されたらデータを保存する処理を書けば良い。
まとめ
ListViewでEditTextやButtonを使う実装は中々難しいのか、有用な情報があまりなかったのですが、今回やっと思った通りの動きが実現できたので良かったです。
もっと良い実装はまだあるかと思うので、知ってる方がいれば是非教えていただきたいと思います。