Complete guide to all Flutter Table Plus features.
- Sorting
- Selection
- Cell Editing
- Column Reordering
- Column Resizing
- Drag Selection
- Merged Rows
- Hover Buttons
- Dynamic Row Heights
- Tooltips
- Dim Rows
- Empty State
- Context Menu (Right-Click)
- Scale / Zoom
Enable column sorting with customizable sort cycles.
// 1. Make columns sortable
TablePlusColumn<User>(
key: 'name',
label: 'Name',
order: 1,
valueAccessor: (user) => user.name,
sortable: true, // Enable sorting for this column
)
// 2. Handle sort events
FlutterTablePlus<User>(
columns: columns,
data: users,
rowId: (user) => user.id,
sortColumnKey: _sortColumn,
sortDirection: _sortDirection,
onSort: (columnKey, direction) {
setState(() {
_sortColumn = columnKey;
_sortDirection = direction;
if (direction == SortDirection.none) {
_users = List.of(_originalUsers); // Reset to original order
} else {
_users.sort((a, b) {
final aVal = _getColumnValue(a, columnKey);
final bVal = _getColumnValue(b, columnKey);
final cmp = aVal.compareTo(bVal);
return direction == SortDirection.ascending ? cmp : -cmp;
});
}
});
},
)// Default: none → ascending → descending → none
sortCycleOrder: SortCycleOrder.ascendingFirst,
// Alternative: none → descending → ascending → none
sortCycleOrder: SortCycleOrder.descendingFirst,TablePlusTheme(
headerTheme: TablePlusHeaderTheme(
sortIcons: SortIcons(
ascending: Icon(Icons.arrow_upward, size: 16),
descending: Icon(Icons.arrow_downward, size: 16),
unsorted: Icon(Icons.unfold_more, size: 16),
),
),
)// Hide all sort UI
onSort: null,
// Or disable per column
TablePlusColumn(sortable: false)Support for single and multiple row selection.
FlutterTablePlus<User>(
isSelectable: true,
selectionMode: SelectionMode.multiple,
selectedRows: _selectedRowIds, // Set<String>
// Row click selection
onRowSelectionChanged: (rowId, isSelected) {
setState(() {
isSelected ? _selectedRowIds.add(rowId) : _selectedRowIds.remove(rowId);
});
},
// Checkbox click (same behavior, different trigger)
onCheckboxChanged: (rowId, isSelected) {
setState(() {
isSelected ? _selectedRowIds.add(rowId) : _selectedRowIds.remove(rowId);
});
},
// Select-all checkbox in header
onSelectAll: (selectAll) {
setState(() {
_selectedRowIds = selectAll
? _users.map((u) => u.id).toSet()
: {};
});
},
)FlutterTablePlus<User>(
isSelectable: true,
selectionMode: SelectionMode.single, // Only one row at a time
selectedRows: _selectedRowIds,
onRowSelectionChanged: (rowId, isSelected) {
setState(() {
_selectedRowIds = isSelected ? {rowId} : {};
});
},
)// Set onSelectAll to null to hide the select-all checkbox
onSelectAll: null,TablePlusTheme(
checkboxTheme: TablePlusCheckboxTheme(
showCheckboxColumn: false, // Use row click only
),
)TablePlusTheme(
checkboxTheme: TablePlusCheckboxTheme(
showRowCheckbox: false, // Header select-all visible, row checkboxes hidden
),
)Enable inline cell editing with auto-save.
// 1. Enable editing globally and per column
FlutterTablePlus<User>(
isEditable: true,
columns: {
'name': TablePlusColumn(
key: 'name',
label: 'Name',
order: 1,
valueAccessor: (user) => user.name,
editable: true, // Enable editing for this column
hintText: 'Enter name', // Placeholder text
),
},
// 2. Handle cell changes
onCellChanged: (User row, String columnKey, int rowIndex, dynamic oldValue, dynamic newValue) {
setState(() {
switch (columnKey) {
case 'name':
_users[rowIndex] = row.copyWith(name: newValue as String);
break;
case 'email':
_users[rowIndex] = row.copyWith(email: newValue as String);
break;
}
});
},
)- Click a cell to start editing
- Enter to save and exit
- Escape to cancel
- Tab / focus loss auto-saves
- Cells with
statefulCellBuildercannot be edited
TablePlusTheme(
editableTheme: TablePlusEditableTheme(
editingCellColor: Colors.yellow.shade100,
editingBorderColor: Colors.blue,
editingBorderWidth: 2.0,
editingBorderRadius: BorderRadius.circular(4),
cursorColor: Colors.blue,
),
)Drag-and-drop to reorder columns.
FlutterTablePlus<User>(
onColumnReorder: (int oldIndex, int newIndex) {
setState(() {
final entries = _columns.entries.toList();
final item = entries.removeAt(oldIndex);
entries.insert(newIndex, item);
// Update order values
_columns = Map.fromEntries(
entries.asMap().entries.map((e) => MapEntry(
e.value.key,
e.value.value.copyWith(order: e.key),
)),
);
});
},
)
// Disable reordering
onColumnReorder: null,Drag header edges to resize columns with min/max constraints.
FlutterTablePlus<User>(
resizable: true,
onColumnResized: (String columnKey, double newWidth) {
// Persist the new width
setState(() {
_columnWidths[columnKey] = newWidth;
});
},
// Per-column constraints
columns: {
'name': TablePlusColumn<User>(
key: 'name',
label: 'Name',
order: 1,
valueAccessor: (user) => user.name,
width: 150,
minWidth: 80,
maxWidth: 300,
),
},
)Save column widths with onColumnResized and restore them with initialResizedWidths:
FlutterTablePlus<User>(
resizable: true,
// Restore saved widths on widget creation.
// Columns in this map are treated as fixed (exact pixel width),
// just as if the user had manually resized them.
initialResizedWidths: prefs.getSavedColumnWidths(),
// Save each resize to your persistence layer.
// Fires on manual drag end and auto-fit double-tap.
onColumnResized: (columnKey, newWidth) {
prefs.saveColumnWidth(columnKey, newWidth);
},
)Note:
initialResizedWidthsis only applied once at widget creation (initState). Subsequent user resizes override the initial values at runtime. Columns not in the map remain flexible and participate in proportional distribution.
Double-tap a resize handle to auto-fit the column width to its content. By default, the built-in measurement uses valueAccessor + body theme textStyle.
For columns with statefulCellBuilder that use custom styles, padding, or text transformations, override the measurement with autoFitColumnWidth:
FlutterTablePlus<User>(
resizable: true,
autoFitColumnWidth: (columnKey) {
if (columnKey == 'description') {
return TableColumnWidthCalculator.calculateColumnWidth<User>(
headerLabel: 'Description',
headerTextStyle: myHeaderStyle,
data: users,
valueAccessor: (user) => user.description.replaceAll('\n', ' '),
bodyTextStyle: myCustomBodyStyle,
bodyPadding: EdgeInsets.symmetric(horizontal: 24.0),
textScaler: MediaQuery.textScalerOf(context),
);
}
return null; // Other columns use default auto-fit
},
)You can also measure a single text value directly:
final width = TableColumnWidthCalculator.measureTextWidth(
text: 'Hello World',
textStyle: TextStyle(fontSize: 14),
padding: EdgeInsets.symmetric(horizontal: 16.0),
extraWidth: 24.0, // e.g., icon space
);TablePlusTheme(
headerTheme: TablePlusHeaderTheme(
resizeHandle: TablePlusResizeHandleTheme(
width: 8.0, // Hit-test area width
thickness: 2.0, // Visible indicator thickness
color: Colors.blue, // Indicator color
indent: 4.0, // Top inset
endIndent: 4.0, // Bottom inset
),
),
)Mouse drag to select row ranges (Excel/Finder style).
FlutterTablePlus<User>(
isSelectable: true,
selectionMode: SelectionMode.multiple,
selectedRows: _selectedRowIds,
enableDragSelection: true,
// Live updates during drag
onDragSelectionUpdate: (Set<String> draggedRowIds) {
setState(() {
_selectedRowIds = draggedRowIds;
});
},
// Final callback when drag ends (optional)
onDragSelectionEnd: (Set<String> draggedRowIds) {
// Finalize selection
},
)Group multiple rows with merged cells.
FlutterTablePlus<User>(
data: users,
rowId: (user) => user.id,
mergedGroups: [
MergedRowGroup<User>(
groupId: 'engineering-team',
rowKeys: ['user-1', 'user-2', 'user-3'], // Row IDs to merge
mergeConfig: {
'department': MergeCellConfig(
shouldMerge: true,
mergedContent: Text(
'Engineering',
style: TextStyle(fontWeight: FontWeight.bold),
),
),
},
),
],
)MergedRowGroup<User>(
groupId: 'sales-team',
rowKeys: ['user-4', 'user-5'],
mergeConfig: {
// Show first row's data in merged cell
'name': MergeCellConfig(
shouldMerge: true,
spanningRowIndex: 0, // Use first row's value
),
// Custom widget
'status': MergeCellConfig(
shouldMerge: true,
mergedContent: Icon(Icons.group, color: Colors.blue),
),
// Editable merged cell
'notes': MergeCellConfig(
shouldMerge: true,
isEditable: true,
),
},
isExpandable: true,
isExpanded: true,
)
// Handle merged cell edits
onMergedCellChanged: (String groupId, String columnKey, dynamic newValue) {
// Update your data
},
// Handle expand/collapse
onMergedRowExpandToggle: (String groupId) {
setState(() {
// Toggle expand state
});
},Action buttons that appear when hovering a row.
FlutterTablePlus<User>(
hoverButtonBuilder: (String rowId, User user) {
return Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
icon: Icon(Icons.edit, size: 18),
onPressed: () => _editUser(user),
tooltip: 'Edit',
),
IconButton(
icon: Icon(Icons.delete, size: 18, color: Colors.red),
onPressed: () => _deleteUser(user.id),
tooltip: 'Delete',
),
],
);
},
hoverButtonPosition: HoverButtonPosition.right, // left, center, right
)TablePlusTheme(
hoverButtonTheme: TablePlusHoverButtonTheme(
horizontalOffset: 8.0,
),
)Support variable height rows based on content.
FlutterTablePlus<User>(
calculateRowHeight: TableRowHeightCalculator.createHeightCalculator(
columns: columnsList,
columnWidths: columnsList.map((c) => c.width).toList(),
defaultTextStyle: TextStyle(fontSize: 14),
minHeight: 48.0,
),
)FlutterTablePlus<User>(
calculateRowHeight: (int rowIndex, User user) {
// Taller rows for longer content
if (user.bio.length > 100) {
return 80.0;
}
return null; // Use default height
},
)TablePlusColumn<User>(
key: 'description',
label: 'Description',
order: 1,
valueAccessor: (user) => user.description,
textOverflow: TextOverflow.visible, // Allow text to expand
)
// Then use TableRowHeightCalculator to auto-calculate heightsText or widget-based tooltips with smart positioning.
TablePlusColumn<User>(
key: 'name',
label: 'Name',
order: 1,
valueAccessor: (user) => user.name,
tooltipFormatter: (user) => 'Employee: ${user.name}\nDepartment: ${user.department}',
tooltipBehavior: TooltipBehavior.always,
)TablePlusColumn<User>(
key: 'profile',
label: 'Profile',
order: 1,
valueAccessor: (user) => user.name,
tooltipBuilder: (context, user) {
return Container(
padding: EdgeInsets.all(12),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
CircleAvatar(backgroundImage: NetworkImage(user.avatarUrl)),
SizedBox(height: 8),
Text(user.name, style: TextStyle(fontWeight: FontWeight.bold)),
Text(user.email),
],
),
);
},
)// Per-column cell tooltip behavior
tooltipBehavior: TooltipBehavior.always, // Always show tooltip
tooltipBehavior: TooltipBehavior.never, // Never show
tooltipBehavior: TooltipBehavior.onlyTextOverflow, // Only when text is truncated
// Per-column header tooltip behavior
headerTooltipBehavior: TooltipBehavior.always,
headerTooltipBehavior: TooltipBehavior.onlyTextOverflow,TablePlusTheme(
tooltipTheme: TablePlusTooltipTheme(
enabled: true,
waitDuration: Duration(milliseconds: 500),
showDuration: Duration(seconds: 2),
backgroundColor: Color(0xFF616161),
borderRadius: BorderRadius.circular(6),
textStyle: TextStyle(color: Colors.white),
direction: TooltipDirection.bottom,
showArrow: true,
offset: 8.0,
),
)Style inactive or disabled rows differently.
FlutterTablePlus<User>(
isDimRow: (User user) => !user.isActive, // Dim inactive users
theme: TablePlusTheme(
bodyTheme: TablePlusBodyTheme(
dimRowColor: Colors.grey.shade200,
dimRowTextStyle: TextStyle(color: Colors.grey),
dimRowHoverColor: Colors.grey.shade300,
),
),
)Custom widget when there's no data.
FlutterTablePlus<User>(
data: [], // Empty list
noDataWidget: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.inbox_outlined, size: 64, color: Colors.grey),
SizedBox(height: 12),
Text('No data available', style: TextStyle(fontSize: 18)),
SizedBox(height: 8),
ElevatedButton(
onPressed: _loadData,
child: Text('Refresh'),
),
],
),
)Right-click context menu support.
FlutterTablePlus<User>(
onRowSecondaryTapDown: (
String rowId,
TapDownDetails details,
RenderBox renderBox,
bool isSelected,
) {
final overlay = Overlay.of(context).context.findRenderObject() as RenderBox;
final position = RelativeRect.fromRect(
Rect.fromPoints(
renderBox.localToGlobal(details.localPosition, ancestor: overlay),
renderBox.localToGlobal(details.localPosition, ancestor: overlay),
),
Offset.zero & overlay.size,
);
showMenu<String>(
context: context,
position: position,
items: [
PopupMenuItem(value: 'view', child: Text('View')),
PopupMenuItem(value: 'edit', child: Text('Edit')),
PopupMenuItem(value: 'delete', child: Text('Delete')),
],
).then((value) {
switch (value) {
case 'view': _viewUser(rowId); break;
case 'edit': _editUser(rowId); break;
case 'delete': _deleteUser(rowId); break;
}
});
},
)Handle double-click on rows.
FlutterTablePlus<User>(
onRowDoubleTap: (String rowId) {
_openDetailView(rowId);
},
theme: TablePlusTheme(
bodyTheme: TablePlusBodyTheme(
doubleClickTime: Duration(milliseconds: 500), // Adjust timing
),
),
)Scale the entire table by a factor — all dimensions (column widths, row heights, font sizes, padding, icons) are multiplied.
double _scale = 1.0;
FlutterTablePlus<User>(
columns: columns,
data: users,
rowId: (user) => user.id,
scale: _scale,
)Provide onScaleChanged to enable Ctrl+wheel (Cmd+wheel on macOS) zoom with automatic scroll prevention.
FlutterTablePlus<User>(
columns: columns,
data: users,
rowId: (user) => user.id,
scale: _scale,
onScaleChanged: (newScale) {
setState(() {
_scale = newScale.clamp(0.5, 3.0); // You control the limits
});
},
scaleStep: 0.05, // Increment per wheel tick (default 0.05)
)When onScaleChanged is non-null:
- Ctrl+wheel events are intercepted internally
- Scrolling is blocked via custom
ScrollPhysics(shouldAcceptUserOffsetreturnsfalsewhen Ctrl is held) - Scroll positions are automatically corrected to keep the same content visible
| Scaled | Not Scaled |
|---|---|
| Row height | Scrollbar (UI chrome) |
| Column widths | Colors, booleans |
| Font sizes | Border/divider thickness |
| Padding | Tooltip (overlay) |
| Sort icon size (via FittedBox) | Duration values |
| Resize handle | |
| Checkbox hit area |
Resized widths are stored in logical (unscaled) units. The onColumnResized callback always reports logical widths, and initialResizedWidths expects logical widths. This means saved widths work correctly regardless of the current scale.
FlutterTablePlus<User>(
scale: _scale,
resizable: true,
initialResizedWidths: savedWidths, // Logical units — scale-independent
onColumnResized: (columnKey, newWidth) {
// newWidth is in logical units, safe to persist
savedWidths[columnKey] = newWidth;
},
)scalemust be greater than zero (assert(scale > 0))- No min/max is enforced by the library — the caller clamps in
onScaleChanged - Material
Checkboxwidget does not visually scale (controlled bymaterialTapTargetSize/visualDensity); only its hit-test area scales - Custom sort icons are automatically scaled via
FittedBoxto match the scaledsortIconWidth