Accessibilità avanzata in Flutter: Semantics, screen reader e contrasto

Foto di Mohammad Rahmani su Unsplash

Best practiceAvanzato45 min Flutter 3.x

Accessibilità avanzata in Flutter: Semantics, screen reader e contrasto

L'accessibilità non è un extra: in molti mercati è un requisito legale (European Accessibility Act) e in tutti è una questione di qualità.

Flutter costruisce, accanto all'albero dei widget e a quello di rendering, un albero semantico che viene esposto a TalkBack (Android), VoiceOver (iOS) e agli screen reader desktop/web. In questa guida avanzata vediamo come controllarlo in modo chirurgico: fondere o escludere nodi, annunciare cambiamenti dinamici, gestire l'ordine di lettura, garantire target tattili e contrasto conformi alle WCAG 2.1 AA, e infine verificare tutto con i test automatici di flutter_test.

Prerequisiti: conoscenza dell'albero dei widget, dei test di widget e di MediaQuery.

  1. 1

    Ispezionare l'albero semantico

    Prima di scrivere codice, osserva cosa vede davvero lo screen reader.

    1. Attiva debugSemantics nei DevTools (tab Flutter Inspector → Show Semantics Debugger) oppure imposta showSemanticsDebugger: true sul MaterialApp.
    2. Da terminale usa debugDumpSemanticsTree() per stampare l'albero.

    L'albero semantico è molto più piccolo di quello dei widget: decorazioni, Container vuoti e Padding non producono nodi. I problemi tipici sono nodi duplicati (icona + testo letti separatamente) e nodi senza label (IconButton senza tooltip, immagini senza descrizione).

    import 'package:flutter/rendering.dart';
    
    void dumpSemantics() {
      // Chiamalo da un bottone di debug o da un hot-reload hook.
      debugDumpSemanticsTree();
    }
    
    // In alternativa, overlay visuale:
    MaterialApp(
      showSemanticsDebugger: true, // solo in debug!
      home: const HomePage(),
    );

    Risultato atteso

    In console compare l'albero dei SemanticsNode con label, flag e rettangoli; sullo schermo vedi i riquadri con le label lette dallo screen reader.

  2. 2

    Modellare i nodi: Semantics, Merge ed Exclude

    Tre widget coprono il 90% dei casi:

    • Semantics: crea o annota un nodo (label, hint, value, button, header, onTap…).
    • MergeSemantics: fonde i figli in un unico nodo, così una card con icona, titolo e sottotitolo viene letta in un'unica frase.
    • ExcludeSemantics: rimuove dal sottoalbero i nodi ridondanti (es. un'icona puramente decorativa).

    Regola d'oro: una zona interattiva = un nodo. Se l'utente può toccare la card, la card deve essere un solo nodo con flag button e una label completa.

    Per le immagini decorative usa ExcludeSemantics; per quelle informative Semantics(image: true, label: ...).

    class ProductCard extends StatelessWidget {
      const ProductCard({super.key, required this.name, required this.price, required this.onTap});
    
      final String name;
      final double price;
      final VoidCallback onTap;
    
      @override
      Widget build(BuildContext context) {
        return Semantics(
          button: true,
          label: '$name, prezzo ${price.toStringAsFixed(2)} euro',
          hint: 'Apre la scheda prodotto',
          onTap: onTap,
          // I figli non devono generare nodi propri.
          child: ExcludeSemantics(
            child: InkWell(
              onTap: onTap,
              child: Padding(
                padding: const EdgeInsets.all(12),
                child: Row(
                  children: [
                    const Icon(Icons.shopping_bag_outlined),
                    const SizedBox(width: 12),
                    Expanded(child: Text(name)),
                    Text('€ ${price.toStringAsFixed(2)}'),
                  ],
                ),
              ),
            ),
          ),
        );
      }
    }

    Risultato atteso

    Lo screen reader legge una sola frase per card ("Scarpe running, prezzo 89,90 euro, pulsante") invece di tre frammenti separati.

  3. 3

    Contenuti dinamici: live region, annunci e ordine di lettura

    Quando la UI cambia senza interazione diretta (caricamento completato, errore, contatore aggiornato) lo screen reader non se ne accorge. Hai due strumenti:

    • Semantics(liveRegion: true, ...): il nodo viene riannunciato a ogni cambio di label.
    • SemanticsService.announce(testo, direzione): annuncio one-shot, utile dopo un'azione (es. "Elemento eliminato").

    Per l'ordine di lettura, quando il layout visivo non corrisponde all'ordine logico (griglie, Stack, barre laterali), usa Semantics(sortKey: OrdinalSortKey(n)): i nodi con sort key vengono letti in ordine crescente all'interno dello stesso contenitore.

    Attenzione: non abusare di liveRegion su liste lunghe, genererebbe un flusso continuo di annunci.

    class StatusBanner extends StatelessWidget {
      const StatusBanner({super.key, required this.message});
      final String message;
    
      @override
      Widget build(BuildContext context) {
        return Semantics(
          liveRegion: true,
          label: message,
          child: ExcludeSemantics(child: Text(message)),
        );
      }
    }
    
    Future<void> deleteItem(BuildContext context, Item item) async {
      await repository.delete(item.id);
      SemanticsService.announce(
        '${item.name} eliminato',
        Directionality.of(context),
      );
    }
    
    // Ordine di lettura forzato
    Stack(
      children: [
        Semantics(sortKey: const OrdinalSortKey(1), child: const Header()),
        Semantics(sortKey: const OrdinalSortKey(2), child: const Body()),
      ],
    );

    Risultato atteso

    TalkBack/VoiceOver annunciano automaticamente i cambi di stato e leggono i nodi nell'ordine logico definito dalle sort key.

  4. 4

    Contrasto, dimensioni dei target e scala del testo

    Le WCAG 2.1 AA richiedono contrasto 4.5:1 per il testo normale e 3:1 per testo grande (≥18pt o 14pt bold) e per i componenti UI. Flutter espone Color.computeLuminance(): puoi calcolare il rapporto a runtime e far fallire un test se un token del tema non è conforme.

    Altri due requisiti spesso ignorati:

    • Target tattili minimi 48x48 dp: avvolgi i controlli piccoli con ConstrainedBox o usa materialTapTargetSize: MaterialTapTargetSize.padded.
    • Scala del testo: non bloccare mai textScaler a 1.0. Al massimo limita con TextScaler.linear(...).clamp(...) e verifica il layout a scala 2.0 evitando altezze fisse.
    double contrastRatio(Color a, Color b) {
      final l1 = a.computeLuminance();
      final l2 = b.computeLuminance();
      final hi = l1 > l2 ? l1 : l2;
      final lo = l1 > l2 ? l2 : l1;
      return (hi + 0.05) / (lo + 0.05);
    }
    
    bool isAaCompliant(Color fg, Color bg, {bool largeText = false}) =>
        contrastRatio(fg, bg) >= (largeText ? 3.0 : 4.5);
    
    // Clamp ragionevole della scala testo (non disattivarla!)
    MaterialApp(
      builder: (context, child) {
        final mq = MediaQuery.of(context);
        return MediaQuery(
          data: mq.copyWith(
            textScaler: mq.textScaler.clamp(minScaleFactor: 1.0, maxScaleFactor: 2.0),
          ),
          child: child!,
        );
      },
      home: const HomePage(),
    );

    Risultato atteso

    I colori del tema superano la soglia 4.5:1, i controlli hanno area tattile ≥48dp e la UI resta leggibile con testo al 200%.

  5. 5

    Testare l'accessibilità in modo automatico

    flutter_test include le accessibility guideline ufficiali: eseguile nella suite di widget test per bloccare le regressioni in CI.

    • androidTapTargetGuideline → target ≥48dp
    • iOSTapTargetGuideline → target ≥44dp
    • textContrastGuideline → contrasto WCAG AA
    • labeledTapTargetGuideline → ogni elemento toccabile ha una label

    Inoltre puoi asserire la struttura semantica con tester.getSemantics() e il matcher matchesSemantics. Ricorda di chiamare SemanticsBinding.instance.ensureSemantics() (tramite tester.ensureSemantics()) e di fare handle.dispose() a fine test.

    import 'package:flutter/material.dart';
    import 'package:flutter_test/flutter_test.dart';
    
    void main() {
      testWidgets('la home rispetta le linee guida di accessibilità', (tester) async {
        final handle = tester.ensureSemantics();
        await tester.pumpWidget(const MyApp());
    
        await expectLater(tester, meetsGuideline(androidTapTargetGuideline));
        await expectLater(tester, meetsGuideline(iOSTapTargetGuideline));
        await expectLater(tester, meetsGuideline(labeledTapTargetGuideline));
        await expectLater(tester, meetsGuideline(textContrastGuideline));
    
        handle.dispose();
      });
    
      testWidgets('la card prodotto è un unico nodo semantico', (tester) async {
        final handle = tester.ensureSemantics();
        await tester.pumpWidget(const MyApp());
    
        expect(
          tester.getSemantics(find.byType(ProductCard).first),
          matchesSemantics(
            label: 'Scarpe running, prezzo 89.90 euro',
            hint: 'Apre la scheda prodotto',
            isButton: true,
            hasTapAction: true,
          ),
        );
    
        handle.dispose();
      });
    }

    Risultato atteso

    `flutter test` passa; se in futuro qualcuno riduce un bottone sotto i 48dp o usa un grigio poco contrastato, la build in CI fallisce.

  6. 6

    Checklist finale e verifica sul dispositivo

    I test automatici coprono la conformità misurabile, non l'esperienza reale. Prima della release:

    1. Android: attiva TalkBack e naviga l'intera app solo con gesti di scorrimento.
    2. iOS: attiva VoiceOver e prova il rotore; verifica anche Aumenta contrasto e Riduci movimento.
    3. Disattiva le animazioni decorative quando MediaQuery.disableAnimationsOf(context) è true.
    4. Verifica che i dialog e i bottom sheet catturino il focus (ModalRoute lo fa di default con barrierDismissible) e che il focus torni al trigger alla chiusura.
    5. Controlla che nessun elemento interattivo sia raggiungibile solo tramite hover o drag.

    Documenta le scelte semantiche nella tua design system: label e hint dovrebbero essere definite insieme ai componenti, non improvvisate schermata per schermata.

    @override
    Widget build(BuildContext context) {
      final reduceMotion = MediaQuery.disableAnimationsOf(context);
      return AnimatedOpacity(
        opacity: visible ? 1 : 0,
        duration: reduceMotion ? Duration.zero : const Duration(milliseconds: 300),
        child: child,
      );
    }

    Risultato atteso

    L'app è navigabile end-to-end con screen reader attivo, rispetta le preferenze di sistema su movimento e contrasto ed è pronta per un audit WCAG AA.

CondividiXLinkedInFacebookWhatsApp

Commenti (0)

Ancora nessun commento. Inizia tu!