diff --git a/doc/flame/inputs/gesture-input.md b/doc/flame/inputs/gesture-input.md index 99e0b602cd7..e4efe5d0ddd 100644 --- a/doc/flame/inputs/gesture-input.md +++ b/doc/flame/inputs/gesture-input.md @@ -21,6 +21,7 @@ of these `mixin`s and its methods: - onTap - onTapCancel - onTapDown + - onLongTapDown - onTapUp - SecondaryTapDetector @@ -241,6 +242,7 @@ components, you can override the following methods on your components: ```dart bool onTapCancel(); bool onTapDown(TapDownInfo info); +bool onLongTapDown(TapDownInfo info); bool onTapUp(TapUpInfo info); ``` @@ -295,19 +297,23 @@ class MyComponent extends PositionComponent with Tappable{ bool onTapDown(TapDownInfo info) { info.handled = true; return true; - } + } } class MyGame extends FlameGame with HasTappables { @override void onTapDown(int pointerId, TapDownInfo info) { - if(info.handled) { + if (info.handled) { // Do something if a child handled the event } } } ``` +The event `onLongTapDown` will be triggered on a component after the user "holds" it for a certain +minimum amount of time. By default, that time is 300ms, but it can be adjusted by overriding the +`longTapDelay` field of the `HasTappables` mixin. + ### Draggable components @@ -472,7 +478,7 @@ for the event to be registered on your component. You can add new hitboxes to the component that has the `GestureHitboxes` mixin just like they are added in the below `Collidable` example. -More information about how to define hitboxes can be found in the hitbox section of the +More information about how to define hitboxes can be found in the hitbox section of the [collision detection](../collision_detection.md#shapehitbox) docs. An example of how to use it can be seen diff --git a/packages/flame/lib/input.dart b/packages/flame/lib/input.dart index ef2fd78ed24..cde56350392 100644 --- a/packages/flame/lib/input.dart +++ b/packages/flame/lib/input.dart @@ -1,6 +1,5 @@ /// {@canonicalFor joystick_component.JoystickComponent} /// {@canonicalFor joystick_component.JoystickDirection} - export 'src/components/input/button_component.dart'; export 'src/components/input/hud_button_component.dart'; export 'src/components/input/hud_margin_component.dart'; @@ -8,5 +7,6 @@ export 'src/components/input/joystick_component.dart'; export 'src/components/input/sprite_button_component.dart'; export 'src/extensions/vector2.dart'; export 'src/game/mixins/keyboard.dart'; +export 'src/game/mixins/multi_touch_tap_detector.dart'; export 'src/gestures/detectors.dart'; export 'src/gestures/events.dart'; diff --git a/packages/flame/lib/src/components/mixins/tappable.dart b/packages/flame/lib/src/components/mixins/tappable.dart index fe5847dd7ed..6b6b5152a5e 100644 --- a/packages/flame/lib/src/components/mixins/tappable.dart +++ b/packages/flame/lib/src/components/mixins/tappable.dart @@ -1,21 +1,24 @@ +import 'package:flutter/gestures.dart'; import 'package:meta/meta.dart'; import '../../../components.dart'; import '../../game/mixins/has_tappables.dart'; import '../../gestures/events.dart'; +/// Mixin that can be added to any [Component] allowing it to receive tap +/// events. +/// +/// When using this mixin, also add [HasTappables] to your game, which handles +/// propagation of tap events from the root game to individual components. +/// +/// See [MultiTapGestureRecognizer] for the description of each individual +/// event. mixin Tappable on Component { - bool onTapCancel() { - return true; - } - - bool onTapDown(TapDownInfo info) { - return true; - } - - bool onTapUp(TapUpInfo info) { - return true; - } + // bool onTap() => true; + bool onTapDown(TapDownInfo info) => true; + bool onLongTapDown(TapDownInfo info) => true; + bool onTapUp(TapUpInfo info) => true; + bool onTapCancel() => true; int? _currentPointerId; @@ -45,14 +48,20 @@ mixin Tappable on Component { return true; } + bool handleLongTapDown(int pointerId, TapDownInfo info) { + if (_checkPointerId(pointerId) && containsPoint(eventPosition(info))) { + return onLongTapDown(info); + } + return true; + } + @override @mustCallSuper void onMount() { super.onMount(); assert( findGame()! is HasTappables, - 'Tappable Components can only be added to a FlameGame with ' - 'HasTappables', + 'Tappable components can only be added to a FlameGame with HasTappables', ); } } diff --git a/packages/flame/lib/src/events/interfaces/multi_tap_listener.dart b/packages/flame/lib/src/events/interfaces/multi_tap_listener.dart new file mode 100644 index 00000000000..6ff169481fc --- /dev/null +++ b/packages/flame/lib/src/events/interfaces/multi_tap_listener.dart @@ -0,0 +1,33 @@ +import 'package:flutter/gestures.dart'; + +import '../../game/mixins/has_tappables.dart'; +import '../../game/mixins/multi_touch_tap_detector.dart'; + +/// Interface that must be implemented by a game in order for it to be eligible +/// to receive events from a [MultiTapGestureRecognizer]. +/// +/// Instead of implementing this class directly consider using one of the +/// prebuilt mixins: +/// - [HasTappables] for a `FlameGame` +/// - [MultiTouchTapDetector] for a custom `Game` +abstract class MultiTapListener { + /// The amount of time before the "long tap down" event is triggered. + double get longTapDelay; + + /// A tap has occurred. + void handleTap(int pointerId); + + /// A pointer has touched the screen. + void handleTapDown(int pointerId, TapDownDetails details); + + /// A pointer stopped contacting the screen. + void handleTapUp(int pointerId, TapUpDetails details); + + /// A pointer that already triggered [handleTapDown] will not trigger + /// [handleTap]. + void handleTapCancel(int pointerId); + + /// A pointer that has previously triggered [handleTapDown] is still touching + /// the screen after [longTapDelay] seconds. + void handleLongTapDown(int pointerId, TapDownDetails details); +} diff --git a/packages/flame/lib/src/game/game_widget/gestures.dart b/packages/flame/lib/src/game/game_widget/gestures.dart index 38214462dd7..78a005f5f42 100644 --- a/packages/flame/lib/src/game/game_widget/gestures.dart +++ b/packages/flame/lib/src/game/game_widget/gestures.dart @@ -2,12 +2,12 @@ import 'package:flutter/gestures.dart'; import 'package:flutter/widgets.dart'; import '../../../extensions.dart'; +import '../../events/interfaces/multi_tap_listener.dart'; import '../../gestures/detectors.dart'; import '../../gestures/events.dart'; import '../mixins/game.dart'; import '../mixins/has_draggables.dart'; import '../mixins/has_hoverables.dart'; -import '../mixins/has_tappables.dart'; bool hasBasicGestureDetectors(Game game) { return game is TapDetector || @@ -22,9 +22,8 @@ bool hasBasicGestureDetectors(Game game) { } bool hasAdvancedGestureDetectors(Game game) { - return game is MultiTouchTapDetector || + return game is MultiTapListener || game is MultiTouchDragDetector || - game is HasTappables || game is HasDraggables; } @@ -184,29 +183,36 @@ Widget applyBasicGesturesDetectors(Game game, Widget child) { Widget applyAdvancedGesturesDetectors(Game game, Widget child) { final gestures = {}; - var lastGeneratedDragId = 0; - void addAndConfigureRecognizer( - T Function() ts, - void Function(T) bindHandlers, + void addRecognizer( + T Function() factory, + void Function(T) handlers, ) { - gestures[T] = GestureRecognizerFactoryWithHandlers( - ts, - bindHandlers, - ); + gestures[T] = GestureRecognizerFactoryWithHandlers(factory, handlers); } - void addTapRecognizer(void Function(MultiTapGestureRecognizer) config) { - addAndConfigureRecognizer( + if (game is MultiTapListener) { + addRecognizer( () => MultiTapGestureRecognizer(), - config, + (MultiTapGestureRecognizer instance) { + final g = game as MultiTapListener; + instance.longTapDelay = Duration( + milliseconds: (g.longTapDelay * 1000).toInt(), + ); + instance.onTap = g.handleTap; + instance.onTapDown = g.handleTapDown; + instance.onTapUp = g.handleTapUp; + instance.onTapCancel = g.handleTapCancel; + instance.onLongTapDown = g.handleLongTapDown; + }, ); } - void addDragRecognizer(Game game, Drag Function(int, DragStartInfo) config) { - addAndConfigureRecognizer( + void addDragRecognizer(Drag Function(int, DragStartInfo) config) { + addRecognizer( () => ImmediateMultiDragGestureRecognizer(), (ImmediateMultiDragGestureRecognizer instance) { + var lastGeneratedDragId = 0; instance.onStart = (Offset o) { final pointerId = lastGeneratedDragId++; @@ -230,30 +236,8 @@ Widget applyAdvancedGesturesDetectors(Game game, Widget child) { ); } - if (game is MultiTouchTapDetector) { - addTapRecognizer((MultiTapGestureRecognizer instance) { - instance.onTapDown = - (i, d) => game.onTapDown(i, TapDownInfo.fromDetails(game, d)); - instance.onTapUp = - (i, d) => game.onTapUp(i, TapUpInfo.fromDetails(game, d)); - instance.onTapCancel = game.onTapCancel; - instance.onTap = game.onTap; - }); - } else if (game is HasTappables) { - addAndConfigureRecognizer( - () => MultiTapGestureRecognizer(), - (MultiTapGestureRecognizer instance) { - instance.onTapDown = - (i, d) => game.onTapDown(i, TapDownInfo.fromDetails(game, d)); - instance.onTapUp = - (i, d) => game.onTapUp(i, TapUpInfo.fromDetails(game, d)); - instance.onTapCancel = (i) => game.onTapCancel(i); - }, - ); - } - if (game is MultiTouchDragDetector) { - addDragRecognizer(game, (int pointerId, DragStartInfo info) { + addDragRecognizer((int pointerId, DragStartInfo info) { game.onDragStart(pointerId, info); return _DragEvent(game) ..onUpdate = ((details) => game.onDragUpdate(pointerId, details)) @@ -261,7 +245,7 @@ Widget applyAdvancedGesturesDetectors(Game game, Widget child) { ..onCancel = (() => game.onDragCancel(pointerId)); }); } else if (game is HasDraggables) { - addDragRecognizer(game, (int pointerId, DragStartInfo position) { + addDragRecognizer((int pointerId, DragStartInfo position) { game.onDragStart(pointerId, position); return _DragEvent(game) ..onUpdate = ((details) => game.onDragUpdate(pointerId, details)) diff --git a/packages/flame/lib/src/game/mixins/has_tappables.dart b/packages/flame/lib/src/game/mixins/has_tappables.dart index f4320e46efe..ad558307929 100644 --- a/packages/flame/lib/src/game/mixins/has_tappables.dart +++ b/packages/flame/lib/src/game/mixins/has_tappables.dart @@ -1,10 +1,25 @@ +import 'package:flutter/gestures.dart'; import 'package:meta/meta.dart'; -import '../../../components.dart'; -import '../../../game.dart'; +import '../../components/mixins/tappable.dart'; +import '../../events/interfaces/multi_tap_listener.dart'; import '../../gestures/events.dart'; +import '../flame_game.dart'; +import 'multi_touch_tap_detector.dart'; -mixin HasTappables on FlameGame { +/// Mixin that can be added to a [FlameGame] allowing it (and the components +/// attached to it) to receive tap events. +/// +/// This mixin is similar to [MultiTouchTapDetector] on Game, however, it also +/// propagates all tap events down the component tree, allowing each individual +/// component to respond to events that happen on that component. +/// +/// This mixin **must be** added to a game if you plan to use any components +/// that are [Tappable]. +/// +/// See [MultiTapGestureRecognizer] for the description of each individual +/// event. +mixin HasTappables on FlameGame implements MultiTapListener { @mustCallSuper void onTapCancel(int pointerId) { propagateToChildren( @@ -25,4 +40,37 @@ mixin HasTappables on FlameGame { (Tappable child) => child.handleTapUp(pointerId, info), ); } + + @mustCallSuper + void onLongTapDown(int pointerId, TapDownInfo info) { + propagateToChildren( + (Tappable child) => child.handleLongTapDown(pointerId, info), + ); + } + + //#region MultiTapListener API + @override + double get longTapDelay => 0.300; + + @override + void handleTap(int pointerId) {} + + @override + void handleTapCancel(int pointerId) => onTapCancel(pointerId); + + @override + void handleTapDown(int pointerId, TapDownDetails details) { + onTapDown(pointerId, TapDownInfo.fromDetails(this, details)); + } + + @override + void handleTapUp(int pointerId, TapUpDetails details) { + onTapUp(pointerId, TapUpInfo.fromDetails(this, details)); + } + + @override + void handleLongTapDown(int pointerId, TapDownDetails details) { + onLongTapDown(pointerId, TapDownInfo.fromDetails(this, details)); + } + //#endregion } diff --git a/packages/flame/lib/src/game/mixins/multi_touch_tap_detector.dart b/packages/flame/lib/src/game/mixins/multi_touch_tap_detector.dart new file mode 100644 index 00000000000..989039be311 --- /dev/null +++ b/packages/flame/lib/src/game/mixins/multi_touch_tap_detector.dart @@ -0,0 +1,54 @@ +import 'package:flutter/gestures.dart'; + +import '../../events/interfaces/multi_tap_listener.dart'; +import '../../gestures/events.dart'; +import 'game.dart'; +import 'has_tappables.dart'; + +/// Mixin that can be added to a [Game] allowing it to receive tap events. +/// +/// The user can override one of the callback methods +/// - [onTapDown] +/// - [onLongTapDown] +/// - [onTapUp] +/// - [onTapCancel] +/// - [onTap] +/// in order to respond to each corresponding event. Those events whose methods +/// are not overridden are ignored. +/// +/// See [MultiTapGestureRecognizer] for the description of each individual +/// event. If your game is derived from the FlameGame class, consider using the +/// [HasTappables] mixin instead. +mixin MultiTouchTapDetector on Game implements MultiTapListener { + void onTapDown(int pointerId, TapDownInfo info) {} + void onLongTapDown(int pointerId, TapDownInfo info) {} + void onTapUp(int pointerId, TapUpInfo info) {} + void onTapCancel(int pointerId) {} + void onTap(int pointerId) {} + + //#region MultiTapListener API + @override + double get longTapDelay => 0.300; + + @override + void handleTap(int pointerId) => onTap(pointerId); + + @override + void handleTapCancel(int pointerId) => onTapCancel(pointerId); + + @override + void handleTapDown(int pointerId, TapDownDetails details) { + onTapDown(pointerId, TapDownInfo.fromDetails(this, details)); + } + + @override + void handleTapUp(int pointerId, TapUpDetails details) { + onTapUp(pointerId, TapUpInfo.fromDetails(this, details)); + } + + @override + void handleLongTapDown(int pointerId, TapDownDetails details) { + onLongTapDown(pointerId, TapDownInfo.fromDetails(this, details)); + } + //#endregion +} diff --git a/packages/flame/lib/src/gestures/detectors.dart b/packages/flame/lib/src/gestures/detectors.dart index 5049afd1ed1..9788593beab 100644 --- a/packages/flame/lib/src/gestures/detectors.dart +++ b/packages/flame/lib/src/gestures/detectors.dart @@ -1,14 +1,6 @@ import '../game/mixins/game.dart'; import 'events.dart'; -mixin MultiTouchTapDetector on Game { - void onTap(int pointerId) {} - void onTapCancel(int pointerId) {} - void onTapDown(int pointerId, TapDownInfo info) {} - void onTapUp(int pointerId, TapUpInfo info) {} - void onLongTapDown(int pointerId, TapDownInfo info) {} -} - mixin MultiTouchDragDetector on Game { void onDragStart(int pointerId, DragStartInfo info) {} void onDragUpdate(int pointerId, DragUpdateInfo info) {} diff --git a/packages/flame/test/game/mixins/has_tappables_test.dart b/packages/flame/test/game/mixins/has_tappables_test.dart index 00e131bc63f..d9e37887c8f 100644 --- a/packages/flame/test/game/mixins/has_tappables_test.dart +++ b/packages/flame/test/game/mixins/has_tappables_test.dart @@ -8,6 +8,7 @@ import 'package:test/test.dart'; class _GameWithTappables extends FlameGame with HasTappables { int handledOnTapDown = 0; + int handledOnLongTapDown = 0; int handledOnTapUp = 0; int handledOnTapCancel = 0; @@ -19,6 +20,14 @@ class _GameWithTappables extends FlameGame with HasTappables { } } + @override + void onLongTapDown(int pointerId, TapDownInfo info) { + super.onLongTapDown(pointerId, info); + if (info.handled) { + handledOnLongTapDown++; + } + } + @override void onTapUp(int pointerId, TapUpInfo info) { super.onTapUp(pointerId, info); @@ -48,6 +57,12 @@ class _TappableComponent extends PositionComponent with Tappable { return true; } + @override + bool onLongTapDown(TapDownInfo info) { + info.handled = true; + return true; + } + @override bool onTapUp(TapUpInfo info) { info.handled = true; @@ -72,7 +87,7 @@ void main() { expect( () => game.ensureAdd(_TappableComponent()), failsAssert( - 'Tappable Components can only be added to a FlameGame with ' + 'Tappable components can only be added to a FlameGame with ' 'HasTappables', ), ); @@ -88,6 +103,22 @@ void main() { await tester.tapAt(const Offset(10, 10)); await tester.pump(const Duration(seconds: 1)); expect(game.handledOnTapDown, 1); + expect(game.handledOnLongTapDown, 0); + expect(game.handledOnTapUp, 1); + expect(game.handledOnTapCancel, 0); + }, + ); + + withTappables.testGameWidget( + 'long tap correctly registered handled event', + setUp: (game, _) async { + await game.ensureAdd(_TappableComponent()); + }, + verify: (game, tester) async { + await tester.longPressAt(const Offset(10, 10)); + await tester.pump(const Duration(seconds: 1)); + expect(game.handledOnTapDown, 1); + expect(game.handledOnLongTapDown, 1); expect(game.handledOnTapUp, 1); expect(game.handledOnTapCancel, 0); }, @@ -102,6 +133,7 @@ void main() { await tester.tapAt(const Offset(110, 110)); await tester.pump(const Duration(seconds: 1)); expect(game.handledOnTapDown, 0); + expect(game.handledOnLongTapDown, 0); expect(game.handledOnTapUp, 0); expect(game.handledOnTapCancel, 0); },