Add Drag and Drop on RecyclerView with DiffUtil

Viewed 1337

I have a list that gets updated from a Room Database. I receive the updated data from Room as a new list and I then pass it to ListAdapter's submitList to get animations for the changes.

list.observe(viewLifecycleOwner, { updatedList ->
    listAdapter.submitList(updatedList)
})

Now, I want to add a drag and drop functionality for the same RecyclerView. I tried to implement it using ItemTouchHelper. However, the notifyItemMoved() is not working as ListAdapter updates its content through the submitList().

override fun onMove(
    recyclerView: RecyclerView,
    viewHolder: RecyclerView.ViewHolder,
    target: RecyclerView.ViewHolder
): Boolean {

    val from = viewHolder.bindingAdapterPosition
    val to = target.bindingAdapterPosition

    val list = itemListAdapter.currentList.toMutableList()
    Collections.swap(list, from, to)

    // does not work for ListAdapter
    // itemListAdapter.notifyItemMoved(from, to)

    itemListAdapter.submitList(list)

    return false
}

The drag and drop now works fine but only when dragged slowly, when the dragging gets fast enough, I get different and inconsistent results.

Screen record

What could be the reason for this? What is the best way that I can achieve a drag and drop functionality for my RecyclerView which uses ListAdapter?

2 Answers

So I made a quick test (this whole thing doesn't fit in a comment so I'm writing an answer)

My Activity contains the adapter, RV, and observes a viewModel. When the ViewModel pushes the initial list from the repo via LiveData, I save a local copy of the list in mutable form (just for the purpose of this test) so I can quickly mutate it on the fly.

This is my "onMove" implementation:

val from = viewHolder.bindingAdapterPosition
val to = target.bindingAdapterPosition

list[from] = list[to].also { list[to] = list[from] }

adapter.submitList(list)
return true

I also added this log to verify something:

Log.d("###", "onMove from: $from (${list[from].id}) to: $to (${list[to].id})")

And I noticed it.. works. But because I'm returning true (you seem to be returning false).

Now... unfortunately, if you drag fast up and down, this causes the list to eventually become shuffled:

E.g.: Let's suppose there are 10 items from 0-9.

You want to grab item 0 and put it between item 1 and 2.

You start Dragging item 0 at position 0, and move it a bit down so now it's between 1 and 2, the new item position in the onMove method is 1 (so far, you're still dragging). Now if you slowly drag further (to position 2), the onMove method is from 1 to 2, NOT from 0 to 2. This is because I returned "true" so every onMove is a "finished operation". This is fine, since the operations are slow and the ListAdapter has time to update and calculate stuff.

But when you drag fast, the operations go out of sync before the adapter has time to catch up.

If you return false instead (like you do) then you get various other effects:

  • The RecyclerView Animations don't play (while you drag) since the viewHolders haven't been "moved" yet. (you returned false)
  • The onMove method is then spammed every time you move your finger over a viewHolder, since the framework wants to perform this move again... but the list is already modified...

So you'd get something like (similar example above, 10 items, moving the item 0)> let's say each item has an ID that corresponds to its position+1 (in the initial state, so item at position 0 has id 1, item at position 1 has id 2, etc.)

This is what the log shows while I slowly drag item 0 "down": (format is `from: position(id of item from) to: position(id of item to)

onMove from: 0 (1) to: 1 (2) // Initial drag of first item down to 2nd item.
onMove from: 0 (2) to: 1 (1) // now the list is inverted, notice the IDs.
onMove from: 0 (1) to: 1 (2) // Back to square one.
onMove from: 0 (2) to: 1 (1) // and undo-again...

I just cut it there, but you can see how it's bouncing all over the place back and forth. I believe this is because you return false but modify the list behind the scenes, so it's getting confused. on one side of the equation the "data" says one thing, (and so does the diff util), but on the other, the adapter is oblivious to this change, at least "yet" until the computations are done, which, as you guessed, when you drag super fast, is not enough time.

Unfortunately, I don't have a good answer (today) as to what would the best approach be. Perhaps, not relying on the ListAdapter's behavior and implementing a normal adapter, where you have better list/source control of the data and when to call submitList and when to simply notifyItemChanged or moved between two positions may be a better alternative for this use-case.

Apologies for the useless answer.

I ended up implementing a new adapter and use it instead of ListAdapter, as mentioned on Martin Marconcini's answer. I added two separate functions. One for receiving updates from Room database (replacement for submitList from ListAdapter) and another for every position change from drag

MyListAdapter.kt

class MyListAdapter(list: ArrayList<Item>) : RecyclerView.Adapter<RecyclerView.ViewHolder>() {

    // save instance instead of creating a new one every submit 
    // list to save some allocation time. Thanks to Martin Marconcini
    private val diffCallback = DiffCallback(list, ArrayList())

    fun submitList(updatedList: List<Item>) {
        diffCallback.newList = updatedList
        val diffResult = DiffUtil.calculateDiff(diffCallback)

        list.clear()
        list.addAll(updatedList)
        diffResult.dispatchUpdatesTo(this)
    }

    fun itemMoved(from: Int, to: Int) {
        Collections.swap(list, from, to)
        notifyItemMoved(from, to)
    }

}

DiffCallback.kt

class DiffCallback(
    val oldList: List<Item>,
    var newList: List<Item>
) : DiffUtil.Callback() {

    override fun getOldListSize(): Int {
        return oldList.size
    }

    override fun getNewListSize(): Int {
        return newList.size
    }

    override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean {
        val oldItem = oldList[oldItemPosition]
        val newItem = newList[newItemPosition]
        return oldItem.id == newItem.id
    }

    override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean {
        val oldItem = oldList[oldItemPosition]
        val newItem = newList[newItemPosition]
        return compareContents(oldItem, newItem)
    }
}

Call itemMoved every position change:

override fun onMove(
    recyclerView: RecyclerView,
    viewHolder: RecyclerView.ViewHolder,
    target: RecyclerView.ViewHolder
): Boolean {
    val from = viewHolder.bindingAdapterPosition
    val to = target.bindingAdapterPosition
    itemListAdapter.itemMoved(from, to)

    // Update database as well if needed

    return true
}

When receiving updates from Room database:

You may also want to check if currently dragging using onSelectedChanged if you are also updating your database every position change to prevent unnecessary calls to submitList

list.observe(viewLifecycleOwner, { updatedList ->
    listAdapter.submitList(updatedList)
})
Related