Unverified Commit 4056d3ff authored by Tong Mu's avatar Tong Mu Committed by GitHub

Explain the "patching" protocol in `KeyMessageManager.keyMessageHandler` and...

Explain the "patching" protocol in `KeyMessageManager.keyMessageHandler` and add an example (#105280)
parent d155bc1b
// Copyright 2014 The Flutter Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
// This example app demonstrates a use case of patching
// `KeyEventManager.keyMessageHandler`: be notified of key events that are not
// handled by any focus handlers (such as shortcuts).
void main() => runApp(
const MaterialApp(
home: Scaffold(
body: Center(
child: FallbackDemo(),
)
),
),
);
class FallbackDemo extends StatefulWidget {
const FallbackDemo({super.key});
@override
State<StatefulWidget> createState() => FallbackDemoState();
}
class FallbackDemoState extends State<FallbackDemo> {
String? _capture;
late final FallbackFocusNode _node = FallbackFocusNode(
onKeyEvent: (KeyEvent event) {
if (event is! KeyDownEvent) {
return false;
}
setState(() {
_capture = event.logicalKey.keyLabel;
});
// TRY THIS: Change the return value to true. You will no longer be able
// to type text, because these key events will no longer be sent to the
// text input system.
return false;
}
);
@override
Widget build(BuildContext context) {
return FallbackFocus(
node: _node,
child: Container(
decoration: BoxDecoration(border: Border.all(color: Colors.red)),
padding: const EdgeInsets.all(10),
constraints: const BoxConstraints(maxWidth: 500, maxHeight: 400),
child: Column(
children: <Widget>[
const Text('This area handles key pressses that are unhandled by any shortcuts by displaying them below. '
'Try text shortcuts such as Ctrl-A!'),
Text(_capture == null ? '' : '$_capture is not handled by shortcuts.'),
const TextField(decoration: InputDecoration(label: Text('Text field 1'))),
Shortcuts(
shortcuts: <ShortcutActivator, Intent>{
const SingleActivator(LogicalKeyboardKey.keyQ): VoidCallbackIntent(() {}),
},
child: const TextField(
decoration: InputDecoration(label: Text('This field also considers key Q as a shortcut (that does nothing).')),
),
),
],
),
)
);
}
}
/// A node used by [FallbackKeyEventRegistrar] to register fallback key handlers.
///
/// This class must not be replaced by bare [KeyEventCallback] because Dart
/// does not allow comparing with `==` on annonymous functions (always returns
/// false.)
class FallbackFocusNode {
FallbackFocusNode({required this.onKeyEvent});
final KeyEventCallback onKeyEvent;
}
/// A singleton class that allows [FallbackFocus] to register fallback key
/// event handlers.
///
/// This class is initialized when [instance] is first called, at which time it
/// patches [KeyEventManager.keyMessageHandler] with its own handler.
///
/// A global registrar like [FallbackKeyEventRegistrar] is almost always needed
/// when patching [KeyEventManager.keyMessageHandler]. This is because
/// [FallbackFocus] will add and and remove callbacks constantly, but
/// [KeyEventManager.keyMessageHandler] can only be patched once, and can not
/// be unpatched. Therefore [FallbackFocus] must not directly interact with
/// [KeyEventManager.keyMessageHandler], but through a separate registrar that
/// handles listening reversibly.
class FallbackKeyEventRegistrar {
FallbackKeyEventRegistrar._();
static FallbackKeyEventRegistrar get instance {
if (!_initialized) {
// Get the global handler.
final KeyMessageHandler? existing = ServicesBinding.instance.keyEventManager.keyMessageHandler;
// The handler is guaranteed non-null since
// `FallbackKeyEventRegistrar.instance` is only called during
// `Focus.onFocusChange`, at which time `ServicesBinding.instance` must
// have been called somewhere.
assert(existing != null);
// Assign the global handler with a patched handler.
ServicesBinding.instance.keyEventManager.keyMessageHandler = _instance._buildHandler(existing!);
_initialized = true;
}
return _instance;
}
static bool _initialized = false;
static final FallbackKeyEventRegistrar _instance = FallbackKeyEventRegistrar._();
final List<FallbackFocusNode> _fallbackNodes = <FallbackFocusNode>[];
// Returns a handler that patches the existing `KeyEventManager.keyMessageHandler`.
//
// The existing `KeyEventManager.keyMessageHandler` is typically the one
// assigned by the shortcut system, but it can be anything. The returned
// handler calls that handler first, and if the event is not handled at all
// by the framework, invokes the innermost `FallbackNode`'s handler.
KeyMessageHandler _buildHandler(KeyMessageHandler existing) {
return (KeyMessage message) {
if (existing(message)) {
return true;
}
if (_fallbackNodes.isNotEmpty) {
for (final KeyEvent event in message.events) {
if (_fallbackNodes.last.onKeyEvent(event)) {
return true;
}
}
}
return false;
};
}
}
/// A widget that, when focused, handles key events only if no other handlers
/// do.
///
/// If a [FallbackFocus] is being focused on, then key events that are not
/// handled by other handlers will be dispatched to the `onKeyEvent` of [node].
/// If `onKeyEvent` returns true, this event is considered "handled" and will
/// not move forward with the text input system.
///
/// If multiple [FallbackFocus] nest, then only the innermost takes effect.
///
/// Internally, this class registers its node to the singleton
/// [FallbackKeyEventRegistrar]. The inner this widget is, the later its node
/// will be added to the registrar's list when focused on.
class FallbackFocus extends StatelessWidget {
const FallbackFocus({
super.key,
required this.node,
required this.child,
});
final Widget child;
final FallbackFocusNode node;
void _onFocusChange(bool focused) {
if (focused) {
FallbackKeyEventRegistrar.instance._fallbackNodes.add(node);
} else {
assert(FallbackKeyEventRegistrar.instance._fallbackNodes.last == node);
FallbackKeyEventRegistrar.instance._fallbackNodes.removeLast();
}
}
@override
Widget build(BuildContext context) {
return Focus(
onFocusChange: _onFocusChange,
child: child,
);
}
}
// Copyright 2014 The Flutter Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_api_samples/widgets/hardware_keyboard/key_event_manager.0.dart'
as example;
import 'package:flutter_test/flutter_test.dart';
void main() {
testWidgets('App tracks lifecycle states', (WidgetTester tester) async {
Future<String> getCapturedKey() async {
final Widget textWidget = tester.firstWidget(
find.textContaining('is not handled by shortcuts.'));
expect(textWidget, isA<Text>());
return (textWidget as Text).data!.split(' ').first;
}
await tester.pumpWidget(
const MaterialApp(
home: Scaffold(
body: Center(
child: example.FallbackDemo(),
)
),
),
);
// Focus on the first text field.
await tester.tap(find.byType(TextField).first);
// Press Q, which is taken as a text input, unhandled by the keyboard system.
await tester.sendKeyEvent(LogicalKeyboardKey.keyQ);
await tester.pump();
expect(await getCapturedKey(), 'Q');
// Press Ctrl-A, which is taken as a text short cut, handled by the keyboard system.
await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft);
await tester.sendKeyEvent(LogicalKeyboardKey.keyA);
await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft);
expect(await getCapturedKey(), 'Q');
// Press A, which is taken as a text input, handled by the keyboard system.
await tester.sendKeyEvent(LogicalKeyboardKey.keyA);
await tester.pump();
expect(await getCapturedKey(), 'A');
// Focus on the second text field.
await tester.tap(find.byType(TextField).last);
// Press Q, which is taken as a stub shortcut, handled by the keyboard system.
await tester.sendKeyEvent(LogicalKeyboardKey.keyQ);
await tester.pump();
expect(await getCapturedKey(), 'A');
// Press B, which is taken as a text input, unhandled by the keyboard system.
await tester.sendKeyEvent(LogicalKeyboardKey.keyB);
await tester.pump();
expect(await getCapturedKey(), 'B');
// Press Ctrl-A, which is taken as a text short cut, handled by the keyboard system.
await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft);
await tester.sendKeyEvent(LogicalKeyboardKey.keyA);
await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft);
expect(await getCapturedKey(), 'B');
});
}
...@@ -618,25 +618,50 @@ enum KeyDataTransitMode { ...@@ -618,25 +618,50 @@ enum KeyDataTransitMode {
keyDataThenRawKeyData, keyDataThenRawKeyData,
} }
/// The assumbled information corresponding to a native key message. /// The assembled information converted from a native key message.
/// ///
/// While Flutter's [KeyEvent]s are created from key messages from the native /// Native key messages, produced by physically pressing or releasing
/// platform, every native message might result in multiple [KeyEvent]s. For /// keyboard keys, are translated into two different event streams in Flutter:
/// example, this might happen in order to synthesize missed modifier key
/// presses or releases.
/// ///
/// A [KeyMessage] bundles all information related to a native key message /// * The [KeyEvent] stream, represented by [KeyMessage.events] (recommended).
/// together for the convenience of propagation on the [FocusNode] tree. /// * The [RawKeyEvent] stream, represented by [KeyMessage.rawEvent] (legacy,
/// to be deprecated).
/// ///
/// When dispatched to handlers or listeners, or propagated through the /// Either the [KeyEvent] stream or the [RawKeyEvent] stream alone provides a
/// [FocusNode] tree, all handlers or listeners belonging to a node are /// complete description of the keyboard messages, but in different event
/// executed regardless of their [KeyEventResult], and all results are combined /// models. Flutter is still transitioning from the legacy model to the new
/// into the result of the node using [combineKeyEventResults]. Empty [events] /// model, therefore it dispatches both streams simultaneously until the
/// or [rawEvent] should be considered as a result of [KeyEventResult.ignored]. /// transition is completed. [KeyMessage] is used to bundle the
/// stream segments of both models from a native key message together for the
/// convenience of propagation.
/// ///
/// In very rare cases, a native key message might not result in a [KeyMessage]. /// Typically, an application either processes [KeyMessage.events]
/// For example, key messages for Fn key are ignored on macOS for the /// or [KeyMessage.rawEvent], not both. For example, handling a
/// convenience of cross-platform code. /// [KeyMessage], means handling each event in [KeyMessage.events].
///
/// In advanced cases, a widget needs to process both streams at the same time.
/// For example, [FocusNode] has an `onKey` that dispatches [RawKeyEvent]s and
/// an `onKeyEvent` that dispatches [KeyEvent]s. To processes a [KeyMessage],
/// it first calls `onKeyEvent` with each [KeyEvent] of [events], and then
/// `onKey` with [rawEvent]. All callbacks are invoked regardless of their
/// [KeyEventResult]. Their results are combined into the result of the node
/// using [combineKeyEventResults].
///
/// ```dart
/// void handleMessage(FocusNode node, KeyMessage message) {
/// final List<KeyEventResult> results = <KeyEventResult>[];
/// if (node.onKeyEvent != null) {
/// for (final KeyEvent event in message.events) {
/// results.add(node.onKeyEvent!(node, event));
/// }
/// }
/// if (node.onKey != null && message.rawEvent != null) {
/// results.add(node.onKey!(node, message.rawEvent!));
/// }
/// final KeyEventResult result = combineKeyEventResults(results);
/// // Progress based on `result`...
/// }
/// ```
@immutable @immutable
class KeyMessage { class KeyMessage {
/// Create a [KeyMessage] by providing all information. /// Create a [KeyMessage] by providing all information.
...@@ -710,39 +735,87 @@ class KeyEventManager { ...@@ -710,39 +735,87 @@ class KeyEventManager {
/// This is typically only called by [ServicesBinding]. /// This is typically only called by [ServicesBinding].
KeyEventManager(this._hardwareKeyboard, this._rawKeyboard); KeyEventManager(this._hardwareKeyboard, this._rawKeyboard);
/// The global handler for all hardware key messages of Flutter. /// The global entrance which handles all key events sent to Flutter.
///
/// Typical applications use [WidgetsBinding], where this field is
/// set by the focus system (see `FocusManger`) on startup to a function that
/// dispatches incoming events to the focus system, including
/// `FocusNode.onKey`, `FocusNode.onKeyEvent`, and `Shortcuts`. In this case,
/// the application does not need to read, assign, or invoke this value.
///
/// For advanced uses, the application can "patch" this callback. See below
/// for details.
///
/// ## Handlers and event results
///
/// Roughly speaking, Flutter processes each native key event with the
/// following phases:
///
/// 1. Platform-side pre-filtering, sometimes used for IME.
/// 2. The key event system.
/// 3. The text input system.
/// 4. Other native components (possibly non-Flutter).
///
/// Each phase will conclude with a boolean called an "event result". If the
/// result is true, this phase _handles_ the event and prevents the event
/// from being propagated to the next phase. This mechanism allows shortcuts
/// such as "Ctrl-C" to not generate a text "C" in the text field, or
/// shortcuts that are not handled by any components to trigger special alerts
/// (such as the "bonk" noise on macOS).
///
/// In the second phase, known as "the key event system", the event is dispatched
/// to several destinations: [RawKeyboard]'s listeners,
/// [HardwareKeyboard]'s handlers, and [keyMessageHandler].
/// All destinations will always receive the event regardless of the handlers'
/// results. If any handler's result is true, then the overall result of the
/// second phase is true, and event propagation is stopped.
///
/// See also:
/// ///
/// Key messages received from the platform are first sent to [RawKeyboard]'s /// * [RawKeyboard.addListener], which adds a raw keyboard listener.
/// listeners and [HardwareKeyboard]'s handlers, then sent to /// * [RawKeyboardListener], which is also implemented by adding a raw
/// [keyMessageHandler], regardless of the results of [HardwareKeyboard]'s /// keyboard listener.
/// handlers. The event results from the handlers and [keyMessageHandler] are /// * [HardwareKeyboard.addHandler], which adds a hardware keyboard handler.
/// combined and returned to the platform. The event result is explained
/// below.
/// ///
/// For most common applications, which use [WidgetsBinding], this field /// ## Advanced usages: Manual assignment or patching
/// is set by the focus system (see `FocusManger`) on startup and should not
/// be change explicitly.
/// ///
/// If you are not using the focus system to manage focus, set this /// If you are not using the focus system to manage focus, set this
/// attribute to a [KeyMessageHandler] that returns true if the propagation /// attribute to a [KeyMessageHandler] that returns true if the propagation
/// on the platform should not be continued. If this field is null, key events /// on the platform should not be continued. If this field is null, key events
/// will be assumed to not have been handled by Flutter. /// will be assumed to not have been handled by Flutter, a result of "false".
/// ///
/// ## Event result /// Even if you are using the focus system, you might also want to do more
/// /// than the focus system allows. In these cases, you can _patch_
/// Key messages on the platform are given to Flutter to be handled by the /// [keyMessageHandler] by setting it to a callback that performs your tasks
/// engine. If they are not handled, then the platform will continue to /// and calls the original callback in between (or not at all.)
/// distribute the keys (i.e. propagate them) to other (possibly non-Flutter) ///
/// components in the application. The return value from this handler tells /// Patching [keyMessageHandler] can not be reverted. You should always assume
/// the platform to either stop propagation (by returning true: "event /// that another component might haved patched it before you and after you.
/// handled"), or pass the event on to other controls (false: "event not /// This means that you might want to write your own global notification
/// handled"). Some platforms might trigger special alerts if the event /// manager, to which callbacks can be added and removed.
/// is not handled by other controls either (such as the "bonk" noise on ///
/// macOS). /// You should not patch [keyMessageHandler] until the `FocusManager` has assigned
/// /// its callback. This is assured during any time within the widget lifecycle
/// The result from [keyMessageHandler] and [HardwareKeyboard]'s handlers /// (such as `initState`), or after calling `WidgetManager.instance`.
/// are combined. If any of the handlers claim to handle the event, ///
/// the overall result will be "event handled". /// {@tool dartpad}
/// This example shows how to process key events that are not handled by any
/// focus handler (such as `Shortcuts`) by patching [keyMessageHandler].
///
/// The app prints out any key events that are not handled by the app body.
/// Try typing something in the first text field. These key presses are not
/// handled by `Shorcuts` and will be sent to the fallback handler and printed
/// out. Now try some text shortcuts, such as Ctrl+A. The KeyA press is
/// handled as a shortcut, and is not sent to the fallback handler and so is
/// not printed out.
///
/// The key widget is `FallbackKeyEventRegistrar`, a necessity class to allow
/// reversible patching. `FallbackFocus` and `FallbackFocusNode` are also
/// useful to recognize the widget tree's structure. `FallbackDemo` is an
/// example of using them in an app.
///
/// ** See code in examples/api/lib/widgets/hardware_keyboard/key_event_manager.0.dart **
/// {@end-tool}
/// ///
/// See also: /// See also:
/// ///
......
...@@ -1466,7 +1466,6 @@ class FocusManager with DiagnosticableTreeMixin, ChangeNotifier { ...@@ -1466,7 +1466,6 @@ class FocusManager with DiagnosticableTreeMixin, ChangeNotifier {
@override @override
void dispose() { void dispose() {
if (ServicesBinding.instance.keyEventManager.keyMessageHandler == _handleKeyMessage) { if (ServicesBinding.instance.keyEventManager.keyMessageHandler == _handleKeyMessage) {
ServicesBinding.instance.keyEventManager.keyMessageHandler = null;
GestureBinding.instance.pointerRouter.removeGlobalRoute(_handlePointerEvent); GestureBinding.instance.pointerRouter.removeGlobalRoute(_handlePointerEvent);
} }
super.dispose(); super.dispose();
......
...@@ -951,7 +951,10 @@ abstract class TestWidgetsFlutterBinding extends BindingBase ...@@ -951,7 +951,10 @@ abstract class TestWidgetsFlutterBinding extends BindingBase
_pendingExceptionDetails = null; _pendingExceptionDetails = null;
_parentZone = null; _parentZone = null;
buildOwner!.focusManager.dispose(); buildOwner!.focusManager.dispose();
ServicesBinding.instance.keyEventManager.keyMessageHandler = null;
buildOwner!.focusManager = FocusManager()..registerGlobalHandlers(); buildOwner!.focusManager = FocusManager()..registerGlobalHandlers();
// Disabling the warning because @visibleForTesting doesn't take the testing // Disabling the warning because @visibleForTesting doesn't take the testing
// framework itself into account, but we don't want it visible outside of // framework itself into account, but we don't want it visible outside of
// tests. // tests.
......
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