terça-feira, 5 de janeiro de 2016

RecyclerView no Android

Olá povo,

Devia ter escrito esse post a muito tempo, mas finalmente saiu :) Desde a primeira versão do Android em 2008 até o começo de 2014, para criarmos telas de listagem utilizávamos a classe ListView juntamente com alguma subclasse de BaseAdapter (ArrayAdapter ou CursorAdapter por exemplo) e esse conjunto funcionava muito bem. Mas conforme o tempo foi passando, ele foi apresentando algumas "limitações" que atrapalhavam sua conformidade aos requisitos/padrões de UI/UX atuais. Nada que fosse impossível de ser implementado, mas que eram mais trabalhosos de serem feitos, tais como:
  • Performance na atualização de itens: a ListView está ligada ao Adapter, que possui uma lista de objetos. Se inserirmos um objeto nessa lista, temos que invocar o método notifyDatasetChanged() que fará com que toda a lista seja refeita/redesenhada. Com a RecyclerView podemos atualizar só um item da lista (inserindo/atualizando/excluindo) ou um intervalo específico.
  • Layouts diferenciados para cada situação: com a RecyclerView podemos configurar gerenciadores de layouts, indicando por exemplo, que a lista terá uma única coluna quando o aparelho estiver em portrait e duas quando estiver em landscape. Ou ainda dizer que o primeiro item da lista será diferente dos demais.
  • Animações e gestos: Com a RecyclerView, à medida que os itens vão sendo adicionados ou removidos, uma animação é realizada dando um feedback visual para o usuário do que aconteceu. A utilização de gestos também ficou bastante simples. Ações como o swipe sobre um item da lista é algo trivial de ser feito.
  • Scroll em ambos os sentidos: a RecyclerView permite o scroll em na horizontal e na vertical, o que não era possível nativamente na ListView.
  • Baixa curva de aprendizagem: o conceito utilizado pela RecyclerView é muito parecido com o que temos na ListView. Então quem já a conhece não terá muitos problemas.
Estes são os mais importantes para mim. Mas já pode te convencer a mudar para a RecyclerView. Se é que não já mudou? Você já mudou, certo? ;)

Pondo em prática

Veremos um exemplo simples de uma listagem de mensagens, mas que obviamente pode ser ajustado para qualquer outro propósito. Adicione as seguintes dependências no build.gradle do projeto.
dependencies {
    compile fileTree(dir: 'libs', include: ['*.jar'])
    compile 'com.android.support:appcompat-v7:23.1.1'
    compile 'com.android.support:design:23.1.1'
    compile 'com.android.support:cardview-v7:23.1.1'
    compile 'com.jakewharton:butterknife:7.0.1'
}
A RecyclerView está biblioteca appcompat. Já a biblioteca de design foi adicionada aqui para podermos utilizar o FloatActionButton. Cada item da lista será um CardView, então adicionamos a dependência. E por fim, utilizamos a ButterKnife que falei nesse post aqui.

Vamos agora para a implementação começando pela classe básica.
public class Mensagem {
    public String titulo;
    public String texto;

    public Mensagem(String titulo, String texto) {
        this.titulo = titulo;
        this.texto = texto;
    }
}
Nada a comentar sobre essa classe, então vamos adicionar o arquivo de layout item_mensagem.xml que representará cada item da lista.
<android.support.v7.widget.CardView
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:layout_margin="4dp"
    android:foreground="?android:attr/selectableItemBackground"
    app:cardCornerRadius="5dp">
    <LinearLayout
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:gravity="center"
        android:orientation="vertical"
        android:padding="8dp">
        <TextView
            android:id="@+id/txtTitulo"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_gravity="center"
            android:textAppearance="?android:attr/textAppearanceLarge"/>
        <TextView
            android:id="@+id/txtMensagem"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:textAppearance="?android:attr/textAppearanceMedium"/>
    </LinearLayout>
</android.support.v7.widget.CardView>
Um ponto interessante a ressaltar nesse layout é a propriedade foreground. A RecyclerView, por padrão, não fornece o feedback de toque como faz a ListView, pois ela parte do pressuposto que seus itens não são clicáveis, ao contrário da ListView que mesmo que não haja tratamento do evento de clique, um feedback visual é dado ao usuário.

O arquivo de layout da activity (activity_main.xml) é exibido a seguir:
<FrameLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent">
    <LinearLayout
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:orientation="vertical"
        android:padding="8dp">
        <EditText
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:hint="@string/titulo"
            android:id="@+id/edtTitulo"/>
        <EditText
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:hint="@string/texto"
            android:id="@+id/edtTexto"/>
        <android.support.v7.widget.RecyclerView
            android:layout_width="match_parent"
            android:layout_height="match_parent"
            android:id="@+id/recyclerView"/>
    </LinearLayout>

    <android.support.design.widget.FloatingActionButton
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_gravity="bottom|end"
        android:layout_marginRight="16dp"
        android:layout_marginBottom="16dp"
        android:id="@+id/fab"
        android:src="@drawable/ic_add_white_24dp"/>
</FrameLayout>
O único detalhe a comentar sobre esse layout é a imagem do ícone do botão. Ela pode ser encontrada no site de ícones do Material Design, que fornece mais de 800 ícones gratuitos que você pode utilizar no seu aplicativo.

Vamos agora implementar o adapter a ser utilizado por nossa RecyclerView.
import android.support.v7.widget.RecyclerView;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.TextView;

import java.util.List;

import butterknife.Bind;
import butterknife.ButterKnife;

public class MensagemAdapter extends 
    RecyclerView.Adapter<MensagemAdapter.VH> {

    List<Mensagem> mMensagens;
    AoClicarNaMensagem mListener;

    public MensagemAdapter(List<Mensagem> mensagens, 
                           AoClicarNaMensagem listener) {
        mMensagens = mensagens;
        mListener = listener;
    }

    @Override
    public VH onCreateViewHolder(ViewGroup parent, 
                                 int viewType) {
        View v= LayoutInflater.from(parent.getContext())
                .inflate(R.layout.item_mensagem, 
                         parent, false);

        final VH vh = new VH(v);
        vh.itemView.setOnClickListener(
          new View.OnClickListener() {
            @Override
            public void onClick(View v) {
              if (mListener != null) {
                int pos = vh.getAdapterPosition();
                Mensagem mensagem = mMensagens.get(pos);
                mListener.mensagemClicada(mensagem);
              }
            }
          });
        return vh;
    }

    @Override
    public void onBindViewHolder(VH holder, int pos) {
        Mensagem msg = mMensagens.get(pos);
        holder.textTitulo.setText(msg.titulo);
        holder.textTexto.setText(msg.texto);
    }

    @Override
    public int getItemCount() {
        return mMensagens != null ? 
            mMensagens.size() : 0;
    }

    public static class VH extends 
        RecyclerView.ViewHolder {

        @Bind(R.id.txtTitulo)   
        public TextView textTitulo;
        @Bind(R.id.txtMensagem) 
        public TextView textTexto;

        public MensagemViewHolder(View itemView) {
            super(itemView);
            ButterKnife.bind(this, itemView);
        }
    }

    public interface AoClicarNaMensagem {
        void mensagemClicada(Mensagem mensagem);
    }
}
Esssa sem sombra de dúvida é a classe mais importante desse exemplo. Então vamos aos detalhes sobre ela:
  • Para definir um adapter para a RecyclerView devemos criar uma subclasse de RecyclerView.Adapter, e como podemos observar, ela é "tipada". Ou seja, ela necessita de um tipo que deve ser uma subclasse de RecycleView.ViewHolder. No nosso exemplo, essa classe é chamada simplesmente de VH e está declarada dentro da própria classe MensagemAdapter.
  • Para criar uma instância do nosso adapter, além da lista de objetos, podemos passar um objeto que tratará o evento de clique do item da lista. Diferentemente do que é feito na ListView, onde temos o OnItemClickListener, o evento de clique em um item da lista é definido no próprio item, ou seja, no adapter. 
    • No nosso caso, quem estiver interessado em ouvir o evento de clique no item da lista, deverá passar como parâmetro para o adapter, um objeto que implemente a interface AoClicarNaMensagem. Na prática, a activity passará esse objeto para o adapter.
  • Obrigatoriamente devemos implementar 3 métodos: 
    • O método onCreateViewHolder criará a instância do ViewHolder baseado no arquivo de layout que representa cada item (no nosso caso item_mensagem.xml). Esse método já implementa, por padrão, a abordagem de um adapter eficiente que expliquei nesse post, e esse é o momento ideal para definirmos o evento de clique do item da lista. Percebam que por meio do atributo itemView (que já é da classe ViewHolder) definimos o evento de clique. E usando o método getAdapterPosition() podemos obter o índice da posição da lista que foi clicada.
    • No onBindViewHolder() é onde preenchemos cada View do ViewHolder. Essa abordagem de create/bind já era usada no CursorAdapter como falei nesse post.
    • O getItemCount() é quantidade de itens que sua lista exibirá, como já é de costume em qualquer adapter.
  • Percebam que estamos utilizando o ButterKnife no ViewHolder, mas fique a vontade em usar o bom e velho findViewById() :)
Vamos utilizar esse adapter na Activity.
public class MainActivity extends AppCompatActivity {
    @Bind(R.id.edtTitulo)
    EditText mEdtTitulo;
    @Bind(R.id.edtTexto)
    EditText mEdtMensagem;
    @Bind(R.id.recyclerView)
    RecyclerView mRecyclerView;

    List<Mensagem> mMensagens;
    MensagemAdapter mAdapter;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
        ButterKnife.bind(this);

        mMensagens = new ArrayList<>();
        mAdapter = new MensagemAdapter(
                mMensagens, mListener);
        mRecyclerView.setAdapter(mAdapter);

        GridLayoutManager layoutManager =
                new GridLayoutManager(this, 2);

        layoutManager.setSpanSizeLookup(
                new GridLayoutManager.SpanSizeLookup() {
                    @Override
                    public int getSpanSize(int pos) {
                        return pos == 0 ? 2 : 1;
                    }
                });
        mRecyclerView.setLayoutManager(layoutManager);
    }

    @OnClick(R.id.fab)
    public void onClick(View view) {
        Mensagem mensagem = new Mensagem(
                mEdtTitulo.getText().toString(),
                mEdtMensagem.getText().toString());
        mMensagens.add(mensagem);
        mAdapter.notifyItemInserted(
                mMensagens.size() - 1);

        mEdtTitulo.getText().clear();
        mEdtMensagem.getText().clear();
    }

    private MensagemAdapter.AoClicarNaMensagem mListener=
        new MensagemAdapter.AoClicarNaMensagem() {
            @Override
            public void mensagemClicada(Mensagem msg) {
                String s = String.format(
                    "%s %s", msg.titulo, msg.texto);
                Toast.makeText(MainActivity.this, 
                    s, Toast.LENGTH_SHORT).show();
            }
        };
}
Vamos agora as explicações relevantes:
  • Estamos utilizando o gerenciador de layout GridLayoutManager que nos permite dividir nossa lista em colunas. 
    • No nosso caso, estamos dizendo que teremos duas colunas. O método setSpanSizeLookup() indica quais itens da linha vão ocupar mais de uma coluna. Para esse exemplo, estipulei que apenas o primeiro item da lista vai ocupar duas colunas, os demais ocuparão uma (das duas colunas) da lista.
    • Existem ainda os gerenciadores de layout LinearLayoutManagerStaggeredGridLayoutManager. O primeiro organiza os itens da lista de forma linear enquanto o segundo organiza os itens em forma de grid, mas suporta itens de tamanhos completamente diferentes e os exibe como se fosse um "mosaico". 
    • E você pode criar seus próprios gerenciadores de layout.
  • Percebam que no evento de clique, ao inserirmos um registo, utilizamos o método notifyItemInserted(). E como sempre adicionamos o item ao final da lista, passamos o tamanho da lista menos um. 
    • Existem também os métodos notifyItemRemoved(), notifyItemChanged(), notifyItemMoved() para quando um item da lista foi respectivamente removido, alterado ou movido de uma posição para a outra da lista.
    • Podemos utilizar os métodos notifyItemRangeInserted(), notifyItemRangeRemoved() e notifyItemRangeChanged(). Que indicam que um intervalo de itens da lista foi inserido, removido ou alterado.
    • Obviamente existe o notifyDatasetChanged(), que possui o mesmo comportamento da ListView.
Ao executar o exemplo e adicionar alguns itens, você terá um resultado similar ao da figura abaixo.
O aplicativo deve estar adicionando os itens na lista. Mas e para remover? Que tal usar o gesto de swipe? ;) Com a RecyclerView isso ficou bem simples. Vejamos o código a seguir.

private void configuraSwipe() {
  ItemTouchHelper.SimpleCallback swipe =
      new ItemTouchHelper.SimpleCallback(0, 
          ItemTouchHelper.LEFT | ItemTouchHelper.RIGHT) {

          @Override
          public boolean onMove(
              RecyclerView recyclerView,
              RecyclerView.ViewHolder viewHolder,
              RecyclerView.ViewHolder target) {
            return false;
          }

          @Override
          public void onSwiped(
              RecyclerView.ViewHolder viewHolder, 
              int swipeDir) {

            final int position = 
              viewHolder.getAdapterPosition();
            mMensagens.remove(position);
            mAdapter.notifyItemRemoved(position);
          }
      };
  ItemTouchHelper itemTouchHelper = new ItemTouchHelper(swipe);
  itemTouchHelper.attachToRecyclerView(mRecyclerView);
}
Com a classe ItemTouchHelper estamos configurando o gesto de swipe para esquerda e para direita. E no método onSwiped() removemos o item da lista e notificamos o adapter. Perceba que com essa classe também podemos fazer o evento de mover itens na lista.
Para concluir, chame esse método no onCreate() da activity.
@Override
protected void onCreate(Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);
    ...
    configuraSwipe();
}
Execute a aplicação novamente e insira alguns itens. Em seguida, tente exclui-los utilizando o gesto de swipe. Perceba que após um item ser excluído, os demais itens são reorganizados com uma animação.

Conclusão

Se você ainda não usa a RecylerView, você está perdendo diversos recursos bacanas que estão sendo adicionados a esse componente. Entretanto ele ainda tem algumas melhorias a serem feitas e o próprio pessoal do Google admitiu isso para mim no Android Dev Summit.
Coisas simples como a empty view já poderiam estar implementadas (nada demais, eu convivo com isso sem problemas). Definir um divisor entre as linhas da RecyclerView é um absurdo. Isso já deveria estar abstraído no componente. Outra coisa que eu sentia falta era um CursorAdapter para a RecyclerView. Mas depois de ver a implementação interna dele, achei melhor fazer o meu mesmo :)
No final das contas, sem sombra de dúvidas o RecyclerView é bem mais flexível que a ListView e o seu resultado final vale muito a pena.

Qualquer dúvida, deixem seus comentários.

4br4ç05,
nglauber

P.S.: Meus agradecimentos a André Mion que me ajudou a discutir os tópicos desse post.

9 comentários:

Daniel Freitas disse...

Grande Glauber. Você tem, ou planeja fazer, algum artigo sobre persistir o estado da recyclerview on rotation? No momento estou usando o LayoutManager.onSaveInstanceState () de forma manual e salvando o Parcelable no Bundle do Fragment.onSaveInstanceState(). Quando o fragmento é recriado e a Recyclerview recebe o novo LayoutManager eu chamo getLauoutManager.onRestoreInstanceState () com o Parcelable que eu coloquei no bundle.

Embora funcione, me incomoda o fato de que a Recyclerview não faça isso automaticamente quando persiste a View State.

Nelson Glauber disse...

Graaaande Daniel!!! Que honra vossa excelência comentando aqui! :)

Cara, pq vc simplesmente não retém a instância do Fragment (setRetainInstance(true))?
Se não for possível, você persiste esses dados (no SQLite ou em arquivo mesmo) e lê quando a Activity é criada...
Uma outra opção (meio gambi) é salvar os dados como atributo de uma classe estática. O EventBus faz isso de uma maneira mais digna.

Qualquer coisa me pinga no hangout que a gente conversa melhor ;)

4br4ç05,
nglauber

Guilherme Costa disse...

Fala Nelson, blz? Sou novo no seu blog gostei do tópico esse do ItemTouchHelper eu não sabia, sempre lendo e aprendendo por aqui! Parabéns.

Blog do ueder disse...

Nelson, show de bola os seu artigos.
Tenho a 2ª edição do seu livro e fiquei com uma dúvida, antes quando utilizava o ListView eu conseguia listar vários registros e não travava o meu aplicativo, hoje utilizando o RecyclerView vejo que ele dá uma travadinha e depois volta a responder. Tem alguma dica pra mim geralmente são mais de 200 registros listados ao criar a activity.
Obrigado

Nelson Glauber disse...

Oi ueder,

Que bom que gostou do post. Em relação a sua dúvida, não creio que seja um problema da RecyclerView. Pode ser algo no seu Adapter. Um outro ponto a ser visto é como você está carregando esses dados. O ideal é que isso seja feito fora da UI thread utilizando AsyncTask, Loader, RX, ...
Se quiser, posta teu código no Gist, paste.bin ou algo do tipo para eu dar uma olhada.

4br4ç05,
nglauber

Blog do ueder disse...

Nelson valeu mesmo, me esclareceu bastante com o que você me disse.
Só havia achado estranho que da mesma maneira que implementei com o listview carregava os dados sem problemas. Mas o que eu fiz pra resolver foi como você me disse, apenas passei a rodar o processo numa thread separada e ficou show!!!
Só me tira mais uma dúvida teria como fazer um SwypeRefresh no evento onCreate da Activity ?
sem precisar do usuário interferir ?
Valeu mesmo, e um abraço.

Nelson Glauber disse...

Oi Ueder,

que bom que resolveu. você pode fazer o carregamento normal no onCreate, entretanto, um "bug" comum do SwipeRefreshLayout é que ele não mostra o indicador de progresso. Mas que você pode resolver com esse workaround aqui...
http://stackoverflow.com/questions/26858692/swiperefreshlayout-setrefreshing-not-showing-indicator-initially

4br4ç05,
nglauber

Blog do ueder disse...

Nelson blz?
Estava lendo o seu livro pra dar uma melhorada aqui no app, e não consegui encontrar nada nesse sentido, acabei lendo tambem a documentação e não achei.
Sem a utilização de biblioteca de terceiros é possível adicionar seções no RecyclerView ?

Nelson Glauber disse...

Oi Ueder,

A Chiu-Ki fez um ótimo tutorial sobre isso ;)
http://blog.sqisland.com/2014/12/recyclerview-grid-with-header.html

4br4ç05,
nglauber