Unverified Commit 06de5678 authored by Darren Austin's avatar Darren Austin Committed by GitHub

Dismiss modal routes with a keyboard shortcut (#59310)

parent c6f6de6d
......@@ -1153,3 +1153,21 @@ class SelectIntent extends Intent {}
/// This is an abstract class that serves as a base class for actions that
/// select something. It is not bound to any key by default.
abstract class SelectAction extends Action<SelectIntent> {}
/// An [Intent] that dismisses the currently focused widget.
///
/// The [WidgetsApp.defaultShortcuts] binds this intent to the
/// [LogicalKeyboardKey.escape] and [LogicalKeyboardKey.gameButtonB] keys.
///
/// See also:
/// - [ModalRoute] which listens for this intent to dismiss modal routes
/// (dialogs, pop-up menus, drawers, etc).
class DismissIntent extends Intent {
/// Creates a const [DismissIntent].
const DismissIntent();
}
/// An action that dismisses the focused widget.
///
/// This is an abstract class that serves as a base class for dismiss actions.
abstract class DismissAction extends Action<DismissIntent> {}
......@@ -852,6 +852,10 @@ class WidgetsApp extends StatefulWidget {
LogicalKeySet(LogicalKeyboardKey.space): const ActivateIntent(),
LogicalKeySet(LogicalKeyboardKey.gameButtonA): const ActivateIntent(),
// Dismissal
LogicalKeySet(LogicalKeyboardKey.escape): const DismissIntent(),
LogicalKeySet(LogicalKeyboardKey.gameButtonB): const ActivateIntent(),
// Keyboard traversal.
LogicalKeySet(LogicalKeyboardKey.tab): const NextFocusIntent(),
LogicalKeySet(LogicalKeyboardKey.shift, LogicalKeyboardKey.tab): const PreviousFocusIntent(),
......@@ -874,6 +878,9 @@ class WidgetsApp extends StatefulWidget {
// Activation
LogicalKeySet(LogicalKeyboardKey.space): const ActivateIntent(),
// Dismissal
LogicalKeySet(LogicalKeyboardKey.escape): const DismissIntent(),
// Keyboard traversal.
LogicalKeySet(LogicalKeyboardKey.tab): const NextFocusIntent(),
LogicalKeySet(LogicalKeyboardKey.shift, LogicalKeyboardKey.tab): const PreviousFocusIntent(),
......@@ -893,6 +900,9 @@ class WidgetsApp extends StatefulWidget {
LogicalKeySet(LogicalKeyboardKey.enter): const ActivateIntent(),
LogicalKeySet(LogicalKeyboardKey.space): const ActivateIntent(),
// Dismissal
LogicalKeySet(LogicalKeyboardKey.escape): const DismissIntent(),
// Keyboard traversal
LogicalKeySet(LogicalKeyboardKey.tab): const NextFocusIntent(),
LogicalKeySet(LogicalKeyboardKey.shift, LogicalKeyboardKey.tab): const PreviousFocusIntent(),
......
......@@ -10,6 +10,7 @@ import 'dart:ui' as ui;
import 'package:flutter/foundation.dart';
import 'package:flutter/scheduler.dart';
import 'actions.dart';
import 'basic.dart';
import 'focus_manager.dart';
import 'focus_scope.dart';
......@@ -651,6 +652,13 @@ mixin LocalHistoryRoute<T> on Route<T> {
}
}
class _DismissModalAction extends DismissAction {
@override
Object invoke(DismissIntent intent) {
return Navigator.of(primaryFocus.context).maybePop();
}
}
class _ModalScopeStatus extends InheritedWidget {
const _ModalScopeStatus({
Key key,
......@@ -762,6 +770,10 @@ class _ModalScopeState<T> extends State<_ModalScope<T>> {
setState(fn);
}
static final Map<Type, Action<Intent>> _actionMap = <Type, Action<Intent>>{
DismissIntent: _DismissModalAction(),
};
@override
Widget build(BuildContext context) {
return _ModalScopeStatus(
......@@ -772,44 +784,47 @@ class _ModalScopeState<T> extends State<_ModalScope<T>> {
offstage: widget.route.offstage, // _routeSetState is called if this updates
child: PageStorage(
bucket: widget.route._storageBucket, // immutable
child: FocusScope(
node: focusScopeNode, // immutable
child: RepaintBoundary(
child: AnimatedBuilder(
animation: _listenable, // immutable
builder: (BuildContext context, Widget child) {
return widget.route.buildTransitions(
context,
widget.route.animation,
widget.route.secondaryAnimation,
// This additional AnimatedBuilder is include because if the
// value of the userGestureInProgressNotifier changes, it's
// only necessary to rebuild the IgnorePointer widget and set
// the focus node's ability to focus.
AnimatedBuilder(
animation: widget.route.navigator?.userGestureInProgressNotifier ?? ValueNotifier<bool>(false),
builder: (BuildContext context, Widget child) {
final bool ignoreEvents = _shouldIgnoreFocusRequest;
focusScopeNode.canRequestFocus = !ignoreEvents;
return IgnorePointer(
ignoring: ignoreEvents,
child: child,
child: Actions(
actions: _actionMap,
child: FocusScope(
node: focusScopeNode, // immutable
child: RepaintBoundary(
child: AnimatedBuilder(
animation: _listenable, // immutable
builder: (BuildContext context, Widget child) {
return widget.route.buildTransitions(
context,
widget.route.animation,
widget.route.secondaryAnimation,
// This additional AnimatedBuilder is include because if the
// value of the userGestureInProgressNotifier changes, it's
// only necessary to rebuild the IgnorePointer widget and set
// the focus node's ability to focus.
AnimatedBuilder(
animation: widget.route.navigator?.userGestureInProgressNotifier ?? ValueNotifier<bool>(false),
builder: (BuildContext context, Widget child) {
final bool ignoreEvents = _shouldIgnoreFocusRequest;
focusScopeNode.canRequestFocus = !ignoreEvents;
return IgnorePointer(
ignoring: ignoreEvents,
child: child,
);
},
child: child,
),
);
},
child: _page ??= RepaintBoundary(
key: widget.route._subtreeKey, // immutable
child: Builder(
builder: (BuildContext context) {
return widget.route.buildPage(
context,
widget.route.animation,
widget.route.secondaryAnimation,
);
},
child: child,
),
);
},
child: _page ??= RepaintBoundary(
key: widget.route._subtreeKey, // immutable
child: Builder(
builder: (BuildContext context) {
return widget.route.buildPage(
context,
widget.route.animation,
widget.route.secondaryAnimation,
);
},
),
),
),
......
......@@ -137,6 +137,8 @@ void main() {
' _FocusMarker\n'
' Semantics\n'
' FocusScope\n'
' _ActionsMarker\n'
' Actions\n'
' PageStorage\n'
' Offstage\n'
' _ModalScopeStatus\n'
......
......@@ -8,6 +8,7 @@ import 'dart:collection';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:mockito/mockito.dart';
......@@ -1319,6 +1320,28 @@ void main() {
expect(tester.takeException(), null);
});
});
testWidgets('can be dismissed with escape keyboard shortcut', (WidgetTester tester) async {
final GlobalKey<NavigatorState> navigatorKey = GlobalKey<NavigatorState>();
await tester.pumpWidget(MaterialApp(
navigatorKey: navigatorKey,
home: const Text('dummy1'),
));
final Element textOnPageOne = tester.element(find.text('dummy1'));
// Show a simple dialog
showDialog<void>(
context: textOnPageOne,
builder: (BuildContext context) => const Text('dialog1'),
);
await tester.pumpAndSettle();
expect(find.text('dialog1'), findsOneWidget);
// Try to dismiss the dialog with the shortcut key
await tester.sendKeyEvent(LogicalKeyboardKey.escape);
await tester.pumpAndSettle();
expect(find.text('dialog1'), findsNothing);
});
}
double _getOpacity(GlobalKey key, WidgetTester tester) {
......
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