Use Motion Layout in RecyclerView

Piotr Prus
Schibsted Tech Polska
8 min readJan 9, 2020

--

Motion Layout is a powerful tool that helps you manage motion and animation in your app. To learn more about it, hit to the official docs and/or check the series of articles by Nicolas Roard. In this article I will focus on how to set nice swipe animation in RecyclerView item.

TL;DR —below is the result. The code can be found on github, scene 13.

Swipe like/dislike in RecyclerView

Preparing XMLs

To create animations using ML we need two XML files, the one that defines layout and the other one that defines motion scene. To animate RV item, the motion layout needs to be placed in xml that defines item view. For simplicity of this example, here is the layout file:

Layout file

Now let’s take the second file to the workshop. Motion Scene, starting from rest/idle state of the card.

State: Rest

ConstraintSet for rest state

I will only describe states and transitions that affect the like animation. Dislike is mirror image of Like. All of the above are probably known and self explanatory, but I would like to focus on two of the constraints. layout_constraintWidth_percent and layout_constraintHeight_percent — these will set the dimensions as percent of the parent view. This is important, due to the fact that if we will constraint all four sides of view, there won’t be a room for a transition. The view would stay in place. Using a percent constraint, we are able to move the card.

State: Like

The deriveConstraintsFrom works for the width, height and other parameters, but not for the layout_constraint. For the end of a swipe-like the view will rotate by 45 degrees and move by 300dp towards. Now we need a transition node to connect between those two states.

Transition: Like

This transition gives us the swipe like animation. The transition needs start and end, which are rest and like, the duration is set to 100ms — we want the view to come back fast to its start state. onTouchUp is just for it. We explicitly set to the auto-complete transition to the start state when user takes off a finger. Let’s check the results so far.

Issues during transition

We are definitely still missing a few things :). Let’s free this transition beyond the boundaries of the grid element first. We need to set android:clipChildren="false" on the RecyclerView element. Setting this property to false will allow the children views to draw outside of their bounds. Exactly what we needed. The result:

Now, the children animate outside the parent, but another issue pops up. Even grid elements are stacked higher than the odd ones, which leads to the unpleasant effect of hiding the view behind its neighbor. We need to change Z transition or elevation of the active item.

At the beginning I was changing the elevation in the motion scene, but this gives me no effect. Then my colleague(Damian Petla) said: You can raise the table in your apartment higher and higher, but you will never be on the same level as your neighbor living on the floor above you. Speaking a language that the engineer understands: The recyclerView child needs to be modified, not the item in ViewHolder.

To manage the elevation properly, we need to know when the item starts animation and when it completes. In bind() function of ViewHolder, I set the MotionLayout.TransitionListener and invoked my custom state listener that pass the value to adapter and then to viewModel. Let’s see some code.

enum represents states of motionLayout animation
ViewHolder with MotionLayout Transition Listener
RV Adapter that inflates motionLayout view and bind item with animationListener

As you can see above, I expanded the functionality of animationStateListener to hold one more value, the item TAG. The tag helps to identify item flawlessly. The zTransition(elevation) magic happens in fragment. Check it out:

Since the default elevation for RV item is 0, we just need to increase it to 1 at the start of animation and restart it to default when animation ends.

The Velocity Tracker error

Sometimes the app crashes during the animation, with fatal error pointing to NPE on VelocityTracker. Quoting BBorombo, who opens issue on the GitHub repo:

The crash only occurs when you perform the animation and try to scroll the list, from outside the cell you played the animation from, but not every time.
Example :
Animation on Item2 + Scroll the list with a touch from the item 2 = OK
Animation on Item4 + Scroll the list with a touch from the item 6 = Crash

After some investigation and internet search I’ve finally found the solution. All you need to do is to create a custom MotionLayout class, which use VelocityTracker in null safety way. As much and as little is needed to get rid of the fatal error.

Item freeze

Item freeze when scrolling RV

Another issue to tackle is the item freeze during scroll of recyclerView. The animation stops at current progress when list starts scroll. There are probably many solutions for this issue, I choose the easiest to implement. Instead of get freeze of item transition I will freeze recyclerView during animation. Hopefully we already have listener for animationState 😃.

Custom recyclerView class that intercept touch

I create custom RecyclerView that block all touch events when the variable swipeFrozen is set to true. The variable is changed in animationState observer:

updated animationState observer

As you can see, I freeze recyclerView when animation starts and defroze itwhen animation completes. Additionally, to prevent a total freeze, I also defroze the recyclerView when user does pull to refresh.

freeze RecyclerView during item animation

Transition: flyOff

It is time to handle the fly off screen animation. We will send our item out of the screen to left or right, depending on our previous action. For that purpose, I prepared ConstraintSet called “goneRight”, which will gently animate item off-screen and trigger the item remove function.

constraintSet that describe position of view at end of like animation in recyclerView

The autoTransition function will perform the animation automatically when motionLayout will detect that start constraintSet is like. This will happen only after user releases his finger.

Item fly off the screen after swipe

As you can see the item flies away, but the list is not refilled and it leaves an ugly hole. Let’s fix it 😃

Add remove item listener

The best place to inform the list that the item should be removed is the end of the swipe animation. I expanded onTransitionCompleted to inform the adapter when it should perform action. For simplicity, I call the same listener for like and dislike.

This listener, indeed removes the item from the list, but it is not removing the view from recyclerView. The view cell is still there, but it hides away of screen. We do not want to delete the view (it is not possible in recyclerVIew), instead we want to reset the constraintSet to default(rest state) and show the next item in that view. This is how recyclerView works. It recyclers views 😃

Reset constraints to default

To achieve that, we need to force motionLayout to set its default state and default transition, also we need to reset the progress from 1 to 0.

private fun resetTransition(motionLayout: MotionLayout?) {
motionLayout?.let {
it
.progress = 0f
it.setTransition(R.id.rest, R.id.like)
}
}

Invoke resetTransition function after removeItemListener and voila!

Make it shine ⭐️

As you saw at the beginning of the article, the item changes during swipe. I added a green overlay with frame for like and red overlay with frame for dislike. Those overlays are standard View and are added to item layout. During swipe I manipulate the alpha of the overlay, using KeyFrameSet and KeyAttribute.

Keyframes allow you to specify a change at a point in time during transition. The node: framePosition is exactly specifying it. The transition splits into 100 frames. Setting it to 50 means the alpha will change from 0 to 1 till 50% of transition. To make it work, set the initial value of alpha to 0 explicitly.

BONUS — wait for recycler to remove the item

As you can see on above gif, there is a little glitch when item is removed from list. The problem occurs, because resetTransition is called immediately after we remove the item. The recyclerView does not have a time to properly react on that deletion. We should wait for the Adapter to perform deletion and then reset transition. To achieve this, I will use Kotlin coroutines in the same way Chris Banes shows in his article Suspending Over Views.

Await list changed

First, let’s prepare a suspend function that will be waiting for item removal. We will pass this function to ViewHolder.

The suspending function awaits for item removal. As Chris mentioned, the unregister of observer is needed to do not leak coroutine, then resume it with position that has been removed.

Pass suspend function as function parameter

This operation is fairly easy. In Kotlin we can pass suspend function as an argument and the only think we need to do is add keyword suspend in high-order function that will receive it. In our case it is a bind function of ViewHolder. Coroutine launch in TransitionListener.onTransitionCompleted.

Afterword

Motion Layout is right now (as of January 2020) in a beta stage, thus there are still things that do not work, are still glitchy and far from perfect. Still, it was fun to experiment with, worth the final effect of beautiful and smooth animations in my application.

Happy animating! 💻

--

--

Piotr Prus
Schibsted Tech Polska

Android Developer @Tilt, Enthusiast of kotlin, jetpack compose and clean architecture. Currently Composing and KMMing all the things ❤️