RecyclerViewでnotifyItemInsertedやnotifyItemRemovedを使う時の注意点


背景

notifyItemInsertednotifyItemRemovedを使っていると、消したいデータと違うデータが消えたり、IndexOutOfExceptionが起こってアプリがクラッシュしたので、原因を調べてみました。

以下の3つを順番に実行した時のAdapter内のリストの状態RecyclerViewのデータ項目の状態アプリ画面の状態がどうなっているのか図で示しながら解説していきます。その後、対処法を示していきます。

  • RecyclerViewを初期化した時
  • リストにデータを追加した時
  • notifyItemInsertedを呼び出した時

RecyclerViewを初期化した時

今回、原因を調べるために作ったAdapterクラスです。それぞれの状態だけを見ると、間違っていなさそうに見えます。
しかし、この状態で削除ボタン(R.id.deleteButton)を押すと、一度目は正しいデータが削除されますが、二度目は、違うデータが消えたり、IndexOutOfExceptionが起こります。

MyRecyclerViewAdapter.kt
class MyRecyclerViewAdapter(private val context: Context, private val sampleList: MutableList<String>) :
    RecyclerView.Adapter<MyRecyclerViewAdapter.MyViewHolder>() {

    class MyViewHolder(view: View) : RecyclerView.ViewHolder(view) {
        val contentTextView: TextView = view.findViewById(R.id.content)
        val deleteButton: Button = view.findViewById(R.id.deleteButton)
    }

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): MyViewHolder {
        return MyViewHolder(LayoutInflater.from(context).inflate(R.layout.sample_list, parent, false))
    }

    override fun getItemCount(): Int = sampleList.size

    override fun onBindViewHolder(holder: MyViewHolder, position: Int) {
        holder.contentTextView.text = "${sampleList[position]} + $position"
        holder.deleteButton.setOnClickListener {
            sampleList.removeAt(position)
            notifyItemRemoved(position)
        }
    }
    fun addItem(position: Int, string: String){
        sampleList.add(position, string)
    }
}

リストにデータを追加した時

Adapter内のリストには変化がありますが、データを追加したことを通知していないため、RecyclerViewのデータ項目の状態やアプリ画面に変化はありません。

MainActivity.kt
//追加するコード
addButton.setOnClickListener {
    (recyclerView.adapter as MyRecyclerViewAdapter).addItem(1, "Z")
}

notifyItemInserted(position=1)を実行した時

notifyItemInserted(position: Int)は、指定した位置に新しいデータが追加されたことを通知しています。指定した位置にあったデータ項目は元の位置 + 1されます。追加したデータは指定した位置でバインドされます。しかし、位置がズレたデータ項目は、リバインドされないのでTextViewや削除ボタンの処理が更新されません。そのため位置2にある削除ボタンの処理を実行すると、リスト.removeAt(1)が実行され、消したいデータと違ったデータが削除されるわけです。

MainActivity.kt
addButton.setOnClickListener {
    (recyclerView.adapter as MyRecyclerViewAdapter).addItem(1, "Z")    
    //追加するコード
    recyclerView.adapter?.notifyItemInserted(1)
}

対処方法

追加や削除を通知した後、notifyItemRangeChangedを使う

notifyItemRangeChanged(startPosition: Int, itemCount: Int)は、startPositionからitemCountまでの範囲に、データの変更があったことを通知し、リバインドしてくれます。そのため、TextViewの値や削除ボタンの処理が適したものに更新されます。

MainActivity.kt
addButton.setOnClickListener {
    (recyclerView.adapter as MyRecyclerViewAdapter).addItem(1, "Z")    
    recyclerView.adapter?.notifyItemInserted(1)
    //追加するコード
    recyclerView.adapter?.notifyItemRangeChanged(2, sampleList.size)
}
MyRecyclerViewAdapter.kt

override fun onBindViewHolder(holder: MyViewHolder, position: Int) {
    holder.contentTextView.text = "${sampleList[position]} + $position"
    holder.deleteButton.setOnClickListener {
        sampleList.removeAt(position)
        notifyItemRemoved(position)
        //追加するコード
        notifyItemRangeChanged(position, itemCount)
    }
}

まとめ

notifyItemInsertednotifyItemRemovedを使うなら、notifyItemRangeChangedを一緒に使いましょう。
notifyDataSetChangedを使うとどちらもやってくれますが、Documentであまりおすすめされていないのとアニメーションがされません。
もし、ここ間違っているよ等あれば、コメントで教えてください!