ExpandableListView ≒ UITableView

Android の [ExpandableListView](https://developer.android.com/reference/android/widget/ExpandableListView) って見た目をほぼ iOS の [UITableView](https://developer.apple.com/documentation/uikit/uitableview) にできるよねって話です。

左：Android (ExpandableListView)、 右：iOS (UITableView)
![cropped.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3853334/35b99f80-78c1-95a3-2993-7ec8a1e0430e.png)

iOS の UITableView は、特に難しいことをしなくてもセクション + 行 というのが作れますが、Android の場合は、ExpandableListView 用のアダプターを作成する必要があります。
ちょっぴり戸惑った記憶があるので残しておきます。

なお、あくまで見た目の話なので、ExpandableListView は、セクションをタップするとそのリストを閉じられたり、UITableView は、リストを横にスライドしたときの動作を簡単に設定できたりと両者でできることは異なります。

## ソースコード
### MainActivity
ExpandableListView を表示するだけです。ソースコードにコメントをしました。
```kotlin:MainActivity.kt
class MainActivity: AppCompatActivity() {
   override fun onCreate(savedInstanceState: Bundle?) {
      super.onCreate(savedInstanceState)
      setContentView(R.layout.activity_main)

      // テストデータ準備
      val list = List(26) { section ->
         TestSection("Section ${'A' + section}", List(3) { row -> TestRow("Row ${row + 1}") })
      }

      // アダプターを設定
      val listView = findViewById<android.widget.ExpandableListView>(R.id.expandableListView)
      listView.setAdapter(TestAdapter(list))
      
      // セクションのクリックリスナーは何もしないように設定
      listView.setOnGroupClickListener { _, _, _, _ -> true }

      // すべてのセクションを開いた状態に設定
      for (i in 0 until listView.expandableListAdapter.groupCount){ listView.expandGroup(i) }

      // 行のクリックリスナーを設定
      listView.setOnChildClickListener { _, _, groupPosition, childPosition, _ ->
         val row = list[groupPosition].rows[childPosition]
         Log.d("ChildClick", "Clicked: ${list[groupPosition].sectionName} ${row.rowName}")
         true
      }
   }
}
```

### カスタムアダプター (TestAdapter) 
`ExpandableListView` のためのカスタムアダプター (`TestAdapter`) の定義です。

このアダプターは、`ExpandableListView` で表示するためのデータ（セクションと行）を提供し、対応するビューを生成します。
```kotlin:TestAdapter.kt
data class TestRow(val rowName : String)
data class TestSection(val sectionName : String, val rows : List<TestRow>)
class TestAdapter(private val listData : List<TestSection>) : BaseExpandableListAdapter() {
   inner class SectionHolder(cell: View){
      val sectionTextView = cell.findViewById<TextView>(R.id.sectionTitle)!!
   }
   inner class RowHolder(cell:View){
      val rowTextView = cell.findViewById<TextView>(android.R.id.text1)!!
   }
   override fun getGroupCount(): Int = listData.size

   override fun getChildrenCount(groupPosition: Int): Int = listData[groupPosition].rows.size

   override fun getGroup(groupPosition: Int): Any = listData[groupPosition]

   override fun getChild(groupPosition: Int, childPosition: Int): Any = listData[groupPosition].rows[childPosition]

   override fun getGroupId(groupPosition: Int): Long = groupPosition.toLong()

   override fun getChildId(groupPosition: Int, childPosition: Int): Long = childPosition.toLong()

   override fun hasStableIds(): Boolean = false

   override fun isChildSelectable(groupPosition: Int, childPosition: Int): Boolean = true

   override fun getGroupView(groupPosition: Int, isExpanded: Boolean, convertView: View?, parent: ViewGroup?): View {
      val view : View
      val viewHolder : SectionHolder
      when (convertView){
         null -> {
            val inflater = LayoutInflater.from(parent?.context)
            view = inflater.inflate(R.layout.listview_section, parent, false)
            viewHolder = SectionHolder(view)
            view.tag = viewHolder
         }
         else -> {
            view = convertView
            viewHolder = view.tag as SectionHolder
         }
      }
      viewHolder.sectionTextView.text = listData[groupPosition].sectionName
      return view
   }

   override fun getChildView(groupPosition: Int, childPosition: Int, isLastChild: Boolean, convertView: View?, parent: ViewGroup?): View {
      val view : View
      val viewHolder : RowHolder
      when (convertView){
         null -> {
            val inflater = LayoutInflater.from(parent?.context)
            view = inflater.inflate(android.R.layout.simple_list_item_1, parent, false)
            viewHolder = RowHolder(view)
            view.tag = viewHolder
         }
         else -> {
            view = convertView
            viewHolder = view.tag as RowHolder
         }
      }
      viewHolder.rowTextView.text = listData[groupPosition].rows[childPosition].rowName
      return view
   }
```

### コードの説明
カスタムアダプター (TestAdapter) のコードを説明していきます。
#### 1. データクラスの定義
 ```kotlin
 data class TestRow(val rowName : String)
 data class TestSection(val sectionName : String, val rows : List<TestRow>)
 ```
`TestRow` は、行を表すデータクラスで、`rowName` という名前のプロパティを持っています。
`TestSection` は、セクションを表すデータ クラスで、`sectionName` というセクション名のプロパティと、`rows` という行のリストを持っています。

#### 2. アダプタークラス `TestAdapter`
```kotlin
class TestAdapter(private val listData : List<TestSection>) : BaseExpandableListAdapter() {
```
`TestAdapter` は、`BaseExpandableListAdapter` を継承しており、`ExpandableListView` に表示するためのデータとビューを提供します。
`listData` は、`TestSection` のリストで、各セクションとその中の行データを保持します。

#### 3. ViewHolderの定義:
```kotlin
inner class SectionHolder(cell: View){
    val sectionTextView = cell.findViewById<TextView>(R.id.sectionTitle)!!
}
inner class RowHolder(cell:View){
    val rowTextView = cell.findViewById<TextView>(android.R.id.text1)!!
}
```
`SectionHolder` と `RowHolder` は、それぞれセクションと行のビューを保持するための内部クラスです。

#### 4. アダプターの基本的なメソッドの実装
```kotlin
// セクションの数
override fun getGroupCount(): Int = listData.size
```

```kotlin
// 指定されたセクションに属する行の数
override fun getChildrenCount(groupPosition: Int): Int = listData[groupPosition].rows.size
```

```kotlin
// 指定されたセクションのデータ
override fun getGroup(groupPosition: Int): Any = listData[groupPosition]
```

```kotlin
// 指定されたセクション内の行データ
override fun getChild(groupPosition: Int, childPosition: Int): Any = listData[groupPosition].rows[childPosition]
```

```kotlin
// セクションと行の一意のID
override fun getGroupId(groupPosition: Int): Long = groupPosition.toLong()
override fun getChildId(groupPosition: Int, childPosition: Int): Long = childPosition.toLong()
```

```kotlin
// 固定 ID を使用するかどうかを設定
override fun hasStableIds(): Boolean = false
```

```kotlin
// 行が選択可能かどうかを設定
override fun isChildSelectable(groupPosition: Int, childPosition: Int): Boolean = true
```
  

#### 5. ビューを提供するメソッドの実装
今回は、セクションのレイアウトのみカスタムして、行のほうはデフォルトで用意されている **android.R.layout.simple_list_item_1** を 使用しています。
```kotlin:セクション
override fun getGroupView(groupPosition: Int, isExpanded: Boolean, convertView: View?, parent: ViewGroup?): View {
   val view : View
   val sectionHolder : SectionHolder
   when (convertView){
      null -> {
         val inflater = LayoutInflater.from(parent?.context)
         view = inflater.inflate(R.layout.listview_section, parent, false)
         sectionHolder = SectionHolder(view)
         view.tag = sectionHolder
      }
      else -> {
         view = convertView
         sectionHolder = view.tag as SectionHolder
      }
   }
   sectionHolder.sectionTextView.text = listData[groupPosition].sectionName
   return view
```
`getGroupView()` は、セクションのビューを提供します。再利用可能なビューがない場合は、新しいビューを生成し、`SectionHolder` にセクション名を設定します。

```kotlin:行
override fun getChildView(groupPosition: Int, childPosition: Int, isLastChild: Boolean, convertView: View?, parent: ViewGroup?): View {
   val view : View
   val rowHolder : RowHolder
   when (convertView){
      null -> {
         val inflater = LayoutInflater.from(parent?.context)
         view = inflater.inflate(android.R.layout.simple_list_item_1, parent, false)
         rowHolder = RowHolder(view)
         view.tag = rowHolder
      }
      else -> {
         view = convertView
         rowHolder = view.tag as RowHolder
      }
   }
   rowHolder.rowTextView.text = listData[groupPosition].rows[childPosition].rowName
   return view
}
```
`getChildView()` は、行のビューを提供します。再利用可能なビューがない場合は、新しいビューを生成し、`RowHolder` に行の名前を設定します。

## セクションのレイアウト
単純に TextView が一つあるレイアウトです。android:background に角が丸くなった長方形リソース（res/drawable/section_header.xml）を設定しています。

```xml:res/layout/listview_section.xml
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:background="@drawable/section_header">

    <TextView
        android:layout_width="0dp"
        android:layout_height="wrap_content"
        android:id="@+id/sectionTitle"
        app:layout_constraintTop_toTopOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintBottom_toBottomOf="parent"
        android:layout_marginTop="4dp"
        android:layout_marginBottom="4dp"
        android:layout_marginStart="8dp"
        android:layout_marginEnd="8dp"
        android:textColor="@android:color/white"
        android:textSize="16sp"
        android:textStyle="bold" />
</androidx.constraintlayout.widget.ConstraintLayout>
```

```xml:res/drawable/section_header.xml
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
    android:shape="rectangle">
    <solid android:color="@color/black" />
    <corners android:radius="8dp" />
</shape>
```
## テスト アプリの見た目
![testapp.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3853334/dce73880-ec33-c1ca-327a-ff0c7f61d3bb.png)


## さいごに
見た目を同じにしたい場合は、UI フレームワークとか使用すればいいじゃない？とも思いますが、そこまではいいんだよなってやつです。

冒頭スクリーンショットのアプリは、これなので動作を見たい方は使ってやってください。150円の有料アプリですが、電車１駅分ぐらいの価値はあるかな～と思っています。

Android:
https://play.google.com/store/apps/details?id=com.jyro.nettorechner

iOS:
https://apps.apple.com/jp/app/%E6%89%8B%E5%8F%96%E3%82%8A%E8%A8%88%E7%AE%97%E6%A9%9F/id1441410311

## 参考URL
https://kamiya-kizuku.hatenablog.com/entry/2018/03/03/ExpandableListView%E3%81%A7%E3%82%BB%E3%82%AF%E3%82%B7%E3%83%A7%E3%83%B3%E8%A1%A8%E7%A4%BA%E3%82%92%E3%82%AD%E3%83%AC%E3%82%A4%E3%81%AB%E5%AF%BE%E5%BF%9C%EF%BC%81