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 {
this.checkerboardOffscreenLayers = false,
this.showSemanticsDebugger = false,
this.debugShowCheckedModeBanner = true,
this.shortcuts,
this.actions,
}) : assert(routes != null),
assert(navigatorObservers != null),
assert(title != null),
......@@ -192,6 +194,67 @@ class CupertinoApp extends StatefulWidget {
/// {@macro flutter.widgets.widgetsApp.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
_CupertinoAppState createState() => _CupertinoAppState();
......@@ -312,6 +375,8 @@ class _CupertinoAppState extends State<CupertinoApp> {
onPressed: onPressed,
);
},
shortcuts: widget.shortcuts,
actions: widget.actions,
);
},
),
......
......@@ -189,6 +189,8 @@ class MaterialApp extends StatefulWidget {
this.checkerboardOffscreenLayers = false,
this.showSemanticsDebugger = false,
this.debugShowCheckedModeBanner = true,
this.shortcuts,
this.actions,
}) : assert(routes != null),
assert(navigatorObservers != null),
assert(title != null),
......@@ -455,6 +457,67 @@ class MaterialApp extends StatefulWidget {
/// {@macro flutter.widgets.widgetsApp.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
/// Material apps.
///
......@@ -626,6 +689,8 @@ class _MaterialAppState extends State<MaterialApp> {
mini: true,
);
},
shortcuts: widget.shortcuts,
actions: widget.actions,
);
assert(() {
......
......@@ -164,8 +164,7 @@ class _CheckboxState extends State<Checkbox> with TickerProviderStateMixin {
void initState() {
super.initState();
_actionMap = <LocalKey, ActionFactory>{
SelectAction.key: _createAction,
if (!kIsWeb) ActivateAction.key: _createAction,
ActivateAction.key: _createAction,
};
}
......@@ -189,7 +188,7 @@ class _CheckboxState extends State<Checkbox> with TickerProviderStateMixin {
Action _createAction() {
return CallbackAction(
SelectAction.key,
ActivateAction.key,
onInvoke: _actionHandler,
);
}
......
......@@ -152,7 +152,7 @@ class _DropdownMenuItemButtonState<T> extends State<_DropdownMenuItemButton<T>>
}
static final Map<LogicalKeySet, Intent> _webShortcuts =<LogicalKeySet, Intent>{
LogicalKeySet(LogicalKeyboardKey.enter): const Intent(SelectAction.key),
LogicalKeySet(LogicalKeyboardKey.enter): const Intent(ActivateAction.key),
};
@override
......@@ -1059,8 +1059,7 @@ class _DropdownButtonState<T> extends State<DropdownButton<T>> with WidgetsBindi
_internalNode ??= _createFocusNode();
}
_actionMap = <LocalKey, ActionFactory>{
SelectAction.key: _createAction,
if (!kIsWeb) ActivateAction.key: _createAction,
ActivateAction.key: _createAction,
};
focusNode.addListener(_handleFocusChanged);
final FocusManager focusManager = WidgetsBinding.instance.focusManager;
......
......@@ -505,8 +505,7 @@ class _InkResponseState<T extends InkResponse> extends State<T> with AutomaticKe
void initState() {
super.initState();
_actionMap = <LocalKey, ActionFactory>{
SelectAction.key: _createAction,
if (!kIsWeb) ActivateAction.key: _createAction,
ActivateAction.key: _createAction,
};
FocusManager.instance.addHighlightModeListener(_handleFocusHighlightModeChange);
}
......
......@@ -192,8 +192,7 @@ class _RadioState<T> extends State<Radio<T>> with TickerProviderStateMixin {
void initState() {
super.initState();
_actionMap = <LocalKey, ActionFactory>{
SelectAction.key: _createAction,
if (!kIsWeb) ActivateAction.key: _createAction,
ActivateAction.key: _createAction,
};
}
......@@ -207,7 +206,7 @@ class _RadioState<T> extends State<Radio<T>> with TickerProviderStateMixin {
Action _createAction() {
return CallbackAction(
SelectAction.key,
ActivateAction.key,
onInvoke: _actionHandler,
);
}
......
......@@ -219,8 +219,7 @@ class _SwitchState extends State<Switch> with TickerProviderStateMixin {
void initState() {
super.initState();
_actionMap = <LocalKey, ActionFactory>{
SelectAction.key: _createAction,
if (!kIsWeb) ActivateAction.key: _createAction,
ActivateAction.key: _createAction,
};
}
......@@ -234,7 +233,7 @@ class _SwitchState extends State<Switch> with TickerProviderStateMixin {
Action _createAction() {
return CallbackAction(
SelectAction.key,
ActivateAction.key,
onInvoke: _actionHandler,
);
}
......
......@@ -738,8 +738,7 @@ abstract class ActivateAction extends Action {
/// An action that selects the currently focused control.
///
/// 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
/// to [LogicalKeyboardKey.space] in the default keyboard map in [WidgetsApp].
/// select something. It is not bound to any key by default.
abstract class SelectAction extends Action {
/// Creates a [SelectAction] with a fixed [key];
const SelectAction() : super(key);
......
This diff is collapsed.
......@@ -322,46 +322,12 @@ void main() {
);
}
// Now activate it with a keypress.
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 buildTest(ActivateAction.key);
await tester.pumpAndSettle();
await tester.sendKeyEvent(LogicalKeyboardKey.space);
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.
expect(box, ripplePattern(30.0, 0));
......
......@@ -48,7 +48,7 @@ void main() {
Shortcuts(
shortcuts: <LogicalKeySet, Intent>{
LogicalKeySet(LogicalKeyboardKey.enter): const Intent(ActivateAction.key),
LogicalKeySet(LogicalKeyboardKey.space): const Intent(SelectAction.key),
LogicalKeySet(LogicalKeyboardKey.space): const Intent(ActivateAction.key),
},
child: Directionality(
textDirection: TextDirection.ltr,
......@@ -78,7 +78,7 @@ void main() {
await tester.pumpAndSettle();
expect(pressed, kIsWeb ? isFalse : isTrue);
expect(pressed, isTrue);
pressed = false;
await tester.sendKeyEvent(LogicalKeyboardKey.space);
......
......@@ -3,9 +3,23 @@
// found in the LICENSE file.
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter/widgets.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() {
testWidgets('WidgetsApp with builder only', (WidgetTester tester) async {
final GlobalKey key = GlobalKey();
......@@ -21,6 +35,67 @@ void main() {
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', () {
Future<void> expectFlutterError({
GlobalKey<NavigatorState> key,
......
......@@ -2,7 +2,6 @@
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'dart:io';
import 'dart:ui' as ui;
import 'package:flutter/foundation.dart';
......@@ -36,11 +35,7 @@ Future<void> pumpTest(
const double dragOffset = 200.0;
// TODO(gspencergoog): Change this to use TargetPlatform.macOS once that is available.
// 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)
final LogicalKeyboardKey modifierKey = defaultTargetPlatform == TargetPlatform.macOS
? LogicalKeyboardKey.metaLeft
: 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