Skip to content

Commit

Permalink
fix: Focus handling with a scope on the GameWidget (#1725)
Browse files Browse the repository at this point in the history
This ignores keyboard events if the game widget doesn't have the primary focus. This allows focus nodes on overlay widgets to take precedence on keyboard resolving.

Also, add focus scope in the game widget for when the overlay is removed, the focus goes back to the game.

Also, add tests for GameWidget for all these cases and some more.
  • Loading branch information
renancaraujo committed Oct 25, 2022
1 parent 9e6bf4f commit d1cd851
Show file tree
Hide file tree
Showing 2 changed files with 306 additions and 41 deletions.
91 changes: 50 additions & 41 deletions packages/flame/lib/src/game/game_widget/game_widget.dart
Original file line number Diff line number Diff line change
Expand Up @@ -297,6 +297,11 @@ class _GameWidgetState<T extends Game> extends State<GameWidget<T>> {

KeyEventResult _handleKeyEvent(FocusNode focusNode, RawKeyEvent event) {
final game = currentGame;

if (!_focusNode.hasPrimaryFocus) {
return KeyEventResult.ignored;
}

if (game is KeyboardEvents) {
return game.onKeyEvent(event, RawKeyboard.instance.keysPressed);
}
Expand Down Expand Up @@ -336,50 +341,54 @@ class _GameWidgetState<T extends Game> extends State<GameWidget<T>> {
// We can use Directionality.maybeOf when that method lands on stable
final textDir = widget.textDirection ?? TextDirection.ltr;

return Focus(
focusNode: _focusNode,
autofocus: widget.autofocus,
onKey: _handleKeyEvent,
child: MouseRegion(
cursor: currentGame.mouseCursor,
child: Directionality(
textDirection: textDir,
child: ColoredBox(
color: currentGame.backgroundColor(),
child: LayoutBuilder(
builder: (_, BoxConstraints constraints) {
return _protectedBuild(() {
final size = constraints.biggest.toVector2();
if (size.isZero()) {
return widget.loadingBuilder?.call(context) ??
Container();
}
currentGame.onGameResize(size);
return FutureBuilder(
future: loaderFuture,
builder: (_, snapshot) {
if (snapshot.hasError) {
final errorBuilder = widget.errorBuilder;
if (errorBuilder == null) {
throw Error.throwWithStackTrace(
snapshot.error!,
snapshot.stackTrace!,
);
} else {
return errorBuilder(context, snapshot.error!);
return FocusScope(
child: Focus(
focusNode: _focusNode,
autofocus: widget.autofocus,
descendantsAreFocusable: true,
onKey: _handleKeyEvent,
child: MouseRegion(
cursor: currentGame.mouseCursor,
child: Directionality(
textDirection: textDir,
child: ColoredBox(
color: currentGame.backgroundColor(),
child: LayoutBuilder(
builder: (_, BoxConstraints constraints) {
return _protectedBuild(() {
final size = constraints.biggest.toVector2();
if (size.isZero()) {
return widget.loadingBuilder?.call(context) ??
Container();
}
currentGame.onGameResize(size);
return FutureBuilder(
future: loaderFuture,
builder: (_, snapshot) {
if (snapshot.hasError) {
final errorBuilder = widget.errorBuilder;
if (errorBuilder == null) {
throw Error.throwWithStackTrace(
snapshot.error!,
snapshot.stackTrace!,
);
} else {
return errorBuilder(context, snapshot.error!);
}
}
}

if (snapshot.connectionState == ConnectionState.done) {
return Stack(children: stackedWidgets);
}
if (snapshot.connectionState ==
ConnectionState.done) {
return Stack(children: stackedWidgets);
}

return widget.loadingBuilder?.call(context) ??
const SizedBox.expand();
},
);
});
},
return widget.loadingBuilder?.call(context) ??
const SizedBox.expand();
},
);
});
},
),
),
),
),
Expand Down
256 changes: 256 additions & 0 deletions packages/flame/test/game/game_widget/game_widget_test.dart
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import 'package:flame/events.dart';
import 'package:flame/game.dart';
import 'package:flame_test/flame_test.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';

class _Wrapper extends StatefulWidget {
Expand All @@ -16,6 +18,21 @@ class _Wrapper extends StatefulWidget {
State<_Wrapper> createState() => _WrapperState();
}

class _GameWithKeyboardEvents extends FlameGame with KeyboardEvents {
final List<LogicalKeyboardKey> keyEvents = [];

_GameWithKeyboardEvents();

@override
KeyEventResult onKeyEvent(
RawKeyEvent event,
Set<LogicalKeyboardKey> keysPressed,
) {
keyEvents.add(event.logicalKey);
return KeyEventResult.handled;
}
}

class _WrapperState extends State<_Wrapper> {
late bool _open;

Expand Down Expand Up @@ -221,4 +238,243 @@ void main() {
expect(game2, isNull);
});
});

group('focus', () {
testWidgets('autofocus starts focused', (tester) async {
final gameFocusNode = FocusNode();

await tester.pumpWidget(
GameWidget(
focusNode: gameFocusNode,
game: FlameGame(),
// ignore: avoid_redundant_argument_values
autofocus: true,
),
);

expect(gameFocusNode.hasFocus, isTrue);
});

testWidgets('autofocus false does not start focused', (tester) async {
final gameFocusNode = FocusNode();

await tester.pumpWidget(
GameWidget(
focusNode: gameFocusNode,
game: FlameGame(),
autofocus: false,
),
);

expect(gameFocusNode.hasFocus, isFalse);
});

group('overlay with focus', () {
testWidgets('autofocus on overlay', (tester) async {
final gameFocusNode = FocusNode();
final overlayFocusNode = FocusNode();

final game = FlameGame();

await tester.pumpWidget(
GameWidget(
focusNode: gameFocusNode,
game: game,
autofocus: false,
initialActiveOverlays: const ['some-overlay'],
overlayBuilderMap: {
'some-overlay': (buildContext, game) {
return Focus(
focusNode: overlayFocusNode,
autofocus: true,
child: const SizedBox.shrink(),
);
}
},
),
);

await game.toBeLoaded();
await tester.pump();

expect(gameFocusNode.hasPrimaryFocus, isFalse);
expect(gameFocusNode.hasFocus, isTrue);
expect(overlayFocusNode.hasPrimaryFocus, isTrue);
});

testWidgets(
'focus goes back to game when overlays is removed',
(tester) async {
final gameFocusNode = FocusNode();
final overlayFocusNode = FocusNode();

final game = FlameGame();

await tester.pumpWidget(
GameWidget(
focusNode: gameFocusNode,
game: game,
initialActiveOverlays: const ['some-overlay'],
overlayBuilderMap: {
'some-overlay': (buildContext, game) {
return Focus(
focusNode: overlayFocusNode,
child: const SizedBox.shrink(),
);
}
},
),
);

await game.toBeLoaded();
await tester.pump();

expect(overlayFocusNode.hasFocus, isFalse);
expect(gameFocusNode.hasPrimaryFocus, isTrue);

overlayFocusNode.requestFocus();
await tester.pump();

expect(overlayFocusNode.hasFocus, isTrue);
expect(gameFocusNode.hasPrimaryFocus, isFalse);

game.overlays.remove('some-overlay');

await tester.pump();

expect(overlayFocusNode.hasFocus, isFalse);
expect(gameFocusNode.hasPrimaryFocus, isTrue);
},
);

testWidgets('autofocus on overlay', (tester) async {
final gameFocusNode = FocusNode();
final overlayFocusNode = FocusNode();

final game = FlameGame();

await tester.pumpWidget(
GameWidget(
focusNode: gameFocusNode,
game: game,
autofocus: false,
initialActiveOverlays: const ['some-overlay'],
overlayBuilderMap: {
'some-overlay': (buildContext, game) {
return Focus(
focusNode: overlayFocusNode,
autofocus: true,
child: const SizedBox.shrink(),
);
}
},
),
);

await game.toBeLoaded();
await tester.pump();

expect(gameFocusNode.hasPrimaryFocus, isFalse);
expect(gameFocusNode.hasFocus, isTrue);
expect(overlayFocusNode.hasPrimaryFocus, isTrue);
});
});
});

group('keyboard events', () {
testWidgets('handles keys when game is KeyboardKeys', (tester) async {
final game = _GameWithKeyboardEvents();

await tester.pumpWidget(
GameWidget(
game: game,
),
);

await game.toBeLoaded();
await tester.pump();

await simulateKeyDownEvent(LogicalKeyboardKey.keyA);
await tester.pump();

expect(game.keyEvents, [LogicalKeyboardKey.keyA]);
});

testWidgets(
'handles keys when focused regardless of being KeyboardKeys',
(tester) async {
final game = FlameGame();

await tester.pumpWidget(
GameWidget(
game: game,
),
);

await game.toBeLoaded();
await tester.pump();

final handled = await simulateKeyDownEvent(LogicalKeyboardKey.keyA);

expect(handled, isTrue);
},
);

testWidgets('handles keys when KeyboardEvents', (tester) async {
final game = _GameWithKeyboardEvents();

await tester.pumpWidget(
GameWidget(
game: game,
),
);

await game.toBeLoaded();
await tester.pump();

await simulateKeyDownEvent(LogicalKeyboardKey.keyA);
await tester.pump();

expect(game.keyEvents, [LogicalKeyboardKey.keyA]);
});

testWidgets('overlay handles keys', (tester) async {
final overlayKeyEvents = <LogicalKeyboardKey>[];
final overlayFocusNode = FocusNode(
onKey: (_, keyEvent) {
overlayKeyEvents.add(keyEvent.logicalKey);
return KeyEventResult.ignored;
},
);

final game = _GameWithKeyboardEvents();

await tester.pumpWidget(
GameWidget(
autofocus: false,
game: game,
initialActiveOverlays: const ['some-overlay'],
overlayBuilderMap: {
'some-overlay': (buildContext, game) {
return Focus(
focusNode: overlayFocusNode,
autofocus: true,
child: const SizedBox.shrink(),
);
}
},
),
);

await game.toBeLoaded();
await tester.pump();

expect(overlayFocusNode.hasPrimaryFocus, isTrue);
await simulateKeyDownEvent(LogicalKeyboardKey.keyA);
await tester.pump();

expect(game.keyEvents, <RawKeyEvent>[]);
expect(overlayKeyEvents, [LogicalKeyboardKey.keyA]);
});
});
}

0 comments on commit d1cd851

Please sign in to comment.