Redo: Modal dismissed by any button (#36956)

This PR makes ModalBarrier dismiss modal with any button press instead of primary button up, by making it use a private recognizer _AnyTapGestureRecognizer that is similar to TapGestureRecognizer but accepts gestures by any single button.
diff --git a/packages/flutter/lib/src/widgets/modal_barrier.dart b/packages/flutter/lib/src/widgets/modal_barrier.dart
index 567a7f9..37b66e5 100644
--- a/packages/flutter/lib/src/widgets/modal_barrier.dart
+++ b/packages/flutter/lib/src/widgets/modal_barrier.dart
@@ -3,6 +3,7 @@
 // found in the LICENSE file.
 
 import 'package:flutter/foundation.dart';
+import 'package:flutter/gestures.dart';
 
 import 'basic.dart';
 import 'container.dart';
@@ -81,12 +82,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 +175,162 @@
     );
   }
 }
+
+// Recognizes tap down by any pointer button.
+//
+// It is similar to [TapGestureRecognizer.onTapDown], but accepts any single
+// button, which means the gesture also takes parts in gesture arenas.
+class _AnyTapGestureRecognizer extends PrimaryPointerGestureRecognizer {
+  _AnyTapGestureRecognizer({
+    Object debugOwner,
+  }) : super(debugOwner: debugOwner);
+
+  VoidCallback onAnyTapDown;
+
+  bool _sentTapDown = false;
+  bool _wonArenaForPrimaryPointer = false;
+  // The buttons sent by `PointerDownEvent`. If a `PointerMoveEvent` comes with a
+  // different set of buttons, the gesture is canceled.
+  int _initialButtons;
+
+  @override
+  void addAllowedPointer(PointerDownEvent event) {
+    super.addAllowedPointer(event);
+    // `_initialButtons` must be assigned here instead of `handlePrimaryPointer`,
+    // because `acceptGesture` might be called before `handlePrimaryPointer`,
+    // which relies on `_initialButtons` to create `TapDownDetails`.
+    _initialButtons = event.buttons;
+  }
+
+  @override
+  void handlePrimaryPointer(PointerEvent event) {
+    if (event is PointerUpEvent || event is PointerCancelEvent) {
+      resolve(GestureDisposition.rejected);
+      _reset();
+    } else if (event.buttons != _initialButtons) {
+      resolve(GestureDisposition.rejected);
+      stopTrackingPointer(primaryPointer);
+    }
+  }
+
+  @override
+  void resolve(GestureDisposition disposition) {
+    if (_wonArenaForPrimaryPointer && disposition == GestureDisposition.rejected) {
+      // This can happen if the gesture has been canceled. For example, when
+      // the pointer has exceeded the touch slop, the buttons have been changed,
+      // or if the recognizer is disposed.
+      assert(_sentTapDown);
+      _reset();
+    }
+    super.resolve(disposition);
+  }
+
+  @override
+  void didExceedDeadlineWithEvent(PointerDownEvent event) {
+    _checkDown(event.pointer);
+  }
+
+  @override
+  void acceptGesture(int pointer) {
+    super.acceptGesture(pointer);
+    if (pointer == primaryPointer) {
+      _checkDown(pointer);
+      _wonArenaForPrimaryPointer = true;
+      _reset();
+    }
+  }
+
+  @override
+  void rejectGesture(int pointer) {
+    super.rejectGesture(pointer);
+    if (pointer == primaryPointer) {
+      // Another gesture won the arena.
+      assert(state != GestureRecognizerState.possible);
+      _reset();
+    }
+  }
+
+  void _checkDown(int pointer) {
+    if (_sentTapDown)
+      return;
+    if (onAnyTapDown != null)
+      onAnyTapDown();
+    _sentTapDown = true;
+  }
+
+  void _reset() {
+    _sentTapDown = false;
+    _wonArenaForPrimaryPointer = false;
+    _initialButtons = null;
+  }
+
+  @override
+  String get debugDescription => 'any tap';
+
+  @override
+  void debugFillProperties(DiagnosticPropertiesBuilder properties) {
+    super.debugFillProperties(properties);
+    properties.add(FlagProperty('wonArenaForPrimaryPointer', value: _wonArenaForPrimaryPointer, ifTrue: 'won arena'));
+    properties.add(FlagProperty('sentTapDown', value: _sentTapDown, ifTrue: 'sent tap down'));
+  }
+}
+
+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..cc5c267 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';
 
@@ -41,7 +42,7 @@
     await tester.tap(find.text('target'));
     await tester.pumpWidget(subject);
     expect(tapped, isFalse,
-      reason: 'because the tap is prevented by ModalBarrier');
+      reason: 'because the tap is not prevented by ModalBarrier');
   });
 
   testWidgets('ModalBarrier does not prevent interactions with widgets in front of it', (WidgetTester tester) async {
@@ -57,10 +58,38 @@
     await tester.tap(find.text('target'));
     await tester.pumpWidget(subject);
     expect(tapped, isTrue,
-      reason: 'because the tap is not prevented by ModalBarrier');
+      reason: 'because the tap is prevented by ModalBarrier');
   });
 
-  testWidgets('ModalBarrier pops the Navigator when dismissed', (WidgetTester tester) async {
+  testWidgets('ModalBarrier does not prevent interactions with translucent widgets in front of it', (WidgetTester tester) async {
+    bool dragged = false;
+    final Widget subject = Stack(
+      textDirection: TextDirection.ltr,
+      children: <Widget>[
+        const ModalBarrier(dismissible: false),
+        GestureDetector(
+          behavior: HitTestBehavior.translucent,
+          onHorizontalDragStart: (_) {
+            dragged = true;
+          },
+          child: const Center(
+            child: Text('target', textDirection: TextDirection.ltr),
+          ),
+        ),
+      ],
+    );
+
+    await tester.pumpWidget(subject);
+    await tester.dragFrom(
+      tester.getBottomRight(find.byType(GestureDetector)) - const Offset(10, 10),
+      const Offset(-20, 0),
+    );
+    await tester.pumpWidget(subject);
+    expect(dragged, isTrue,
+      reason: 'because the drag is prevented by ModalBarrier');
+  });
+
+  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 +114,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>{