feat: Add DoubleTapCallbacks that receives double-tap events. (#2327)

As-is
As mentioned in #2321, the user needs to propagate double-tap events to the component tree using DoubleTapDetector & propagateToChildren until now.

To-be
Any components that are mixed into the DoubleTapCallbacks receive double-tap-related events.
Same as DragCallbacks, there is no need to add mixin to the game like HasDoubleTapCallbaks as before.
This commit is contained in:
Hwanseok Barth Kang
2023-02-11 03:29:36 +09:00
committed by GitHub
parent 0c14d4cb87
commit b5f79d1ce4
10 changed files with 420 additions and 0 deletions

View File

@ -473,6 +473,31 @@ class MyGame extends FlameGame with HasHoverables {
```
### DoubleTapCallbacks
Flame also offers a mixin named `DoubleTapCallbacks` to receive a double-tap event from the
component. To start receiving double tap events in a component, add the
`DoubleTapCallbacks` mixin to your `PositionComponent`.
```dart
class MyComponent extends PositionComponent with DoubleTapCallbacks {
@override
void onDoubleTapUp(DoubleTapEvent event) {
/// Do something
}
@override
void onDoubleTapCancel(DoubleTapCancelEvent event) {
/// Do something
}
@override
void onDoubleTapDown(DoubleTapDownEvent event) {
/// Do something
}
```
### GestureHitboxes
The `GestureHitboxes` mixin is used to more accurately recognize gestures on top of your

View File

@ -0,0 +1,65 @@
import 'package:examples/commons/ember.dart';
import 'package:flame/components.dart';
import 'package:flame/experimental.dart';
import 'package:flame/game.dart';
import 'package:flutter/material.dart';
class DoubleTapCallbacksExample extends FlameGame with DoubleTapCallbacks {
static const String description = '''
In this example, we show how you can use the `DoubleTapCallbacks` mixin on
a `Component`. Double tap Ember and see her color changing.
The example also adds white circles when double-tapping on the game area.
''';
@override
Future<void> onLoad() async {
children.register<DoubleTappableEmber>();
}
@override
void onGameResize(Vector2 canvasSize) {
children
.query<DoubleTappableEmber>()
.forEach((element) => element.removeFromParent());
add(DoubleTappableEmber(position: canvasSize / 2));
super.onGameResize(canvasSize);
}
@override
void onDoubleTapDown(DoubleTapDownEvent event) {
add(
CircleComponent(
radius: 30,
position: event.localPosition,
anchor: Anchor.center,
),
);
}
}
class DoubleTappableEmber extends Ember with DoubleTapCallbacks {
@override
bool debugMode = true;
DoubleTappableEmber({Vector2? position})
: super(
position: position ?? Vector2.all(100),
size: Vector2.all(100),
);
@override
void onDoubleTapUp(DoubleTapEvent event) {
debugColor = Colors.greenAccent;
}
@override
void onDoubleTapCancel(DoubleTapCancelEvent event) {
debugColor = Colors.red;
}
@override
void onDoubleTapDown(DoubleTapDownEvent event) {
debugColor = Colors.blue;
}
}

View File

@ -1,5 +1,6 @@
import 'package:dashbook/dashbook.dart';
import 'package:examples/commons/commons.dart';
import 'package:examples/stories/input/double_tap_callbacks_example.dart';
import 'package:examples/stories/input/draggables_example.dart';
import 'package:examples/stories/input/gesture_hitboxes_example.dart';
import 'package:examples/stories/input/hardware_keyboard_example.dart';
@ -38,6 +39,16 @@ void addInputStories(Dashbook dashbook) {
codeLink: baseLink('input/draggables_example.dart'),
info: DraggablesExample.description,
)
..add(
'Double Tap (Component)',
(context) {
return GameWidget(
game: DoubleTapCallbacksExample(),
);
},
codeLink: baseLink('input/draggables_example.dart'),
info: DoubleTapCallbacksExample.description,
)
..add(
'Hoverables',
(_) => GameWidget(game: HoverablesExample()),

View File

@ -21,6 +21,8 @@ export 'src/camera/viewports/fixed_aspect_ratio_viewport.dart'
export 'src/camera/viewports/fixed_size_viewport.dart' show FixedSizeViewport;
export 'src/camera/viewports/max_viewport.dart' show MaxViewport;
export 'src/camera/world.dart' show World;
export 'src/events/component_mixins/double_tap_callbacks.dart'
show DoubleTapCallbacks;
export 'src/events/component_mixins/drag_callbacks.dart' show DragCallbacks;
export 'src/events/component_mixins/tap_callbacks.dart' show TapCallbacks;
export 'src/events/flame_game_mixins/has_draggable_components.dart'
@ -32,6 +34,10 @@ export 'src/events/flame_game_mixins/has_tappable_components.dart'
show HasTappableComponents;
export 'src/events/flame_game_mixins/has_tappables_bridge.dart'
show HasTappablesBridge;
export 'src/events/messages/double_tap_cancel_event.dart'
show DoubleTapCancelEvent;
export 'src/events/messages/double_tap_down_event.dart' show DoubleTapDownEvent;
export 'src/events/messages/double_tap_event.dart' show DoubleTapEvent;
export 'src/events/messages/drag_cancel_event.dart' show DragCancelEvent;
export 'src/events/messages/drag_end_event.dart' show DragEndEvent;
export 'src/events/messages/drag_start_event.dart' show DragStartEvent;

View File

@ -0,0 +1,37 @@
import 'package:flame/game.dart';
import 'package:flame/src/components/core/component.dart';
import 'package:flame/src/events/flame_game_mixins/double_tap_dispatcher.dart';
import 'package:flame/src/events/messages/double_tap_cancel_event.dart';
import 'package:flame/src/events/messages/double_tap_down_event.dart';
import 'package:flame/src/events/messages/double_tap_event.dart';
/// [DoubleTapCallbacks] adds the ability to receive double-tap events in a
/// component.
///
/// In addition to adding this mixin, the component must also implement the
/// [containsLocalPoint] method.
///
/// At present, flutter detects only one double-tap events simultaneously.
/// This means that if you're double-tapping two [DoubleTapCallbacks] located
/// far away from each other, only one callback will be fired (or none).
mixin DoubleTapCallbacks on Component {
/// This triggers when the pointer stops contacting the device after the
/// second tap.
void onDoubleTapUp(DoubleTapEvent event) {}
/// This triggers immediately after the down event of the second tap.
void onDoubleTapDown(DoubleTapDownEvent event) {}
/// This triggers once the gesture loses the arena if [onDoubleTapDown] has
/// previously been triggered.
void onDoubleTapCancel(DoubleTapCancelEvent event) {}
@override
void onMount() {
super.onMount();
final game = findGame()! as FlameGame;
if (game.firstChild<DoubleTapDispatcher>() == null) {
game.add(DoubleTapDispatcher());
}
}
}

View File

@ -0,0 +1,66 @@
import 'package:flame/components.dart';
import 'package:flame/game.dart';
import 'package:flame/src/events/component_mixins/double_tap_callbacks.dart';
import 'package:flame/src/events/messages/double_tap_cancel_event.dart';
import 'package:flame/src/events/messages/double_tap_down_event.dart';
import 'package:flame/src/events/messages/double_tap_event.dart';
import 'package:flutter/gestures.dart';
import 'package:meta/meta.dart';
/// [DoubleTapDispatcher] propagates double-tap events to every components in
/// the component tree that is mixed with [DoubleTapCallbacks]. This will be
/// attached to the [FlameGame] instance automatically whenever any
/// [DoubleTapCallbacks] are mounted into the component tree.
@internal
class DoubleTapDispatcher extends Component with HasGameRef<FlameGame> {
final _components = <DoubleTapCallbacks>{};
bool _eventHandlerRegistered = false;
void _onDoubleTapDown(DoubleTapDownEvent event) {
event.deliverAtPoint(
rootComponent: game,
eventHandler: (DoubleTapCallbacks component) {
_components.add(component..onDoubleTapDown(event));
},
);
}
void _onDoubleTapUp(DoubleTapEvent event) {
_components.forEach((component) => component.onDoubleTapUp(event));
_components.clear();
}
void _onDoubleTapCancel(DoubleTapCancelEvent event) {
_components.forEach((component) => component.onDoubleTapCancel(event));
_components.clear();
}
@override
void onMount() {
if (game.firstChild<DoubleTapDispatcher>() == null) {
game.gestureDetectors.add(
DoubleTapGestureRecognizer.new,
(DoubleTapGestureRecognizer instance) {
instance.onDoubleTapDown =
(details) => _onDoubleTapDown(DoubleTapDownEvent(details));
instance.onDoubleTapCancel =
() => _onDoubleTapCancel(DoubleTapCancelEvent());
instance.onDoubleTap = () => _onDoubleTapUp(DoubleTapEvent());
},
);
_eventHandlerRegistered = true;
} else {
removeFromParent();
}
}
@override
void onRemove() {
if (!_eventHandlerRegistered) {
return;
}
game.gestureDetectors.remove<DoubleTapGestureRecognizer>();
_eventHandlerRegistered = false;
}
}

View File

@ -0,0 +1,3 @@
import 'package:flame/src/events/messages/event.dart';
class DoubleTapCancelEvent extends Event {}

View File

@ -0,0 +1,14 @@
import 'package:flame/extensions.dart';
import 'package:flame/src/events/messages/position_event.dart';
import 'package:flutter/gestures.dart';
class DoubleTapDownEvent extends PositionEvent {
final PointerDeviceKind deviceKind;
DoubleTapDownEvent(TapDownDetails details)
: deviceKind = details.kind ?? PointerDeviceKind.unknown,
super(
canvasPosition: details.localPosition.toVector2(),
devicePosition: details.globalPosition.toVector2(),
);
}

View File

@ -0,0 +1,3 @@
import 'package:flame/src/events/messages/event.dart';
class DoubleTapEvent extends Event {}

View File

@ -0,0 +1,190 @@
import 'package:flame/components.dart';
import 'package:flame/experimental.dart';
import 'package:flame/game.dart';
import 'package:flame/src/events/flame_game_mixins/double_tap_dispatcher.dart';
import 'package:flame_test/flame_test.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
group('DoubleTapCallbacks', () {
testWidgets(
'receives double-tap event',
(tester) async {
final component = _DoubleTapCallbacksComponent()
..position = Vector2.all(10);
await tester.pumpWidget(
GameWidget(
game: FlameGame(children: [component]),
),
);
await tester.pump();
await tester.pump();
final gesture = await tester.createGesture();
await gesture.down(const Offset(10, 10));
await tester.pump();
await gesture.up();
expect(component.doubleTapDown, 0);
expect(component.doubleTapCancel, 0);
expect(component.doubleTap, 0);
await tester.pump(kDoubleTapMinTime);
await gesture.down(const Offset(10, 10));
expect(component.doubleTapDown, 1);
expect(component.doubleTapCancel, 0);
expect(component.doubleTap, 0);
await gesture.up();
expect(component.doubleTapDown, 1);
expect(component.doubleTapCancel, 0);
expect(component.doubleTap, 1);
await tester.pump(kDoubleTapMinTime);
},
);
testWidgets(
'''does not receive an event when double-tapping a position far from the component''',
(tester) async {
final component = _DoubleTapCallbacksComponent()
..position = Vector2.all(10);
await tester.pumpWidget(
GameWidget(
game: FlameGame(children: [component]),
),
);
await tester.pump();
await tester.pump();
final gesture = await tester.createGesture();
await gesture.down(const Offset(100, 100));
await gesture.up();
await tester.pump(kDoubleTapMinTime);
await gesture.down(const Offset(100, 100));
expect(component.doubleTapDown, 0);
expect(component.doubleTapCancel, 0);
expect(component.doubleTap, 0);
await gesture.up();
expect(component.doubleTapDown, 0);
expect(component.doubleTapCancel, 0);
expect(component.doubleTap, 0);
await tester.pump(kDoubleTapMinTime);
},
);
testWidgets(
'receives a cancel event when gesture is canceled by drag',
(tester) async {
final component = _DoubleTapCallbacksComponent()
..position = Vector2.all(10);
await tester.pumpWidget(
GameWidget(
game: FlameGame(children: [component]),
),
);
await tester.pump();
await tester.pump();
final gesture = await tester.createGesture();
await gesture.down(const Offset(10, 10));
await gesture.up();
await tester.pump(kDoubleTapMinTime);
await gesture.down(const Offset(10, 10));
expect(component.doubleTapDown, 1);
expect(component.doubleTapCancel, 0);
expect(component.doubleTap, 0);
await gesture.moveBy(const Offset(100, 100));
expect(component.doubleTapDown, 1);
expect(component.doubleTapCancel, 1);
expect(component.doubleTap, 0);
await tester.pump(kDoubleTapMinTime);
},
);
testWidgets(
'receives a cancel event when gesture is canceled by cancel',
(tester) async {
final component = _DoubleTapCallbacksComponent()
..position = Vector2.all(10);
await tester.pumpWidget(
GameWidget(
game: FlameGame(children: [component]),
),
);
await tester.pump();
await tester.pump();
final gesture = await tester.createGesture();
await gesture.down(const Offset(10, 10));
await gesture.up();
await tester.pump(kDoubleTapMinTime);
await gesture.down(const Offset(10, 10));
expect(component.doubleTapDown, 1);
expect(component.doubleTapCancel, 0);
expect(component.doubleTap, 0);
await gesture.cancel();
expect(component.doubleTapDown, 1);
expect(component.doubleTapCancel, 1);
expect(component.doubleTap, 0);
await tester.pump(kDoubleTapMinTime);
},
);
testWithFlameGame(
'DoubleTapDispatcher is added to game when the callback is mounted',
(game) async {
final component = _DoubleTapCallbacksComponent();
await game.add(component);
await game.ready();
expect(game.firstChild<DoubleTapDispatcher>(), isNotNull);
},
);
});
}
class _DoubleTapCallbacksComponent extends PositionComponent
with DoubleTapCallbacks {
_DoubleTapCallbacksComponent() {
anchor = Anchor.center;
size = Vector2.all(10);
}
int doubleTapDown = 0;
int doubleTapCancel = 0;
int doubleTap = 0;
@override
void onDoubleTapUp(DoubleTapEvent event) {
doubleTap++;
}
@override
void onDoubleTapCancel(DoubleTapCancelEvent event) {
doubleTapCancel++;
}
@override
void onDoubleTapDown(DoubleTapDownEvent event) {
doubleTapDown++;
}
}