diff --git a/examples/api/lib/widgets/hardware_keyboard/key_event_manager.0.dart b/examples/api/lib/widgets/hardware_keyboard/key_event_manager.0.dart new file mode 100644 index 0000000000..e3c0ab6ba3 --- /dev/null +++ b/examples/api/lib/widgets/hardware_keyboard/key_event_manager.0.dart @@ -0,0 +1,183 @@ +// 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 createState() => FallbackDemoState(); +} + +class FallbackDemoState extends State { + 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: [ + 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: { + 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 _fallbackNodes = []; + + // 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, + ); + } +} diff --git a/examples/api/test/widgets/hardware_keyboard/key_event_manager.0_test.dart b/examples/api/test/widgets/hardware_keyboard/key_event_manager.0_test.dart new file mode 100644 index 0000000000..513c760a62 --- /dev/null +++ b/examples/api/test/widgets/hardware_keyboard/key_event_manager.0_test.dart @@ -0,0 +1,68 @@ +// 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 getCapturedKey() async { + final Widget textWidget = tester.firstWidget( + find.textContaining('is not handled by shortcuts.')); + expect(textWidget, isA()); + 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'); + }); +} diff --git a/packages/flutter/lib/src/services/hardware_keyboard.dart b/packages/flutter/lib/src/services/hardware_keyboard.dart index 347ce9e94e..aabc0828cd 100644 --- a/packages/flutter/lib/src/services/hardware_keyboard.dart +++ b/packages/flutter/lib/src/services/hardware_keyboard.dart @@ -618,25 +618,50 @@ enum KeyDataTransitMode { 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 -/// platform, every native message might result in multiple [KeyEvent]s. For -/// example, this might happen in order to synthesize missed modifier key -/// presses or releases. +/// Native key messages, produced by physically pressing or releasing +/// keyboard keys, are translated into two different event streams in Flutter: /// -/// A [KeyMessage] bundles all information related to a native key message -/// together for the convenience of propagation on the [FocusNode] tree. +/// * The [KeyEvent] stream, represented by [KeyMessage.events] (recommended). +/// * The [RawKeyEvent] stream, represented by [KeyMessage.rawEvent] (legacy, +/// to be deprecated). /// -/// When dispatched to handlers or listeners, or propagated through the -/// [FocusNode] tree, all handlers or listeners belonging to a node are -/// executed regardless of their [KeyEventResult], and all results are combined -/// into the result of the node using [combineKeyEventResults]. Empty [events] -/// or [rawEvent] should be considered as a result of [KeyEventResult.ignored]. +/// Either the [KeyEvent] stream or the [RawKeyEvent] stream alone provides a +/// complete description of the keyboard messages, but in different event +/// models. Flutter is still transitioning from the legacy model to the new +/// model, therefore it dispatches both streams simultaneously until the +/// 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]. -/// For example, key messages for Fn key are ignored on macOS for the -/// convenience of cross-platform code. +/// Typically, an application either processes [KeyMessage.events] +/// or [KeyMessage.rawEvent], not both. For example, handling a +/// [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 results = []; +/// 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 class KeyMessage { /// Create a [KeyMessage] by providing all information. @@ -710,39 +735,87 @@ class KeyEventManager { /// This is typically only called by [ServicesBinding]. 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. /// - /// Key messages received from the platform are first sent to [RawKeyboard]'s - /// listeners and [HardwareKeyboard]'s handlers, then sent to - /// [keyMessageHandler], regardless of the results of [HardwareKeyboard]'s - /// handlers. The event results from the handlers and [keyMessageHandler] are - /// combined and returned to the platform. The event result is explained - /// below. + /// 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 most common applications, which use [WidgetsBinding], this field - /// is set by the focus system (see `FocusManger`) on startup and should not - /// be change explicitly. + /// 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: + /// + /// * [RawKeyboard.addListener], which adds a raw keyboard listener. + /// * [RawKeyboardListener], which is also implemented by adding a raw + /// keyboard listener. + /// * [HardwareKeyboard.addHandler], which adds a hardware keyboard handler. + /// + /// ## Advanced usages: Manual assignment or patching /// /// If you are not using the focus system to manage focus, set this /// attribute to a [KeyMessageHandler] that returns true if the propagation /// 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_ + /// [keyMessageHandler] by setting it to a callback that performs your tasks + /// and calls the original callback in between (or not at all.) /// - /// Key messages on the platform are given to Flutter to be handled by the - /// engine. If they are not handled, then the platform will continue to - /// distribute the keys (i.e. propagate them) to other (possibly non-Flutter) - /// components in the application. The return value from this handler tells - /// the platform to either stop propagation (by returning true: "event - /// handled"), or pass the event on to other controls (false: "event not - /// handled"). Some platforms might trigger special alerts if the event - /// is not handled by other controls either (such as the "bonk" noise on - /// macOS). + /// Patching [keyMessageHandler] can not be reverted. You should always assume + /// that another component might haved patched it before you and after you. + /// This means that you might want to write your own global notification + /// manager, to which callbacks can be added and removed. /// - /// The result from [keyMessageHandler] and [HardwareKeyboard]'s handlers - /// are combined. If any of the handlers claim to handle the event, - /// the overall result will be "event handled". + /// You should not patch [keyMessageHandler] until the `FocusManager` has assigned + /// its callback. This is assured during any time within the widget lifecycle + /// (such as `initState`), or after calling `WidgetManager.instance`. + /// + /// {@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: /// diff --git a/packages/flutter/lib/src/widgets/focus_manager.dart b/packages/flutter/lib/src/widgets/focus_manager.dart index 7558a77b25..67c8d1728e 100644 --- a/packages/flutter/lib/src/widgets/focus_manager.dart +++ b/packages/flutter/lib/src/widgets/focus_manager.dart @@ -1466,7 +1466,6 @@ class FocusManager with DiagnosticableTreeMixin, ChangeNotifier { @override void dispose() { if (ServicesBinding.instance.keyEventManager.keyMessageHandler == _handleKeyMessage) { - ServicesBinding.instance.keyEventManager.keyMessageHandler = null; GestureBinding.instance.pointerRouter.removeGlobalRoute(_handlePointerEvent); } super.dispose(); diff --git a/packages/flutter_test/lib/src/binding.dart b/packages/flutter_test/lib/src/binding.dart index 238af86db1..3c74950cfa 100644 --- a/packages/flutter_test/lib/src/binding.dart +++ b/packages/flutter_test/lib/src/binding.dart @@ -951,7 +951,10 @@ abstract class TestWidgetsFlutterBinding extends BindingBase _pendingExceptionDetails = null; _parentZone = null; buildOwner!.focusManager.dispose(); + + ServicesBinding.instance.keyEventManager.keyMessageHandler = null; buildOwner!.focusManager = FocusManager()..registerGlobalHandlers(); + // Disabling the warning because @visibleForTesting doesn't take the testing // framework itself into account, but we don't want it visible outside of // tests.