Cet article explique comment reproduire le bouton réaction de Facebook, utilisant Reactive Programming, Overlay, Animation, Streams, BLoC Pattern et GestureDetector.

Difficulté: Intermédiaire

Introduction

Récemment, quelqu’un m’a demandé comment il serait possible d’imiter le buton Facebook Reaction (=bouton réaction) en Flutter. Après réflexion, j’ai réalisé que c’était l’occasion de mettre en pratique des sujets que j’ai récemment abordés dans des articles précédents.

La solution que je vais expliquer (je l’ai appelée “reactive button”) utilise les notions suivantes:

Le code source de cet article se trouve sur GitHub.

Il est également disponible en tant que package Flutter: flutter_reactive_button.

Voici une animation qui montre le résultat de cet article.

Reactive Button Reactive Button

Besoins

Avant d’entrer dans les détails de l’implémentation, considérons d’abord comment fonctionne le Bouton de réaction Facebook:

  • Lorsqu’un utilisateur appuie sur un bouton, attend un certain temps (appelé Long Press), un panneau s’affiche à l’écran, invitant l’utilisateur à sélectionner l’une des icônes contenues dans ce panneau;
  • Si l’utilisateur déplace son doigt sur une icône, celle-ci grandit;
  • Si l’utilisateur retire son doigt de cette icône, cette dernière récupère sa taille d’origine;
  • Si l’utilisateur relâche son doigt alors qu’il se trouve encore sur une icône, cette dernière est sélectionnée;
  • Si l’utilisateur relâche son doigt alors qu’aucune icône n’est survolée, il n’y a pas de sélection;
  • Si l’utilisateur appuie simplement sur le bouton, ce qui signifie qu’il n’est pas considéré comme un Long Press, l’action est considérée comme un tap normal;
  • Nous pourrions avoir plusieurs instances de ce reactive button à l’écran;
  • Les icônes doivent être affichées dans la partie visible de l’écran (=Viewport).

Description de la Solution

Les différentes parties visuelles

Le graphique suivant montre les différentes parties impliquées dans la solution:

  • ReactiveButton

    Le ReactiveButton peut être positionné n’importe où sur l’écran. Lorsque l’utilisateur effectue un Long Press sur celui-ci, il déclenche l’affichage de ReactiveIconContainer.

  • ReactiveIconContainer

    Conteneur simple pour afficher les différents ReactiveIcons

  • ReactiveIcon

    Une icône qui peut augmenter de taille si l’utilisateur la survole.

Reactive Button Reactive Button

Overlay

Lorsqu’une application a démarré, Flutter crée et génère automatiquement un widget Overlay. Ce widget Overlay n’est rien d’autre qu’un Stack qui permet aux widgets visuels de “flotter” par-dessus les autres Widgets.

Généralement, cet Overlay est principalement utilisé par le Navigator pour afficher des Routes (= page ou écran), Dialogs, DropDown

L’image suivante illustre la notion de * Overlay *. Les widgets sont disposés les uns sur les autres.

Reactive Button Overlay Reactive Button Overlay

Chaque Widget que vous positionnez dans un Overlay doit être inséré via un OverlayEntry.

Profitant de ce concept, nous pouvons facilement afficher le ReactiveIconContainer, par-dessus tout contenu, via un OverlayEntry.

Pourquoi utiliser OverlayEntry et pas un Stack normal ?

L’une des exigences stipule que nous devons afficher la liste des icônes par-dessus n’importe quel contenu.

@override
Widget build(BuildContext context){
    return Stack(
        children: <Widget>[
            _buildButton(),
            _buildIconsContainer(),
            ...
        ],
    );
}

Widget _buildIconsContainer(){
    return !_isContainerVisible ? Container() : ...;
}

Si nous utilisions un Stack, comme indiqué ci-dessus, cela entraînerait quelques problèmes:

  1. Nous ne serions jamais certains que le ReactiveIconContainer serait systématiquement par-dessus n’importe quel contenu, puisque le ReactiveButton pourrait lui-même faire partie d’un autre Stack (et peut-être être sous un autre Widget);

  2. Il faudrait implémenter une logique pour afficher ou non le ReactiveIconContainer et donc reconstruire (=build) le Stack, ce qui ne serait pas très efficace.

Sur cette base, j’ai décidé d’utiliser les notions de Overlay et OverlayEntry pour afficher le ReactiveIconContainer.

Détection des gestes

Afin de savoir ce que nous devons faire (afficher les icônes, agrandir / réduire une icône, sélectionner …), nous devrons utiliser la détection de mouvements. En d’autres termes, la gestion des événements liés au(x) doigt(s) de l’utilisateur. Dans Flutter, il existe différentes manières de gérer l’interaction avec le(s) doigt(s) de l’utilisateur.

En Flutter, le doigt de l’utilisateur est appelé Pointer.

Pour cette solution, j’ai opté pour pour l’utilisation du GestureDetector, qui fournit tous les outils pratiques dont nous avons besoin, parmi lesquels:

  • onHorizontalDragDown & onVerticalDragDown

    un callback (=fonction de rappel) qui est appelé lorsque le pointer a touché l’écran

  • onHorizontalDragStart & onVerticalDragStart

    un callback qui est appelé lorsque le pointer commence à se déplacer sur l’écran

  • onHorizontalDragEnd & onVerticalDragEnd

    un callback qui est appelé lorsque le pointer, précédemment en contact avec l’écran, ne touche plus l’écran

  • onHorizontalDragUpdate & onVerticalDragUpdate

    un callback qui est appelé lorsque le pointer se déplace sur l’écran

  • onTap

    un callback qui est appelé lorsque le GestureDetector considère que l’utilisateur a tapé sur l’écran

  • onHorizontalDragCancel & onVerticalDragCancel

    un callback qui est appelé lorsque le GestureDetector considère que l’action qui vient d’être faite avec le pointer, qui a précédemment touché l’écran, ne conduira à aucun événement tap

Comme tout commence lorsque l’utilisateur touche l’écran au niveau du ReactiveButton, il semble naturel d’envelopper (=wrap) le ReactiveButton avec un GestureDetector, comme suit:

@override
Widget build(BuildContext context){
    return GestureDetector(
        onHorizontalDragStart: _onDragStart,
        onVerticalDragStart: _onDragStart,
        onHorizontalDragCancel: _onDragCancel,
        onVerticalDragCancel: _onDragCancel,
        onHorizontalDragEnd: _onDragEnd,
        onVerticalDragEnd: _onDragEnd,
        onHorizontalDragDown: _onDragReady,
        onVerticalDragDown: _onDragReady,
        onHorizontalDragUpdate: _onDragMove,
        onVerticalDragUpdate: _onDragMove,
        onTap: _onTap,
        child: _buildButton(),
    );
}

Remarque liée aux callbacks de type onPan…

Le GestureDetector fournit également des callbacks, appelés onPanStart, onPanCancel…, qui pourraient également être utilisés et qui fonctionnent correctement en l’absence de Scrolling Area. Etant donné que dans cet exemple, nous devons également considérer le cas où le ReactiveButton serait positionné dans une Scrollable Area, cela ne fonctionnera pas.

En effet, avec les callbacks onPan…, quand l’utilisateur fera glisser son doigt sur l’écran, cela provoquera également le défilement de la Scroll Area; ce que nous ne désirons pas.

Remarque liée au callback onLongPress

Comme vous pouvez le constater, je n’utilise pas le rappel onLongPress alors que les exigences indiquent que nous devons afficher le ReactiveIconContainer lorsque l’utilisateur appuie longuement sur le bouton. Pourquoi ?

La raison est double:

  • Je vais capturer les événements gestuels pour déterminer quelle icône est survolée/sélectionnée et en utilisant l’événement onLongPress, les mouvements de type “drag” sont ignorés (ou ne fonctionnent pas toujours correctement)

  • Peut-être aurions-nous besoin de personnaliser la durée “Long Press


Responsabilités

Voyons maintenant la/les responsabilité(s) des différentes parties …

ReactiveButton

Le ReactiveButton sera responsable de:

  • capturer les événements gestuels
  • afficher le ReactiveIconContainer lorsqu’un longPress est détecté
  • cacher le ReactiveIconContainer lorsque l’utilisateur relâche son doigt de l’écran
  • fournir à l’appelant le résultat de l’activité de l’utilisateur (onTap, onSelected)
  • afficher correctement ReactiveIconContainer sur l’écran

ReactiveIconContainer

Le ReactiveIconContainer sera uniquement responsable de:

  • construire le conteneur d’icônes
  • instancier les icônes

ReactiveIcon

Le ReactiveIcon sera responsable de:

  • montrer l’icône dans une taille différente selon qu’il soit survolée ou non
  • dire au ReactiveButton s’il est survolé ou non

Communication entre les composants

Nous venons de voir que nous devons initier une communication entre les composants afin que:

  • le ReactiveButton puisse fournir au ReactiveIcon la position du pointer sur l’écran (qui sera utilisée pour déterminer si une icône est survolée ou non)
  • le ReactiveIcon puisse dire au ReactiveButton s’il est survolé ou non

Afin de ne pas avoir de code spaghetti, je ferai appel à la notion de Streams.

De cette façon:

  • le ReactiveButton va diffuser (=broadcast) la position du pointer à quiconque est intéressé de le savoir
  • le ReactiveIcon va diffuser (=broadcast) à quiconque est intéressé, s’il est survolé ou non

L’image suivante illustre cette idée.

Reactive Button Streams Reactive Button Streams

Position exacte du ReactiveButton

Comme le ReactiveButton pourrait être placé n’importe où dans la page, nous devons obtenir sa position pour pouvoir afficher le ReactiveIconContainer et comme l’écran pourrait être plus grand que la fenêtre et le ReactiveButton, situé n’importe où sur l’écran, nous devons obtenir ses coordonnées physiques.

La classe d’assistance suivante nous donne cette position, ainsi que des informations supplémentaires relatives au device, de l’écran, …



Détails de la solution

Ok, maintenant que nous avons les gros blocs de la solution, construisons tout cela …

Détermination des intentions de l’utilisateur

La partie la plus délicate de ce Widget est de comprendre ce que l’utilisateur veut faire, c’est-à-dire comprendre les gestes.

1. LongPress vs Tap

Comme mentionné précédemment, nous ne pouvons pas utiliser le callback onLongPress car nous considérons également les mouvements de type Dragging. Par conséquent, nous devrons le mettre en œuvre nous-mêmes.

Cela sera réalisé comme suit:

  1. Lorsque l’utilisateur touche l’écran (via onHorizontalDragDown ou onVerticalDragDown), nous démarrons un Timer

  2. Si l’utilisateur relâche son doigt de l’écran avant le délai du Timer, cela signifie que le LongPress ne s’est pas réalisé

  3. Si l’utilisateur n’a pas relâché son doigt avant le délai du Timer, cela signifie que nous devons considérer le LongPress et non plus le Tap. Nous affichons ensuite le ReactiveIconContainer.

  4. Si le callback onTap est appelé, nous devons annuler le Timer.

L’extrait de code suivant illustre l’implémentation de l’explication ci-dessus.

2. Afficher/Cacher les icônes

Lorsque nous avons déterminé qu’il est temps d’afficher les icônes, comme expliqué précédemment, nous allons les afficher par-dessus n’importe quel contenu, en utilisant un OverlayEntry.

L’extrait de code suivant montre comment instancier le ReactiveIconContainer et l’ajouter à l’Overlay (ainsi que de le retirer de l’Overlay).

  • Ligne #9

    Nous récupérons l’instance de Overlay depuis le BuildContext

  • Lignes #12-18

    Nous créons une nouvelle instance de OverlayEntry, qui incorpore une nouvelle instance de ReactiveIconContainer

  • Ligne 21

    Nous ajoutons le OverlayEntry à l’Overlay

  • Ligne 25

    Lorsque nous devons retirer le ReactiveIconContainer de l’écran, nous supprimons simplement le OverlayEntry correspondant.

3. Diffusion (=Broadcasting) des gestes

Précédemment, nous avons dit que le ReactiveButton serait utilisé pour diffuser les déplacements du Pointer vers ReactiveIcon via l’utilisation de Streams.

Pour ce faire, nous devons créer le Stream qui sera utilisé pour véhiculer ces informations.

3.1. Simple StreamController vs BLoC

Une implémentation simple aurait pu être la suivante, au niveau ReactiveButton:

StreamController<Offset> _gestureStream = StreamController<Offset>.broadcast();

// puis, lorsque nous instancions l'OverlayEntry
...
_overlayEntry = OverlayEntry(
    builder: (BuildContext context) {
        return ReactiveIconContainer(
            stream: _gestureStream.stream,
        );
    }
);

// lorsque nous devons transmettre les gestes
void _onDragMove(DragUpdateDetails details){
    _gestureStream.sink.add(details.globalPosition);
}

Cela aurait bien fonctionné mais, plus tôt dans l’article, nous avons également mentionné qu’un second Stream serait utilisé pour transmettre les informations du ReactiveIcons au ReactiveButton. Par conséquent, j’ai décidé de partir sur la base d’un BLoC Pattern.

Voici ci-dessous l’extrait du BLoC résultant, qui ne sera utilisé que pour transmettre le geste en utilisant des Streams.

Comme vous pouvez le remarquer, j’utilise le package RxDart, et plus spécifiquement PublishSubject et Observable (et non StreamController ou Stream) car ces classes offrent des fonctionnalités supplémentaires que nous utiliserons un peu plus tard.

3.2. Instanciation du BLoC et sa mise à disposition au ReactiveIcon

Comme le ReactiveButton est responsable de la diffusion des gestes, il est logique qu’il soit également chargé d’instancier le BLoC, de le fournir aux ReactiveIcons et de relâcher ses ressources quand il n’est plus nécessaire.

Nous le ferons de la manière suivante:

  • Ligne 10: nous instancions bloc
  • Ligne 19: nous relâchons ses ressources
  • Ligne 29: lorsqu’un geste Drag est détecté, nous le transmettons aux icônes, via le Stream
  • Ligne 47: nous passons le BLoC au ReactiveIconContainer

Détermination du ReactiveIcon survolé/sélectionné

Une autre partie intéressante est de savoir quel ReactiveIcon est survolé afin de le mettre en évidence.

1. Chaque icône utilisera le Stream pour obtenir la position du pointeur

Pour obtenir la position Pointer, chaque ReactiveIcon s’abonnera (=subscribe) aux Streams comme suit:

Nous utilisons un StreamSubscription pour écouter les positions gestuelles, diffusées par le ReactiveButton via le BLoC.

Comme le Pointer peut se déplacer assez souvent, il ne serait pas très efficace de vérifier s’il survole ou non l’icône à chaque fois qu’un changement de position se produit. Afin de réduire ce nombre de validations, nous tirons parti du Observable pour bufferiser les événements émis par ReactiveButton et ne considérer les changements que toutes les 100 millisecondes.

2. Détermination si le pointeur survole l’icône

Afin de déterminer si le Pointer survole une icône, nous:

  • obtenons sa position, via la classe d’assistance WidgetPosition
  • vérifions si la position du Pointer survole l’icône, via le widgetPosition.rect.contains(position)

Comme le fait de mettre en mémoire tampon les événements émis par le Stream a généré un tableau de positions, nous ne considérons que le dernier élément de ce tableau. Ceci explique le position.last, utilisé dans cette routine.

3. Mettre en évidence le ReactiveIcon survolé

Afin de mettre en évidence le ReactiveIcon survolé, nous utiliserons une Animation pour augmenter ses dimensions, comme suit:

Explications:

  • Ligne 1: nous utilisons un SingleTickerProviderStateMixin pour l’animation
  • Lignes 16-20: nous initialisons l’AnimationController
  • Lignes 20-23: lorsque l’animation sera lancée, nous reconstruirons (=build via setState) le ReactiveIcon
  • Ligne 37: nous devons libérer les ressources liées au AnimationController lorsque ReactiveIcon sera supprimé
  • Lignes 44-48: nous allons mettre à l’échelle le ReactiveIcon basé sur la valeur AnimationController.value (range[0..1]) par un rapport à une échelle arbitraire
  • Ligne 67: lorsque le ReactiveIcon est survolé, lancer l’Animation (de 0 -> 1)
  • Ligne 72: lorsque le ReactiveIcon n’est plus survolé, lancer l’Animation (de 1 -> 0)

4. Faire savoir si un ReactiveIcon est survolé

La toute dernière partie de cette explication concerne le fait de transmettre au ReactiveButton le ReactiveIcon actuellement survolé afin que, si l’utilisateur relâche son doigt de l’écran à ce moment, nous puissions savoir quel ReactiveIcon doit être considéré comme choisi.

Comme mentionné précédemment, nous utiliserons un deuxième Stream pour transmettre cette information.

4.1. Message à transmettre

Pour indiquer au ReactiveButton quel ReactiveIcon est en train d’être survolé ou quel ReactiveIcon n’est plus survolé, nous utiliserons un message propriétaire: ReactiveIconSelectionMessage. Ce message indiquera “quelle icône est potentiellement sélectionnée” et “quelle icône n’est plus potentiellement sélectionnée”.

4.2. Modifications appliquées au BLoC

Le BLoC doit maintenant inclure le nouveau Stream pour transmettre le message.

Voici le nouveau BLoC:

4.3. Permettre au ReactiveButton de recevoir les messages

Pour que le ReactiveButton reçoive de telles notifications, émises par ReactiveIcons, nous devons souscrire aux événements de message, comme suit:

  • Ligne 14: nous nous abonnons à tous les messages, émis par le Stream
  • Ligne 21: nous libérons l’abonnement lorsque le ReactiveButton est supprimé
  • Lignes 32-42: traitement du message, émis par le ReactiveIcons

4.4. Transmission du message par le ReactiveIcon

Au niveau du ReactiveIcon, lorsque celui-ci doit envoyer un message au ReactiveButton, il utilise simplement le Stream comme suit:

  • Lignes 8 et 14: appel de la méthode _sendNotification lorsqu’une modification s’applique à la variable _isHovered
  • Lignes 23-29: émettre un ReactiveIconSelectionMessage au Stream

Conclusions

Je ne pense pas qu’il soit nécessaire d’expliquer davantage le reste du code car il ne concerne que les aspects de paramétrisation ou d’apparence du Widget ReactiveButton.

L’objectif de cet article était de montrer comment combiner plusieurs sujets (BLoC, Reactive Programming, Animation, Overlay) et de fournir un exemple pratique d’utilisation.

J’espère que vous aurez trouvé cet article intéressant.

Restez à l’écoute pour les prochains articles très bientôt et surtout: “Happy coding”.