Comment afficher un calque en superposition d’un élément particulier, présent dans une zone de défilement, suite à une action de type longPress?

Difficulté: Intermédiaire

Introduction

Dans l’un de mes développements actuels, je devais trouver une solution pour afficher un calque ne couvrant qu’un élément spécifique, faisant partie d’une zone défilante, afin de montrer à l’utilisateur une série d’actions pouvant être appliquées à l’élément sélectionné.

L’animation suivante montre les objectifs que je devais atteindre.

Overlay Hover Overlay Hover

Ce court article explique la solution que j’ai mise en œuvre. D’autres solutions existent, mais concentrons-nous sur celle-ci, car cela me donne l’occasion d’introduire de nouveaux sujets.


Objectifs

Voici la liste complète des exigences:

  • Une partie de l’écran répertorie les éléments dans une zone à défilement.
  • Lorsque l’utilisateur effectue un “longPress” sur un de ces éléments, un calque doit:
    • couvrir l’élément sur lequel l’utilisateur a appliqué le longPress
    • uniquement couvrir cet élément, pas les autres
    • le calque doit afficher certaines actions (icônes, par exemple)
    • lorsque l’utilisateur appuie n’importe où en dehors du calque, ce calque doit être supprimé
    • il doit être possible de supprimer ce calque programmatiquement
    • le calque ne peut pas déborder de la zone de défilement

Première tentative: PopupRoute

Ma première idée était d’imiter la manière dont un widget de type DropDown est implémenté. Ce dernier affiche un “popup” en superposition de l’écran pour permettre à l’utilisateur de sélectionner un élément; ce qui est plus ou moins ce que je dois réaliser.

En Flutter, on peut traduire un popup comme une Route. Le Widget DropDown original implémente ceci via un PopupRoute.

La documentation dit: “Un PopupRoute est une Route modale qui recouvre un widget par-dessus la route existante”.

Donc, si j’essaie d’implémenter ce concept, le code résultant pourrait ressembler à ceci:

  ...
  GestureDetector(
    onLongPress: () {
        _showOverlay(context);
    }
  ),
  ...

  void _showOverlay(BuildContext context){
      Rect position = _getItemPosition(context);
      Navigator.push(
          context,
          _PopupItemRoute(
              position: position,
          ),
      );
  }
  ...
  class _PopupItemRoute extends PopupRoute {
      _PopupItemRoute({
          this.position,
      });
      final Rect position;

      ...
      @override
      bool get barrierDismissible => true;

      @override
      Widget buildPage(BuildContext context, Animation<double> animation,
        Animation<double> secondaryAnimation) {
        return MediaQuery.removePadding(
            context: context,
            removeTop: true,
            removeBottom: true,
            removeLeft: true,
            removeRight: true,
            child: Builder(
                builder: (BuildContext context) {
                    return CustomSingleChildLayout(
                        delegate: _PopupItemRouteLayout(
                            position,
                        ),
                        child: ...,
                    );
                },
            ),
        );
    }
  }

  class _PopupItemRouteLayout extends SingleChildLayoutDelegate {
    _PopupItemRouteLayout(this.position);

  final Rect position;

  @override
  BoxConstraints getConstraintsForChild(BoxConstraints constraints) {
    return BoxConstraints.loose(Size(position.width, position.height));
  }

  @override
  Offset getPositionForChild(Size size, Size childSize) {
    return Offset(position.left, position.top);
  }

  @override
  bool shouldRelayout(_PepiteTypeSelectorRouteLayout oldDelegate) {
    return position != oldDelegate.position;
  }
}

Ce code donne déjà une sorte de résultat cependant, il ne répond pas à l’une des exigences principales: “le calque ne peut pas déborder de la zone de défilement”. Or nous voyons que le calque se trouve au-dessus de tout autre élément de la page. Pourquoi?

Tout simplement parce que Navigator.push(…) demande que la nouvelle Route soit placée “au-dessus de tout”, comme le dit la documentation: “au-dessus de la Route en cours” (comprenez “au-dessus de la page en cours”).

Alors, comment résoudre ce problème?


Deuxième tentative: Stack

J’ai ensuite pensé à utiliser un Stack, comme enfant d’un SingleChildScrollView et parent de la liste des éléments à afficher.

Avec cette approche, nous arrivons à quelque chose comme:

@override
Widget build(BuildContext context){
    List<Widget> children = <Widget>[
        GridView.builder(...),
    ];

    if (_hovered){
        children.add(
            GestureDetector(
            onTap: (){
                ...
            },
            child: Material(
                    color: Colors.black12,
                    child: ...
                ),
            ),
        );
    }

    return Scaffold(
        ...
        body: SingleChildScrollView(
            child: Stack(
                children: children,
            ),
        ),
    );
}

Avec cette solution, qui est également acceptable, nous pouvons réaliser ce que nous devons faire mais, comme vous pouvez le constater, cela est assez complexe car:

  • nous devons reconstruire tout le contenu
  • mémoriser le fait que nous affichons un calque

et… en plus de cela, le code devient très lié à la mise en œuvre que nous avons choisie.

Par conséquent, il doit y avoir une autre alternative. Regardons la troisième tentative.


Troisième tentative: Overlay

À la troisième tentative, je me suis souvenu du fonctionnement des Routes…

Elle est basée sur l’utilisation d’un Overlay, d’un Stack de OverlayEntry.

J’ai donc trouvé la solution: utiliser un autre Overlay qui offre un moyen d’insérer dynamiquement un ou plusieurs OverlayEntry, sur demande.

Laissez-moi expliquer le raisonnement:

  • on instancie un Overlay
  • son premier OverlayEntry contient la Zone de Défilement
  • lorsque nous devons afficher le calque par-dessus l’élément sélectionné, nous l’insérons comme un autre OverlayEntry et nous le positionnons correctement
  • ce calque sera positionné au-dessus de tout autre OverlayEntry de CETTE instance spécifique de l’Overlay, et non plus au-dessus de l’écran en cours

Par conséquent, la solution devient quelque chose comme ceci:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
    class MyContainer extends StatelesWidget {
        @override
        Widget build(BuildContext context){
            return Overlay(
                initialEntries: <OverlayEntry>[
                    OverlayEntry(
                        builder: (BuildContext context){
                            return GridView.builder(
                                gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
                                    crossAxisCount: 2,
                                    childAspectRatio: 1.0,
                                ),
                                itemCount: items.length,
                                itemBuilder: (BuildContext context, int index){
                                    return GestureDetector(
                                        onLongPress:(){
                                            _showLayer(context);
                                        },
                                        child: Container(
                                            color: Colors.red,
                                            child: Text('This is an item'),
                                        ),
                                    );
                                }
                            );
                        },
                    ),
                ],
            );
        }
    }

Explication

  • Ligne #4: On insère un Overlay comme un ancestor de la zone de défilement
  • Ligne #5: Le widget Overlay requière une liste de OverlayEntry en tant que “children
  • Lignes #16-18: on intercepte le longPress et passons le BuildContext de l’élément sélectionné à la routine qui affichera le calque

Comment construire le calque de superposition ?

Comme les exigences l’indiquent, nous devons pouvoir supprimer le calque si l’utilisateur appuie n’importe où sur l’écran (en dehors de l’élément sélectionné), nous devons détecter une telle action.

Pour ce faire, un moyen très simple consiste à créer un widget Material qui s’étendra et couvrira le plus d’espace possible. Lorsque l’utilisateur appuie sur ce widget, nous interceptons l’événement et le supprimons le calque.

Le code devient:

    GestureDetector(
        onTap: _removeOverlay,
        child: Material(
            color: Colors.black12,
            child: ...
        ),
    ),

Comment positionner le calque ?

Nous devons maintenant créer la superposition qui couvrira uniquement l’élément sélectionné. Par conséquent, nous devons obtenir les dimensions et la position à l’écran de l’élément sélectionné.

Cela peut facilement être fait puisque nous avons son BuildContext (voir les lignes 16 à 18 dans l’explication ci-dessus), comme suit:

///
/// Retourne la position (en tant que Rect) d'un élément
/// identifié par son BuildContext
///
Rect _getPosition(BuildContext context) {
    final RenderBox box = context.findRenderObject() as RenderBox;
    final Offset topLeft = box.size.topLeft(box.localToGlobal(Offset.zero));
    final Offset bottomRight = box.size.bottomRight(box.localToGlobal(Offset.zero));
    return Rect.fromLTRB(topLeft.dx, topLeft.dy, bottomRight.dx, bottomRight.dy);
}

La deuxième étape consiste à afficher et positionner ce calque à l’écran.

Pour ce faire, il existe un widget très pratique, appelé CustomSingleChildLayout qui permet de contrôler la mise en page d’un seul enfant (=child).

Entre nous, j’aurais aussi pu utiliser un widget Positioned dans une Stack, mais profitons de cette occasion pour présenter le widget CustomSingleChildLayout

Ainsi, le widget CustomSingleChildLayout délègue le contrôle de la mise en page de son enfant à une classe SingleChildLayoutDelegate qui permet de:

  • positioner le child;
  • dimensionner le child.

C’est exactement ce dont nous avons besoin … Le code devient alors:

    GestureDetector(
        onTap: _removeOverlay,
        child: Material(
            color: Colors.black12,
            child: CustomSingleChildLayout(
                delegate: _OverlayableContainerLayout(
                    position: position,
                ),
                child: ...,
            ),
        ),
    ),

...
class _OverlayableContainerLayout extends SingleChildLayoutDelegate {
  _OverlayableContainerLayout(this.position);

  final Rect position;

  @override
  BoxConstraints getConstraintsForChild(BoxConstraints constraints) {
    return BoxConstraints.loose(Size(position.width, position.height));
  }

  @override
  Offset getPositionForChild(Size size, Size childSize) {
    return Offset(position.left, position.top);
  }

  @override
  bool shouldRelayout(_OverlayableContainerLayout oldDelegate) {
    return position != oldDelegate.position;
  }
}

C’est presque OK, mais le positionnement n’est pas encore correct car nous n’avons pas pris en compte la position du conteneur (= Overlay).

Appliquons les modifications nécessaires à la méthode _showLayer(…).

//
// Nous devons mémoriser l' OverlayEntry 
// afin de pouvoir le supprimer par la suite
//
OverlayEntry _overlayEntry;

void _showLayer(BuildContext context){
    //
    // Récupère l'instance de l' Overlay que nous avons ajouté
    //
    OverlayState overlayState = Overlay.of(context);

    //
    // Sa position à l'écran
    //
    final Rect overlayPosition = _getPosition(overlayState.context);

    //
    // La position de l'élément sélectionné.  Nous devons adapter
    // les coordonnées par rapport à son parent (= Overlay)
    //
    final Rect widgetPosition = _getPosition(context).translate(
        -overlayPosition.left, 
        -overlayPosition.top,
    );

    //
    // On crée l'instance OverlayEntry et l'insérons
    //
    _overlayEntry = OverlayEntry(
        builder: (BuildContext context){
            return GestureDetector(
                onTap: _removeOverlay,
                child: Material(
                    color: Colors.black12,
                    child: CustomSingleChildLayout(
                        delegate: _OverlayableContainerLayout(
                            position: widgetPosition,
                        ),
                        child: ...,
                    ),
                ),
            );
        },
    );

    overlayState.insert(
        _overlayEntry,
    );
}

//
// Suppression de l'OverlayEntry
//
void _removeOverlay(){
    _overlayEntry?.remove();
    _overlayEntry = null;
}

Dernière chose … pour nous assurer que le calque ne débordera pas de son conteneur Overlay, nous allons simplement “couper” (= clip) l’Overlay comme suit:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
    class MyContainer extends StatelesWidget {
        @override
        Widget build(BuildContext context){
            return ClipRect(                // <== modification à appliquer
                child: Overlay(
                    initialEntries: <OverlayEntry>[
                        OverlayEntry(
                            builder: (BuildContext context){
                                return GridView.builder(
                                    gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
                                        crossAxisCount: 2,
                                        childAspectRatio: 1.0,
                                    ),
                                    itemCount: items.length,
                                    itemBuilder: (BuildContext context, int index){
                                        return GestureDetector(
                                            onLongPress:(){
                                                _showLayer(context);
                                            },
                                            child: Container(
                                                color: Colors.red,
                                                child: Text('This is an item'),
                                            ),
                                        );
                                    }
                                );
                            },
                        ),
                    ],
                ),
            );
        }
    }

Dernière touche…

J’ai créé un nouveau Widget, appelé OverlayableContainerOnLongPress, qui a la signature suivante:

typedef OverlayableContainerOnLongPressBuilder(
    BuildContext context, 
    VoidCallback hideOverlayCallback
);

class OverlayableContainerOnLongPress extends StatefulWidget {
  OverlayableContainerOnLongPress({
    Key key,
    @required this.child,
    @required this.overlayContentBuilder,
    this.onTap,
  }): super(key: key);

  final Widget child;
  final OverlayableContainerOnLongPressBuilder overlayContentBuilder;
  final VoidCallback onTap;
}
  • child

    correspond au widget à afficher sur lequel l’utilisateur peut appliquer un longPress

  • overlayContentBuilder

    est un builder qui permet de construire le calque qui sera affiché par-dessus l’élément après un longPress. Le hideOverlayCallback est la méthode à appeler si l’on doit contrôler la suppression du calque

  • onTap

    rappel lorsque l’utilisateur effectue un tap sur l’élément.

Le code source de ce widget ainsi qu’un exemple complet se trouvent sur GitHub.


Conclusions

Cela fait maintenant longtemps que je n’avais pas posté d’astuces, j’ai pensé que ce petit article pourrait peut-être vous intéresser comme cela couvre une fonctionalité courante.

Comme je l’ai dit, d’autres solutions existent mais celle-ci fonctionne pour moi.

Restez à l’écoute pour de nouveaux articles et, en attendant, je vous souhaite un bon codage!