Add Drag and Drop on RecyclerView with DiffUtil
Asked Answered
K

3

9

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?

Ked answered 20/7, 2021 at 6:59 Comment(6)
It's been a while but shouldn't the positions be adapterPosition instead of bindingAdapterPosition or is that a new thing due to ViewBinding?Cleaning
adapterPosition is now deprecated and replaced with absoluteAdapterPosition or bindingAdapterPositionKed
Ahh thanks. I should read the docs more often. I think the issue my be the "time" it takes the collection to be copied (to a mutable list), then the items moved, and finally the submit list needs to use the Diff Util to compute the changes. Most of this is asynchronous so it may be an timing issue. Are you using DiffUtil or AsyncDiffUtil with your list? Can you show your diff Util? How many items do you have? How many calls are you seeing in onItemMoved (while you drag)?Cleaning
Yeah, it's probably a time issue. The ListAdapter uses AsyncListDiffer. My diff util compares id's onareItemsTheSame and then the contents on areContentsTheSame. I even tried to do it without the custom object with many contents and rather only use string but still gets the same issue. The onItemMoved are called the same amount as the number of position changes when dragged slowly, and inconsistent counts (less than the supposed amount as some are skipped) when dragged quickly.Ked
(unrelated) but you can save the Collections.Swap call with Kotlin's magic of value capture: list[from] = list[to].also { list[to] = list[from] } (though I'd argue this is more obscure for newcomers and basically anyone who reads this code) :)Cleaning
Thanks, I didn't know thatKed
K
5

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)
})
Ked answered 20/7, 2021 at 16:2 Comment(1)
Good solution; a tiny optimization would be to save the DiffUtilCallback instance and not create a new one when you submit list, since those can be reused. It will save you some allocation time. You can call DiffUtil.calculateDiff(callback) any time.Cleaning
C
4

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.

Cleaning answered 20/7, 2021 at 9:20 Comment(1)
Thank you for the detailed explanation. I tried to implement a new adapter and not rely on the ListAdapter's behavior on this answer, and it's working as expected so far. Thanks.Ked
B
3

I've tried danartillaga's answer and got a ConcurrentModificationException for the list variable. I use LiveData in the code and it looks like the data was changed during invalidation of the list.

I've tried to keep the ListAdapter implementation and concluded to the following solution:

class MyListAdapter : ListAdapter<Item, RecyclerView.ViewHolder>(MyDiffUtil) {

    var modifiableList = mutableListOf<Item>()
        private set

    fun moveItem(from: Int, to: Int) {
        Collections.swap(modifiableList, to, from)
        notifyItemMoved(from, to)
    }

    override fun submitList(list: List<CourseData>?) {
        modifiableList = list.orEmpty().toMutableList()
        super.submitList(modifiableList)
    }
}

and the onMove code from ItemTouchHelper.SimpleCallback:

override fun onMove(recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder, target: RecyclerView.ViewHolder): Boolean {
    val adapter = recyclerView.adapter as CoursesDownloadedAdapter
    val from = viewHolder.bindingAdapterPosition
    val to = target.bindingAdapterPosition
    val list = adapter.modifiableList

    // Change your DB here

    adapter.moveItem(from, to)
    return true
}

The magic here is saving the modifiableList inside the adapter. ListAdapter stores a link to the list from submitList call, so we can change it externally. During the Drag&Drop the list is changed with Collections.swap and RecyclerView is updated with notifyItemMoved with no DiffCallback calls. But the data inside ListAdapter was changed and the next submitList call will use the updated list to calculate the difference.

Bighorn answered 23/11, 2022 at 12:13 Comment(3)
Any idea how to do with Java code?Environs
It's almost the sameBighorn
This works for me as it should, thank you for an idea! I'm kinda confused why google deliver the library but some things like dragging not working out of the box :(Patrilineal

© 2022 - 2024 — McMap. All rights reserved.