Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix: Focus handling with a scope on the GameWidget #1725

Merged
merged 3 commits into from
Oct 25, 2022
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
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', () {
renancaraujo marked this conversation as resolved.
Show resolved Hide resolved
testWidgets('autofocus starts focused', (tester) async {
final gameFocusNode = FocusNode();

await tester.pumpWidget(
GameWidget(
focusNode: gameFocusNode,
game: FlameGame(),
// ignore: avoid_redundant_argument_values
autofocus: true,
Comment on lines +250 to +251
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
// ignore: avoid_redundant_argument_values
autofocus: true,

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's pretty good to keep this imho, it gives the reader of the test more context of what is being tested.

Copy link
Member Author

@renancaraujo renancaraujo Oct 24, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Disagree with the suggestion, that is to explicitly show what that test tests.

),
);

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]);
});
});
}