A highly customizable, type-safe Flutter table widget with synchronized scrolling, sorting, selection, editing, and more.
| Feature | Description |
|---|---|
| Type-Safe Generics | Works with any data model <T> — no more Map<String, dynamic> |
| Synchronized Scrolling | Header and body scroll together seamlessly |
| Sorting | Multi-state column sorting (ascending ↔ descending ↔ none) |
| Selection | Single or multiple row selection with checkboxes |
| Inline Editing | Click-to-edit cells with auto-save |
| Column Reordering | Drag-and-drop columns, drop on empty space to move to last |
| Column Resizing | Drag header edges to resize columns with min/max constraints and width persistence |
| Auto-Fit Columns | Double-tap resize handle to auto-fit, with custom override callback |
| Stretch Last Column | Last column fills remaining space when all columns are fixed |
| Drag Selection | Mouse drag to select row ranges with auto-scroll and a configurable rubber band rectangle (Finder/Explorer-style marquee) |
| Merged Rows | Group rows with custom merged content |
| Hover Buttons | Action buttons on row hover |
| Dynamic Row Heights | Support for variable height rows |
| Smart Tooltips | Text, widget, and whole-row tooltips, anchored beside the widget or beside the cursor |
| Dim Rows | Style inactive rows differently |
| Smooth Wheel Scrolling | Animated mouse wheel scrolling, on by default and tuned or turned off through wheelMotion, with the header and scrollbars in step |
| Scale / Zoom | Ctrl/Cmd+wheel zoom with platform-aware modifier key, scroll-safe physics, automatic position correction, and optional blockModifierScroll control |
| Deep Theming | One call from your app's ColorScheme, or nested theme classes down to individual borders, dividers and placeholder text |
| Minimal Dependencies | Only just_tooltip, flutter_checkbox and flutter_smooth_wheel_scroll |
dependencies:
flutter_table_plus: ^2.19.0flutter pub getclass User {
final String id;
final String name;
final String email;
final int age;
const User({
required this.id,
required this.name,
required this.email,
required this.age,
});
}import 'package:flutter_table_plus/flutter_table_plus.dart';
final columns = TableColumnsBuilder<User>()
..addColumn('name', TablePlusColumn(
key: 'name',
label: 'Name',
order: 1,
width: 150,
valueAccessor: (user) => user.name,
sortable: true,
))
..addColumn('email', TablePlusColumn(
key: 'email',
label: 'Email',
order: 2,
width: 200,
valueAccessor: (user) => user.email,
))
..addColumn('age', TablePlusColumn(
key: 'age',
label: 'Age',
order: 3,
width: 80,
valueAccessor: (user) => user.age,
sortable: true,
));
final users = [
User(id: '1', name: 'John Doe', email: 'john@example.com', age: 28),
User(id: '2', name: 'Jane Smith', email: 'jane@example.com', age: 34),
];
FlutterTablePlus<User>(
columns: columns.build(),
data: users,
rowId: (user) => user.id, // Unique identifier for each row
// Colours from your app's ColorScheme, so a dark app gets a dark table
theme: TablePlusTheme.fromColorScheme(Theme.of(context).colorScheme),
// Sorting
sortColumnKey: _sortColumn,
sortDirection: _sortDirection,
onSort: (columnKey, direction) {
setState(() {
_sortColumn = columnKey;
_sortDirection = direction;
// Sort your data here
});
},
// Selection
isSelectable: true,
selectionMode: SelectionMode.multiple,
selectedRows: _selectedRows,
onRowSelectionChanged: (rowId, isSelected) {
setState(() {
isSelected ? _selectedRows.add(rowId) : _selectedRows.remove(rowId);
});
},
)Save and restore user-resized column widths across sessions:
FlutterTablePlus<User>(
columns: columns.build(),
data: users,
rowId: (user) => user.id,
resizable: true,
// Restore saved widths (reacts to changes — safe with Riverpod watch)
initialResizedWidths: savedWidths, // e.g. {'name': 200, 'email': 150}
// Save widths when user resizes
onColumnResized: (columnKey, newWidth) {
savedWidths[columnKey] = newWidth;
// Persist to DB / SharedPreferences / etc.
},
)The mouse wheel animates by default, as WheelMotion.spring() (400ms, no bounce). wheelMotion changes it, and can change at runtime:
FlutterTablePlus<User>(
columns: columns.build(),
data: users,
rowId: (user) => user.id,
// Pick one:
wheelMotion: const WheelMotion.spring(
duration: Duration(milliseconds: 250),
bounce: 0.1,
),
// wheelMotion: const WheelMotion.curve(curve: Curves.easeOut), // any Curve, your own too
// wheelMotion: const WheelMotion.lerp(
// timeConstant: Duration(milliseconds: 80),
// ),
// wheelMotion: null, // no animation: every notch moves at once
)WheelMotion is exported by this package; there is no second import. See Smooth Wheel Scrolling for every option.
TablePlusTheme.fromColorScheme builds the whole table theme from the ColorScheme your app already has: surfaces, dividers, the selected row, the editing cell, the checkbox. Adjust the result with copyWith:
final base = TablePlusTheme.fromColorScheme(Theme.of(context).colorScheme);
FlutterTablePlus<User>(
// ...
theme: base.copyWith(
bodyTheme: base.bodyTheme.copyWith(rowHeight: 40),
),
)To set every value yourself instead, build a TablePlusTheme(...) directly. Without a theme, the table uses the package's own light defaults, as it always has. The Theming Guide lists which role each colour takes.
Flutter Table Plus follows a UI-only, data-agnostic design:
- ✅ You own your data — The package never stores or mutates your data
- ✅ Callback-driven — All interactions flow through callbacks you control
- ✅ State management agnostic — Works with
setState, Provider, Riverpod, Bloc, etc. - ✅ Maximum flexibility — No assumptions about your data structure or business logic
// You handle sorting
onSort: (columnKey, direction) {
// Sort your data however you want -- into a NEW list.
// `data` and `rowId` are read as one snapshot and invalidated on the
// list's identity, so an in-place `_myData.sort(...)` is not seen.
_myData = List.of(_myData)..sort(...);
}
// You handle selection
onRowSelectionChanged: (rowId, isSelected) {
// Update your selection state
_selectedIds.add(rowId);
}
// You handle editing
onCellChanged: (row, columnKey, rowIndex, oldValue, newValue) {
// Update your data model -- again into a NEW list. An in-place
// `_myData[rowIndex] = ...` renders the new value but leaves the cached
// row height, so a `calculateRowHeight` that measures the cell keeps the
// pre-edit height.
_myData = List.of(_myData)..[rowIndex] = row.copyWith(name: newValue);
}| Guide | Description |
|---|---|
| Features Guide | Sorting, Selection, Editing, Merged Rows, Hover Buttons, Smooth Wheel Scrolling, and more |
| Theming Guide | Every theme class, what each field reaches, and what scaledBy scales |
| Migration Guide | Migrating from v1.x (Map) to v2.x (Generic<T>) |
MIT License — see LICENSE for details.
