MotionLayout- Kotlin Tutorial Series – 2

Hello everyone,

In this post, I am going to try to explain the following topics.

  • What is MotionLayout?
  • Why are we using MotionLayout?
  • MotionLayout sample

MotionLayout is longer than one article but I am going to explain some basics about MotionLayout. Let’s get started.

MotionLayout is a type of ConstraintLayout. It came with ConstraintLayout 2.0 library and it makes it easy to create complex animations. MotionLayout, separates animation codes from the UI code. Because of that, you can create more clear UI and complex animation files. Animations can trigger from XML files or you can trigger them programmatically.

The separated animation xml’s name is MotionScene. Every MotionLayout should have their own MotionScene file. Otherwise, you can see the error on the MotionLayout tag.

All elements in the MotionLayout should have their own id property. If you do not add, you can see errors when you try to run your application. Id property is important, because in MotionScene(scene_01.xml) file, we are going to position elements via this property.

In the MotionScene, we define ConstraintSet elements and Constraint elements inside of the ConstraintSet element. In MotionLayout, animated objects have a start and end location. Animation happens between these two locations. We define those locations via ConstraintSet elements.

We have one more element before animating the properties. It’s Transition. Transitions can think like an animation path. We defined locations via ConstraintSet elements. With transition, we connect start and end ConstraintSet elements and animation happens between these two locations. 

Basically, MotionLayout is this. Now, let’s start the tutorial. I am going to explain some extra elements during the tutorial.

To use MotionLayout in your project, you have to include ConstraintLayout to your project higher than the version below.

dependencies {
    implementation 'androidx.constraintlayout:constraintlayout:2.0.0-beta1'
}

If you updated your ConstraintLayout version, then you have to sync your project.

Now, we are ready to use MotionLayout. In this tutorial, We are going to create the following screen.

I am starting a new project for this tutorial. After creating a new project, I am going to add a new xml file. This file will be my MotionLayout. For this, Layout > New > Layout Resource File. My new file name is content_main.xml.

After creating a new xml file, I am going to include this xml file to my activity xml file. For this, I am going to add the following code. My activity xml file is already ConstraintLayout.

<include layout="@layout/content_main" />

In this step, we are going to create our MotionLayout. It is similar to ConstraintLayout. In MotionLayout, we have 2 more properties. One of them is layoutDescription property. This property is mandatory. If you do not add this, you will see errors. Every MotionLayout should have their own MotionScene XML file. Second property is optional. It is showPaths property. I am using this property to see my animation path. The starter code is like the following. 

<?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:id="@+id/main_content"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:background="@drawable/background"
    android:padding="16dp"
    app:layoutDescription="@xml/scene_01"
    tools:context=".MainActivity"
    tools:showIn="@layout/activity_main"
    tools:showPaths="true">

</androidx.constraintlayout.motion.widget.MotionLayout>

Now, I am going to design my page (content_main.xml). For this, I added the following widgets to the xml file. Final code for the file is the following.

<?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:id="@+id/main_content"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:background="@drawable/background"
    android:padding="16dp"
    app:layoutDescription="@xml/scene_01"
    tools:context=".MainActivity"
    tools:showIn="@layout/activity_main"
    tools:showPaths="true">

    <androidx.appcompat.widget.AppCompatImageView
        android:id="@+id/image_sign"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:src="@drawable/level_crossing"
        app:layout_constraintBottom_toTopOf="@+id/description_background"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent"
        app:srcCompat="@drawable/level_crossing" />

    <androidx.appcompat.widget.AppCompatImageView
        android:id="@+id/description_background"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:src="@drawable/content_background"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toBottomOf="@+id/image_sign"
        app:srcCompat="@drawable/content_background" />

    <androidx.appcompat.widget.AppCompatTextView
        android:id="@+id/title_text"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:fontFamily="@font/heebo_bold"
        android:gravity="center"
        android:padding="16dp"
        android:text="@string/hello_text"
        android:textColor="@color/black"
        android:textSize="24sp"
        app:layout_constraintBottom_toTopOf="@+id/description_text"
        app:layout_constraintEnd_toEndOf="@id/description_background"
        app:layout_constraintStart_toStartOf="@id/description_background"
        app:layout_constraintTop_toBottomOf="@+id/description_background" />

    <androidx.appcompat.widget.AppCompatTextView
        android:id="@+id/description_text"
        android:layout_width="0dp"
        android:layout_height="wrap_content"
        android:fontFamily="@font/heebo"
        android:gravity="center"
        android:padding="16dp"
        android:text="@string/hello_text"
        android:textColor="@color/black"
        android:textSize="16sp"
        app:layout_constraintBottom_toTopOf="@+id/description_background"
        app:layout_constraintEnd_toEndOf="@id/description_background"
        app:layout_constraintStart_toStartOf="@id/description_background"
        app:layout_constraintTop_toBottomOf="@+id/title_text" />

</androidx.constraintlayout.motion.widget.MotionLayout>

Now, the page is ready but animations not. Let’s get started to add some animations for the page. Earlier, I created scene_01.xml (MotionScene) on my project. Starting code for this file is like the following.

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

</MotionScene>

Now, I am going to add the first ConstraintSet element in my MotionScene file. I defined the start locations for my widgets and I gave an id to my ConstraintSet. I will use this id property in my Transition element. The added code for this part is like the following.

<ConstraintSet android:id="@+id/start">
        <Constraint
            android:id="@+id/image_sign"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
           app:layout_constraintBottom_toTopOf="@+id/description_background"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toTopOf="parent"
            app:layout_constraintVertical_chainStyle="spread" />

        <Constraint
            android:id="@+id/description_background"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            app:layout_constraintBottom_toBottomOf="parent"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toBottomOf="@+id/image_sign" />

        <Constraint
            android:id="@+id/title_text"
            android:layout_width="0dp"
            android:layout_height="wrap_content"
            app:layout_constraintBottom_toTopOf="@+id/description_text"
            app:layout_constraintEnd_toEndOf="@id/description_background"
           app:layout_constraintStart_toStartOf="@id/description_background"
            app:layout_constraintTop_toTopOf="@+id/description_background"
            app:layout_constraintVertical_chainStyle="packed" />

        <Constraint
            android:id="@id/description_text"
            android:layout_width="0dp"
            android:layout_height="wrap_content"
        app:layout_constraintBottom_toBottomOf="@+id/description_background"
            app:layout_constraintEnd_toEndOf="@id/description_background"
           app:layout_constraintStart_toStartOf="@id/description_background"
            app:layout_constraintTop_toBottomOf="@+id/title_text" />
    </ConstraintSet>

Now, I am going to define my end locations. Like I said, my animations will happen between these two locations. The following code for end locations. For this sample, locations are similar. Because after animation, my widgets are returning to the starting point.

    <ConstraintSet android:id="@+id/end">
        <Constraint
            android:id="@+id/image_sign"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
           app:layout_constraintBottom_toTopOf="@+id/description_background"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toTopOf="parent"
            app:layout_constraintVertical_chainStyle="spread" />

        <Constraint
            android:id="@+id/description_background"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            app:layout_constraintBottom_toBottomOf="parent"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toBottomOf="@+id/image_sign" />

        <Constraint
            android:id="@+id/title_text"
            android:layout_width="0dp"
            android:layout_height="wrap_content"
            app:layout_constraintBottom_toTopOf="@+id/description_text"
            app:layout_constraintEnd_toEndOf="@id/description_background"
           app:layout_constraintStart_toStartOf="@id/description_background"
            app:layout_constraintTop_toTopOf="@+id/description_background"
            app:layout_constraintVertical_chainStyle="packed" />

        <Constraint
            android:id="@id/description_text"
            android:layout_width="0dp"
            android:layout_height="wrap_content"
        app:layout_constraintBottom_toBottomOf="@+id/description_background"
            app:layout_constraintEnd_toEndOf="@id/description_background"
           app:layout_constraintStart_toStartOf="@id/description_background"
            app:layout_constraintTop_toBottomOf="@+id/title_text" />
    </ConstraintSet>

Now, Time to add Transition element. With this Transition element, our object will start to move with given specifications. Let’s deep dive.

My basic Transition element is like the following. I gave an id because I will use this transition programmatically. I defined my starting and end points which I created in the previous step. I added duration property this is for animation. I defined it as a second.

<Transition
        android:id="@+id/swipeLeft"
        app:constraintSetEnd="@id/end"
        app:constraintSetStart="@id/start"
        app:duration="1000">

</Transition>

If I try to run this animation after this step, It works. But I am going to define KeyFrameSet element before running my animation. I am going to use this KeyFrameSet element to create more showy animations. This KeyFrameSet is going to happen between start and end points.

First of this step, I added two KeyPosition elements to customize the animation path. I said earlier in the article, animation happens randomly between two points. While those KeyPosition elements, MotionLayout are going to be pointed to locations on the selected times. It is possible to add more.

            <KeyPosition
                app:framePosition="25"
                app:keyPositionType="parentRelative"
                app:motionTarget="@id/image_sign"
                app:percentX="-0.5"
                app:percentY="0.3" />

            <KeyPosition
                app:framePosition="75"
                app:keyPositionType="parentRelative"
                app:motionTarget="@id/image_sign"
                app:percentX="1.5"
                app:percentY="0.3" />

Those two KeyPosition elements decided the left and right corner for the following triangle. First KeyPosition element is the left point, the second KeyPosition element is right. When I run the animation, the image will follow the shown triangle.

Moving path is completed for the image. Now, I am going to change opacity during the animation process. For this, I am going to define three KeyAttribute elements. It is similar to KeyPosition but with KeyAttribute, It is possible to manipulate the attributes. It is possible to change different attributes like size, background color etc. For this tutorial, the selected attribute is Opacity. Opacity is going to be 0 in the first element and between two elements (second and third) it is going to stay 0 and on the final attribute it is going to be 1. The codes are like the following.

            <KeyAttribute
                android:alpha="0.0"
                app:framePosition="25"
                app:motionTarget="@id/image_sign" />

            <KeyAttribute
                android:alpha="0.0"
                app:framePosition="75"
                app:motionTarget="@id/image_sign" />

            <KeyAttribute
                android:alpha="1.0"
                app:framePosition="100"
                app:motionTarget="@id/image_sign" />

After this step, we have to do the same things for the other 3 widgets. I am going to pass this step but you can find those on the GitHub repository.

We completed the front side. Now, let’s start to write back end. In this property, I am going to recognize screen touches and I will decide the right Transition via this. After every swipe I am going to change the item on the screen. 

First for this step, I am going to define my data class. The class code is like the following.

data class SliderItem(
    val id: Int,
    val image: Int,
    val title: String,
    val description: String
)

And then, I am going to create mock data for my tutorial. Mock data code is like the following.

class SliderItems {
    companion object Contents {
        fun getSliderItems(context: Context): List<SliderItem> {
            val items = arrayListOf<SliderItem>()

            items.add(SliderItem(1, R.drawable.no_u_turn, "No U Turns", "The no u-turn sign is designed to stop vehicles from turning onto the other side where it may be dangerous to other vehicles. "))
            items.add(SliderItem(2, R.drawable.no_turn_right, "No Turn Right", "The use of diagram 612 shows where turning right at a junction is prohibited."))
            items.add(SliderItem(3, R.drawable.level_crossing, "Level Crossing", "Level crossing signs are typically found on the approach to a crossing to warn drivers of the hazard ahead."))
            items.add(SliderItem(4, R.drawable.max_speed, "Max Speed", "The basic speed limit sign, which has been around for decades, is a red circle with a black number contained within it."))
            items.add(SliderItem(5, R.drawable.double_bend, "Double Bend Sign", "The warning triangle alone warns of a double bend, but accompanied by a supplementary plate detailing a distance which the hazard extends over warns of a series of bends over that distance"))
            items.add(SliderItem(6, R.drawable.road_work, "Road Working", "Throughout your driving career you will encounter road works on many occasions. Typically on single carriageway roads you will often see a road works warning sign initially to warn of the hazard ahead, followed by the ‘When red light shows wait here’ sign."))

            return items
        }
    }
}

Now, we are ready. Let’s return to Activity and start to make decisions. Firstly, I am going to define my variables. First two double(x1, x2) for detect to swipe direction. Third integer(displayItem) for visible item index and last two for object list and current object. The code for variable initializing is like the following.

    var x1 = 0.0f
    var x2 = 0.0f
    var displayItem = 0
    private lateinit var signList: List<SliderItem>
    private lateinit var item: SliderItem

On the next step, I am going to fill my list and I am going to show my first item on the screen. Before, I created a method which is returning a mock content list to me. I am adding the following code to the onCreate method.

signList = SliderItems.getSliderItems(this)
item = signList[displayItem]
image_sign.setImageResource(item.image)
title_text.text = item.title
description_text.text = item.description

Now, I am going to complete the last step. It is dispatchTouchEvent method. For this on windows, you can press CTRL + O and choose from the list. In the method, firstly I am going to find the first touched point. Because of that, I added the following code.

if (x1 == 0.0f) {
    x1 = ev!!.x
}

We used an if statement because we need to take the first touch point. If we do not use this, x1 changes during the swipe. After this I am going to add a WHEN statement and I am going to filter action. If the action is ACTION_MOVE, I am going to find a finish point when this action happens and I am going to decide the direction. 

when (ev!!.action) {
      MotionEvent.ACTION_MOVE -> {
      }
      else -> {
          Log.i("Information", "Unnecessary action")
      }
}

Inside of the filter, I added the following code.

x2 = ev.x
val content = main_content

First line, to take the finish point. Second line is not mandatory. I just assigned my layout to a variable. After this, I am going to compare x1 and x2. With this comparison, I am going to decide my direction. I added the following code.

if ((x1 < x2) && (x2.minus(x1) > 150)) {

                    if (displayItem > 0) {
                        displayItem = displayItem.minus(1)

                        content.setTransition(R.id.swipeLeft)
                        content.transitionToEnd()

                        Handler().postDelayed({
                            item = signList[displayItem]
                            image_sign.setImageResource(item.image)
                            title_text.text = item.title
                            description_text.text = item.description
                        }, 300)

                        content.setTransitionListener(object : MotionLayout.TransitionListener {
                            override fun onTransitionCompleted(p0: MotionLayout?, p1: Int) {
                                content.setTransition(R.id.swipeLeftReset)
                                content.transitionToEnd()
                                x1 = 0.0f
                            }

                            override fun onTransitionStarted(p0: MotionLayout?, p1: Int, p2: Int) {
                                //TODO("Not yet implemented")
                            }

                            override fun onTransitionChange(
                                p0: MotionLayout?,
                                p1: Int,
                                p2: Int,
                                p3: Float
                            ) {
                                //TODO("Not yet implemented")
                            }

                            override fun onTransitionTrigger(
                                p0: MotionLayout?,
                                p1: Int,
                                p2: Boolean,
                                p3: Float
                            ) {
                                //TODO("Not yet implemented")
                            }
                        })
                    }
                } else if ((x1 > x2) && x1.minus(x2) > 150) {

                    if (displayItem + 1 < signList.count()) {

                        displayItem = displayItem.plus(1)

                        content.setTransition(R.id.swipeRight)
                        content.transitionToEnd()

                        Handler().postDelayed({
                            item = signList[displayItem]
                            image_sign.setImageResource(item.image)
                            title_text.text = item.title
                            description_text.text = item.description
                        }, 300)

                        content.setTransitionListener(object : MotionLayout.TransitionListener {
                            override fun onTransitionCompleted(p0: MotionLayout?, p1: Int) {
                                content.setTransition(R.id.swipeRightReset)
                                content.transitionToEnd()
                                x1 = 0.0f
                            }

                            override fun onTransitionStarted(p0: MotionLayout?, p1: Int, p2: Int) {
                                //TODO("Not yet implemented")
                            }

                            override fun onTransitionChange(
                                p0: MotionLayout?,
                                p1: Int,
                                p2: Int,
                                p3: Float
                            ) {
                                //TODO("Not yet implemented")
                            }

                            override fun onTransitionTrigger(
                                p0: MotionLayout?,
                                p1: Int,
                                p2: Boolean,
                                p3: Float
                            ) {
                                //TODO("Not yet implemented")
                            }
                        })
                    }
                }

In the if statements, 150 is optional. I used this because I do not want to trigger my action on every touch. It is the minimum swipe value.

Now, the tutorial is ready to run. It is possible to run on a real device or emulator.

GitHub Link: Sample code for MotionLayout article. (github.com)

In this article, I tried to explain something about MotionLayout. Hopefully it would be useful.

See you 🙂

Leave a Reply

Your email address will not be published. Required fields are marked *