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.
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)
}
}
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:
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.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.
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With