From 22b0a62a0c81677e54506938f62dcc90afcdd0b1 Mon Sep 17 00:00:00 2001 From: Greg Spencer Date: Thu, 12 Oct 2023 14:04:41 -0700 Subject: [PATCH] Allow `TapRegion` to consume tap events (#136305) ## Description In order for `MenuAnchor` menus to be able to not pass on the taps that close their menus, `TapRegion` needed a way to consume them. This change adds a flag to the `TapRegion`, `consumeOutsideTap` that will consume taps that occur outside of the region if the flag is set (it is false by default). The same flag is added to `MenuAnchor` to allow selecting the behavior for menus. `TapRegion` consumes the tap event by registering with the gesture arena and immediately resolving the tap as accepted if any regions in a group have `consumeOutsideTap` set to true. This PR also deprecates `MenuAnchor.anchorTapClosesMenu`, since it is a much more limited version of the same feature that only applied to the anchor itself, and even then only applied to closing the menu, not passing along the tap. The same functionality can now be implemented by handling a tap on the anchor widget and checking to see if the menu is open before closing it. ## Related Issues - https://github.com/flutter/flutter/issues/135327 ## Tests - Added tests for `TapRegion` to make sure taps are consumed properly. --- .../material/menu_anchor/menu_anchor.1.dart | 5 +- .../flutter/lib/src/material/menu_anchor.dart | 22 ++ .../flutter/lib/src/widgets/tap_region.dart | 74 ++++++- .../test/material/menu_anchor_test.dart | 206 +++++++++++++----- .../flutter/test/widgets/tap_region_test.dart | 110 +++++++++- 5 files changed, 350 insertions(+), 67 deletions(-) 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 7ce3e78c118..e6b6d07f2e4 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 608345823e0..b512d50c9f4 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 c736f3a51f5..721b8ee4805 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 19e5b2e72c2..fb15af9d891 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 a244518417d..4f31a9e8454 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',