Posted By: Kunal Chaubal
Animations and transitions are essential ingredients for developing any mobile application. Think about how rarely we come across apps that do not have at least one animated component. It helps in keeping the app as user friendly as possible and becomes a very effective marketing tool.
At the same time, developing complex animations and transitions could be quite challenging, and not every developer looks forward to working on such tasks. While the user experience is highly increased with animations, we can’t say the same about development experience.
To address these issues, MotionLayout was introduced as a subclass of ConstraintLayout in v2.0
Objective
We will not be talking about the basics and fundamentals of MotionLayout, but rather take a deep dive on how to implement dynamic transitions with MotionLayout.
At the end of this article, we will be developing the following sample application which demonstrates a bottom navigation bar with some animations:
Sample App screenshot
Prerequisite
- Basic knowledge of MotionLayout (Check this article that explains some basics concepts pretty well)
- ConstraintLayout
- Basic Kotlin knowledge
Getting Started
Let’s go through this implementation in a step-by-step process:
- Adding the dependency
- Creating the UI (without animation)
- Generating MotionScene for MotionLayout
- Setting up Transitions
Here is a link to the sample repository in case you want to take a look at it first
1. Adding the dependency
Let’s start by adding the dependency constraint layout v2.0 in Gradle:
implementation 'androidx.constraintlayout:constraintlayout:2.0.0-beta6'
2. Creating the UI (without animation)
Let us begin developing the UI with MotionLayout without adding MotionScene to the app:layoutDescription
. The XML file would look something like this:
<?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:layout_width="match_parent" | |
android:layout_height="match_parent" | |
android:background="@color/colorBackground" | |
tools:context=".MainActivity"> | |
<androidx.constraintlayout.motion.widget.MotionLayout | |
android:id="@+id/motion_layout" | |
android:layout_width="match_parent" | |
android:layout_height="60dp" | |
android:background="@color/colorViewBackground" | |
app:layout_constraintBottom_toBottomOf="parent" | |
app:layout_constraintEnd_toEndOf="parent" | |
app:layout_constraintStart_toStartOf="parent"> | |
<androidx.constraintlayout.utils.widget.ImageFilterView | |
android:id="@+id/iv_home" | |
android:layout_width="wrap_content" | |
android:layout_height="wrap_content" | |
android:padding="4dp" | |
android:src="@drawable/ic_home_inactive" | |
app:altSrc="@drawable/ic_home_active" | |
app:layout_constraintBottom_toBottomOf="parent" | |
app:layout_constraintEnd_toStartOf="@id/tv_home" | |
app:layout_constraintHorizontal_chainStyle="spread" | |
app:layout_constraintStart_toStartOf="parent" | |
app:layout_constraintTop_toTopOf="parent" | |
app:overlay="false" | |
tools:ignore="ContentDescription" /> | |
<TextView | |
android:id="@+id/tv_home" | |
android:layout_width="wrap_content" | |
android:layout_height="wrap_content" | |
android:text="@string/text_home" | |
android:textColor="@color/colorText" | |
android:textSize="18sp" | |
android:visibility="gone" | |
app:layout_constraintBottom_toBottomOf="parent" | |
app:layout_constraintEnd_toStartOf="@id/iv_search" | |
app:layout_constraintHorizontal_chainStyle="spread" | |
app:layout_constraintStart_toEndOf="@id/iv_home" | |
app:layout_constraintTop_toTopOf="parent" /> | |
<androidx.constraintlayout.utils.widget.ImageFilterView | |
android:id="@+id/iv_search" | |
android:layout_width="wrap_content" | |
android:layout_height="wrap_content" | |
android:padding="4dp" | |
android:src="@drawable/ic_search_inactive" | |
app:altSrc="@drawable/ic_search_active" | |
app:layout_constraintBottom_toBottomOf="parent" | |
app:layout_constraintEnd_toStartOf="@id/tv_search" | |
app:layout_constraintHorizontal_chainStyle="spread" | |
app:layout_constraintStart_toEndOf="@id/tv_home" | |
app:layout_constraintTop_toTopOf="parent" | |
app:overlay="false" | |
tools:ignore="ContentDescription" /> | |
<TextView | |
android:id="@+id/tv_search" | |
android:layout_width="wrap_content" | |
android:layout_height="wrap_content" | |
android:text="@string/text_search" | |
android:textColor="@color/colorText" | |
android:textSize="18sp" | |
android:visibility="gone" | |
app:layout_constraintBottom_toBottomOf="parent" | |
app:layout_constraintEnd_toStartOf="@id/iv_like" | |
app:layout_constraintHorizontal_chainStyle="spread" | |
app:layout_constraintStart_toEndOf="@id/iv_search" | |
app:layout_constraintTop_toTopOf="parent" /> | |
<androidx.constraintlayout.utils.widget.ImageFilterView | |
android:id="@+id/iv_like" | |
android:layout_width="wrap_content" | |
android:layout_height="wrap_content" | |
android:padding="4dp" | |
android:src="@drawable/ic_like_inactive" | |
app:altSrc="@drawable/ic_like_active" | |
app:layout_constraintBottom_toBottomOf="parent" | |
app:layout_constraintEnd_toStartOf="@id/tv_like" | |
app:layout_constraintHorizontal_chainStyle="spread" | |
app:layout_constraintStart_toEndOf="@id/tv_search" | |
app:layout_constraintTop_toTopOf="parent" | |
app:overlay="false" | |
tools:ignore="ContentDescription" /> | |
<TextView | |
android:id="@+id/tv_like" | |
android:layout_width="wrap_content" | |
android:layout_height="wrap_content" | |
android:text="@string/text_like" | |
android:textColor="@color/colorText" | |
android:textSize="18sp" | |
android:visibility="gone" | |
app:layout_constraintBottom_toBottomOf="parent" | |
app:layout_constraintEnd_toStartOf="@id/iv_profile" | |
app:layout_constraintHorizontal_chainStyle="spread" | |
app:layout_constraintStart_toEndOf="@id/iv_like" | |
app:layout_constraintTop_toTopOf="parent" /> | |
<androidx.constraintlayout.utils.widget.ImageFilterView | |
android:id="@+id/iv_profile" | |
android:layout_width="wrap_content" | |
android:layout_height="wrap_content" | |
android:padding="4dp" | |
android:src="@drawable/ic_profile_inactive" | |
app:altSrc="@drawable/ic_profile_active" | |
app:layout_constraintBottom_toBottomOf="parent" | |
app:layout_constraintEnd_toStartOf="@id/tv_profile" | |
app:layout_constraintHorizontal_chainStyle="spread" | |
app:layout_constraintStart_toEndOf="@id/tv_like" | |
app:layout_constraintTop_toTopOf="parent" | |
app:overlay="false" | |
tools:ignore="ContentDescription" /> | |
<TextView | |
android:id="@+id/tv_profile" | |
android:layout_width="wrap_content" | |
android:layout_height="wrap_content" | |
android:text="@string/text_profile" | |
android:textColor="@color/colorText" | |
android:textSize="18sp" | |
android:visibility="gone" | |
app:layout_constraintBottom_toBottomOf="parent" | |
app:layout_constraintEnd_toEndOf="parent" | |
app:layout_constraintHorizontal_chainStyle="spread" | |
app:layout_constraintStart_toEndOf="@id/iv_profile" | |
app:layout_constraintTop_toTopOf="parent" /> | |
</androidx.constraintlayout.motion.widget.MotionLayout> | |
</androidx.constraintlayout.widget.ConstraintLayout> |
ImageFilterView
is a ConstraintLayout util widget which can be used for crossfading. We provide the two images in android:src
and app:altSrc
app:overlay
is set to false so that both the images do not overlap each other
We have used constraint chains to keep the views evenly distributed. There are multiple chaining styles available, you can choose the one that suits your application, best. For this sample app, we have used spread
chaining style
app:layout_constraintHorizontal_chainStyle="spread"
Notice that we have set all TextView visibility to ‘gone’. This is because, as per our requirement, only the text of the active tab should be visible. Hence, we are trying to achieve a base state where no tabs are selected at the app launch.
Your app UI after Step 2 should look something like this:
App UI representing Base State
3. Creating MotionScene for MotionLayout
Now, before creating a motion scene file, let’s try to understand what kind of constraint sets and transitions we are trying to achieve
Diagram to represent all States
States 1–4 are achieved when the respective images are clicked.
Since the view represents a bottom navigation bar, these states are notachieved sequentially. It is not necessary that the state transition will occur only in the following way:
Base State → State 1 → State 2 → State 3 → State 4
In reality, there can be multiple permutations while reaching any state. Hence, to achieve this scenario lets create a constraint set for every State that we discussed until now. The ConstraintSet
for base state would look like this:
<ConstraintSet android:id="@+id/base"> | |
<Constraint | |
android:id="@+id/iv_home" | |
android:layout_width="wrap_content" | |
android:layout_height="wrap_content" | |
app:layout_constraintBottom_toBottomOf="parent" | |
app:layout_constraintEnd_toStartOf="@id/tv_home" | |
app:layout_constraintHorizontal_chainStyle="spread" | |
app:layout_constraintStart_toStartOf="parent" | |
app:layout_constraintTop_toTopOf="parent"> | |
<CustomAttribute | |
app:attributeName="crossfade" | |
app:customFloatValue="0" /> | |
</Constraint> | |
<Constraint | |
android:id="@+id/iv_search" | |
android:layout_width="wrap_content" | |
android:layout_height="wrap_content" | |
app:layout_constraintBottom_toBottomOf="parent" | |
app:layout_constraintEnd_toStartOf="@id/tv_search" | |
app:layout_constraintHorizontal_chainStyle="spread" | |
app:layout_constraintStart_toEndOf="@id/tv_home" | |
app:layout_constraintTop_toTopOf="parent"> | |
<CustomAttribute | |
app:attributeName="crossfade" | |
app:customFloatValue="0" /> | |
</Constraint> | |
<Constraint | |
android:id="@+id/iv_like" | |
android:layout_width="wrap_content" | |
android:layout_height="wrap_content" | |
app:layout_constraintBottom_toBottomOf="parent" | |
app:layout_constraintEnd_toStartOf="@id/tv_like" | |
app:layout_constraintHorizontal_chainStyle="spread" | |
app:layout_constraintStart_toEndOf="@id/tv_search" | |
app:layout_constraintTop_toTopOf="parent"> | |
<CustomAttribute | |
app:attributeName="crossfade" | |
app:customFloatValue="0" /> | |
</Constraint> | |
<Constraint | |
android:id="@+id/iv_profile" | |
android:layout_width="wrap_content" | |
android:layout_height="wrap_content" | |
app:layout_constraintBottom_toBottomOf="parent" | |
app:layout_constraintEnd_toStartOf="@id/tv_profile" | |
app:layout_constraintHorizontal_chainStyle="spread" | |
app:layout_constraintStart_toEndOf="@id/tv_like" | |
app:layout_constraintTop_toTopOf="parent"> | |
<CustomAttribute | |
app:attributeName="crossfade" | |
app:customFloatValue="0" /> | |
</Constraint> | |
</ConstraintSet> |
ConstraintSet for Base State
Job Offers
Here, we make sure that the default constraints are set to copy the base state. CustomAttribute
is used to crossfade the image within the two images we provided earlier. app:customFloatValue
has a range of 0 to 1
<CustomAttribute app:attributeName="crossfade" app:customFloatValue="0" />
The ConstraintSet
for State 1 would look like this:
<ConstraintSet android:id="@+id/home_expand" | |
app:deriveConstraintsFrom="@id/base"> | |
<Constraint | |
android:id="@+id/iv_home" | |
android:layout_width="wrap_content" | |
android:layout_height="wrap_content" | |
app:layout_constraintBottom_toBottomOf="parent" | |
app:layout_constraintEnd_toStartOf="@id/tv_home" | |
app:layout_constraintHorizontal_chainStyle="spread" | |
app:layout_constraintStart_toStartOf="parent" | |
app:layout_constraintTop_toTopOf="parent"> | |
<CustomAttribute | |
app:attributeName="crossfade" | |
app:customFloatValue="1" /> | |
</Constraint> | |
<Constraint | |
android:id="@+id/tv_home" | |
android:layout_width="wrap_content" | |
android:layout_height="wrap_content" | |
android:visibility="visible" | |
app:layout_constraintBottom_toBottomOf="parent" | |
app:layout_constraintEnd_toStartOf="@id/iv_search" | |
app:layout_constraintHorizontal_chainStyle="spread" | |
app:layout_constraintStart_toEndOf="@id/iv_home" | |
app:layout_constraintTop_toTopOf="parent" /> | |
</ConstraintSet> |
ConstraintSet for State 1
Here, we modify the visibility of the TextView and toggle the image by setting app:customFloatValue
to 1
Notice that we have derived constraints from our base state by adding this line of code — app:deriveConstraintFrom=”@id/base"
This is the most important piece of code since we need to make sure that we reset all the other states while transitioning to a user-selected state.
All the constraints from ‘base’ ConstraintSet would be implemented and the constraints that we have further defined in this ConstraintSet are the overridden constraints.
ConstraintSet for other states would be similar to State 1. Here is the final motion_scene.xml
file:
<?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"> | |
<ConstraintSet android:id="@+id/base"> | |
<Constraint | |
android:id="@+id/iv_home" | |
android:layout_width="wrap_content" | |
android:layout_height="wrap_content" | |
app:layout_constraintBottom_toBottomOf="parent" | |
app:layout_constraintEnd_toStartOf="@id/tv_home" | |
app:layout_constraintHorizontal_chainStyle="spread" | |
app:layout_constraintStart_toStartOf="parent" | |
app:layout_constraintTop_toTopOf="parent"> | |
<CustomAttribute | |
app:attributeName="crossfade" | |
app:customFloatValue="0" /> | |
</Constraint> | |
<Constraint | |
android:id="@+id/iv_search" | |
android:layout_width="wrap_content" | |
android:layout_height="wrap_content" | |
app:layout_constraintBottom_toBottomOf="parent" | |
app:layout_constraintEnd_toStartOf="@id/tv_search" | |
app:layout_constraintHorizontal_chainStyle="spread" | |
app:layout_constraintStart_toEndOf="@id/tv_home" | |
app:layout_constraintTop_toTopOf="parent"> | |
<CustomAttribute | |
app:attributeName="crossfade" | |
app:customFloatValue="0" /> | |
</Constraint> | |
<Constraint | |
android:id="@+id/iv_like" | |
android:layout_width="wrap_content" | |
android:layout_height="wrap_content" | |
app:layout_constraintBottom_toBottomOf="parent" | |
app:layout_constraintEnd_toStartOf="@id/tv_like" | |
app:layout_constraintHorizontal_chainStyle="spread" | |
app:layout_constraintStart_toEndOf="@id/tv_search" | |
app:layout_constraintTop_toTopOf="parent"> | |
<CustomAttribute | |
app:attributeName="crossfade" | |
app:customFloatValue="0" /> | |
</Constraint> | |
<Constraint | |
android:id="@+id/iv_profile" | |
android:layout_width="wrap_content" | |
android:layout_height="wrap_content" | |
app:layout_constraintBottom_toBottomOf="parent" | |
app:layout_constraintEnd_toStartOf="@id/tv_profile" | |
app:layout_constraintHorizontal_chainStyle="spread" | |
app:layout_constraintStart_toEndOf="@id/tv_like" | |
app:layout_constraintTop_toTopOf="parent"> | |
<CustomAttribute | |
app:attributeName="crossfade" | |
app:customFloatValue="0" /> | |
</Constraint> | |
</ConstraintSet> | |
<ConstraintSet android:id="@+id/home_expand" | |
app:deriveConstraintsFrom="@id/base"> | |
<Constraint | |
android:id="@+id/iv_home" | |
android:layout_width="wrap_content" | |
android:layout_height="wrap_content" | |
app:layout_constraintBottom_toBottomOf="parent" | |
app:layout_constraintEnd_toStartOf="@id/tv_home" | |
app:layout_constraintHorizontal_chainStyle="spread" | |
app:layout_constraintStart_toStartOf="parent" | |
app:layout_constraintTop_toTopOf="parent"> | |
<CustomAttribute | |
app:attributeName="crossfade" | |
app:customFloatValue="1" /> | |
</Constraint> | |
<Constraint | |
android:id="@+id/tv_home" | |
android:layout_width="wrap_content" | |
android:layout_height="wrap_content" | |
android:visibility="visible" | |
app:layout_constraintBottom_toBottomOf="parent" | |
app:layout_constraintEnd_toStartOf="@id/iv_search" | |
app:layout_constraintHorizontal_chainStyle="spread" | |
app:layout_constraintStart_toEndOf="@id/iv_home" | |
app:layout_constraintTop_toTopOf="parent" /> | |
</ConstraintSet> | |
<ConstraintSet android:id="@+id/search_expand" | |
app:deriveConstraintsFrom="@id/base"> | |
<Constraint | |
android:id="@+id/iv_search" | |
android:layout_width="wrap_content" | |
android:layout_height="wrap_content" | |
app:layout_constraintBottom_toBottomOf="parent" | |
app:layout_constraintEnd_toStartOf="@id/tv_search" | |
app:layout_constraintHorizontal_chainStyle="spread" | |
app:layout_constraintStart_toEndOf="@id/tv_home" | |
app:layout_constraintTop_toTopOf="parent"> | |
<CustomAttribute | |
app:attributeName="crossfade" | |
app:customFloatValue="1" /> | |
</Constraint> | |
<Constraint | |
android:id="@+id/tv_search" | |
android:layout_width="wrap_content" | |
android:layout_height="wrap_content" | |
android:visibility="visible" | |
app:layout_constraintBottom_toBottomOf="parent" | |
app:layout_constraintEnd_toStartOf="@id/iv_like" | |
app:layout_constraintHorizontal_chainStyle="spread" | |
app:layout_constraintStart_toEndOf="@id/iv_search" | |
app:layout_constraintTop_toTopOf="parent" /> | |
</ConstraintSet> | |
<ConstraintSet android:id="@+id/like_expand" | |
app:deriveConstraintsFrom="@id/base"> | |
<Constraint | |
android:id="@+id/iv_like" | |
android:layout_width="wrap_content" | |
android:layout_height="wrap_content" | |
app:layout_constraintBottom_toBottomOf="parent" | |
app:layout_constraintEnd_toStartOf="@id/tv_like" | |
app:layout_constraintHorizontal_chainStyle="spread" | |
app:layout_constraintStart_toEndOf="@id/tv_search" | |
app:layout_constraintTop_toTopOf="parent"> | |
<CustomAttribute | |
app:attributeName="crossfade" | |
app:customFloatValue="1" /> | |
</Constraint> | |
<Constraint | |
android:id="@+id/tv_like" | |
android:layout_width="wrap_content" | |
android:layout_height="wrap_content" | |
android:visibility="visible" | |
app:layout_constraintBottom_toBottomOf="parent" | |
app:layout_constraintEnd_toStartOf="@id/iv_profile" | |
app:layout_constraintHorizontal_chainStyle="spread" | |
app:layout_constraintStart_toEndOf="@id/iv_like" | |
app:layout_constraintTop_toTopOf="parent" /> | |
</ConstraintSet> | |
<ConstraintSet android:id="@+id/profile_expand" | |
app:deriveConstraintsFrom="@id/base"> | |
<Constraint | |
android:id="@+id/iv_profile" | |
android:layout_width="wrap_content" | |
android:layout_height="wrap_content" | |
app:layout_constraintBottom_toBottomOf="parent" | |
app:layout_constraintEnd_toStartOf="@id/tv_profile" | |
app:layout_constraintHorizontal_chainStyle="spread" | |
app:layout_constraintStart_toEndOf="@id/tv_like" | |
app:layout_constraintTop_toTopOf="parent"> | |
<CustomAttribute | |
app:attributeName="crossfade" | |
app:customFloatValue="1" /> | |
</Constraint> | |
<Constraint | |
android:id="@+id/tv_profile" | |
android:layout_width="wrap_content" | |
android:layout_height="wrap_content" | |
android:visibility="visible" | |
app:layout_constraintBottom_toBottomOf="parent" | |
app:layout_constraintEnd_toEndOf="parent" | |
app:layout_constraintHorizontal_chainStyle="spread" | |
app:layout_constraintStart_toEndOf="@id/iv_profile" | |
app:layout_constraintTop_toTopOf="parent" /> | |
</ConstraintSet> | |
</MotionScene> |
Don’t forget to link motion_scene.xml to your motion layout in the following way:
app:layoutDescription="@xml/motion_scene"
4. Setting up Transitions
Now that we have MotionLayout and MotionScene in place, all that’s left to do is to add transitions to the ConstraintSets we implemented in the previous step.
If you are wondering why didn’t we implement transitions in the same MotionScene file, it is because these transitions are not expected to occur in a sequential static flow. As discussed earlier, depending on what the user selects, constraintSetStart
and constraintSetEnd
are derived.
Let’s consider the user selection is in the following manner:
- State 1 → State 3
- State 3 → State 2
Now, for this transition to work, we would need to defineconstraintSetStart
and constraintSetEnd
while defining the Transition in motion_scene.xml
for both the state changes i.e from State 1 to 3 and State 3 to 2. This doesn’t seem possible to achieve for a dynamic transition.
Hence, to achieve the required transition effect, we would need to define the current state in constraintSetStart
during runtime and the ConstraintSet of the user-selected image in constraintSetEnd
:
class MainActivity : AppCompatActivity() { | |
override fun onCreate(savedInstanceState: Bundle?) { | |
super.onCreate(savedInstanceState) | |
setContentView(R.layout.activity_main) | |
// Apply default transition on app launch | |
motion_layout.setTransition(motion_layout.currentState, R.id.home_expand) | |
motion_layout.transitionToEnd() | |
setClickListener() | |
} | |
private fun setClickListener() { | |
iv_home.setOnClickListener { | |
setTransition(motion_layout.currentState, R.id.home_expand) | |
} | |
iv_search.setOnClickListener { | |
setTransition(motion_layout.currentState, R.id.search_expand) | |
} | |
iv_like.setOnClickListener { | |
setTransition(motion_layout.currentState, R.id.like_expand) | |
} | |
iv_profile.setOnClickListener { | |
setTransition(motion_layout.currentState, R.id.profile_expand) | |
} | |
} | |
private fun setTransition(startState: Int, endState: Int) { | |
motion_layout.setTransition(startState, endState) | |
motion_layout.setTransitionDuration(200) | |
motion_layout.transitionToEnd() | |
} | |
} |
Depending upon the default state that the user would like to show when app launches, we can set the required transition state
motion_layout.setTransition(motion_layout.currentState, R.id.home_expand) motion_layout.transitionToEnd()
Here, motion_layout.
currentState gives us the current state during runtime. R.id.
home_expand is the ID of the view we want to highlight.
We have implemented click listeners to identify which view was clicked and set the respective transition accordingly.
iv_search.setOnClickListener { setTransition(motion_layout.currentState, R.id.search_expand) }
By setting transitions in the activity, it becomes convenient to set transitions during runtime in motion layout.
That’s all for this article, let me know your thoughts on this approach. Since I am still exploring MotionLayout, if you know any better way to achieve this functionality, I would love to discuss it.
You can find the sample app on GitHub:
KunalChaubal/MotionLayoutExampleContribute to KunalChaubal/MotionLayoutExample development by creating an account on GitHub. |
Feel free to Fork it/contribute to it.
Happy Coding!