Unverified Commit f7d16161 authored by Greg Spencer's avatar Greg Spencer Committed by GitHub

Overridable default platform key bindings (#45102)

This adds actions and shortcuts arguments to WidgetsApp (and MaterialApp and CupertinoApp) to allow developers to override the default mappings on an application, and to allow for a more complex definition of the default mappings.

I've stopped using SelectAction here, in favor of using ActivateAction for all activations, but haven't removed it, to avoid a breaking change, and to allow a common base class for these types of actions. This is because some platforms use the same mapping (web) for both kinds of activations (both select and activate).
parent 9011cece
...@@ -91,6 +91,8 @@ class CupertinoApp extends StatefulWidget { ...@@ -91,6 +91,8 @@ class CupertinoApp extends StatefulWidget {
this.checkerboardOffscreenLayers = false, this.checkerboardOffscreenLayers = false,
this.showSemanticsDebugger = false, this.showSemanticsDebugger = false,
this.debugShowCheckedModeBanner = true, this.debugShowCheckedModeBanner = true,
this.shortcuts,
this.actions,
}) : assert(routes != null), }) : assert(routes != null),
assert(navigatorObservers != null), assert(navigatorObservers != null),
assert(title != null), assert(title != null),
...@@ -192,6 +194,67 @@ class CupertinoApp extends StatefulWidget { ...@@ -192,6 +194,67 @@ class CupertinoApp extends StatefulWidget {
/// {@macro flutter.widgets.widgetsApp.debugShowCheckedModeBanner} /// {@macro flutter.widgets.widgetsApp.debugShowCheckedModeBanner}
final bool debugShowCheckedModeBanner; final bool debugShowCheckedModeBanner;
/// {@macro flutter.widgets.widgetsApp.shortcuts}
/// {@tool sample}
/// This example shows how to add a single shortcut for
/// [LogicalKeyboardKey.select] to the default shortcuts without needing to
/// add your own [Shortcuts] widget.
///
/// Alternatively, you could insert a [Shortcuts] widget with just the mapping
/// you want to add between the [WidgetsApp] and its child and get the same
/// effect.
///
/// ```dart
/// Widget build(BuildContext context) {
/// return WidgetsApp(
/// shortcuts: <LogicalKeySet, Intent>{
/// ... WidgetsApp.defaultShortcuts,
/// LogicalKeySet(LogicalKeyboardKey.select): const Intent(ActivateAction.key),
/// },
/// color: const Color(0xFFFF0000),
/// builder: (BuildContext context, Widget child) {
/// return const Placeholder();
/// },
/// );
/// }
/// ```
/// {@end-tool}
/// {@macro flutter.widgets.widgetsApp.shortcuts.seeAlso}
final Map<LogicalKeySet, Intent> shortcuts;
/// {@macro flutter.widgets.widgetsApp.actions}
/// {@tool sample}
/// This example shows how to add a single action handling an
/// [ActivateAction] to the default actions without needing to
/// add your own [Actions] widget.
///
/// Alternatively, you could insert a [Actions] widget with just the mapping
/// you want to add between the [WidgetsApp] and its child and get the same
/// effect.
///
/// ```dart
/// Widget build(BuildContext context) {
/// return WidgetsApp(
/// actions: <LocalKey, ActionFactory>{
/// ... WidgetsApp.defaultActions,
/// ActivateAction.key: () => CallbackAction(
/// ActivateAction.key,
/// onInvoke: (FocusNode focusNode, Intent intent) {
/// // Do something here...
/// },
/// ),
/// },
/// color: const Color(0xFFFF0000),
/// builder: (BuildContext context, Widget child) {
/// return const Placeholder();
/// },
/// );
/// }
/// ```
/// {@end-tool}
/// {@macro flutter.widgets.widgetsApp.actions.seeAlso}
final Map<LocalKey, ActionFactory> actions;
@override @override
_CupertinoAppState createState() => _CupertinoAppState(); _CupertinoAppState createState() => _CupertinoAppState();
...@@ -312,6 +375,8 @@ class _CupertinoAppState extends State<CupertinoApp> { ...@@ -312,6 +375,8 @@ class _CupertinoAppState extends State<CupertinoApp> {
onPressed: onPressed, onPressed: onPressed,
); );
}, },
shortcuts: widget.shortcuts,
actions: widget.actions,
); );
}, },
), ),
......
...@@ -189,6 +189,8 @@ class MaterialApp extends StatefulWidget { ...@@ -189,6 +189,8 @@ class MaterialApp extends StatefulWidget {
this.checkerboardOffscreenLayers = false, this.checkerboardOffscreenLayers = false,
this.showSemanticsDebugger = false, this.showSemanticsDebugger = false,
this.debugShowCheckedModeBanner = true, this.debugShowCheckedModeBanner = true,
this.shortcuts,
this.actions,
}) : assert(routes != null), }) : assert(routes != null),
assert(navigatorObservers != null), assert(navigatorObservers != null),
assert(title != null), assert(title != null),
...@@ -455,6 +457,67 @@ class MaterialApp extends StatefulWidget { ...@@ -455,6 +457,67 @@ class MaterialApp extends StatefulWidget {
/// {@macro flutter.widgets.widgetsApp.debugShowCheckedModeBanner} /// {@macro flutter.widgets.widgetsApp.debugShowCheckedModeBanner}
final bool debugShowCheckedModeBanner; final bool debugShowCheckedModeBanner;
/// {@macro flutter.widgets.widgetsApp.shortcuts}
/// {@tool sample}
/// This example shows how to add a single shortcut for
/// [LogicalKeyboardKey.select] to the default shortcuts without needing to
/// add your own [Shortcuts] widget.
///
/// Alternatively, you could insert a [Shortcuts] widget with just the mapping
/// you want to add between the [WidgetsApp] and its child and get the same
/// effect.
///
/// ```dart
/// Widget build(BuildContext context) {
/// return WidgetsApp(
/// shortcuts: <LogicalKeySet, Intent>{
/// ... WidgetsApp.defaultShortcuts,
/// LogicalKeySet(LogicalKeyboardKey.select): const Intent(ActivateAction.key),
/// },
/// color: const Color(0xFFFF0000),
/// builder: (BuildContext context, Widget child) {
/// return const Placeholder();
/// },
/// );
/// }
/// ```
/// {@end-tool}
/// {@macro flutter.widgets.widgetsApp.shortcuts.seeAlso}
final Map<LogicalKeySet, Intent> shortcuts;
/// {@macro flutter.widgets.widgetsApp.actions}
/// {@tool sample}
/// This example shows how to add a single action handling an
/// [ActivateAction] to the default actions without needing to
/// add your own [Actions] widget.
///
/// Alternatively, you could insert a [Actions] widget with just the mapping
/// you want to add between the [WidgetsApp] and its child and get the same
/// effect.
///
/// ```dart
/// Widget build(BuildContext context) {
/// return WidgetsApp(
/// actions: <LocalKey, ActionFactory>{
/// ... WidgetsApp.defaultActions,
/// ActivateAction.key: () => CallbackAction(
/// ActivateAction.key,
/// onInvoke: (FocusNode focusNode, Intent intent) {
/// // Do something here...
/// },
/// ),
/// },
/// color: const Color(0xFFFF0000),
/// builder: (BuildContext context, Widget child) {
/// return const Placeholder();
/// },
/// );
/// }
/// ```
/// {@end-tool}
/// {@macro flutter.widgets.widgetsApp.actions.seeAlso}
final Map<LocalKey, ActionFactory> actions;
/// Turns on a [GridPaper] overlay that paints a baseline grid /// Turns on a [GridPaper] overlay that paints a baseline grid
/// Material apps. /// Material apps.
/// ///
...@@ -626,6 +689,8 @@ class _MaterialAppState extends State<MaterialApp> { ...@@ -626,6 +689,8 @@ class _MaterialAppState extends State<MaterialApp> {
mini: true, mini: true,
); );
}, },
shortcuts: widget.shortcuts,
actions: widget.actions,
); );
assert(() { assert(() {
......
...@@ -164,8 +164,7 @@ class _CheckboxState extends State<Checkbox> with TickerProviderStateMixin { ...@@ -164,8 +164,7 @@ class _CheckboxState extends State<Checkbox> with TickerProviderStateMixin {
void initState() { void initState() {
super.initState(); super.initState();
_actionMap = <LocalKey, ActionFactory>{ _actionMap = <LocalKey, ActionFactory>{
SelectAction.key: _createAction, ActivateAction.key: _createAction,
if (!kIsWeb) ActivateAction.key: _createAction,
}; };
} }
...@@ -189,7 +188,7 @@ class _CheckboxState extends State<Checkbox> with TickerProviderStateMixin { ...@@ -189,7 +188,7 @@ class _CheckboxState extends State<Checkbox> with TickerProviderStateMixin {
Action _createAction() { Action _createAction() {
return CallbackAction( return CallbackAction(
SelectAction.key, ActivateAction.key,
onInvoke: _actionHandler, onInvoke: _actionHandler,
); );
} }
......
...@@ -152,7 +152,7 @@ class _DropdownMenuItemButtonState<T> extends State<_DropdownMenuItemButton<T>> ...@@ -152,7 +152,7 @@ class _DropdownMenuItemButtonState<T> extends State<_DropdownMenuItemButton<T>>
} }
static final Map<LogicalKeySet, Intent> _webShortcuts =<LogicalKeySet, Intent>{ static final Map<LogicalKeySet, Intent> _webShortcuts =<LogicalKeySet, Intent>{
LogicalKeySet(LogicalKeyboardKey.enter): const Intent(SelectAction.key), LogicalKeySet(LogicalKeyboardKey.enter): const Intent(ActivateAction.key),
}; };
@override @override
...@@ -1059,8 +1059,7 @@ class _DropdownButtonState<T> extends State<DropdownButton<T>> with WidgetsBindi ...@@ -1059,8 +1059,7 @@ class _DropdownButtonState<T> extends State<DropdownButton<T>> with WidgetsBindi
_internalNode ??= _createFocusNode(); _internalNode ??= _createFocusNode();
} }
_actionMap = <LocalKey, ActionFactory>{ _actionMap = <LocalKey, ActionFactory>{
SelectAction.key: _createAction, ActivateAction.key: _createAction,
if (!kIsWeb) ActivateAction.key: _createAction,
}; };
focusNode.addListener(_handleFocusChanged); focusNode.addListener(_handleFocusChanged);
final FocusManager focusManager = WidgetsBinding.instance.focusManager; final FocusManager focusManager = WidgetsBinding.instance.focusManager;
......
...@@ -505,8 +505,7 @@ class _InkResponseState<T extends InkResponse> extends State<T> with AutomaticKe ...@@ -505,8 +505,7 @@ class _InkResponseState<T extends InkResponse> extends State<T> with AutomaticKe
void initState() { void initState() {
super.initState(); super.initState();
_actionMap = <LocalKey, ActionFactory>{ _actionMap = <LocalKey, ActionFactory>{
SelectAction.key: _createAction, ActivateAction.key: _createAction,
if (!kIsWeb) ActivateAction.key: _createAction,
}; };
FocusManager.instance.addHighlightModeListener(_handleFocusHighlightModeChange); FocusManager.instance.addHighlightModeListener(_handleFocusHighlightModeChange);
} }
......
...@@ -192,8 +192,7 @@ class _RadioState<T> extends State<Radio<T>> with TickerProviderStateMixin { ...@@ -192,8 +192,7 @@ class _RadioState<T> extends State<Radio<T>> with TickerProviderStateMixin {
void initState() { void initState() {
super.initState(); super.initState();
_actionMap = <LocalKey, ActionFactory>{ _actionMap = <LocalKey, ActionFactory>{
SelectAction.key: _createAction, ActivateAction.key: _createAction,
if (!kIsWeb) ActivateAction.key: _createAction,
}; };
} }
...@@ -207,7 +206,7 @@ class _RadioState<T> extends State<Radio<T>> with TickerProviderStateMixin { ...@@ -207,7 +206,7 @@ class _RadioState<T> extends State<Radio<T>> with TickerProviderStateMixin {
Action _createAction() { Action _createAction() {
return CallbackAction( return CallbackAction(
SelectAction.key, ActivateAction.key,
onInvoke: _actionHandler, onInvoke: _actionHandler,
); );
} }
......
...@@ -219,8 +219,7 @@ class _SwitchState extends State<Switch> with TickerProviderStateMixin { ...@@ -219,8 +219,7 @@ class _SwitchState extends State<Switch> with TickerProviderStateMixin {
void initState() { void initState() {
super.initState(); super.initState();
_actionMap = <LocalKey, ActionFactory>{ _actionMap = <LocalKey, ActionFactory>{
SelectAction.key: _createAction, ActivateAction.key: _createAction,
if (!kIsWeb) ActivateAction.key: _createAction,
}; };
} }
...@@ -234,7 +233,7 @@ class _SwitchState extends State<Switch> with TickerProviderStateMixin { ...@@ -234,7 +233,7 @@ class _SwitchState extends State<Switch> with TickerProviderStateMixin {
Action _createAction() { Action _createAction() {
return CallbackAction( return CallbackAction(
SelectAction.key, ActivateAction.key,
onInvoke: _actionHandler, onInvoke: _actionHandler,
); );
} }
......
...@@ -738,8 +738,7 @@ abstract class ActivateAction extends Action { ...@@ -738,8 +738,7 @@ abstract class ActivateAction extends Action {
/// An action that selects the currently focused control. /// An action that selects the currently focused control.
/// ///
/// This is an abstract class that serves as a base class for actions that /// This is an abstract class that serves as a base class for actions that
/// select something, like a checkbox or a radio button. By default, it is bound /// select something. It is not bound to any key by default.
/// to [LogicalKeyboardKey.space] in the default keyboard map in [WidgetsApp].
abstract class SelectAction extends Action { abstract class SelectAction extends Action {
/// Creates a [SelectAction] with a fixed [key]; /// Creates a [SelectAction] with a fixed [key];
const SelectAction() : super(key); const SelectAction() : super(key);
......
This diff is collapsed.
...@@ -322,46 +322,12 @@ void main() { ...@@ -322,46 +322,12 @@ void main() {
); );
} }
// Now activate it with a keypress. await buildTest(ActivateAction.key);
await tester.sendKeyEvent(LogicalKeyboardKey.space);
await tester.pump();
RenderBox box = Material.of(tester.element(find.byType(InkWell))) as dynamic;
if (kIsWeb) {
expect(box, isNot(ripplePattern(30.0, 0)));
} else {
// ripplePattern always add a translation of topLeft.
expect(box, ripplePattern(30.0, 0));
// The ripple fades in for 75ms. During that time its alpha is eased from
// 0 to the splashColor's alpha value.
await tester.pump(const Duration(milliseconds: 50));
expect(box, ripplePattern(56.0, 120));
// At 75ms the ripple has faded in: it's alpha matches the splashColor's
// alpha.
await tester.pump(const Duration(milliseconds: 25));
expect(box, ripplePattern(73.0, 180));
// At this point the splash radius has expanded to its limit: 5 past the
// ink well's radius parameter. The fade-out is about to start.
// The fade-out begins at 225ms = 50ms + 25ms + 150ms.
await tester.pump(const Duration(milliseconds: 150));
expect(box, ripplePattern(105.0, 180));
// After another 150ms the fade-out is complete.
await tester.pump(const Duration(milliseconds: 150));
expect(box, ripplePattern(105.0, 0));
}
// Now try it with a select action instead.
await buildTest(SelectAction.key);
await tester.pumpAndSettle(); await tester.pumpAndSettle();
await tester.sendKeyEvent(LogicalKeyboardKey.space); await tester.sendKeyEvent(LogicalKeyboardKey.space);
await tester.pump(); await tester.pump();
box = Material.of(tester.element(find.byType(InkWell))) as dynamic; final RenderBox box = Material.of(tester.element(find.byType(InkWell))) as dynamic;
// ripplePattern always add a translation of topLeft. // ripplePattern always add a translation of topLeft.
expect(box, ripplePattern(30.0, 0)); expect(box, ripplePattern(30.0, 0));
......
...@@ -48,7 +48,7 @@ void main() { ...@@ -48,7 +48,7 @@ void main() {
Shortcuts( Shortcuts(
shortcuts: <LogicalKeySet, Intent>{ shortcuts: <LogicalKeySet, Intent>{
LogicalKeySet(LogicalKeyboardKey.enter): const Intent(ActivateAction.key), LogicalKeySet(LogicalKeyboardKey.enter): const Intent(ActivateAction.key),
LogicalKeySet(LogicalKeyboardKey.space): const Intent(SelectAction.key), LogicalKeySet(LogicalKeyboardKey.space): const Intent(ActivateAction.key),
}, },
child: Directionality( child: Directionality(
textDirection: TextDirection.ltr, textDirection: TextDirection.ltr,
...@@ -78,7 +78,7 @@ void main() { ...@@ -78,7 +78,7 @@ void main() {
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(pressed, kIsWeb ? isFalse : isTrue); expect(pressed, isTrue);
pressed = false; pressed = false;
await tester.sendKeyEvent(LogicalKeyboardKey.space); await tester.sendKeyEvent(LogicalKeyboardKey.space);
......
...@@ -3,9 +3,23 @@ ...@@ -3,9 +3,23 @@
// found in the LICENSE file. // found in the LICENSE file.
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
class TestAction extends Action {
TestAction() : super(key);
static const LocalKey key = ValueKey<Type>(TestAction);
int calls = 0;
@override
void invoke(FocusNode node, Intent intent) {
calls += 1;
}
}
void main() { void main() {
testWidgets('WidgetsApp with builder only', (WidgetTester tester) async { testWidgets('WidgetsApp with builder only', (WidgetTester tester) async {
final GlobalKey key = GlobalKey(); final GlobalKey key = GlobalKey();
...@@ -21,6 +35,67 @@ void main() { ...@@ -21,6 +35,67 @@ void main() {
expect(find.byKey(key), findsOneWidget); expect(find.byKey(key), findsOneWidget);
}); });
testWidgets('WidgetsApp can override default key bindings', (WidgetTester tester) async {
bool checked = false;
final GlobalKey key = GlobalKey();
await tester.pumpWidget(
WidgetsApp(
key: key,
builder: (BuildContext context, Widget child) {
return Material(
child: Checkbox(
value: checked,
autofocus: true,
onChanged: (bool value) {
checked = value;
},
),
);
},
color: const Color(0xFF123456),
),
);
await tester.pump(); // Wait for focus to take effect.
await tester.sendKeyEvent(LogicalKeyboardKey.space);
await tester.pumpAndSettle();
// Default key mapping worked.
expect(checked, isTrue);
checked = false;
final TestAction action = TestAction();
await tester.pumpWidget(
WidgetsApp(
key: key,
actions: <LocalKey, ActionFactory>{
TestAction.key: () => action,
},
shortcuts: <LogicalKeySet, Intent> {
LogicalKeySet(LogicalKeyboardKey.space): const Intent(TestAction.key),
},
builder: (BuildContext context, Widget child) {
return Material(
child: Checkbox(
value: checked,
autofocus: true,
onChanged: (bool value) {
checked = value;
},
),
);
},
color: const Color(0xFF123456),
),
);
await tester.pump();
await tester.sendKeyEvent(LogicalKeyboardKey.space);
await tester.pumpAndSettle();
// Default key mapping was not invoked.
expect(checked, isFalse);
// Overridden mapping was invoked.
expect(action.calls, equals(1));
});
group('error control test', () { group('error control test', () {
Future<void> expectFlutterError({ Future<void> expectFlutterError({
GlobalKey<NavigatorState> key, GlobalKey<NavigatorState> key,
......
...@@ -2,7 +2,6 @@ ...@@ -2,7 +2,6 @@
// Use of this source code is governed by a BSD-style license that can be // Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file. // found in the LICENSE file.
import 'dart:io';
import 'dart:ui' as ui; import 'dart:ui' as ui;
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
...@@ -36,11 +35,7 @@ Future<void> pumpTest( ...@@ -36,11 +35,7 @@ Future<void> pumpTest(
const double dragOffset = 200.0; const double dragOffset = 200.0;
// TODO(gspencergoog): Change this to use TargetPlatform.macOS once that is available. final LogicalKeyboardKey modifierKey = defaultTargetPlatform == TargetPlatform.macOS
// https://github.com/flutter/flutter/issues/31366
// Can't be const, since Platform.macOS asserts if called in const context.
// ignore: prefer_const_declarations
final LogicalKeyboardKey modifierKey = (!kIsWeb && Platform.isMacOS)
? LogicalKeyboardKey.metaLeft ? LogicalKeyboardKey.metaLeft
: LogicalKeyboardKey.controlLeft; : LogicalKeyboardKey.controlLeft;
......
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