MergeAdapterを試してみた

これまでRecyclerViewで異なるレイアウトのコンポーネントを組み合わせる場合、共通のAdapter内でViewHolderを複数作成し、`viewType`で処理を分けるのが一般的だったと思います。

その結果、Adapterが肥大化して可読性が悪くなってしまう経験をした方は多いのではないでしょうか。

そんなあなたへの朗報、**MergeAdapter**を使うことでAdapterを分離し、**スッキリ**と書くことができるようになりました。

この記事では、このMergeAdapterについて簡単な実装からちょっとした疑問点へのお答えまでできればと思います。

- 実装方法
- １つのAdapterのみ更新したらどうなるのか
- viewTypeは指定しなくて良いのか

# MergeAdapterとは

ドキュメントは[こちら](https://developer.android.com/reference/kotlin/androidx/recyclerview/widget/MergeAdapter)。

>An Adapter implementation that presents the contents of multiple adapters in sequence

複数のadapterを順番に、１つのadapterにまとめられるよ~という感じです。

```kotlin
val adapter1: MyAdapter = ...
val adapter2: AnotherAdapter = ...
val merged = MergeAdapter(adapter1, adapter2)
recyclerView.adapter = merged
```
 

# 実装方法

### どんなのを実装するか
以下のように、3つのセクションで構成されるRecyclerViewを例に実装方法を紹介します。

- TextViewを持つコンポーネント x N個
- Buttonを持つコンポーネント x 1個
- Switchを持つコンポーネント x N個

なお、今回作ったサンプルコードは[こちら](https://github.com/nichiyoshi/android_merge_adapter_sample/blob/master/README.md)


![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/214755/f0d33a2e-4c0e-9d03-1dce-bc61341b6600.png)

### recyclerview:1.2.0-alpha02を使う

このMergeAdapterは `recyclerview:1.2.0-alpha02` 以降で使えるため、これ以降のバージョンを指定します。

```gradle
implementation 'androidx.recyclerview:recyclerview:1.2.0-alpha02'
```

### セクションごとにAdapterを定義する
まず、それぞれのセクションのAdapterを実装します

以下は、TextViewの部分のAdapterです

```kotlin
class TextSectionAdapter: ListAdapter<TextItem, TextSectionViewHolder>(DIFF_CALLBACK) {

    companion object {
        private val DIFF_CALLBACK = object : DiffUtil.ItemCallback<TextItem>() {
            override fun areContentsTheSame(
                oldItem: TextItem,
                newItem: TextItem
            ): Boolean {
                android.util.Log.d("TextSectionAdapter", "onChanged")
                return oldItem == newItem
            }

            override fun areItemsTheSame(
                oldItem: TextItem,
                newItem: TextItem
            ) = oldItem.id == newItem.id
        }
    }

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): TextSectionViewHolder {
        val binding = ItemSectionTextBinding.inflate(LayoutInflater.from(parent.context), parent, false)
        return TextSectionViewHolder(binding)
    }

    override fun onBindViewHolder(holder: TextSectionViewHolder, position: Int) {
        holder.bind(getItem(position))
    }


}

class TextSectionViewHolder(private val binding: ItemSectionTextBinding): RecyclerView.ViewHolder(binding.root) {
    fun bind(item: TextItem) {
        binding.textView.text = item.text
    }
}

data class TextItem(val id: Int, val text: String)
```

この１つのレイアウトに関する操作だけで済むため、Adapterがスッキリしてるかと思います。
また同様の理由で、`getItemViewType`もオーバーライドしてません。

同じようにして、他のセクション用のAdapterも定義します。

### 各AdapterをMergeしてRecyclerViewにセットする

**MergeAdapter**を使って各AdapterをMergeしてRecyclerViewにセットして準備OKです。
なお、**MergeAdapterの引数に入れた順番にadapterが結合**されます。
UIの表示順を変えたい時には、ここで引数に入れる順番を変えてください。

```kotlin
        val textSectionAdapter = TextSectionAdapter()
        val switchSectionAdapter = SwitchSectionAdapter()
        val buttonSectionAdapter = ButtonSectionAdapter()

        val mergeAdapter = MergeAdapter(
            textSectionAdapter,
            buttonSectionAdapter,
            switchSectionAdapter
        )

        recyclerView.apply {
            setHasFixedSize(true)
            layoutManager = LinearLayoutManager(context)
            adapter = mergeAdapter
        }
```

### 各Adapterのアイテムを更新する

今回は、TextのセクションとSwitchのセクションに、それぞれ15個の値をlistにして流し込んでいます。

```kotlin
        val textItems = arrayListOf<TextItem>()
        val switchItems = arrayListOf<SwitchItem>()

        (1..15).forEach {
            textItems.add(TextItem(id = it, text = it.toString()))
            switchItems.add(SwitchItem(id = it, isChecked = it%2==0))
        }
        
        textSectionAdapter.submitList(textItems)
        switchSectionAdapter.submitList(switchItems)
```

これで、初期表示で以下のような画面を表示することができます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/214755/f0d33a2e-4c0e-9d03-1dce-bc61341b6600.png)


# １つのAdapterのみ更新したらどうなるのか

ここで疑問に感じるのが、もし**一部だけのadapterのアイテムを更新したらどうなるの？？**ということです。
この一部のadapterにだけ更新が通知されるのか、あるいはmergeされた全てのadapterに通知されるのか。

結論から言うと、**更新をかけたadapterのみで更新が走り、他のadapterには影響はない**ようです。

これを検証するため、TOGGLE BUTTONを押下したら、Switchセクションのみ全てのswitch状態を反転させるようにしてみました。

```kotlin

            val buttonSectionAdapter = ButtonSectionAdapter() {
            android.util.Log.d("buttonSectionAdapter", "onClicked")

            // Switchセクションのアイテムを更新
            switchSectionAdapter.submitList(switchSectionAdapter.currentList.map { it.copy( isChecked = !it.isChecked) })

            }


class ButtonSectionAdapter(private val onClick: () -> Unit): RecyclerView.Adapter<ButtonSectionViewHolder>() {

...

}
```

同時に、SwitchASectiondapterとTextSectionAdapterのDiffUtilコールバックの箇所にもログを入れます。

```kotlin

private val DIFF_CALLBACK = object : DiffUtil.ItemCallback<SwitchItem>() {

            override fun areContentsTheSame(
                oldItem: SwitchItem,
                newItem: SwitchItem
            ): Boolean {
                android.util.Log.d("SwitchSectionAdapter", "ItemCallback")
                return oldItem == newItem
            }
...

}


  private val DIFF_CALLBACK = object : DiffUtil.ItemCallback<TextItem>() {
            override fun areContentsTheSame(
                oldItem: TextItem,
                newItem: TextItem
            ): Boolean {
                android.util.Log.d("TextSectionAdapter", "ItemCallback")
                return oldItem == newItem
            }
...

}
```

この状態でTOGGLE BUTTONを押下するとログは以下のようになり、**SwitchSectionAdapterのみ更新通知**され、TextSectionAdapterの方では更新処理がされていないことが分かります。

(SwitchSectionAdapterでなぜ二回ItemCallbackが呼ばれているのかは分かりません。。)

```terminal
D/buttonSectionAdapter: onClicked
D/SwitchSectionAdapter: ItemCallback
D/SwitchSectionAdapter: ItemCallback
```

# ViewTypeは指定しなくて良いのか

１つのAdapterないでViewHolderを使い分ける時にはvewTypeを使い分けていましたが、MergeAdapterを使う場合には不要なのでしょうか。

結論から言うと、mergeされる**各Adapter間で異なるViewHolderを使うのであれば不要**です。

MergeAdapterの初期化に[Config](https://developer.android.com/reference/kotlin/androidx/recyclerview/widget/MergeAdapter.Config)を設定でき、さらにその中で[isolateViewTypes](https://developer.android.com/reference/kotlin/androidx/recyclerview/widget/MergeAdapter.Config#isolateviewtypes)というフラグを指定することができます。

>By default, it is set to true which means MergeAdapter will isolate view types across adapters, preventing them from using the same ViewHolders.

このフラグにより各AdapterでviewTypeを独立させるかどうかを指定でき、デフォルトのConfig(Config.DEFAULT)では `isolateViewTypes = true` になっています。

```kotlin
val mergeAdapter = MergeAdapter(
            // このように明示的にViewTypeを独立させることを指定することもできるが、指定無しでもデフォルトでそうなっている
            MergeAdapter.Config.Builder().setIsolateViewTypes(true).build(),
            textSectionAdapter,
            buttonSectionAdapter,
            switchSectionAdapter
        )
```

なお、この`isolateViewTypes`がtrueの場合、以下のようにしてMergeAdapter側でadapterごとにViewTypeの値を++して区別するようにしています。



```java

|  class IsolatedViewTypeStorage implements ViewTypeStorage { |
|:--|
|         SparseArray<NestedAdapterWrapper> mGlobalTypeToWrapper = new SparseArray<>(); |
|         int mNextViewType = 0; |
|         int obtainViewType(NestedAdapterWrapper wrapper) { |
              // AdapterごとにViewTypeを++している
|             int nextId = mNextViewType++; |
|             mGlobalTypeToWrapper.put(nextId, wrapper); |
|             return nextId; |
|         } |

```

https://android.googlesource.com/platform/frameworks/support/+/androidx-master-dev/recyclerview/recyclerview/src/main/java/androidx/recyclerview/widget/ViewTypeStorage.java#125

逆に、adapter間でViewHolderを共有したい場合には、`setIsolateViewTypes(false)`にし、各adapterで明示的にidを指定してあげる必要があります。

参考：https://medium.com/androiddevelopers/merge-adapters-sequentially-with-mergeadapter-294d2942127a

# まとめ

このように、MergeAdapterを使うことで、複数のViewHolderを使ってのRecyclerViewが扱いやすくなりました。

皆さんもぜひ使ってみてください。

なお、今回作ったサンプルコードは[こちら](https://github.com/nichiyoshi/android_merge_adapter_sample/blob/master/README.md)。