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
3 changes: 2 additions & 1 deletion packages/two_dimensional_scrollables/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
## NEXT
## 0.5.3

* Updates minimum supported SDK version to Flutter 3.38/Dart 3.10.
* Fixes an issue where pinned cells were losing hit tests to underlying non-pinned cells.

## 0.5.2

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -581,7 +581,13 @@ class RenderTableViewport extends RenderTwoDimensionalViewport {
continue;
}
final Rect cellRect = cellParentData.paintOffset! & cell.size;
if (cellRect.contains(position)) {
// Intersect with the section clip rect for this cell to prevent
// scrollable cells from stealing hits inside the trailing pinned area.
// This mirrors the pushClipRect applied to each section during painting.
if (cellRect.contains(position) &&
_sectionClipRectFor(
cellParentData.tableVicinity,
).contains(position)) {
result.addWithPaintOffset(
offset: cellParentData.paintOffset,
position: position,
Expand Down Expand Up @@ -619,6 +625,80 @@ class RenderTableViewport extends RenderTwoDimensionalViewport {
return false;
}

// Returns the viewport-space clip rect that painting applies to the section
// a cell belongs to (leading-pinned, non-pinned, or trailing-pinned for each
// axis). Intersecting the raw cell rect with this rect in hitTestChildren
// prevents scrollable cells from capturing taps inside pinned areas.
Rect _sectionClipRectFor(TableVicinity vicinity) {
Comment thread
beroso marked this conversation as resolved.
final bool reversedH = axisDirectionIsReversed(horizontalAxisDirection);
final bool reversedV = axisDirectionIsReversed(verticalAxisDirection);
final Size viewportSize = viewportDimension;

final double colLeft, colRight;
if (vicinity.column < delegate.pinnedColumnCount) {
// Leading pinned column — visually on the right when axis is reversed.
if (reversedH) {
colLeft = viewportSize.width - _leadingPinnedColumnsExtent;
colRight = viewportSize.width;
} else {
colLeft = 0.0;
colRight = _leadingPinnedColumnsExtent;
}
} else if (_firstTrailingPinnedColumn != null &&
vicinity.column >= _firstTrailingPinnedColumn!) {
// Trailing pinned column — visually on the left when axis is reversed.
if (reversedH) {
colLeft = 0.0;
colRight = _trailingPinnedColumnsExtent;
} else {
colLeft = viewportSize.width - _trailingPinnedColumnsExtent;
colRight = viewportSize.width;
}
} else {
// Non-pinned column.
if (reversedH) {
colLeft = _trailingPinnedColumnsExtent;
colRight = viewportSize.width - _leadingPinnedColumnsExtent;
} else {
colLeft = _leadingPinnedColumnsExtent;
colRight = viewportSize.width - _trailingPinnedColumnsExtent;
}
}

final double rowTop, rowBottom;
if (vicinity.row < delegate.pinnedRowCount) {
// Leading pinned row — visually on the bottom when axis is reversed.
if (reversedV) {
rowTop = viewportSize.height - _leadingPinnedRowsExtent;
rowBottom = viewportSize.height;
} else {
rowTop = 0.0;
rowBottom = _leadingPinnedRowsExtent;
}
} else if (_firstTrailingPinnedRow != null &&
vicinity.row >= _firstTrailingPinnedRow!) {
// Trailing pinned row — visually on the top when axis is reversed.
if (reversedV) {
rowTop = 0.0;
rowBottom = _trailingPinnedRowsExtent;
} else {
rowTop = viewportSize.height - _trailingPinnedRowsExtent;
rowBottom = viewportSize.height;
}
} else {
// Non-pinned row.
if (reversedV) {
rowTop = _trailingPinnedRowsExtent;
rowBottom = viewportSize.height - _leadingPinnedRowsExtent;
} else {
rowTop = _leadingPinnedRowsExtent;
rowBottom = viewportSize.height - _trailingPinnedRowsExtent;
}
}

return Rect.fromLTRB(colLeft, rowTop, colRight, rowBottom);
}

// Updates the cached column metrics for the table.
//
// By default, existing column metrics will be updated if they have changed.
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.2
version: 0.5.3
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
278 changes: 278 additions & 0 deletions packages/two_dimensional_scrollables/test/table_view/table_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -4631,6 +4631,284 @@ void main() {
expect(mergedRect.top, 200);
expect(mergedRect.bottom, 400);
});

group('Table pinned cells hit test', () {
// Regression tests for https://github.com/flutter/flutter/issues/186876
// Trailing pinned cells must take precedence over underlying scrollable cells
// that have scrolled into the same screen rect.
//
// Geometry shared by all four tests (10 columns × 10 rows, 100 × 100 px,
// viewport 400 × 400 px):
//
// paintOffset.dx(col k) = k × 100 − scroll (for non-pinned columns)
//
// At scroll = 525:
// • _targetTrailingColumnPixel = 825, so col 8 (trailingOffset = 900) is
// the last non-pinned column built (900 ≥ 825).
// • col 8 lands at x = 8×100 − 525 = 275 → paint rect x = 275..375.
// • Trailing pinned col 9 always sits at x = 300..400.
// • Both rects contain x = 350, so without the _sectionClipRectFor fix
// the forward child-list scan hits col 8 first and swallows the tap.
//
// At scroll = 500 the bug is NOT triggered: _targetTrailingColumnPixel = 800
// causes col 7 (trailingOffset = 800) to be the last non-pinned column built
// (col 8 is never added to the child list), so there is no overlap.
// The same argument applies symmetrically for rows.

testWidgets(
'Trailing pinned column takes precedence over scrolled-under regular cell',
(WidgetTester tester) async {
final horizontalController = ScrollController();
TableVicinity? lastTapped;

await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: SizedBox(
height: 400,
width: 400,
child: TableView.builder(
cacheExtent: 0.0,
columnCount: 10,
rowCount: 1,
trailingPinnedColumnCount: 1,
horizontalDetails: ScrollableDetails.horizontal(
controller: horizontalController,
),
columnBuilder: (_) =>
const TableSpan(extent: FixedTableSpanExtent(100)),
rowBuilder: (_) =>
const TableSpan(extent: FixedTableSpanExtent(100)),
cellBuilder: (_, TableVicinity vicinity) {
return TableViewCell(
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () => lastTapped = vicinity,
child: const SizedBox.expand(),
),
);
},
),
),
),
),
);

// col 8 (regular) lands at x = 275..375, overlapping the trailing
// pinned col 9 (x = 300..400). A tap at x = 350 hits both; col 9 wins.
horizontalController.jumpTo(525);
await tester.pump();

await tester.tapAt(const Offset(350, 50));
await tester.pumpAndSettle();

expect(
lastTapped?.column,
9,
reason:
'Trailing pinned column 9 must receive the tap; column 8 (scrolled underneath) must not.',
);
},
);

testWidgets(
'Trailing pinned row takes precedence over scrolled-under regular cell',
(WidgetTester tester) async {
final verticalController = ScrollController();
TableVicinity? lastTapped;

await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: SizedBox(
height: 400,
width: 400,
child: TableView.builder(
cacheExtent: 0.0,
columnCount: 1,
rowCount: 10,
trailingPinnedRowCount: 1,
verticalDetails: ScrollableDetails.vertical(
controller: verticalController,
),
columnBuilder: (_) =>
const TableSpan(extent: FixedTableSpanExtent(100)),
rowBuilder: (_) =>
const TableSpan(extent: FixedTableSpanExtent(100)),
cellBuilder: (_, TableVicinity vicinity) {
return TableViewCell(
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () => lastTapped = vicinity,
child: const SizedBox.expand(),
),
);
},
),
),
),
),
);

// row 8 (regular) lands at y = 275..375, overlapping the trailing
// pinned row 9 (y = 300..400). A tap at y = 350 hits both; row 9 wins.
verticalController.jumpTo(525);
await tester.pump();

await tester.tapAt(const Offset(50, 350));
await tester.pumpAndSettle();

expect(
lastTapped?.row,
9,
reason:
'Trailing pinned row 9 must receive the tap; row 8 (scrolled underneath) must not.',
);
},
);

testWidgets(
'Trailing pinned corner cell takes precedence over scrolled-under regular cells',
(WidgetTester tester) async {
final horizontalController = ScrollController();
final verticalController = ScrollController();
TableVicinity? lastTapped;

await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: SizedBox(
height: 400,
width: 400,
child: TableView.builder(
cacheExtent: 0.0,
columnCount: 10,
rowCount: 10,
trailingPinnedColumnCount: 1,
trailingPinnedRowCount: 1,
horizontalDetails: ScrollableDetails.horizontal(
controller: horizontalController,
),
verticalDetails: ScrollableDetails.vertical(
controller: verticalController,
),
columnBuilder: (_) =>
const TableSpan(extent: FixedTableSpanExtent(100)),
rowBuilder: (_) =>
const TableSpan(extent: FixedTableSpanExtent(100)),
cellBuilder: (_, TableVicinity vicinity) {
return TableViewCell(
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () => lastTapped = vicinity,
child: const SizedBox.expand(),
),
);
},
),
),
),
),
);

// Regular cell (row 8, col 8) lands at x = 275..375, y = 275..375,
// overlapping the trailing corner cell (row 9, col 9) at x = 300..400,
// y = 300..400. A tap at (350, 350) hits both; (row 9, col 9) wins.
horizontalController.jumpTo(525);
verticalController.jumpTo(525);
await tester.pump();

await tester.tapAt(const Offset(350, 350));
await tester.pumpAndSettle();

expect(
lastTapped,
const TableVicinity(column: 9, row: 9),
reason:
'Trailing pinned corner cell (row 9, col 9) must receive the tap; the regular cell (row 8, col 8) scrolled underneath must not.',
);
},
);

testWidgets(
'Leading and trailing pinned columns each clip to their own viewport band',
(WidgetTester tester) async {
final horizontalController = ScrollController();
final tappedColumns = <int>[];

await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: SizedBox(
height: 100,
width: 400,
child: TableView.builder(
cacheExtent: 0.0,
columnCount: 10,
rowCount: 1,
// col 0 → always at x = 0..100
pinnedColumnCount: 1,
// col 9 → always at x = 300..400
trailingPinnedColumnCount: 1,
horizontalDetails: ScrollableDetails.horizontal(
controller: horizontalController,
),
columnBuilder: (_) =>
const TableSpan(extent: FixedTableSpanExtent(100)),
rowBuilder: (_) =>
const TableSpan(extent: FixedTableSpanExtent(100)),
cellBuilder: (_, TableVicinity vicinity) {
return TableViewCell(
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () => tappedColumns.add(vicinity.column),
child: const SizedBox.expand(),
),
);
},
),
),
),
),
);

// With leading pinned extent = 100 and trailing pinned extent = 100 the
// non-pinned band is x = 100..300 (200 px wide). The formula becomes
// paintOffset.dx(col k) = k×100 − scroll
// so col 8 lands at x = 275..375 at scroll = 525, overlapping trailing
// col 9 (x = 300..400) at x = 300..375.
horizontalController.jumpTo(525);
await tester.pump();

// 1. Tap the trailing band (x = 350) → col 9, NOT the underlying col 8.
await tester.tapAt(const Offset(350, 50));
await tester.pumpAndSettle();
expect(
tappedColumns.last,
9,
reason: 'Tap at x=350 should hit trailing pinned col 9, not col 8.',
);

// 2. Tap the leading band (x = 50) → col 0.
await tester.tapAt(const Offset(50, 50));
await tester.pumpAndSettle();
expect(
tappedColumns.last,
0,
reason: 'Tap at x=50 should hit leading pinned col 0.',
);

// 3. Tap the middle of the non-pinned band (x = 200) → neither pinned col.
await tester.tapAt(const Offset(200, 50));
await tester.pumpAndSettle();
expect(
tappedColumns.last,
isNot(anyOf(0, 9)),
reason: 'Tap at x=200 should hit a non-pinned column.',
);
},
);
});
}

class _NullBuildContext implements BuildContext, TwoDimensionalChildManager {
Expand Down