Skip to content
This repository was archived by the owner on Feb 22, 2023. It is now read-only.

Commit 4056d3f

Browse files
authored
Explain the "patching" protocol in KeyMessageManager.keyMessageHandler and add an example (#105280)
1 parent d155bc1 commit 4056d3f

File tree

5 files changed

+369
-43
lines changed

5 files changed

+369
-43
lines changed
Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,183 @@
1+
// Copyright 2014 The Flutter Authors. All rights reserved.
2+
// Use of this source code is governed by a BSD-style license that can be
3+
// found in the LICENSE file.
4+
5+
import 'package:flutter/material.dart';
6+
import 'package:flutter/services.dart';
7+
8+
// This example app demonstrates a use case of patching
9+
// `KeyEventManager.keyMessageHandler`: be notified of key events that are not
10+
// handled by any focus handlers (such as shortcuts).
11+
12+
void main() => runApp(
13+
const MaterialApp(
14+
home: Scaffold(
15+
body: Center(
16+
child: FallbackDemo(),
17+
)
18+
),
19+
),
20+
);
21+
22+
class FallbackDemo extends StatefulWidget {
23+
const FallbackDemo({super.key});
24+
25+
@override
26+
State<StatefulWidget> createState() => FallbackDemoState();
27+
}
28+
29+
class FallbackDemoState extends State<FallbackDemo> {
30+
String? _capture;
31+
late final FallbackFocusNode _node = FallbackFocusNode(
32+
onKeyEvent: (KeyEvent event) {
33+
if (event is! KeyDownEvent) {
34+
return false;
35+
}
36+
setState(() {
37+
_capture = event.logicalKey.keyLabel;
38+
});
39+
// TRY THIS: Change the return value to true. You will no longer be able
40+
// to type text, because these key events will no longer be sent to the
41+
// text input system.
42+
return false;
43+
}
44+
);
45+
46+
@override
47+
Widget build(BuildContext context) {
48+
return FallbackFocus(
49+
node: _node,
50+
child: Container(
51+
decoration: BoxDecoration(border: Border.all(color: Colors.red)),
52+
padding: const EdgeInsets.all(10),
53+
constraints: const BoxConstraints(maxWidth: 500, maxHeight: 400),
54+
child: Column(
55+
children: <Widget>[
56+
const Text('This area handles key pressses that are unhandled by any shortcuts by displaying them below. '
57+
'Try text shortcuts such as Ctrl-A!'),
58+
Text(_capture == null ? '' : '$_capture is not handled by shortcuts.'),
59+
const TextField(decoration: InputDecoration(label: Text('Text field 1'))),
60+
Shortcuts(
61+
shortcuts: <ShortcutActivator, Intent>{
62+
const SingleActivator(LogicalKeyboardKey.keyQ): VoidCallbackIntent(() {}),
63+
},
64+
child: const TextField(
65+
decoration: InputDecoration(label: Text('This field also considers key Q as a shortcut (that does nothing).')),
66+
),
67+
),
68+
],
69+
),
70+
)
71+
);
72+
}
73+
}
74+
75+
/// A node used by [FallbackKeyEventRegistrar] to register fallback key handlers.
76+
///
77+
/// This class must not be replaced by bare [KeyEventCallback] because Dart
78+
/// does not allow comparing with `==` on annonymous functions (always returns
79+
/// false.)
80+
class FallbackFocusNode {
81+
FallbackFocusNode({required this.onKeyEvent});
82+
83+
final KeyEventCallback onKeyEvent;
84+
}
85+
86+
/// A singleton class that allows [FallbackFocus] to register fallback key
87+
/// event handlers.
88+
///
89+
/// This class is initialized when [instance] is first called, at which time it
90+
/// patches [KeyEventManager.keyMessageHandler] with its own handler.
91+
///
92+
/// A global registrar like [FallbackKeyEventRegistrar] is almost always needed
93+
/// when patching [KeyEventManager.keyMessageHandler]. This is because
94+
/// [FallbackFocus] will add and and remove callbacks constantly, but
95+
/// [KeyEventManager.keyMessageHandler] can only be patched once, and can not
96+
/// be unpatched. Therefore [FallbackFocus] must not directly interact with
97+
/// [KeyEventManager.keyMessageHandler], but through a separate registrar that
98+
/// handles listening reversibly.
99+
class FallbackKeyEventRegistrar {
100+
FallbackKeyEventRegistrar._();
101+
static FallbackKeyEventRegistrar get instance {
102+
if (!_initialized) {
103+
// Get the global handler.
104+
final KeyMessageHandler? existing = ServicesBinding.instance.keyEventManager.keyMessageHandler;
105+
// The handler is guaranteed non-null since
106+
// `FallbackKeyEventRegistrar.instance` is only called during
107+
// `Focus.onFocusChange`, at which time `ServicesBinding.instance` must
108+
// have been called somewhere.
109+
assert(existing != null);
110+
// Assign the global handler with a patched handler.
111+
ServicesBinding.instance.keyEventManager.keyMessageHandler = _instance._buildHandler(existing!);
112+
_initialized = true;
113+
}
114+
return _instance;
115+
}
116+
static bool _initialized = false;
117+
static final FallbackKeyEventRegistrar _instance = FallbackKeyEventRegistrar._();
118+
119+
final List<FallbackFocusNode> _fallbackNodes = <FallbackFocusNode>[];
120+
121+
// Returns a handler that patches the existing `KeyEventManager.keyMessageHandler`.
122+
//
123+
// The existing `KeyEventManager.keyMessageHandler` is typically the one
124+
// assigned by the shortcut system, but it can be anything. The returned
125+
// handler calls that handler first, and if the event is not handled at all
126+
// by the framework, invokes the innermost `FallbackNode`'s handler.
127+
KeyMessageHandler _buildHandler(KeyMessageHandler existing) {
128+
return (KeyMessage message) {
129+
if (existing(message)) {
130+
return true;
131+
}
132+
if (_fallbackNodes.isNotEmpty) {
133+
for (final KeyEvent event in message.events) {
134+
if (_fallbackNodes.last.onKeyEvent(event)) {
135+
return true;
136+
}
137+
}
138+
}
139+
return false;
140+
};
141+
}
142+
}
143+
144+
/// A widget that, when focused, handles key events only if no other handlers
145+
/// do.
146+
///
147+
/// If a [FallbackFocus] is being focused on, then key events that are not
148+
/// handled by other handlers will be dispatched to the `onKeyEvent` of [node].
149+
/// If `onKeyEvent` returns true, this event is considered "handled" and will
150+
/// not move forward with the text input system.
151+
///
152+
/// If multiple [FallbackFocus] nest, then only the innermost takes effect.
153+
///
154+
/// Internally, this class registers its node to the singleton
155+
/// [FallbackKeyEventRegistrar]. The inner this widget is, the later its node
156+
/// will be added to the registrar's list when focused on.
157+
class FallbackFocus extends StatelessWidget {
158+
const FallbackFocus({
159+
super.key,
160+
required this.node,
161+
required this.child,
162+
});
163+
164+
final Widget child;
165+
final FallbackFocusNode node;
166+
167+
void _onFocusChange(bool focused) {
168+
if (focused) {
169+
FallbackKeyEventRegistrar.instance._fallbackNodes.add(node);
170+
} else {
171+
assert(FallbackKeyEventRegistrar.instance._fallbackNodes.last == node);
172+
FallbackKeyEventRegistrar.instance._fallbackNodes.removeLast();
173+
}
174+
}
175+
176+
@override
177+
Widget build(BuildContext context) {
178+
return Focus(
179+
onFocusChange: _onFocusChange,
180+
child: child,
181+
);
182+
}
183+
}
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
// Copyright 2014 The Flutter Authors. All rights reserved.
2+
// Use of this source code is governed by a BSD-style license that can be
3+
// found in the LICENSE file.
4+
5+
import 'package:flutter/material.dart';
6+
import 'package:flutter/services.dart';
7+
import 'package:flutter_api_samples/widgets/hardware_keyboard/key_event_manager.0.dart'
8+
as example;
9+
import 'package:flutter_test/flutter_test.dart';
10+
11+
void main() {
12+
testWidgets('App tracks lifecycle states', (WidgetTester tester) async {
13+
Future<String> getCapturedKey() async {
14+
final Widget textWidget = tester.firstWidget(
15+
find.textContaining('is not handled by shortcuts.'));
16+
expect(textWidget, isA<Text>());
17+
return (textWidget as Text).data!.split(' ').first;
18+
}
19+
20+
await tester.pumpWidget(
21+
const MaterialApp(
22+
home: Scaffold(
23+
body: Center(
24+
child: example.FallbackDemo(),
25+
)
26+
),
27+
),
28+
);
29+
30+
// Focus on the first text field.
31+
await tester.tap(find.byType(TextField).first);
32+
33+
// Press Q, which is taken as a text input, unhandled by the keyboard system.
34+
await tester.sendKeyEvent(LogicalKeyboardKey.keyQ);
35+
await tester.pump();
36+
expect(await getCapturedKey(), 'Q');
37+
38+
// Press Ctrl-A, which is taken as a text short cut, handled by the keyboard system.
39+
await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft);
40+
await tester.sendKeyEvent(LogicalKeyboardKey.keyA);
41+
await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft);
42+
expect(await getCapturedKey(), 'Q');
43+
44+
// Press A, which is taken as a text input, handled by the keyboard system.
45+
await tester.sendKeyEvent(LogicalKeyboardKey.keyA);
46+
await tester.pump();
47+
expect(await getCapturedKey(), 'A');
48+
49+
// Focus on the second text field.
50+
await tester.tap(find.byType(TextField).last);
51+
52+
// Press Q, which is taken as a stub shortcut, handled by the keyboard system.
53+
await tester.sendKeyEvent(LogicalKeyboardKey.keyQ);
54+
await tester.pump();
55+
expect(await getCapturedKey(), 'A');
56+
57+
// Press B, which is taken as a text input, unhandled by the keyboard system.
58+
await tester.sendKeyEvent(LogicalKeyboardKey.keyB);
59+
await tester.pump();
60+
expect(await getCapturedKey(), 'B');
61+
62+
// Press Ctrl-A, which is taken as a text short cut, handled by the keyboard system.
63+
await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft);
64+
await tester.sendKeyEvent(LogicalKeyboardKey.keyA);
65+
await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft);
66+
expect(await getCapturedKey(), 'B');
67+
});
68+
}

0 commit comments

Comments
 (0)