flutter_table_plus 2.17.0
flutter_table_plus: ^2.17.0 copied to clipboard
A highly customizable and efficient table widget for Flutter, featuring synchronized scrolling, theming, sorting, selection, column reordering, hover buttons, and expandable rows.
2.17.0 #
-
FIX: a drag held while the rows change height now reports the row actually under the pointer, instead of the range it had before the rebuild (#133)
- What a consumer sees differently. Change row height while a drag-select is in progress — a density toggle, a font-size slider, a new
calculateRowHeight, a newtheme.bodyTheme.rowHeight— andonDragSelectionUpdate/onDragSelectionEndnow end the range at the row that is under the pointer at its new height. They previously answered against the heights the rows had before the rebuild, and a pointer that was not moving never got a correction - The auto-scroll arm is the one that never recovered. Shrinking the rows shrinks
maxScrollExtent, the offset is clamped onto it, and the next tick's scroll returnsfalse— which stops the timer on exactly the tick whose successful scroll would have re-resolved the pointer. Measured over 30 rows going 80px to 40px mid-drag: the pointer sat on row 29 and the callback said row 15, and it stayed there for as long as the drag was held. Growing the rows self-corrected on a later tick, so there only the window between the rebuild and that tick was wrong — which is why both arms are pinned rather than the one that fails loudest - The anchor was already correct, and the issue was written assuming it was not. #133 proposed re-pointing the anchor at its row id so it would survive a height change. Measured before anything was written:
classifyRowCacheInvalidationanswersmeasurementOnlythere, that branch keepsRowLookupand the renderable-index list, and the rebuiltRowGeometrytherefore carries the sameidswith newheights— so index to id never moved and the anchor still denotes the row it was taken on. Nothing touches it. The rule is now stated instead: the anchor holds the row you pressed, the far endpoint tracks the row under the pointer, and a rebuild is one of the things that changes which row that is - Replacing
datamid-gesture is still undefined, and now says so out loud. The anchor is a position in the list the drag began on, so a different list re-points it — measured with a reversed six-row list, where an anchor taken on'2'reported from'3'. Documented ononDragSelectionUpdateand recorded in the MAP as a known hole rather than quietly repaired, because what "the row you pressed" means when that row may no longer exist has three defensible answers and no one has needed one - It costs nothing when no drag is in flight. The re-resolve is scheduled only while
DragSelectionController.isDragging, and post-frame rather than inline — the body clears itsRowGeometrylater in the same build and the scroll offset is not clamped onto the new extent until layout, so an inline call would read the snapshot being invalidated at the old offset
- What a consumer sees differently. Change row height while a drag-select is in progress — a density toggle, a font-size slider, a new
-
FIX: a merged cell's
spanningRowIndexno longer throws out of a widget build, and no longer blanks the cell in silence (#173)- What a consumer sees differently, two things. An index past the end of
rowKeysthrewRangeErrorfrom inside_buildMergedCell— an exception out of a widget build, which is a red screen in release rather than a caught error; it now clamps to the last member. An index naming a rowdatadoes not hold resolved to nothing and rendered an empty merged cell, so that row's value left the screen with no exception and no banner; the resolution now walks forward throughrowKeys, wrapping once, to the first member that is present - Each says so once in debug, naming the group, the column and what it did. Inside an
assert, so a release build carries neither the check nor the message, and printing rather than throwing for the reason #161 established: this package's own documentation taught the form that trips it - The prior question was what the index means, and it is now settled as a contract rather than repaired as a defect.
spanningRowIndexis a position inrowKeys— the list you wrote — and not a position among the rows asdataorders them. Rendered side by side to decide it:data: ['a','b']withrowKeys: ['b','a']andspanningRowIndex: 1shows'a', and always did. Resolving among the rendered rows instead would move a merged cell's content on every sort, from a configuration the caller never touched — and position is not what the index is for here, since a merged cell is drawn once across the whole group and only whose value is in question - So the rule the rest of this territory holds gains a stated exception rather than a third instance.
rowKeysis still a set of claims rather than positions for every question about how far a group reaches — anchor, tail, hover (#135, #151) — because those decide geometry. This one decides content. The presence walk above is a fallback consulted only when the named position holds nothing, so it cannot move the answer where the named row exists - Non-breaking.
getSpanningRowKey(columnKey)keeps its signature and gains the clamp; the presence walk needs the data list, so it lands in a newresolveSpanningRowKey(columnKey, allData, rowId)beside the existinggetRowData(allData, rowKey, rowId). A test that pinnedthrowsRangeErroris retired with the behaviour it pinned
- What a consumer sees differently, two things. An index past the end of
-
DOCS:
TablePlusDragSelectionThemeis documented, and two counts that said it was stop being counts- A public sub-theme had no page.
dragSelectionThemesits onTablePlusTheme, carries five fields, has its ownscaledBy, and appeared nowhere indocs/THEMING.mdordocs/FEATURES.md— whileREADME.mdadvertised "a configurable rubber band rectangle" and pointed at the guide that did not configure it. It now has a section, and a row in thescaledBytable saying thatborderWidthis the one thing that scales - Both README claims were counts, and both were wrong in the direction a count goes. "8 nested theme classes" and "all 8 theme classes" — the guide documented seven of the root's eight, and the package publishes eleven theme classes in total. Neither number is written any more, for the reason the repository keeps meeting: nothing reads a count in a document, so it is a second answer with nowhere to be checked
- A public sub-theme had no page.
-
FEAT: six values the table painted are now theme fields, including the column divider's width and the editor's error colour (#171)
- What a consumer sees differently. Nothing at the default theme — every new field defaults to exactly what the widget drew before. What changes is that six things can now be named:
bodyTheme.verticalDividerColorandverticalDividerThickness(the line between two columns, previously a hardcoded 0.5px at alpha 0.5),bodyTheme.memberDividerColor(alpha 0.3),bodyTheme.emptyStateTextStyle("No data available"),bodyTheme.mergedRowCountTextStyle(a group's "N rows" caption), and oneditableThemethe four halves of the borders that were not settable:errorBorderColor,focusedErrorBorderColor,errorBorderWidthandenabledBorderWidth Colors.redwas unreachable, and it was the sharpest of the six. The word error did not appear anywhere inlib/src/models/theme/, so an app whose palette has no red still got Material red on a validation failure, andTablePlusTheme.scaledBycould not touch it either- The default is the derivation, not the colour it produces. Each field is
field ?? <what it drew before>— the shapesummaryRowBackgroundColorandenabledBorderColoralready had in these same two classes — so a caller who movesdividerColorkeeps the hierarchy between the three lines this package draws (a row boundary at full alpha, a column rule at 0.5, a member separator at 0.3) without restating any of it - #153 fixed four instances of this and recorded that only widths were affected. Measured false in both directions: two more widths did not follow the theme, and the alphas do not "follow" it either — a literal multiplier applied to a theme colour is not the same thing as a theme value
- Two border widths now scale that did not.
editableTheme.scaledByalready doublededitingBorderWidth; the resting and error borders stayed at a hairline because their width was a literal in the widget. Both are resolved before scaling now, so a scaled table's editor borders keep their proportions - Auto-fit was measuring against a divider width it had stopped knowing.
_handleColumnAutoFitadded the cell's right border back as the literal0.5— correct only while that border was hardcoded to match. It now readsverticalDividerSide.width. Measured atverticalDividerThickness4, 8 and 12, the old code reported 461.0px for all three, the same width it reports with the divider off, so a thickened column rule took its width out of the glyphs; it now reports 465 / 469 / 473 dividerThicknessstill does not reach the column divider, deliberately, and there is now an assertion saying so. Defaulting it there would change what every existing caller renders — and it is the wrong half of the seam to move blind, because the header's half of the same line readsheaderTheme.verticalDivider.thicknessand defaults to 1.0. The new field is what makes that choice possible to make and to undo
- What a consumer sees differently. Nothing at the default theme — every new field defaults to exactly what the widget drew before. What changes is that six things can now be named:
-
FIX: a merged group's last row and its hover button are resolved from the members
dataactually holds, not from where the keys are written (#151)- What a consumer sees differently. A group whose
rowKeysare not indataorder reported the wrong last row:data: ['a','b']withrowKeys: ['b','a']is the table's last render row, said it was not, and drew a bottom border the defaultLastRowBorderBehavior.neverforbids. A group whoserowKeysname a rowdatano longer holds did the same, because the index lookup returns null andnull == data.length - 1is false. And such a group lost its hover button entirely — the builder was handed the first key, resolved nothing, and was never called - #135 fixed one end of a positional read and left the other. It replaced the group's
rowKeys.firstanchor with_mergedGroupAnchor— the earliest member actually present — and therowKeys.lastread at the far end kept its shape for three months._mergedGroupTailis now its mirror, andhoverDataresolves the first member present rather than the first key written - The rule in
docs/map/territory/merged-rows.mdwas itself a hand-list and has been restated. It read "a group is anchored, measured and drawn on the members that are actually there", which names three consumers and was silent about the three this change found. It now says what makes a consumer covered without naming any:rowKeysis a set of claims, not a set of positions - One site is deliberately not in this change.
MergedRowGroup.getSpanningRowKeyindexesrowKeysbyspanningRowIndex, which is public API and may be a contract rather than a defect; it lives inmodels/, where no row lookup reaches. Filed separately
- What a consumer sees differently. A group whose
-
FIX: a merged group's separators are decided at member level instead of row level, so the last group in a table keeps its internal lines and no group draws its bottom edge twice (#157)
- What a consumer sees differently, three things, all at the default theme. A group that is the last row in the table drew no separators between its members and rendered as one undivided block; it now separates them. Every other group drew two lines at its bottom edge — the last member's separator at
dividerColoralpha 0.3 with the group's own border at full alpha immediately below — and now draws one. An expanded group's summary boundary painted the last member's themed line plus a hardcoded 0.5px line on top of it, so atdividerThickness: 4that one boundary drew 4px + 0.5px where every other member boundary drew 4px; the hardcoded line is gone - One substitution, two opposite symptoms. A member's separator was gated on
shouldShowBottomBorderanswered for the group — is it the table's last row — and applied to a member-level line. With the group not last the predicate returned true for every member including the last; with the group last,LastRowBorderBehavior.neverreturned false for every member. Neither symptom is reachable by fixing the other - The rule that replaced it: every boundary has exactly one owner. A group's inner boundaries belong to its members — a cell draws beneath itself only when another cell follows it inside the group — and its outer boundary belongs to the group's own row decoration, the same box that draws a plain row's. A group now renders the way a run of plain rows does, and
lastRowBorderBehaviorgoverns its outer edge exactly as it governs a plain last row's - It also closed a residual #121 had recorded as unavoidable. The last member's centred content sat half a
dividerThicknessabove its ungrouped twin, and the reason on record was that "the border has to come from somewhere and this is the one cell adjacent to it". Measured: the border comes from the group's decoration and the member was drawing a second one, so with the doubled edge gone the offset is 0.0 and grouped matches ungrouped exactly, last member included - A test that was shaped around the defect is retired with it.
merged_row_cell_parity_test.dartparked a fourth row after the group so its divider assertion could measure anything at all, with a comment saying why. The group now covers every row and the assertion still measures the width it names
- What a consumer sees differently, three things, all at the default theme. A group that is the last row in the table drew no separators between its members and rendered as one undivided block; it now separates them. Every other group drew two lines at its bottom edge — the last member's separator at
-
PERF: the two widgets that cache row-derived state now answer "what did this update invalidate?" through one function instead of two copies of the rule (#169)
- What a consumer sees differently. Nothing, at any row count they are likely to have. A
scaleortheme.rowHeightchange used to rebuildFlutterTablePlus'sRowLookup— theidslist plus two O(rows) maps — for answers about which row is where, which no measurement input can move. Measured: skipping it saves 50µs at 1,000 rows, 718µs at 10,000, 5.1ms at 50,000. Against what a zoom step actually costs, that is 1% at a thousand rows and 15% at ten thousand; the widget rebuild and layout dominate everything below that - The finding is the asymmetry, not the microseconds.
TablePlusBodyStatehad split its update into structural and measurement-only branches and reasoned, in a comment, that identity answers survive a measurement change.FlutterTablePlusStateheld the same rule and kept one branch. That is visible inside #120's own issue body, which quotes both files side by side, and it survived #120, #128, #132 and #135 — every one of which was two places holding one rule and drifting rowMeasurementChangedwas half a repair. It unified which inputs count as a measurement change; what each caller then dropped stayed copied, and drifted.classifyRowCacheInvalidationreturnsstructural/measurementOnly/noneand both widgets switch on it, each dropping only its own cachesstructuraldominates, and that costs something. A build can move the snapshot and the measurement at once, so the id walk is consulted before a measurement-only answer can be returned — where the parent's old||chain short-circuited it away. Measured rather than assumed:RowLookup.idsMatchis 13–15% ofRowLookup.buildat 1k/10k/50k, so trading the walk for the rebuild keeps 85% of it. Skipping the walk would report a list sorted in place as measurement-only, which is a wrong answer rather than a cheap one
- What a consumer sees differently. Nothing, at any row count they are likely to have. A
-
FIX: a
headerTheme.decorationcarrying a border no longer slides the whole header against the body (#160)- What a consumer sees differently. Set a border on
TablePlusHeaderTheme.decorationand every header column used to start further right than its body column by the left border's width — measured at 2.0px forBorder.all(width: 2), at rest and at full scroll, with no exception and no overflow banner. The header is oneContaineraround the whole row of header cells and the body has no equivalent box, so the border was folded into the header's child inset and nowhere else - The fix is one widget.
SizedBox+DecoratedBoxin place ofContainer(decoration:).DecoratedBoxis aRenderProxyBox: it paints the decoration and lays the child out at full size, applying no inset. Same painted result, same paint order, and the child keeps the width the columns were measured against - Growing the box by the inset instead does not work, and was measured before being discarded: the defect is the child's origin, not its width, so a wider box leaves the desync exactly where it was and adds a
RenderFlexoverflow of the full horizontal inset on top - One visible change on the default theme, and it is sub-pixel. The header's own top and bottom borders were insetting the content too, so the label was centred in
height - borders; it is now centred inheight. Measured: the default header label's centre moves fromy = 27.5toy = 28.0. Nothing else moves
- What a consumer sees differently. Set a border on
-
FEAT:
TableRowHeightCalculatorcan now measure what the glyphs actually get. Three inputs were invisible to it, all in the same direction, and a row sized from them is one the text does not fit inside- What a consumer sees differently. Nothing, until they pass the new arguments — the defaults preserve today's numbers exactly. Measured on the default theme with a
TextOverflow.visiblecolumn: a style naming onlyfontSizepredicts a 100px row for a paragraph the screen lays out at 120px, so 20px of text is clipped with no overflow banner; at an OS text scale of 1.25 the same prediction stands against a real 225px - New:
contextoncreateHeightCalculator. Given one, it merges the ambientDefaultTextStyleunder your style — the font family,letterSpacingandheighta theme style typically does not name — and readsMediaQuery.textScalerOf. These are exactly whatTextresolves, and a bareTextPaintersees neither - New:
textScaleroncalculateTextHeightandcalculateRowHeight, defaulting toTextScaler.noScaling— the same shapeTableColumnWidthCalculatoralready takes. AndextraWidthoncalculateRowHeight, for what the cell's own decoration takes off the declared width, the same shape as that calculator'sbodyExtraWidth - A performance defect the docs were teaching, now announced rather than silent.
createHeightCalculatorwas shown inline in the widget's constructor, and the height caches — plus the row geometry every drag hit-test is answered from — drop whenever the callback is not==to the previous one. Measured: two calls with identical arguments return callbacks that compare!=, so the inline form re-measures every row on every frame — 62ms per rebuild at a thousand rows with a text-measuring height function, against a 16.7ms frame. Hold it in a field. (#161)- In debug, the table now says so. One
debugPrintper table, after several consecutive builds on which the callback changed identity and the data, columns, scale and row height did not. It sits inside anassert, so a release build carries neither the check nor the message, and it prints rather than throws — this package's own documentation taught the inline form until this release, so a crash would punish callers for following it. Thecolumnsidentity term is load-bearing rather than decorative: a caller who passes new columns down afteronColumnResizedrebuilds their callback legitimately on every frame of a resize drag, which outruns any threshold - Giving the parameter a value type was measured and refused. Dart compares two tear-offs of the same method by whether their receivers are
identical, never by whether they are==. And a value object does not rescue it either: probed with non-constinstances, one compares equal only when the caller already holds theircolumnsstable — the configuration contains aList<TablePlusColumn>whosevalueAccessoris a function, and a hand-written==on that class still fails for an inline accessor. That is the same discipline as holding the callback stable, so the breaking signature change buys nothing already unreachable.const-ness alone flips the comparison, and the analyzer pushes callers towardconst— the fast path would have existed by lint accident
- In debug, the table now says so. One
- What a consumer sees differently. Nothing, until they pass the new arguments — the defaults preserve today's numbers exactly. Measured on the default theme with a
-
FIX:
TooltipBehavior.onlyTextOverflowno longer withholds the tooltip on exactly the text it is meant to reveal. The overflow measurement was laying the text out against a width and a font the glyphs never get (#156)- What a consumer sees differently. A cell whose value just fills its column showed no tooltip while the value was visibly cut. Measured on the default theme: a column declaring 120px hands its text 87.5px while the detector was told 88.0 — the vertical divider's 0.5px border is folded into the child's inset by
Container, and nothing subtracted it. Separately, the detector measured with the theme's ownTextStyle, which names a size and a colour and nothing else, while the painted glyphs carry the ambient Material family,letterSpacingandheight— about 9.9px of under-measurement on a 33-character value, roughly twenty times the divider term. AndMediaQuery.textScalerwas never read at all, so at an OS text scale of 1.25 the detector under-measured by a further 25% - No single one of those repairs the case, which is why they land together. Measured against one reproduction: with the divider term alone the detector still reports "fits"; with the style merge alone it still reports "fits"; only both together report "overflows". The ticket had proposed two fixes and left "together or in sequence" open — the number closed it
- The auto-fit path had been getting all of this right for releases.
_handleColumnAutoFitin the same widget already mergesDefaultTextStyle, already readsMediaQuery.textScalerOf, and already subtracts the divider, with a comment naming each. Two expressions computed how much room a cell's text has, from the same theme, and disagreed in three inputs. This is one of them catching up, not four independent defects - The cache now keys on the measurement rather than on
(text, width). That pair is a hand-written subset of what the answer depends on, so a row becoming selected — or a user changing their system text size — left the previous verdict in place for as long as the cell stayed on screen. It now keys on theTextMeasurementthe layout consumes, so the memo and the measurement cannot list different inputs textDirectionis resolved fromDirectionalitytoo, where it was hardcodedltr. No failure was ever measured under it — only bidirectional strings could break differently — but it is an input the glyphs get, and the point of keying on the measurement is that the set is not curated by which inputs have embarrassed us so far- A sorted header column was measured with the wrong style on every build. The header measured
theme.textStyleand paintedsortedColumnTextStyle, with no cache and no staleness needed to reach it - The merged row's spanning cell is fixed too. #155 routed group members through the ordinary cell, so they inherit this fix; the spanning cell kept its own copy of the measurement and needed its own
- Two things were deliberately left.
willTextOverflow'stextWidth > maxWidthdisjunct is unreachable — the painter's reported width is clamped to the layout width, so the verdict rests ondidExceedMaxLinesalone — andTableRowHeightCalculator.calculateTextHeight, a public export, is a third measurement site with a fourth input set. Both were raised and neither is in this change - A worry the probe killed. The detector fixes
maxLines: 1while the cell'sTextpasses none, and two adversarial passes independently reasoned that an ellipsis column therefore wraps to a second line rather than ellipsizing. It does not: measured on the default theme, a value needing 470px in an 87.5px cell renders as exactly one 20.0px line
- What a consumer sees differently. A cell whose value just fills its column showed no tooltip while the value was visibly cut. Measured on the default theme: a column declaring 120px hands its text 87.5px while the detector was told 88.0 — the vertical divider's 0.5px border is folded into the child's inset by
-
FIX: a merged group's member cells are now drawn by the same cell widget a plain row uses, instead of by a second copy of it that had drifted one decision at a time (#155)
- What a consumer sees differently. A member's vertical divider was hand-built at width
1while the theme's is0.5— twice as thick, at the default theme, in every grouped table. Its separator was hardcoded at1while every other row divider readsdividerThickness, so raising that value thickened every line on screen except the ones inside a group. OnTooltipBehavior.onlyTextOverflowa member measured its text against the group's tallest-member height in a parameter namedmaxWidth— a 200px column over a 48px group measured against ~16px, so nearly every value claimed overflow and drew a tooltip it should not have. A widget tooltip anchored to the bareTextrather than to the cell, leaving most of the cell unhoverable. And the overflow measurement was uncached, alone among the cells - The divider now stays while a cell is being edited, for plain rows too. The cell did
if (!isCellEditing) border = ..., so the column line vanished under an open editor and returned on commit. This is a deliberate reversal, and the references agree with it:pluto_gridbranches its cell decoration on which cell is current and never on whether it is editing, letting the editor draw no border of its own so the container's survives; Flutter'sDataTableputs no decoration on a cell at all and never has to choose. If you relied on the gap, the editable theme's own border is where to put it back - New:
TablePlusBodyTheme.memberDividerSide. The separator between two members of one merged group, atdividerColoralpha 0.3 anddividerThickness. Distinct from the group's own outer border, which the row decoration draws at fulldividerColor— a member is not a row, so no row decoration can reach between two of them. Additive, and not scaled byscaledBy, exactly asdividerThicknessis not - And a merged group no longer draws a selection cell the rest of the table does not have. It gated on
isSelectablewhere a plain row gates on the column, and the column is injected only whenshowCheckboxColumnis also true — a documented setting whose whole point is selecting by tapping the row. The phantom cell took its width from the first data column, so the group was displaced by a column rather than by a checkbox: measured at one 200px column in a 600px viewport, a plain row's text atx = 16and the group's atx = 616, off the viewport, the group blank - A fifth inline copy of
editKeyActionwent with it. The extraction that folded four editing-cell key handlers into one classifier missed the merged row's stacked branch, and the count in its own doc-comment said four for as long as the fifth survived - The parity test is the finding, not the fix. Asserting grouped-against-ungrouped is worthless once both paths are the same widget: breaking that widget breaks both sides and the comparison still holds. Measured — replacing the cell's
theme.verticalDividerSidewith a hardcodedBorderSide(width: 1)left every assertion green. The discriminating assertions read the theme, and the fixture runs atdividerThickness: 4.0because at the default1.0the old literal and the theme value are the same number and every divider assertion is unfailable. Flipping the existingmerged_row_member_heights_testfrom1.0to4.0turned it from blind to a positional witness: it fails by exactly(t − 1) / 2without this change - Four defects in the same file were deliberately left, because they are about what the merged row assembles rather than what a cell draws: a member's separator is gated on the group's
isLastRow, so at the defaultLastRowBorderBehavior.neverthe last group in a table loses all of its internal separators; a merged row builds a selection cell wheneverisSelectableeven whenshowCheckboxColumnis off; the summary cell'stopborder is hardcoded and ungated; andgetSpanningRowKeyindexesrowKeyspositionally. Raised rather than repaired quietly under a ticket sized for the cell - And two the ordinary cell already had, which every member therefore inherited (#156): its overflow width subtracted padding but not the 0.5px divider inset, and the detector ignored
MediaQuery.textScalerwhile the auto-fit width path already took one. Both are fixed in this same release — see the #156 entry above, which also names two more that neither ticket had found. Reusing the cell is what made one fix reach every member
- What a consumer sees differently. A member's vertical divider was hand-built at width
-
FIX: a merged group now draws each member at the height you returned for it, instead of at an equal share of the group. The group's total was always the sum of its members' measured heights and still is — only the distribution inside it was wrong (#121)
- What a consumer sees differently. A group over rows measuring 48 / 96 / 48 was 192px tall and drew three 64px members: the tall one clipped, the short ones padded, every member misaligned against the same row rendered outside a group. It now draws 48 / 96 / 48. Nothing moves for a caller who passes no
calculateRowHeight, or whose group's members all measure the same and is not expanded - Every member's height was already computed, passed into the widget, and then discarded. The cells were laid out in an
Expanded, which isFlexible(fit: FlexFit.tight), and a tight flex child is forced to the extent the flex division allocated — soflex: 1on every member divided the total evenly no matter what had been measured - The last cell in a column is left flexible and the rest get fixed extents, which is a measured choice rather than an obvious one. The group's
Containercarries the row's bottom border in itsdecoration, and aBoxDecorationborder consumes the child's space, so theColumnreceives the group's height minusdividerThickness. Fixed extents for every cell overflow by exactly that. Distributing the shortfall proportionally instead was written first, and it moves every member off the position its ungrouped twin occupies, by an amount that grows withdividerThickness— atdividerThickness: 4the third member of that group lands 2.0px high, silently. The border sits at the bottom, so the cell against it absorbs it and the others land exactly - An expanded group's summary row stays at
bodyTheme.rowHeightwhile its members keep their own measurements. Before this, all of them — summary included — were equal shares, so an expanded group changes even when its members all measure the same TablePlusMergedRow.individualHeightswas removed, replaced by a map keyed by row key. Internal: the widget is not exported from the barrel, so no caller can name either. The tallest-member value that path read was derived from that map rather than carried beside it — two representations of one fact is the shapedocs/map/invariant/no-hand-enumeration.mdexists for, and this change had briefly added a third
- What a consumer sees differently. A group over rows measuring 48 / 96 / 48 was 192px tall and drew three 64px members: the tall one clipped, the short ones padded, every member misaligned against the same row rendered outside a group. It now draws 48 / 96 / 48. Nothing moves for a caller who passes no
-
FIX: a merged group whose
rowKeysname a rowdatadoes not hold no longer takes rows off the screen, and no longer reserves height for the member that is missing. Four derivations answer "which rows render, and how tall is a group", and two of them disagreed with the other two — both in the body, both against the parent (#135)- What a consumer sees differently. Hand the table a
datalist that no longer holds a row some group still names — the supported path, a new list — and the group's surviving members are drawn instead of vanishing with it. Measured 2026-08-31: a group over['0','1']against adatalist holding only'1'drew neither row; it now drawsr1. A group missing a member is also one row shorter than it was, so rows below it move up by one row height each and a table that was scrolling by exactly that much stops rowKeysorder stopped mattering too. The anchor wasindexOf(rowKeys.first), so a group whose first key was not its earliest row indatarendered nothing while its members were marked handled — they were drawn by neither branch. A test named "preserves the out-of-order-rowKeys quirk (group skipped)" pinned that as correct; it is rewritten rather than deleted, because the case is still a case- The two that were right were left alone, and that is how the other two were found.
computeTableMetricshas always anchored a group at the earliest member actually present, andFlutterTablePlusState._getMergedRowHeighthas always skipped a key it cannot resolve. So the parent counted a group the body drew nothing for, and decided whether a scrollbar was needed from a total the body was not laying out. There was no product call to make here — only a question of which two of four were correct - And
RowLookup.idsMatchlanded with it, in that order. The invalidation guard #132 costed and deferred compares the snapshot's ids against what the currentrowIdproduces, in bothdidUpdateWidgets. It catches a swappedrowIdover an unchanged list, a list sorted in place, and a list shrunk in place — which used to throwRangeErroron a merged-group table. It does not catch an element replaced under the same id: the ids are identical and the index-keyed height cache keeps the pre-edit measurement - The order is the finding, not the guard. Switched on first it would have made things worse: 412 tests green and the
RangeErrorgone, but its rebuild ran the broken derivation, so a loud crash became a silently missing row — and which of the two a caller got depended on which member they removed. #132 recorded the reason as "unaffordable", corrected it to "ordered behind something else", and only the second reason had an expiry date - The rule had seven copies, not four. The issue was written against the four the pure functions and the state methods hold; the adversarial pass found three more inside the widget —
_buildRowWidget's own anchor, its per-member height loop, and the stacked-cell loop that drew an empty cell for a memberdatadoes not hold. The four that were testable in isolation were the four that got found, which is worth stating as a property of hand-lists rather than of this one. The anchor now has a single home,_mergedGroupAnchor - Two of those three have no test, on purpose.
_buildRowWidgetnow passes the extent the list actually allocated instead of letting the widget fall back totheme.rowHeight * effectiveRowCount, and its height loop skips an absent key. Measured under mutation, both change only the height the merged row's innerSizedBoxasks for — and the list hands that row a tight constraint, so the box, the texts and every position are identical either way. They are still right, because a widget asking for a height it will not be given is a disagreement waiting for the constraint to loosen; but the only assertion available reaches into the implementation, which this project's own rules forbid. The reason is recorded beside the tests instead- Half of that stopped being true inside this same unreleased version. #121 gave the per-member height loop's output a job: it now sets each member's fixed extent, so restoring an absent key to it moves a surviving member on screen, and
test/merged_row_member_heights_test.dartobserves exactly that. ThecalculatedHeighthalf is unchanged and the sentence above still holds for it. Left in place with this correction rather than rewritten — the reasoning was right when it was written, and what it was right about is the part worth keeping
- Half of that stopped being true inside this same unreleased version. #121 gave the per-member height loop's output a job: it now sets each member's fixed extent, so restoring an absent key to it moves a surviving member on screen, and
- Passing a new list is still the cheaper signal and still the documented one. In-place mutation being caught is a safety net, not a second supported route, and
FlutterTablePlus.data/.rowId/.mergedGroupsnow say exactly that
- What a consumer sees differently. Hand the table a
-
BREAKING: removed
onMergedRowExpandToggleandMergedRowGroup.isExpandable. Both were dead surface — the callback was declared onFlutterTablePlus, threaded throughTablePlusBodyintoTablePlusMergedRow, stored, and never invoked by anything;isExpandablewas an extra&&in front ofisExpandedand gated nothing on its own. Deleting both, plus all five ofisExpandable's consumption sites, left the suite at 405 passing tests with no assertion touched- Migration is deleting the arguments. No behaviour changes, because neither did anything: a callback that never fired cannot have been load-bearing, and
isExpandable: truewas the only value that ever meant anything.isExpandedandsummaryBuilderare untouched and keep working exactly as before - The reason this needed removing rather than documenting:
docs/FEATURES.mdshowed the callback in asetStateexample, in the same code block asonMergedCellChanged, which is wired — so a reader had no way to tell them apart.docs/map/territory/merged-rows.mdcalled expansion "reported throughonMergedRowExpandToggle", andMergedRowGroup's own doc-comment promised thatisExpandablewould show an expand/collapse icon the package has never drawn. Three surfaces describing a working feature that did not exist - Deprecating first was considered and rejected. A deprecation window exists to migrate working code, and there was none to migrate; what it would actually have bought is one more release in which the API still accepts an argument it ignores. The false documentation is the harm, and it is fixed either way
- The expand control was always the caller's to draw, and now the docs say so: put an
IconButtonin the merged cell'smergedContentand wire it to your ownsetState. AMergedRowGroupis an immutable value you rebuild, so the state has to live where the data does.example/lib/recipes/merged_rows_recipe.dartis a complete working version and already did this before the removal - Everything below in this section was written while the release was numbered 2.16.2. The version moved because this entry landed, not because anything below it changed
- Migration is deleting the arguments. No behaviour changes, because neither did anything: a callback that never fired cannot have been load-bearing, and
-
FIX: dragging a column boundary now honours
minWidth/maxWidthat anyscale. The handle accumulated the drag in rendered pixels and clamped it against bounds a caller declares in logical ones — two coordinate spaces compared as one, so away fromscale: 1.0a column's declared range was wrong by exactly the factor. Measured 2026-08-26: a column declaringminWidth: 80, maxWidth: 300atscale: 2.0reported 40 and 150, and since it already sat past that halved ceiling at rest, touching the handle at all snapped it there before the pointer had moved anywhereonColumnResizedtherefore reports different numbers than 2.16.1 did for an app running at a scale other than 1.0. The old numbers were the defect:minWidth/maxWidthare documented as logical and were being enforced as rendered. Nothing in the public API changed, and atscale: 1.0— where the two spaces coincide — no behaviour moves at all- The bounds are now converted where the handle is built, the same way
_handleColumnAutoFithas always converted them for the double-tap path. One rule, and both paths into the sharedclampnow reach it - Affected range: 2.9.0 – 2.16.1, at any
scaleother than 1.0. The defect entered withscalein 2.9.0; releases before that had no factor for the two spaces to disagree by - Why no test caught it: all three drag-to-resize tests ran at
scale: 1.0, where logical and rendered pixels are the same numbers — so no assertion could have separated the two implementations. The suite now drags at 2.0 as well, including a column that declares nomaxWidth, which is a separate branch of the conversion that nothing else reaches - Three neighbouring paths were unobserved for the same reason and are guarded now, without changing their behaviour: auto-fit's measurement branch (which converts) and its
autoFitColumnWidthoverride branch (which correctly does not), and the rendered position of the boundary itself — deleting the unscale on the live resize path used to leave the whole suite green while the column ran away from the pointer at 2.5x - Not fixed, and now recorded in
docs/map/territory/column-resize.md: changing the zoom during a held drag still reports a width in the pre-change space, andmaxWidth * scale / scaleis not exact at every double, so the reported number can sit ~1e-14 outside the declared bound at a scale accumulated by repeated wheel steps. Layout is unaffected in both cases
-
FIX: a table at a
scaleother than 1.0 no longer resets five of the caller's checkbox style fields to their defaults.TablePlusCheckboxTheme.scaledByrebuiltCheckboxStyleby listing its fields, and the list had fallen behind the type — which belongs toflutter_checkboxand grows when that package ships, with no commit here to point at- Measured 2026-08-26 at
scale: 2.0against the pinnedflutter_checkbox 0.3.1:checkScale0.42 → 1.0,hoverColor/focusColor/splashColor→ null,disabledOpacity0.17 → 0.4. So a zoomed table lost its checkbox hover, focus and splash colours, its check scale and its disabled opacity — in every selection checkbox it draws: row, header select-all and merged-row - Affected: 2.16.0 (retracted) and 2.16.1 — one available release. The list was complete when it was written:
flutter_checkbox 0.2.0and 0.2.1 both declared exactly the 17 fields it named, so 2.10.0 through 2.15.3 are correct. 0.3.0 added all five at once, and 2.16.0 took^0.2.1→^0.3.0.shadowsbecomes a sixth on 0.3.2, which^0.3.1already admits, so an app that ranpub getafter 0.3.2 shipped was losing six - 2.16.0's own entry walked past it. It called 0.3.0 "purely additive over the surface this package uses" and, in the same sentence, named both
CheckboxStyle.copyWithandCheckboxStyle.checkScale— the fix and the defect. The claim was true of the upstream API and false of this package's behaviour, because an additive change to a type you hand-list is a subtractive change to your copy of it. That is the whole reason the fix iscopyWithrather than a longer list - Narrower than it sounds, and worth stating plainly. Three things had to hold together: a scale other than 1.0, a selectable table (
isSelectabledefaults tofalse), and a caller who had explicitly set one of the five. The hand-list reset each dropped field to its own default, so a caller who never named them got a byte-identical style — andTablePlusCheckboxTheme.colored()cannot reach any of the five, so it took a hand-builtCheckboxStyle. A real defect, not one reachable from the defaults - Both levels now use
copyWithand name only what they change.TablePlusHeaderTheme.scaledBywas re-assemblingTablePlusResizeHandleThemethe same way; it happened to list all five fields, so nothing was lost there — that one is a structural fix with no behaviour change - This is #50 one level down. #50 rebuilt the root
TablePlusTheme.scaledByoncopyWithfor exactly this reason and left the sub-themes alone - Guarded at both levels, at a factor of 2.0 because
scaledBy(1.0)returns its receiver and proves nothing. The outerTablePlusCheckboxThemeis guarded too: a first attempt at this fix left its five layout flags at their defaults in the fixture, and re-assembling that class droppedshowRowCheckbox,cellTapTogglesCheckboxand three more with the whole suite still green - And a tripwire for the next upstream release. Neither type implements
==, so no value assertion can fail when a field is added.test/checkbox_style_field_set_test.dartreads the resolvedflutter_checkboxsource instead and pins its field set: green at 0.3.1, and against 0.3.2 it fails namingshadows. It asserts nothing about behaviour — it says go read the new field and decide whether the factor applies to it, which is the halfcopyWithcannot answer: an added dimensional field is now carried faithfully and never scaled
- Measured 2026-08-26 at
-
EXAMPLE: The example app is being rebuilt as a recipe browser — one page per feature, each a single file you can read end to end and paste into your own app. In progress; this entry grows as the series lands (#98)
- Every recipe runs inside a viewport preview. A recipe renders at a chosen phone / tablet / desktop size and is told that size is the whole screen, so a table's column widths — and any
MediaQuery-dependent branch your own code would take — resolve as they would on that device rather than in the desktop window. The frame owns its ownOverlay, because a real viewport does:Draggablefeedback andjust_tooltipboth resolveOverlay.of(context)to the nearest one, so without it a header cell dragged out of a preview drawn at 0.46x rendered at 1:1, floating over the whole window at more than twice the size of the row it came from - Each recipe shows its own source, read from the asset bundle. Not a copy of the code in a string — the file itself, so a snippet cannot drift from what you just watched run
- Shipped so far: sorting, drag selection and cell editing; column reorder, resizing and zoom. Each carries its knobs, and each names the traps it found rather than designing around them —
TablePlusColumn.widthis a preference that flexible columns share proportionally (maxWidth == widthis the only opt-out),onColumnReorder's indices count displayed non-selection columns, andresizableis table-wide while the bounds are per-column - And since: tooltips, the row card, merged rows and dynamic row heights (#107). Four more traps named rather than designed around —
TooltipBehavior.alwaysmeans whenever the column ellipsizes, which is every column by default, so a table nobody configured already draws a cell tooltip over every cell; a cell tooltip nests inside the row card and the innermost wins, so a card only appears where the cells above it were silenced; a header'salwaysignorestextOverflowentirely and itsonlyTextOverflowmeasures uncached, so the two tooltip behaviours are two different rules wearing one type; andMergedRowGroup.isExpandedadds a summary row rather than hiding the members, which is the opposite of what "expand" suggests - The width lesson from the previous batch caught the next recipe anyway. The entry above already said
TablePlusColumn.widthis a preference that flexible columns share proportionally — and the dynamic-height recipe still measured its text at the declared 300 while the column painted at 517.5 in an 1100px window, giving every row a wrap 250px narrower than the one it got.maxWidth == widthis the opt-out, and it is now asserted at two viewports rather than described once - The demo table decides its own tooltip colours, in both brightnesses. The package's default is a fixed
#616161with white text — legible in either, which is why nothing forced the decision until a recipe drew one.headerTooltipThemestays null on purpose: null is the documented fallback totooltipTheme, and the tooltips recipe exists partly to walk it - Two font bugs the app could not report, because a missing glyph does not throw. Flutter draws an absent character from a platform face, so both of these rendered as a typeface seam rather than as an error, and both survived several releases. The bundled Pretendard subset held six of General Punctuation and not the em dash, under a doc-comment claiming it was bundled for Korean it had never carried and the demo data has never contained; the charset is now written down as ranges in
scripts/fonts/subset_pretendard.pyand a test reads the shippedcmapagainst a scan of the sources, rather than checking the characters somebody remembered. And the Code pane setfontFamilyFallbackwithoutfontFamily— which appends to the inherited family instead of replacing it — so it had been drawing source in the proportional chrome font since the pane was built, with indentation that could not line up (#122, #123) - The Code pane highlights, and the highlighter is proved unable to change what you paste. A pure-Dart tokenizer with zero imports returns a partition — concatenating the token texts reproduces the bundle's bytes exactly. There are two ways the file leaves the pane and they are proved separately, because they are different paths: the copy button hands over the bundle's bytes directly and never passes through the highlighter, while selecting and copying does, and is checked by flattening the rendered spans with the same call the clipboard makes. The property is structural rather than asserted: tokens are cut from recorded end offsets, which makes the two shapes a hand-written scanner reaches for naturally unreachable — splitting on
\nand re-joining, which silently normalises CRLF, and lower-casing a word to match a keyword. It is hue-free, because the chrome is achromatic on purpose and the scheme ismonochromeseeded from black, so weight, slant and the grey roles are the whole vocabulary; comments are the one thing not dimmed, inverting the usual mapping, because in this corpus they are 29% of the lines and they are the teaching. A copy button joins the path bar, disabled while loading and on failure rather than hidden — the two states where copying would silently yield nothing. Line numbers were dropped: the pane is the affordance of the pasteable claim, not a source viewer, and their natural implementation is also the broken one, since an inline gutter of placeholder spans writes\u{FFFC}into every copied line (#113)- Two of the three grounds offered for these decisions were falsified before they shipped, and the decisions kept anyway. The copy button was argued as a touch-platform capability — measured false, because
SelectableTextdefaults itscontextMenuBuildertoAdaptiveTextSelectionToolbarand long-press → Select all → Copy already worked. Line numbers were dropped because "nothing in the app names a line" — false at repo scope, since two scenarios citefile:lineand the MAP gate actively rejects line numbers in favour of symbols. Both survive on one sentence that was already written and that neither argument had cited. A conclusion reached on a wrong reason is a conclusion the next reader deletes as obsolete, which is #69's lesson arriving through a decision rather than through a doc comment - And two guards were added because the obvious ones look sufficient and are not. The #123 monospace test reads
EditableText.style— the wrapperSelectableTextputs around a given span tree — so once the pane went.richa token span naming a font family would have rendered proportional with that test still green; it is measured, by mutation, that the new leaf-level assertion reddens while all three original ones stay green. The achromatic-chrome test hand-listed fourColorSchemeroles and the highlighter draws from two others. Both are the shape that let #110 close as "the demo goes neutral" while four blues survived: a check that reads a value's source rather than its destination passes anything that acquired the value another way
- Two of the three grounds offered for these decisions were falsified before they shipped, and the decisions kept anyway. The copy button was argued as a touch-platform capability — measured false, because
- A rationale this repo had already withdrawn was still on one page. #69 established that a row card anchors at the pointer because a row is
contentWidthwide — not because its centre scrolls off screen, which stopped being true atjust_tooltip 0.4.2. The anchor page kept the old reason, in phrasing that sweep never matched. Same conclusion, and the wrong reason is the one that gets the requirement deleted as obsolete (#124) - The app has a real theme now, light and dark, with no hard-coded colours left in the demo table. The playground's own colour decisions were deleted rather than re-themed, and the demo data was lifted out of it so every page shares one source (#99, #100, #110)
- And a mode that draws every viewport at once. The Device Wall puts desktop, tablet and mobile side by side on the same knobs and the same state, so an interaction in one frame paints its result in the other two — what changed between two widths, at once, rather than held against a memory of the other mode. It was specified to take no pointer input and ships live instead: the reason given was that scaling put drag selection's coordinate frame in question, which was measured and withdrawn, and the fallback reason — three frames are too small to operate — is true of one of them. They do not share a scale, because each viewport is fit into an equal column and the frame never scales up: measured at an 1800px window, desktop 0.28×, tablet 0.48×, and mobile at 1.0×, its full size. The desktop frame's rows are ~11px and imprecise to hit; the single-viewport modes are where precision lives. Every column is always fit, so the Fit / 1:1 control is not drawn in this mode (#108)
- And two scenarios, which are a recipe's opposite bargain. A recipe shows one feature with nothing else switched on, which is what makes it readable and what makes it unlike any real screen. A scenario assembles several and answers the other question — what does this look like when you put it together: an HR dashboard that merges four departments, sorts inside each one and selects across them, and a table of up to a hundred thousand rows that times its own generation and its own sort against the playground's existing performance monitor. The seam between the two zones is asserted rather than assumed —
lib/recipes/is held to an import allow-list andlib/scenarios/is deliberately outside it, so a scenario may reuse the monitor and a recipe may not, and the test also insists at least one scenario really does import something a recipe could not (#109)- Sorting a merged table is the caller's problem, and the dashboard is what the answer looks like.
dataandmergedGroupsare two caller lists the package validates neither against the other, so a global sort by salary interleaves the departments and draws each group stacked at wherever its earliest surviving member landed — nothing throws and nothing warns. Sorting inside each department and rebuilding the group list in the same pass is not a workaround for a limitation; it is what "group by department, sort by salary" means. Measured under mutation: building the groups from the unsorted list leaves every other assertion in the suite green, because every id is still in the right group and only where it is drawn has moved - The Device Wall exclusion stopped being a comment. It was written in three places and enforced in none; it is now
StageDestination.allowsWall— the destination's call, not the page's — plusShellPageleaving the wall when a destination that refuses it is opened. That second half is the one nothing would have reported:SegmentedButtonasserts three things and none of them is thatselectedis a subset ofsegments, so dropping the segment out from under the selection draws a bar with nothing highlighted, over a wall that should not be there
- Sorting a merged table is the caller's problem, and the dashboard is what the answer looks like.
- Nothing was removed. The playground and every existing entry point still work; the browser is a new surface beside them
- Every recipe runs inside a viewport preview. A recipe renders at a chosen phone / tablet / desktop size and is told that size is the whole screen, so a table's column widths — and any
-
FIX: changing
calculateRowHeightnow re-measures the rows.TablePlusBodyinvalidated its height cache only whendataormergedGroupschanged identity, so handing the table a new height function while the list stayed the same object changed nothing on screen: the rows kept the heights they were first measured at. Measured 2026-08-26 — swapping a height function from 100 to 40 with thedatalist held identical left the rendered row pitch at 100FlutterTablePlushas always watchedcalculateRowHeight, so its own total-height figure did update. That figure decidesneedsVerticalScroll, which is passed back down and also settles the last row's bottom border — so the table could conclude it needed vertical scrolling, or that it did not, from heights it was not drawing. (The ListView's scroll extent is not affected: it comes from the body's ownitemExtentBuilder, reading the same stale cache as the rows, so those two stayed consistent with each other. An earlier draft of this entry said the extent and the rows disagreed; they do not.)- Only reachable when the height function's identity changes, which is why there is no report and why nothing caught it: a static tear-off — what this package's suite and both example recipes passed — can never change identity. A closure over state (a density toggle, a font-size slider) is a new object on every build, and that is the ordinary way to write one
- Affected: every 2.x release, 2.0.0 – 2.16.1, wherever a caller passed a
calculateRowHeightthat is not a constant tear-off. The body's height cache and the parent's identity check both arrived before 2.0.0 — the cache withitemExtentBuilder, the check with the generic<T>migration — andgit log -Sfinds the parent's condition written once and never edited since, so the two have disagreed for the whole major. A table with no height function, or with a tear-off, renders exactly as before calculateRowHeightjoinsscalein the measurement branch rather thandatain the structure branch. Its identity changing says nothing about which rows exist, soRowLookupand the renderable indices survive and only the heights — and the geometry accumulated from them — are dropped. The two branches now say what kind of change they handle, which is something a future field can be checked against; the previous split was by which field happened to be listed- The
scaleclause turned out to be untested as well, found by deleting it and watching the suite stay green. Reaching it needs a table that usescalculateRowHeightand a rebuild changing onlyscale, and nothing had both — so a zoomed table with per-row heights was resting on an unproven line. Its behaviour was always correct; only the proof was missing, and it has a test now
-
TESTS: drag selection is now exercised against changing row heights.
TablePlusBodyStateanswers every hit test from aRowGeometrysnapshot built lazily on the first drag and held until something clears it — and nothing checked that it ever was cleared: deleting the clear left the whole suite green, because every drag test ran on one uniform height and never re-pumped. Two tests close that, one per arm of the invalidation branch- Two separate things are pinned, and the distinction is worth keeping straight. Both lines of the measurement branch are load-bearing — deleting either the geometry clear or the height clear reddens both tests, because
_buildGeometryre-reads the height cache and a rebuilt snapshot would otherwise re-read stale numbers. Both arms of the guard are load-bearing too, and there one test each: dropping thescalearm reddens only the scale test, dropping thecalculateRowHeightarm only the other. So neither test is redundant - Each test is also verified unable to pass for the wrong reason: with the invalidation deleted, removing the priming drag or letting the second pump take a fresh list turns it green. The first would leave the snapshot unbuilt, the second would rebuild it through the structure branch — and a fixture that can go green either way proves nothing
- Writing those tests found the defect below, which is why this entry has a FIX under it
- Two separate things are pinned, and the distinction is worth keeping straight. Both lines of the measurement branch are load-bearing — deleting either the geometry clear or the height clear reddens both tests, because
-
FIX: changing row height through the theme now invalidates the caches derived from it.
TablePlusBodyStateandFlutterTablePlusStateeach cached row heights behind a hand-written condition listingdata,mergedGroups,calculateRowHeightandscale— andtheme.bodyTheme.rowHeightis a height input that appeared in neither. Changing it while the data list kept its identity left both caches serving the previous height- Two symptoms, and neither one looks like a stale cache. The rows are drawn correctly throughout —
itemExtentanditemExtentBuilderread the theme live — so only what is derived from the caches is wrong. In the body that is theRowGeometryevery drag hit-test is answered from: measured 2026-08-31, rows drawn at 40px while a drag across four of them reported the two they would have covered at 80. In the parent it is the cached total that decidesneedsVerticalScroll, so the vertical scrollbar can silently fail to appear when rows grow past the viewport - Affected: 2.13.0 – 2.16.1 for the drag half —
RowGeometryarrived in 2.13.0 — and every 2.x release for the scrollbar half, whose cache dates to the 1.17.x series. Reachable without any height callback at all, which is what makes it broader than the #120 case: this repository's own example has a row-height slider that reaches it - The three measurement inputs now live in one predicate,
rowMeasurementChanged, called by both widgets. It is a reduced list, not a derivation — Dart cannot enumerate what a computation reads, and folding the inputs into a value type with an==would move the hand-listing into that operator, which is the shape that dropped fields fromscaledByin #50 and #116. What one predicate removes is the failure that actually happened twice: two conditions that disagreed docs/map/invariant/no-hand-enumeration.mdis widened to hold both shapes. The interval is the finding — #50 to #116 was six releases, #120 to #128 was one- One test per input, each holding the data list identical. The scale term turned out to be redundant for the body and load-bearing for the parent, because the two callers pass differently-scaled heights; a mutation deleting it survived until a test reached the parent's path alone
- Two symptoms, and neither one looks like a stale cache. The rows are drawn correctly throughout —
-
EXAMPLE:
example/README.mddescribes the recipe browser. It was still sixteen lines of theflutter createtemplate — "A new Flutter project. This project is a starting point for a Flutter application." — on a package whose example is a curated recipe browser, and pub.flutter-io.cn givesexample/its own tab, so it is a published surface (#138)- It deliberately carries no list of recipes, not even in prose. The roster is
lib/shell/recipe_catalog.dart; a second copy in a README is a thing a new recipe has to remember to join. A first draft of this README listed all eleven in a sentence, one paragraph above saying it would not - And writing that sentence found the guard pointing the wrong way.
test/recipe_seam_test.dartcheckedexpect(onDisk, containsAll(registered))under a comment reading "A recipe added and never listed is exactly the one nobody would think to check" — which is the case that assertion passes. The intent was written and the assertion did the reverse. Both directions are checked now, and the new one was verified by adding an unregistered file and watching it fail
- It deliberately carries no list of recipes, not even in prose. The roster is
-
EXAMPLE: the playground stopped demonstrating the mutation the package documents against, and stopped paying for the accident that hid it. It sorted and removed rows in place and replaced cells with
_data[rowIndex] = ..., all of which hold the list identity so the table never re-derives — and it was safe anyway only becausemergedGroups: ... : []allocated a fresh list on every build, rebuilding every cache every build. Both are fixed, in that order, since removing the rescue first would have exposed the mutations (#136)- A department edit left the grouping describing the previous departments.
_updateMergedGroupsderives its groups fromEmployee.departmentand_handleCellChangednever called it, so editing that cell changed the grouping input and nothing recomputed the grouping. Found by reading the call graph, not by reproducing it — the playground needs a window, which is not an agent gate here - The page also contradicted its own recipe.
calculateRowHeightwas an inline closure, whichrecipes/dynamic_row_height_recipe.dartspends two paragraphs advising against and prices; it is astaticfunction now - Pinned by a source scan rather than by a pumped widget, because nothing else could catch it: the analyzer has no opinion about
list.sort(), and a widget test renders the new contents correctly since rendering readsdatalive.example/test/snapshot_idiom_test.dartstates what it cannot see — it matches spellings, not semantics — and each of the three reverts reddens exactly its own assertion - The two recipe getters that rebuild their group list every build are left alone, on purpose. They are correct for staleness and pay a rebuild for it, their row counts are small, and memoising would put a cache into code whose point is that a
MergedRowGroupis an immutable value you rebuild. The over-invalidation direction is now named inFlutterTablePlus.mergedGroups, which previously said only "rebuild the list as well"
- A department edit left the grouping describing the previous departments.
-
FIX: a
calculateRowHeightwritten as aStatemethod tear-off no longer drops the row-height cache and the drag hit-test geometry on every build.rowMeasurementChangedcompared it withidentical, and the comment justifying that choice named a hazard==does not have — measured 2026-08-31,==agrees withidenticalon every shape a caller writes except a tear-off, where it istruebecause it is the same function on the same receiver (#137)identicalwas not even stable on that shape. AStatetear-off comparesfalsein the JIT test VM andtrueunder AOT, so the guard behaved one way in tests and another in the build users ship. That is the same JIT/AOT trap that put a wrong row intodocs/map/territory/row-identity.md's measurement table in the first place- Two tear-offs of the same method on different receivers still compare unequal, which is the case that had to keep working; it is pinned rather than assumed. Nothing else in the suite moves — reverting the operator reddens exactly the two new tests and no others
- No caller sees a behaviour change, only fewer measurements: the heights were always recomputed to the same values, which is why nothing on screen could observe this and the test counts callback invocations instead
-
DOCS:
data,rowIdandmergedGroupsnow say what they oblige the caller to do. The three are one snapshot: every id-keyed derivation —RowLookup's two maps, the renderable-index list, and the ids the drag hit-test geometry is answered from — is built from the pair(data, rowId)and dropped only whendatais a different object. So writinggroups[0] = newGroupon the samemergedGroupsis not seen. SwappingrowIdwhile keeping the same list was not seen either when this entry was written; the FIX at the top of this release closes it, and the paragraphs below describe the state #132 left rather than the state 2.17.0 ships. Each field's doc-comment was one line before and said none of this (#132)- Measured 2026-08-31, with a drag before the change so the lazily-built geometry actually exists: after a
rowIdswap over an unchanged list the rows render asX0..X5and the selection highlight follows the caller's new ids, while the drag callback keeps reporting{0, 1, 2, 3}— ids that no longer exist in the caller's space. That is #128's shape: the screen right and the answer wrong - The in-place group mutation is milder, and the word is doing narrow work. On its own nothing diverges: render and hit-test read the same stale lookup, agree with each other, and the table shows the previous state while describing it correctly -- the update is missed rather than misreported. Put a measurement change in the same build and that stops being true. The parent ORs
rowMeasurementChangedinto the branch that also holds its two identity checks, so it rebuilds from the live group list; the body keeps those in anelse if, so it keeps the stale lookup. The parent then believes 320px of content in 300px, raisesneedsVerticalScrolland draws a vertical scrollbar over aListViewwhosemaxScrollExtentis0.0. Pre-existing, and documented nowhere before this rowIdwas not guarded here, and the reason was ordering rather than cost — the ordering resolved later in this same release. The first version of this entry said the guard was unaffordable. That is true of the obvious guard and only of that one:rowIdis required, every call site writes an inline closure -- all fifteen in this repo's own example -- and an inline closure is a new object on every build even when it captures nothing, so!identical(rowId)fires for every caller on every build and rebuilds both lookups, measured AOT at 0.08% of a 16.7ms frame at 100 rows, 0.70% at 1,000 and 10.0% at 10,000- The option nobody costed was comparing the answers.
RowLookupalready stores the ids, so regenerating them through the current closure and comparing is complete rather than heuristic, and costs 0.010% / 0.112% / 0.971% at those sizes -- about a tenth. It is not even a new pattern here:lib/src/utils/overflow_cache.dartkeys on the derived(text, width)pair and never compares themeasurefunction it is handed - What holds it back is what it does once switched on. Prototyped: 412 tests green, and an in-place
removeWherethat used to throw aRangeErrorstops throwing. But on a merged-group table the rebuild it triggers runscomputeRenderableIndices, which drops a group's remaining members when the removed row was the group's first key -- so a loud crash becomes a silently missing row, and which of the two a caller gets depends on which member they removed. Measured. The derivation is fixed first, then the guard — and that is exactly the order both landed in, in the entry at the top of this section
- The option nobody costed was comparing the answers.
- This is the obligation Flutter already states for lists, four times over —
SliverChildListDelegate.children,TwoDimensionalChildListDelegate,MultiChildRenderObjectWidget.childrenandPlatformMenuBar.menus, that last a list of non-Widget data objects and so the closest documented shape tomergedGroups. It states none for a function, because where the SDK takes one it either compares it (ListWheelChildBuilderDelegate.shouldRebuild) or caches nothing from it (AnimatedListreadsitemBuilderlive). This package extracts identity through a separate required function — a deliberate divergence, and the thing that stopped the data's identity covering the caches derived from it - No public API and no runtime behaviour changed. A caller already handing over a new list whenever the data changes — the ordinary way, and what every recipe here does — was never affected and still is not
- The sweep found this package teaching the pattern the contract forbids.
README.md's Core Philosophy block anddocs/FEATURES.md's sorting example both wrote_myData.sort(...)in place — andFEATURES.md's other branch already built a new list, so the two arms of one example disagreed with each other. The playground did the same with an in-place sort and tworemoveWheres, and was correct only by accident: itsmergedGroups:ternary allocates a bare[]on the false branch, a fresh object every build, which invalidated every cache every build and hid the mutation. All of them now pass a new list - And one comment in
lib/was false in the way that matters._rowLookup's own doc said it is "rebuilt whenever the data or merged groups change" — a content claim in front of an identity check, sitting directly above the cache this entry is about.MergedRowGroup.isExpandedtaught rebuilding the group and stopped one step short of the list;RowLookup.build's doc named two of its three inputs; androwMeasurementChanged's "deliberately absent" list nameddataandmergedGroupsand notrowId, which is absent for the opposite reason — not that it fails to change what a height is, but that it is the one caller function this package could not watch - Commit or cancel an open cell edit before changing the id space — including the supported way, which is the part worth knowing. A session is pinned by index and re-pinned by id, so a new
datalist makes it search that list for the id it captured, which is an id from the space just left; it is not found, the session is disposed, and the typed text goes with it without anonCellChanged. The duplicate-id validator likewise does not run over a swappedrowId, being gated on the same list identity.docs/map/territory/cell-editing.mdrecords this, and its## Governing decisionshad already listed "whether an edit survives a rebuild with different data" as unrecorded - This entry first said the opposite, and the correction is the useful part. It claimed an in-flight edit "commits against the abandoned id space". Measured: it does not, and it cannot —
_stopEditingreportsdata[session.rowIndex], the column key and the index, and touches no id at all. So the hazard is losing an edit, never misreporting one, and the reasoning had inverted which side of the contract carries the risk: it is the caller who obeys the new rule who loses the edit - And #120's own residue, found by re-reading the same predicate.
example/lib/recipes/dynamic_row_height_recipe.dartstill told readers, in the present tense, that the per-index height cache "is not keyed on the callback" and that a function answering differently "leaves the old heights on screen" -- quoting #120's own measurement as a live hazard. The advice it supports (hold the callback still, prefer a pure function) is unchanged; the second of its two reasons is what stopped being true- It stopped being true in this unreleased version, not in a shipped one, and the difference is the whole point of stating it: #120's fix is in 2.17.0 and the latest published version is 2.16.1, so for every reader on a released build that warning is still accurate today. The recipe now says "Fixed in 2.17.0" rather than describing it as past. An earlier draft of this entry said "one release ago", which would have been a count of zero
- Two production defects surfaced by the pass that checked this work, both filed rather than fixed here — this entry changes no runtime behaviour and neither of them can be closed without doing so. On a table with
mergedGroups, shrinkingdatain place throws aRangeError:itemCountreads the cached render-index list while the row build readsdatalive, and two of the three readers of that index already range-guard while the third does not. And a caller who obeys the new rule exactly — a newdatalist — still loses rows silently if that list no longer holds a row some group names, becausecomputeRenderableIndicesadds nothing when the group's first key is missing and marks the rest processed anyway. The doc-comments ondataandmergedGroupssaid both plainly, since "is not seen" reads as benign and one of these is a red screen. Both were then fixed under this same version and those comments rewritten again; the FIX at the top of this release is where the current contract lives - Two tests pin the supported path rather than the stale one, on purpose: asserting the stale answer would freeze a documented non-guarantee into the suite and make a later decision to guard
rowIdread as a regression. The rule lives indocs/map/territory/row-identity.md, whose## Reference behaviouralso stops saying theDataTablecomparison "has never been written down";docs/map/invariant/no-hand-enumeration.mdrecords it as shape 2's second exit — name the input as a contract instead of adding it to a list — and carries the grep for the next one: of fifteen function-typed parameters on the public widget, exactly two have cached results
- Measured 2026-08-31, with a drag before the change so the lazily-built geometry actually exists: after a
-
INTERNAL:
lib/changed in the three fixes above, in the removal at the top of this entry, and in doc-comments: one correction that #128's sweep reclaimed, plus #132's contract ondata/rowId/mergedGroupsand the fourlib/comments its own sweep reclaimed — the cached-geometry field still described the invalidation set #120 had already widened. (This line read "one place", then "three places", then "four places", as each further change landed under the same open version. It has stopped carrying a count: the count was reassurance that a patch release was small, and this is no longer a patch release. The list is the honest form.)- Verified against
flutter_checkbox0.3.2, the version a consumer'spub getpicks today, not only against the 0.3.1 that happened to be resolved locally. The constraint stays^0.3.1— 0.3.1 is fine and excluding it would be a floor nobody needs — and 0.3.2 declares the identicalsdk >=3.6.0/flutter >=3.27.0, so nothing moves.example/pubspec.lockrecords it; the package's own lockfile is git-ignored, as a published library's should be, which is exactly why "all tests pass against 0.3.x" has always meant whatever that machine resolved that day - The new tripwire earned itself on that upgrade, an hour after it was written: it failed naming
shadows, upstream's changelog settled that the field is deliberately unscaled — "likeborderWidth,borderRadiusandcheckStrokeWidth, shadow offsets and radii stay in logical pixels whilescaleresizes the box" — and the field set was updated with that reason attached. Against 0.3.2 the old hand-list reddens two tests instead of none. Alongside it the repository grew a territory map (docs/map/, with its own gate) and a compiled agent build (docs/agents/thegraph.md). Neither ships: the root.pubignoreexcludesdocs/, so the archive is unchanged by them
- Verified against
2.16.1 #
- DEPS:
flutter_checkbox: ^0.3.0→^0.3.1, which lowers this package's minimum Flutter to3.27.0(Dart3.6.0), down from the3.35.0(Dart3.9.2) that 2.16.0 declared.lib/is byte-for-byte unchanged; all package and example tests pass against 0.3.1 unmodified- 2.16.0 was retracted. It shipped a
3.35floor that was too aggressive — a floor it never actually needed. This release supersedes it. Relative to 2.16.0 the floor moves down (3.35→3.27), so this entry is not breaking. But 2.16.0 is retracted, sopubupgrades come from the last non-retracted release, 2.15.3 (Flutter3.13); relative to 2.15.3 this still raises the floor to3.27and inherits 2.16.0'sflutter_checkbox^0.2.1→^0.3.xbump — read the 2.16.0 entry for that BREAKING context. 2.16.1 only reduces the size of the raise 2.16.0 attempted - The
3.35floor was never this package's own requirement — it was inherited. 0.3.0 had set its floor to3.35, the SDK it happened to be built with rather than the one its code needs, and taking^0.3.0made that3.35our transitive requirement. 0.3.1 corrects it to the real minimum —Color.withValuesis its newest call (Flutter3.27/ Dart3.6), everything else needs only Dart3.0— so this package's floor is free to follow it down. The other binding,just_tooltip0.4.4, floors at3.13, so the honest floor ismax(3.27, 3.13) = 3.27 - The constraint is raised to
^0.3.1, not left at^0.3.0, on purpose:^0.3.0still admits 0.3.0, whose3.35floor a3.27user could not satisfy. A declared SDK range is only honest if it holds for every version the constraint allows — the same reasoning the 2.16.0 raise applied when it moved the floor up, applied here to move it down
- 2.16.0 was retracted. It shipped a
2.16.0 #
- BREAKING: minimum Flutter is now
3.35.0(Dart3.9.2), up from3.13.0(Dart3.1.0). No class, method or field in this package changed — the floor did.flutter_checkbox0.3.0 corrected its own declared minimum from aflutter createdefault (>=1.17.0) to its real one,Dart ^3.9.2/Flutter >=3.35.0(it usesColor.withValues, 3.27+, and 3.9.2 language features). Taking^0.3.0makes 3.35 the transitive requirement, so any floor lower than that here would be a promise this package could not keep — the same honesty the 3.13 raise served in 2.15.0. This subsumes the Flutter 3.13 floorjust_tooltipimposed, which still stands unchanged upstream- An app that pinned an older SDK can stay on 2.15.3; upgrading to 2.16.0 requires Flutter 3.35+
- DEPS:
flutter_checkbox: ^0.2.1→^0.3.0. 0.3.0 is purely additive over the surface this package uses — it adds constructor-levelactiveColor/checkColor/semanticLabel,CheckboxStyle.copyWith, style-resolved overlay colors, andCheckboxStyle.checkScale, and removes nothing (the last removals were 0.2.0, which this package already sits above). Solib/is byte-for-byte unchanged; all 382 package tests and 67 example tests pass against 0.3.0 unmodified- Reachable from the defaults: 0.3.0 merges the checkbox's state semantics and its tap action onto one node (
MergeSemantics), where before a screen reader saw two. Every selection checkbox this table renders — row, header select-all, merged-row — now announces and activates as a single control to assistive tech, with no code change here
- Reachable from the defaults: 0.3.0 merges the checkbox's state semantics and its tap action onto one node (
2.15.3 #
- DEPS:
just_tooltip: ^0.4.3→^0.4.4. A floor, not a preference. 0.4.4 stops a tooltip that has nothing to draw from displacing the tooltips around it (just_tooltip#46) — the very guarantee 2.15.2 secured for itself by never building such a tooltip. That guard is gone now, so under 0.4.3 this package would reissue the bug 2.15.2 fixed, rather than merely miss an upstream improvement- The SDK floor is unchanged. 0.4.4 still declares
sdk >=3.1.0 <4.0.0andflutter >=3.13.0, so this package's Flutter 3.13 floor stands - 0.4.4 also makes a tooltip that is already on screen follow changes to its
message,tooltipBuilder,theme,directionandalignment(just_tooltip#47); the overlay rendered them live but nothing ever asked it to rebuild. Nothing here relied on the old behaviour — all 382 package tests and 67 example tests pass against 0.4.4 unmodified
- The SDK floor is unchanged. 0.4.4 still declares
- REFACTOR:
wrapWithTooltipno longer skips the wrap when the resolved message is empty. just_tooltip owns that rule now, and a local copy of the predicate was a second thing that had to agree with the first, with nothing in the build to make ita cell tooltip that cannot show does not suppress the carddid not change one character. It observes the contract — the row card survives — and not the mechanism, so it kept passing once upstream took the mechanism over- Removing the guard while allowing 0.4.3 is what would break, and only there: the same test is the single failure across the suite when both are done
- DOCS: 2.15.2 argued for its fix with a rationale that 0.4.4 has made false, and no test guards a rationale. It held that suppressing ancestors from
MouseRegion.onEnterwas an intended contract of just_tooltip, so a tooltip that cannot show must never be built. Upstream has since judged that a trap in its own default and fixed it there. What 2.15.2 shipped was right for 0.4.3; the reason it gave was not, and a reader following that reason today would conclude the guard must stay- Withdrawn from
TablePlusTooltipTheme.hideOnEmptyMessage,TablePlusColumn.tooltipFormatter,wrapWithTooltip,docs/THEMING.mdand the tests that repeated it.hideOnEmptyMessagenow reads "draws nothing, and displaces nothing" FlutterTablePlus.rowTooltipBuilderneeded no correction: "a cell only takes the card's place when it has something to show" is what 0.4.4 made upstream law
- Withdrawn from
2.15.2 #
- FIX: a cell whose
tooltipFormatterreturns an empty string no longer swallows therowTooltipBuildercard. Hovering such a cell showed nothing at all — not the cell's tooltip, not the row's- A tooltip suppresses its ancestors from
MouseRegion.onEnter, before it decides whether it has anything to draw, andhideOnEmptyMessagedecides "nothing" inside_show(). The cell had already taken the card down. Hoisting that guard to where the cell is wrapped means a tooltip that cannot show is never built, so it cannot suppress anything. The header has guarded this way all along — an empty label gets no tooltip — but a cell's message is only known oncetooltipFormatterhas run hideOnEmptyMessage: falseis unchanged: that asks for the empty bubble, and it still wins over the card
- A tooltip suppresses its ancestors from
- DOCS:
rowTooltipBuilder's "the whole row is the hover region" is now pinned by tests, including over a cell whose value is empty. It was never broken there — a row'sMouseRegionis opaque and hit-tests itself, so a zero-widthTextunder the pointer changes nothing — but nothing said so, and the neighbouringtooltipFormatterbug looked exactly like a hole in that region. The doc comment now says which boundaries the region actually has, and which cells take the card's place - DOCS:
TablePlusTooltipTheme.hideOnEmptyMessagewas absent fromdocs/THEMING.mdaltogether. It was a footnote when a tooltip stood alone; it decides whether the row card appears once one nests inside another
2.15.1 #
- DEPS:
just_tooltip: ^0.4.2→^0.4.3. No API change — 0.4.3 declares the same public classes, enums and fields, and touches two internal files. All 378 package tests and 28 example tests pass against it unmodified, and its SDK floor is the same, so this package's Flutter 3.13 floor stands- It fixes an
interactivetooltip dying for good once the cursor returns from the tooltip body to its child (just_tooltip#43). Leaving the tooltip armed a 100 ms bridge that re-entering the child never cancelled; it fired unseen, started a fade-out, and a pointer already inside the child sends no furtheronEnterto revive it. The tooltip vanished roughly 250 ms after the cursor came home and stayed gone - This was reachable from the defaults.
TablePlusTooltipTheme.interactiveistrueand every cell, header and row tooltip passes it.TooltipAnchor.pointer, added in 2.15.0, draws the tooltip beside the cursor — exactly the arrangement that walks the cursor into the tooltip body
- It fixes an
- EXAMPLE: The playground's settings panel is searchable
- 68 controls across five sections, and no way to find one except to remember which section held it. Typing narrows the panel to the controls whose labels match; a section holding a match opens to show them, one holding none stands aside, and clearing the search restores what was open before
- The row card gained its own wait duration, separate from the cells'. A card interrupts more than a line of text does. Its transparent background and zero padding stay fixed: the builder draws its own surface, so a tooltip surface behind it would be a second card
Tooltip Enabledalso silences the row card, which the toggle never said. It says so now
- INTERNAL: No library code changed in this release —
lib/is byte-for-byte the 2.15.0 tree
2.15.0 #
-
BREAKING: minimum Flutter is now
3.13.0(Dart3.1.0), up from3.10.0.just_tooltip0.4.2 walksRenderObject.parent, which wasAbstractNode?— a type with nodescribeApproximatePaintClip— before Flutter 3.13. Thejust_tooltip: ^0.4.0constraint already resolved 0.4.2, so the old floor had quietly become a promise this package could not keep; raising it is the honest fix, not the cause -
DEPS:
just_tooltip: ^0.4.2. Upstream now anchorsTooltipAnchor.childto the visible part of the child, re-aims a shown tooltip when its child moves, and hides it once the child is clipped out of sight. Cell and row tooltip behaviour here is unchanged; all 378 tests pass unmodified -
FEAT:
TablePlusTooltipTheme.anchor— position a tooltip beside the cursor instead of beside the widget it wrapsTooltipAnchor.child(the default) anchors to the visible part of the hovered widget's rect. That is wrong whenever the widget is far wider than the neighbourhood the user is pointing at: a cell in a column wider than the viewport gets a tooltip at the centre of whatever slice is on screen, wherever the cursor may be.TooltipAnchor.pointerkeeps the same hover region and anchors at the cursor- Against a point there are no target edges to align to, so under
TooltipAnchor.pointerthealignmentfield selects which of the tooltip's own edges lands on the cursor TooltipAnchoris now re-exported, so you no longer needjust_tooltipin your ownpubspec.yamlto name it- Row tooltips built by
rowTooltipBuilderalways anchored at the pointer and still do; they ignore this field. A row is as wide as the table's content, so a child anchor would aim at the centre of whatever slice of the row is on screen — visible, but unrelated to where along the row the cursor is
-
FEAT:
TablePlusTheme.headerTooltipTheme— style header tooltips apart from cell tooltips- Header and cell tooltips were styled by one
tooltipTheme, so tooltip behavior was separable (headerTooltipBehaviorvstooltipBehavior) while tooltip style was not. Withanchorexposed, that asymmetry bit: anchoring a header at the pointer dragged every cell along with it - Nullable, and falls back to
tooltipThemewhen unset — the same shape asrowTooltipTheme. Leave it null and nothing changes
- Header and cell tooltips were styled by one
-
FIX: A scaled table no longer loses
rowTooltipThemeTablePlusTheme.scaledBy()rebuilt the theme without carryingrowTooltipTheme, so at anyscaleother than1.0it went null and the documented fallback handed the row tooltip totooltipThemeinstead. A card styled for its own surface — transparent, unpadded — came back wearing the grey text tooltip'sscaledBy(1.0)returns the receiver untouched, so this never fired at the default scale, which is why it went unnoticedscaledBy()now names only the sub-themes it actually scales and leans oncopyWithto carry the rest, so a theme field added later cannot be dropped by forgetting to list it
2.14.0 #
- FIX: Row splash / hover / highlight now use the colors you set on
TablePlusBodyTheme, in every mode- The row's ink colors were passed as
nullwhenever the row was not tap-selectable — notably in editing mode — to suppress the ink. Butnulldoes not suppress it:InkWellresolveswidget.splashColor ?? Theme.of(context).splashColor, so the table silently rendered Flutter's faint grey default instead of your theme. On a white row that default is nearly invisible, which made "no splash" and "a splash you cannot see" look identical.TablePlusBodyTheme.splashColoralready documented the real contract: passColors.transparentto disable - Which ink appears is now decided by which callbacks are wired, which is what
InkWellactually gates on — a splash/highlight needs a primary-button callback, a hover highlight needs any callback. The row shell previously forwardedonDoubleTap/onSecondaryTapDownas non-null closures that merely called a possibly-null handler, soInkWellalways looked enabled and painted a splash for a tap that did nothing. Those closures are now forwarded only when a handler exists, and the theme's colors are always passed through
- The row's ink colors were passed as
- FEAT:
rowTooltipBuilder— a rich card shown while hovering anywhere on a rowFlutterTablePlus.rowTooltipBuilder(context, rowData)returns the card, ornullfor a row that should not have one. The whole row is the hover region, so the card does not blink off as the pointer crosses columns, and it is anchored beside the pointer rather than at the row's centre — a row is as wide as the table's content, which can far exceed the viewport- A cell with a tooltip of its own wins: exactly one tooltip is visible, the innermost under the pointer
TooltipBehavior.always— the default — leaves the card nowhere to appear.alwaysmeans "whenever the column is ellipsized", not "whenever the text is actually cut", so every ordinary text column already has a tooltip. UseTooltipBehavior.onlyTextOverflowon text columns alongside a row card- Style it with
TablePlusTheme.rowTooltipTheme(falls back totooltipTheme). A card draws its own surface, so it wantspadding: EdgeInsets.zero, a transparentbackgroundColorand no elevation — settings that would ruin plain text tooltips - Merged rows stand for several data rows, so there is no single
rowDatato build from; they carry no card
- FIX: A column's
tooltipBuilderno longer depends on the cell's text being truncated, and custom cells can finally have a tooltip- The decision "should this cell show a tooltip?" was made by asking "has this cell's text been truncated?" — so a
tooltipBuilder, whose content has nothing to do with that text, only rendered on a column that happened to be ellipsized and non-empty. SincetextOverflowdefaults toTextOverflow.ellipsis, this only bit columns that opt intoTextOverflow.visible, which dynamic row heights encourage - A cell built by
statefulCellBuilderreturned before the tooltip wrapper was ever reached, in the normal row and in both merged-row paths. Such a column — a status badge, say, exactly the kind that wants a rich tooltip — could not have one at all - Ink for a widget tooltip now covers the whole cell, not just the text. An empty cell's
Textis zero-wide and a short one leaves most of its cell unhoverable, so the tooltip was attached but unreachable. Text tooltips still belong to the glyphs: hovering the blank part of a wide column shows nothing, exactly as before TooltipBehavior.neverstill suppresses everything. For a widget tooltip,alwaysandonlyTextOverflowboth show it — "text overflow" is undefined for builder content
- The decision "should this cell show a tooltip?" was made by asking "has this cell's text been truncated?" — so a
- BEHAVIOR: Tapping a row now selects it while
isEditableis true- Tapping an editable column still starts editing that cell — the cell's own
GestureDetectorwins the gesture arena against the row-level tap. Tapping anywhere else on a selectable row now toggles its selection, as it does outside editing mode - Row-tap selection was suppressed during editing since editing was introduced, when the two modes were mutually exclusive (
assert((isSelectable && isEditable) == false)). That assert was removed to let them coexist, but the row guard survived, leaving a contradiction: while editing you could still select a row via its checkbox, just not by clicking it - If you relied on rows not selecting on tap while editing, handle it in your
onRowSelectionChanged
- Tapping an editable column still starts editing that cell — the cell's own
- PERF: The selection cell no longer allocates its own
Materials — up to three per row become oneTablePlusSelectionCellwrapped its checkbox in a transparentMaterial, and its cell-tapInkWellin another, so thatFlutterCheckbox's internalInkWellwould find aMaterialancestor and the table would render without aScaffold(#3). ButCustomInkWellalready wraps the whole row in one, and the selection cell only renders when the row is selectable — which is exactly when that rowMaterialexists. Both were redundant- A
Materialis not cheap: with the defaultcanvastype it expands toAnimatedDefaultTextStyle→AnimatedPhysicalModel→PhysicalModel→_InkFeatures, i.e. two render objects and two implicit-animation controllers. Removing them takes a selection-enabled row from three to one - Ink is still painted per row, so this is unrelated to the root-
Materialhoist rejected in #38 — that regressed scroll because it moved ink painting outside each row'sRepaintBoundary
- CHORE: Bump
just_tooltipdependency^0.3.0→^0.4.0- Picks up a fix for tooltips laid out in a nested
Navigatoror an insetOverlay, which were displaced by the Overlay's offset (just_tooltip#24), and reliable innermost-wins behavior for nested tooltips (just_tooltip#22). Neither is reachable from this package's current widget tree, so nothing changes here - Adds
TooltipAnchor.pointer, which #45 needs: a row tooltip must keep the whole row as its hover region while anchoring beside the cursor
- Picks up a fix for tooltips laid out in a nested
- TEST: The #3 regression guard now exercises the checkbox, not just its rendering
InkWellresolvesMaterial.ofonly when it paints ink — on tap (_createSplash) and on hover/press (updateHighlight) — so a checkbox with noMaterialancestor builds fine and throws only when touched. The old guard pumped the table and asserted no exception, which passed for an incidental reason: the row'sInkdemands aMaterialat build. The guard now taps the checkbox, taps the cell (cellTapTogglesCheckbox, previously untested), and hovers the checkbox
2.13.1 #
- PERF: Rows no longer rebuild on pointer hover when there are no hover buttons
- Each row wrapped its content in a hover-tracking
MouseRegionthat calledsetStateon every pointer enter/exit, but that state drives only the hover button. WhenhoverButtonBuilder == null(the common case), the rebuild — all cells, including tooltip overflow re-measurement — had no visible effect, and fired continuously while the mouse moved over the table during scrolling - The
MouseRegion+setStateare now installed only when there are hover buttons. Row hover / splash / highlight colors are unchanged (painted by the row'sCustomInkWell), and the hover-button reveal is unchanged when a builder is set
- Each row wrapped its content in a hover-tracking
- PERF: Cell
FocusNodeis now allocated lazily, only when a cell can editTablePlusCellcreated aFocusNode(and registered a listener) for every cell ininitState, though only editing uses it. It is now created on first edit, so non-editable tables and non-editable columns allocate none — one fewer object + listener per visible cell as rows scroll into view
- CHORE: Bump
just_tooltipdependency^0.2.5→^0.3.0 - PERF: Uniform-height tables now scroll with O(1)-per-frame layout instead of O(n)
TablePlusBodyusedListView.builder(itemExtentBuilder: ...)unconditionally, which drivesRenderSliverVariedExtentList— it sums every row's extent on each layout to know the total scroll extent and to map a scroll offset to an index. On a 100k-row table this is ~O(n) per frame and caused visible scroll jank- When there are no merged groups and no
calculateRowHeight(i.e. all rows sharetheme.rowHeight), the body now passes a fixeditemExtent, so Flutter usesRenderSliverFixedExtentList(offset↔index by division). Merged-group and dynamic-height tables keepitemExtentBuilderwith identical geometry - Local harness (100k uniform rows, 60
jumpTolayouts): ~83.6 ms/jump → ~22.1 ms/jump end-to-end, and the per-frame layout cost no longer grows with row count
2.13.0 #
- FIX: Row-level gestures now fire while
isEditableistrueonRowDoubleTapandonRowSecondaryTapDownpreviously never fired in edit mode because the row's interaction layer was gated on selection being active (isSelectable && !isEditable). They now fire whenever a handler is provided — e.g. right-click a row to delete it while other rows stay editable- Tap-to-select is still suppressed while editing (a single tap edits a cell); the selection ink splash is not shown for edit-mode gestures
- FIX: Checkboxes no longer require a
Material/Scaffoldancestor- The
InkWellinsideFlutterCheckboxneeds aMaterialancestor; the row selection cell and header select-all cell now wrap the checkbox in a transparentMaterial, so the table works in non-Material desktop apps without aScaffold
- The
- FIX: Narrow selection columns no longer clip the checkbox
- The row and header select-all cells no longer wrap the (already-centered) checkbox in the full horizontal padding, which squeezed the content area to zero. A
checkboxColumnWidthbelow ~40 now keeps the checkbox visible
- The row and header select-all cells no longer wrap the (already-centered) checkbox in the full horizontal padding, which squeezed the content area to zero. A
- PERF: Column-width layout is now near-linear in the column count
- The max-width redistribution used to cap one exceeding column and restart the scan (~O(n²)). It now settles all caps in a single sweep over columns sorted by
maxWidth / width(~O(n log n)) — identical widths, verified by a differential test against the previous algorithm across randomized inputs - ~28× faster at 10,000 columns in the local benchmark (9.7 ms → 0.35 ms); negligible difference for normal column counts
- The max-width redistribution used to cap one exceeding column and restart the scan (~O(n²)). It now settles all caps in a single sweep over columns sorted by
- INTERNAL: Large testability refactor — no public API or behavior change
- The column-width algorithm, the row hit-test geometry behind the drag-selection
RowLocatorport, table metrics (total height / row count / renderable indices), the sort-direction cycle, the single/double-tap timing, the scroll-sync reentrancy guard, and several theme/row helpers were extracted into pure, unit-tested modules - Test count
271→331; line coverage ~85% → ~91%. Addedbenchmark/pure_paths_benchmark.dartmicrobenchmarks (run withflutter test benchmark/pure_paths_benchmark.dart)
- The column-width algorithm, the row hit-test geometry behind the drag-selection
2.12.2 #
- CHORE: Bump
flutter_checkboxdependency^0.2.0→^0.2.1
2.12.1 #
- FIX: Explicitly set
mouseCursor: SystemMouseCursors.clickon interactiveInkWellwidgets so the pointing-hand cursor appears reliably on recent Flutter versionsCustomInkWell(used by row and merged-row selection) now resolves toSystemMouseCursors.clickwhenonTaporonDoubleTapis provided, and toMouseCursor.deferotherwise —deferleaves the cursor decision to an ancestorMouseRegion(e.g., resize handles) instead of stomping it withbasicTablePlusSelectionCell's checkbox-cellInkWellmirrors the same pattern, guarded onrowId != null- Previously, the wrapped
InkWellrelied on the implicitWidgetStateMouseCursor.clickabledefault, which no longer flips toclickin all configurations after recent Flutter cursor-resolution changes
2.12.0 #
- FEAT: Horizontal auto-scroll during drag selection
- When the table is wider than its viewport (
SingleChildScrollViewis scrollable horizontally), dragging the pointer near the left or right edge of the visible viewport now scrolls the table horizontally at proximity-proportional speed, mirroring the existing vertical auto-scroll - Edge detection runs in visible-viewport coordinates (uses
horizontalController.position.viewportDimension), not body-local coordinates, so the edge zone tracks the user's actual visible area - Both axes can scroll simultaneously when the pointer is in a corner (e.g., bottom-right)
- The rubber band rectangle is content-anchored on both axes — its origin tracks the underlying content via the symmetric
downLocal − hDelta/vDeltaformula, so scrolling on either axis grows the rectangle visually - Horizontal proximity is clamped to
[0, 1]so dragging the pointer far past the visible viewport edge does not overshoot the configured max speed (the vertical axis retains its original feel) - The drag-selection
Listenersits at the body's viewport level (outside the body's horizontalScrollable), so itsevent.localPositionis viewport-local on both axes — there is no "body slid in screen" compensation to maintain. As a consequence, the auto-scroll engine progresses cleanly tomaxScrollExtentwhile the pointer is held in an edge zone, instead of stalling once accumulated horizontal scroll delta would have pushed a stale captured origin out of the zone
- When the table is wider than its viewport (
- FEAT: Rubber band rectangle for drag selection (Finder/Explorer-style marquee)
- When
enableDragSelectionis true, a translucent rectangle is now drawn from the pointer-down position to the current pointer position while the drag is active, providing immediate visual feedback for the selection range - The rectangle is decoupled from anchor establishment: it appears as soon as the activation threshold is passed, even when the drag stays entirely inside empty space (no row anchor yet) or when the pointer crosses back above row 1 into the header area
- Content-anchored rectangle: the origin is fixed to the underlying content rather than the viewport, so auto-scroll causes the rectangle to grow visually — matching OS marquee conventions
- New
TablePlusDragSelectionTheme(composed intoTablePlusTheme.dragSelectionTheme) withshow,fillColor,borderColor,borderWidth,borderRadius. Defaultshow: true; set tofalseto keep drag-selection logic without the visual cue borderWidthparticipates inTablePlusTheme.scaledBy(); colors andborderRadiusare intentionally not scaled- Re-exported from the main library for convenient theming
- When
- FIX: Drag selection no longer snaps to the last row when the pointer is in the empty area below the data
_renderIndexFromLocalYnow returnsnullfor coordinates outside the actual row area (above the first row or below the last); callers' existing null-guards activate sticky behavior at the last reached row instead of clamping to a row the pointer never crossed- Lazy activation: when pointer-down lands in the empty area below the last row, the drag anchor is deferred until the pointer first crosses into a real row — starting a drag from the empty area and moving into rows still works, but a drag confined to empty space no longer selects anything
- Side-aware release: when a drag started from the below-data empty area and the pointer returns to that same area, the lazy anchor is released and the selection collapses (mirroring OS marquee behavior in Finder/Explorer). Re-entering a row lazy-activates a fresh anchor. Crossing instead above row 1 into the header area preserves the sticky range, so sweeping up through every row and continuing past the header keeps the full selection intact
- Sticky preservation for in-row starts: when a drag started inside the rows moves past the last row into empty space (or above row 1 into the header area), the selection freezes at the last reached row instead of being coerced to the boundary
- EXAMPLE: Added
5quick preset to the playground for testing small-data drag-selection scenarios (logarithmic slider lower bound:10→5) - INTERNAL: Drag-selection coordinate model unified to a single viewport-local frame
- Header and body now use independent horizontal
SingleChildScrollViews synced via a shared-controller pattern (SyncedScrollControllersadds a 5th controller slot for the header). The header isNeverScrollableScrollPhysics— body is the master input source TablePlusBodyis a pure row renderer; drag-selection state, pointer handlers, auto-scroll engine, and rubber band painter all live in_FlutterTablePlusState.TablePlusBodyStateexposesrenderIndexAtLocalYandrowIdsBetweenfor the parent's lookup needs (accessed viaGlobalKey<TablePlusBodyState<T>>)- Single-axis auto-scroll engine extracted (
_performAxisAutoScroll); per-axis wrappers now differ only in coordinate source and aclampProximityflag. Vertical retains its historical >1 acceleration past the edge zone; horizontal still caps atmaxSpeed
- Header and body now use independent horizontal
- TEST: Added
test/drag_selection_test.dartwith 8 widget tests covering basic drag, threshold gating, sticky range at empty/header boundaries, vertical / horizontal / dual-axis auto-scroll, and merged-group traversal
2.11.0 #
- BREAKING: Rename
blockCtrlScroll→blockModifierScrollto accurately reflect platform-aware behavior (Ctrl on Windows/Linux, Cmd on macOS) - FIX: Use platform-aware modifier key check to fix Cmd+scroll zoom not working on macOS
- FEAT: Export
isScaleModifierPressed()helper for library consumers implementing custom Ctrl/Cmd + scroll zoom
2.10.0 #
- BREAKING: Replaced Material
Checkboxwithflutter_checkboxpackageTablePlusCheckboxThemenow uses a singleCheckboxStyle styleproperty instead of individual Material properties- Removed:
fillColor,overlayColor,checkColor,focusColor,hoverColor,side,shape,materialTapTargetSize,visualDensity,splashRadius,size,tapTargetSize - Added:
style(CheckboxStyle) — controls all visual aspects (colors, shape, size, border, hover ring, animations) buildCheckbox()now createsFlutterCheckboxwith CustomPainter rendering for crisp display at any sizescaledBy()usesCheckboxStyle.scalefor accurate visual scaling (previously SizedBox-only scaling)- Renamed
material3()factory →colored()factory - Re-exported
FlutterCheckbox,CheckboxStyle,CheckboxShapefrom main library for convenience
- Migration:
TablePlusCheckboxTheme(fillColor: ..., checkColor: ..., size: 18)→TablePlusCheckboxTheme(style: CheckboxStyle(activeColor: ..., checkColor: ..., size: 18))tapTargetSize→CheckboxStyle(hoverRingPadding: ...)splashRadius→ removed (hover ring replaces ripple effect)- Table-specific properties (
showCheckboxColumn,showSelectAllCheckbox,checkboxColumnWidth,cellTapTogglesCheckbox,showRowCheckbox) remain unchanged
2.9.1 #
- FEAT: Added
blockModifierScrollparameter toFlutterTablePlus— independently control whether Ctrl+wheel (Cmd+wheel on macOS) scrolling is blocked- When
true, Ctrl+wheel events are consumed and do not scroll the table - When
false, Ctrl+wheel scrolls normally even ifonScaleChangedis set - Defaults to
null— automatically followsonScaleChanged(blocked when non-null, allowed when null), preserving existing behavior - Enables use cases where Ctrl+scroll blocking is desired without zoom, or zoom without scroll blocking
- When
2.9.0 #
- FEAT: Added
scaleparameter toFlutterTablePlus— zoom in/out by scaling all table dimensions- Multiplies column widths, row heights, font sizes, padding, and icon sizes by the scale factor
- Default
1.0(100%); no upper limit enforced — caller is responsible for clamping assert(scale > 0)prevents division-by-zero crashes- Scroll positions are automatically adjusted when scale changes so that the same content remains visible
- Resized column widths are stored in logical (unscaled) units — survive scale changes and
onColumnResizedreports logical widths
- FEAT: Added
onScaleChangedcallback — enables Ctrl+wheel (Cmd+wheel on macOS) zoom with scroll prevention- When non-null, the library intercepts Ctrl+wheel events and calls the callback with the proposed new scale
- Uses
_ScaleBlockingScrollPhysicsinternally: overridesshouldAcceptUserOffset()to returnfalsewhen Ctrl is held, preventingScrollablefrom registering a scroll handler — no scroll contamination - Pre-scale scroll offsets are saved as a backup for position correction in
didUpdateWidget scaleStepparameter controls the increment per wheel tick (default0.05)
- FEAT: Added
scaledBy(double factor)method to all theme classesTablePlusTheme,TablePlusHeaderTheme,TablePlusBodyTheme,TablePlusCheckboxTheme,TablePlusEditableTheme,TablePlusScrollbarTheme,TablePlusHoverButtonTheme- Returns a new instance with dimensional values (heights, font sizes, padding, icon sizes) scaled by the factor
- Colors, booleans, durations, and border thickness are intentionally not scaled
- Scrollbar theme and tooltip theme are excluded from scaling (UI chrome, overlay)
- Short-circuits with
return thiswhenfactor == 1.0
- IMPROVEMENT: Sort icons now wrapped in
FittedBox— custom sort icon widgets scale correctly withsortIconWidth - IMPROVEMENT: Body
_cachedRowHeightscleared when scale changes to prevent stale height values
2.8.2 #
- REFACTOR: Deduplicated checkbox creation across header, body, and merged row widgets
- Added
buildCheckbox()helper method toTablePlusCheckboxTheme— builds a fully-themedCheckboxwidget in one call - Replaced 3 identical inline
Checkbox(...)blocks (~47 lines) withcheckboxTheme.buildCheckbox()calls
- Added
- REFACTOR: Deduplicated vertical divider border creation across cell widgets
- Added
verticalDividerSideandverticalDividerBordergetters toTablePlusBodyTheme - Replaced 5 identical inline
Border(right: BorderSide(...))blocks with single getter calls
- Added
- REFACTOR: Moved
_shouldShowBottomBorderlogic toTablePlusBodyTheme.shouldShowBottomBorder()- Removed identical private methods from
_TablePlusRowStateand_TablePlusMergedRowState
- Removed identical private methods from
- REFACTOR: Consolidated hover button positioning logic into
HoverButtonPosition.buildPositioned()- Added
buildPositioned()method toHoverButtonPositionenum - Replaced identical switch-case blocks (~25 lines each) in
TablePlusRowandTablePlusMergedRowwith single method calls
- Added
- REFACTOR: Removed dead if/else branch in
_handleRegularRowSelectionToggle— both branches were identical - REFACTOR: Moved
nonSelectionColumnsfiltering outsideList.generateloop inTablePlusMergedRowto avoid redundant per-iteration computation - REFACTOR: Extracted
_buildScrollbarTrack()helper to consolidate identical vertical/horizontal scrollbar widget trees (~140 lines → ~60 lines) - No API or behavioral changes — all appearance and functionality remain identical
2.8.1 #
- Bumped
just_tooltipdependency to^0.2.5- Supports new
TooltipAlignment.startTargetCenterandTooltipAlignment.endTargetCentervalues — arrow dynamically points toward the center of the target widget
- Supports new
2.8.0 #
- BREAKING: Migrated tooltip system from custom implementation to
just_tooltippackage- Removed
CustomTooltipWrapperwidget andCustomTooltipWrapperThemeclass - Removed
decoration,margin,preferBelow,verticalOffset,exitDuration,customWrapperfromTablePlusTooltipTheme - Added
just_tooltip-based properties:backgroundColor,borderRadius,elevation,boxShadow,borderColor,borderWidth,showArrow,arrowBaseWidth,arrowLength,arrowPositionRatio - Added layout/behavior properties:
direction,alignment,offset,crossAxisOffset,screenMargin,enableTap,enableHover,interactive,animation,animationCurve,fadeBegin,scaleBegin,slideOffset,rotationBegin,animationDuration,hideOnEmptyMessage FlutterTooltipPlusnow accepts bothmessage(String?) andtooltipBuilder(WidgetBuilder?) — unified text and widget tooltips- Added
toJustTooltipTheme()helper onTablePlusTooltipTheme - Re-exported
TooltipDirection,TooltipAlignment,TooltipAnimationfromjust_tooltip
- Removed
2.7.1 #
- FIX:
initialResizedWidthsnow reacts to runtime changes viadidUpdateWidget- Previously only applied once at widget creation (
initState); switching data contexts (e.g., different servers) kept stale resize widths - Uses
mapEqualsfor value-based comparison — avoids unnecessary resets when state management (Riverpod, Provider, etc.) rebuilds pass an equivalent map - Widget no longer needs a
Keyswap to apply new initial widths
- Previously only applied once at widget creation (
2.7.0 #
- FEAT: Added
stretchLastColumnparameter toFlutterTablePlus— last column absorbs remaining space when all columns have fixed widths- When
false(default), columns keep their exact widths and empty space may appear on the right (Windows Explorer behavior) - When
true, the last visible column stretches to fill any leftover space after auto-fit or manual resize - Only activates when remaining space exists; no effect when columns already fill or exceed available width
- Selection column (
__selection__) is excluded from stretching
- When
- FIX: Column reorder now works when dragging to empty space right of the last column
- Added trailing
DragTargetin header row to accept drops beyond the last column - Dropped column moves to the last position, consistent with drag-to-column behavior
- Added trailing
- FEAT: Added
initialResizedWidthsparameter toFlutterTablePlus— restore saved column widths from a previous session- Columns in this map are treated as fixed (exact pixel width), same as user-resized columns
- Only applied once at widget creation; subsequent user resizes override at runtime
- Pair with
onColumnResizedto implement full column width persistence
- FIX: Flexible columns no longer jump in size when window crosses the fixed-total threshold
- Changed proportional distribution condition from
spaceForFlexible <= 0tospaceForFlexible < flexiblePreferredTotal - Flexible columns keep their preferred width (with horizontal scroll) until enough space exists for proportional expansion
- Ensures smooth, continuous width transitions during window resize
- Changed proportional distribution condition from
2.6.0 #
- FEAT: Added
autoFitColumnWidthcallback toFlutterTablePlus— override default auto-fit measurement for columns with custom cell builders- Return a width to override, or
nullto fall back to built-in text measurement - Useful for
statefulCellBuildercolumns with custom styles, padding, or text transformations - Result is clamped to per-column
minWidth/maxWidthconstraints
- Return a width to override, or
- FEAT: Added
TableColumnWidthCalculatorutility class for external column width measurementmeasureTextWidth()— measure a single text string with style, padding, and extra widthcalculateColumnWidth()— measure header + all body values and return optimal width- Follows the same
TextPainter-based pattern asTableRowHeightCalculator
2.5.0 #
- FEAT: Added
showRowCheckboxtoTablePlusCheckboxTheme— hide individual row checkboxes while keeping the header select-all checkbox- When
false, the checkbox column still renders with the header select-all checkbox, but row cells show no checkbox - Row selection is done via row tap only; defaults to
true(backward compatible) - Supported in normal rows, merged rows, and the
material3factory
- When
- BREAKING: Removed deprecated
cellBuilderfromTablePlusColumn- Use
statefulCellBuilderinstead — same functionality with additionalisSelectedandisDimparameters - Migration:
cellBuilder: (ctx, row) => ...→statefulCellBuilder: (ctx, row, _, _) => ...
- Use
2.4.2 #
- FEAT: Added
sortIconWidthtoTablePlusHeaderThemefor accurate tooltip overflow detection with custom sort icons- Sort icon is wrapped in
SizedBox(width: sortIconWidth)to enforce consistent layout - Header tooltip calculation uses
sortIconSpacing + sortIconWidthinstead of hardcoded24.0 - Tooltip now checks actual icon visibility — no space subtracted when
unsortedicon isnull - Default
16.0matches built-inSortIcons.defaultIconssize
- Sort icon is wrapped in
2.4.1 #
- FIX: Columns hitting
maxWidthno longer leave unused space at the end of the table- Proportional width distribution now uses iterative redistribution — when a flexible column is clamped to
maxWidth, the excess space is re-distributed to remaining flexible columns - Guarantees all available width is consumed when uncapped columns exist
- Proportional width distribution now uses iterative redistribution — when a flexible column is clamped to
2.4.0 #
- FEAT: Added
statefulCellBuildertoTablePlusColumn— custom cell builder withisSelectedandisDimstate- Signature:
Widget Function(BuildContext context, T rowData, bool isSelected, bool isDim) - Takes precedence over
cellBuilderwhen both are provided - Added
hasCustomCellBuildergetter andbuildCustomCell()helper onTablePlusColumn
- Signature:
- DEPRECATED:
cellBuilder— usestatefulCellBuilderinstead for access to row selection and dim state
2.3.5 #
- FIX: Last row no longer obscured by horizontal scrollbar
- Automatically reserves space equal to
scrollbarTheme.trackWidthwhen horizontal scrollbar is visible - No new parameters required — applied internally based on existing scroll/theme conditions
- Automatically reserves space equal to
2.3.4 #
- FEAT: Added
cellTapTogglesCheckboxtoTablePlusCheckboxTheme— expands checkbox tap area to the entire selection column cell, preventing accidental single-select when missing the checkbox
2.3.3 #
- FIX:
checkboxColumnWidthbelow defaultminWidth(50) no longer crashes- Selection column now sets
minWidthequal tocheckboxColumnWidth, preventingclamp(min > max)error - e.g.
checkboxColumnWidth: 45previously threwInvalid argument(s): 50.0
- Selection column now sets
2.3.2 #
- FIX: Checkbox column no longer expands proportionally with available width
- Added
maxWidthconstraint equal tocheckboxColumnWidthon the internal__selection__column - Ensures the selection column stays at its configured fixed width regardless of table size
- Added
- FIX: Fixed-width columns no longer cause space loss in proportional layout
- Columns whose
maxWidthcaps their preferred width are now excluded from proportional distribution - Remaining space is distributed only among flexible columns, eliminating the right-side gap
- Columns whose
2.3.1 #
- BREAKING: Extracted
TablePlusResizeHandleThemefrom flat fields onTablePlusHeaderTheme- Removed
resizeHandleWidth,resizeHandleColor,resizeHandleThickness,resizeHandleIndent,resizeHandleEndIndentfromTablePlusHeaderTheme - Added
TablePlusResizeHandleThemeclass withwidth,color,thickness,indent,endIndentandcopyWith - New composed property:
TablePlusHeaderTheme.resizeHandle(defaultconst TablePlusResizeHandleTheme()) - Consistent with existing
TablePlusHeaderBorderTheme/TablePlusHeaderDividerThemepattern
- Removed
- FIX: Hide vertical divider on column reorder drag feedback
- The floating header cell during drag-and-drop reorder no longer renders the right-edge vertical divider
- Added
showDividerparameter to_HeaderCell(defaulttrue, set tofalsefor feedback only)
2.3.0 #
- FEAT: Added
tapTargetSizetoTablePlusCheckboxTheme- Expands the checkbox tap/hover hit-test area without changing the visual checkbox size
- Configurable in logical pixels (e.g.,
tapTargetSize: 40gives a 40×40 hit area) - Defaults to
sizewhen not set — fully backward compatible - Applied to body rows, header select-all, and merged row checkboxes
- BREAKING: Refactored header border/divider into separate theme classes
- Removed
showVerticalDividers,showBottomDivider,dividerColor,dividerThicknessfromTablePlusHeaderTheme - Added
TablePlusHeaderBorderThemefor top/bottom horizontal borders (show,color,thickness) - Added
TablePlusHeaderDividerThemefor vertical column dividers withindent/endIndentsupport - New properties:
topBorder(default hidden),bottomBorder(default visible),verticalDivider(default visible) - Vertical dividers now rendered as
Stackoverlay instead ofBoxDecoration.border, enabling indent control
- Removed
- FEAT: Resize handle
indent/endIndent/thicknessthemingresizeHandleThicknesscontrols the visible indicator line width (default2.0)resizeHandleIndent/resizeHandleEndIndentinset the indicator from top/bottom edges
- FIX: Resize handle now centered on column boundary
- Previously the handle was positioned entirely inside the left column (
right: 0), making it asymmetric - Now uses a header-level
Stackoverlay withleft: cumulativeWidth - handleWidth / 2, giving equal hit area on both sides of the border - Visual indicator line renders at the exact column boundary center
ValueKeyper handle ensures stable state across rebuilds and column reorders
- Previously the handle was positioned entirely inside the left column (
2.2.0 #
- FEAT: Drag-to-select rows
enableDragSelectionparameter enables mouse drag row selection (Excel/Finder style)onDragSelectionUpdatecallback fires during drag with the dragged range row IDsonDragSelectionEndcallback fires once when drag ends- Auto-scroll when dragging near viewport edges (~60fps, speed proportional to edge proximity)
- 8px activation threshold prevents conflicts with existing tap/click gestures
- Works with uniform heights (O(1)), dynamic heights, and merged row groups
- Parent controls selection behavior (replace or additive) — consistent with UI-only philosophy
2.1.1 #
- IMPROVEMENT: Auto-scroll during column resize drag
- When dragging a resize handle near the viewport edge, the table automatically scrolls in that direction
- Scroll speed is proportional to pointer proximity to the edge (50px activation zone)
2.1.0 #
- FEAT: Column resizing support
resizableparameter enables drag-to-resize on column header edgesonColumnResizedcallback fires with(String columnKey, double newWidth)for persistence- Respects per-column
minWidth/maxWidthconstraints; selection column excluded
- FEAT: Resize handle theming in
TablePlusHeaderThemeresizeHandleWidth(default8.0) andresizeHandleColorproperties
- FIX:
minWidth/maxWidthconstraints now enforced in all layout calculation paths
2.0.2 #
- FIX:
showCheckboxColumn: falseinTablePlusCheckboxThemenow properly hides the checkbox column - IMPROVEMENT: Header select-all checkbox auto-hides when
onSelectAllis null
2.0.1 #
- FIX: Fixed header-body column width misalignment when table width exceeds total column widths
2.0.0 #
- BREAKING: Migrated from
Map<String, dynamic>to generic type parameter<T>FlutterTablePlus<T>accepts any data model typerowIdKey→rowId: String Function(T)dimRowKey/invertDimRow→isDimRow: bool Function(T)?TablePlusColumn<T>requiresvalueAccessor: (T) => dynamiccellBuilder,hoverButtonBuilder,calculateRowHeightsignatures useTinstead ofMaponCellChangedreceivesTinstead ofMapMergedRowGroup<T>parameterized with data typesummaryRowData→summaryBuilder: Widget? Function(String columnKey)?
1.17.2 #
- PERF: Eliminated full table rebuilds on mouse hover
- PERF: Cached total data height, row count, and visible columns computation
- PERF: Added overflow detection caching in
TablePlusCell - FIX: Added missing
TextPainter.dispose()inTableRowHeightCalculator
1.17.1 #
- FIX: Fixed scroll controllers being destroyed on every parent rebuild
- FIX: Improved scroll sync reliability in
SyncedScrollControllers
1.17.0 #
- IMPROVEMENT: Added
itemExtentBuilderfor improved scroll performance with large datasets (10,000+ rows)
1.16.7 #
- FEAT: Added
verticalOffsettoTablePlusTooltipThemefor customizable tooltip positioning
1.16.6 #
- BREAKING: Replaced
isDimRowcallback withdimRowKeyandinvertDimRow
1.16.5 #
- FEAT: Added dim row feature with
isDimRowcallback and theme support
1.16.4 #
- FIX: Rapid consecutive taps now correctly trigger multiple
onRowTapwhenonRowDoubleTapis null
1.16.3 #
- FEAT: Added
tooltipBuilderfor custom widget tooltips (priority:tooltipBuilder>tooltipFormatter> default) - FEAT: Enhanced tooltip timing with
exitDurationproperty and hover interaction support - FEAT: Intelligent tooltip positioning that adapts to available screen space
- FEAT: Added
CustomTooltipWrapperThemefor tooltip configuration
1.16.2 #
- FEAT: Added configurable
doubleClickTimetoTablePlusBodyTheme(default 500ms)
1.16.1 #
- FEAT: Added
isSelectedparameter toonRowSecondaryTapDowncallback - IMPROVEMENT: Enhanced
TablePlusScrollbarThemewith independent track/thumb styling (trackWidth,thickness,radius,thumbColor,trackBorder)
1.16.0 #
- BREAKING: Removed deprecated
TablePlusSelectionTheme- Selection styling →
TablePlusBodyTheme(selectedRowColor,selectedRowTextStyle) - Checkbox properties →
TablePlusCheckboxTheme - Row interaction colors →
TablePlusBodyTheme
- Selection styling →
- BREAKING:
onRowSecondaryTap→onRowSecondaryTapDownwithTapDownDetailsandRenderBox
1.15.6 #
- FEAT: Added
TooltipBehavior.onlyTextOverflow— tooltips only appear when text overflows - FIX: Fixed row hover colors not appearing due to Stack blocking
CustomInkWell - REFACTOR: Moved row interaction properties from
TablePlusSelectionThemetoTablePlusBodyTheme - DEPRECATED: Row interaction properties in
TablePlusSelectionTheme(removed in 1.16.0)
1.15.5 #
- FEAT: Added
TablePlusCheckboxThemewith Material 3WidgetStatePropertysupport - DEPRECATED: Checkbox properties in
TablePlusSelectionTheme
1.15.4 #
- BREAKING: Removed
TooltipBehavior.onOverflowOnly
1.15.3 #
- FEAT: Added
onCheckboxChangedcallback to distinguish checkbox clicks from row clicks - FEAT: Added checkbox color customization (
hoverColor,focusColor,fillColor,side) - FIX: Fixed tooltip null check error during column reordering
1.15.2 #
- FEAT: Added
tooltipFormattertoTablePlusColumnfor custom tooltip content - UPDATE: Minimum Flutter version changed to >=3.10.0
1.15.1 #
- FIX: Static analysis fixes
1.15.0 #
- BREAKING: Removed frozen column functionality (
frozenColumns,TablePlusFrozenTheme,TablePlusDividerTheme) - FEAT: Hover button system with
hoverButtonBuilder,HoverButtonPosition, andTablePlusHoverButtonTheme - FEAT: Enhanced expandable row functionality for merged row groups
1.14.2 #
- FEAT: Expandable summary rows for merged row groups
isExpandable,isExpanded,summaryRowDataonMergedRowGrouponMergedRowExpandTogglecallback
- FEAT: Added
summaryRowBackgroundColortoTablePlusBodyTheme - FIX: Fixed tooltip and height calculation in merged rows
1.14.1 #
- FEAT: Added
lastRowBorderBehaviortoTablePlusBodyTheme(never,always,smart) - FIX: Fixed sorting with merged rows
1.14.0 #
- FEAT: Frozen column divider with
TablePlusDividerTheme - FIX: Fixed layout overflow in constrained height containers
- FIX: Fixed vertical scrollbar track height calculation
1.13.2 #
- FIX: Fixed single selection mode — clicking selected row now correctly deselects
1.13.1 #
- FEAT: Added
calculateRowHeightcallback andTableRowHeightCalculatorutility
1.13.0 #
- BREAKING: Removed dynamic row height feature (
RowHeightMode,minRowHeight,TextHeightCalculator)
1.12.0 #
- FEAT: Dynamic row height with
RowHeightMode.dynamicandminRowHeight - FEAT: Improved sorting with merged rows
- REFACTOR:
MergedRowGroupusesrowKeysinstead oforiginalIndices - FIX: Fixed alternate row color logic and background color application
1.11.1 #
- IMPROVEMENT: Improved focus handling for editable cells
1.11.0 #
- FEAT: Merged row functionality with
MergedRowGroupandMergeCellConfig- Selection and editing support for merged cells
- Auto-save on focus loss for editable cells
- FEAT: Added
cellContainerPaddingtoTablePlusEditableTheme
1.10.1 #
- CHORE: Applied
dart format
1.10.0 #
- FEAT: Dynamic row height calculation based on content
- FEAT: Dynamic scrollbar visibility based on content height
1.9.0 #
- FEAT: Added
headerTooltipBehaviortoTablePlusColumn
1.8.0 #
- FEAT: Added
tooltipBehaviortoTablePlusColumn(always,onOverflowOnly,never) - DEPRECATED:
showTooltipOnOverflowin favor oftooltipBehavior
1.7.0 #
- FEAT: Added
noDataWidgetfor custom empty state display - IMPROVEMENT: Sorting auto-disabled when data is empty
1.6.2 #
- FEAT: Added
visibleproperty toTablePlusColumnfor column visibility control
1.6.1 #
- FEAT: Added
selectedTextStyletoTablePlusTheme
1.6.0 #
- FEAT:
decorationandcellDecorationinTablePlusHeaderTheme - FEAT: Granular row interaction colors in
TablePlusSelectionTheme(hoverColor,splashColor,highlightColor) - FEAT:
dividerThicknessinTablePlusHeaderTheme - FEAT:
rowIdKeyfor custom row identifier field - FEAT:
textOverflowproperty onTablePlusColumnwith auto-tooltip on ellipsis
1.5.0 #
- FEAT: Added
SelectionMode.singlefor single row selection
1.4.0 #
- FEAT: Disable column reordering with
onColumnReorder: null - FEAT: Disable sorting with
onSort: null
1.3.0 #
- FEAT: Enabled simultaneous selection and editing
1.2.0 #
- FEAT: Configurable sort cycle order via
sortCycle
1.1.2 #
- FEAT: Added
hintTexttoTablePlusColumnandhintStyletoTablePlusEditableTheme - FEAT: Added
onRowDoubleTapandonRowSecondaryTapcallbacks
1.1.1 #
- Updated README.md
1.1.0 #
- FEAT: Cell editing with
isEditable, per-columneditable,onCellChanged, andTablePlusEditableTheme
1.0.0 #
- Initial release — customizable table widget with synchronized scrolling, theming, sorting, selection, column reordering, and custom cell builders