diff --git a/doc/flame/game_widget.md b/doc/flame/game_widget.md index 5b9b95896d5..416faf5303b 100644 --- a/doc/flame/game_widget.md +++ b/doc/flame/game_widget.md @@ -76,7 +76,8 @@ instead. ```{note} When using `deferToChild` or `translucent`, `FlameGame` determines whether a position has an interactive component by traversing the component tree via -`componentsAtPoint`. Games that directly extend the low-level `Game` class -report a hit on their entire surface by default; override -`containsEventHandlerAt` to customize this. +`componentsAtPoint`, and treating any component that implements +`PointerInputCallbacks` as interactive. Games that directly extend the +low-level `Game` class report a hit on their entire surface by default; +override `containsEventHandlerAt` to customize this. ``` diff --git a/packages/flame/lib/events.dart b/packages/flame/lib/events.dart index 7062c09c4e2..ed444e1cf23 100644 --- a/packages/flame/lib/events.dart +++ b/packages/flame/lib/events.dart @@ -1,7 +1,10 @@ export 'src/events/callbacks/double_tap_callbacks.dart' show DoubleTapCallbacks; export 'src/events/callbacks/drag_callbacks.dart' show DragCallbacks; export 'src/events/callbacks/hover_callbacks.dart' show HoverCallbacks; +export 'src/events/callbacks/input_callbacks.dart' show InputCallbacks; export 'src/events/callbacks/long_press_callbacks.dart' show LongPressCallbacks; +export 'src/events/callbacks/pointer_input_callbacks.dart' + show PointerInputCallbacks; export 'src/events/callbacks/pointer_move_callbacks.dart' show PointerMoveCallbacks; export 'src/events/callbacks/scale_callbacks.dart' show ScaleCallbacks; diff --git a/packages/flame/lib/src/events/callbacks/double_tap_callbacks.dart b/packages/flame/lib/src/events/callbacks/double_tap_callbacks.dart index 6489374760f..eaaee2650fb 100644 --- a/packages/flame/lib/src/events/callbacks/double_tap_callbacks.dart +++ b/packages/flame/lib/src/events/callbacks/double_tap_callbacks.dart @@ -12,7 +12,7 @@ import 'package:flame/events.dart'; /// far away from each other, only one callback will be fired (or none). /// /// This callback uses [DoubleTapDispatcher] to route events. -mixin DoubleTapCallbacks on Component { +mixin DoubleTapCallbacks on Component implements PointerInputCallbacks { /// This triggers when the pointer stops contacting the device after the /// second tap. void onDoubleTapUp(DoubleTapEvent event) {} diff --git a/packages/flame/lib/src/events/callbacks/drag_callbacks.dart b/packages/flame/lib/src/events/callbacks/drag_callbacks.dart index b02d90ce930..c00bff2544f 100644 --- a/packages/flame/lib/src/events/callbacks/drag_callbacks.dart +++ b/packages/flame/lib/src/events/callbacks/drag_callbacks.dart @@ -12,7 +12,7 @@ import 'package:meta/meta.dart'; /// This mixin is the replacement of the Draggable mixin. /// /// This callback uses [MultiDragScaleDispatcher] to route events. -mixin DragCallbacks on Component { +mixin DragCallbacks on Component implements PointerInputCallbacks { bool _isDragged = false; /// Returns true while the component is being dragged. diff --git a/packages/flame/lib/src/events/callbacks/input_callbacks.dart b/packages/flame/lib/src/events/callbacks/input_callbacks.dart new file mode 100644 index 00000000000..1fbb6a8fd80 --- /dev/null +++ b/packages/flame/lib/src/events/callbacks/input_callbacks.dart @@ -0,0 +1,4 @@ +/// Marker interface implemented by every input callbacks mixin. +/// +/// See `PointerInputCallbacks` for the positional subset. +abstract interface class InputCallbacks {} diff --git a/packages/flame/lib/src/events/callbacks/long_press_callbacks.dart b/packages/flame/lib/src/events/callbacks/long_press_callbacks.dart index 6e6c729106d..dde89d5b841 100644 --- a/packages/flame/lib/src/events/callbacks/long_press_callbacks.dart +++ b/packages/flame/lib/src/events/callbacks/long_press_callbacks.dart @@ -17,7 +17,7 @@ import 'package:flutter/foundation.dart'; /// - [onLongPressCancel]: called if the gesture is cancelled before completion. /// /// This callback uses [LongPressDispatcher] to route events. -mixin LongPressCallbacks on Component { +mixin LongPressCallbacks on Component implements PointerInputCallbacks { bool _isLongPressing = false; /// Returns true while a long press gesture is active on this component. diff --git a/packages/flame/lib/src/events/callbacks/pointer_input_callbacks.dart b/packages/flame/lib/src/events/callbacks/pointer_input_callbacks.dart new file mode 100644 index 00000000000..f870aeef453 --- /dev/null +++ b/packages/flame/lib/src/events/callbacks/pointer_input_callbacks.dart @@ -0,0 +1,8 @@ +import 'package:flame/src/events/callbacks/input_callbacks.dart'; + +/// Marker interface implemented by every input callbacks mixin whose events +/// carry a position (taps, drags, scrolls, hover) and thus participate in +/// hit-testing. +/// +/// Non-positional input, such as keyboard, implements [InputCallbacks]. +abstract interface class PointerInputCallbacks implements InputCallbacks {} diff --git a/packages/flame/lib/src/events/callbacks/pointer_move_callbacks.dart b/packages/flame/lib/src/events/callbacks/pointer_move_callbacks.dart index 5670baccbb7..bb7b81452e0 100644 --- a/packages/flame/lib/src/events/callbacks/pointer_move_callbacks.dart +++ b/packages/flame/lib/src/events/callbacks/pointer_move_callbacks.dart @@ -6,7 +6,7 @@ import 'package:meta/meta.dart'; /// pointer movement events. /// /// This callback uses [PointerMoveDispatcher] to route events. -mixin PointerMoveCallbacks on Component { +mixin PointerMoveCallbacks on Component implements PointerInputCallbacks { void onPointerMove(PointerMoveEvent event) {} void onPointerMoveStop(PointerMoveEvent event) {} diff --git a/packages/flame/lib/src/events/callbacks/scale_callbacks.dart b/packages/flame/lib/src/events/callbacks/scale_callbacks.dart index 3481232abf0..a819f616ce9 100644 --- a/packages/flame/lib/src/events/callbacks/scale_callbacks.dart +++ b/packages/flame/lib/src/events/callbacks/scale_callbacks.dart @@ -3,7 +3,7 @@ import 'package:flame/events.dart'; import 'package:flutter/foundation.dart'; /// Mixin for components that respond to scale (pinch/zoom/rotate) gestures. -mixin ScaleCallbacks on Component { +mixin ScaleCallbacks on Component implements PointerInputCallbacks { bool _isScaling = false; /// Returns true while the component is being scaled. diff --git a/packages/flame/lib/src/events/callbacks/scroll_callbacks.dart b/packages/flame/lib/src/events/callbacks/scroll_callbacks.dart index 9d1c50a38a8..1a6a6eb6df8 100644 --- a/packages/flame/lib/src/events/callbacks/scroll_callbacks.dart +++ b/packages/flame/lib/src/events/callbacks/scroll_callbacks.dart @@ -6,7 +6,7 @@ import 'package:meta/meta.dart'; /// pointer scroll (mouse wheel) events. /// /// This callback uses [ScrollDispatcher] to route events. -mixin ScrollCallbacks on Component { +mixin ScrollCallbacks on Component implements PointerInputCallbacks { void onScroll(ScrollEvent event) {} @override diff --git a/packages/flame/lib/src/events/callbacks/secondary_tap_callbacks.dart b/packages/flame/lib/src/events/callbacks/secondary_tap_callbacks.dart index 5cbeaea0ad7..01736388b85 100644 --- a/packages/flame/lib/src/events/callbacks/secondary_tap_callbacks.dart +++ b/packages/flame/lib/src/events/callbacks/secondary_tap_callbacks.dart @@ -13,7 +13,7 @@ import 'package:meta/meta.dart'; /// [containsLocalPoint]; so this can be used at the game level. /// /// This callback uses [NonPrimaryTapDispatcher] to route events. -mixin SecondaryTapCallbacks on Component { +mixin SecondaryTapCallbacks on Component implements PointerInputCallbacks { void onSecondaryTapDown(SecondaryTapDownEvent event) {} void onSecondaryTapUp(SecondaryTapUpEvent event) {} void onSecondaryTapCancel(SecondaryTapCancelEvent event) {} diff --git a/packages/flame/lib/src/events/callbacks/tap_callbacks.dart b/packages/flame/lib/src/events/callbacks/tap_callbacks.dart index 4c898ed3658..5733ec2a9eb 100644 --- a/packages/flame/lib/src/events/callbacks/tap_callbacks.dart +++ b/packages/flame/lib/src/events/callbacks/tap_callbacks.dart @@ -12,7 +12,7 @@ import 'package:meta/meta.dart'; /// [containsLocalPoint]; so this can be used at the game level. /// /// This callback uses [MultiTapDispatcher] to route events. -mixin TapCallbacks on Component { +mixin TapCallbacks on Component implements PointerInputCallbacks { void onTapDown(TapDownEvent event) {} void onLongTapDown(TapDownEvent event) {} void onTapUp(TapUpEvent event) {} diff --git a/packages/flame/lib/src/events/callbacks/tertiary_tap_callbacks.dart b/packages/flame/lib/src/events/callbacks/tertiary_tap_callbacks.dart index 1d7561b723e..3fcae3521bf 100644 --- a/packages/flame/lib/src/events/callbacks/tertiary_tap_callbacks.dart +++ b/packages/flame/lib/src/events/callbacks/tertiary_tap_callbacks.dart @@ -13,7 +13,7 @@ import 'package:meta/meta.dart'; /// [containsLocalPoint]; so this can be used at the game level. /// /// This callback uses [NonPrimaryTapDispatcher] to route events. -mixin TertiaryTapCallbacks on Component { +mixin TertiaryTapCallbacks on Component implements PointerInputCallbacks { void onTertiaryTapDown(TertiaryTapDownEvent event) {} void onTertiaryTapUp(TertiaryTapUpEvent event) {} void onTertiaryTapCancel(TertiaryTapCancelEvent event) {} diff --git a/packages/flame/lib/src/game/flame_game.dart b/packages/flame/lib/src/game/flame_game.dart index c0e035e0d0d..dad96c311fd 100644 --- a/packages/flame/lib/src/game/flame_game.dart +++ b/packages/flame/lib/src/game/flame_game.dart @@ -254,11 +254,7 @@ class FlameGame extends ComponentTreeRoot return true; } for (final component in super.componentsAtPoint(position)) { - if (component is TapCallbacks || - component is DragCallbacks || - component is DoubleTapCallbacks || - component is ScaleCallbacks || - component is SecondaryTapCallbacks) { + if (component is PointerInputCallbacks) { return true; } } diff --git a/packages/flame/test/game/contains_event_handler_at_test.dart b/packages/flame/test/game/contains_event_handler_at_test.dart new file mode 100644 index 00000000000..71a5f17c787 --- /dev/null +++ b/packages/flame/test/game/contains_event_handler_at_test.dart @@ -0,0 +1,126 @@ +import 'package:flame/components.dart'; +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_test/flutter_test.dart'; + +void main() { + group('FlameGame.containsEventHandlerAt', () { + testWithGame( + 'detects any component implementing the marker', + FlameGame.new, + (game) async { + await game.ensureAdd(_CustomInputComponent()); + + expect(game.containsEventHandlerAt(Vector2(30, 30)), isTrue); + expect(game.containsEventHandlerAt(Vector2(400, 300)), isFalse); + }, + ); + + testWithGame( + 'ignores components that handle no input', + FlameGame.new, + (game) async { + await game.ensureAdd(_PlainComponent()); + + expect(game.containsEventHandlerAt(Vector2(30, 30)), isFalse); + }, + ); + + testWithGame( + 'detects a built-in mixin', + FlameGame.new, + (game) async { + await game.ensureAdd(_ScrollComponent()); + + expect(game.containsEventHandlerAt(Vector2(30, 30)), isTrue); + }, + ); + + testWithGame( + 'detects a game that handles input itself', + _ScrollGame.new, + (game) async { + // FlameGame is itself a Component, so componentsAtPoint yields the + // game and any in-bounds point counts as a hit. + expect(game.containsEventHandlerAt(Vector2(400, 300)), isTrue); + expect(game.containsEventHandlerAt(Vector2(900, 700)), isFalse); + }, + ); + }); + + group('GameWidget hit test', () { + testWidgets( + 'long presses reach a LongPressCallbacks component under deferToChild', + (tester) async { + var buttonTapped = false; + final component = _LongPressComponent() + ..size = Vector2(800, 600) + ..position = Vector2.zero(); + final game = _TransparentGame()..add(component); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Stack( + children: [ + Center( + child: ElevatedButton( + onPressed: () => buttonTapped = true, + child: const Text('Tap me'), + ), + ), + Positioned.fill( + child: GameWidget( + game: game, + behavior: HitTestBehavior.deferToChild, + ), + ), + ], + ), + ), + ), + ); + await tester.pump(); + await tester.pump(); + expect(component.isMounted, isTrue); + + await tester.longPressAt(const Offset(400, 300)); + await tester.pump(const Duration(milliseconds: 100)); + + expect(component.longPressCount, equals(1)); + expect(buttonTapped, isFalse); + }, + ); + }); +} + +mixin _CustomInputCallbacks on Component implements PointerInputCallbacks {} + +class _TransparentGame extends FlameGame { + @override + Color backgroundColor() => const Color(0x00000000); +} + +class _ScrollGame extends FlameGame with ScrollCallbacks {} + +class _Box extends PositionComponent { + _Box() : super(position: Vector2.all(10), size: Vector2.all(50)); +} + +class _PlainComponent extends _Box {} + +class _CustomInputComponent extends _Box with _CustomInputCallbacks {} + +class _ScrollComponent extends _Box with ScrollCallbacks {} + +class _LongPressComponent extends _Box with LongPressCallbacks { + int longPressCount = 0; + + @override + void onLongPressStart(LongPressStartEvent event) { + super.onLongPressStart(event); + longPressCount++; + } +}