12.
Animating Properties Using Compose
Written by Denis Buketa
Great job on completing the previous chapter. So far, in the third section of this book, you’ve learned how to use ConstraintLayout, build complex UI and react to Compose lifecycles. Those things are certainly fun, but what’s even more fun? Playing with animations! And that’s what you’ll do now. :]
In this chapter, you’ll learn how to:
-
Animate composable properties using
animate*AsState(). - Use
updateTransition()to animate multiple properties of your composables. - Animate composable content.
- Implement an animated button to join a subreddit.
- Implement an animated toast that displays when the user joins a subreddit.
Before diving straight into the animation world, you’ll create a composable representing a button that lets users join an imaginary subreddit.
You’ll start by implementing a simple button, like the one shown below:
If a user hasn’t joined the subreddit yet, they can do so by clicking the blue button with the plus icon. If the user is a member already, a white button with a blue check represents that state. Clicking the button again returns it to its previous state.
To follow along with the code examples, open this chapter’s starter project in Android Studio and select Open an existing project.
Next, navigate to 12-animating-properties-using-compose/projects and select the starter folder as the project root. Once the project opens, let it build and sync and you’re ready to go!
Note that if you skip ahead to the final project, you’ll find the completed button with all the animation logic implemented.
Now that you’re all set, it’s time to start coding.
Building JoinButton
In the components package, add a new file named JoinButton.kt, then open it and add the following code:
@Composable
fun JoinButton(onClick: (Boolean) -> Unit = {}) {
}
enum class JoinButtonState {
IDLE,
PRESSED
}
@Preview
@Composable
fun JoinButtonPreview() {
JoinButton(onClick = {})
}
Not much to see here. You just created a root composable for your button and added a preview. Right now, there’s nothing to preview because you haven’t added any content yet.
You also added JoinButtonState, which represents the state of the button, The two options for the state are IDLE or PRESSED.
Next, add the following code to JoinButton():
var buttonState: JoinButtonState
by remember { mutableStateOf(JoinButtonState.IDLE) }
// Button shape
val shape = RoundedCornerShape(corner = CornerSize(12.dp))
// Button background
val buttonBackgroundColor: Color =
if (buttonState == JoinButtonState.PRESSED)
Color.White
else
Color.Blue
// Button icon
val iconAsset: ImageVector =
if (buttonState == JoinButtonState.PRESSED)
Icons.Default.Check
else
Icons.Default.Add
val iconTintColor: Color =
if (buttonState == JoinButtonState.PRESSED)
Color.Blue
else
Color.White
Box(
modifier = Modifier
.clip(shape)
.border(width = 1.dp, color = Color.Blue, shape = shape)
.background(color = buttonBackgroundColor)
.size(width = 40.dp, height = 24.dp)
.clickable(onClick = {
buttonState =
if (buttonState == JoinButtonState.IDLE) {
onClick.invoke(true)
JoinButtonState.PRESSED
} else {
onClick.invoke(false)
JoinButtonState.IDLE
}
}),
contentAlignment = Alignment.Center
) {
Icon(
imageVector = iconAsset,
contentDescription = "Plus Icon",
tint = iconTintColor,
modifier = Modifier.size(16.dp)
)
}
This might look like a lot of code, but you’ll see that it’s pretty simple. Here’s a breakdown, starting from the top.
You first declared a buttonState with remember(). Ideally, you’d represent your state with PostModel, but this simplified approach is enough to demonstrate how animations work.
Next, you used RoundedCornerShape() to define the shape of the button.
You also defined the button’s background color, which will change depending on the buttonState. When the button has JoinButtonState.PRESSED, it will be white. When it’s JoinButtonState.IDLE, it will be blue.
Next, you defined the button’s icon and icon color. When the button’s state is JoinButtonState.PRESSED, you’ll represent the icon with a white plus sign. If it’s JoinButtonState.IDLE, you’ll represent it with a blue check mark.
The last thing you added is the code that emits the button’s UI. You used Box() to define the button shape and background and Icon() to define how the button’s icon will look.
For that code to work, you need to add a few imports as well:
import androidx.compose.foundation.shape.CornerSize
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.size
import androidx.compose.material.Icon
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Check
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
Great! Now, build the project and check the preview panel.
Note that you can change buttonState’s initial state to PRESSED, to preview the different settings for your button.
Awesome! Next, you’ll add this button to Post().
Adding JoinButton to Post
Before animating JoinButton(), you’ll add it to Post() so you can see it in the app.
Open Post.kt and edit Header() to look like this:
@Composable
fun Header(
post: PostModel,
onJoinButtonClick: (Boolean) -> Unit = {} // here
) {
Row(
modifier = Modifier.padding(start = 16.dp),
verticalAlignment = Alignment.CenterVertically // here
) {
Image(
ImageBitmap.imageResource(id = R.drawable.subreddit_placeholder),
contentDescription = stringResource(id = R.string.subreddits),
Modifier
.size(40.dp)
.clip(CircleShape)
)
Spacer(modifier = Modifier.width(8.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = stringResource(
R.string.subreddit_header,
post.subreddit
),
fontWeight = FontWeight.Medium,
color = MaterialTheme.colors.primaryVariant
)
Text(
text = stringResource(
R.string.post_header,
post.username,
post.postedTime
),
color = Color.Gray
)
}
Spacer(modifier = Modifier.width(4.dp)) // here
JoinButton(onJoinButtonClick) // here
MoreActionsMenu()
}
Title(text = post.title)
}
In the code above, you added:
-
verticalAlignmenttoRow()to center the header content vertically. -
JoinButton()andSpacer()toHeader(). -
onJoinButtonClicktoHeader().
Excellent! Now, build and run the app. Check how your posts look:
Click one of the JoinButtons and you’ll see how the icon and the background change instantly.
Animating the JoinButton background
So far, you’ve made the button background change from one color to another when the state changes. In this section, you’ll animate that transition.
In JoinButton.kt, replace the current definition of buttonBackgroundColor with the following code:
// Button background
val buttonBackgroundColor: Color by animateColorAsState(
if (buttonState == JoinButtonState.PRESSED)
Color.White
else
Color.Blue
)
Add one more import as well:
import androidx.compose.animation.animateColorAsState
Here, you wrapped the if clause that defined two different background colors with animateColorAsState(). By doing that, you implemented the animation between the two colors when the state changes.
With this simple change, you added your first animation. Can you even believe how easy that was? :]
Now, take a closer look at animateColorAsState(). It is just one of the animate*AsState() functions. In the Jetpack Compose documentation, you’ll find a dozen different animate*AsState() signatures that allow you to animate a dozen different properties out of the box, including Float, Color, Dp, Position, Size and other. You can even define your own properties.
All those definitions have something in common: You use them for fire-and-forget animations. Once you create a fire-and-forget animation, the app will memorize its position, like other composables. To trigger the animation, or alter the course of the animation, you simply supply a different target to the composable.
The animate*AsState() functions are the simplest animation APIs in Compose for animating a single value. You only provide the end value (or target value), and the API starts animation from the current value to the specified value.
Now build and run the app. Click on any JoinButton in the app and notice how the background color changes.
The figure shows how the button looks across several frames of the animation. Notice how the icon changes immediately after the click, while the background slowly transitions from one color to another. What’s really impressive here is how easy it was to implement this animation, which makes your app even nicer!
Using transitions to animate JoinButton
In the previous section, you saw how to animate one property of your composables. Now, you’ll add more content to JoinButton(). This will give you the opportunity to animate several properties at once.
The figure shows how you’ll change JoinButton’s appearance in the JoinButtonState.IDLE state.
Before adding any code, analyze how you’ll accomplish this animation. Which properties do you have to animate? To give the button its new look, you need to:
- Animate the background, as you did in the previous example.
- Change the icon. You’ll change the asset the same way as before, but you’ll improve that change by animating the icon color.
- Hide and show the text depending on the state.
- Animate the button’s width.
So you need to animate four different properties. Keep that in mind when adding the following code.
Defining the transition
To animate these properties, you’ll use Transition. Transition manages one or more animations as its children and runs them simultaneously between multiple states.
In JoinButton.kt, add the following code to JoinButton(), just below shape:
val transition = updateTransition(
targetState = buttonState,
label = "JoinButtonTransition"
)
Add this import as well:
import androidx.compose.animation.core.updateTransition
updateTransition creates and remembers an instance of Transition and updates its state. When targetState changes, Transition will run all of its child animations towards their target values specified for the new targetState. You’ll add those target values next. You also passed in the label property, which let you inspect and debug those animations in Android Studio.
Next, you’ll define child animations. Replace buttonBackgroundColor definition with the following code:
val duration = 600
val buttonBackgroundColor: Color
by transition.animateColor(
transitionSpec = { tween(duration) },
label = "Button Background Color"
) { state ->
when (state) {
JoinButtonState.IDLE -> Color.Blue
JoinButtonState.PRESSED -> Color.White
}
}
Add these imports as well:
import androidx.compose.animation.animateColor
import androidx.compose.animation.core.tween
Here, you defined the transition duration and first child animation in your transition. You used animateColor which is one of animate* extension functions that allow you to define a child animation in your transition. You specified the target values for each of the states. These animate* functions return an animation value that is updated every frame during the animation when the transition state is updated with updateTransition.
You also used tween(). With tween(), you created a TweenSpec configured with the given duration, delay and easing curve. Since you only specified a duration, the code uses 0 for delayMillis and FastOutSlowInEasing() for easing.
Easing is a way to adjust an animation’s fraction. The fraction represents how far along the animation you are and its values are within the [0, 1] range, or [0, 100], representing the percent of the animation you finished.
Easing allows transitioning elements to speed up and slow down, rather than moving at a constant, linear, rate.
Next, add the remaining child animations. Below buttonBackgroundColor, add the following code:
val buttonWidth: Dp
by transition.animateDp(
transitionSpec = { tween(duration) },
label = "Button Width"
) { state ->
when (state) {
JoinButtonState.IDLE -> 70.dp
JoinButtonState.PRESSED -> 32.dp
}
}
val textMaxWidth: Dp
by transition.animateDp(
transitionSpec = { tween(duration) },
label = "Text Max Width"
) { state ->
when (state) {
JoinButtonState.IDLE -> 40.dp
JoinButtonState.PRESSED -> 0.dp
}
}
Don’t forget to add these imports:
import androidx.compose.ui.unit.Dp
import androidx.compose.animation.core.animateDp
Finally, replace the current iconTintColor definition with this:
val iconTintColor: Color
by transition.animateColor(
transitionSpec = { tween(duration) },
label = "Icon Tint Color"
) { state ->
when (state) {
JoinButtonState.IDLE -> Color.White
JoinButtonState.PRESSED -> Color.Blue
}
}
Great! You’ve now prepared everything you need for your transition, but you still have to connect this code with the composables you want to animate.
Connecting the transition to the composables
Properties buttonBackgroundColor and iconTintColor are already in place so you don’t have to change that.
Next, replace the Box() definition with the following:
Box(
modifier = Modifier
.clip(shape)
.border(width = 1.dp, color = Color.Blue, shape = shape)
.background(color = buttonBackgroundColor)
.size(
width = buttonWidth, // here
height = 24.dp
)
.clickable(onClick = {
buttonState =
if (buttonState == JoinButtonState.IDLE) {
onClick.invoke(true)
JoinButtonState.PRESSED
} else {
onClick.invoke(false)
JoinButtonState.IDLE
}
}),
contentAlignment = Alignment.Center
) {
Row( // here
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = iconAsset,
contentDescription = "Plus Icon",
tint = iconTintColor,
modifier = Modifier.size(16.dp)
)
Text( // here
text = "Join",
color = Color.White,
fontSize = 14.sp,
maxLines = 1,
modifier = Modifier.widthIn(
min = 0.dp,
max = textMaxWidth // here
)
)
}
}
First, notice how you changed Box()’s content. You used a Row() to align an Icon() and a Text() beside one another. Second, notice how you didn’t have to change how you access specific transition properties in Box()’s modifier and how you’re using it for Icon() and Text(). Just like before, you used buttonBackgroundColor to access the button background or iconTintColor to access the icon tint color.
Add the following imports as well.
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.widthIn
import androidx.compose.material.Text
import androidx.compose.ui.unit.sp
And that’s it! This is now a complete button that will animate from one state to another. Build and run the app. You’ll now see the new JoinButton in the posts.
Click the button in any of the posts and see how it animates from one state to the other.
You see how the button’s width and text change as well as the color animations in the button’s background and icon.
Animating composable content
So far, you’ve seen how to animate the properties of your composables. In this section, you’ll explore a different approach to creating animations by learning how to animate composable content.
Note: At the time of writing, this animation API was in an experimental phase, so keep that in mind when you see
@ExperimentalAnimationApiannotations in the code.
In this section, you’ll implement a toast composable that appears when the user joins a subreddit. It will look like this:
This toast will appear any time you join a new subreddit, by tapping the JoinButton. There are a few things you need to do, to implement such behavior, so let’s start by creating the initial toast composable.
Adding JoinedToast
In components, create a new file named JoinedToast.kt. Then, add the following code to it:
@Composable
fun JoinedToast(visible: Boolean) {
ToastContent()
}
@Composable
private fun ToastContent() {
val shape = RoundedCornerShape(4.dp)
Box(
modifier = Modifier
.clip(shape)
.background(Color.White)
.border(1.dp, Color.Black, shape)
.height(40.dp)
.padding(horizontal = 8.dp),
contentAlignment = Alignment.Center
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
painter = painterResource(
id = R.drawable.ic_planet
),
contentDescription = "Subreddit Icon"
)
Spacer(modifier = Modifier.width(8.dp))
Text(text = "You have joined this community!")
}
}
}
@Preview
@Composable
fun JoinedToastPreview() {
JoinedToast(visible = true)
}
Here’s what the code above does. You used a Box() to give your toast a specific background, shape, size and padding. In the Box(), you added a Row() to align an Icon(), Spacer() and Text().
For this to work, add the following imports as well:
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.Icon
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.raywenderlich.android.jetreddit.R
Build the project and check the preview panel to see your composable.
Awesome! Next, you’ll animate the toast. :]
Animating JoinedToast
In JoinedToast.kt, replace the JoinedToast() code with the following:
@ExperimentalAnimationApi
@Composable
fun JoinedToast(visible: Boolean) {
AnimatedVisibility(
visible = visible,
enter = slideInVertically(initialOffsetY = { +40 }) +
fadeIn(),
exit = slideOutVertically() + fadeOut()
) {
ToastContent()
}
}
Android Studio will complain if you don’t add these imports as well.
import androidx.compose.animation.ExperimentalAnimationApi
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
import androidx.compose.animation.fadeOut
As mentioned earlier, @ExperimentalAnimationApi is there because this is an experimental API — at least, at the time of writing.
Here, you wrapped ToastContent() with AnimatedVisibility(), which animates the appearance and disappearance of its content as the visible value changes.
This is AnimatedVisibility()’s signature, taken from the Jetpack Compose documentation:
@Composable
fun AnimatedVisibility(
visible: Boolean,
modifier: Modifier = Modifier,
enter: EnterTransition = fadeIn() + expandIn(),
exit: ExitTransition = shrinkOut() + fadeOut(),
initiallyVisible: Boolean = visible,
content: @Composable () -> Unit
): Unit
You can define different EnterTransition and ExitTransition in enter and exit for the appearance and disappearance animations. There are three types of EnterTransition and ExitTransition: fade, expand/shrink and slide. By using the + sign, you combine the enter and exit transitions. The combination’s order doesn’t matter since the transition animations start simultaneously.
Now, back to your code. You passed visible from JoinedToast() to AnimatedVisibility(). With that, you’ll control when the animation triggers. When visible changes to true, it triggers the enter animation. Otherwise, it triggers the exit animation.
For the enter transition, you combined two transitions: slideInVertically() and fadeIn(). slideInVertically() slides the content vertically from a starting offset defined in initialOffsetY to 0. You control the direction of the slide by configuring initialOffsetY. A positive initial offset means the animation will slide up, whereas a negative value will slide the content down.
For the exit transition, you used slideOutVertically() and fadeOut().
Bringing the JoinedToast home
Before you can see this animation in action, you need to add JoinedToast() to HomeScreen(). You also need to add @ExperimentalAnimationApi to any parent composable of JoinedToast().
Start by adding @ExperimentalAnimationApi to JoinedToastPreview():
@ExperimentalAnimationApi
@Preview
@Composable
fun JoinedToastPreview() {
JoinedToast(visible = true)
}
Next, open HomeScreen.kt and update HomeScreen() like this:
@ExperimentalAnimationApi
@Composable
fun HomeScreen(viewModel: MainViewModel) {
val posts: List<PostModel>
by viewModel.allPosts.observeAsState(listOf())
var isToastVisible by remember { mutableStateOf(false) }
val onJoinClickAction: (Boolean) -> Unit = { joined ->
isToastVisible = joined
if (isToastVisible) {
Timer().schedule(3000) {
isToastVisible = false
}
}
}
Box(modifier = Modifier.fillMaxSize()) {
LazyColumn(modifier = Modifier.background(color = MaterialTheme.colors.secondary)) {
items(posts) {
if (it.type == PostType.TEXT) {
TextPost(it, onJoinButtonClick = onJoinClickAction)
} else {
ImagePost(it, onJoinButtonClick = onJoinClickAction)
}
Spacer(modifier = Modifier.height(6.dp))
}
}
Box(
modifier = Modifier
.align(Alignment.BottomCenter)
.padding(bottom = 16.dp)
) {
JoinedToast(visible = isToastVisible)
}
}
}
Add the following imports as well:
import androidx.compose.animation.ExperimentalAnimationApi
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.ui.Alignment
import com.raywenderlich.android.jetreddit.components.JoinedToast
import java.util.Timer
import kotlin.concurrent.schedule
You did a couple of things in the code above. You wrapped a LazyColumn() with a Box(), which allows you to fill the max size of the screen. You also added a second Box() and added JoinedToast() to its content. This second Box() lets you position JoinedToast() at the bottom. Then, you used remember() to define the visibility state of the toast.
Next, you defined the onJoinClickAction. Tapping any JoinButton triggers onJoinClickAction() and displays a toast. After three seconds, you hide the toast by changing isToastVisible to false.
Finally, you used onJoinClickAction as a parameter for the different posts. However, right now, the TextPost() and ImagePost() don’t have an onJoinButtonClick parameter, so you’ll see an error. You’re going to fix that next.
Adding onJoinButtonClick to the Posts
Open Post.kt and replace TextPost(), ImagePost() and Post() with the following code:
@Composable
fun TextPost(
post: PostModel,
onJoinButtonClick: (Boolean) -> Unit = {}
) {
Post(post, onJoinButtonClick) {
TextContent(post.text)
}
}
@Composable
fun ImagePost(
post: PostModel,
onJoinButtonClick: (Boolean) -> Unit = {}
) {
Post(post, onJoinButtonClick) {
ImageContent(post.image!!)
}
}
@Composable
fun Post(
post: PostModel,
onJoinButtonClick: (Boolean) -> Unit = {},
content: @Composable () -> Unit = {}
) {
Card(shape = MaterialTheme.shapes.large) {
Column(
modifier = Modifier.padding(
top = 8.dp,
bottom = 8.dp
)
) {
Header(post, onJoinButtonClick)
Spacer(modifier = Modifier.height(4.dp))
content.invoke()
Spacer(modifier = Modifier.height(8.dp))
PostActions(post)
}
}
}
What’s most important here is that you added onJoinButtonClick to the TextPost, ImagePost and Post signatures and passed it down to the Header(). Excellent work! The header already passes onJoinButtonClick to JoinButton() and handles everything, so you don’t have to update those composables. However, because you’re using an experimental animation API, you need to add appropriate annotations to your composables.
Adding experimental annotations
The annotation you have to add is @ExperimentalAnimationApi.
Open JetRedditApp.kt and add @ExperimentalAnimationApi to the following composables:
MainScreenContainer()AppContent()JetRedditApp()
You can follow Android Studio errors and use quick actions to easily add these imports. Otherwise, find these three functions and paste the following statement at the top of those functions: @ExperimentalAnimationApi.
Add this import as well:
import androidx.compose.animation.ExperimentalAnimationApi
Finally, open MainActivity.kt and add @ExperimentalAnimationApi to onCreate(), like this:
@ExperimentalAnimationApi
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
JetRedditApp(viewModel)
}
}
Don’t forget to add an import for @ExperimentalAnimationApi as well:
import androidx.compose.animation.ExperimentalAnimationApi
Whew! Now, build and run the app. Click any JoinButton and observe the toast’s enter and exit animations.
With that, you used three different APIs to animate your composables. Well done!
Key points
- You use
animate*AsState()for fire-and-forget animations targeting single properties of your composables. This is very useful for animating size, color, alpha and similar simple properties. - You use
TransitionandupdateTransition()for state-based transitions. - Use
Transitions when you have to animate multiple properties of your composables, or when you have multiple states between which you can animate. - Transitions are very good when showing content for the first time or leaving the screen, menu, option pickers and similar. They are also great when animating between multiple states when filling in forms, selecting options and pressing buttons!
- You use
AnimatedVisibility()when you want to animate the appearance and disappearance of composable content. -
AnimatedVisibility()lets you combine different types of visibility animations and lets you define directions if you use predefined transition animations.
Hopefully, this was a fun ride for you. You had the chance to play with three different APIs to create some simple, yet beautiful animations. What follows is the last chapter of this book. You’ve come a long way indeed!
In the next chapter, you’ll see how to combine the old View framework with Jetpack Compose and how both can coexist in the same codebase.
See you there! :]