Creare una FAQ a fisarmonica in Flutter con ExpansionTile

Foto di Jason Leung su Unsplash

GuidePrincipiante35 min Flutter 3.x

Creare una FAQ a fisarmonica in Flutter con ExpansionTile

Quasi ogni app ha una schermata di FAQ, Termini di servizio o Impostazioni avanzate in cui il contenuto va mostrato solo su richiesta dell'utente. Il pattern classico è la fisarmonica (accordion): un titolo cliccabile che, al tocco, rivela il contenuto sottostante con una piccola animazione.

Flutter offre due widget pronti all'uso per questo scopo, entrambi inclusi nella libreria Material:

  • ExpansionTile: la soluzione più semplice, perfetta per elenchi di voci indipendenti;
  • ExpansionPanelList (e la sua variante .radio): più configurabile, permette di controllare lo stato di apertura dall'esterno e di tenere aperto un solo pannello alla volta.

In questo tutorial costruiremo passo dopo passo una schermata FAQ completa: partiremo da un modello dati minimo, creeremo la lista espandibile, la personalizzeremo (icone, colori, bordi arrotondati, padding) e infine implementeremo il comportamento "una sola risposta aperta alla volta". Chiuderemo con qualche accorgimento su prestazioni e accessibilità.

Non serve nessun pacchetto esterno: tutto quello che useremo è già dentro Flutter.

  1. 1

    Preparare il progetto e il modello dati

    Crea un nuovo progetto (o apri quello esistente) e prepara una piccola classe che rappresenti una voce della FAQ: una domanda e una risposta.

    flutter create faq_demo
    cd faq_demo
    

    Crea il file lib/faq_item.dart. Usiamo un modello immutabile con campi final e costruttore const: in questo modo potremo creare la lista come const e Flutter potrà riutilizzarla senza ricostruirla ad ogni rebuild.

    Nel file dello schermo principale (lib/main.dart) definiamo anche una lista statica di dati di esempio.

    // lib/faq_item.dart
    class FaqItem {
      final String question;
      final String answer;
    
      const FaqItem({
        required this.question,
        required this.answer,
      });
    }
    
    const faqData = <FaqItem>[
      FaqItem(
        question: 'Come posso creare un account?',
        answer:
            'Tocca "Registrati" nella schermata di accesso, inserisci la tua email '
            'e scegli una password di almeno 8 caratteri.',
      ),
      FaqItem(
        question: 'Ho dimenticato la password, cosa faccio?',
        answer:
            'Dalla schermata di accesso tocca "Password dimenticata": riceverai '
            'un link di reimpostazione valido per 24 ore.',
      ),
      FaqItem(
        question: 'L\'app funziona offline?',
        answer:
            'Sì. I contenuti già scaricati restano consultabili anche senza rete; '
            'le modifiche vengono sincronizzate appena torni online.',
      ),
      FaqItem(
        question: 'Come contatto l\'assistenza?',
        answer:
            'Scrivi a supporto@example.com oppure usa il modulo nella sezione '
            'Impostazioni > Aiuto. Rispondiamo entro 2 giorni lavorativi.',
      ),
    ];

    Risultato atteso

    Il progetto compila e hai a disposizione una lista `faqData` con quattro domande di esempio.

  2. 2

    Costruire la schermata con il primo ExpansionTile

    Sostituisci il contenuto di lib/main.dart con una MaterialApp che mostra la lista delle FAQ.

    Usiamo ListView.builder perché costruisce solo gli elementi visibili: è la scelta corretta anche se oggi le voci sono poche ma domani potrebbero diventare cinquanta.

    Il cuore del passo è ExpansionTile, che nella forma minima richiede solo:

    • title: il widget sempre visibile (la domanda);
    • children: la lista di widget mostrati quando il tile è aperto (la risposta).

    Di default ExpansionTile gestisce da solo lo stato aperto/chiuso e anima la freccia di espansione.

    import 'package:flutter/material.dart';
    import 'faq_item.dart';
    
    void main() => runApp(const FaqApp());
    
    class FaqApp extends StatelessWidget {
      const FaqApp({super.key});
    
      @override
      Widget build(BuildContext context) {
        return MaterialApp(
          title: 'FAQ Demo',
          debugShowCheckedModeBanner: false,
          theme: ThemeData(
            colorSchemeSeed: Colors.indigo,
            useMaterial3: true,
          ),
          home: const FaqScreen(),
        );
      }
    }
    
    class FaqScreen extends StatelessWidget {
      const FaqScreen({super.key});
    
      @override
      Widget build(BuildContext context) {
        return Scaffold(
          appBar: AppBar(title: const Text('Domande frequenti')),
          body: ListView.builder(
            padding: const EdgeInsets.all(12),
            itemCount: faqData.length,
            itemBuilder: (context, index) {
              final item = faqData[index];
              return ExpansionTile(
                title: Text(item.question),
                children: [
                  Padding(
                    padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
                    child: Text(item.answer),
                  ),
                ],
              );
            },
          ),
        );
      }
    }

    Risultato atteso

    Avviando l'app vedi l'elenco delle domande: toccandone una, la risposta appare con un'animazione e la freccia ruota verso l'alto.

  3. 3

    Personalizzare l'aspetto del tile

    L'aspetto di default è funzionale ma anonimo. ExpansionTile espone molte proprietà utili:

    • leading / trailing: widget a sinistra e a destra (se imposti trailing sostituisci la freccia di default);
    • tilePadding e childrenPadding: spaziature interne;
    • collapsedTextColor / textColor e collapsedIconColor / iconColor: colori nei due stati;
    • shape e collapsedShape: la forma del bordo quando il tile è aperto o chiuso;
    • backgroundColor / collapsedBackgroundColor;
    • initiallyExpanded: se la voce parte già aperta;
    • onExpansionChanged: callback con un bool quando l'utente apre o chiude.

    Un dettaglio che confonde spesso i principianti: ExpansionTile disegna due Divider sopra e sotto quando è aperto. Per eliminarli basta avvolgere il widget in un Theme con dividerColor: Colors.transparent (in Material 3 puoi anche usare ExpansionTileTheme).

    Estraiamo il tutto in un widget riutilizzabile FaqTile.

    class FaqTile extends StatelessWidget {
      final FaqItem item;
    
      const FaqTile({super.key, required this.item});
    
      @override
      Widget build(BuildContext context) {
        final theme = Theme.of(context);
    
        return Card(
          elevation: 0,
          margin: const EdgeInsets.symmetric(vertical: 6),
          color: theme.colorScheme.surfaceContainerHighest,
          shape: RoundedRectangleBorder(
            borderRadius: BorderRadius.circular(16),
          ),
          clipBehavior: Clip.antiAlias,
          child: Theme(
            // Rimuove le linee divisorie disegnate dall'ExpansionTile.
            data: theme.copyWith(dividerColor: Colors.transparent),
            child: ExpansionTile(
              leading: Icon(
                Icons.help_outline,
                color: theme.colorScheme.primary,
              ),
              title: Text(
                item.question,
                style: const TextStyle(fontWeight: FontWeight.w600),
              ),
              tilePadding: const EdgeInsets.symmetric(horizontal: 16),
              childrenPadding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
              expandedCrossAxisAlignment: CrossAxisAlignment.start,
              iconColor: theme.colorScheme.primary,
              collapsedIconColor: theme.colorScheme.onSurfaceVariant,
              onExpansionChanged: (isOpen) {
                debugPrint('${item.question} -> ${isOpen ? "aperta" : "chiusa"}');
              },
              children: [
                Text(
                  item.answer,
                  style: theme.textTheme.bodyMedium,
                ),
              ],
            ),
          ),
        );
      }
    }
    
    // Nel ListView.builder dello step precedente:
    // itemBuilder: (context, index) => FaqTile(item: faqData[index]),

    Risultato atteso

    Ogni domanda è ora una card arrotondata con icona, testo in grassetto e nessuna linea divisoria indesiderata.

  4. 4

    Tenere aperta una sola risposta alla volta

    Nel comportamento di default ogni tile è indipendente: l'utente può aprirle tutte. Se vuoi il classico accordion esclusivo devi portare lo stato "quale indice è aperto" nel widget padre, che quindi diventa uno StatefulWidget.

    Il punto delicato: ExpansionTile conserva il proprio stato interno, quindi cambiare initiallyExpanded da solo non basta. La soluzione più semplice per un principiante è assegnare una ValueKey che cambia quando lo stato di apertura cambia: Flutter ricrea il tile con il nuovo valore iniziale.

    In alternativa, dalla versione 3.13 puoi usare ExpansionTileController e chiamare controller.collapse() sugli altri tile. Il trucco della key resta però il più immediato da capire.

    class FaqScreen extends StatefulWidget {
      const FaqScreen({super.key});
    
      @override
      State<FaqScreen> createState() => _FaqScreenState();
    }
    
    class _FaqScreenState extends State<FaqScreen> {
      int? _openIndex;
    
      @override
      Widget build(BuildContext context) {
        return Scaffold(
          appBar: AppBar(title: const Text('Domande frequenti')),
          body: ListView.builder(
            padding: const EdgeInsets.all(12),
            itemCount: faqData.length,
            itemBuilder: (context, index) {
              final isOpen = _openIndex == index;
              return ExpansionTile(
                // La key cambia quando cambia lo stato: il tile viene ricostruito
                // rispettando il nuovo initiallyExpanded.
                key: ValueKey('faq_${index}_$isOpen'),
                initiallyExpanded: isOpen,
                title: Text(faqData[index].question),
                onExpansionChanged: (expanded) {
                  setState(() => _openIndex = expanded ? index : null);
                },
                childrenPadding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
                expandedCrossAxisAlignment: CrossAxisAlignment.start,
                children: [Text(faqData[index].answer)],
              );
            },
          ),
        );
      }
    }

    Risultato atteso

    Aprendo una domanda, quella precedentemente aperta si chiude automaticamente.

  5. 5

    La variante pronta all'uso: ExpansionPanelList.radio

    Se il comportamento "uno alla volta" è proprio ciò che ti serve, Flutter ha già un widget dedicato: ExpansionPanelList.radio. Gestisce l'esclusività da solo, senza setState né key.

    Punti da ricordare:

    • ogni pannello è un ExpansionPanelRadio e richiede un value univoco (qui usiamo la domanda stessa);
    • headerBuilder restituisce l'intestazione e riceve un bool isExpanded;
    • ExpansionPanelList non è scrollabile: va inserito dentro un SingleChildScrollView o un ListView;
    • puoi controllare le spaziature con elevation, expandedHeaderPadding e dividerColor.

    Se invece ti serve il controllo manuale dei pannelli, usa il costruttore standard ExpansionPanelList con expansionCallback e un flag isExpanded per ogni elemento.

    class FaqRadioScreen extends StatelessWidget {
      const FaqRadioScreen({super.key});
    
      @override
      Widget build(BuildContext context) {
        final theme = Theme.of(context);
    
        return Scaffold(
          appBar: AppBar(title: const Text('FAQ (una alla volta)')),
          body: SingleChildScrollView(
            padding: const EdgeInsets.all(12),
            child: ExpansionPanelList.radio(
              elevation: 1,
              expandedHeaderPadding: EdgeInsets.zero,
              dividerColor: theme.colorScheme.outlineVariant,
              children: faqData.map<ExpansionPanelRadio>((item) {
                return ExpansionPanelRadio(
                  value: item.question, // deve essere univoco
                  canTapOnHeader: true, // apre toccando tutta l'intestazione
                  headerBuilder: (context, isExpanded) => ListTile(
                    leading: const Icon(Icons.help_outline),
                    title: Text(
                      item.question,
                      style: TextStyle(
                        fontWeight:
                            isExpanded ? FontWeight.bold : FontWeight.normal,
                      ),
                    ),
                  ),
                  body: Padding(
                    padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
                    child: Align(
                      alignment: Alignment.centerLeft,
                      child: Text(item.answer),
                    ),
                  ),
                );
              }).toList(),
            ),
          ),
        );
      }
    }

    Risultato atteso

    La schermata mostra i pannelli con la stessa logica esclusiva del passo precedente, ma con molto meno codice e senza gestione manuale dello stato.

  6. 6

    Rifiniture: stato conservato, ricerca e accessibilità

    Alcuni accorgimenti finali che fanno la differenza in un'app reale.

    1. Conservare lo stato durante lo scroll. Con liste lunghe, un tile aperto che esce dallo schermo può tornare chiuso. Assegna una PageStorageKey (chiave unica e stabile) per far memorizzare lo stato allo scroll.

    2. Evitare l'overflow del testo. Le domande lunghe su schermi stretti possono causare il classico errore giallo-nero: Text dentro ExpansionTile va già a capo, ma se metti una Row nel title ricordati di avvolgere il testo in Expanded.

    3. Accessibilità. ExpansionTile espone già la semantica di espansione, ma è buona norma aggiungere un semanticsLabel o un Semantics(header: true) sulle domande, così gli screen reader annunciano correttamente la struttura.

    4. Filtro di ricerca. Con un TextField e un semplice where sulla lista ottieni una FAQ filtrabile in poche righe.

    Qui sotto la versione rifinita dell'itemBuilder.

    class SearchableFaq extends StatefulWidget {
      const SearchableFaq({super.key});
    
      @override
      State<SearchableFaq> createState() => _SearchableFaqState();
    }
    
    class _SearchableFaqState extends State<SearchableFaq> {
      String _query = '';
    
      @override
      Widget build(BuildContext context) {
        final items = faqData
            .where((f) => f.question.toLowerCase().contains(_query.toLowerCase()))
            .toList();
    
        return Scaffold(
          appBar: AppBar(title: const Text('Domande frequenti')),
          body: Column(
            children: [
              Padding(
                padding: const EdgeInsets.all(12),
                child: TextField(
                  decoration: const InputDecoration(
                    prefixIcon: Icon(Icons.search),
                    hintText: 'Cerca una domanda...',
                    border: OutlineInputBorder(),
                  ),
                  onChanged: (value) => setState(() => _query = value),
                ),
              ),
              Expanded(
                child: items.isEmpty
                    ? const Center(child: Text('Nessun risultato'))
                    : ListView.builder(
                        padding: const EdgeInsets.symmetric(horizontal: 12),
                        itemCount: items.length,
                        itemBuilder: (context, index) {
                          final item = items[index];
                          return ExpansionTile(
                            // Chiave stabile: lo stato sopravvive allo scroll.
                            key: PageStorageKey<String>(item.question),
                            title: Semantics(
                              header: true,
                              child: Text(item.question),
                            ),
                            childrenPadding:
                                const EdgeInsets.fromLTRB(16, 0, 16, 16),
                            expandedCrossAxisAlignment: CrossAxisAlignment.start,
                            children: [Text(item.answer)],
                          );
                        },
                      ),
              ),
            ],
          ),
        );
      }
    }

    Risultato atteso

    La FAQ è filtrabile dal campo di ricerca, i tile aperti restano aperti durante lo scroll e gli screen reader leggono correttamente le domande come intestazioni.

CondividiXLinkedInFacebookWhatsApp

Commenti (0)

Ancora nessun commento. Inizia tu!