tablex

A production-grade Flutter data grid — zero dependency on any third-party grid engine.


Why tablex?

Flutter ships no production-ready data grid. Building one yourself means solving the same problems every time. tablex solves them once:

Infinite scroll eats memory. tablex's infinite mode keeps only a configurable window of pages in memory. Old pages are evicted as new ones arrive and the scroll position is compensated so the viewport never jumps. Data races from concurrent in-flight fetches are eliminated with a generation counter — stale responses are dropped silently.

Every pixel is yours to own — or leave alone. tablex inherits Material 3 ColorScheme by default, so it looks right in any app without a single line of theme config. When you do need to customize, TablexThemeData lets you override exactly what you want — border radius, row colours, checkbox style, density, cell borders, pagination background — without touching what you don't. Wrap a subtree with TablexTheme to scope a theme to a single screen. Every major UI surface has an escape hatch: replace the filter bar with filterBarBuilder, the pagination footer with footerBuilder, the selection summary with selectionSummaryBuilder, and individual cells with cellRenderer. You can swap any one piece without re-implementing the rest.

Server pagination is hard to get right. Page caching, cache invalidation when sort or filters change, deduplication of in-flight requests, back-navigation without re-fetching — tablex handles all of it. You write one fetchTask callback that receives the current query state and returns a page of rows. The library does the rest.

Cursor-based APIs (GraphQL Relay, DynamoDB, Firestore, etc.) are harder still. Opaque tokens can't be computed from a page number — the footer has to remember every token it has seen. tablex maintains a cursor history automatically. Return nextCursor from your fetch and the footer switches to cursor mode transparently: the page indicator updates on every navigation tap and back-navigation reuses cached tokens without a round trip.

Export is always an afterthought — until it isn't. tablex ships a toolbar that exports to CSV, Excel, and PDF out of the box. Export the full dataset or only the selected rows. CSV output is formula-injection safe. PDF auto-detects RTL text (Arabic, Hebrew, etc.) and switches text direction per cell — no flag required. Supply a bundled TTF or use Google Fonts; both paths work.

Filter UI is boilerplate that never ends. Return TablexResponseMeta.filters from your fetch function and tablex renders interactive filter chips above the grid automatically. Each chip opens a checkbox or radio dialog; the selected values flow back into query.params and trigger a re-fetch. Replace the chip UI entirely with filterBarBuilder to render your own inline controls — no subclassing required.

Inline editing in a grid is surprisingly complex. Double-tap any cell to edit it. Tab / Shift+Tab moves between editable columns; ↓ / ↑ moves between rows. Replace the default TextField with any widget — dropdown, date picker, colour swatch — via editRenderer. Confirm with Enter, cancel with Escape. onEdit fires with the typed domain object so you can apply an optimistic update without touching the row key.


Screenshots

iOS macOS
iOS macOS

Web


What's included

Capability Detail
Grid modes static, lazy-paged, infinite scroll, select-picker
Pagination offset-based and cursor-based (opaque tokens), 10-page cache, page-jump indicator
Sorting server-side or client-side, multi-column ready
Filter bar server-driven chips, single-select (radio) and multi-select (checkbox), custom override
Cell renderers identifier, two-line, avatar + two-line, currency, date, status chip, action buttons
Inline editing per-cell double-tap, keyboard nav, custom edit widget
Export CSV (injection-safe), Excel (.xlsx), PDF (landscape, RTL auto-detect)
Import CSV and Excel via toolbar
Selection single / multiple with summary bar, bulk actions, selected-only export
Column management resize, reorder, show/hide, groups, frozen columns
Skeleton loading placeholder rows during first fetch
Theming & customisation inherit Material 3 by default; override any surface via TablexThemeData; swap filter bar, footer, summary bar, or any cell renderer independently
i18n slang-based locale strings, fully overridable

Getting started

dependencies:
  tablex: ^0.7.4

Usage

TablexConsumer is the highest-level widget. It wraps Tablex.lazyPaged and adds a rounded bordered container, an optional title/filter header slot, automatic filter-chip rendering, and a selection summary bar. A TablexController is created and disposed for you unless you supply your own.

TablexConsumer<Employee>(
  columns: [
    TablexColumn<Employee, String>(
      fieldKey: 'name',
      title: 'Name',
      width: 180,
      valueGetter: (e) => e.name,
    ),
    TablexColumn<Employee, double>(
      fieldKey: 'salary',
      title: 'Salary',
      width: 130,
      textAlign: TextAlign.end,
      valueGetter: (e) => e.salary,
      cellRenderer: TablexRenderers.currency(symbol: '\$'),
    ),
  ],
  fetchTask: (query) async {
    final resp = await api.getEmployees(
      page: query.page,
      pageSize: query.pageSize,
      sort: query.sort?.field,
      sortAsc: query.sort?.direction == TablexSortDirection.ascending,
    );
    return TablexFetchResult(rows: resp.items, totalRows: resp.total);
  },
  initialPageSize: 13,
  tableHeader: const Text('Employees', style: TextStyle(fontWeight: FontWeight.bold)),
)

Optional header & filter slots

TablexConsumer<Employee>(
  // ...
  tableHeader: Row(
    children: [
      const Text('Employees'),
      const Spacer(),
      TablexToolbar<Employee>(controller: _controller, columns: _columns),
    ],
  ),
  tableFilter: MySearchField(onChanged: (v) => _controller.setParam('q', v)),
)

Skeleton loading

Pre-populate with placeholder rows until the first real page arrives:

TablexConsumer<Employee>(
  // ...
  loadingBuilder: TablexLoadingBuilder(
    skeletonData: List.generate(13, (_) => Employee.placeholder()),
    builder: (context, table) => Skeletonizer(enabled: true, child: table),
  ),
)
TablexConsumer<Employee>(
  // ...
  enablePageJump: true, // editable page-number field
  footerBuilder: (context, info) => Row(
    children: [
      Text('Page ${info.page} of ${info.totalPages}'),
      IconButton(
        icon: const Icon(Icons.chevron_right),
        onPressed: info.nextPage,
      ),
    ],
  ),
)

Tablex.static — in-memory grid

All rows are provided upfront. Sorting is handled client-side.

Tablex<Employee>.static(
  columns: columns,
  rows: employees,
  rowBuilder: (e) => TablexRow(
    data: e,
    key: e.id.toString(),
    cells: {'name': e.name, 'salary': e.salary},
  ),
)

Tablex.lazyPaged — server-side pagination

Fetches one page at a time. The built-in pagination footer handles page navigation, page-size selection, and a page cache (up to 10 pages cached to avoid re-fetching on back-navigation).

Tablex<Employee>.lazyPaged(
  columns: columns,
  fetchTask: (query) async {
    final result = await api.fetchPage(
      page: query.page,
      pageSize: query.pageSize,
      sortField: query.sort?.field,
      sortAsc: query.sort?.direction == TablexSortDirection.ascending,
    );
    return TablexFetchResult(rows: result.items, totalRows: result.total);
  },
  rowBuilder: rowBuilder,
  initialPageSize: 20,
)

Tablex.infinite — infinite scroll with sliding window

New pages are fetched automatically as the user scrolls toward the bottom. A configurable sliding window (windowPages) keeps only that many pages in memory at once — old pages are evicted as new ones arrive, and the scroll position is compensated so the viewport never jumps.

Tablex<Employee>.infinite(
  columns: columns,
  fetchTask: (query) async {
    final result = await api.fetchPage(
      page: query.page,
      pageSize: query.pageSize,
    );
    return TablexFetchResult(rows: result.items, totalRows: result.total);
  },
  rowBuilder: rowBuilder,
  fetchSize: 50,
  windowPages: 5,        // keep at most 5 pages in memory
  loadingBuilder: TablexLoadingBuilder(
    skeletonData: List.generate(15, (_) => Employee.placeholder()),
    builder: (context, table) => Skeletonizer(enabled: true, child: table),
  ),
)

Sorting resets the scroll position, clears all loaded pages, and re-fetches from page 1 — stale in-flight requests are discarded via a generation counter so no data races occur.


Tablex.select — picker / combobox

Turns the grid into a single or multi-select picker. Density defaults to compact.

Tablex<Country>.select(
  columns: countryColumns,
  rows: countries,
  rowBuilder: countryRowBuilder,
  multiSelect: true,
  onSelectionChanged: (selected) => setState(() => _picked = selected),
)

TablexController

The controller is optional — each widget creates its own unless you pass one. Provide your own when you need to drive the grid from outside.

final _controller = TablexController<Employee>();

// Refresh (re-fetches current page, invalidates the page cache)
_controller.refresh();

// Pass arbitrary params to fetchTask
_controller.setParam('status', 'active');

// Programmatic navigation
_controller.goToPage(3);
_controller.nextPage();
_controller.previousPage();

// Sort
_controller.setSort(const TablexColumnSort(
  field: 'name',
  direction: TablexSortDirection.ascending,
));

// Row manipulation (useful for optimistic updates)
_controller.updateRow(0, updatedEmployee, rowBuilder: rowBuilder);
_controller.removeRow(0);

// Export — all rows
final csv   = _controller.exportToCsv(columns);
final xlsx  = _controller.exportToExcel(columns);
final pdf   = await _controller.exportToPdf(columns);   // async

// Export — selected rows only
final csv2  = _controller.exportSelectedToCsv(columns);
final xlsx2 = _controller.exportSelectedToExcel(columns);
final pdf2  = await _controller.exportSelectedToPdf(columns);

// Selection
_controller.selectAll(_controller.getAllRowData());
_controller.clearSelection();

Do not forget to dispose the controller when you own it:

@override
void dispose() {
  _controller.dispose();
  super.dispose();
}

Column definitions

TablexColumn

TablexColumn<Employee, String>(
  fieldKey: 'name',   // must match the key in TablexRow.cells
  title: 'Name',
  width: 180,
  minWidth: 80,
  enableSorting: true,
  enableFiltering: true,
  textAlign: TextAlign.start,
  cellRenderer: TablexRenderers.twoLine(secondLine: (e) => e.email),

  // Hide this column when every loaded row has a null/empty value for it.
  hideIfEmpty: true,

  // Override the string written to CSV / Excel / PDF for this column.
  // Receives the full typed row so you can derive the value from any field.
  exportFormatter: (employee) => '${employee.firstName} ${employee.lastName}',

  // Enable double-tap-to-edit for this column.
  enableEditing: true,
)

Column groups

Span a header label across multiple columns:

Tablex<Employee>.lazyPaged(
  columns: columns,
  columnGroups: [
    TablexColumnGroup(
      title: 'Personal',
      fieldKeys: ['firstName', 'lastName', 'email'],
    ),
    TablexColumnGroup(
      title: 'Compensation',
      fieldKeys: ['salary', 'bonus'],
    ),
  ],
  // ...
)

Built-in renderers

Renderer Description
TablexRenderers.identifier() Monospaced ID / UUID chip
TablexRenderers.twoLine(secondLine: ...) Primary text + dimmed secondary line
TablexRenderers.avatarTwoLine(avatar: ..., secondLine: ...) Circular avatar + two lines
TablexRenderers.currency(symbol: '\$', decimals: 2) Right-aligned number with currency symbol
TablexRenderers.date(format: ...) Formatted DateTime
TablexRenderers.statusChip(colors: ..., labels: ...) Rounded coloured chip
TablexRenderers.actions(actions: ...) Row of icon buttons

Inline cell editing

Set enableEditing: true on any column to allow in-place editing. Double-tap a cell (or call _controller.beginEdit(rowIndex, fieldKey)) to enter edit mode.

TablexColumn<Employee, String>(
  fieldKey: 'name',
  title: 'Name',
  enableEditing: true,
)

Keyboard navigation

Key Action
Tab / Shift+Tab Move to next / previous editable column
↓ / ↑ Move to same column in the row below / above
Enter Confirm and exit edit mode
Escape Cancel — restores the original value

Custom edit cell

Supply buildEditCell to replace the default TextField with any widget (dropdown, date picker, etc.):

TablexColumn<Employee, String>(
  fieldKey: 'department',
  title: 'Department',
  enableEditing: true,
  buildEditCell: (context, row, value, onSubmit, onCancel) => DropdownButton<String>(
    value: value as String?,
    items: departments.map((d) => DropdownMenuItem(value: d, child: Text(d))).toList(),
    onChanged: (v) { if (v != null) onSubmit(v); },
  ),
)

Programmatic editing

_controller.beginEdit(rowIndex, 'name');   // enter edit mode
_controller.confirmEdit('New Name');        // save and exit
_controller.cancelEdit();                  // discard and exit

Toolbar (export & import)

TablexToolbar gives you column-visibility management, CSV export, Excel export, PDF export, CSV import, and Excel import as a single drop-in widget.

When rows are selected the export buttons automatically switch to selected-only mode — the tooltip updates to show the count (e.g. 'Export PDF (3 selected)').

TablexConsumer<Employee>(
  tableHeader: TablexToolbar<Employee>(
    controller: _controller,
    columns: _columns,
    // Enable import by providing a factory that parses one CSV/Excel row
    importRowFactory: (map) => TablexRow(
      data: Employee.fromMap(map),
      key: map['id'],
      cells: {'name': map['name']!, 'salary': double.parse(map['salary']!)},
    ),
  ),
  // ...
)

Override individual actions while keeping the rest:

TablexToolbar<Employee>(
  controller: _controller,
  columns: _columns,
  onExportCsv:   (csv)   async => await api.uploadCsv(csv),
  onExportExcel: (bytes) async => await FileSaver.saveFile('report.xlsx', bytes),
  onExportPdf:   (bytes) async => await FileSaver.saveFile('report.pdf', bytes),
)

PDF export

exportToPdf and exportSelectedToPdf generate a styled .pdf byte array. The page switches automatically to landscape when there are more than six visible columns; number and currency columns are right-aligned.

// Via the controller directly
final bytes = await _controller.exportToPdf(columns);
await FileSaver.saveFile('report.pdf', bytes);

// Selected rows only
final bytes = await _controller.exportSelectedToPdf(columns);

Non-Latin fonts & RTL (Arabic, Hebrew, CJK, …)

The default PDF fonts only cover Latin characters. Supply a font via TablexPdfConfig and set it once on the controller — toolbar buttons and direct calls all pick it up automatically.

RTL direction is detected automatically from cell content. No flag is needed.

// Option A — bundled asset (works offline, no extra package):
controller.pdfConfig = TablexPdfConfig(
  fontData:     await rootBundle.load('assets/fonts/Cairo-Regular.ttf'),
  fontBoldData: await rootBundle.load('assets/fonts/Cairo-Bold.ttf'),
);

// Option B — Google Fonts at runtime (requires the `printing` package):
controller.pdfConfig = TablexPdfConfig(
  font:     await PdfGoogleFonts.cairoRegular(),
  fontBold: await PdfGoogleFonts.cairoBold(),
);

Filter bar

TablexConsumer automatically renders an interactive filter bar above the grid whenever your fetchTask returns filters in its response metadata. No extra widget needed — just populate TablexResponseMeta.filters and the chips appear.

Future<TablexFetchResult<Employee>> _fetch(TablexQuery query) async {
  final data = await api.getEmployees(
    page: query.page,
    department: query.params['department'],   // read active filter value
    status: query.params['status'],
  );

  return TablexFetchResult(
    rows: data.items,
    totalRows: data.total,
    meta: TablexResponseMeta(
      filters: [
        TablexActiveFilter(
          key: 'department',
          label: 'Department',
          values: [
            TablexActiveFilterValue(value: 'engineering', label: 'Engineering'),
            TablexActiveFilterValue(value: 'design',      label: 'Design'),
            TablexActiveFilterValue(value: 'marketing',   label: 'Marketing'),
          ],
        ),
        TablexActiveFilter(
          key: 'status',
          label: 'Status',
          singleSelect: true,   // radio buttons instead of checkboxes
          values: [
            TablexActiveFilterValue(value: 'active',   label: 'Active'),
            TablexActiveFilterValue(value: 'inactive', label: 'Inactive'),
          ],
        ),
      ],
    ),
  );
}

Each filter becomes a FilterChip. Tapping it opens a dialog with checkboxes (default) or radio buttons (singleSelect: true). The selected values are written to query.params[key] as a comma-separated string, which your fetchTask can read back. An × on the chip clears that filter; a Clear all button resets every active filter at once.

Custom filter bar override

Replace the default chip-dialog bar with any widget using filterBarBuilder. The callback receives the active filters, the controller (to read/write query.params), and the BuildContext. It is called only when filters are non-empty.

TablexConsumer<Employee>(
  // ...
  filterBarBuilder: (context, filters, controller) {
    // Read currently-selected departments from query params
    final active = (controller.query.params['department'] as String? ?? '')
        .split(',')
        .where((s) => s.isNotEmpty)
        .toSet();

    return SingleChildScrollView(
      scrollDirection: Axis.horizontal,
      padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
      child: Row(
        children: filters.first.values.map((v) {
          final selected = active.contains(v.value);
          return Padding(
            padding: const EdgeInsets.only(right: 6),
            child: FilterChip(
              label: Text(v.label),
              selected: selected,
              showCheckmark: false,
              onSelected: (_) {
                final next = Set<String>.from(active);
                selected ? next.remove(v.value) : next.add(v.value);
                controller.setParam('department', next.join(','));
              },
            ),
          );
        }).toList(),
      ),
    );
  },
)

Return SizedBox.shrink() from filterBarBuilder to hide the filter bar entirely while keeping the fetch metadata wired up.


Row selection & bulk actions

Tablex<Employee>.static(
  // ...
  selectionMode: TablexSelectionMode.multiple,
  showSelectionSummary: true,
  selectionActions: [
    TablexSelectionAction<Employee>(
      label: 'Delete selected',
      icon: Icons.delete_outline,
      onPressed: (selected) => _bulkDelete(selected),
    ),
  ],
)

When showSelectionSummary is true, the summary bar automatically shows CSV, Excel, and PDF export buttons for the selected rows. Override any of them to plug in your own handler:

Tablex<Employee>.static(
  // ...
  showSelectionSummary: true,
  onExportSelectedCsv:   (csv)   async => await api.uploadCsv(csv),
  onExportSelectedExcel: (bytes) async => await FileSaver.saveFile('selected.xlsx', bytes),
  onExportSelectedPdf:   (bytes) async => await FileSaver.saveFile('selected.pdf', bytes),
)

Replace the entire summary bar with a custom widget:

selectionSummaryBuilder: (context, selected, clearSelection) => ColoredBox(
  color: Theme.of(context).colorScheme.primaryContainer,
  child: Row(children: [
    Text('${selected.length} selected'),
    const Spacer(),
    TextButton(onPressed: clearSelection, child: const Text('Clear')),
  ]),
),

Theming

All colours fall back to the ambient Material 3 ColorScheme when not set. Override only what you need:

Tablex<Employee>.static(
  theme: const TablexThemeData(
    showVerticalCellBorders: false,
    borderRadius: BorderRadius.all(Radius.circular(12)),
    checkboxTheme: TablexCheckboxTheme(
      activeColor: Colors.indigo,
      checkColor: Colors.white,
      size: 18,
    ),
  ),
  // ...
)

Alternatively, wrap your subtree with TablexTheme to apply a theme to all grids in scope:

TablexTheme(
  data: const TablexThemeData(showVerticalCellBorders: true),
  child: MyScreen(),
)

Selectable cell text

On web, cell text is rendered with SelectableText by default so users can copy values by dragging. Disable it or opt in on desktop:

TablexThemeData(
  enableTextSelection: false,  // disable on web
  // enableTextSelection: true, // opt in on desktop
)

Custom renderers can read ctx.enableTextSelection to follow the same setting:

cellRenderer: (row, value, ctx) => ctx.enableTextSelection
    ? SelectableText(value, maxLines: 1)
    : Text(value, overflow: TextOverflow.ellipsis),

Empty cell placeholder

Set a grid-wide placeholder for null cells without touching every column:

TablexThemeData(emptyCellPlaceholder: 'N/A')

Column-level TablexColumnBase.emptyCellPlaceholder takes precedence when set. The fallback chain is: column → theme → '—' (when showEmptyAsDash) → blank.

Selection summary bar colour

TablexThemeData(selectionSummaryBarColor: Colors.indigo.shade50)

Additional information