Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions doc/flame/inputs/keyboard_input.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
30 changes: 23 additions & 7 deletions packages/flame/lib/src/game/game_widget/game_widget.dart
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,10 @@ class GameWidget<T extends Game> 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
Expand Down Expand Up @@ -216,7 +220,12 @@ class GameWidgetState<T extends Game>() extends State<GameWidget<T>> {

Future<void>? _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;
Expand Down Expand Up @@ -301,7 +310,6 @@ class GameWidgetState<T extends Game>() extends State<GameWidget<T>> {
void initState() {
super.initState();
initCurrentGame();
_focusNode = widget.focusNode ?? FocusNode();
if (widget.autofocus) {
_focusNode.requestFocus();
}
Expand All @@ -320,6 +328,17 @@ class GameWidgetState<T extends Game>() extends State<GameWidget<T>> {
_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
Expand All @@ -331,11 +350,8 @@ class GameWidgetState<T extends Game>() extends State<GameWidget<T>> {
@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();
}

Expand Down
107 changes: 107 additions & 0 deletions packages/flame/test/game/game_widget/game_widget_keyboard_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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', () {
Expand Down
Loading