Hi everyone!
Mobile applications have come a long way, and one thing that has continually captured our attention and delight is animations. Whether you’re a user enjoying the seamless transitions or a developer striving to craft captivating user experiences, animations are the beating heart of mobile apps. They define the character and personality of an app, elevating it from a mere digital tool to a delightful and immersive experience.
As developers, many of us have faced the challenge of implementing animations that not only look good but also perform efficiently. In the early days, achieving this balance was often a struggle. However, the world of mobile app development witnessed a game-changing innovation — MotionLayout. Initially introduced in XML layouts to create cleaner animations, MotionLayout has since evolved and now seamlessly integrates with Jetpack Compose, opening up new avenues for crafting visually stunning and interactive UIs.
Today, I’m excited to share my journey and experiences with MotionLayout in Jetpack Compose. Our goal is simple yet instructive — to create a button that triggers an engaging animation when items are added to a shopping basket. While this task may seem straightforward, the journey promises to be a rich learning experience, unveiling the potential and power of MotionLayout within the realm of modern app development.
Starting
To begin, we’ll start by implementing the latest version of the ConstraintLayout Compose library. In my case, I opted for version 1.1.0-alpha13, as the stable release had some known bugs that led to occasional crashes. It’s always a good practice to ensure the library you’re using is stable and reliable for a seamless development experience.
Our first step is to create a composable that will serve as the foundation for our animation. This composable will house the elements and components essential to crafting our desired animation effect.
Box(
modifier = modifier
) {
MotionLayout() {
Icon(
painter = painterResource(id = R.drawable.ic_basket),
contentDescription = null,
tint = Color.WHITE,
modifier = Modifier
.padding(top = 12.dp, bottom = 12.dp, start = 12.dp)
.size(20.dp, 20.dp)
)
Text(
text = "Add To Basket",
modifier = Modifier
.padding(12.dp),
style = Typography.Body,
color = Color.WHITE,
maxLines = 1
)
}
}
Our next step involves setting up a default scene for the IDLE state. To achieve this, you can create a JSON5 file named ‘scene_button.json5’ and place it in the ‘raw’ folder. This JSON5 file will define the initial configuration for our animation.
{
ConstraintSets: {
start: {
basket: {
start: ['parent', 'start'],
top: ['parent', 'top'],
bottom: ['parent', 'bottom']
},
text: {
start: ['basket', 'end'],
end: ['parent', 'end'],
top: ['parent', 'top'],
bottom: ['parent', 'bottom']
}
},
end: {
basket: {
start: ['parent', 'start'],
top: ['parent', 'top'],
bottom: ['parent', 'bottom']
},
text: {
start: ['basket', 'end'],
end: ['parent', 'end'],
top: ['parent', 'top'],
bottom: ['parent', 'bottom']
}
}
}
}
Following this, we’ll proceed by adding the layout IDs in our code to associate properties with the views. To achieve this, we’ll read the scene file and set it as a motion scene for the layout.
val context = LocalContext.current
val sceneFile = R.raw.scene_button
val motionSceneContent = remember(sceneFile) {
context.resources
.openRawResource(sceneFile)
.readBytes()
.decodeToString()
}
Box(
modifier = modifier
.background(
color = Color.GREEN,
shape = RoundedCornerShape(50)
)
) {
MotionLayout(
motionScene = MotionScene(
content = motionSceneContent
), progress = 0f
) {
Box(
modifier = ...
.layoutId("product")
)
Icon(
modifier = ..
.layoutId("basket")
)
Text(
modifier = ...
.layoutId("text")
)
}
}
After this code block applied you should have the preview as like:
Now we need a product view that will fall into the basket, for this we will create a box to create that view.
Box(...) {
MotionLayout(...) {
Box(
modifier = Modifier
.size(8.dp)
.background(
color = Color.YELLOW,
shape = RoundedCornerShape(percent = 25)
)
.layoutId("product")
)
Icon(...)
Text(
text = "Add To Basket"
)
}
}
And let’s add this product view also in our scene file as well, our default scene file look like this in the end:
{
ConstraintSets: {
start: {
product: {
start: ['basket', 'start'],
end: ['basket', 'end'],
bottom: ['parent', 'top']
},
basket: {
start: ['parent', 'start'],
top: ['parent', 'top'],
bottom: ['parent', 'bottom']
},
text: {
start: ['basket', 'end'],
end: ['parent', 'end'],
top: ['parent', 'top'],
bottom: ['parent', 'bottom']
}
},
end: {
product: {
start: ['basket', 'start'],
end: ['basket', 'end'],
bottom: ['parent', 'top']
},
basket: {
start: ['parent', 'start'],
top: ['parent', 'top'],
bottom: ['parent', 'bottom']
},
text: {
start: ['basket', 'end'],
end: ['parent', 'end'],
top: ['parent', 'top'],
bottom: ['parent', 'bottom']
}
}
}
}
This motion will hide product view since it should not be visible in the first place.
Let’s Dive In — Bringing Composable to Life!
Now that we’re all set, it’s time to infuse life into our views without creating chaos in our code. In this section, I’ll showcase some of MotionLayout’s features, providing you with insights and tips to apply similar effects in your own animations with ease.
Our first task is to animate the product item as it gracefully lands into the shopping basket. To achieve this, we’ll define the starting and ending constraints of the view, orchestrating a seamless transition from its initial position to its final destination. To facilitate this, we’ll create a new scene file, naming it ‘scene_button_add.json5,’ and configure the essential attributes to work their magic.
{
ConstraintSets: {
start: {
product: {
start: ['basket', 'start'],
bottom: ['parent', 'top']
},
basket: {
start: ['parent', 'start'],
top: ['parent', 'top'],
bottom: ['parent', 'bottom']
},
text: {
start: ['basket', 'end'],
end: ['parent', 'end'],
top: ['parent', 'top'],
bottom: ['parent', 'bottom']
}
},
end: {
product: {
end: ['basket', 'end'],
top: ['parent', 'top'],
bottom: ['parent', 'bottom']
},
basket: {
start: ['parent', 'start'],
top: ['parent', 'top'],
bottom: ['parent', 'bottom']
},
text: {
start: ['basket', 'end'],
end: ['parent', 'end'],
top: ['parent', 'top'],
bottom: ['parent', 'bottom']
}
}
}
}
By these attributes what we are expecting it for product view is to start from the top left of the view and end in the bottom right of the basket view.
Now, we need something more here, we want to play some with the product and basket view to make them look like they are interacting with each other. For that we will use the Transitions, we will add a rotation and alpha animations to the product view and rotation animation for the basket view to make it look like it’s catching the product.
{
ConstraintSets: {
start: {
product: {
start: ['basket', 'start'],
bottom: ['parent', 'top']
},
basket: {
start: ['parent', 'start'],
top: ['parent', 'top'],
bottom: ['parent', 'bottom']
},
text: {
start: ['basket', 'end'],
end: ['parent', 'end'],
top: ['parent', 'top'],
bottom: ['parent', 'bottom']
}
},
end: {
product: {
end: ['basket', 'end'],
top: ['parent', 'top'],
bottom: ['parent', 'bottom']
},
basket: {
start: ['parent', 'start'],
top: ['parent', 'top'],
bottom: ['parent', 'bottom']
},
text: {
start: ['basket', 'end'],
end: ['parent', 'end'],
top: ['parent', 'top'],
bottom: ['parent', 'bottom']
}
}
},
Transitions: {
default: {
from: 'start',
to: 'end',
KeyFrames: {
KeyAttributes: [
{
target: ['product'],
frames: [0, 50],
rotationZ: [0, 90]
},
{
target: ['product'],
frames: [0, 50, 75, 100],
alpha: [1, 1, 1, 0]
},
{
target: ['basket'],
frames: [0, 75, 80, 90, 100],
rotationZ: [0, 0, 30, 30, 0]
}
]
}
}
}
}
Explanation of the animations here:
- View defined as “product” between the 0–50 frames out of 100, rotate in Z axis 90 degrees and between 50–100, stay as it is
- View defined as “product” between the 0–75 frames out of 100, alpha stays as 1f and then 75–100 fades away and becomes 0f
- View defined as “basket” between the 0–75 frames out of 100, stay as it is and then 75 to 80 rotate 30 degrees and keep it until 90th frame and then 90–100 go back to 0.
The output of this is:
We finally have something that looks nice (not too bad, you can definitely create something better, this is just for the demonstration!)
The next thing is we want to add something that MotionLayout does not supports in the default attributes, what we can do? They have a solution for this as well, Custom Attributes!
Custom Attributes
MotionLayout has some attributes we can use it directly but also it has a way to define your own variable that will automatically calculate the value in between start — end scenes by time. In this example we will add blur effect on the text to show how can we use the custom attributes.
start: {
product: {
start: ['basket', 'start'],
bottom: ['parent', 'top']
},
basket: {
start: ['parent', 'start'],
top: ['parent', 'top'],
bottom: ['parent', 'bottom']
},
text: {
start: ['basket', 'end'],
end: ['parent', 'end'],
top: ['parent', 'top'],
bottom: ['parent', 'bottom'],
custom: {
blur: 0
}
}
}
We have defined the blur variable in the object named as custom. The next step is to define this in the code for the view that we want to apply it.
MotionLayout(
...
) {
val textProperties = customProperties(id = "text")
Text(
text = "Add To Basket",
modifier = Modifier
.padding(12.dp)
.layoutId("text")
.blur(textProperties.distance("blur"))
)
}
Now let’s see the result:
Animate It
Without adding too much in our codebase we have implemented an animation, let’s make this animation playable. For this we will basically add a trigger on the click event of the button but you can change this by the state of the screen (once your request has been sent etc.).
@Composable
fun AnimatedButton(
modifier: Modifier = Modifier,
duration: Int = 500
) {
var isAdding by remember {
mutableStateOf(false)
}
val context = LocalContext.current
val sceneFile = if (isAdding) {
R.raw.scene_button_add
} else {
R.raw.scene_button
}
val motionSceneContent = remember(sceneFile) {
context.resources
.openRawResource(sceneFile)
.readBytes()
.decodeToString()
}
var progress by remember(motionSceneContent) {
mutableFloatStateOf(0f)
}
Box(modifier = modifier
.clickable {
isAdding = true
}
.background(
color = Color.GREEN,
shape = RoundedCornerShape(50)
)
) {
MotionLayout(
motionScene = MotionScene(
content = motionSceneContent
), progress = progress
) {
val textProperties = customProperties(id = "text")
Box(
modifier = Modifier
.size(8.dp)
.background(
color = Color.YELLOW,
shape = RoundedCornerShape(percent = 25)
)
.layoutId("product")
)
Icon(
painter = painterResource(id = R.drawable.ic_basket),
contentDescription = null,
tint = Color.WHITE,
modifier = Modifier
.padding(top = 12.dp, bottom = 12.dp, start = 12.dp)
.size(20.dp, 20.dp)
.layoutId("basket")
)
Text(
text = "Add To Basket",
modifier = Modifier
.padding(12.dp)
.layoutId("text")
.blur(textProperties.distance("blur")),
style = Typography.Body,
color = Color.WHITE,
maxLines = 1
)
}
}
LaunchedEffect(sceneFile) {
animate(
initialValue = 0f,
targetValue = 1f,
animationSpec = tween(durationMillis = duration, easing = LinearEasing)
) { value, _ ->
progress = value
if (progress == 1f) {
isAdding = false
}
}
}
}
This example serves as a fundamental introduction to MotionLayout, offering you a straightforward way to grasp its core concepts. Now, it’s your turn to take what you’ve learned here and apply it to more complex animations in your projects. Don’t hesitate to experiment and explore the vast possibilities MotionLayout has to offer.
I trust you found this article both informative and inspiring. If you have any questions or want to share your thoughts, please do so. Your feedback is invaluable to us as we continue to explore the exciting world of mobile app animation.
Thank you for joining me on this journey, and I wish you a fantastic day filled with creativity and innovation!