Add CallbackShortcuts widget (#86045)
This commit is contained in:
parent
cb17425df0
commit
00d9f8df14
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
Loading…
x
Reference in New Issue
Block a user