GridSheet
It is a highly flexible, high-performance Flutter DataTable / DataGrid package designed for building rich tabular UIs similar to Excel or spreadsheet applications, with minimal configuration and powerful built-in features. It gives developers full control over both data management and UI presentation while remaining easy to set up and extend.
It supports essential table and data-grid features such as CRUD operations, sorting, filtering, pagination (client-side, server-side & infinite scroll), column resizing, row/column reordering, row expansion panels, dropdown cells, column formulas, data reload (manual & scheduled), and advanced cell customization, making it a robust alternative to Flutter’s built-in DataTable.
With GridSheet, you can fully customize table headers, rows, filter cells, autofill cells, footers, frozen columns, and context menus using your own widgets or wrapper-based designs. This flexibility makes it ideal for building Excel-like tables, and highly customized table experiences in Flutter—without sacrificing performance or simplicity.
Demo
Explore GridSheet's features through our live demos.
- Live Demo: View GridSheet Features
Found a bug or have an idea to improve GridSheet? We'd love to hear from you.
- Report bugs or request features: here
Table of Contents
- Highly Configurable
- Features
- Installation
- Import
- Usage
- Example
- API Overview
- Configuration
- Advanced Features
- Sorting
- Column Formulas
- Column Reorder
- Row Reorder
- Row Expansion
- Right-to-Left (RTL) Support
- Runtime Configuration Updates
- Data Reload
- Dropdown Cell Type
- Conditional Formatting
- Custom Row State Indicators
- Custom Wrappers
- Custom Expression Evaluator
- Custom Cell Widgets
- Context Menus
- Pagination
- Selection Events
- No Rows Custom Widget
- No Columns Custom Widget
- Custom Loading Widget
- Undo Delete
- Data Operations
- Autofill (Cell Drag-to-Copy)
- RTL Support
- Platform Support
- Notes
- Contributions
- License
Highly Configurable
GridSheet is designed to adapt to different application requirements rather than enforcing a fixed set of behaviors.
Most features are configurable and can be enabled, disabled, or customized through the available configuration classes and callbacks. Whether you need a lightweight data table or a fully featured spreadsheet-like experience, you can choose only the functionality your application requires.
Every application has different requirements, so GridSheet focuses on providing sensible defaults while allowing you to override behavior whenever needed. Most visual components, interactions, and data handling mechanisms can be customized through configuration objects, builder callbacks, and event listeners.
You can integrate GridSheet with local data sources, server-side APIs, or your own state management solution. Features can be enabled independently, allowing you to keep the widget lightweight while adding advanced capabilities only where they are needed.
The package also exposes customization points for headers, rows, filters, footers, cells, pagination, loading states, empty states, context menus, and styling. This makes it easy to match your application's design system without modifying the package itself.
As new features are introduced, they are designed to remain configurable and backward compatible whenever possible, ensuring existing implementations continue to work while giving you access to additional functionality.
The goal of GridSheet is to provide a flexible foundation that can scale from simple data tables to complex enterprise applications with minimal changes to your existing code.
Features
Core Functionality
- ✅ Data Display & Management - Display tabular data with full CRUD operations on rows and columns
- ✅ Multi-Column Sorting - Sort by multiple columns with ascending/descending order
- ✅ Advanced Filtering - Filter data with multiple comparison modes (contains, exact match)
- ✅ Pagination - Built-in pagination with customizable rows per page (client-side, server-side, and infinite scroll)
- ✅ Column Pinning - Pin columns to the left or right side, excluded from horizontal scrolling
- ✅ Column Reordering - Drag-and-drop column reordering with visual feedback,.
onColumnReordercallback - ✅ Row Reordering - Drag-and-drop row repositioning with visual feedback,
onRowReordercallback. - ✅ Column Resizing - Resize columns by dragging column borders
- ✅ Row Resizing - Resize rows by dragging serial number column cell borders
- ✅ Cell Editing - Inline cell editing with type-specific input formatters
- ✅ Dropdown Cell Type - Built-in dropdown/select cells with a constrained options list and popup menu
Selection & Navigation
- ✅ Multi-Selection - Select multiple rows, columns, or cells simultaneously
- ✅ Keyboard Navigation - Navigate between cells with arrow keys, Tab/Shift+Tab, and Enter/Shift+Enter
- ✅ Select All - Tri-state header checkbox (unchecked/indeterminate/checked) to select/deselect all visible rows
- ✅ Selection Callbacks - Events for row, column, and cell selection changes
Data Manipulation
- ✅ Change Tracking - Track inserted, modified, and deleted rows
- ✅ Position-Based Insert - Insert rows/columns above/below or before/after the current selection
- ✅ Undo Operations - Revert changes to individual rows or all changes
- ✅ Bulk Operations - Clear, fill, find, and replace operations on cell ranges
- ✅ Copy/Paste - Clipboard support for tab-delimited data
- ✅ Auto-Fill - Fill cells with patterns and automatic incrementing
Visual Customization
- ✅ Conditional Formatting - Apply background colors and text styles based on expressions
- ✅ Custom Cell Widgets - Build custom widgets for headers, filters, and data cells
- ✅ Row State Indicators - Visual indicators for inserted, modified, and deleted rows
- ✅ Theme Support - Comprehensive styling with light/dark theme support
- ✅ Custom Wrappers - Wrap header, filter, and row widgets with custom containers
Advanced Features
- ✅ Row & Column Virtualization - Rows and non-pinned columns are windowed (Sliver-backed), only building what's currently visible plus a small overscan buffer — scrolls smoothly with 100K+ rows and very wide schemas
- ✅ Row Expansion - Inline master-detail panel below each row with responsive field layout, animated open/close, and full cell widget consistency
- ✅ Runtime Configuration Updates - Toggle visual and behavioral flags at runtime via
gridManager.updateConfigurationwithout rebuilding the parent widget - ✅ Data Reload - Manual reload on demand via
gridManager.reload, plus interval-based and daily scheduled auto-reload withonBeforeReload/onAfterReloadlifecycle callbacks - ✅ Expression Evaluation - Evaluate conditional expressions for cell editability and formatting
- ✅ Context Menus - Right-click context menus for cells, rows, and columns
- ✅ Column Visibility - Show/hide columns dynamically
- ✅ Row Height Management - Auto-fit or manually adjust row heights
- ✅ Column Width Management - Auto-fit or manually adjust column widths
- ✅ Snapshot/Restore - Save and restore grid state for undo/redo functionality
- ✅ Statistics - Calculate sum, average, min, max, median for numeric columns
- ✅ Data Export - Export to CSV with customizable options
- ✅ Loading States - Display custom loading indicators during operations
- ✅ Formula Columns - Excel-style formulas, automatic evaluation, reactive updates, and filter support
Installation
Add grid_sheet to your pubspec.yaml:
dependencies:
grid_sheet: ^2.2.0
Then run:
flutter pub get
Import
import 'package:grid_sheet/grid_sheet.dart';
Usage
Basic Setup
GridSheet(
columns: [
GridSheetColumn(
key: const ValueKey('col_name'),
index: 0,
name: 'COL_NAME',
title: 'Name',
type: GridSheetColumnType.text,
width: 200,
),
GridSheetColumn(
key: const ValueKey('col_age'),
index: 1,
name: 'AGE',
title: 'Age',
type: GridSheetColumnType.double,
width: 80,
textAlign: TextAlign.right,
),
GridSheetColumn(
key: const ValueKey('col_active'),
index: 2,
name: 'ACTIVE',
title: 'Active',
type: GridSheetColumnType.boolean,
width: 80,
),
],
rows: List.generate(
50,
(i) => GridSheetRow(
key: ValueKey('row_$i'),
index: i,
data: [
'Person $i',
20 + i,
i.isEven,
],
),
),
onLoaded: (event) {
gridManager = event.gridManager;
debugPrint('Table initialized!');
},
);
With Custom Header and Footer
GridSheet(
columns: columns,
rows: rows,
headerWidget: (gridManager) {
return Padding(
padding: const EdgeInsets.all(8.0),
child: Row(
children: [
ElevatedButton(
onPressed: () async {
final newRow = GridSheetRow(
key: ValueKey('row_${DateTime.now().millisecondsSinceEpoch}'),
index: gridManager.rows.length,
data: List.filled(gridManager.columns.length, null),
);
await gridManager.insertRowsAt(0, [newRow]);
},
child: Text('Add Row'),
),
SizedBox(width: 8),
ElevatedButton(
onPressed: () async {
await gridManager.deleteRowsByKeys(
gridManager.selectedRowKeys,
);
},
child: Text('Delete Selected'),
),
],
),
);
},
footerWidget: (gridManager) {
return Container(
padding: EdgeInsets.all(8),
child: Text('Total Rows: ${gridManager.rows.length}'),
);
},
);
Example
import 'package:flutter/material.dart';
import 'package:grid_sheet/grid_sheet.dart';
void main() => runApp(GridSheetApp());
class GridSheetApp extends StatefulWidget {
const GridSheetApp({super.key});
@override
State<GridSheetApp> createState() => _GridSheetAppState();
}
class _GridSheetAppState extends State<GridSheetApp> {
late final GridSheetManager gridManager;
@override
Widget build(BuildContext context) {
return MaterialApp(
home: Scaffold(
appBar: AppBar(title: Text('GridSheet Example')),
body: GridSheet(
columns: [
GridSheetColumn(
key: ValueKey('col_name'),
index: 0,
name: 'COL_NAME',
title: 'Name',
type: GridSheetColumnType.text,
width: 200,
),
GridSheetColumn(
key: ValueKey('col_age'),
index: 1,
name: 'AGE',
title: 'Age',
type: GridSheetColumnType.double,
width: 80,
textAlign: TextAlign.right,
),
GridSheetColumn(
key: ValueKey('col_active'),
index: 2,
name: 'ACTIVE',
title: 'Active',
type: GridSheetColumnType.boolean,
width: 80,
),
],
rows: List.generate(
50,
(i) => GridSheetRow(
key: ValueKey('row_$i'),
index: i,
data: ['Person $i', 20 + i, i % 2 == 0],
),
),
onLoaded: (event) {
gridManager = event.gridManager;
debugPrint('Table initialized!');
},
),
),
);
}
}
API Overview
GridSheet Widget
The main widget that displays the data grid.
Key Properties:
columns- List of column definitionsrows- List of row datadirection- Controls the grid's layout direction, with support for left-to-right (LTR) and right-to-left (RTL) layouts.indexColumn- Configures the built-in serial-number/index column, including its title, width, pinning, alignment, and visibility.styleConfiguration- Visual styling optionsscrollConfiguration- Scrollbar customizationautofillConfiguration- Autofill (cell-drag-to-copy) CustomizationtableGenerationConfiguration- Used to dynamically generate table columns and rows based on the provided counts.conditionalFormatRules- List of formatting rulesheaderWidget- Custom widget above the gridfooterWidget- Custom widget below the gridnoRowsWidget- Widget shown when no data is availablenoColumnsWidget- Widget shown (in place of the whole table) when every column is hiddenloadingWidget- Widget shown during async operationsundoDeleteWidget- Custom widget for the undo-delete promptcellBuilder- Custom cell renderer (Builds a custom widget for a grid cell)contextMenuWidget- Right-click context menu builderonLoaded- Callback when grid is initializedonRowsSelected- Callback for row selection changesonColumnsSelected- Callback for column selection changesonCellsSelected- Callback for cell selection changesonCellValueChanged- Callback when cell value changesonPageChange- Async callback for server-side pagination and infinite scroll (receivesGridSheetPageRequest, returnsGridSheetPageData)rowExpansionConfiguration- Inline row expansion panel configuration (GridSheetRowExpansionConfiguration)rowExpandedWidgetBuilder- Fully custom expansion panel builder; overrides the built-in widget when providedonColumnReorder- Callback fired after a column drag-and-drop reorder completesonRowReorder- Callback fired after a row drag-and-drop reorder completesreloadConfiguration- Interval and/or scheduled-time settings for auto-reload (GridSheetReloadConfiguration)rowsLoader- Async callback that returns freshList<GridSheetRow>on each client-side reloadonBeforeReload- Callback fired immediately before every reload (manual or scheduled); receivesGridSheetReloadEventonAfterReload- Callback fired immediately after every reload; receivesGridSheetReloadEventsortIconBuilder- Custom builder for sort direction indicators in column headersrowStateColorBuilder- Custom builder for row state background colors in the index columnrowStateIconBuilder- Custom builder for row state icons (inserted/modified/deleted indicators)headerWrapper- Optional wrapper widget for the entire header rowfilterWrapper- Optional wrapper widget for the entire filter rowrowWrapper- Optional wrapper widget for each data rowevaluator- Custom expression evaluator for conditional expressions (defaults to built inGridSheetExpressionEvaluator)
GridSheetManager
The state manager interface providing programmatic control over the grid. Access it via the onLoaded callback or other event callbacks.
Data Operations:
// Row operations
// index based row insertion
await gridManager.insertRowsAt(index, rows);
// Position based row insertion
await gridManager.insertRows(rows);
await gridManager.deleteRowsByKeys(rowKeys);
gridManager.updateRowByKey(rowKey, newData);
// Row reordering
gridManager.reorderRowsByPositions(fromIndex: 0, toIndex: 3);
gridManager.reorderRowsByKeys(fromRowKey: ValueKey('row_5'), toRowKey: ValueKey('row_2'));
gridManager.resetRowOrder(); // restore original load order
// Column operations
// Index based insert
await gridManager.insertColumnsAt(index, columns);
// Position based column insertion
await gridManager.insertColumns(columns);
await gridManager.deleteColumnsByKeys(columnKeys);
await gridManager.hideColumns(['COL_NAME']);
await gridManager.showColumns(['COL_NAME']);
await gridManager.renameColumns({
'fname': (newName: 'first_name', newTitle: 'First Name'),
'lname': (newName: 'last_name', newTitle: 'Last Name'),
});
// Cell operations
gridManager.updateCurrentCell(rowKey: key, columnKey: key, value: value);
await gridManager.clearCellRange(startRow: 0, endRow: 5, startColumn: 0, endColumn: 2);
// Position based insert
await gridManager.insertRows(
[newRow],
position: GridSheetRowInsertPosition.belowSelection,
);
await gridManager.insertColumns(
[newColumn],
position: GridSheetColumnInsertPosition.afterSelection,
);
Selection:
// Programmatic selection
gridManager.selectRowsByKeys([rowKey1, rowKey2]);
gridManager.selectColumnsByKeys([colKey1]);
gridManager.selectCellsByKeys([cellKey1, cellKey2]);
// Query selection
final selectedRows = gridManager.selectedRowKeys;
final selectedCells = gridManager.selectedCellKeys;
Filtering & Sorting:
// Add filters
await gridManager.addFilters(
{'status': ['Active', 'Pending']},
comparison: GridSheetFilterComparison.exact,
);
// Sort by column
await gridManager.sortByColumn('age', direction: GridSheetSortDirection.desc);
// Clear all
await gridManager.clearSort();
await gridManager.removeAllFilters();
Pagination:
// Navigate (client-side and server-side)
await gridManager.goToPage(3);
gridManager.nextPage();
gridManager.previousPage();
// Change page size
await gridManager.changeRowsPerPage(50);
// Query state
debugPrint('Page ${gridManager.currentPage} of ${gridManager.totalPages}');
debugPrint('Total rows: ${gridManager.totalFilteredRows}');
// Server-side / infinite scroll specific
debugPrint('Has more rows: ${gridManager.hasMoreRows}');
debugPrint('Server total: ${gridManager.serverTotalCount}');
Data Export:
// Export as CSV
final csv = gridManager.exportToCSV(
includeHeaders: true,
onlyVisibleColumns: true,
);
// Export with formula columns' live formula text (e.g. '=A1+B1') instead
// of their evaluated value
final csvWithFormulas = gridManager.exportToCSVWithFormulas(
includeFormulas: true,
);
// Get row data as maps
final maps = gridManager.rowsDataToMapList(gridManager.rows);
// Get statistics
final stats = gridManager.getColumnStatistics('salary');
debugPrint('Average: ${stats['average']}');
Note: CSV export automatically neutralizes cell values that start with
=,+,-, or@(by prefixing a leading') to prevent CSV/spreadsheet formula injection when the exported file is opened in Excel, Sheets, or LibreOffice. A formula column's live formula text exported viaincludeFormulas: trueis exempt, since it's meant to stay a live formula.
Runtime Configuration Updates:
// Toggle visual/behavioral flags without rebuilding the parent widget
gridManager.updateConfiguration(
gridManager.configuration.copyWith(
showColumnFilters: false,
showColumnHeaderRow: true,
enableColumnReorder: false,
enableRowReorder: true,
enableRowHover: false,
showRowBorders: false,
showColumnBorders: true,
),
);
Reload:
// Manual reload — shows loading overlay, then re-fetches or refreshes
await gridManager.reload();
// Runtime schedule control
gridManager.stopAutoReload();
gridManager.startAutoReload();
// Check whether auto-reload is currently active
debugPrint('Auto-reload active: ${gridManager.isAutoReloadActive}');
Core Models
GridSheetColumn
Note: The name property serves as the unique internal identifier for a column. Use this value consistently across expressions, conditional formatting rules, and other grid configurations to reference the column reliably.
GridSheetColumn(
key: ValueKey('unique_key'),
name: 'COL_NAME', // Internal identifier
title: 'Display Title', // Shown in header
description: 'Full description shown as a tooltip on hover', // optional
type: GridSheetColumnType.text,
width: 150,
visible: true,
pinnedLeft: false, // Pin to left
pinnedRight: false, // Pin to right
editable: true,
sortable: true,
resize: true,
textAlign: TextAlign.left,
conditionalEditExpression: 'age > 18', // Control editability
)
| Property | Type | Default | Description |
|---|---|---|---|
key |
ValueKey<String> |
required | Unique stable identifier |
index |
int |
required | Position in GridSheetRow.data |
name |
String |
required | Internal identifier used in expressions and filters |
title |
String |
required | Text displayed in the column header |
description |
String? |
null |
Tooltip shown on header hover — ideal for abbreviated titles |
type |
GridSheetColumnType |
required | Data type (text, integer, double, datetime, boolean, dropdown, formula, custom) |
width |
double |
120.0 |
Column width in logical pixels |
visible |
bool |
true |
Whether the column is shown |
pinnedLeft |
bool |
false |
Pin column to the left (excluded from horizontal scroll) |
pinnedRight |
bool |
false |
Pin column to the right (excluded from horizontal scroll). Takes precedence over pinnedLeft if both are set |
editable |
bool |
true |
Allow cell editing |
sortable |
bool |
false |
Enable sort on header double-tap |
resize |
bool |
false |
Allow column width drag-resize |
textAlign |
TextAlign |
TextAlign.left |
Cell content alignment |
filterHint |
String |
'Search..' |
Placeholder text in the filter input |
dropdownOptions |
List<String>? |
null |
Selectable values for dropdown columns |
noTextControllerWidget |
bool |
false |
Skip TextEditingController (use for checkboxes, dropdowns) |
showFormulaInEdit |
bool |
true |
Show raw formula while editing formula columns |
showOverflowTooltip |
bool |
false |
Show a hover tooltip with the full value when cell text is truncated. |
conditionalEditExpression |
String? |
null |
Expression evaluated per row to control cell editability |
GridSheetRow
GridSheetRow(
key: ValueKey('unique_key'),
index: 0,
data: ['value1', 'value2', 'value3'],
height: 40.0, // defaults to 30.0 if omitted
state: GridSheetRowState.existing, // existing (default), inserted, modified, deleted
)
Each row remembers the height it was constructed with. Resizing (via drag handles,
updateRowHeights, setAllRowHeights, or auto-fit) only changes the live height —
call row.resetHeight to restore that row's original height, or
gridManager.resetAllRowHeights to restore every row at once. Rows constructed with
different initial heights (e.g. 30, 40, 50) are restored to those individual values,
not to a single shared default.
GridSheetConfiguration.rowHeight is deprecated in favor of the per-row height above.
GridSheetCell
final cellKey = GridSheetCell(
ValueKey('row_1'),
ValueKey('col_name'),
);
Configuration
GridSheet Configuration
GridSheetConfiguration(
// Pagination
enableDefaultPagination: true,
enableCustomPagination: false,
rowsPerPage: 20,
// Selection
enableRowSelectionOnFirstColumnTap: true,
enableColumnSelection: true,
enableCellSelection: true,
enableMultiSelection: true,
selectOnlyPageRows: false,
selectionIncludesPinnedLeftCells: false,
selectionIncludesPinnedRightCells: false,
conditionalFormatIncludesPinnedLeftCells: false,
conditionalFormatIncludesPinnedRightCells: false,
// Position based insertion
rowInsertPosition: GridSheetRowInsertPosition.aboveSelection,
columnInsertPosition: GridSheetColumnInsertPosition.beforeSelection,
// Visual
enableRowHover: true,
enableRowResize: false,
showBorderOnSelection: false,
// serialNumberColumn / serialNumberColumnWidth are deprecated — use
// GridSheet.indexColumn instead (see "GridSheet Widget" above).
showRowBorders: true,
showColumnBorders: true,
// grow scrollable columns to fill leftover width instead of leaving it blank
stretchColumnsToFillWidth: false,
showColumnFilters: true,
// To show column header row
showColumnHeaderRow: true,
// show filter area above column header
filterPosition: GridSheetFilterPosition.above,
// Interaction
enableColumnReorder: true,
enableRowReorder: true,
enableRowContextMenu: true,
enableColumnContextMenu: true,
enableCellContextMenu: true,
// Sizing
headerHeight: 40,
filterHeight: 40,
// Deprecated use [GridSheetRow.height] instead
rowHeight: 40,
// Server-side / Infinite scroll
serverSidePagination: false, // fetch rows from server on each page change
infiniteScroll: false, // append rows as user scrolls to bottom
infiniteScrollThreshold: 200.0, // pixels from bottom that trigger the next fetch
// show prompt after deletions instead of removing them permanently right away
enableUndoDelete: true,
)
Dynamic Table Generation
GridSheetDynamicTableGeneration allows GridSheet to automatically generate its columns and rows at runtime based on the provided configuration.
It is designed for grid/table components where the structure can be created at runtime instead of being statically defined.
Important: columns and rows must be empty lists when using dynamic generation
GridSheet(
columns: [], // Must be an empty list when dynamic generation is enabled
rows: [], // Must be an empty list when dynamic generation is enabled
tableGenerationConfiguration: GridSheetDynamicTableGeneration(
enabled: true, // Enables dynamic table generation
columnsCount: 20, // Number of columns to generate
rowsCount: 30, // Number of rows to generate
// Customizes each generated column beyond the defaults — any
// GridSheetColumn property can be overridden per index.
columnBuilder: (index, column) =>
column.copyWith(type: GridSheetColumnType.integer, width: 150.0),
// Customizes each generated row beyond the defaults — any GridSheetRow
// property (height, state, seeded data) can be overridden per index.
rowBuilder: (index, row) =>
row.copyWith(state: GridSheetRowState.inserted),
),
);
Autofill Configuration
GridSheetAutoFillConfiguration(
enabled: true, // Enables or disables the auto-fill feature.
fillHandleSize: 8.0, // Size of the fill handle (in logical pixels).
fillHandleColor: Colors.blue, // Color of the fill handle.
fillHandleShape: BoxShape.rectangle, // BoxShape.rectangle or BoxShape.circle.
fillHandleBorderRadius: BorderRadius.zero, // Corner radius.
showPreviewOverlay: true, // Whether to show a visual preview.
enablePatternDetection: true, // Automatically detects and continues patterns.
// Prevents auto-fill if source and target cell value column types do not match.
enforceTypeMatching: false,
//Mouse cursor displayed while dragging the fill handle.
dragCursor: SystemMouseCursors.cell,
)
Scroll Configuration
GridSheetScrollBarConfiguration(
radius: const Radius.circular(4), // Corner radius of the scrollbar thumb.
thickness: 10.0, // Thickness of the scrollbar (in logical pixels).
thumbVisibility: true, // Whether the scrollbar thumb is always visible.
trackVisibility: true, // Whether the scrollbar track is visible behind the thumb.
// false disables vertical scrolling entirely (not just the bar)
showVerticalScrollbar: true,
// false disables horizontal scrolling entirely (not just the bar)
showHorizontalScrollbar: true,
// Only matters when showVerticalScrollbar is true.
verticalScrollbarPlacement: GridSheetVerticalScrollbarPlacement.afterLastColumn,
// Replaces the scrollbar widget entirely (e.g. RawScrollbar with a custom
// thumb) instead of just restyling the built-in Scrollbar.
scrollbarBuilder: null,
)
scrollbarBuilder: (context, child, controller, axis) => Widget— wrapchild(the scrollable content) in your own scrollbar widget and return it, reusing the givencontroller. See the in-repo example (example/lib/features/scrollbar.dart) for aRawScrollbarimplementation, including thenotificationPredicateneeded for the horizontal axis since the grid nests its horizontal scroll view inside the vertical one.showVerticalScrollbar/showHorizontalScrollbardisable scrolling on that axis entirely (mouse wheel, trackpad, and drag) as well as hiding the scrollbar —scrollbarBuilderis not called for a disabled axis either.verticalScrollbarPlacement:afterLastColumn(default) reserves a strip of space after the last column so the vertical scrollbar never covers content;overlaydraws it directly on top of the last column instead — like a native/desktop overlay scrollbar — so the last column extends fully to the grid's right edge. Has no effect whenshowVerticalScrollbarisfalse.
Row Expansion Configuration
GridSheetRowExpansionConfiguration(
enabled: true, // Show expansion chevron in the serial-number column
// Max height in pixels — the panel shrinks to fit shorter content and scrolls past this
panelHeight: 300.0,
animationDuration: Duration(milliseconds: 200), // Open/close animation speed
backgroundColor: null, // Panel background
// Icon shown in the serial-number column (rotates 90° when open)
icon: Icons.chevron_right,
showOnlyEditableColumns: true, // Only show columns editable for that row
)
Column Animation Configuration
GridSheetAnimationConfiguration(
enabled: false, // Off by default
// Total time for the whole cascade, not a single column
animationDuration: Duration(milliseconds: 700),
curve: Curves.easeOutCubic, // Shapes each column's own slide/fade transition
slideDistance: 24.0, // Logical pixels each column slides in from
replayOnPageChange: true, // Replay the cascade on every pagination change
// Replay the same cascade per-row after a sort/filter
animateRowsOnSortOrFilter: false,
// Flash a cell's background when its value changes
cellChangeFlashEnabled: false,
cellChangeFlashColor: Colors.amber, // Flash color
// How long the flash takes to fade out
cellChangeFlashDuration: Duration(milliseconds: 800),
// Fade a column in when it's shown via showColumns
columnVisibilityFadeEnabled: false,
// Replace the built-in slide+fade for column/row reveal
revealTransitionBuilder: null,
// Replace the built-in flat-color cell-change flash
cellFlashBuilder: null,
)
- Set
replayOnPageChange: falseto play the animation only on the initial mount. The animation never replays onreloador automatic reloads. animateRowsOnSortOrFilterreplays the same staggered slide-and-fade animation for rows (top to bottom) whenever the current page is sorted or filtered. The row animation additionally runs afterserverSidePaginationpage changes, whileinfiniteScrollanimates only newly appended rows. Client-side pagination never triggers a row reveal.revealTransitionBuilder: (context, child, progress, axis) => Widgetswaps out the built-in slide+fade for column/row reveal with your own transition —progressruns 0.0→1.0, already shaped bycurve.cellFlashBuilder: (context, progress) => Widgetswaps out the built-in flat-color cell-change flash with your own effect —progressruns 1.0 (just changed) → 0.0 (settled) overcellChangeFlashDuration.
Style Configuration
GridSheetStyleConfiguration(
// Colors
gridBackgroundColor: Colors.white,
headerColor: Colors.grey[200]!,
rowColor: Colors.white,
evenRowColor: Colors.grey[50],
oddRowColor: Colors.white,
hoverColorOnRow: Colors.blue,
filterColor: Colors.grey[100]!,
selectionColor: Colors.blue[100]!,
ascSortIconColor: Colors.green,
descSortIconColor: Colors.red,
// bgcolor of the screen overlay shown behind the loader
loadingOverlayColor: Colors.black[300]!,
// Borders
gridBorderColor: Colors.grey[300]!,
gridBorderWidth: 1.0,
rowBorderColor: Colors.grey[300]!, // Requires GridSheetConfiguration.showRowBorders: true
rowBorderWidth: 1.0,
columnBorderColor: Colors.grey[300]!, // Requires GridSheetConfiguration.showColumnBorders: true
columnBorderWidth: 1.0,
cellTextFieldBorderColor: Colors.grey[300]!, // Border of the in-place cell editor text field
cellTextFieldBorderWidth: 1.0,
gridBorderRadius: BorderRadius.all(Radius.circular(4)),
// Selection / hover / resize / drag feedback
selectionBorderWidth: 2.0,
resizeHandleColor: null, // null → falls back to selectionColor
resizeHandleThickness: 3.0, // Thickness of the column/row resize indicator line
// Elevation of the floating preview while reordering a column
columnDragFeedbackElevation: 8.0,
// Elevation of the floating preview while reordering a row
rowDragFeedbackElevation: 4.0,
dragFeedbackOpacity: 0.35, // Opacity of the placeholder left at the drag origin
// Customize dividers between pinned columns and the main grid.
pinnedLeftDividerColor: Colors.blue[100]!,
pinnedRightDividerColor: Colors.yellow[100]!,
pinnedColumnsDividerWidth: 2,
// Text styles
headerTextStyle: TextStyle(fontWeight: FontWeight.bold),
cellTextStyle: TextStyle(fontSize: 14),
// Horizontal padding — use it mostly when wrappers are provided.
leftPadding: 8,
rightPadding: 8,
)
Theming
You can switch between light and dark themes by configuring the style options.
For a full working example, please see the Example tab.
Column Configuration
Column Types:
GridSheetColumnType.text- String valuesGridSheetColumnType.double- Numeric values with decimal supportGridSheetColumnType.int- Integer valuesGridSheetColumnType.boolean- True/false valuesGridSheetColumnType.datetime- Date and time valuesGridSheetColumnType.formula- Excel-style formula columnGridSheetColumnType.dropdown- Dropdown/select cell constrained to a fixed options listGridSheetColumnType.custom- Custom type
Column Features:
GridSheetColumn(
// Identity
key: ValueKey('col_1'),
name: 'internalName',
title: 'Display Name',
index: 0, // Position in data array
// Behavior
type: GridSheetColumnType.text,
editable: true,
sortable: true,
resize: true,
pinnedLeft: false,
pinnedRight: false,
visible: true,
// Use `noTextControllerWidget: true` for read-only columns or
// when using custom widgets that do not require a `TextEditingControllers`,
// preventing unnecessary controller creation and improving performance.
noTextControllerWidget: false,
// Appearance
width: 150,
// caps growth under GridSheetConfiguration.stretchColumnsToFillWidth;
maxStretchWidth: null, // null = uncapped
textAlign: TextAlign.left,
// Conditional cell editability
conditionalEditExpression: 'status == "Active"',
// Dropdown options (required when type == GridSheetColumnType.dropdown)
dropdownOptions: ['Active', 'Inactive', 'Pending'],
)
Advanced Features
Sorting
This feature works only when the column is configured with sortable = true and the user double-clicks on the column header to apply sorting. The custom icons are displayed when sorting is active.
Custom sort icons allow you to build and display your own sorting indicators in column headers. These icons are shown when sorting is applied, giving you full control over their appearance and behavior instead of using the default built-in sort icons.
GridSheetColumn(
// To enable sorting for that column
sortable: true,
);
// custom sort icons
GridSheet(
sortIconBuilder: (column, direction, priority) {
final isAsc = direction == GridSheetSortDirection.asc;
return Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
isAsc ? Icons.arrow_upward : Icons.arrow_downward,
size: 16,
color: isAsc ? Colors.green : Colors.red,
),
if (priority > 1)
Text(
' $priority',
style: TextStyle(fontSize: 10, fontWeight: FontWeight.bold),
),
],
);
},
)
Column Pinning
Pin columns to either edge of the grid so they stay visible while the rest of the table scrolls horizontally. Pinned-left columns render in a fixed pane before the scrollable columns; pinned-right columns render in a fixed pane after them, at the true right edge of the grid — the vertical scrollbar (when verticalScrollbarPlacement: afterLastColumn, the default) renders after the pinned-right pane too, not between it and the scrollable columns.
A column can only be pinned to one side at a time — pinning it to the other side clears the existing pin.
// Declaratively, at construction:
GridSheetColumn(pinnedLeft: true, /* ... */);
GridSheetColumn(pinnedRight: true, /* ... */);
// Dynamically, via the manager — index-based:
await gridManager.pinColumnsLeftUptoIndex(1); // pins columns [0, 1]
await gridManager.pinColumnsRightFromIndex(4); // pins columns [4..end]
// Dynamically, by name — authoritative (moves the named columns to the
// front/end and unpins anything else previously pinned on that side):
await gridManager.pinColumnsToLeft(['id', 'name']);
await gridManager.pinColumnsToRight(['actions']);
// Unpin: all pinned columns
await gridManager.clearPinColumns();
// Read the current pin state:
gridManager.pinnedLeftColumns;
gridManager.pinnedRightColumns;
Marking the pin boundary visually: GridSheetStyleConfiguration.pinnedLeftDividerColor/pinnedRightDividerColor draw an optional divider at the seam between a pinned pane and the scrollable columns, sized by pinnedColumnsDividerWidth — null (the default) draws nothing on that side.
GridSheet(
styleConfiguration: GridSheetStyleConfiguration(
pinnedLeftDividerColor: Colors.grey,
pinnedRightDividerColor: Colors.grey,
pinnedColumnsDividerWidth: 2,
),
)
Each side is independent — set one, both, or neither, and each side's color can differ. A divider is only drawn when that side actually has pinned columns. Unlike the per-column separators drawn by columnBorderColor/columnBorderWidth, this divider is a real element inserted between the panes (not an overlay painted on top of them), so it always reserves its own width — however large pinnedColumnsDividerWidth is set to, it never covers any active-pane cell content; the scrollable pane is simply narrower by that amount.
Column Reorder
Column reordering is enabled by long-pressing a column header and dragging it to the desired position.
GridSheet(
configuration: GridSheetConfiguration(
enableColumnReorder: true,
),
onColumnReorder: (event) {
// event.fromIndex — original visual position
// event.toIndex — new visual position
// event.fromColumnKey — stable key of the moved column
// event.toColumnKey — stable key of the drop-target column
debugPrint('Column moved: ${event.fromIndex} → ${event.toIndex}');
// Persist new order
final order = event.gridManager.visibleColumns.map((c) => c.name).toList();
userPrefs.saveColumnOrder(order);
},
)
Row Reorder
Row reordering is enabled by long-pressing the serial-number cell of a row and dragging it to the desired position. Row reorder is automatically disabled while a sort is active.
GridSheet(
configuration: GridSheetConfiguration(
enableRowReorder: true,
),
onRowReorder: (event) {
// event.fromIndex — original position in the rows list
// event.toIndex — new position after the move
// event.fromRowKey — stable key of the dragged row
// event.toRowKey — stable key of the drop-target row
debugPrint('Row moved: ${event.fromIndex} → ${event.toIndex}');
myDataSource.reorder(event.fromIndex, event.toIndex);
},
)
Row Expansion
Row expansion enables an inline master-detail panel that slides open directly below any row when the user taps the serial-number cell. The panel displays all column values using the exact same cell widgets as the grid row — editability, conditional expressions, dropdown types, and custom cellBuilder overrides all apply identically.
Enable it via rowExpansionConfiguration:
GridSheet(
columns: columns,
rows: rows,
rowExpansionConfiguration: GridSheetRowExpansionConfiguration(
enabled: true,
panelHeight: 280.0,
animationDuration: Duration(milliseconds: 200),
backgroundColor: Colors.grey[50], // optional; defaults to row background color
icon: Icons.chevron_right, // rotates 90° when expanded
),
)
Behavior:
- Tapping the serial-number cell toggles the expansion panel open/closed.
- Multiple rows can be expanded simultaneously.
Custom expansion panel:
Supply rowExpandedWidgetBuilder to replace the built-in form entirely:
GridSheet(
rowExpansionConfiguration: GridSheetRowExpansionConfiguration(enabled: true),
rowExpandedWidgetBuilder: (context, row, columns) {
return Container(
color: Colors.blue[50],
padding: const EdgeInsets.all(16),
child: Text('Detail view for row ${row.index}'),
);
},
)
Custom serial-number cell with expansion support:
When using cellBuilder for GridSheetCellKind.indexing, the built-in chevron icon is not added automatically. Use cell.isRowExpanded and cell.onToggleRowExpansion from GridSheetCellContext to render your own icon and handle the toggle.
Relocating the expand toggle to another column:
onToggleRowExpansion/isRowExpanded aren't limited to the index column — they're populated on GridSheetCellContext for any row cell (any column) while row expansion is enabled, so a cellBuilder on a different column can wire the toggle in there instead:
GridSheet(
rowExpansionConfiguration: GridSheetRowExpansionConfiguration(enabled: true),
cellBuilder: (cell) {
if (cell.kind != GridSheetCellKind.row || cell.column.name != 'STATUS') {
return null; // fall back to the default cell for everything else
}
return GestureDetector(
onTap: cell.onToggleRowExpansion,
child: Icon(cell.isRowExpanded == true
? Icons.expand_less
: Icons.expand_more),
);
},
)
Right-to-Left (RTL) Support
GridSheet provides full native RTL support, mirroring layout, alignment, scrolling, interactions, icons, and navigation while preserving logical column identity, state, selection, filters, sorting, and callbacks.
Enable it either explicitly, or by inheriting the ambient app direction — no other configuration is required:
// Explicit — overrides whatever Directionality is ambient above it:
GridSheet(
columns: columns,
rows: rows,
direction: GridSheetDirection.rightToLeft,
)
// Or inherit from an ambient Directionality/Localizations (e.g. the
// app's locale) by leaving direction unset (the default, `null`):
MaterialApp(
locale: const Locale('ar'), // or any RTL locale
home: Scaffold(
body: GridSheet(columns: columns, rows: rows),
),
)
Known limitations:
- Mixed-direction text within a single cell value (BiDi shaping) is handled by Flutter's own text layer, not overridden per-run by the grid.
- RTL numeral formatting (Eastern Arabic-Indic digits) is a locale/
NumberFormatconcern the grid doesn't touch. - Custom
cellBuilder/headerWrapper/filterWrapper/rowWrapperwidgets inherit ambientDirectionalitybut their internal layout isn't audited — that's the consuming app's responsibility, same as any other custom Flutter widget.
Runtime Configuration Updates
updateConfiguration lets you toggle visual and behavioral flags at runtime via GridSheetManager without triggering a parent widget rebuild.
Safe to update at runtime:
| Field | Description |
|---|---|
showColumnFilters |
Show/hide the filter row |
showColumnHeaderRow |
Show/hide the column header row |
enableColumnReorder |
Enable/disable column drag-to-reorder |
enableRowReorder |
Enable/disable row drag-to-reorder |
enableRowHover |
Enable/disable row hover highlight |
serialNumberColumn (deprecated) |
Show/hide the serial-number (index) column — use GridSheet.indexColumn instead |
serialNumberColumnWidth (deprecated) |
Width of the serial-number column — use GridSheet.indexColumn instead |
showRowBorders |
Show/hide horizontal row borders |
showColumnBorders |
Show/hide vertical column borders |
stretchColumnsToFillWidth |
Grow scrollable columns to fill leftover width instead of leaving it blank |
headerHeight |
Height of the column header row |
filterHeight |
Height of the filter row |
enableRowContextMenu |
Enable/disable the row right-click menu |
enableColumnContextMenu |
Enable/disable the column right-click menu |
enableCellContextMenu |
Enable/disable the cell right-click menu |
enableUndoDelete |
Enable/disable the undo-delete prompt for row/column deletions (see "Undo Delete") |
Structural fields are locked — the following cannot be changed at runtime because they affect data loading and pagination mode. Pass a new value for them and it will be silently ignored; use the dedicated API instead:
| Field | Use instead |
|---|---|
rowsPerPage |
gridManager.changeRowsPerPage(n) |
enableDefaultPagination |
Rebuild the widget |
enableCustomPagination |
Rebuild the widget |
serverSidePagination |
Rebuild the widget |
infiniteScroll |
Rebuild the widget |
// Example: hide filters and disable column reorder from a toolbar button
gridManager.updateConfiguration(
gridManager.configuration.copyWith(
showColumnFilters: false,
enableColumnReorder: false,
),
);
GridSheet.indexColumn isn't part of GridSheetConfiguration, so it isn't changed via updateConfiguration — rebuild the widget with a new value instead (the same pattern styleConfiguration/autofillConfiguration already use):
// Example: toggle the serial-number column via a rebuild
GridSheet(
indexColumn: showIndex ? GridSheetColumn.defaultIndexColumn : null,
// ...
)
Data Reload
GridSheet supports both manual and scheduled automatic reload. On each reload the grid re-applies the current sort and filter so the displayed data stays consistent.
Reload behaviour by data mode:
| Mode | What happens on reload |
|---|---|
| Server-side pagination / Infinite scroll | Re-fetches page 1 from the server with the current sort and filter state |
Client-side with rowsLoader |
Calls rowsLoader, replaces internal row list, then re-applies sort/filter |
Client-side without rowsLoader |
Re-applies the current sort and filter to existing rows (view refresh only) |
Manual Reload
Call gridManager.reload at any time — from a button, a WebSocket event, or any external trigger:
GridSheet(
rowsLoader: () async {
final data = await api.fetchUsers();
return data.map((e) => GridSheetRow(...)).toList();
},
onLoaded: (event) => gridManager = event.gridManager,
loadingWidget: const Center(child: CircularProgressIndicator()),
...
)
// Reload from a toolbar button
ElevatedButton(
onPressed: () => gridManager.reload(),
child: const Text('Refresh'),
)
Interval-based Auto-Reload
Reload automatically every N duration using GridSheetReloadConfiguration.interval:
GridSheet(
rowsLoader: () async => await api.fetchRows(),
reloadConfiguration: const GridSheetReloadConfiguration(
interval: Duration(minutes: 5),
showLoadingOnAutoReload: false, // silent background refresh (default)
),
onAfterReload: (event) {
debugPrint('Auto-refreshed at ${DateTime.now()}');
},
...
)
Daily Scheduled Auto-Reload
Reload once per day at a fixed wall-clock time using GridSheetReloadConfiguration.scheduledTime:
GridSheet(
rowsLoader: () async => await api.fetchRows(),
reloadConfiguration: const GridSheetReloadConfiguration(
scheduledTime: TimeOfDay(hour: 9, minute: 0), // fires every day at 09:00
),
...
)
Combined — Interval + Scheduled
Both options can be set together:
GridSheetReloadConfiguration(
interval: Duration(hours: 1), // every hour
scheduledTime: TimeOfDay(hour: 0, minute: 0), // plus a midnight reset
)
Lifecycle Callbacks
onBeforeReload and onAfterReload are fired for every reload regardless of source. Use event.isAutoReload to distinguish automatic reloads from manual ones:
GridSheet(
onBeforeReload: (event) {
if (!event.isAutoReload) showSpinner();
},
onAfterReload: (event) {
hideSpinner();
debugPrint('Rows after reload: ${event.gridManager.rows.length}');
},
...
)
Runtime Schedule Control
Stop and restart the auto-reload schedule at runtime without rebuilding the widget:
// Pause auto-reload when the user navigates away
gridManager.stopAutoReload();
// Resume when they come back
gridManager.startAutoReload();
// Check current state
if (gridManager.isAutoReloadActive) { ... }
Dropdown Cell Type
Set type: GridSheetColumnType.dropdown and provide a dropdownOptions list to render a built-in dropdown/select cell. Tapping the cell opens a Material popup menu anchored below the cell; the currently selected value is shown with a checkmark.
GridSheetColumn(
key: ValueKey('col_status'),
name: 'STATUS',
title: 'Status',
type: GridSheetColumnType.dropdown,
dropdownOptions: ['Active', 'Inactive', 'Pending'],
width: 130,
editable: true,
)
- Read-only state: If
editable: false,conditionalEditExpressionevaluates to false, ordropdownOptionsis null/empty, the cell renders as plain text with no arrow.
Column Formulas
It supports mostly Excel-style formulas in cells, with automatic evaluation, editing, and filtering. Dependent values automatically update when source cells change.
GridSheetColumn(
name: 'FORMULA_COL',
type: GridSheetColumnType.formula, // formula type column
showFormulaInEdit: true, // shows formula when focused, else only evaluated value
);
// Can evaluate formulas on load
// Can contain any supported formula in its cells,
// e.g., '=F1 * 2', '=SUM(E1:F3)', '=A1 + B2'.
// Can contain column names
rows = [
['=F1 * 2'],
['=SUM(E1:F3)'],
['=COLUMN_NAME3 * 2'],
];
Filter support: Works on both formulas and computed values
await gridManager.addFilters(
{'FORMULA_COL': ['=F1 * 2']},
comparison: GridSheetFilterComparison.exact,
);
await gridManager.addFilters(
{'FORMULA_COL': [1400]}, // e.g., if F1 value is 700
comparison: GridSheetFilterComparison.exact,
);
Reading a cell's formula: Get the raw formula text back (as opposed to its evaluated value)
final formula = gridManager.getCellFormula(
rowKey: row.key,
columnKey: column.key,
); // '=A1+B1', or null if the cell has no stored formula
Conditional Formatting
Apply visual styles to cells dynamically based on their values, enabling better data visualization and quick identification of important or out-of-range data.
Pinned-left and pinned-right columns are excluded from conditional formatting by default — set conditionalFormatIncludesPinnedLeftCells: true / conditionalFormatIncludesPinnedRightCells: true in GridSheetConfiguration to also evaluate and apply rules to them.
GridSheet(
conditionalFormatRules: [
// Row-level formatting
GridSheetConditionalFormatRule(
name: 'HighlightHighValues',
scope: GridSheetFormatScope.row,
backgroundColorExpression: 'age > 50 ? "#FFCDD2" : null',
textStyle: TextStyle(color: Color(0xFFE65100), fontStyle: FontStyle.italic),
),
// Column-level formatting
GridSheetConditionalFormatRule(
name: 'status',
scope: GridSheetFormatScope.column,
backgroundColorExpression: 'status == "Active" ? "#C8E6C9" : null',
textStyle: TextStyle(color: Color(0xFFE65100), fontStyle: FontStyle.bold),
),
// Cell-level formatting
GridSheetConditionalFormatRule(
name: 'salary',
scope: GridSheetFormatScope.cell,
backgroundColorExpression: 'salary > 100000 ? "#FFE0B2" : null',
textStyle: TextStyle(color: Color(0xFFE65100)),
),
],
)
GridSheetConditionalFormatRule(
name: 'HighlightErrors',
scope: GridSheetFormatScope.cell,
backgroundColorExpression: 'status == "Error" ? "#FFCDD2" : null',
// errors always win outright — no lower-priority rule adds to this cell
stopIfTrue: true,
)
Priority and conflict resolution (Excel-style):
conditionalFormatRules' list order is priority order — the first rule is highest priority — matching Excel's Conditional Formatting Rules Manager. When more than one rule matches the same cell:
- For the same attribute (background color, or a given
textStylefield like font weight), the highest-priority matching rule wins; lower-priority rules never override an attribute a higher-priority rule already set. - For different attributes, results merge instead of one rule replacing another wholesale — e.g. a higher-priority rule that only sets a background color still picks up font weight from a lower-priority rule that only sets that.
- Set
stopIfTrue: trueon a rule to opt out of that merging — once a matching rule withstopIfTrueapplies, no lower-priority rule is considered for that cell at all, mirroring Excel's "Stop If True". - Scope (
row/column/cell) has no inherent priority of its own — only list position does.
Dynamic rule management: Conditional formatting rules can be added, updated or removed dynamically, allowing styles to adapt as data or requirements change.
// Add rule (appended at the end = lowest priority by default)
await gridManager.addConditionalFormatRule(rule);
// Add rule at a specific priority (0 = highest priority)
await gridManager.addConditionalFormatRule(rule, atIndex: 0);
// Change an existing rule's priority
await gridManager.reorderConditionalFormatRulesByPositions(
fromIndex: 2,
toIndex: 0, // make it the highest-priority rule
);
// Remove rule
await gridManager.removeConditionalFormatRule(
name: 'HighlightHighValues',
scope: GridSheetFormatScope.row,
);
// Update rule
await gridManager.updateConditionalFormatRule(
oldName: 'HighlightHighValues',
scope: GridSheetFormatScope.row,
newRule: GridSheetConditionalFormatRule(
name: 'HighlightHighValues',
scope: GridSheetFormatScope.cell,
backgroundColorExpression: 'age > 60 ? "#FFCDD2" : null',
textStyle: TextStyle(color: Color(0xFFE65100), fontStyle: FontStyle.italic),
),
);
// Remove all rules by scope
await gridManager.removeConditionalFormatRulesByScope(
GridSheetFormatScope.row,
);
// Remove all rules
await gridManager.removeAllConditionalFormatRules();
// Find rules
final rules = gridManager.findConditionalFormatRules(
name: 'HighlightHighValues',
scope: GridSheetFormatScope.row,
);
// Refresh formatting
gridManager.refreshConditionalFormatting();
// returns a JSON-safe map of only the cells
// where a rule actually resolved a background color or text style
final styles = gridManager.getConditionalFormattingStyles();
final cellStyle = styles['row_3|col_amount'];
if (cellStyle != null) {
print(cellStyle['bgColor']); // e.g. '#FFFFCDD2'
}
Custom Row State Indicators
Use custom row state indicators to display visual markers(icon/bgColor) in the serial number column when a row is modified, deleted, or inserted, helping track changes directly in the grid.
GridSheet(
rowStateColorBuilder: (row) {
if (row.isInserted) return Colors.green[50]!;
if (row.isModified) return Colors.orange[50]!;
if (row.isDeleted) return Colors.red[50]!;
return Colors.white;
},
rowStateIconBuilder: (row) {
if (row.isInserted) {
return Icon(Icons.add_circle, color: Colors.green, size: 16);
}
if (row.isModified) {
return Icon(Icons.edit, color: Colors.orange, size: 16);
}
if (row.isDeleted) {
return Icon(Icons.delete, color: Colors.red, size: 16);
}
return null;
},
)
Custom Wrappers
Wrappers allow you to decorate grid sections such as headers, filters, and rows by wrapping the widgets with custom UI (e.g., padding, cards, borders, or shadows) without changing the cell content itself.
Important: When using wrappers with padding, you must use styleConfiguration.leftPadding and styleConfiguration.rightPadding for horizontal spacing to avoid overflow issues.
final styleConfiguration = GridSheetStyleConfiguration(
// Horizontal padding — mainly used when wrappers are provided.
leftPadding: 16,
rightPadding: 16,
);
GridSheet(
styleConfiguration: styleConfiguration,
headerWrapper: (context, child, isPinnedLeft, isPinnedRight) {
return Padding(
padding: isPinnedLeft
? EdgeInsets.only(left: styleConfiguration.leftPadding)
: EdgeInsets.only(right: styleConfiguration.rightPadding),
child: Card(
clipBehavior: Clip.antiAlias,
margin: const EdgeInsets.only(
top: 4,
bottom: 4,
),
shape: RoundedRectangleBorder(
borderRadius: isPinnedLeft
? const BorderRadius.only(
topLeft: Radius.circular(8),
bottomLeft: Radius.circular(8),
)
: const BorderRadius.only(
topRight: Radius.circular(8),
bottomRight: Radius.circular(8),
),
),
child: child,
),
);
},
filterWrapper: (context, child, isPinnedLeft, isPinnedRight) {
return Padding(
padding: isPinnedLeft
? EdgeInsets.only(left: styleConfiguration.leftPadding)
: EdgeInsets.only(right: styleConfiguration.rightPadding),
child: Card(
clipBehavior: Clip.antiAlias,
margin: const EdgeInsets.only(bottom: 4),
shape: RoundedRectangleBorder(
borderRadius: isPinnedLeft
? const BorderRadius.only(
topLeft: Radius.circular(8),
bottomLeft: Radius.circular(8),
)
: const BorderRadius.only(
topRight: Radius.circular(8),
bottomRight: Radius.circular(8),
),
),
child: child,
),
);
},
rowWrapper: (context, child, isPinnedLeft, isPinnedRight, isHovered, isSelected) {
return Padding(
padding: isPinnedLeft
? EdgeInsets.only(left: styleConfiguration.leftPadding)
: EdgeInsets.only(right: styleConfiguration.rightPadding),
child: Card(
clipBehavior: Clip.antiAlias,
elevation: isSelected ? 4 : 1,
margin: const EdgeInsets.only(bottom: 4),
shape: RoundedRectangleBorder(
borderRadius: isPinnedLeft
? const BorderRadius.only(
topLeft: Radius.circular(8),
bottomLeft: Radius.circular(8),
)
: const BorderRadius.only(
topRight: Radius.circular(8),
bottomRight: Radius.circular(8),
),
),
child: child,
),
);
},
);
Custom Expression Evaluator
Default Built-in GridSheetExpressionEvaluator
Extend ExpressionEvaluator to implement your own custom logic for conditional expressions:
Note: Handling null values and values type in the expression evaluator is crucial for a stable expression engine.
class CustomEvaluator extends ExpressionEvaluator {
@override
dynamic eval(Expression expression, Map<String, dynamic> context) {
// Custom evaluation logic
// Handle null values, custom functions, special operators, etc.
return super.eval(expression, context);
}
}
GridSheet(
evaluator: CustomEvaluator(),
columns: [
GridSheetColumn(
name: 'status',
conditionalEditExpression: 'age >= 18 && status == "Active"',
// This expression will be evaluated using CustomEvaluator
),
],
)
Custom Cell Widgets
Build custom widgets for any cell type:
IGridSheetCustomCellWidgetBuilder
The IGridSheetCustomCellWidgetBuilder abstract class allows developers to create and render custom Flutter widgets for individual cells in the GridSheet. By implementing this interface, you can fully control the appearance and behavior of cells at the row, column, or cell level.
This enables embedding interactive and complex widgets—such as buttons, icons, switches, progress indicators, or formatted layouts—directly within grid cells instead of using default text-based rendering. The renderer receives contextual information including the current row, cell value, row index, and column index, allowing conditional UI rendering and user interaction handling.
Using IGridSheetCustomCellWidgetBuilder provides greater flexibility and customization while maintaining GridSheet performance and structure, making it suitable for advanced tabular UI requirements.
late IGridSheetCustomCellWidgetBuilder _cellRenderer;
@override
void initState() {
super.initState();
_cellRenderer = CellWidgetBuilder(
onValueChanged: (rowIndex, colIndex, value) {
rows[rowIndex][colIndex] = value;
},
);
}
GridSheet(
// Your own custom widgets for cells.
cellBuilder: (cell) {
switch (cell.kind) {
case GridSheetCellKind.selectAll: // select all rows cell
return _cellRenderer.buildSelectAll(context, cell);
case GridSheetCellKind.indexing: // row-indexed cells
return _cellRenderer.buildIndexes(context, cell);
case GridSheetCellKind.header: // column-header cells
return _cellRenderer.buildHeader(context, cell);
case GridSheetCellKind.filter: // column-filter cells
return _cellRenderer.buildFilter(context, cell.column, cell);
case GridSheetCellKind.row: // column-row cells
final row = cell.row!;
final column = cell.column;
return _cellRenderer.buildCell(context, column, row, cell);
}
},
)
// Sample custom cell builder class for reference
class CellWidgetBuilder implements IGridSheetCustomCellWidgetBuilder {
final CellValueChanged? onValueChanged;
CellWidgetBuilder({this.onValueChanged});
GridSheetCellWidgetType _resolveRenderType(GridSheetColumn column) {
if (GridSheetColumnType.boolean == column.type) {
return GridSheetCellWidgetType.checkbox;
}
if (GridSheetColumnType.datetime == column.type) {
return GridSheetCellWidgetType.date;
}
return GridSheetCellWidgetType.text;
}
@override
Widget buildCell(
BuildContext context,
GridSheetColumn column,
GridSheetRow row,
GridSheetCellContext cell,
) {
if (column.name == 'Status') {
return _buildStatusChip(row.data[column.index].toString());
}
return getCellWidget(cell, context, row, column);
}
Widget getCellWidget(
GridSheetCellContext cell,
BuildContext context,
GridSheetRow row,
GridSheetColumn column,
) {
switch (_resolveRenderType(column)) {
case GridSheetCellWidgetType.checkbox:
return _buildCheckbox(cell, row, column);
case GridSheetCellWidgetType.date:
return _buildDateCell(context, cell, row, column);
case GridSheetCellWidgetType.text:
default:
return _buildTextCell(context, cell, row, column);
}
}
}
Using Default and Custom Cell Widgets
You can use built-in default cell widgets for certain cell kinds and provide custom widgets only where needed. To do this, return a widget for the cell kinds you want to customize, and return null for the remaining kinds so the grid falls back to its default rendering.
GridSheet(
// Example: If you want to use the default built-in widgets for filter cells,
// simply return `null` for the `filter` cell kind.
// The grid will automatically fall back to its default renderer.
cellBuilder: (cell) {
switch (cell.kind) {
case GridSheetCellKind.selectAll:
return _cellRenderer.buildSelectAll(context, cell);
case GridSheetCellKind.indexing:
return _cellRenderer.buildIndexes(context, cell);
case GridSheetCellKind.header:
return _cellRenderer.buildHeader(context, cell);
case GridSheetCellKind.filter:
return null; // fall back to its default renderer.
case GridSheetCellKind.row:
final row = cell.row!;
final column = cell.column;
return _cellRenderer.buildCell(context, column, row, cell);
}
},
)
Context Menus
Add right-click context menus:
GridSheet(
contextMenuWidget: (info) {
if (info.target == GridSheetContextMenuTarget.cell) {
final cellInfo = info as GridSheetCellContextInfo;
return Card(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
leading: Icon(Icons.copy),
title: Text('Copy'),
onTap: () {
// Copy cell value
},
),
ListTile(
leading: Icon(Icons.delete),
title: Text('Clear'),
onTap: () async {
await cellInfo.gridManager.clearCellRange(
startRow: cellInfo.row!.index,
endRow: cellInfo.row!.index,
startColumn: cellInfo.column.index,
endColumn: cellInfo.column.index,
);
},
),
],
),
);
}
return null;
},
)
Context Menu Configuration
contextMenuWidget controls the menu's content; GridSheetContextMenuConfiguration controls everything wrapped around it — elevation, corner radius, background color, and the full-screen backdrop behind it (which also dismisses the menu on tap):
GridSheet(
contextMenuWidget: (info) => MyMenu(info: info),
contextMenuConfiguration: GridSheetContextMenuConfiguration(
elevation: 8.0, // Material elevation (drop-shadow depth) of the menu popup
borderRadius: BorderRadius.circular(4), // Corner radius of the menu popup
backgroundColor: null, // null → Material's own default (theme card/canvas color)
// Set a translucent color for a dimming scrim behind the menu
backdropColor: Colors.transparent,
),
)
Pagination
GridSheet provides built-in pagination support with both default UI and custom implementation options.
Default Pagination
GridSheet(
columns: columns,
rows: rows,
configuration: GridSheetConfiguration(
enableDefaultPagination: true,
),
styleConfiguration: styleConfiguration,
),
Custom Pagination
GridSheet(
columns: columns,
rows: rows,
configuration: const GridSheetConfiguration(
// Only one of these should be enabled.
enableCustomPagination: true,
enableDefaultPagination: false,
),
styleConfiguration: styleConfiguration,
footerWidget: (manager) {
// Use the built-in pagination widget or provide your own.
return GridSheetPagination(
gridManager: manager,
paginationConfiguration: PaginationConfiguration(
backgroundColor: styleConfiguration.rowColor,
borderColor: styleConfiguration.rowBorderColor,
selectionColor: styleConfiguration.selectionColor,
textStyle: theme.textTheme.bodySmall!,
),
);
},
);
Server-Side Pagination
Fetch rows from the server on every page change. The grid keeps the existing pagination UI (page buttons, rows-per-page selector) and uses the server-reported total count to calculate the correct number of pages.
Set serverSidePagination: true alongside either enableDefaultPagination or enableCustomPagination, then provide onPageChange.
If rows is empty on mount, the grid automatically fires onPageChange for page 1.
onPageChange is called for every event that requires a server fetch:
The request carries the full current state so the server can apply sort and filter in one go:
GridSheet(
columns: columns,
rows: const [], // start empty — grid auto-fetches page 1
configuration: GridSheetConfiguration(
enableDefaultPagination: true,
serverSidePagination: true,
rowsPerPage: 50,
),
loadingWidget: const Center(child: CircularProgressIndicator()),
onPageChange: (GridSheetPageRequest req) async {
// req.page — 1-based page number
// req.rowsPerPage — rows per page
// req.offset — zero-based offset (SQL OFFSET / REST skip)
// req.sortKeys — List<GridSheetSortState> in priority order
// req.filters — Map<columnName, List<values>>
// req.filterComparisons — Map<columnName, GridSheetFilterComparison>
var query = supabase
.from('users')
.select('*', const FetchOptions(count: CountOption.exact))
.range(req.offset, req.offset + req.rowsPerPage - 1);
for (final sort in req.sortKeys) {
query = query.order(sort.columnName,
ascending: sort.direction == GridSheetSortDirection.asc);
}
for (final entry in req.filters.entries) {
final cmp = req.filterComparisons[entry.key]
?? GridSheetFilterComparison.contains;
for (final value in entry.value) {
query = cmp == GridSheetFilterComparison.exact
? query.eq(entry.key, value)
: query.ilike(entry.key, '%$value%');
}
}
final res = await query;
return GridSheetPageData(
rows: res.data!.map(rowFromMap).toList(),
totalCount: res.count ?? 0,
);
},
)
Note: Prefer
deleteRowsByKeysoverdeleteRowsAtin server-side scenarios.deleteRowsAtoperates on the positional index within the currently loaded rows, which may not correspond to server-side row numbers.deleteRowsByKeysuses stable keys and is unambiguous regardless of which page is loaded.
Infinite Scroll
Rows are appended automatically as the user scrolls toward the bottom. The pagination footer is hidden. Fetching stops once the number of loaded rows equals GridSheetPageData.totalCount.
GridSheet(
columns: columns,
rows: const [], // start empty — first batch is fetched automatically
configuration: GridSheetConfiguration(
infiniteScroll: true,
rowsPerPage: 30, // batch size per fetch
infiniteScrollThreshold: 300, // start loading 300 px before bottom (default 200)
),
loadingWidget: const Center(child: CircularProgressIndicator()),
onPageChange: (GridSheetPageRequest req) async {
// req.offset — number of already-loaded rows (reliable server OFFSET)
// req.sortKeys — active sorts (re-sent on sort change after full reset)
// req.filters — active filters (re-sent on filter change after full reset)
var query = supabase
.from('users')
.select('*', const FetchOptions(count: CountOption.exact))
.range(req.offset, req.offset + req.rowsPerPage - 1);
for (final sort in req.sortKeys) {
query = query.order(sort.columnName,
ascending: sort.direction == GridSheetSortDirection.asc);
}
final res = await query;
return GridSheetPageData(
rows: res.data!.map(rowFromMap).toList(),
totalCount: res.count ?? 0,
);
},
footerWidget: (manager) {
if (!manager.hasMoreRows) {
return const Padding(
padding: EdgeInsets.all(12),
child: Text('All rows loaded'),
);
}
return const SizedBox.shrink();
},
)
Key points:
onPageChangeis the single callback for page navigation, sort, filter, and infinite-scroll triggers — no separate API needed.manager.hasMoreRowsbecomesfalseonce all server rows are loaded.
Selection Events
GridSheet provides comprehensive selection callbacks for rows, columns, and cells. Each callback provides detailed information about the selection state and selected data.
Row Selection Event
Triggered when row selection changes (click on row index or first column). To enable the Select All Rows feature from the serial number column header, ensure the following property is set: enableRowSelectionOnFirstColumnTap: true
The header checkbox is tri-state: unchecked when no rows are selected, indeterminate (a dash) when some but not all are selected, and checked when every row is selected. Tapping it always selects every row unless all are already selected, in which case it deselects all — the indeterminate state is only ever a display of the current selection, never something you tap into directly.
GridSheet(
configuration: GridSheetConfiguration(
enableRowSelectionOnFirstColumnTap: true,
enableMultiSelection: true, // Allow selecting multiple rows
),
onRowsSelected: (event) {
// Access selection data
debugPrint('Selected ${event.selectedRowKeys.length} rows');
// Get selected row objects
for (final row in event.selectedRows) {
debugPrint('Row ${row.index}: ${row.data}');
debugPrint('Row state: ${row.state}'); // existing, inserted, modified, deleted
}
// Get selected rows as maps (column name → value)
for (final rowMap in event.selectedRowsData) {
debugPrint('Name: ${rowMap['name']}, Age: ${rowMap['age']}');
}
// Access grid manager for further operations
final manager = event.gridManager;
// Example: Delete selected rows
// await manager.deleteRowsByKeys(event.selectedRowKeys);
},
)
Column Selection Event
Triggered when the column selection changes (via interaction with the column header):
- Single click → Selects the column
- Double click → Sorts the column (if sorting is enabled)
- Long press → Reorders columns (if reordering is enabled)
GridSheet(
configuration: GridSheetConfiguration(
enableColumnSelection: true,
enableMultiSelection: true, // Allow selecting multiple columns
),
onColumnsSelected: (event) {
// Access selection data
debugPrint('Selected ${event.selectedColumnKeys.length} columns');
// Get selected column objects
for (final column in event.selectedColumns) {
debugPrint('Column: ${column.name} (${column.title})');
debugPrint('Type: ${column.type}, Width: ${column.width}');
}
// Get column names
debugPrint('Selected columns: ${event.selectedColumnNames.join(", ")}');
// Get all values from selected columns
event.selectedColumnsData.forEach((columnName, values) {
debugPrint('$columnName has ${values.length} values');
debugPrint('Values: $values');
});
// Access grid manager
final manager = event.gridManager;
// Example: Hide selected columns
// await manager.hideColumns(event.selectedColumnNames);
// Example: Auto-fit selected columns
// await manager.autoFitColumnWidths(event.selectedColumnNames);
},
)
Cell Selection Event
Triggered when cell selection changes (click on individual cells).
GridSheet(
configuration: GridSheetConfiguration(
enableCellSelection: true,
enableMultiSelection: true, // Allow selecting multiple cells
),
onCellsSelected: (event) {
// Access selection data
debugPrint('Selected ${event.selectedCells.length} cells');
// Get selected cell keys
for (final cellKey in event.selectedCells) {
debugPrint('Cell: Row=${cellKey.rowKey}, Column=${cellKey.columnKey}');
}
// Get selected cell data grouped by column
event.selectedCellsData.forEach((columnName, values) {
debugPrint('Column "$columnName": $values');
// Example: ['Active', 'Pending', 'Active']
});
// Access grid manager
final manager = event.gridManager;
// Example: Clear selected cells
// manager.clearCellsByKeys(event.selectedCells);
// Example: Fill selected cells with a value
// manager.fillCellsByKeys(event.selectedCells, 'New Value');
},
)
No Rows Custom Widget
Provide a custom widget to display when the grid has no rows. This allows you to show personalized messages or visuals instead of leaving the grid empty. If no custom widget is supplied, the grid automatically falls back to its default built-in widget.
GridSheet(
columns: columns,
rows: rows,
styleConfiguration: styleConfiguration,
onLoaded: (event) {
gridManager = event.gridManager;
},
noRowsWidget: Padding(
padding: EdgeInsets.all(4),
child: Text(
'No data to display',
style: theme.textTheme.bodySmall,
),
),
);
No Columns Custom Widget
Provide a custom widget to display when every column is hidden (via GridSheetColumn.visible: false, or hidden at runtime with GridSheetManager.hideColumns). It replaces the entire table — header, filters, and body — since there's nothing meaningful left to show in any of them; headerWidget/footerWidget stay visible regardless, since that's typically where the controls to show a column again live. The built-in serial-number column (GridSheet.indexColumn) doesn't count as a real column for this check — it can never be hidden, so a grid with every actual column hidden would otherwise render as a bare, unexplained strip of row numbers. If no custom widget is supplied, the grid automatically falls back to its default built-in widget.
GridSheet(
columns: columns,
rows: rows,
styleConfiguration: styleConfiguration,
onLoaded: (event) {
gridManager = event.gridManager;
},
noColumnsWidget: Padding(
padding: EdgeInsets.all(4),
child: Text(
'Every column is hidden',
style: theme.textTheme.bodySmall,
),
),
);
Custom Loading Widget
Provide your own custom loading widget to display while the grid is fetching or processing data, replacing the default loading indicator with a personalized UI for async operations.
Note: If loadingWidget is not provided, no loading indicator will be shown, as there is no default loading widget in the grid.
GridSheet(
columns: columns,
rows: rows,
styleConfiguration: styleConfiguration,
onLoaded: (event) {
gridManager = event.gridManager;
},
loadingWidget: loadingWidget,
);
Widget get loadingWidget {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
CircularProgressIndicator(),
SizedBox(height: 16),
Text(
'Processing...',
style: TextStyle(color: Colors.white, fontSize: 16),
),
],
),
);
}
Undo Delete
Shows a "N row(s)/column(s) deleted — UNDO" prompt, instead of deleting permanently right away. Enabled by default (enableUndoDelete: true); set it to false to restore immediate, permanent deletion.
With no undoDeleteWidget supplied, a built-in banner is shown at the bottom of the grid for 5 seconds. Provide undoDeleteWidget for a fully custom prompt (own message, styling, position, and/or timeout):
GridSheet(
columns: columns,
rows: rows,
configuration: GridSheetConfiguration(
enableUndoDelete: true,
),
onLoaded: (event) {
gridManager = event.gridManager;
},
undoDeleteWidget: (context, event) {
final count = event.isColumnDeletion
? event.deletedColumns.length
: event.deletedRows.length;
final noun = event.isColumnDeletion ? 'column' : 'row';
return Material(
color: Colors.black87,
child: ListTile(
title: Text('$count $noun${count == 1 ? '' : 's'} deleted', style: TextStyle(color: Colors.white)),
trailing: TextButton(
onPressed: event.onUndo,
child: Text('UNDO'),
),
),
);
},
);
onUndo- call to restore the deleted row(s)/column(s)onDismiss- call to finalize the deletion permanently (e.g. from your own timer or a close button)
Autofill (Cell Drag-to-Copy)
This feature provides autofill cell drag to copy functionality, allowing users to quickly populate cells by dragging a fill handle.
When enabled, a small square handle appears at the bottom-right corner of selected cells. Users can drag this handle to copy data to adjacent cells, with automatic pattern detection and formula adjustment.
GridSheet(
columns: columns,
rows: rows,
configuration: GridSheetConfiguration(
enableCellSelection: true, //Required for autofill
enableMultiSelection: true, // Required for autofill pattern detection
),
autofillConfiguration: GridSheetAutofillConfiguration(
enabled: true,
fillHandleSize: 8.0,
fillHandleColor: Colors.blue,
showPreviewOverlay: true,
enablePatternDetection: true,
enforceTypeMatching: true, // Strict type checking (horizontal)
),
);
How It Works:
- Cell Selection
- Select one or more cells that contain the source data
- A small fill handle appears at the bottom-right corner of the selection
- Drag Operation
- Click and hold the fill handle
- Drag in any direction (up, down, left, right)
- A preview overlay shows which cells will be filled
- Release to Apply
- Release the mouse button to apply the fill.
- Target cells are populated with data based on the detected pattern.
Data Operations
Change Tracking:
// Get changed data
final inserted = gridManager.insertedRows;
final modified = gridManager.modifiedRows;
final deleted = gridManager.deletedRows;
final allChanges = gridManager.changedRows;
// Persist changes
for (final row in inserted) {
await api.createRow(gridManager.rowsDataToMapList([row]).first);
}
// Undo changes
await gridManager.undoRowChanges(rowKey);
await gridManager.undoAllRowsChanges();
Bulk Operations:
// Find cells
final highSalaries = gridManager.findCells(
predicate: (value, row, column) => value is num && value > 100000,
columnNames: ['salary'],
);
// Find and replace
final count = await gridManager.findAndReplace(
findValue: 'Active',
replaceValue: 'Completed',
exactMatch: true,
);
// Fill pattern
await gridManager.fillCells(
startRowKey: ValueKey('row_1'),
endRowKey: ValueKey('row_10'),
columnKey: ValueKey('col_1'),
value: 1,
);
// fill vertical cells
final skipRows = computeRowsToSkip();
await gridManager.autofillVerticalCells(
startRowKey: ValueKey('row_1'),
endRowKey: ValueKey('row_100'),
columnKey: ValueKey('col_a'),
sourceValues: [1, 2], // increment value
enablePatternDetection: true,
skipRowKeys: skipRows, // skip cells
);
// fill horizontal cells
final skipColumns = computeColumnsToSkip();
await gridManager.autofillHorizontalCells(
startRowKey: ValueKey('row_1'),
endRowKey: ValueKey('row_5'),
startColumnKey: ValueKey('col_q1'),
endColumnKey: ValueKey('col_q4'),
sourceValues: [1, 2], // increment value
enablePatternDetection: true,
skipRowKeys: skipRows, // skip cells
skipColumnKeys: skipColumns, // skip cells
);
Auto-Sizing:
Auto-fit calculations rely on text measurements.
Ensure StyleConfiguration headerTextStyle and cellTextStyle are provided for correct results.
// Auto-fit column widths
await gridManager.autoFitColumnWidths(['firstName', 'lastName']);
await gridManager.autoFitAllColumnWidths();
// Auto-fit row heights
await gridManager.autoFitRowHeights([rowKey1, rowKey2]);
await gridManager.autoFitAllRowHeights();
Platform Support
GridSheet works in web browsers and supports all Flutter platforms:
- ✅ Android
- ✅ iOS
- ✅ Web (Chrome, Edge, Firefox, Safari)
- ✅ Windows
- ✅ macOS
- ✅ Linux
Notes
Performance Considerations
- Both rows and non-pinned columns are virtualized (windowed): only the rows and columns currently visible, plus a small overscan buffer, are actually built — scrolling in either direction stays smooth into the 100K-row / hundreds-of-column range without needing pagination purely for rendering performance.
- Pinned-left and pinned-right columns are never windowed (there are typically few of them), and always fully built for every visible row.
- Pagination (client-side, server-side, or infinite scroll) is still the right tool when the dataset itself — not just its on-screen rendering — is too large to hold in memory at once; use server-side pagination or infinite scroll for very large remote datasets so only one page of rows is fetched/held at a time.
- Limit the number of conditional format rules for better performance
- Avoid excessive column reordering in production
- The controller pool automatically manages TextEditingControllers, bounding retention to the visible row window (plus overscan) rather than the whole current page — most impactful with pagination disabled, where the "current page" is the entire dataset
- Column/row drag-resize and row-expansion animation frames update only the affected row or column's layout instead of rebuilding the whole grid's row/column extents
- Formula expressions are parsed once and cached by their text, so repeated recalculation of the same formula doesn't re-tokenize/re-parse it
Limitations
- Browser storage APIs (localStorage, sessionStorage) are not supported in artifacts mode
- Column expressions use the
expressionspackage for evaluation - Custom evaluators can be provided for advanced expression handling
- Multi-column sorting preserves order by priority (first added = highest priority)
Best Practices
- Use stable keys - Always provide unique
ValueKey<String>for rows and columns - Leverage callbacks - Use
onCellValueChangedto track changes in real-time - Manage state externally - Use
GridSheetManagerfrom callbacks to update external state - Optimize rendering - Use
noTextControllerWidget: truefor read-only columns or when using custom widgets that do not require aTextEditingController, preventing unnecessary controller creation and improving performance.showOverflowTooltipdefaults tofalse(no per-cell overflow measurement); opt it in withshowOverflowTooltip: trueonly on columns where truncated text is likely (e.g. free-formtextcolumns) and the hover tooltip is worth the cost. - Handle loading states - Provide a
loadingWidgetfor better UX during async operations
Contributions
If GridSheet has been helpful, please consider giving our Pub package a 👍. You can also support the project by spreading the word and sharing it with others.
We truly appreciate your support and contributions to the ongoing development of this project. We welcome discussions, feedback, and pull requests—feel free to share your ideas or suggestions on our GitHub repository. Issues Tracker
License
This project is licensed under the MIT License - see the LICENSE file for details.