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
- Smooth Wheel Scrolling
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 {
// A new list, not an in-place sort: `data` is invalidated on the
// list's identity, so `_users.sort(...)` would not be seen.
_users = List.of(_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()
: {};
});
},
)The {rowId} below is required, not stylistic. selectedRows is your set
and the table never writes to it, so SelectionMode.single does not clear the
previous row for you — replace the set on every change, or you get
multi-selection wearing a single-select label. The mode's own effect is
narrower: drag-to-select is wired only in SelectionMode.multiple.
FlutterTablePlus<User>(
isSelectable: true,
selectionMode: SelectionMode.single,
selectedRows: _selectedRowIds,
onRowSelectionChanged: (rowId, isSelected) {
// Replace, never add — this line is what makes `single` mean single.
setState(() {
_selectedRowIds = isSelected ? {rowId} : {};
});
},
)The header draws its select-all checkbox when showSelectAllCheckbox is true
and onSelectAll is non-null. It does not consult selectionMode, so a
single-selection table still shows a working select-all until you withhold one
of the two — usually the callback, since that is what the button would call.
// 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(() {
// A new list, as with the sort above: an in-place
// `_users[rowIndex] = ...` renders the new value but keeps the cached
// row height.
switch (columnKey) {
case 'name':
_users = List.of(_users)..[rowIndex] = row.copyWith(name: newValue as String);
break;
case 'email':
_users = List.of(_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(() {
// Both indices count the columns *as displayed*: sorted by `order`,
// invisible ones dropped, selection column excluded. Sort before
// indexing — a Map's own iteration order agrees only by coincidence.
final entries = _columns.entries.toList()
..sort((a, b) => a.value.order.compareTo(b.value.order));
final item = entries.removeAt(oldIndex);
entries.insert(newIndex, item);
// Renumber from 1: `TableColumnsBuilder` reserves 0 and below, and the
// synthetic selection column sits at -1.
_columns = {
for (var i = 0; i < entries.length; i++)
entries[i].key: entries[i].value.copyWith(order: i + 1),
};
});
},
)
// Disable reordering — there is no separate flag
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 read at widget creation and re-adopted whenever the map changes by value — it is not a one-shot despite the name. That is what makes the loop above work: store whatonColumnResizedhands back, pass it down on the next build. An unchanged map leaves the table's own state alone, so a rebuild never interrupts a resize in progress. Columns not in the map remain flexible and participate in proportional distribution.
Every width here is logical (unscaled) pixels — including the bounds.
onColumnResizedreports the width the column would have atscale: 1.0, not the pixels the drag covered, andinitialResizedWidthsis read the same way — so a stored width still means the same thing when the app reopens at a different zoom.minWidthandmaxWidthare declared in the same space and the table converts them itself, so a column stops at the range you wrote at every zoom level, not at a range scaled along with it. (Before 2.17.0 the drag path did not convert them, so atscale: 2.0a declaredmaxWidth: 300behaved as 150.)
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).
Every line below marked required is required. Drag selection wires no
handler at all unless all four hold, and when one is missing the feature is
inert rather than broken — no error, no warning, dragging simply does nothing.
Passing only onDragSelectionEnd is the usual way to hit this.
FlutterTablePlus<User>(
isSelectable: true, // required
selectionMode: SelectionMode.multiple, // required — single mode never drags
selectedRows: _selectedRowIds,
enableDragSelection: true, // required
// required — without this callback no drag is detected at all
onDragSelectionUpdate: (Set<String> draggedRowIds) {
setState(() {
_selectedRowIds = draggedRowIds;
});
},
// optional — if omitted, onDragSelectionUpdate is also the final callback
onDragSelectionEnd: (Set<String> draggedRowIds) {
// Finalize selection
},
)A density toggle or a font-size slider is operable with the mouse button still down, and it rebuilds the table underneath the gesture. The two ends of the drag answer that differently, on purpose:
- the anchor keeps the row the drag was pressed on — the layout moving does not move which row you started from;
- the far end re-resolves to whatever now sits under the pointer at the new height.
So onDragSelectionUpdate can fire with a different set from a rebuild alone,
with no pointer movement behind it. Nothing needs to be done about it.
Replacing data mid-drag is not covered by that. The anchor is a position
in the list the drag began on, so a different list re-points it, and the range
reported for the rest of that gesture is undefined — end the gesture first, or
accept whatever it reports.
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 the value of the row you listed first in `rowKeys`
'name': MergeCellConfig(
shouldMerge: true,
spanningRowIndex: 0,
),
// Custom widget
'status': MergeCellConfig(
shouldMerge: true,
mergedContent: Icon(Icons.group, color: Colors.blue),
),
// Editable merged cell
'notes': MergeCellConfig(
shouldMerge: true,
isEditable: true,
),
},
// Adds the summary row. It does not hide the member rows.
isExpanded: _expandedGroups.contains('group_1'),
summaryBuilder: (columnKey) => columnKey == 'notes'
? const Text('Total', style: TextStyle(fontWeight: FontWeight.w700))
: null,
)
// Handle merged cell edits
onMergedCellChanged: (String groupId, String columnKey, dynamic newValue) {
// Update your data
},spanningRowIndex counts positions in rowKeys, not rows on screen. The
list you wrote, in the order you wrote it — so with data: ['a', 'b'] and
rowKeys: ['b', 'a'], spanningRowIndex: 1 is 'a'. Written in data order
the two are the same thing, which is why the difference only shows up when they
are not.
It is defined this way so that sorting cannot move it. data order is
yours to change and this package sorts nothing itself; if the index counted
rendered rows, every sort would change which row's value a merged cell shows,
from a configuration you had not touched.
Two cases are handled for you rather than left to fail, both since 2.17.0:
| you wrote | what happens |
|---|---|
an index past the end of rowKeys |
clamps to the last member. It used to throw a RangeError out of the widget build |
an index naming a row data does not hold |
walks forward through rowKeys, wrapping once, to the first member that is present. It used to render an empty cell, so the value simply disappeared |
Each prints one debugPrint naming the group, the column and what it did.
Neither throws, and neither costs anything in a release build — the check sits
inside an assert. A merged cell is empty only when the group has no member
data holds at all, which is the one case where empty is the right answer.
The expand control is yours to draw. The package renders no
expand/collapse affordance anywhere and fires no callback for one — put an
IconButton in the merged cell's mergedContent and wire it to your own
setState:
'department': MergeCellConfig(
shouldMerge: true,
mergedContent: Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
icon: Icon(expanded ? Icons.expand_less : Icons.expand_more),
onPressed: () => setState(() { /* toggle your own state */ }),
),
Text(department),
],
),
),A MergedRowGroup is an immutable value you rebuild, so the state has to
live where your data does. example/lib/recipes/merged_rows_recipe.dart is
a complete working version.
Changed in 2.17.0.
onMergedRowExpandToggleandisExpandablewere removed. The callback was declared, threaded through three widgets and never invoked — this document showed it as working, which is the reason it went unnoticed.isExpandablewas an extra&&in front ofisExpandedand gated nothing on its own. Delete both arguments; nothing else changes.
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.
// Build it ONCE and hold it in a field — see the two notes below.
_rowHeight = TableRowHeightCalculator.createHeightCalculator(
columns: columnsList,
columnWidths: columnsList.map((c) => c.width).toList(),
defaultTextStyle: TextStyle(fontSize: 14),
minHeight: 48.0,
// Resolves the ambient DefaultTextStyle and MediaQuery.textScalerOf, which
// are what the glyphs actually get. Without it the measurement is of a
// different string than the one on screen.
context: context,
);
FlutterTablePlus<User>(
calculateRowHeight: _rowHeight,
)Pass
context. ATextPaintersees only the style you hand it. TheTextin the cell merges the inheritedDefaultTextStyleunder it — that is where the font family,letterSpacingandheightcome from when your style does not name them — and appliesMediaQuery.textScalerOf. Measured on the default theme: a style naming onlyfontSizepredicts 100px for a paragraph the screen lays out at 120px, and at an OS text scale of 1.25 it needs 225px against the same prediction. The text is clipped and nothing warns you.
Do not build it inline in the widget's constructor. The height caches — and the row geometry every drag hit-test is answered from — drop whenever this callback is not equal to the previous one, and a closure built fresh in
buildnever is. Measured: two calls with identical arguments return callbacks that compare!=, so the inline form re-measures every row on every frame. Hold it in a field and rebuild it indidChangeDependencies, which is also exactly when the ambient inputs above can have changed.Since 2.17.0 the table says so in debug — one
debugPrintper table, when the callback changes identity on several consecutive builds while the data and the columns do not. It is a heuristic, so it prints and does not throw, and a release build carries neither the check nor the message.
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),
],
),
);
},
)A card shown while the pointer is anywhere on the row — not just over a cell's text. It is anchored beside the pointer, so it stays where you are looking even on a table wide enough to scroll horizontally.
FlutterTablePlus<User>(
// ...
rowTooltipBuilder: (context, user) {
if (!user.hasProfile) return null; // this row gets no card
return AccountCard(user);
},
theme: TablePlusTheme(
// A card draws its own surface, so the tooltip must not draw one behind it.
rowTooltipTheme: TablePlusTooltipTheme(
backgroundColor: Colors.transparent,
padding: EdgeInsets.zero,
elevation: 0,
),
),
)When a cell under the pointer has a tooltip of its own, that one wins — exactly one tooltip is ever visible. So a truncated cell still reveals its full text, while the rest of the row shows the card.
The whole row is the hover region, including the empty space past the last column and cells whose value is empty. A cell only takes the card's place when it has a tooltip to put there: tooltipBehavior: TooltipBehavior.always gives every ellipsized text column one whether or not its text is cut, which leaves the card nowhere to appear. Use TooltipBehavior.onlyTextOverflow on the text columns of a table that shows a card.
A tooltipFormatter returning an empty string means "no tooltip on this cell", so the card shows there too. A tooltipBuilder returning an invisible widget does not: a widget tooltip is always taken to have content, and it wins.
Merged rows stand for several data rows, so there is no single rowData to build from. They carry no card.
rowTooltipTheme falls back to tooltipTheme when omitted.
A row card and
TooltipBehavior.alwaysdo not mix.alwaysshows a text tooltip on every ellipsized column whether or not its text is actually cut — and ellipsis is the default. Since the cell's tooltip wins, the card would surface nowhere. Give your text columnstooltipBehavior: TooltipBehavior.onlyTextOverflowalongside a row card, so a text tooltip appears only when it has something to add.
// Per-column cell tooltip behavior
tooltipBehavior: TooltipBehavior.always, // Whenever the column is ellipsized — even if the text is not actually cut
tooltipBehavior: TooltipBehavior.never, // Never show
tooltipBehavior: TooltipBehavior.onlyTextOverflow, // Only when the text is actually 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, via CheckboxStyle.scale |
|
| The selection column's width | |
minWidth / maxWidth (converted before they bound a drag) |
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.
minWidth and maxWidth are declared in the same units and travel the other way — the table multiplies them by scale before they bound a drag measured on screen. So the range a column stops at is the range you wrote, not a range scaled along with the zoom. (Fixed in 2.17.0; from 2.9.0 to 2.16.1 the drag path compared them unconverted, so at scale: 2.0 a declared maxWidth: 300 behaved as 150.)
One caveat on the reported number. Multiplying by
scaleand dividing back is exact for most factors but not all — at a scale accumulated by repeated Ctrl+wheel steps,onColumnResizedcan report a width outside the declared bound by around 1e-14. Layout re-clamps in logical space and is never outside it. Compare with a tolerance if you assert on the callback's value.
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 - The checkbox scales visually through
CheckboxStyle.scale. Every otherCheckboxStylefield is carried through untouched — this bullet used to say the opposite, describing the MaterialCheckboxthat 2.10.0 replaced - Custom sort icons are automatically scaled via
FittedBoxto match the scaledsortIconWidth
A mouse wheel notch animates the body by default, vertically and with Shift+wheel horizontally, as WheelMotion.spring(): 400ms, no bounce. The header and scrollbars follow it on every frame. Pass another wheelMotion to change the feel, or null to move at once on every notch.
FlutterTablePlus<Employee>(
columns: columns,
data: employees,
rowId: (e) => e.id,
wheelMotion: const WheelMotion.spring(
duration: Duration(milliseconds: 250),
),
)
FlutterTablePlus<Employee>(
columns: columns,
data: employees,
rowId: (e) => e.id,
wheelMotion: null, // no animation
)The example app's Smooth wheel recipe and playground have a knob for every parameter below.
WheelMotion comes from flutter_smooth_wheel_scroll and is re-exported here, so no extra import is needed.
| Motion | Feel |
|---|---|
WheelMotion.spring({duration, bounce}) |
Keeps its speed when you keep scrolling |
WheelMotion.curve({duration, curve}) |
Plays a Curve, starting over on every notch |
WheelMotion.lerp({timeConstant}) |
Fast at first, slowing as it arrives |
- Notches during a motion add to its target, so fast consecutive notches travel the full distance
wheelMotioncan change at runtime; the next notch uses it, and the scroll position is kept- Only the wheel is animated. Dragging a scrollbar, drag-selection auto-scroll and scale correction move the body at once, and stop a motion in progress
- A wheel turned over a scrollbar moves it at once
- Inside a page that scrolls too, a notch the table cannot use goes to the page, even while a motion is still heading for the table's end
- Ctrl/Cmd+wheel zoom is unaffected: with
onScaleChangedset it still changes scale and does not scroll - How far one notch travels is app-wide, not per table: install
SmoothWheelBindingfromflutter_smooth_wheel_scrollinmain()