De zero a heroi: Hero animations em Flutter

Publicado em 31 ago 2020. Uns 17 minutos de leitura.

Animações são uma excelente maneira de tornar um app mais interativo e engajar os usuários. Flutter tem várias opções para criar animações, com diferentes níveis de complexididade. Uma das (se não a) mais simples são as Hero animations, que, com apenas um widget, permitem animar um componente durante a transição entre duas telas. Vamos ver como fazer uma animação super rápida e explorar possibilidades mais avançadas para criar animações mais complexas com essa ferramenta.

Antes de começar, alguns exemplos de animações que vamos implementar neste post:

Tudo começa com o widget Hero. Ele serve para identificar, nas telas de origem e destino da transição, qual é o widget que vai ser animado. Ele recebe um atributo child que é o widget e um outro tag, que deve ser igual nas duas telas (isso permite ter mais de uma Hero animation ao mesmo tempo).

Hero(
  tag: 'charizard',
  child: Image.network('https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/6.png'),
)

Animando um widget durante uma transição de telas

No exemplo acima, envolvemos um widget Image com um Hero. Agora, só precisamos colocar este Hero em duas telas e criar uma transição entre elas.

class FirstScreen extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text('First screen'),
      ),
      body: Column(
        children: [
          // O Hero na primeira tela
          Hero(
            tag: 'charizard',
            child: Image.network(
                'https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/6.png'),
          ),
          RaisedButton(
            child: Text('Go to next screen'),
            onPressed: () {
              Navigator.of(context).push(
                MaterialPageRoute(builder: (_) => SecondScreen()),
              );
            },
          ),
        ],
      ),
    );
  }
}

class SecondScreen extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text('Second screen'),
      ),
      body: Center(
        // O mesmo Hero na segunda tela
        child: Hero(
          tag: 'charizard',
          child: Image.network(
              'https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/6.png'),
        ),
      ),
    );
  }
}

E é isso! Com apenas um widget em cada tela, temos essa animação na transição. Ela funciona tanto na ida como na volta e já dá um efeito legal.

Em algumas plataformas, como no iOS, é costumeiro o usuário poder voltar para a tela anterior deslizando o dedo do canto da tela. Por padrão, Hero animations não vão animar nesse tipo de transção. Se você quiser que anime, é só adicionar o atributo transitionOnUserGestures nos dois Heros, assim:

Hero(
  tag: 'charizard',
  transitionOnUserGestures: true, // O novo atributo
  child: Image.network('https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/6.png'),
)

Nos vídeos abaixo, temos um exemplo de usuário arrastando a tela para voltar sem e com transitionOnUserGestures, respectivamente.

Como eu comentei antes, é possível ter mais de um Hero na mesma tela. E não necessariamente você precisa ter um correspondente nas duas telas, pois se uma transição encontrar um Hero de um lado só, ela não vai animar o elemento mas nenhum erro acontecerá. Podemos usar isso para fazer uma animação dos elementos de uma lista para uma tela de detalhes.

Para isso, precisamos apenas garantir que a tag vai ser única entre os elementos e que conseguimos replicá-la na segunda tela. Neste exemplo, vamos partir de uma classe Pokemon que tem um atributo number, único entre cada espécie de Pokémon. Para montar a lista, usei um SliverGrid que serve para montar uma lista em formato de Grid dentro de um CustomScrollView. Como Slivers funcionam é assunto pra outra conversa, mas o que é relevante é que ele recebe uma classe com um builder que, para cada item da lista, vai renderizar um widget.

SliverChildBuilderDelegate((context, index) {
  final pokemon = pokedex.pokemonNumbered(index + 1);

  return GestureDetector(
    onTap: () => toPokemonDetail(pokemon),
    child: Hero(
      tag: 'sprite-${pokemon.number}',
      transitionOnUserGestures: true,
      child: Image.network(pokemon.spriteUrl),
    ),
  );
})

Na tag, vamos gerar uma string usando o número do Pokémon. Na tela de detalhe, só precisamos repetir a mesma tag e a Hero animation vai funcionar. Como a tela de detalhe recebe como parâmetro o Pokémon para exibir, ela tem o número dele e pode criar um Hero com a mesma tag. Todos os outros Heros na lista não vão ter um correspondente na tela de detalhe, então eles não serão animados.

No vídeo do exemplo, eu coloquei um segundo Hero para animar o número do Pokémon, também. É a mesma ideia, mas ao invés do child ser uma imagem, é um texto. A tag precisa ser diferente, e por isso eu coloquei o prefixo "sprite" na tag do primeiro exemplo. Para o número, você pode usar 'number-${pokemon.number}'.

Note que o estado inicial e final não precisam ter o mesmo tamanho. O Hero vai interpolar as dimensões automaticamente. Na maioria dos casos, isso é suficiente, mas existem maneiras de modificar o funcionamento das transições. Isso é particulamente interessante quando usamos uma Hero animation em que o estado inicial e final não são o mesmo widget, e eles podem ter filhos com layouts diferentes e que, se apenas interpolarmos as dimensões, podemos ter comportamentos estranhos.

Redefinindo o comportamento de transição

Vamos dar uma olhada num uso mais avançado do Hero. Nos exemplos anteriores, nós apenas definimos o estado final e inicial. Nosso objetivo vai ser implementar uma animação assim:

Como o código-fonte desse exemplo é um pouco maior, vou explicar alguns dos novos conceitos primeiro e depois colocar o código completo do exemplo.

Separando em partes, o que acontece no exemplo é:

  1. Ao clicar no botão, ele diminui e mostra um indicador de carregamento.
  2. Após um tempo, ele fica verde e mostra um ícone de sucesso.
  3. O botão expande e toma a tela inteira, deixando-a verde.
  4. Uma mensagem de sucesso aparece na tela verde.
  5. Ao tocar no botão que apareceu agora, a tela fecha.

Para simular uma transação, a tela é um StatefulWidget que tem um atributo para guardar o seu estado. Ele pode ter o valor waiting (inicial), processing e successful. Não há nenhuma comunicação real acontecendo, e a mudança entre estados acontece usando await em timers, quando o botão é clicado.

RawMaterialButton(
  onPressed: () async {
    if (purchaseState != PurchaseState.waiting) return;

    setState(() {
      purchaseState = PurchaseState.processing;
    });

    await Future.delayed(Duration(seconds: 2));

    setState(() {
      purchaseState = PurchaseState.successful;
    });

    await Future.delayed(Duration(milliseconds: 500));

    await Navigator.of(context)
        .pushReplacement(MaterialPageRoute(
      builder: (_) => SuccessScreen(),
      fullscreenDialog: true,
    ));
  },
  fillColor: _buttonColor,
  shape: _buttonShape,
  child: SizedBox(
    height: 50,
    child: Center(child: _buttonChild),
  ),
)

As propriedades _buttonColor, _buttonShape e _buttonChild são getters adicionados no widget que fazem um switch/case no estado e retornam a cor de fundo, formato e conteúdo do botão, respectivamente.

Os ítens 1 e 2 da lista são presentes do RawMaterialButton, uma classe base para criar botões no Material Design. O botão mudar de um formato de pílula para um círculo acontece pois mudamos o seu atributo Shape. O RawMaterialButton anima essa mudança de formato automaticamente.

O item 3, a expansão do botão até tomar a tela toda, que é o nosso foco. Ele é uma Hero animation. Pode não parecer óbvio pois, ao clicar, não vemos uma nova tela abrindo e um widget voando da tela antiga para a nova. O segredo é que o widget de destino é a tela nova inteira. Na tela de origem, o Hero está em volta do botão. Já na tela de destino, o Hero está em volta da tela toda. Isso faz com que o botão se transforme na tela seguinte inteira.

Se você olhar no código-fonte do exemplo, que está todo na próxima seção, vai notar que o Hero da tela de destino é um pouco diferente dos que fizemos até agora (além do fato de ele englobar uma tela inteira). Ele tem dois atributos que não usamos ainda: flightShuttleBuilder e createRectTween. Precisamos desses dois atributos para deixar o efeito com a cara que ficou.

Começando pelo flightShuttleBuilder, ele serve para definir como renderizar o widget durante a transição. Por padrão, o próprio widget é usado na transição (nos exemplos dos Pokémon, nós vemos a sprite ele se mover de uma tela para a outra). Porém, neste caso, o botão começa bem pequeno e cresce até ocupar a tela toda. Porém, o widget da tela em si possui textos e outros elementos grandes, que dependem do espaço disponível para que seu layout seja calculado. Durante a transição (e expansão), esse espaço vai ser insuficiente e vamos ter problemas de overflow.

Há mais de uma maneira de resolver isso, mas eu parti para uma bem simples: durante a transição, vamos renderizar o widget apenas como um círculo verde expandindo. Ele já vai começar como um círculo verde, no sucesso da compra, e vai expandir com um raio que, garantidamente, vai preencher a tela inteira.

Hero(
  // ...
  flightShuttleBuilder: (flightContext, animation, flightDirection,
      fromHeroContext, toHeroContext) {
    return Container(
      decoration: BoxDecoration(
        color: Colors.green,
        shape: BoxShape.circle,
      ),
    );
  },
)

O flightShuttleBuilder recebe cinco parâmetros, que deixei explícitos no exemplo acima para ilustrar suas existências. Não precisamos deles pois vamos renderizar, em todos os quadros da animação, o mesmo círculo verde. Mas temos acesso ao contexto das telas originais e finais, da transição, a direção dessa transição e, talvez mais interessante, a animation em si. Ela pode ser usada para modificar o widget durante a transição de acordo com o progresso da animação (que estará em animation.value, de zero a um).

Eu comentei acima sobre garantir que o raio do círculo vai cubrir a tela inteira. O flightShuttleBuilder acima não tem nada sobre isso. Para isso vamos usar a outra nova propriedade: createRectTween. Ela serve para definir a área e a posição ocupadas pelo widget durante a transição. Quando não definimos essa propriedade, por padrão a Hero animation vai desenhar o widget num retângulo onde o Hero estava na primeira tela e interpolar até o retângulo em que o Hero está na segunda tela. Ou seja, o widget original aparenta se mover e mudar de tamanho para se transformar nas dimensões da tela de destino.

Para a animação que queria fazer, meu objetivo era que o círculo expandisse sem sair do lugar, ou seja, sem mudar seu centro. Para isso, precisava que o retângulo de origem e destino tivessem o mesmo centro, e que o retângulo de destino crescesse o suficiente para que o círculo do nosso flightShuttleBuilder cobrisse a tela inteira. Flutter já tem maneiras de interpolar dois retângulos (com Rect.lerp()), então eu preciso apenas definir quais são os retângulos inicial e final.

O retângulo inicial é o próprio botão. Ele convenientemente já é um quadrado que tem um círculo verde dentro (o botão). Já o retângulo final precisa conter um círculo que contenha a tela inteira. Isso é meio confuso, mas a ideia é que o raio do círculo precisa ser a distância entre o centro do círculo e o canto da tela mais longe dele. É isso que vamos calcular no construtor da classe HeroTween, abaixo. Fazemos isso no construtor pois os retângulos inicial e final não mudam durante a animação.

Hero(
  // ...
  createRectTween: (begin, end) {
    return HeroTween(begin: begin, end: end);
  },
)

// ...

class HeroTween extends RectTween {
  HeroTween({Rect begin, Rect end})
      : radius = sqrt([
          end.topLeft,
          end.topRight,
          end.bottomLeft,
          end.bottomRight
        ]
            .map((p) => begin.center - p)
            .map((o) => o.distanceSquared)
            .reduce(max)),
        super(begin: begin, end: end);

  final double radius;

  @override
  Rect lerp(double t) {
    return Rect.lerp(
        begin, Rect.fromCircle(center: begin.center, radius: radius), t);
  }
}

Ao criar a classe HeroTween, que estende RectTween, passamos um begin e um end. Essas propriedades são os retângulos (Rects) ocupados pelo widget na tela de origem e de destino da animação. Assim, vamos interpolar entre o retângulo original (begin) e um retângulo que contenha um círculo cujo centro é o centro do original (begin.center) e o raio é a distância que calculamos no construtor.

A inicialização no construtor pode não parecer clara. O que ela está fazendo é calcular, para cada vértice do retângulo final (end), a diferênça entre o vértice e o centro do retângulo inicial (begin). O resultado dessa diferença é um Offset. A classe Offset já tem um getter que retorna a distância, .distance, porém ela também tem um .distanceSquared que é mais eficiente. Como não sabemos qual distância vamos usar, podemos usar a .distanceSquared para comparar e encontrar a maior primeiro, e só tirar a raiz quadrada da que realmente vamos usar, a maior.

Ao final da transição, a animação vai parar de renderizar nosso flightShuttleBuilder e desenhar o child do Hero de destino, que é a tela de sucesso. Ela já tem o fundo verde, o que ajuda na ilusão e cumpre o item 4 da lista acima. Como a transição entre as telas é feita usando o método pushReplacement do Navigator, a tela anterior não é mais acessível e, ao fazer um pop, não voltamos para a tela com o botão animado, mas sim para onde estávamos antes de tudo isso (cuprindo o item 5). Por essa razão, não temos que nos preocupar com o funcionamento da animação no sentido contrário e, por isso, não definimos esses atributos adicionais no Hero da tela de origem.

Ufa! Se você chegou até aqui, dá uma respirada. Vimos bastante coisa de uma vez! Hero animations podem ser bem simples de usar, mas possuem mecanismos extra para quando queremos ir além do básico e criar animações para interações mais específicas e personalizadas. Dá uma olhada no código abaixo, ele é o código completo das duas telas da animação desse exemplo. Boa parte dele é referente à UI das duas telas, mas deixei uns comentários marcando as partes mais relevantes da nossa animação.

Código-fonte do exemplo

class PurchaseScreen extends StatefulWidget {
  @override
  _PurchaseScreenState createState() => _PurchaseScreenState();
}

enum PurchaseState { waiting, processing, successful }

class _PurchaseScreenState extends State<PurchaseScreen>
    with TickerProviderStateMixin {
  // Para controlar o estado do botão
  PurchaseState purchaseState = PurchaseState.waiting;

  @override
  Widget build(BuildContext context) {
    final theme = Theme.of(context);
    final headlineStyle = theme.textTheme.headline4;
    final labelStyle = theme.textTheme.subtitle1;
    final valueStyle = labelStyle.copyWith(fontWeight: FontWeight.w500);

    return Scaffold(
      appBar: AppBar(title: Text('Review your purchase')),
      body: Padding(
        padding: const EdgeInsets.all(8),
        child: Column(
          mainAxisAlignment: MainAxisAlignment.spaceEvenly,
          crossAxisAlignment: CrossAxisAlignment.center,
          children: [
            Text(
              'Pikachu pillow',
              style: headlineStyle,
              textAlign: TextAlign.center,
            ),
            Image.network(
              'https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/25.png',
              width: 250,
              fit: BoxFit.contain,
              filterQuality: FilterQuality.none,
            ),
            Column(
              children: [
                Row(
                  mainAxisAlignment: MainAxisAlignment.spaceBetween,
                  children: [
                    Text('Delivery estimate', style: labelStyle),
                    Text('4 business days', style: valueStyle)
                  ],
                ),
                Row(
                  mainAxisAlignment: MainAxisAlignment.spaceBetween,
                  children: [
                    Text('Price', style: labelStyle),
                    Text('\$ 30', style: valueStyle)
                  ],
                ),
              ],
            ),
            // O Hero da tela inicial, em volta do botão, sem propriedades além da tag e child
            Hero(
              tag: 'order-success',
              child: RawMaterialButton(
                onPressed: () async {
                  if (purchaseState != PurchaseState.waiting) return;

                  setState(() {
                    purchaseState = PurchaseState.processing;
                  });

                  // Simulando uma transação real com await em Future.delayed
                  await Future.delayed(Duration(seconds: 2));

                  setState(() {
                    purchaseState = PurchaseState.successful;
                  });

                  await Future.delayed(Duration(milliseconds: 500));

                  // pushReplacement para não ser possível voltar para essa tela
                  Navigator.of(context).pushReplacement(MaterialPageRoute(
                    builder: (_) => SuccessScreen(),
                    fullscreenDialog: true,
                  ));
                },
                // Os atributos iniciados em _ são getters definidos logo abaixo do build
                fillColor: _buttonColor,
                shape: _buttonShape,
                child: SizedBox(
                  height: 50,
                  child: Center(child: _buttonChild),
                ),
              ),
            ),
          ],
        ),
      ),
    );
  }

  Widget get _buttonChild {
    final theme = Theme.of(context);
    final themeContrastColor = theme.primaryTextTheme.headline6.color;

    switch (purchaseState) {
      case PurchaseState.processing:
        return CircularProgressIndicator();

      case PurchaseState.successful:
        return Icon(Icons.check, color: Colors.white);

      case PurchaseState.waiting:
      default:
        return Text(
          'PLACE ORDER',
          style: TextStyle(
            color: themeContrastColor,
            fontWeight: FontWeight.w500,
            fontSize: 24,
          ),
        );
    }
  }

  Color get _buttonColor {
    switch (purchaseState) {
      case PurchaseState.processing:
        return Colors.grey[200];

      case PurchaseState.successful:
        return Colors.green;

      case PurchaseState.waiting:
      default:
        return Theme.of(context).primaryColor;
    }
  }

  // A mudança do shape é animada pelo RawMaterialButton automaticamente
  ShapeBorder get _buttonShape {
    switch (purchaseState) {
      case PurchaseState.processing:
      case PurchaseState.successful:
        return CircleBorder();

      case PurchaseState.waiting:
      default:
        return RoundedRectangleBorder(borderRadius: BorderRadius.circular(25));
    }
  }
}
class SuccessScreen extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    // O Hero aqui engloba a tela inteira e tem propriedades adicionais
    return Hero(
      tag: 'order-success',
      // Definindo como o widget vai aparecer durante a animação
      flightShuttleBuilder: (flightContext, animation, flightDirection,
          fromHeroContext, toHeroContext) {
        return Container(
          decoration: BoxDecoration(
            color: Colors.green,
            shape: BoxShape.circle,
          ),
        );
      },
      // Definindo o tamanho do widget durante a animação (a classe é criada abaixo desta)
      createRectTween: (begin, end) {
        return HeroTween(begin: begin, end: end);
      },
      child: Scaffold(
        backgroundColor: Colors.green,
        body: Padding(
          padding: const EdgeInsets.all(8),
          child: Column(
            mainAxisAlignment: MainAxisAlignment.spaceEvenly,
            crossAxisAlignment: CrossAxisAlignment.center,
            children: [
              Icon(
                Icons.shopping_basket,
                color: Colors.white,
                size: 200,
              ),
              Text(
                'Your order is on its way!',
                textAlign: TextAlign.center,
                style: TextStyle(
                  color: Colors.white,
                  fontSize: 40,
                ),
              ),
              RaisedButton(
                onPressed: () {
                  Navigator.of(context).pop();
                },
                child: Text('Got it'),
              )
            ],
          ),
        ),
      ),
    );
  }
}

// A classe usada pelo createRectTween
class HeroTween extends RectTween {
  // Calculando o raio no construtor
  HeroTween({Rect begin, Rect end})
      : radius = sqrt([
          end.topLeft,
          end.topRight,
          end.bottomLeft,
          end.bottomRight
        ]
            .map((p) => begin.center - p)
            .map((o) => o.distanceSquared)
            .reduce(max)),
        super(begin: begin, end: end);

  final double radius;

  @override
  Rect lerp(double t) {
    return Rect.lerp(
        begin, Rect.fromCircle(center: begin.center, radius: radius), t);
  }
}