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:
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.
How does it work?
The code relies heavily on
notifyItemChanged(Int, Payload)
andTransitionManager.beginDelayedTransition()
. Let’s go over how these work first.First,
notifyItemChanged(Int, Payload)
will ensure that the view holder passed toonBindViewHolder(RecyclerView.ViewHolder, Int, MutableList<Any>)
is the same view holder as the view holder that is currently bound. Eg. let’s sayA
is the view holder currently bound to item 0. If we callnotifyItemChanged(0, Unit)
then we can guarantee thatA
will be passed toonBindViewHolder(RecyclerView.ViewHolder, Int, MutableList<Any>)
. In addition to this, RecyclerViews are very good at animating changes in item view height sonotifyItemChanged()
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 whenConstraintSet.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:
toggleExpanded()
is called.ConstraintSet.applyTo()
is called to apply our expanded layout to the view and to animate the changes.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:
toggleExpanded()
is called.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.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 beforenotifyItemChanged(h.adapterPosition, Unit)
is crucial. This is because the view holder passed toonBindViewHolder
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()
inonBindViewHolder
instead, the state it will save is that the view is unbound. WhenConstraintSet.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.