Thanks to visit codestin.com
Credit goes to github.com

Skip to content

Commit 2ec2236

Browse files
authored
[two_dimensional_scrollables] Fixes TreeView crash when empty or last node collapsed (#11622)
This PR provides a robust fix for a crash in TreeView and resolves a regression introduced by a previous partial fix in PR #9103. Previous attempts to fix the "empty tree" crash (shrinking to 0 rows) by moving _updateScrollBounds to the end of layout introduced a subtle inconsistency. When a node collapse triggered a scroll correction, the visible row range was recalculated after the layout loop had finished. This resulted in the paint phase attempting to access children that were never built, causing a null dereference (as seen in the Expand then collapse with offscreen nodes (top) test). * _updateVerticalScrollBounds is now called before the child layout loop. This ensures that any vertical scroll corrections (e.g., clamping when content shrinks) happen first, so the layout loop builds exactly the rows that will be visible. * Accurate Extent Calculation: Updated _updateScrollBounds to calculate the vertical scroll extent using the total height of all rows in _rowMetrics (via _rowMetrics.last.trailingOffset). This provides a consistent maxScrollExtent and correctly handles the "empty tree" state. * Correct Horizontal Bounds: Updated the horizontal extent calculation to use absolute content-relative positions, ensuring correct scroll bounds when scrolling horizontally. Fixes flutter/flutter#164981 ## Pre-Review Checklist **Note**: The Flutter team is currently trialing the use of [Gemini Code Assist for GitHub](https://developers.google.com/gemini-code-assist/docs/review-github-code). Comments from the `gemini-code-assist` bot should not be taken as authoritative feedback from the Flutter team. If you find its comments useful you can update your code accordingly, but if you are unsure or disagree with the feedback, please feel free to wait for a Flutter team member's review for guidance on which automated comments should be addressed. [^1]: Regular contributors who have demonstrated familiarity with the repository guidelines only need to comment if the PR is not auto-exempted by repo tooling.
1 parent 93cbed6 commit 2ec2236

4 files changed

Lines changed: 174 additions & 24 deletions

File tree

packages/two_dimensional_scrollables/CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
1+
## 0.5.2
2+
3+
* Fixes a crash in `TreeView` when it collapses to 0 rows or the last node is collapsed.
4+
15
## 0.5.1
26

37
* Fixes an infinite loop of onExit/onEnter events when setState is called within onEnter in a TableSpan.

packages/two_dimensional_scrollables/lib/src/tree_view/render_tree.dart

Lines changed: 44 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -339,31 +339,35 @@ class RenderTreeViewport extends RenderTwoDimensionalViewport {
339339
}
340340
}
341341

342-
void _updateScrollBounds() {
343-
final double maxHorizontalExtent = math.max(
344-
0.0,
345-
_furthestHorizontalExtent - viewportDimension.width,
346-
);
347-
_horizontalOverflows = maxHorizontalExtent > 0.0;
348-
349-
final double verticalLeadingExtent = verticalOffset.pixels;
350-
final double verticalTrailingExtent =
351-
_rowMetrics[_lastRow!]!.trailingOffset - viewportDimension.height;
352-
final double maxVerticalExtent = math.max(
342+
void _updateVerticalScrollBounds() {
343+
final double maxVerticalExtent = _rowMetrics.isEmpty
344+
? 0.0
345+
: math.max(
346+
0.0,
347+
_rowMetrics[_rowMetrics.length - 1]!.trailingOffset -
348+
viewportDimension.height,
349+
);
350+
_verticalOverflows = maxVerticalExtent > 0.0;
351+
final bool acceptedDimension = verticalOffset.applyContentDimensions(
353352
0.0,
354-
math.max(verticalLeadingExtent, verticalTrailingExtent),
353+
maxVerticalExtent,
355354
);
356-
_verticalOverflows = maxVerticalExtent > 0.0;
357-
358-
final bool acceptedDimension =
359-
horizontalOffset.applyContentDimensions(0.0, maxHorizontalExtent) &&
360-
verticalOffset.applyContentDimensions(0.0, maxVerticalExtent);
361-
362355
if (!acceptedDimension) {
356+
// If the scroll offset was corrected (e.g., clamped), we must
357+
// re-calculate which rows are now visible.
363358
_updateFirstAndLastVisibleRow();
364359
}
365360
}
366361

362+
void _updateHorizontalScrollBounds() {
363+
final double maxHorizontalExtent = math.max(
364+
0.0,
365+
_furthestHorizontalExtent - viewportDimension.width,
366+
);
367+
_horizontalOverflows = maxHorizontalExtent > 0.0;
368+
horizontalOffset.applyContentDimensions(0.0, maxHorizontalExtent);
369+
}
370+
367371
@override
368372
void layoutChildSequence() {
369373
_updateAnimationCache();
@@ -389,12 +393,28 @@ class RenderTreeViewport extends RenderTwoDimensionalViewport {
389393
}
390394
}
391395

396+
// Ensure vertical scroll bounds are updated before layout. This allows
397+
// any scroll corrections (e.g., clamping when the tree shrinks) to
398+
// be applied immediately, ensuring the layout loop builds the rows
399+
// that will actually be visible at the corrected offset.
400+
_updateVerticalScrollBounds();
401+
392402
if (_firstRow == null) {
393-
assert(_lastRow == null);
403+
// If no rows are visible, we must still update horizontal bounds
404+
// before returning to ensure the horizontal scroll controller
405+
// has the latest information.
406+
_updateHorizontalScrollBounds();
407+
// To satisfy older framework versions that require at least one vicinity
408+
// to be laid out (even if no child is built).
409+
// See also: https://github.com/flutter/flutter/pull/180563
410+
buildOrObtainChildFor(const TreeVicinity(depth: 0, row: 0));
411+
// Return early to avoid a framework crash in RenderTwoDimensionalViewport
412+
// where it expects at least one child to be laid out if the layout
413+
// pass completes.
394414
return;
395415
}
396-
assert(_firstRow != null && _lastRow != null);
397416

417+
assert(_lastRow != null);
398418
_Span rowSpan;
399419
double rowOffset =
400420
-verticalOffset.pixels +
@@ -423,11 +443,13 @@ class RenderTreeViewport extends RenderTwoDimensionalViewport {
423443
);
424444
rowOffset += rowHeight + rowSpan.configuration.padding.trailing;
425445
_furthestHorizontalExtent = math.max(
426-
parentData.layoutOffset!.dx + child.size.width,
446+
parentData.layoutOffset!.dx +
447+
horizontalOffset.pixels +
448+
child.size.width,
427449
_furthestHorizontalExtent,
428450
);
429451
}
430-
_updateScrollBounds();
452+
_updateHorizontalScrollBounds();
431453
}
432454

433455
// Maps the UniqueKey associated with animating node segments with the clip

packages/two_dimensional_scrollables/pubspec.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
name: two_dimensional_scrollables
22
description: Widgets that scroll using the two dimensional scrolling foundation.
3-
version: 0.5.1
3+
version: 0.5.2
44
repository: https://github.com/flutter/packages/tree/main/packages/two_dimensional_scrollables
55
issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+two_dimensional_scrollables%22+
66

packages/two_dimensional_scrollables/test/tree_view/render_tree_test.dart

Lines changed: 125 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -682,7 +682,9 @@ void main() {
682682
await tester.pumpWidget(MaterialApp(home: treeView));
683683
await tester.pump();
684684
expect(verticalController.position.pixels, 0.0);
685-
expect(verticalController.position.maxScrollExtent, 600.0);
685+
// The total height accounts for all visible nodes (7 nodes * 400 = 2800).
686+
// With a default viewport height of 600, the max scroll extent is 2200 (2800 - 600).
687+
expect(verticalController.position.maxScrollExtent, 2200.0);
686688

687689
bool rowNeedsPaint(String row) {
688690
return find.text(row).evaluate().first.renderObject!.debugNeedsPaint;
@@ -866,6 +868,128 @@ void main() {
866868
);
867869
});
868870
});
871+
872+
group('Scroll bounds', () {
873+
// Regression tests for https://github.com/flutter/flutter/issues/164981
874+
testWidgets('shrinking to 0 rows updates scroll bounds and does not crash', (
875+
WidgetTester tester,
876+
) async {
877+
// Setup a TreeView with 10 rows to ensure the content exceeds the viewport height.
878+
var rows = 10;
879+
late StateSetter setState;
880+
final controller = ScrollController();
881+
await tester.pumpWidget(
882+
MaterialApp(
883+
home: Scaffold(
884+
body: SizedBox(
885+
height: 400,
886+
width: 400,
887+
child: StatefulBuilder(
888+
builder: (BuildContext context, StateSetter setter) {
889+
setState = setter;
890+
return TreeView<String>(
891+
verticalDetails: ScrollableDetails.vertical(
892+
controller: controller,
893+
),
894+
tree: List<TreeViewNode<String>>.generate(
895+
rows,
896+
(int index) => TreeViewNode<String>('Row $index'),
897+
),
898+
treeRowBuilder: (TreeViewNode<String> node) =>
899+
const TreeRow(extent: FixedTreeRowExtent(64.0)),
900+
treeNodeBuilder:
901+
(
902+
BuildContext context,
903+
TreeViewNode<String> node,
904+
AnimationStyle toggleAnimationStyle,
905+
) => Text(node.content),
906+
);
907+
},
908+
),
909+
),
910+
),
911+
),
912+
);
913+
914+
await tester.pump();
915+
final double oldMax = controller.position.maxScrollExtent;
916+
expect(oldMax, greaterThan(0));
917+
918+
// Jump to the maximum scroll extent to test position correction.
919+
controller.jumpTo(oldMax);
920+
await tester.pump();
921+
expect(controller.offset, oldMax);
922+
923+
// Shrink to 0 rows.
924+
setState(() {
925+
rows = 0;
926+
});
927+
// This should not crash and should update scroll bounds.
928+
await tester.pump();
929+
930+
// Verify that the scroll bounds are updated to 0.0 and the offset is corrected to 0.0.
931+
expect(controller.position.maxScrollExtent, 0.0);
932+
expect(controller.offset, 0.0);
933+
});
934+
935+
testWidgets('collapsing last node updates scroll bounds and does not crash', (
936+
WidgetTester tester,
937+
) async {
938+
final treeController = TreeViewController();
939+
final scrollController = ScrollController();
940+
941+
// Setup a TreeView with one expanded root node and one child node.
942+
final treeNodes = <TreeViewNode<String>>[
943+
TreeViewNode<String>(
944+
'Root',
945+
expanded: true,
946+
children: <TreeViewNode<String>>[TreeViewNode<String>('Child')],
947+
),
948+
];
949+
950+
await tester.pumpWidget(
951+
MaterialApp(
952+
home: Scaffold(
953+
body: SizedBox(
954+
height: 100,
955+
width: 400,
956+
child: TreeView<String>(
957+
controller: treeController,
958+
verticalDetails: ScrollableDetails.vertical(
959+
controller: scrollController,
960+
),
961+
tree: treeNodes,
962+
treeRowBuilder: (TreeViewNode<String> node) =>
963+
const TreeRow(extent: FixedTreeRowExtent(60.0)),
964+
treeNodeBuilder:
965+
(
966+
BuildContext context,
967+
TreeViewNode<String> node,
968+
AnimationStyle toggleAnimationStyle,
969+
) => Text(node.content),
970+
),
971+
),
972+
),
973+
),
974+
);
975+
976+
await tester.pump();
977+
// Root (60) + Child (60) = 120. Viewport is 100. Max scroll extent is 20.
978+
expect(scrollController.position.maxScrollExtent, 20.0);
979+
980+
// Jump to the maximum scroll extent.
981+
scrollController.jumpTo(20.0);
982+
await tester.pump();
983+
984+
// Collapse the Root node. Now only Root (60) is visible, fitting within the viewport (100).
985+
treeController.toggleNode(treeNodes[0]);
986+
await tester.pumpAndSettle();
987+
988+
// Verify that the scroll bounds are updated to 0.0 and the offset is corrected to 0.0.
989+
expect(scrollController.position.maxScrollExtent, 0.0);
990+
expect(scrollController.offset, 0.0);
991+
});
992+
});
869993
});
870994
}
871995

0 commit comments

Comments
 (0)