Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Constraint Set animation inside Recycler View not animating properly

I am using a constraint layout for my recycler view items. To animate (expand/collapse) them I use Constraint Set animation. The opening animation runs fine on all items. The closing animation runs fine also, but when the closing animation starts on item that is not the last all items jump up when animation starts, and not at the end of the animation.

Animation is performed on item click:

itemView.setOnClickListener {
                val smallItemConstraint = ConstraintSet()
                smallItemConstraint.clone(itemView.context, R.layout.day_of_week_small)
                val largeItemConstraint = ConstraintSet()
                largeItemConstraint.clone(itemView.context, R.layout.day_of_week)

                val constraintToApply = if (isViewExpanded) smallItemConstraint else
                    largeItemConstraint

                animateItemView(constraintToApply, itemView.dayOfWeekConstraintLayout)

                if (!isViewExpanded) {
                    itemView.dayOfWeekWeatherIcon.visibility = View.VISIBLE
                } else {
                    itemView.dayOfWeekWeatherIcon.visibility = View.GONE
                }

                isViewExpanded = !isViewExpanded
            }

Where animateItemView is:

private fun animateItemView(constraintToApply: ConstraintSet,
                                constraintLayout: ConstraintLayout) {
        TransitionManager.beginDelayedTransition(constraintLayout)
        constraintToApply.applyTo(constraintLayout)
    }

day_of_week.xml (expanded) layout:

<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:id="@+id/dayOfWeekConstraintLayout"
    android:layout_width="match_parent"
    android:layout_height="wrap_content">

    <ImageView
        android:id="@+id/dayOfWeekWeatherIcon"
        android:layout_width="90dp"
        android:layout_height="90dp"
        android:layout_marginStart="8dp"
        android:layout_marginTop="8dp"
        android:layout_marginEnd="8dp"
        android:layout_marginBottom="8dp"
        android:contentDescription="@string/weather_image"
        app:layout_constraintBottom_toBottomOf="@+id/dayOfWeekHumidityLabel"
        app:layout_constraintEnd_toStartOf="@+id/dayOfWeekItemVerticalGuideline"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent"
        tools:srcCompat="@tools:sample/avatars" />

    <TextView
        android:id="@+id/dayOfWeekText"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_marginStart="8dp"
        android:layout_marginTop="8dp"
        android:text="@string/today"
        android:textAllCaps="true"
        android:textSize="14sp"
        android:textStyle="bold"
        app:layout_constraintStart_toStartOf="@+id/dayOfWeekItemVerticalGuideline"
        app:layout_constraintTop_toTopOf="parent" />

    <androidx.constraintlayout.widget.Guideline
        android:id="@+id/dayOfWeekItemVerticalGuideline"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:orientation="vertical"
        app:layout_constraintGuide_begin="192dp" />

    <TextView
        android:id="@+id/dayOfWeekCurrentTemperatureText"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_marginStart="8dp"
        android:layout_marginTop="8dp"
        android:textAllCaps="true"
        android:textSize="24sp"
        app:layout_constraintStart_toStartOf="@+id/dayOfWeekItemVerticalGuideline"
        app:layout_constraintTop_toBottomOf="@+id/dayOfWeekText" />

    <TextView
        android:id="@+id/dayOfWeekDegreeCelsiusSign"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_marginStart="8dp"
        android:layout_marginTop="8dp"
        android:text="@string/degree_celsius"
        android:textAllCaps="true"
        android:textSize="24sp"
        app:layout_constraintStart_toEndOf="@+id/dayOfWeekCurrentTemperatureText"
        app:layout_constraintTop_toBottomOf="@+id/dayOfWeekText" />

    <TextView
        android:id="@+id/dayOfWeekWeatherStateText"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_marginStart="8dp"
        android:layout_marginTop="8dp"
        android:text="@string/weather_state_text"
        android:textSize="24sp"
        app:layout_constraintStart_toStartOf="@+id/dayOfWeekItemVerticalGuideline"
        app:layout_constraintTop_toBottomOf="@+id/dayOfWeekDegreeCelsiusSign" />

    <TextView
        android:id="@+id/dayOfWeekWindLabel"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_marginStart="8dp"
        android:layout_marginTop="8dp"
        android:text="@string/wind_label"
        android:textAllCaps="true"
        android:textSize="14sp"
        android:textStyle="bold"
        app:layout_constraintStart_toStartOf="@+id/dayOfWeekItemVerticalGuideline"
        app:layout_constraintTop_toBottomOf="@+id/dayOfWeekWeatherStateText" />

    <TextView
        android:id="@+id/dayOfWeekHumidityLabel"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_margin="8dp"
        android:text="@string/humidityLabel"
        android:textAllCaps="true"
        android:textSize="14sp"
        android:textStyle="bold"
        app:layout_constraintStart_toStartOf="@+id/dayOfWeekItemVerticalGuideline"
        app:layout_constraintTop_toBottomOf="@+id/dayOfWeekWindLabel" />

    <TextView
        android:id="@+id/dayOfWeekWindDirection"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_marginStart="8dp"
        android:layout_marginTop="8dp"
        android:textAllCaps="true"
        android:textSize="14sp"
        android:textStyle="bold"
        app:layout_constraintStart_toEndOf="@+id/dayOfWeekWindLabel"
        app:layout_constraintTop_toBottomOf="@+id/dayOfWeekWeatherStateText" />

    <TextView
        android:id="@+id/dayOfWeekWindSpeed"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_marginStart="8dp"
        android:layout_marginTop="8dp"
        android:textSize="14sp"
        app:layout_constraintStart_toEndOf="@+id/dayOfWeekWindDirection"
        app:layout_constraintTop_toBottomOf="@+id/dayOfWeekWeatherStateText" />

    <TextView
        android:id="@+id/dayOfWeekWindSpeedLabel"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_marginStart="8dp"
        android:layout_marginTop="8dp"
        android:text="@string/wind_speed"
        android:textSize="14sp"
        android:textStyle="bold"
        app:layout_constraintStart_toEndOf="@+id/dayOfWeekWindSpeed"
        app:layout_constraintTop_toBottomOf="@+id/dayOfWeekWeatherStateText" />

    <TextView
        android:id="@+id/dayOfWeekHumidityText"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_marginStart="8dp"
        android:layout_marginTop="8dp"
        android:textSize="14sp"
        android:textStyle="bold"
        app:layout_constraintStart_toEndOf="@+id/dayOfWeekHumidityLabel"
        app:layout_constraintTop_toBottomOf="@+id/dayOfWeekWindSpeedLabel" />

    <TextView
        android:id="@+id/dayOfWeekHumidityPercentageLabel"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_margin="8dp"
        android:text="@string/percentage_sign"
        android:textAllCaps="true"
        android:textSize="14sp"
        android:textStyle="bold"
        app:layout_constraintStart_toEndOf="@+id/dayOfWeekHumidityText"
        app:layout_constraintTop_toBottomOf="@+id/dayOfWeekWindSpeedLabel" />

</androidx.constraintlayout.widget.ConstraintLayout>

And day_of_week_small.xml (collapsed) layout:

<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:id="@+id/dayOfWeekConstraintLayout"
    android:layout_width="match_parent"
    android:layout_height="wrap_content">

    <ImageView
        android:id="@+id/dayOfWeekWeatherIcon"
        android:layout_width="90dp"
        android:layout_height="90dp"
        android:layout_marginStart="8dp"
        android:layout_marginTop="8dp"
        android:layout_marginEnd="8dp"
        android:layout_marginBottom="8dp"
        android:contentDescription="@string/weather_image"
        android:visibility="gone"
        app:layout_constraintBottom_toBottomOf="@+id/dayOfWeekHumidityLabel"
        app:layout_constraintEnd_toStartOf="@+id/dayOfWeekItemVerticalGuideline"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent"
        tools:srcCompat="@tools:sample/avatars" />

    <TextView
        android:id="@+id/dayOfWeekText"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_marginStart="8dp"
        android:layout_marginTop="8dp"
        android:text="@string/today"
        android:textAllCaps="true"
        android:textSize="14sp"
        android:textStyle="bold"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent" />

    <androidx.constraintlayout.widget.Guideline
        android:id="@+id/dayOfWeekItemVerticalGuideline"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:orientation="vertical"
        app:layout_constraintGuide_begin="192dp" />

    <TextView
        android:id="@+id/dayOfWeekCurrentTemperatureText"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_marginStart="8dp"
        android:layout_marginTop="8dp"
        android:textAllCaps="true"
        android:textSize="40sp"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toBottomOf="@+id/dayOfWeekText" />

    <TextView
        android:id="@+id/dayOfWeekDegreeCelsiusSign"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_marginStart="8dp"
        android:layout_marginTop="8dp"
        android:text="@string/degree_celsius"
        android:textAllCaps="true"
        android:textSize="40sp"
        app:layout_constraintStart_toEndOf="@+id/dayOfWeekCurrentTemperatureText"
        app:layout_constraintTop_toBottomOf="@+id/dayOfWeekText" />

    <TextView
        android:id="@+id/dayOfWeekWeatherStateText"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_marginStart="8dp"
        android:layout_marginTop="8dp"
        android:text="@string/weather_state_text"
        android:textSize="24sp"
        android:visibility="gone"
        app:layout_constraintStart_toStartOf="@+id/dayOfWeekItemVerticalGuideline"
        app:layout_constraintTop_toBottomOf="@+id/dayOfWeekDegreeCelsiusSign" />

    <TextView
        android:id="@+id/dayOfWeekWindLabel"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_marginStart="8dp"
        android:layout_marginTop="8dp"
        android:text="@string/wind_label"
        android:textAllCaps="true"
        android:textSize="14sp"
        android:textStyle="bold"
        android:visibility="gone"
        app:layout_constraintStart_toStartOf="@+id/dayOfWeekItemVerticalGuideline"
        app:layout_constraintTop_toBottomOf="@+id/dayOfWeekWeatherStateText" />

    <TextView
        android:id="@+id/dayOfWeekHumidityLabel"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_margin="8dp"
        android:text="@string/humidityLabel"
        android:textAllCaps="true"
        android:textSize="14sp"
        android:textStyle="bold"
        android:visibility="gone"
        app:layout_constraintStart_toStartOf="@+id/dayOfWeekItemVerticalGuideline"
        app:layout_constraintTop_toBottomOf="@+id/dayOfWeekWindLabel" />

    <TextView
        android:id="@+id/dayOfWeekWindDirection"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_marginStart="8dp"
        android:layout_marginTop="8dp"
        android:textAllCaps="true"
        android:textSize="14sp"
        android:textStyle="bold"
        android:visibility="gone"
        app:layout_constraintStart_toEndOf="@+id/dayOfWeekWindLabel"
        app:layout_constraintTop_toBottomOf="@+id/dayOfWeekWeatherStateText" />

    <TextView
        android:id="@+id/dayOfWeekWindSpeed"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_marginStart="8dp"
        android:layout_marginTop="8dp"
        android:textSize="14sp"
        android:visibility="gone"
        app:layout_constraintStart_toEndOf="@+id/dayOfWeekWindDirection"
        app:layout_constraintTop_toBottomOf="@+id/dayOfWeekWeatherStateText" />

    <TextView
        android:id="@+id/dayOfWeekWindSpeedLabel"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_marginStart="8dp"
        android:layout_marginTop="8dp"
        android:text="@string/wind_speed"
        android:textSize="14sp"
        android:textStyle="bold"
        android:visibility="gone"
        app:layout_constraintStart_toEndOf="@+id/dayOfWeekWindSpeed"
        app:layout_constraintTop_toBottomOf="@+id/dayOfWeekWeatherStateText" />

    <TextView
        android:id="@+id/dayOfWeekHumidityText"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_marginStart="8dp"
        android:layout_marginTop="8dp"
        android:textSize="14sp"
        android:textStyle="bold"
        android:visibility="gone"
        app:layout_constraintStart_toEndOf="@+id/dayOfWeekHumidityLabel"
        app:layout_constraintTop_toBottomOf="@+id/dayOfWeekWindSpeedLabel" />

    <TextView
        android:id="@+id/dayOfWeekHumidityPercentageLabel"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_margin="8dp"
        android:text="@string/percentage_sign"
        android:textAllCaps="true"
        android:textSize="14sp"
        android:textStyle="bold"
        android:visibility="gone"
        app:layout_constraintStart_toEndOf="@+id/dayOfWeekCurrentTemperatureText"
        app:layout_constraintTop_toBottomOf="@+id/dayOfWeekCurrentTemperatureText" />

</androidx.constraintlayout.widget.ConstraintLayout>

What is the issue here and how do I fix it? Thank you.

Animation example:

https://gph.is/g/aj8GdB4

like image 456
Varga I. Avatar asked Mar 06 '19 23:03

Varga I.


1 Answers

Before we get to how I was able to get everything to work, let's take a look at what is causing the behavior in your gif.

The reason why the other item views jumps up is because animations are purely visual. That is, the collapse animation doesn't actually animate the height of your item from a layout perspective, it only animates how the item is drawn. This is done for performance reasons (imagine having to re-layout all of the views 60 times a second). That is why when your item is collapsed all of the other views jump to the end layout position.

RecyclerViews are very good at animating the heights of their children and this is what we will use to solve the entire animation problem. I outline the complete solution below.


Preview GIF: https://giphy.com/gifs/SVlBnpeW3wIwNIVpVU

I was able to get ConstraintLayout + ConstrainSet + RecyclerViews working after some experimentation. I will share how I got it working.

The code

Here is a quick preview of the code.

private inner class MatchInfoAdapter (
  private val context: Context
) : RecyclerView.Adapter<RecyclerView.ViewHolder>() {

  private var items = listOf<MatchItem>()
  private val inflater: LayoutInflater = LayoutInflater.from(context)

  override fun onCreateViewHolder(
    parent: ViewGroup, 
    viewType: Int
  ): RecyclerView.ViewHolder = 
    FullViewHolder(inflater.inflate(viewType, parent, false))

  override fun onBindViewHolder(
    holder: RecyclerView.ViewHolder,
    position: Int,
    payloads: MutableList<Any>
  ) {
    if (payloads.isEmpty()) {
      super.onBindViewHolder(holder, position, payloads)
    } else {
      val item = items[position]
      val h = holder as FullViewHolder
  
      if (!item.isExpanded) {
        h.collapsedConstraintSet.applyTo(h.rootView)
      }
    }
  }

  override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
    val item = items[position]
    val h = holder as FullViewHolder
    val isExpanded = item.isExpanded
  
    val constraint = if (isExpanded) 
      h.expandedConstraintSet 
    else 
      h.collapsedConstraintSet
    constraint.applyTo(h.rootView)

    bindGeneralViews(h, item, isExpanded)
    if (isExpanded) {
      bindExpandedExtraViews(h, item)
    }

    h.clickView.setOnClickListener {
      toggleExpanded(h)
    }
  }


  private fun bindGeneralViews(
    h: FullViewHolder,
    item: MatchItem,
    isExpanded: Boolean
  ) {
      // bind views that are visible when expanded and collapsed
  }
  
  private funbindExpandedExtraViews(
    h: FullViewHolder,
    item: MatchItem
  ) {
      // bind views that are only shown when the item is expanded
  }

  

  private fun toggleExpanded(
    h: FullViewHolder
  ) {
    if (h.adapterPosition< 0) return // touch event can technically fire after a view is unbound

    val autoTransition = AutoTransition()
    
    val item = items[position]
    item.isExpanded = !item.isExpanded
    
    bindGeneralViews(h, item, newIsExpanded)
    if (item.isExpanded) {
      bindExpandedExtraViews(h, item)
  
      autoTransition.ordering = AutoTransition.ORDERING_TOGETHER
      autoTransition.duration = ANIMATION_DURATION_MS
      TransitionManager.beginDelayedTransition(h.rootView, autoTransition)
      h.expandedConstraintSet.applyTo(h.rootView)
  
      notifyItemChanged(h.adapterPosition, Unit)
    } else {
      autoTransition.ordering = AutoTransition.ORDERING_TOGETHER
      autoTransition.duration = ANIMATION_DURATION_MS
      TransitionManager.beginDelayedTransition((h.rootView.parent as ViewGroup), autoTransition)
      notifyItemChanged(h.adapterPosition, Unit)
    }
  }
}

data class MatchItem(
...
) {
  // Exclude this field from equals/hachcode by declaring it in class body
  var isExpanded: Boolean = false
}

private class FullViewHolder (itemView: View) : RecyclerView.ViewHolder(itemView) {
  ...

  val collapsedConstraintSet: ConstraintSet = ConstraintSet()
  val expandedConstraintSet: ConstraintSet = ConstraintSet()
  
  init {
    collapsedConstraintSet.clone(rootView)
    expandedConstraintSet.clone(rootView.context, R.layout.build_full_item)
  }
}

How does it work?

The code relies heavily on notifyItemChanged(Int, Payload) and TransitionManager.beginDelayedTransition(). Let’s go over how these work first.

First, notifyItemChanged(Int, Payload) will ensure that the view holder passed to onBindViewHolder(RecyclerView.ViewHolder, Int, MutableList<Any>) is the same view holder as the view holder that is currently bound. Eg. let’s say A is the view holder currently bound to item 0. If we call notifyItemChanged(0, Unit) then we can guarantee that A will be passed to onBindViewHolder(RecyclerView.ViewHolder, Int, MutableList<Any>). In addition to this, RecyclerViews are very good at animating changes in item view height so notifyItemChanged() will notify the RecyclerView to check if the height changed and if it has to play a nice animation that will either animate other items up or down.

Second, TransitionManager.beginDelayedTransition() takes a snapshot of the current state of the view passed in. Then when ConstraintSet.applyTo() is called, the difference between the saved state and the current state is calculated and animations are applied to transition between the two, automatically.

Now that the basics are out of the way. Here is how expanding and collapsing items work.

For expanding an item:

  1. User taps on the items.
  2. toggleExpanded() is called.
  3. Item’s view state is updated to expanded.
  4. We pre-bind all of the views to the view holder so that no flickering occurs during animation and all views are fully bound.
  5. TransitionManager.beginDelayedTransition() is called to take a snapshot of the item view state.
  6. ConstraintSet.applyTo() is called to apply our expanded layout to the view and to animate the changes.
  7. notifyItemChanged(h.``adapterPosition``, Unit) is called. This guarantees that when onBindViewHolder is called, we will get our fully bound view holder passed to us. In addition, it notifies the recyclerview that the height of the item has changed will let the recyclerview handle animating the height change.

For collapsing an item:

  1. User taps on the items.
  2. toggleExpanded() is called.
  3. Item’s view state is updated to collapsed.
  4. TransitionManager.beginDelayedTransition() is called to take a snapshot of the item view state.
  5. notifyItemChanged(h.``adapterPosition``, Unit) is called. This guarantees that when onBindViewHolder is called, we will get our fully bound view holder passed to us. In addition, it notifies the recyclerview that the height of the item has changed will let the recyclerview handle animating the height change.
  6. ConstraintSet.applyTo() is called to apply our collapsed layout to the view and to animate the changes.

Additional fun facts

Collapsing an item is actually way more complicated than meets the eye. The TransitionManager.beginDelayedTransition() call before notifyItemChanged(h.adapterPosition, Unit) is crucial. This is because the view holder passed to onBindViewHolder is always unbound due to how recyclerviews are implemented.

Why is this an issue? Well it means that if we were to call TransitionManager.beginDelayedTransition() in onBindViewHolder instead, the state it will save is that the view is unbound. When ConstraintSet.applyTo() is called, it will animate between an unbound view to a bound view and the default animation for this is to fade the view in. This is not what we want and the animation looks very ugly.

like image 147
idunnololz Avatar answered Sep 19 '22 13:09

idunnololz