Dismiss modal with any button press (#32770)
This PR makes ModalBarrier dismiss modal with any button press instead of primary button up, by making it use a private recognizer _AnyTapGestureRecognizer that claims victor and calls onAnyTapDown immediately after it receives any PointerDownEvent.
diff --git a/packages/flutter/lib/src/widgets/modal_barrier.dart b/packages/flutter/lib/src/widgets/modal_barrier.dart
index 567a7f9..e9be288 100644
--- a/packages/flutter/lib/src/widgets/modal_barrier.dart
+++ b/packages/flutter/lib/src/widgets/modal_barrier.dart
@@ -3,6 +3,9 @@
// found in the LICENSE file.
import 'package:flutter/foundation.dart';
+import 'package:flutter/gestures.dart' show
+ PrimaryPointerGestureRecognizer,
+ GestureDisposition;
import 'basic.dart';
import 'container.dart';
@@ -81,12 +84,11 @@
// On Android, the back button is used to dismiss a modal. On iOS, some
// modal barriers are not dismissible in accessibility mode.
excluding: !semanticsDismissible || !modalBarrierSemanticsDismissible,
- child: GestureDetector(
- onTapDown: (TapDownDetails details) {
+ child: _ModalBarrierGestureDetector(
+ onAnyTapDown: () {
if (dismissible)
Navigator.maybePop(context);
},
- behavior: HitTestBehavior.opaque,
child: Semantics(
label: semanticsDismissible ? semanticsLabel : null,
textDirection: semanticsDismissible && semanticsLabel != null ? Directionality.of(context) : null,
@@ -175,3 +177,102 @@
);
}
}
+
+// Recognizes tap down by any pointer button unconditionally. When it receives a
+// PointerDownEvent, it immediately claims victor of arena and calls
+// [onAnyTapDown] without any checks.
+//
+// It is used by ModalBarrier to detect any taps on the overlay.
+class _AnyTapGestureRecognizer extends PrimaryPointerGestureRecognizer {
+ _AnyTapGestureRecognizer({
+ Object debugOwner,
+ this.onAnyTapDown,
+ }) : super(debugOwner: debugOwner);
+
+ VoidCallback onAnyTapDown;
+
+ bool _sentTapDown = false;
+
+ @override
+ void addAllowedPointer(PointerDownEvent event) {
+ super.addAllowedPointer(event);
+ resolve(GestureDisposition.accepted);
+ }
+
+ @override
+ void handlePrimaryPointer(PointerEvent event) {
+ if (!_sentTapDown) {
+ if (onAnyTapDown != null)
+ onAnyTapDown();
+ _sentTapDown = true;
+ }
+ }
+
+ @override
+ void didStopTrackingLastPointer(int pointer) {
+ super.didStopTrackingLastPointer(pointer);
+ _sentTapDown = false;
+ }
+
+ @override
+ String get debugDescription => 'any tap';
+}
+
+class _ModalBarrierSemanticsDelegate extends SemanticsGestureDelegate {
+ const _ModalBarrierSemanticsDelegate({this.onAnyTapDown});
+
+ final VoidCallback onAnyTapDown;
+
+ @override
+ void assignSemantics(RenderSemanticsGestureHandler renderObject) {
+ renderObject.onTap = onAnyTapDown;
+ }
+}
+
+class _AnyTapGestureRecognizerFactory extends GestureRecognizerFactory<_AnyTapGestureRecognizer> {
+ const _AnyTapGestureRecognizerFactory({this.onAnyTapDown});
+
+ final VoidCallback onAnyTapDown;
+
+ @override
+ _AnyTapGestureRecognizer constructor() => _AnyTapGestureRecognizer();
+
+ @override
+ void initializer(_AnyTapGestureRecognizer instance) {
+ instance.onAnyTapDown = onAnyTapDown;
+ }
+}
+
+// A GestureDetector used by ModalBarrier. It only has one callback,
+// [onAnyTapDown], which recognizes tap down unconditionally.
+class _ModalBarrierGestureDetector extends StatelessWidget {
+ const _ModalBarrierGestureDetector({
+ Key key,
+ @required this.child,
+ @required this.onAnyTapDown,
+ }) : assert(child != null),
+ assert(onAnyTapDown != null),
+ super(key: key);
+
+ /// The widget below this widget in the tree.
+ /// See [RawGestureDetector.child].
+ final Widget child;
+
+ /// Immediately called when a pointer causes a tap down.
+ /// See [_AnyTapGestureRecognizer.onAnyTapDown].
+ final VoidCallback onAnyTapDown;
+
+ @override
+ Widget build(BuildContext context) {
+ final Map<Type, GestureRecognizerFactory> gestures = <Type, GestureRecognizerFactory>{
+ _AnyTapGestureRecognizer: _AnyTapGestureRecognizerFactory(onAnyTapDown: onAnyTapDown),
+ };
+
+ return RawGestureDetector(
+ gestures: gestures,
+ behavior: HitTestBehavior.opaque,
+ semantics: _ModalBarrierSemanticsDelegate(onAnyTapDown: onAnyTapDown),
+ child: child,
+ );
+ }
+}
diff --git a/packages/flutter/test/widgets/modal_barrier_test.dart b/packages/flutter/test/widgets/modal_barrier_test.dart
index d80ec98..70584a1 100644
--- a/packages/flutter/test/widgets/modal_barrier_test.dart
+++ b/packages/flutter/test/widgets/modal_barrier_test.dart
@@ -7,6 +7,7 @@
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter/widgets.dart';
+import 'package:flutter/gestures.dart' show kSecondaryButton;
import 'semantics_tester.dart';
@@ -60,7 +61,7 @@
reason: 'because the tap is not prevented by ModalBarrier');
});
- testWidgets('ModalBarrier pops the Navigator when dismissed', (WidgetTester tester) async {
+ testWidgets('ModalBarrier pops the Navigator when dismissed by primay tap', (WidgetTester tester) async {
final Map<String, WidgetBuilder> routes = <String, WidgetBuilder>{
'/': (BuildContext context) => FirstWidget(),
'/modal': (BuildContext context) => SecondWidget(),
@@ -85,6 +86,56 @@
reason: 'The route should have been dismissed by tapping the barrier.');
});
+ testWidgets('ModalBarrier pops the Navigator when dismissed by primary tap down', (WidgetTester tester) async {
+ final Map<String, WidgetBuilder> routes = <String, WidgetBuilder>{
+ '/': (BuildContext context) => FirstWidget(),
+ '/modal': (BuildContext context) => SecondWidget(),
+ };
+
+ await tester.pumpWidget(MaterialApp(routes: routes));
+
+ // Initially the barrier is not visible
+ expect(find.byKey(const ValueKey<String>('barrier')), findsNothing);
+
+ // Tapping on X routes to the barrier
+ await tester.tap(find.text('X'));
+ await tester.pump(); // begin transition
+ await tester.pump(const Duration(seconds: 1)); // end transition
+
+ // Tap on the barrier to dismiss it
+ await tester.press(find.byKey(const ValueKey<String>('barrier')));
+ await tester.pump(); // begin transition
+ await tester.pump(const Duration(seconds: 1)); // end transition
+
+ expect(find.byKey(const ValueKey<String>('barrier')), findsNothing,
+ reason: 'The route should have been dismissed by tapping the barrier.');
+ });
+
+ testWidgets('ModalBarrier pops the Navigator when dismissed by non-primary tap down', (WidgetTester tester) async {
+ final Map<String, WidgetBuilder> routes = <String, WidgetBuilder>{
+ '/': (BuildContext context) => FirstWidget(),
+ '/modal': (BuildContext context) => SecondWidget(),
+ };
+
+ await tester.pumpWidget(MaterialApp(routes: routes));
+
+ // Initially the barrier is not visible
+ expect(find.byKey(const ValueKey<String>('barrier')), findsNothing);
+
+ // Tapping on X routes to the barrier
+ await tester.tap(find.text('X'));
+ await tester.pump(); // begin transition
+ await tester.pump(const Duration(seconds: 1)); // end transition
+
+ // Tap on the barrier to dismiss it
+ await tester.press(find.byKey(const ValueKey<String>('barrier')), buttons: kSecondaryButton);
+ await tester.pump(); // begin transition
+ await tester.pump(const Duration(seconds: 1)); // end transition
+
+ expect(find.byKey(const ValueKey<String>('barrier')), findsNothing,
+ reason: 'The route should have been dismissed by tapping the barrier.');
+ });
+
testWidgets('ModalBarrier does not pop the Navigator with a WillPopScope that returns false', (WidgetTester tester) async {
bool willPopCalled = false;
final Map<String, WidgetBuilder> routes = <String, WidgetBuilder>{