Animando as transições de Views em um aplicativo Android

Ultimamente passei por um problema aparentemente simples, mas que para uma pessoa sem experiência no desenvolvimento para Android, ou até mesmo alguém com mais bagagem, é uma questão não tão rápida de se resolver. Pensando nisso resolvi compartilhar aqui o problema que eu tive, quais foram as soluções que tentei e como resolvi o desejado.

Em toda aplicação Android, uma UI (User Interface) e uma UX (User Experience) bem construída é algo fundamental para que o aplicativo seja aceito pelos usuários e talvez tenha sucesso. Um detalhe que ajuda a se chegar nesse objetivo é a utilização de animações, que mesmo sendo simples passam a impressão de algo bem construído, organizado e amigável. Esse pensamento, inclusive, é bem difundido nas diretrizes elicitadas pelo Material Design:

Why does motion matter?
Motion shows how an app is organized and what it can do. 
Motion provides: 
Guided focus between views
Hints at what will happen if a user completes a gesture
Hierarchical and spatial relationships between elements
Distraction from what’s happening behind the scenes (like fetching content or loading the next view)
 — — Tradução — — 
Por que o movimento é importante?
Movimento mostra como um aplicativo é organizado e o que ele pode fazer.
Movimento fornece:
Foco orientado entre views
Dicas sobre o que acontecerá se um usuário concluir um gesto
Relações hierárquicas e espaciais entre elementos
Distração do que está acontecendo nos bastidores (como buscar conteúdo ou carregar a próxima view)

Um exemplo de animação que segue essas ideias do Material Design é quando o aplicativo exibe poucas informações em uma determinada área (um Card, por exemplo) e quando o usuário toca nessa área, a view tocada cresce para exibir mais informações. Imagine uma tela que possua dois Cards, um logo abaixo do outro, onde dentro de cada Card possui um título e outros campos de entrada (um form). Seguindo o conceito de adicionar movimento, animação, aos elementos do aplicativo, o ideal seria que quando o usuário tocasse em um desses Cards (neste exemplo implementados com o widget CardView), os campos do formulário fossem exibidos ou ocultados com a animação correta, animando tanto a aparição deles quanto dos Cards em que eles estão. Um jeito de projetar esse layout da tela é conforme o exemplo abaixo (algumas tags omitidas para ficar melhor de ler):

<LinearLayout ...>
<CardView ...>
<ConstraintLayout ...>
<TextView android:id="@+id/titulo" .../>
<!-- Outros textviews, edittexts e button -->
</ConstraintLayout>
</CardView>
<CardView ...>
<ConstraintLayout ...>
<TextView android:id="@+id/titulo2" .../>
<!-- Outros textviews, edittexts e button -->
</ConstraintLayout>
</CardView>
</LinearLayout>

Para alcançar o objetivo citado anteriormente, a ideia é que os TextViews de título respondam ao toque do usuário, fazendo com que a visibilidade das demais views do ConstraintLayout fique alternando entre View.VISIBLE e View.GONE. Dessa forma, a altura do ConstraintLayout é alterada para exibir ou ocultar as demais views, assim como a altura do CardView e do LinearLayout. Se a implementação do layout for realizada conforme descrita até aqui, com os eventos de clique, porém sem se preocupar com nenhuma animação, o resultado será o visto abaixo.

Layout sem animações

Como é possível visualizar, não é tão agradável a transição das views do estado de invisível para visível. Nem existe uma transição, um movimento que demonstra para o usuário o que aconteceu com a tela. Do ponto de vista do Material Design qual seria a transição ideal? O ideal seria que o conteúdo dos Cards fossem exibidos de maneira suave (por exemplo, interpolando o alpha dos seus componentes, onde o alpha é uma propriedade que cada view possui e define a transparência do seu desenho), ao mesmo tempo que a altura do Card aumentasse também suavemente, e, caso seja necessário, que o outro Card mude a sua posição Y (altura relativa ao topo da tela, ou seja, alterando o valor de Y ele sobe ou desce na tela) também com um movimento suave.

Fazendo uma pesquisa na documentação é possível encontrar uma seção falando apenas de animações (Developer Android). Desde animações simples, como deslocar um objeto, alterar o alpha de uma view, até animações mais complexas, como animar bitmaps e mudanças de activities. Se você se aprofundar nos tipos de animações vai perceber que a animação ideal, para o exemplo deste post, é possível implementando um LayoutTransition. A maneira mais simples de implementar isso, segundo o link, é adicionando a tag animateLayoutChanges com o valor true no ViewGroup que se deseja animar. Dessa forma, as mudanças que ocorrerem nas views filhas do ViewGroup serão animadas. Como o objetivo é que o CardView anime, basta adicionar a tag no seu ViewGroup, que no caso é o LinearLayout:

<LinearLayout android:animateLayoutChanges="true"...>
<CardView ...>
<ConstraintLayout ...>
<TextView android:id="@+id/titulo" .../>
<!-- Outros textviews, edittexts e button -->
</ConstraintLayout>
</CardView>
<CardView ...>
<ConstraintLayout ...>
<TextView android:id="@+id/titulo2" .../>
<!-- Outros textviews, edittexts e button -->
</ConstraintLayout>
</CardView>
</LinearLayout>

Eis o resultado dessa mudança:

Hum… nada aconteceu :(

Se você reler a página citada acima, do LayoutTransition, ela fala que deveria ser animada a adição/remoção de uma view, mas não aconteceu. Isso ocorre porque no LinearLayout (ViewGroup que encapsula os dois cards retângulos brancos) em que a tag foi adicionada não houve adição/remoção de view, o Card (retângulo braco) apenas teve sua altura modificada. As views que tiveram sua visibilidade alterada fazem parte do ConstraintLayout, que não possuía a tag animateLayoutChanges. Ou seja, essa animação padrão do LayoutTransition não é repassada para as views aninhadas, ela é aplicada diretamente as views filhas do ViewGroup escolhido. Se uma das views filhas for um ViewGroup, a adição de uma nova view nesse ViewGroup não será animada.

Parando para se pensar no passo a passo da animação desejada, pode-se listar os seguintes objetivos:

  1. Animar o aumento da altura do Card que foi tocado pelo usuário
  2. Caso seja necessário, animar o deslocamento do outro Card em decorrência do aumento do primeiro
  3. Animar a exibição das Views que estão ocultas dentro do ConstraintLayout do Card que foi tocado

Para chegar a esse resultado é preciso ter um LayoutTransition no ConstraintLayout (com intuito de conseguir o objetivo 3) e um no LinearLayout (para conseguir os objetivos 1 e 2). Ficando desta forma:

<LinearLayout android:animateLayoutChanges="true"...>
<CardView ...>
<ConstraintLayout android:animateLayoutChanges="true"...>
<TextView android:id="@+id/titulo" .../>
<!-- Outros textviews, edittexts e button -->
</ConstraintLayout>
</CardView>
<CardView ...>
<ConstraintLayout android:animateLayoutChanges="true"...>
<TextView android:id="@+id/titulo2" .../>
<!-- Outros textviews, edittexts e button -->
</ConstraintLayout>
</CardView>
</LinearLayout>

Dando o build e executando, eis o resultado:

Objetivos 1 e 3 ok, mas o objetivo 2 não foi atingido

O objetivo 3 foi conseguido, com a exibição das Views dentro do Card sendo animada. O objetivo 1 também foi atingido, com a mudança de altura dos Cards. Porém, o objetivo 2 não foi totalmente completado. Ao visualizar o gif com atenção, é possível notar que o Card de Situação Eleitoral expande e contrai realizando a animação correta, mas quando o Card de Local de Votação é alterado, o outro Card não segue o seu movimento. Quando ele é expandido, o segundo Card some da sua posição e aparece embaixo, na sua nova posição. E quando ele é contraído, o segundo Card é exibido na nova posição antes da animação de contração terminar.

Investigando o código-fonte do ViewGroup, para entender o que está acontecendo, tem-se o seguinte trecho de código:

case R.styleable.ViewGroup_animateLayoutChanges:
boolean animateLayoutChanges = a.getBoolean(attr, false);
if (animateLayoutChanges) {
setLayoutTransition(new LayoutTransition());
}
break;

Ou seja, uma versão padrão do LayoutTransition é o responsável por fazer as animações de transição. Se você analisar o código da classe LayoutTransition, para entender o que ela faz quando seu construtor padrão é usado, é possível ler o seguinte trecho:

/**
* These are the types of transition animations that the LayoutTransition is reacting
* to. By default, appearing/disappearing and the change animations related to them are
* enabled (not CHANGING).
*/
private int mTransitionTypes = FLAG_CHANGE_APPEARING | FLAG_CHANGE_DISAPPEARING | FLAG_APPEARING | FLAG_DISAPPEARING;

Então, quando apenas a tag animateLayoutChanges é utilizada, o ViewGroup adiciona um LayoutTransition que anima apenas os eventos de aparecer e desaparecer uma view, ignorando os eventos quando uma view sofre outra mudança (deslocamento, por exemplo). Para que o objetivo 2 seja realizado, o segundo Card deve se deslocar quando o primeiro expandir, portanto é preciso adicionar, manualmente, um LayoutTransition que responda também aos eventos de mudança. Aproveitando as extensions functions de Kotlin, pode-se criar uma função genérica da seguinte forma:

fun ViewGroup.setupAllAnimations() {
val transition = LayoutTransition()

// enableTransitionType está disponível a partir do Jelly Bean
if (Build.VERSION.SDK_INT >= 16) {
transition.enableTransitionType(LayoutTransition.CHANGING)
}

this.layoutTransition = transition
}

E utilizá-la no LinearLayout para que ele anime os deslocamentos dos Cards: linearLayout.setupAllAnimations(). Uma vez que será adicionado o LayoutTransition manualmente, a tag animateLayoutChanges pode ser removida do LinearLayout. Resultado:

Objetivos alcançados, mas uma das animações ficou com problema

A mudança realizada surtiu efeito, fazendo com que o deslocamento dos Cards fossem animados, mas um problema surgiu: quando um deles é contraído, a animação de diminuição da altura é realizada e logo após o Card sofre uma alteração, como se sua altura aumentasse e diminuísse rapidamente. No momento da expansão esse problema não ocorre.

Esse imprevisto é mais complicado. Mas se você adicionar um listener ao LayoutTransition e analisar o que ele anima e em qual ordem, dá para notar que quando se define um LayoutTransition para um ViewGroup, por padrão, ele anima algumas mudanças também do seu ViewGroup pai. Exemplo: quando o conteúdo do ConstraintLayout é exibido, a animação de exibição é executada, a animação do ConstraintLayout crescendo é executada, mas uma animação também ocorre para o CardView (ViewGroup pai do ConstraintLayout). Da mesma forma, ao configurar o LinearLayout, quando o CardView é deslocado, a animação do CardView é executada, a do próprio LinearLayout e a do seu pai, o NestedScrollView (ocultado dos trechos de código do layout para simplificar). Se você notar, ocorrem duas animações envolvendo o CardView, uma causada pelo LayoutTransition do ConstraintLayout e uma iniciada pelo LayoutTransition do LinearLayout. Isso é o que causa o comportamento indesejado, é preciso arrumar uma maneira de apenas uma animação ser executada no CardView.

Analisando mais uma vez o código da classe LayoutTransition, tem-se o seguinte método:

/**
* This flag controls whether CHANGE_APPEARING or CHANGE_DISAPPEARING animations will
* cause the default changing animation to be run on the parent hierarchy as well...
*
(Resumo: Essa flag controla se as animações de aparecer e
* ou desaparecer também serão executadas na view pai)
*
(Comentário resumido para exibir apenas a frase inicial)
*/
public void setAnimateParentHierarchy(boolean animateParentHierarchy) {
mAnimateParentHierarchy = animateParentHierarchy;
}

Portanto, é possível configurar o LayoutTransition para não animar a view pai quando os eventos de CHANGE_APPEARING e CHANGE_DISAPPEARING acontecerem. Esse é exatamente o caso do ConstraintLayout do layout de exemplo, ele está sofrendo uma animação por conta de views que estão aparecendo/desaparecendo e está repassando essa animação para o Card (view pai), causando a animação duplicada. Se você alterar o LayoutTransition, associado ao ConstraintLayout, para que ele não repasse essa animação para o seu pai, apenas uma animação será executada em cima do Card, decorrente do LayoutTransition do LinearLayout. Customizando a extension function anterior, tem-se:

fun ViewGroup.setupAllAnimations(animateParent: Boolean) {
val transition = LayoutTransition()

// Disponível a partir do Ice Cream Sandwich
transition.setAnimateParentHierarchy(animateParent)
    // enableTransitionType está disponível a partir do Jelly Bean
if (Build.VERSION.SDK_INT >= 16) {
transition.enableTransitionType(LayoutTransition.CHANGING)
}

this.layoutTransition = transition
}

E agora é possível utilizar um LayoutTransition animando tudo no LinearLayout: linearLayout.setupAllAnimations(true) e um que não anima o pai para os dois ConstraintLayout: constraintLayout.setupAllAnimations(false) . Como esses códigos já adicionam os LayoutTransitions necessários, as tags animateLayoutChanges não são mais necessárias em nenhum XML de layout, deixando o layout assim como foi definido inicialmente, no primeiro trecho.

Resultado Final

Resultado final das animações
  • O Card aumentando e diminuindo está sendo animado
  • O deslocamento dos Cards está sendo animado
  • As views que estão sendo adicionadas e removidas estão sofrendo animações

\o/

No fim, conseguiu-se as animações desejadas, com uma interface mais suave e responsiva as ações do usuário, além de entender um pouco o funcionamento do LayoutTransition.


Espero que isso tenha sido útil para você, encurtando o caminho para se chegar a solução desse tipo de animação. Qualquer dúvida estou aberto aos comentários.