Loupe

Android : comment créer un RecyclerView infini / endless ?

Dans cet article, nous verrons comment créer un RecyclerView affichant une liste infinie d'éléments. Au lieu d'afficher tous les éléments d'un seul bloc, on n'en affichera qu'un nombre restreint puis on demandera le chargement d'un bloc supplémentaire lorsque l'utilisateur arrivera proche de la fin de la liste.

Pour rappel, cet article fait partie d'une série sur le composant RecyclerView d'Android :

  1. Android : Optimiser l'affichage de vos listes en utilisant un RecyclerView
  2. Ajouter des sticky headers sur vos RecyclerView
  3. Ajouter des en-têtes repliables (collapsible headers) sur vos RecyclerView
  4. Créer des RecyclerView horizontaux, les fameux "horizontal RecyclerView"
  5. Cet article.
  6. Suspens.

Mise en place théorique

Partons du principe dans la suite de cet article que vous êtes en mesure de faire appel à une API paginée. On pourra donc demander une page d'un certain nombre d'éléments et la réponse contiendra les éléments ainsi que le nombre de page total d'éléments.

Nous allons créer un ReyclerView qui affiche un bloc d'éléments suivi d'un élément spécial qui correspond à un loader. Ce dernier sera utilisé pour indiquer à l'utilisateur que la donnée est en cours de chargement.

Ensuite, dans la méthode de Binding du ViewHolder nous allons mettre en place cette logique : 

  • Si la position de l'élément bindé est proche de la fin de la liste, on demande le chargement de la page suivante à la source de données.
  • Sinon on ne fait rien d'autre que de binder la vue.

Source de données

La source de données va consister en une classe dérivant d'ObservableCollection à laquelle on va ajouter quelques méthodes utilitaires pour gérer son cycle de vie. Elle sera définie comme abstract afin de pouvoir en dériver et l'utiliser au besoin avec différentes données. Elle n'a vraiment rien de spécifique à Xamarin ou Android et vous pouvez l'utiliser facilement sur d'autres plateformes.

La première chose dont nous allons avoir besoin est de définir un retour de l'API paginée. 

public interface IPaginationInfo
{
    int Page { get; set; }
    int TotalNumberOfItems { get; set; }
}

public interface IPaginatedResult<TData> : IList<TData>
{
    IPaginationInfo PaginationInfo { get; }
}

Une fois cela fait, créons la structure de base de notre source de données. Nous verrons le détail de la méthode de chargement dans un second extrait de code. Cette classe dérive d'ObservableCollection pour pouvoir notifier un changement dans la donnée. On définit aussi plusieurs propriétés intéressantes pour les consommateurs de la source de données : IsLoading, IsEmpty, HasMoreItems et IsInError. Aussi, plusieurs éléments abstraits sont ajoutés et seront à implémenter par les classes dérivant de cette classe de base : une propriété PageSize retournant la taille d'une page, une méthode (CreateFromData) permettant de créer un ViewModel à partir d'une donnée brute et une méthode LoadDataAsync retournant la donnée pour une page en particulier.

public abstract class MaSourceDeDonnees<T, TData>
     : ObservableCollection<T>, INotifyPropertyChanged
{
    protected abstract int PageSize { get; }
    protected abstract T CreateFromData(TData data);
    protected abstract Task<IPaginatedResult<TData>> 
                        LoadDataAsync(int page);
    protected MaSourceDeDonnees()
    {
        IsInError = false;
        HasMoreItems = true;
        IsLoading = true;
    }
    public new event PropertyChangedEventHandler PropertyChanged
    {
        add { base.PropertyChanged += value; }
        remove { base.PropertyChanged -= value; }
    }
    private bool _isLoading;
    public bool IsLoading
    {
        get => _isLoading;
        set
        {
            _isLoading = value;
            OnPropertyChanged(
    new PropertyChangedEventArgs(nameof(IsLoading)));
        }
    }
    private bool _isInError;
    public bool IsInError
    {
        get => _isInError;
        set
        {
            _isInError = value;
            OnPropertyChanged(
    new PropertyChangedEventArgs(nameof(IsInError)));
        }
    }
    private bool _isEmpty;
    public bool IsEmpty
    {
        get => _isEmpty;
        set
        {
            _isEmpty = value;
            OnPropertyChanged(
    new PropertyChangedEventArgs(nameof(IsEmpty)));
        }
    }
    private bool _hasMoreItems;
    public bool HasMoreItems
    {
        get => _hasMoreItems;
        private set
        {
            _hasMoreItems = value;
            if (!value && Count <= 0)
            {
                IsEmpty = true;
            }
        }
    }
    public async Task LoadMoreItemsAsync()
    {

    }
}

Il reste à faire le chargement de données en lui-même. J'utilise un ici un lock asynchrone pour éviter tout problème de chargement concurrent de données. Je stocke et j'utilise également le nombre de chargements de page demandé pour éviter de faire plusieurs fois le chargement de la même page. On n'aura donc qu'un seul chargement de page à la fois. Personnellement, je ne déclare la liste comme en erreur que si aucun élément n'a été déjà chargé mais c'est un choix que vous pouvez adapter à votre besoin.

private int reloadAsked = 0;
private readonly AsyncLock LoadMoreItemsAsyncLock 
        = new AsyncLock();

public async Task LoadMoreItemsAsync()
{
  reloadAsked = reloadAsked + 1;
  using (await LoadMoreItemsAsyncLock.LockAsync())
  {
    if (reloadAsked <= 0 || (!HasMoreItems && !IsInError))
    {
      return;
    }
    reloadAsked = 0;
    try
    {
      IsLoading = true;
      IsInError = false;
      
      // calcul de la page à demander
      var page = Count / PageSize + 1;

      // chargement des données
      var queryResponse = await LoadDataAsync(page);

      // ajoutons les résultats à la source de données
      foreach (var c in queryResponse)
      {
        Add(CreateFromData(c));
      }

      // Plus d'éléments à afficher ?
      HasMoreItems = queryResponse
        .PaginationInfo.TotalNumberOfItems > Count;
    }
    catch
    {
      IsInError = !this.Any();
      HasMoreItems = false;
      return;
    }
    finally
    {
      IsLoading = false;
    }
  }
}

 

Modification de l'adapter

La première étape consiste à modifier la méthode OnBindViewHolder : si on est en train de faire l'affichage d'un élément à moins de 10 éléments de la fin de la source de données, alors on demande un chargement. Aussi si l'on demande l'affichage du dernier élément (le loader), alors on se content de mettre à jour son affichage.

public override void OnBindViewHolder(RecyclerView.ViewHolder holder, int position)
{
    if (position + 10 > _data.Length)
    {
        _dataSource.LoadMoreItemsAsync(loadAllEpisodes: true);
    }

    var realHolder = (MonViewHolder)holder;
    if(position==_data.Length)
    {
        realHolder.UpdateLoader(_data.IsLoading);
    }
    else{
        var item = _data[position];
        realHolder.Fill(item);
    }
}

On va par ailleurs s'abonner aux changements de valeur de la source de données lors de la création de l'Adapter afin d'y réagir de manière adéquate en demandant un ré-affichage des données à l'adapter avec la méthode NotifyDataSetChangedUn appel à NotifyItemChanged sur la position du loader est aussi fait pour demander un re-binding des données de celui-ci. On prend soin de se mettre dans une configuration avec des identifiants stables afin de conserver de bonnes performances.

public MonRecyclerAdapter(MaSourceDeDonnees<VM,Data> data)
{
    HasStableIds = true;
    _data = data;
    commentsDatasource.CollectionChanged 
        += CommentsDatasource_CollectionChanged;
}
private void CommentsDatasource_CollectionChanged(
        object sender, 
        NotifyCollectionChangedEventArgs e)
{
    NotifyItemChanged(_lastFooterPosition);
    NotifyDataSetChanged();
}

Finalement, on surchargera la méthode ItemCount pour toujours retourner un élément supplémentaire que le nombre réel d'éléments : cela correspondra à notre Loader. 

public override int ItemCount => _data.Count + 1;

Et si je ne veux pas que cela soit automatique ?

Ce code permet un chargement automatique des pages et cela peut même être transparent pour votre utilisateur si l'API répond suffisamment rapidement. Cependant, il peut être intéressant dans certains scenarii de laisser l'utilisateur indiquer explicitement qu'il veut charger une page supplémentaire. Ce cas est assez simple à gérer avec ce qu'on a mis en place :

  • On ajoute un bouton dans la vue correspondant au dernier élément.
  • On s'abonne au click sur ce bouton et on appelle la méthode LoadMoreItemsAsync() dans le gestionnaire d'événement.
  • On masque le bouton s'il n'y a plus d'éléments à charger.

Pour cela, je doit donner à mon ViewHolder la source de données et ma méthode Fill l'utilisera pour calculer l'affichage correct du loader, du bouton, ou pour masquer ces deux éléments.

public class MonViewHolder {

    public MonViewHolder(View view, MaSourceDeDonnees data ){
        LoadMoreButton = view.FindViewById(Resource.Id.LoadMoreButton);
        LoadMoreButton.Click += (_, __) => { data.LoadMoreItemsAsync(); };
        
        ...
    }

    public void Fill()
    {
        Loader.Visibility = _data.IsLoading 
                ? ViewStates.Visible : ViewStates.Gone;
        LoadMoreButton.Visibility = 
                !_data.IsLoading && _data.HasMoreItems ? 
                     ViewStates.Visible : ViewStates.Gone;
    }
}

Cela permet par exemple d'avoir ensuite ce genre d'affichage :

Screenshot_2019-03-21-23-23-26-368_com.jonathanantoine.TVST.jpeg

Happy coding :)

Photo de profil

Ces billets pourraient aussi vous intéresser

Vous nous direz ?!

Commentaires

comments powered by Disqus