この記事はAndroid #2 Advent Calendar 2019の10日目の記事になります。
はじめに
DiffUtilとListAdapterでリスト(RecyclerView)を実装すると、 ListAdapter.submitList()
でアイテム配列を送るだけで、データの差分をとって必要なアイテムの追加や更新、削除をDiffUtil側で勝手に行ってくれます。
そんな便利なDiffUtilですが、自分が実装する上でその動作を勘違いしていたことがあったため、同じ轍を踏む人が生まれないように、その注意点を共有したいと思います。
ちなみに今回対象となるDiffUtilは、以下のようなシンプルな作りのものとしています。
class SampleDiffUtilListAdapter :
ListAdapter<SampleDiffUtilListAdapter.SampleDillUtilItem, RecyclerView.ViewHolder>(DIFF_CALLBACK) {
companion object {
private val DIFF_CALLBACK = object : DiffUtil.ItemCallback<SampleDillUtilItem>() {
override fun areItemsTheSame(
oldItem: SampleDillUtilItem,
newItem: SampleDillUtilItem
): Boolean {
return oldItem.id == newItem.id
}
override fun areContentsTheSame(
oldItem: SampleDillUtilItem,
newItem: SampleDillUtilItem
): Boolean {
return oldItem.count == newItem.count
}
}
}
// 中略
data class SampleDillUtilItem(
val id: Int
)
}
なお、全体のサンプルコードはGitHubで公開しています。
SampRa-android/app/src/main/java/io/github/yamacraft/app/sampra/ui/diffutil
areContentsTheSame()がデータの更新を検出しない
リストに送るアイテム配列を更新してsubmitList()
で送ることで、DiffUtilが差分をチェックし、変更があった項目の表示を更新してくれます。
この時、データの差分を行うのが areContentsTheSame()
です。
例として、SampleDillUtilItemにcountという変数を追加し、リストの該当項目がタップされた回数を記録してリストに反映するように実装します。
private val _items = MutableLiveData<List<SampleDiffUtilListAdapter.SampleDillUtilItem>>()
val items: LiveData<List<SampleDiffUtilListAdapter.SampleDillUtilItem>> = _items
fun itemCountUp(id: Int) {
_items.value = _items.value?.map {
if (it.id == id) {
it.count = it.count + 1
}
it
}
}
class SampleDiffUtilActivity : AppCompatActivity() {
private lateinit var viewModel: SampleDiffUtilViewModel
private lateinit var listAdapter: SampleDiffUtilListAdapter
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_list_adapter)
viewModel = ViewModelProviders.of(this)[SampleDiffUtilViewModel::class.java]
viewModel.items.observe(this, Observer {
listAdapter.submitList(it)
})
}
listAdapter = SampleDiffUtilListAdapter().apply {
// クリックリスナーの実装部分の解説は省略します
onItemClickListener = {
viewModel.itemCountUp(it.id)
}
}
recycler_view.apply {
adapter = listAdapter
layoutManager = LinearLayoutManager(context)
}
}
}
この実装で、リストの項目がタップされるたびにitemが更新されて、submitList()
で送られていくことまでは確認できますが、リストの表示が更新されません。
問題はitemCountUp()
内の処理です。
このやり方では、areContentsTheSame()
のoldItemとnewItemが同一のオブジェクトを参照することになってしまうので、比較結果がtrue(同一)と判断されてリストの更新処理が走りません。
fun itemCountUp(id: Int) {
_items.value = _items.value?.map {
if (it.id == id) {
it.copy(count = it.count + 1)
} else {
it
}
}
}
このようにcopy()
を使って、該当アイテムがオブジェクトごと変わるようにしましょう。
そうなると、countの変数自体もmutableにする必要がありません。valで宣言しましょう。
そもそもこのようなミスを無くすためにも、アイテム配列に使うdata class内の変数は、全てImmutableで宣言するようにすべきでした。
DiffUtilのデータ追加の仕様について
DiffUtilはユーザーのUXを損なわないようにするため、現在表示している項目を基準に、データの追加や更新が行われます。
例えばSwipeToRefreshを実装し、更新のたびに固定のデータを毎回ランダムに並び替えて取得するようにしてみます。
fun refresh() {
_items.value = createItems()
}
private fun createItems(): List<SampleDiffUtilListAdapter.SampleDillUtilItem> {
val items = mutableListOf<SampleDiffUtilListAdapter.SampleDillUtilItem>()
for (i in 0..100) {
items.add(SampleDiffUtilListAdapter.SampleDillUtilItem(i, 0))
}
return items.toList().shuffled()
}
動作させると、このように動きます。
現時点で一番上に表示されている項目が視覚的に動くことなく、リストのデータが追加されたり移動されていることが分かります。
この動作はDiffUtilの仕様です。
そのため、更新後にリストのスクロールを一番上にもっていきたい場合は、主に2つの方法があります。
更新データを送る前に、先にnullをsubmitList()
に送ることでリストを空にする方法、listAdapterのAdapterDataObserverを使って、データ更新のたびにscrollToPosition()
する方法があります。
一度リストを空にする方法の場合、一瞬リストがまっさらになる瞬間がユーザーに見えてしまう欠点がある代わりに簡単に実装ができます。
AdapterDataObserverで制御する方法は、ちらつきがない代わりに、実装の難易度が高めです(適当に実装すると、意図しない更新でもスクロールが移動するため)。
まとめ
- リストに使うアイテム(data class)は、全てImmutableで宣言しましょう
- DiffUtilのデータ追加時の挙動は把握した上で実装しましょう