Unverified Commit 00d9f8df authored by Greg Spencer's avatar Greg Spencer Committed by GitHub

Add CallbackShortcuts widget (#86045)

parent cb17425d
......@@ -1026,6 +1026,8 @@ class ShortcutManager extends ChangeNotifier with Diagnosticable {
///
/// See also:
///
/// * [CallbackShortcuts], a less complicated (but less flexible) way of
/// defining key bindings that just invoke callbacks.
/// * [Intent], a class for containing a description of a user action to be
/// invoked.
/// * [Action], a class for defining an invocation of a user action.
......@@ -1197,3 +1199,90 @@ class _ShortcutsMarker extends InheritedNotifier<ShortcutManager> {
ShortcutManager get manager => super.notifier!;
}
/// A widget that provides an uncomplicated mechanism for binding a key
/// combination to a specific callback.
///
/// This is similar to the functionality provided by the [Shortcuts] widget, but
/// instead of requiring a mapping to an [Intent], and an [Actions] widget
/// somewhere in the widget tree to bind the [Intent] to, it just takes a set of
/// bindings that bind the key combination directly to a [VoidCallback].
///
/// Because it is a simpler mechanism, it doesn't provide the ability to disable
/// the callbacks, or to separate the definition of the shortcuts from the
/// definition of the code that is triggered by them (the role that actions play
/// in the [Shortcuts]/[Actions] system).
///
/// However, for some applications the complexity and flexibility of the
/// [Shortcuts] and [Actions] mechanism is overkill, and this widget is here for
/// those apps.
///
/// [Shortcuts] and [CallbackShortcuts] can both be used in the same app. As
/// with any key handling widget, if this widget handles a key event then
/// widgets above it in the focus chain will not receive the event. This means
/// that if this widget handles a key, then an ancestor [Shortcuts] widget (or
/// any other key handling widget) will not receive that key, and similarly, if
/// a descendant of this widget handles the key, then the key event will not
/// reach this widget for handling.
///
/// See also:
/// * [Focus], a widget that defines which widgets can receive keyboard focus.
class CallbackShortcuts extends StatelessWidget {
/// Creates a const [CallbackShortcuts] widget.
const CallbackShortcuts({
Key? key,
required this.bindings,
required this.child,
}) : super(key: key);
/// A map of key combinations to callbacks used to define the shortcut
/// bindings.
///
/// If a descendant of this widget has focus, and a key is pressed, the
/// activator keys of this map will be asked if they accept the key event. If
/// they do, then the corresponding callback is invoked, and the key event
/// propagation is halted. If none of the activators accept the key event,
/// then the key event continues to be propagated up the focus chain.
///
/// If more than one activator accepts the key event, then all of the
/// callbacks associated with activators that accept the key event are
/// invoked.
///
/// Some examples of [ShortcutActivator] subclasses that can be used to define
/// the key combinations here are [SingleActivator], [CharacterActivator], and
/// [LogicalKeySet].
final Map<ShortcutActivator, VoidCallback> bindings;
/// The widget below this widget in the tree.
///
/// {@macro flutter.widgets.ProxyWidget.child}
final Widget child;
// A helper function to make the stack trace more useful if the callback
// throws, by providing the activator and event as arguments that will appear
// in the stack trace.
bool _applyKeyBinding(ShortcutActivator activator, RawKeyEvent event) {
if (activator.accepts(event, RawKeyboard.instance)) {
bindings[activator]!.call();
return true;
}
return false;
}
@override
Widget build(BuildContext context) {
return Focus(
canRequestFocus: false,
skipTraversal: true,
onKey: (FocusNode node, RawKeyEvent event) {
KeyEventResult result = KeyEventResult.ignored;
// Activates all key bindings that match, returns "handled" if any handle it.
for (final ShortcutActivator activator in bindings.keys) {
result = _applyKeyBinding(activator, event) ? KeyEventResult.handled : result;
}
return result;
},
child: child,
);
}
}
\ No newline at end of file
......@@ -1077,4 +1077,171 @@ void main() {
invoked = 0;
});
});
group('CallbackShortcuts', () {
testWidgets('trigger on key events', (WidgetTester tester) async {
int invoked = 0;
await tester.pumpWidget(
CallbackShortcuts(
bindings: <ShortcutActivator, VoidCallback>{
const SingleActivator(LogicalKeyboardKey.keyA): () {
invoked += 1;
},
},
child: const Focus(
autofocus: true,
child: Placeholder(),
),
),
);
await tester.pump();
await tester.sendKeyDownEvent(LogicalKeyboardKey.keyA);
expect(invoked, equals(1));
await tester.sendKeyUpEvent(LogicalKeyboardKey.keyA);
expect(invoked, equals(1));
});
testWidgets('nested CallbackShortcuts stop propagation', (WidgetTester tester) async {
int invokedOuter = 0;
int invokedInner = 0;
await tester.pumpWidget(
CallbackShortcuts(
bindings: <ShortcutActivator, VoidCallback>{
const SingleActivator(LogicalKeyboardKey.keyA): () {
invokedOuter += 1;
},
},
child: CallbackShortcuts(
bindings: <ShortcutActivator, VoidCallback>{
const SingleActivator(LogicalKeyboardKey.keyA): () {
invokedInner += 1;
},
},
child: const Focus(
autofocus: true,
child: Placeholder(),
),
),
),
);
await tester.pump();
await tester.sendKeyDownEvent(LogicalKeyboardKey.keyA);
expect(invokedOuter, equals(0));
expect(invokedInner, equals(1));
await tester.sendKeyUpEvent(LogicalKeyboardKey.keyA);
expect(invokedOuter, equals(0));
expect(invokedInner, equals(1));
});
testWidgets('non-overlapping nested CallbackShortcuts fire appropriately', (WidgetTester tester) async {
int invokedOuter = 0;
int invokedInner = 0;
await tester.pumpWidget(
CallbackShortcuts(
bindings: <ShortcutActivator, VoidCallback>{
const CharacterActivator('b'): () {
invokedOuter += 1;
},
},
child: CallbackShortcuts(
bindings: <ShortcutActivator, VoidCallback>{
const CharacterActivator('a'): () {
invokedInner += 1;
},
},
child: const Focus(
autofocus: true,
child: Placeholder(),
),
),
),
);
await tester.pump();
await tester.sendKeyDownEvent(LogicalKeyboardKey.keyA);
expect(invokedOuter, equals(0));
expect(invokedInner, equals(1));
await tester.sendKeyDownEvent(LogicalKeyboardKey.keyB);
expect(invokedOuter, equals(1));
expect(invokedInner, equals(1));
await tester.sendKeyUpEvent(LogicalKeyboardKey.keyA);
await tester.sendKeyUpEvent(LogicalKeyboardKey.keyB);
expect(invokedOuter, equals(1));
expect(invokedInner, equals(1));
});
testWidgets('Works correctly with Shortcuts too', (WidgetTester tester) async {
int invokedCallbackA = 0;
int invokedCallbackB = 0;
int invokedActionA = 0;
int invokedActionB = 0;
void clear() {
invokedCallbackA = 0;
invokedCallbackB = 0;
invokedActionA = 0;
invokedActionB = 0;
}
await tester.pumpWidget(
Actions(
actions: <Type, Action<Intent>>{
TestIntent: TestAction(
onInvoke: (Intent intent) {
invokedActionA += 1;
return true;
},
),
TestIntent2: TestAction(
onInvoke: (Intent intent) {
invokedActionB += 1;
return true;
},
),
},
child: CallbackShortcuts(
bindings: <ShortcutActivator, VoidCallback>{
const CharacterActivator('b'): () {
invokedCallbackB += 1;
},
},
child: Shortcuts(
shortcuts: <LogicalKeySet, Intent>{
LogicalKeySet(LogicalKeyboardKey.keyA): const TestIntent(),
LogicalKeySet(LogicalKeyboardKey.keyB): const TestIntent2(),
},
child: CallbackShortcuts(
bindings: <ShortcutActivator, VoidCallback>{
const CharacterActivator('a'): () {
invokedCallbackA += 1;
},
},
child: const Focus(
autofocus: true,
child: Placeholder(),
),
),
),
),
),
);
await tester.pump();
await tester.sendKeyDownEvent(LogicalKeyboardKey.keyA);
expect(invokedCallbackA, equals(1));
expect(invokedCallbackB, equals(0));
expect(invokedActionA, equals(0));
expect(invokedActionB, equals(0));
await tester.sendKeyUpEvent(LogicalKeyboardKey.keyA);
clear();
await tester.sendKeyDownEvent(LogicalKeyboardKey.keyB);
expect(invokedCallbackA, equals(0));
expect(invokedCallbackB, equals(0));
expect(invokedActionA, equals(0));
expect(invokedActionB, equals(1));
await tester.sendKeyUpEvent(LogicalKeyboardKey.keyB);
});
});
}
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