From 1aa4628fa27034deb68743d636be5aba04b4ad6f Mon Sep 17 00:00:00 2001 From: Tong Mu Date: Thu, 18 Jul 2019 15:33:49 -0700 Subject: [PATCH] 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. --- .../lib/src/widgets/modal_barrier.dart | 107 +++++++++++++++++- .../test/widgets/modal_barrier_test.dart | 53 ++++++++- 2 files changed, 156 insertions(+), 4 deletions(-) diff --git a/packages/flutter/lib/src/widgets/modal_barrier.dart b/packages/flutter/lib/src/widgets/modal_barrier.dart index 567a7f9d35..e9be288e98 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 @@ class ModalBarrier extends StatelessWidget { // 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 @@ class AnimatedModalBarrier extends AnimatedWidget { ); } } + +// 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 gestures = { + _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 d80ec98d0f..70584a1ea1 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/foundation.dart'; 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 @@ void main() { 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 routes = { '/': (BuildContext context) => FirstWidget(), '/modal': (BuildContext context) => SecondWidget(), @@ -85,6 +86,56 @@ void main() { 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 routes = { + '/': (BuildContext context) => FirstWidget(), + '/modal': (BuildContext context) => SecondWidget(), + }; + + await tester.pumpWidget(MaterialApp(routes: routes)); + + // Initially the barrier is not visible + expect(find.byKey(const ValueKey('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('barrier'))); + await tester.pump(); // begin transition + await tester.pump(const Duration(seconds: 1)); // end transition + + expect(find.byKey(const ValueKey('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 routes = { + '/': (BuildContext context) => FirstWidget(), + '/modal': (BuildContext context) => SecondWidget(), + }; + + await tester.pumpWidget(MaterialApp(routes: routes)); + + // Initially the barrier is not visible + expect(find.byKey(const ValueKey('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('barrier')), buttons: kSecondaryButton); + await tester.pump(); // begin transition + await tester.pump(const Duration(seconds: 1)); // end transition + + expect(find.byKey(const ValueKey('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 routes = {