Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,16 @@ mixin HoverCallbacks on Component implements PointerMoveCallbacks {

void onHoverExit() {}

/// Called when a hover is interrupted because a pointer button was pressed
/// while the component was hovered.
///
/// Flutter does not emit `PointerHoverEvent`s while a button is held, so the
/// hover state ends as soon as the press starts. Override this to react to
/// that transition (e.g. clear a hover-driven highlight). After a cancel,
/// [onHoverEnter] will fire again only when a fresh button-free hover
/// re-enters the area.
void onHoverCancel() {}

void _doHoverEnter() {
_isHovered = true;
onHoverEnter();
Expand All @@ -33,6 +43,16 @@ mixin HoverCallbacks on Component implements PointerMoveCallbacks {
onHoverExit();
}

/// Called by [PointerMoveDispatcher] when a pointer button is pressed while
/// this component is hovered. Not intended to be called by user code.
@internal
void cancelHover() {
if (_isHovered) {
_isHovered = false;
onHoverCancel();
}
}

@override
void onPointerMove(PointerMoveEvent event) {
final position = event.localPosition;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,23 @@ class PointerMoveDispatcher extends Dispatcher<FlameGame> {
onMouseMove(PointerMoveEvent.fromPointerHoverEvent(game, event));
}

/// Cancels the hover on every currently-hovered [HoverCallbacks] tracked by
/// this dispatcher when a pointer button is pressed. Flutter routes such
/// presses through `Listener.onPointerDown` (not `MouseRegion.onHover`), so
/// without this hook hovered components would never learn the hover ended.
/// See issue #2741.
void _handlePointerPress(flutter.PointerDownEvent _) {
final cancelled = <TaggedComponent<PointerMoveCallbacks>>[];
for (final record in _records) {
final component = record.component;
if (component is HoverCallbacks && component.isHovered) {
component.cancelHover();
cancelled.add(record);
}
}
_records.removeAll(cancelled);
}

static void addDispatcher(Component component) {
Dispatcher.addDispatcher(
component,
Expand All @@ -55,11 +72,13 @@ class PointerMoveDispatcher extends Dispatcher<FlameGame> {
@override
void onMount() {
game.mouseDetector = _handlePointerMove;
game.mousePressDetector = _handlePointerPress;
}

@override
void onRemove() {
game.mouseDetector = null;
game.mousePressDetector = null;
Dispatcher.removeDispatcher(game, const MouseMoveDispatcherKey());
}
}
Expand Down
13 changes: 13 additions & 0 deletions packages/flame/lib/src/game/game.dart
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,19 @@ abstract mixin class Game {
refreshWidget();
}

/// Set by the PointerMoveDispatcher to receive mouse press events from the
/// game widget so it can fire `onHoverCancel` on hovered `HoverCallbacks`
/// components when the user presses a button while hovering.
void Function(PointerDownEvent event)? get mousePressDetector =>
_mousePressDetector;
void Function(PointerDownEvent event)? _mousePressDetector;
set mousePressDetector(
void Function(PointerDownEvent event)? newMousePressDetector,
) {
_mousePressDetector = newMousePressDetector;
refreshWidget();
}

/// Set by the ScrollDispatcher to receive pointer scroll events from the
/// game widget.
void Function(PointerScrollEvent event)? get scrollDetector =>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -177,21 +177,21 @@ bool hasMouseDetectors(Game game) {
return game is MouseMovementDetector ||
game is ScrollDetector ||
game.mouseDetector != null ||
game.mousePressDetector != null ||
game.scrollDetector != null;
}

Widget applyMouseDetectors(Game game, Widget child) {
final mouseMoveFn = game is MouseMovementDetector ? game.onMouseMove : null;
final mouseDetector = game.mouseDetector;
final mousePressDetector = game.mousePressDetector;
final scrollDetector = game.scrollDetector;
return Listener(
child: MouseRegion(
child: child,
onHover: (PointerHoverEvent e) {
mouseMoveFn?.call(PointerHoverInfo.fromDetails(game, e));
mouseDetector?.call(e);
},
),
// Forward pointer-down to the dispatcher so it can fire `onHoverCancel`
// on hovered HoverCallbacks components — Flutter stops emitting
// PointerHoverEvents the moment a button is pressed, so without this hook
// the hover state would silently linger. See issue #2741.
onPointerDown: mousePressDetector,
onPointerSignal: (event) {
if (event is PointerScrollEvent) {
if (game is ScrollDetector) {
Expand All @@ -200,5 +200,12 @@ Widget applyMouseDetectors(Game game, Widget child) {
scrollDetector?.call(event);
}
},
child: MouseRegion(
onHover: (PointerHoverEvent e) {
mouseMoveFn?.call(PointerHoverInfo.fromDetails(game, e));
mouseDetector?.call(e);
},
child: child,
),
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ 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() {
Expand Down Expand Up @@ -53,6 +54,48 @@ void main() {
_mouseEvent(game, Vector2.all(20));
component.checkHoverEventCounts(enter: 2, exit: 2);
});

testWidgets(
'fires onHoverCancel when a button is pressed mid-hover '
'(regression #2741)',
(tester) async {
final component = _HoverCallbacksComponent(
position: Vector2.all(10),
size: Vector2.all(10),
);
await tester.pumpWidget(
GameWidget(game: FlameGame(children: [component])),
);
await tester.pump();

final pointer = TestPointer(1, PointerDeviceKind.mouse);

// Hover into the component — no buttons pressed.
await tester.sendEventToBinding(pointer.hover(const Offset(15, 15)));
component.checkHoverEventCounts(enter: 1, exit: 0, cancel: 0);

// Press the primary button while hovering — onHoverCancel must fire
// (not onHoverExit; the press semantically ends the hover).
await tester.sendEventToBinding(pointer.down(const Offset(15, 15)));
component.checkHoverEventCounts(enter: 1, exit: 0, cancel: 1);

// Drag around while pressed — no further hover events should fire,
// because we are no longer hovering anything until release.
await tester.sendEventToBinding(
pointer.move(const Offset(40, 40), buttons: kPrimaryButton),
);
await tester.sendEventToBinding(
pointer.move(const Offset(15, 15), buttons: kPrimaryButton),
);
component.checkHoverEventCounts(enter: 1, exit: 0, cancel: 1);

// Release and re-hover — onHoverEnter must fire again, since the
// hover state is now eligible to resume.
await tester.sendEventToBinding(pointer.up());
await tester.sendEventToBinding(pointer.hover(const Offset(15, 15)));
component.checkHoverEventCounts(enter: 2, exit: 0, cancel: 1);
},
);
});
}

Expand All @@ -75,8 +118,13 @@ void _hasDispatcher(FlameGame game) {
mixin _HoverInspector on HoverCallbacks {
int hoverEnterEvent = 0;
int hoverExitEvent = 0;
int hoverCancelEvent = 0;

void checkHoverEventCounts({required int enter, required int exit}) {
void checkHoverEventCounts({
required int enter,
required int exit,
int? cancel,
}) {
expect(
hoverEnterEvent,
equals(enter),
Expand All @@ -87,6 +135,13 @@ mixin _HoverInspector on HoverCallbacks {
equals(exit),
reason: 'Mismatched hover exit event count',
);
if (cancel != null) {
expect(
hoverCancelEvent,
equals(cancel),
reason: 'Mismatched hover cancel event count',
);
}
}

@override
Expand All @@ -98,6 +153,11 @@ mixin _HoverInspector on HoverCallbacks {
void onHoverExit() {
hoverExitEvent++;
}

@override
void onHoverCancel() {
hoverCancelEvent++;
}
}

class _HoverCallbacksComponent extends PositionComponent
Expand Down
Loading