これまでRecyclerViewで異なるレイアウトのコンポーネントを組み合わせる場合、共通のAdapter内でViewHolderを複数作成し、viewType
で処理を分けるのが一般的だったと思います。
その結果、Adapterが肥大化して可読性が悪くなってしまう経験をした方は多いのではないでしょうか。
そんなあなたへの朗報、MergeAdapterを使うことでAdapterを分離し、スッキリと書くことができるようになりました。
この記事では、このMergeAdapterについて簡単な実装からちょっとした疑問点へのお答えまでできればと思います。
- 実装方法
- 1つのAdapterのみ更新したらどうなるのか
- viewTypeは指定しなくて良いのか
MergeAdapterとは
ドキュメントはこちら。
An Adapter implementation that presents the contents of multiple adapters in sequence
複数のadapterを順番に、1つのadapterにまとめられるよ~という感じです。
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個
なお、今回作ったサンプルコードはこちら
recyclerview:1.2.0-alpha02を使う
このMergeAdapterは recyclerview:1.2.0-alpha02
以降で使えるため、これ以降のバージョンを指定します。
implementation 'androidx.recyclerview:recyclerview:1.2.0-alpha02'
セクションごとにAdapterを定義する
まず、それぞれのセクションのAdapterを実装します
以下は、TextViewの部分のAdapterです
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)
この1つのレイアウトに関する操作だけで済むため、Adapterがスッキリしてるかと思います。
また同様の理由で、getItemViewType
もオーバーライドしてません。
同じようにして、他のセクション用のAdapterも定義します。
各AdapterをMergeしてRecyclerViewにセットする
MergeAdapterを使って各AdapterをMergeしてRecyclerViewにセットして準備OKです。
なお、MergeAdapterの引数に入れた順番にadapterが結合されます。
UIの表示順を変えたい時には、ここで引数に入れる順番を変えてください。
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にして流し込んでいます。
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)
これで、初期表示で以下のような画面を表示することができます。
1つのAdapterのみ更新したらどうなるのか
ここで疑問に感じるのが、もし一部だけのadapterのアイテムを更新したらどうなるの??ということです。
この一部のadapterにだけ更新が通知されるのか、あるいはmergeされた全てのadapterに通知されるのか。
結論から言うと、更新をかけたadapterのみで更新が走り、他のadapterには影響はないようです。
これを検証するため、TOGGLE BUTTONを押下したら、Switchセクションのみ全てのswitch状態を反転させるようにしてみました。
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コールバックの箇所にもログを入れます。
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が呼ばれているのかは分かりません。。)
D/buttonSectionAdapter: onClicked
D/SwitchSectionAdapter: ItemCallback
D/SwitchSectionAdapter: ItemCallback
ViewTypeは指定しなくて良いのか
1つのAdapterないでViewHolderを使い分ける時にはvewTypeを使い分けていましたが、MergeAdapterを使う場合には不要なのでしょうか。
結論から言うと、mergeされる各Adapter間で異なるViewHolderを使うのであれば不要です。
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
になっています。
val mergeAdapter = MergeAdapter(
// このように明示的にViewTypeを独立させることを指定することもできるが、指定無しでもデフォルトでそうなっている
MergeAdapter.Config.Builder().setIsolateViewTypes(true).build(),
textSectionAdapter,
buttonSectionAdapter,
switchSectionAdapter
)
なお、このisolateViewTypes
がtrueの場合、以下のようにしてMergeAdapter側でadapterごとにViewTypeの値を++して区別するようにしています。
| 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; |
| } |
逆に、adapter間でViewHolderを共有したい場合には、setIsolateViewTypes(false)
にし、各adapterで明示的にidを指定してあげる必要があります。
参考:https://medium.com/androiddevelopers/merge-adapters-sequentially-with-mergeadapter-294d2942127a
まとめ
このように、MergeAdapterを使うことで、複数のViewHolderを使ってのRecyclerViewが扱いやすくなりました。
皆さんもぜひ使ってみてください。
なお、今回作ったサンプルコードはこちら。