Loupe

Android : ajouter des en-têtes repliables (collapsible headers) sur vos RecyclerView

Une demande fréquente des utilisateurs, une fois que vous leur avez proposé d'avoir une liste d'éléments groupée, est de pouvoir replier/déplier ces groupes. L'usage sous-jacent est de pouvoir accéder plus rapidement sans scroller au groupe qui les intéresse. Dans cet article, nous verrons comment ajouter des en-têtes repliables (collapsible headers) sur vos RecyclerView.

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. Android : ajouter des sticky headers sur vos RecyclerView
  3. Cet article.
  4. Suspens.

Voici un exemple en vidéo de ce que l'on souhaite faire dans une application de gestion de séries :

Mise en place théorique

Avec la logique et plomberie déjà créée dans nos précédents articles, vous allez voir que cela va être assez direct à implémenter. 

  1. On s'abonne dans les ViewHolder au click sur un en-tête,
  2. Sur un click sur un en-tête, on inverse l'état souhaité pour l'en-tête,
  3. On demande un rechargement du RecyclerView.
  4. On modifier le code de notre Adapter de RecyclerView pour ne retourner que l'élément en-tête d'un groupe si celui-ci est replié.

Il restera un cas un dernier cas à gérer : les sticky-headers ! En effet, l'élément que l'on dessine par dessus notre RecyclerView ne peut pas recevoir de click même si l'utilisateur le voit, et cela sera l'élément en-dessous qui va recevoir le click. Heureusement, nous allons voir qu'il y a une solution.

Abonnement au click et stockage de l'état de l'en-tête

Lors de la création de notre ViewHolder nous allons nous abonner sur le click de la vue crée pour afficher un en-tête. Comme nous aurons besoin de demander un ré-affichage du RecyclerView, nous passerons aussi en paramètre l'adapter que nous stockerons en tant que champ privé.

public MyViewHolder(View view, RecyclerView.Adapter adapter) 
   : base(view)
{
   _adapter= adapter;
   Title = view.FindViewById<TextView>(Resource.Id.Header_Title);
   Image = view.FindViewById<ImageView>(Resource.Id.Header_ArrowUpDownImage);
   view.Click += (_, __) => HeaderClick();
}

Stockage de l'état et demande de rechargement

Dans le gestionnaire d'événement du click sur le Header, on stocke son nouvel état (replié ou pas) sur la donnée correspond à l'en-tête et on demande à l'adapter un nouvel affichage en appelant NotifyDataSetChanged après avoir remis à plat la donnée. L'élément correspondant à un en-tête a été stocké précédemment sur le ViewHolder dans la méthode Fill :

internal void HeaderClick()
{
   var headerItem = HeaderItem;
   var adapter = _adapter;
   headerItem.IsCollapsed = !headerItem.IsCollapsed;
   adapter.Flatten(adapter.OriginalsShows);
   adapter.NotifyDataSetChanged();
}

Reste à revoir un peu le code de notre méthode Flatten pour ne garder que les en-têtes des groupes repliés. Voici une version simplifiée du code : 

private Tuple<HomeShowGroup, ShowInListViewModel>[] _data;
private void Flatten(
     IList<IGrouping<HomeShowGroup, ShowInListViewModel>> casted)
{
    if (casted == null)
    {
      _data = new Tuple<HomeShowGroup, ShowInListViewModel>[] { };
      return;
    }
 
    var howMuch = casted.Sum(g => 1 + g.Count);
    var flat = new Tuple<HomeShowGroup, ShowInListViewModel>[howMuch];
 
    var iterator = 0;
    for (int i = 0; i < casted.Count; i++)
    {
     var group = casted[i];
     flat[iterator] 
       = new Tuple<HomeShowGroup, ShowInListViewModel>(group.Key, null);
     iterator++;
 
    // ajout des éléments que si pas replié
    if(!group.isCollasped)
      {
        foreach (var item in group)
        {
         flat[iterator] 
           = new Tuple<HomeShowGroup, ShowInListViewModel>(null, item);
         iterator++;
        }
     }

    }
    _data = flat;
}

Prise en compte des sticky headers

Si vous n'avez pas ajouté cette fonctionnalité, votre travail est terminé ; sinon il va falloir les gérer. Pour rappel, le problème est le suivant : l'élément visuel que l'on dessine par dessus notre RecyclerView ne peut pas recevoir de click, même si l'utilisateur le voit, et cela sera l'élément en-dessous qui va recevoir le click.  Pour un rappel sur la mise en place des sticky headers, suivez ce lien !

La solution n'est pas simple car elle implique plusieurs composants à mettre en place : il va falloir intercepter les différents éléments tactiles reçus par le RecyclerView, vérifier si un tap est effectué par l'utilisateur et si c'est le cas :

  • Bloquer le tap (pour ne pas clicker l'élément dessous) s'il est fait en haut du RecyclerView (nous avions stocké la taille de l'en-tête affiché sur notre StickyItemDecoration justement à cet effet) et déclencher l'appel au HeaderClick précédemment codé.
  • Laisser passer le tap si le tap n'est pas fait sur un header en haut du RecyclerView.

Pour détecter les tap, il va falloir créer une classe dérivant de SimpleOnGestureListener. Celle-ci sera nourrie par les événements tactiles dans un second temps.

Elle nécessite comme paramètre de son constructeur :

  • l'instance de StickyItemDecoration utilisée. Elle permettra de connaître la hauteur de l'en-tête affiché ainsi que sa position dans l'adapter du RecyclerView.
  • l'instance du RecyclerView utilisé, afin d'obtenir l'élément tappé et d'appeler la méthode HeaderClicked si nécessaire. 

On surcharge la méthode OnSingleTapUp pour savoir quand un tap est effectué et on peut alors utiliser la méthode de base du RecyclerAdapter FindViewHolderForAdapterPosition pour trouver le ViewHolder correspondant à l'en-tête affiché.

public class GestureDetectorListener : SimpleOnGestureListener
{
  private readonly RecyclerView _recyclerView;
  private readonly StickyItemRecyclerDecoration _itemDecoration;
  public GestureDetectorListener(
      RecyclerView recyclerView, 
      StickyItemRecyclerDecoration itemRecyclerDecoration)
  {
    _recyclerView = recyclerView;
   _itemDecoration = itemRecyclerDecoration;
  }
  public override bool OnSingleTapUp(MotionEvent e)
  {    
    var y = e.GetY();
    if (y <= _itemDecoration.LastSeenHeight)
    {
      if (e.Action == MotionEventActions.Up)
      {
        var lastSeenHeaderPosition 
            = _itemDecoration.LastSeenHeaderPosition;
        var adapter = _recyclerView.GetAdapter() 
            as MonRecyclerAdapter;

        var viewHolder = _recyclerView
            .FindViewHolderForAdapterPosition(lastSeenHeaderPosition) 
            as MonViewHolder;

        viewHolder?.HeaderClick();
        
        _recyclerView.ScrollToPosition(lastSeenHeaderPosition);
      }
      return true;
    }
    
    return base.OnSingleTapUp(e);
  }
}

 

Pour intercepter les événements tactiles, il va falloir créer une classe dérivant de RecyclerView.SimpleOnItemTouchListener pour recevoir les inputs faits par le RecyclerView. Celui-ci prendra les mêmes paramètres dans son constructeur que notre GestureListener qu'il se chargera d'ailleurs d'instancier et d'ajouter dans un GestureDetector. Ensuite, à chaque événement tactile, il donnera l'instance de MotionEvent au GestureDetector qui se chargera de détecter si un Tap a été fait ou non. Aussi, si l'événement est réalisé sur la hauteur correspondant à un en-tête dessiné en haut du RecyclerView, on retourne true afin d'empêcher les clicks sur les éléments dessous.

public class BlockTouchOnTopHeaderTouchListener 
    : RecyclerView.SimpleOnItemTouchListener
{
    private StickyItemRecyclerDecoration _itemDecoration;
    private GestureDetector _gestureDetector;
    public BlockTouchOnTopHeaderTouchListener(
      RecyclerView recyclerView, 
      StickyItemRecyclerDecoration itemDecoration)
    {
      _itemDecoration = itemDecoration;
      var detectListener = new GestureDetectorListener(
            recyclerView, 
            itemRecyclerDecoration);
      _gestureDetector = new GestureDetector(
          recyclerView.Context, 
          detectListener);
    }
    public override bool OnInterceptTouchEvent(
      RecyclerView rv, 
      MotionEvent e)
    {
      _gestureDetector.OnTouchEvent(e);
      var y = e.GetY();
      if (y <= _itemDecoration.LastSeenHeight)
      {
        if (e.Action == MotionEventActions.Up)
        {
          return true;
        }
      }
      return base.OnInterceptTouchEvent(rv, e);
    }
}

Il ne reste alors plus qu'à brancher tout cela sur notre RecyclerView : 

 
 _recyclerView.AddOnItemTouchListener(
     new BlockTouchOnTopHeaderTouchListener(
         _recyclerView, 
          _stickyItemRecyclerDecoration)); 

Et c'est maintenant terminé !! Happy coding !

 

Photo de profil

Ces billets pourraient aussi vous intéresser

Vous nous direz ?!

Commentaires

comments powered by Disqus