Flutter: Sistema de Animação

Animação é a alma da UI — uma interface sem animação é como uma apresentação de slides, com animação é como um filme.

📋 Pré-requisitos: Você já deve estar familiarizado com

1. O Que Você Vai Aprender


2. Uma História Real de uma Interface Rígida

(1) O Problema: Mudanças de Estado Parecem Slides

A página de detalhes do produto do ShopApp do Bob salta instantaneamente quando "Adicionar ao Carrinho" é tocado — a quantidade vai de 0 para 1, o preço salta de $0 para $99.99, e o badge do carrinho mostra "3" de repente. Os usuários acham que o app "congelou" ou "tem um bug" — quando na verdade, faltam apenas animações de transição. 35% dos usuários da versão sem animações relataram "lentidão na UI".

(2) A Solução com Animação Implícita

Os widgets de animação implícita do Flutter só precisam de um valor alvo — o framework gera automaticamente a animação de transição.

DART
import 'package:flutter/material.dart';

// Before: instant change (looks like a bug)
Text('\$$_total')

// After: smooth animation
AnimatedSwitcher(
  duration: const Duration(milliseconds: 300),
  child: Text('\$$_total', key: ValueKey(_total)),
)
TEXT
> Saída: Execute localmente com o Flutter SDK (Flutter 3.x / Dart 3.x). O servidor Piston não possui o Flutter instalado; use `flutter run` na sua máquina local para comparação. A UI/estado real pode variar ligeiramente entre plataformas.

(3) Benefício: Suavidade Percebida Melhorada

Após Bob adicionar animações, relatos de "lentidão na UI" caíram de 35% para 2%, e a duração das sessões dos usuários aumentou 20%.


3. Visão Geral do Sistema de Animação

100%
graph TD
    A[Animation System] --> IA[Implicit Animation]
    A --> EA[Explicit Animation]
    IA --> AC[AnimatedContainer]
    IA --> AO[AnimatedOpacity]
    IA --> AS2[AnimatedSwitcher]
    EA --> ACTRL[AnimationController]
    ACTRL --> TW[Tween]
    ACTRL --> CURVE[CurvedAnimation]
    EA --> AB[AnimatedBuilder]
    EA --> HERO[Hero Transition]
TEXT
> Saída: Execute localmente com o Flutter SDK (Flutter 3.x / Dart 3.x). O servidor Piston não possui o Flutter instalado; use `flutter run` na sua máquina local para comparação. A UI/estado real pode variar ligeiramente entre plataformas.

(1) Animações Implícitas vs Explícitas

Dimensão Animação Implícita Animação Explícita
Controle Automático (defina valor alvo) Manual (controlado por Controller)
Complexidade Baixa Alta
Flexibilidade Limitada Controle total
Loop/Reverso Não suportado Suportado
Caso de Uso Transições simples Animações personalizadas complexas

4. Animações Implícitas

▶ Exemplo

TEXT
> Saída: Execute localmente com o Flutter SDK (Flutter 3.x / Dart 3.x). O servidor Piston não possui o Flutter instalado; use `flutter run` na sua máquina local para comparação. A UI/estado real pode variar ligeiramente entre plataformas.

: AnimatedContainer expandir/recolher

DART
import 'package:flutter/material.dart';

class ExpandableCard extends StatefulWidget {
  const ExpandableCard({super.key});

  @override
  State<ExpandableCard> createState() => _ExpandableCardState();
}

class _ExpandableCardState extends State<ExpandableCard> {
  bool _expanded = false;

  @override
  Widget build(BuildContext context) {
    return GestureDetector(
      onTap: () => setState(() => _expanded = !_expanded),
      child: AnimatedContainer(
        duration: const Duration(milliseconds: 300),
        curve: Curves.easeInOutCubic,
        height: _expanded ? 200 : 80,
        padding: EdgeInsets.all(_expanded ? 16 : 8),
        decoration: BoxDecoration(
          color: _expanded ? Colors.blue[50] : Colors.white,
          borderRadius: BorderRadius.circular(_expanded ? 16 : 8),
          boxShadow: [BoxShadow(blurRadius: _expanded ? 12 : 4, color: Colors.black12)],
        ),
        child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
          Text('Pro Laptop', style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),
          if (_expanded) ...[
            const SizedBox(height: 12),
            const Text('Latest model with M3 chip. 16GB RAM, 512GB SSD.'),
            const SizedBox(height: 8),
            const Text('\$1,299.99', style: TextStyle(fontSize: 20, color: Colors.green)),
          ],
        ]),
      ),
    );
  }
}
TEXT
> Saída: Execute localmente com o Flutter SDK (Flutter 3.x / Dart 3.x). O servidor Piston não possui o Flutter instalado; use `flutter run` na sua máquina local para comparação. A UI/estado real pode variar ligeiramente entre plataformas.

▶ Exemplo

TEXT
> Saída: Execute localmente com o Flutter SDK (Flutter 3.x / Dart 3.x). O servidor Piston não possui o Flutter instalado; use `flutter run` na sua máquina local para comparação. A UI/estado real pode variar ligeiramente entre plataformas.

: AnimatedSwitcher troca de números

DART
import 'package:flutter/material.dart';

class AnimatedPrice extends StatelessWidget {
  final double price;
  const AnimatedPrice({super.key, required this.price});

  @override
  Widget build(BuildContext context) {
    return AnimatedSwitcher(
      duration: const Duration(milliseconds: 300),
      transitionBuilder: (child, animation) => FadeTransition(opacity: animation, child: child),
      child: Text('\$${price.toStringAsFixed(2)}',
        key: ValueKey(price),
        style: const TextStyle(fontSize: 24, fontWeight: FontWeight.bold, color: Colors.green),
      ),
    );
  }
}
TEXT
> Saída: Execute localmente com o Flutter SDK (Flutter 3.x / Dart 3.x). O servidor Piston não possui o Flutter instalado; use `flutter run` na sua máquina local para comparação. A UI/estado real pode variar ligeiramente entre plataformas.

▶ Exemplo

TEXT
> Saída: Execute localmente com o Flutter SDK (Flutter 3.x / Dart 3.x). O servidor Piston não possui o Flutter instalado; use `flutter run` na sua máquina local para comparação. A UI/estado real pode variar ligeiramente entre plataformas.

: AnimatedCrossFade troca de visualização

DART
import 'package:flutter/material.dart';

// Assume isFavorite is defined elsewhere
// bool isFavorite = false;

AnimatedCrossFade(
  firstChild: const Icon(Icons.favorite_border, size: 32),
  secondChild: const Icon(Icons.favorite, size: 32, color: Colors.red),
  crossFadeState: isFavorite ? CrossFadeState.showSecond : CrossFadeState.showFirst,
  duration: const Duration(milliseconds: 300),
)
TEXT
> Saída: Execute localmente com o Flutter SDK (Flutter 3.x / Dart 3.x). O servidor Piston não possui o Flutter instalado; use `flutter run` na sua máquina local para comparação. A UI/estado real pode variar ligeiramente entre plataformas.

5. Animações Explícitas

(1) AnimationController + Tween + Curve

Componente Responsabilidade
AnimationController Controla tempo, direção e estado da animação
Tween<T> Define valores de início e fim
CurvedAnimation Aplica curvas de easing

▶ Exemplo

TEXT
> Saída: Execute localmente com o Flutter SDK (Flutter 3.x / Dart 3.x). O servidor Piston não possui o Flutter instalado; use `flutter run` na sua máquina local para comparação. A UI/estado real pode variar ligeiramente entre plataformas.

: Animação de voar-para-o-carrinho

DART
import 'package:flutter/material.dart';

class FlyToCartAnimation extends StatefulWidget {
  final GlobalKey cartKey;
  final Widget child;
  final VoidCallback onComplete;
  const FlyToCartAnimation({super.key, required this.cartKey, required this.child, required this.onComplete});

  @override
  State<FlyToCartAnimation> createState() => _FlyToCartAnimationState();
}

class _FlyToCartAnimationState extends State<FlyToCartAnimation> with TickerProviderStateMixin {
  late AnimationController _controller;
  late Animation<double> _animation;

  @override
  void initState() {
    super.initState();
    _controller = AnimationController(duration: const Duration(milliseconds: 600), vsync: this);
    _animation = CurvedAnimation(parent: _controller, curve: Curves.easeInOutCubic);
    _controller.addStatusListener((status) {
      if (status == AnimationStatus.completed) widget.onComplete();
    });
  }

  void startFly() {
    _controller.forward(from: 0);
  }

  @override
  void dispose() { _controller.dispose(); super.dispose(); }

  @override
  Widget build(BuildContext context) {
    return AnimatedBuilder(animation: _animation, builder: (context, child) {
      final cartBox = widget.cartKey.currentContext?.findRenderObject() as RenderBox?;
      if (cartBox == null) return child!;
      final cartPos = cartBox.localToGlobal(Offset.zero);
      return Transform.translate(
        offset: Offset(cartPos.dx * _animation.value, cartPos.dy * _animation.value),
        child: Transform.scale(scale: 1 - _animation.value * 0.5, child: child),
      );
    }, child: widget.child);
  }
}
TEXT
> Saída: Execute localmente com o Flutter SDK (Flutter 3.x / Dart 3.x). O servidor Piston não possui o Flutter instalado; use `flutter run` na sua máquina local para comparação. A UI/estado real pode variar ligeiramente entre plataformas.

6. Animações de Transição Hero

▶ Exemplo

TEXT
> Saída: Execute localmente com o Flutter SDK (Flutter 3.x / Dart 3.x). O servidor Piston não possui o Flutter instalado; use `flutter run` na sua máquina local para comparação. A UI/estado real pode variar ligeiramente entre plataformas.

: Transição de elemento compartilhado da imagem do produto

DART
import 'package:flutter/material.dart';

// Custom class definition source:
// - product: see Lesson 11 json_serializable model

// In product list
Hero(
  tag: 'product_${product.id}',
  child: ClipRRect(
    borderRadius: BorderRadius.circular(12),
    child: Image.network(product.imageUrl, fit: BoxFit.cover),
  ),
)

// In product detail page
Hero(
  tag: 'product_${product.id}',
  child: Image.network(product.imageUrl, fit: BoxFit.cover),
)
TEXT
> Saída: Execute localmente com o Flutter SDK (Flutter 3.x / Dart 3.x). O servidor Piston não possui o Flutter instalado; use `flutter run` na sua máquina local para comparação. A UI/estado real pode variar ligeiramente entre plataformas.

▶ Exemplo

TEXT
> Saída: Execute localmente com o Flutter SDK (Flutter 3.x / Dart 3.x). O servidor Piston não possui o Flutter instalado; use `flutter run` na sua máquina local para comparação. A UI/estado real pode variar ligeiramente entre plataformas.

: Transição personalizada com PageRouteBuilder

DART
import 'package:flutter/material.dart';

// Custom class definition source:
// - DetailPage/product: see Lesson 11 / Lesson 14

class SlideUpRoute extends PageRouteBuilder {
  final Widget page;
  SlideUpRoute(this.page) : super(
    pageBuilder: (context, animation, secondaryAnimation) => page,
    transitionsBuilder: (context, animation, secondaryAnimation, child) {
      return SlideTransition(
        position: Tween<Offset>(begin: const Offset(0, 1), end: Offset.zero)
            .animate(CurvedAnimation(parent: animation, curve: Curves.easeOutCubic)),
        child: child,
      );
    },
    transitionDuration: const Duration(milliseconds: 400),
  );
}

// Usage
Navigator.push(context, SlideUpRoute(DetailPage(product: product)));
TEXT
> Saída: Execute localmente com o Flutter SDK (Flutter 3.x / Dart 3.x). O servidor Piston não possui o Flutter instalado; use `flutter run` na sua máquina local para comparação. A UI/estado real pode variar ligeiramente entre plataformas.

7. Curvas de Easing

Curva Efeito Caso de Uso
Curves.linear Velocidade constante Raramente usada
Curves.easeIn Lento → Rápido Saída
Curves.easeOut Rápido → Lento Entrada
Curves.easeInOut Lento → Rápido → Lento Uso geral
Curves.easeOutCubic Chegada rápida Efeito elástico
Curves.elasticOut Bounce elástico Animações lúdicas
Curves.bounceOut Bounce Sensação de jogo

8. Exemplo Completo: Suíte de Animação do Card de Produto do ShopApp

DART
import 'package:flutter/material.dart';

// Custom class definition source:
// - Product: see Lesson 11 json_serializable model

class AnimatedProductCard extends StatefulWidget {
  final Product product;
  final VoidCallback onAddToCart;
  const AnimatedProductCard({super.key, required this.product, required this.onAddToCart});

  @override
  State<AnimatedProductCard> createState() => _AnimatedProductCardState();
}

class _AnimatedProductCardState extends State<AnimatedProductCard> with SingleTickerProviderStateMixin {
  bool _isFavorite = false;
  bool _isAdded = false;
  late AnimationController _scaleController;
  late Animation<double> _scaleAnimation;

  @override
  void initState() {
    super.initState();
    _scaleController = AnimationController(duration: const Duration(milliseconds: 200), vsync: this);
    _scaleAnimation = Tween<double>(begin: 1.0, end: 0.95).animate(
      CurvedAnimation(parent: _scaleController, curve: Curves.easeInOut));
  }

  void _onTap() {
    _scaleController.forward().then((_) => _scaleController.reverse());
  }

  void _addToCart() {
    setState(() => _isAdded = true);
    widget.onAddToCart();
    Future.delayed(const Duration(seconds: 2), () => setState(() => _isAdded = false));
  }

  @override
  void dispose() { _scaleController.dispose(); super.dispose(); }

  @override
  Widget build(BuildContext context) {
    return GestureDetector(
      onTapDown: (_) => _scaleController.forward(),
      onTapUp: (_) => _scaleController.reverse(),
      onTapCancel: () => _scaleController.reverse(),
      child: ScaleTransition(
        scale: _scaleAnimation,
        child: Card(
          clipBehavior: Clip.antiAlias,
          child: Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: [
            Expanded(flex: 3, child: Stack(children: [
              Hero(tag: 'product_${widget.product.id}',
                child: Image.network(widget.product.imageUrl, fit: BoxFit.cover, width: double.infinity)),
              Positioned(top: 8, right: 8,
                child: AnimatedSwitcher(duration: const Duration(milliseconds: 300),
                  child: IconButton(key: ValueKey(_isFavorite),
                    icon: Icon(_isFavorite ? Icons.favorite : Icons.favorite_border,
                      color: _isFavorite ? Colors.red : Colors.white),
                    onPressed: () => setState(() => _isFavorite = !_isFavorite),
                    style: IconButton.styleFactory(backgroundColor: Colors.white70)))),
            ]),
            Expanded(flex: 2, child: Padding(padding: const EdgeInsets.all(8), child: Column(
              crossAxisAlignment: CrossAxisAlignment.start, children: [
                Text(widget.product.name, maxLines: 1, overflow: TextOverflow.ellipsis),
                const Spacer(),
                Row(mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [
                  Text('\$${widget.product.price.toStringAsFixed(2)}', style: const TextStyle(color: Colors.green)),
                  AnimatedSwitcher(duration: const Duration(milliseconds: 300),
                    child: _isAdded ? const Icon(Icons.check_circle, color: Colors.green, key: ValueKey('added'))
                        : IconButton(icon: const Icon(Icons.add_shopping_cart, size: 20),
                            onPressed: _addToCart, key: const ValueKey('add'))),
                ]),
              ])))),
          ]),
        ),
      ),
    );
  }
}
TEXT
> Saída: Execute localmente com o Flutter SDK (Flutter 3.x / Dart 3.x). O servidor Piston não possède o Flutter instalado; use `flutter run` na sua máquina local para comparação. A UI/estado real pode variar ligeiramente entre plataformas.

❓ Perguntas Frequentes

P: Como escolher entre animações implícitas e explícitas? R: Use implícita quando possível (mais simples e segura); use explícita quando precisar de loop, reversão ou controle preciso.

P: O AnimationController deve ser descartado no dispose()? R: Sim. Não descartar causa vazamentos de memória. Use SingleTickerProviderStateMixin para fornecer vsync.

P: Qual a diferença entre AnimatedBuilder e AnimatedWidget? R: AnimatedBuilder usa padrão de callback, enquanto AnimatedWidget requer subclassificação. AnimatedBuilder é mais flexível e recomendado.

P: A tag do Hero deve ser a mesma nas duas páginas? R: Sim. O Hero usa a tag para corresponder widgets entre páginas — a tag deve ser globalmente única e consistente.

P: Como otimizar engasgos de animação? R: 1) Use AnimatedBuilder para limitar o escopo de rebuild; 2) Use RepaintBoundary para isolar repinturas; 3) Evite operações pesadas em callbacks de animação.

P: Como orquestrar múltiplas animações? R: Use Interval para escalonar múltiplas animações, ou use addStatusListener do AnimationController para encadeá-las manualmente.


📖 Resumo


📝 Exercícios

  1. Básico (dificuldade ⭐): Use AnimatedContainer para criar um card expansível/recolhível que alterna altura e cor de fundo ao tocar.
  2. Intermediário (dificuldade ⭐⭐): Implemente uma animação de transição Hero da lista de produtos para a página de detalhes, com a imagem voando da posição do card para a imagem hero da página de detalhes.
  3. Desafio (dificuldade ⭐⭐⭐): Implemente uma animação completa de "voar para o carrinho": após tocar em adicionar ao carrinho, a miniatura do produto voa do card para o ícone do carrinho no AppBar, diminuindo e sumindo durante o voo.

← Aula Anterior | Próxima Aula →

Web-Tutorial.com

Equipe Técnica Web-Tutorial

Uma plataforma de tutoriais mantida por diversos desenvolvedores. Cada tutorial é escrito e revisado por profissionais da área correspondente. Trabalhamos para manter nosso conteúdo preciso e confiável — se encontrar algum problema, avise-nos.

100%