Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions packages/two_dimensional_scrollables/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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 +
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion packages/two_dimensional_scrollables/pubspec.yaml
Original file line number Diff line number Diff line change
@@ -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+

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Comment thread
Piinks marked this conversation as resolved.

bool rowNeedsPaint(String row) {
return find.text(row).evaluate().first.renderObject!.debugNeedsPaint;
Expand Down Expand Up @@ -866,6 +868,128 @@ void main() {
);
});
});

Comment thread
Piinks marked this conversation as resolved.
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<String>(
verticalDetails: ScrollableDetails.vertical(
controller: controller,
),
tree: List<TreeViewNode<String>>.generate(
rows,
(int index) => TreeViewNode<String>('Row $index'),
),
treeRowBuilder: (TreeViewNode<String> node) =>
const TreeRow(extent: FixedTreeRowExtent(64.0)),
treeNodeBuilder:
(
BuildContext context,
TreeViewNode<String> 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<String>>[
TreeViewNode<String>(
'Root',
expanded: true,
children: <TreeViewNode<String>>[TreeViewNode<String>('Child')],
),
];

await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: SizedBox(
height: 100,
width: 400,
child: TreeView<String>(
controller: treeController,
verticalDetails: ScrollableDetails.vertical(
controller: scrollController,
),
tree: treeNodes,
treeRowBuilder: (TreeViewNode<String> node) =>
const TreeRow(extent: FixedTreeRowExtent(60.0)),
treeNodeBuilder:
(
BuildContext context,
TreeViewNode<String> 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);
});
});
});
}

Expand Down
Loading