tablex 0.4.0
tablex: ^0.4.0 copied to clipboard
A production-grade Flutter data grid with zero dependency on any third-party grid engine.
tablex #
A production-grade Flutter data grid with no dependency on any third-party grid engine.
Screenshots #
| iOS | macOS |
|---|---|
![]() |
![]() |

Features #
- Four grid modes — static in-memory, lazy-paged (server-side), infinite-scroll, and select-picker
- Sliding-window infinite scroll — only keeps a configurable number of pages in memory; evicts old pages as the user scrolls, with seamless scroll-position compensation
- Skeleton loading — pre-populate the grid with placeholder rows that shimmer while the first page loads
- Column management — resizable, sortable, reorderable headers; show/hide via column manager
- Row selection — single or multi-select with a customisable summary bar and bulk-action buttons
- Built-in cell renderers — identifier, two-line, avatar+two-line, currency, date, status chip, action buttons
- CSV & Excel export/import — built-in toolbar with formula-injection protection
- Density presets —
compact,standard,comfortable - Column groups — spanning header labels across multiple columns
- Theming — full override via
TablexThemeDataor inherit from Material 3 - i18n — locale strings via
slang(override to ship your own language) - Zero third-party grid engine dependency
Getting started #
dependencies:
tablex: ^0.4.0
Usage #
TablexConsumer — recommended for server-paginated data #
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),
),
)
Custom pagination footer
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
final csv = _controller.exportToCsv(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),
)
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 |
Toolbar (export & import) #
TablexToolbar gives you column-visibility management, CSV export, Excel export, CSV import, and Excel import as a single drop-in widget.
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(bytes),
)
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),
),
],
)
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(),
)
Additional information #
- Source & issues
- PRs and bug reports are welcome.


