Animações do Jetpack Compose

1. Introdução

ea1442f28b3c3b39.png

Última atualização: 25/01/2021

Neste codelab, você aprenderá a usar algumas APIs de animação no Jetpack Compose.

O Jetpack Compose é um kit de ferramentas de IU moderno projetado para simplificar o desenvolvimento de IUs. Se você está começando a usar o Jetpack Compose, há vários codelabs que convém testar antes deste.

O que vamos aprender

  • Como usar várias APIs básicas de animação
  • Quando usar uma API

Prerequisites

O que é necessário

2. Etapas da configuração

Faça o download do código do codelab. Você pode clonar o repositório da seguinte maneira:

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

Também é possível fazer o download do arquivo ZIP.

Importe o projeto AnimationCodelab no Android Studio.

7a7c10526864d5c2.png

O projeto tem vários módulos:

  • start é o estado inicial do codelab.
  • O finished é o estado final do app após a conclusão deste codelab.

Confira se o start

está selecionado na lista suspensa da configuração de execução.

39b7acb33706a9b.png.

Começaremos a trabalhar em vários cenários de animação no próximo capítulo. Cada snippet de código em que trabalhamos neste codelab é marcado com um comentário // TODO. Um bom truque é abrir a janela de ferramentas TODO no Android Studio e navegar para cada um dos comentários TODO para o capítulo.

c4a2180b956cad9f.png

3. Animar uma mudança simples de valor

Vamos começar com a API Animation mais simples no Compose.

Execute a configuração start e tente alternar entre as guias clicando nos botões "Home" e "Work" na parte superior. Ela não altera realmente o conteúdo da guia, mas é possível ver que a cor do plano de fundo do conteúdo muda.

Clique em TODO 1 na janela de ferramentas "TODO" e veja como isso é implementado. Ele está no elemento Home.

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

Aqui, tabPage é um Int apoiado por um objeto State. Dependendo do valor, a cor de fundo é alternada entre roxo e verde. Queremos animar essa mudança de valor.

Para animar uma mudança simples de valor como essa, podemos usar as APIs animate*AsState. Para criar um valor de animação, una o valor variável com a variante correspondente dos elementos animate*AsState que podem ser compostos, neste caso, animateColorAsState. O valor retornado é um objeto State<T>. Portanto, podemos usar uma propriedade delegada local com uma declaração by para tratá-la como uma variável normal.

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

Execute o app novamente e tente alternar entre as guias. A mudança de cor está animada.

6946feb47acc2cc6.gif

4. Animar a visibilidade

Ao rolar o conteúdo do app, você verá que o botão de ação flutuante se expande e diminui de acordo com a direção de rolagem.

Encontre o TODO 2-1 e veja como isso funciona. Ele está no elemento HomeFloatingActionButton. O texto"COPIE"é exibido ou oculto usando uma instrução if.

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

Animar essa mudança de visibilidade é tão simples quanto substituir o if por um elemento que pode ser composto AnimatedVisibility.

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

Execute o app e veja como o FAB se expande e diminui agora.

37a613b87156bfbe.gif

AnimatedVisibility executa a animação sempre que o valor Boolean especificado é alterado. Por padrão, a AnimatedVisibility mostra o elemento aparecendo e ocultando-o e desaparecendo e diminuindo. Esse comportamento funciona muito bem neste exemplo com o FAB, mas também podemos personalizá-lo.

Tente clicar no FAB. Você verá a mensagem "O recurso de edição não é compatível". Ele também usa AnimatedVisibility para animar a aparência e o desaparecimento dele. Vamos ver como é possível personalizar essa animação para que o elemento deslize de cima para baixo e deslize para cima.

11d77a9c6af0309c.png

Localize TODO 2-2 e confira o código no EditMessage que pode ser composto.

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 a animação, adicione os parâmetros enter e exit à AnimatedVisibility que pode ser composta.

O parâmetro enter precisa ser uma instância de EnterTransition. Neste exemplo, podemos usar a função slideInVertically para criar um EnterTransition. Essa função permite mais personalização pelos parâmetros initialOffsetY e animationSpec. O initialOffsetY precisa ser um lambda que retorna a posição inicial. A lambda recebe um argumento, a altura do elemento, para que possamos simplesmente retornar o valor negativo. Ao usar slideInVertically, o deslocamento desejado do após o slide sempre será 0 (pixel). initialOffsetY pode ser especificado como um valor absoluto ou uma porcentagem da altura total do elemento por uma função lambda.

animationSpec é um parâmetro comum para várias APIs de animação, incluindo EnterTransition e ExitTransition. É possível transmitir um dos vários tipos de AnimationSpec para especificar como o valor da animação será modificado ao longo do tempo. Neste exemplo, vamos usar um AnimationSpec simples baseado em duração. Ela pode ser criada com a função tween. A duração é de 150 ms e o easing é LinearOutSlowInEasing.

Da mesma forma, podemos usar a função slideOutVertically para o parâmetro exit. slideOutVertically considera que o deslocamento inicial é 0, então apenas targetOffsetY precisa ser especificado. Vamos usar a mesma função tween para o parâmetro animationSpec, mas com duração de 250 ms e easing de FastOutLinearInEasing.

O código resultante será semelhante a este:

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

Execute o app e clique no FAB novamente. Você verá que a mensagem agora aparece na parte superior.

76895615b43b9263.gif

5. Animar a mudança de tamanho do conteúdo

O app mostra vários tópicos no conteúdo. Clique em uma delas para abrir o texto do corpo do tópico. O card com o texto se expande e diminui quando o corpo é exibido ou oculto.

Confira o código do TODO 3 no TopicRow que pode ser composto.

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

O Column que pode ser composto muda o tamanho dele à medida que o conteúdo é modificado. Podemos animar a mudança de tamanho adicionando o modificador animateContentSize.

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

Execute o app e clique em um dos tópicos. A animação se expande e diminui de tamanho.

c0ad7381779fcb09.gif

6. Animação com vários valores

Agora que estamos familiarizados com algumas APIs básicas de animação, vamos ver a API Transition que nos permite criar animações mais complexas. Neste exemplo, o indicador de guia foi personalizado. É um retângulo mostrado na guia selecionada no momento.

Localize TODO 4 no HomeTabIndicator que pode ser composto e veja como o indicador de guia é implementado.

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

Aqui, indicatorLeft é a posição horizontal da borda esquerda do indicador na linha da guia. indicatorRight é a posição horizontal da borda direita do indicador. A cor também é alterada entre roxo e verde.

Para animar esses vários valores simultaneamente, podemos usar um Transition. Um Transition pode ser criado com a função updateTransition. Transmita o índice da guia selecionada atualmente como o parâmetro targetState.

Cada valor de animação pode ser declarado com as funções de extensão animate* de Transition. Neste exemplo, usamos animateDp e animateColor. Eles usam um bloco lambda e podemos especificar o valor desejado para cada um dos estados. Já sabemos quais são os valores de segmentação, então podemos simplesmente unir os valores abaixo. É possível usar uma declaração by e torná-la uma propriedade delegada local novamente aqui, porque as funções animate* retornam um 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
}

Execute o app agora e veja que a alternância de guias ficou muito mais interessante. À medida que clicar na guia muda o valor do estado tabPage, todos os valores de animação associados a transition começam a animar ao valor especificado para o estado de destino.

3262270d174e77bf.gif

Além disso, é possível especificar o parâmetro transitionSpec para personalizar o comportamento da animação. Por exemplo, podemos conseguir um efeito elástico para o indicador fazendo com que a borda mais próxima do destino se mova mais rapidamente do que a outra. Podemos usar a função de infixo isTransitioningTo em lambdas transitionSpec para determinar a direção da mudança 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
}

Execute o app novamente e tente alternar entre as guias.

2ad4adbefce04ae2.gif-pt

O Android Studio é compatível com a inspeção de transições na visualização do Compose. Para usar a Visualização de animação, clique no ícone "Iniciar modo interativo" no canto superior direito de um elemento que pode ser composto na visualização. Você deve ativar esse recurso nas configurações experimentais conforme indicado aqui se não for possível encontrar o ícone. Tente clicar no ícone do PreviewHomeTabBar que pode ser composto. Depois, clique no ícone "Iniciar inspeção de animação" no canto superior direito do modo interativo. Isso abre um novo painel "Animações"

Clique no botão de ícone "Reproduzir" para exibi-la. Também é possível arrastar a barra de busca para ver cada um dos frames de animação. Para uma descrição melhor dos valores de animação, especifique o parâmetro label nos métodos updateTransition e animate*.

2d3c5020ae28120b.png

7. Animação repetida

Tente clicar no botão do ícone de atualização ao lado da temperatura atual. O app começa a carregar as informações meteorológicas mais recentes (em que finge). Até que o carregamento seja concluído, você verá um indicador de carregamento, que é um círculo cinza e uma barra. Vamos animar o valor alfa desse indicador para deixar mais claro que o processo está em andamento.

c2912ddc2d73bdfc.png

Localize TODO 5 no LoadingRow que pode ser composto.

val alpha = 1f

Gostaríamos de tornar esse valor animado entre 0f e 1f repetidamente. Podemos usar InfiniteTransition para essa finalidade. Essa API é semelhante à API Transition da seção anterior. Ambos animam diversos valores, mas enquanto Transition anima os valores com base nas mudanças de estado, InfiniteTransition anima os valores indefinidamente.

Para criar um InfiniteTransition, use a função rememberInfiniteTransition. Em seguida, cada mudança de valor de animação pode ser declarada com uma das funções de extensão animate* da InfiniteTransition. Nesse caso, estamos animando um valor alfa. Portanto, vamos usar animatedFloat. O parâmetro initialValue precisa ser 0f e o targetValue 1f. Também podemos especificar um AnimationSpec para essa animação, mas essa API só usa um InfiniteRepeatableSpec. Use a função infiniteRepeatable para criar uma. Esse AnimationSpec encapsula qualquer AnimationSpec baseado em duração e o torna repetível. Por exemplo, o código resultante será semelhante a este:

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

Execute o app e tente clicar no botão "Atualizar". Agora você pode ver a animação do indicador de carregamento.

ca4d1d5bfe87b2a9.gif

8. Animação por gestos

Nesta seção final, você vai aprender como executar animações com base nas entradas de toque. Nessa situação, há vários fatores a serem considerados. Primeiro, qualquer animação em andamento pode ser interceptada por um evento de toque. Em segundo lugar, o valor da animação pode não ser a única fonte da verdade. Em outras palavras, pode ser necessário sincronizar o valor da animação com valores provenientes de eventos de toque.

Localize TODO 6-1 no modificador swipeToDismiss. Aqui, estamos tentando criar um modificador que torna o elemento deslizante com toque. Quando o elemento é exibido na borda da tela, chamamos o callback onDismissed para que o elemento possa ser removido.

Animatable é a API de nível mais baixo que vimos até agora. Ela tem vários recursos úteis em cenários de gestos. Por isso, vamos criar uma instância de Animatable e usá-la para representar o deslocamento horizontal do elemento deslizante.

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

TODO 6-2 é onde acabamos de receber um evento de toque. É necessário interceptar a animação se ela estiver em execução. Para isso, chame stop no Animatable. A chamada será ignorada se a animação não estiver em execução.

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

No TODO 6-3, estamos recebendo eventos de arrastar continuamente. Temos que sincronizar a posição do evento de toque com o valor da animação. Podemos usar snapTo no Animatable para isso.

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

O TODO 6-4 é onde o elemento acabou de ser lançado e lançado. Precisamos calcular a posição final em que a navegação rápida está se encaixando para decidir se precisamos mover o elemento de volta para a posição original ou se deve removê-lo e invocar o callback.

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

Em TODO 6-5, estamos prestes a iniciar a animação. Mas antes disso, queremos definir limites superiores e inferiores para o Animatable para que ele pare assim que os atingir. O modificador pointerInput permite acessar o tamanho do elemento pela propriedade size. Portanto, vamos usá-lo para definir os limites.

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

TODO 6-6 é onde podemos finalmente iniciar a animação. Primeiro, comparamos a posição de liquidação da rolagem que calculamos anteriormente e o tamanho do elemento. Se a posição de liquidação estiver abaixo do tamanho, significa que a velocidade da rolagem não foi suficiente. Podemos usar animateTo para animar o valor de volta para 0f. Caso contrário, usamos animateDecay para iniciar a animação com rolagem. Quando a animação é concluída (provavelmente pelos limites definidos anteriormente), podemos chamar o callback.

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 fim, consulte TODO 6-7. Temos todas as animações e gestos configurados, então não se esqueça de aplicar o deslocamento ao elemento.

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

Como resultado desta seção, você terá um código como este:

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

Execute o app e tente deslizar uma das tarefas. É possível ver que o elemento desliza de volta para a posição padrão ou desaparece e é removido, dependendo da velocidade da rolagem. Também é possível capturar o elemento durante a animação.

7cdefce823f6b9bd.png

9. Parabéns!

Parabéns! Você aprendeu as APIs básicas de animação do Compose.

Aprendemos como criar vários padrões de animação comuns usando APIs de animação de alto nível, como animateContentSize e AnimatedVisibility. Também vimos que podemos usar animate*AsState para animar um único valor, updateTransition para animar vários valores e infiniteTransition para animar valores indefinidamente. Também usamos o Animatable para criar uma animação personalizada em combinação com gestos de toque.

Qual é a próxima etapa?

Confira os outros codelabs no módulo do Compose.

Para saber mais, consulte Compose Animations e estes documentos de referência: