Drag & Dropping the first item of the RecyclerView moves several random positions

Viewed 779

Currently, I have a RecyclerView implementing the new ListAdapter, using submitList to differ elements and proceed to update the UI automatically.

Lately i had to implement drag & drop to the list using the well known ItemTouchHelper. Here is my implementation, pretty straight forward:

class DraggableItemTouchHelper(private val adapter: DestinationsAdapter) : ItemTouchHelper.Callback() {
private val dragFlags = ItemTouchHelper.UP or ItemTouchHelper.DOWN
private val swipeFlags = 0

override fun isLongPressDragEnabled() = false
override fun isItemViewSwipeEnabled() = false

override fun getMovementFlags(recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder): Int {
    return makeMovementFlags(dragFlags, swipeFlags)
}

override fun onMove(
    recyclerView: RecyclerView,
    viewHolder: RecyclerView.ViewHolder,
    target: RecyclerView.ViewHolder
): Boolean {
    val oldPos = viewHolder.adapterPosition
    val newPos = target.adapterPosition

    adapter.swap(oldPos, newPos)
    return true
}

override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) {
}

}

this is my swap function inside the adapter:

fun swap(from: Int, to: Int) {
    submitList(ArrayList(currentList).also {
        it[from] = currentList[to]
        it[to] = currentList[from]
    })
}

Everything works well EXCEPT when moving the FIRST item of the list. Sometimes it behaves OK, but most of the time (like 90%), it snaps several positions even when moving it slightly above the second item (to move 1st item on 2nd position for example). The new position seems random and i couldn't figure out the issue.

As a guide, i used the https://github.com/material-components/material-components-android example to implement Drag&Drop and for their (simple) list&layout works well. My list is a bit complex since it's inside a viewpager, using Navigation component and having many other views constrained together in that screen, but i don't think this should be related.

At this point i don't even know how to search on the web for this issue anymore. The closest solution I found for this might be https://issuetracker.google.com/issues/37018279 but after implementing and having the same behaviour, I am thinking it's because I use ListAdapter which differs and updates the list asynchronously, when the solution uses RecyclerView.Adapter which uses notifyItemMoved and other similar methods.

Switching to RecyclerView.Adapter is not a solution.

1 Answers

This seems to be a bug in AsyncListDiffer, which is used under the hood by ListAdapter. My solution lets you manually diff changes when you need to. However, it's rather hacky, uses reflection, and may not work with future appcompat versions (The version I've tested it with is 1.3.0).

Since mDiffer is private in ListAdapter and you need to work directly with it, you'll have to create your own ListAdapter implementation(you can just copy the original source). And then add the following method:

fun setListWithoutDiffing(list: List<T>) {
    setOf("mList", "mReadOnlyList").forEach { fieldName ->
        val field = mDiffer::class.java.getDeclaredField(fieldName)
        field.isAccessible = true
        field.set(mDiffer, list)
    }
}

This method silently changes the current list in the underlying AsyncListDiffer without triggering any diffing, as submitList() would.

The resulting file should look like this:

package com.example.yourapp

import androidx.recyclerview.widget.AdapterListUpdateCallback
import androidx.recyclerview.widget.AsyncDifferConfig
import androidx.recyclerview.widget.AsyncListDiffer
import androidx.recyclerview.widget.AsyncListDiffer.ListListener
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.RecyclerView

abstract class ListAdapter<T, VH : RecyclerView.ViewHolder?> : RecyclerView.Adapter<VH> {
    private val mDiffer: AsyncListDiffer<T>
    private val mListener =
        ListListener<T> { previousList, currentList -> onCurrentListChanged(previousList, currentList) }

    protected constructor(diffCallback: DiffUtil.ItemCallback<T>) {
        mDiffer = AsyncListDiffer(
            AdapterListUpdateCallback(this),
            AsyncDifferConfig.Builder(diffCallback).build()
        ).apply {
            addListListener(mListener)
        }
    }

    protected constructor(config: AsyncDifferConfig<T>) {
        mDiffer = AsyncListDiffer(AdapterListUpdateCallback(this), config).apply {
            addListListener(mListener)
        }
    }

    fun setListWithoutDiffing(list: List<T>) {
        setOf("mList", "mReadOnlyList").forEach { fieldName ->
            val field = mDiffer::class.java.getDeclaredField(fieldName)
            field.isAccessible = true
            field.set(mDiffer, list)
        }
    }

    open fun submitList(list: List<T>?) {
        mDiffer.submitList(list)
    }

    fun submitList(list: List<T>?, commitCallback: Runnable?) {
        mDiffer.submitList(list, commitCallback)
    }

    protected fun getItem(position: Int): T {
        return mDiffer.currentList[position]
    }

    override fun getItemCount(): Int {
        return mDiffer.currentList.size
    }

    val currentList: List<T>
        get() = mDiffer.currentList

    open fun onCurrentListChanged(previousList: List<T>, currentList: List<T>) {}
}

Now you need to change your adapter implementation to inherit from your custom ListAdapter rather than androidx.recyclerview.widget.ListAdapter.

Finally you'll need to change your adapter's swap() method implementation to use the setListWithoutDiffing() and notifyItemMoved() methods:

fun swap(from: Int, to: Int) {
    setListWithoutDiffing(ArrayList(currentList).also {
        it[from] = currentList[to]
        it[to] = currentList[from]
    })
    notifyItemMoved(from, to)
}

An alternative solution would be to create a custom AsyncListDiffer version that lets you do the same without reflection, but this way seems easier. I will also file a feature request for supporting manual diffing out of the box and update the question with a Google Issue Tracker link.

Related