Unverified Commit 24f483cf authored by Tong Mu's avatar Tong Mu Committed by GitHub

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.
parent 616794fc
...@@ -3,6 +3,7 @@ ...@@ -3,6 +3,7 @@
// found in the LICENSE file. // found in the LICENSE file.
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/gestures.dart';
import 'basic.dart'; import 'basic.dart';
import 'container.dart'; import 'container.dart';
...@@ -81,12 +82,11 @@ class ModalBarrier extends StatelessWidget { ...@@ -81,12 +82,11 @@ class ModalBarrier extends StatelessWidget {
// On Android, the back button is used to dismiss a modal. On iOS, some // On Android, the back button is used to dismiss a modal. On iOS, some
// modal barriers are not dismissible in accessibility mode. // modal barriers are not dismissible in accessibility mode.
excluding: !semanticsDismissible || !modalBarrierSemanticsDismissible, excluding: !semanticsDismissible || !modalBarrierSemanticsDismissible,
child: GestureDetector( child: _ModalBarrierGestureDetector(
onTapDown: (TapDownDetails details) { onAnyTapDown: () {
if (dismissible) if (dismissible)
Navigator.maybePop(context); Navigator.maybePop(context);
}, },
behavior: HitTestBehavior.opaque,
child: Semantics( child: Semantics(
label: semanticsDismissible ? semanticsLabel : null, label: semanticsDismissible ? semanticsLabel : null,
textDirection: semanticsDismissible && semanticsLabel != null ? Directionality.of(context) : null, textDirection: semanticsDismissible && semanticsLabel != null ? Directionality.of(context) : null,
...@@ -175,3 +175,162 @@ class AnimatedModalBarrier extends AnimatedWidget { ...@@ -175,3 +175,162 @@ class AnimatedModalBarrier extends AnimatedWidget {
); );
} }
} }
// 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,
);
}
}
...@@ -7,6 +7,7 @@ import 'package:flutter/foundation.dart'; ...@@ -7,6 +7,7 @@ import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart'; import 'package:flutter/rendering.dart';
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
import 'package:flutter/gestures.dart' show kSecondaryButton;
import 'semantics_tester.dart'; import 'semantics_tester.dart';
...@@ -41,7 +42,7 @@ void main() { ...@@ -41,7 +42,7 @@ void main() {
await tester.tap(find.text('target')); await tester.tap(find.text('target'));
await tester.pumpWidget(subject); await tester.pumpWidget(subject);
expect(tapped, isFalse, 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 { testWidgets('ModalBarrier does not prevent interactions with widgets in front of it', (WidgetTester tester) async {
...@@ -57,10 +58,38 @@ void main() { ...@@ -57,10 +58,38 @@ void main() {
await tester.tap(find.text('target')); await tester.tap(find.text('target'));
await tester.pumpWidget(subject); await tester.pumpWidget(subject);
expect(tapped, isTrue, 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>{ final Map<String, WidgetBuilder> routes = <String, WidgetBuilder>{
'/': (BuildContext context) => FirstWidget(), '/': (BuildContext context) => FirstWidget(),
'/modal': (BuildContext context) => SecondWidget(), '/modal': (BuildContext context) => SecondWidget(),
...@@ -85,6 +114,56 @@ void main() { ...@@ -85,6 +114,56 @@ void main() {
reason: 'The route should have been dismissed by tapping the barrier.'); 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 { testWidgets('ModalBarrier does not pop the Navigator with a WillPopScope that returns false', (WidgetTester tester) async {
bool willPopCalled = false; bool willPopCalled = false;
final Map<String, WidgetBuilder> routes = <String, WidgetBuilder>{ final Map<String, WidgetBuilder> routes = <String, WidgetBuilder>{
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment