diff --git a/doc/flame/inputs/keyboard_input.md b/doc/flame/inputs/keyboard_input.md index 239acab7915..984da3f8ea6 100644 --- a/doc/flame/inputs/keyboard_input.md +++ b/doc/flame/inputs/keyboard_input.md @@ -123,6 +123,10 @@ On the widget level, it is possible to use the the game is focused or not. `GameWidget` has an optional `focusNode` parameter that allow its focus to be controlled externally. +The `focusNode` can be changed while the game is running, for example to keep one node per game +when several games are shown in the same `GameWidget`. If the old node had the focus, the new one +takes it over. `GameWidget` only disposes the node that it creates itself, so a `focusNode` that you +pass has to be disposed by you. By default `GameWidget` has its `autofocus` set to true, which means it will get focused once it is mounted, and again whenever its `game` is replaced by a new one. To override that behavior, set diff --git a/packages/flame/lib/src/game/game_widget/game_widget.dart b/packages/flame/lib/src/game/game_widget/game_widget.dart index 6de6485e1d5..67b47c45688 100644 --- a/packages/flame/lib/src/game/game_widget/game_widget.dart +++ b/packages/flame/lib/src/game/game_widget/game_widget.dart @@ -156,6 +156,10 @@ class GameWidget extends StatefulWidget { /// The [FocusNode] to control the games focus to receive event inputs. /// If omitted, defaults to an internally controlled focus node. + /// + /// When it is replaced by another node, the new node takes over the focus + /// if the old one had it. A node passed here is never disposed by the + /// [GameWidget]. final FocusNode? focusNode; /// Whether the [focusNode] requests focus once the game is mounted, and @@ -216,7 +220,12 @@ class GameWidgetState() extends State> { Future? _loaderFuture; - late FocusNode _focusNode; + /// The focus node that is created when [GameWidget.focusNode] is null. It is + /// owned, and disposed, by this state. + FocusNode? _internalFocusNode; + + FocusNode get _focusNode => + widget.focusNode ?? (_internalFocusNode ??= FocusNode()); /// The number of `build()` functions currently executing. int _buildDepth = 0; @@ -301,7 +310,6 @@ class GameWidgetState() extends State> { void initState() { super.initState(); initCurrentGame(); - _focusNode = widget.focusNode ?? FocusNode(); if (widget.autofocus) { _focusNode.requestFocus(); } @@ -320,6 +328,17 @@ class GameWidgetState() extends State> { _focusNode.requestFocus(); } } + + if (oldWidget.focusNode != widget.focusNode) { + // The old node is still attached at this point, the new one is attached + // in the next build. Move the focus over so the game keeps receiving + // key events. If a descendant, like an overlay, has the focus it is + // moved under the new node in the next build, so it keeps the focus. + final oldFocusNode = oldWidget.focusNode ?? _internalFocusNode; + if (oldFocusNode?.hasPrimaryFocus ?? false) { + _focusNode.requestFocus(); + } + } } @override @@ -331,11 +350,8 @@ class GameWidgetState() extends State> { @override void dispose() { disposeCurrentGame(callGameOnDispose: true); - // If we received a focus node from the user, they are responsible - // for disposing it - if (widget.focusNode == null) { - _focusNode.dispose(); - } + // A focus node received from the user is disposed by the user. + _internalFocusNode?.dispose(); super.dispose(); } diff --git a/packages/flame/test/game/game_widget/game_widget_keyboard_test.dart b/packages/flame/test/game/game_widget/game_widget_keyboard_test.dart index 3282e03e799..d8d1e418de3 100644 --- a/packages/flame/test/game/game_widget/game_widget_keyboard_test.dart +++ b/packages/flame/test/game/game_widget/game_widget_keyboard_test.dart @@ -183,6 +183,113 @@ void main() { expect(game.keyboardHandler.keysPressed, ['z', 'f', 'i']); }, ); + + testWidgets( + 'a new focusNode takes over the focus and the key events', + (tester) async { + final game = _KeyboardEventsGame(); + final oldNode = FocusNode(); + final newNode = FocusNode(); + + await tester.pumpWidget( + _GamePage( + child: GameWidget(game: game, focusNode: oldNode), + ), + ); + await tester.pumpWidget( + _GamePage( + child: GameWidget(game: game, focusNode: newNode), + ), + ); + await tester.sendKeyDownEvent(LogicalKeyboardKey.keyA); + + expect(oldNode.hasFocus, isFalse); + expect(newNode.hasFocus, isTrue); + expect(game.keysPressed, ['a']); + + // The app gives the focus back to the game through the new node, for + // example after a text field had it. + newNode.unfocus(); + await tester.pump(); + newNode.requestFocus(); + await tester.pump(); + await tester.sendKeyDownEvent(LogicalKeyboardKey.keyB); + + expect(game.keysPressed, ['a', 'b']); + }, + ); + + testWidgets( + 'switching between a focusNode and the internal one keeps the focus', + (tester) async { + final game = _KeyboardEventsGame(); + final focusNode = FocusNode(); + + await tester.pumpWidget(_GamePage(child: GameWidget(game: game))); + await tester.pumpWidget( + _GamePage( + child: GameWidget(game: game, focusNode: focusNode), + ), + ); + expect(focusNode.hasFocus, isTrue); + + await tester.pumpWidget(_GamePage(child: GameWidget(game: game))); + expect(focusNode.hasFocus, isFalse); + await tester.sendKeyDownEvent(LogicalKeyboardKey.keyA); + + expect(game.keysPressed, ['a']); + }, + ); + + testWidgets( + 'a focusNode from the user is not disposed by the GameWidget', + (tester) async { + final focusNode = FocusNode(); + + await tester.pumpWidget( + GameWidget(game: _KeyboardEventsGame(), focusNode: focusNode), + ); + await tester.pumpWidget(GameWidget(game: _KeyboardEventsGame())); + await tester.pumpWidget(const SizedBox()); + + // Using a disposed node throws. + expect(() => focusNode.addListener(() {}), returnsNormally); + }, + ); + + testWidgets( + 'an overlay keeps the focus when the focusNode changes', + (tester) async { + final game = _KeyboardEventsGame(); + final overlayFocusNode = FocusNode(); + + Widget buildPage(FocusNode focusNode) { + return _GamePage( + child: GameWidget( + game: game, + focusNode: focusNode, + overlayBuilderMap: { + 'input': (_, _) => Material( + child: TextField(focusNode: overlayFocusNode), + ), + }, + initialActiveOverlays: const ['input'], + ), + ); + } + + await tester.pumpWidget(buildPage(FocusNode())); + await tester.pump(); + overlayFocusNode.requestFocus(); + await tester.pump(); + expect(overlayFocusNode.hasPrimaryFocus, isTrue); + + await tester.pumpWidget(buildPage(FocusNode())); + await tester.pump(); + + expect(overlayFocusNode.hasPrimaryFocus, isTrue); + }, + ); }); group('GameWidget.managed', () {