Diseño Android : Endless RecyclerView asíncrono

Última actualización: 21/12/2020

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 se puede implementar con 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. Habitualmente se suele mostrar 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 verticales y horizontales en el que se mostraba una lista de colores, por lo que si el lector no conoce bien el funcionamiento del componente RecyclerView recomiendo echarle un vistazo para mejorar la comprensión del presente tutorial en el que sólo veremos el código estrictamente relevante.


Lo implementaremos todo nosotros, pero desde la publicación de la primera versión de este tutorial allá por 2015 el desarrollo en Android ha mejorado notablemente y en la actualidad para trabajar con listas dinámicas contamos con la biblioteca de paginación de AndroidX que nos da toda la infraestructura necesaria. No obstante, he decidido mantener el tutorial actualizado pues es un ejercicio sumamente interesante para aprender a crear de forma realista interfaces basadas en RecyclerView y además todavía es fácil encontrar código que no utiliza las herramientas de paginación.

A diferencia del ejemplo del tutorial anteriormente mencionado, ahora 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 primera vez. 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">

    <com.google.android.material.appbar.MaterialToolbar
        android:id="@+id/toolbar"
        android:layout_width="match_parent"
        android:layout_height="?attr/actionBarSize"
        android:elevation="@dimen/elevation_toolbar"
        tools:targetApi="lollipop" />

    <androidx.recyclerview.widget.RecyclerView
        android:id="@+id/recyclerView"
        android:layout_below="@+id/toolbar"
        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>

Voy a desglosar la implementación en los siguientes pasos.

  1. Detectar si el usuario ha hecho scroll como mínimo hasta cierta posición del listado («n» posiciones antes de la última, la constante MIN_POSITION_TO_END del ejemplo) capturando el evento con una implementación de RecyclerView.OnScrollListener. La posición del último elemento mostrado en pantalla se obtendrá directamente del LinearLayoutManager. Asimismo, se delega a una interfaz propia (ScrollerClient) la operación a realizar para cargar más elementos en el RecyclerView, responsabilidad que no debería asumir el listener.
    package com.danielme.android.recyclerview.endless.rv;
    
    import androidx.annotation.NonNull;
    import androidx.recyclerview.widget.LinearLayoutManager;
    import androidx.recyclerview.widget.RecyclerView;
    
    public class EndlessScrollListener extends RecyclerView.OnScrollListener {
    
      private static final int MIN_POSITION_TO_END = 2;
    
      private final ScrollerClient scrollerClient;
      private boolean loadMore;
    
      public interface ScrollerClient {
        void loadData();
      }
    
      public EndlessScrollListener(ScrollerClient scrollerClient) {
        this.scrollerClient = scrollerClient;
        this.loadMore = true;
      }
    
      @Override
      public void onScrolled(@NonNull RecyclerView recyclerView, int dx, int dy) {
        super.onScrolled(recyclerView, dx, dy);
        if (loadMore) {
          LinearLayoutManager layoutManager = (LinearLayoutManager) recyclerView.getLayoutManager();
          //position starts at 0
          if (layoutManager.findLastCompletelyVisibleItemPosition()
                  >= layoutManager.getItemCount() - MIN_POSITION_TO_END) {
            loadMore = false;
            scrollerClient.loadData();
          }
        }
      }
    
      public void loadMore() {
        loadMore = true;
      }
    
    }
    

    Para conocer si hay que cargar más elementos, además de comprobar que se ha realizado scroll hasta los últimos elementos de la lista, se verifica el flag loadMore. Cuando se solicita una carga de datos se pone a false para que el listener no pueda volver a solicitar una nueva carga de datos hasta que invoquemos al método loadMore(), llamada que sólo haremos cuando se finalice la obtención de datos en curso y si quedan datos por cargar. De este modo, evitaremos la llamada a loadData si mientras se obtienen los datos en segundo plano el usuario vuelve a hacer scroll hacia el final.

  2. Mientras se realiza la carga de los nuevos datos se mostrará temporalmente un ProgressDialog a modo de Footer, esto es, en 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 trivial puesto que podemos mostrar y ocultar un footer, pero en RecyclerView es necesario implementar esta funcionalidad.

    Ahora tendremos que mostrar filas de distinto tipo en el RecyclerView. Esto implica que para cada clase de la colección de datos que deberá mostrar el RecyclerView (List<ItemRv>) tendremos un ViewHolder y su correspondiente layout. En función del tipo de objeto que RecyclerView intente mostrar en cada momento al llamar a onCreateViewHolder, y dado por su posición en el listado, se utilizará el ViewHolder y layout adecuado. Este tipo de objeto lo calcularemos en el método getItemViewType y lo definimos con un entero. En nuestro ejemplo, el listado constará de objetos de las clases Color y Footer, ambos de tipo ItemRv.

    public class MaterialPaletteAdapter extends RecyclerView.Adapter<RecyclerView.ViewHolder> {
      private final List<ItemRv> data;
      private final RecyclerViewOnItemClickListener recyclerViewOnItemClickListener;
    
      private static final int TYPE_COLOR = 0;
      private static final int TYPE_FOOTER = 1;
    
      public MaterialPaletteAdapter(@NonNull List<ItemRv> 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
      @NonNull
      public RecyclerView.ViewHolder onCreateViewHolder(@NonNull 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(@NonNull RecyclerView.ViewHolder holder, int position) {
        if (holder instanceof PaletteViewHolder) {
          Color color = (Color) data.get(position);
          ((PaletteViewHolder) holder).bindRow(color);
        }
        //FOOTER: nothing to do
    
      }
    
      @Override
      public int getItemCount() {
        return data.size();
      }
    
    }
    

    El ViewHolder para Color ya lo teníamos del tutorial anterior, así que tendremos que crear el correspondiente para Footer aunque realmente no hace nada.

    package com.danielme.android.recyclerview.endless.rv;
    
    import android.view.View;
    
    import androidx.recyclerview.widget.RecyclerView;
    
    class FooterViewHolder extends RecyclerView.ViewHolder {
    
      public FooterViewHolder(View itemView) {
        super(itemView);
      }
    }
    
  3. La carga asíncrona la vamos a realizar con la clásica AsyncTask, aunque sorprendentemente ha sido marcada como obsoleta en Android 11 (api 30). Se ejecutarán secuencialmente los métodos onPreExecute, doInBackground y onPostExecute. doInBackground se ejecuta en segundo plano y es donde obtenemos los datos, habitualmente llamando a un servicio REST (para estos menesteres utilizo Retrofit), pero en el ejemplo con Thread.sleep() se simula una demora en la obtención de los datos. Los otros dos métodos se ejecutan en el hilo principal para poder actualizar la interfaz gráfica.
    package com.danielme.android.recyclerview.endless;
    
    import android.os.AsyncTask;
    import android.util.Log;
    
    import com.danielme.android.recyclerview.endless.model.Color;
    
    import java.lang.ref.WeakReference;
    import java.util.List;
    
    public class LoadColorsAsyncTask extends AsyncTask<Void, Void, Void> {
    
      private static final int FAKE_DELAY = 3000;
    
      private final WeakReference<MainActivity> activityWR;
      private List<Color> colors;
    
      public LoadColorsAsyncTask(MainActivity activity) {
        this.activityWR = new WeakReference<>(activity);
      }
    
      @Override
      protected void onPreExecute() {
        if (activityWR.get() != null) {
          activityWR.get().displayLoadingIndicator();
        }
      }
    
      @Override
      protected Void doInBackground(Void... params) {
        try {
          Thread.sleep(FAKE_DELAY);
          colors = Colors.buildColors(activityWR.get());
        } catch (Exception e) {
          Log.e(this.getClass().toString(), e.getMessage());
        }
        return null;
      }
    
      @Override
      protected void onPostExecute(Void aVoid) {
        if (!isCancelled() && activityWR.get() != null) {
          if (colors == null || activityWR.get().forceError()) {
            activityWR.get().onError();
          } else {
            activityWR.get().displayData(colors);
          }
        }
      }
    
    }
    
    

    La AsyncTask se ha definido en su propia clase de forma específica para MainActivity (podría generalizarse si fuera necesario utilizando una interfaz), por lo que para interactuar con la interfaz gráfica que gestiona la Activity tenemos que pasarle esta última.

    Pero debemos tener cuidado de no guardar en nuestra AsyncTask una referencia a la misma ya que si la Activity se finaliza pero está referenciada por la AsyncTask en ejecución en segundo plano, Android no podrá «destruirla». Por ello, en la AsyncTask vamos a guardar la referencia de la Activity en un WeakReference, contenedor para la referencia de un objeto que puede ser liberada inmediatamente por el recolector de basura. Simplemente tendremos que comprobar si ese objeto sigue existiendo antes de utilizarlo.

    Obsérvese también que capturamos todas las excepciones durante la obtención de los datos de tal modo que si hay un error la lista de colores es null, ni siquiera una lista vacía. Más adelante veremos la gestión de errores.

    Cursos aplicaciones móviles

  4. En nuestra Activity tenemos que:
    • Crear la instancia del ScrollListener y pasarla al RecyclerView
       endlessScrollListener = new EndlessScrollListener(this);
       recyclerView.addOnScrollListener(endlessScrollListener);
      
    • Implementar la interfaz requerida por el listener para obtener los datos
        @Override
        public void loadData() {
          launchAsynTask();
        }
      
        private void launchAsynTask() {
          asyncTask = new LoadColorsAsyncTask(this);
          asyncTask.execute();
        }
      
    • Implementar los métodos utilizados desde la AsyncTask para actualizar la interfaz gráfica. Para una mayor eficiencia, al añadir filas al adapter «notificamos» exactamente las filas que cambian en lugar de refrescar el listado completo llamando a notifyDataSetChanged.

      Vamos a suponer que no existen más de MAX_LIST_ELEMENTS colores para mostrar, así que en función del número de elementos que ya tengamos invocaremos, si procede, el método loadMore del ScrollListener para que se puedan, o no, intentar la obtención de más datos.

        public void displayLoadingIndicator() {   
            itemRvs.add(new Footer());
            recyclerView.getAdapter().notifyItemInserted(itemRvs.size() - 1);
          }
        }
      
       public void displayLoadingIndicator() {
          //no hay datos, la lista está vacía, mostrar loading en el centro
          if (itemRvs.isEmpty()) {
            progressBar.setVisibility(View.VISIBLE);
          } else {
            //si ya se muestran datos, añadimos el footer al final
            itemRvs.add(new Footer());
            recyclerView.getAdapter().notifyItemInserted(itemRvs.size() - 1);
          }
        }
      
        public void displayData(List<Color> colors) {
          progressBar.setVisibility(View.GONE);
          removeFooter();
          itemRvs.addAll(colors);
          recyclerView.getAdapter().notifyItemRangeChanged(itemRvs.size() - 1, colors.size());
          if (itemRvs.size() > MAX_LIST_ELEMENTS) {
            Toast.makeText(MainActivity.this, getString(R.string.end), Toast.LENGTH_SHORT).show();
          } else {
            endlessScrollListener.loadMore();
          }
        }
      
        private void removeFooter() {
          int size = itemRvs.size();
          if (!itemRvs.isEmpty() && itemRvs.get(size - 1) instanceof Footer) {
            itemRvs.remove(size - 1);
            recyclerView.getAdapter().notifyItemRemoved(size - 1);
          }
        }
      
        public void onError() {
          
        }
      
    • 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);
          }
        }
      

Código completo:

package com.danielme.android.recyclerview.endless;

import android.os.AsyncTask;
import android.os.Bundle;
import android.view.View;
import android.widget.ProgressBar;
import android.widget.Toast;

import androidx.appcompat.app.AppCompatActivity;
import androidx.appcompat.widget.Toolbar;
import androidx.recyclerview.widget.DividerItemDecoration;
import androidx.recyclerview.widget.LinearLayoutManager;
import androidx.recyclerview.widget.RecyclerView;

import com.danielme.android.recyclerview.endless.model.Color;
import com.danielme.android.recyclerview.endless.model.Footer;
import com.danielme.android.recyclerview.endless.model.ItemRv;
import com.danielme.android.recyclerview.endless.rv.EndlessScrollListener;
import com.danielme.android.recyclerview.endless.rv.MaterialPaletteAdapter;
import com.danielme.android.recyclerview.endless.rv.RecyclerViewOnItemClickListener;

import java.util.ArrayList;
import java.util.List;

/**
 * @author danielme.com
 */
public class MainActivity extends AppCompatActivity implements EndlessScrollListener.ScrollerClient {

  private static final int MAX_LIST_ELEMENTS = 35;

  private RecyclerView recyclerView;
  private ProgressBar progressBar;

  private List<ItemRv> itemRvs;
  private AsyncTask<Void, Void, Void> asyncTask;
  private EndlessScrollListener endlessScrollListener;

  @Override
  protected void onCreate(Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);
    setContentView(R.layout.activity_main);
    setSupportActionBar((Toolbar) findViewById(R.id.toolbar));
    initAttributes();
    setupRecyclerView();
    launchAsynTask();
  }

  @Override
  protected void onDestroy() {
    super.onDestroy();
    if (asyncTask != null) {
      asyncTask.cancel(false);
    }
  }

  private void initAttributes() {
    recyclerView = findViewById(R.id.recyclerView);
    progressBar = findViewById(R.id.progressBar);
    itemRvs = new ArrayList<>();
  }

  private void setupRecyclerView() {
    recyclerView.setAdapter(new MaterialPaletteAdapter(itemRvs, new
            RecyclerViewOnItemClickListener() {
              @Override
              public void onClick(View v, int position) {
                if (itemRvs.get(position) instanceof Color) {
                  String text = position + " " + ((Color) itemRvs.get(position)).getName();
                  Toast.makeText(MainActivity.this, text, Toast.LENGTH_SHORT).show();
                }
              }
            }));
    recyclerView.setLayoutManager(new LinearLayoutManager(this));
    DividerItemDecoration dividerItemDecoration = new DividerItemDecoration(recyclerView.getContext(),
            ((LinearLayoutManager) recyclerView.getLayoutManager()).getOrientation());
    recyclerView.addItemDecoration(dividerItemDecoration);

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

  private void launchAsynTask() {
    asyncTask = new LoadColorsAsyncTask(this);
    asyncTask.execute();
  }

  @Override
  public void loadData() {
    launchAsynTask();
  }

  public void displayLoadingIndicator() {
    //no hay datos, la lista está vacía, mostrar loading en el centro
    if (itemRvs.isEmpty()) {
      progressBar.setVisibility(View.VISIBLE);
    } else {
      //si ya se muestran datos, añadimos el footer al final
      itemRvs.add(new Footer());
      recyclerView.getAdapter().notifyItemInserted(itemRvs.size() - 1);
    }
  }

  public void displayData(List<Color> colors) {
    progressBar.setVisibility(View.GONE);
    removeFooter();
    itemRvs.addAll(colors);
    //se informa al adapter de los datos que han cambiado
    recyclerView.getAdapter().notifyItemRangeChanged(itemRvs.size() - 1, colors.size());
    if (itemRvs.size() > MAX_LIST_ELEMENTS) {
      Toast.makeText(MainActivity.this, getString(R.string.end), Toast.LENGTH_SHORT).show();
    } else {
      //se indica al scroll que quedan más datos por obtener si el usuario hace scroll hasta el final
      endlessScrollListener.loadMore();
    }
  }

  private void removeFooter() {
    int size = itemRvs.size();
    if (!itemRvs.isEmpty() && itemRvs.get(size - 1) instanceof Footer) {
      itemRvs.remove(size - 1);
      recyclerView.getAdapter().notifyItemRemoved(size - 1);
    }
  }

  public void onError() {

  }

}

Esta es la estructura final

android endless scroll view uml

El siguiente video muestra el resultado.

Gestión de errores

Vamos a ver de forma pormenorizada cómo gestionar los errores producidos en el proceso de obtención de datos en la AsyncTask. El tratamiento de los mismos será muy genérico pero es un buen punto de partido para, a partir de ahí, realizar un tratamiento más exhaustivo según lo vayamos necesitando.

Si no se pueden obtener los datos, se invocará al método onError de la Activity que de momento tenemos vacío. La casuística que vamos a implementar en ese método es la siguiente:

  • Si ya se están mostrando datos en la lista, simplemente mostramos un Toast informando que se ha producido un error, además de eliminar el footer. El usuario puede intentar obtener nuevamente los datos haciendo scroll.
  • Si no hay datos en pantalla, esto es, el listado se encuentra vacío, además del Toast mostraremos un botón para que el usuario pueda intentar obtener otra vez los colores. En este escenario, recordemos, el proceso de carga se indica mediante una ProgressBar en el centro de la pantalla.

Nos falta añadir el mencionado botón al layout. Llamará al método retry.

    <Button
        android:id="@+id/retryButton"
        android:visibility="gone"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_centerHorizontal="true"
        android:layout_centerVertical="true"
        android:minWidth="@dimen/minButton"
        android:text="@string/retry"
        android:onClick="retry"/>

El nuevo código que necesitamos en la Activity es el siguiente

  public void onError() {
    Toast.makeText(this, R.string.error, Toast.LENGTH_SHORT).show();
    progressBar.setVisibility(View.GONE);
    //si ha fallado y la lista está vacia se muestra el botón.
    if (recyclerView.getAdapter().getItemCount() == 0) {
      retryButton.setVisibility(View.VISIBLE);
    } else {
      removeFooter();
      //si ya hay elementos, las nuevas cargas de datos se realizarán mediante scroll
      //el scroll no se debloquea hasta que se hayan renderizado todos los cambios en pantalla
      //para evitar que si se está mostrando actualmente el footer, al eliminarse se haga scroll automático
      //y pueda entrar en un bucle infinito
      final Handler handlerUI = new Handler(Looper.getMainLooper());
      Runnable r = new Runnable() {
        public void run() {
          endlessScrollListener.loadMore();
        }
      };
      handlerUI.post(r);
    }
  }

  public void retry(View view) {
    launchAsynTask();
  }

Creo que lo más complicado es encontrar un modo de probar el tratamiento de errores. Lo que se me ha ocurrido es mostrar un botón en la ActionBar que cambie el valor de un flag boolean (forceError), y tener en cuenta ese valor en la AsyncTask para llamar a onError.

  @Override
  protected void onPostExecute(Void aVoid) {
    if (activityWR.get() != null) {
      if (colors == null || activityWR.get().forceError()) {
        activityWR.get().onError();
      } else {
        activityWR.get().displayData(colors);
      }
    }
  }

El botón lo pondremos con un menú. Su pulsación invierte el valor actual de forceError, y se muestra el nuevo valor en un Toast para evitar confusiones si lo pulsamos repetidas veces.

<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    tools:context=".MainActivity">

    <item
        android:id="@+id/action_add"
        android:title="@string/action_toggle_error"
        app:showAsAction="always" />

</menu>
  @Override
  public boolean onCreateOptionsMenu(Menu menu) {
    MenuInflater inflater = getMenuInflater();
    inflater.inflate(R.menu.activity_main_menu, menu);
    return super.onCreateOptionsMenu(menu);
  }

  @Override
  public boolean onOptionsItemSelected(MenuItem item) {
    forceError = !forceError;
    Toast.makeText(this, "error " + forceError, Toast.LENGTH_SHORT).show();
    return true;
  }

Ahora ya tenemos el resultado final del tutorial.

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.

9 comentarios sobre “Diseño Android : Endless RecyclerView asíncrono

  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.

    1. El tutorial se ha quedado un poco obsoleto porque como bien dices ahora mismo es recomendable usar la Paging Library de AndroidX. Lo tengo apuntado para un futuro tutorial, pero ahora mismo estoy centrado en escribir sobre Jakarta EE.

Deja un comentario

Este sitio utiliza Akismet para reducir el spam. Conoce cómo se procesan los datos de tus comentarios.