ListViewでCheckedTextViewを実装したい

前回、ListViewでCheckedTextViewを実装する方法をまとめました。
しかしその後の調査で、前回まとめた方法がツッコミどころ満載の方法だったということが分かりました。
これ以上セルフツッコミはしたくない...！ということで再びListViewについてまとめます。

前回の記事は[こちら](https://qiita.com/satona-oinuma/items/b033ffcc346dc167f749)です。
CheckedTextViewの基本と、ListViewへのツッコミ力が得られるかと思います。

そして、前回実装したListViewにセルフツッコミした部分は以下の2点です。
1. ListViewの特徴であるViewの再利用ができていない
2. 要素が画面外に出た時に初期化されてしまう

今回は、前回まとめたソースコードを元に、実装を行います。


◯実行環境
Android Studio：4.2.2
Kotlin：1.3.72

##1. ListViewの特徴であるviewの再利用を実装

実はListViewには、convertViewという画面外に出たviewを再利用できる仕組みがあります。

前回私が実装したListViewでは、リスト要素の描画をするgetViewメソッドの中で毎回inflateが呼ばれるようになっていました。
しかし、inflateは重い処理のため、リスト要素がたくさんある場合に毎回呼び出すと処理が遅く、動作がカクカクしてしまいます。
そのような状況を防ぐためにListViewにはconvertViewという仕組みがあり、convertViewを効果的に使うことでぬるぬると動く操作性が実現できるとのことでした。

以下のように実装することで、画面外に出たViewを再利用します。
①データクラスを設置
②convertViewがnullの場合のみinflateし、nullでない場合はviewを再利用する

今回はgetViewの中でPairクラスを用いて、holderとviewの2つの値を返すよう設定しています。
Pairクラスの実装方法について：[参考-Qiita](https://qiita.com/sdkei/items/2d5dab51b53975286945)

```kotlin:MyAdapter.kt
data class MyViewHolder(val checkedTextView: CheckedTextView)//①データクラスを設置

override fun getView(position: Int, convertView: View?, parent: ViewGroup): View {
   val (holder, view) = if(convertView == null) {
       // ②初回時など、convertViewがnullの場合のみinflateする
       val inflater = LayoutInflater.from(context)
       val v = inflater.inflate(R.layout.item, parent, false)
       val checkedTextView = v.findViewById<CheckedTextView>(R.id.checked_text_view)
       val viewHolder = MyViewHolder(checkedTextView)
       v.tag = viewHolder
       viewHolder to v
   } else {
       // ②再利用時など、convertViewがnull出ない場合はviewを再利用する
       convertView.tag as MyViewHolder to convertView
   }
   holder.checkedTextView.text = itemList[position]
   // viewにクリックリスナーを設定
   holder.checkedTextView.setOnClickListener {
       val view = it as CheckedTextView
       if (view.isChecked) {
           //押し直した時にAndroidのマークになるように設定
           view.setCheckMarkDrawable(R.drawable.ic_android_black_24dp)
           view.isChecked = false
       } else {
           //1回押した時にチェックマークが出るように設定
           view.setCheckMarkDrawable(R.drawable.ic_baseline_check_24)
           view.isChecked = true
       }
   }
   return view
}
```
以上でぬるぬる動作を実現できました。
しかし、この状態ではチェックマークの位置が正確に取れていないようです。
![スクリーンショット 2021-09-01 13.50.38.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1396691/f145e7fd-dfa7-ba7a-ef6e-6e0568c1d11d.png)



そこで、次は画面外に出た時にもチェックマークの位置がずれないよう対策をしていきます。

##2. 要素が画面外に出た時のチェックマークを対策

チェックマークはCheckedTextViewのisChecked()による条件分けで設置しています。
1のviewの再利用を実装する前、つまり前回の記事の実装では、一度画面外に出て戻ってきた要素は全てAndroidマークに戻っていました。
これはgetView()が呼ばれた際に毎回inflateをし、新しいviewを作成していたためです。

しかし、現在はチェックマークが別の要素に移動しているように見えます。
これはどうやら、再利用するviewに設定されていた真偽値の影響を、再利用後のviewが受けてしまうことが原因のようです。

ここで先ほどの写真のテキストに注目してみると、チェックマークの位置がずれている場合でもテキストの順番は正しく表示されていることが分かります。
テキストはチェックマークと違い、viewが生成された後に改めて設定しています。
よって、チェックの状態についてもviewの再利用をした後で改めて設定するようにします。
そして今回はmapを導入し、以下のように実装しました。

①mapのインスタンスを生成
②クリック時にpositionをキーとして真偽値を追加する
③ ②の値を使ってチェックマークやクリック時の処理の分岐を行う

```kotlin:MyAdapter.kt
private val map = mutableMapOf<Int, Boolean>()//①mapのインスタンスを生成
data class MyViewHolder(val checkedTextView: CheckedTextView)

override fun getView(position: Int, convertView: View?, parent: ViewGroup): View {
   val (holder, view) = if(convertView == null) {
       // 初回時など、convertViewがnullの場合のみinflateする
       val inflater = LayoutInflater.from(context)
       val v = inflater.inflate(R.layout.item, parent, false)
       val checkedTextView = v.findViewById<CheckedTextView>(R.id.checked_text_view)
       val viewHolder = MyViewHolder(checkedTextView)
       v.tag = viewHolder
       viewHolder to v
   } else {
       // 再利用時など、convertViewがnullでない場合はviewを再利用する
       convertView.tag as MyViewHolder to convertView
   }
   holder.checkedTextView.text = itemList[position]
   if (map[position] == true) {//③map[position]がtrueのときチェックマークを設置
       holder.checkedTextView.setCheckMarkDrawable(R.drawable.ic_baseline_check_24)
   } else { //③map[position]がnullもしくはfalseのときAndroidマークを設置
       holder.checkedTextView.setCheckMarkDrawable(R.drawable.ic_android_black_24dp)
   }

   // viewにクリックリスナーを設定
   holder.checkedTextView.setOnClickListener {
       val view = it as CheckedTextView
       if (map[position] == true) {
           //③map[position]がtrueのときチェックマークを設置
           view.setCheckMarkDrawable(R.drawable.ic_android_black_24dp)
           map[position] = false //②positionをキーとして真偽値を追加
       } else {
           //③map[position]がnullもしくはfalseのときAndroidマークを設置
           view.setCheckMarkDrawable(R.drawable.ic_baseline_check_24)
           map[position] = true //②positionをキーとして真偽値を追加
       }
   }
   return view
}
```

以上で、チェックマークのずれないCheckedTextViewを実装することができました。


ここまでのコードの全体像は以下です。

```kotlin:MainActivity.kt
import android.os.Bundle
import android.widget.BaseAdapter
import android.widget.ListView
import androidx.appcompat.app.AppCompatActivity

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

       //方法② arrayからとってくる
       val itemList = resources.getStringArray(R.array.nutrients)

       //activity_main.xmlに定義したListViewを読み込む
       val listView = findViewById<ListView>(R.id.list_view)
       // リスト項目とListViewを対応付けるArrayAdapterを用意する
       // ArrayAdapterではcontext、1項目分のレイアウトファイル、項目を定義した配列を指定する
       val adapter: BaseAdapter = MyAdapter(this, itemList)
       listView.adapter = adapter
   }
}

```

```kotlin:MyAdapter.kt
import android.content.Context
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.BaseAdapter
import android.widget.CheckedTextView

class MyAdapter(
   private val context: Context,
   private var itemList: Array<String>
) : BaseAdapter() {

   override fun getCount(): Int {
       return itemList.size
   }

   override fun getItem(position: Int): Any {
       return itemList.get(position)
   }

   override fun getItemId(position: Int): Long {
       return position.toLong()
   }

   private val map = mutableMapOf<Int, Boolean>()//①mapのインスタンスを生成
   data class MyViewHolder(val checkedTextView: CheckedTextView)

   override fun getView(position: Int, convertView: View?, parent: ViewGroup): View {
       val (holder, view) = if(convertView == null) {
           // 初回時など、convertViewがnullの場合のみinflateする
           val inflater = LayoutInflater.from(context)
           val v = inflater.inflate(R.layout.item, parent, false)
           val checkedTextView = v.findViewById<CheckedTextView>(R.id.checked_text_view)
           val viewHolder = MyViewHolder(checkedTextView)
           v.tag = viewHolder
           viewHolder to v
       } else {
           // 再利用時など、convertViewがnullでない場合はviewを再利用する
           convertView.tag as MyViewHolder to convertView
       }
       holder.checkedTextView.text = itemList[position]
       if (map[position] == true) {//③map[position]がtrueのときチェックマークを設置
           holder.checkedTextView.setCheckMarkDrawable(R.drawable.ic_baseline_check_24)
       } else { //③map[position]がnullもしくはfalseのときAndroidマークを設置
           holder.checkedTextView.setCheckMarkDrawable(R.drawable.ic_android_black_24dp)
       }

       // viewにクリックリスナーを設定
       holder.checkedTextView.setOnClickListener {
           val view = it as CheckedTextView
           if (map[position] == true) {
               //③map[position]がtrueのときチェックマークを設置
               view.setCheckMarkDrawable(R.drawable.ic_android_black_24dp)
               map[position] = false //②positionをキーとして真偽値を追加
           } else {
               //③map[position]がnullもしくはfalseのときAndroidマークを設置
               view.setCheckMarkDrawable(R.drawable.ic_baseline_check_24)
               map[position] = true //②positionをキーとして真偽値を追加
           }
       }
       return view
   }
}
```

```list_item.xml
<resources>
   <string-array name="nutrients">
       <item>タンパク質</item>
       <item>脂質</item>
       <item>飽和脂肪酸</item>
…
   </string-array>
</resources>
```

```kotlin:activity_main.xml
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout
   xmlns:android="http://schemas.android.com/apk/res/android"
   xmlns:app="http://schemas.android.com/apk/res-auto"
   android:layout_width="match_parent"
   android:layout_height="wrap_content">

   <ListView
       android:id="@+id/list_view"
       android:layout_width="match_parent"
       android:layout_height="match_parent"
       app:layout_constraintStart_toStartOf="parent"
       app:layout_constraintTop_toTopOf="parent"
       />

</androidx.constraintlayout.widget.ConstraintLayout>
```

```kotlin:item.xml
<?xml version="1.0" encoding="utf-8"?>
<CheckedTextView
   android:id="@+id/checked_text_view"
   android:layout_width="match_parent"
   android:layout_height="wrap_content"
   android:padding="10dp"
   android:textSize="20sp"
   android:checked="false"
   android:checkMarkTint="@color/teal_700"
   android:checkMark="@drawable/ic_android_black_24dp"
   app:layout_constraintStart_toStartOf="parent"
   app:layout_constraintTop_toTopOf="parent"
   xmlns:app="http://schemas.android.com/apk/res-auto"
   xmlns:android="http://schemas.android.com/apk/res/android"
   />
```

##まだまだツッコミどころは残っていた...

望んだ動き自体は実装することができました。
しかしコードを見ていると、より良い方法があるのではないかと思う部分があります。

例えば、以下のような点です。
①折角CheckedTextViewを使っているのにそのチェック状態を使っていない
②テキストを保持する配列、チェック状態を保持するmap、viewを保持するデータクラスなどコレクションが乱立している

①に関してはもはや本末転倒な気がしていますが、現状私の力では今回の実装が精一杯でした。ここを通過点として今後もより良い方法を探していきたい気持ちです。
改善点やお気づきの点がありましたらぜひコメントをお願いいたします。


###参考
[Qiita：AndroidのListViewやRecyclerViewの、ViewHolderやDataBindingを調べた記録](https://qiita.com/paming/items/b06a54632a0432923122)
[Qiita：AndroidのViewHolderパターンをKotlinでいい感じに書く](https://qiita.com/atsuya046/items/b6800393e17c656fbe0b )
[stackoverflow：BaseAdapter causing ListView to go out of order when scrolled](https://stackoverflow.com/questions/3121153/baseadapter-causing-listview-to-go-out-of-order-when-scrolled)