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..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,31 +339,33 @@ 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) { _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,45 +391,46 @@ class RenderTreeViewport extends RenderTwoDimensionalViewport { } } - if (_firstRow == null) { - assert(_lastRow == null); - return; - } - assert(_firstRow != null && _lastRow != null); + _updateVerticalScrollBounds(); - _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 + + horizontalOffset.pixels + + 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(); + _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..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); + }, + ); + }); }); }