@@ -4631,6 +4631,284 @@ void main() {
46314631 expect (mergedRect.top, 200 );
46324632 expect (mergedRect.bottom, 400 );
46334633 });
4634+
4635+ group ('Table pinned cells hit test' , () {
4636+ // Regression tests for https://github.com/flutter/flutter/issues/186876
4637+ // Trailing pinned cells must take precedence over underlying scrollable cells
4638+ // that have scrolled into the same screen rect.
4639+ //
4640+ // Geometry shared by all four tests (10 columns × 10 rows, 100 × 100 px,
4641+ // viewport 400 × 400 px):
4642+ //
4643+ // paintOffset.dx(col k) = k × 100 − scroll (for non-pinned columns)
4644+ //
4645+ // At scroll = 525:
4646+ // • _targetTrailingColumnPixel = 825, so col 8 (trailingOffset = 900) is
4647+ // the last non-pinned column built (900 ≥ 825).
4648+ // • col 8 lands at x = 8×100 − 525 = 275 → paint rect x = 275..375.
4649+ // • Trailing pinned col 9 always sits at x = 300..400.
4650+ // • Both rects contain x = 350, so without the _sectionClipRectFor fix
4651+ // the forward child-list scan hits col 8 first and swallows the tap.
4652+ //
4653+ // At scroll = 500 the bug is NOT triggered: _targetTrailingColumnPixel = 800
4654+ // causes col 7 (trailingOffset = 800) to be the last non-pinned column built
4655+ // (col 8 is never added to the child list), so there is no overlap.
4656+ // The same argument applies symmetrically for rows.
4657+
4658+ testWidgets (
4659+ 'Trailing pinned column takes precedence over scrolled-under regular cell' ,
4660+ (WidgetTester tester) async {
4661+ final horizontalController = ScrollController ();
4662+ TableVicinity ? lastTapped;
4663+
4664+ await tester.pumpWidget (
4665+ MaterialApp (
4666+ home: Scaffold (
4667+ body: SizedBox (
4668+ height: 400 ,
4669+ width: 400 ,
4670+ child: TableView .builder (
4671+ cacheExtent: 0.0 ,
4672+ columnCount: 10 ,
4673+ rowCount: 1 ,
4674+ trailingPinnedColumnCount: 1 ,
4675+ horizontalDetails: ScrollableDetails .horizontal (
4676+ controller: horizontalController,
4677+ ),
4678+ columnBuilder: (_) =>
4679+ const TableSpan (extent: FixedTableSpanExtent (100 )),
4680+ rowBuilder: (_) =>
4681+ const TableSpan (extent: FixedTableSpanExtent (100 )),
4682+ cellBuilder: (_, TableVicinity vicinity) {
4683+ return TableViewCell (
4684+ child: GestureDetector (
4685+ behavior: HitTestBehavior .opaque,
4686+ onTap: () => lastTapped = vicinity,
4687+ child: const SizedBox .expand (),
4688+ ),
4689+ );
4690+ },
4691+ ),
4692+ ),
4693+ ),
4694+ ),
4695+ );
4696+
4697+ // col 8 (regular) lands at x = 275..375, overlapping the trailing
4698+ // pinned col 9 (x = 300..400). A tap at x = 350 hits both; col 9 wins.
4699+ horizontalController.jumpTo (525 );
4700+ await tester.pump ();
4701+
4702+ await tester.tapAt (const Offset (350 , 50 ));
4703+ await tester.pumpAndSettle ();
4704+
4705+ expect (
4706+ lastTapped? .column,
4707+ 9 ,
4708+ reason:
4709+ 'Trailing pinned column 9 must receive the tap; column 8 (scrolled underneath) must not.' ,
4710+ );
4711+ },
4712+ );
4713+
4714+ testWidgets (
4715+ 'Trailing pinned row takes precedence over scrolled-under regular cell' ,
4716+ (WidgetTester tester) async {
4717+ final verticalController = ScrollController ();
4718+ TableVicinity ? lastTapped;
4719+
4720+ await tester.pumpWidget (
4721+ MaterialApp (
4722+ home: Scaffold (
4723+ body: SizedBox (
4724+ height: 400 ,
4725+ width: 400 ,
4726+ child: TableView .builder (
4727+ cacheExtent: 0.0 ,
4728+ columnCount: 1 ,
4729+ rowCount: 10 ,
4730+ trailingPinnedRowCount: 1 ,
4731+ verticalDetails: ScrollableDetails .vertical (
4732+ controller: verticalController,
4733+ ),
4734+ columnBuilder: (_) =>
4735+ const TableSpan (extent: FixedTableSpanExtent (100 )),
4736+ rowBuilder: (_) =>
4737+ const TableSpan (extent: FixedTableSpanExtent (100 )),
4738+ cellBuilder: (_, TableVicinity vicinity) {
4739+ return TableViewCell (
4740+ child: GestureDetector (
4741+ behavior: HitTestBehavior .opaque,
4742+ onTap: () => lastTapped = vicinity,
4743+ child: const SizedBox .expand (),
4744+ ),
4745+ );
4746+ },
4747+ ),
4748+ ),
4749+ ),
4750+ ),
4751+ );
4752+
4753+ // row 8 (regular) lands at y = 275..375, overlapping the trailing
4754+ // pinned row 9 (y = 300..400). A tap at y = 350 hits both; row 9 wins.
4755+ verticalController.jumpTo (525 );
4756+ await tester.pump ();
4757+
4758+ await tester.tapAt (const Offset (50 , 350 ));
4759+ await tester.pumpAndSettle ();
4760+
4761+ expect (
4762+ lastTapped? .row,
4763+ 9 ,
4764+ reason:
4765+ 'Trailing pinned row 9 must receive the tap; row 8 (scrolled underneath) must not.' ,
4766+ );
4767+ },
4768+ );
4769+
4770+ testWidgets (
4771+ 'Trailing pinned corner cell takes precedence over scrolled-under regular cells' ,
4772+ (WidgetTester tester) async {
4773+ final horizontalController = ScrollController ();
4774+ final verticalController = ScrollController ();
4775+ TableVicinity ? lastTapped;
4776+
4777+ await tester.pumpWidget (
4778+ MaterialApp (
4779+ home: Scaffold (
4780+ body: SizedBox (
4781+ height: 400 ,
4782+ width: 400 ,
4783+ child: TableView .builder (
4784+ cacheExtent: 0.0 ,
4785+ columnCount: 10 ,
4786+ rowCount: 10 ,
4787+ trailingPinnedColumnCount: 1 ,
4788+ trailingPinnedRowCount: 1 ,
4789+ horizontalDetails: ScrollableDetails .horizontal (
4790+ controller: horizontalController,
4791+ ),
4792+ verticalDetails: ScrollableDetails .vertical (
4793+ controller: verticalController,
4794+ ),
4795+ columnBuilder: (_) =>
4796+ const TableSpan (extent: FixedTableSpanExtent (100 )),
4797+ rowBuilder: (_) =>
4798+ const TableSpan (extent: FixedTableSpanExtent (100 )),
4799+ cellBuilder: (_, TableVicinity vicinity) {
4800+ return TableViewCell (
4801+ child: GestureDetector (
4802+ behavior: HitTestBehavior .opaque,
4803+ onTap: () => lastTapped = vicinity,
4804+ child: const SizedBox .expand (),
4805+ ),
4806+ );
4807+ },
4808+ ),
4809+ ),
4810+ ),
4811+ ),
4812+ );
4813+
4814+ // Regular cell (row 8, col 8) lands at x = 275..375, y = 275..375,
4815+ // overlapping the trailing corner cell (row 9, col 9) at x = 300..400,
4816+ // y = 300..400. A tap at (350, 350) hits both; (row 9, col 9) wins.
4817+ horizontalController.jumpTo (525 );
4818+ verticalController.jumpTo (525 );
4819+ await tester.pump ();
4820+
4821+ await tester.tapAt (const Offset (350 , 350 ));
4822+ await tester.pumpAndSettle ();
4823+
4824+ expect (
4825+ lastTapped,
4826+ const TableVicinity (column: 9 , row: 9 ),
4827+ reason:
4828+ 'Trailing pinned corner cell (row 9, col 9) must receive the tap; the regular cell (row 8, col 8) scrolled underneath must not.' ,
4829+ );
4830+ },
4831+ );
4832+
4833+ testWidgets (
4834+ 'Leading and trailing pinned columns each clip to their own viewport band' ,
4835+ (WidgetTester tester) async {
4836+ final horizontalController = ScrollController ();
4837+ final tappedColumns = < int > [];
4838+
4839+ await tester.pumpWidget (
4840+ MaterialApp (
4841+ home: Scaffold (
4842+ body: SizedBox (
4843+ height: 100 ,
4844+ width: 400 ,
4845+ child: TableView .builder (
4846+ cacheExtent: 0.0 ,
4847+ columnCount: 10 ,
4848+ rowCount: 1 ,
4849+ // col 0 → always at x = 0..100
4850+ pinnedColumnCount: 1 ,
4851+ // col 9 → always at x = 300..400
4852+ trailingPinnedColumnCount: 1 ,
4853+ horizontalDetails: ScrollableDetails .horizontal (
4854+ controller: horizontalController,
4855+ ),
4856+ columnBuilder: (_) =>
4857+ const TableSpan (extent: FixedTableSpanExtent (100 )),
4858+ rowBuilder: (_) =>
4859+ const TableSpan (extent: FixedTableSpanExtent (100 )),
4860+ cellBuilder: (_, TableVicinity vicinity) {
4861+ return TableViewCell (
4862+ child: GestureDetector (
4863+ behavior: HitTestBehavior .opaque,
4864+ onTap: () => tappedColumns.add (vicinity.column),
4865+ child: const SizedBox .expand (),
4866+ ),
4867+ );
4868+ },
4869+ ),
4870+ ),
4871+ ),
4872+ ),
4873+ );
4874+
4875+ // With leading pinned extent = 100 and trailing pinned extent = 100 the
4876+ // non-pinned band is x = 100..300 (200 px wide). The formula becomes
4877+ // paintOffset.dx(col k) = k×100 − scroll
4878+ // so col 8 lands at x = 275..375 at scroll = 525, overlapping trailing
4879+ // col 9 (x = 300..400) at x = 300..375.
4880+ horizontalController.jumpTo (525 );
4881+ await tester.pump ();
4882+
4883+ // 1. Tap the trailing band (x = 350) → col 9, NOT the underlying col 8.
4884+ await tester.tapAt (const Offset (350 , 50 ));
4885+ await tester.pumpAndSettle ();
4886+ expect (
4887+ tappedColumns.last,
4888+ 9 ,
4889+ reason: 'Tap at x=350 should hit trailing pinned col 9, not col 8.' ,
4890+ );
4891+
4892+ // 2. Tap the leading band (x = 50) → col 0.
4893+ await tester.tapAt (const Offset (50 , 50 ));
4894+ await tester.pumpAndSettle ();
4895+ expect (
4896+ tappedColumns.last,
4897+ 0 ,
4898+ reason: 'Tap at x=50 should hit leading pinned col 0.' ,
4899+ );
4900+
4901+ // 3. Tap the middle of the non-pinned band (x = 200) → neither pinned col.
4902+ await tester.tapAt (const Offset (200 , 50 ));
4903+ await tester.pumpAndSettle ();
4904+ expect (
4905+ tappedColumns.last,
4906+ isNot (anyOf (0 , 9 )),
4907+ reason: 'Tap at x=200 should hit a non-pinned column.' ,
4908+ );
4909+ },
4910+ );
4911+ });
46344912}
46354913
46364914class _NullBuildContext implements BuildContext , TwoDimensionalChildManager {
0 commit comments