Animation

Jetpack Compose provides powerful and extensible APIs that make it easy to implement various animations in your app's UI. This document describes how to use these APIs as well as which API to use depending on your animation scenario.

Overview

Animations are essential in a modern mobile app in order to realize a smooth and understandable user experience. Many Jetpack Compose Animation APIs are available as composable functions just like layouts and other UI elements, and they are backed by lower-level APIs built with Kotlin coroutine suspend functions. This guide starts with the high-level APIs that are useful in many practical scenarios, and moves on to explain the low-level APIs that give you further control and customization.

This chart below helps you decide what API to use to implement your animation.

  • If you are animating content change in layout:
    • If you are animating appearance and disappearance:
      • Use AnimationVisibility.
    • Swapping content based on state:
      • If you are crossfading content:
        • Use Crossfade.
      • Otherwise, use AnimatedContent.
    • Otherwise, use Modifier.contentSize.
  • If the animation is state-based:
    • If the animation happens during composition:
      • If the animation is infinite:
        • Use rememberInfiniteTransition.
      • If you are animating multiple values simultaneously:
        • Use updateTransition.
      • Otherwise, use animate*AsState.
  • If you want to have fine control over animation time:
    • Use Animation.
  • If the animation is the only source of truth
    • Use Animatable.
  • Otherwise, use AnimationState or animate.

Flowchart describing the decision tree for choosing the appropriate animation API

High-level animation APIs

Compose offers high-level animation APIs for several common animation patterns used in many apps. These APIs are tailored to align with the best practices of Material Design Motion.

AnimatedVisibility (experimental)

The AnimatedVisibility composable animates the appearance and disappearance of its content.

var editable by remember { mutableStateOf(true) }
AnimatedVisibility(visible = editable) {
    Text(text = "Edit")
}

By default, the content appears by fading in and expanding, and it disappears by fading out and shrinking. The transition can be customized by specifying EnterTransition and ExitTransition.

var visible by remember { mutableStateOf(true) }
val density = LocalDensity.current
AnimatedVisibility(
    visible = visible,
    enter = slideInVertically(
        // Slide in from 40 dp from the top.
        initialOffsetY = { with(density) { -40.dp.roundToPx() } }
    ) + expandVertically(
        // Expand from the top.
        expandFrom = Alignment.Top
    ) + fadeIn(
        // Fade in with the initial alpha of 0.3f.
        initialAlpha = 0.3f
    ),
    exit = slideOutVertically() + shrinkVertically() + fadeOut()
) {
    Text("Hello", Modifier.fillMaxWidth().height(200.dp))
}

As you can see in the example above, you can combine multiple EnterTransition or ExitTransition objects with a + operator, and each accepts optional parameters to customize its behavior. See the references for more information.

EnterTransition examples

fadeIn:

slideIn:

slideInHorizontally:

slideInVertically:

scaleIn:

expandIn:

expandHorizontally:

expandVertically:

ExitTransition examples

fadeOut:

slideOut:

slideOutHorizontally:

slideOutVertically:

scaleOut:

shrinkOut:

shrinkHorizontally:

shrinkVertically:

AnimatedVisibility also offers a variant that takes a MutableTransitionState. This allows you to trigger an animation as soon as the AnimatedVisibility is added to the composition tree. It is also useful for observing the animation state.

// Create a MutableTransitionState<Boolean> for the AnimatedVisibility.
val state = remember {
    MutableTransitionState(false).apply {
        // Start the animation immediately.
        targetState = true
    }
}
Column {
    AnimatedVisibility(visibleState = state) {
        Text(text = "Hello, world!")
    }

    // Use the MutableTransitionState to know the current animation state
    // of the AnimatedVisibility.
    Text(
        text = when {
            state.isIdle && state.currentState -> "Visible"
            !state.isIdle && state.currentState -> "Disappearing"
            state.isIdle && !state.currentState -> "Invisible"
            else -> "Appearing"
        }
    )
}

Animate enter and exit for children

Content within AnimatedVisibility (direct or indirect children) can use the animateEnterExit modifier to specify different animation behavior for each of them. The visual effect for each of these children is a combination of the animations specified at the AnimatedVisibility composable and the child's own enter and exit animations.

AnimatedVisibility(
    visible = visible,
    // Fade in/out the background and the foreground.
    enter = fadeIn(),
    exit = fadeOut()
) {
    Box(Modifier.fillMaxSize().background(Color.DarkGray)) {
        Box(
            Modifier
                .align(Alignment.Center)
                .animateEnterExit(
                    // Slide in/out the inner box.
                    enter = slideInVertically(),
                    exit = slideOutVertically()
                )
                .sizeIn(minWidth = 256.dp, minHeight = 64.dp)
                .background(Color.Red)
        ) {
            // Content of the notification…
        }
    }
}

In some cases, you may want to have AnimatedVisibility apply no animations at all so that children can each have their own distinct animations by animateEnterExit. To achieve this, specify EnterTransition.None and ExitTransition.None at the AnimatedVisibility composable.

Add custom animation

If you want to add custom animation effects beyond the built-in enter and exit animations, access the underlying Transition instance via the transition property inside the content lambda for AnimatedVisibility. Any animation states added to the Transition instance will run simultaneously with the enter and exit animations of AnimatedVisibility. AnimatedVisibility waits until all animations in the Transition have finished before removing its content. For exit animations created independent of Transition (such as using animate*AsState), AnimatedVisibility would not be able to account for them, and therefore may remove the content composable before they finish.

AnimatedVisibility(
    visible = visible,
    enter = fadeIn(),
    exit = fadeOut()
) { // this: AnimatedVisibilityScope
    // Use AnimatedVisibilityScope.transition() to add a custom animation
    // to the AnimatedVisibility.
    val background by transition.animateColor { state ->
        if (state == EnterExitState.Visible) Color.Blue else Color.Gray
    }
    Box(modifier = Modifier.size(128.dp).background(background))
}

See updateTransition for the details about Transition.

AnimatedContent (experimental)

The AnimatedContent composable animates its content as it changes based on a target state.

Row {
    var count by remember { mutableStateOf(0) }
    Button(onClick = { count++ }) {
        Text("Add")
    }
    AnimatedContent(targetState = count) { targetCount ->
        // Make sure to use `targetCount`, not `count`.
        Text(text = "Count: $targetCount")
    }
}

Note that you should always use the lambda parameter and reflect it to the content. The API uses this value as the key to identify the content that's currently shown.

By default, the initial content fades out and then the target content fades in (this behavior is called fade through). You can customize this animation behavior by specifying a ContentTransform object to the transitionSpec parameter. You can create ContentTransform by combining an EnterTransition with an ExitTransition using the with infix function. You can apply SizeTransform to the ContentTransform by attaching it with the using infix function.

AnimatedContent(
    targetState = count,
    transitionSpec = {
        // Compare the incoming number with the previous number.
        if (targetState > initialState) {
            // If the target number is larger, it slides up and fades in
            // while the initial (smaller) number slides up and fades out.
            slideInVertically({ height -> height }) + fadeIn() with
                slideOutVertically({ height -> -height }) + fadeOut()
        } else {
            // If the target number is smaller, it slides down and fades in
            // while the initial number slides down and fades out.
            slideInVertically({ height -> -height }) + fadeIn() with
                slideOutVertically({ height -> height }) + fadeOut()
        }.using(
            // Disable clipping since the faded slide-in/out should
            // be displayed out of bounds.
            SizeTransform(clip = false)
        )
    }
) { targetCount ->
    Text(text = "$targetCount")
}

EnterTransition defines how the target content should appear, and ExitTransition defines how the initial content should disappear. In addition to all of the EnterTransition and ExitTransition functions available for AnimatedVisibility, AnimatedContent offers slideIntoContainer and slideOutOfContainer. These are convenient alternatives to slideInHorizontally/Vertically and slideOutHorizontally/Vertically that calculate the slide distance based on the sizes of the initial content and the target content of the AnimatedContent content.

SizeTransform defines how the size should animate between the initial and the target contents. You have access to both the initial size and the target size when you are creating the animation. SizeTransform also controls whether the content should be clipped to the component size during animations.

var expanded by remember { mutableStateOf(false) }
Surface(
    color = MaterialTheme.colors.primary,
    onClick = { expanded = !expanded }
) {
    AnimatedContent(
        targetState = expanded,
        transitionSpec = {
            fadeIn(animationSpec = tween(150, 150)) with
                fadeOut(animationSpec = tween(150)) using
                SizeTransform { initialSize, targetSize ->
                    if (targetState) {
                        keyframes {
                            // Expand horizontally first.
                            IntSize(targetSize.width, initialSize.height) at 150
                            durationMillis = 300
                        }
                    } else {
                        keyframes {
                            // Shrink vertically first.
                            IntSize(initialSize.width, targetSize.height) at 150
                            durationMillis = 300
                        }
                    }
                }
        }
    ) { targetExpanded ->
        if (targetExpanded) {
            Expanded()
        } else {
            ContentIcon()
        }
    }
}

Animate enter/exit for children

Just like AnimatedVisibility, the animateEnterExit modifier is available inside the content lambda of AnimatedContent. Use this to apply EnterAnimation and ExitAnimation to each of the direct or indirect children separately.

Add custom animation

Just like AnimatedVisibility, the transition field is available inside the content lambda of AnimatedContent. Use this to create a custom animation effect that runs simultaneously with the AnimatedContent transition. See updateTransition for the details.

animateContentSize

The animateContentSize modifier animates a size change.

var number by remember { mutableStateOf(1) }
Button(
    onClick = { number *= 10 },
    modifier = Modifier.animateContentSize()
) {
    Text(text = "$number times")
}

Crossfade

Crossfade animates between two layouts with a crossfade animation. By toggling the value passed to the current parameter, the content is switched with a crossfade animation.

var currentPage by remember { mutableStateOf("A") }
Crossfade(targetState = currentPage) { screen ->
    when (screen) {
        "A" -> Text("Page A")
        "B" -> Text("Page B")
    }
}

Low-level Animation APIs

All the high-level animation APIs mentioned in the previous section are built on top of the foundation of the low-level animation APIs.

The animate*AsState functions are the simplest APIs, that render an instant value change as an animation value. It is backed by Animatable, which is a coroutine-based API for animating a single value. updateTransition creates a transition object that can manage multiple animating values and run them based on a state change. rememberInfiniteTransition is similar, but it creates an infinite transition that can manage multiple animations that keep on running indefinitely. All of these APIs are composables except for Animatable, which means these animations can be created outside of composition.

All of these APIs are based on the more fundamental Animation API. Though most apps will not interact directly with Animation, some of the customization capabilities for Animation are available through higher-level APIs. See Customize animations for more information on AnimationVector and AnimationSpec.

Diagram showing the relationship between the various low-level animation APIs

animate*AsState

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.

Below is an example of animating alpha using this API. By simply wrapping the target value in animateFloatAsState, the alpha value is now an animation value between the provided values (1f or 0.5f in this case).

val alpha: Float by animateFloatAsState(if (enabled) 1f else 0.5f)
Box(
    Modifier.fillMaxSize()
        .graphicsLayer(alpha = alpha)
        .background(Color.Red)
)

Note that you don't need to create an instance of any animation class, or handle interruption. Under the hood, an animation object (namely, an Animatable instance) will be created and remembered at the call site, with the first target value as its initial value. From there on, any time you supply this composable a different target value, an animation is automatically started towards that value. If there's already an animation in flight, the animation starts from its current value (and velocity) and animates toward the target value. During the animation, this composable gets recomposed and returns an updated animation value every frame.

Out of the box, Compose provides animate*AsState functions for Float, Color, Dp, Size, Offset, Rect, Int, IntOffset, and IntSize. You can easily add support for other data types by providing a TwoWayConverter to animateValueAsState that takes a generic type.

You can customize the animation specifications by providing an AnimationSpec. See AnimationSpec for more information.

Animatable

Animatable is a value holder that can animate the value as it is changed via animateTo. This is the API backing up the implementation of animate*AsState. It ensures consistent continuation and mutual exclusiveness, meaning that the value change is always continuous and any ongoing animation will be canceled.

Many features of Animatable, including animateTo, are provided as suspend functions. This means that they need to be wrapped in an appropriate coroutine scope. For example, you can use the LaunchedEffect composable to create a scope just for the duration of the specified key value.

// Start out gray and animate to green/red based on `ok`
val color = remember { Animatable(Color.Gray) }
LaunchedEffect(ok) {
    color.animateTo(if (ok) Color.Green else Color.Red)
}
Box(Modifier.fillMaxSize().background(color.value))

In the example above, we create and remember an instance of Animatable with the initial value of Color.Gray. Depending on the value of the boolean flag ok, the color animates to either Color.Green or Color.Red. Any subsequent change to the boolean value starts animation to the other color. If there's an ongoing animation when the value is changed, the animation is canceled, and the new animation starts from the current snapshot value with the current velocity.

This is the animation implementation that backs up the animate*AsState API mentioned in the previous section. Compared to animate*AsState, using Animatable directly gives us finer-grained control on several respects. First, Animatable can have an initial value different from its first target value. For example, the code example above shows a gray box at first, which immediately starts animating to either green or red. Second, Animatable provides more operations on the content value, namely snapTo and animateDecay. snapTo sets the current value to the target value immediately. This is useful when the animation itself is not the only source of truth and has to be synced with other states, such as touch events. animateDecay starts an animation that slows down from the given velocity. This is useful for implementing fling behavior. See Gesture and animation for more information.

Out of the box, Animatable supports Float and Color, but any data type can be used by providing a TwoWayConverter. See AnimationVector for more information.

You can customize the animation specifications by providing an AnimationSpec. See AnimationSpec for more information.

updateTransition

Transition manages one or more animations as its children and runs them simultaneously between multiple states.

The states can be of any data type. In many cases, you can use a custom enum type to ensure type safety, as in this example:

private enum class BoxState {
    Collapsed,
    Expanded
}

updateTransition creates and remembers an instance of Transition and updates its state.

var currentState by remember { mutableStateOf(BoxState.Collapsed) }
val transition = updateTransition(currentState)

You can then use one of animate* extension functions to define a child animation in this transition. Specify 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.

val rect by transition.animateRect { state ->
    when (state) {
        BoxState.Collapsed -> Rect(0f, 0f, 100f, 100f)
        BoxState.Expanded -> Rect(100f, 100f, 300f, 300f)
    }
}
val borderWidth by transition.animateDp { state ->
    when (state) {
        BoxState.Collapsed -> 1.dp
        BoxState.Expanded -> 0.dp
    }
}

Optionally, you can pass a transitionSpec parameter to specify a different AnimationSpec for each of the combinations of transition state changes. See AnimationSpec for more information.

val color by transition.animateColor(
    transitionSpec = {
        when {
            BoxState.Expanded isTransitioningTo BoxState.Collapsed ->
                spring(stiffness = 50f)
            else ->
                tween(durationMillis = 500)
        }
    }
) { state ->
    when (state) {
        BoxState.Collapsed -> MaterialTheme.colors.primary
        BoxState.Expanded -> MaterialTheme.colors.background
    }
}

Once a transition has arrived at the target state, Transition.currentState will be the same as Transition.targetState. This can be used as a signal for whether the transition has finished.

We sometimes want to have an initial state different from the first target state. We can use updateTransition with MutableTransitionState to achieve this. For example, it allows us to start animation as soon as the code enters composition.

// Start in collapsed state and immediately animate to expanded
var currentState = remember { MutableTransitionState(BoxState.Collapsed) }
currentState.targetState = BoxState.Expanded
val transition = updateTransition(currentState)
// ...

For a more complex transition involving multiple composable functions, you can use createChildTransition to create a child transition. This technique is useful for separating concerns among multiple subcomponents in a complex composable. The parent transition will be aware of all the animation values in the child transitions.

enum class DialerState { DialerMinimized, NumberPad }

@Composable
fun DialerButton(isVisibleTransition: Transition<Boolean>) {
    // `isVisibleTransition` spares the need for the content to know
    // about other DialerStates. Instead, the content can focus on
    // animating the state change between visible and not visible.
}

@Composable
fun NumberPad(isVisibleTransition: Transition<Boolean>) {
    // `isVisibleTransition` spares the need for the content to know
    // about other DialerStates. Instead, the content can focus on
    // animating the state change between visible and not visible.
}

@Composable
fun Dialer(dialerState: DialerState) {
    val transition = updateTransition(dialerState)
    Box {
        // Creates separate child transitions of Boolean type for NumberPad
        // and DialerButton for any content animation between visible and
        // not visible
        NumberPad(
            transition.createChildTransition {
                it == DialerState.NumberPad
            }
        )
        DialerButton(
            transition.createChildTransition {
                it == DialerState.DialerMinimized
            }
        )
    }
}

Use transition with AnimatedVisibility and AnimatedContent

AnimatedVisibility and AnimatedContent are available as extension functions of Transition. The targetState for Transition.AnimatedVisibility and Transition.AnimatedContent is derived from the Transition, and triggering enter/exit transitions as needed when the Transition's targetState has changed. These extension functions allow all the enter/exit/sizeTransform animations that would otherwise be internal to AnimatedVisibility/AnimatedContent to be hoisted into the Transition. With these extension functions, AnimatedVisibility/AnimatedContent's state change can be observed from outside. Instead of a boolean visible parameter, this version of AnimatedVisibility takes a lambda that converts the parent transition's target state into a boolean.

See AnimatedVisibility and AnimatedContent for the details.

var selected by remember { mutableStateOf(false) }
// Animates changes when `selected` is changed.
val transition = updateTransition(selected)
val borderColor by transition.animateColor { isSelected ->
    if (isSelected) Color.Magenta else Color.White
}
val elevation by transition.animateDp { isSelected ->
    if (isSelected) 10.dp else 2.dp
}
Surface(
    onClick = { selected = !selected },
    shape = RoundedCornerShape(8.dp),
    border = BorderStroke(2.dp, borderColor),
    elevation = elevation
) {
    Column(modifier = Modifier.fillMaxWidth().padding(16.dp)) {
        Text(text = "Hello, world!")
        // AnimatedVisibility as a part of the transition.
        transition.AnimatedVisibility(
            visible = { targetSelected -> targetSelected },
            enter = expandVertically(),
            exit = shrinkVertically()
        ) {
            Text(text = "It is fine today.")
        }
        // AnimatedContent as a part of the transition.
        transition.AnimatedContent { targetState ->
            if (targetState) {
                Text(text = "Selected")
            } else {
                Icon(imageVector = Icons.Default.Phone, contentDescription = "Phone")
            }
        }
    }
}

Encapsulate a Transition and make it reusable

For simple use cases, defining transition animations in the same composable as your UI is a perfectly valid option. When you are working on a complex component with a number of animated values, however, you might want to separate the animation implementation from the composable UI.

You can do so by creating a class that holds all the animation values and an ‘update’ function that returns an instance of that class. The transition implementation can be extracted into the new separate function. This pattern is useful when there is a need to centralize the animation logic, or make complex animations reusable.

enum class BoxState { Collapsed, Expanded }

@Composable
fun AnimatingBox(boxState: BoxState) {
    val transitionData = updateTransitionData(boxState)
    // UI tree
    Box(
        modifier = Modifier
            .background(transitionData.color)
            .size(transitionData.size)
    )
}

// Holds the animation values.
private class TransitionData(
    color: State<Color>,
    size: State<Dp>
) {
    val color by color
    val size by size
}

// Create a Transition and return its animation values.
@Composable
private fun updateTransitionData(boxState: BoxState): TransitionData {
    val transition = updateTransition(boxState)
    val color = transition.animateColor { state ->
        when (state) {
            BoxState.Collapsed -> Color.Gray
            BoxState.Expanded -> Color.Red
        }
    }
    val size = transition.animateDp { state ->
        when (state) {
            BoxState.Collapsed -> 64.dp
            BoxState.Expanded -> 128.dp
        }
    }
    return remember(transition) { TransitionData(color, size) }
}

Tooling support

Android Studio supports inspection of Transition in Compose Preview.

  • Frame-by-frame preview of the transition
  • Value inspection for all animations in the transition
  • Preview of transition between any initial and target state

When you start the animation inspector, you see below the interactive preview the "Animations" pane where you can run any transition included in the preview. The transition as well as each of the animation values of it is labeled with a default name. You can customize the label by specifying the label parameter in updateTransition and the animate* functions. To learn more about Compose Preview, see Layout preview.

rememberInfiniteTransition

InfiniteTransition holds one or more child animations like Transition, but the animations start running as soon as they enter the composition and do not stop unless they are removed. You can create an instance of InfiniteTransition with rememberInfiniteTransition. Child animations can be added with animateColor, animatedFloat, or animatedValue. You also need to specify an infiniteRepeatable to specify the animation specifications.

val infiniteTransition = rememberInfiniteTransition()
val color by infiniteTransition.animateColor(
    initialValue = Color.Red,
    targetValue = Color.Green,
    animationSpec = infiniteRepeatable(
        animation = tween(1000, easing = LinearEasing),
        repeatMode = RepeatMode.Reverse
    )
)

Box(Modifier.fillMaxSize().background(color))

TargetBasedAnimation

TargetBasedAnimation is the lowest-level Animation API we've seen so far. Other APIs cover most use cases, but using TargetBasedAnimation directly allows you to control the animation play time yourself. In the example below, the play time of the TargetAnimation is manually controlled based on the frame time provided by withFrameMillis.

val anim = remember {
    TargetBasedAnimation(
        animationSpec = tween(200),
        typeConverter = Float.VectorConverter,
        initialValue = 200f,
        targetValue = 1000f
    )
}
var playTime by remember { mutableStateOf(0L) }

LaunchedEffect(anim) {
    val startTime = withFrameNanos { it }

    do {
        playTime = withFrameNanos { it } - startTime
        val animationValue = anim.getValueFromNanos(playTime)
    } while (someCustomCondition())
}

Customize animations

Many of the Animation APIs commonly accept parameters for customizing their behavior.

AnimationSpec

Most animation APIs allow developers to customize animation specifications by an optional AnimationSpec parameter.

val alpha: Float by animateFloatAsState(
    targetValue = if (enabled) 1f else 0.5f,
    // Configure the animation duration and easing.
    animationSpec = tween(durationMillis = 300, easing = FastOutSlowInEasing)
)

There are different kinds of AnimationSpec for creating different types of animation.

spring

spring creates a physics-based animation between start and end values. It takes 2 parameters: dampingRatio and stiffness.

dampingRatio defines how bouncy the spring should be. The default value is Spring.DampingRatioNoBouncy.

Animated graphic showing the behavior of different damping ratios

stiffness defines how fast the spring should move toward the end value. The default value is Spring.StiffnessMedium.

val value by animateFloatAsState(
    targetValue = 1f,
    animationSpec = spring(
        dampingRatio = Spring.DampingRatioHighBouncy,
        stiffness = Spring.StiffnessMedium
    )
)

spring can handle interruptions more smoothly than duration-based AnimationSpec types because it guarantees the continuity of velocity when target value changes amid animations. spring is used as the default AnimationSpec by many animation APIs, such as animate*AsState and updateTransition.

tween

tween animates between start and end values over the specified durationMillis using an easing curve. See Easing for more information. You can also specify delayMillis to postpone the start of the animation.

val value by animateFloatAsState(
    targetValue = 1f,
    animationSpec = tween(
        durationMillis = 300,
        delayMillis = 50,
        easing = LinearOutSlowInEasing
    )
)

keyframes

keyframes animates based on the snapshot values specified at different timestamps in the duration of the animation. At any given time, the animation value will be interpolated between two keyframe values. For each of these keyframes, Easing can be specified to determine the interpolation curve.

It is optional to specify the values at 0 ms and at the duration time. If you do not specify these values, they default to the start and end values of the animation, respectively.

val value by animateFloatAsState(
    targetValue = 1f,
    animationSpec = keyframes {
        durationMillis = 375
        0.0f at 0 with LinearOutSlowInEasing // for 0-15 ms
        0.2f at 15 with FastOutLinearInEasing // for 15-75 ms
        0.4f at 75 // ms
        0.4f at 225 // ms
    }
)

repeatable

repeatable runs a duration-based animation (such as tween or keyframes) repeatedly until it reaches the specified iteration count. You can pass the repeatMode parameter to specify whether the animation should repeat by starting from the beginning (RepeatMode.Restart) or from the end (RepeatMode.Reverse).

val value by animateFloatAsState(
    targetValue = 1f,
    animationSpec = repeatable(
        iterations = 3,
        animation = tween(durationMillis = 300),
        repeatMode = RepeatMode.Reverse
    )
)

infiniteRepeatable

infiniteRepeatable is like repeatable, but it repeats for an infinite amount of iterations.

val value by animateFloatAsState(
    targetValue = 1f,
    animationSpec = infiniteRepeatable(
        animation = tween(durationMillis = 300),
        repeatMode = RepeatMode.Reverse
    )
)

In tests using ComposeTestRule, animations using infiniteRepeatable are not run. The component will be rendered using the initial value of each animated value.

snap

snap is a special AnimationSpec that immediately switches the value to the end value. You can specify delayMillis in order to delay the start of the animation.

val value by animateFloatAsState(
    targetValue = 1f,
    animationSpec = snap(delayMillis = 50)
)

Easing

Duration-based AnimationSpec operations (such as tween or keyframes) use Easing to adjust an animation's fraction. This allows the animating value to speed up and slow down, rather than moving at a constant rate. Fraction is a value between 0 (start) and 1.0 (end) indicating the current point in the animation.

Easing is in fact a function that takes a fraction value between 0 and 1.0 and returns a float. The returned value can be outside the boundary to represent overshoot or undershoot. A custom Easing can be created like the code below.

val CustomEasing = Easing { fraction -> fraction * fraction }

@Composable
fun EasingUsage() {
    val value by animateFloatAsState(
        targetValue = 1f,
        animationSpec = tween(
            durationMillis = 300,
            easing = CustomEasing
        )
    )
    // … …
}

Compose provides several built-in Easing functions that cover most use cases. See Speed - Material Design for more information about what Easing to use depending on your scenario.

  • FastOutSlowInEasing
  • LinearOutSlowInEasing
  • FastOutLinearEasing
  • LinearEasing
  • CubicBezierEasing

AnimationVector

Most Compose animation APIs support Float, Color, Dp, and other basic data types as animation values out of the box, but you sometimes need to animate other data types including your custom ones. During animation, any animating value is represented as an AnimationVector. The value is converted into an AnimationVector and vice versa by a corresponding TwoWayConverter so that the core animation system can handle them uniformly. For example, an Int is represented as an AnimationVector1D that holds a single float value. TwoWayConverter for Int looks like this:

val IntToVector: TwoWayConverter<Int, AnimationVector1D> =
    TwoWayConverter({ AnimationVector1D(it.toFloat()) }, { it.value.toInt() })

Color is essentially a set of 4 values, red, green, blue, and alpha, so Color is converted into an AnimationVector4D that holds 4 float values. In this manner, every data type used in animations is converted to either AnimationVector1D, AnimationVector2D, AnimationVector3D, or AnimationVector4D depending on its dimensionality. This allows different components of the object to be animated independently, each with their own velocity tracking. Built-in converters for basic data types can be accessed using Color.VectorConverter, Dp.VectorConverter, and so on.

When you want to add support for a new data type as an animating value, you can create your own TwoWayConverter and provide it to the API. For example, you can use animateValueAsState to animate your custom data type like this:

data class MySize(val width: Dp, val height: Dp)

@Composable
fun MyAnimation(targetSize: MySize) {
    val animSize: MySize by animateValueAsState<MySize, AnimationVector2D>(
        targetSize,
        TwoWayConverter(
            convertToVector = { size: MySize ->
                // Extract a float value from each of the `Dp` fields.
                AnimationVector2D(size.width.value, size.height.value)
            },
            convertFromVector = { vector: AnimationVector2D ->
                MySize(vector.v1.dp, vector.v2.dp)
            }
        )
    )
}

Gesture and animation (advanced)

There are several things we have to take into consideration when we are working with touch events and animations, compared to when we are working with animations alone. First of all, we might need to interrupt an ongoing animation when touch events begin as user interaction should have the highest priority.

In the example below, we use an Animatable to represent the offset position of a circle component. Touch events are processed with the pointerInput modifier. When we detect a new tap event, we call animateTo to animate the offset value to the tap position. A tap event can happen during the animation too, and in that case, animateTo interrupts the ongoing animation and starts the animation to the new target position while maintaining the velocity of the interrupted animation.

@Composable
fun Gesture() {
    val offset = remember { Animatable(Offset(0f, 0f), Offset.VectorConverter) }
    Box(
        modifier = Modifier
            .fillMaxSize()
            .pointerInput(Unit) {
                coroutineScope {
                    while (true) {
                        // Detect a tap event and obtain its position.
                        val position = awaitPointerEventScope {
                            awaitFirstDown().position
                        }
                        launch {
                            // Animate to the tap position.
                            offset.animateTo(position)
                        }
                    }
                }
            }
    ) {
        Circle(modifier = Modifier.offset { offset.value.toIntOffset() })
    }
}

private fun Offset.toIntOffset() = IntOffset(x.roundToInt(), y.roundToInt())

Another frequent pattern is we need to synchronize animation values with values coming from touch events, such as drag. In the example below, we see "swipe to dismiss" implemented as a Modifier (rather than using the SwipeToDismiss composable). The horizontal offset of the element is represented as an Animatable. This API has a characteristic useful in gesture animation. Its value can be changed by touch events as well as the animation. When we receive a touch down event, we stop the Animatable by the stop method so that any ongoing animation is intercepted.

During a drag event, we use snapTo to update the Animatable value with the value calculated from touch events. For fling, Compose provides VelocityTracker to record drag events and calculate velocity. The velocity can be fed directly to animateDecay for the fling animation. When we want to slide the offset value back to the original position, we specify the target offset value of 0f with the animateTo method.

fun Modifier.swipeToDismiss(
    onDismissed: () -> Unit
): Modifier = composed {
    val offsetX = remember { Animatable(0f) }
    pointerInput(Unit) {
        // Used to calculate fling decay.
        val decay = splineBasedDecay<Float>(this)
        // Use suspend functions for touch events and the Animatable.
        coroutineScope {
            while (true) {
                // Detect a touch down event.
                val pointerId = awaitPointerEventScope { awaitFirstDown().id }
                val velocityTracker = VelocityTracker()
                // Stop any ongoing animation.
                offsetX.stop()
                awaitPointerEventScope {
                    horizontalDrag(pointerId) { change ->
                        // Update the animation value with touch events.
                        launch {
                            offsetX.snapTo(
                                offsetX.value + change.positionChange().x
                            )
                        }
                        velocityTracker.addPosition(
                            change.uptimeMillis,
                            change.position
                        )
                    }
                }
                // No longer receiving touch events. Prepare the animation.
                val velocity = velocityTracker.calculateVelocity().x
                val targetOffsetX = decay.calculateTargetValue(
                    offsetX.value,
                    velocity
                )
                // The animation stops when it reaches the bounds.
                offsetX.updateBounds(
                    lowerBound = -size.width.toFloat(),
                    upperBound = size.width.toFloat()
                )
                launch {
                    if (targetOffsetX.absoluteValue <= size.width) {
                        // Not enough velocity; Slide back.
                        offsetX.animateTo(
                            targetValue = 0f,
                            initialVelocity = velocity
                        )
                    } else {
                        // The element was swiped away.
                        offsetX.animateDecay(velocity, decay)
                        onDismissed()
                    }
                }
            }
        }
    }
        .offset { IntOffset(offsetX.value.roundToInt(), 0) }
}

Testing

Compose offers ComposeTestRule that allows you to write tests for animations in a deterministic manner with full control over the test clock. This allows you to verify intermediate animation values. In addition, a test can run quicker than the actual duration of the animation.

ComposeTestRule exposes its test clock as mainClock. You can set the autoAdvance property to false to control the clock in your test code. After initiating the animation you want to test, the clock can be moved forward with advanceTimeBy.

One thing to note here is that advanceTimeBy doesn't move the clock exactly by the specified duration. Rather, it rounds it up to the nearest duration that is a multiplier of the frame duration.

@get:Rule
val rule = createComposeRule()

@Test
fun testAnimationWithClock() {
    // Pause animations
    rule.mainClock.autoAdvance = false
    var enabled by mutableStateOf(false)
    rule.setContent {
        val color by animateColorAsState(
            targetValue = if (enabled) Color.Red else Color.Green,
            animationSpec = tween(durationMillis = 250)
        )
        Box(Modifier.size(64.dp).background(color))
    }

    // Initiate the animation.
    enabled = true

    // Let the animation proceed.
    rule.mainClock.advanceTimeBy(50L)

    // Compare the result with the image showing the expected result.
    // `assertAgainGolden` needs to be implemented in your code.
    rule.onRoot().captureToImage().assertAgainstGolden()
}

Learn more

To learn more about animation in Jetpack Compose, consult the following additional resources:

Codelabs

Videos