diff --git a/doc/flame/inputs/drag_events.md b/doc/flame/inputs/drag_events.md index d910ec24f34..e0887366ac3 100644 --- a/doc/flame/inputs/drag_events.md +++ b/doc/flame/inputs/drag_events.md @@ -113,26 +113,41 @@ method must be implemented manually. If your component is a part of a larger hierarchy, then it will only receive drag events if its ancestors have all implemented the `containsLocalPoint` correctly. -```dart -class MyComponent extends PositionComponent with DragCallbacks { - MyComponent({super.size}); - final _paint = Paint(); - bool _isDragged = false; +### isDragged - @override - void onDragStart(DragStartEvent event) => _isDragged = true; +The `DragCallbacks` mixin provides an `isDragged` getter that returns `true` while the component is +actively being dragged. This is set to `true` at `onDragStart` and back to `false` at `onDragEnd`. +It can be used, for example, to change the component's visual appearance during a drag. + + +## Combining with ScaleCallbacks + +A component can use both `DragCallbacks` and `ScaleCallbacks` at the same time. When both mixins are +present, single-finger gestures produce drag events and two-finger gestures produce both drag and +scale events. This is useful for components that should be draggable with one finger and +pinch-to-zoom or rotatable with two fingers. + +```dart +class InteractiveRectangle extends RectangleComponent + with ScaleCallbacks, DragCallbacks { + + double _initialAngle = 0; @override - void onDragUpdate(DragUpdateEvent event) => position += event.delta; + void onDragUpdate(DragUpdateEvent event) { + position += event.localDelta; + } @override - void onDragEnd(DragEndEvent event) => _isDragged = false; + void onScaleStart(ScaleStartEvent event) { + super.onScaleStart(event); + _initialAngle = angle; + } @override - void render(Canvas canvas) { - _paint.color = _isDragged? Colors.red : Colors.white; - canvas.drawRect(size.toRect(), _paint); + void onScaleUpdate(ScaleUpdateEvent event) { + angle = _initialAngle + event.rotation; } } ``` diff --git a/doc/flame/inputs/inputs.md b/doc/flame/inputs/inputs.md index 17be1165d94..d3acefbee83 100644 --- a/doc/flame/inputs/inputs.md +++ b/doc/flame/inputs/inputs.md @@ -10,6 +10,7 @@ works, but adapted for Flame's component tree. - [Tap Events](tap_events.md) - [Drag Events](drag_events.md) +- [Scale Events](scale_events.md) - [Long Press Events](long_press_events.md) - [Gesture Input](gesture_input.md) - [Keyboard Input](keyboard_input.md) @@ -22,6 +23,7 @@ works, but adapted for Flame's component tree. Tap Events Drag Events +Scale Events Long Press Events Gesture Input Keyboard Input diff --git a/doc/flame/inputs/scale_events.md b/doc/flame/inputs/scale_events.md index df99a42ae27..aa841bcf1cc 100644 --- a/doc/flame/inputs/scale_events.md +++ b/doc/flame/inputs/scale_events.md @@ -100,85 +100,67 @@ method must be implemented manually. If your component is a part of a larger hierarchy, then it will only receive scale events if its ancestors have all implemented the `containsLocalPoint` correctly. -```dart -class ScaleOnlyRectangle extends RectangleComponent with ScaleCallbacks { - ScaleOnlyRectangle({ - required Vector2 position, - required Vector2 size, - Color color = Colors.blue, - Anchor anchor = Anchor.center, - }) : super( - position: position, - size: size, - anchor: anchor, - paint: Paint()..color = color, - ); +### isScaling + +The `ScaleCallbacks` mixin provides an `isScaling` getter that returns `true` while the component is +actively being scaled. This is set to `true` at the start of `onScaleStart` and back to `false` at +`onScaleEnd`. It can be used, for example, to change the component's visual appearance during a scale +gesture. + + +### scaleThreshold + +Scale events are not fired immediately when two fingers touch the screen. Instead, a small movement +threshold must be crossed first. By default, the fingers must spread or pinch by at least 5% (a +scale factor of 1.05) before `onScaleStart` is called. This prevents accidental scale gestures when +the user simply places two fingers without intending to scale. + +The threshold can be changed by accessing the `MultiDragScaleDispatcher` from your game and setting +`scaleThreshold` before any `ScaleCallbacks` component mounts: + +```dart +class MyGame extends FlameGame { @override Future onLoad() async { - final text = TextComponent( - text: 'scale', - textRenderer: TextPaint( - style: const TextStyle(fontSize: 25, color: Colors.white), - ), - position: size / 2, - anchor: Anchor.center, - ); - add(text); + final dispatcher = MultiDragScaleDispatcher()..scaleThreshold = 1.02; + registerKey(const MultiDragScaleDispatcherKey(), dispatcher); + add(dispatcher); } +} +``` + +A lower value makes the recognizer more sensitive (reacts to smaller pinch movements), while a +higher value requires a more deliberate gesture before scale events fire. + + +## Combining with DragCallbacks - bool isScaling = false; - double initialAngle = 0; - Vector2 initialScale = Vector2.all(1); - double lastScale = 1.0; +A component can use both `ScaleCallbacks` and `DragCallbacks` at the same time. When both mixins are +present, single-finger gestures produce drag events and two-finger gestures produce both scale and +drag events. This is useful for components that should be draggable with one finger and +pinch-to-zoom or rotatable with two fingers. + +```dart +class InteractiveRectangle extends RectangleComponent + with ScaleCallbacks, DragCallbacks { + + double _initialAngle = 0; - /// ScaleCallbacks overrides @override - void onScaleStart(ScaleStartEvent event) { - super.onScaleStart(event); - isScaling = true; - initialAngle = angle; - initialScale = scale; - lastScale = 1.0; - debugPrint('Scale started at ${event.devicePosition}'); + void onDragUpdate(DragUpdateEvent event) { + position += event.localDelta; } @override - void onScaleUpdate(ScaleUpdateEvent event) { - super.onScaleUpdate(event); - // scale rectangle size by pinch - angle = initialAngle + event.rotation; - // delta scale since last frame - if (lastScale == 0) { - return; - } - final scaleDelta = event.scale / lastScale; - lastScale = event.scale; // update for next frame - - // apply delta gently - scale *= sqrt(scaleDelta); - - // clamp - scale.clamp(Vector2.all(0.8), Vector2.all(3)); + void onScaleStart(ScaleStartEvent event) { + super.onScaleStart(event); + _initialAngle = angle; } @override - void onScaleEnd(ScaleEndEvent event) { - super.onScaleEnd(event); - isScaling = false; - debugPrint('Scale ended with velocity ${event.velocity}'); + void onScaleUpdate(ScaleUpdateEvent event) { + angle = _initialAngle + event.rotation; } } - ``` - - -## Scale and drag gestures interactions - -A multi drag gesture can sometimes look exactly like a scale gesture. -This is the case for instance, if you try to move two components toward each other at the same time. -If you added both a component using ScaleCallbacks and -one using DragCallbacks (or one using both), this issue will arise. -The Scale gesture will win over the drag gesture -and prevent your user to perform the multi drag gesture as they wanted. This is a limitation -with the current implementation that devs need to be aware of. diff --git a/examples/lib/stories/input/dynamic_scale_drag_example.dart b/examples/lib/stories/input/dynamic_scale_drag_example.dart new file mode 100644 index 00000000000..35e56bc35c7 --- /dev/null +++ b/examples/lib/stories/input/dynamic_scale_drag_example.dart @@ -0,0 +1,313 @@ +import 'dart:math'; + +import 'package:flame/components.dart'; +import 'package:flame/events.dart'; +import 'package:flame/game.dart'; +import 'package:flutter/material.dart'; + +class DynamicScaleDragExample extends FlameGame { + static const String description = ''' + Demonstrates dynamically adding draggable and scalable components at + runtime. Use the buttons to spawn components with different interaction + types: drag-only (green), scale-only (blue), or both (red). + + Drag components by touching and moving. Scale components by pinching + with two fingers. Components with both mixins support either gesture. + The system seamlessly handles mixing different interaction types. + '''; + + @override + Future onLoad() async { + camera.viewport.add( + _Button( + text: '+ Drag', + position: Vector2(10, 40), + color: Colors.green, + onPressed: _addDragComponent, + ), + ); + camera.viewport.add( + _Button( + text: '+ Scale', + position: Vector2(120, 40), + color: Colors.blue, + onPressed: _addScaleComponent, + ), + ); + camera.viewport.add( + _Button( + text: '+ Both', + position: Vector2(230, 40), + color: Colors.red, + onPressed: _addBothComponent, + ), + ); + camera.viewport.add( + _Button( + text: 'Clear', + position: Vector2(340, 40), + color: Colors.grey, + onPressed: _clearComponents, + ), + ); + } + + int _nextId = 1; + + void _addDragComponent() { + final pos = _randomWorldPosition(); + world.add( + _DragBox( + label: 'Drag ${_nextId++}', + position: pos, + color: Colors.green, + ), + ); + } + + void _addScaleComponent() { + final pos = _randomWorldPosition(); + world.add( + _ScaleBox( + label: 'Scale ${_nextId++}', + position: pos, + color: Colors.blue, + ), + ); + } + + void _addBothComponent() { + final pos = _randomWorldPosition(); + world.add( + _DragScaleBox( + label: 'Both ${_nextId++}', + position: pos, + color: Colors.red, + ), + ); + } + + void _clearComponents() { + world.removeAll( + world.children.where( + (c) => c is _DragBox || c is _ScaleBox || c is _DragScaleBox, + ), + ); + } + + Vector2 _randomWorldPosition() { + final rng = Random(); + return Vector2( + (rng.nextDouble() - 0.5) * 300, + (rng.nextDouble() - 0.5) * 200, + ); + } +} + +class _Button extends PositionComponent with TapCallbacks { + _Button({ + required String text, + required super.position, + required Color color, + required VoidCallback onPressed, + }) : _color = color, + _onPressed = onPressed, + _text = text, + super(size: Vector2(100, 30)); + + final Color _color; + final VoidCallback _onPressed; + final String _text; + + @override + Future onLoad() async { + add( + TextComponent( + text: _text, + textRenderer: TextPaint( + style: const TextStyle(fontSize: 16, color: Colors.white), + ), + position: size / 2, + anchor: Anchor.center, + ), + ); + } + + @override + void render(Canvas canvas) { + canvas.drawRRect( + RRect.fromRectAndRadius(size.toRect(), const Radius.circular(6)), + Paint()..color = _color, + ); + } + + @override + void onTapUp(TapUpEvent event) { + _onPressed(); + } +} + +/// A rectangle that only responds to drag. +class _DragBox extends RectangleComponent + with DragCallbacks, HasGameReference { + _DragBox({ + required String label, + required Vector2 position, + required Color color, + }) : _label = label, + super( + position: position, + size: Vector2.all(120), + anchor: Anchor.center, + paint: Paint()..color = color, + ); + + final String _label; + + @override + Future onLoad() async { + add( + TextComponent( + text: _label, + textRenderer: TextPaint( + style: const TextStyle(fontSize: 18, color: Colors.white), + ), + position: size / 2, + anchor: Anchor.center, + ), + ); + } + + @override + void onDragUpdate(DragUpdateEvent event) { + position += event.localDelta; + } +} + +/// A rectangle that only responds to scale (pinch/zoom). +class _ScaleBox extends RectangleComponent with ScaleCallbacks { + _ScaleBox({ + required String label, + required Vector2 position, + required Color color, + }) : _label = label, + super( + position: position, + size: Vector2.all(120), + anchor: Anchor.center, + paint: Paint()..color = color, + ); + + final String _label; + double _initialAngle = 0; + double _lastScale = 1.0; + + @override + Future onLoad() async { + add( + TextComponent( + text: _label, + textRenderer: TextPaint( + style: const TextStyle(fontSize: 18, color: Colors.white), + ), + position: size / 2, + anchor: Anchor.center, + ), + ); + } + + @override + void onScaleStart(ScaleStartEvent event) { + super.onScaleStart(event); + _initialAngle = angle; + _lastScale = 1.0; + } + + @override + void onScaleUpdate(ScaleUpdateEvent event) { + angle = _initialAngle + event.rotation; + if (_lastScale != 0) { + final delta = event.scale / _lastScale; + scale *= sqrt(delta); + scale.clamp(Vector2.all(0.5), Vector2.all(3)); + } + _lastScale = event.scale; + } +} + +/// A rectangle that responds to both drag and scale. +class _DragScaleBox extends RectangleComponent + with ScaleCallbacks, DragCallbacks, HasGameReference { + _DragScaleBox({ + required String label, + required Vector2 position, + required Color color, + }) : _label = label, + super( + position: position, + size: Vector2.all(120), + anchor: Anchor.center, + paint: Paint()..color = color, + ); + + final String _label; + double _initialAngle = 0; + double _lastScale = 1.0; + + @override + Future onLoad() async { + add( + TextComponent( + text: _label, + textRenderer: TextPaint( + style: const TextStyle(fontSize: 18, color: Colors.white), + ), + position: size / 2, + anchor: Anchor.center, + ), + ); + } + + @override + void onDragUpdate(DragUpdateEvent event) { + // When a scale gesture is active, translation is handled in onScaleUpdate + // via the focal point delta. Applying it here too would double-count the + // movement (one call per finger). + if (isScaling) { + return; + } + _applyLocalDeltaToPosition(event.localDelta); + } + + @override + void onScaleStart(ScaleStartEvent event) { + super.onScaleStart(event); + _initialAngle = angle; + _lastScale = 1.0; + } + + @override + void onScaleUpdate(ScaleUpdateEvent event) { + // Translate by focal point movement (single authoritative source). + _applyLocalDeltaToPosition(event.localDelta); + + angle = _initialAngle + event.rotation; + if (_lastScale != 0) { + final delta = event.scale / _lastScale; + scale *= sqrt(delta); + scale.clamp(Vector2.all(0.5), Vector2.all(3)); + } + _lastScale = event.scale; + } + + void _applyLocalDeltaToPosition(Vector2 d) { + // Transform from component local space back to parent space, accounting + // for this component's rotation and scale. + final c = cos(angle); + final s = sin(angle); + position += Vector2( + c * scale.x * d.x - s * scale.y * d.y, + s * scale.x * d.x + c * scale.y * d.y, + ); + } +} diff --git a/examples/lib/stories/input/input.dart b/examples/lib/stories/input/input.dart index c73dc115237..850fc616d08 100644 --- a/examples/lib/stories/input/input.dart +++ b/examples/lib/stories/input/input.dart @@ -3,6 +3,7 @@ import 'package:examples/commons/commons.dart'; import 'package:examples/stories/input/advanced_button_example.dart'; import 'package:examples/stories/input/double_tap_callbacks_example.dart'; import 'package:examples/stories/input/drag_callbacks_example.dart'; +import 'package:examples/stories/input/dynamic_scale_drag_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/hover_callbacks_example.dart'; @@ -157,5 +158,11 @@ void addInputStories(Dashbook dashbook) { (_) => GameWidget(game: AdvancedButtonExample()), codeLink: baseLink('input/advanced_button_example.dart'), info: AdvancedButtonExample.description, + ) + ..add( + 'Dynamic Scale & Drag', + (_) => GameWidget(game: DynamicScaleDragExample()), + codeLink: baseLink('input/dynamic_scale_drag_example.dart'), + info: DynamicScaleDragExample.description, ); } diff --git a/examples/lib/stories/input/scale_example.dart b/examples/lib/stories/input/scale_example.dart deleted file mode 100644 index 96aa24585d4..00000000000 --- a/examples/lib/stories/input/scale_example.dart +++ /dev/null @@ -1,294 +0,0 @@ -import 'dart:math'; -import 'package:flame/components.dart'; -import 'package:flame/events.dart'; -import 'package:flame/game.dart'; -import 'package:flutter/material.dart'; - -void main() { - runApp(GameWidget(game: ScaleExample())); -} - -class ScaleExample extends FlameGame { - late RectangleComponent rect; - late TextComponent debugText; - - late InteractiveRectangle interactiveRectangle; - Vector2 zoomCenter = Vector2.zero(); - double startingZoom = 1; - - final bool addScaleOnlyRectangle = true; - final bool addDragOnlyRectangle = true; - final bool addScaleDragRectangle = true; - final bool addZoom = false; - final bool addCameraRotation = false; - - @override - Future onLoad() async { - camera.viewfinder.zoom = 1; - - debugText = TextComponent( - text: 'hello', - textRenderer: TextPaint( - style: const TextStyle(fontSize: 25, color: Colors.white), - ), - position: Vector2(50, 50), - ); - - if (addScaleOnlyRectangle) { - final scaleOnlyRectangle = ScaleOnlyRectangle( - position: Vector2(0, 0), - size: Vector2.all(150), - ); - world.add(scaleOnlyRectangle); - } - if (addDragOnlyRectangle) { - final dragOnlyRectangle = DragOnlyRectangle( - position: Vector2(-200, -200), - size: Vector2.all(150), - color: Colors.green, - ); - world.add(dragOnlyRectangle); - } - - if (addScaleDragRectangle) { - interactiveRectangle = InteractiveRectangle( - position: Vector2(200, 200), - size: Vector2.all(150), - color: Colors.red, - ); - world.add(interactiveRectangle); - } - - camera.viewport.add(debugText); - } - - @override - void update(double dt) { - super.update(dt); - - if (addCameraRotation) { - camera.viewfinder.angle += 0.1 * dt; - } - if (addZoom) { - debugText.text = '${camera.viewfinder.zoom}'; - camera.viewfinder.zoom += 0.1 * dt; - } - } -} - -/// A rectangle component that can respond to both drag and scale gestures. -class InteractiveRectangle extends RectangleComponent - with ScaleCallbacks, DragCallbacks, HasGameReference { - InteractiveRectangle({ - required Vector2 position, - required Vector2 size, - Color color = Colors.blue, - Anchor anchor = Anchor.center, - }) : super( - position: position, - size: size, - anchor: anchor, - paint: Paint()..color = color, - ); - - bool isDoingScaling = false; - double initialAngle = 0; - Vector2 initialScale = Vector2.all(1); - double lastScale = 1.0; - - @override - Future onLoad() async { - final text = TextComponent( - text: 'drag + scale', - textRenderer: TextPaint( - style: const TextStyle(fontSize: 25, color: Colors.white), - ), - position: size / 2, - anchor: Anchor.center, - ); - add(text); - } - - /// DragCallbacks overrides - @override - void onDragStart(DragStartEvent event) { - super.onDragStart(event); - debugPrint('Drag started at ${event.devicePosition}'); - } - - @override - void onDragUpdate(DragUpdateEvent event) { - super.onDragUpdate(event); - if (isScaling) { - return; - } - final rotated = event.canvasDelta.clone() - ..rotate(game.camera.viewfinder.angle); - position.add(rotated); - } - - @override - void onDragEnd(DragEndEvent event) { - super.onDragEnd(event); - debugPrint('Drag ended with velocity ${event.velocity}'); - } - - /// ScaleCallbacks overrides - @override - void onScaleStart(ScaleStartEvent event) { - super.onScaleStart(event); - isDoingScaling = true; - initialAngle = angle; - initialScale = scale; - lastScale = 1.0; - debugPrint('Scale started at ${event.devicePosition}'); - } - - @override - void onScaleUpdate(ScaleUpdateEvent event) { - super.onScaleUpdate(event); - // scale rectangle size by pinch - angle = initialAngle + event.rotation; - - // delta scale since last frame - if (lastScale == 0) { - return; - } - final scaleDelta = event.scale / lastScale; - lastScale = event.scale; // update for next frame - - // apply delta gently - scale *= sqrt(scaleDelta); - - // clamp - scale.clamp(Vector2.all(0.8), Vector2.all(3)); - } - - @override - void onScaleEnd(ScaleEndEvent event) { - super.onScaleEnd(event); - isDoingScaling = false; - debugPrint('Scale ended with velocity ${event.velocity}'); - } -} - -/// A rectangle that only responds to drag -class DragOnlyRectangle extends RectangleComponent - with DragCallbacks, HasGameReference { - DragOnlyRectangle({ - required Vector2 position, - required Vector2 size, - Color color = Colors.blue, - Anchor anchor = Anchor.center, - }) : super( - position: position, - size: size, - anchor: anchor, - paint: Paint()..color = color, - ); - - @override - Future onLoad() async { - final text = TextComponent( - text: 'drag', - textRenderer: TextPaint( - style: const TextStyle(fontSize: 25, color: Colors.white), - ), - position: size / 2, - anchor: Anchor.center, - ); - add(text); - } - - /// DragCallbacks overrides - @override - void onDragStart(DragStartEvent event) { - super.onDragStart(event); - debugPrint('Drag started at ${event.devicePosition}'); - } - - @override - void onDragUpdate(DragUpdateEvent event) { - super.onDragUpdate(event); - debugPrint('On Drag update'); - final rotated = event.canvasDelta.clone() - ..rotate(game.camera.viewfinder.angle); - position.add(rotated); - } - - @override - void onDragEnd(DragEndEvent event) { - super.onDragEnd(event); - debugPrint('Drag ended with velocity ${event.velocity}'); - } -} - -/// A rectangle that only responds to scale -class ScaleOnlyRectangle extends RectangleComponent with ScaleCallbacks { - ScaleOnlyRectangle({ - required Vector2 position, - required Vector2 size, - Color color = Colors.blue, - Anchor anchor = Anchor.center, - }) : super( - position: position, - size: size, - anchor: anchor, - paint: Paint()..color = color, - ); - - @override - Future onLoad() async { - final text = TextComponent( - text: 'scale', - textRenderer: TextPaint( - style: const TextStyle(fontSize: 25, color: Colors.white), - ), - position: size / 2, - anchor: Anchor.center, - ); - add(text); - } - - bool isDoingScaling = false; - double initialAngle = 0; - Vector2 initialScale = Vector2.all(1); - double lastScale = 1.0; - - /// ScaleCallbacks overrides - @override - void onScaleStart(ScaleStartEvent event) { - super.onScaleStart(event); - isDoingScaling = true; - initialAngle = angle; - initialScale = scale; - lastScale = 1.0; - debugPrint('Scale started at ${event.devicePosition}'); - } - - @override - void onScaleUpdate(ScaleUpdateEvent event) { - super.onScaleUpdate(event); - // scale rectangle size by pinch - angle = initialAngle + event.rotation; - // delta scale since last frame - if (lastScale == 0) { - return; - } - final scaleDelta = event.scale / lastScale; - lastScale = event.scale; // update for next frame - - // apply delta gently - scale *= sqrt(scaleDelta); - - // clamp - scale.clamp(Vector2.all(0.8), Vector2.all(3)); - } - - @override - void onScaleEnd(ScaleEndEvent event) { - super.onScaleEnd(event); - isDoingScaling = false; - debugPrint('Scale ended with velocity ${event.velocity}'); - } -} diff --git a/packages/flame/lib/events.dart b/packages/flame/lib/events.dart index 43d28b58e8c..c521b22f328 100644 --- a/packages/flame/lib/events.dart +++ b/packages/flame/lib/events.dart @@ -13,18 +13,20 @@ export 'src/events/component_mixins/secondary_tap_callbacks.dart' export 'src/events/component_mixins/tap_callbacks.dart' show TapCallbacks; export 'src/events/component_mixins/tertiary_tap_callbacks.dart' show TertiaryTapCallbacks; +export 'src/events/deprecated.dart' + show MultiDragDispatcher, MultiDragDispatcherKey; export 'src/events/flame_game_mixins/double_tap_dispatcher.dart' show DoubleTapDispatcher, DoubleTapDispatcherKey; export 'src/events/flame_game_mixins/long_press_dispatcher.dart' show LongPressDispatcher, LongPressDispatcherKey; -export 'src/events/flame_game_mixins/multi_drag_dispatcher.dart' - show MultiDragDispatcher, MultiDragDispatcherKey; export 'src/events/flame_game_mixins/multi_tap_dispatcher.dart' show MultiTapDispatcher, MultiTapDispatcherKey; export 'src/events/flame_game_mixins/non_primary_tap_dispatcher.dart' show NonPrimaryTapDispatcher, NonPrimaryTapDispatcherKey; export 'src/events/flame_game_mixins/pointer_move_dispatcher.dart' show PointerMoveDispatcher, MouseMoveDispatcherKey; +export 'src/events/flame_game_mixins/scale_drag_dispatcher.dart' + show MultiDragScaleDispatcher, MultiDragScaleDispatcherKey; export 'src/events/flame_game_mixins/scroll_dispatcher.dart' show ScrollDispatcher, ScrollDispatcherKey; export 'src/events/game_mixins/multi_touch_drag_detector.dart' diff --git a/packages/flame/lib/src/events/component_mixins/drag_callbacks.dart b/packages/flame/lib/src/events/component_mixins/drag_callbacks.dart index 6429aa1bcfd..bb05312471f 100644 --- a/packages/flame/lib/src/events/component_mixins/drag_callbacks.dart +++ b/packages/flame/lib/src/events/component_mixins/drag_callbacks.dart @@ -11,7 +11,7 @@ import 'package:meta/meta.dart'; /// /// This mixin is the replacement of the Draggable mixin. /// -/// This callback uses [MultiDragDispatcher] to route events. +/// This callback uses [MultiDragScaleDispatcher] to route events. mixin DragCallbacks on Component { bool _isDragged = false; @@ -63,6 +63,21 @@ mixin DragCallbacks on Component { @mustCallSuper void onMount() { super.onMount(); - MultiDragDispatcher.addDispatcher(this); + MultiDragScaleDispatcher.addDispatcher( + this, + hasDrag: true, + hasScale: false, + ); + } + + @override + @mustCallSuper + void onRemove() { + MultiDragScaleDispatcher.removeDispatcher( + this, + hasDrag: true, + hasScale: false, + ); + super.onRemove(); } } diff --git a/packages/flame/lib/src/events/component_mixins/scale_callbacks.dart b/packages/flame/lib/src/events/component_mixins/scale_callbacks.dart index b5a25cc3733..3481232abf0 100644 --- a/packages/flame/lib/src/events/component_mixins/scale_callbacks.dart +++ b/packages/flame/lib/src/events/component_mixins/scale_callbacks.dart @@ -1,9 +1,8 @@ import 'package:flame/components.dart'; import 'package:flame/events.dart'; -import 'package:flame/src/events/flame_game_mixins/scale_dispatcher.dart'; import 'package:flutter/foundation.dart'; -/// This callback uses [ScaleDispatcher] to route events. +/// Mixin for components that respond to scale (pinch/zoom/rotate) gestures. mixin ScaleCallbacks on Component { bool _isScaling = false; @@ -26,6 +25,21 @@ mixin ScaleCallbacks on Component { @mustCallSuper void onMount() { super.onMount(); - ScaleDispatcher.addDispatcher(this); + MultiDragScaleDispatcher.addDispatcher( + this, + hasDrag: false, + hasScale: true, + ); + } + + @override + @mustCallSuper + void onRemove() { + MultiDragScaleDispatcher.removeDispatcher( + this, + hasDrag: false, + hasScale: true, + ); + super.onRemove(); } } diff --git a/packages/flame/lib/src/events/deprecated.dart b/packages/flame/lib/src/events/deprecated.dart new file mode 100644 index 00000000000..2c5bda16f2f --- /dev/null +++ b/packages/flame/lib/src/events/deprecated.dart @@ -0,0 +1,7 @@ +import 'package:flame/src/events/flame_game_mixins/scale_drag_dispatcher.dart'; + +@Deprecated('Use MultiDragScaleDispatcher instead.') +typedef MultiDragDispatcher = MultiDragScaleDispatcher; + +@Deprecated('Use MultiDragScaleDispatcherKey instead.') +typedef MultiDragDispatcherKey = MultiDragScaleDispatcherKey; diff --git a/packages/flame/lib/src/events/flame_game_mixins/multi_drag_dispatcher.dart b/packages/flame/lib/src/events/flame_game_mixins/multi_drag_dispatcher.dart deleted file mode 100644 index d09cf68010d..00000000000 --- a/packages/flame/lib/src/events/flame_game_mixins/multi_drag_dispatcher.dart +++ /dev/null @@ -1,219 +0,0 @@ -import 'dart:async'; - -import 'package:flame/components.dart'; -import 'package:flame/events.dart'; -import 'package:flame/src/events/flame_drag_adapter.dart'; -import 'package:flame/src/events/flame_game_mixins/dispatcher.dart'; -import 'package:flame/src/events/tagged_component.dart'; -import 'package:flame/src/game/flame_game.dart'; -import 'package:flame/src/game/game_render_box.dart'; -import 'package:flutter/gestures.dart'; -import 'package:meta/meta.dart'; - -class MultiDragDispatcherKey implements ComponentKey { - const MultiDragDispatcherKey(); - - @override - int get hashCode => 91604879; // 'MultiDragDispatcherKey' as hashCode - - @override - bool operator ==(Object other) => - other is MultiDragDispatcherKey && other.hashCode == hashCode; -} - -/// **MultiDragDispatcher** facilitates dispatching of drag events to the -/// [DragCallbacks] components in the component tree. It will be attached to -/// the [FlameGame] instance automatically whenever any [DragCallbacks] -/// components are mounted into the component tree. -class MultiDragDispatcher extends Dispatcher - implements MultiDragListener { - /// The record of all components currently being touched. - final Set> _records = {}; - - final _dragUpdateController = StreamController.broadcast( - sync: true, - ); - - Stream get onUpdate => _dragUpdateController.stream; - - final _dragStartController = StreamController.broadcast( - sync: true, - ); - - Stream get onStart => _dragStartController.stream; - - final _dragEndController = StreamController.broadcast( - sync: true, - ); - - Stream get onEnd => _dragEndController.stream; - - final _dragCancelController = StreamController.broadcast( - sync: true, - ); - - Stream get onCancel => _dragCancelController.stream; - - /// Called when the user initiates a drag gesture, for example by touching the - /// screen and then moving the finger. - /// - /// The handler propagates the [event] to any component located at the point - /// of touch and that uses the [DragCallbacks] mixin. The event will be first - /// delivered to the topmost such component, and then propagated to the - /// components below only if explicitly requested. - /// - /// Each [event] has an `event.pointerId` to keep track of multiple touches - /// that may occur simultaneously. - @mustCallSuper - void onDragStart(DragStartEvent event) { - event.deliverAtPoint( - rootComponent: game, - eventHandler: (DragCallbacks component) { - _records.add(TaggedComponent(event.pointerId, component)); - component.onDragStart(event); - }, - ); - } - - /// Called continuously during the drag as the user moves their finger. - /// - /// The default handler propagates this event to those components who received - /// the initial [onDragStart] event. If the position of the pointer is outside - /// of the bounds of the component, then this event will nevertheless be - /// delivered, however its `event.localPosition` property will contain NaNs. - @mustCallSuper - void onDragUpdate(DragUpdateEvent event) { - final updated = >{}; - // Defer cleanup so stale targets can be cancelled after iteration. - final stale = >{}; - event.deliverAtPoint( - rootComponent: game, - deliverToAll: true, - eventHandler: (DragCallbacks component) { - final record = TaggedComponent(event.pointerId, component); - if (_records.contains(record)) { - if (!component.isMounted || component.isRemoving) { - stale.add(record); - } else { - component.onDragUpdate(event); - updated.add(record); - } - } - }, - ); - for (final record in _records) { - if (record.pointerId != event.pointerId) { - continue; - } - final component = record.component; - if (!component.isMounted || component.isRemoving) { - stale.add(record); - continue; - } - if (!updated.contains(record)) { - component.onDragUpdate(event); - } - } - if (stale.isNotEmpty) { - final cancelEvent = DragCancelEvent(event.pointerId); - for (final record in stale) { - record.component.onDragCancel(cancelEvent); - } - _records.removeAll(stale); - } - } - - /// Called when the drag gesture finishes. - /// - /// The default handler will deliver this event to all components who has - /// previously received the corresponding [onDragStart] event and - /// [onDragUpdate]s. - @mustCallSuper - void onDragEnd(DragEndEvent event) { - _records.removeWhere((record) { - if (record.pointerId == event.pointerId) { - record.component.onDragEnd(event); - return true; - } - return false; - }); - } - - @mustCallSuper - void onDragCancel(DragCancelEvent event) { - _records.removeWhere((record) { - if (record.pointerId == event.pointerId) { - record.component.onDragCancel(event); - return true; - } - return false; - }); - } - - //#region MultiDragListener API - - @internal - @override - void handleDragStart(int pointerId, DragStartDetails details) { - final event = DragStartEvent(pointerId, game, details); - onDragStart(event); - _dragStartController.add(event); - } - - @internal - @override - void handleDragUpdate(int pointerId, DragUpdateDetails details) { - final event = DragUpdateEvent(pointerId, game, details); - onDragUpdate(event); - _dragUpdateController.add(event); - } - - @internal - @override - void handleDragEnd(int pointerId, DragEndDetails details) { - final event = DragEndEvent(pointerId, details); - onDragEnd(event); - _dragEndController.add(event); - } - - @internal - @override - void handleDragCancel(int pointerId) { - final event = DragCancelEvent(pointerId); - onDragCancel(event); - _dragCancelController.add(event); - } - - //#endregion - - static void addDispatcher(Component component) { - Dispatcher.addDispatcher( - component, - const MultiDragDispatcherKey(), - MultiDragDispatcher.new, - ); - } - - @override - void onMount() { - game.gestureDetectors.register( - ImmediateMultiDragGestureRecognizer.new, - (ImmediateMultiDragGestureRecognizer instance) { - instance.onStart = (Offset point) => FlameDragAdapter(this, point); - }, - ); - } - - @override - void onRemove() { - game.gestureDetectors.unregister(); - Dispatcher.removeDispatcher(game, const MultiDragDispatcherKey()); - _dragUpdateController.close(); - _dragCancelController.close(); - _dragStartController.close(); - _dragEndController.close(); - } - - @override - GameRenderBox get renderBox => game.renderBox; -} diff --git a/packages/flame/lib/src/events/flame_game_mixins/scale_dispatcher.dart b/packages/flame/lib/src/events/flame_game_mixins/scale_dispatcher.dart deleted file mode 100644 index 560611ef9ae..00000000000 --- a/packages/flame/lib/src/events/flame_game_mixins/scale_dispatcher.dart +++ /dev/null @@ -1,364 +0,0 @@ -import 'dart:math' as math; - -import 'package:flame/components.dart'; -import 'package:flame/events.dart'; -import 'package:flame/game.dart'; -import 'package:flame/src/events/flame_game_mixins/dispatcher.dart'; -import 'package:flame/src/events/interfaces/scale_listener.dart'; -import 'package:flame/src/events/tagged_component.dart'; -import 'package:flame/src/game/game_widget/gesture_detector_builder.dart'; -import 'package:flutter/gestures.dart'; -import 'package:meta/meta.dart'; - -/// Defines a line between two pointers on screen. -/// -/// [_LineBetweenPointers] is an abstraction of a line between two pointers in -/// contact with the screen. Used to track the rotation and scale of a scaleAabb -/// gesture. -class _LineBetweenPointers { - /// Creates a [_LineBetweenPointers]. None of the [pointerStartLocation] - /// [pointerEndLocation] must be null. - /// should be different. - _LineBetweenPointers({ - this.pointerStartLocation = Offset.zero, - this.pointerEndLocation = Offset.zero, - }); - - // The location and the id of the pointer that marks the start of the line. - final Offset pointerStartLocation; - - // The location and the id of the pointer that marks the end of the line. - final Offset pointerEndLocation; -} - -/// Unique key for the [ScaleDispatcher] so the game can identify it. -class ScaleDispatcherKey implements ComponentKey { - const ScaleDispatcherKey(); - - @override - int get hashCode => 31650892; // arbitrary unique number - - @override - bool operator ==(Object other) => - other is ScaleDispatcherKey && other.hashCode == hashCode; -} - -/// A component that dispatches scale (pinch/zoom) events to components -/// implementing [ScaleCallbacks]. It will be attached to -/// the [FlameGame] instance automatically whenever any [ScaleCallbacks] -/// components are mounted into the component tree. -class ScaleDispatcher extends Dispatcher implements ScaleListener { - /// Records all components currently being scaled, keyed by pointerId. - final Set> _records = {}; - - /// Store the last drag events - DragStartDetails? lastDragStart; - DragUpdateDetails? lastDragUpdate; - DragEndDetails? lastDragEnd; - - _LineBetweenPointers? _currentLine; - - _LineBetweenPointers? _lineAtFirstUpdate; - - MultiDragDispatcher? _multiDragDispatcher; - - /// Called when the user starts a scale gesture. - @mustCallSuper - void onScaleStart(ScaleStartEvent event) { - event.deliverAtPoint( - rootComponent: game, - eventHandler: (ScaleCallbacks component) { - _records.add(TaggedComponent(event.pointerId, component)); - component.onScaleStart(event); - }, - ); - } - - /// Called continuously as the user updates the scale gesture. - @mustCallSuper - void onScaleUpdate(ScaleUpdateEvent event) { - final updated = >{}; - - // Deliver to components under the pointer - event.deliverAtPoint( - rootComponent: game, - deliverToAll: true, - eventHandler: (ScaleCallbacks component) { - final record = TaggedComponent(event.pointerId, component); - if (_records.contains(record)) { - component.onScaleUpdate(event); - updated.add(record); - } - }, - ); - - // Also deliver to components that started the scale but weren't under - // the pointer this frame - // Currently, the id passed to the scale - // events is always 0, so maybe it's not relevant. - for (final record in _records) { - if (record.pointerId == event.pointerId && !updated.contains(record)) { - record.component.onScaleUpdate(event); - } - } - } - - /// Called when the scale gesture ends. - @mustCallSuper - void onScaleEnd(ScaleEndEvent event) { - _records.removeWhere((record) { - if (record.pointerId == event.pointerId) { - record.component.onScaleEnd(event); - return true; - } - return false; - }); - } - - //#region ScaleListener API - - @internal - @override - void handleScaleStart(ScaleStartDetails details) { - onScaleStart(ScaleStartEvent(0, game, details)); - } - - @internal - @override - void handleScaleUpdate(ScaleUpdateDetails details) { - if (details.pointerCount != 1) { - onScaleUpdate(ScaleUpdateEvent(0, game, details)); - return; - } - - final newDetails = _buildNewUpdateDetails(details); - if (newDetails != null) { - onScaleUpdate(ScaleUpdateEvent(0, game, newDetails)); - } - } - - /// If the user is doing a scale gesture, and we have more - /// than one pointer in contact with the screen, we don't have - /// anything special to do. - /// However, if the user is doing a scale gesture but a single pointer - /// is registered (such as when [ImmediateMultiDragGestureRecognizer] is - /// added to the [GestureDetectorBuilder] game gesture detectors), then - /// the [ScaleUpdateDetails] details won't contain any useful data, so - /// we need to rebuild it using data from the drag gesture. - ScaleUpdateDetails? _buildNewUpdateDetails(ScaleUpdateDetails details) { - if (lastDragUpdate == null) { - return null; - } - - _currentLine = _LineBetweenPointers( - pointerStartLocation: details.focalPoint, - pointerEndLocation: lastDragUpdate!.globalPosition, - ); - - // Register the line between the two pointers when the first update is - // triggered. This line will serve as a reference to compute scale - // and rotation data. - _lineAtFirstUpdate ??= _currentLine; - - // Do we also need to recompute local focal point, - // local relative to what ? - return ScaleUpdateDetails( - focalPoint: _computeFocalPoint(details), - rotation: _computeRotationFactor(details), - scale: _computeScale(details), - verticalScale: _computeVerticalScale(details), - horizontalScale: _computeHorizontalScale(details), - pointerCount: details.pointerCount, - focalPointDelta: details.focalPointDelta, - sourceTimeStamp: details.sourceTimeStamp, - ); - } - - /// Compute the focal point of the scale gesture using the one pointer - /// focal point (which is just the position of the pointer itself) and - /// the position of the last pointer triggering a drag update. - Offset _computeFocalPoint(ScaleUpdateDetails details) { - if (lastDragUpdate != null) { - return details.focalPoint + lastDragUpdate!.globalPosition / 2.0; - } else { - return details.focalPoint; - } - } - - /// Compute the rotation of the scale gesture using the initial - /// line formed between the two pointers that form the scale gesture, - /// and the subsequent lines they form as they move. The rotation factor - /// is just the angle in radian between the two lines. - double _computeRotationFactor(ScaleUpdateDetails details) { - if (lastDragUpdate == null || - _lineAtFirstUpdate == null || - _currentLine == null) { - return 0.0; - } - - final fx = _lineAtFirstUpdate!.pointerStartLocation.dx; - final fy = _lineAtFirstUpdate!.pointerStartLocation.dy; - final sx = _lineAtFirstUpdate!.pointerEndLocation.dx; - final sy = _lineAtFirstUpdate!.pointerEndLocation.dy; - - final nfx = _currentLine!.pointerStartLocation.dx; - final nfy = _currentLine!.pointerStartLocation.dy; - final nsx = _currentLine!.pointerEndLocation.dx; - final nsy = _currentLine!.pointerEndLocation.dy; - - final angle1 = math.atan2(fy - sy, fx - sx); - final angle2 = math.atan2(nfy - nsy, nfx - nsx); - - return angle2 - angle1; - } - - /// Compute the scale of the scale gesture using the initial - /// line formed between the two pointers that form the scale gesture, - /// and the subsequent lines they form as they move. The scale factor - /// is just length of current line over length of initial line. - double _computeScale(ScaleUpdateDetails details) { - if (lastDragUpdate == null || - _currentLine == null || - _lineAtFirstUpdate == null) { - return 1.0; - } - - final currentLineDistance = - (_currentLine!.pointerStartLocation - _currentLine!.pointerEndLocation) - .distance; - - final firstLineDistance = - (_lineAtFirstUpdate!.pointerStartLocation - - _lineAtFirstUpdate!.pointerEndLocation) - .distance; - - return currentLineDistance / firstLineDistance; - } - - /// Compute the vertical scale of the scale gesture using the initial - /// line formed between the two pointers that form the scale gesture, - /// and the subsequent lines they form as they move. The scale factor - /// is just length of current line vertical part over - /// length of initial line part. - double _computeVerticalScale(ScaleUpdateDetails details) { - if (lastDragUpdate == null || - _currentLine == null || - _lineAtFirstUpdate == null) { - return 1.0; - } - - final currentLineVerticalDistance = - (_currentLine!.pointerStartLocation.dy - - _currentLine!.pointerEndLocation.dy) - .abs(); - final firstLineVerticalDistance = - (_lineAtFirstUpdate!.pointerStartLocation.dy - - _lineAtFirstUpdate!.pointerEndLocation.dy) - .abs(); - - return currentLineVerticalDistance / firstLineVerticalDistance; - } - - /// Compute the vertical scale of the scale gesture using the initial - /// line formed between the two pointers that form the scale gesture, - /// and the subsequent lines they form as they move. The scale factor - /// is just length of current line horizontal part over - /// length of initial line part. - double _computeHorizontalScale(ScaleUpdateDetails details) { - if (lastDragUpdate == null || - _currentLine == null || - _lineAtFirstUpdate == null) { - return 1.0; - } - - final currentLineHorizontalDistance = - (_currentLine!.pointerStartLocation.dx - - _currentLine!.pointerEndLocation.dx) - .abs(); - final firstLineHorizontalDistance = - (_lineAtFirstUpdate!.pointerStartLocation.dx - - _lineAtFirstUpdate!.pointerEndLocation.dx) - .abs(); - - return currentLineHorizontalDistance / firstLineHorizontalDistance; - } - - @internal - @override - void handleScaleEnd(ScaleEndDetails details) { - _currentLine = null; - _lineAtFirstUpdate = null; - onScaleEnd(ScaleEndEvent(0, details)); - } - - //#endregion - - static void addDispatcher(Component component) { - Dispatcher.addDispatcher( - component, - const ScaleDispatcherKey(), - ScaleDispatcher.new, - ); - } - - @override - void onMount() { - game.gestureDetectors.register( - ScaleGestureRecognizer.new, - (ScaleGestureRecognizer instance) { - instance - ..onStart = handleScaleStart - ..onUpdate = handleScaleUpdate - ..onEnd = handleScaleEnd; - }, - ); - - final existingDispatcher = game.findByKey(const MultiDragDispatcherKey()); - if (existingDispatcher != null) { - _attachMultiDragDispatcher(existingDispatcher as MultiDragDispatcher); - } - - super.onMount(); - } - - @override - void onChildrenChanged(Component child, ChildrenChangeType type) { - super.onChildrenChanged(child, type); - - if (type == ChildrenChangeType.added && child is MultiDragDispatcher) { - _attachMultiDragDispatcher(child); - } - } - - void _attachMultiDragDispatcher(MultiDragDispatcher newDispatcher) { - if (_multiDragDispatcher != null) { - return; - } - - _multiDragDispatcher = newDispatcher; - listenToDragDispatcher(newDispatcher); - } - - @override - void onRemove() { - game.gestureDetectors.unregister(); - Dispatcher.removeDispatcher(game, const ScaleDispatcherKey()); - super.onRemove(); - } - - /// Subscribe to an external MultiDragDispatcher, we need - /// this in order to get the data of pointers used by - /// [ImmediateMultiDragGestureRecognizer], as it is necessary - /// to compute things such as rotation and scale of the scale gesture. - void listenToDragDispatcher(MultiDragDispatcher multiDragDispatcher) { - multiDragDispatcher.onUpdate.listen((event) { - lastDragUpdate = event.raw; - }); - multiDragDispatcher.onStart.listen((event) { - lastDragStart = event.raw; - }); - multiDragDispatcher.onEnd.listen((event) { - lastDragEnd = event.raw; - }); - } -} diff --git a/packages/flame/lib/src/events/flame_game_mixins/scale_drag_dispatcher.dart b/packages/flame/lib/src/events/flame_game_mixins/scale_drag_dispatcher.dart new file mode 100644 index 00000000000..df5a3c47bc8 --- /dev/null +++ b/packages/flame/lib/src/events/flame_game_mixins/scale_drag_dispatcher.dart @@ -0,0 +1,389 @@ +import 'package:flame/components.dart'; +import 'package:flame/events.dart'; +import 'package:flame/src/events/flame_drag_adapter.dart'; +import 'package:flame/src/events/flame_game_mixins/dispatcher.dart'; +import 'package:flame/src/events/interfaces/scale_listener.dart'; +import 'package:flame/src/events/multi_drag_scale_recognizer.dart'; +import 'package:flame/src/events/tagged_component.dart'; +import 'package:flame/src/game/flame_game.dart'; +import 'package:flame/src/game/game_render_box.dart'; +import 'package:flutter/gestures.dart'; +import 'package:meta/meta.dart'; + +class MultiDragScaleDispatcherKey implements ComponentKey { + const MultiDragScaleDispatcherKey(); + + @override + int get hashCode => 91604875; // 'MultiDragScaleDispatcherKey' as hashCode + + @override + bool operator ==(Object other) => + other is MultiDragScaleDispatcherKey && other.hashCode == hashCode; +} + +/// Dispatches both drag and scale events to [DragCallbacks] and +/// [ScaleCallbacks] components. Attached to the [FlameGame] automatically +/// when either callback type is first mounted. +/// +/// Use [enableDrag] and [enableScale] (called via [addDispatcher]) to control +/// which event types are forwarded to the underlying +/// [MultiDragScaleGestureRecognizer]. +class MultiDragScaleDispatcher extends Dispatcher + implements MultiDragListener, ScaleListener { + /// The record of all components currently being touched. + final Set> _records = {}; + + // Reference counts rather than booleans so that enableDrag/enableScale can + // be called before onMount (when _recognizer is null). onMount uses these + // counts to initialize the recognizer flags. + int _dragCount = 0; + int _scaleCount = 0; + MultiDragScaleGestureRecognizer? _recognizer; + + /// The minimum scale factor change required before a scale gesture is + /// recognized. Must be greater than 1.0. The default value is 1.05, meaning + /// the fingers must spread or pinch by at least 5% before scale events start + /// firing. Set this before the first [ScaleCallbacks] component mounts. + double scaleThreshold = 1.05; + + @visibleForTesting + bool get hasDrag => _dragCount > 0; + + @visibleForTesting + bool get hasScale => _scaleCount > 0; + + /// Enables drag forwarding on the underlying recognizer. + /// + /// Safe to call before or after [onMount]. + void enableDrag() { + _dragCount++; + _recognizer?.hasDrag = true; + } + + /// Enables scale forwarding on the underlying recognizer. + /// + /// Safe to call before or after [onMount]. + void enableScale() { + _scaleCount++; + _recognizer?.hasScale = true; + } + + void _disableDrag() { + assert(_dragCount > 0, '_disableDrag called more times than enableDrag'); + _dragCount--; + if (_dragCount == 0) { + _recognizer?.hasDrag = false; + } + } + + void _disableScale() { + assert(_scaleCount > 0, '_disableScale called more times than enableScale'); + _scaleCount--; + if (_scaleCount == 0) { + _recognizer?.hasScale = false; + } + } + + /// Ensures a [MultiDragScaleDispatcher] is registered on the game that owns + /// [component], then enables drag and/or scale as requested. + /// + /// For a component that mixes both [DragCallbacks] and [ScaleCallbacks], + /// this method is called twice from their separate [onMount] chains, once + /// with [hasDrag]=true and once with [hasScale]=true. The second call finds + /// the existing dispatcher and simply enables the remaining flag. + static void addDispatcher( + Component component, { + required bool hasDrag, + required bool hasScale, + }) { + final game = component.findRootGame()!; + var dispatcher = + game.findByKey(const MultiDragScaleDispatcherKey()) + as MultiDragScaleDispatcher?; + if (dispatcher == null) { + dispatcher = MultiDragScaleDispatcher(); + game.registerKey(const MultiDragScaleDispatcherKey(), dispatcher); + game.add(dispatcher); + } + if (hasDrag) { + dispatcher.enableDrag(); + } + if (hasScale) { + dispatcher.enableScale(); + } + } + + /// Decrements the reference counts for [component]'s event types and + /// disables the corresponding recognizer flags when the count reaches zero. + static void removeDispatcher( + Component component, { + required bool hasDrag, + required bool hasScale, + }) { + final game = component.findRootGame(); + if (game == null) { + return; + } + final dispatcher = + game.findByKey(const MultiDragScaleDispatcherKey()) + as MultiDragScaleDispatcher?; + if (dispatcher == null) { + return; + } + if (hasDrag) { + dispatcher._disableDrag(); + } + if (hasScale) { + dispatcher._disableScale(); + } + } + + /// Called when the user initiates a drag gesture, for example by touching the + /// screen and then moving the finger. + /// + /// The handler propagates the [event] to any component located at the point + /// of touch and that uses the [DragCallbacks] mixin. The event will be first + /// delivered to the topmost such component, and then propagated to the + /// components below only if explicitly requested. + /// + /// Each [event] has an `event.pointerId` to keep track of multiple touches + /// that may occur simultaneously. + @mustCallSuper + void onDragStart(DragStartEvent event) { + event.deliverAtPoint( + rootComponent: game, + eventHandler: (DragCallbacks component) { + _records.add(TaggedComponent(event.pointerId, component)); + component.onDragStart(event); + }, + ); + } + + /// Called continuously during the drag as the user moves their finger. + /// + /// The default handler propagates this event to those components who received + /// the initial [onDragStart] event. If the position of the pointer is outside + /// of the bounds of the component, then this event will nevertheless be + /// delivered, however its `event.localPosition` property will contain NaNs. + @mustCallSuper + void onDragUpdate(DragUpdateEvent event) { + final updated = >{}; + final stale = >{}; + event.deliverAtPoint( + rootComponent: game, + deliverToAll: true, + eventHandler: (DragCallbacks component) { + final record = TaggedComponent(event.pointerId, component); + if (_records.contains(record)) { + if (!component.isMounted || component.isRemoving) { + stale.add(record); + } else { + component.onDragUpdate(event); + updated.add(record); + } + } + }, + ); + for (final record in _records) { + if (record.pointerId != event.pointerId) { + continue; + } + final component = record.component; + if (!component.isMounted || component.isRemoving) { + stale.add(record); + continue; + } + if (!updated.contains(record)) { + component.onDragUpdate(event); + } + } + if (stale.isNotEmpty) { + final cancelEvent = DragCancelEvent(event.pointerId); + for (final record in stale) { + record.component.onDragCancel(cancelEvent); + } + _records.removeAll(stale); + } + } + + /// Called when the drag gesture finishes. + /// + /// The default handler will deliver this event to all components who has + /// previously received the corresponding [onDragStart] event and + /// [onDragUpdate]s. + @mustCallSuper + void onDragEnd(DragEndEvent event) { + _records.removeWhere((record) { + if (record.pointerId == event.pointerId) { + record.component.onDragEnd(event); + return true; + } + return false; + }); + } + + @mustCallSuper + void onDragCancel(DragCancelEvent event) { + _records.removeWhere((record) { + if (record.pointerId == event.pointerId) { + record.component.onDragCancel(event); + return true; + } + return false; + }); + } + + //#region MultiDragListener API + + @internal + @override + void handleDragStart(int pointerId, DragStartDetails details) { + final event = DragStartEvent(pointerId, game, details); + onDragStart(event); + } + + @internal + @override + void handleDragUpdate(int pointerId, DragUpdateDetails details) { + final event = DragUpdateEvent(pointerId, game, details); + onDragUpdate(event); + } + + @internal + @override + void handleDragEnd(int pointerId, DragEndDetails details) { + final event = DragEndEvent(pointerId, details); + onDragEnd(event); + } + + @internal + @override + void handleDragCancel(int pointerId) { + final event = DragCancelEvent(pointerId); + onDragCancel(event); + } + + final Set> _scaleRecords = {}; + + /// Called when the user starts a scale gesture. + @mustCallSuper + void onScaleStart(ScaleStartEvent event) { + event.deliverAtPoint( + rootComponent: game, + eventHandler: (ScaleCallbacks component) { + _scaleRecords.add(TaggedComponent(event.pointerId, component)); + component.onScaleStart(event); + }, + ); + } + + /// Called continuously as the user updates the scale gesture. + @mustCallSuper + void onScaleUpdate(ScaleUpdateEvent event) { + final updated = >{}; + final stale = >{}; + + // Deliver to components under the pointer + event.deliverAtPoint( + rootComponent: game, + deliverToAll: true, + eventHandler: (ScaleCallbacks component) { + final record = TaggedComponent(event.pointerId, component); + if (_scaleRecords.contains(record)) { + if (!component.isMounted || component.isRemoving) { + stale.add(record); + } else { + component.onScaleUpdate(event); + updated.add(record); + } + } + }, + ); + + // Also deliver to components that started the scale but weren't under + // the pointer this frame + // Currently, the id passed to the scale + // events is always 0, so maybe it's not relevant. + for (final record in _scaleRecords) { + if (record.pointerId != event.pointerId) { + continue; + } + final component = record.component; + if (!component.isMounted || component.isRemoving) { + stale.add(record); + continue; + } + if (!updated.contains(record)) { + record.component.onScaleUpdate(event); + } + } + + if (stale.isNotEmpty) { + final endEvent = ScaleEndEvent(event.pointerId, ScaleEndDetails()); + for (final record in stale) { + record.component.onScaleEnd(endEvent); + } + _scaleRecords.removeAll(stale); + } + } + + /// Called when the scale gesture ends. + @mustCallSuper + void onScaleEnd(ScaleEndEvent event) { + _scaleRecords.removeWhere((record) { + if (record.pointerId == event.pointerId) { + record.component.onScaleEnd(event); + return true; + } + return false; + }); + } + + //#region ScaleListener API + + @internal + @override + void handleScaleStart(ScaleStartDetails details) { + onScaleStart(ScaleStartEvent(0, game, details)); + } + + @internal + @override + void handleScaleUpdate(ScaleUpdateDetails details) { + onScaleUpdate(ScaleUpdateEvent(0, game, details)); + } + + @internal + @override + void handleScaleEnd(ScaleEndDetails details) { + onScaleEnd(ScaleEndEvent(0, details)); + } + + //#endregion + + @override + void onMount() { + game.gestureDetectors.register( + () => MultiDragScaleGestureRecognizer(scaleThreshold: scaleThreshold), + (MultiDragScaleGestureRecognizer instance) { + _recognizer = instance; + instance.hasDrag = _dragCount > 0; + instance.hasScale = _scaleCount > 0; + instance.onStart = (Offset point) => FlameDragAdapter(this, point); + instance.onScaleStart = handleScaleStart; + instance.onScaleUpdate = handleScaleUpdate; + instance.onScaleEnd = handleScaleEnd; + }, + ); + } + + @override + void onRemove() { + _recognizer = null; + game.gestureDetectors.unregister(); + game.unregisterKey(const MultiDragScaleDispatcherKey()); + } + + @override + GameRenderBox get renderBox => game.renderBox; +} diff --git a/packages/flame/lib/src/events/multi_drag_scale_recognizer.dart b/packages/flame/lib/src/events/multi_drag_scale_recognizer.dart new file mode 100644 index 00000000000..ffaeafd486d --- /dev/null +++ b/packages/flame/lib/src/events/multi_drag_scale_recognizer.dart @@ -0,0 +1,568 @@ +import 'dart:math' as math; +import 'package:flutter/gestures.dart'; + +/// A gesture recognizer that can recognize both individual pointer drags +/// and scale gestures simultaneously. +/// +/// This recognizer tracks each pointer independently (like Flutter's +/// [ImmediateMultiDragGestureRecognizer]) while also tracking the overall +/// scale gesture (like Flutter's [ScaleGestureRecognizer]). Each pointer +/// fires its own drag callbacks independently. When 2+ pointers are down +/// and movement exceeds [scaleThreshold], scale callbacks also fire. +/// +/// Use [hasDrag] and [hasScale] to enable only the features needed. Both +/// default to false; the dispatcher sets them via enableDrag/enableScale. +class MultiDragScaleGestureRecognizer extends GestureRecognizer { + /// Create a gesture recognizer for tracking multi-drag and scale gestures. + MultiDragScaleGestureRecognizer({ + super.debugOwner, + super.supportedDevices, + AllowedButtonsFilter? allowedButtonsFilter, + this.scaleThreshold = 1.05, + }) : super( + allowedButtonsFilter: + allowedButtonsFilter ?? _defaultButtonAcceptBehavior, + ); + + // Accept the input if, and only if, [kPrimaryButton] is pressed. + static bool _defaultButtonAcceptBehavior(int buttons) => + buttons == kPrimaryButton; + + /// The threshold for determining when a scale gesture has occurred. + /// Default is 1.05 (5% change in scale). + final double scaleThreshold; + + /// Whether drag callbacks should fire. Controlled by the dispatcher. + bool hasDrag = false; + + /// Whether scale callbacks should fire. Controlled by the dispatcher. + bool hasScale = false; + + /// Called when a pointer starts dragging. One callback per pointer. + /// Return a Drag object to receive updates for this specific pointer. + GestureMultiDragStartCallback? onStart; + + /// Called when a scale gesture starts (when 2+ pointers are active). + GestureScaleStartCallback? onScaleStart; + + /// Called when a scale gesture is updated. + GestureScaleUpdateCallback? onScaleUpdate; + + /// Called when a scale gesture ends. + GestureScaleEndCallback? onScaleEnd; + + final _DragState _drag = _DragState(); + final _ScaleState _scale = _ScaleState(); + + int get pointerCount => _drag.count; + + @override + void addAllowedPointer(PointerDownEvent event) { + if (!hasDrag && !hasScale) { + return; + } + assert( + !_drag.pointers.containsKey(event.pointer), + 'Pointer ${event.pointer} is already tracked by this recognizer.', + ); + final state = _DragPointerState(recognizer: this, event: event); + _drag.pointers[event.pointer] = state; + GestureBinding.instance.pointerRouter.addRoute(event.pointer, _handleEvent); + state.arenaEntry = GestureBinding.instance.gestureArena.add( + event.pointer, + this, + ); + + if (hasScale) { + if (_drag.count <= 2) { + // Re-baseline focal point and spans on the first two pointer-down + // events. For count==1 the span is zero; for count==2 the 2-finger + // midpoint differs from the single-finger initial position, so we + // reset both. For 3+ pointers the gesture is already in progress; + // leave the baseline unchanged. + _updateScaleFields(); + _scale.initialFocalPoint = _scale.currentFocalPoint; + _scale.initialSpan = _scale.currentSpan; + _scale.initialHorizontalSpan = _scale.currentHorizontalSpan; + _scale.initialVerticalSpan = _scale.currentVerticalSpan; + } + } + } + + void _handleEvent(PointerEvent event) { + assert(_drag.pointers.containsKey(event.pointer)); + final state = _drag.pointers[event.pointer]!; + + if (event is PointerMoveEvent) { + state._move(event); + if (hasScale) { + _updateScale(event); + } + } else if (event is PointerUpEvent) { + assert(event.delta == Offset.zero); + state._up(event); + _removeState(event.pointer); + if (hasScale) { + _scale.lastTransform = event.transform; + _updateScaleFields(); + _updateLines(); + _endScaleIfNeeded(); + } + } else if (event is PointerCancelEvent) { + assert(event.delta == Offset.zero); + state._cancel(event); + _removeState(event.pointer); + if (hasScale) { + _scale.lastTransform = event.transform; + _updateScaleFields(); + _updateLines(); + _endScaleIfNeeded(); + // No need to reset initialSpan/initialLine here: addAllowedPointer + // re-initializes them whenever a new two-finger gesture begins. + } + } else if (event is! PointerDownEvent) { + assert(false); + } + } + + void _updateScale(PointerMoveEvent event) { + _scale.lastTransform = event.transform; + _updateScaleFields(); + _updateLines(); + final focalPoint = _scale.currentFocalPoint!; + + if (_drag.count >= 2 && !_scale.active && _checkScaleGestureThreshold()) { + _scale.active = true; + _scale.initialEventTimestamp = event.timeStamp; + _scale.velocityTracker = VelocityTracker.withKind(event.kind); + + if (onScaleStart != null) { + invokeCallback('onScaleStart', () { + onScaleStart!( + ScaleStartDetails( + focalPoint: focalPoint, + localFocalPoint: _scale.localFocalPoint, + pointerCount: pointerCount, + sourceTimeStamp: _scale.initialEventTimestamp, + ), + ); + }); + } + } + + if (_scale.active && _drag.count >= 2) { + _scale.velocityTracker?.addPosition(event.timeStamp, focalPoint); + + if (onScaleUpdate != null) { + invokeCallback('onScaleUpdate', () { + onScaleUpdate!( + ScaleUpdateDetails( + scale: _scale.scaleFactor, + horizontalScale: _scale.horizontalScaleFactor, + verticalScale: _scale.verticalScaleFactor, + focalPoint: focalPoint, + localFocalPoint: _scale.localFocalPoint, + rotation: _computeRotationFactor(), + pointerCount: pointerCount, + focalPointDelta: _scale.delta, + sourceTimeStamp: event.timeStamp, + ), + ); + }); + } + } + } + + void _endScaleIfNeeded() { + if (!_scale.active || _drag.count >= 2) { + return; + } + if (onScaleEnd != null) { + final velocity = _scale.velocityTracker?.getVelocity() ?? Velocity.zero; + + if (_isFlingGesture(velocity)) { + final pixelsPerSecond = velocity.pixelsPerSecond; + if (pixelsPerSecond.distanceSquared > + kMaxFlingVelocity * kMaxFlingVelocity) { + final clampedVelocity = Velocity( + pixelsPerSecond: + (pixelsPerSecond / pixelsPerSecond.distance) * + kMaxFlingVelocity, + ); + invokeCallback( + 'onScaleEnd', + () => onScaleEnd!( + ScaleEndDetails( + velocity: clampedVelocity, + pointerCount: pointerCount, + ), + ), + ); + } else { + invokeCallback( + 'onScaleEnd', + () => onScaleEnd!( + ScaleEndDetails( + velocity: velocity, + pointerCount: pointerCount, + ), + ), + ); + } + } else { + invokeCallback( + 'onScaleEnd', + () => onScaleEnd!( + ScaleEndDetails(pointerCount: pointerCount), + ), + ); + } + } + + _scale.reset(); + } + + bool _checkScaleGestureThreshold() { + if (_drag.pointers.isEmpty || _scale.initialFocalPoint == null) { + return false; + } + + final kind = _drag.pointers.values.first.kind; + final spanDelta = (_scale.currentSpan - _scale.initialSpan).abs(); + final scaleFactor = _scale.scaleFactor; + final focalDelta = + (_scale.currentFocalPoint! - _scale.initialFocalPoint!).distance; + + if (spanDelta > computeScaleSlop(kind) || + math.max(scaleFactor, 1.0 / scaleFactor) > scaleThreshold || + focalDelta > computePanSlop(kind, gestureSettings)) { + for (final state in _drag.pointers.values) { + if (!state._resolved) { + state._arenaEntry?.resolve(GestureDisposition.accepted); + } + } + return true; + } + return false; + } + + void _updateScaleFields() { + final previousFocalPoint = _scale.currentFocalPoint; + + var focalPoint = Offset.zero; + for (final state in _drag.pointers.values) { + focalPoint += state.currentPosition; + } + _scale.currentFocalPoint = _drag.pointers.isEmpty + ? Offset.zero + : focalPoint / _drag.pointers.length.toDouble(); + + if (previousFocalPoint == null) { + _scale.localFocalPoint = PointerEvent.transformPosition( + _scale.lastTransform, + _scale.currentFocalPoint!, + ); + _scale.delta = Offset.zero; + } else { + _scale.localFocalPoint = PointerEvent.transformPosition( + _scale.lastTransform, + _scale.currentFocalPoint!, + ); + _scale.delta = _scale.currentFocalPoint! - previousFocalPoint; + } + + var totalDeviation = 0.0; + var totalHorizontalDeviation = 0.0; + var totalVerticalDeviation = 0.0; + for (final state in _drag.pointers.values) { + totalDeviation += + (_scale.currentFocalPoint! - state.currentPosition).distance; + totalHorizontalDeviation += + (_scale.currentFocalPoint!.dx - state.currentPosition.dx).abs(); + totalVerticalDeviation += + (_scale.currentFocalPoint!.dy - state.currentPosition.dy).abs(); + } + final count = _drag.pointers.length; + _scale.currentSpan = count > 0 ? totalDeviation / count : 0.0; + _scale.currentHorizontalSpan = count > 0 + ? totalHorizontalDeviation / count + : 0.0; + _scale.currentVerticalSpan = count > 0 + ? totalVerticalDeviation / count + : 0.0; + } + + void _updateLines() { + final count = _drag.pointers.length; + final pointerIds = _drag.pointers.keys.toList(); + + if (count < 2) { + _scale.initialLine = _scale.currentLine; + } else if (_scale.initialLine != null && + _scale.initialLine!.pointerStartId == pointerIds[0] && + _scale.initialLine!.pointerEndId == pointerIds[1]) { + _scale.currentLine = _LineBetweenPointers( + pointerStartId: pointerIds[0], + pointerStartLocation: _drag.pointers[pointerIds[0]]!.currentPosition, + pointerEndId: pointerIds[1], + pointerEndLocation: _drag.pointers[pointerIds[1]]!.currentPosition, + ); + } else { + _scale.initialLine = _LineBetweenPointers( + pointerStartId: pointerIds[0], + pointerStartLocation: _drag.pointers[pointerIds[0]]!.currentPosition, + pointerEndId: pointerIds[1], + pointerEndLocation: _drag.pointers[pointerIds[1]]!.currentPosition, + ); + _scale.currentLine = _scale.initialLine; + } + } + + double _computeRotationFactor() { + var factor = 0.0; + final initialLine = _scale.initialLine; + final currentLine = _scale.currentLine; + if (initialLine != null && currentLine != null) { + final fx = initialLine.pointerStartLocation.dx; + final fy = initialLine.pointerStartLocation.dy; + final sx = initialLine.pointerEndLocation.dx; + final sy = initialLine.pointerEndLocation.dy; + + final nfx = currentLine.pointerStartLocation.dx; + final nfy = currentLine.pointerStartLocation.dy; + final nsx = currentLine.pointerEndLocation.dx; + final nsy = currentLine.pointerEndLocation.dy; + + final angle1 = math.atan2(fy - sy, fx - sx); + final angle2 = math.atan2(nfy - nsy, nfx - nsx); + factor = angle2 - angle1; + } + return factor; + } + + bool _isFlingGesture(Velocity velocity) { + final speedSquared = velocity.pixelsPerSecond.distanceSquared; + return speedSquared > kMinFlingVelocity * kMinFlingVelocity; + } + + Drag? _startDrag(Offset initialPosition, int pointer) { + assert(_drag.pointers.containsKey(pointer)); + if (!hasDrag || onStart == null) { + return null; + } + return invokeCallback('onStart', () => onStart!(initialPosition)); + } + + @override + void acceptGesture(int pointer) { + final state = _drag.pointers[pointer]; + if (state == null) { + return; + } + state._accepted(() => _startDrag(state.initialPosition, pointer)); + } + + @override + void rejectGesture(int pointer) { + final state = _drag.pointers[pointer]; + if (state != null) { + state._rejected(); + _removeState(pointer); + if (hasScale) { + _updateScaleFields(); + _updateLines(); + _endScaleIfNeeded(); + } + } + } + + void _removeState(int pointer) { + if (!_drag.pointers.containsKey(pointer)) { + return; + } + GestureBinding.instance.pointerRouter.removeRoute(pointer, _handleEvent); + _drag.pointers.remove(pointer)!._dispose(); + } + + @override + void dispose() { + final pointers = _drag.pointers.keys.toList(); + for (final pointer in pointers) { + _removeState(pointer); + } + assert(_drag.pointers.isEmpty); + super.dispose(); + } + + @override + String get debugDescription => 'multi-drag-scale'; +} + +/// Groups per-recognizer drag state. Per-pointer state lives in +/// [_DragPointerState]. +class _DragState { + final Map pointers = {}; + int get count => pointers.length; +} + +/// Groups all scale-tracking state for a [MultiDragScaleGestureRecognizer]. +class _ScaleState { + bool active = false; + Offset? initialFocalPoint; + Offset? currentFocalPoint; + double initialSpan = 0.0; + double currentSpan = 0.0; + double initialHorizontalSpan = 0.0; + double currentHorizontalSpan = 0.0; + double initialVerticalSpan = 0.0; + double currentVerticalSpan = 0.0; + Offset localFocalPoint = Offset.zero; + _LineBetweenPointers? initialLine; + _LineBetweenPointers? currentLine; + Matrix4? lastTransform; + Offset delta = Offset.zero; + VelocityTracker? velocityTracker; + Duration? initialEventTimestamp; + + // Spans are mean distances from the focal point, so they are always >= 0. + double get scaleFactor => initialSpan > 0.0 ? currentSpan / initialSpan : 1.0; + + double get horizontalScaleFactor => initialHorizontalSpan > 0.0 + ? currentHorizontalSpan / initialHorizontalSpan + : 1.0; + + double get verticalScaleFactor => initialVerticalSpan > 0.0 + ? currentVerticalSpan / initialVerticalSpan + : 1.0; + + void reset() { + active = false; + velocityTracker = null; + } +} + +class _DragPointerState { + _DragPointerState({ + required this.recognizer, + required PointerDownEvent event, + }) : initialPosition = event.position, + currentPosition = event.position, + kind = event.kind { + velocityTracker = VelocityTracker.withKind(kind); + } + + final MultiDragScaleGestureRecognizer recognizer; + final Offset initialPosition; + final PointerDeviceKind kind; + + Offset currentPosition; + late VelocityTracker velocityTracker; + GestureArenaEntry? _arenaEntry; + Drag? _drag; + bool _resolved = false; + + // Accumulates deltas before the gesture is accepted so they can be delivered + // as a single initial update (matches ImmediateMultiDragGestureRecognizer). + Offset _pendingDelta = Offset.zero; + + set arenaEntry(GestureArenaEntry entry) { + _arenaEntry = entry; + } + + void _move(PointerMoveEvent event) { + if (!event.synthesized) { + velocityTracker.addPosition(event.timeStamp, event.position); + } + + final delta = event.position - currentPosition; + currentPosition = event.position; + + if (!_resolved) { + _pendingDelta += delta; + if (!recognizer.hasScale) { + // Drag-only mode: accept on any movement. If accepted synchronously, + // _accepted fires the initial update with _pendingDelta; no extra + // update fires here because we are still in the if(!_resolved) branch. + _arenaEntry?.resolve(GestureDisposition.accepted); + } else { + final distance = (currentPosition - initialPosition).distance; + if (distance > computePanSlop(kind, recognizer.gestureSettings)) { + _arenaEntry?.resolve(GestureDisposition.accepted); + } else if (recognizer._drag.count >= 2) { + recognizer._checkScaleGestureThreshold(); + } + } + } else if (_drag != null) { + _drag!.update( + DragUpdateDetails( + globalPosition: event.position, + delta: delta, + sourceTimeStamp: event.timeStamp, + localPosition: PointerEvent.transformPosition( + event.transform, + event.position, + ), + ), + ); + } + } + + void _up(PointerUpEvent event) { + if (_drag != null) { + _drag!.end(DragEndDetails(velocity: velocityTracker.getVelocity())); + } + _resolved = true; + } + + void _cancel(PointerCancelEvent event) { + _drag?.cancel(); + _resolved = true; + } + + void _accepted(Drag? Function() starter) { + if (!_resolved) { + _resolved = true; + _drag = starter(); + // In drag-only mode, fire an initial update matching + // ImmediateMultiDragGestureRecognizer: delta is Offset.zero when + // accepted before any moves (via microtask), or the accumulated + // pending delta when accepted during a move. + // Scale mode skips this to avoid an extra update per pointer. + if (_drag != null && !recognizer.hasScale) { + _drag!.update( + DragUpdateDetails( + globalPosition: initialPosition, + delta: _pendingDelta, + ), + ); + _pendingDelta = Offset.zero; + } + } + } + + void _rejected() { + _resolved = true; + } + + void _dispose() { + if (!_resolved) { + _arenaEntry?.resolve(GestureDisposition.rejected); + } + _arenaEntry = null; + _drag = null; + } +} + +class _LineBetweenPointers { + _LineBetweenPointers({ + required this.pointerStartId, + required this.pointerStartLocation, + required this.pointerEndId, + required this.pointerEndLocation, + }); + + final int pointerStartId; + final Offset pointerStartLocation; + final int pointerEndId; + final Offset pointerEndLocation; +} diff --git a/packages/flame/lib/src/events/tagged_component.dart b/packages/flame/lib/src/events/tagged_component.dart index b1b4dbb627c..0420027dcde 100644 --- a/packages/flame/lib/src/events/tagged_component.dart +++ b/packages/flame/lib/src/events/tagged_component.dart @@ -1,12 +1,12 @@ import 'package:flame/src/components/core/component.dart'; -import 'package:flame/src/events/flame_game_mixins/multi_drag_dispatcher.dart'; import 'package:flame/src/events/flame_game_mixins/multi_tap_dispatcher.dart'; +import 'package:flame/src/events/flame_game_mixins/scale_drag_dispatcher.dart'; import 'package:meta/meta.dart'; /// [TaggedComponent] is a utility class that represents a pair of a component /// and a pointer id. /// -/// This class is used by [MultiTapDispatcher] and [MultiDragDispatcher] +/// This class is used by [MultiTapDispatcher] and [MultiDragScaleDispatcher] /// to store information about which components were affected by which pointer /// event, so that subsequent events can be reliably delivered to the same /// components. diff --git a/packages/flame/test/components/joystick_component_test.dart b/packages/flame/test/components/joystick_component_test.dart index 0c7123bbd70..a3a2e878f18 100644 --- a/packages/flame/test/components/joystick_component_test.dart +++ b/packages/flame/test/components/joystick_component_test.dart @@ -1,5 +1,5 @@ import 'package:flame/components.dart'; -import 'package:flame/src/events/flame_game_mixins/multi_drag_dispatcher.dart'; +import 'package:flame/events.dart'; import 'package:flame_test/flame_test.dart'; import 'package:flutter/widgets.dart'; import 'package:test/test.dart'; @@ -85,7 +85,7 @@ void main() { await game.add(joystick); await game.ready(); expect(joystick.knob!.position, closeToVector(Vector2(10, 10))); - final dragDispatcher = game.firstChild()!; + final dragDispatcher = game.firstChild()!; // Start dragging the joystick dragDispatcher.handleDragStart( 1, @@ -131,7 +131,7 @@ void main() { ); await game.add(joystick); await game.ready(); - final dragDispatcher = game.firstChild()!; + final dragDispatcher = game.firstChild()!; dragDispatcher.handleDragStart( 1, diff --git a/packages/flame/test/events/component_mixins/drag_callbacks_test.dart b/packages/flame/test/events/component_mixins/drag_callbacks_test.dart index ec349b3c0f8..d52b1985605 100644 --- a/packages/flame/test/events/component_mixins/drag_callbacks_test.dart +++ b/packages/flame/test/events/component_mixins/drag_callbacks_test.dart @@ -5,19 +5,58 @@ import 'package:flame_test/flame_test.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'input_test_helper.dart'; + void main() { group('DragCallbacks', () { testWithFlameGame( 'make sure DragCallback components can be added to a FlameGame', (game) async { - await game.add(_DragCallbacksComponent()); + await game.add(DragCallbacksComponent()); + await game.ready(); + expect(game.children.toList()[2], isA()); + }, + ); + + testWithFlameGame( + 'removing the last DragCallbacks component disables hasDrag on the ' + 'recognizer', + (game) async { + final component = DragCallbacksComponent()..size = Vector2.all(10); + await game.ensureAdd(component); + final dispatcher = game.firstChild()!; + expect(dispatcher.hasDrag, isTrue); + + game.remove(component); + await game.ready(); + + expect(dispatcher.hasDrag, isFalse); + }, + ); + + testWithFlameGame( + 'hasDrag stays true while at least one DragCallbacks component remains', + (game) async { + final a = DragCallbacksComponent()..size = Vector2.all(10); + final b = DragCallbacksComponent()..size = Vector2.all(10); + await game.ensureAdd(a); + await game.ensureAdd(b); + final dispatcher = game.firstChild()!; + + game.remove(a); + await game.ready(); + + expect(dispatcher.hasDrag, isTrue); + + game.remove(b); await game.ready(); - expect(game.children.toList()[2], isA()); + + expect(dispatcher.hasDrag, isFalse); }, ); testWithFlameGame('drag event start', (game) async { - final component = _DragCallbacksComponent() + final component = DragCallbacksComponent() ..x = 10 ..y = 10 ..width = 10 @@ -25,8 +64,8 @@ void main() { game.add(component); await game.ready(); - expect(game.children.whereType().length, 1); - game.firstChild()!.onDragStart( + expect(game.children.whereType().length, 1); + game.firstChild()!.onDragStart( createDragStartEvents( game: game, localPosition: const Offset(12, 12), @@ -37,13 +76,13 @@ void main() { }); testWithFlameGame('drag event start, update and cancel', (game) async { - final component = _DragCallbacksComponent() + final component = DragCallbacksComponent() ..x = 10 ..y = 10 ..width = 10 ..height = 10; await game.ensureAdd(component); - final dispatcher = game.firstChild()!; + final dispatcher = game.firstChild()!; dispatcher.onDragStart( createDragStartEvents( @@ -74,13 +113,13 @@ void main() { testWithFlameGame( 'removed dragged component receives cancel on update and clears state', (game) async { - final component = _DragCallbacksComponent() + final component = DragCallbacksComponent() ..x = 10 ..y = 10 ..width = 10 ..height = 10; await game.ensureAdd(component); - final dispatcher = game.firstChild()!; + final dispatcher = game.firstChild()!; dispatcher.onDragStart( createDragStartEvents( @@ -115,13 +154,13 @@ void main() { testWithFlameGame( 'drag event update not called without onDragStart', (game) async { - final component = _DragCallbacksComponent() + final component = DragCallbacksComponent() ..x = 10 ..y = 10 ..width = 10 ..height = 10; await game.ensureAdd(component); - final dispatcher = game.firstChild()!; + final dispatcher = game.firstChild()!; expect(component.dragStartEvent, equals(0)); expect(component.dragUpdateEvent, equals(0)); @@ -139,7 +178,7 @@ void main() { testWidgets( 'drag correctly registered handled event', (tester) async { - final component = _DragCallbacksComponent() + final component = DragCallbacksComponent() ..x = 10 ..y = 10 ..width = 10 @@ -162,7 +201,7 @@ void main() { testWidgets( 'drag outside of component is not registered as handled', (tester) async { - final component = _DragCallbacksComponent()..size = Vector2.all(100); + final component = DragCallbacksComponent()..size = Vector2.all(100); final game = FlameGame(children: [component]); await tester.pumpWidget(GameWidget(game: game)); await tester.pump(); @@ -179,18 +218,18 @@ void main() { testWithGame( 'make sure the FlameGame can registers DragCallback on itself', - _DragCallbacksGame.new, + DragCallbacksGame.new, (game) async { await game.ready(); expect(game.children.length, equals(3)); - expect(game.children.elementAt(1), isA()); + expect(game.children.elementAt(1), isA()); }, ); testWidgets( 'drag correctly registered handled event directly on FlameGame', (tester) async { - final game = _DragCallbacksGame()..onGameResize(Vector2.all(300)); + final game = DragCallbacksGame()..onGameResize(Vector2.all(300)); await tester.pumpWidget(GameWidget(game: game)); await tester.pump(); await tester.pump(); @@ -208,7 +247,7 @@ void main() { testWidgets( 'isDragged is changed', (tester) async { - final component = _DragCallbacksComponent()..size = Vector2.all(100); + final component = DragCallbacksComponent()..size = Vector2.all(100); final game = FlameGame(children: [component]); await tester.pumpWidget(GameWidget(game: game)); await tester.pump(); @@ -234,7 +273,7 @@ void main() { var nDragEndCalled = 0; final game = FlameGame( children: [ - _DragWithCallbacksComponent( + DragWithCallbacksComponent( position: Vector2(20, 20), size: Vector2(100, 100), onDragStart: (e) => nDragStartCalled++, @@ -248,8 +287,8 @@ void main() { await tester.pump(const Duration(milliseconds: 10)); expect(game.children.length, 4); - expect(game.children.elementAt(1), isA<_DragWithCallbacksComponent>()); - expect(game.children.elementAt(2), isA()); + expect(game.children.elementAt(1), isA()); + expect(game.children.elementAt(2), isA()); // regular drag await tester.timedDragFrom( @@ -277,20 +316,20 @@ void main() { var nEvents = 0; final game = FlameGame( children: [ - _DragWithCallbacksComponent( + DragWithCallbacksComponent( size: Vector2.all(100), onDragStart: (e) => nEvents++, onDragUpdate: (e) => nEvents++, onDragEnd: (e) => nEvents++, ), - _SimpleDragCallbacksComponent(size: Vector2.all(200)), + SimpleDragCallbacksComponent(size: Vector2.all(200)), ], ); await tester.pumpWidget(GameWidget(game: game)); await tester.pump(); await tester.pump(); expect(game.children.length, 5); - expect(game.children.elementAt(3), isA()); + expect(game.children.elementAt(3), isA()); await tester.timedDragFrom( const Offset(20, 20), @@ -307,7 +346,7 @@ void main() { final points = []; final game = FlameGame( children: [ - _DragWithCallbacksComponent( + DragWithCallbacksComponent( size: Vector2.all(95), position: Vector2.all(5), onDragUpdate: (e) => points.add(e.localStartPosition), @@ -318,7 +357,7 @@ void main() { await tester.pump(); await tester.pump(); expect(game.children.length, 4); - expect(game.children.elementAt(2), isA()); + expect(game.children.elementAt(2), isA()); await tester.timedDragFrom( const Offset(80, 80), @@ -353,7 +392,7 @@ void main() { final deltas = []; await game.world.add( - _DragWithCallbacksComponent( + DragWithCallbacksComponent( position: Vector2.all(-5), size: Vector2.all(10), onDragUpdate: (event) => deltas.add(event.localDelta), @@ -390,7 +429,7 @@ void main() { final deltas = []; await game.world.add( - _DragWithCallbacksComponent( + DragWithCallbacksComponent( position: Vector2.all(-5), size: Vector2.all(10), onDragUpdate: (event) => deltas.add(event.localDelta), @@ -434,94 +473,3 @@ void main() { }, ); } - -mixin _DragCounter on DragCallbacks { - int dragStartEvent = 0; - int dragUpdateEvent = 0; - int dragEndEvent = 0; - int dragCancelEvent = 0; - int isDraggedStateChange = 0; - - bool _wasDragged = false; - - @override - void onDragStart(DragStartEvent event) { - super.onDragStart(event); - expect(event.raw, isNotNull); - event.handled = true; - dragStartEvent++; - if (_wasDragged != isDragged) { - ++isDraggedStateChange; - _wasDragged = isDragged; - } - } - - @override - void onDragUpdate(DragUpdateEvent event) { - expect(event.raw, isNotNull); - event.handled = true; - dragUpdateEvent++; - } - - @override - void onDragEnd(DragEndEvent event) { - super.onDragEnd(event); - expect(event.raw, isNotNull); - event.handled = true; - dragEndEvent++; - if (_wasDragged != isDragged) { - ++isDraggedStateChange; - _wasDragged = isDragged; - } - } - - @override - void onDragCancel(DragCancelEvent event) { - super.onDragCancel(event); - event.handled = true; - dragCancelEvent++; - } -} - -class _DragCallbacksComponent extends PositionComponent - with DragCallbacks, _DragCounter {} - -class _DragCallbacksGame extends FlameGame with DragCallbacks, _DragCounter {} - -class _DragWithCallbacksComponent extends PositionComponent with DragCallbacks { - _DragWithCallbacksComponent({ - void Function(DragStartEvent)? onDragStart, - void Function(DragUpdateEvent)? onDragUpdate, - void Function(DragEndEvent)? onDragEnd, - super.position, - super.size, - }) : _onDragStart = onDragStart, - _onDragUpdate = onDragUpdate, - _onDragEnd = onDragEnd; - - final void Function(DragStartEvent)? _onDragStart; - final void Function(DragUpdateEvent)? _onDragUpdate; - final void Function(DragEndEvent)? _onDragEnd; - - @override - void onDragStart(DragStartEvent event) { - super.onDragStart(event); - return _onDragStart?.call(event); - } - - @override - void onDragUpdate(DragUpdateEvent event) { - return _onDragUpdate?.call(event); - } - - @override - void onDragEnd(DragEndEvent event) { - super.onDragEnd(event); - return _onDragEnd?.call(event); - } -} - -class _SimpleDragCallbacksComponent extends PositionComponent - with DragCallbacks { - _SimpleDragCallbacksComponent({super.size}); -} diff --git a/packages/flame/test/events/component_mixins/input_test_helper.dart b/packages/flame/test/events/component_mixins/input_test_helper.dart new file mode 100644 index 00000000000..79af9556f77 --- /dev/null +++ b/packages/flame/test/events/component_mixins/input_test_helper.dart @@ -0,0 +1,432 @@ +import 'package:flame/components.dart'; +import 'package:flame/events.dart' hide PointerMoveEvent; +import 'package:flame/game.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter_test/flutter_test.dart'; + +/// Creates a [FlameGame] with a fixed 80x60 resolution camera, matching the +/// canvas size used across the scale/drag event tests. +FlameGame makeFixedResolutionGame() => FlameGame( + camera: CameraComponent.withFixedResolution(width: 80, height: 60), +); + +mixin DragCounter on DragCallbacks { + int dragStartEvent = 0; + int dragUpdateEvent = 0; + int dragEndEvent = 0; + int dragCancelEvent = 0; + int isDraggedStateChange = 0; + + bool _wasDragged = false; + + @override + void onDragStart(DragStartEvent event) { + super.onDragStart(event); + event.handled = true; + dragStartEvent++; + if (_wasDragged != isDragged) { + ++isDraggedStateChange; + _wasDragged = isDragged; + } + } + + @override + void onDragUpdate(DragUpdateEvent event) { + super.onDragUpdate(event); + event.handled = true; + dragUpdateEvent++; + } + + @override + void onDragEnd(DragEndEvent event) { + super.onDragEnd(event); + event.handled = true; + dragEndEvent++; + if (_wasDragged != isDragged) { + ++isDraggedStateChange; + _wasDragged = isDragged; + } + } + + @override + void onDragCancel(DragCancelEvent event) { + super.onDragCancel(event); + event.handled = true; + dragCancelEvent++; + } +} + +mixin ScaleCounter on ScaleCallbacks { + int scaleStartEvent = 0; + int scaleUpdateEvent = 0; + int scaleEndEvent = 0; + + int isScaledStateChange = 0; + + bool _wasScaled = false; + + @override + void onScaleStart(ScaleStartEvent event) { + super.onScaleStart(event); + expect(event.raw, isNotNull); + event.handled = true; + scaleStartEvent++; + if (_wasScaled != isScaling) { + ++isScaledStateChange; + _wasScaled = isScaling; + } + } + + @override + void onScaleUpdate(ScaleUpdateEvent event) { + super.onScaleUpdate(event); + expect(event.raw, isNotNull); + event.handled = true; + scaleUpdateEvent++; + } + + @override + void onScaleEnd(ScaleEndEvent event) { + super.onScaleEnd(event); + expect(event.raw, isNotNull); + event.handled = true; + scaleEndEvent++; + if (_wasScaled != isScaling) { + ++isScaledStateChange; + _wasScaled = isScaling; + } + } +} + +class DragWithCallbacksComponent extends PositionComponent with DragCallbacks { + DragWithCallbacksComponent({ + void Function(DragStartEvent)? onDragStart, + void Function(DragUpdateEvent)? onDragUpdate, + void Function(DragEndEvent)? onDragEnd, + super.position, + super.size, + }) : _onDragStart = onDragStart, + _onDragUpdate = onDragUpdate, + _onDragEnd = onDragEnd; + + final void Function(DragStartEvent)? _onDragStart; + final void Function(DragUpdateEvent)? _onDragUpdate; + final void Function(DragEndEvent)? _onDragEnd; + + @override + void onDragStart(DragStartEvent event) { + super.onDragStart(event); + return _onDragStart?.call(event); + } + + @override + void onDragUpdate(DragUpdateEvent event) { + return _onDragUpdate?.call(event); + } + + @override + void onDragEnd(DragEndEvent event) { + super.onDragEnd(event); + return _onDragEnd?.call(event); + } +} + +class ScaleWithCallbacksComponent extends PositionComponent + with ScaleCallbacks { + ScaleWithCallbacksComponent({ + void Function(ScaleStartEvent)? onScaleStart, + void Function(ScaleUpdateEvent)? onScaleUpdate, + void Function(ScaleEndEvent)? onScaleEnd, + super.position, + super.size, + }) : _onScaleStart = onScaleStart, + _onScaleUpdate = onScaleUpdate, + _onScaleEnd = onScaleEnd; + + final void Function(ScaleStartEvent)? _onScaleStart; + final void Function(ScaleUpdateEvent)? _onScaleUpdate; + final void Function(ScaleEndEvent)? _onScaleEnd; + + @override + void onScaleStart(ScaleStartEvent event) { + super.onScaleStart(event); + return _onScaleStart?.call(event); + } + + @override + void onScaleUpdate(ScaleUpdateEvent event) { + return _onScaleUpdate?.call(event); + } + + @override + void onScaleEnd(ScaleEndEvent event) { + super.onScaleEnd(event); + return _onScaleEnd?.call(event); + } +} + +class ScaleDragCallbacksComponent extends PositionComponent + with ScaleCallbacks, DragCallbacks, ScaleCounter, DragCounter {} + +class ScaleDragCallbacksGame extends FlameGame + with ScaleCallbacks, DragCallbacks, ScaleCounter, DragCounter {} + +class SimpleScaleDragCallbacksComponent extends PositionComponent + with ScaleCallbacks, DragCallbacks { + SimpleScaleDragCallbacksComponent({super.size}); +} + +class ScaleDragWithCallbacksComponent extends PositionComponent + with ScaleCallbacks, DragCallbacks, ScaleCounter, DragCounter { + ScaleDragWithCallbacksComponent({ + void Function(ScaleStartEvent)? onScaleStart, + void Function(ScaleUpdateEvent)? onScaleUpdate, + void Function(ScaleEndEvent)? onScaleEnd, + void Function(DragStartEvent)? onDragStart, + void Function(DragUpdateEvent)? onDragUpdate, + void Function(DragEndEvent)? onDragEnd, + super.position, + super.size, + }) : _onScaleStart = onScaleStart, + _onScaleUpdate = onScaleUpdate, + _onScaleEnd = onScaleEnd, + _onDragStart = onDragStart, + _onDragUpdate = onDragUpdate, + _onDragEnd = onDragEnd; + + final void Function(ScaleStartEvent)? _onScaleStart; + final void Function(ScaleUpdateEvent)? _onScaleUpdate; + final void Function(ScaleEndEvent)? _onScaleEnd; + final void Function(DragStartEvent)? _onDragStart; + final void Function(DragUpdateEvent)? _onDragUpdate; + final void Function(DragEndEvent)? _onDragEnd; + + @override + void onScaleStart(ScaleStartEvent event) { + super.onScaleStart(event); + return _onScaleStart?.call(event); + } + + @override + void onScaleUpdate(ScaleUpdateEvent event) { + super.onScaleUpdate(event); + return _onScaleUpdate?.call(event); + } + + @override + void onScaleEnd(ScaleEndEvent event) { + super.onScaleEnd(event); + return _onScaleEnd?.call(event); + } + + @override + void onDragStart(DragStartEvent event) { + super.onDragStart(event); + return _onDragStart?.call(event); + } + + @override + void onDragUpdate(DragUpdateEvent event) { + super.onDragUpdate(event); + return _onDragUpdate?.call(event); + } + + @override + void onDragEnd(DragEndEvent event) { + super.onDragEnd(event); + return _onDragEnd?.call(event); + } +} + +extension ZoomTesting on WidgetTester { + /// Simulates a timed two-finger pinch/zoom gesture by generating pointer + /// event records with accurate timestamps for both pointers. + Future timedZoomFrom( + Offset start1, + Offset offset1, + Offset start2, + Offset offset2, + Duration duration, { + int? pointer, + int buttons = kPrimaryButton, + int intervals = 30, + }) { + assert(intervals > 1); + final pointer1 = pointer ?? nextPointer; + final pointer2 = pointer1 + 1; + + final records = [ + // Both pointers land simultaneously at t=0. + PointerEventRecord(Duration.zero, [ + PointerAddedEvent(position: start1), + PointerAddedEvent(position: start2), + PointerDownEvent( + position: start1, + pointer: pointer1, + buttons: buttons, + ), + PointerDownEvent( + position: start2, + pointer: pointer2, + buttons: buttons, + ), + ]), + ]; + + // Generate interleaved move events for both pointers at each step. + var previousPosition1 = start1; + var previousPosition2 = start2; + for (var step = 0; step <= intervals; step++) { + final progress = step / intervals; + final timeStamp = duration * step ~/ intervals; + final position1 = start1 + offset1 * progress; + final position2 = start2 + offset2 * progress; + records.add( + PointerEventRecord(timeStamp, [ + PointerMoveEvent( + timeStamp: timeStamp, + position: position1, + delta: position1 - previousPosition1, + pointer: pointer1, + buttons: buttons, + ), + PointerMoveEvent( + timeStamp: timeStamp, + position: position2, + delta: position2 - previousPosition2, + pointer: pointer2, + buttons: buttons, + ), + ]), + ); + previousPosition1 = position1; + previousPosition2 = position2; + } + + // Both pointers lift at the end. + records.add( + PointerEventRecord(duration, [ + PointerUpEvent( + timeStamp: duration, + position: previousPosition1, + pointer: pointer1, + ), + PointerUpEvent( + timeStamp: duration, + position: previousPosition2, + pointer: pointer2, + ), + ]), + ); + + return TestAsyncUtils.guard(() async { + await handlePointerEventRecord(records); + }); + } + + Future zoomFrom({ + required Offset startLocation1, + required Offset offset1, + required Offset startLocation2, + required Offset offset2, + }) async { + // Start two gestures on opposite sides of that center + final gesture1 = await startGesture(startLocation1); + final gesture2 = await startGesture(startLocation2); + + await pump(); + + await gesture1.moveBy(offset1); + await gesture2.moveBy(offset2); + await pump(); + + // release fingers + await gesture1.up(); + await gesture2.up(); + + await pump(); + } + + Future dragWithInjection( + Offset start, + Offset delta, + Duration duration, + Future Function() onHalfway, { + int steps = 20, + }) async { + final gesture = await startGesture(start); + final dt = duration ~/ steps; + + for (var i = 0; i < steps; i++) { + if (i == steps ~/ 2) { + await onHalfway(); + } + + final t = (i + 1) / steps; + await gesture.moveTo(start + delta * t); + await pump(dt); + } + + await gesture.up(); + await pump(); + } + + Future zoomFromWithInjection({ + required Offset startLocation1, + required Offset offset1, + required Offset startLocation2, + required Offset offset2, + required Duration duration, + required Future Function() onHalfway, + int steps = 20, + }) async { + // Start both fingers + final gesture1 = await startGesture(startLocation1); + final gesture2 = await startGesture(startLocation2); + + await pump(); + + final dt = duration ~/ steps; + + for (var i = 0; i < steps; i++) { + // Inject custom logic at halfway + if (i == steps ~/ 2) { + await onHalfway(); + await pump(); + } + + final t = (i + 1) / steps; + + await gesture1.moveTo(startLocation1 + offset1 * t); + await gesture2.moveTo(startLocation2 + offset2 * t); + + await pump(dt); + } + + // Release both gestures + await gesture1.up(); + await gesture2.up(); + + await pump(); + } +} + +class ScaleCallbacksComponent extends PositionComponent + with ScaleCallbacks, ScaleCounter {} + +class ScaleCallbacksGame extends FlameGame with ScaleCallbacks, ScaleCounter {} + +class SimpleScaleCallbacksComponent extends PositionComponent + with ScaleCallbacks { + SimpleScaleCallbacksComponent({super.size}); +} + +class SimpleDragCallbacksComponent extends PositionComponent + with DragCallbacks { + SimpleDragCallbacksComponent({super.size}); +} + +class DragCallbacksComponent extends PositionComponent + with DragCallbacks, DragCounter {} + +class DragCallbacksGame extends FlameGame with DragCallbacks, DragCounter {} diff --git a/packages/flame/test/events/component_mixins/scale_callbacks_test.dart b/packages/flame/test/events/component_mixins/scale_callbacks_test.dart index aae99b33148..19cb593734c 100644 --- a/packages/flame/test/events/component_mixins/scale_callbacks_test.dart +++ b/packages/flame/test/events/component_mixins/scale_callbacks_test.dart @@ -1,27 +1,62 @@ import 'dart:math'; -import 'package:flame/components.dart'; import 'package:flame/events.dart' hide PointerMoveEvent; import 'package:flame/game.dart'; -import 'package:flame/src/events/flame_game_mixins/scale_dispatcher.dart'; import 'package:flame_test/flame_test.dart'; -import 'package:flutter/gestures.dart' show PointerAddedEvent, kPrimaryButton; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'input_test_helper.dart'; void main() { group('ScaleCallbacks', () { testWithFlameGame( 'make sure ScaleCallback components can be added to a FlameGame', (game) async { - await game.add(_ScaleCallbacksComponent()); + await game.add(ScaleCallbacksComponent()); await game.ready(); - expect(game.children.toList()[2], isA()); + expect(game.children.toList()[2], isA()); + }, + ); + + testWithFlameGame( + 'removing the last ScaleCallbacks component disables hasScale on the ' + 'recognizer', + (game) async { + final component = ScaleCallbacksComponent()..size = Vector2.all(10); + await game.ensureAdd(component); + final dispatcher = game.firstChild()!; + expect(dispatcher.hasScale, isTrue); + + game.remove(component); + await game.ready(); + + expect(dispatcher.hasScale, isFalse); + }, + ); + + testWithFlameGame( + 'hasScale stays true while at least one ScaleCallbacks component remains', + (game) async { + final a = ScaleCallbacksComponent()..size = Vector2.all(10); + final b = ScaleCallbacksComponent()..size = Vector2.all(10); + await game.ensureAdd(a); + await game.ensureAdd(b); + final dispatcher = game.firstChild()!; + + game.remove(a); + await game.ready(); + + expect(dispatcher.hasScale, isTrue); + + game.remove(b); + await game.ready(); + + expect(dispatcher.hasScale, isFalse); }, ); }); testWithFlameGame('scale event start', (game) async { - final component = _ScaleCallbacksComponent() + final component = ScaleCallbacksComponent() ..x = 10 ..y = 10 ..width = 10 @@ -29,8 +64,8 @@ void main() { game.add(component); await game.ready(); - expect(game.children.whereType().length, 1); - game.firstChild()!.onScaleStart( + expect(game.children.whereType().length, 1); + game.firstChild()!.onScaleStart( createScaleStartEvents( game: game, localFocalPoint: const Offset(12, 12), @@ -41,13 +76,13 @@ void main() { }); testWithFlameGame('scale event start, update and end', (game) async { - final component = _ScaleCallbacksComponent() + final component = ScaleCallbacksComponent() ..x = 10 ..y = 10 ..width = 10 ..height = 10; await game.ensureAdd(component); - final dispatcher = game.firstChild()!; + final dispatcher = game.firstChild()!; dispatcher.onScaleStart( createScaleStartEvents( @@ -75,16 +110,55 @@ void main() { expect(component.scaleEndEvent, equals(1)); }); + testWithFlameGame( + 'removed scale component receives scale end on update and clears state', + (game) async { + final component = ScaleCallbacksComponent() + ..x = 10 + ..y = 10 + ..width = 10 + ..height = 10; + await game.ensureAdd(component); + final dispatcher = game.firstChild()!; + + dispatcher.onScaleStart( + createScaleStartEvents( + game: game, + localFocalPoint: const Offset(12, 12), + focalPoint: const Offset(12, 12), + ), + ); + expect(component.isScaling, isTrue); + + game.remove(component); + await game.ready(); + + dispatcher.onScaleUpdate( + createScaleUpdateEvents( + game: game, + localFocalPoint: const Offset(15, 15), + focalPoint: const Offset(15, 15), + ), + ); + + expect(component.scaleEndEvent, equals(1)); + expect(component.isScaling, isFalse); + + dispatcher.onScaleEnd(ScaleEndEvent(1, ScaleEndDetails())); + expect(component.scaleEndEvent, equals(1)); + }, + ); + testWithFlameGame( 'scale event update not called without onScaleStart', (game) async { - final component = _ScaleCallbacksComponent() + final component = ScaleCallbacksComponent() ..x = 10 ..y = 10 ..width = 10 ..height = 10; await game.ensureAdd(component); - final dispatcher = game.firstChild()!; + final dispatcher = game.firstChild()!; expect(component.scaleStartEvent, equals(0)); expect(component.scaleUpdateEvent, equals(0)); @@ -100,7 +174,7 @@ void main() { ); testWidgets('scale correctly registered handled event', (tester) async { - final component = _ScaleCallbacksComponent() + final component = ScaleCallbacksComponent() ..x = 100 ..y = 100 ..width = 150 @@ -110,8 +184,7 @@ void main() { await tester.pumpWidget(GameWidget(game: game)); await tester.pump(); - await _zoomFrom( - tester, + await tester.zoomFrom( startLocation1: const Offset(180, 150), offset1: const Offset(15, 2), startLocation2: const Offset(120, 150), @@ -121,23 +194,22 @@ void main() { expect(game.children.length, equals(4)); expect(component.isMounted, isTrue); - expect(component.scaleStartEvent, equals(2)); + expect(component.scaleStartEvent, equals(1)); expect(component.scaleUpdateEvent, greaterThan(0)); - expect(component.scaleEndEvent, equals(2)); + expect(component.scaleEndEvent, equals(1)); }); testWidgets( 'scale outside of component is not registered as handled', (tester) async { - final component = _ScaleCallbacksComponent()..size = Vector2.all(100); + final component = ScaleCallbacksComponent()..size = Vector2.all(100); final game = FlameGame(children: [component]); await tester.pumpWidget(GameWidget(game: game)); await tester.pump(); await tester.pump(); expect(component.isMounted, isTrue); - await _zoomFrom( - tester, + await tester.zoomFrom( startLocation1: const Offset(250, 200), offset1: const Offset(15, 2), startLocation2: const Offset(150, 200), @@ -152,42 +224,41 @@ void main() { testWithGame( 'make sure the FlameGame can registers Scale Callbacks on itself', - _ScaleCallbacksGame.new, + ScaleCallbacksGame.new, (game) async { await game.ready(); expect(game.children.length, equals(3)); - expect(game.children.elementAt(1), isA()); + expect(game.children.elementAt(1), isA()); }, ); testWidgets( 'scale correctly registered handled event directly on FlameGame', (tester) async { - final game = _ScaleCallbacksGame()..onGameResize(Vector2.all(300)); + final game = ScaleCallbacksGame()..onGameResize(Vector2.all(300)); await tester.pumpWidget(GameWidget(game: game)); await tester.pump(); await tester.pump(); expect(game.children.length, equals(3)); expect(game.isMounted, isTrue); - await _zoomFrom( - tester, + await tester.zoomFrom( startLocation1: const Offset(50, 100), offset1: const Offset(15, 2), startLocation2: const Offset(150, 100), offset2: const Offset(-15, -2), ); - expect(game.scaleStartEvent, equals(2)); + expect(game.scaleStartEvent, equals(1)); expect(game.scaleUpdateEvent, greaterThan(0)); - expect(game.scaleEndEvent, equals(2)); + expect(game.scaleEndEvent, equals(1)); }, ); testWidgets( 'isScaled is changed', (tester) async { - final component = _ScaleCallbacksComponent() + final component = ScaleCallbacksComponent() ..size = Vector2.all(100) ..x = 100 ..y = 100; @@ -198,26 +269,24 @@ void main() { await tester.pump(); // Inside component - await _zoomFrom( - tester, + await tester.zoomFrom( startLocation1: const Offset(180, 100), offset1: const Offset(15, 2), startLocation2: const Offset(120, 100), offset2: const Offset(-15, -2), ); - expect(component.isScaledStateChange, equals(4)); + expect(component.isScaledStateChange, equals(2)); // Outside component - await _zoomFrom( - tester, + await tester.zoomFrom( startLocation1: const Offset(330, 300), offset1: const Offset(15, 2), startLocation2: const Offset(270, 300), offset2: const Offset(-15, -2), ); - expect(component.isScaledStateChange, equals(4)); + expect(component.isScaledStateChange, equals(2)); }, ); group('HasScalableComponents', () { @@ -227,21 +296,20 @@ void main() { var nEvents = 0; final game = FlameGame( children: [ - _ScaleWithCallbacksComponent( + ScaleWithCallbacksComponent( size: Vector2.all(100), onScaleStart: (e) => nEvents++, onScaleUpdate: (e) => nEvents++, onScaleEnd: (e) => nEvents++, ), - _SimpleScaleCallbacksComponent(size: Vector2.all(200)) + SimpleScaleCallbacksComponent(size: Vector2.all(200)) ..priority = 10, ], ); await tester.pumpWidget(GameWidget(game: game)); await tester.pump(); await tester.pump(); - await _zoomFrom( - tester, + await tester.zoomFrom( startLocation1: const Offset(80, 50), offset1: const Offset(15, 2), startLocation2: const Offset(20, 50), @@ -256,7 +324,7 @@ void main() { (tester) async { var nEvents = 0; const intervals = 50; - final component = _ScaleWithCallbacksComponent( + final component = ScaleWithCallbacksComponent( size: Vector2.all(30), position: Vector2.all(100), onScaleUpdate: (e) => nEvents++, @@ -268,7 +336,7 @@ void main() { await tester.pump(); const center = Offset(115, 115); - await tester._timedZoomFrom( + await tester.timedZoomFrom( center.translate(-10, 0), const Offset(-30, 0), center.translate(10, 0), @@ -277,27 +345,21 @@ void main() { intervals: intervals, ); - expect(nEvents, intervals * 2 + 2); + expect(nEvents, intervals * 2 - 1); }, ); }); testWidgets( - 'scale event scale respects camera & zoom', + 'scale event scale factor respects camera & zoom', (tester) async { - final resolution = Vector2(80, 60); - final game = FlameGame( - camera: CameraComponent.withFixedResolution( - width: resolution.x, - height: resolution.y, - ), - ); + final game = makeFixedResolutionGame(); final scales = []; game.camera.viewfinder.zoom = 3; await game.world.add( - _ScaleWithCallbacksComponent( + ScaleWithCallbacksComponent( position: Vector2.all(-5), size: Vector2.all(10), onScaleUpdate: (event) { @@ -312,7 +374,7 @@ void main() { final canvasSize = game.canvasSize; final center = (canvasSize / 2).toOffset(); - await tester._timedZoomFrom( + await tester.timedZoomFrom( center.translate(-1, 0), const Offset(-20, 0), center.translate(1, 0), @@ -321,26 +383,20 @@ void main() { intervals: 10, ); - expect(scales.skip(1), List.generate(21, (i) => i + 1)); + expect(scales, List.generate(20, (i) => i + 2)); }, ); testWidgets( 'scale event rotation respects camera & zoom', (tester) async { - final resolution = Vector2(80, 60); - final game = FlameGame( - camera: CameraComponent.withFixedResolution( - width: resolution.x, - height: resolution.y, - ), - ); - var rotations = []; + final game = makeFixedResolutionGame(); + final rotations = []; game.camera.viewfinder.zoom = 3; await game.world.add( - _ScaleWithCallbacksComponent( + ScaleWithCallbacksComponent( position: Vector2.all(-5), size: Vector2.all(10), onScaleUpdate: (event) { @@ -355,7 +411,7 @@ void main() { final canvasSize = game.canvasSize; final center = (canvasSize / 2).toOffset(); - await tester._timedZoomFrom( + await tester.timedZoomFrom( center.translate(-1, 0), const Offset(0, 20), center.translate(1, 0), @@ -366,211 +422,10 @@ void main() { // computation of angle using trigonometry with triangle having a size // of length 1 and one of length i. - final expected = List.generate(21, (i) => -atan(i)); - - // remove the first element that is registered twice in the simulation - rotations = rotations.sublist(1); + final expected = List.generate(20, (i) => -atan(i + 1)); for (var i = 0; i < expected.length; i++) { expect(rotations[i], closeTo(expected[i], 1e-6)); // tolerance } }, ); } - -Future _zoomFrom( - WidgetTester tester, { - required Offset startLocation1, - required Offset offset1, - required Offset startLocation2, - required Offset offset2, -}) async { - // Start two gestures on opposite sides of that center - final gesture1 = await tester.startGesture(startLocation1); - final gesture2 = await tester.startGesture(startLocation2); - - await tester.pump(); - - await gesture1.moveBy(offset1); - await gesture2.moveBy(offset2); - await tester.pump(); - - // release fingers - await gesture1.up(); - await gesture2.up(); - - await tester.pump(); -} - -mixin _ScaleCounter on ScaleCallbacks { - int scaleStartEvent = 0; - int scaleUpdateEvent = 0; - int scaleEndEvent = 0; - - int isScaledStateChange = 0; - - bool _wasScaled = false; - - @override - void onScaleStart(ScaleStartEvent event) { - super.onScaleStart(event); - expect(event.raw, isNotNull); - event.handled = true; - scaleStartEvent++; - if (_wasScaled != isScaling) { - ++isScaledStateChange; - _wasScaled = isScaling; - } - } - - @override - void onScaleUpdate(ScaleUpdateEvent event) { - expect(event.raw, isNotNull); - event.handled = true; - scaleUpdateEvent++; - } - - @override - void onScaleEnd(ScaleEndEvent event) { - super.onScaleEnd(event); - expect(event.raw, isNotNull); - event.handled = true; - scaleEndEvent++; - if (_wasScaled != isScaling) { - ++isScaledStateChange; - _wasScaled = isScaling; - } - } -} - -// Source - https://stackoverflow.com/a/75171528 -// Posted by Alexander -// Retrieved 2025-11-19, License - CC BY-SA 4.0 - -extension _ZoomTesting on WidgetTester { - Future _timedZoomFrom( - Offset startLocation1, - Offset offset1, - Offset startLocation2, - Offset offset2, - Duration duration, { - int? pointer, - int buttons = kPrimaryButton, - int intervals = 30, - }) { - assert(intervals > 1); - pointer ??= nextPointer; - final pointer2 = pointer + 1; - final timeStamps = [ - for (int t = 0; t <= intervals; t += 1) duration * t ~/ intervals, - ]; - final offsets1 = [ - startLocation1, - for (int t = 0; t <= intervals; t += 1) - startLocation1 + offset1 * (t / intervals), - ]; - final offsets2 = [ - startLocation2, - for (int t = 0; t <= intervals; t += 1) - startLocation2 + offset2 * (t / intervals), - ]; - final records = [ - PointerEventRecord(Duration.zero, [ - PointerAddedEvent( - position: startLocation1, - ), - PointerAddedEvent( - position: startLocation2, - ), - PointerDownEvent( - position: startLocation1, - pointer: pointer, - buttons: buttons, - ), - PointerDownEvent( - position: startLocation2, - pointer: pointer2, - buttons: buttons, - ), - ]), - ...[ - for (int t = 0; t <= intervals; t += 1) - PointerEventRecord(timeStamps[t], [ - PointerMoveEvent( - timeStamp: timeStamps[t], - position: offsets1[t + 1], - delta: offsets1[t + 1] - offsets1[t], - pointer: pointer, - buttons: buttons, - ), - PointerMoveEvent( - timeStamp: timeStamps[t], - position: offsets2[t + 1], - delta: offsets2[t + 1] - offsets2[t], - pointer: pointer2, - buttons: buttons, - ), - ]), - ], - PointerEventRecord(duration, [ - PointerUpEvent( - timeStamp: duration, - position: offsets1.last, - pointer: pointer, - ), - PointerUpEvent( - timeStamp: duration, - position: offsets2.last, - pointer: pointer2, - ), - ]), - ]; - return TestAsyncUtils.guard(() async { - await handlePointerEventRecord(records); - }); - } -} - -class _ScaleCallbacksComponent extends PositionComponent - with ScaleCallbacks, _ScaleCounter {} - -class _ScaleCallbacksGame extends FlameGame - with ScaleCallbacks, _ScaleCounter {} - -class _SimpleScaleCallbacksComponent extends PositionComponent - with ScaleCallbacks { - _SimpleScaleCallbacksComponent({super.size}); -} - -class _ScaleWithCallbacksComponent extends PositionComponent - with ScaleCallbacks { - _ScaleWithCallbacksComponent({ - void Function(ScaleStartEvent)? onScaleStart, - void Function(ScaleUpdateEvent)? onScaleUpdate, - void Function(ScaleEndEvent)? onScaleEnd, - super.position, - super.size, - }) : _onScaleStart = onScaleStart, - _onScaleUpdate = onScaleUpdate, - _onScaleEnd = onScaleEnd; - - final void Function(ScaleStartEvent)? _onScaleStart; - final void Function(ScaleUpdateEvent)? _onScaleUpdate; - final void Function(ScaleEndEvent)? _onScaleEnd; - - @override - void onScaleStart(ScaleStartEvent event) { - super.onScaleStart(event); - return _onScaleStart?.call(event); - } - - @override - void onScaleUpdate(ScaleUpdateEvent event) { - return _onScaleUpdate?.call(event); - } - - @override - void onScaleEnd(ScaleEndEvent event) { - super.onScaleEnd(event); - return _onScaleEnd?.call(event); - } -} diff --git a/packages/flame/test/events/component_mixins/scale_drag_callbacks_test.dart b/packages/flame/test/events/component_mixins/scale_drag_callbacks_test.dart new file mode 100644 index 00000000000..0058860593e --- /dev/null +++ b/packages/flame/test/events/component_mixins/scale_drag_callbacks_test.dart @@ -0,0 +1,793 @@ +import 'dart:math'; + +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'; +import 'input_test_helper.dart'; + +void main() { + group('ScaleAndDragCallbacks', () { + testWithFlameGame( + '''make sure adding a component with both scale and drag mixins + adds a MultiDragScaleDispatcher''', + (game) async { + await game.add(ScaleDragCallbacksComponent()); + await game.ready(); + expect(game.children.toList()[2], isA()); + }, + ); + }); + + testWithFlameGame( + '''scale and drag events start, update and end on component + with both scale and drag mixins ''', + (game) async { + final component = ScaleDragCallbacksComponent() + ..x = 10 + ..y = 10 + ..width = 10 + ..height = 10; + await game.ensureAdd(component); + final scaleCallback = game.firstChild()!; + final dragCallback = game.firstChild()!; + + scaleCallback.onScaleStart( + createScaleStartEvents( + game: game, + localFocalPoint: const Offset(12, 12), + focalPoint: const Offset(12, 12), + ), + ); + expect(component.scaleStartEvent, 1); + expect(component.scaleUpdateEvent, 0); + expect(component.scaleEndEvent, 0); + + scaleCallback.onScaleUpdate( + createScaleUpdateEvents( + game: game, + localFocalPoint: const Offset(15, 15), + focalPoint: const Offset(15, 15), + ), + ); + + expect(game.containsLocalPoint(Vector2(9, 9)), isTrue); + expect(component.scaleUpdateEvent, equals(1)); + + scaleCallback.onScaleEnd(ScaleEndEvent(1, ScaleEndDetails())); + expect(component.scaleEndEvent, equals(1)); + + dragCallback.onDragStart( + createDragStartEvents( + game: game, + localPosition: const Offset(12, 12), + globalPosition: const Offset(12, 12), + ), + ); + expect(component.dragStartEvent, 1); + expect(component.dragUpdateEvent, 0); + expect(component.dragEndEvent, 0); + + dragCallback.onDragUpdate( + createDragUpdateEvents( + game: game, + localPosition: const Offset(15, 15), + globalPosition: const Offset(15, 15), + ), + ); + + expect(game.containsLocalPoint(Vector2(9, 9)), isTrue); + expect(component.dragUpdateEvent, equals(1)); + + dragCallback.onDragEnd(DragEndEvent(1, DragEndDetails())); + expect(component.dragEndEvent, equals(1)); + }, + ); + + testWithFlameGame( + 'removed dragged component receives cancel on update and clears state', + (game) async { + final component = ScaleDragCallbacksComponent() + ..x = 10 + ..y = 10 + ..width = 10 + ..height = 10; + await game.ensureAdd(component); + final dispatcher = game.firstChild()!; + + dispatcher.onDragStart( + createDragStartEvents( + game: game, + localPosition: const Offset(12, 12), + globalPosition: const Offset(12, 12), + ), + ); + expect(component.isDragged, isTrue); + + game.remove(component); + await game.ready(); + + dispatcher.onDragUpdate( + createDragUpdateEvents( + game: game, + localPosition: const Offset(15, 15), + globalPosition: const Offset(15, 15), + ), + ); + + expect(component.dragCancelEvent, equals(1)); + expect(component.dragEndEvent, equals(1)); + expect(component.isDragged, isFalse); + + dispatcher.onDragEnd(DragEndEvent(1, DragEndDetails())); + expect(component.dragCancelEvent, equals(1)); + expect(component.dragEndEvent, equals(1)); + }, + ); + + testWithFlameGame( + 'removed scale component receives scale end on update and clears state', + (game) async { + final component = ScaleDragCallbacksComponent() + ..x = 10 + ..y = 10 + ..width = 10 + ..height = 10; + await game.ensureAdd(component); + final dispatcher = game.firstChild()!; + + dispatcher.onScaleStart( + createScaleStartEvents( + game: game, + localFocalPoint: const Offset(12, 12), + focalPoint: const Offset(12, 12), + ), + ); + expect(component.isScaling, isTrue); + + game.remove(component); + await game.ready(); + + dispatcher.onScaleUpdate( + createScaleUpdateEvents( + game: game, + localFocalPoint: const Offset(15, 15), + focalPoint: const Offset(15, 15), + ), + ); + + expect(component.scaleEndEvent, equals(1)); + expect(component.isScaling, isFalse); + + dispatcher.onScaleEnd(ScaleEndEvent(1, ScaleEndDetails())); + expect(component.scaleEndEvent, equals(1)); + }, + ); + + testWithFlameGame( + 'onDragCancel is routed to components that received onDragStart', + (game) async { + final component = ScaleDragCallbacksComponent() + ..x = 10 + ..y = 10 + ..width = 10 + ..height = 10; + await game.ensureAdd(component); + final dispatcher = game.firstChild()!; + + dispatcher.onDragStart( + createDragStartEvents( + game: game, + localPosition: const Offset(12, 12), + globalPosition: const Offset(12, 12), + ), + ); + expect(component.dragStartEvent, equals(1)); + expect(component.dragCancelEvent, equals(0)); + + dispatcher.onDragCancel(DragCancelEvent(1)); + expect(component.dragCancelEvent, equals(1)); + // onDragCancel delegates to onDragEnd internally + expect(component.dragEndEvent, equals(1)); + expect(component.isDragged, isFalse); + + // record removed after cancel; subsequent end for same pointer is a no-op + dispatcher.onDragEnd(DragEndEvent(1, DragEndDetails())); + expect(component.dragEndEvent, equals(1)); + }, + ); + + testWithFlameGame( + 'scale and drag events update not called without onStart', + (game) async { + final component = ScaleDragCallbacksComponent() + ..x = 10 + ..y = 10 + ..width = 10 + ..height = 10; + await game.ensureAdd(component); + final dispatcher = game.firstChild()!; + expect(component.scaleStartEvent, equals(0)); + expect(component.scaleUpdateEvent, equals(0)); + + dispatcher.onScaleUpdate( + createScaleUpdateEvents( + game: game, + localFocalPoint: const Offset(15, 15), + focalPoint: const Offset(15, 15), + ), + ); + expect(component.scaleUpdateEvent, equals(0)); + expect(component.dragStartEvent, equals(0)); + expect(component.dragUpdateEvent, equals(0)); + + dispatcher.onDragUpdate( + createDragUpdateEvents( + game: game, + localPosition: const Offset(15, 15), + globalPosition: const Offset(15, 15), + ), + ); + expect(component.dragUpdateEvent, equals(0)); + }, + ); + + testWidgets('scale and drag correctly registered handled event', ( + tester, + ) async { + final component = ScaleDragCallbacksComponent() + ..x = 100 + ..y = 100 + ..width = 150 + ..height = 150; + final game = FlameGame(children: [component]); + + await tester.pumpWidget(GameWidget(game: game)); + await tester.pump(); + + await tester.zoomFrom( + startLocation1: const Offset(180, 150), + offset1: const Offset(15, 2), + startLocation2: const Offset(120, 150), + offset2: const Offset(-15, -2), + ); + await tester.pump(); + await tester.pump(); + + expect(game.children.length, equals(4)); + expect(component.isMounted, isTrue); + + expect(component.scaleStartEvent, equals(1)); + expect(component.scaleUpdateEvent, greaterThan(0)); + expect(component.scaleEndEvent, equals(1)); + + expect(component.dragStartEvent, equals(2)); + expect(component.dragUpdateEvent, greaterThan(0)); + expect(component.dragEndEvent, equals(2)); + expect(component.dragCancelEvent, equals(0)); + }); + + testWidgets( + 'scale and outside of component is not registered as handled', + (tester) async { + final component = ScaleDragCallbacksComponent()..size = Vector2.all(100); + final game = FlameGame(children: [component]); + await tester.pumpWidget(GameWidget(game: game)); + await tester.pump(); + await tester.pump(); + expect(component.isMounted, isTrue); + + await tester.zoomFrom( + startLocation1: const Offset(250, 200), + offset1: const Offset(15, 2), + startLocation2: const Offset(150, 200), + offset2: const Offset(-15, -2), + ); + + expect(component.scaleStartEvent, equals(0)); + expect(component.scaleUpdateEvent, equals(0)); + expect(component.scaleEndEvent, equals(0)); + expect(component.dragStartEvent, equals(0)); + expect(component.dragUpdateEvent, equals(0)); + expect(component.dragEndEvent, equals(0)); + }, + ); + + testWithGame( + 'make sure the FlameGame can registers Scale and Drag Callbacks on itself', + ScaleDragCallbacksGame.new, + (game) async { + await game.ready(); + expect(game.children.length, equals(3)); + expect(game.children.elementAt(1), isA()); + }, + ); + + testWidgets( + 'scale and drag correctly registered handled event directly on FlameGame', + (tester) async { + final game = ScaleDragCallbacksGame()..onGameResize(Vector2.all(300)); + await tester.pumpWidget(GameWidget(game: game)); + await tester.pump(); + await tester.pump(); + expect(game.children.length, equals(3)); + expect(game.isMounted, isTrue); + await tester.pump(); + await tester.pump(); + + await tester.zoomFrom( + startLocation1: const Offset(50, 100), + offset1: const Offset(15, 2), + startLocation2: const Offset(150, 100), + offset2: const Offset(-15, -2), + ); + + expect(game.scaleStartEvent, equals(1)); + expect(game.scaleUpdateEvent, greaterThan(0)); + expect(game.scaleEndEvent, equals(1)); + expect(game.dragStartEvent, equals(2)); + expect(game.dragUpdateEvent, greaterThan(0)); + expect(game.dragEndEvent, equals(2)); + expect(game.dragCancelEvent, equals(0)); + }, + ); + + testWidgets( + 'isScaling and isDragged is changed', + (tester) async { + final component = ScaleDragCallbacksComponent() + ..size = Vector2.all(100) + ..x = 100 + ..y = 100; + + final game = FlameGame(children: [component]); + await tester.pumpWidget(GameWidget(game: game)); + await tester.pump(); + await tester.pump(); + + // Inside component + await tester.zoomFrom( + startLocation1: const Offset(180, 100), + offset1: const Offset(15, 2), + startLocation2: const Offset(120, 100), + offset2: const Offset(-15, -2), + ); + + expect(component.isScaledStateChange, equals(2)); + expect(component.isDraggedStateChange, equals(2)); + + // Outside component + await tester.zoomFrom( + startLocation1: const Offset(330, 300), + offset1: const Offset(15, 2), + startLocation2: const Offset(270, 300), + offset2: const Offset(-15, -2), + ); + + expect(component.isScaledStateChange, equals(2)); + expect(component.isDraggedStateChange, equals(2)); + }, + ); + + group('HasScaleAndDragMixins', () { + testWidgets( + 'scale and drag events does not affect more than one component', + (tester) async { + var nEvents = 0; + final game = FlameGame( + children: [ + ScaleDragWithCallbacksComponent( + size: Vector2.all(100), + onScaleStart: (e) => nEvents++, + onScaleUpdate: (e) => nEvents++, + onScaleEnd: (e) => nEvents++, + onDragStart: (e) => nEvents++, + onDragEnd: (e) => nEvents++, + onDragUpdate: (e) => nEvents++, + ), + SimpleScaleDragCallbacksComponent(size: Vector2.all(200)) + ..priority = 10, + ], + ); + await tester.pumpWidget(GameWidget(game: game)); + await tester.pump(); + await tester.pump(); + await tester.zoomFrom( + startLocation1: const Offset(80, 50), + offset1: const Offset(15, 2), + startLocation2: const Offset(20, 50), + offset2: const Offset(-15, -2), + ); + expect(nEvents, 0); + }, + ); + + testWidgets( + 'scale and drag event can move outside the component bounds and fire', + (tester) async { + var nScaleEvents = 0; + var nDragEvents = 0; + const intervals = 50; + final component = ScaleDragWithCallbacksComponent( + size: Vector2.all(30), + position: Vector2.all(100), + onScaleUpdate: (e) => nScaleEvents++, + onDragUpdate: (e) => nDragEvents++, + ); + final game = FlameGame( + children: [component], + ); + await tester.pumpWidget(GameWidget(game: game)); + await tester.pump(); + + const center = Offset(115, 115); + await tester.timedZoomFrom( + center.translate(-10, 0), + const Offset(-30, 0), + center.translate(10, 0), + const Offset(30, 0), + const Duration(milliseconds: 300), + intervals: intervals, + ); + expect(nScaleEvents, intervals * 2 - 1); + expect(nDragEvents, intervals * 2 + 2); + }, + ); + + testWidgets( + 'two fingers on different components both receive drag updates', + (tester) async { + var nDragUpdatesA = 0; + var nDragUpdatesB = 0; + final game = FlameGame( + children: [ + ScaleDragWithCallbacksComponent( + position: Vector2(0, 0), + size: Vector2.all(100), + onDragUpdate: (e) => nDragUpdatesA++, + ), + ScaleDragWithCallbacksComponent( + position: Vector2(200, 0), + size: Vector2.all(100), + onDragUpdate: (e) => nDragUpdatesB++, + ), + ], + ); + await tester.pumpWidget(GameWidget(game: game)); + await tester.pump(); + await tester.pump(); + + const intervals = 10; + await tester.timedZoomFrom( + const Offset(50, 50), + const Offset(10, 0), + const Offset(250, 50), + const Offset(-10, 0), + const Duration(milliseconds: 300), + intervals: intervals, + ); + + expect(nDragUpdatesA, greaterThan(0)); + expect(nDragUpdatesB, greaterThan(0)); + }, + ); + + testWidgets( + 'scale event scale factor respects camera & zoom', + (tester) async { + final game = makeFixedResolutionGame(); + final scales = []; + + game.camera.viewfinder.zoom = 3; + + await game.world.add( + ScaleDragWithCallbacksComponent( + position: Vector2.all(-5), + size: Vector2.all(10), + onScaleUpdate: (event) { + scales.add(event.scale); + }, + ), + ); + await tester.pumpWidget(GameWidget(game: game)); + await tester.pump(); + await tester.pump(); + + final canvasSize = game.canvasSize; + + final center = (canvasSize / 2).toOffset(); + await tester.timedZoomFrom( + center.translate(-1, 0), + const Offset(-20, 0), + center.translate(1, 0), + const Offset(20, 0), + const Duration(milliseconds: 300), + intervals: 10, + ); + + expect(scales, List.generate(20, (i) => i + 2)); + }, + ); + + testWidgets( + 'scale event rotation respects camera & zoom', + (tester) async { + final game = makeFixedResolutionGame(); + final rotations = []; + + game.camera.viewfinder.zoom = 3; + + await game.world.add( + ScaleDragWithCallbacksComponent( + position: Vector2.all(-5), + size: Vector2.all(10), + onScaleUpdate: (event) { + rotations.add(event.rotation); + }, + ), + ); + await tester.pumpWidget(GameWidget(game: game)); + await tester.pump(); + await tester.pump(); + + final canvasSize = game.canvasSize; + + final center = (canvasSize / 2).toOffset(); + await tester.timedZoomFrom( + center.translate(-1, 0), + const Offset(0, 20), + center.translate(1, 0), + const Offset(0, -20), + const Duration(milliseconds: 300), + intervals: 10, + ); + + // computation of angle using trigonometry with triangle having a size + // of length 1 and one of length i. + final expected = List.generate(20, (i) => -atan(i + 1)); + for (var i = 0; i < expected.length; i++) { + expect(rotations[i], closeTo(expected[i], 1e-6)); + } + }, + ); + + testWidgets( + 'drag event delta respects camera & zoom', + (tester) async { + // canvas size is 800x600 so this means a 10x logical scale across + // both dimensions + final game = makeFixedResolutionGame(); + + game.camera.viewfinder.zoom = 2; + + final deltas = []; + await game.world.add( + ScaleDragWithCallbacksComponent( + position: Vector2.all(-5), + size: Vector2.all(10), + onDragUpdate: (event) => deltas.add(event.localDelta), + ), + ); + await tester.pumpWidget(GameWidget(game: game)); + await tester.pump(); + await tester.pump(); + + final canvasSize = game.canvasSize; + await tester.dragFrom( + (canvasSize / 2).toOffset(), + Offset(canvasSize.x / 10, 0), + ); + final totalDelta = deltas.reduce((a, b) => a + b); + expect(totalDelta, Vector2(4, 0)); + }, + ); + + testWidgets( + 'drag event delta respects widget positioning', + (tester) async { + // canvas size is 800x600 so this means a 10x logical scale across + // both dimensions + final game = makeFixedResolutionGame(); + + game.camera.viewfinder.zoom = 1 / 2; + + final deltas = []; + await game.world.add( + ScaleDragWithCallbacksComponent( + position: Vector2.all(-5), + size: Vector2.all(10), + onDragUpdate: (event) => deltas.add(event.localDelta), + ), + ); + await tester.pumpWidget( + MaterialApp( + home: Stack( + children: [ + Positioned( + left: 100.0, + top: 200.0, + width: 800, + height: 600, + child: GameWidget(game: game), + ), + ], + ), + ), + ); + await tester.pump(); + await tester.pump(); + + final canvasSize = game.canvasSize; + + // no offset + await tester.dragFrom( + (canvasSize / 2).toOffset(), + Offset(canvasSize.x / 10, 0), + ); + expect(deltas, isEmpty); + + // accounting for offset + await tester.dragFrom( + (canvasSize / 2 + Vector2(100, 200)).toOffset(), + Offset(canvasSize.x / 10, 0), + ); + expect(deltas, isNotEmpty); + final totalDelta = deltas.reduce((a, b) => a + b); + expect(totalDelta, Vector2(16, 0)); + }, + ); + }); + + group('ScaleAndDragInteractions', () { + testWidgets( + 'scale event triggers both scale and drag', + (tester) async { + final game = makeFixedResolutionGame(); + + final component = ScaleDragWithCallbacksComponent( + position: Vector2.all(-5), + size: Vector2.all(10), + ); + await game.world.add(component); + await tester.pumpWidget(GameWidget(game: game)); + await tester.pump(); + await tester.pump(); + + final canvasSize = game.canvasSize; + + final center = (canvasSize / 2).toOffset(); + await tester.timedZoomFrom( + center.translate(-1, 0), + const Offset(0, 20), + center.translate(1, 0), + const Offset(0, -20), + const Duration(milliseconds: 300), + intervals: 10, + ); + + await tester.pump(); + await tester.pump(); + + expect(component.scaleStartEvent, equals(1)); + expect(component.scaleUpdateEvent, greaterThan(0)); + expect(component.scaleEndEvent, equals(1)); + expect(component.dragStartEvent, equals(2)); + expect(component.dragUpdateEvent, greaterThan(0)); + expect(component.dragEndEvent, equals(2)); + }, + ); + + testWidgets( + '''adding drag component after scale component + upgrade dispatcher to multiDragMultiDragScaleDispatcher''', + (tester) async { + final game = makeFixedResolutionGame(); + + final scaleComponent = ScaleWithCallbacksComponent(); + await game.world.add(scaleComponent); + await tester.pumpWidget(GameWidget(game: game)); + await tester.pump(Durations.short1); + + final dragComponent = DragWithCallbacksComponent(); + await game.world.add(dragComponent); + + await tester.pump(); + await tester.pump(); + expect(game.children.toList()[1], isA()); + }, + ); + + testWidgets( + '''adding scale component after drag + component allows current dragging to continue''', + (tester) async { + final game = makeFixedResolutionGame(); + final dragComponent = DragWithCallbacksComponent( + position: Vector2.all(-5), + size: Vector2.all(10), + ); + + await game.world.add(dragComponent); + await tester.pumpWidget(GameWidget(game: game)); + await tester.pump(); + + Future injectScale() async { + final scaleComponent = ScaleWithCallbacksComponent(); + await game.world.add(scaleComponent); + await tester.pump(); + expect(dragComponent.isDragged, true); + } + + final center = (game.canvasSize / 2).toOffset(); + + await tester.dragWithInjection( + center, + const Offset(20, 0), + const Duration(milliseconds: 200), + injectScale, + ); + }, + ); + + testWidgets( + '''adding drag component after scale + component allows current scaling to continue''', + (tester) async { + final game = makeFixedResolutionGame(); + final scaleComponent = ScaleWithCallbacksComponent( + position: Vector2.all(-5), + size: Vector2.all(10), + ); + + await game.world.add(scaleComponent); + await tester.pumpWidget(GameWidget(game: game)); + await tester.pump(); + + Future injectDrag() async { + final dragComponent = DragWithCallbacksComponent(); + await game.world.add(dragComponent); + await tester.pump(); + expect(scaleComponent.isScaling, true); + } + + final center = (game.canvasSize / 2).toOffset(); + + await tester.zoomFromWithInjection( + startLocation1: center.translate(-3, 0), + offset1: const Offset(15, 2), + startLocation2: center.translate(3, 0), + offset2: const Offset(-15, -2), + duration: const Duration(milliseconds: 200), + onHalfway: injectDrag, + ); + }, + ); + + testWithFlameGame( + 'adding DragCallbacks after ScaleCallbacks reuses the same dispatcher', + (game) async { + await game.ensureAdd(ScaleCallbacksComponent()); + expect(game.children.whereType().length, 1); + + await game.ensureAdd(DragCallbacksComponent()); + game.update(0); + + expect(game.children.whereType().length, 1); + }, + ); + + testWithFlameGame( + 'adding ScaleCallbacks after DragCallbacks reuses the same dispatcher', + (game) async { + await game.ensureAdd(DragCallbacksComponent()); + expect(game.children.whereType().length, 1); + + await game.ensureAdd(ScaleCallbacksComponent()); + game.update(0); + + expect(game.children.whereType().length, 1); + }, + ); + }); +}