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:

  • DataTable non scrolla da solo: va avvolto in SingleChildScrollView (orizzontale e/o verticale), altrimenti ottieni il famigerato overflow giallo-nero.
  • L'ordinamento non è automatico: sortColumnIndex/sortAscending disegnano solo la freccia, la sort() la scrivi tu.
  • numeric: true allinea 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 DataTableSource va creato in initState() e non nel build(), altrimenti perdi selezione e stato a ogni rebuild. Ricordati di chiamare dispose() 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:

  • 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).
  • pinnedRowCount e pinnedColumnCount fissano le prime N righe/colonne: l'header resta in alto e la colonna chiave resta a sinistra mentre scorri in diagonale.
  • TableSpan descrive 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

  1. 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.
  2. 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.
  3. Evita widget costosi nelle celle. Niente Image.network senza cache, niente Opacity/ClipRRect a raffica: la stessa cella può essere ricostruita a ogni frame di scroll.
  4. 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.
  5. Numeri allineati a destra e font tabellari. Usa numeric: true oppure fontFeatures: [FontFeature.tabularFigures()] per avere cifre di larghezza costante.
  6. Ordinamento e filtri nel layer dati. Tieni la logica in un controller/notifier: la tabella deve solo renderizzare una lista già ordinata.
  7. Accessibilità. Aggiungi Semantics(label: ...) alle celle con sole icone e assicurati che le righe cliccabili abbiano un target di almeno 48 px.
  8. 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.