1. Introdução
Ú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
- Conhecimento básico sobre Kotlin
- Conhecimento básico sobre o Compose, como:
- Layout simples (coluna, linha, caixa etc.)
- Elementos simples da IU (botão, texto etc.)
- Estados e recomposição
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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*
.
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.
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.
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.
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:
- AnimatedVisibility
- animateContentSize (em inglês)
- animateColorAsState
- updateTransition (em inglês)
- rememberInfiniteTransition (link em inglês)
- Animável