1. Introducción
Ú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.
- Conceptos básicos de Jetpack Compose
- Diseños en Jetpack Compose
- Cómo usar el estado en Jetpack Compose
Qué aprenderemos
- Cómo usar varias API básicas de animación
- Cuándo usar las distintas API
Requisitos previos
- Conocimientos básicos de Kotlin
- Conocimientos básicos de Compose, como los siguientes:
- Diseño simple (columna, fila, caja, etcétera)
- Elementos simples de la IU (botón, texto, etcétera)
- Estados y recomposición
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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*
.
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.
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.
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.
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: