[{"data":1,"prerenderedAt":78},["ShallowReactive",2],{"tutorial-creare-una-faq-a-fisarmonica-in-flutter-con-expansiontile":3,"comments-tutorial-creare-una-faq-a-fisarmonica-in-flutter-con-expansiontile":77},{"id":4,"title":5,"slug":6,"excerpt":7,"intro":8,"cover_image":9,"cover_remote_url":10,"cover_credit":11,"video_url":15,"video":16,"difficulty":20,"estimated_minutes":21,"flutter_version":22,"status":23,"published_at":24,"meta_title":25,"meta_description":26,"category":27,"author":31,"steps":33},105,"Creare una FAQ a fisarmonica in Flutter con ExpansionTile","creare-una-faq-a-fisarmonica-in-flutter-con-expansiontile","Impara a costruire una sezione FAQ espandibile in Flutter: dal primo ExpansionTile alla personalizzazione grafica, fino all'apertura di una sola risposta alla volta con ExpansionPanelList.radio.","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.\n\nFlutter offre due widget pronti all'uso per questo scopo, entrambi inclusi nella libreria Material:\n\n- **`ExpansionTile`**: la soluzione più semplice, perfetta per elenchi di voci indipendenti;\n- **`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.\n\nIn 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à.\n\n> Non serve nessun pacchetto esterno: tutto quello che useremo è già dentro Flutter.","https:\u002F\u002Fflutter.it\u002Fstorage\u002Ftutorials\u002Ff21488b1-7f12-4d32-91d1-37600d258cbc.jpg","https:\u002F\u002Fimages.unsplash.com\u002Fphoto-1730578726753-dc0cf2f12c6f?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=M3w5NzA2NTJ8MHwxfHJhbmRvbXx8fHx8fHx8fDE3OTAzMTA2OTd8&ixlib=rb-4.1.0&q=80&w=1080",{"name":12,"author_url":13,"photo_url":14},"Jason Leung","https:\u002F\u002Funsplash.com\u002F@ninjason","https:\u002F\u002Funsplash.com\u002Fphotos\u002Fa-close-up-of-an-old-fashioned-typewriter-oE40l4jOAlQ","https:\u002F\u002Fwww.youtube.com\u002Fwatch?v=gchpcMngi5Y",{"youtube_id":17,"duration_seconds":18,"published_at":19},"gchpcMngi5Y",126,"2026-09-25T09:54:19+00:00","beginner",35,"3.x","published","2026-09-25T04:31:37+00:00","ExpansionTile Flutter: creare una FAQ a fisarmonica","Tutorial Flutter per principianti: crea una sezione FAQ espandibile con ExpansionTile ed ExpansionPanelList.radio, con personalizzazione grafica e best practice.",{"id":28,"name":29,"slug":30},1,"Guide","guide",{"id":28,"name":32},"Flutter Bot",[34,42,49,56,63,70],{"id":35,"position":28,"title":36,"body":37,"code_snippet":38,"code_language":39,"expected_result":40,"demo_url":41,"video_url":41},701,"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**.\n\n```bash\nflutter create faq_demo\ncd faq_demo\n```\n\nCrea il file `lib\u002Ffaq_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.\n\nNel file dello schermo principale (`lib\u002Fmain.dart`) definiamo anche una lista statica di dati di esempio.","\u002F\u002F lib\u002Ffaq_item.dart\nclass FaqItem {\n  final String question;\n  final String answer;\n\n  const FaqItem({\n    required this.question,\n    required this.answer,\n  });\n}\n\nconst faqData = \u003CFaqItem>[\n  FaqItem(\n    question: 'Come posso creare un account?',\n    answer:\n        'Tocca \"Registrati\" nella schermata di accesso, inserisci la tua email '\n        'e scegli una password di almeno 8 caratteri.',\n  ),\n  FaqItem(\n    question: 'Ho dimenticato la password, cosa faccio?',\n    answer:\n        'Dalla schermata di accesso tocca \"Password dimenticata\": riceverai '\n        'un link di reimpostazione valido per 24 ore.',\n  ),\n  FaqItem(\n    question: 'L\\'app funziona offline?',\n    answer:\n        'Sì. I contenuti già scaricati restano consultabili anche senza rete; '\n        'le modifiche vengono sincronizzate appena torni online.',\n  ),\n  FaqItem(\n    question: 'Come contatto l\\'assistenza?',\n    answer:\n        'Scrivi a supporto@example.com oppure usa il modulo nella sezione '\n        'Impostazioni > Aiuto. Rispondiamo entro 2 giorni lavorativi.',\n  ),\n];","dart","Il progetto compila e hai a disposizione una lista `faqData` con quattro domande di esempio.",null,{"id":43,"position":44,"title":45,"body":46,"code_snippet":47,"code_language":39,"expected_result":48,"demo_url":41,"video_url":41},702,2,"Costruire la schermata con il primo ExpansionTile","Sostituisci il contenuto di `lib\u002Fmain.dart` con una `MaterialApp` che mostra la lista delle FAQ.\n\nUsiamo **`ListView.builder`** perché costruisce solo gli elementi visibili: è la scelta corretta anche se oggi le voci sono poche ma domani potrebbero diventare cinquanta.\n\nIl cuore del passo è **`ExpansionTile`**, che nella forma minima richiede solo:\n\n- `title`: il widget sempre visibile (la domanda);\n- `children`: la lista di widget mostrati quando il tile è aperto (la risposta).\n\nDi default `ExpansionTile` gestisce da solo lo stato aperto\u002Fchiuso e anima la freccia di espansione.","import 'package:flutter\u002Fmaterial.dart';\nimport 'faq_item.dart';\n\nvoid main() => runApp(const FaqApp());\n\nclass FaqApp extends StatelessWidget {\n  const FaqApp({super.key});\n\n  @override\n  Widget build(BuildContext context) {\n    return MaterialApp(\n      title: 'FAQ Demo',\n      debugShowCheckedModeBanner: false,\n      theme: ThemeData(\n        colorSchemeSeed: Colors.indigo,\n        useMaterial3: true,\n      ),\n      home: const FaqScreen(),\n    );\n  }\n}\n\nclass FaqScreen extends StatelessWidget {\n  const FaqScreen({super.key});\n\n  @override\n  Widget build(BuildContext context) {\n    return Scaffold(\n      appBar: AppBar(title: const Text('Domande frequenti')),\n      body: ListView.builder(\n        padding: const EdgeInsets.all(12),\n        itemCount: faqData.length,\n        itemBuilder: (context, index) {\n          final item = faqData[index];\n          return ExpansionTile(\n            title: Text(item.question),\n            children: [\n              Padding(\n                padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),\n                child: Text(item.answer),\n              ),\n            ],\n          );\n        },\n      ),\n    );\n  }\n}","Avviando l'app vedi l'elenco delle domande: toccandone una, la risposta appare con un'animazione e la freccia ruota verso l'alto.",{"id":50,"position":51,"title":52,"body":53,"code_snippet":54,"code_language":39,"expected_result":55,"demo_url":41,"video_url":41},703,3,"Personalizzare l'aspetto del tile","L'aspetto di default è funzionale ma anonimo. `ExpansionTile` espone molte proprietà utili:\n\n- `leading` \u002F `trailing`: widget a sinistra e a destra (se imposti `trailing` sostituisci la freccia di default);\n- `tilePadding` e `childrenPadding`: spaziature interne;\n- `collapsedTextColor` \u002F `textColor` e `collapsedIconColor` \u002F `iconColor`: colori nei due stati;\n- `shape` e `collapsedShape`: la forma del bordo quando il tile è aperto o chiuso;\n- `backgroundColor` \u002F `collapsedBackgroundColor`;\n- `initiallyExpanded`: se la voce parte già aperta;\n- `onExpansionChanged`: callback con un `bool` quando l'utente apre o chiude.\n\nUn 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`).\n\nEstraiamo il tutto in un widget riutilizzabile `FaqTile`.","class FaqTile extends StatelessWidget {\n  final FaqItem item;\n\n  const FaqTile({super.key, required this.item});\n\n  @override\n  Widget build(BuildContext context) {\n    final theme = Theme.of(context);\n\n    return Card(\n      elevation: 0,\n      margin: const EdgeInsets.symmetric(vertical: 6),\n      color: theme.colorScheme.surfaceContainerHighest,\n      shape: RoundedRectangleBorder(\n        borderRadius: BorderRadius.circular(16),\n      ),\n      clipBehavior: Clip.antiAlias,\n      child: Theme(\n        \u002F\u002F Rimuove le linee divisorie disegnate dall'ExpansionTile.\n        data: theme.copyWith(dividerColor: Colors.transparent),\n        child: ExpansionTile(\n          leading: Icon(\n            Icons.help_outline,\n            color: theme.colorScheme.primary,\n          ),\n          title: Text(\n            item.question,\n            style: const TextStyle(fontWeight: FontWeight.w600),\n          ),\n          tilePadding: const EdgeInsets.symmetric(horizontal: 16),\n          childrenPadding: const EdgeInsets.fromLTRB(16, 0, 16, 16),\n          expandedCrossAxisAlignment: CrossAxisAlignment.start,\n          iconColor: theme.colorScheme.primary,\n          collapsedIconColor: theme.colorScheme.onSurfaceVariant,\n          onExpansionChanged: (isOpen) {\n            debugPrint('${item.question} -> ${isOpen ? \"aperta\" : \"chiusa\"}');\n          },\n          children: [\n            Text(\n              item.answer,\n              style: theme.textTheme.bodyMedium,\n            ),\n          ],\n        ),\n      ),\n    );\n  }\n}\n\n\u002F\u002F Nel ListView.builder dello step precedente:\n\u002F\u002F itemBuilder: (context, index) => FaqTile(item: faqData[index]),","Ogni domanda è ora una card arrotondata con icona, testo in grassetto e nessuna linea divisoria indesiderata.",{"id":57,"position":58,"title":59,"body":60,"code_snippet":61,"code_language":39,"expected_result":62,"demo_url":41,"video_url":41},704,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`.\n\nIl 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.\n\n> 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 {\n  const FaqScreen({super.key});\n\n  @override\n  State\u003CFaqScreen> createState() => _FaqScreenState();\n}\n\nclass _FaqScreenState extends State\u003CFaqScreen> {\n  int? _openIndex;\n\n  @override\n  Widget build(BuildContext context) {\n    return Scaffold(\n      appBar: AppBar(title: const Text('Domande frequenti')),\n      body: ListView.builder(\n        padding: const EdgeInsets.all(12),\n        itemCount: faqData.length,\n        itemBuilder: (context, index) {\n          final isOpen = _openIndex == index;\n          return ExpansionTile(\n            \u002F\u002F La key cambia quando cambia lo stato: il tile viene ricostruito\n            \u002F\u002F rispettando il nuovo initiallyExpanded.\n            key: ValueKey('faq_${index}_$isOpen'),\n            initiallyExpanded: isOpen,\n            title: Text(faqData[index].question),\n            onExpansionChanged: (expanded) {\n              setState(() => _openIndex = expanded ? index : null);\n            },\n            childrenPadding: const EdgeInsets.fromLTRB(16, 0, 16, 16),\n            expandedCrossAxisAlignment: CrossAxisAlignment.start,\n            children: [Text(faqData[index].answer)],\n          );\n        },\n      ),\n    );\n  }\n}","Aprendo una domanda, quella precedentemente aperta si chiude automaticamente.",{"id":64,"position":65,"title":66,"body":67,"code_snippet":68,"code_language":39,"expected_result":69,"demo_url":41,"video_url":41},705,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.\n\nPunti da ricordare:\n\n- ogni pannello è un **`ExpansionPanelRadio`** e richiede un `value` **univoco** (qui usiamo la domanda stessa);\n- `headerBuilder` restituisce l'intestazione e riceve un `bool isExpanded`;\n- `ExpansionPanelList` **non è scrollabile**: va inserito dentro un `SingleChildScrollView` o un `ListView`;\n- puoi controllare le spaziature con `elevation`, `expandedHeaderPadding` e `dividerColor`.\n\nSe 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 {\n  const FaqRadioScreen({super.key});\n\n  @override\n  Widget build(BuildContext context) {\n    final theme = Theme.of(context);\n\n    return Scaffold(\n      appBar: AppBar(title: const Text('FAQ (una alla volta)')),\n      body: SingleChildScrollView(\n        padding: const EdgeInsets.all(12),\n        child: ExpansionPanelList.radio(\n          elevation: 1,\n          expandedHeaderPadding: EdgeInsets.zero,\n          dividerColor: theme.colorScheme.outlineVariant,\n          children: faqData.map\u003CExpansionPanelRadio>((item) {\n            return ExpansionPanelRadio(\n              value: item.question, \u002F\u002F deve essere univoco\n              canTapOnHeader: true, \u002F\u002F apre toccando tutta l'intestazione\n              headerBuilder: (context, isExpanded) => ListTile(\n                leading: const Icon(Icons.help_outline),\n                title: Text(\n                  item.question,\n                  style: TextStyle(\n                    fontWeight:\n                        isExpanded ? FontWeight.bold : FontWeight.normal,\n                  ),\n                ),\n              ),\n              body: Padding(\n                padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),\n                child: Align(\n                  alignment: Alignment.centerLeft,\n                  child: Text(item.answer),\n                ),\n              ),\n            );\n          }).toList(),\n        ),\n      ),\n    );\n  }\n}","La schermata mostra i pannelli con la stessa logica esclusiva del passo precedente, ma con molto meno codice e senza gestione manuale dello stato.",{"id":71,"position":72,"title":73,"body":74,"code_snippet":75,"code_language":39,"expected_result":76,"demo_url":41,"video_url":41},706,6,"Rifiniture: stato conservato, ricerca e accessibilità","Alcuni accorgimenti finali che fanno la differenza in un'app reale.\n\n**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.\n\n**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`.\n\n**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.\n\n**4. Filtro di ricerca.** Con un `TextField` e un semplice `where` sulla lista ottieni una FAQ filtrabile in poche righe.\n\nQui sotto la versione rifinita dell'`itemBuilder`.","class SearchableFaq extends StatefulWidget {\n  const SearchableFaq({super.key});\n\n  @override\n  State\u003CSearchableFaq> createState() => _SearchableFaqState();\n}\n\nclass _SearchableFaqState extends State\u003CSearchableFaq> {\n  String _query = '';\n\n  @override\n  Widget build(BuildContext context) {\n    final items = faqData\n        .where((f) => f.question.toLowerCase().contains(_query.toLowerCase()))\n        .toList();\n\n    return Scaffold(\n      appBar: AppBar(title: const Text('Domande frequenti')),\n      body: Column(\n        children: [\n          Padding(\n            padding: const EdgeInsets.all(12),\n            child: TextField(\n              decoration: const InputDecoration(\n                prefixIcon: Icon(Icons.search),\n                hintText: 'Cerca una domanda...',\n                border: OutlineInputBorder(),\n              ),\n              onChanged: (value) => setState(() => _query = value),\n            ),\n          ),\n          Expanded(\n            child: items.isEmpty\n                ? const Center(child: Text('Nessun risultato'))\n                : ListView.builder(\n                    padding: const EdgeInsets.symmetric(horizontal: 12),\n                    itemCount: items.length,\n                    itemBuilder: (context, index) {\n                      final item = items[index];\n                      return ExpansionTile(\n                        \u002F\u002F Chiave stabile: lo stato sopravvive allo scroll.\n                        key: PageStorageKey\u003CString>(item.question),\n                        title: Semantics(\n                          header: true,\n                          child: Text(item.question),\n                        ),\n                        childrenPadding:\n                            const EdgeInsets.fromLTRB(16, 0, 16, 16),\n                        expandedCrossAxisAlignment: CrossAxisAlignment.start,\n                        children: [Text(item.answer)],\n                      );\n                    },\n                  ),\n          ),\n        ],\n      ),\n    );\n  }\n}","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.",[],1792316929379]