Using Actions and Shortcuts
This page describes how to bind physical keyboard events to actions in the user interface. For instance, to define keyboard shortcuts in your application, this page is for you.
Overview
#For a GUI application to do anything, it has to have actions: users want to tell the application to do something. Actions are often simple functions that directly perform the action (such as set a value or save a file). In a larger application, however, things are more complex: the code for invoking the action, and the code for the action itself might need to be in different places. Shortcuts (key bindings) might need definition at a level that knows nothing about the actions they invoke.
That's where Flutter's actions and shortcuts system comes in. It allows developers to define actions that fulfill intents bound to them. In this context, an intent is a generic action that the user wishes to perform, and an Intent
class instance represents these user intents in Flutter. An Intent
can be general purpose, fulfilled by different actions in different contexts. An Action
can be a simple callback (as in the case of the CallbackAction
) or something more complex that integrates with entire undo/redo architectures (for example) or other logic.
Shortcuts
are key bindings that activate by pressing a key or combination of keys. The key combinations reside in a table with their bound intent. When the Shortcuts
widget invokes them, it sends their matching intent to the actions subsystem for fulfillment.
To illustrate the concepts in actions and shortcuts, this article creates a simple app that allows a user to select and copy text in a text field using both buttons and shortcuts.
Why separate Actions from Intents?
#You might wonder: why not just map a key combination directly to an action? Why have intents at all? This is because it is useful to have a separation of concerns between where the key mapping definitions are (often at a high level), and where the action definitions are (often at a low level), and because it is important to be able to have a single key combination map to an intended operation in an app, and have it adapt automatically to whichever action fulfills that intended operation for the focused context.
For instance, Flutter has an ActivateIntent
widget that maps each type of control to its corresponding version of an ActivateAction
(and that executes the code that activates the control). This code often needs fairly private access to do its work. If the extra layer of indirection that Intent
s provide didn't exist, it would be necessary to elevate the definition of the actions to where the defining instance of the Shortcuts
widget could see them, causing the shortcuts to have more knowledge than necessary about which action to invoke, and to have access to or provide state that it wouldn't necessarily have or need otherwise. This allows your code to separate the two concerns to be more independent.
Intents configure an action so that the same action can serve multiple uses. An example of this is DirectionalFocusIntent
, which takes a direction to move the focus in, allowing the DirectionalFocusAction
to know which direction to move the focus. Just be careful: don't pass state in the Intent
that applies to all invocations of an Action
: that kind of state should be passed to the constructor of the Action
itself, to keep the Intent
from needing to know too much.
Why not use callbacks?
#You also might wonder: why not just use a callback instead of an Action
object? The main reason is that it's useful for actions to decide whether they are enabled by implementing isEnabled
. Also, it is often helpful if the key bindings, and the implementation of those bindings, are in different places.
If all you need are callbacks without the flexibility of Actions
and Shortcuts
, you can use the CallbackShortcuts
widget:
@override
Widget build(BuildContext context) {
return CallbackShortcuts(
bindings: <ShortcutActivator, VoidCallback>{
const SingleActivator(LogicalKeyboardKey.arrowUp): () {
setState(() => count = count + 1);
},
const SingleActivator(LogicalKeyboardKey.arrowDown): () {
setState(() => count = count - 1);
},
},
child: Focus(
autofocus: true,
child: Column(
children: <Widget>[
const Text('Press the up arrow key to add to the counter'),
const Text('Press the down arrow key to subtract from the counter'),
Text('count: $count'),
],
),
),
);
}
Shortcuts
#As you'll see below, actions are useful on their own, but the most common use case involves binding them to a keyboard shortcut. This is what the Shortcuts
widget is for.
It is inserted into the widget hierarchy to define key combinations that represent the user's intent when that key combination is pressed. To convert that intended purpose for the key combination into a concrete action, the Actions
widget used to map the Intent
to an Action
. For instance, you can define a SelectAllIntent
, and bind it to your own SelectAllAction
or to your CanvasSelectAllAction
, and from that one key binding, the system invokes either one, depending on which part of your application has focus. Let's see how the key binding part works:
@override
Widget build(BuildContext context) {
return Shortcuts(
shortcuts: <LogicalKeySet, Intent>{
LogicalKeySet(LogicalKeyboardKey.control, LogicalKeyboardKey.keyA):
const SelectAllIntent(),
},
child: Actions(
dispatcher: LoggingActionDispatcher(),
actions: <Type, Action<Intent>>{
SelectAllIntent: SelectAllAction(model),
},
child: Builder(
builder: (context) => TextButton(
onPressed: Actions.handler<SelectAllIntent>(
context,
const SelectAllIntent(),
),
child: const Text('SELECT ALL'),
),
),
),
);
}
The map given to a Shortcuts
widget maps a LogicalKeySet
(or a ShortcutActivator
, see note below) to an Intent
instance. The logical key set defines a set of one or more keys, and the intent indicates the intended purpose of the keypress. The Shortcuts
widget looks up key presses in the map, to find an Intent
instance, which it gives to the action's invoke()
method.
The ShortcutManager
#The shortcut manager, a longer-lived object than the Shortcuts
widget, passes on key events when it receives them. It contains the logic for deciding how to handle the keys, the logic for walking up the tree to find other shortcut mappings, and maintains a map of key combinations to intents.
While the default behavior of the ShortcutManager
is usually desirable, the Shortcuts
widget takes a ShortcutManager
that you can subclass to customize its functionality.
For example, if you wanted to log each key that a Shortcuts
widget handled, you could make a LoggingShortcutManager
:
class LoggingShortcutManager extends ShortcutManager {
@override
KeyEventResult handleKeypress(BuildContext context, KeyEvent event) {
final KeyEventResult result = super.handleKeypress(context, event);
if (result == KeyEventResult.handled) {
print('Handled shortcut $event in $context');
}
return result;
}
}
Now, every time the Shortcuts
widget handles a shortcut, it prints out the key event and relevant context.
Actions
#Actions
allow for the definition of operations that the application can perform by invoking them with an Intent
. Actions can be enabled or disabled, and receive the intent instance that invoked them as an argument to allow configuration by the intent.
Defining actions
#Actions, in their simplest form, are just subclasses of Action<Intent>
with an invoke()
method. Here's a simple action that simply invokes a function on the provided model:
class SelectAllAction extends Action<SelectAllIntent> {
SelectAllAction(this.model);
final Model model;
@override
void invoke(covariant SelectAllIntent intent) => model.selectAll();
}
Or, if it's too much of a bother to create a new class, use a CallbackAction
:
CallbackAction(onInvoke: (intent) => model.selectAll());
Once you have an action, you add it to your application using the Actions
widget, which takes a map of Intent
types to Action
s:
@override
Widget build(BuildContext context) {
return Actions(
actions: <Type, Action<Intent>>{
SelectAllIntent: SelectAllAction(model),
},
child: child,
);
}
The Shortcuts
widget uses the Focus
widget's context and Actions.invoke
to find which action to invoke. If the Shortcuts
widget doesn't find a matching intent type in the first Actions
widget encountered, it considers the next ancestor Actions
widget, and so on, until it reaches the root of the widget tree, or finds a matching intent type and invokes the corresponding action.
Invoking Actions
#The actions system has several ways to invoke actions. By far the most common way is through the use of a Shortcuts
widget covered in the previous section, but there are other ways to interrogate the actions subsystem and invoke an action. It's possible to invoke actions that are not bound to keys.
For instance, to find an action associated with an intent, you can use:
Action<SelectAllIntent>? selectAll =
Actions.maybeFind<SelectAllIntent>(context);
This returns an Action
associated with the SelectAllIntent
type if one is available in the given context
. If one isn't available, it returns null. If an associated Action
should always be available, then use find
instead of maybeFind
, which throws an exception when it doesn't find a matching Intent
type.
To invoke the action (if it exists), call:
Object? result;
if (selectAll != null) {
result =
Actions.of(context).invokeAction(selectAll, const SelectAllIntent());
}
Combine that into one call with the following:
Object? result =
Actions.maybeInvoke<SelectAllIntent>(context, const SelectAllIntent());
Sometimes you want to invoke an action as a result of pressing a button or another control. You can do this with the Actions.handler
function. If the intent has a mapping to an enabled action, the Actions.handler
function creates a handler closure. However, if it doesn't have a mapping, it returns null
. This allows the button to be disabled if there is no enabled action that matches in the context.
@override
Widget build(BuildContext context) {
return Actions(
actions: <Type, Action<Intent>>{
SelectAllIntent: SelectAllAction(model),
},
child: Builder(
builder: (context) => TextButton(
onPressed: Actions.handler<SelectAllIntent>(
context,
SelectAllIntent(controller: controller),
),
child: const Text('SELECT ALL'),
),
),
);
}
The Actions
widget only invokes actions when isEnabled(Intent intent)
returns true, allowing the action to decide if the dispatcher should consider it for invocation. If the action isn't enabled, then the Actions
widget gives another enabled action higher in the widget hierarchy (if it exists) a chance to execute.
The previous example uses a Builder
because Actions.handler
and Actions.invoke
(for example) only finds actions in the provided context
, and if the example passes the context
given to the build
function, the framework starts looking above the current widget. Using a Builder
allows the framework to find the actions defined in the same build
function.
You can invoke an action without needing a BuildContext
, but since the Actions
widget requires a context to find an enabled action to invoke, you need to provide one, either by creating your own Action
instance, or by finding one in an appropriate context with Actions.find
.
To invoke the action, pass the action to the invoke
method on an ActionDispatcher
, either one you created yourself, or one retrieved from an existing Actions
widget using the Actions.of(context)
method. Check whether the action is enabled before calling invoke
. Of course, you can also just call invoke
on the action itself, passing an Intent
, but then you opt out of any services that an action dispatcher might provide (like logging, undo/redo, and so on).
Action dispatchers
#Most of the time, you just want to invoke an action, have it do its thing, and forget about it. Sometimes, however, you might want to log the executed actions.
This is where replacing the default ActionDispatcher
with a custom dispatcher comes in. You pass your ActionDispatcher
to the Actions
widget, and it invokes actions from any Actions
widgets below that one that doesn't set a dispatcher of its own.
The first thing Actions
does when invoking an action is look up the ActionDispatcher
and pass the action to it for invocation. If there is none, it creates a default ActionDispatcher
that simply invokes the action.
If you want a log of all the actions invoked, however, you can create your own LoggingActionDispatcher
to do the job:
class LoggingActionDispatcher extends ActionDispatcher {
@override
Object? invokeAction(
covariant Action<Intent> action,
covariant Intent intent, [
BuildContext? context,
]) {
print('Action invoked: $action($intent) from $context');
super.invokeAction(action, intent, context);
return null;
}
@override
(bool, Object?) invokeActionIfEnabled(
covariant Action<Intent> action,
covariant Intent intent, [
BuildContext? context,
]) {
print('Action invoked: $action($intent) from $context');
return super.invokeActionIfEnabled(action, intent, context);
}
}
Then you pass that to your top-level Actions
widget:
@override
Widget build(BuildContext context) {
return Actions(
dispatcher: LoggingActionDispatcher(),
actions: <Type, Action<Intent>>{
SelectAllIntent: SelectAllAction(model),
},
child: Builder(
builder: (context) => TextButton(
onPressed: Actions.handler<SelectAllIntent>(
context,
const SelectAllIntent(),
),
child: const Text('SELECT ALL'),
),
),
);
}
This logs every action as it executes, like so:
flutter: Action invoked: SelectAllAction#906fc(SelectAllIntent#a98e3) from Builder(dependencies: _[ActionsMarker])
Putting it together
#The combination of Actions
and Shortcuts
is powerful: you can define generic intents that map to specific actions at the widget level. Here's a simple app that illustrates the concepts described above. The app creates a text field that also has "select all" and "copy to clipboard" buttons next to it. The buttons invoke actions to accomplish their work. All the invoked actions and shortcuts are logged.
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
/// A text field that also has buttons to select all the text and copy the
/// selected text to the clipboard.
class CopyableTextField extends StatefulWidget {
const CopyableTextField({super.key, required this.title});
final String title;
@override
State<CopyableTextField> createState() => _CopyableTextFieldState();
}
class _CopyableTextFieldState extends State<CopyableTextField> {
late final TextEditingController controller = TextEditingController();
@override
void dispose() {
controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Actions(
dispatcher: LoggingActionDispatcher(),
actions: <Type, Action<Intent>>{
ClearIntent: ClearAction(controller),
CopyIntent: CopyAction(controller),
SelectAllIntent: SelectAllAction(controller),
},
child: Builder(builder: (context) {
return Scaffold(
body: Center(
child: Row(
children: <Widget>[
const Spacer(),
Expanded(
child: TextField(controller: controller),
),
IconButton(
icon: const Icon(Icons.copy),
onPressed:
Actions.handler<CopyIntent>(context, const CopyIntent()),
),
IconButton(
icon: const Icon(Icons.select_all),
onPressed: Actions.handler<SelectAllIntent>(
context, const SelectAllIntent()),
),
const Spacer(),
],
),
),
);
}),
);
}
}
/// A ShortcutManager that logs all keys that it handles.
class LoggingShortcutManager extends ShortcutManager {
@override
KeyEventResult handleKeypress(BuildContext context, KeyEvent event) {
final KeyEventResult result = super.handleKeypress(context, event);
if (result == KeyEventResult.handled) {
print('Handled shortcut $event in $context');
}
return result;
}
}
/// An ActionDispatcher that logs all the actions that it invokes.
class LoggingActionDispatcher extends ActionDispatcher {
@override
Object? invokeAction(
covariant Action<Intent> action,
covariant Intent intent, [
BuildContext? context,
]) {
print('Action invoked: $action($intent) from $context');
super.invokeAction(action, intent, context);
return null;
}
}
/// An intent that is bound to ClearAction in order to clear its
/// TextEditingController.
class ClearIntent extends Intent {
const ClearIntent();
}
/// An action that is bound to ClearIntent that clears its
/// TextEditingController.
class ClearAction extends Action<ClearIntent> {
ClearAction(this.controller);
final TextEditingController controller;
@override
Object? invoke(covariant ClearIntent intent) {
controller.clear();
return null;
}
}
/// An intent that is bound to CopyAction to copy from its
/// TextEditingController.
class CopyIntent extends Intent {
const CopyIntent();
}
/// An action that is bound to CopyIntent that copies the text in its
/// TextEditingController to the clipboard.
class CopyAction extends Action<CopyIntent> {
CopyAction(this.controller);
final TextEditingController controller;
@override
Object? invoke(covariant CopyIntent intent) {
final String selectedString = controller.text.substring(
controller.selection.baseOffset,
controller.selection.extentOffset,
);
Clipboard.setData(ClipboardData(text: selectedString));
return null;
}
}
/// An intent that is bound to SelectAllAction to select all the text in its
/// controller.
class SelectAllIntent extends Intent {
const SelectAllIntent();
}
/// An action that is bound to SelectAllAction that selects all text in its
/// TextEditingController.
class SelectAllAction extends Action<SelectAllIntent> {
SelectAllAction(this.controller);
final TextEditingController controller;
@override
Object? invoke(covariant SelectAllIntent intent) {
controller.selection = controller.selection.copyWith(
baseOffset: 0,
extentOffset: controller.text.length,
affinity: controller.selection.affinity,
);
return null;
}
}
/// The top level application class.
///
/// Shortcuts defined here are in effect for the whole app,
/// although different widgets may fulfill them differently.
class MyApp extends StatelessWidget {
const MyApp({super.key});
static const String title = 'Shortcuts and Actions Demo';
@override
Widget build(BuildContext context) {
return MaterialApp(
title: title,
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
),
home: Shortcuts(
shortcuts: <LogicalKeySet, Intent>{
LogicalKeySet(LogicalKeyboardKey.escape): const ClearIntent(),
LogicalKeySet(LogicalKeyboardKey.control, LogicalKeyboardKey.keyC):
const CopyIntent(),
LogicalKeySet(LogicalKeyboardKey.control, LogicalKeyboardKey.keyA):
const SelectAllIntent(),
},
child: const CopyableTextField(title: title),
),
);
}
}
void main() => runApp(const MyApp());
Unless stated otherwise, the documentation on this site reflects the latest stable version of Flutter. Page last updated on 2024-12-15. View source or report an issue.