From b5f79d1ce45276d957d0512353ca9cc890b6fef1 Mon Sep 17 00:00:00 2001 From: Hwanseok Barth Kang Date: Sat, 11 Feb 2023 03:29:36 +0900 Subject: [PATCH] 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. --- doc/flame/inputs/gesture_input.md | 25 +++ .../input/double_tap_callbacks_example.dart | 65 ++++++ examples/lib/stories/input/input.dart | 11 + packages/flame/lib/experimental.dart | 6 + .../double_tap_callbacks.dart | 37 ++++ .../double_tap_dispatcher.dart | 66 ++++++ .../messages/double_tap_cancel_event.dart | 3 + .../messages/double_tap_down_event.dart | 14 ++ .../src/events/messages/double_tap_event.dart | 3 + .../double_tap_callbacks_test.dart | 190 ++++++++++++++++++ 10 files changed, 420 insertions(+) create mode 100644 examples/lib/stories/input/double_tap_callbacks_example.dart create mode 100644 packages/flame/lib/src/events/component_mixins/double_tap_callbacks.dart create mode 100644 packages/flame/lib/src/events/flame_game_mixins/double_tap_dispatcher.dart create mode 100644 packages/flame/lib/src/events/messages/double_tap_cancel_event.dart create mode 100644 packages/flame/lib/src/events/messages/double_tap_down_event.dart create mode 100644 packages/flame/lib/src/events/messages/double_tap_event.dart create mode 100644 packages/flame/test/events/component_mixins/double_tap_callbacks_test.dart diff --git a/doc/flame/inputs/gesture_input.md b/doc/flame/inputs/gesture_input.md index 8a434c6c3..c1aef1da3 100644 --- a/doc/flame/inputs/gesture_input.md +++ b/doc/flame/inputs/gesture_input.md @@ -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 diff --git a/examples/lib/stories/input/double_tap_callbacks_example.dart b/examples/lib/stories/input/double_tap_callbacks_example.dart new file mode 100644 index 000000000..78de95ad2 --- /dev/null +++ b/examples/lib/stories/input/double_tap_callbacks_example.dart @@ -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 onLoad() async { + children.register(); + } + + @override + void onGameResize(Vector2 canvasSize) { + children + .query() + .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; + } +} diff --git a/examples/lib/stories/input/input.dart b/examples/lib/stories/input/input.dart index 4e1e7961e..9866d50aa 100644 --- a/examples/lib/stories/input/input.dart +++ b/examples/lib/stories/input/input.dart @@ -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()), diff --git a/packages/flame/lib/experimental.dart b/packages/flame/lib/experimental.dart index cefdd7373..0d316c8eb 100644 --- a/packages/flame/lib/experimental.dart +++ b/packages/flame/lib/experimental.dart @@ -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; diff --git a/packages/flame/lib/src/events/component_mixins/double_tap_callbacks.dart b/packages/flame/lib/src/events/component_mixins/double_tap_callbacks.dart new file mode 100644 index 000000000..7cfcc206d --- /dev/null +++ b/packages/flame/lib/src/events/component_mixins/double_tap_callbacks.dart @@ -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() == null) { + game.add(DoubleTapDispatcher()); + } + } +} diff --git a/packages/flame/lib/src/events/flame_game_mixins/double_tap_dispatcher.dart b/packages/flame/lib/src/events/flame_game_mixins/double_tap_dispatcher.dart new file mode 100644 index 000000000..df0f34e15 --- /dev/null +++ b/packages/flame/lib/src/events/flame_game_mixins/double_tap_dispatcher.dart @@ -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 { + final _components = {}; + 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() == 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(); + _eventHandlerRegistered = false; + } +} diff --git a/packages/flame/lib/src/events/messages/double_tap_cancel_event.dart b/packages/flame/lib/src/events/messages/double_tap_cancel_event.dart new file mode 100644 index 000000000..dbb03bb08 --- /dev/null +++ b/packages/flame/lib/src/events/messages/double_tap_cancel_event.dart @@ -0,0 +1,3 @@ +import 'package:flame/src/events/messages/event.dart'; + +class DoubleTapCancelEvent extends Event {} diff --git a/packages/flame/lib/src/events/messages/double_tap_down_event.dart b/packages/flame/lib/src/events/messages/double_tap_down_event.dart new file mode 100644 index 000000000..dfd0f8138 --- /dev/null +++ b/packages/flame/lib/src/events/messages/double_tap_down_event.dart @@ -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(), + ); +} diff --git a/packages/flame/lib/src/events/messages/double_tap_event.dart b/packages/flame/lib/src/events/messages/double_tap_event.dart new file mode 100644 index 000000000..64f0630f8 --- /dev/null +++ b/packages/flame/lib/src/events/messages/double_tap_event.dart @@ -0,0 +1,3 @@ +import 'package:flame/src/events/messages/event.dart'; + +class DoubleTapEvent extends Event {} diff --git a/packages/flame/test/events/component_mixins/double_tap_callbacks_test.dart b/packages/flame/test/events/component_mixins/double_tap_callbacks_test.dart new file mode 100644 index 000000000..c5435a8c5 --- /dev/null +++ b/packages/flame/test/events/component_mixins/double_tap_callbacks_test.dart @@ -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(), 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++; + } +}