Android : ajouter des sticky headers sur vos RecyclerView
Le parcours d'une liste d'éléments groupés peut facilement perdre vos utilisateurs si les groupes possèdent beaucoup d'éléments : une fois le titre du groupe masqué par le scroll, il ne sait plus dans lequel il est sans avoir à le mémoriser. Une solution que j'aime mettre en place consiste à avoir des sticky-headers : les en-têtes de la liste scrollent avec le contenu jusqu'à atteindre le haut de l'écran auquel ils restent attachés jusqu'à être poussés par le prochain en-tête. Dans cet article nous verrons comment mettre en place ce comportement sur Android avec un RecyclerView.
Voici un exemple de ce type de fonctionnement dans une application de suivi de séries :
Le comportement des en-têtes à remarquer dans la vidéo est celui-ci : ils sont "maintenus" en haut du RecylerView lors du scroll sinon au sein du contenu de si le haut de l'écran n'est pas atteint :
Pour rappel, cet article fait partie d'une série sur le composant RecyclerView d'Android :
- Android : Optimiser l'affichage de vos listes en utilisant un RecyclerView
- Cet article.
- Ajouter des en-têtes repliables (collapsible headers) sur vos RecyclerView
- Suspens
Mise en place technique
Toute notre solution technique repose sur la mise en place de décorations d'éléments (ItemDecoration dans le code) sur un RecylerView. Une classe itemDecoration permet littéralement de dessiner au dessus ou en dessous des éléments du RecylerView à un emplacement (offset) donné. Cela ne sera pas utilisé dans notre exemple mais il est tout à fait possible d'ajouter plusieurs ItemDecorations sur un RecyclerView qui seront dessinés dans l'ordre de leur ajout.
Nous allons donc créer notre propre ItemDecoration qui va effectuer ce travail :
- Créer une fois pour toute un ViewHolder correspondant à un en-tête.
- Récupérer les différents en-têtes actuellement affichés par le RecyclerView.
- Sélectionner l'en-tête le plus "haut" (celui que l'on devra afficher en "sticky").
- Demander à l'adapter du RecyclerView la position de la donnée de l'en-tête à afficher (l'adapter possède la liste de données complètes et on ne parcourait que les "vues" affichées).
- Mettre à jour le ViewHolder de l'en-tête précédemment créé avec les données correspondant à l'en-tête à afficher.
- Dessiner la vue du ViewHolder au dessus des éléments du RecyclerView à la bonne position.
Dans le cas ou aucun en-tête n'est trouvé dans la vue affichée alors on devra détecter l'en-tête correspondant au premier élément affiché dans la vue.
Créer notre propre ItemDecoration
La création de l'ItemDecoration est simple : on ajoute une classe qui dérive de RecyclerView.ItemDecoration du SDK Android.
Etant donné que nous avons vu le processus fonctionnel auparavant, je sais quoi lui demander comme paramètre obligatoire dans le constructeur de notre ItemDecoration :
- Un prédicat pour savoir si un élément à une position donnée est un en-tête.
- Une fonction capable de retourner l'en-tête précédant une position donnée dans la liste.
- Une fonction capable de créer un ViewHolder pour un en-tête.
public class StickyItemRecyclerDecoration : RecyclerView.ItemDecoration { private readonly Predicate<int> _isSectionPredicate; private readonly Func<int, int?> _findPreviousHeaderToDisplay; private readonly Func<RecyclerView, ViewHolder> _viewHolderCreation; public StickyItemRecyclerDecoration( Predicate<int> isSectionPredicate, Func<int, int?> findPreviousHeaderToDisplay, Func<RecyclerView, ViewHolder> viewHolderCreation) { _isSectionPredicate = isSectionPredicate; _findPreviousHeaderToDisplay = findPreviousHeaderToDisplay; _viewHolderCreation = viewHolderCreation; } public override void OnDrawOver (Canvas c, RecyclerView parent, RecyclerView.State state) { base.OnDrawOver(c, parent, state); } }
Cycle de vie du ViewHolder
Pour créer un ViewHolder, rien de plus simple : on utilise la méthode dédiée de l'adapter de notre RecyclerView. On aura donc la fonction _viewHolderCreation qui sera définie de cette manière et donnée au constructeur de l'ItemDecoration :
position => _curAdapter?.OnCreateViewHolder( position, RecyclerAdapter.GroupHeaderViewType)
La création du ViewHolder est une opération couteuse car elle nécessite l'inflation d'une vue XML et il faut donc limiter au maximum son occurence. On va donc garder une instance de notre ViewHolder attachée à notre ItemDecoration et créer celle-ci sur l'appel à OnDrawOver si elle n'existe pas encore :
if (_viewholder == null) { var viewholder = _viewHolderCreation(parent); _viewholder = viewholder; FixLayoutSize(_viewholder.ItemView, parent); }
Il reste un problème à régler : le ViewHolder ainsi créé n'est pas réellement affiché et n'a pas de connaissance de la taille qu'il a a sa disposition. De plus, sa taille peut varier en fonction du contenu qu'il affiche. Pour corriger ce problème, il faudra appeler une méthode utilitaire "FixLayoutSize" à la création du ViewHolder et à chaque modification de la donnée affichée = c'est à dire à chaque changement de la position de l'en-tête actuellement dessiné. On donnera à cette méthode le RecylerView parent et la vue (celle du ViewHolder) à dimensionner :
private void FixLayoutSize(View view, ViewGroup parent) { int widthSpec = View.MeasureSpec.MakeMeasureSpec( parent.Width, MeasureSpecMode.Exactly); int heightSpec = View.MeasureSpec.MakeMeasureSpec( parent.Height, MeasureSpecMode.Unspecified); int childWidth = ViewGroup.GetChildMeasureSpec( widthSpec, parent.PaddingLeft + parent.PaddingRight, view.LayoutParameters?.Width ?? parent.Width); int childHeight = ViewGroup.GetChildMeasureSpec( heightSpec, parent.PaddingTop + parent.PaddingBottom, view.LayoutParameters?.Height ?? 0); view.Measure(childWidth, childHeight); view.Layout(0, 0, view.MeasuredWidth, view.MeasuredHeight); _sizeFixed = true; LastSeenHeight = view.MeasuredHeight; }
Merci ce blog en anglais pour l'exemple de code ! Vous remarquerez que je stocke la dernière taille de Header trouvée car j'en aurais besoin dans ... un prochain article :)
Algorithme de dessin
J'aime bien ce mot algorithme car cela fait un peu "formule magique" quand on doit expliquer quelque chose :D. En attendant voici la transposition en C# du processus théorique précédemment décrit :
bool headerFoundInTheDisplayedItems = false; View headerToDraw = null; View otherHeaderFound = null; int positionInAdapterToDrawn = -1; Adapter adapter = parent.GetAdapter(); for (int i = 0; i < parent.ChildCount && i < adapter.ItemCount; i++) { View child = parent.GetChildAt(i); // est-ce que c'est un header ? int positionInAdapter = parent.GetChildAdapterPosition(child); if (!_isSectionPredicate(positionInAdapter)) { continue; } // si oui et si pas déjà traité if (child.Top <= 0) { headerFoundInTheDisplayedItems = true; positionInAdapterToDrawn = positionInAdapter; headerToDraw = child; } else if (LastSeenHeaderPosition == positionInAdapter && child.Top >= 0) { LastSeenHeaderPosition = int.MaxValue; } else if (child.Top >= 0 && child != headerToDraw) { // multi header in the view, take the top one if (otherHeaderFound == null || child.Top < otherHeaderFound.Top) { otherHeaderFound = child; } } } if (otherHeaderFound != null && otherHeaderFound == headerToDraw) { otherHeaderFound = null; } if (headerFoundInTheDisplayedItems) { if (parent.ChildCount > 0) { var firstChild = headerToDraw ?? otherHeaderFound ?? parent.GetChildAt(0); int firstChildPositionInAdapter = parent.GetChildAdapterPosition(firstChild); var previousHeaderPositionInAdapter = _findPreviousHeaderToDisplay(firstChildPositionInAdapter) ?? 0; if (previousHeaderPositionInAdapter >= 0) { positionInAdapterToDrawn = previousHeaderPositionInAdapter; } } adapter.BindViewHolder(_viewholder, positionInAdapterToDrawn); LastSeenHeaderPosition = positionInAdapterToDrawn; DrawHeader( c, _viewholder.ItemView, headerToDraw.Top, 0, parent, positionInAdapterToDrawn); } else if (parent.ChildCount > 0) { var firstChild = parent.GetChildAt(0); int firstChildPositionInAdapter = parent.GetChildAdapterPosition(firstChild); var previousHeaderPositionInAdapter = _findPreviousHeaderToDisplay(firstChildPositionInAdapter) ?? 0; if (previousHeaderPositionInAdapter >= 0) { LastSeenHeaderPosition = previousHeaderPositionInAdapter; adapter.BindViewHolder( _viewholder, previousHeaderPositionInAdapter); DrawHeader( c, _viewholder.ItemView, 0, 0, parent, previousHeaderPositionInAdapter); } }
Dessiner un en-tête
La méthode DrawHeader permet de dessiner un en-tête sur un Canvas donné par le Runtime Android en paramètre de la méthode OnDrawOver.
On s'assure que la taille a bien été donnée à l'en-tête, puis on applique la translation nécessaire pour positionner l'en-tête au bon endroit. Finalement on dessine la vue du ViewHolder qui a été remplie avec les bonnes données dans l'algorithme précédent :
private void DrawHeader( Canvas c, View headerView, int childTop, int deltaToAdd, RecyclerView parent, int indexDrawn) { if (!_sizeFixed || _lastDrawn != indexDrawn) { FixLayoutSize(_viewholder.ItemView, parent); _lastDrawn = indexDrawn; } c.Save(); c.Translate(0, Math.Max(0, childTop - headerView.Height) + deltaToAdd); headerView.Draw(c); c.Restore(); }
Modification de l'adapter du RecyclerView
Il reste deux méthodes à fournir à notre ItemDecoration :
- Un prédicat pour savoir si un élément à une position donnée est un en-tête.
- Une fonction capable de retourner l'en-tête précédant une position donnée dans la liste.
Le prédicat est simple à fournir car il existe déjà une méthode GetItemViewType que l'on peut surcharger sur un Adapter de RecylerView. Je vais donc l'utiliser directement pour créer un prédicat :
pos => _curAdapter?.GetItemViewType(pos) == RecyclerAdapter.GroupHeaderViewType
La deuxième fonction sera pour sa part du code à écrire complètement et je vais la nommer GetPreviousHeaderFromPosition. Son implémentation est d'une complexité indéniable :
internal int GetPreviousHeaderFromPosition(int curPos) { if (curPos <= 0) { return 0; } return _previousSection[curPos]; }
J'utilise en effet un tableau "_previousSection" que je calcule automatiquement dans la méthode "Flatten" évoquée dans mon précédent article. Parcourant déjà la liste complète de mes éléments dans cette méthode, je peux me permettre de créer assez facilement un tableau contenant la dernière section rencontrée lors du parcours afin d'avoir un code simple et optimisé pour GetPreviousHeaderFromPosition.
Enregistrement de l'ItemDecoration
Il ne reste plus qu'à utiliser les méthode en place et ajouter une instance fraîchement créée de notre ItemDecoration sur un RecyclerView :
var itemDeco = new StickyItemRecyclerDecoration( isSectionPredicate: pos => _curAdapter?.GetItemViewType(pos) == RecyclerAdapter.GroupHeaderViewType, findPreviousHeaderToDisplay: p => _curAdapter ?.GetPreviousHeaderFromPosition(p), viewHolderCreation: r => _curAdapter ?.OnCreateViewHolder(r, RecyclerAdapter.GroupHeaderViewType)); _recyclerView.AddItemDecoration(itemDeco);
Happy coding :)
Commentaires