Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

RecyclerView sets wrong MotionLayout state for its items

First: I created a sample project showing this problem. By now I begin to think that this is a bug in either RecyclerView or MotionLayout.

https://github.com/muetzenflo/SampleRecyclerView

This project is set up a little bit different than what is described below: It uses data binding to toggle between the MotionLayout states. But the outcome is the same. Just play around with toggling the state and swiping between the items. Sooner than later you'll come upon a ViewHolder with the wrong MotionLayout state.


So the main problem is:

ViewHolders outside of the screen are not updated correctly when transition from one MotionLayout state to another.


So here is the problem / What I've found so far:

I am using a RecyclerView.

It has only 1 item type which is a MotionLayout (so every item of the RV is a MotionLayout).

This MotionLayout has 2 states, let's call them State big and State small

All items should always have the same State. So whenever the state is switched for example from big => small then ALL items should be in small from then on.

But what happens is that the state changes to small and most(!) of the items are also updated correctly. But one or two items are always left with the old State. I am pretty sure it has to do with recycled ViewHolders. These steps produce the issue reliably when using the adapter code below (not in the sample project):

  1. swipe from item 1 to the right to item 2
  2. change from big to small
  3. change back from small to big
  4. swipe from item 2 to the left to item 1 => item 1 is now in the small state, but should be in the big state

Additional findings:

  • After step 4 if I continue swiping to the left, there comes 1 more item in the small state (probably the recycled ViewHolder from step 4). After that no other item is wrong.

  • Starting from step 4, I continue swiping for a few items (let's say 10) and then swipe all the way back, no item is in the wrong small state anymore. The faulty recycled ViewHolder seems to be corrected then.

What did I try?

  • I tried to call notifyDataSetChanged() whenever the transition has completed
  • I tried keeping a local Set of created ViewHolders to call the transition on them directly
  • I tried to use data-binding to set the motionProgress to the MotionLayout
  • I tried to set viewHolder.isRecycable(true|false) to block recycling during the transition
  • I searched this great in-depth article about RVs for hint what to try next

Anyone had this problem and found a good solution?

Just to avoid confusion: big and small does not indicate that I want to collapse or expand each item! It is just a name for different arrangement of the motionlayouts' children.

class MatchCardAdapter() : DataBindingAdapter<Match>(DiffCallback, clickListener) {

    private val viewHolders = ArrayList<RecyclerView.ViewHolder>()

    private var direction = Direction.UNDEFINED

    fun setMotionProgress(direction: MatchCardViewModel.Direction) {
        if (this.direction == direction) return

        this.direction = direction

        viewHolders.forEach {
            updateItemView(it)
        }
    }

    private fun updateItemView(viewHolder: RecyclerView.ViewHolder) {
        if (viewHolder.adapterPosition >= 0) {
            val motionLayout = viewHolder.itemView as MotionLayout
            when (direction) {
                Direction.TO_END -> motionLayout.transitionToEnd()
                Direction.TO_START -> motionLayout.transitionToStart()
                Direction.UNDEFINED -> motionLayout.transitionToStart()
            }
        }
    }

    override fun onBindViewHolder(holder: DataBindingViewHolder<Match>, position: Int) {
        val item = getItem(position)
        holder.bind(item, clickListener)

        val itemView = holder.itemView
        if (itemView is MotionLayout) {
            if (!viewHolders.contains(holder)) {
                viewHolders.add(holder)
            }

            updateItemView(holder)
        }
    }

    override fun onViewRecycled(holder: DataBindingViewHolder<Match>) {
        if (holder.adapterPosition >= 0 && viewHolders.contains(holder)) {
            viewHolders.remove(holder)
        }
        super.onViewRecycled(holder)
    }
}
like image 593
muetzenflo Avatar asked Feb 12 '26 06:02

muetzenflo


1 Answers

I made some progress but this is not a final solution, it has a few quirks to polish. Like the animation from end to start doesn't work properly, it just jumps to the final position.

https://github.com/fmatosqg/SampleRecyclerView/commit/907ec696a96bb4a817df20c78ebd5cb2156c8424

Some things that I changed but are not relevant to the solution, but help with finding the problem:

  • made duration 1sec
  • more items in recycler view
  • recyclerView.setItemViewCacheSize(0) to try to keep as few unseen items as possible, although if you track it closely you know they tend to stick around
  • eliminated data binding for handling transitions. Because I don't trust it in view holders in general, I could never make them work without a bad side-effect
  • upgraded constraint library with implementation "androidx.constraintlayout:constraintlayout:2.0.0-rc1"

Going into details about what made it work better:

all calls to motion layout are done in a post manner

    //    https://stackoverflow.com/questions/51929153/when-manually-set-progress-to-motionlayout-it-clear-all-constraints
    fun safeRunBlock(block: () -> Unit) {

        if (ViewCompat.isLaidOut(motionLayout)) {
            block()
        } else {
            motionLayout.post(block)
        }

    }

Compared actual vs desired properties

    val goalProgress =
            if (currentState) 1f
            else 0f
    val desiredState =
                if (currentState) motionLayout.startState
                else motionLayout.endState

 safeRunBlock {
            startTransition(currentState)
        }
 
 if (motionLayout.progress != goalProgress) {

    if (motionLayout.currentState != desiredState) {

        safeRunBlock {
            startTransition(currentState)
        }

    }
 }

This would be the full class of the partial solution


class DataBindingViewHolder<T>(private val binding: ViewDataBinding) :
    RecyclerView.ViewHolder(binding.root) {

    val motionLayout: MotionLayout =
        binding.root.findViewById<MotionLayout>(R.id.root_item_recycler_view)
            .also {
                it.setTransitionDuration(1_000)
                it.setDebugMode(DEBUG_SHOW_PROGRESS or DEBUG_SHOW_PATH)
            }

    var lastPosition: Int = -1


    fun bind(item: T, position: Int, layoutState: Boolean) {

        if (position != lastPosition)
            Log.i(
                "OnBind",
                "Position=$position lastPosition=$lastPosition - $layoutState "
            )



        lastPosition = position

        setMotionLayoutState(layoutState)

        binding.setVariable(BR.item, item)
        binding.executePendingBindings()
    }

    //    https://stackoverflow.com/questions/51929153/when-manually-set-progress-to-motionlayout-it-clear-all-constraints
    fun safeRunBlock(block: () -> Unit) {

        if (ViewCompat.isLaidOut(motionLayout)) {
            block()
        } else {
            motionLayout.post(block)
        }

    }

    fun setMotionLayoutState(currentState: Boolean) {
        
        val goalProgress =
            if (currentState) 1f
            else 0f

        safeRunBlock {
            startTransition(currentState)
        }

        if (motionLayout.progress != goalProgress) {

            val desiredState =
                if (currentState) motionLayout.startState
                else motionLayout.endState

            if (motionLayout.currentState != desiredState) {
                Log.i("Pprogress", "Desired doesn't match at position $lastPosition")
                safeRunBlock {
                    startTransition(currentState)
                }
            }
        }
    }

    fun startTransition(currentState: Boolean) {
        if (currentState) {
            motionLayout.transitionToStart()
        } else {
            motionLayout.transitionToEnd()
        }
    }

}

Edit: added constraint layout version

like image 175
Fabio Avatar answered Feb 14 '26 19:02

Fabio



Donate For Us

If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!