[{"data":1,"prerenderedAt":27},["ShallowReactive",2],{"articolo-tabelle-di-dati-in-flutter-da-datatable-a-tableview-con-scroll-bidirezionale":3,"comments-article-tabelle-di-dati-in-flutter-da-datatable-a-tableview-con-scroll-bidirezionale":26},{"id":4,"title":5,"slug":6,"excerpt":7,"body":8,"cover_image":9,"cover_remote_url":10,"cover_credit":11,"video_url":15,"status":16,"published_at":17,"meta_title":18,"meta_description":19,"category":20,"author":24},126,"Tabelle di dati in Flutter: da DataTable a TableView con scroll bidirezionale","tabelle-di-dati-in-flutter-da-datatable-a-tableview-con-scroll-bidirezionale","Mostrare dati tabellari in Flutter è più complesso di quanto sembri: DataTable va in overflow, le liste lunghe rallentano. Guida pratica a DataTable, PaginatedDataTable, data_table_2 e al TableView di two_dimensional_scrollables con righe e colonne fissate.","## Perché le tabelle in Flutter sono un problema sottovalutato\n\nAppena un'app esce dal territorio delle liste verticali e deve mostrare dati tabellari — report, listini, log, fogli di calcolo, dashboard gestionali — il classico `ListView` non basta più. Servono intestazioni fisse, colonne allineate, scroll orizzontale **e** verticale sincronizzati, ordinamento e paginazione.\n\nFlutter offre strumenti diversi a seconda della scala del problema:\n\n| Scenario | Soluzione consigliata |\n|---|---|\n| Poche decine di righe, poche colonne | `DataTable` |\n| Centinaia\u002Fmigliaia di righe, dataset lato client o server | `PaginatedDataTable` + `DataTableSource` |\n| Header fisso, larghezze intelligenti, paginazione async | pacchetto `data_table_2` |\n| Griglia enorme con scroll su due assi e celle \"lazy\" | `TableView` di `two_dimensional_scrollables` |\n\nVediamoli tutti e tre, con il codice che serve davvero in produzione.\n\n## DataTable: semplice, ma costruisce tutto\n\n`DataTable` è un widget Material che renderizza **tutte** le righe che gli passi. È perfetto per 10-50 righe, disastroso per 5.000.\n\n```dart\nclass Product {\n  const Product(this.name, this.category, this.price, this.stock);\n  final String name;\n  final String category;\n  final double price;\n  final int stock;\n}\n\nclass ProductsTable extends StatefulWidget {\n  const ProductsTable({super.key, required this.products});\n  final List\u003CProduct> products;\n\n  @override\n  State\u003CProductsTable> createState() => _ProductsTableState();\n}\n\nclass _ProductsTableState extends State\u003CProductsTable> {\n  int _sortColumnIndex = 0;\n  bool _sortAscending = true;\n  late List\u003CProduct> _rows = [...widget.products];\n\n  void _sort\u003CT extends Comparable\u003CObject>>(\n    T Function(Product p) getField,\n    int columnIndex,\n    bool ascending,\n  ) {\n    _rows.sort((a, b) {\n      final av = getField(a);\n      final bv = getField(b);\n      return ascending ? av.compareTo(bv) : bv.compareTo(av);\n    });\n    setState(() {\n      _sortColumnIndex = columnIndex;\n      _sortAscending = ascending;\n    });\n  }\n\n  @override\n  Widget build(BuildContext context) {\n    return SingleChildScrollView(\n      scrollDirection: Axis.horizontal,\n      child: DataTable(\n        sortColumnIndex: _sortColumnIndex,\n        sortAscending: _sortAscending,\n        showCheckboxColumn: false,\n        headingRowColor: WidgetStateProperty.all(\n          Theme.of(context).colorScheme.surfaceContainerHighest,\n        ),\n        columns: [\n          DataColumn(\n            label: const Text('Prodotto'),\n            onSort: (i, asc) => _sort((p) => p.name, i, asc),\n          ),\n          DataColumn(\n            label: const Text('Categoria'),\n            onSort: (i, asc) => _sort((p) => p.category, i, asc),\n          ),\n          DataColumn(\n            label: const Text('Prezzo'),\n            numeric: true,\n            onSort: (i, asc) => _sort((p) => p.price, i, asc),\n          ),\n          const DataColumn(label: Text('Scorte'), numeric: true),\n        ],\n        rows: [\n          for (final p in _rows)\n            DataRow(\n              onSelectChanged: (_) => _openDetail(p),\n              cells: [\n                DataCell(Text(p.name)),\n                DataCell(Text(p.category)),\n                DataCell(Text('€ ${p.price.toStringAsFixed(2)}')),\n                DataCell(Text('${p.stock}')),\n              ],\n            ),\n        ],\n      ),\n    );\n  }\n\n  void _openDetail(Product p) {\u002F* ... *\u002F}\n}\n```\n\nPunti da ricordare:\n\n- `DataTable` **non scrolla da solo**: va avvolto in `SingleChildScrollView` (orizzontale e\u002Fo verticale), altrimenti ottieni il famigerato overflow giallo-nero.\n- L'ordinamento non è automatico: `sortColumnIndex`\u002F`sortAscending` disegnano solo la freccia, la `sort()` la scrivi tu.\n- `numeric: true` allinea a destra la colonna, convenzione utile per importi e quantità.\n- Con lo scroll orizzontale **l'intestazione scorre insieme alle righe**: non c'è header sticky nativo.\n\n## PaginatedDataTable e DataTableSource\n\nQuando le righe diventano centinaia, `PaginatedDataTable` mostra una pagina alla volta e costruisce solo le righe visibili. La sorgente dati è un `DataTableSource`, che è un `ChangeNotifier`: quando i dati cambiano chiami `notifyListeners()` e la tabella si aggiorna.\n\n```dart\nclass ProductsSource extends DataTableSource {\n  ProductsSource(this._items);\n\n  List\u003CProduct> _items;\n  final Set\u003Cint> _selected = {};\n\n  void updateItems(List\u003CProduct> items) {\n    _items = items;\n    _selected.clear();\n    notifyListeners();\n  }\n\n  @override\n  DataRow? getRow(int index) {\n    if (index >= _items.length) return null;\n    final p = _items[index];\n    return DataRow.byIndex(\n      index: index,\n      selected: _selected.contains(index),\n      onSelectChanged: (value) {\n        if (value == null) return;\n        value ? _selected.add(index) : _selected.remove(index);\n        notifyListeners();\n      },\n      cells: [\n        DataCell(Text(p.name)),\n        DataCell(Text(p.category)),\n        DataCell(Text('€ ${p.price.toStringAsFixed(2)}')),\n      ],\n    );\n  }\n\n  @override\n  bool get isRowCountApproximate => false;\n\n  @override\n  int get rowCount => _items.length;\n\n  @override\n  int get selectedRowCount => _selected.length;\n}\n```\n\nUso nel widget:\n\n```dart\nPaginatedDataTable(\n  header: const Text('Catalogo prodotti'),\n  actions: [IconButton(onPressed: _export, icon: const Icon(Icons.download))],\n  rowsPerPage: _rowsPerPage,\n  availableRowsPerPage: const [10, 25, 50],\n  onRowsPerPageChanged: (v) => setState(() => _rowsPerPage = v ?? 10),\n  columns: const [\n    DataColumn(label: Text('Prodotto')),\n    DataColumn(label: Text('Categoria')),\n    DataColumn(label: Text('Prezzo'), numeric: true),\n  ],\n  source: _source,\n)\n```\n\n> **Attenzione:** il `DataTableSource` va creato in `initState()` e **non** nel `build()`, altrimenti perdi selezione e stato a ogni rebuild. Ricordati di chiamare `dispose()` sulla source.\n\n### Paginazione lato server\n\nCon un backend paginato puoi far restituire `rowCount` dal totale dichiarato dall'API e caricare le pagine on demand: in `getRow()` restituisci `null` per le righe non ancora arrivate (la tabella mostra un placeholder) e avvii il fetch. In questo caso è molto più comodo il pacchetto `data_table_2`, che offre `AsyncPaginatedDataTable2` con `AsyncDataTableSource` pensato proprio per questo, più header fisso e colonne dimensionate in proporzione:\n\n```yaml\ndependencies:\n  data_table_2: ^2.5.15\n```\n\n```dart\nDataTable2(\n  columnSpacing: 12,\n  horizontalMargin: 12,\n  minWidth: 800, \u002F\u002F sotto questa soglia compare lo scroll orizzontale\n  fixedTopRows: 1, \u002F\u002F header sempre visibile\n  columns: const [\n    DataColumn2(label: Text('Prodotto'), size: ColumnSize.L),\n    DataColumn2(label: Text('Categoria'), size: ColumnSize.M),\n    DataColumn2(label: Text('Prezzo'), size: ColumnSize.S, numeric: true),\n  ],\n  rows: rows,\n)\n```\n\n## TableView: scroll bidirezionale e celle lazy\n\nPer griglie davvero grandi (migliaia di righe × decine di colonne) serve un widget che costruisca **solo le celle visibili su entrambi gli assi**. È esattamente ciò che fa `TableView`, parte del pacchetto ufficiale del team Flutter `two_dimensional_scrollables`, costruito sopra le API `TwoDimensionalScrollView` introdotte nel framework.\n\n```yaml\ndependencies:\n  two_dimensional_scrollables: ^0.3.3\n```\n\n```dart\nimport 'package:flutter\u002Fmaterial.dart';\nimport 'package:two_dimensional_scrollables\u002Ftwo_dimensional_scrollables.dart';\n\nclass SpreadsheetView extends StatelessWidget {\n  const SpreadsheetView({super.key, required this.data});\n\n  \u002F\u002F\u002F Matrice righe x colonne (la riga 0 è l'intestazione).\n  final List\u003CList\u003CString>> data;\n\n  @override\n  Widget build(BuildContext context) {\n    final theme = Theme.of(context);\n\n    return TableView.builder(\n      rowCount: data.length,\n      columnCount: data.first.length,\n      pinnedRowCount: 1,    \u002F\u002F intestazione sempre visibile\n      pinnedColumnCount: 1, \u002F\u002F prima colonna congelata\n      columnBuilder: (int index) => TableSpan(\n        extent: index == 0\n            ? const FixedTableSpanExtent(160)\n            : const FixedTableSpanExtent(120),\n        foregroundDecoration: const TableSpanDecoration(\n          border: TableSpanBorder(\n            trailing: BorderSide(color: Color(0x1F000000)),\n          ),\n        ),\n      ),\n      rowBuilder: (int index) => TableSpan(\n        extent: const FixedTableSpanExtent(48),\n        backgroundDecoration: TableSpanDecoration(\n          color: index == 0\n              ? theme.colorScheme.surfaceContainerHighest\n              : (index.isEven ? theme.colorScheme.surface : null),\n        ),\n      ),\n      cellBuilder: (BuildContext context, TableVicinity vicinity) {\n        final value = data[vicinity.row][vicinity.column];\n        return TableViewCell(\n          child: Align(\n            alignment: vicinity.column == 0\n                ? Alignment.centerLeft\n                : Alignment.centerRight,\n            child: Padding(\n              padding: const EdgeInsets.symmetric(horizontal: 12),\n              child: Text(\n                value,\n                maxLines: 1,\n                overflow: TextOverflow.ellipsis,\n                style: vicinity.row == 0\n                    ? theme.textTheme.labelLarge\n                    : theme.textTheme.bodyMedium,\n              ),\n            ),\n          ),\n        );\n      },\n    );\n  }\n}\n```\n\nCosa succede qui:\n\n- `cellBuilder` riceve una `TableVicinity` con `row` e `column`: è il \"coordinate system\" della griglia e viene invocato solo per le celle nel viewport (più il `cacheExtent`).\n- `pinnedRowCount` e `pinnedColumnCount` fissano le prime N righe\u002Fcolonne: l'header resta in alto e la colonna chiave resta a sinistra mentre scorri in diagonale.\n- `TableSpan` descrive la \"corsia\": estensione (`FixedTableSpanExtent`, `FractionalTableSpanExtent`, `MaxTableSpanExtent`, `RemainingTableSpanExtent`), decorazioni e bordi.\n- Puoi unire celle con `TableViewCell(rowMergeStart: ..., rowMergeSpan: ...)` e l'equivalente per le colonne.\n\n### Controllare lo scroll su due assi\n\n`TableView` espone `verticalDetails` e `horizontalDetails`, entrambi `ScrollableDetails`, quindi puoi agganciare un `ScrollController` per ogni asse — utile per il pulsante \"torna all'inizio\" o per sincronizzare una mini-mappa:\n\n```dart\nfinal _vertical = ScrollController();\nfinal _horizontal = ScrollController();\n\nTableView.builder(\n  verticalDetails: ScrollableDetails.vertical(controller: _vertical),\n  horizontalDetails: ScrollableDetails.horizontal(controller: _horizontal),\n  \u002F\u002F ...\n);\n\nvoid resetScroll() {\n  _vertical.jumpTo(0);\n  _horizontal.jumpTo(0);\n}\n```\n\nRicorda di fare `dispose()` di entrambi i controller.\n\n### Selezione di celle e tastiera\n\nSu desktop e web una griglia senza interazione da tastiera è monca. Dato che `cellBuilder` restituisce widget normali, puoi avvolgere la cella in `InkWell` + `Focus` e gestire le frecce con `Shortcuts`\u002F`Actions`, aggiornando un `ValueNotifier\u003CTableVicinity?>` per la cella attiva: il rebuild resta limitato alle celle visibili se usi un `ValueListenableBuilder` dentro `cellBuilder`.\n\n## Responsive: la tabella non è sempre la risposta\n\nSu uno schermo da 390 px una tabella a 8 colonne è illeggibile. Il pattern più solido è degradare la tabella in una lista di card:\n\n```dart\nLayoutBuilder(\n  builder: (context, constraints) {\n    if (constraints.maxWidth \u003C 700) {\n      return ListView.builder(\n        itemCount: products.length,\n        itemBuilder: (context, i) => ProductCard(product: products[i]),\n      );\n    }\n    return ProductsTable(products: products);\n  },\n)\n```\n\nIn alternativa, su mobile mostra solo 2-3 colonne chiave e apri un bottom sheet con il dettaglio completo al tap sulla riga.\n\n## Best practice\n\n1. **Scegli in base al numero di righe.** Sotto le ~50 righe `DataTable`; oltre, paginazione o `TableView`. Costruire 3.000 `DataRow` significa 3.000 sottoalberi di widget creati a ogni rebuild.\n2. **Fissa le larghezze.** Calcolare la larghezza dal contenuto costringe il layout a misurare tutto: con `TableSpan` usa estensioni fisse o frazionarie e tronca il testo con `TextOverflow.ellipsis`.\n3. **Evita widget costosi nelle celle.** Niente `Image.network` senza cache, niente `Opacity`\u002F`ClipRRect` a raffica: la stessa cella può essere ricostruita a ogni frame di scroll.\n4. **Formatta i dati fuori dal builder.** Le conversioni con `intl` (`NumberFormat`, `DateFormat`) vanno fatte una volta, non dentro `cellBuilder`: istanzia i formatter come campi `static final`.\n5. **Numeri allineati a destra e font tabellari.** Usa `numeric: true` oppure `fontFeatures: [FontFeature.tabularFigures()]` per avere cifre di larghezza costante.\n6. **Ordinamento e filtri nel layer dati.** Tieni la logica in un controller\u002Fnotifier: la tabella deve solo renderizzare una lista già ordinata.\n7. **Accessibilità.** Aggiungi `Semantics(label: ...)` alle celle con sole icone e assicurati che le righe cliccabili abbiano un target di almeno 48 px.\n8. **Golden test.** Le tabelle sono tra i componenti più soggetti a regressioni visive: un golden test con dataset fisso ti salva da modifiche di padding o di tema.\n\n## Conclusione\n\nNon esiste \"il widget tabella\" in Flutter: esiste una scala di soluzioni. `DataTable` risolve il 70% dei casi reali in poche righe, `PaginatedDataTable` (o `data_table_2`) copre le liste lunghe con paginazione sincrona o asincrona, mentre `TableView` di `two_dimensional_scrollables` è l'unica opzione sensata quando servono scroll su due assi, righe\u002Fcolonne congelate e lazy building delle celle. Parti dal più semplice e sali di complessità solo quando i numeri lo impongono: è la strategia che mantiene l'app fluida senza riscrivere da zero un motore di griglia.","https:\u002F\u002Fflutter.it\u002Fstorage\u002Farticles\u002Fc099884e-a659-42e5-a17a-fe70f9153c88.jpg","https:\u002F\u002Fimages.unsplash.com\u002Fphoto-1560221328-12fe60f83ab8?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=M3w5NzA2NTJ8MHwxfHJhbmRvbXx8fHx8fHx8fDE3OTE4NjQxMjN8&ixlib=rb-4.1.0&q=80&w=1080",{"name":12,"author_url":13,"photo_url":14},"Nicholas Cappello","https:\u002F\u002Funsplash.com\u002F@bash__profile","https:\u002F\u002Funsplash.com\u002Fphotos\u002Fclose-up-photo-of-monitor-displaying-graph-Wb63zqJ5gnE",null,"published","2026-10-13T04:02:03+00:00","Tabelle dati in Flutter: DataTable e TableView","Guida pratica alle tabelle in Flutter: DataTable, PaginatedDataTable, data_table_2 e TableView con scroll bidirezionale, righe fissate e best practice.",{"id":21,"name":22,"slug":23},1,"Guide","guide",{"id":21,"name":25},"Flutter Bot",[],1792316902043]