Loupe

Ajouter des swipes actions sur vos Listview avec Xamarin Forms

Il est de plus en plus courant de voir apparaitre des swipes actions dans nos listes de contenus. Cela permet d'effectuer rapidement une action sur un élément une fois que l'utilisateur à découvert la fonctionnalité. Dans cet article nous verrons comment créer un contrôle permettant d'ajouter des swipe actions a n'importe quelle vue Xamarin Forms.

Voici un exemple du résultat final dans une application de gestion de Podcasts que je développe sur mon temps libre :

Mise à disposition du code

Le code de cet article est disponible sur GitHub !

Principe théorique

Pour atteindre cet objectif, je vais créer un contrôle SwipeWrapper contenant :

  • Une propriété CenterView qui sera le contenu principal du Wrapper,
  • Une propriété LeftView qui sera le contenu affiché à gauche lors du swipe,
  • Une propriété RightView qui sera le contenu affiché à droite lors du swipe
  •  Des événements LeftActionTriggered et RightActionTriggered qui seront déclenchés lorsque le swipe sera "concrétisé" : si l'utilisateur tire suffisamment vers la droite ou la gauche.
  • Quelques autres propriétés pour personnaliser le tout !

On affichera donc la vue Center au centre et les deux autres sur les côtés. Lors d'un swipe on effectuera alors tout simplement une translation des vues du bon côté. 

Capture d’écran 2018-07-13 à 14.02.31.png

Déclaration des propriétés

Afin de pouvoir faire du Binding, les propriétés seront déclarées sous la forme de BindableProperty. Voici un exemple pour CenterView qui appelle une méthode GenerateView à chaque changement de valeur.

  public static readonly BindableProperty CenterViewProperty =
   BindableProperty.Create(
    nameof(CenterView),
    typeof(View),
    typeof(SwipeWrapper),
    propertyChanged: OnCenterViewPropertyChanged);

  public View CenterView
  {
   get => (View)GetValue(CenterViewProperty);
   set => SetValue(CenterViewProperty, value);
  }
  private static void OnCenterViewPropertyChanged
     (BindableObject bindable, object oldValue, object newValue)
  {
   ((SwipeWrapper)bindable).GenerateView();
  }

Initialisation de la vue

On va simplement créer des Frame pour contenir nos différentes vues et l'on veillera à initialiser correctement leurs propriétés pour éviter des effets d'ombrages et autres petits amusements. La vue gauche sera alignée à droite et la vue droite sera alignée à gauche. On applique aussi ici les différentes couleurs de la configuration.

_rightContainer = new Frame                
{
    BackgroundColor = RightActionColor,
    BorderColor = RightActionColor,
    HasShadow = false,
    Margin = new Thickness(0),
    Padding = new Thickness(0),
    CornerRadius = 0,
    HorizontalOptions = new LayoutOptions
    {
        Alignment = LayoutAlignment.End
    },
    VerticalOptions = new LayoutOptions
    {
        Alignment = LayoutAlignment.Fill
    }
};

_leftContainer = new Frame
{
    BackgroundColor = LeftActionColor,
    BorderColor = LeftActionColor,
    HasShadow = false,
    Margin = new Thickness(0),
    Padding = new Thickness(0),
    CornerRadius = 0,
    HorizontalOptions = new LayoutOptions
    {
        Alignment = LayoutAlignment.Start
    },
    VerticalOptions = new LayoutOptions
    {
        Alignment = LayoutAlignment.Fill
    }
};

_innerGrid = new Grid
{
    ColumnSpacing = 0,
    Padding = new Thickness(0),
    Margin = new Thickness(0)
};

Content = _innerGrid;

Ensuite à chaque changement de valeur de CenterView, LeftView ou RightView on assignera ou non le contenu des frames :

private void GenerateView()
{
    if (CenterView == null)
    {
        return;
    }

    _disablePan = false;
    _innerGrid.TranslationX = 0;
    _innerGrid.Children.Clear();

    if (RightView != null)
    {
        _innerGrid.Children.Add(_rightContainer);
        _rightContainer.Content = RightView;
    }

    if (LeftView != null)
    {
        _innerGrid.Children.Add(_leftContainer);
        _leftContainer.Content = LeftView;
    }

    _innerGrid.Children.Add(CenterView);
}                                                   

Finalement, il est important de s'abonner à chaque changement de Layout pour ré-assigner les tailles à nos éléments Frames :

private void OnLayoutChanged(object sender, EventArgs e)
{
    _rightContainer.WidthRequest = Width;
    _rightContainer.Margin = new Thickness(0, 0, -Width, 0)

    _leftContainer.WidthRequest = Width;
    _leftContainer.Margin = new Thickness(-Width, 0, 0, 0);
}                                                          

Détection et états du swipe

La détection du swipe se fait très simplement en Xamarin Forms à l'aide d'un PanGestureRecoginzer :

var panGesture = new PanGestureRecognizer();
panGesture.PanUpdated += OnPanUpdated;
GestureRecognizers.Add(panGesture);       

La méthode OnPanUpdated va nous permettre de gérer 4 différents états :

  •   Started: un swipe commence, on initialise le processus.
  •   Running : un swipe est en cours, on fait la translation vers la bonne valeur.
  •   Canceled: un swipe est annulé, on annule tout aussi (sauf si l'utilisateur revient....).
  •   Completed : le swipe est terminé : on calcule pour savoir si le swipe doit valider l'action ou non. Si c'est le cas, on effectue une petite animation (on affiche complètement la vue de "swipe").

Initialisation du swipe

En théorie, il n'y a pas grand-chose à faire à part indiquer que l'on est en train d'effectuer un swipe dans un booléen.

En pratique, le swipe peut être perturbé (arrêt aléatoire de sa détection) lorsqu'il est dans une Listview ayant le focus ou avec un PullToRefresh activé. Pour éviter cette petite contrariété on va ajouter une propriété ListView qui sera à fournir lors de l'utilisation du contrôle et on désactivera le PullToRefresh pendant le swipe latéral. On pourrait parcourir l'arbre visuel pour trouver un parent de type ListView mais je préfère éviter cette opération coûteuse. À noter que je stocke la valeur initiale du "IsPullToRefresh" pour ne le réactiver que si nécessaire à la fin du swipe.

IsSwipping = true;
if (ParentListView != null)
{
    _ParentListView_IsPullToRefreshEnabled 
        = ParentListView.IsPullToRefreshEnabled;
    ParentListView.IsPullToRefreshEnabled = false;
    ParentListView.Unfocus();
}                                                 

Translation des vues

La distance de swipe parcourue est fournie en paramètre du gestionnaire d'événement sur la propriété TotalX qui nous permet de créer une méthode HandleTouch responsable de faire les translations correctement. On utilise ensuite les propriétés TranslateX pour appliquer la translation. À chaque appel, on recalcule la couleur de la vue Swippée (droite ou gauche) pour ne la colorer que si la distance d'activation minimum est parcourue. Ce calcul est fait dans une méthode Helper "CanTriggerXXXAction" :

private void HandleTouch(double xTranslation, 
       bool animated = true)
{
 _previousXTranslation = xTranslation;

 // scrolling back to "normal-centered" view
 if (Math.Abs(xTranslation) < 0.002)
 {
  if (_translatedLeftOrRight)
  {
   if (animated)
   {
    _innerGrid
     .TranslateTo(0, 0,
        length: 200, easing: Easing.CubicOut);
   }
   else
   {
    _innerGrid.TranslationX = 0;
   }
   _translatedLeftOrRight = false;
  }
 }
 // scrolling to left if the RightView exists
 else if (xTranslation < 0 && RightView != null)
 {
  double totalX = Math.Max(xTranslation, -Width / 2);
  _innerGrid.TranslationX = totalX;
  _rightContainer.BorderColor = 
   _rightContainer.BackgroundColor = 
    CanTriggerRightAction(totalX) ? 
    RightActionColor : NotActionnableColor;
  _translatedLeftOrRight = true;
 }
 // scrolling to right if the LeftView exists
 else if (xTranslation > 0 && LeftView != null)
 {
  double totalX = Math.Min(xTranslation, Width / 2);
  _innerGrid.TranslationX = totalX;
  _leftContainer.BorderColor = 
   _leftContainer.BackgroundColor 
   = CanTriggerLeftAction(totalX) ?
     LeftActionColor : NotActionnableColor;
  _translatedLeftOrRight = true;
 }
}                 

Complétion du swipe

La fin du swipe est très simple à mettre en place avec des appels à des méthodes existantes :

IsSwipping = false;

if (ParentListView != null)
{
    ParentListView
        .IsPullToRefreshEnabled 
         = _ParentListView_IsPullToRefreshEnabled;
}
if (RightView != null 
    && CanTriggerRightAction(_previousXTranslation))
{
    AnimRightActionAsync();
}
else if (LeftView != null
         && CanTriggerLeftAction(_previousXTranslation))
{
    AnimLeftActionAsync();
}
else
{
    HandleTouch(0, animated: false);
}                                                        

L'animation de "complétion" est faite dans AnimXXXAction en orchestrant des appels à TranslateTo. Aussi on prend soin de désactiver le Pan pendant une animation sans quoi on aurait des effets de bord visuel un peu étranges...

private async Task AnimLeftActionAsync()
{
    _disablePan = true;
    await _innerGrid
        .TranslateTo(Width, 
                     0, length: 200,
                     easing: Easing.CubicOut);
    await Task.Delay(200);
    _disablePan = false;
    RaiseLeftActionTriggered(BindingContext);
    HandleTouch(0, animated: false);
}                                             

Code spécifique Android

Ce code fonctionne parfaitement en théorie mais une fois déployé sur Android on se rend compte que cela ne marche pas du tout dans une Listview : celle-ci intercepte les interactions tactiles et empêche le PanGestureRecoginizer de fonctionner correctement. La solution consiste (merci beaucoup les forums Xamarin !) à créer un ViewRenderer pour notre contrôle XamarinForm qui se chargera de ne plus transmettre les événements tactiles à sa vue parente. On ne le fera que si un Swipe est en cours pour permettre de continuer à scroller verticalement  :

public class SwipeWrapperRenderer 
 : ViewRenderer<SwipeWrapper, View>
{
 public SwipeWrapperRenderer(Context context)
  : base(context)
 {
 }

 private bool _disallowed;
 public override bool OnTouchEvent(MotionEvent e)
 {
  switch (e.Action)
  {
   case MotionEventActions.Move:
   case MotionEventActions.Down:
    if (Element.IsSwipping && !_disallowed)
    {
     RequestDisallowInterceptTouchEvent(true
     _disallowed = true;
     return true;
    }
    break;

   case MotionEventActions.Cancel:
   case MotionEventActions.Up:
    RequestDisallowInterceptTouchEvent(false);
    _disallowed = false;
    break;
  }

  return base.OnTouchEvent(e);
 }
}                

Bien sûr, on n'oublie pas de déclarer notre ViewRenderer :

[assembly: ExportRenderer(typeof(SwipeWrapper), typeof(SwipeWrapperRenderer))]

Code spécifique iOS

Sur iOS, cela fonctionne aussi très bien en théorie mais on se rend compte sur un vrai téléphone que dans une listview, le scroll vertical est alors désactivé. Par chance, pas besoin de Renderer cette fois-ci mais une configuration dans la partie "Forms" suffit :

Xamarin.Forms.Application.Current
       .On<Xamarin.Forms.PlatformConfiguration.iOS>()
       .SetPanGestureRecognizerShouldRecognizeSimultaneously(true);

Conclusion

Mission accomplie, le swipe fonctionne et il peut être mis en place assez rapidement dans un projet ! Par contre nous sommes encore du loin du code unique qui fonctionne sur toutes les plateformes avec Xamarin Forms :(

via GIPHY

Happy coding :)

 

Photo de profil

Ces billets pourraient aussi vous intéresser

Vous nous direz ?!

Commentaires

comments powered by Disqus