Cómo animar elementos en Jetpack Compose

1. Introducción

Logotipo de Jetpack Compose

Última actualización: 21/11/2023

En este codelab, aprenderás a usar algunas de las APIs de Animation en Jetpack Compose.

Jetpack Compose es un kit de herramientas moderno diseñado para simplificar el desarrollo de IU. Si recién estás empezando a usar Jetpack Compose, hay varios codelabs que podrías probar antes de este.

Qué aprenderás

  • Cómo usar varias APIs básicas de Animation

Requisitos previos

Requisitos

2. Cómo prepararte

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

$ git clone https://github.com/android/codelab-android-compose.git

También tienes la opción de descargar el repositorio como archivo ZIP:

Importa el proyecto AnimationCodelab en Android Studio.

Cómo importar Codelab de animación a Android Studio

El proyecto tiene 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 el elemento start esté seleccionado en el menú desplegable para la configuración de ejecución.

Se muestra el inicio seleccionado en Android Studio

Comenzaremos a trabajar en varias situaciones de animación en el siguiente capítulo. Cada fragmento de código con el que trabajaremos en este codelab está marcado 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 del capítulo.

Lista de tareas pendientes que se muestra en Android Studio

3. Cómo animar un cambio simple de valor

Comencemos con una de las API de Animation más simples de Compose: las API de animate*AsState. Debes usar esta API para animar los cambios de State.

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

Pestaña Home seleccionada

Pestaña Work seleccionada

Haz clic en TODO 1 en la ventana de herramientas de TODO para ver la implementación. Se encuentra en el elemento Home componible.

val backgroundColor = if (tabPage == TabPage.Home) Seashell else GreenLight

Aquí, tabPage es una TabPage respaldada por un objeto State. Según su valor, el color de fondo cambia entre durazno y verde. Queremos animar este cambio de valor.

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

val backgroundColor by animateColorAsState(
        targetValue = if (tabPage == TabPage.Home) Seashell else GreenLight,
        label = "background color")

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

Animación de cambio de color en acción entre pestañas

4. Cómo animar la visibilidad

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

Botón de acción flotante de Edit expandido

Botón de acción flotante de Edit pequeño

Busca TODO 2-1 y mira cómo funciona. Se encuentra en el elemento de componibilidad HomeFloatingActionButton. El texto que dice "EDIT" se muestra o se oculta con 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 de componibilidad AnimatedVisibility.

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.

Animación del botón de acción flotante de Edit

AnimatedVisibility ejecuta su animación cada vez que cambia el valor especificado de Boolean. De forma predeterminada, AnimatedVisibility muestra el elemento con un fundido de entrada y una expansión, y lo oculta con un fundido de salida y una contracción. Este comportamiento funciona muy bien para este ejemplo con un BAF, pero también podemos personalizarlo.

Si haces clic en el BAF, deberías ver un mensaje que diga "Edit feature is not supported" (No se admite la función de edición). También usa AnimatedVisibility para animar su aparición y desaparición. A continuación, personalizarás este comportamiento para que el mensaje se deslice desde la parte superior y hacia afuera de ella.

Mensaje que detalla que no se admite la función de edición

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

AnimatedVisibility(
    visible = shown
) {
    Surface(
        modifier = Modifier.fillMaxWidth(),
        color = MaterialTheme.colorScheme.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 de componibilidad AnimatedVisibility.

El parámetro enter debe ser una instancia de EnterTransition. En este ejemplo, podemos usar la función slideInVertically a fin de crear un EnterTransition y un slideOutVertically para la transición de salida. Cambia el código de la siguiente manera:

AnimatedVisibility(
    visible = shown,
    enter = slideInVertically(),
    exit = slideOutVertically()
)

Vuelve a ejecutar la app. Al hacer clic en el botón Editar, notarás que la animación se ve mejor, pero no es la correcta, ya que el comportamiento predeterminado de slideInVertically y slideOutVertically usa la mitad del alto del elemento.

El deslizamiento se corta verticalmente a la mitad

Para la transición de entrada, podemos ajustar el comportamiento predeterminado a fin de usar toda la altura del elemento y animarlo correctamente mediante la configuración del parámetro initialOffsetY. initialOffsetY debe ser una lambda que muestre la posición inicial.

La lambda recibe un argumento: la altura del elemento. Para garantizar que el elemento se deslice desde la parte superior de la pantalla, se muestra su valor negativo, ya que en la parte superior de la pantalla se muestra el valor 0. Queremos que la animación comience de -height a 0 (su posición final en reposo) para que comience desde arriba y tenga una animación.

Cuando se usa slideInVertically, el desplazamiento objetivo después de la diapositiva siempre es 0 (píxel). initialOffsetY se puede especificar como un valor absoluto o un porcentaje de la altura completa del elemento a través de una función lambda.

De manera similar, slideOutVertically supone que el desplazamiento inicial es 0, por lo que solo se debe especificar targetOffsetY.

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

Si vuelves a ejecutar la app, podemos ver que la animación se ajusta más a lo que esperábamos:

Animación de deslizamiento en la que funciona el desplazamiento

Podemos personalizar más nuestra animación con el parámetro animationSpec. animationSpec es un parámetro común para muchas APIs de Animation, incluidas EnterTransition y ExitTransition. Podemos pasar uno de varios tipos de AnimationSpec para especificar cómo debe cambiar con el tiempo el valor de la animación. En este ejemplo, usaremos un AnimationSpec simple basado en la duración. Se puede crear con la función tween. La duración es de 150 ms y la velocidad es de LinearOutSlowInEasing. Para la animación de salida, usemos la misma función tween con el parámetro animationSpec, pero con una duración de 250 ms y una aceleración de FastOutLinearInEasing.

El código resultante debería verse de la siguiente manera:

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.colorScheme.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. Puedes ver que el mensaje ahora se desliza hacia adentro y afuera desde la parte superior con diferentes funciones de aceleración y duraciones:

Animación que muestra el mensaje de edición deslizándose desde la parte superior

5. Cómo animar el cambio de tamaño del contenido

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

Lista de temas contraída

Lista de temas expandida

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

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

Aquí, el elemento de componibilidad Column cambia de tamaño a medida que cambia su 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 una animación.

Animación de lista de temas expandida y contraída

animateContentSize también se puede personalizar con una animationSpec de tu elección. Podemos proporcionar opciones para cambiar el tipo de animación de resorte a interpolación, etc. Consulta la documentación de Personalización de animaciones para obtener más información.

6. Cómo animar varios valores

Ahora que conocemos algunas APIs básicas de Animation, veamos la API de Transition, que nos permite crear animaciones más complejas. El uso de la API de Transition nos permite realizar un seguimiento de cuándo finalizan todas las animaciones en un Transition, lo cual no es posible cuando se usan las APIs individuales de animate*AsState que vimos anteriormente. La API de Transition también nos permite definir diferentes transitionSpec cuando se realiza la transición entre diferentes estados. Veamos cómo podemos usarla:

Para este ejemplo, personalizaremos el indicador de pestañas. Es un rectángulo que se muestra en la pestaña seleccionada.

Pestaña Home seleccionada

Pestaña Wok seleccionada

Busca TODO 4 en el elemento de componibilidad 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) PaleDogwood else Green

Aquí, indicatorLeft es la posición horizontal del borde izquierdo del indicador en la fila de pestañas. indicatorRight es la posición horizontal del borde derecho del indicador. El color también cambia entre durazno y verde.

Para animar estos valores de forma simultánea, podemos usar una Transition. Puedes 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 puede declararse con las funciones de extensión animate* de Transition. En este ejemplo, usamos animateDp y animateColor. Estas toman un bloque lambda y podemos especificar el valor objetivo para cada uno de los estados. Ya sabemos cuáles deberían ser sus valores objetivo, por lo que podemos unirlos 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, label = "Tab indicator")
val indicatorLeft by transition.animateDp(label = "Indicator left") { page ->
   tabPositions[page.ordinal].left
}
val indicatorRight by transition.animateDp(label = "Indicator right") { page ->
   tabPositions[page.ordinal].right
}
val color by transition.animateColor(label = "Border color") { page ->
   if (page == TabPage.Home) PaleDogwood else Green
}

Ejecuta la app y verás que el cambio de pestaña ahora 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 al valor especificado para el estado objetivo.

Animación entre las pestañas Home y Work

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 objetivo 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) PaleDogwood else Green
}

Vuelve a ejecutar la app y prueba cambiar de pestaña.

Efecto elástico personalizado al cambiar de pestaña

Android Studio admite la inspección de transiciones en la vista previa de Compose. Para usar la Animation Preview, inicia el modo interactivo haciendo clic en el ícono "Start Animation Preview" en la esquina superior derecha de un elemento componible en la vista previa (ícono de Ícono de vista previa de animación). Prueba hacer clic en el ícono del elemento PreviewHomeTabBar componible. Se abrirá un nuevo panel "Animations".

Para ejecutar la animación, haz clic en el ícono del botón "Play". 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*.

Cómo buscar animaciones en Android Studio

7. Cómo repetir animaciones

Haz clic en el ícono del botón de actualización junto a la temperatura actual. La app comenzará a cargar la información meteorológica más reciente (esta es una simulación). 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.

Imagen estática de la tarjeta de información de marcador de posición que todavía no está animada

Busca TODO 5 en el elemento de componibilidad LoadingRow.

val alpha = 1f

Deseamos animar este valor entre 0f y 1f varias veces. Para ello, podemos usar InfiniteTransition. Esta API es similar a la API de Transition de la sección anterior. Ambas animan varios valores, pero mientras que Transition los anima en función de los cambios de estado, InfiniteTransition los anima indefinidamente.

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 usaremos 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
    ),
    label = "alpha"
)

El repeatMode predeterminado es RepeatMode.Restart. Esta transición va de initialValue a targetValue y vuelve a comenzar en el initialValue. Si estableces repeatMode en RepeatMode.Reverse, la animación pasará de initialValue a targetValue y, luego, de targetValue a initialValue. La animación avanzará de 0 a 1 y, luego, de 1 a 0.

La animación keyFrames es otro tipo de animationSpec (algunos son tween y spring) que permite cambios en el valor en curso a diferentes milisegundos. Inicialmente, configuramos durationMillis en 1,000 ms. Luego, podemos definir fotogramas clave en la animación. Por ejemplo, a los 500 ms, nos gustaría que el valor alfa sea 0,7 f. Esto cambiará la progresión de la animación: avanzará rápidamente de 0 a 0.7 en los primeros 500 ms de la animación, y de 0.7 a 1.0 entre los 500 ms y 1,000 ms de la animación, ralentizando el proceso hacia el final.

Si queremos más de un fotograma clave, podemos definir varios keyFrames de la siguiente manera:

animation = keyframes {
   durationMillis = 1000
   0.7f at 500
   0.9f at 800
}

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

Repetición del contenido de marcador de posición animado

8. Animación basada en gestos

En esta sección final, aprenderemos a ejecutar animaciones basadas en entradas táctiles. Compilaremos un modificador swipeToDismiss desde cero.

Busca TODO 6-1 en el modificador swipeToDismiss. Aquí, intentamos crear un modificador que haga que el elemento sea deslizable con el tacto. Cuando se coloca el elemento en el borde de la pantalla, llamamos a la devolución de llamada onDismissed para quitarlo.

Para compilar un modificador swipeToDismiss, debemos comprender algunos conceptos clave. En primer lugar, un usuario coloca el dedo en la pantalla y genera un evento táctil con una coordenada x y una y. Luego, mueve el dedo hacia la derecha o la izquierda y traslada x e y en función de su movimiento. El elemento que está tocando debe moverse con el dedo, por lo que actualizaremos la posición del elemento según la velocidad y la posición del evento táctil.

Podemos usar varios de los conceptos descritos en la documentación de gestos de Compose. Con el modificador pointerInput, podemos obtener acceso detallado a los eventos táctiles del puntero entrante y realizar un seguimiento de la velocidad en la que el usuario arrastra con el mismo puntero. Si el usuario levanta el dedo antes de que el elemento haya pasado el límite para descartarlo, el elemento volverá a su posición.

Hay varias cosas únicas que debes tener en cuenta en esta situación. En primer lugar, un evento táctil puede interceptar cualquier animación en curso. En segundo lugar, es posible que el valor de la animación no sea la única fuente de información. En otras palabras, es posible que debamos sincronizar el valor de la animación con los valores provenientes de eventos táctiles.

Animatable es la API más detallada que vimos hasta el momento. Tiene varias funciones que son útiles en situaciones de gestos, como la capacidad de ajustar instantáneamente al nuevo valor que proviene de un gesto y detener cualquier animación en curso cuando se activa un nuevo evento táctil. Crearemos una instancia de Animatable y la usaremos para representar el desplazamiento horizontal del elemento deslizable. Asegúrate de importar Animatable de androidx.compose.animation.core.Animatable y no de androidx.compose.animation.Animatable.

val offsetX = remember { Animatable(0f) } // Add this line
// used to receive user touch events
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) {
            // ...

TODO 6-2 es donde acabamos de recibir un evento táctil. Debemos interceptar la animación si se está ejecutando actualmente. Para ello, se debe llamar a stop en el Animatable. Ten en cuenta que la llamada se ignora si la animación no se está ejecutando. Se usará VelocityTracker para calcular qué tan rápido se mueve un usuario de izquierda a derecha. awaitPointerEventScope es una función de suspensión que puede esperar eventos de entrada del usuario y responder a ellos.

// Wait for a touch down event. Track the pointerId based on the touch
val pointerId = awaitPointerEventScope { awaitFirstDown().id }
offsetX.stop() // Add this line to cancel any on-going animations
// Prepare for drag events and record velocity of a fling gesture
val velocityTracker = VelocityTracker()
// Wait for drag events.
awaitPointerEventScope {

En TODO 6-3, recibimos continuamente eventos de arrastre. Debemos sincronizar la posición del evento táctil en el valor de la animación. Para ello, podemos usar snapTo en Animatable. Se debe llamar a snapTo dentro de otro bloque launch, ya que awaitPointerEventScope y horizontalDrag son permisos de corrutinas restringidos. Esto significa que solo pueden usar suspend para awaitPointerEvents; snapTo no es un evento de puntero.

horizontalDrag(pointerId) { change ->
    // Add these 4 lines
    // Get the drag amount change to offset the item with
    val horizontalDragOffset = offsetX.value + change.positionChange().x
    // Need to call this in a launch block in order to run it separately outside of the awaitPointerEventScope
    launch {
        // Instantly set the Animable to the dragOffset to ensure its moving
        // as the user's finger moves
        offsetX.snapTo(horizontalDragOffset)
    }
    // Record the velocity of the drag.
    velocityTracker.addPosition(change.uptimeMillis, change.position)

    // Consume the gesture event, not passed to external
    if (change.positionChange() != Offset.Zero) change.consume()

}

En TODO 6-4, el elemento se acaba de lanzar y deslizar. Debemos calcular la posición final en la que se establece el deslizamiento para decidir si debemos desplazar el elemento de nuevo a la posición original, o bien alejarlo y, luego, invocar la devolución de llamada. Usaremos el objeto decay creado anteriormente para calcular targetOffsetX:

// Dragging finished. Calculate the velocity of the fling.
val velocity = velocityTracker.calculateVelocity().x
// Add this line to calculate where it would end up with
// the current velocity and position
val targetOffsetX = decay.calculateTargetValue(offsetX.value, velocity)

En las TODO 6-5, estamos a punto de comenzar la animación. Pero antes, queremos establecer límites de valores superiores e inferiores para el Animatable de modo que este se detenga en cuanto alcance los límites (-size.width y size.width, ya que no queremos que el offsetX pueda extenderse más allá de estos dos valores). El modificador pointerInput nos permite acceder al tamaño del elemento mediante la propiedad size, así que usemos eso para obtener nuestros límites.

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

En TODO 6-6, podemos comenzar la animación. Primero, compararemos la posición de desplazamiento definida que calculamos anteriormente y el tamaño del elemento. Si la posición de desplazamiento es inferior al tamaño, significa que la velocidad del deslizamiento no fue suficiente. Podemos usar animateTo para volver a animar el valor a 0 f. De lo contrario, usaremos animateDecay para iniciar la animación de desplazamiento. Cuando finalice la animación (probablemente, cerca de los límites que establecimos antes), podremos 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 el desplazamiento al elemento, esto moverá el elemento en pantalla al valor producido por nuestro gesto o animación:

.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 e intenta deslizar uno de los elementos de la tarea. Puedes ver que el elemento regresa a la posición predeterminada o se desliza y desaparece según la velocidad del deslizamiento. También puedes capturar el elemento durante la animación.

Animación de gesto de deslizamiento para descartar elementos

9. ¡Felicitaciones!

¡Felicitaciones! Aprendiste las APIs básicas de Animation de Compose.

En este codelab, aprendimos a usar lo siguiente:

APIs de Animation generales:

  • animatedContentSize
  • AnimatedVisibility

APIs de Animation detalladas:

  • animate*AsState para animar un solo valor
  • updateTransition para animar varios valores
  • infiniteTransition para animar valores indefinidamente
  • Animatable para compilar animaciones personalizadas con gestos táctiles

¿Qué sigue?

Consulta los otros codelabs sobre la ruta de aprendizaje de Compose.

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