feat: Add HoverCallbacks (#2706)

This creates HoverCallbacks (and PointerMoveCallbacks) to replicate the Hoverables behaviour in the new camera and event system.
This commit is contained in:
Luan Nico
2023-09-10 11:49:52 -07:00
committed by GitHub
parent 83f5ea45dc
commit d460b846c2
18 changed files with 624 additions and 22 deletions

View File

@ -156,3 +156,4 @@ viewports
vsync
widget's
unawaited
proxied

View File

@ -18,6 +18,7 @@ import 'package:doc_flame_examples/move_to_effect.dart';
import 'package:doc_flame_examples/opacity_by_effect.dart';
import 'package:doc_flame_examples/opacity_effect_with_target.dart';
import 'package:doc_flame_examples/opacity_to_effect.dart';
import 'package:doc_flame_examples/pointer_events.dart';
import 'package:doc_flame_examples/ray_cast.dart';
import 'package:doc_flame_examples/ray_trace.dart';
import 'package:doc_flame_examples/remove_effect.dart';
@ -60,6 +61,7 @@ void main() {
'opacity_by_effect': OpacityByEffectGame.new,
'opacity_effect_with_target': OpacityEffectWithTargetGame.new,
'opacity_to_effect': OpacityToEffectGame.new,
'pointer_events': PointerEventsGame.new,
'ray_cast': RayCastExample.new,
'ray_trace': RayTraceExample.new,
'remove_effect': RemoveEffectGame.new,

View File

@ -0,0 +1,51 @@
import 'dart:math';
import 'package:flame/components.dart';
import 'package:flame/events.dart';
import 'package:flame/game.dart';
import 'package:flutter/rendering.dart';
class PointerEventsGame extends FlameGame with TapCallbacks {
@override
Future<void> onLoad() async {
add(HoverTarget(Vector2(100, 200)));
add(HoverTarget(Vector2(300, 300)));
add(HoverTarget(Vector2(400, 50)));
}
@override
void onTapDown(TapDownEvent event) {
add(HoverTarget(event.localPosition));
}
}
class HoverTarget extends PositionComponent with HoverCallbacks {
static final Random _random = Random();
HoverTarget(Vector2 position)
: super(
position: position,
size: Vector2.all(50),
anchor: Anchor.center,
);
final _paint = Paint()
..color = HSLColor.fromAHSL(1, _random.nextDouble() * 360, 1, 0.8)
.toColor()
.withOpacity(0.5);
@override
void render(Canvas canvas) {
canvas.drawRect(size.toRect(), _paint);
}
@override
void onHoverEnter() {
_paint.color = _paint.color.withOpacity(1);
}
@override
void onHoverExit() {
_paint.color = _paint.color.withOpacity(0.5);
}
}

View File

@ -0,0 +1,84 @@
# Pointer Events
```{note}
This document describes the new events API. The old (legacy) approach,
which is still supported, is described in [](gesture_input.md).
```
**Pointer events** are Flutter's generalized "mouse-movement"-type events (for desktop or web).
If you want to interact with mouse movement events within your component or game, you can use the
`PointerMoveCallbacks` mixin.
For example:
```dart
class MyComponent extends PositionComponent with PointerMoveCallbacks {
MyComponent() : super(size: Vector2(80, 60));
@override
void onPointerMove(PointerMoveEvent event) {
// Do something in response to the mouse move (e.g. update coordinates)
}
}
```
The mixin adds two overridable methods to your component:
- `onPointerMove`: called when the mouse moves within the component
- `onPointerMoveStop`: called once if the component was being hovered and the mouse leaves
By default, each of these methods does nothing, they need to be overridden in order to perform any
function.
In addition, the component must implement the `containsLocalPoint()` method (already implemented in
`PositionComponent`, so most of the time you don't need to do anything here) -- this method allows
Flame to know whether the event occurred within the component or not.
Note that only mouse events happening within your component will be proxied along. However,
`onPointerMoveStop` will be fired once on the first mouse movement that leaves your component, so
you can handle any exit conditions there.
## HoverCallbacks
If you want to specifically know if your component is being hovered or not, or if you want to hook
into hover enter and exist events, you can use a more dedicated mixin called `HoverCallbacks`.
For example:
```dart
class MyComponent extends PositionComponent with HoverCallbacks {
MyComponent() : super(size: Vector2(80, 60));
@override
void update(double dt) {
// use `isHovered` to know if the component is being hovered
}
@override
void onHoverEnter() {
// Do something in response to the mouse entering the component
}
@override
void onHoverExit() {
// Do something in response to the mouse leaving the component
}
}
```
Note that you can still listen to the "raw" onPointerMove methods for additional functionality, just
make sure to call the `super` version to enable the `HoverCallbacks` behavior.
### Demo
Play with the demo below to see the pointer hover events in action.
```{flutter-app}
:sources: ../flame/examples
:page: pointer_events
:show: widget code
```

View File

@ -1,7 +1,7 @@
# Tap Events
```{note}
This document describes the new tap events API. The old (legacy) approach,
This document describes the new events API. The old (legacy) approach,
which is still supported, is described in [](gesture_input.md).
```

View File

@ -1,37 +1,37 @@
// ignore_for_file: deprecated_member_use
import 'package:flame/components.dart';
import 'package:flame/events.dart';
import 'package:flame/extensions.dart';
import 'package:flame/game.dart';
import 'package:flame/input.dart';
import 'package:flutter/material.dart';
class HoverablesExample extends FlameGame with HasHoverables, TapDetector {
class HoverCallbacksExample extends FlameGame with TapCallbacks {
static const String description = '''
This example shows how to use `Hoverable`s.\n\n
This example shows how to use `HoverCallbacks`s.\n\n
Add more squares by clicking and hover them to change their color.
''';
@override
Future<void> onLoad() async {
add(HoverableSquare(Vector2(200, 500)));
add(HoverableSquare(Vector2(700, 300)));
add(HoverSquare(Vector2(200, 500)));
add(HoverSquare(Vector2(700, 300)));
}
@override
void onTapDown(TapDownInfo info) {
add(HoverableSquare(info.eventPosition.game));
void onTapDown(TapDownEvent event) {
add(HoverSquare(event.localPosition));
}
}
class HoverableSquare extends PositionComponent with Hoverable {
class HoverSquare extends PositionComponent with HoverCallbacks {
static final Paint _white = Paint()..color = const Color(0xFFFFFFFF);
static final Paint _grey = Paint()..color = const Color(0xFFA5A5A5);
HoverableSquare(Vector2 position)
: super(position: position, size: Vector2.all(100)) {
anchor = Anchor.center;
}
HoverSquare(Vector2 position)
: super(
position: position,
size: Vector2.all(100),
anchor: Anchor.center,
);
@override
void render(Canvas canvas) {

View File

@ -4,7 +4,7 @@ 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';
import 'package:examples/stories/input/hoverables_example.dart';
import 'package:examples/stories/input/hover_callbacks_example.dart';
import 'package:examples/stories/input/joystick_advanced_example.dart';
import 'package:examples/stories/input/joystick_example.dart';
import 'package:examples/stories/input/keyboard_example.dart';
@ -50,10 +50,10 @@ void addInputStories(Dashbook dashbook) {
info: DoubleTapCallbacksExample.description,
)
..add(
'Hoverables',
(_) => GameWidget(game: HoverablesExample()),
codeLink: baseLink('input/hoverables_example.dart'),
info: HoverablesExample.description,
'HoverCallbacks',
(_) => GameWidget(game: HoverCallbacksExample()),
codeLink: baseLink('input/hover_callbacks_example.dart'),
info: HoverCallbacksExample.description,
)
..add(
'Keyboard',

View File

@ -1,6 +1,9 @@
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/hover_callbacks.dart' show HoverCallbacks;
export 'src/events/component_mixins/pointer_move_callbacks.dart'
show PointerMoveCallbacks;
export 'src/events/component_mixins/tap_callbacks.dart' show TapCallbacks;
export 'src/events/flame_game_mixins/has_draggables_bridge.dart'
show HasDraggablesBridge;
@ -22,6 +25,7 @@ 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;
export 'src/events/messages/drag_update_event.dart' show DragUpdateEvent;
export 'src/events/messages/pointer_move_event.dart' show PointerMoveEvent;
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,61 @@
import 'package:flame/events.dart';
import 'package:flame/src/components/core/component.dart';
import 'package:meta/meta.dart';
/// This mixin can be added to a [Component] allowing it to receive hover
/// events.
///
/// In addition to adding this mixin, the component must also implement the
/// [containsLocalPoint] method -- the component will only be considered
/// "hovered" if the point where the hover event occurred is inside the
/// component.
///
/// This mixin is the replacement of the Hoverable mixin.
mixin HoverCallbacks on Component implements PointerMoveCallbacks {
bool _isHovered = false;
/// Returns true while the component is being dragged.
bool get isHovered => _isHovered;
void onHoverEnter() {}
void onHoverExit() {}
void _doHoverEnter() {
_isHovered = true;
onHoverEnter();
}
void _doHoverExit() {
_isHovered = false;
onHoverExit();
}
@override
void onPointerMove(PointerMoveEvent event) {
final position = event.localPosition;
if (containsLocalPoint(position)) {
if (!_isHovered) {
_doHoverEnter();
}
} else {
if (_isHovered) {
_doHoverExit();
}
}
}
@override
void onPointerMoveStop(PointerMoveEvent event) {
if (_isHovered) {
_doHoverExit();
}
}
@override
@mustCallSuper
void onMount() {
super.onMount();
PointerMoveCallbacks.onMountHandler(this);
}
}

View File

@ -0,0 +1,29 @@
import 'package:flame/events.dart';
import 'package:flame/src/components/core/component.dart';
import 'package:flame/src/events/flame_game_mixins/pointer_move_dispatcher.dart';
import 'package:meta/meta.dart';
/// This mixin can be added to a [Component] allowing it to receive
/// pointer movement events.
mixin PointerMoveCallbacks on Component {
void onPointerMove(PointerMoveEvent event) {}
void onPointerMoveStop(PointerMoveEvent event) {}
@override
@mustCallSuper
void onMount() {
super.onMount();
onMountHandler(this);
}
static void onMountHandler(PointerMoveCallbacks instance) {
final game = instance.findGame()!;
const key = MouseMoveDispatcherKey();
if (game.findByKey(key) == null) {
final dispatcher = PointerMoveDispatcher();
game.registerKey(key, dispatcher);
game.add(dispatcher);
}
}
}

View File

@ -0,0 +1,71 @@
import 'package:flame/events.dart';
import 'package:flame/src/components/core/component.dart';
import 'package:flame/src/components/core/component_key.dart';
import 'package:flame/src/events/tagged_component.dart';
import 'package:flame/src/game/flame_game.dart';
import 'package:flutter/gestures.dart' as flutter;
import 'package:meta/meta.dart';
/// **MouseMoveDispatcher** facilitates dispatching of mouse move events to the
/// [PointerMoveCallbacks] components in the component tree. It will be attached
/// to the [FlameGame] instance automatically whenever any
/// [PointerMoveCallbacks] components are mounted into the component tree.
@internal
class PointerMoveDispatcher extends Component {
/// The record of all components currently being hovered.
final Set<TaggedComponent<PointerMoveCallbacks>> _records = {};
FlameGame get game => parent! as FlameGame;
@mustCallSuper
void onMouseMove(PointerMoveEvent event) {
final updated = <TaggedComponent<PointerMoveCallbacks>>{};
event.deliverAtPoint(
rootComponent: game,
deliverToAll: true,
eventHandler: (PointerMoveCallbacks component) {
final tagged = TaggedComponent(event.pointerId, component);
_records.add(tagged);
updated.add(tagged);
component.onPointerMove(event);
},
);
final toRemove = <TaggedComponent<PointerMoveCallbacks>>{};
for (final record in _records) {
if (record.pointerId == event.pointerId && !updated.contains(record)) {
// one last "exit" event
record.component.onPointerMoveStop(event);
toRemove.add(record);
}
}
_records.removeAll(toRemove);
}
void _handlePointerMove(flutter.PointerHoverEvent event) {
onMouseMove(PointerMoveEvent.fromPointerHoverEvent(game, event));
}
@override
void onMount() {
game.mouseDetector = _handlePointerMove;
}
@override
void onRemove() {
game.mouseDetector = null;
game.unregisterKey(const MouseMoveDispatcherKey());
}
}
class MouseMoveDispatcherKey implements ComponentKey {
const MouseMoveDispatcherKey();
@override
int get hashCode => 'MouseMoveDispatcherKey'.hashCode;
@override
bool operator ==(dynamic other) =>
other is MouseMoveDispatcherKey && other.hashCode == hashCode;
}

View File

@ -0,0 +1,44 @@
import 'package:flame/extensions.dart';
import 'package:flame/game.dart';
import 'package:flame/src/events/messages/position_event.dart';
import 'package:flutter/gestures.dart' as flutter;
class PointerMoveEvent extends PositionEvent {
PointerMoveEvent(
this.pointerId,
super.game,
flutter.PointerHoverEvent rawEvent,
) : timestamp = rawEvent.timeStamp,
delta = rawEvent.delta.toVector2(),
super(
devicePosition: rawEvent.position.toVector2(),
);
final int pointerId;
final Duration timestamp;
final Vector2 delta;
static final _nanPoint = Vector2.all(double.nan);
@override
Vector2 get localPosition {
return renderingTrace.isEmpty ? _nanPoint : renderingTrace.last;
}
@override
String toString() => 'PointerMoveEvent(devicePosition: $devicePosition, '
'canvasPosition: $canvasPosition, '
'delta: $delta, '
'pointerId: $pointerId, timestamp: $timestamp)';
factory PointerMoveEvent.fromPointerHoverEvent(
Game game,
flutter.PointerHoverEvent event,
) {
return PointerMoveEvent(
event.pointer,
game,
event,
);
}
}

View File

@ -8,6 +8,7 @@ import 'package:flame/src/game/game_render_box.dart';
import 'package:flame/src/game/game_widget/gesture_detector_builder.dart';
import 'package:flame/src/game/overlay_manager.dart';
import 'package:flame/src/game/projector.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter/widgets.dart';
import 'package:meta/meta.dart';
@ -35,6 +36,10 @@ abstract mixin class Game {
late final GestureDetectorBuilder gestureDetectors =
GestureDetectorBuilder(refreshWidget)..initializeGestures(this);
/// Set by the PointerMoveDispatcher to receive mouse events from the
/// game widget.
void Function(PointerHoverEvent event)? mouseDetector;
/// This should update the state of the game.
void update(double dt);

View File

@ -174,7 +174,8 @@ bool hasMouseDetectors(Game game) {
return game is MouseMovementDetector ||
game is ScrollDetector ||
// ignore: deprecated_member_use_from_same_package
game is HasHoverables;
game is HasHoverables ||
game.mouseDetector != null;
}
Widget applyMouseDetectors(Game game, Widget child) {
@ -182,10 +183,14 @@ Widget applyMouseDetectors(Game game, Widget child) {
? game.onMouseMove
// ignore: deprecated_member_use_from_same_package
: (game is HasHoverables ? game.onMouseMove : null);
final mouseDetector = game.mouseDetector;
return Listener(
child: MouseRegion(
child: child,
onHover: (e) => mouseMoveFn?.call(PointerHoverInfo.fromDetails(game, e)),
onHover: (PointerHoverEvent e) {
mouseMoveFn?.call(PointerHoverInfo.fromDetails(game, e));
mouseDetector?.call(e);
},
),
onPointerSignal: (event) =>
game is ScrollDetector && event is PointerScrollEvent

View File

@ -0,0 +1,109 @@
import 'package:flame/components.dart';
import 'package:flame/events.dart';
import 'package:flame/game.dart';
import 'package:flame/src/events/flame_game_mixins/pointer_move_dispatcher.dart';
import 'package:flame_test/flame_test.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
group('HoverCallbacks', () {
testWithFlameGame(
'make sure HoverCallbacks components can be added to a FlameGame',
(game) async {
await game.ensureAdd(_HoverCallbacksComponent());
await game.ready();
_hasDispatcher(game);
});
testWithFlameGame('receive hover events', (game) async {
final component = _HoverCallbacksComponent(
position: Vector2.all(10),
size: Vector2.all(10),
);
game.add(component);
await game.ready();
_hasDispatcher(game);
_mouseEvent(game, Vector2.all(12));
component.checkHoverEventCounts(enter: 1, exit: 0);
_mouseEvent(game, Vector2.all(14));
component.checkHoverEventCounts(enter: 1, exit: 0);
_mouseEvent(game, Vector2.all(16));
component.checkHoverEventCounts(enter: 1, exit: 0);
_mouseEvent(game, Vector2.all(18));
component.checkHoverEventCounts(enter: 1, exit: 0);
_mouseEvent(game, Vector2.all(20));
component.checkHoverEventCounts(enter: 1, exit: 1);
_mouseEvent(game, Vector2.all(22));
component.checkHoverEventCounts(enter: 1, exit: 1);
_mouseEvent(game, Vector2.all(18));
component.checkHoverEventCounts(enter: 2, exit: 1);
_mouseEvent(game, Vector2.all(19));
component.checkHoverEventCounts(enter: 2, exit: 1);
_mouseEvent(game, Vector2.all(20));
component.checkHoverEventCounts(enter: 2, exit: 2);
});
});
}
void _mouseEvent(FlameGame game, Vector2 position) {
game.firstChild<PointerMoveDispatcher>()!.onMouseMove(
createMouseMoveEvent(
game: game,
position: position,
),
);
}
void _hasDispatcher(FlameGame game) {
expect(
game.children.whereType<PointerMoveDispatcher>(),
hasLength(1),
);
}
mixin _HoverInspector on HoverCallbacks {
int hoverEnterEvent = 0;
int hoverExitEvent = 0;
void checkHoverEventCounts({required int enter, required int exit}) {
expect(
hoverEnterEvent,
equals(enter),
reason: 'Mismatched hover enter event count',
);
expect(
hoverExitEvent,
equals(exit),
reason: 'Mismatched hover exit event count',
);
}
@override
void onHoverEnter() {
hoverEnterEvent++;
}
@override
void onHoverExit() {
hoverExitEvent++;
}
}
class _HoverCallbacksComponent extends PositionComponent
with HoverCallbacks, _HoverInspector {
_HoverCallbacksComponent({
super.position,
super.size,
});
}

View File

@ -0,0 +1,113 @@
import 'package:flame/components.dart';
import 'package:flame/events.dart';
import 'package:flame/game.dart';
import 'package:flame/src/events/flame_game_mixins/pointer_move_dispatcher.dart';
import 'package:flame_test/flame_test.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
group('PointerMoveCallbacks', () {
testWithFlameGame(
'make sure PointerMoveCallbacks components can be added to a FlameGame',
(game) async {
await game.ensureAdd(_PointerMoveCallbacksComponent());
await game.ready();
_hasDispatcher(game);
});
testWithFlameGame('receive pointer move events on component', (game) async {
final c1 = _PointerMoveCallbacksComponent(
position: Vector2.all(10),
size: Vector2.all(10),
);
game.add(c1);
final c2 = _PointerMoveCallbacksComponent(
position: Vector2.all(15),
size: Vector2.all(10),
);
game.add(c2);
await game.ready();
_hasDispatcher(game);
_mouseEvent(game, Vector2.all(12));
expect(c1.removeSingle(), Vector2.all(2));
expect(c2.receivedEventsAt, isEmpty);
_mouseEvent(game, Vector2.all(1));
expect(c1.receivedEventsAt, isEmpty);
expect(c2.receivedEventsAt, isEmpty);
_mouseEvent(game, Vector2.all(19));
expect(c1.removeSingle(), Vector2.all(9));
expect(c2.removeSingle(), Vector2.all(4));
_mouseEvent(game, Vector2.all(21));
expect(c1.receivedEventsAt, isEmpty);
expect(c2.removeSingle(), Vector2.all(6));
});
testWithGame(
'receive pointer move events on game',
_PointerMoveCallbacksGame.new,
(game) async {
_hasDispatcher(game);
_mouseEvent(game, Vector2.all(12));
expect(game.removeSingle(), Vector2.all(12));
_mouseEvent(game, Vector2.all(1));
expect(game.removeSingle(), Vector2.all(1));
_mouseEvent(game, Vector2.all(19));
expect(game.removeSingle(), Vector2.all(19));
_mouseEvent(game, Vector2.all(21));
expect(game.removeSingle(), Vector2.all(21));
},
);
});
}
void _mouseEvent(FlameGame game, Vector2 position) {
game.firstChild<PointerMoveDispatcher>()!.onMouseMove(
createMouseMoveEvent(
game: game,
position: position,
),
);
}
void _hasDispatcher(FlameGame game) {
expect(
game.children.whereType<PointerMoveDispatcher>(),
hasLength(1),
);
}
mixin _PointerMoveInspector on PointerMoveCallbacks {
List<Vector2> receivedEventsAt = [];
Vector2 removeSingle() {
expect(receivedEventsAt, hasLength(1));
return receivedEventsAt.removeAt(0);
}
@override
void onPointerMove(PointerMoveEvent event) {
receivedEventsAt.add(event.localPosition);
}
}
class _PointerMoveCallbacksComponent extends PositionComponent
with PointerMoveCallbacks, _PointerMoveInspector {
_PointerMoveCallbacksComponent({
super.position,
super.size,
});
}
class _PointerMoveCallbacksGame extends FlameGame
with PointerMoveCallbacks, _PointerMoveInspector {}

View File

@ -6,6 +6,7 @@ export 'src/fails_assert.dart';
export 'src/flame_test.dart';
export 'src/mock_gesture_events.dart';
export 'src/mock_image.dart';
export 'src/mock_pointer_move_event.dart';
export 'src/mock_tap_drag_events.dart';
export 'src/random_test.dart';
export 'src/test_flame_game.dart';

View File

@ -0,0 +1,22 @@
import 'package:flame/events.dart';
import 'package:flame/extensions.dart';
import 'package:flame/game.dart';
import 'package:flutter/gestures.dart' as flutter;
PointerMoveEvent createMouseMoveEvent({
required Game game,
int? pointerId,
Vector2? position,
Vector2? delta,
Duration? timestamp,
}) {
return PointerMoveEvent(
pointerId ?? 1,
game,
flutter.PointerHoverEvent(
timeStamp: timestamp ?? Duration.zero,
position: position?.toOffset() ?? Offset.zero,
delta: delta?.toOffset() ?? Offset.zero,
),
);
}