Comment afficher n’importe quoi en superposition de n’importe quelle page en Flutter ?

Difficulté: Intermédiaire

Contexte

Dernièrement, alors que je développais une routine pour utiliser avec WebSockets, j’ai eu besoin d’afficher une icône en superposition de n’importe quelle page afin d’informer l’utilisateur qu’une notification, message, avait été envoyée par le serveur.

J’ai essayé d’utiliser PopupRoute, showDialog… mais le résultat que j’obtenais ne correspondait pas à mes attentes.

Lorsque l’on utilise des Routes, l’écran est entièrement recouvert par cette route et l’utilisateur perd la possibilité d’utiliser normalement la page recouverte. Cela provient du fait que chaque action (Tap, Drag…) n’a plus lieu sur la page recouverte mais sur celle qui la recouvre. J’ai dès lors continué mes recherches et suis tombé sur les notions de Overlay et OverlayEntry.

En lisant le code source de Flutter, j’ai découvert l’utilisation d’un OverlayEntry pour afficher l’avatar lors d’un Drag. J’en ai donc conclu que c’était ce que je recherchais.

Overlay

La documentation officielle mentionne: “Un ‘overlay’ est une pile (=Stack) d’entrées qui peuvent être gérées indépendamment. Les ‘Overlays’ permettent à des Widgets enfants de faire ‘flotter’ des éléments visuels au-dessus d’autres Widgets…”. Eh bien … pas très explicite.

Maintenant, en utilisant mes propres mots.

A première vue, un Overlay n’est rien d’autre qu’une simple couche (= layer), StatefulWidget, qui se situe au-dessus de tout, composé d’un Stack dans lequel on peut mettre n’importe quel Widget.

Ces Widgets sont appelés OverlayEntry

OverlayState

Un Overlay est un StatefulWidget. Un OverlayState est le State d’une instance spécifique d’un Widget de type Overlay. Un OverlayState est donc responsable du Build (= rendu).

OverlayEntry

Un OverlayEntry est un Widget que l’on insère dans un Overlay. Un OverlayEntry ne peut appartenir qu’à un seul Overlay à la fois.

Comme un Overlay utilise un Stack pour son rendu, nous pouvons utiliser les Widgets Positioned et AnimatedPositioned pour positionner précisément sur un Overlay.

C’est exactement ce dont j’ai besoin pour pouvoir afficher mon icône de notification n’importe où sur l’écran.

Plongeons directement dans du code

La classe suivante affiche une icône aux coordonnées (50.0, 50.0) et la supprime après 2 secondes.

 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
import 'package:flutter/material.dart';
import 'dart:async';

class ShowNotificationIcon {

    void show(BuildContext context) async {
        OverlayState overlayState = Overlay.of(context);
        OverlayEntry overlayEntry = new OverlayEntry(builder: _build);

        overlayState.insert(overlayEntry);

        await new Future.delayed(const Duration(seconds: 2));

        overlayEntry.remove();
    }

    Widget _build(BuildContext context){
      return new Positioned(
        top: 50.0,
        left: 50.0,
        child: new Material(
            color: Colors.transparent,
            child: new Icon(Icons.warning, color: Colors.purple),
        ),
      );
    }
}

Comme vous pouvez le voir, pour pouvoir montrer l’icône, nous devons fournir un context. Pourquoi?

La documentation officielle n’explique pas cela mais, en regardant le code source, nous voyons qu’un Overlay est créé pour chaque Route et appartient donc à un arborescence de contextes (voir mon article qui traite de la notion de contexte). Il est donc compréhensible de devoir retrouver l’instance du OverlayState qui corresponde à un certain context (Ligne #7).

C’est tout! De cet exemple, nous pouvons dériver n’importe quel type de contenu et de comportement de superposition.

Afin d’illustrer cela, construisons une sorte de blinking Toast (message clignotant) qui affiche un widget à une certaine position sur l’écran, au-dessus de n’importe quel contenu et qui disparaît après une certaine durée.

Exemple: Blinking Toast

Cette première classe est une généralisation de l’exemple précédent qui permet d’externaliser la construction du Widget à afficher.

import 'dart:async';
import 'package:flutter/material.dart';

class BlinkingToast {
    bool _isVisible = false;

    ///
    /// BuildContext context: le contexte à partir duquel nous devons retrouver l'Overlay
    /// WidgetBuilder externalBuilder: (obligatoire) routine externe pour créer le Widget à afficher
    /// Duration duration: (optionnel) durée au bout de laquelle le Widget sera retiré
    /// Offset position: (optionnel) position où vous voulez afficher le widget
    ///
    void show({
        @required BuildContext context,
        @required WidgetBuilder externalBuilder, 
        Duration duration = const Duration(seconds: 2),
        Offset position = Offset.zero,
        }) async {

        // Empêcher d'afficher plusieurs widgets en même temps
        if (_isVisible){
            return;
        }

        _isVisible = true;

        OverlayState overlayState = Overlay.of(context);
        OverlayEntry overlayEntry = new OverlayEntry(
            builder: (BuildContext context) => new BlinkingToastWidget(
                widget: externalBuilder(context),
                position: position,
            ),
        );
        overlayState.insert(overlayEntry);

        await new Future.delayed(duration);

        overlayEntry.remove();

        _isVisible = false;
    }
}

Cette deuxième classe affiche le widget à une certaine position sur l’écran et le fait clignoter.

Pour plus d’explications sur la notion d’Animation, consultez mon article qui traite du sujet.

class BlinkingToastWidget extends StatefulWidget {
    BlinkingToastWidget({
        Key key,
        @required this.widget,
        @required this.position,
    }): super(key: key);

    final Widget widget;
    final Offset position;

    @override
    _BlinkingToastWidgetState createState() => new _BlinkingToastWidgetState();
}

class _BlinkingToastWidgetState extends State<BlinkingToastWidget>
    with SingleTickerProviderStateMixin {

AnimationController _controller;
  Animation<double> _animation;

  @override
  void initState() {
    super.initState();
    _controller = new AnimationController(
        duration: const Duration(milliseconds: 500), vsync: this);
    _animation = new Tween(begin: 0.0, end: 1.0).animate(new CurvedAnimation(
      parent: _controller,
      curve: new Interval(0.0, 0.5)
    ))
      ..addListener(() {
        if (mounted){        
          setState(() {
            // Refresh
          });
        }
      })
      ..addStatusListener((AnimationStatus status){
        if (status == AnimationStatus.completed){
          _controller.reverse().orCancel;
        } else if (status == AnimationStatus.dismissed){
          _controller.forward().orCancel;
        }
      });
    _controller.forward().orCancel;
  }

  @override
  void dispose() {
    _controller.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return new Positioned(
        top:  widget.position.dy,
        left: widget.position.dx,
        child: new IgnorePointer(
          child: new Material(
            color: Colors.transparent,
            child: new Opacity(
              opacity: _animation.value,
              child: widget.widget,
            ),
          ),
        ));
  }
}

Pour utiliser le BlinkingToast:

BlinkingToast toast = new BlinkingToast();

toast.show(
    context: context,
    externalBuilder: (BuildContext context){
        return new Icon(Icons.warning, color: Colors.purple);
    },
    duration: new Duration(seconds: 5),
    position: new Offset(50.0, 50.0),
);

Conclusion

Ceci est un article très court, visant uniquement à partager la façon d’afficher un widget en superposition sur n’importe quel écran.

En espérant que vous aurez trouvé cet article utile.

Restez à l’écoute pour les prochains articles et bon codage.