ConcatAdapterを使ってRecyclerViewでExpandableListViewみたいなリストを作ろう

カテゴリーごとで、開閉可能なリストを作りたいなーと思ったことはないでしょうか。
ExpandableListViewというViewがAPI Level 1から存在しますが、ListViewの拡張で、ちょっと今となってはレガシーですし、拡張するのも大変なViewです。RecyclerViewを使いましょう。
しかし、RecyclerView#Adapterを工夫すれば作れますが、一から作るのはちょっと大変そうですね。
本稿執筆時点でまだbeta01ですが、recyclerviewの1.2.0からConcatAdapter（当初はMergeAdapterという名前でしたが、alpha04でConcatAdapterにリネームされました）というAdapterが使えるようになります。これを使えば比較的簡単に作ることができますのでそのご紹介。

![](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/155171/812d1324-59e0-032d-a2b2-bb97d7f21e22.gif)

## 作り方

ConcatAdapterは複数のAdapterをまとめて一つのAdapterとして機能させてくれるAdapterです。なので、一つのカテゴリー、つまり、一つのタイトルとそこに折りたたまれる要素を持つAdapterを作り、それを複数個ConcatAdapterでまとめれば完成です。
ここではシンプルにTextViewだけを並べたものを作ります。

まずはタイトル、テキストと展開状態を表すアイコンがあります。

```xml:cell_title.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"
    android:background="?attr/selectableItemBackground"
    >

    <TextView
        android:id="@+id/text"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_margin="8dp"
        android:textAppearance="@style/TextAppearance.MaterialComponents.Subtitle1"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent"
        />

    <ImageView
        android:id="@+id/expand_icon"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_margin="16dp"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintTop_toTopOf="parent"
        app:srcCompat="@drawable/ic_expand_more"
        />
</androidx.constraintlayout.widget.ConstraintLayout>
```

次が折りたたまれる要素で、TextViewが一つあるだけです。

```xml:cell_item.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"
    android:background="?attr/selectableItemBackground"
    >

    <TextView
        android:id="@+id/text"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_margin="8dp"
        android:textAppearance="@style/TextAppearance.MaterialComponents.Subtitle1"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent"
        />
</androidx.constraintlayout.widget.ConstraintLayout>
```

Adapterはカテゴリー一つを担当する形で、タイトルと折りたたまれた要素に表示するテキストのリストを受け取るようにします。

```kotlin:ExpandableItemAdapter.kt
class ExpandableItemAdapter(
    context: Context,
    private val title: String,
    private val items: List<String>
) : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
    private val layoutInflater: LayoutInflater = LayoutInflater.from(context)
    private var expanded: Boolean = false

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder =
        if (viewType == 0) TitleViewHolder(CellTitleBinding.inflate(layoutInflater, parent, false))
        else ItemViewHolder(CellItemBinding.inflate(layoutInflater, parent, false))

    override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
        if (holder is TitleViewHolder) {
            holder.binding.text.text = title
            holder.binding.expandIcon.setImageResource(
                if (expanded) R.drawable.ic_expand_less else R.drawable.ic_expand_more)
            holder.binding.root.setOnClickListener {
                toggleExpand()
            }
        } else if (holder is ItemViewHolder) {
            holder.binding.text.text = items[position - 1]
        }
    }

    override fun getItemCount(): Int = if (expanded) items.size + 1 else 1

    override fun getItemViewType(position: Int): Int = if (position == 0) 0 else 1

    private fun toggleExpand() {
        expanded = !expanded
        notifyItemChanged(0)
        if (expanded) {
            notifyItemRangeInserted(1, items.size)
        } else {
            notifyItemRangeRemoved(1, items.size)
        }
    }

    class TitleViewHolder(val binding: CellTitleBinding) : RecyclerView.ViewHolder(binding.root)

    class ItemViewHolder(val binding: CellItemBinding) : RecyclerView.ViewHolder(binding.root)
}
```

タイトルとアイテムの2種類のViewを持ち、タイトルのタップでアイテムの有無がトグルし、`notifyItemRangeInserted` / `notifyItemRangeRemoved`で追加削除される範囲をAdapterに伝えます。
カテゴリー一つ分なので非常にシンプルに実装することができます。

最後に、このAdapterをConcatAdapterでまとめます。

```kotlin:MainActivity.kt
binding.recyclerView.adapter = ConcatAdapter(
    ExpandableItemAdapter(this, "1", listOf("1-1", "1-2", "1-3")),
    ExpandableItemAdapter(this, "2", listOf("2-1", "2-2", "2-3")),
    ExpandableItemAdapter(this, "3", listOf("3-1", "3-2", "3-3")),
)
```

これで完成です。
全部が同一の表示要素なのなら、このように同じクラスのインスタンスを並べればいいですし、カテゴリーごとに表示が異なるならそれぞれでAdapterを作ってまとめれば複雑なUIも簡単に実装することができますね。

以上です。