Cos'è un Overlay in Flutter

In Flutter, l'Overlay è uno stack di elementi (gli OverlayEntry) che vengono renderizzati sopra il resto dell'interfaccia. Ogni volta che apri un Dialog, un SnackBar, un Tooltip o un menu a tendina, dietro le quinte Flutter sta usando l'Overlay per posizionare quei widget al di sopra dell'albero principale.

Gestire manualmente un OverlayEntry è sempre stato possibile, ma richiedeva molta attenzione: bisognava inserirlo, rimuoverlo e gestirne il ciclo di vita a mano, con il rischio di lasciare entry orfane o di causare memory leak. Dalla versione 3.10, Flutter ha introdotto OverlayPortal, un widget che semplifica enormemente questo lavoro.

In questo articolo vedremo entrambi gli approcci, partendo dal classico OverlayEntry per poi passare al più moderno e sicuro OverlayPortal.

L'approccio classico: OverlayEntry

Prima di OverlayPortal, per mostrare qualcosa in overlay si procedeva così:

class ClassicOverlayExample extends StatefulWidget {
  const ClassicOverlayExample({super.key});

  @override
  State<ClassicOverlayExample> createState() => _ClassicOverlayExampleState();
}

class _ClassicOverlayExampleState extends State<ClassicOverlayExample> {
  OverlayEntry? _entry;

  void _show() {
    _entry = OverlayEntry(
      builder: (context) => Positioned(
        top: 100,
        left: 50,
        child: Material(
          elevation: 4,
          borderRadius: BorderRadius.circular(8),
          child: const Padding(
            padding: EdgeInsets.all(16),
            child: Text('Sono un overlay!'),
          ),
        ),
      ),
    );
    Overlay.of(context).insert(_entry!);
  }

  void _hide() {
    _entry?.remove();
    _entry = null;
  }

  @override
  void dispose() {
    _hide(); // fondamentale per evitare leak
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return ElevatedButton(
      onPressed: () => _entry == null ? _show() : _hide(),
      child: const Text('Toggle overlay'),
    );
  }
}

Questo funziona, ma ha diversi punti critici: devi ricordarti di rimuovere l'entry nel dispose, gestire lo stato null manualmente e stare attento a non inserire due volte la stessa entry.

Il modo moderno: OverlayPortal

OverlayPortal risolve tutti questi problemi. Il widget si occupa automaticamente di inserire e rimuovere l'entry seguendo il ciclo di vita del widget stesso. Il controllo avviene tramite un OverlayPortalController.

class PortalExample extends StatefulWidget {
  const PortalExample({super.key});

  @override
  State<PortalExample> createState() => _PortalExampleState();
}

class _PortalExampleState extends State<PortalExample> {
  final _controller = OverlayPortalController();

  @override
  Widget build(BuildContext context) {
    return OverlayPortal(
      controller: _controller,
      overlayChildBuilder: (context) {
        return const Positioned(
          top: 120,
          left: 40,
          child: Material(
            elevation: 4,
            child: Padding(
              padding: EdgeInsets.all(16),
              child: Text('Overlay con OverlayPortal'),
            ),
          ),
        );
      },
      child: ElevatedButton(
        onPressed: _controller.toggle,
        child: const Text('Toggle'),
      ),
    );
  }
}

Nessun dispose manuale, nessuna variabile nullable da controllare. Il controller offre i metodi show(), hide() e toggle(), oltre alla proprietà isShowing.

Posizionare l'overlay accanto a un widget

Uno dei casi d'uso più frequenti è mostrare un popup ancorato a un altro widget (ad esempio un menu che appare sotto un pulsante). Per farlo si combina OverlayPortal con CompositedTransformTarget e CompositedTransformFollower, che sfruttano un LayerLink per collegare la posizione dei due widget.

class AnchoredMenu extends StatefulWidget {
  const AnchoredMenu({super.key});

  @override
  State<AnchoredMenu> createState() => _AnchoredMenuState();
}

class _AnchoredMenuState extends State<AnchoredMenu> {
  final _controller = OverlayPortalController();
  final _link = LayerLink();

  @override
  Widget build(BuildContext context) {
    return CompositedTransformTarget(
      link: _link,
      child: OverlayPortal(
        controller: _controller,
        overlayChildBuilder: (context) {
          return Stack(
            children: [
              // Barriera per chiudere toccando fuori
              Positioned.fill(
                child: GestureDetector(
                  behavior: HitTestBehavior.translucent,
                  onTap: _controller.hide,
                ),
              ),
              CompositedTransformFollower(
                link: _link,
                targetAnchor: Alignment.bottomLeft,
                followerAnchor: Alignment.topLeft,
                offset: const Offset(0, 8),
                child: Material(
                  elevation: 6,
                  borderRadius: BorderRadius.circular(8),
                  child: Column(
                    mainAxisSize: MainAxisSize.min,
                    children: [
                      ListTile(
                        title: const Text('Modifica'),
                        onTap: _controller.hide,
                      ),
                      ListTile(
                        title: const Text('Elimina'),
                        onTap: _controller.hide,
                      ),
                    ],
                  ),
                ),
              ),
            ],
          );
        },
        child: ElevatedButton(
          onPressed: _controller.toggle,
          child: const Text('Apri menu'),
        ),
      ),
    );
  }
}

Con CompositedTransformFollower il menu segue automaticamente la posizione del pulsante anche durante lo scroll, senza bisogno di calcolare coordinate globali con RenderBox.

Aggiungere una barriera e chiusura al tocco esterno

Como mostrato nell'esempio, un pattern comune è avvolgere il contenuto in uno Stack con un Positioned.fill che intercetta i tap fuori dal popup e ne provoca la chiusura. Impostare behavior: HitTestBehavior.translucent garantisce che i tocchi vengano rilevati anche sulle aree trasparenti.

Quando usare OverlayPortal e quando no

  • Usa OverlayPortal per tooltip personalizzati, menu contestuali, dropdown, popover e badge fluttuanti legati a un widget specifico.
  • Usa showDialog o showMenu quando ti bastano i componenti Material standard: sono già ottimizzati e accessibili.
  • Evita l'overlay per elementi che fanno parte del flusso normale della UI: in quei casi è meglio un semplice Stack o un layout condizionale.

Buone pratiche

  1. Preferisci OverlayPortal a OverlayEntry per il ciclo di vita automatico e la sicurezza contro i leak.
  2. Usa LayerLink invece di calcolare posizioni manuali: è più robusto rispetto ai cambi di layout.
  3. Gestisci la chiusura con una barriera trasparente o intercettando il tasto "indietro" su Android.
  4. Cura l'accessibilità aggiungendo Semantics appropriati ai popup, poiché non vengono annunciati automaticamente come i dialog standard.

Conclusione

OverlayPortal rappresenta l'evoluzione naturale della gestione degli overlay in Flutter: elimina il boilerplate del vecchio OverlayEntry e riduce drasticamente i rischi di errore. Combinato con LayerLink, CompositedTransformTarget e CompositedTransformFollower, permette di costruire menu, tooltip e popover ancorati con poche righe di codice pulito e manutenibile. La prossima volta che ti serve un elemento fluttuante sopra la UI, sai da dove partire.