Animación

Jetpack Compose proporciona API potentes y extensibles que facilitan la implementación de varias animaciones en la IU de tu app. En este documento, se describe cómo usar estas API y qué API utilizar en función de tu situación de animación.

Descripción general

Las animaciones son esenciales en una app para dispositivos móviles moderna con el fin de proporcionar una experiencia del usuario fluida y comprensible. Muchas API de animación de Jetpack Compose están disponibles como funciones que admiten composición como diseños y otros elementos de IU, y están respaldadas por las API de nivel inferior compiladas con funciones de suspensión de corrutinas de Kotlin. Esta guía comienza con las API de alto nivel que son útiles en muchas situaciones prácticas y continúa para explicar las API de bajo nivel que te otorgan más control y personalización.

En el siguiente gráfico, encontrarás ayuda para decidir qué API utilizar a fin de implementar tu animación.

  • Si quieres animar cambios de contenido en el diseño:
    • Si quieres animar la aparición y la desaparición:
      • Utiliza AnimationVisibility.
    • Cambiar contenido según el estado:
      • Si quieres encadenar contenido:
        • Utiliza Crossfade.
      • De lo contrario, usa AnimatedContent.
    • De lo contrario, usa Modifier.contentSize.
  • Si la animación se basa en el estado:
    • Si la animación ocurre durante la composición:
      • Si la animación es infinita:
        • Utiliza rememberInfiniteTransition.
      • Si quieres animar múltiples valores simultáneamente:
        • Utiliza updateTransition.
      • De lo contrario, usa animate*AsState.
  • Si quieres controlar de forma avanzada el tiempo de la animación, haz lo siguiente:
    • Utiliza Animation.
  • Si la animación es la única fuente de confianza
    • Utiliza Animatable.
  • De lo contrario, usa AnimationState o animate.

Diagrama de flujo que describe el árbol de decisión para elegir la API de animación apropiada

API de animación de alto nivel

Compose ofrece API de animación de alto nivel para varios patrones de animación comunes que se utilizan en muchas apps. Estas API están diseñadas para alinearse con las recomendaciones de Material Design Motion.

AnimatedVisibility (experimental)

El elemento componible AnimatedVisibility anima la aparición y la desaparición de su contenido.

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

De forma predeterminada, el contenido aparece atenuado y expandido, y desaparece desvaneciéndose y achicándose. Puedes personalizar la transición especificando EnterTransition y 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))
}

Como puedes ver en el ejemplo anterior, puedes combinar varios objetos EnterTransition o ExitTransition con un operador +, y cada uno acepta parámetros opcionales para personalizar su comportamiento. Consulta las referencias para obtener más información.

EnterTransition ejemplos

fadeIn

slideIn

expandIn

expandHorizontally

expandVertically

slideInHorizontally

slideInVertically

ExitTransition ejemplos

fadeOut

slideOut

shrinkOut

shrinkHorizontally

shrinkVertically

slideOutHorizontally

slideOutVertically

AnimatedVisibility también ofrece una variante que toma MutableTransitionState. De esta manera, puedes activar una animación en cuanto se agrega AnimatedVisibility al árbol de la composición. También es útil para observar el estado de la animación.

// 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"
        }
    )
}

Cómo animar la entrada y la salida de elementos secundarios

El contenido dentro de AnimatedVisibility (elementos secundarios directos o indirectos) puede usar el modificador animateEnterExit a fin de especificar un comportamiento de animación diferente para cada uno de ellos. El efecto visual para cada uno de estos elementos secundarios es una combinación de las animaciones que se especifican en el elemento AnimatedVisibility que admite composición y las animaciones de entrada y salida propias del elemento secundario.

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…
        }
    }
}

En algunos casos, es posible que quieras que AnimatedVisibility no aplique ninguna animación de modo que cada elemento secundario pueda tener sus propias animaciones distintas mediante animateEnterExit. Para lograrlo, especifica EnterTransition.None y ExitTransition.None en el elemento AnimatedVisibility que admite composición.

Cómo agregar animación personalizada

Si quieres agregar efectos de animación personalizada además de las animaciones de entrada y salida integradas, accede a la instancia Transition subyacente a través de la propiedad transition dentro de la lambda de contenido para AnimatedVisibility. Todos los estados de animación que se agreguen a la instancia Transition se ejecutarán de manera simultánea con las animaciones de entrada y salida de AnimatedVisibility. AnimatedVisibility espera hasta que terminen todas las animaciones en Transition antes de quitar su contenido. En el caso de las animaciones de salida que se crean independientemente de Transition (por ejemplo, mediante animate*AsState), AnimatedVisibility no podría responder por ellas y, por lo tanto, es posible que quite el contenido que admite composición antes de finalizar.

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))
}

Consulta updateTransition para obtener los detalles sobre Transition.

AnimatedContent (experimental)

El elemento AnimatedContent que admite composición anima su contenido a medida que cambia en función de un estado objetivo.

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")
    }
}

Ten en cuenta que siempre debes usar el parámetro lambda y reflejarlo en el contenido. La API usa este valor como clave para identificar el contenido que se muestra en el momento.

De forma predeterminada, el contenido inicial aplica un fundido de salida y, luego, el contenido objetivo aplica un fundido de entrada (este comportamiento se denomina atenuación). Puedes personalizar este comportamiento de animación si especificas un objeto ContentTransform en el parámetro transitionSpec. Puedes crear ContentTransform si combinas EnterTransition con ExitTransition mediante la función infija with. Puedes aplicar SizeTransform a ContentTransform si lo adjuntas con la función infija using.

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 define cómo debe aparecer el contenido objetivo, y ExitTransition define cómo debe desaparecer el contenido inicial. Además de todas las funciones EnterTransition y ExitTransition disponibles para AnimatedVisibility, AnimatedContent ofrece slideIntoContainer y slideOutOfContainer. Estas son alternativas convenientes para slideInHorizontally/Vertically y slideOutHorizontally/Vertically que calculan la distancia de la diapositiva según los tamaños del contenido inicial y el contenido objetivo del contenido AnimatedContent.

SizeTransform define cómo se debe animar el tamaño entre el contenido inicial y el objetivo. Cuando creas la animación, tienes acceso al tamaño inicial y al tamaño objetivo. SizeTransform también controla si el contenido debe recortarse según el tamaño del componente durante las animaciones.

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()
        }
    }
}

Cómo animar la entrada o la salida de elementos secundarios

Al igual que AnimatedVisibility, el modificador animateEnterExit está disponible dentro de la lambda de contenido de AnimatedContent. Úsalo para aplicar EnterAnimation y ExitAnimation a cada uno de los elementos secundarios directos o indirectos por separado.

Cómo agregar animación personalizada

Al igual que AnimatedVisibility, el campo transition está disponible dentro de la lambda de contenido de AnimatedContent. Úsalo para crear un efecto de animación personalizada que se ejecuta de manera simultánea con la transición AnimatedContent. Consulta updateTransition para obtener más detalles.

animateContentSize

El modificador animateContentSize anima un cambio de tamaño.

var message by remember { mutableStateOf("Hello") }
Box(
    modifier = Modifier.background(Color.Blue).animateContentSize()
) {
    Text(text = message)
}

Crossfade

Crossfade anima entre dos diseños con una animación de encadenado. Si alternas el valor que se pasa al parámetro current, el contenido se cambia con una animación de encadenado.

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

API de animación de bajo nivel

Todas las API de animación de alto nivel mencionadas en la sección anterior se compilan sobre la base de las API de animación de bajo nivel.

Las funciones animate*AsState son las API más simples que procesan un cambio de valor instantáneo como un valor de animación. Cuenta con el respaldo de Animatable, que es una API basada en corrutinas para animar un valor único. updateTransition crea un objeto de transición que puede administrar múltiples valores de animación y ejecutarlos según un cambio de estado. rememberInfiniteTransition es similar, pero crea una transición infinita que puede administrar varias animaciones que se mantienen en ejecución indefinidamente. Todas estas API admiten composición, excepto Animatable, lo que significa que se pueden crear estas animaciones fuera de la composición.

Todas estas API se basan en la API de Animation más básica. Si bien la mayoría de las apps no interactuarán directamente con Animation, algunas de las capacidades de personalización de Animation están disponibles a través de API de nivel superior. Consulta Cómo personalizar animaciones para obtener más información sobre AnimationVector y AnimationSpec.

Diagrama que muestra la relación entre las diversas API de animación de bajo nivel

animate*AsState

Las funciones animate*AsState son las API de animación más simples en Compose para crear un valor único. Solo debes proporcionar el valor final (o valor objetivo), y la API comienza la animación desde el valor actual hasta el especificado.

A continuación, se muestra un ejemplo de animación de alfa con esta API. Con solo unir el valor objetivo en animateFloatAsState, el valor alfa ahora es un valor de animación entre los valores proporcionados (1f o 0.5f en este caso).

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

Ten en cuenta que no necesitas crear una instancia de ninguna clase de animación ni procesar la interrupción. De forma interna, se creará un objeto de animación (es decir, una instancia de Animatable) y se lo recordará en el sitio que realiza la llamada, con el primer valor objetivo como valor inicial. A partir de ese momento, cada vez que proporciones un valor objetivo diferente a este elemento componible, se iniciará automáticamente una animación con ese valor. Si ya hay una animación en curso, esta comienza desde su valor (y velocidad) actual, y la anima al valor de destino. Durante la animación, este elemento componible se vuelve a componer y muestra un valor actualizado de la animación en cada fotograma.

Desde el primer momento, Compose proporciona funciones animate*AsState para Float, Color, Dp, Size, Bounds, Offset y Rect. , Int, IntOffset y IntSize. Puedes agregar compatibilidad con otros tipos de datos proporcionando un TwoWayConverter a animateValueAsState que tome un tipo genérico.

Puedes personalizar las especificaciones de la animación proporcionando un AnimationSpec. Consulta AnimationSpec para obtener más información.

Animatable

Animatable es un contenedor de valor que puede animar el valor a medida que se modifica a través de animateTo. Esta es la API que respalda la implementación de animate*AsState. Garantiza una continuación coherente y una exclusividad mutua, lo que significa que el cambio de valor es siempre continuo, y cualquier animación en curso será cancelada.

Muchas funciones de Animatable, como animateTo, se proporcionan como funciones de suspensión. Por lo tanto, deben unirse a un alcance de corrutinas apropiado. Por ejemplo, puedes usar el elemento componible LaunchedEffect para crear un alcance únicamente para la duración del par clave-valor especificado.

// 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))

En el ejemplo anterior, creamos y recordamos una instancia de Animatable con el valor inicial de Color.Gray. Según el valor de la marca booleana ok, el color se anima a Color.Green o Color.Red. Cualquier cambio posterior al valor booleano inicia la animación en el otro color. Si hay una animación en curso cuando cambia el valor, la animación se cancela, y la animación nueva comienza desde el valor de instantánea actual con la velocidad actual.

Esta es la implementación de animación que crea una copia de seguridad de la API de animate*AsState mencionada en la sección anterior. En comparación con animate*AsState, el uso directo de Animatable brinda un control más preciso sobre varios aspectos. En primer lugar, Animatable puede tener un valor inicial diferente del primer valor objetivo. Por ejemplo, el código de ejemplo anterior muestra un cuadro gris al principio, que comienza inmediatamente a animarse en verde o rojo. En segundo lugar, Animatable proporciona más operaciones sobre el valor del contenido, es decir, snapTo y animateDecay. snapTo establece el valor actual en el valor objetivo de inmediato. Esto es útil cuando la animación en sí no es la única fuente de confianza y debe sincronizarse con otros estados, como eventos táctiles. animateDecay inicia una animación que se ralentiza a partir de la velocidad determinada. Esto es útil para implementar comportamientos de navegación. Consulta Gestos y animación para obtener más información.

Desde el primer momento, Animatable admite Float y Color, pero cualquier tipo de datos puede usarse si se proporciona un TwoWayConverter. Consulta AnimationVector para obtener más información.

Puedes personalizar las especificaciones de la animación proporcionando un AnimationSpec. Consulta AnimationSpec para obtener más información.

updateTransition

Transition administra una o más animaciones como elementos secundarios, y las ejecuta de forma simultánea entre varios estados.

Los estados pueden ser de cualquier tipo de datos. En muchos casos, puedes usar un tipo enum personalizado para garantizar la seguridad del tipo, como en este ejemplo:

private enum class BoxState {
    Collapsed,
    Expanded
}

updateTransition crea y recuerda una instancia de Transition, y actualiza su estado.

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

Luego, puedes usar una de las funciones de extensión animate* para definir una animación secundaria en esta transición. Especifica los valores objetivo para cada uno de los estados. Estas funciones animate* muestran un valor de animación que se actualiza con cada fotograma durante la animación cuando el estado de transición se actualiza con 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
    }
}

De manera opcional, puedes pasar un parámetro transitionSpec a fin de especificar un AnimationSpec diferente para cada una de las combinaciones de cambios de estado de transición. Consulta AnimationSpec para obtener más información.

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
    }
}

Una vez que haya una transición en el estado objetivo, Transition.currentState será la misma que en Transition.targetState. Esto se puede usar como indicador para comprobar si finalizó la transición.

En ocasiones, queremos tener un estado inicial diferente del primer estado objetivo. Podemos usar updateTransition con MutableTransitionState para lograrlo. Por ejemplo, nos permite iniciar la animación en cuanto el código entra en conflicto.

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

En el caso de una transición más compleja que involucra varias funciones que admiten composición, puedes usar createChildTransition para crear una transición secundaria. Esta técnica es útil para separar los problemas entre varios subcomponentes en un elemento complejo que admite composición. La transición superior tendrá en cuenta todos los valores de animación en las transiciones secundarias.

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
            }
        )
    }
}

Cómo usar la transición con AnimatedVisibility y AnimatedContent {#use-transition-with-animatedvisibility-and-animatedcontent}

AnimatedVisibility y AnimatedContent están disponibles como funciones de extensión de Transition. targetState para Transition.AnimatedVisibility y Transition.AnimatedContent se deriva de Transition y activa la transición de entrada y salida en los casos necesarios cuando cambia el targetState de Transition. Estas funciones de extensión permiten que todas las animaciones de entrada, salida y sizeTransform que, de lo contrario, serían internas para AnimatedVisibility o AnimatedContent se eleven a Transition. Con estas funciones de extensión, el cambio de estado de AnimatedVisibility o AnimatedContent se puede observar desde el exterior. En lugar de un parámetro booleano visible, esta versión de AnimatedVisibility toma una lambda que convierte el estado objetivo de la transición superior en un valor booleano.

Consulta AnimatedVisibility y AnimatedContent para obtener más detalles.

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")
            }
        }
    }
}

Cómo encapsular una transición y volver a usarla

Para casos de uso simples, definir las animaciones de transición en el mismo elemento componible que tu IU es una opción perfectamente válida. Sin embargo, si trabajas en un componente complejo con una serie de valores animados, es posible que quieras separar la implementación de la animación de la IU del elemento componible.

Para hacerlo, crea una clase que contenga todos los valores de animación y una función "update" que muestre una instancia de esa clase. La implementación de la transición se puede extraer en la nueva función separada. Este patrón es útil cuando hay una necesidad de centralizar la lógica de animación o hacer que las animaciones complejas se puedan volver a usar.

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) }
}

Compatibilidad con herramientas

Android Studio admite la inspección de transiciones en Compose Preview.

  • Vista previa de fotograma por fotograma de la transición
  • Inspección de valores para todas las animaciones en la transición
  • Vista previa de la transición entre cualquier estado inicial y objetivo

Cuando inicies el inspector de animaciones, verás debajo de la vista previa interactiva el panel "Animations", donde puedes ejecutar cualquier transición incluida en la vista previa. La transición y cada uno de los valores de la animación se etiquetan con un nombre predeterminado. Puedes personalizar la etiqueta especificando el parámetro label en las funciones updateTransition y animate*. Para obtener más información sobre la vista previa de Compose, consulta Vista previa de diseño.

rememberInfiniteTransition

InfiniteTransition contiene una o más animaciones secundarias, como Transition, pero las animaciones comienzan a ejecutarse apenas entran en la composición y no se detienen, a menos que se las quite. Puedes crear una instancia de InfiniteTransition con rememberInfiniteTransition. Se pueden agregar animaciones secundarias con animateColor, animatedFloat o animatedValue. También debes especificar un infiniteRepeatable para especificar las especificaciones de la animación.

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 es la API de animación de nivel más bajo que hemos visto hasta el momento. Otras API abarcan la mayoría de los casos de uso, pero utilizar directamente TargetBasedAnimation te permite controlar por tu cuenta el tiempo de reproducción de la animación. En el siguiente ejemplo, el tiempo de reproducción del objeto TargetAnimation se controla de forma manual en función del tiempo de procesamiento que proporciona 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())
}

Cómo personalizar animaciones

Muchas de las API de animación suelen aceptar parámetros para personalizar su comportamiento.

AnimationSpec

La mayoría de las API de animación permiten a los desarrolladores personalizar las especificaciones de animaciones mediante un parámetro AnimationSpec opcional.

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

Hay diferentes tipos de AnimationSpec para crear diferentes tipos de animaciones.

spring

spring crea una animación basada en la física entre valores iniciales y finales. Toma 2 parámetros: dampingRatio y stiffness.

dampingRatio define el nivel de efectividad que debería tener el resorte. El valor predeterminado es Spring.DampingRatioNoBouncy.

Gráfico animado que muestra el comportamiento de las distintas relaciones de amortiguamiento

stiffness define la velocidad con la que debe moverse el resorte hacia el valor final. El valor predeterminado es Spring.StiffnessMedium.

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

spring puede controlar las interrupciones de manera más fluida que los tipos AnimationSpec basados en la duración, ya que garantiza la continuidad de la velocidad cuando cambia el valor objetivo entre las animaciones. spring se usa como el valor predeterminado de AnimationSpec para muchas API de animación, como animate*AsState y updateTransition.

tween

tween anima entre los valores inicial y final sobre el durationMillis especificado mediante una curva de aceleración. Consulta Aceleración para obtener más información. También puedes especificar delayMillis para posponer el inicio de la animación.

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

keyframes

keyframes anima en función de los valores de instantánea especificados en diferentes marcas de tiempo en la duración de la animación. El valor de la animación se interpolará entre dos valores de fotogramas clave. Para cada uno de esos fotogramas clave, se puede especificar la aceleración a fin de determinar la curva de interpolación.

Es opcional especificar los valores en 0 ms y en el tiempo de duración. Si no especificas esos valores, se establecerán de manera predeterminada en los valores de inicio y finalización de la animación, respectivamente.

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 ejecuta una animación basada en la duración (como tween o keyframes) varias veces hasta que alcanza el recuento de iteración especificado. Puedes pasar el parámetro repeatMode para especificar si la animación se debe repetir comenzando desde el principio (RepeatMode.Restart) o desde el final (RepeatMode.Reverse).

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

infiniteRepeatable

infiniteRepeatable es como repeatable, pero se repite durante una cantidad infinita de iteraciones.

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

En las pruebas que usan ComposeTestRule, no se ejecutan las animaciones que usan infiniteRepeatable. El componente se renderizará con el valor inicial de cada valor animado.

snap

snap es un AnimationSpec especial que cambia inmediatamente el valor al valor final. Puedes especificar delayMillis para retrasar el inicio de la animación.

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

Easing

Las operaciones de AnimationSpec basadas en la duración (como tween o keyframes) usan Easing para ajustar la fracción de una animación. Eso permite que el valor de la animación se acelere y se ralentice, en lugar de moverse a una velocidad constante. La fracción es un valor entre 0 (inicio) y 1.0 (final) que indica el punto actual en la animación.

La aceleración es una función que toma un valor de fracción entre 0 y 1.0, y muestra un número de punto flotante. El valor que se muestra puede estar fuera de los límites para representar una suboscilación o una sobreoscilación. Se puede crear una aceleración personalizada como el siguiente código.

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

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

Compose proporciona varias funciones Easing integradas que abarcan la mayoría de los casos de uso. Consulta Velocidad: Material Design para obtener más información sobre qué tipo de Easing debes usar según tu caso.

  • FastOutSlowInEasing
  • LinearOutSlowInEasing
  • FastOutLinearEasing
  • LinearEasing
  • CubicBezierEasing

AnimationVector

La mayoría de las API de animación de Compose admiten Float, Color, Dp y otros tipos de datos básicos como valores de animación listos para usar, pero a veces necesitas animar otros tipos de datos, como los que personalizas. Durante la animación, cualquier valor de animación se representa como un AnimationVector. El valor se convierte en un AnimationVector, y viceversa, por medio de un TwoWayConverter correspondiente para que el sistema de animación principal pueda controlarlos de manera uniforme. Por ejemplo, un Int se representa como un AnimationVector1D que tiene un solo valor de número de punto flotante. TwoWayConverter de Int tiene este aspecto:

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

Color es, básicamente, un conjunto de 4 valores (rojo, verde, azul y alfa), por lo que Color se convierte en un AnimationVector4D que tiene 4 valores de número de punto flotante. De esta manera, cada tipo de datos que se usa en las animaciones se convierte en AnimationVector1D, AnimationVector2D, AnimationVector3D o AnimationVector4D, según su dimensionalidad. Esto permite que diferentes componentes del objeto se animen de forma independiente, cada uno con su propio seguimiento de velocidad. Se puede acceder a los convertidores integrados para tipos de datos básicos mediante Color.VectorConverter, Dp.VectorConverter, etcétera.

Si deseas agregar compatibilidad con un nuevo tipo de datos como un valor de animación, puedes crear tu propio TwoWayConverter y proporcionarlo a la API. Por ejemplo, puedes usar animateValueAsState para animar tu tipo de datos personalizados de la siguiente manera:

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)
            }
        )
    )
}

Gesto y animación (avanzado)

Hay varios aspectos que se deben tener en cuenta cuando trabajamos con animaciones y eventos táctiles, en comparación con los casos en que trabajamos solo con animaciones. En primer lugar, es posible que debamos interrumpir una animación en curso cuando comienzan los eventos táctiles, ya que la interacción del usuario debe tener la prioridad más alta.

En el siguiente ejemplo, usamos un Animatable para representar la posición de desplazamiento de un componente circular. Los eventos táctiles se procesan con el modificador pointerInput. Cuando detectamos un nuevo evento de toque, llamamos a animateTo para animar el valor de desplazamiento a la posición del toque. Un evento de toque también puede ocurrir durante la animación y, en ese caso, animateTo interrumpe la animación en curso y comienza la animación a la nueva posición objetivo, a la vez que se mantiene la velocidad de la animación interrumpida.

@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())

Otro patrón frecuente es tener que sincronizar valores de animación con valores provenientes de eventos táctiles, como los arrastres. En el siguiente ejemplo, vemos que "deslizar para descartar" se implementa como un Modifier (en lugar de usar el SwipeToDismiss componible). El desplazamiento horizontal del elemento se representa como un Animatable. Esta API tiene una característica útil en la animación de gestos. Su valor puede cambiar mediante eventos táctiles y la animación. Cuando recibimos un evento de toque, detenemos el Animatable mediante el método stop para que se intercepte cualquier animación en curso.

Durante un evento de arrastre, usamos snapTo para actualizar el valor Animatable con el valor calculado de los eventos táctiles. Para la navegación, Compose proporciona VelocityTracker a fin de registrar eventos de arrastre y calcular la velocidad. Se puede transmitir la velocidad directamente a animateDecay para la animación de navegación. Cuando queremos deslizar el valor de desplazamiento de vuelta a la posición original, especificamos el valor de desplazamiento objetivo de 0f con el método animateTo.

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) }
}

Pruebas

Compose ofrece ComposeTestRule, que te permite escribir pruebas para animaciones de manera determinista con control total sobre el reloj de prueba. Esto te permite verificar los valores de animación intermedios. Además, una prueba puede ejecutarse más rápido que la duración real de la animación.

ComposeTestRule expone su reloj de prueba como mainClock. Puedes configurar la propiedad autoAdvance como "false" para controlar el reloj en tu código de prueba. Después de iniciar la animación que deseas probar, el reloj puede moverse con advanceTimeBy.

Debes tener en cuenta que advanceTimeBy no mueve el reloj exactamente según la duración especificada. En cambio, se redondea a la duración más cercana que sea multiplicador de la duración del fotograma.

@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.
    rule.onRoot().captureToImage().assertAgainstGolden()
}

Más información

Para obtener más información, prueba el codelab de animación de Jetpack Compose.