How to perfect android animations using MotionLayout

Star InactiveStar InactiveStar InactiveStar InactiveStar Inactive
 

Animations are fantastic.

They make our apps feel interactive, increase engagement, and make our designers happy.

But until recently, animations were a real pain point, which made it hard to deliver good looking animations, especially if we had a tight deadline.

Thankfully, no more.

Google introduced a tool called MotionLayout, which makes it easy to create beautiful animations with a high level of flexibility.

So how can we use MotionLayout?

MotionLayout inherits from ConstraintLayout, which means we have all the power of constraints to define our states.

MotionLayout calculates the difference between our layout at the beginning and at the end to create our animation.

But just like any useful tool, we have the power to decide how things morph in between, and we can define what the trigger to our animation is.

Now, I don’t know what about you; I find the best way to learn something is with examples, so let’s create one.

 

That’s what we’ll achieve — created using Android Studio 4.0 canary 7, with the new MotionEditor.

What are we seeing?

Our layout holds a few views:

  • Toolbar with a title.
  • Navigation and Menu icons.
  • SearchView
  • RecyclerView

What changes are we seeing?

  1. The height of the toolbar and the color of the icons adjust based on our scroll.
  2. The size of our SearchView alter to fit the new constraints between the icons.
  3. The title moves up and goes out of the screen.

Let’s begin by creating our layout file.

I’ll name our layout file sample_collapsing_animation We use MotionLayout as our root tag. Yours should look similar to this:

<androidx.constraintlayout.motion.widget.MotionLayout
    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:layout_width="match_parent"  
    android:layout_height="match_parent"  
    app:layoutDescription="@xml/sample_collapsing_animation_scene">

 

The attribute app:layoutDescription is used to attach our motion scene file, which we will create later.

Inside our layout, I want to define a Guideline . This will help us by acting as a natural singular point to change the height of our views.

<androidx.constraintlayout.widget.Guideline  
    android:id="@+id/guidline_toolbar"  
    android:layout_width="wrap_content"  
    android:layout_height="wrap_content"  
    android:orientation="horizontal"  
    app:layout_constraintGuide_begin="140dp" />

To build our Toolbar, we’re not going to use the component of Toolbar.
We are substituting it with a View to form the illusion of a Toolbar.

Doing that will result in a flat layout with all of the views exposed. Thus providing us with the option to gain access to our title and morph it out of the screen, and the SearchView, later on, can use the icons as constraints.

<View  
    android:id="@+id/toolbar"  
    android:layout_width="match_parent"  
    android:layout_height="0dp"  
    android:background="@color/colorPrimary"  
    android:elevation="4dp"  
    app:layout_constraintBottom_toTopOf="@id/guidline_toolbar"  
    app:layout_constraintTop_toTopOf="parent" />  

<androidx.appcompat.widget.AppCompatImageView  
    android:id="@+id/navigation_icon"  
    android:layout_width="wrap_content"  
    android:layout_height="wrap_content"  
    android:elevation="4dp"  
    android:padding="24dp"  
    app:layout_constraintStart_toStartOf="parent"  
    app:layout_constraintTop_toTopOf="parent"  
    app:srcCompat="@drawable/ic_baseline_menu_24" />  

<androidx.appcompat.widget.AppCompatTextView  
    android:id="@+id/toolbar_title"  
    android:layout_width="wrap_content"  
    android:layout_height="wrap_content"  
    android:elevation="4dp"  
    android:text="MotionLayout"  
    android:textColor="@color/md_black_1000"  
    android:textSize="20sp"  
    android:textStyle="bold"  
    app:layout_constraintBottom_toBottomOf="@+id/navigation_icon"  
    app:layout_constraintStart_toEndOf="@+id/navigation_icon"  
    app:layout_constraintTop_toTopOf="@+id/navigation_icon" />  

<androidx.appcompat.widget.AppCompatImageView  
    android:id="@+id/menu_icon"  
    android:layout_width="wrap_content"  
    android:layout_height="wrap_content"  
    android:elevation="4dp"  
    android:padding="24dp"  
    app:layout_constraintEnd_toEndOf="parent"  
    app:layout_constraintTop_toTopOf="parent"  
    app:srcCompat="@drawable/ic_baseline_favorite_24" />  

Our SearchView has nothing special going on with it.

<androidx.appcompat.widget.SearchView  
    android:id="@+id/search_view"  
    android:layout_width="match_parent"  
    android:layout_height="wrap_content"  
    android:layout_marginStart="16dp"  
    android:layout_marginEnd="16dp"  
    android:background="@color/md_white_1000"  
    android:elevation="4dp"  
    app:layout_constraintEnd_toEndOf="parent"  
    app:layout_constraintStart_toStartOf="parent"  
    app:layout_constraintTop_toBottomOf="@id/navigation_icon"  
    app:queryHint="Search" />  

Our final piece of the puzzle is our RecyclerView; it will act as a trigger for the animation.
Every time we scroll, the animation will update accordingly.

<androidx.recyclerview.widget.RecyclerView  
    android:id="@+id/scrollable_content"  
    android:layout_width="match_parent"  
    android:layout_height="wrap_content"  
    android:layout_marginTop="16dp"  
    app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"  
    app:layout_constraintEnd_toEndOf="parent"  
    app:layout_constraintStart_toStartOf="parent"  
    app:layout_constraintTop_toBottomOf="@id/guidline_toolbar" />  

The entire layout looks like this:

<?xml version="1.0" encoding="utf-8"?>  
<androidx.constraintlayout.motion.widget.MotionLayout 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:layout_width="match_parent"  
    android:layout_height="match_parent"  
    app:layoutDescription="@xml/sample_collapsing_animation_scene">  

    <View  
        android:id="@+id/toolbar"  
        android:layout_width="match_parent"  
        android:layout_height="0dp"  
        android:background="@color/colorPrimary"  
        android:elevation="4dp"  
        app:layout_constraintBottom_toTopOf="@id/guidline_toolbar"  
        app:layout_constraintTop_toTopOf="parent" />  

    <androidx.appcompat.widget.AppCompatImageView  
        android:id="@+id/navigation_icon"  
        android:layout_width="wrap_content"  
        android:layout_height="wrap_content"  
        android:elevation="4dp"  
        android:padding="24dp"  
        app:layout_constraintStart_toStartOf="parent"  
        app:layout_constraintTop_toTopOf="parent"  
        app:srcCompat="@drawable/ic_baseline_menu_24" />  

    <androidx.appcompat.widget.AppCompatTextView  
        android:id="@+id/toolbar_title"  
        android:layout_width="wrap_content"  
        android:layout_height="wrap_content"  
        android:elevation="4dp"  
        android:text="MotionLayout"  
        android:textColor="@color/md_black_1000"  
        android:textSize="20sp"  
        android:textStyle="bold"  
        app:layout_constraintBottom_toBottomOf="@+id/navigation_icon"  
        app:layout_constraintStart_toEndOf="@+id/navigation_icon"  
        app:layout_constraintTop_toTopOf="@+id/navigation_icon" />  

    <androidx.appcompat.widget.AppCompatImageView  
        android:id="@+id/menu_icon"  
        android:layout_width="wrap_content"  
        android:layout_height="wrap_content"  
        android:elevation="4dp"  
        android:padding="24dp"  
        app:layout_constraintEnd_toEndOf="parent"  
        app:layout_constraintTop_toTopOf="parent"  
        app:srcCompat="@drawable/ic_baseline_favorite_24" />  

    <androidx.appcompat.widget.SearchView  
        android:id="@+id/search_view"  
        android:layout_width="match_parent"  
        android:layout_height="wrap_content"  
        android:layout_marginStart="16dp"  
        android:layout_marginEnd="16dp"  
        android:background="@color/md_white_1000"  
        android:elevation="4dp"  
        app:layout_constraintEnd_toEndOf="parent"  
        app:layout_constraintStart_toStartOf="parent"  
        app:layout_constraintTop_toBottomOf="@id/navigation_icon"  
        app:queryHint="Search" />  

    <androidx.constraintlayout.widget.Guideline  
        android:id="@+id/guidline_toolbar"  
        android:layout_width="wrap_content"  
        android:layout_height="wrap_content"  
        android:orientation="horizontal"  
        app:layout_constraintGuide_begin="140dp" />  


    <androidx.recyclerview.widget.RecyclerView  
        android:id="@+id/scrollable_content"  
        android:layout_width="match_parent"  
        android:layout_height="wrap_content"  
        android:layout_marginTop="16dp"  
        app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"  
        app:layout_constraintEnd_toEndOf="parent"  
        app:layout_constraintStart_toStartOf="parent"  
        app:layout_constraintTop_toBottomOf="@id/guidline_toolbar" />  

</androidx.constraintlayout.motion.widget.MotionLayout

MotionScene

Cool. Now that we have the layout file ready, we can start work on our animation or our MotionScene file.

To do that, we create an XML file and place it within the res/xml folder.

I’ll call mine sample_collapsing_animation_scene.xml.

We use MotionScene as our root tag.

<MotionScene xmlns:android="http://schemas.android.com/apk/res/android"  
    xmlns:motion="http://schemas.android.com/apk/res-auto">
</MotionScene>
  1. Inside the file, we define three things. “constraintSetStart” which means how the animation looks at the initial (0%).
  2. “constraintSetEnd” which means how the animation looks at the end (100%).
  3. Trigger or duration.

When we define the start/end states, we probably want to change how views look or how they are positioned.

To do that we create a ConstraintSet which will hold all of our views.

For us to define how a view will behave in a ConstraintSet, we use the Constraint tag.

Start state

In our start constraint, we want the SearchView to be positioned below the icons and have a 16dp margin from both sides.

<Constraint
    android:id="@id/search_view"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:layout_marginStart="16dp"
    android:layout_marginEnd="16dp"
    android:elevation="4dp"
    motion:layout_constraintEnd_toEndOf="parent"
    motion:layout_constraintStart_toStartOf="parent"
    motion:layout_constraintTop_toBottomOf="@id/navigation_icon" />

The title should look like a regular Toolbar title:

<Constraint
    android:id="@+id/toolbar_title"
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    android:elevation="4dp"
    motion:layout_constraintBottom_toBottomOf="@id/navigation_icon"
    motion:layout_constraintStart_toEndOf="@id/navigation_icon"
    motion:layout_constraintTop_toTopOf="@id/navigation_icon" />

The icons should look like navigation and menu icons and we’ll define their color to black.

<Constraint android:id="@id/navigation_icon">
    <CustomAttribute
        motion:attributeName="ColorFilter"
        motion:customColorValue="#000000" />
</Constraint>

<Constraint android:id="@id/menu_icon">
    <CustomAttribute
        motion:attributeName="ColorFilter"
        motion:customColorValue="#000000" />
</Constraint>

Currently, we need to create a CustomAttribute with the name of ColorFilter to change the tint of an image. It might change in the future, but for now, that’s how we do it.

The entire start ConstraintSet:

<ConstraintSet android:id="@+id/start">
    <Constraint
        android:id="@id/search_view"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:layout_marginStart="16dp"
        android:layout_marginEnd="16dp"
        android:elevation="4dp"
        motion:layout_constraintEnd_toEndOf="parent"
        motion:layout_constraintStart_toStartOf="parent"
        motion:layout_constraintTop_toBottomOf="@id/navigation_icon" />

    <Constraint
        android:id="@+id/toolbar_title"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:elevation="4dp"
        motion:layout_constraintBottom_toBottomOf="@id/navigation_icon"
        motion:layout_constraintStart_toEndOf="@id/navigation_icon"
        motion:layout_constraintTop_toTopOf="@id/navigation_icon" />

    <Constraint android:id="@id/navigation_icon">
        <CustomAttribute
            motion:attributeName="ColorFilter"
            motion:customColorValue="#000000" />
    </Constraint>

    <Constraint android:id="@id/menu_icon">
        <CustomAttribute
            motion:attributeName="ColorFilter"
            motion:customColorValue="#000000" />
    </Constraint>
</ConstraintSet>

End state

 

In the end constraint, the SearchView should now be:

  • Side constrained to our icons.
  • Top constrained to the top of the screen
<Constraint
    android:id="@id/search_view"
    android:layout_width="0dp"
    android:layout_height="0dp"
    android:layout_marginTop="16dp"
    android:layout_marginBottom="16dp"
    android:elevation="4dp"
    motion:layout_constraintBottom_toTopOf="@id/guideline_toolbar"
    motion:layout_constraintEnd_toStartOf="@id/menu_icon"
    motion:layout_constraintStart_toEndOf="@id/navigation_icon"
    motion:layout_constraintTop_toTopOf="parent" />

The title moves above the toolbar and outside of view.

<Constraint
    android:id="@+id/toolbar_title"
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    android:elevation="4dp"
    motion:layout_constraintBottom_toTopOf="parent"
    motion:layout_constraintStart_toEndOf="@id/navigation_icon" />

Due to the fact that the Toolbar, SearchView, and RecyclerView are all constrained to a Guideline , the entire height animation can be produced by modifying thelayout_constraintGuide_begin attribute of the Guideline.

<Constraint
    android:id="@+id/guideline_toolbar"
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    motion:layout_constraintGuide_begin="65dp" />

The only thing we change in the icons is the color from black to white.

<Constraint android:id="@id/navigation_icon">
    <CustomAttribute
        motion:attributeName="ColorFilter"
        motion:customColorValue="#ffffff" />
</Constraint>

<Constraint android:id="@id/menu_icon">
    <CustomAttribute
        motion:attributeName="ColorFilter"
        motion:customColorValue="#ffffff" />
</Constraint>

The entire end ConstraintSet:

<ConstraintSet android:id="@+id/end">
    <Constraint
        android:id="@id/search_view"
        android:layout_width="0dp"
        android:layout_height="0dp"
        android:layout_marginTop="16dp"
        android:layout_marginBottom="16dp"
        android:elevation="4dp"
        motion:layout_constraintBottom_toTopOf="@+id/guideline_toolbar"
        motion:layout_constraintEnd_toStartOf="@id/menu_icon"
        motion:layout_constraintStart_toEndOf="@id/navigation_icon"
        motion:layout_constraintTop_toTopOf="parent" />

    <Constraint
        android:id="@+id/toolbar_title"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:elevation="4dp"
        motion:layout_constraintBottom_toTopOf="parent"
        motion:layout_constraintStart_toEndOf="@id/navigation_icon" />

    <Constraint
        android:id="@+id/guideline_toolbar"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        motion:layout_constraintGuide_begin="65dp" />

    <Constraint android:id="@id/navigation_icon">
        <CustomAttribute
            motion:attributeName="ColorFilter"
            motion:customColorValue="#ffffff" />
    </Constraint>

    <Constraint android:id="@id/menu_icon">
        <CustomAttribute
            motion:attributeName="ColorFilter"
            motion:customColorValue="#ffffff" />
    </Constraint>
</ConstraintSet>

Binding it all together

Okay. Up to this moment, we defined how the layout looks, how we want the start to look, and how we want to end the animation.

The last step of the puzzle is to create the transition. In other words, how the states will morph from one to the other.

To do that we use the Transition tag.

We configure it to use the start, endconstraintSet and we tell it that we want the overall interpolation to ease in and out.

<Transition
    motion:constraintSetEnd="@+id/end"
    motion:constraintSetStart="@id/start"
    motion:motionInterpolator="easeInOut">
</Transition>

We want to set our RecyclerView’s scroll as our trigger, which means that the animation will progress as the user scrolls.

To do that we add the tag OnSwipe to our now defined Transition.

We construe it with two attributes:

  • motion:dragDirection which means what side to swipe from: dragUp, dragDown, dragLeft, dragRight.
  • motion:touchAnchorId which allows us to attach the event onto a certain view. In our case; we assign this job to our RecyclerView.
<OnSwipe
    motion:dragDirection="dragUp"
    motion:touchAnchorId="@id/scrollable_content" />

The complete MotionScene file looks as follows:

<?xml version="1.0" encoding="utf-8"?>
<MotionScene xmlns:android="http://schemas.android.com/apk/res/android"
	xmlns:motion="http://schemas.android.com/apk/res-auto">

    <Transition
        motion:constraintSetEnd="@+id/end"
        motion:constraintSetStart="@id/start"
        motion:motionInterpolator="easeInOut">
        <OnSwipe
            motion:dragDirection="dragUp"
            motion:touchAnchorId="@id/scrollable_content" />
    </Transition>

    <ConstraintSet android:id="@+id/start">
        <Constraint
            android:id="@id/search_view"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:layout_marginStart="16dp"
            android:layout_marginEnd="16dp"
            android:elevation="4dp"
            motion:layout_constraintEnd_toEndOf="parent"
            motion:layout_constraintStart_toStartOf="parent"
            motion:layout_constraintTop_toBottomOf="@id/navigation_icon" />

        <Constraint
            android:id="@+id/toolbar_title"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:elevation="4dp"
            motion:layout_constraintBottom_toBottomOf="@id/navigation_icon"
            motion:layout_constraintStart_toEndOf="@id/navigation_icon"
            motion:layout_constraintTop_toTopOf="@id/navigation_icon" />

        <Constraint android:id="@id/navigation_icon">
            <CustomAttribute
                motion:attributeName="ColorFilter"
                motion:customColorValue="#000000" />
        </Constraint>

        <Constraint android:id="@id/menu_icon">
            <CustomAttribute
                motion:attributeName="ColorFilter"
                motion:customColorValue="#000000" />
        </Constraint>
    </ConstraintSet>

    <ConstraintSet android:id="@+id/end">
        <Constraint
            android:id="@id/search_view"
            android:layout_width="0dp"
            android:layout_height="0dp"
            android:layout_marginTop="16dp"
            android:layout_marginBottom="16dp"
            android:elevation="4dp"
            motion:layout_constraintBottom_toTopOf="@+id/guideline_toolbar"
            motion:layout_constraintEnd_toStartOf="@id/menu_icon"
            motion:layout_constraintStart_toEndOf="@id/navigation_icon"
            motion:layout_constraintTop_toTopOf="parent" />

        <Constraint
            android:id="@+id/toolbar_title"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:elevation="4dp"
            motion:layout_constraintBottom_toTopOf="parent"
            motion:layout_constraintStart_toEndOf="@id/navigation_icon" />

        <Constraint
            android:id="@+id/guideline_toolbar"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            motion:layout_constraintGuide_begin="65dp" />

        <Constraint android:id="@id/navigation_icon">
            <CustomAttribute
                motion:attributeName="ColorFilter"
                motion:customColorValue="#ffffff" />
        </Constraint>

        <Constraint android:id="@id/menu_icon">
            <CustomAttribute
                motion:attributeName="ColorFilter"
                motion:customColorValue="#ffffff" />
        </Constraint>

    </ConstraintSet>
</MotionScene>

To make the sample work, I’ve filled the RecyclerView with some dummy list data, and this is the final result.

 

To summarize

MotionLayout is an excellent tool. It gives us the power to create great experiences, beautiful animations, and enjoy the process thanks to the Android Studio team that created the MotionEditor.

Source

Tags:

Search

Articles - Category