Compose Animation de Jetpack

1. Introducción

ea1442f28b3c3b39.png

Última actualización: 25/01/2021

En este codelab, aprenderás a usar algunas API de animación en Jetpack Compose.

Jetpack Compose es un kit de herramientas de IU moderno diseñado para simplificar el desarrollo de IU. Si recién empiezas a usar Jetpack Compose, hay varios codelabs que te gustaría probar antes.

Qué aprenderemos

  • Cómo usar varias API básicas de animación
  • Cuándo usar las distintas API

Requisitos previos

Qué necesitará

2. Cómo prepararte

Descarga el código del codelab. Puedes clonar el repositorio de la siguiente manera:

$ git clone git@github.com:googlecodelabs/android-compose-codelabs.git

También puedes descargar el archivo ZIP.

Importa el proyecto AnimationCodelab en Android Studio.

7a7c10526864d5c2.png

El proyecto contiene varios módulos:

  • start es el estado inicial del codelab.
  • finished es el estado final de la app después de completar este codelab.

Asegúrate de que start

está seleccionado en el menú desplegable de la configuración de ejecución.

39b7acb33706a9b.png

Comenzaremos a trabajar en varias situaciones de animación en el siguiente capítulo. Cada fragmento de código en el que trabajamos en este codelab se marca con un comentario // TODO. Un buen truco es abrir la ventana de herramientas TODO en Android Studio y navegar a cada uno de los comentarios correspondientes al capítulo.

c4a2162b956cad9f.png

3. Cómo animar un cambio simple de valor

Comencemos con la API de animación más simple en Compose.

Ejecuta la configuración de start e intenta cambiar de pestaña; para ello, haz clic en los botones Inicio y Trabajo de la parte superior. No cambia el contenido de la pestaña, pero puedes ver que cambia el color de fondo.

Haga clic en TODO 1 en la ventana de herramientas de TODO y vea cómo se implementa. Se encuentra en el elemento Home que admite composición.

val backgroundColor = if (tabPage == TabPage.Home) Purple100 else Green300

Aquí, tabPage es un Int respaldado por un objeto State. Según su valor, el color de fondo se alterna entre púrpura y verde. Queremos animar este cambio de valor.

Para animar un cambio de valor simple como este, podemos usar las API de animate*AsState. Para crear un valor de animación, simplemente une el valor cambiante con la variante correspondiente de animate*AsState que admite composición, en este caso, animateColorAsState. El valor que se muestra es un objeto State<T>, de modo que podemos usar una propiedad delegada local con una declaración by para tratarla como una variable normal.

val backgroundColor by animateColorAsState(if (tabPage == TabPage.Home) Purple100 else Green300)

Vuelve a ejecutar la app y prueba cambiar de pestaña. Ahora, el cambio de color es animado.

6946feb47acc2cc6.gif

4. Animación de la visibilidad

Si te desplazas por el contenido de la aplicación, notarás que el botón de acción flotante se expande y se contrae según la dirección del desplazamiento.

Encuentra TODO 2-1 y mira cómo funciona. Se encuentra en el elemento HomeFloatingActionButton que admite composición. El texto que dice"EDITAR"se muestra u oculta mediante una sentencia if.

if (extended) {
    Text(
        text = stringResource(R.string.edit),
        modifier = Modifier
            .padding(start = 8.dp, top = 3.dp)
    )
}

Animar este cambio de visibilidad es tan simple como reemplazar if por un elemento AnimatedVisibility que admite composición.

AnimatedVisibility(extended) {
    Text(
        text = stringResource(R.string.edit),
        modifier = Modifier
            .padding(start = 8.dp, top = 3.dp)
    )
}

Ejecuta la app y observa cómo el BAF se expande y se contrae ahora.

37a613b87156bfbe.gif

AnimatedVisibility ejecuta su animación cada vez que cambia el valor especificado Boolean. De forma predeterminada, AnimatedVisibility muestra el elemento atenuado y expandido, y lo oculta atenuando y achicando. Este comportamiento funciona bien para este ejemplo con el BAF, pero también podemos personalizarlo.

Haz clic en el BAF y verás un mensaje que dice “La función Editar no es compatible”. También usa AnimatedVisibility para animar su apariencia y desaparición. Veamos cómo personalizar esta animación para que el elemento se deslice desde la parte superior y hacia afuera.

11d77a9c6af0309c.png

Busca TODO 2-2 y revisa el código en el elemento componible EditMessage.

AnimatedVisibility(
    visible = shown
) {
    Surface(
        modifier = Modifier.fillMaxWidth(),
        color = MaterialTheme.colors.secondary,
        elevation = 4.dp
    ) {
        Text(
            text = stringResource(R.string.edit_message),
            modifier = Modifier.padding(16.dp)
        )
    }
}

Para personalizar la animación, agrega los parámetros enter y exit al elemento componible AnimatedVisibility.

El parámetro enter debe ser una instancia de EnterTransition. En este ejemplo, podemos usar la función slideInVertically para crear un EnterTransition. Esta función permite una mayor personalización mediante los parámetros initialOffsetY y animationSpec. initialOffsetY debe ser una lambda que muestre la posición inicial. La lambda recibe un argumento, la altura del elemento, de modo que simplemente podemos mostrar su valor negativo. Cuando se usa slideInVertically, el desplazamiento objetivo para después de una diapositiva siempre es 0 (píxel). initialOffsetY se puede especificar como un valor absoluto o un porcentaje de la altura completa del elemento mediante una función lambda.

animationSpec es un parámetro común para muchas API de animación, incluidas EnterTransition y ExitTransition. Podemos pasar uno de varios tipos de AnimationSpec para especificar cómo debe cambiar el valor de la animación con el tiempo. En este ejemplo, usaremos un objeto AnimationSpec simple basado en la duración. Se puede crear con la función tween. La duración es de 150 ms y la aceleración es LinearOutSlowInEasing.

De manera similar, podemos usar la función slideOutVertically para el parámetro exit. slideOutVertically supone que el desplazamiento inicial es 0, por lo que solo se debe especificar targetOffsetY. Usemos la misma función tween para el parámetro animationSpec, pero con una duración de 250 ms y la aceleración de FastOutLinearInEasing.

El código resultante debería verse como se muestra a continuación.

AnimatedVisibility(
    visible = shown,
    enter = slideInVertically(
        // Enters by sliding down from offset -fullHeight to 0.
        initialOffsetY = { fullHeight -> -fullHeight },
        animationSpec = tween(durationMillis = 150, easing = LinearOutSlowInEasing)
    ),
    exit = slideOutVertically(
        // Exits by sliding up from offset 0 to -fullHeight.
        targetOffsetY = { fullHeight -> -fullHeight },
        animationSpec = tween(durationMillis = 250, easing = FastOutLinearInEasing)
    )
) {
    Surface(
        modifier = Modifier.fillMaxWidth(),
        color = MaterialTheme.colors.secondary,
        elevation = 4.dp
    ) {
        Text(
            text = stringResource(R.string.edit_message),
            modifier = Modifier.padding(16.dp)
        )
    }
}

Ejecuta la app y vuelve a hacer clic en el BAF. Como puede ver, ahora el mensaje se desliza hacia arriba y hacia afuera desde la parte superior.

76895615b43b9263.gif

5. Cambio del tamaño del contenido animado

La app muestra varios temas en el contenido. Intenta hacer clic en uno de ellos para que se abra y muestre el texto del cuerpo de ese tema. La tarjeta que contiene el texto se expande y se achica cuando se muestra u oculta el cuerpo.

Consulta el código de TODO 3 en el elemento componible TopicRow.

Column(
    modifier = Modifier
        .fillMaxWidth()
        .padding(16.dp)
) {
    // ... the title and the body
}

El elemento Column que admite composición cambia su tamaño a medida que se modifica el contenido. Podemos animar el cambio de su tamaño agregando el modificador animateContentSize.

Column(
    modifier = Modifier
        .fillMaxWidth()
        .padding(16.dp)
        .animateContentSize()
) {
    // ... the title and the body
}

Ejecuta la app y haz clic en uno de los temas. Puedes ver que se expande y se contrae con la animación.

c0ad7381779fcb09.gif

6. Animación de varios valores

Ahora que conocemos algunas de las API de animación básicas, veamos la API de Transition que nos permite crear animaciones más complejas. En este ejemplo, personalizamos el indicador de pestaña. Es un rectángulo que se muestra en la pestaña seleccionada actualmente.

Busca TODO 4 en el elemento componible HomeTabIndicator y observa cómo se implementa el indicador de la pestaña.

val indicatorLeft = tabPositions[tabPage.ordinal].left
val indicatorRight = tabPositions[tabPage.ordinal].right
val color = if (tabPage == TabPage.Home) Purple700 else Green800

Aquí, indicatorLeft es la posición horizontal del borde izquierdo del indicador en la fila de la pestaña. indicatorRight es la posición horizontal del borde derecho del indicador. También cambia el color entre púrpura y verde.

Para animar estos valores múltiples de forma simultánea, podemos usar un Transition. Se puede crear un Transition con la función updateTransition. Pasa el índice de la pestaña seleccionada actualmente como el parámetro targetState.

Cada valor de animación se puede declarar con las funciones de extensión animate* de Transition. En este ejemplo, usamos animateDp y animateColor. Toman un bloque de lambda y podemos especificar el valor objetivo para cada uno de los estados. Ya sabemos cuáles deben ser sus valores objetivo, por lo que podemos unir los valores como se muestra a continuación. Ten en cuenta que podemos usar una declaración by y convertirla en una propiedad delegada local aquí nuevamente porque las funciones animate* muestran un objeto State.

val transition = updateTransition(tabPage)
val indicatorLeft by transition.animateDp { page ->
    tabPositions[page.ordinal].left
}
val indicatorRight by transition.animateDp { page ->
    tabPositions[page.ordinal].right
}
val color by transition.animateColor { page ->
    if (page == TabPage.Home) Purple700 else Green800
}

Ejecuta la app ahora, y el interruptor de la pestaña es mucho más interesante. Cuando haces clic en la pestaña, se cambia el valor del estado tabPage, todos los valores de animación asociados con transition comienzan a animarse en el valor especificado para el estado objetivo.

3262270d174e77bf.gif

Además, podemos especificar el parámetro transitionSpec para personalizar el comportamiento de la animación. Por ejemplo, podemos lograr un efecto elástico para el indicador si el borde más cercano al destino se mueve más rápido que el otro borde. Podemos usar la función infija isTransitioningTo en lambdas transitionSpec para determinar la dirección del cambio de estado.

val transition = updateTransition(
    tabPage,
    label = "Tab indicator"
)
val indicatorLeft by transition.animateDp(
    transitionSpec = {
        if (TabPage.Home isTransitioningTo TabPage.Work) {
            // Indicator moves to the right.
            // The left edge moves slower than the right edge.
            spring(stiffness = Spring.StiffnessVeryLow)
        } else {
            // Indicator moves to the left.
            // The left edge moves faster than the right edge.
            spring(stiffness = Spring.StiffnessMedium)
        }
    },
    label = "Indicator left"
) { page ->
    tabPositions[page.ordinal].left
}
val indicatorRight by transition.animateDp(
    transitionSpec = {
        if (TabPage.Home isTransitioningTo TabPage.Work) {
            // Indicator moves to the right
            // The right edge moves faster than the left edge.
            spring(stiffness = Spring.StiffnessMedium)
        } else {
            // Indicator moves to the left.
            // The right edge moves slower than the left edge.
            spring(stiffness = Spring.StiffnessVeryLow)
        }
    },
    label = "Indicator right"
) { page ->
    tabPositions[page.ordinal].right
}
val color by transition.animateColor(
    label = "Border color"
) { page ->
    if (page == TabPage.Home) Purple700 else Green800
}

Vuelve a ejecutar la app e intenta cambiar de pestaña.

2ad4adbefce04ae2.gif

Android Studio admite la inspección de transiciones en Compose Preview. Para usar la Vista previa de animación, inicia el modo interactivo haciendo clic en el ícono "Iniciar modo interactivo" que se encuentra en la esquina superior derecha de un elemento componible en la vista previa. Debes habilitar esta función en la configuración experimental como se indica aquí si no encuentras el ícono. Haz clic en el ícono del elemento PreviewHomeTabBar que admite composición. Luego, haz clic en el ícono de "Iniciar animación de inspección" que se encuentra en la esquina superior derecha del modo interactivo. Se abrirá un nuevo panel &Animations.

Para ejecutar la animación, haz clic en el botón "Play" (Reproducir). También puedes arrastrar la barra de búsqueda para ver cada uno de los fotogramas de animación. Para obtener una mejor descripción de los valores de animación, puedes especificar el parámetro label en updateTransition y los métodos animate*.

2d3c5020ae28120b.png

7. Animación repetida

Haz clic en el botón de actualización junto a la temperatura actual. La app comienza a cargar la información meteorológica más reciente (simula). Hasta que se complete la carga, verás un indicador de carga, que es un círculo gris y una barra. Animemos el valor alfa de este indicador para aclarar que el proceso está en curso.

c2912ddc2d73bdfc.png

Busca TODO 5 en el elemento componible LoadingRow.

val alpha = 1f

Queremos que este valor tenga una animación de entre 0 f y 1 f varias veces. Podemos usar InfiniteTransition para este propósito. Esta API es similar a la API de Transition de la sección anterior. Ambos animan varios valores, pero si bien Transition anima los valores en función de los cambios de estado, InfiniteTransition anima los valores de manera indefinida.

Para crear un InfiniteTransition, usa la función rememberInfiniteTransition. Luego, cada cambio de valor de la animación se puede declarar con una de las funciones de extensión animate* de InfiniteTransition. En este caso, animaremos un valor alfa, así que usemos animatedFloat. El parámetro initialValue debe ser 0f y el targetValue 1f. También podemos especificar un AnimationSpec para esta animación, pero esta API solo toma un InfiniteRepeatableSpec. Usa la función infiniteRepeatable para crear una. Este AnimationSpec une cualquier AnimationSpec basado en la duración y lo hace repetible. Por ejemplo, el código resultante debería verse como se muestra a continuación.

val infiniteTransition = rememberInfiniteTransition()
val alpha by infiniteTransition.animateFloat(
    initialValue = 0f,
    targetValue = 1f,
    animationSpec = infiniteRepeatable(
        animation = keyframes {
            durationMillis = 1000
            0.7f at 500
        },
        repeatMode = RepeatMode.Reverse
    )
)

Ejecuta la app y haz clic en el botón para actualizar. Ahora puede ver la animación del indicador de carga.

ca4d1d5bfe87b2a9.gif

8. Animación por gestos

En esta sección final, aprenderemos a ejecutar animaciones basadas en entradas táctiles. Hay varios aspectos únicos que se deben tener en cuenta en esta situación. En primer lugar, es posible que un evento táctil intercepte cualquier animación en curso. En segundo lugar, es posible que el valor de la animación no sea la única fuente de confianza. En otras palabras, es posible que debamos sincronizar el valor de la animación con los valores provenientes de los eventos táctiles.

Busca TODO 6-1 en el modificador swipeToDismiss. Aquí, estamos intentando crear un modificador que permita que el elemento se deslice con el tacto. Cuando el elemento se coloca en el borde de la pantalla, llamamos a la devolución de llamada onDismissed para que se pueda quitar el elemento.

Animatable es la API de nivel más bajo que hemos visto hasta el momento. Tiene varias funciones que son útiles en situaciones de gestos, así que vamos a crear una instancia de Animatable y usarla para representar el desplazamiento horizontal del elemento deslizable.

val offsetX = remember { Animatable(0f) } // Add this line
pointerInput {
    // Used to calculate a settling position of a fling animation.
    val decay = splineBasedDecay<Float>(this)
    // Wrap in a coroutine scope to use suspend functions for touch events and animation.
    coroutineScope {
        while (true) {
            // ...

En TODO 6-2, recibimos un evento de touchdown. Debemos interceptar la animación si se está ejecutando. Para ello, puedes llamar a stop en Animatable. Ten en cuenta que se ignora la llamada si la animación no se está ejecutando.

// Wait for a touch down event.
val pointerId = awaitPointerEventScope { awaitFirstDown().id }
offsetX.stop() // Add this line
// Prepare for drag events and record velocity of a fling.
val velocityTracker = VelocityTracker()
// Wait for drag events.
awaitPointerEventScope {

En TODO 6-3, recibimos eventos de arrastre de forma continua. Debemos sincronizar la posición del evento táctil en el valor de la animación. Podemos usar snapTo en Animatable.

horizontalDrag(pointerId) { change ->
    // Add these 4 lines
    val horizontalDragOffset = offsetX.value + change.positionChange().x
    launch {
        offsetX.snapTo(horizontalDragOffset)
    }
    // Record the velocity of the drag.
    velocityTracker.addPosition(change.uptimeMillis, change.position)
    // Consume the gesture event, not passed to external
    change.consumePositionChange()
}

TODO 6-4 es donde se acaba de soltar y lanzar el elemento. Debemos calcular la posición eventual en la que se establece el desplazamiento para decidir si debemos deslizar el elemento nuevamente a la posición original, o moverlo y, luego, invocar la devolución de llamada.

// Dragging finished. Calculate the velocity of the fling.
val velocity = velocityTracker.calculateVelocity().x
// Add this line
val targetOffsetX = decay.calculateTargetValue(offsetX.value, velocity)

En TODO 6-5, estamos a punto de iniciar la animación. Sin embargo, antes de eso, queremos establecer límites de valor inferior y superior al elemento Animatable para que se detenga en cuanto los alcance. El modificador pointerInput nos permite acceder al tamaño del elemento mediante la propiedad size, así que utilícelo para obtener nuestros límites.

offsetX.updateBounds(
    lowerBound = -size.width.toFloat(),
    upperBound = size.width.toFloat()
)

TODO 6-6 es donde finalmente podemos comenzar nuestra animación. Primero, comparamos la posición de ajuste del desplazamiento que calculamos anteriormente y el tamaño del elemento. Si la posición de liquidación es inferior al tamaño, significa que la velocidad del lanzamiento no fue suficiente. Podemos usar animateTo para animar el valor de nuevo a 0f. De lo contrario, usamos animateDecay para iniciar la animación de navegación. Cuando finaliza la animación (probablemente según los límites que configuramos antes), podemos llamar a la devolución de llamada.

launch {
    if (targetOffsetX.absoluteValue <= size.width) {
        // Not enough velocity; Slide back.
        offsetX.animateTo(targetValue = 0f, initialVelocity = velocity)
    } else {
        // Enough velocity to slide away the element to the edge.
        offsetX.animateDecay(velocity, decay)
        // The element was swiped away.
        onDismissed()
    }
}

Por último, consulta TODO 6-7. Configuramos todas las animaciones y los gestos, así que no olvides aplicar la compensación al elemento.

.offset { IntOffset(offsetX.value.roundToInt(), 0) }

Como resultado de esta sección, obtendrás un código como el siguiente:

private fun Modifier.swipeToDismiss(
    onDismissed: () -> Unit
): Modifier = composed {
    // This `Animatable` stores the horizontal offset for the element.
    val offsetX = remember { Animatable(0f) }
    pointerInput(Unit) {
        // Used to calculate a settling position of a fling animation.
        val decay = splineBasedDecay<Float>(this)
        // Wrap in a coroutine scope to use suspend functions for touch events and animation.
        coroutineScope {
            while (true) {
                // Wait for a touch down event.
                val pointerId = awaitPointerEventScope { awaitFirstDown().id }
                // Interrupt any ongoing animation.
                offsetX.stop()
                // Prepare for drag events and record velocity of a fling.
                val velocityTracker = VelocityTracker()
                // Wait for drag events.
                awaitPointerEventScope {
                    horizontalDrag(pointerId) { change ->
                        // Record the position after offset
                        val horizontalDragOffset = offsetX.value + change.positionChange().x
                        launch {
                            // Overwrite the `Animatable` value while the element is dragged.
                            offsetX.snapTo(horizontalDragOffset)
                        }
                        // Record the velocity of the drag.
                        velocityTracker.addPosition(change.uptimeMillis, change.position)
                        // Consume the gesture event, not passed to external
                        change.consumePositionChange()
                    }
                }
                // Dragging finished. Calculate the velocity of the fling.
                val velocity = velocityTracker.calculateVelocity().x
                // Calculate where the element eventually settles after the fling animation.
                val targetOffsetX = decay.calculateTargetValue(offsetX.value, velocity)
                // The animation should end as soon as it reaches these bounds.
                offsetX.updateBounds(
                    lowerBound = -size.width.toFloat(),
                    upperBound = size.width.toFloat()
                )
                launch {
                    if (targetOffsetX.absoluteValue <= size.width) {
                        // Not enough velocity; Slide back to the default position.
                        offsetX.animateTo(targetValue = 0f, initialVelocity = velocity)
                    } else {
                        // Enough velocity to slide away the element to the edge.
                        offsetX.animateDecay(velocity, decay)
                        // The element was swiped away.
                        onDismissed()
                    }
                }
            }
        }
    }
        // Apply the horizontal offset to the element.
        .offset { IntOffset(offsetX.value.roundToInt(), 0) }
}

Ejecuta la app y desliza uno de los elementos de la tarea. Puedes ver que el elemento se desliza hacia atrás en la posición predeterminada o que se desliza hacia afuera y se quita según la velocidad de tu navegación. También puedes capturar el elemento mientras se está animando.

7cdefce823f6b9bd.png

9. ¡Felicitaciones!

¡Felicitaciones! Ya aprendiste las API básicas de animación de Compose.

Aprendimos a compilar varios patrones de animación comunes con API de animación de alto nivel, como animateContentSize y AnimatedVisibility. También aprendimos que podemos usar animate*AsState para animar un solo valor, updateTransition para animar varios valores y infiniteTransition para animar valores indefinidamente. También usamos Animatable para compilar animaciones personalizadas junto con gestos táctiles.

¿Qué sigue?

Consulta los otros codelabs sobre la ruta de Compose.

Para obtener más información, consulta Animaciones de Compose y estos documentos de referencia: