feat!: Support secondary taps (right click) on new callbacks system (#3741)

Support secondary taps (right click) on new callbacks system.

In order to follow through with our [event system
migration](https://docs.google.com/document/d/1nBUup9QCPioVwWL1zs79z1hWF882tAYfNywFMdye2Qc),
we need to make sure the new system is equipped to support all use
cases; also changes the existing TapCallbacks to be primary-only.

I noticed that we don't support "secondary taps" (i.e. right clicks), so
I am adding this. I honestly really dislike the fact that this is
considered a completely different event from the left click, instead of
just a property on the click event. But I kept this structure to
replicate what Flutter does, so this is more familiar for users. I think
that is worth the slight verbosity of having yet another detector. Also,
it plays well this way with Flutter because that underlying events are a
bit different (for example, the secondary ones don't support `pointId`).

Note: this is a slight breaking change because the existing detector
works for BOTH left and right click, but there is NO WAY of
distinguishing them because the `buttons` property is not propagated in
the Flutter end (massive oversight I believe - might put a PR later).
Since this provides the secondary as a solution, it also removes
secondary clicks from triggering the primary. I think this is more
versatile than having tap detector=`(primary OR secondary)` and
secondary=`(secondary only)`.

I don't think this should affect basically any users because (1) desktop
only and (2) this acceptance of right clicks was probably a bug anyway
(for example, on the example it would rotate the square and also open
the context menu, which is jarring).

However I am happy to add an option or pursue a different approach, I
believe this is the best path forward, IMO.
This commit is contained in:
Luan Nico
2025-10-06 08:11:31 -07:00
committed by GitHub
parent 8534a55740
commit 46bd385675
12 changed files with 593 additions and 1 deletions

View File

@ -170,6 +170,34 @@ class MyComponent extends Component with TapCallbacks {
```
### SecondaryTapCallbacks
In addition to the primary tap events (i.e. left mouse button on desktop), Flame also supports
secondary tap events (i.e. right mouse button on desktop). To receive these events, add the
`SecondaryTapCallbacks` mixin to your `PositionComponent`.
```dart
class MyComponent extends PositionComponent with SecondaryTapCallbacks {
@override
void onSecondaryTapUp(SecondaryTapUpEvent event) {
/// Do something
}
@override
void onSecondaryTapCancel(SecondaryTapCancelEvent event) {
/// Do something
}
@override
void onSecondaryTapDown(SecondaryTapDownEvent event) {
/// Do something
}
```
You can extend both `TapCallbacks` and `SecondaryTapCallbacks` in the same component to
receive both primary and secondary tap events.
### DoubleTapCallbacks
Flame also offers a mixin named `DoubleTapCallbacks` to receive a double-tap event from the

View File

@ -16,6 +16,7 @@ import 'package:examples/stories/input/multitap_advanced_example.dart';
import 'package:examples/stories/input/multitap_example.dart';
import 'package:examples/stories/input/overlapping_tap_callbacks_example.dart';
import 'package:examples/stories/input/scroll_example.dart';
import 'package:examples/stories/input/secondary_tap_callbacks_example.dart';
import 'package:examples/stories/input/tap_callbacks_example.dart';
import 'package:flame/game.dart';
import 'package:flutter/material.dart';
@ -28,6 +29,12 @@ void addInputStories(Dashbook dashbook) {
codeLink: baseLink('input/tap_callbacks_example.dart'),
info: TapCallbacksExample.description,
)
..add(
'SecondaryTapCallbacks',
(_) => GameWidget(game: SecondaryTapCallbacksExample()),
codeLink: baseLink('input/secondary_tap_callbacks_example.dart'),
info: SecondaryTapCallbacksExample.description,
)
..add(
'DragCallbacks',
(context) {

View File

@ -0,0 +1,63 @@
import 'package:flame/components.dart';
import 'package:flame/events.dart';
import 'package:flame/extensions.dart';
import 'package:flame/game.dart';
import 'package:flame/palette.dart';
import 'package:flutter/material.dart';
class SecondaryTapCallbacksExample extends FlameGame {
static const String description = '''
In this example we show how to listen to both primary (left) and
secondary (right) tap events using the `TapCallbacks` and
the `SecondaryTapCallbacks` mixin to any `PositionComponent`.\n\n
The squares will change color depending on which button was used to tap them.
''';
@override
Future<void> onLoad() async {
world.add(TappableSquare()..anchor = Anchor.center);
world.add(TappableSquare()..y = 350);
}
}
class TappableSquare extends PositionComponent
with TapCallbacks, SecondaryTapCallbacks {
static final Paint _red = BasicPalette.red.paint();
static final Paint _blue = BasicPalette.blue.paint();
static final TextPaint _text = TextPaint(
style: TextStyle(color: BasicPalette.white.color, fontSize: 24),
);
int counter = 0;
Paint _paint = _red;
TappableSquare({Vector2? position})
: super(
position: position ?? Vector2.all(100),
size: Vector2.all(100),
anchor: Anchor.center,
);
@override
void render(Canvas canvas) {
canvas.drawRect(size.toRect(), _paint);
_text.render(
canvas,
'$counter',
size / 2,
anchor: Anchor.center,
);
}
@override
void onTapDown(_) {
_paint = _red;
counter++;
}
@override
void onSecondaryTapDown(_) {
_paint = _blue;
counter++;
}
}

View File

@ -4,6 +4,8 @@ export 'src/events/component_mixins/drag_callbacks.dart' show DragCallbacks;
export 'src/events/component_mixins/hover_callbacks.dart' show HoverCallbacks;
export 'src/events/component_mixins/pointer_move_callbacks.dart'
show PointerMoveCallbacks;
export 'src/events/component_mixins/secondary_tap_callbacks.dart'
show SecondaryTapCallbacks;
export 'src/events/component_mixins/tap_callbacks.dart' show TapCallbacks;
export 'src/events/flame_game_mixins/double_tap_dispatcher.dart'
show DoubleTapDispatcher, DoubleTapDispatcherKey;
@ -13,6 +15,8 @@ export 'src/events/flame_game_mixins/multi_tap_dispatcher.dart'
show MultiTapDispatcher, MultiTapDispatcherKey;
export 'src/events/flame_game_mixins/pointer_move_dispatcher.dart'
show PointerMoveDispatcher, MouseMoveDispatcherKey;
export 'src/events/flame_game_mixins/secondary_tap_dispatcher.dart'
show SecondaryTapDispatcher, SecondaryTapDispatcherKey;
export 'src/events/game_mixins/multi_touch_drag_detector.dart'
show MultiTouchDragDetector;
export 'src/events/game_mixins/multi_touch_tap_detector.dart'
@ -30,6 +34,12 @@ export 'src/events/messages/drag_end_event.dart' show DragEndEvent;
export 'src/events/messages/drag_start_event.dart' show DragStartEvent;
export 'src/events/messages/drag_update_event.dart' show DragUpdateEvent;
export 'src/events/messages/pointer_move_event.dart' show PointerMoveEvent;
export 'src/events/messages/secondary_tap_cancel_event.dart'
show SecondaryTapCancelEvent;
export 'src/events/messages/secondary_tap_down_event.dart'
show SecondaryTapDownEvent;
export 'src/events/messages/secondary_tap_up_event.dart'
show SecondaryTapUpEvent;
export 'src/events/messages/tap_cancel_event.dart' show TapCancelEvent;
export 'src/events/messages/tap_down_event.dart' show TapDownEvent;
export 'src/events/messages/tap_up_event.dart' show TapUpEvent;

View File

@ -0,0 +1,30 @@
import 'package:flame/components.dart';
import 'package:flame/events.dart';
import 'package:meta/meta.dart';
/// This mixin can be added to a [Component] allowing it to receive secondary
/// tap events (i.e. right mouse clicks).
///
/// In addition to adding this mixin, the component must also implement the
/// [containsLocalPoint] method -- the component will only be considered
/// "tapped" if the point where the tap has occurred is inside the component.
///
/// Note that FlameGame _is_ a [Component] and does implement
/// [containsLocalPoint]; so this can be used at the game level.
mixin SecondaryTapCallbacks on Component {
void onSecondaryTapDown(SecondaryTapDownEvent event) {}
void onSecondaryTapUp(SecondaryTapUpEvent event) {}
void onSecondaryTapCancel(SecondaryTapCancelEvent event) {}
@override
@mustCallSuper
void onMount() {
super.onMount();
final game = findRootGame()!;
if (game.findByKey(const SecondaryTapDispatcherKey()) == null) {
final dispatcher = SecondaryTapDispatcher();
game.registerKey(const SecondaryTapDispatcherKey(), dispatcher);
game.add(dispatcher);
}
}
}

View File

@ -154,7 +154,9 @@ class MultiTapDispatcher extends Component implements MultiTapListener {
@override
void onMount() {
game.gestureDetectors.add<MultiTapGestureRecognizer>(
MultiTapGestureRecognizer.new,
() => MultiTapGestureRecognizer(
allowedButtonsFilter: (buttons) => buttons == kPrimaryButton,
),
(MultiTapGestureRecognizer instance) {
instance.longTapDelay = Duration(
milliseconds: (longTapDelay * 1000).toInt(),

View File

@ -0,0 +1,69 @@
import 'package:flame/components.dart';
import 'package:flame/events.dart';
import 'package:flame/game.dart';
import 'package:flutter/gestures.dart';
class SecondaryTapDispatcherKey implements ComponentKey {
const SecondaryTapDispatcherKey();
@override
int get hashCode => 'SecondaryTapDispatcherKey'.hashCode;
@override
bool operator ==(Object other) =>
other is SecondaryTapDispatcherKey && other.hashCode == hashCode;
}
/// [SecondaryTapDispatcher] propagates secondary-tap events (i.e. right mouse
/// clicks) to every components in the component tree that is mixed with
/// [SecondaryTapCallbacks]. This will be attached to the [FlameGame] instance
/// automatically whenever any [SecondaryTapCallbacks] are mounted into the
/// component tree.
class SecondaryTapDispatcher extends Component
with HasGameReference<FlameGame> {
final _components = <SecondaryTapCallbacks>{};
void _onSecondaryTapDown(SecondaryTapDownEvent event) {
event.deliverAtPoint(
rootComponent: game,
eventHandler: (SecondaryTapCallbacks component) {
_components.add(component..onSecondaryTapDown(event));
},
);
}
void _onSecondaryTapUp(SecondaryTapUpEvent event) {
for (final component in _components) {
component.onSecondaryTapUp(event);
}
_components.clear();
}
void _onSecondaryTapCancel(SecondaryTapCancelEvent event) {
for (final component in _components) {
component.onSecondaryTapCancel(event);
}
_components.clear();
}
@override
void onMount() {
game.gestureDetectors.add(
TapGestureRecognizer.new,
(TapGestureRecognizer instance) {
instance.onSecondaryTapDown = (details) =>
_onSecondaryTapDown(SecondaryTapDownEvent(game, details));
instance.onSecondaryTapCancel = () =>
_onSecondaryTapCancel(SecondaryTapCancelEvent());
instance.onSecondaryTapUp = (details) =>
_onSecondaryTapUp(SecondaryTapUpEvent(game, details));
},
);
}
@override
void onRemove() {
game.gestureDetectors.remove<TapGestureRecognizer>();
game.unregisterKey(const SecondaryTapDispatcherKey());
}
}

View File

@ -0,0 +1,22 @@
import 'package:flame/src/events/messages/event.dart';
import 'package:flame/src/events/messages/secondary_tap_down_event.dart';
/// The event propagated through the Flame engine when a secondary tap
/// (i.e. right mouse button click) on a component is cancelled.
///
/// This event may occur for several reasons, such as:
/// - a secondary tap was converted into a drag event (for a game where
/// secondary drag events are enabled);
/// - a secondary tap was cancelled on the game widget itself -- for example,
/// if another app came into the foreground, or device turned off, etc;
/// - a secondary tap was cancelled on a particular component because that
/// component has moved away from the point of contact.
///
/// The [SecondaryTapCancelEvent] will only occur if there was a previous
/// [SecondaryTapDownEvent].
class SecondaryTapCancelEvent extends Event<void> {
SecondaryTapCancelEvent() : super(raw: null);
@override
String toString() => 'SecondaryTapCancel()';
}

View File

@ -0,0 +1,28 @@
import 'package:flame/events.dart';
import 'package:flame/extensions.dart';
import 'package:flame/src/events/messages/position_event.dart';
import 'package:flutter/gestures.dart';
/// The event propagated through the Flame engine when the user starts a
/// secondary touch (i.e. right mouse click) on the game canvas.
///
/// This is a [PositionEvent], where the position is the point of touch.
///
/// In order for a component to be eligible to receive this event, it must add
/// the [SecondaryTapCallbacks] mixin.
class SecondaryTapDownEvent extends PositionEvent<TapDownDetails> {
SecondaryTapDownEvent(super.game, TapDownDetails details)
: deviceKind = details.kind ?? PointerDeviceKind.unknown,
super(
raw: details,
devicePosition: details.globalPosition.toVector2(),
);
final PointerDeviceKind deviceKind;
@override
String toString() =>
'TapDownEvent(canvasPosition: $canvasPosition, '
'devicePosition: $devicePosition, '
'deviceKind: $deviceKind)';
}

View File

@ -0,0 +1,29 @@
import 'package:flame/extensions.dart';
import 'package:flame/src/events/messages/position_event.dart';
import 'package:flame/src/events/messages/secondary_tap_down_event.dart';
import 'package:flutter/gestures.dart';
/// The event propagated through the Flame engine when the user stops secondary
/// touching (i.e. right mouse button) the game canvas.
///
/// This is a [PositionEvent], where the position is the point where the touch
/// has last occurred.
///
/// The [SecondaryTapUpEvent] will only occur if there was a previous
/// [SecondaryTapDownEvent].
class SecondaryTapUpEvent extends PositionEvent<TapUpDetails> {
SecondaryTapUpEvent(super.game, TapUpDetails details)
: deviceKind = details.kind,
super(
raw: details,
devicePosition: details.globalPosition.toVector2(),
);
final PointerDeviceKind deviceKind;
@override
String toString() =>
'SecondaryTapUpEvent(canvasPosition: $canvasPosition, '
'devicePosition: $devicePosition, '
'deviceKind: $deviceKind)';
}

View File

@ -0,0 +1,272 @@
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/gestures.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
group('SecondaryTapCallbacks', () {
testWidgets(
'receives secondary tap event',
(tester) async {
final component = _SecondaryTapCallbacksComponent()
..position = Vector2.all(10);
await tester.pumpWidget(
GameWidget(
game: FlameGame(children: [component]),
),
);
await tester.pump();
final gesture = await tester.createGesture(
buttons: kSecondaryButton,
);
await gesture.down(const Offset(10, 10));
expect(component.secondaryTapDown, 1);
expect(component.secondaryTapCancel, 0);
expect(component.secondaryTap, 0);
await gesture.up();
expect(component.secondaryTapDown, 1);
expect(component.secondaryTapCancel, 0);
expect(component.secondaryTap, 1);
await tester.pump(kDoubleTapMinTime);
},
);
testWidgets(
'primary and secondary are received separately',
(tester) async {
final component = _BothTapCallbacksComponent()
..position = Vector2.all(10);
await tester.pumpWidget(
GameWidget(
game: FlameGame(children: [component]),
),
);
await tester.pump();
final primaryGesture = await tester.createGesture();
final secondaryGesture = await tester.createGesture(
buttons: kSecondaryButton,
);
await primaryGesture.down(const Offset(10, 10));
expect(component.primaryTapDown, 1);
expect(component.secondaryTapDown, 0);
await primaryGesture.up();
expect(component.primaryTapUp, 1);
expect(component.secondaryTapUp, 0);
await secondaryGesture.down(const Offset(10, 10));
expect(component.primaryTapDown, 1);
expect(component.secondaryTapDown, 1);
await secondaryGesture.up();
expect(component.primaryTapUp, 1);
expect(component.secondaryTapUp, 1);
await primaryGesture.down(const Offset(10, 10));
expect(component.primaryTapDown, 2);
expect(component.secondaryTapDown, 1);
await secondaryGesture.down(const Offset(10, 10));
expect(component.primaryTapDown, 2);
expect(component.secondaryTapDown, 2);
await primaryGesture.up();
expect(component.primaryTapUp, 2);
expect(component.secondaryTapUp, 1);
await secondaryGesture.up();
expect(component.primaryTapUp, 2);
expect(component.secondaryTapUp, 2);
await tester.pump(kDoubleTapMinTime);
},
);
testWidgets(
'''does not receive an event when secondary-tapping a position far from the component''',
(tester) async {
final component = _SecondaryTapCallbacksComponent()
..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));
expect(component.secondaryTapDown, 0);
expect(component.secondaryTapCancel, 0);
expect(component.secondaryTap, 0);
await gesture.up();
expect(component.secondaryTapDown, 0);
expect(component.secondaryTapCancel, 0);
expect(component.secondaryTap, 0);
await tester.pump(kDoubleTapMinTime);
},
);
testWidgets(
'receives a cancel event when gesture is canceled by drag',
(tester) async {
final component = _SecondaryTapCallbacksComponent()
..position = Vector2.all(10);
await tester.pumpWidget(
GameWidget(
game: FlameGame(children: [component]),
),
);
await tester.pump();
await tester.pump();
final gesture = await tester.createGesture(
buttons: kSecondaryButton,
);
await gesture.down(const Offset(10, 10));
expect(component.secondaryTapDown, 1);
expect(component.secondaryTapCancel, 0);
expect(component.secondaryTap, 0);
await gesture.moveBy(const Offset(100, 100));
expect(component.secondaryTapDown, 1);
expect(component.secondaryTapCancel, 1);
expect(component.secondaryTap, 0);
await tester.pump(kDoubleTapMinTime);
},
);
testWidgets(
'receives a cancel event when gesture is canceled by cancel',
(tester) async {
final component = _SecondaryTapCallbacksComponent()
..position = Vector2.all(10);
await tester.pumpWidget(
GameWidget(
game: FlameGame(children: [component]),
),
);
await tester.pump();
await tester.pump();
final gesture = await tester.createGesture(
buttons: kSecondaryButton,
);
await gesture.down(const Offset(10, 10));
expect(component.secondaryTapDown, 1);
expect(component.secondaryTapCancel, 0);
expect(component.secondaryTap, 0);
await gesture.cancel();
expect(component.secondaryTapDown, 1);
expect(component.secondaryTapCancel, 1);
expect(component.secondaryTap, 0);
await tester.pump(kDoubleTapMinTime);
},
);
testWithFlameGame(
'SecondaryTapDispatcher is added to game when the callback is mounted',
(game) async {
final component = _SecondaryTapCallbacksComponent();
await game.add(component);
await game.ready();
expect(game.firstChild<SecondaryTapDispatcher>(), isNotNull);
},
);
});
}
class _SecondaryTapCallbacksComponent extends PositionComponent
with SecondaryTapCallbacks {
_SecondaryTapCallbacksComponent() {
anchor = Anchor.center;
size = Vector2.all(10);
}
int secondaryTapDown = 0;
int secondaryTapCancel = 0;
int secondaryTap = 0;
@override
void onSecondaryTapUp(SecondaryTapUpEvent event) {
secondaryTap++;
}
@override
void onSecondaryTapCancel(SecondaryTapCancelEvent event) {
secondaryTapCancel++;
}
@override
void onSecondaryTapDown(SecondaryTapDownEvent event) {
secondaryTapDown++;
}
}
class _BothTapCallbacksComponent extends PositionComponent
with TapCallbacks, SecondaryTapCallbacks {
_BothTapCallbacksComponent() {
anchor = Anchor.center;
size = Vector2.all(10);
}
int primaryTapDown = 0;
int primaryTapUp = 0;
int primaryTapCancel = 0;
int secondaryTapDown = 0;
int secondaryTapUp = 0;
int secondaryTapCancel = 0;
@override
void onTapUp(TapUpEvent event) {
primaryTapUp++;
}
@override
void onTapCancel(TapCancelEvent event) {
primaryTapCancel++;
}
@override
void onTapDown(TapDownEvent event) {
primaryTapDown++;
}
@override
void onSecondaryTapUp(SecondaryTapUpEvent event) {
secondaryTapUp++;
}
@override
void onSecondaryTapCancel(SecondaryTapCancelEvent event) {
secondaryTapCancel++;
}
@override
void onSecondaryTapDown(SecondaryTapDownEvent event) {
secondaryTapDown++;
}
}

View File

@ -38,6 +38,38 @@ TapUpEvent createTapUpEvents({
);
}
SecondaryTapDownEvent createSecondaryTapDownEvents({
required Game game,
PointerDeviceKind? kind,
Offset? globalPosition,
Offset? localPosition,
}) {
return SecondaryTapDownEvent(
game,
TapDownDetails(
localPosition: localPosition ?? Offset.zero,
globalPosition: globalPosition ?? Offset.zero,
kind: kind ?? PointerDeviceKind.touch,
),
);
}
SecondaryTapUpEvent createSecondaryTapUpEvents({
required Game game,
PointerDeviceKind? kind,
Offset? globalPosition,
Offset? localPosition,
}) {
return SecondaryTapUpEvent(
game,
TapUpDetails(
localPosition: localPosition ?? Offset.zero,
globalPosition: globalPosition ?? Offset.zero,
kind: kind ?? PointerDeviceKind.touch,
),
);
}
DragStartEvent createDragStartEvents({
required Game game,
int? pointerId,