diff --git a/examples/api/lib/material/menu_anchor/menu_anchor.1.dart b/examples/api/lib/material/menu_anchor/menu_anchor.1.dart index 7ce3e78c11819..e6b6d07f2e476 100644 --- a/examples/api/lib/material/menu_anchor/menu_anchor.1.dart +++ b/examples/api/lib/material/menu_anchor/menu_anchor.1.dart @@ -126,7 +126,6 @@ class _MyContextMenuState extends State { onSecondaryTapDown: _handleSecondaryTapDown, child: MenuAnchor( controller: _menuController, - anchorTapClosesMenu: true, menuChildren: [ MenuItemButton( child: Text(MenuEntry.about.label), @@ -221,6 +220,10 @@ class _MyContextMenuState extends State { } void _handleTapDown(TapDownDetails details) { + if (_menuController.isOpen) { + _menuController.close(); + return; + } switch (defaultTargetPlatform) { case TargetPlatform.android: case TargetPlatform.fuchsia: diff --git a/packages/flutter/lib/src/material/menu_anchor.dart b/packages/flutter/lib/src/material/menu_anchor.dart index 608345823e032..b512d50c9f419 100644 --- a/packages/flutter/lib/src/material/menu_anchor.dart +++ b/packages/flutter/lib/src/material/menu_anchor.dart @@ -129,7 +129,12 @@ class MenuAnchor extends StatefulWidget { this.style, this.alignmentOffset = Offset.zero, this.clipBehavior = Clip.hardEdge, + @Deprecated( + 'Use consumeOutsideTap instead. ' + 'This feature was deprecated after v3.16.0-8.0.pre.', + ) this.anchorTapClosesMenu = false, + this.consumeOutsideTap = false, this.onOpen, this.onClose, this.crossAxisUnconstrained = true, @@ -207,8 +212,23 @@ class MenuAnchor extends StatefulWidget { /// system by the user. In this case [anchorTapClosesMenu] should be true. /// /// Defaults to false. + @Deprecated( + 'Use consumeOutsideTap instead. ' + 'This feature was deprecated after v3.16.0-8.0.pre.', + ) final bool anchorTapClosesMenu; + /// Whether or not a tap event that closes the menu will be permitted to + /// continue on to the gesture arena. + /// + /// If false, then tapping outside of a menu when the menu is open will both + /// close the menu, and allow the tap to participate in the gesture arena. If + /// true, then it will only close the menu, and the tap event will be + /// consumed. + /// + /// Defaults to false. + final bool consumeOutsideTap; + /// A callback that is invoked when the menu is opened. final VoidCallback? onOpen; @@ -356,6 +376,7 @@ class _MenuAnchorState extends State { if (!widget.anchorTapClosesMenu) { child = TapRegion( groupId: _root, + consumeOutsideTaps: _root._isOpen && widget.consumeOutsideTap, onTapOutside: (PointerDownEvent event) { assert(_debugMenuInfo('Tapped Outside ${widget.controller}')); _closeChildren(); @@ -3522,6 +3543,7 @@ class _Submenu extends StatelessWidget { ), child: TapRegion( groupId: anchor._root, + consumeOutsideTaps: anchor._root._isOpen && anchor.widget.consumeOutsideTap, onTapOutside: (PointerDownEvent event) { anchor._close(); }, diff --git a/packages/flutter/lib/src/widgets/tap_region.dart b/packages/flutter/lib/src/widgets/tap_region.dart index c736f3a51f59f..721b8ee48051c 100644 --- a/packages/flutter/lib/src/widgets/tap_region.dart +++ b/packages/flutter/lib/src/widgets/tap_region.dart @@ -134,8 +134,9 @@ class TapRegionSurface extends SingleChildRenderObjectWidget { /// A render object that provides notification of a tap inside or outside of a /// set of registered regions, without participating in the [gesture -/// disambiguation](https://flutter.dev/gestures/#gesture-disambiguation) -/// system. +/// disambiguation](https://flutter.dev/gestures/#gesture-disambiguation) system +/// (other than to consume tap down events if [TapRegion.consumeOutsideTaps] is +/// true). /// /// The regions are defined by adding [RenderTapRegion] render objects in the /// render tree around the regions of interest, and they will register with this @@ -170,10 +171,10 @@ class TapRegionSurface extends SingleChildRenderObjectWidget { /// /// See also: /// -/// * [TapRegionSurface], a widget that inserts a [RenderTapRegionSurface] into -/// the render tree. -/// * [TapRegionRegistry.of], which can find the nearest ancestor -/// [RenderTapRegionSurface], which is a [TapRegionRegistry]. +/// * [TapRegionSurface], a widget that inserts a [RenderTapRegionSurface] into +/// the render tree. +/// * [TapRegionRegistry.of], which can find the nearest ancestor +/// [RenderTapRegionSurface], which is a [TapRegionRegistry]. class RenderTapRegionSurface extends RenderProxyBoxWithHitTestBehavior implements TapRegionRegistry { final Expando _cachedResults = Expando(); final Set _registeredRegions = {}; @@ -268,14 +269,26 @@ class RenderTapRegionSurface extends RenderProxyBoxWithHitTestBehavior implement // If they're not inside, then they're outside. final Set outsideRegions = _registeredRegions.difference(insideRegions); + bool consumeOutsideTaps = false; for (final RenderTapRegion region in outsideRegions) { assert(_tapRegionDebug('Calling onTapOutside for $region')); + if (region.consumeOutsideTaps) { + assert(_tapRegionDebug('Stopping tap propagation for $region (and all of ${region.groupId})')); + consumeOutsideTaps = true; + } region.onTapOutside?.call(event); } for (final RenderTapRegion region in insideRegions) { assert(_tapRegionDebug('Calling onTapInside for $region')); region.onTapInside?.call(event); } + + // If any of the "outside" regions have consumeOutsideTaps set, then stop + // the propagation of the event through the gesture recognizer by adding it + // to the recognizer and immediately resolving it. + if (consumeOutsideTaps) { + GestureBinding.instance.gestureArena.add(event.pointer, _DummyTapRecognizer()).resolve(GestureDisposition.accepted); + } } // Returns the registered regions that are in the hit path. @@ -291,10 +304,22 @@ class RenderTapRegionSurface extends RenderProxyBoxWithHitTestBehavior implement } } +// A dummy tap recognizer so that we don't have to deal with the lifecycle of +// TapGestureRecognizer, since we're just going to immediately resolve it +// anyhow. +class _DummyTapRecognizer extends GestureArenaMember { + @override + void acceptGesture(int pointer) { } + + @override + void rejectGesture(int pointer) { } +} + /// A widget that defines a region that can detect taps inside or outside of /// itself and any group of regions it belongs to, without participating in the -/// [gesture disambiguation](https://flutter.dev/gestures/#gesture-disambiguation) -/// system. +/// [gesture +/// disambiguation](https://flutter.dev/gestures/#gesture-disambiguation) system +/// (other than to consume tap down events if [consumeOutsideTaps] is true). /// /// This widget indicates to the nearest ancestor [TapRegionSurface] that the /// region occupied by its child will participate in the tap detection for that @@ -316,6 +341,7 @@ class TapRegion extends SingleChildRenderObjectWidget { this.onTapOutside, this.onTapInside, this.groupId, + this.consumeOutsideTaps = false, String? debugLabel, }) : debugLabel = kReleaseMode ? null : debugLabel; @@ -357,6 +383,19 @@ class TapRegion extends SingleChildRenderObjectWidget { /// If the group id is null, then only this region is hit tested. final Object? groupId; + /// If true, then the group that this region belongs to will stop the + /// propagation of the tap down event in the gesture arena. + /// + /// This is useful if you want to block the tap down from being given to a + /// [GestureDetector] when [onTapOutside] is called. + /// + /// If other [TapRegion]s with the same [groupId] have [consumeOutsideTaps] + /// set to false, but this one is true, then this one will take precedence, + /// and the event will be consumed. + /// + /// Defaults to false. + final bool consumeOutsideTaps; + /// An optional debug label to help with debugging in debug mode. /// /// Will be null in release mode. @@ -367,6 +406,7 @@ class TapRegion extends SingleChildRenderObjectWidget { return RenderTapRegion( registry: TapRegionRegistry.maybeOf(context), enabled: enabled, + consumeOutsideTaps: consumeOutsideTaps, behavior: behavior, onTapOutside: onTapOutside, onTapInside: onTapInside, @@ -430,6 +470,7 @@ class RenderTapRegion extends RenderProxyBoxWithHitTestBehavior { RenderTapRegion({ TapRegionRegistry? registry, bool enabled = true, + bool consumeOutsideTaps = false, this.onTapOutside, this.onTapInside, super.behavior = HitTestBehavior.deferToChild, @@ -437,6 +478,7 @@ class RenderTapRegion extends RenderProxyBoxWithHitTestBehavior { String? debugLabel, }) : _registry = registry, _enabled = enabled, + _consumeOutsideTaps = consumeOutsideTaps, _groupId = groupId, debugLabel = kReleaseMode ? null : debugLabel; @@ -473,6 +515,21 @@ class RenderTapRegion extends RenderProxyBoxWithHitTestBehavior { } } + /// Whether or not the tap down even that triggers a call to [onTapOutside] + /// will continue on to participate in the gesture arena. + /// + /// If any [RenderTapRegion] in the same group has [consumeOutsideTaps] set to + /// true, then the tap down event will be consumed before other gesture + /// recognizers can process them. + bool get consumeOutsideTaps => _consumeOutsideTaps; + bool _consumeOutsideTaps; + set consumeOutsideTaps(bool value) { + if (_consumeOutsideTaps != value) { + _consumeOutsideTaps = value; + markNeedsLayout(); + } + } + /// An optional group ID that groups [RenderTapRegion]s together so that they /// operate as one region. If any member of a group is hit by a particular /// tap, then the [onTapOutside] will not be called for any members of the @@ -583,6 +640,7 @@ class TextFieldTapRegion extends TapRegion { super.enabled, super.onTapOutside, super.onTapInside, + super.consumeOutsideTaps, super.debugLabel, }) : super(groupId: EditableText); } diff --git a/packages/flutter/test/material/menu_anchor_test.dart b/packages/flutter/test/material/menu_anchor_test.dart index 19e5b2e72c272..fb15af9d8910c 100644 --- a/packages/flutter/test/material/menu_anchor_test.dart +++ b/packages/flutter/test/material/menu_anchor_test.dart @@ -79,6 +79,10 @@ void main() { AlignmentGeometry? alignment, Offset alignmentOffset = Offset.zero, TextDirection textDirection = TextDirection.ltr, + bool consumesOutsideTap = false, + void Function(TestMenu)? onPressed, + void Function(TestMenu)? onOpen, + void Function(TestMenu)? onClose, }) { final FocusNode focusNode = FocusNode(); addTearDown(focusNode.dispose); @@ -87,44 +91,61 @@ void main() { home: Material( child: Directionality( textDirection: textDirection, - child: Center( - child: MenuAnchor( - childFocusNode: focusNode, - controller: controller, - alignmentOffset: alignmentOffset, - style: MenuStyle(alignment: alignment), - menuChildren: [ - MenuItemButton( - key: menuItemKey, - shortcut: const SingleActivator( - LogicalKeyboardKey.keyB, - control: true, + child: Column( + children: [ + GestureDetector(onTap: () { + onPressed?.call(TestMenu.outsideButton); + }, child: Text(TestMenu.outsideButton.label)), + MenuAnchor( + childFocusNode: focusNode, + controller: controller, + alignmentOffset: alignmentOffset, + consumeOutsideTap: consumesOutsideTap, + style: MenuStyle(alignment: alignment), + onOpen: () { + onOpen?.call(TestMenu.anchorButton); + }, + onClose: () { + onClose?.call(TestMenu.anchorButton); + }, + menuChildren: [ + MenuItemButton( + key: menuItemKey, + shortcut: const SingleActivator( + LogicalKeyboardKey.keyB, + control: true, + ), + onPressed: () { + onPressed?.call(TestMenu.subMenu00); + }, + child: Text(TestMenu.subMenu00.label), ), - onPressed: () {}, - child: Text(TestMenu.subMenu00.label), - ), - MenuItemButton( - leadingIcon: const Icon(Icons.send), - trailingIcon: const Icon(Icons.mail), - onPressed: () {}, - child: Text(TestMenu.subMenu01.label), - ), - ], - builder: (BuildContext context, MenuController controller, Widget? child) { - return ElevatedButton( - focusNode: focusNode, - onPressed: () { - if (controller.isOpen) { - controller.close(); - } else { - controller.open(); - } - }, - child: child, - ); - }, - child: const Text('Press Me'), - ), + MenuItemButton( + leadingIcon: const Icon(Icons.send), + trailingIcon: const Icon(Icons.mail), + onPressed: () { + onPressed?.call(TestMenu.subMenu01); + }, + child: Text(TestMenu.subMenu01.label), + ), + ], + builder: (BuildContext context, MenuController controller, Widget? child) { + return ElevatedButton( + focusNode: focusNode, + onPressed: () { + if (controller.isOpen) { + controller.close(); + } else { + controller.open(); + } + onPressed?.call(TestMenu.anchorButton); + }, + child: child, + ); + }, + child: Text(TestMenu.anchorButton.label), + ), + ], ), ), ), @@ -739,26 +760,26 @@ void main() { await tester.pumpWidget(buildTestApp()); final Rect buttonRect = tester.getRect(find.byType(ElevatedButton)); - expect(buttonRect, equals(const Rect.fromLTRB(328.0, 276.0, 472.0, 324.0))); + expect(buttonRect, equals(const Rect.fromLTRB(328.0, 14.0, 472.0, 62.0))); final Finder findMenuScope = find.ancestor(of: find.byKey(menuItemKey), matching: find.byType(FocusScope)).first; // Open the menu and make sure things are the right size, in the right place. await tester.tap(find.text('Press Me')); await tester.pump(); - expect(tester.getRect(findMenuScope), equals(const Rect.fromLTRB(328.0, 324.0, 602.0, 436.0))); + expect(tester.getRect(findMenuScope), equals(const Rect.fromLTRB(328.0, 62.0, 602.0, 174.0))); await tester.pumpWidget(buildTestApp(alignment: AlignmentDirectional.topStart)); await tester.pump(); - expect(tester.getRect(findMenuScope), equals(const Rect.fromLTRB(328.0, 276.0, 602.0, 388.0))); + expect(tester.getRect(findMenuScope), equals(const Rect.fromLTRB(328.0, 14.0, 602.0, 126.0))); await tester.pumpWidget(buildTestApp(alignment: AlignmentDirectional.center)); await tester.pump(); - expect(tester.getRect(findMenuScope), equals(const Rect.fromLTRB(400.0, 300.0, 674.0, 412.0))); + expect(tester.getRect(findMenuScope), equals(const Rect.fromLTRB(400.0, 38.0, 674.0, 150.0))); await tester.pumpWidget(buildTestApp(alignment: AlignmentDirectional.bottomEnd)); await tester.pump(); - expect(tester.getRect(findMenuScope), equals(const Rect.fromLTRB(472.0, 324.0, 746.0, 436.0))); + expect(tester.getRect(findMenuScope), equals(const Rect.fromLTRB(472.0, 62.0, 746.0, 174.0))); await tester.pumpWidget(buildTestApp(alignment: AlignmentDirectional.topStart)); await tester.pump(); @@ -782,7 +803,7 @@ void main() { await tester.pumpWidget(buildTestApp(textDirection: TextDirection.rtl)); final Rect buttonRect = tester.getRect(find.byType(ElevatedButton)); - expect(buttonRect, equals(const Rect.fromLTRB(328.0, 276.0, 472.0, 324.0))); + expect(buttonRect, equals(const Rect.fromLTRB(328.0, 14.0, 472.0, 62.0))); final Finder findMenuScope = find.ancestor(of: find.text(TestMenu.subMenu00.label), matching: find.byType(FocusScope)).first; @@ -790,20 +811,20 @@ void main() { // Open the menu and make sure things are the right size, in the right place. await tester.tap(find.text('Press Me')); await tester.pump(); - expect(tester.getRect(findMenuScope), equals(const Rect.fromLTRB(198.0, 324.0, 472.0, 436.0))); + expect(tester.getRect(findMenuScope), equals(const Rect.fromLTRB(198.0, 62.0, 472.0, 174.0))); await tester.pumpWidget(buildTestApp(textDirection: TextDirection.rtl, alignment: AlignmentDirectional.topStart)); await tester.pump(); - expect(tester.getRect(findMenuScope), equals(const Rect.fromLTRB(198.0, 276.0, 472.0, 388.0))); + expect(tester.getRect(findMenuScope), equals(const Rect.fromLTRB(198.0, 14.0, 472.0, 126.0))); await tester.pumpWidget(buildTestApp(textDirection: TextDirection.rtl, alignment: AlignmentDirectional.center)); await tester.pump(); - expect(tester.getRect(findMenuScope), equals(const Rect.fromLTRB(126.0, 300.0, 400.0, 412.0))); + expect(tester.getRect(findMenuScope), equals(const Rect.fromLTRB(126.0, 38.0, 400.0, 150.0))); await tester .pumpWidget(buildTestApp(textDirection: TextDirection.rtl, alignment: AlignmentDirectional.bottomEnd)); await tester.pump(); - expect(tester.getRect(findMenuScope), equals(const Rect.fromLTRB(54.0, 324.0, 328.0, 436.0))); + expect(tester.getRect(findMenuScope), equals(const Rect.fromLTRB(54.0, 62.0, 328.0, 174.0))); await tester.pumpWidget(buildTestApp(textDirection: TextDirection.rtl, alignment: AlignmentDirectional.topStart)); await tester.pump(); @@ -824,7 +845,7 @@ void main() { await tester.pumpWidget(buildTestApp(alignmentOffset: const Offset(100, 50))); final Rect buttonRect = tester.getRect(find.byType(ElevatedButton)); - expect(buttonRect, equals(const Rect.fromLTRB(328.0, 276.0, 472.0, 324.0))); + expect(buttonRect, equals(const Rect.fromLTRB(328.0, 14.0, 472.0, 62.0))); final Finder findMenuScope = find.ancestor(of: find.text(TestMenu.subMenu00.label), matching: find.byType(FocusScope)).first; @@ -832,13 +853,13 @@ void main() { // Open the menu and make sure things are the right size, in the right place. await tester.tap(find.text('Press Me')); await tester.pump(); - expect(tester.getRect(findMenuScope), equals(const Rect.fromLTRB(428.0, 374.0, 702.0, 486.0))); + expect(tester.getRect(findMenuScope), equals(const Rect.fromLTRB(428.0, 112.0, 702.0, 224.0))); // Now move the menu by calling open() again with a local position on the // anchor. controller.open(position: const Offset(200, 200)); await tester.pump(); - expect(tester.getRect(findMenuScope), equals(const Rect.fromLTRB(526.0, 476.0, 800.0, 588.0))); + expect(tester.getRect(findMenuScope), equals(const Rect.fromLTRB(526.0, 214.0, 800.0, 326.0))); }); testWidgetsWithLeakTracking('menu position in RTL', (WidgetTester tester) async { @@ -848,8 +869,8 @@ void main() { )); final Rect buttonRect = tester.getRect(find.byType(ElevatedButton)); - expect(buttonRect, equals(const Rect.fromLTRB(328.0, 276.0, 472.0, 324.0))); - expect(buttonRect, equals(const Rect.fromLTRB(328.0, 276.0, 472.0, 324.0))); + expect(buttonRect, equals(const Rect.fromLTRB(328.0, 14.0, 472.0, 62.0))); + expect(buttonRect, equals(const Rect.fromLTRB(328.0, 14.0, 472.0, 62.0))); final Finder findMenuScope = find.ancestor(of: find.text(TestMenu.subMenu00.label), matching: find.byType(FocusScope)).first; @@ -857,13 +878,13 @@ void main() { // Open the menu and make sure things are the right size, in the right place. await tester.tap(find.text('Press Me')); await tester.pump(); - expect(tester.getRect(findMenuScope), equals(const Rect.fromLTRB(98.0, 374.0, 372.0, 486.0))); + expect(tester.getRect(findMenuScope), equals(const Rect.fromLTRB(98.0, 112.0, 372.0, 224.0))); // Now move the menu by calling open() again with a local position on the // anchor. controller.open(position: const Offset(400, 200)); await tester.pump(); - expect(tester.getRect(findMenuScope), equals(const Rect.fromLTRB(526.0, 476.0, 800.0, 588.0))); + expect(tester.getRect(findMenuScope), equals(const Rect.fromLTRB(526.0, 214.0, 800.0, 326.0))); }); testWidgetsWithLeakTracking('works with Padding around menu and overlay', (WidgetTester tester) async { @@ -1116,6 +1137,79 @@ void main() { expect(closed, equals([TestMenu.mainMenu1])); }); + + testWidgetsWithLeakTracking('Menus close and consume tap when open and tapped outside', (WidgetTester tester) async { + await tester.pumpWidget( + buildTestApp(consumesOutsideTap: true, onPressed: onPressed, onOpen: onOpen, onClose: onClose), + ); + + expect(opened, isEmpty); + expect(closed, isEmpty); + + // Doesn't consume tap when the menu is closed. + await tester.tap(find.text(TestMenu.outsideButton.label)); + await tester.pump(); + expect(selected, equals([TestMenu.outsideButton])); + selected.clear(); + + await tester.tap(find.text(TestMenu.anchorButton.label)); + await tester.pump(); + expect(opened, equals([TestMenu.anchorButton])); + expect(closed, isEmpty); + expect(selected, equals([TestMenu.anchorButton])); + opened.clear(); + closed.clear(); + selected.clear(); + + await tester.tap(find.text(TestMenu.outsideButton.label)); + await tester.pump(); + + expect(opened, isEmpty); + expect(closed, equals([TestMenu.anchorButton])); + // When the menu is open, don't expect the outside button to be selected: + // it's supposed to consume the key down. + expect(selected, isEmpty); + selected.clear(); + opened.clear(); + closed.clear(); + }); + + testWidgetsWithLeakTracking("Menus close and don't consume tap when open and tapped outside", (WidgetTester tester) async { + await tester.pumpWidget( + buildTestApp(onPressed: onPressed, onOpen: onOpen, onClose: onClose), + ); + + expect(opened, isEmpty); + expect(closed, isEmpty); + + // Doesn't consume tap when the menu is closed. + await tester.tap(find.text(TestMenu.outsideButton.label)); + await tester.pump(); + expect(selected, equals([TestMenu.outsideButton])); + selected.clear(); + + await tester.tap(find.text(TestMenu.anchorButton.label)); + await tester.pump(); + expect(opened, equals([TestMenu.anchorButton])); + expect(closed, isEmpty); + expect(selected, equals([TestMenu.anchorButton])); + opened.clear(); + closed.clear(); + selected.clear(); + + await tester.tap(find.text(TestMenu.outsideButton.label)); + await tester.pump(); + + expect(opened, isEmpty); + expect(closed, equals([TestMenu.anchorButton])); + // Because consumesOutsideTap is false, this is expected to receive its + // tap. + expect(selected, equals([TestMenu.outsideButton])); + selected.clear(); + opened.clear(); + closed.clear(); + }); + testWidgetsWithLeakTracking('select works', (WidgetTester tester) async { await tester.pumpWidget( MaterialApp( @@ -3503,7 +3597,9 @@ enum TestMenu { subSubMenu110('Sub Sub Menu 11&0'), subSubMenu111('Sub Sub Menu 11&1'), subSubMenu112('Sub Sub Menu 11&2'), - subSubMenu113('Sub Sub Menu 11&3'); + subSubMenu113('Sub Sub Menu 11&3'), + anchorButton('Press Me'), + outsideButton('Outside'); const TestMenu(this.acceleratorLabel); final String acceleratorLabel; diff --git a/packages/flutter/test/widgets/tap_region_test.dart b/packages/flutter/test/widgets/tap_region_test.dart index a244518417d1f..4f31a9e8454b4 100644 --- a/packages/flutter/test/widgets/tap_region_test.dart +++ b/packages/flutter/test/widgets/tap_region_test.dart @@ -102,6 +102,111 @@ void main() { expect(tappedOutside, isEmpty); }); + testWidgetsWithLeakTracking('TapRegionSurface consumes outside taps when asked', (WidgetTester tester) async { + final Set tappedOutside = {}; + int propagatedTaps = 0; + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: Column( + children: [ + const Text('Outside Surface'), + TapRegionSurface( + child: Row( + children: [ + GestureDetector( + onTap: () { + propagatedTaps += 1; + }, + child: const Text('Outside'), + ), + TapRegion( + consumeOutsideTaps: true, + onTapOutside: (PointerEvent event) { + tappedOutside.add('No Group'); + }, + child: const Text('No Group'), + ), + TapRegion( + groupId: 1, + onTapOutside: (PointerEvent event) { + tappedOutside.add('Group 1 A'); + }, + child: const Text('Group 1 A'), + ), + TapRegion( + groupId: 1, + consumeOutsideTaps: true, + onTapOutside: (PointerEvent event) { + tappedOutside.add('Group 1 B'); + }, + child: const Text('Group 1 B'), + ), + ], + ), + ), + ], + ), + ), + ); + + await tester.pump(); + + Future click(Finder finder) async { + final TestGesture gesture = await tester.startGesture( + tester.getCenter(finder), + kind: PointerDeviceKind.mouse, + ); + await gesture.up(); + await gesture.removePointer(); + } + + expect(tappedOutside, isEmpty); + expect(propagatedTaps, equals(0)); + + await click(find.text('No Group')); + expect( + tappedOutside, + unorderedEquals({ + 'Group 1 A', + 'Group 1 B', + })); + expect(propagatedTaps, equals(0)); + tappedOutside.clear(); + + await click(find.text('Group 1 A')); + expect( + tappedOutside, + equals({ + 'No Group', + })); + expect(propagatedTaps, equals(0)); + tappedOutside.clear(); + + await click(find.text('Group 1 B')); + expect( + tappedOutside, + equals({ + 'No Group', + })); + expect(propagatedTaps, equals(0)); + tappedOutside.clear(); + + await click(find.text('Outside')); + expect( + tappedOutside, + unorderedEquals({ + 'No Group', + 'Group 1 A', + 'Group 1 B', + })); + expect(propagatedTaps, equals(0)); + tappedOutside.clear(); + + await click(find.text('Outside Surface')); + expect(tappedOutside, isEmpty); + }); + testWidgetsWithLeakTracking('TapRegionSurface detects inside taps', (WidgetTester tester) async { final Set tappedInside = {}; await tester.pumpWidget( @@ -206,8 +311,6 @@ void main() { ConstrainedBox( constraints: const BoxConstraints.tightFor(width: 100, height: 100), child: TapRegion( - // ignore: avoid_redundant_argument_values - behavior: HitTestBehavior.deferToChild, onTapInside: (PointerEvent event) { tappedInside.add(noGroupKey.value); }, @@ -263,7 +366,8 @@ void main() { await click(find.byKey(group1AKey)); // No hittable children, but set to opaque, so it hits, triggering the // group. - expect(tappedInside, + expect( + tappedInside, equals({ 'Group 1 A', 'Group 1 B',