Exemplos de Código: Capítulo 19 — Animações Fluidas no Jetpack Compose

Especialização em Mobile Nativo com Android • Demonstrações Práticas Compiláveis


💻 Código de Demonstração: AnimatedVisibility, animateContentSize, Transições de Valor e Física de Molas

O código abaixo demonstra as três camadas principais do ecossistema de animações declarativas do Jetpack Compose:

  1. Animações de Presença (AnimatedVisibility): Entrada e saída com interpolações combinadas de escala, transparência e deslocamento vertical.
  2. Animações de Dimensão Automática (animateContentSize): Redimensionamento fluído de contêineres quando o conteúdo interno se expande.
  3. Animações de Propriedade (animateFloatAsState, animateColorAsState): Rotação suave de chevron indicativo e alternância de cores de destaque.
  4. Física de Molas (spring): Utilização de dampingRatio e stiffness para sensações táteis realistas sem interpolação linear artificial.
package br.com.portal.mobile.android.cap19

import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.animateContentSize
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.spring
import androidx.compose.animation.core.tween
import androidx.compose.animation.expandVertically
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.AccountBalanceWallet
import androidx.compose.material.icons.filled.KeyboardArrowDown
import androidx.compose.material.icons.filled.Visibility
import androidx.compose.material.icons.filled.VisibilityOff
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.rotate
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp

// =====================================================================
// 1. Tela Demonstrativa de Animações Declarativas
// =====================================================================

@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AnimacoesComposeScreen() {
    var cardExpandido by remember { mutableStateOf(false) }
    var saldoVisivel by remember { mutableStateOf(true) }
    var temaAtivoDestaque by remember { mutableStateOf(false) }

    Scaffold(
        topBar = {
            TopAppBar(
                title = { Text("Animações Fluidas Compose") },
                colors = TopAppBarDefaults.topAppBarColors(
                    containerColor = MaterialTheme.colorScheme.primaryContainer,
                    titleContentColor = MaterialTheme.colorScheme.onPrimaryContainer
                )
            )
        }
    ) { paddingVal ->
        Column(
            modifier = Modifier
                .fillMaxSize()
                .padding(paddingVal)
                .padding(16.dp),
            verticalArrangement = Arrangement.spacedBy(20.dp)
        ) {
            // Demonstração 1: Transição de Cor e Ocultação com AnimatedVisibility
            CardSaldoAnimado(
                saldoVisivel = saldoVisivel,
                emDestaque = temaAtivoDestaque,
                onAlternarVisibilidade = { saldoVisivel = !saldoVisivel },
                onAlternarDestaque = { temaAtivoDestaque = !temaAtivoDestaque }
            )

            // Demonstração 2: Card Expansível com animateContentSize e Rotação de Ícone
            CardExpansivelDetalhes(
                expandido = cardExpandido,
                onCliqueExpansao = { cardExpandido = !cardExpandido }
            )
        }
    }
}

// =====================================================================
// 2. Componente: Card de Saldo com AnimatedVisibility e animateColorAsState
// =====================================================================

@Composable
fun CardSaldoAnimado(
    saldoVisivel: Boolean,
    emDestaque: Boolean,
    onAlternarVisibilidade: () -> Unit,
    onAlternarDestaque: () -> Unit
) {
    // Interpolação de cor animada via especificação de mola (Spring)
    val corFundoCard by animateColorAsState(
        targetValue = if (emDestaque) Color(0xFF1B5E20) else MaterialTheme.colorScheme.surfaceVariant,
        animationSpec = spring(stiffness = Spring.StiffnessLow),
        label = "CorFundoAnimada"
    )

    val corTexto by animateColorAsState(
        targetValue = if (emDestaque) Color.White else MaterialTheme.colorScheme.onSurfaceVariant,
        label = "CorTextoAnimada"
    )

    Card(
        modifier = Modifier.fillMaxWidth(),
        colors = CardDefaults.cardColors(containerColor = corFundoCard),
        shape = RoundedCornerShape(16.dp)
    ) {
        Column(modifier = Modifier.padding(20.dp)) {
            Row(
                modifier = Modifier.fillMaxWidth(),
                horizontalArrangement = Arrangement.SpaceBetween,
                verticalAlignment = Alignment.CenterVertically
            ) {
                Row(verticalAlignment = Alignment.CenterVertically) {
                    Icon(
                        imageVector = Icons.Default.AccountBalanceWallet,
                        contentDescription = null,
                        tint = corTexto,
                        modifier = Modifier.size(24.dp)
                    )
                    Spacer(modifier = Modifier.size(8.dp))
                    Text(
                        text = "Patrimônio Líquido",
                        style = MaterialTheme.typography.titleMedium,
                        color = corTexto
                    )
                }

                IconButton(onClick = onAlternarVisibilidade) {
                    Icon(
                        imageVector = if (saldoVisivel) Icons.Default.Visibility else Icons.Default.VisibilityOff,
                        contentDescription = "Alternar Visibilidade",
                        tint = corTexto
                    )
                }
            }

            Spacer(modifier = Modifier.height(12.dp))

            // Entrada e saída fluida de valores numéricos confidenciais
            AnimatedVisibility(
                visible = saldoVisivel,
                enter = fadeIn(animationSpec = tween(300)) + expandVertically(animationSpec = spring(stiffness = Spring.StiffnessMediumLow)),
                exit = fadeOut(animationSpec = tween(200)) + shrinkVertically()
            ) {
                Text(
                    text = "R$ 48.750,92",
                    fontSize = 28.sp,
                    fontWeight = FontWeight.ExtraBold,
                    color = corTexto
                )
            }

            if (!saldoVisivel) {
                Text(
                    text = "•••••••••••",
                    fontSize = 24.sp,
                    fontWeight = FontWeight.Bold,
                    color = corTexto.copy(alpha = 0.6f)
                )
            }

            Spacer(modifier = Modifier.height(16.dp))

            Button(
                onClick = onAlternarDestaque,
                modifier = Modifier.fillMaxWidth()
            ) {
                Text(if (emDestaque) "Restaurar Tema Padrão" else "Destacar Meta Atingida (Verde)")
            }
        }
    }
}

// =====================================================================
// 3. Componente: Card Expansível com Rotação Suave e animateContentSize
// =====================================================================

@Composable
fun CardExpansivelDetalhes(
    expandido: Boolean,
    onCliqueExpansao: () -> Unit
) {
    // Animação de rotação angular entre 0° (fechado) e 180° (aberto)
    val rotacaoIcone by animateFloatAsState(
        targetValue = if (expandido) 180f else 0f,
        animationSpec = spring(
            dampingRatio = Spring.DampingRatioMediumBouncy,
            stiffness = Spring.StiffnessLow
        ),
        label = "RotacaoChevron"
    )

    Card(
        modifier = Modifier
            .fillMaxWidth()
            .clip(RoundedCornerShape(14.dp))
            .clickable { onCliqueExpansao() }
            .animateContentSize( // Ajusta automaticamente a altura com base no conteúdo adicionado
                animationSpec = spring(
                    dampingRatio = Spring.DampingRatioLowBouncy,
                    stiffness = Spring.StiffnessLow
                )
            ),
        colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface)
    ) {
        Column(modifier = Modifier.padding(18.dp)) {
            Row(
                modifier = Modifier.fillMaxWidth(),
                horizontalArrangement = Arrangement.SpaceBetween,
                verticalAlignment = Alignment.CenterVertically
            ) {
                Text(
                    text = "Detalhamento de Rentabilidade",
                    style = MaterialTheme.typography.titleMedium,
                    fontWeight = FontWeight.SemiBold
                )

                Icon(
                    imageVector = Icons.Default.KeyboardArrowDown,
                    contentDescription = "Expandir detalhes",
                    modifier = Modifier.rotate(rotacaoIcone)
                )
            }

            // Conteúdo condicional revelado de forma natural
            if (expandido) {
                Spacer(modifier = Modifier.height(14.dp))
                Text(
                    text = "• CDI Over (100%): R$ 1.230,40 (+1,05%/mês)",
                    style = MaterialTheme.typography.bodyMedium,
                    color = MaterialTheme.colorScheme.onSurfaceVariant
                )
                Text(
                    text = "• Tesouro IPCA+ 2029: R$ 850,15 (+0,78%/mês)",
                    style = MaterialTheme.typography.bodyMedium,
                    color = MaterialTheme.colorScheme.onSurfaceVariant
                )
                Text(
                    text = "• Fundos Imobiliários (FIIs): R$ 420,00 (Dividendos)",
                    style = MaterialTheme.typography.bodyMedium,
                    color = MaterialTheme.colorScheme.onSurfaceVariant
                )
            }
        }
    }
}

📌 Guia de Execução e Boas Práticas de Animação

  1. Evite recomposições desnecessárias: use animate*AsState diretamente em modifiers que aceitam lambdas de desenho (como Modifier.graphicsLayer { rotationZ = rotacao }) quando o valor mudar em 60/120 FPS, minimizando o impacto na pipeline de layout.
  2. Física de Molas vs Interpolações Temporizadas: Prefira spring() para interações provocadas pelo toque humano (gestos de arrastar e clicar) e tween() para transições cronometradas e automáticas.