Diseño Android : Endless RecyclerView

Última actualización: 01/01/2019

android

En numerosos listados en los que se puedan mostrar una gran cantidad de elementos resulta necesario algún mecanismo de paginación que permita ir obteniendo, por ejemplo desde un servicio web REST, los elementos a mostrar a medida que el usuario solicita más datos al hacer scroll del listado:

  • Hacia arriba. Si existen elementos más recientes que los actualmente mostrados deberían añadirse en la parte superior del listado. Por tanto, en este tipo de listados al intentar avanzar más allá del primer elemento, se obtienen los más recientes y se añaden. Este patrón es fácil de utilizar gracias a SwipeRefreshLayout.
  • Hacia abajo. En este caso, si hay elementos más antiguos que los actualmente mostrados estos se irán añadiendo en la parte inferior de la lista a medida que el usuario la vaya deslizando hacia su final. Se muestra una barra de progreso indeterminado al final de la lista mientras se realiza la obtención de los siguientes elementos; en raras ocasiones se muestra un botón para que el usuario solicite explícitamente más datos.

youtube endless

El segundo comportamiento fue implementado en el tutorial Android ListView: Estrategias de paginación y en esta ocasión haremos lo mismo pero utilizando un RecyclerView, en concreto el desarrollado en el tutorial Android Recycler View: Listas por lo que recomiendo echarle un vistazo para mejorar la comprensión del código del presente tutorial.

A diferencia del ejemplo del tutorial anteriormente mencionado, todos los datos van a obtenerse de forma asíncrona lo que incluye la primera página o lote de items al mostrarse el RecyclerView de forma inicial. Por ello, vamos a modificar el layout para añadir un ProgressBar que indicará esta primera carga de elementos.

<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".MainActivity">

    <android.support.v7.widget.Toolbar
        android:id="@+id/toolbar"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:background="?attr/colorPrimary"
        android:elevation="4dp"
        android:minHeight="?attr/actionBarSize"
        android:theme="@style/ThemeOverlay.AppCompat.ActionBar"
        tools:targetApi="lollipop" />

    <android.support.v7.widget.RecyclerView
        android:id="@+id/recyclerView"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:scrollbars="vertical" />

    <ProgressBar
        android:id="@+id/progressBar"
        style="?android:attr/progressBarStyle"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_centerHorizontal="true"
        android:layout_centerVertical="true"/>


</RelativeLayout>

Podemos dividir la implementación cuatro pasos:

  1. Detectar si el usuario ha hecho scroll como mínimo hasta cierta posición del listado (en el ejemplo dos posiciones antes de la última) utilizando el evento RecyclerView.OnScrollListener. La posición del último elemento mostrado en pantalla se obtendrá directamente del LinearLayoutManager. Asimismo, usaremos un boolean llamado hasMore para saber si quedan más elementos por obtenerse.
     recyclerView.addOnScrollListener(new RecyclerView.OnScrollListener() {
          @Override
          public void onScrolled(RecyclerView recyclerView, int dx, int dy) {
            super.onScrolled(recyclerView, dx, dy);
            if (hasMore && !(hasFooter())) {
              LinearLayoutManager layoutManager = (LinearLayoutManager) recyclerView.getLayoutManager();
              //position starts at 0
              if (layoutManager.findLastCompletelyVisibleItemPosition()
                  >= layoutManager.getItemCount() - 2) {
    
                //displays the footer after the onscroll listener
                colors.add(new Footer());
                Handler handler = new Handler();
    
                final Runnable r = new Runnable() {
                  public void run() {
                    MainActivity.this.recyclerView.getAdapter().notifyItemInserted(colors.size() - 1);
                  }
                };
                handler.post(r);
                asyncTask = new BackgroundTask();
                asyncTask.execute((Object[]) null);
              }
            }
          }
        });
    

    Para conocer si ya se está realizando una carga de nuevos elementos se comprueba si se está mostrando el ProgressDialog (ver punto siguiente). Esto implica que el footer no se puede eliminar del RecyclerView hasta que no haya finalizado todo el proceso de carga en segundo plano.

    private boolean hasFooter() {
        return colors.get(colors.size() - 1) instanceof Footer;
      }
    
  2. Mientras se realiza la carga de lo nuevos datos en segundo plano gracias a la AsyncTask se mostrará un ProgressDialog a modo de Footer, esto es, la última fila del RecyclerView.
    <?xml version="1.0" encoding="utf-8"?>
    <LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:orientation="vertical">
    
        <ProgressBar
            android:id="@+id/footer"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_gravity="center_horizontal" />
    
    </LinearLayout>
    
    

    En un ListView esta tarea es inmediata puesto que podemos mostrar y ocultar un footer, pero en RecyclerView es necesario implementar esta funcionalidad. La estrategia que suelo emplear para mostrar filas de distinto tipo en un RecyclerView consiste en utilizar como dataset para el Adapter una lista con objetos de los distintos tipos que se utilicen de tal modo que cada tipo (clase) tendrá su propio ViewHolder y View. En función del tipo de objeto que RecyclerView intente mostrar en cada momento al llamar a onCreateViewHolder se utilizará el ViewHolder y layout correspondiente al elemento de la posición deseada. El tipo de objeto lo informaremos mediante un entero implementando el método getItemViewType.

    public class MaterialPaletteAdapter extends RecyclerView.Adapter<RecyclerView.ViewHolder> {
      private List<Item> data;
      private RecyclerViewOnItemClickListener recyclerViewOnItemClickListener;
    
      private static final int TYPE_COLOR = 0;
      private static final int TYPE_FOOTER = 1;
    
      public MaterialPaletteAdapter(@NonNull List<Item> data, RecyclerViewOnItemClickListener
          recyclerViewOnItemClickListener) {
        this.data = data;
        this.recyclerViewOnItemClickListener = recyclerViewOnItemClickListener;
      }
    
      @Override
      public int getItemViewType(int position) {
        if (data.get(position) instanceof Color) {
          return TYPE_COLOR;
        } else if (data.get(position) instanceof Footer) {
          return TYPE_FOOTER;
        } else {
          throw new RuntimeException("ItemViewType unknown");
        }
      }
    
      @Override
      public RecyclerView.ViewHolder onCreateViewHolder(ViewGroup parent, int viewType) {
        if (viewType == TYPE_COLOR) {
          View row = LayoutInflater.from(parent.getContext()).inflate(R.layout.row, parent, false);
          return new PaletteViewHolder(row, recyclerViewOnItemClickListener);
        } else {
          View row = LayoutInflater.from(parent.getContext()).inflate(R.layout.progress_footer,
              parent, false);
          return new FooterViewHolder(row);
        }
      }
    
      @Override
      public void onBindViewHolder(RecyclerView.ViewHolder holder, int position) {
        if (holder instanceof PaletteViewHolder) {
          Color color = (Color) data.get(position);
    
          PaletteViewHolder paletteViewHolder = (PaletteViewHolder) holder;
          paletteViewHolder.getTitleTextView().setText(color.getName());
          paletteViewHolder.getSubtitleTextView().setText(color.getHex());
    
          GradientDrawable gradientDrawable = (GradientDrawable) paletteViewHolder.getCircleView()
              .getBackground();
          int colorId = android.graphics.Color.parseColor(color.getHex());
          gradientDrawable.setColor(colorId);
        }
        //FOOTER: nothing to do
    
      }
    
      @Override
      public int getItemCount() {
        return data.size();
      }
    
      private static class FooterViewHolder extends RecyclerView.ViewHolder { 
    
        public FooterViewHolder(View itemView) {
          super(itemView);
        }
    }
    ...
    
  3. La AsyncTask realiza la carga de datos en segundo plano y actualiza el listado. Con Thread.sleep() se simula la demora en la obtención de los datos. Asimismo se considera que cuando se hayan obtenido más de 35 elementos ya no quedan más elementos por cargar por lo que el scroll infinito queda deshabilitado.
      private class BackgroundTask extends AsyncTask<Void, Void, Void> {
    
        @Override
        protected Void doInBackground(Void... params) {
          try {
            Thread.sleep(3000);
          } catch (InterruptedException e) {
            Log.e(this.getClass().toString(), e.getMessage());
          }
          return null;
        }
    
        @Override
        protected void onPostExecute(Void aVoid) {
          progressBar.setVisibility(View.GONE);
    
          int size = colors.size();
          if (!colors.isEmpty() && colors.get(size - 1) instanceof Footer) {
            colors.remove(size - 1);//removes footer if exists
          }
    
          colors.addAll(buildColors());
          recyclerView.getAdapter().notifyItemRangeChanged(size - 1, colors.size() - size);
          if (colors.size() > 35) {
            hasMore = false;
            Toast.makeText(MainActivity.this, getString(R.string.end), Toast.LENGTH_SHORT).show();
          }
        }
    
      }
        
    

    Cursos aplicaciones móviles

    No podemos olvidarnos de cancelar la ejecución de la AsyncTask al destruirse la Activity.

      @Override
      protected void onDestroy() {
        super.onDestroy();
        if (asyncTask != null) {
          asyncTask.cancel(false);
        }
      }
    
    package com.danielme.android.recyclerview.endless;
    
    import android.os.AsyncTask;
    import android.os.Bundle;
    import android.os.Handler;
    import android.support.v4.content.ContextCompat;
    import android.support.v7.app.AppCompatActivity;
    import android.support.v7.widget.DividerItemDecoration;
    import android.support.v7.widget.LinearLayoutManager;
    import android.support.v7.widget.RecyclerView;
    import android.support.v7.widget.Toolbar;
    import android.util.Log;
    import android.view.View;
    import android.widget.ProgressBar;
    import android.widget.Toast;
    
    import java.util.ArrayList;
    import java.util.List;
    
    /**
     * @author danielme.com
     */
    public class MainActivity extends AppCompatActivity {
    
      private List<Item> colors;
      private boolean hasMore;
      private AsyncTask asyncTask;
      private RecyclerView recyclerView;
      private ProgressBar progressBar;
    
    
      @Override
      protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
        Toolbar toolbar = findViewById(R.id.toolbar);
        setSupportActionBar(toolbar);
        progressBar = findViewById(R.id.progressBar);
    
        colors = new ArrayList<>();
    
        hasMore = true;
    
        recyclerView = findViewById(R.id.recyclerView);
        recyclerView.setAdapter(new MaterialPaletteAdapter(colors, new
                RecyclerViewOnItemClickListener() {
                  @Override
                  public void onClick(View v, int position) {
                    if (colors.get(position) instanceof Color) {
                      Toast toast = Toast.makeText(MainActivity.this, String.valueOf(position), Toast
                              .LENGTH_SHORT);
                      int color = android.graphics.Color.parseColor(((Color) colors.get(position)).getHex());
                      toast.getView().setBackgroundColor(color);
                      toast.show();
                    }
                  }
                }));
        recyclerView.setLayoutManager(new LinearLayoutManager(this));
        DividerItemDecoration dividerItemDecoration = new DividerItemDecoration(recyclerView.getContext(),
                ((LinearLayoutManager) recyclerView.getLayoutManager()).getOrientation());
        recyclerView.addItemDecoration(dividerItemDecoration);
        recyclerView.addOnScrollListener(new RecyclerView.OnScrollListener() {
          @Override
          public void onScrolled(RecyclerView recyclerView, int dx, int dy) {
            super.onScrolled(recyclerView, dx, dy);
            if (hasMore && !(hasFooter())) {
              LinearLayoutManager layoutManager = (LinearLayoutManager) recyclerView.getLayoutManager();
              //position starts at 0
              if (layoutManager.findLastCompletelyVisibleItemPosition()
                      >= layoutManager.getItemCount() - 2) {
    
                //displays the footer after the onscroll listener
                colors.add(new Footer());
                Handler handler = new Handler();
    
                final Runnable r = new Runnable() {
                  public void run() {
                    MainActivity.this.recyclerView.getAdapter().notifyItemInserted(colors.size() - 1);
                  }
                };
                handler.post(r);
                asyncTask = new BackgroundTask();
                asyncTask.execute((Object[]) null);
              }
            }
          }
        });
    
        asyncTask = new BackgroundTask();
        asyncTask.execute((Object[]) null);
      }
    
      @Override
      protected void onDestroy() {
        super.onDestroy();
        if (asyncTask != null) {
          asyncTask.cancel(false);
        }
      }
    
    
      private class BackgroundTask extends AsyncTask<Void, Void, Void> {
    
        @Override
        protected Void doInBackground(Void... params) {
          try {
            Thread.sleep(3000);
          } catch (InterruptedException e) {
            Log.e(this.getClass().toString(), e.getMessage());
          }
          return null;
        }
    
        @Override
        protected void onPostExecute(Void aVoid) {
          progressBar.setVisibility(View.GONE);
    
          int size = colors.size();
          if (!colors.isEmpty() && colors.get(size - 1) instanceof Footer) {
            colors.remove(size - 1);//removes footer if exists
          }
    
          colors.addAll(buildColors());
          recyclerView.getAdapter().notifyItemRangeChanged(size - 1, colors.size() - size);
          if (colors.size() > 35) {
            hasMore = false;
            Toast.makeText(MainActivity.this, getString(R.string.end), Toast.LENGTH_SHORT).show();
          }
        }
    
      }
    
      private boolean hasFooter() {
        return colors.get(colors.size() - 1) instanceof Footer;
      }
    
      private ArrayList<Item> buildColors() {
        ArrayList<Item> colors = new ArrayList<>(13);
    
        colors.add(new Color(getString(R.string.blue), getColorString(R.color.blue)));
        colors.add(new Color(getString(R.string.indigo), getColorString(R.color.indigo)));
        colors.add(new Color(getString(R.string.red), getColorString(R.color.red)));
        colors.add(new Color(getString(R.string.green), getColorString(R.color.green)));
        colors.add(new Color(getString(R.string.orange), getColorString(R.color.orange)));
        colors.add(new Color(getString(R.string.grey), getColorString(R.color.bluegrey)));
        colors.add(new Color(getString(R.string.amber), getColorString(R.color.teal)));
        colors.add(new Color(getString(R.string.deeppurple), getColorString(R.color.deeppurple)));
        colors.add(new Color(getString(R.string.bluegrey), getColorString(R.color.bluegrey)));
        colors.add(new Color(getString(R.string.yellow), getColorString(R.color.yellow)));
        colors.add(new Color(getString(R.string.cyan), getColorString(R.color.cyan)));
        colors.add(new Color(getString(R.string.brown), getColorString(R.color.brown)));
        colors.add(new Color(getString(R.string.teal), getColorString(R.color.teal)));
    
        return colors;
      }
    
      //returns the string hex value of a color in colors.xml
      private String getColorString(int colorId) {
        return "#" + Integer.toHexString(ContextCompat.getColor(this, colorId)).toUpperCase()
                .substring(2);
      }
    
    }
    

El siguiente video muestra el resultado final del proyecto de ejemplo.

Abstrayendo el scroller

Se puede generalizar la estrategia de scroll infinito que hemos visto para aplicarla a cualquier RecyclerView y reutilizarla fácilmente, además de hacer el código más limpio, encapsulando el código en un clase fuera de nuestra Activity. En esta clase, que he llamado EndlessScrollListener, sólo se delega la responsabilidad de detectar si hay que obtener más datos. Esta obtención de datos será responsabilidad de otra clase que le proporcionaremos como dependencia y que será la implementación de una interfaz ScrollerClient.

package com.danielme.android.recyclerview.endless;

import android.support.annotation.NonNull;
import android.support.v7.widget.LinearLayoutManager;
import android.support.v7.widget.RecyclerView;

public class EndlessScrollListener extends RecyclerView.OnScrollListener {

  private static final int MIN_POSICION = 2;

  public interface ScrollerClient {

    boolean hasFooter();

    void onScrolled();
  }

  private final ScrollerClient scrollerClient;
  private boolean hasMore;

  public EndlessScrollListener(ScrollerClient scrollerClient) {
    this.scrollerClient = scrollerClient;
    this.hasMore = true;
  }

  @Override
  public void onScrolled(@NonNull RecyclerView recyclerView, int dx, int dy) {
    super.onScrolled(recyclerView, dx, dy);
    if (hasMore && !(scrollerClient.hasFooter())) {
      LinearLayoutManager layoutManager = (LinearLayoutManager) recyclerView.getLayoutManager();
      //position starts at 0
      if (layoutManager.findLastCompletelyVisibleItemPosition()
              >= layoutManager.getItemCount() - MIN_POSICION) {

        scrollerClient.onScrolled();
      }
    }
  }

  public void setHasMore(boolean hasMore) {
    this.hasMore = hasMore;
  }  

}

Ahora MainActivity debe implementar la interfaz del siguiente modo.

  @Override
  public boolean hasFooter() {
    return colors.get(colors.size() - 1) instanceof Footer;
  }

  @Override
  public void onScrolled() {
    //displays the footer after the onscroll listener
    colors.add(new Footer());
    Handler handler = new Handler();

    final Runnable r = new Runnable() {
      public void run() {
        MainActivity.this.recyclerView.getAdapter().notifyItemInserted(colors.size() - 1);
      }
    };
    handler.post(r);

    asyncTask = new BackgroundTask();
    asyncTask.execute((Object[]) null);
  }

Instanciamos EndlessScrollListener y lo asignamos a un atributo

 endlessScrollListener = new EndlessScrollListener(this);
 recyclerView.addOnScrollListener(endlessScrollListener);

En la AsyncTask informamos al EndlessScrollListener cuando no haya más elementos por obtener.

if (colors.size() > 35) {
    endlessScrollListener.setHasMore(false);
    Toast.makeText(MainActivity.this, getString(R.string.end), Toast.LENGTH_SHORT).show();
}

Ver MainActivity.java.

Código de ejemplo

El proyecto de ejemplo para Android Studio se encuentra disponible en GitHub. Para más información sobre cómo utilizar GitHub, consultar este artículo.

6 comentarios sobre “Diseño Android : Endless RecyclerView

  1. Gracias por compartir tu conocimiento con todos, te queria preguntar como puedo colocar un spinner dentro de un recyclcerview, cuentas con algun tutorial o link al respecto, te agradeceria demasiado.

Responder

Introduce tus datos o haz clic en un icono para iniciar sesión:

Logo de WordPress.com

Estás comentando usando tu cuenta de WordPress.com. Cerrar sesión /  Cambiar )

Google photo

Estás comentando usando tu cuenta de Google. Cerrar sesión /  Cambiar )

Imagen de Twitter

Estás comentando usando tu cuenta de Twitter. Cerrar sesión /  Cambiar )

Foto de Facebook

Estás comentando usando tu cuenta de Facebook. Cerrar sesión /  Cambiar )

Conectando a %s

This site uses Akismet to reduce spam. Learn how your comment data is processed.