Design & UX

2 mar, 2018

Transições contínuas de shared elements: RecyclerView para ViewPager

Publicidade

Artigo de Shalom Gibly, publicado originalmente pelo Android Developers Blog. A tradução foi feita pela Redação iMasters com autorização.

***

As transições nos aplicativos com Material Design oferecem continuidade visual. À medida que o usuário navega no aplicativo, as views no app mudam de estado. O movimento e a transformação reforçam a ideia de que as interfaces são tangíveis, conectando elementos comuns de uma view à próxima.

Este artigo pretende fornecer diretrizes e implementação para uma transição contínua específica entre Android Fragments. Vamos demonstrar como implementar uma transição de uma imagem em um RecyclerView para uma imagem em um ViewPager e vice-versa, usando ‘Shared Elements’ para determinar quais views participam da transição e como. Também vamos tratar do complicado caso de transição de volta para a grade após a paginação para um item que anteriormente estava fora da tela.

Este é o resultado que buscamos:

Se você deseja ignorar a explicação e seguir direto para o código, você pode encontrá-lo aqui.

O que são shared elements?

Uma transição de shared element determina como as views que estão presentes em dois fragmentos transitam. Por exemplo, uma imagem que é exibida em um ImageView no Fragmento A e no Fragmento B transita de A para B quando B fica visível.

Existem inúmeros exemplos publicados anteriormente que explicam como os elementos compartilhados funcionam e como implementar uma transição Fragment básica. Este artigo irá ignorar a maioria dos conceitos básicos e irá percorrer as especificações sobre como criar uma transição de trabalho para um ViewPager e vice-versa. No entanto, se você quiser saber mais sobre as transições, recomendo começar a ler sobre transições no site de desenvolvedores do Android, e aproveite o tempo para assistir à esta apresentação do Google I/O 2016.

Os desafios

Mapeamento de elementos compartilhados

Gostaríamos de suportar uma transição direta de ida e volta. Isso inclui uma transição da grade para o pager e, em seguida, uma transição de volta para a imagem relevante, mesmo quando o usuário paginou para uma imagem diferente.

Para isso, precisamos encontrar uma maneira de dinamicamente remapear os elementos compartilhados para fornecer ao sistema de transição do Android o que ele precisa para fazer sua mágica!

Carregamento atrasado

As transições de elementos compartilhados são poderosas, mas podem ser complicadas ao lidar com elementos que precisam ser carregados antes que possamos fazer transição para eles. A transição pode simplesmente não funcionar como esperado quando as views no fragmento alvo não estão dispostas e preparadas.

Neste projeto, existem duas áreas em que o tempo de carregamento afeta a transição do elemento compartilhado:

  1. Demora alguns milésimos de segundo para o ViewPager carregar seus fragmentos internos. Além disso, leva tempo para carregar uma imagem no fragmento de pager exibido (pode até incluir um tempo de download para o recurso).
  2. O RecyclerView também enfrenta um atraso semelhante ao carregar as imagens em suas views.

Design do app de demonstração

Estrutura básica

Antes de mergulhar nas transições, aqui está um pouco sobre como o app de demonstração está estruturado.

O MainActivity carrega um GridFragment para apresentar um RecyclerView de imagens. O adaptador RecyclerView carrega os itens de imagem (um array constante que é definido na classe ImageData) e gerencia os eventos onClick, substituindo o GridFragment exibido por um ImagePagerFragment.

O adaptador ImagePagerFragment carrega os ImageFragments aninhados para exibir as imagens individuais quando a paginação acontece.

Nota: A implementação do app de demonstração usa Glide, que carrega imagens em views de forma assíncrona. As imagens no app de demonstração são agrupadas com ele. No entanto, você pode facilmente converter a classe ImageData para manter strings de URL que apontam para imagens online.

Coordenando uma posição selecionada/exibida

Para comunicar a posição da imagem selecionada entre os fragmentos, usaremos o MainActivity como um local para armazenar a posição.

Quando um item é clicado ou quando uma página é alterada, o MainActivity é atualizado com a posição do item relevante.

A posição armazenada é usada mais tarde em vários lugares:

  • Ao determinar a página a ser exibida no ViewPager.
  • Ao navegar de volta para a grade e auto-rolagem para a posição para se certificar de que está visível.
  • E, claro, ao conectar as callbacks de transição, como veremos na próxima seção.

Configurando as transições

Conforme mencionado acima, precisamos encontrar uma maneira de dinamicamente remapear os elementos compartilhados para dar ao sistema de transição o que ele precisa para fazer sua magia.

O uso de um mapeamento estático configurando atributos transitionName para as views das imagens no XML não funcionará, pois estamos lidando com uma quantidade arbitrária de views que compartilham o mesmo layout (por exemplo, exibições infladas pelo adaptador RecyclerView ou views infladas pelo ImageFragment).

Para realizar isso, usaremos algumas coisas que o sistema de transição nos fornece:

  1. Definimos um nome de transição nas views da imagem chamando setTransitionName. Isso identificará a view com um nome exclusivo para a transição. setTransitionName é chamado ao vincular uma view no adaptador RecyclerView da grade, e onCreateView no ImageFragment. Em ambos os locais, usamos o recurso de imagem exclusivo como um nome para identificar a view.
  2. Configuramos SharedElementCallbacks para interceptar onMapSharedElements e ajustamos o mapeamento dos nomes dos elementos compartilhados para as views. Isso será feito ao sair do GridFragment e ao entrar no ImagePagerFragment.

Configurando a transação FragmentManager

A primeira coisa que configuramos para iniciar uma transição para uma substituição de fragmentos é a preparação da transação FragmentManager. Precisamos informar o sistema de que temos uma transição de elemento compartilhado.

fragment.getFragmentManager()
   .beginTransaction()
   .setReorderingAllowed(true) // setAllowOptimization before 26.1.0
   .addSharedElement(imageView, imageView.getTransitionName())
   .replace(R.id.fragment_container, 
        new ImagePagerFragment(),
        ImagePagerFragment.class.getSimpleName())
   .addToBackStack(null)
   .commit();

O setReorderingAllowed está definido como verdadeiro. Ele reorganizará as mudanças de estado dos fragmentos para permitir melhores transições de elementos compartilhados. Os fragmentos adicionados terão onCreate (Bundle) chamado antes de fragmentos substituídos terem onDestroy() chamado, permitindo que a view compartilhada seja criada e estabelecida antes de a transição iniciar.

Transição de imagem

Para definir como a imagem transita quando ela se anima para sua nova localização, configuramos um TransitionSet em um arquivo XML e o carregamos no ImagePagerFragment.

<ImagePagerFragment.java>

Transition transition =
   TransitionInflater.from(getContext())
       .inflateTransition(R.transition.image_shared_element_transition);
setSharedElementEnterTransition(transition);

<image_shared_element_transition.xml>

<?xml version="1.0" encoding="utf-8"?>
<transitionSet
   xmlns:android="http://schemas.android.com/apk/res/android"
   android:duration="375"
   android:interpolator="@android:interpolator/fast_out_slow_in"
   android:transitionOrdering="together">
 <changeClipBounds/>
 <changeTransform/>
 <changeBounds/>
</transitionSet>

Ajustando o mapeamento de elementos compartilhados

Começaremos ajustando o mapeamento de elementos compartilhados ao sair do GridFragment. Para isso, chamaremos setExitSharedElementCallback() e forneceremos um SharedElementCallback que irá mapear os nomes dos elementos para as views que gostaríamos de incluir na transição.

É importante notar que essa callback será chamada ao sair do Fragment quando a transação de fragmento ocorrer e enquanto voltar a inserir o Fragment quando ele for lançado fora do backstack (na navegação traseira).

Usaremos esse comportamento para remapear a view compartilhada e ajustar a transição para lidar com casos em que a view seja alterada após a paginação das imagens.

Neste caso específico, estamos apenas interessados em uma única transição do ImageView da grade para o fragmento que o dispositivo da view possui, portanto, o mapeamento só precisa ser ajustado para o primeiro elemento nomeado recebido na callback onMapSharedElements.

<GridFragment.java>

setExitSharedElementCallback(
   new SharedElementCallback() {
     @Override
     public void onMapSharedElements(
         List<String> names, Map<String, View> sharedElements) {
       // Locate the ViewHolder for the clicked position.
       RecyclerView.ViewHolder selectedViewHolder = recyclerView
           .findViewHolderForAdapterPosition(MainActivity.currentPosition);
       if (selectedViewHolder == null || selectedViewHolder.itemView == null) {
         return;
       }

       // Map the first shared element name to the child ImageView.
       sharedElements
           .put(names.get(0),
                selectedViewHolder.itemView.findViewById(R.id.card_image));
     }
   });

Também precisamos ajustar o mapeamento de elementos compartilhados ao inserir o ImagePagerFragment. Para isso, chamaremos setEnterSharedElementCallback().

<ImagePagerFragment.java>

setEnterSharedElementCallback(
   new SharedElementCallback() {
     @Override
     public void onMapSharedElements(
         List<String> names, Map<String, View> sharedElements) {
          // Locate the image view at the primary fragment (the ImageFragment
          // that is currently visible). To locate the fragment, call
          // instantiateItem with the selection position.
          // At this stage, the method will simply return the fragment at the
          // position and will not create a new one.
       Fragment currentFragment = (Fragment) viewPager.getAdapter()
           .instantiateItem(viewPager, MainActivity.currentPosition);
       View view = currentFragment.getView();
       if (view == null) {
         return;
       }

       // Map the first shared element name to the child ImageView.
       sharedElements.put(names.get(0), view.findViewById(R.id.image));
     }
   });

Adiando a transição

As imagens das quais gostaríamos de fazer transição, são carregadas na grade e no pager, e leva tempo para carregar. Para que isso funcione corretamente, precisamos adiar a transição até que as views participantes estejam prontas (por exemplo, apresentadas e carregadas com os dados da imagem).

Para fazer isso, chamamos postponeEnterTransition() no onCreateView() de nossos fragmentos, e uma vez que a imagem é carregada, começamos a transição chamando startPostponedEnterTransition().

Nota: o adiamento é chamado tanto para a grade como para os fragmentos do pager para suportar as transições para frente e para trás ao navegar no aplicativo.

Como estamos usando o Glide para carregar as imagens, configuramos ouvintes que ativam a transição de entrada quando as imagens são carregadas.

Isso é feito em dois lugares:

  1. Quando uma imagem ImageFragment é carregada, uma chamada é feita para o parente dela, ImagePagerFragment para iniciar a transição.
  2. Ao retornar para a grade, uma transição de início é chamada depois que a imagem “selecionada” é carregada.

Veja como o ImageFragment carrega uma imagem e notifica seu pai quando ela está pronta.

Observe que posponerEnterTransition é feito no ImagePagerFragment, enquanto startPostponeEnterTransition é chamado do ImageFragment filho que é criado pelo pager.

<ImageFragment.java>

Glide.with(this)
   .load(arguments.getInt(KEY_IMAGE_RES)) // Load the image resource
   .listener(new RequestListener<Drawable>() {
     @Override
     public boolean onLoadFailed(@Nullable GlideException e, Object model,
         Target<Drawable> target, boolean isFirstResource) {
       getParentFragment().startPostponedEnterTransition();
       return false;
     }

     @Override
     public boolean onResourceReady(Drawable resource, Object model,
         Target<Drawable> target, DataSource dataSource, boolean isFirstResource) {
       getParentFragment().startPostponedEnterTransition();
       return false;
     }
   })
   .into((ImageView) view.findViewById(R.id.image));

Como você pode ter notado, também chamamos para iniciar a transição adiada quando o carregamento falha. Isso é importante para evitar que a UI seja suspensa durante a falha.

Toques finais

Para tornar nossas transições ainda mais suaves, gostaríamos de enfraquecer os itens da grade quando a imagem transitar para a view do pager.

Para fazer isso, criamos um TransitionSet que é aplicado como uma transição de saída ao GridFragment.

<GridFragment.java>

setExitTransition(TransitionInflater.from(getContext())
   .inflateTransition(R.transition.grid_exit_transition));

<grid_exit_transition.xml>

<?xml version="1.0" encoding="utf-8"?>
<transitionSet xmlns:android="http://schemas.android.com/apk/res/android"
   android:duration="375"
   android:interpolator="@android:interpolator/fast_out_slow_in"
   android:startDelay="25">
 <fade>
   <targets android:targetId="@id/card_view"/>
 </fade>
</transitionSet>

Essa é a aparência da transição após a transição de saída estar configurada:

Como você pode ter notado, a transição ainda não está completamente polida com essa configuração. A animação de desvanecimento é executada para todas as views do cartão da grade, incluindo o cartão que contém a imagem que transita para o pager.

Para corrigi-la, excluímos o cartão clicado da transição de saída antes de comprometer a transação de fragmentos no GridAdapter.

// The 'view' is the card view that was clicked to initiate the transition.
((TransitionSet) fragment.getExitTransition()).excludeTarget(view, true);

Após essa mudança, a animação parece muito melhor (o cartão clicado não desaparece como parte da transição de saída, enquanto o resto dos cartões desaparece):

Como toque final, configuramos o GridFragment para rolar e revelar o cartão no qual transitamos ao navegar de volta do pager (feito no onViewCreated):

<GridFragment.java>

recyclerView.addOnLayoutChangeListener(
   new OnLayoutChangeListener() {
      @Override
      public void onLayoutChange(View view,
                int left, 
                int top, 
                int right, 
                int bottom, 
                int oldLeft, 
                int oldTop, 
                int oldRight, 
                int oldBottom) {
         recyclerView.removeOnLayoutChangeListener(this);
         final RecyclerView.LayoutManager layoutManager =
            recyclerView.getLayoutManager();
         View viewAtPosition = 
            layoutManager.findViewByPosition(MainActivity.currentPosition);
         // Scroll to position if the view for the current position is null (not   
         // currently part of layout manager children), or it's not completely
         // visible.
         if (viewAtPosition == null 
             || layoutManager.isViewPartiallyVisible(viewAtPosition, false, true)){
            recyclerView.post(() 
               -> layoutManager.scrollToPosition(MainActivity.currentPosition));
         }
     }
});

Encerrando

Neste artigo, implementamos uma transição suave a partir de um RecyclerView para um ViewPager e vice-versa.
Mostramos como adiar uma transição e iniciá-la depois que as views estiverem prontas. Também implementamos o remapeamento de elemento compartilhado para que a transição aconteça quando as views compartilhadas estejam mudando dinamicamente ao navegar no aplicativo.

Essas mudanças transformaram as transições de fragmentos do nosso app para proporcionar uma melhor continuidade visual à medida que os usuários interagem com ele.

Para isso:

O código para o app de demonstração pode ser encontrado aqui.

***

Este artigo é do Android Developers Blog. Ele foi escrito por Shalom Gibly. A tradução foi feita pela Redação iMasters com autorização. Você pode acessar o original em: https://android-developers.googleblog.com/2018/02/continuous-shared-element-transitions.html