diff --git a/packages/two_dimensional_scrollables/CHANGELOG.md b/packages/two_dimensional_scrollables/CHANGELOG.md index 813b9bbc00ed..f85e1038b66f 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 collapses 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..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 @@ -339,31 +339,35 @@ class RenderTreeViewport extends RenderTwoDimensionalViewport { } } - void _updateScrollBounds() { - final double maxHorizontalExtent = math.max( - 0.0, - _furthestHorizontalExtent - viewportDimension.width, - ); - _horizontalOverflows = maxHorizontalExtent > 0.0; - - final double verticalLeadingExtent = verticalOffset.pixels; - final double verticalTrailingExtent = - _rowMetrics[_lastRow!]!.trailingOffset - viewportDimension.height; - final double maxVerticalExtent = math.max( + void _updateVerticalScrollBounds() { + 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 = verticalOffset.applyContentDimensions( 0.0, - math.max(verticalLeadingExtent, verticalTrailingExtent), + maxVerticalExtent, ); - _verticalOverflows = maxVerticalExtent > 0.0; - - final bool acceptedDimension = - horizontalOffset.applyContentDimensions(0.0, maxHorizontalExtent) && - verticalOffset.applyContentDimensions(0.0, maxVerticalExtent); - if (!acceptedDimension) { + // If the scroll offset was corrected (e.g., clamped), we must + // re-calculate which rows are now visible. _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,12 +393,28 @@ 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); + // If no rows are visible, we must still update horizontal bounds + // 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. return; } - assert(_firstRow != null && _lastRow != null); + assert(_lastRow != null); _Span rowSpan; double rowOffset = -verticalOffset.pixels + @@ -423,11 +443,13 @@ 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 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..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 @@ -682,7 +682,9 @@ void main() { await tester.pumpWidget(MaterialApp(home: treeView)); await tester.pump(); expect(verticalController.position.pixels, 0.0); - expect(verticalController.position.maxScrollExtent, 600.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) { return find.text(row).evaluate().first.renderObject!.debugNeedsPaint; @@ -866,6 +868,128 @@ 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(); + 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)); + + // Jump to the maximum scroll extent to test position correction. + 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(); + + // 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); + }); + + 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')], + ), + ]; + + 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. 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 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); + }); + }); }); }