Chapters

Hide chapters

Android Animations by Tutorials

First Edition · Android 12 · Kotlin 1.5 · Android Studio Artic Fox

Section II: Screen Transitions

Section 2: 3 chapters
Show chapters Hide chapters

10. Jetpack Compose Animations
Written by Prateek Prasad

So far in this book, you’ve worked on animating views and screens based on the UI toolkit. However, now that Jetpack Compose is gaining in popularity, more and more apps will start migrating to it, so it’s a good idea to know how to animate those apps.

Jetpack Compose offers a host of modern features when building UIs, and it makes things like state management a lot simpler. In this chapter, you’ll learn about animations in Jetpack Compose.

Setting up the project

Open the starter project for this chapter in Android Studio. Build and run. You’ll notice that everything looks the same as in the previous chapter.

The difference lies in the project’s code.

Expand the project structure, and you’ll notice a new package named ui:

The UI package contains three packages:

  • components: Contains the components built using Jetpack Compose.
  • screen: Contains the screens built using Jetpack Compose and the components mentioned above.
  • theme: Contains the color and typography definition used to build the theme for the app.

The rest of the core architecture of the app is still the same, down to the UI scaffold. The three screens of your app still use fragments, but the fragments host composable functions instead of inflating an XML.

With that out of the way, you’ll now dive in and add some sweet animations to this app.

Animating visibility changes

When you open a movie’s details in the app’s current form, you’ll notice that the Cast section snaps into existence as soon as it’s done loading — which feels quite janky.

For your first bit of UX improvement, you’ll add an animation that animates the cast row’s visibility to smoothly bring it into view once it finishes loading.

Open CastRow.kt, which contains the CastItem and CastRow composable functions.

Add the following code below the SectionHeader:


val visibleState = remember {
  MutableTransitionState(initialState = false).apply {
    targetState = true
  }
}

In the snippet above, you create a state, which the visibility animation will use. Initially, the cast row should be invisible, so you set initialState to false. However, the cast row should become visible in its final state, so you set the targetState to true.

Next, wrap the LazyRow inside an AnimatedVisibility composable:

AnimatedVisibility(
  visibleState = visibileState,
  enter = fadeIn()
) {
  LazyRow(contentPadding = PaddingValues(end = 24.dp)) {
    items(it) {
      CastItem(it.profilePath)
    }
  }
}

AnimatedVisibility takes in a state to determine the visibility of its wrapped composable. It also accepts an enter and exit transition for the animation. In this case, you use a fadeIn enter transition.

Before you build and run the app to check out the animation, there’s one small step you need to take: As of this writing, AnimatedVisibility is still an experimental animation API. So you need to annotate the composable functions using AnimatedVisibility with an @ExperimentalAnimationApi annotation.

You need to do the same for any component that might end up using a composable that uses AnimatedVisibility.

Add the annotation above the CastRow as shown below.

@ExperimentalAnimationApi
@Composable
fun CastRow(cast: List<Cast>?) {
	...
}

Repeat the same step as above and add the @ExperimentalAnimationApi for the MovieDetails, MovieDetailsBody and MovieDetailsContent composable functions located in MovieDetails.kt.

Wasn’t that fun!

Now, build and run. When you tap any movie to bring up the details screen, you’ll notice that the cast row subtly fades into view.

While the fade-in animation is pretty fun now, there’s still room to spice it up a bit.

Adding a slide-in animation

To make your animation a little more exciting, you’ll now introduce a slide-in animation when the cast row appears.

Open CastRow.kt and chain a slideInVertically() animation to AnimatedVisibility’s enter animation, in CastRow as shown below:

enter = fadeIn() + slideInVertically()

AnimatedVisibility allows you to chain multiple animations together in sequence using the + operator.

Build and run. Now, the cast row slides in from the top while also fading into view.

Excellent job adding your first Jetpack Compose-based animation! You can tell how low-effort this animation implementation was compared to the XML world.

Next, you’ll improve the UX of the details screen even further.

Animating content sizes

In the app, a few of the movies have lengthy overviews. Unfortunately, these movies’ overviews take up so much space that they push the cast row and the Add to Favorites button off the screen.

It would be better to restrict summaries to a few lines, so other sections of the UI remain visible. In addition, the user should have an option to expand the overview if they want to read more. You’ll tackle this issue next.

Hiding and showing long text

Create a new file named Overview.kt in the components package and create a new composable function, Overview, that takes in a movie object.

@Composable
fun Overview(movie: Movie) {
}

Since the overview section will contain a bit of functionality of its own, it’s best to extract it out to its own file to consolidate the logic cleanly in one place.

Open MovieDetails.kt and add the following line of code right above the line CastRow(cast):

Overview(movie = movie)

Now, find and cut out the Text composable from the MovieDetailsBody rendering the overview. Then, paste it into your newly created Overview composable, as shown below:

@Composable
fun Overview(movie: Movie) {
    Text(
      text = movie.overview,
      style = MaterialTheme.typography.body2,
      textAlign = TextAlign.Start,
      modifier = Modifier.padding(horizontal = 16.dp),
    )
}

First, you want to add a basic state to determine whether the overview is expanded or collapsed. Add the following code above the Text composable:

var overviewExpanded by remember { mutableStateOf(false) }

overviewExpanded is a Boolean state with an initial value of false, meaning the overview will begin in a collapsed state.

Next, based on the character length of the movie overview, you want to show a toggle to expand and collapse the overview text. Add the following code below the Text composable that renders the overview:

//1
if (movie.overview.length > 200) {
  Text(
    //2
    text = if (overviewExpanded) "READ LESS" else "READ MORE",
    style = MaterialTheme.typography.overline,
    modifier = Modifier
      .padding(24.dp)
      .clickable {
        //3
        overviewExpanded = !overviewExpanded
      },
  )
}

You only want to show the toggle if the movie overview exceeds 200 characters — roughly four lines in the app. If the overviewExpanded state is true, the toggle should say READ LESS; otherwise, it should say READ MORE. When the user clicks the toggle, you need to change the value of the overviewExpanded toggle state.

Build and run. The newly added toggle now shows up for movies with lengthy overviews — and now, when you tap the toggle, the text changes.

Now, you’ll make the overview text expand and collapse based on the toggle. First, add the following maxLines property to the Text that renders the overview:

Text(
  text = movie.overview,
  style = MaterialTheme.typography.body2,
  textAlign = TextAlign.Start,
  modifier = Modifier.padding(horizontal = 16.dp),
  maxLines = if (overviewExpanded) Int.MAX_VALUE else 4
)

With the maxLines restriction in place, if the overviewExpanded state is true, the app will show the entire overview. Else, it will limit the overview to four lines.

Build and run. Now, tapping the READ MORE toggle will expand and collapse the overview text.

Finally, you’ll animate how the overview text expands and contracts.

Animating the change in the text

Wrap both Text composables inside a Column, as shown below, to animate the text:

@Composable
fun Overview(movie: Movie) {
  var overviewExpanded by remember { mutableStateOf(false) }

  Column(
    modifier = Modifier.animateContentSize(),
    verticalArrangement = Arrangement.Top,
    horizontalAlignment = Alignment.CenterHorizontally
  ) {
    Text(
      text = movie.overview,
      style = MaterialTheme.typography.body2,
      textAlign = TextAlign.Start,
      modifier = Modifier.padding(horizontal = 16.dp),
      maxLines = if (overviewExpanded) Int.MAX_VALUE else 4
    )
    if (movie.overview.length > 200) {
      Text(
        text = if (overviewExpanded) "READ LESS" 
               else "READ MORE",
        style = MaterialTheme.typography.overline,
        modifier = Modifier
          .padding(24.dp)
          .clickable {
            overviewExpanded = !overviewExpanded
          },
      )
    }
  }
}

The column you added has a special modifier attached to it, animateContentSize(), that animates the column’s size when its child changes size.

Build and run. Now, toggling the overview text will expand and collapse the text with a fun little animation.

Wasn’t that easy? Imagine achieving the same animation using views!

Next, you’ll add an animation to the Add to Favorites button in the details screen.

Animating state changes

In the details screen of any movie, tapping the Add to Favorites button will bring up a circular progress bar that displays while the operation is in progress:

Since this feels pretty lackluster, you’ll replicate the animation you added for this button in Chapter 2, “Animating Custom Views”.

Before you can achieve the desired animation, you have to take care of a few things:

  • Determine the visibility of the progress bar based on the loading state.
  • Reduce the button width and hide the text and icon when the button is loading.
  • Bring the button back to its original width, then show the text and icon when the operation finishes.

The button already tracks the loading state through the contentState property you passed to it as a parameter. Using this state’s value, you’ll trigger the animations. Jetpack Compose has a perfect candidate for use cases where multiple properties need to change depending on the state: updateTransition().

updateTransition() sets up a transition based on the target state you supply. When the target state changes, it runs all of its child animation for its new target state.

Giving the button a state

To use updateTransition(), the button needs a state of its own. Open AddToFavoritesButton.kt and add the following enum to the top of the file:

enum class ButtonState {
  IDLE, PRESSED
}

This enum will denote the button’s two states.

Next, add the following code to the AddToFavoritesButton composable, right after the Column:

//1
val buttonState = remember { mutableStateOf(ButtonState.IDLE) }

//2
val transition = updateTransition(buttonState.value, "Button Transition")

//3
val width = transition.animateDp(label = "Button width animation") { state ->
  when (state) {
    ButtonState.IDLE -> 250.dp
    ButtonState.PRESSED -> 56.dp
  }
}

In the snippet above, you create:

  1. A mutable state instance called buttonState that has an initial state of ButtonState.IDLE.
  2. A transition using updateTransition() that uses buttonState to determine the target state.
  3. A width property using transition.animateDp(), which will toggle the value from 250dp when in the ButtonState.IDLE state to 56dp in the ButtonState.PRESSED state.

Toggling the button’s state

Now that you’ve set up a state for the button, you need to add a mechanism for toggling that state. To do that, you’ll use contentState, which MovieDetails observes and passes down as a property.

Add the following code below the width property declaration:

buttonState.value = if (contentState is Events.Loading) {
  ButtonState.PRESSED
} else ButtonState.IDLE

In the snippet above, the button’s state will be automatically toggled when the contentState property changes.

With all the core pieces in place, it’s finally time to animate the button.

Animating the button

First, get rid of the check that renders theCircularProgressIndicator when contentState is Loading. Remove the if statement starting with the following including the else portion, all the way to the } for the else:

if (contentState is Events.Loading) {
      CircularProgressIndicator(
        modifier = Modifier.padding(top = 8.dp),
        strokeWidth = 2.5.dp,
        color = Color.Black
      )
    } else {
    ...
    }

CircularProgressIndicator will now be a part of the button instead of rendering separately.

Now, move the CircularProgressIndicator inside the button, by placing the below code segment after the line } else ButtonState.IDLE:

Button(
  modifier = Modifier
    .size(250.dp, 56.dp),
  shape = RoundedCornerShape(32.dp),
  colors = ButtonDefaults.buttonColors(
    backgroundColor = MaterialTheme.colors.secondary
  ),
  onClick = { onFavoriteButtonClick(movie) },
) {
  Row(verticalAlignment = Alignment.CenterVertically) {
    if (buttonState.value == ButtonState.PRESSED) {
      CircularProgressIndicator(
        modifier = Modifier.padding(top = 8.dp),
        strokeWidth = 2.5.dp,
        color = Color.Black
      )
    } else {
      Icon(
          imageVector = if (movie.isFavorite) {
              Icons.Default.Favorite
          } else {
              Icons.Default.FavoriteBorder
          },
          contentDescription = null
      )
      Spacer(modifier = Modifier.width(16.dp))
      Text(
          text = if (movie.isFavorite) {
              stringResource(
                  id = R.string.remove_from_favorites
              )
          } else {
              stringResource(
                  id = R.string.add_to_favorites
              )
          },
          style = MaterialTheme.typography.button,
          maxLines = 1
      )
    }
  }
}

With this change in place, buttonState now determines the button’s content. When buttonState is:

  • ButtonState.PRESSED: The circular progress displays.
  • ButtonState.IDLE: The icon and text display.

Changing the size of the button

There’s one final thing to sort out before the animation is ready: To make the button shrink and grow, you need to use the width property you created earlier.

Replace the hard-coded 250dp in the button’s size modifier with the width property, as shown below:

Button(
  modifier = Modifier
    .size(width.value, 56.dp),
  shape = RoundedCornerShape(32.dp),
  colors = ButtonDefaults.buttonColors(
    backgroundColor = MaterialTheme.colors.secondary
  ),
  onClick = { onFavoriteButtonClick(movie) },
) {

	...
}

With this last change, you’ve completed your animation. Build and run to see it in action.

This animation is way more appealing. Best of all, compared to your original implementation, it’s far less involved.

Challenge: Animating the button color

You animated the button width using the animateDp() extension available for the Transition in the button animation.

There are several other helpful extensions available for Transition that let you manipulate various properties across state changes. For example, animateSize animates both the width and the height across state changes, while you can use animateOffset to animate a composable’s position.

As a quick exercise to flex your Jetpack Compose muscles, add an animation that will animate the button’s color, as well as its size. You’ll change the color from MaterialTheme.colors.secondary to Color.Cyan, based on the button’s state.

For some visual aid, here is what that animation should look like:

Feel free to check out the challenge project for a solution.

Key points

  • Jetpack Compose introduces a comparatively simple set of APIs to add animations to your app.
  • AnimatedVisibility lets you animate the visibility changes of a composable.
  • AnimatedVisibility is still an experimental API at the time of this writing, so composables using this need the @ExperimentalAnimationApi annotation.
  • To animate content size changes, use animateContentSize() on the parent container of a composable.
  • To trigger based on state changes in your app, use updateTransition().
  • Transition has several convenient extensions. For example, animateDp and animateSize let you animate properties of a composable across state changes.

Where to go from here?

This chapter provided an introduction to the Jetpack Compose animations API. While you covered some of the simple use cases, you barely scratched the surface of what Jetpack Compose offers for animations.

If you are curious and want to learn more, check out the official Jetpack Compose documentation on animation.

We also have a Jetpack Compose animations video course that goes into more detail on this topic.

If you haven’t had the chance to get started with Jetpack Compose, check out our book, Jetpack Compose by Tutorials.

Have a technical question? Want to report a bug? You can ask questions and report bugs to the book authors in our official book forum here.
© 2026 Kodeco Inc.