From 949123bad4d7b717af4aadce0ea52635642f7988 Mon Sep 17 00:00:00 2001 From: Kate Lovett Date: Thu, 30 Apr 2026 12:05:41 -0500 Subject: [PATCH 1/7] Fix crash due to empty or fully collapsed tree --- .../two_dimensional_scrollables/CHANGELOG.md | 4 + .../lib/src/tree_view/render_tree.dart | 83 ++++++------- .../two_dimensional_scrollables/pubspec.yaml | 2 +- .../test/tree_view/render_tree_test.dart | 117 +++++++++++++++++- 4 files changed, 161 insertions(+), 45 deletions(-) diff --git a/packages/two_dimensional_scrollables/CHANGELOG.md b/packages/two_dimensional_scrollables/CHANGELOG.md index 813b9bbc00ed..2ea969b833dd 100644 --- a/packages/two_dimensional_scrollables/CHANGELOG.md +++ b/packages/two_dimensional_scrollables/CHANGELOG.md @@ -1,3 +1,7 @@ +## 0.5.2 + +* Fixes a crash in `TreeView` when it shrinks to 0 rows or the last node is collapsed. + ## 0.5.1 * Fixes an infinite loop of onExit/onEnter events when setState is called within onEnter in a TableSpan. diff --git a/packages/two_dimensional_scrollables/lib/src/tree_view/render_tree.dart b/packages/two_dimensional_scrollables/lib/src/tree_view/render_tree.dart index 10eb1ac3646a..20bdad621399 100644 --- a/packages/two_dimensional_scrollables/lib/src/tree_view/render_tree.dart +++ b/packages/two_dimensional_scrollables/lib/src/tree_view/render_tree.dart @@ -346,13 +346,13 @@ class RenderTreeViewport extends RenderTwoDimensionalViewport { ); _horizontalOverflows = maxHorizontalExtent > 0.0; - final double verticalLeadingExtent = verticalOffset.pixels; - final double verticalTrailingExtent = - _rowMetrics[_lastRow!]!.trailingOffset - viewportDimension.height; - final double maxVerticalExtent = math.max( - 0.0, - math.max(verticalLeadingExtent, verticalTrailingExtent), - ); + final double maxVerticalExtent = _rowMetrics.isEmpty + ? 0.0 + : math.max( + 0.0, + _rowMetrics[_rowMetrics.length - 1]!.trailingOffset - + viewportDimension.height, + ); _verticalOverflows = maxVerticalExtent > 0.0; final bool acceptedDimension = @@ -389,43 +389,40 @@ class RenderTreeViewport extends RenderTwoDimensionalViewport { } } - if (_firstRow == null) { - assert(_lastRow == null); - return; - } - assert(_firstRow != null && _lastRow != null); - - _Span rowSpan; - double rowOffset = - -verticalOffset.pixels + - _rowMetrics[_firstRow!]!.leadingOffset + - _vAlignmentOffset; - for (int row = _firstRow!; row <= _lastRow!; row++) { - rowSpan = _rowMetrics[row]!; - final double rowHeight = rowSpan.extent; - if (_animationLeadingIndices.keys.contains(row)) { - rowOffset -= rowSpan.animationOffset; + if (_firstRow != null) { + assert(_lastRow != null); + _Span rowSpan; + double rowOffset = + -verticalOffset.pixels + + _rowMetrics[_firstRow!]!.leadingOffset + + _vAlignmentOffset; + for (int row = _firstRow!; row <= _lastRow!; row++) { + rowSpan = _rowMetrics[row]!; + final double rowHeight = rowSpan.extent; + if (_animationLeadingIndices.keys.contains(row)) { + rowOffset -= rowSpan.animationOffset; + } + rowOffset += rowSpan.configuration.padding.leading; + + final vicinity = TreeVicinity(depth: _rowDepths[row]!, row: row); + final RenderBox child = buildOrObtainChildFor(vicinity)!; + final TwoDimensionalViewportParentData parentData = parentDataOf(child); + final childConstraints = BoxConstraints( + minHeight: rowHeight, + maxHeight: rowHeight, + // Width is allowed to be unbounded. + ); + child.layout(childConstraints, parentUsesSize: true); + parentData.layoutOffset = Offset( + (_rowDepths[row]! * indentation) - horizontalOffset.pixels, + rowOffset, + ); + rowOffset += rowHeight + rowSpan.configuration.padding.trailing; + _furthestHorizontalExtent = math.max( + parentData.layoutOffset!.dx + child.size.width, + _furthestHorizontalExtent, + ); } - rowOffset += rowSpan.configuration.padding.leading; - - final vicinity = TreeVicinity(depth: _rowDepths[row]!, row: row); - final RenderBox child = buildOrObtainChildFor(vicinity)!; - final TwoDimensionalViewportParentData parentData = parentDataOf(child); - final childConstraints = BoxConstraints( - minHeight: rowHeight, - maxHeight: rowHeight, - // Width is allowed to be unbounded. - ); - child.layout(childConstraints, parentUsesSize: true); - parentData.layoutOffset = Offset( - (_rowDepths[row]! * indentation) - horizontalOffset.pixels, - rowOffset, - ); - rowOffset += rowHeight + rowSpan.configuration.padding.trailing; - _furthestHorizontalExtent = math.max( - parentData.layoutOffset!.dx + child.size.width, - _furthestHorizontalExtent, - ); } _updateScrollBounds(); } diff --git a/packages/two_dimensional_scrollables/pubspec.yaml b/packages/two_dimensional_scrollables/pubspec.yaml index 3942be484044..df4051284eeb 100644 --- a/packages/two_dimensional_scrollables/pubspec.yaml +++ b/packages/two_dimensional_scrollables/pubspec.yaml @@ -1,6 +1,6 @@ name: two_dimensional_scrollables description: Widgets that scroll using the two dimensional scrolling foundation. -version: 0.5.1 +version: 0.5.2 repository: https://github.com/flutter/packages/tree/main/packages/two_dimensional_scrollables issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+two_dimensional_scrollables%22+ diff --git a/packages/two_dimensional_scrollables/test/tree_view/render_tree_test.dart b/packages/two_dimensional_scrollables/test/tree_view/render_tree_test.dart index 2dc2a717dba8..a8c0e56183a8 100644 --- a/packages/two_dimensional_scrollables/test/tree_view/render_tree_test.dart +++ b/packages/two_dimensional_scrollables/test/tree_view/render_tree_test.dart @@ -682,7 +682,7 @@ void main() { await tester.pumpWidget(MaterialApp(home: treeView)); await tester.pump(); expect(verticalController.position.pixels, 0.0); - expect(verticalController.position.maxScrollExtent, 600.0); + expect(verticalController.position.maxScrollExtent, 2200.0); bool rowNeedsPaint(String row) { return find.text(row).evaluate().first.renderObject!.debugNeedsPaint; @@ -866,6 +866,121 @@ void main() { ); }); }); + + group('Scroll bounds', () { + testWidgets( + 'shrinking to 0 rows updates scroll bounds and does not crash', + (WidgetTester tester) async { + var rows = 10; + late StateSetter setState; + final controller = ScrollController(); + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: SizedBox( + height: 400, + width: 400, + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setter) { + setState = setter; + return TreeView( + verticalDetails: ScrollableDetails.vertical( + controller: controller, + ), + tree: List>.generate( + rows, + (int index) => TreeViewNode('Row $index'), + ), + treeRowBuilder: (TreeViewNode node) => + const TreeRow(extent: FixedTreeRowExtent(64.0)), + treeNodeBuilder: + ( + BuildContext context, + TreeViewNode node, + AnimationStyle toggleAnimationStyle, + ) => Text(node.content), + ); + }, + ), + ), + ), + ), + ); + + await tester.pump(); + final double oldMax = controller.position.maxScrollExtent; + expect(oldMax, greaterThan(0)); + controller.jumpTo(oldMax); + await tester.pump(); + expect(controller.offset, oldMax); + + // Shrink to 0 rows. + setState(() { + rows = 0; + }); + // This should not crash and should update scroll bounds. + await tester.pump(); + + expect(controller.position.maxScrollExtent, 0.0); + expect(controller.offset, 0.0); + }, + ); + + testWidgets( + 'collapsing last node updates scroll bounds and does not crash', + (WidgetTester tester) async { + final treeController = TreeViewController(); + final scrollController = ScrollController(); + + final treeNodes = >[ + TreeViewNode( + 'Root', + expanded: true, + children: >[TreeViewNode('Child')], + ), + ]; + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: SizedBox( + height: 100, + width: 400, + child: TreeView( + controller: treeController, + verticalDetails: ScrollableDetails.vertical( + controller: scrollController, + ), + tree: treeNodes, + treeRowBuilder: (TreeViewNode node) => + const TreeRow(extent: FixedTreeRowExtent(60.0)), + treeNodeBuilder: + ( + BuildContext context, + TreeViewNode node, + AnimationStyle toggleAnimationStyle, + ) => Text(node.content), + ), + ), + ), + ), + ); + + await tester.pump(); + // Root (60) + Child (60) = 120. Viewport is 100. + expect(scrollController.position.maxScrollExtent, 20.0); + scrollController.jumpTo(20.0); + await tester.pump(); + + // Collapse Root. Now only Root (60) is visible. + treeController.toggleNode(treeNodes[0]); + await tester.pumpAndSettle(); + + expect(scrollController.position.maxScrollExtent, 0.0); + expect(scrollController.offset, 0.0); + }, + ); + }); }); } From dca1ce243ba307ddc9cf08985a0823f6f13c80e7 Mon Sep 17 00:00:00 2001 From: Kate Lovett Date: Thu, 30 Apr 2026 12:18:02 -0500 Subject: [PATCH 2/7] G-feedback --- .../lib/src/tree_view/render_tree.dart | 34 +++++++++++-------- 1 file changed, 20 insertions(+), 14 deletions(-) diff --git a/packages/two_dimensional_scrollables/lib/src/tree_view/render_tree.dart b/packages/two_dimensional_scrollables/lib/src/tree_view/render_tree.dart index 20bdad621399..d11947fad9ba 100644 --- a/packages/two_dimensional_scrollables/lib/src/tree_view/render_tree.dart +++ b/packages/two_dimensional_scrollables/lib/src/tree_view/render_tree.dart @@ -339,13 +339,7 @@ class RenderTreeViewport extends RenderTwoDimensionalViewport { } } - void _updateScrollBounds() { - final double maxHorizontalExtent = math.max( - 0.0, - _furthestHorizontalExtent - viewportDimension.width, - ); - _horizontalOverflows = maxHorizontalExtent > 0.0; - + void _updateVerticalScrollBounds() { final double maxVerticalExtent = _rowMetrics.isEmpty ? 0.0 : math.max( @@ -354,16 +348,24 @@ class RenderTreeViewport extends RenderTwoDimensionalViewport { viewportDimension.height, ); _verticalOverflows = maxVerticalExtent > 0.0; - - final bool acceptedDimension = - horizontalOffset.applyContentDimensions(0.0, maxHorizontalExtent) && - verticalOffset.applyContentDimensions(0.0, maxVerticalExtent); - + final bool acceptedDimension = verticalOffset.applyContentDimensions( + 0.0, + maxVerticalExtent, + ); if (!acceptedDimension) { _updateFirstAndLastVisibleRow(); } } + void _updateHorizontalScrollBounds() { + final double maxHorizontalExtent = math.max( + 0.0, + _furthestHorizontalExtent - viewportDimension.width, + ); + _horizontalOverflows = maxHorizontalExtent > 0.0; + horizontalOffset.applyContentDimensions(0.0, maxHorizontalExtent); + } + @override void layoutChildSequence() { _updateAnimationCache(); @@ -389,6 +391,8 @@ class RenderTreeViewport extends RenderTwoDimensionalViewport { } } + _updateVerticalScrollBounds(); + if (_firstRow != null) { assert(_lastRow != null); _Span rowSpan; @@ -419,12 +423,14 @@ class RenderTreeViewport extends RenderTwoDimensionalViewport { ); rowOffset += rowHeight + rowSpan.configuration.padding.trailing; _furthestHorizontalExtent = math.max( - parentData.layoutOffset!.dx + child.size.width, + parentData.layoutOffset!.dx + + horizontalOffset.pixels + + child.size.width, _furthestHorizontalExtent, ); } } - _updateScrollBounds(); + _updateHorizontalScrollBounds(); } // Maps the UniqueKey associated with animating node segments with the clip From 4d81dbdb3857a6df7809bf6520bd423267b1572d Mon Sep 17 00:00:00 2001 From: Kate Lovett Date: Fri, 8 May 2026 08:48:01 -0500 Subject: [PATCH 3/7] Review feedbcak --- packages/two_dimensional_scrollables/CHANGELOG.md | 2 +- .../test/tree_view/render_tree_test.dart | 15 +++++++++++++-- 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/packages/two_dimensional_scrollables/CHANGELOG.md b/packages/two_dimensional_scrollables/CHANGELOG.md index 2ea969b833dd..f85e1038b66f 100644 --- a/packages/two_dimensional_scrollables/CHANGELOG.md +++ b/packages/two_dimensional_scrollables/CHANGELOG.md @@ -1,6 +1,6 @@ ## 0.5.2 -* Fixes a crash in `TreeView` when it shrinks to 0 rows or the last node is collapsed. +* Fixes a crash in `TreeView` when it collapses to 0 rows or the last node is collapsed. ## 0.5.1 diff --git a/packages/two_dimensional_scrollables/test/tree_view/render_tree_test.dart b/packages/two_dimensional_scrollables/test/tree_view/render_tree_test.dart index a8c0e56183a8..4f9a60862b6b 100644 --- a/packages/two_dimensional_scrollables/test/tree_view/render_tree_test.dart +++ b/packages/two_dimensional_scrollables/test/tree_view/render_tree_test.dart @@ -682,6 +682,8 @@ void main() { await tester.pumpWidget(MaterialApp(home: treeView)); await tester.pump(); expect(verticalController.position.pixels, 0.0); + // The total height accounts for all visible nodes (7 nodes * 400 = 2800). + // With a default viewport height of 600, the max scroll extent is 2200 (2800 - 600). expect(verticalController.position.maxScrollExtent, 2200.0); bool rowNeedsPaint(String row) { @@ -868,9 +870,11 @@ void main() { }); group('Scroll bounds', () { + // Regression tests for https://github.com/flutter/flutter/issues/164981 testWidgets( 'shrinking to 0 rows updates scroll bounds and does not crash', (WidgetTester tester) async { + // Setup a TreeView with 10 rows to ensure the content exceeds the viewport height. var rows = 10; late StateSetter setState; final controller = ScrollController(); @@ -910,6 +914,8 @@ void main() { await tester.pump(); final double oldMax = controller.position.maxScrollExtent; expect(oldMax, greaterThan(0)); + + // Jump to the maximum scroll extent to test position correction. controller.jumpTo(oldMax); await tester.pump(); expect(controller.offset, oldMax); @@ -921,6 +927,7 @@ void main() { // This should not crash and should update scroll bounds. await tester.pump(); + // Verify that the scroll bounds are updated to 0.0 and the offset is corrected to 0.0. expect(controller.position.maxScrollExtent, 0.0); expect(controller.offset, 0.0); }, @@ -932,6 +939,7 @@ void main() { final treeController = TreeViewController(); final scrollController = ScrollController(); + // Setup a TreeView with one expanded root node and one child node. final treeNodes = >[ TreeViewNode( 'Root', @@ -967,15 +975,18 @@ void main() { ); await tester.pump(); - // Root (60) + Child (60) = 120. Viewport is 100. + // Root (60) + Child (60) = 120. Viewport is 100. Max scroll extent is 20. expect(scrollController.position.maxScrollExtent, 20.0); + + // Jump to the maximum scroll extent. scrollController.jumpTo(20.0); await tester.pump(); - // Collapse Root. Now only Root (60) is visible. + // Collapse the Root node. Now only Root (60) is visible, fitting within the viewport (100). treeController.toggleNode(treeNodes[0]); await tester.pumpAndSettle(); + // Verify that the scroll bounds are updated to 0.0 and the offset is corrected to 0.0. expect(scrollController.position.maxScrollExtent, 0.0); expect(scrollController.offset, 0.0); }, From 8880ff95eb0d66f05114983d01c2b0082d0691f0 Mon Sep 17 00:00:00 2001 From: Kate Lovett Date: Fri, 8 May 2026 08:48:58 -0500 Subject: [PATCH 4/7] Format --- .../test/tree_view/render_tree_test.dart | 190 +++++++++--------- 1 file changed, 94 insertions(+), 96 deletions(-) diff --git a/packages/two_dimensional_scrollables/test/tree_view/render_tree_test.dart b/packages/two_dimensional_scrollables/test/tree_view/render_tree_test.dart index 4f9a60862b6b..4ed7c204bd96 100644 --- a/packages/two_dimensional_scrollables/test/tree_view/render_tree_test.dart +++ b/packages/two_dimensional_scrollables/test/tree_view/render_tree_test.dart @@ -871,126 +871,124 @@ void main() { group('Scroll bounds', () { // Regression tests for https://github.com/flutter/flutter/issues/164981 - testWidgets( - 'shrinking to 0 rows updates scroll bounds and does not crash', - (WidgetTester tester) async { + testWidgets('shrinking to 0 rows updates scroll bounds and does not crash', ( + WidgetTester tester, + ) async { // Setup a TreeView with 10 rows to ensure the content exceeds the viewport height. - var rows = 10; - late StateSetter setState; - final controller = ScrollController(); - await tester.pumpWidget( - MaterialApp( - home: Scaffold( - body: SizedBox( - height: 400, - width: 400, - child: StatefulBuilder( - builder: (BuildContext context, StateSetter setter) { - setState = setter; - return TreeView( - verticalDetails: ScrollableDetails.vertical( - controller: controller, - ), - tree: List>.generate( - rows, - (int index) => TreeViewNode('Row $index'), - ), - treeRowBuilder: (TreeViewNode node) => - const TreeRow(extent: FixedTreeRowExtent(64.0)), - treeNodeBuilder: - ( - BuildContext context, - TreeViewNode node, - AnimationStyle toggleAnimationStyle, - ) => Text(node.content), - ); - }, - ), + var rows = 10; + late StateSetter setState; + final controller = ScrollController(); + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: SizedBox( + height: 400, + width: 400, + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setter) { + setState = setter; + return TreeView( + verticalDetails: ScrollableDetails.vertical( + controller: controller, + ), + tree: List>.generate( + rows, + (int index) => TreeViewNode('Row $index'), + ), + treeRowBuilder: (TreeViewNode node) => + const TreeRow(extent: FixedTreeRowExtent(64.0)), + treeNodeBuilder: + ( + BuildContext context, + TreeViewNode node, + AnimationStyle toggleAnimationStyle, + ) => Text(node.content), + ); + }, ), ), ), - ); + ), + ); + + await tester.pump(); + final double oldMax = controller.position.maxScrollExtent; + expect(oldMax, greaterThan(0)); - await tester.pump(); - final double oldMax = controller.position.maxScrollExtent; - expect(oldMax, greaterThan(0)); - // Jump to the maximum scroll extent to test position correction. - controller.jumpTo(oldMax); - await tester.pump(); - expect(controller.offset, oldMax); + controller.jumpTo(oldMax); + await tester.pump(); + expect(controller.offset, oldMax); - // Shrink to 0 rows. - setState(() { - rows = 0; - }); - // This should not crash and should update scroll bounds. - await tester.pump(); + // Shrink to 0 rows. + setState(() { + rows = 0; + }); + // This should not crash and should update scroll bounds. + await tester.pump(); // Verify that the scroll bounds are updated to 0.0 and the offset is corrected to 0.0. - expect(controller.position.maxScrollExtent, 0.0); - expect(controller.offset, 0.0); - }, - ); + expect(controller.position.maxScrollExtent, 0.0); + expect(controller.offset, 0.0); + }); - testWidgets( - 'collapsing last node updates scroll bounds and does not crash', - (WidgetTester tester) async { - final treeController = TreeViewController(); - final scrollController = ScrollController(); + testWidgets('collapsing last node updates scroll bounds and does not crash', ( + WidgetTester tester, + ) async { + final treeController = TreeViewController(); + final scrollController = ScrollController(); // Setup a TreeView with one expanded root node and one child node. - final treeNodes = >[ - TreeViewNode( - 'Root', - expanded: true, - children: >[TreeViewNode('Child')], - ), - ]; + final treeNodes = >[ + TreeViewNode( + 'Root', + expanded: true, + children: >[TreeViewNode('Child')], + ), + ]; - await tester.pumpWidget( - MaterialApp( - home: Scaffold( - body: SizedBox( - height: 100, - width: 400, - child: TreeView( - controller: treeController, - verticalDetails: ScrollableDetails.vertical( - controller: scrollController, - ), - tree: treeNodes, - treeRowBuilder: (TreeViewNode node) => - const TreeRow(extent: FixedTreeRowExtent(60.0)), - treeNodeBuilder: - ( - BuildContext context, - TreeViewNode node, - AnimationStyle toggleAnimationStyle, - ) => Text(node.content), + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: SizedBox( + height: 100, + width: 400, + child: TreeView( + controller: treeController, + verticalDetails: ScrollableDetails.vertical( + controller: scrollController, ), + tree: treeNodes, + treeRowBuilder: (TreeViewNode node) => + const TreeRow(extent: FixedTreeRowExtent(60.0)), + treeNodeBuilder: + ( + BuildContext context, + TreeViewNode node, + AnimationStyle toggleAnimationStyle, + ) => Text(node.content), ), ), ), - ); + ), + ); - await tester.pump(); + await tester.pump(); // Root (60) + Child (60) = 120. Viewport is 100. Max scroll extent is 20. - expect(scrollController.position.maxScrollExtent, 20.0); - + expect(scrollController.position.maxScrollExtent, 20.0); + // Jump to the maximum scroll extent. - scrollController.jumpTo(20.0); - await tester.pump(); + scrollController.jumpTo(20.0); + await tester.pump(); // Collapse the Root node. Now only Root (60) is visible, fitting within the viewport (100). - treeController.toggleNode(treeNodes[0]); - await tester.pumpAndSettle(); + treeController.toggleNode(treeNodes[0]); + await tester.pumpAndSettle(); // Verify that the scroll bounds are updated to 0.0 and the offset is corrected to 0.0. - expect(scrollController.position.maxScrollExtent, 0.0); - expect(scrollController.offset, 0.0); - }, - ); + expect(scrollController.position.maxScrollExtent, 0.0); + expect(scrollController.offset, 0.0); + }); }); }); } From 5636677ddb6f8f7e69b673216806c94ec882291a Mon Sep 17 00:00:00 2001 From: Kate Lovett Date: Fri, 8 May 2026 12:20:02 -0500 Subject: [PATCH 5/7] Fix for stable and master --- .../lib/src/tree_view/render_tree.dart | 85 +++++++++++-------- 1 file changed, 50 insertions(+), 35 deletions(-) diff --git a/packages/two_dimensional_scrollables/lib/src/tree_view/render_tree.dart b/packages/two_dimensional_scrollables/lib/src/tree_view/render_tree.dart index d11947fad9ba..c9b11dacbd3b 100644 --- a/packages/two_dimensional_scrollables/lib/src/tree_view/render_tree.dart +++ b/packages/two_dimensional_scrollables/lib/src/tree_view/render_tree.dart @@ -353,6 +353,8 @@ class RenderTreeViewport extends RenderTwoDimensionalViewport { maxVerticalExtent, ); if (!acceptedDimension) { + // If the scroll offset was corrected (e.g., clamped), we must + // re-calculate which rows are now visible. _updateFirstAndLastVisibleRow(); } } @@ -391,44 +393,57 @@ class RenderTreeViewport extends RenderTwoDimensionalViewport { } } + // Ensure vertical scroll bounds are updated before layout. This allows + // any scroll corrections (e.g., clamping when the tree shrinks) to + // be applied immediately, ensuring the layout loop builds the rows + // that will actually be visible at the corrected offset. _updateVerticalScrollBounds(); - if (_firstRow != null) { - assert(_lastRow != null); - _Span rowSpan; - double rowOffset = - -verticalOffset.pixels + - _rowMetrics[_firstRow!]!.leadingOffset + - _vAlignmentOffset; - for (int row = _firstRow!; row <= _lastRow!; row++) { - rowSpan = _rowMetrics[row]!; - final double rowHeight = rowSpan.extent; - if (_animationLeadingIndices.keys.contains(row)) { - rowOffset -= rowSpan.animationOffset; - } - rowOffset += rowSpan.configuration.padding.leading; - - final vicinity = TreeVicinity(depth: _rowDepths[row]!, row: row); - final RenderBox child = buildOrObtainChildFor(vicinity)!; - final TwoDimensionalViewportParentData parentData = parentDataOf(child); - final childConstraints = BoxConstraints( - minHeight: rowHeight, - maxHeight: rowHeight, - // Width is allowed to be unbounded. - ); - child.layout(childConstraints, parentUsesSize: true); - parentData.layoutOffset = Offset( - (_rowDepths[row]! * indentation) - horizontalOffset.pixels, - rowOffset, - ); - rowOffset += rowHeight + rowSpan.configuration.padding.trailing; - _furthestHorizontalExtent = math.max( - parentData.layoutOffset!.dx + - horizontalOffset.pixels + - child.size.width, - _furthestHorizontalExtent, - ); + if (_firstRow == null) { + // If no rows are visible, we must still update horizontal bounds + // before returning to ensure the horizontal scroll controller + // has the latest information. + _updateHorizontalScrollBounds(); + // Return early to avoid a framework crash in RenderTwoDimensionalViewport + // where it expects at least one child to be laid out if the layout + // pass completes. + return; + } + + assert(_lastRow != null); + _Span rowSpan; + double rowOffset = + -verticalOffset.pixels + + _rowMetrics[_firstRow!]!.leadingOffset + + _vAlignmentOffset; + for (int row = _firstRow!; row <= _lastRow!; row++) { + rowSpan = _rowMetrics[row]!; + final double rowHeight = rowSpan.extent; + if (_animationLeadingIndices.keys.contains(row)) { + rowOffset -= rowSpan.animationOffset; } + rowOffset += rowSpan.configuration.padding.leading; + + final vicinity = TreeVicinity(depth: _rowDepths[row]!, row: row); + final RenderBox child = buildOrObtainChildFor(vicinity)!; + final TwoDimensionalViewportParentData parentData = parentDataOf(child); + final childConstraints = BoxConstraints( + minHeight: rowHeight, + maxHeight: rowHeight, + // Width is allowed to be unbounded. + ); + child.layout(childConstraints, parentUsesSize: true); + parentData.layoutOffset = Offset( + (_rowDepths[row]! * indentation) - horizontalOffset.pixels, + rowOffset, + ); + rowOffset += rowHeight + rowSpan.configuration.padding.trailing; + _furthestHorizontalExtent = math.max( + parentData.layoutOffset!.dx + + horizontalOffset.pixels + + child.size.width, + _furthestHorizontalExtent, + ); } _updateHorizontalScrollBounds(); } From 27337907f307a1a869fbb0a1c312a1fd529c7f67 Mon Sep 17 00:00:00 2001 From: Kate Lovett Date: Fri, 8 May 2026 17:28:58 -0500 Subject: [PATCH 6/7] Works both ways --- .../two_dimensional_scrollables/lib/src/table_view/table.dart | 4 ++++ .../lib/src/tree_view/render_tree.dart | 4 ++++ 2 files changed, 8 insertions(+) diff --git a/packages/two_dimensional_scrollables/lib/src/table_view/table.dart b/packages/two_dimensional_scrollables/lib/src/table_view/table.dart index dc46613574f0..303f13f71bc9 100644 --- a/packages/two_dimensional_scrollables/lib/src/table_view/table.dart +++ b/packages/two_dimensional_scrollables/lib/src/table_view/table.dart @@ -1115,6 +1115,10 @@ class RenderTableViewport extends RenderTwoDimensionalViewport { _firstTrailingPinnedRow == null && _firstTrailingPinnedColumn == null) { assert(_lastNonPinnedCell == null); + // To satisfy older framework versions that require at least one vicinity + // to be laid out (even if no child is built). + // See also: https://github.com/flutter/flutter/pull/180563 + buildOrObtainChildFor(TableVicinity.zero); return; } diff --git a/packages/two_dimensional_scrollables/lib/src/tree_view/render_tree.dart b/packages/two_dimensional_scrollables/lib/src/tree_view/render_tree.dart index c9b11dacbd3b..cbf1bc561a11 100644 --- a/packages/two_dimensional_scrollables/lib/src/tree_view/render_tree.dart +++ b/packages/two_dimensional_scrollables/lib/src/tree_view/render_tree.dart @@ -404,6 +404,10 @@ class RenderTreeViewport extends RenderTwoDimensionalViewport { // before returning to ensure the horizontal scroll controller // has the latest information. _updateHorizontalScrollBounds(); + // To satisfy older framework versions that require at least one vicinity + // to be laid out (even if no child is built). + // See also: https://github.com/flutter/flutter/pull/180563 + buildOrObtainChildFor(const TreeVicinity(depth: 0, row: 0)); // Return early to avoid a framework crash in RenderTwoDimensionalViewport // where it expects at least one child to be laid out if the layout // pass completes. From a75791adea6284568b903c3702881e4c180b64d2 Mon Sep 17 00:00:00 2001 From: Kate Lovett Date: Fri, 8 May 2026 18:30:02 -0500 Subject: [PATCH 7/7] Update table.dart --- .../two_dimensional_scrollables/lib/src/table_view/table.dart | 4 ---- 1 file changed, 4 deletions(-) diff --git a/packages/two_dimensional_scrollables/lib/src/table_view/table.dart b/packages/two_dimensional_scrollables/lib/src/table_view/table.dart index 303f13f71bc9..dc46613574f0 100644 --- a/packages/two_dimensional_scrollables/lib/src/table_view/table.dart +++ b/packages/two_dimensional_scrollables/lib/src/table_view/table.dart @@ -1115,10 +1115,6 @@ class RenderTableViewport extends RenderTwoDimensionalViewport { _firstTrailingPinnedRow == null && _firstTrailingPinnedColumn == null) { assert(_lastNonPinnedCell == null); - // To satisfy older framework versions that require at least one vicinity - // to be laid out (even if no child is built). - // See also: https://github.com/flutter/flutter/pull/180563 - buildOrObtainChildFor(TableVicinity.zero); return; }