やりたいこと
RecyclerViewのデータ更新をnotifyDataSetChangedとかを使わずに、楽してやりたい。。
サンプルアプリの仕様
話を単純化するためにシンプルなものを作ります。
- Addボタンを押すとリストが追加される
- アイテムをタップすると削除される
実装
基本的にはMVVMを意識した設計になっています。
登場人物はMainActivityとそれに紐づいたMainViewModel、
Adapterと各RecyclerView.ViewHolderに紐づいたItemViewModel、そしてDiffUtil.Callbackです。
MainActivity
ボタンとRecyclerViewだけを定義したシンプルな画面です。
MainViewModelのbindingをやっています。
RecyclerViewの app:viewModels="@{viewModel.items}"
はBindingAdapterを定義しています。ここの実装は後述します。
class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val binding = DataBindingUtil.setContentView<ActivityMainBinding>(this, R.layout.activity_main)
binding?.viewModel = MainViewModel()
binding?.adapter = ItemAdapter()
}
}
<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools">
<data>
<variable
name="adapter"
type="com.sample.recyclerviewsample.adapter.ItemAdapter" />
<variable
name="viewModel"
type="com.sample.recyclerviewsample.MainViewModel" />
</data>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:gravity="center_horizontal"
android:orientation="vertical"
tools:context=".MainActivity">
<android.support.v7.widget.RecyclerView
android:id="@+id/recycler_view"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"
android:adapter="@{adapter}"
app:layoutManager="android.support.v7.widget.LinearLayoutManager"
app:viewModels="@{viewModel.items}" />
<Button
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:onClick="@{() -> viewModel.onClickAddButton()}"
android:text="ADD" />
</LinearLayout>
</layout>
次はMainActivity用のViewModelです。RecyclerViewで表示するアイテムリストを保持しています。
ここで大事なのはObservableArrayListでadapterで使用するリストを管理していることです。これをBindingAdapterを使ってRecyclerViewにセットしています。
また、今回はとりあえずViewHolderのクリックイベントもここで受け取っています。
class MainViewModel: ItemViewModel.Listener {
val items: ObservableArrayList<ItemViewModel> = ObservableArrayList()
fun onClickAddButton() {
// クリックイベントを受け取ったらitemsにitemを追加します
items.add(ItemViewModel("No.${items.size}", items.size, this))
}
// itemをクリックされたら、クリックされたアイテムを削除します
override fun onClick(id: Int) {
val target = items.find { it.id == id }
items.remove(target)
}
}
リストアイテム
ここはViewModelのtitleを素直にViewHolderに設定しているだけです
class ItemViewModel(val title: String = "",
val id: Int = 0,
private val listener: Listener) {
fun onClick() = listener.onClick(id)
interface Listener {
fun onClick(id: Int)
}
}
<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android">
<data>
<variable
name="viewModel"
type="com.shcahill.recyclerviewsample.adapter.ItemViewModel" />
</data>
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:onClick="@{() -> viewModel.onClick()}"
android:text="@{viewModel.title}" />
</layout>
Adapter
Adapter自体は特筆すべきところはありません。
class ItemAdapter : Adapter<ItemAdapter.ViewHolder>() {
var items: List<ItemViewModel> = listOf()
override fun getItemCount(): Int = items.size
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
val binding = RowItemBinding.inflate(LayoutInflater.from(parent.context), parent, false)
return ViewHolder(binding)
}
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
holder.binding.viewModel = items[position]
}
class ViewHolder(val binding: RowItemBinding) : RecyclerView.ViewHolder(binding.root)
}
BindingAdapter and DiffUtil
ここからが大事なところです。
DiffUtilで使用するDiffUtil.Callbackを下記のように定義しました。
記述場所はどこでもよいですが、私はAdapterに記述しました。
class Callback(private val old: List<ItemViewModel>,
private val new: List<ItemViewModel>) : DiffUtil.Callback() {
override fun getOldListSize(): Int = old.size
override fun getNewListSize(): Int = new.size
override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean =
old[oldItemPosition].id == new[newItemPosition].id
override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean =
old[oldItemPosition].title == new[newItemPosition].title
}
oldとnewのリストを受け取り、差分のチェックを行うコールバックです。オブジェクト自体が変わっている場合はareItemsTheSame
でfalseが返るように実装します。また、オブジェクトは同じでも、中のコンテンツが変更されている場合はareContentsTheSame
でfalseが返るように実装します。
このCallbackは次のBindingAdapterで使用します。
@BindingAdapter("viewModels")
fun setViewModels(recyclerView: RecyclerView, items: ObservableArrayList<ItemViewModel>) {
val adapter = recyclerView.adapter as ItemAdapter
val diff = DiffUtil.calculateDiff(Callback(adapter.items, items), true)
// ここでListに変換してあげないとdispatchUpdatesToをかけれない
adapter.items = items.toList()
diff.dispatchUpdatesTo(adapter)
}
BindingAdapterでRecyclerViewにViewModelのリストをセットする口を用意します。RecyclerViewはObservableArrayListで受け取っているため、リストの更新があるとここに処理が飛んできます。その際、adapterが保持しているリストと、新しく受け取るリストの差分を先ほどのCallbackでチェックします。その結果がdiff
です。
このdiffに対してdispatchUpdatesTo
を実行してあげることでadapterの最適なnotify系のメソッドがコールされます。
ここで、注意すべきことは、引数で渡ってきたitemsはObservableArrayListのため、toListで単純なリストに変換してあげる必要があるようです。これをやらないと、DiffUtilをかける前に、勝手にadapterのリストが更新されてしまうため、RecyclerViewの更新がうまく実行されませんでした。
自分のやり方がマズいのかもしれませんが、もっとシンプルなやり方があればご指摘いただけると幸いです。