Perché le tabelle in Flutter sono un problema sottovalutato
Appena 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.
Flutter offre strumenti diversi a seconda della scala del problema:
| Scenario | Soluzione consigliata |
|---|---|
| Poche decine di righe, poche colonne | DataTable |
| Centinaia/migliaia di righe, dataset lato client o server | PaginatedDataTable + DataTableSource |
| Header fisso, larghezze intelligenti, paginazione async | pacchetto data_table_2 |
| Griglia enorme con scroll su due assi e celle "lazy" | TableView di two_dimensional_scrollables |
Vediamoli tutti e tre, con il codice che serve davvero in produzione.
DataTable: semplice, ma costruisce tutto
DataTable è un widget Material che renderizza tutte le righe che gli passi. È perfetto per 10-50 righe, disastroso per 5.000.
class Product {
const Product(this.name, this.category, this.price, this.stock);
final String name;
final String category;
final double price;
final int stock;
}
class ProductsTable extends StatefulWidget {
const ProductsTable({super.key, required this.products});
final List<Product> products;
@override
State<ProductsTable> createState() => _ProductsTableState();
}
class _ProductsTableState extends State<ProductsTable> {
int _sortColumnIndex = 0;
bool _sortAscending = true;
late List<Product> _rows = [...widget.products];
void _sort<T extends Comparable<Object>>(
T Function(Product p) getField,
int columnIndex,
bool ascending,
) {
_rows.sort((a, b) {
final av = getField(a);
final bv = getField(b);
return ascending ? av.compareTo(bv) : bv.compareTo(av);
});
setState(() {
_sortColumnIndex = columnIndex;
_sortAscending = ascending;
});
}
@override
Widget build(BuildContext context) {
return SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: DataTable(
sortColumnIndex: _sortColumnIndex,
sortAscending: _sortAscending,
showCheckboxColumn: false,
headingRowColor: WidgetStateProperty.all(
Theme.of(context).colorScheme.surfaceContainerHighest,
),
columns: [
DataColumn(
label: const Text('Prodotto'),
onSort: (i, asc) => _sort((p) => p.name, i, asc),
),
DataColumn(
label: const Text('Categoria'),
onSort: (i, asc) => _sort((p) => p.category, i, asc),
),
DataColumn(
label: const Text('Prezzo'),
numeric: true,
onSort: (i, asc) => _sort((p) => p.price, i, asc),
),
const DataColumn(label: Text('Scorte'), numeric: true),
],
rows: [
for (final p in _rows)
DataRow(
onSelectChanged: (_) => _openDetail(p),
cells: [
DataCell(Text(p.name)),
DataCell(Text(p.category)),
DataCell(Text('€ ${p.price.toStringAsFixed(2)}')),
DataCell(Text('${p.stock}')),
],
),
],
),
);
}
void _openDetail(Product p) {/* ... */}
}
Punti da ricordare:
DataTablenon scrolla da solo: va avvolto inSingleChildScrollView(orizzontale e/o verticale), altrimenti ottieni il famigerato overflow giallo-nero.- L'ordinamento non è automatico:
sortColumnIndex/sortAscendingdisegnano solo la freccia, lasort()la scrivi tu. numeric: trueallinea a destra la colonna, convenzione utile per importi e quantità.- Con lo scroll orizzontale l'intestazione scorre insieme alle righe: non c'è header sticky nativo.
PaginatedDataTable e DataTableSource
Quando 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.
class ProductsSource extends DataTableSource {
ProductsSource(this._items);
List<Product> _items;
final Set<int> _selected = {};
void updateItems(List<Product> items) {
_items = items;
_selected.clear();
notifyListeners();
}
@override
DataRow? getRow(int index) {
if (index >= _items.length) return null;
final p = _items[index];
return DataRow.byIndex(
index: index,
selected: _selected.contains(index),
onSelectChanged: (value) {
if (value == null) return;
value ? _selected.add(index) : _selected.remove(index);
notifyListeners();
},
cells: [
DataCell(Text(p.name)),
DataCell(Text(p.category)),
DataCell(Text('€ ${p.price.toStringAsFixed(2)}')),
],
);
}
@override
bool get isRowCountApproximate => false;
@override
int get rowCount => _items.length;
@override
int get selectedRowCount => _selected.length;
}
Uso nel widget:
PaginatedDataTable(
header: const Text('Catalogo prodotti'),
actions: [IconButton(onPressed: _export, icon: const Icon(Icons.download))],
rowsPerPage: _rowsPerPage,
availableRowsPerPage: const [10, 25, 50],
onRowsPerPageChanged: (v) => setState(() => _rowsPerPage = v ?? 10),
columns: const [
DataColumn(label: Text('Prodotto')),
DataColumn(label: Text('Categoria')),
DataColumn(label: Text('Prezzo'), numeric: true),
],
source: _source,
)
Attenzione: il
DataTableSourceva creato ininitState()e non nelbuild(), altrimenti perdi selezione e stato a ogni rebuild. Ricordati di chiamaredispose()sulla source.
Paginazione lato server
Con 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:
dependencies:
data_table_2: ^2.5.15
DataTable2(
columnSpacing: 12,
horizontalMargin: 12,
minWidth: 800, // sotto questa soglia compare lo scroll orizzontale
fixedTopRows: 1, // header sempre visibile
columns: const [
DataColumn2(label: Text('Prodotto'), size: ColumnSize.L),
DataColumn2(label: Text('Categoria'), size: ColumnSize.M),
DataColumn2(label: Text('Prezzo'), size: ColumnSize.S, numeric: true),
],
rows: rows,
)
TableView: scroll bidirezionale e celle lazy
Per 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.
dependencies:
two_dimensional_scrollables: ^0.3.3
import 'package:flutter/material.dart';
import 'package:two_dimensional_scrollables/two_dimensional_scrollables.dart';
class SpreadsheetView extends StatelessWidget {
const SpreadsheetView({super.key, required this.data});
/// Matrice righe x colonne (la riga 0 è l'intestazione).
final List<List<String>> data;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return TableView.builder(
rowCount: data.length,
columnCount: data.first.length,
pinnedRowCount: 1, // intestazione sempre visibile
pinnedColumnCount: 1, // prima colonna congelata
columnBuilder: (int index) => TableSpan(
extent: index == 0
? const FixedTableSpanExtent(160)
: const FixedTableSpanExtent(120),
foregroundDecoration: const TableSpanDecoration(
border: TableSpanBorder(
trailing: BorderSide(color: Color(0x1F000000)),
),
),
),
rowBuilder: (int index) => TableSpan(
extent: const FixedTableSpanExtent(48),
backgroundDecoration: TableSpanDecoration(
color: index == 0
? theme.colorScheme.surfaceContainerHighest
: (index.isEven ? theme.colorScheme.surface : null),
),
),
cellBuilder: (BuildContext context, TableVicinity vicinity) {
final value = data[vicinity.row][vicinity.column];
return TableViewCell(
child: Align(
alignment: vicinity.column == 0
? Alignment.centerLeft
: Alignment.centerRight,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12),
child: Text(
value,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: vicinity.row == 0
? theme.textTheme.labelLarge
: theme.textTheme.bodyMedium,
),
),
),
);
},
);
}
}
Cosa succede qui:
cellBuilderriceve unaTableVicinityconrowecolumn: è il "coordinate system" della griglia e viene invocato solo per le celle nel viewport (più ilcacheExtent).pinnedRowCountepinnedColumnCountfissano le prime N righe/colonne: l'header resta in alto e la colonna chiave resta a sinistra mentre scorri in diagonale.TableSpandescrive la "corsia": estensione (FixedTableSpanExtent,FractionalTableSpanExtent,MaxTableSpanExtent,RemainingTableSpanExtent), decorazioni e bordi.- Puoi unire celle con
TableViewCell(rowMergeStart: ..., rowMergeSpan: ...)e l'equivalente per le colonne.
Controllare lo scroll su due assi
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:
final _vertical = ScrollController();
final _horizontal = ScrollController();
TableView.builder(
verticalDetails: ScrollableDetails.vertical(controller: _vertical),
horizontalDetails: ScrollableDetails.horizontal(controller: _horizontal),
// ...
);
void resetScroll() {
_vertical.jumpTo(0);
_horizontal.jumpTo(0);
}
Ricorda di fare dispose() di entrambi i controller.
Selezione di celle e tastiera
Su 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/Actions, aggiornando un ValueNotifier<TableVicinity?> per la cella attiva: il rebuild resta limitato alle celle visibili se usi un ValueListenableBuilder dentro cellBuilder.
Responsive: la tabella non è sempre la risposta
Su uno schermo da 390 px una tabella a 8 colonne è illeggibile. Il pattern più solido è degradare la tabella in una lista di card:
LayoutBuilder(
builder: (context, constraints) {
if (constraints.maxWidth < 700) {
return ListView.builder(
itemCount: products.length,
itemBuilder: (context, i) => ProductCard(product: products[i]),
);
}
return ProductsTable(products: products);
},
)
In alternativa, su mobile mostra solo 2-3 colonne chiave e apri un bottom sheet con il dettaglio completo al tap sulla riga.
Best practice
- Scegli in base al numero di righe. Sotto le ~50 righe
DataTable; oltre, paginazione oTableView. Costruire 3.000DataRowsignifica 3.000 sottoalberi di widget creati a ogni rebuild. - Fissa le larghezze. Calcolare la larghezza dal contenuto costringe il layout a misurare tutto: con
TableSpanusa estensioni fisse o frazionarie e tronca il testo conTextOverflow.ellipsis. - Evita widget costosi nelle celle. Niente
Image.networksenza cache, nienteOpacity/ClipRRecta raffica: la stessa cella può essere ricostruita a ogni frame di scroll. - Formatta i dati fuori dal builder. Le conversioni con
intl(NumberFormat,DateFormat) vanno fatte una volta, non dentrocellBuilder: istanzia i formatter come campistatic final. - Numeri allineati a destra e font tabellari. Usa
numeric: trueoppurefontFeatures: [FontFeature.tabularFigures()]per avere cifre di larghezza costante. - Ordinamento e filtri nel layer dati. Tieni la logica in un controller/notifier: la tabella deve solo renderizzare una lista già ordinata.
- Accessibilità. Aggiungi
Semantics(label: ...)alle celle con sole icone e assicurati che le righe cliccabili abbiano un target di almeno 48 px. - 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.
Conclusione
Non 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/colonne 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.