diff --git a/doc/flame/inputs/scale_events.md b/doc/flame/inputs/scale_events.md index 1d7c5b2e783..aaf2fef944d 100644 --- a/doc/flame/inputs/scale_events.md +++ b/doc/flame/inputs/scale_events.md @@ -171,14 +171,3 @@ class ScaleOnlyRectangle extends RectangleComponent with ScaleCallbacks { } ``` - - -## 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/scale_example.dart b/examples/lib/stories/input/scale_drag_example.dart similarity index 94% rename from examples/lib/stories/input/scale_example.dart rename to examples/lib/stories/input/scale_drag_example.dart index 96aa24585d4..886061808e3 100644 --- a/examples/lib/stories/input/scale_example.dart +++ b/examples/lib/stories/input/scale_drag_example.dart @@ -1,8 +1,8 @@ import 'dart:math'; -import 'package:flame/components.dart'; -import 'package:flame/events.dart'; -import 'package:flame/game.dart'; -import 'package:flutter/material.dart'; +import 'package:flame/components.dart' hide Matrix4; +import 'package:flame/events.dart' hide PointerMoveEvent; +import 'package:flame/game.dart' hide Matrix4; +import 'package:flutter/material.dart' hide PointerMoveEvent, Matrix4; void main() { runApp(GameWidget(game: ScaleExample())); @@ -16,7 +16,7 @@ class ScaleExample extends FlameGame { Vector2 zoomCenter = Vector2.zero(); double startingZoom = 1; - final bool addScaleOnlyRectangle = true; + final bool addScaleOnlyRectangle = false; final bool addDragOnlyRectangle = true; final bool addScaleDragRectangle = true; final bool addZoom = false; @@ -52,7 +52,7 @@ class ScaleExample extends FlameGame { if (addScaleDragRectangle) { interactiveRectangle = InteractiveRectangle( - position: Vector2(200, 200), + position: Vector2(100, 100), size: Vector2.all(150), color: Colors.red, ); @@ -67,11 +67,11 @@ class ScaleExample extends FlameGame { super.update(dt); if (addCameraRotation) { - camera.viewfinder.angle += 0.1 * dt; + camera.viewfinder.angle += 0.001; } if (addZoom) { debugText.text = '${camera.viewfinder.zoom}'; - camera.viewfinder.zoom += 0.1 * dt; + camera.viewfinder.zoom += 0.001; } } } @@ -119,9 +119,6 @@ class InteractiveRectangle extends RectangleComponent @override void onDragUpdate(DragUpdateEvent event) { super.onDragUpdate(event); - if (isScaling) { - return; - } final rotated = event.canvasDelta.clone() ..rotate(game.camera.viewfinder.angle); position.add(rotated); @@ -210,7 +207,6 @@ class DragOnlyRectangle extends RectangleComponent @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); diff --git a/packages/flame/lib/events.dart b/packages/flame/lib/events.dart index 8c1727ad370..2ff9f4a0f9e 100644 --- a/packages/flame/lib/events.dart +++ b/packages/flame/lib/events.dart @@ -16,6 +16,8 @@ export 'src/events/flame_game_mixins/multi_tap_dispatcher.dart' show MultiTapDispatcher, MultiTapDispatcherKey; export 'src/events/flame_game_mixins/pointer_move_dispatcher.dart' show PointerMoveDispatcher, MouseMoveDispatcherKey; +export 'src/events/flame_game_mixins/scale_drag_dispatcher.dart' + show MultiDragScaleDispatcher, MultiDragScaleDispatcherKey; export 'src/events/flame_game_mixins/secondary_tap_dispatcher.dart' show SecondaryTapDispatcher, SecondaryTapDispatcherKey; 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 42f564fd1ab..09e080afd83 100644 --- a/packages/flame/lib/src/events/component_mixins/drag_callbacks.dart +++ b/packages/flame/lib/src/events/component_mixins/drag_callbacks.dart @@ -1,5 +1,6 @@ import 'package:flame/components.dart'; import 'package:flame/events.dart'; +import 'package:flame/src/events/flame_game_mixins/scale_dispatcher.dart'; import 'package:meta/meta.dart'; /// This mixin can be added to a [Component] allowing it to receive drag events. @@ -61,11 +62,51 @@ mixin DragCallbacks on Component { @mustCallSuper void onMount() { super.onMount(); + final game = findRootGame()!; - if (game.findByKey(const MultiDragDispatcherKey()) == null) { - final dispatcher = MultiDragDispatcher(); - game.registerKey(const MultiDragDispatcherKey(), dispatcher); + final scaleDispatcher = game.findByKey(const ScaleDispatcherKey()); + final multiDragDispatcher = game.findByKey(const MultiDragDispatcherKey()); + final multiDragScaleDispatcher = game.findByKey( + const MultiDragScaleDispatcherKey(), + ); + + // If MultiDragScaleDispatcher already exists, we're done + if (multiDragScaleDispatcher != null) { + return; + } + + // If MultiDragDispatcher exists but component has ScaleCallbacks, + // upgrade it + if (multiDragDispatcher != null && this is ScaleCallbacks) { + final dispatcher = MultiDragScaleDispatcher(); + game.registerKey(const MultiDragScaleDispatcherKey(), dispatcher); + game.add(dispatcher); + (multiDragDispatcher as MultiDragDispatcher).markForRemoval(); + return; + } + + // If MultiDragDispatcher exists and no ScaleCallbacks, we're done + if (multiDragDispatcher != null) { + return; + } + + if (scaleDispatcher == null && multiDragDispatcher == null) { + // Check if component also has ScaleCallbacks + if (this is ScaleCallbacks) { + final dispatcher = MultiDragScaleDispatcher(); + game.registerKey(const MultiDragScaleDispatcherKey(), dispatcher); + game.add(dispatcher); + } else { + final dispatcher = MultiDragDispatcher(); + game.registerKey(const MultiDragDispatcherKey(), dispatcher); + game.add(dispatcher); + } + } else if (scaleDispatcher != null && multiDragDispatcher == null) { + // Upgrade ScaleDispatcher to MultiDragScaleDispatcher + final dispatcher = MultiDragScaleDispatcher(); + game.registerKey(const MultiDragScaleDispatcherKey(), dispatcher); game.add(dispatcher); + (scaleDispatcher as ScaleDispatcher).markForRemoval(); } } } 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 7d4c5ba8a5d..efd832908cf 100644 --- a/packages/flame/lib/src/events/component_mixins/scale_callbacks.dart +++ b/packages/flame/lib/src/events/component_mixins/scale_callbacks.dart @@ -25,11 +25,32 @@ mixin ScaleCallbacks on Component { @mustCallSuper void onMount() { super.onMount(); + // Skip if DragCallbacks will handle it + if (this is DragCallbacks) { + return; + } + final game = findRootGame()!; - if (game.findByKey(const ScaleDispatcherKey()) == null) { + final scaleDispatcher = game.findByKey(const ScaleDispatcherKey()); + final multiDragDispatcher = game.findByKey(const MultiDragDispatcherKey()); + final multiDragScaleDispatcher = game.findByKey( + const MultiDragScaleDispatcherKey(), + ); + + // If MultiDragScaleDispatcher exists, DragCallbacks already handled it + if (multiDragScaleDispatcher != null) { + return; + } + + if (scaleDispatcher == null && multiDragDispatcher == null) { final dispatcher = ScaleDispatcher(); game.registerKey(const ScaleDispatcherKey(), dispatcher); game.add(dispatcher); + } else if (scaleDispatcher == null && multiDragDispatcher != null) { + final dispatcher = MultiDragScaleDispatcher(); + game.registerKey(const MultiDragScaleDispatcherKey(), dispatcher); + game.add(dispatcher); + (multiDragDispatcher as MultiDragDispatcher).markForRemoval(); } } } 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 index 53c0d928bd9..1cab782f305 100644 --- 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 @@ -1,5 +1,3 @@ -import 'dart:async'; - import 'package:flame/components.dart'; import 'package:flame/events.dart'; import 'package:flame/src/events/flame_drag_adapter.dart'; @@ -7,6 +5,7 @@ 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:flutter/material.dart'; import 'package:meta/meta.dart'; class MultiDragDispatcherKey implements ComponentKey { @@ -28,32 +27,10 @@ class MultiDragDispatcher extends Component 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; - FlameGame get game => parent! as FlameGame; + bool _shouldBeRemoved = false; + /// Called when the user initiates a drag gesture, for example by touching the /// screen and then moving the finger. /// @@ -134,9 +111,11 @@ class MultiDragDispatcher extends Component implements MultiDragListener { @internal @override void handleDragStart(int pointerId, DragStartDetails details) { + if (_shouldBeRemoved) { + return; + } final event = DragStartEvent(pointerId, game, details); onDragStart(event); - _dragStartController.add(event); } @internal @@ -144,7 +123,6 @@ class MultiDragDispatcher extends Component implements MultiDragListener { void handleDragUpdate(int pointerId, DragUpdateDetails details) { final event = DragUpdateEvent(pointerId, game, details); onDragUpdate(event); - _dragUpdateController.add(event); } @internal @@ -152,7 +130,7 @@ class MultiDragDispatcher extends Component implements MultiDragListener { void handleDragEnd(int pointerId, DragEndDetails details) { final event = DragEndEvent(pointerId, details); onDragEnd(event); - _dragEndController.add(event); + _tryRemoving(); } @internal @@ -160,13 +138,32 @@ class MultiDragDispatcher extends Component implements MultiDragListener { void handleDragCancel(int pointerId) { final event = DragCancelEvent(pointerId); onDragCancel(event); - _dragCancelController.add(event); + _tryRemoving(); + } + + void markForRemoval() { + _shouldBeRemoved = true; + _tryRemoving(); + } + + bool _tryRemoving() { + // there's no more fingers + // that started dragging before _shouldBeRemoved flag was set to true. + if (_records.isEmpty && _shouldBeRemoved && isMounted) { + removeFromParent(); + return true; + } + return false; } //#endregion @override void onMount() { + if (_tryRemoving()) { + return; + } + game.gestureDetectors.add( ImmediateMultiDragGestureRecognizer.new, (ImmediateMultiDragGestureRecognizer instance) { @@ -179,10 +176,6 @@ class MultiDragDispatcher extends Component implements MultiDragListener { void onRemove() { game.gestureDetectors.remove(); game.unregisterKey(const MultiDragDispatcherKey()); - _dragUpdateController.close(); - _dragCancelController.close(); - _dragStartController.close(); - _dragEndController.close(); } @override 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 index 3a59887099a..ffd7a417d28 100644 --- a/packages/flame/lib/src/events/flame_game_mixins/scale_dispatcher.dart +++ b/packages/flame/lib/src/events/flame_game_mixins/scale_dispatcher.dart @@ -1,35 +1,11 @@ -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/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(); @@ -52,16 +28,7 @@ class ScaleDispatcher extends Component implements ScaleListener { FlameGame get game => parent! as FlameGame; - /// Store the last drag events - DragStartDetails? lastDragStart; - DragUpdateDetails? lastDragUpdate; - DragEndDetails? lastDragEnd; - - _LineBetweenPointers? _currentLine; - - _LineBetweenPointers? _lineAtFirstUpdate; - - MultiDragDispatcher? _multiDragDispatcher; + bool _shouldBeRemoved = false; /// Called when the user starts a scale gesture. @mustCallSuper @@ -127,175 +94,38 @@ class ScaleDispatcher extends Component implements ScaleListener { @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; + onScaleUpdate(ScaleUpdateEvent(0, game, details)); } @internal @override void handleScaleEnd(ScaleEndDetails details) { - _currentLine = null; - _lineAtFirstUpdate = null; onScaleEnd(ScaleEndEvent(0, details)); + _tryRemoving(); } //#endregion + void markForRemoval() { + _shouldBeRemoved = true; + _tryRemoving(); + } + + bool _tryRemoving() { + // there's no more fingers + // that started dragging before _shouldBeRemoved flag was set to true. + if (_records.isEmpty && _shouldBeRemoved && isMounted) { + removeFromParent(); + return true; + } + return false; + } + @override void onMount() { + if (_tryRemoving()) { + return; + } game.gestureDetectors.add( ScaleGestureRecognizer.new, (ScaleGestureRecognizer instance) { @@ -305,52 +135,12 @@ class ScaleDispatcher extends Component implements ScaleListener { ..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.remove(); 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..79f0b39a8b6 --- /dev/null +++ b/packages/flame/lib/src/events/flame_game_mixins/scale_drag_dispatcher.dart @@ -0,0 +1,237 @@ +import 'package:flame/components.dart'; +import 'package:flame/events.dart'; +import 'package:flame/src/events/flame_drag_adapter.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/material.dart'; +import 'package:meta/meta.dart'; + +class MultiDragScaleDispatcherKey implements ComponentKey { + const MultiDragScaleDispatcherKey(); + + @override + int get hashCode => 91604875; // 'MultiDragDispatcherKey' as hashCode + + @override + bool operator ==(Object other) => + other is MultiDragScaleDispatcherKey && 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 MultiDragScaleDispatcher extends Component + implements MultiDragListener, ScaleListener { + /// The record of all components currently being touched. + final Set> _records = {}; + + FlameGame get game => parent! as FlameGame; + + /// 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 = >{}; + event.deliverAtPoint( + rootComponent: game, + deliverToAll: true, + eventHandler: (DragCallbacks component) { + final record = TaggedComponent(event.pointerId, component); + if (_records.contains(record)) { + component.onDragUpdate(event); + updated.add(record); + } + }, + ); + for (final record in _records) { + if (record.pointerId == event.pointerId && !updated.contains(record)) { + record.component.onDragUpdate(event); + } + } + } + + /// 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 = >{}; + + // 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)) { + 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 && !updated.contains(record)) { + record.component.onScaleUpdate(event); + } + } + } + + /// 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.add( + MultiDragScaleGestureRecognizer.new, + (MultiDragScaleGestureRecognizer instance) { + instance.onStart = (Offset point) => FlameDragAdapter(this, point); + instance.onScaleStart = handleScaleStart; + instance.onScaleUpdate = handleScaleUpdate; + instance.onScaleEnd = handleScaleEnd; + }, + ); + } + + @override + void onRemove() { + game.gestureDetectors.remove(); + 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..6017a7da962 --- /dev/null +++ b/packages/flame/lib/src/events/multi_drag_scale_recognizer.dart @@ -0,0 +1,538 @@ +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 [ImmediateMultiDragGestureRecognizer]) +/// while also tracking the overall +/// scale gesture (like [ScaleGestureRecognizer]). +/// Each pointer can drag independently, and when 2+ pointers are down, scale +/// callbacks also fire. +class MultiDragScaleGestureRecognizer extends GestureRecognizer { + /// Create a gesture recognizer for tracking multi-drag and scale gestures. + MultiDragScaleGestureRecognizer({ + super.debugOwner, + super.supportedDevices, + AllowedButtonsFilter? allowedButtonsFilter, + this.dragStartBehavior = DragStartBehavior.down, + 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; + + /// Determines what point is used as the starting point in all calculations. + final DragStartBehavior dragStartBehavior; + + /// The threshold for determining when a scale gesture has occurred. + /// Default is 1.05 (5% change in scale). + final double scaleThreshold; + + /// 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 Map _pointers = {}; + bool _scaleGestureActive = false; + + // Scale-specific fields + 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? _scaleVelocityTracker; + Duration? _initialScaleEventTimestamp; + + int get pointerCount => _pointers.length; + + double get _pointerScaleFactor => + _initialSpan > 0.0 ? _currentSpan / _initialSpan : 1.0; + + double get _pointerHorizontalScaleFactor => _initialHorizontalSpan > 0.0 + ? _currentHorizontalSpan / _initialHorizontalSpan + : 1.0; + + double get _pointerVerticalScaleFactor => _initialVerticalSpan > 0.0 + ? _currentVerticalSpan / _initialVerticalSpan + : 1.0; + + @override + void addAllowedPointer(PointerDownEvent event) { + assert(!_pointers.containsKey(event.pointer)); + final state = _DragPointerState( + recognizer: this, + event: event, + ); + _pointers[event.pointer] = state; + GestureBinding.instance.pointerRouter.addRoute(event.pointer, _handleEvent); + state.arenaEntry = GestureBinding.instance.gestureArena.add( + event.pointer, + this, + ); + + // Initialize scale tracking when first pointer is added + if (_pointers.length == 1) { + _update(); + _initialFocalPoint = _currentFocalPoint; + _initialSpan = _currentSpan; + _initialHorizontalSpan = _currentHorizontalSpan; + _initialVerticalSpan = _currentVerticalSpan; + } + } + + void _handleEvent(PointerEvent event) { + assert(_pointers.containsKey(event.pointer)); + final state = _pointers[event.pointer]!; + + if (event is PointerMoveEvent) { + state._move(event); + _updateScale(event); + } else if (event is PointerUpEvent) { + assert(event.delta == Offset.zero); + state._up(event); + _removeState(event.pointer); + _updateScaleAfterRemoval(event); + } else if (event is PointerCancelEvent) { + assert(event.delta == Offset.zero); + state._cancel(event); + _removeState(event.pointer); + _updateScaleAfterRemoval(event); + } else if (event is! PointerDownEvent) { + assert(false); + } + } + + void _updateScale(PointerEvent event) { + _lastTransform = event.transform; + + // Update all pointer positions for scale calculation + _update(); + _updateLines(); + + // Check if we should accept all gestures based on scale threshold + if (_pointers.length >= 2 && !_scaleGestureActive) { + _checkScaleGestureThreshold(); + } + + // Start scale gesture if we now have 2+ pointers + if (!_scaleGestureActive && _pointers.length >= 2) { + _scaleGestureActive = true; + _initialFocalPoint = _currentFocalPoint; + _initialSpan = _currentSpan; + _initialHorizontalSpan = _currentHorizontalSpan; + _initialVerticalSpan = _currentVerticalSpan; + _initialLine = _currentLine; + _initialScaleEventTimestamp = event.timeStamp; + _scaleVelocityTracker = VelocityTracker.withKind(PointerDeviceKind.touch); + + if (onScaleStart != null) { + invokeCallback('onScaleStart', () { + onScaleStart!( + ScaleStartDetails( + focalPoint: _currentFocalPoint!, + localFocalPoint: _localFocalPoint, + pointerCount: pointerCount, + sourceTimeStamp: _initialScaleEventTimestamp, + ), + ); + }); + } + } + + // Update scale gesture if active and we still have 2+ pointers + if (_scaleGestureActive && _pointers.length >= 2) { + _scaleVelocityTracker?.addPosition( + event.timeStamp, + Offset(_pointerScaleFactor, 0), + ); + + if (onScaleUpdate != null) { + invokeCallback('onScaleUpdate', () { + onScaleUpdate!( + ScaleUpdateDetails( + scale: _pointerScaleFactor, + horizontalScale: _pointerHorizontalScaleFactor, + verticalScale: _pointerVerticalScaleFactor, + focalPoint: _currentFocalPoint!, + localFocalPoint: _localFocalPoint, + rotation: _computeRotationFactor(), + pointerCount: pointerCount, + focalPointDelta: _delta, + sourceTimeStamp: event.timeStamp, + ), + ); + }); + } + } + } + + void _updateScaleAfterRemoval(PointerEvent event) { + _lastTransform = event.transform; + + // Update all pointer positions for scale calculation (after removal) + _update(); + _updateLines(); + + // End scale gesture if we drop below 2 pointers + if (_scaleGestureActive && _pointers.length < 2) { + if (onScaleEnd != null) { + final velocity = _scaleVelocityTracker?.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, + scaleVelocity: velocity.pixelsPerSecond.dx, + pointerCount: pointerCount, + ), + ), + ); + } else { + invokeCallback( + 'onScaleEnd', + () => onScaleEnd!( + ScaleEndDetails( + velocity: velocity, + scaleVelocity: velocity.pixelsPerSecond.dx, + pointerCount: pointerCount, + ), + ), + ); + } + } else { + invokeCallback( + 'onScaleEnd', + () => onScaleEnd!( + ScaleEndDetails( + scaleVelocity: velocity.pixelsPerSecond.dx, + pointerCount: pointerCount, + ), + ), + ); + } + } + + _scaleGestureActive = false; + _scaleVelocityTracker = null; + } + } + + void _checkScaleGestureThreshold() { + if (_pointers.isEmpty || _initialFocalPoint == null) { + return; + } + + final spanDelta = (_currentSpan - _initialSpan).abs(); + final scaleFactor = _pointerScaleFactor; + + // Get the kind from any pointer state + final kind = _pointers.values.first.kind; + + // If we detect a scale gesture, accept all pointer gestures + if (spanDelta > computeScaleSlop(kind) || + math.max(scaleFactor, 1.0 / scaleFactor) > scaleThreshold) { + for (final state in _pointers.values) { + if (!state._resolved) { + state._arenaEntry?.resolve(GestureDisposition.accepted); + } + } + } + } + + void _update() { + final previousFocalPoint = _currentFocalPoint; + + // Compute the focal point + var focalPoint = Offset.zero; + for (final state in _pointers.values) { + focalPoint += state.currentPosition; + } + _currentFocalPoint = _pointers.isEmpty + ? Offset.zero + : focalPoint / _pointers.length.toDouble(); + + if (previousFocalPoint == null) { + _localFocalPoint = PointerEvent.transformPosition( + _lastTransform, + _currentFocalPoint!, + ); + _delta = Offset.zero; + } else { + final localPreviousFocalPoint = _localFocalPoint; + _localFocalPoint = PointerEvent.transformPosition( + _lastTransform, + _currentFocalPoint!, + ); + _delta = _localFocalPoint - localPreviousFocalPoint; + } + + final count = _pointers.length; + var pointerFocalPoint = Offset.zero; + for (final state in _pointers.values) { + pointerFocalPoint += state.currentPosition; + } + if (count > 0) { + pointerFocalPoint = pointerFocalPoint / count.toDouble(); + } + + // Calculate span + var totalDeviation = 0.0; + var totalHorizontalDeviation = 0.0; + var totalVerticalDeviation = 0.0; + for (final state in _pointers.values) { + totalDeviation += (pointerFocalPoint - state.currentPosition).distance; + totalHorizontalDeviation += + (pointerFocalPoint.dx - state.currentPosition.dx).abs(); + totalVerticalDeviation += + (pointerFocalPoint.dy - state.currentPosition.dy).abs(); + } + _currentSpan = count > 0 ? totalDeviation / count : 0.0; + _currentHorizontalSpan = count > 0 ? totalHorizontalDeviation / count : 0.0; + _currentVerticalSpan = count > 0 ? totalVerticalDeviation / count : 0.0; + } + + void _updateLines() { + final count = _pointers.length; + final pointerIds = _pointers.keys.toList(); + + if (count < 2) { + _initialLine = _currentLine; + } else if (_initialLine != null && + _initialLine!.pointerStartId == pointerIds[0] && + _initialLine!.pointerEndId == pointerIds[1]) { + _currentLine = _LineBetweenPointers( + pointerStartId: pointerIds[0], + pointerStartLocation: _pointers[pointerIds[0]]!.currentPosition, + pointerEndId: pointerIds[1], + pointerEndLocation: _pointers[pointerIds[1]]!.currentPosition, + ); + } else { + _initialLine = _LineBetweenPointers( + pointerStartId: pointerIds[0], + pointerStartLocation: _pointers[pointerIds[0]]!.currentPosition, + pointerEndId: pointerIds[1], + pointerEndLocation: _pointers[pointerIds[1]]!.currentPosition, + ); + _currentLine = _initialLine; + } + } + + double _computeRotationFactor() { + var factor = 0.0; + 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(_pointers.containsKey(pointer)); + Drag? drag; + if (onStart != null) { + drag = invokeCallback('onStart', () { + return onStart!(initialPosition); + }); + } + return drag; + } + + @override + void acceptGesture(int pointer) { + final state = _pointers[pointer]; + if (state == null) { + return; // Already removed + } + state._accepted(() => _startDrag(state.initialPosition, pointer)); + } + + @override + void rejectGesture(int pointer) { + final state = _pointers[pointer]; + if (state != null) { + state._rejected(); + _removeState(pointer); + } + } + + void _removeState(int pointer) { + if (!_pointers.containsKey(pointer)) { + return; + } + GestureBinding.instance.pointerRouter.removeRoute(pointer, _handleEvent); + _pointers.remove(pointer)!._dispose(); + } + + @override + void dispose() { + final pointers = _pointers.keys.toList(); + for (final pointer in pointers) { + _removeState(pointer); + } + assert(_pointers.isEmpty); + super.dispose(); + } + + @override + String get debugDescription => 'multi-drag-scale'; +} + +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; + + 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) { + // Check if we should resolve the gesture based on individual + // pointer movement + final distance = (currentPosition - initialPosition).distance; + if (distance > computePanSlop(kind, recognizer.gestureSettings)) { + _arenaEntry?.resolve(GestureDisposition.accepted); + } + // Also check if we should resolve based on scale gesture + // This happens when multiple pointers are moving + else if (recognizer._pointers.length >= 2) { + recognizer._checkScaleGestureThreshold(); + } + } + + 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(); + } + } + + void _rejected() { + _resolved = true; + } + + void _dispose() { + _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/test/events/component_mixins/drag_callbacks_test.dart b/packages/flame/test/events/component_mixins/drag_callbacks_test.dart index 005225bdd0f..9fe520824e6 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,21 @@ 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('drag event start', (game) async { - final component = _DragCallbacksComponent() + final component = DragCallbacksComponent() ..x = 10 ..y = 10 ..width = 10 @@ -37,7 +39,7 @@ void main() { }); testWithFlameGame('drag event start, update and cancel', (game) async { - final component = _DragCallbacksComponent() + final component = DragCallbacksComponent() ..x = 10 ..y = 10 ..width = 10 @@ -74,7 +76,7 @@ void main() { testWithFlameGame( 'drag event update not called without onDragStart', (game) async { - final component = _DragCallbacksComponent() + final component = DragCallbacksComponent() ..x = 10 ..y = 10 ..width = 10 @@ -98,7 +100,7 @@ void main() { testWidgets( 'drag correctly registered handled event', (tester) async { - final component = _DragCallbacksComponent() + final component = DragCallbacksComponent() ..x = 10 ..y = 10 ..width = 10 @@ -121,7 +123,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(); @@ -138,7 +140,7 @@ 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)); @@ -149,7 +151,7 @@ void main() { 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(); @@ -167,7 +169,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(); @@ -193,7 +195,7 @@ void main() { var nDragEndCalled = 0; final game = FlameGame( children: [ - _DragWithCallbacksComponent( + DragWithCallbacksComponent( position: Vector2(20, 20), size: Vector2(100, 100), onDragStart: (e) => nDragStartCalled++, @@ -207,7 +209,7 @@ 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(1), isA()); expect(game.children.elementAt(2), isA()); // regular drag @@ -236,13 +238,13 @@ 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)); @@ -266,7 +268,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), @@ -312,7 +314,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), @@ -349,7 +351,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), @@ -393,94 +395,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..d62ce03f55c --- /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'; + +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); + } +} + +// 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); + }); + } + 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(); +} + +Future dragWithInjection( + WidgetTester tester, + Offset start, + Offset delta, + Duration duration, + Future Function() onHalfway, { + int steps = 20, +}) async { + final gesture = await tester.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 tester.pump(dt); + } + + await gesture.up(); + await tester.pump(); +} + +Future zoomFromWithInjection( + WidgetTester tester, { + 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 tester.startGesture(startLocation1); + final gesture2 = await tester.startGesture(startLocation2); + + await tester.pump(); + + final dt = duration ~/ steps; + + for (var i = 0; i < steps; i++) { + // Inject custom logic at halfway + if (i == steps ~/ 2) { + await onHalfway(); + await tester.pump(); + } + + final t = (i + 1) / steps; + + await gesture1.moveTo(startLocation1 + offset1 * t); + await gesture2.moveTo(startLocation2 + offset2 * t); + + await tester.pump(dt); + } + + // Release both gestures + await gesture1.up(); + await gesture2.up(); + + await tester.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..e4c2ef25559 100644 --- a/packages/flame/test/events/component_mixins/scale_callbacks_test.dart +++ b/packages/flame/test/events/component_mixins/scale_callbacks_test.dart @@ -5,23 +5,23 @@ 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()); }, ); }); testWithFlameGame('scale event start', (game) async { - final component = _ScaleCallbacksComponent() + final component = ScaleCallbacksComponent() ..x = 10 ..y = 10 ..width = 10 @@ -41,7 +41,7 @@ void main() { }); testWithFlameGame('scale event start, update and end', (game) async { - final component = _ScaleCallbacksComponent() + final component = ScaleCallbacksComponent() ..x = 10 ..y = 10 ..width = 10 @@ -78,7 +78,7 @@ void main() { testWithFlameGame( 'scale event update not called without onScaleStart', (game) async { - final component = _ScaleCallbacksComponent() + final component = ScaleCallbacksComponent() ..x = 10 ..y = 10 ..width = 10 @@ -100,7 +100,7 @@ void main() { ); testWidgets('scale correctly registered handled event', (tester) async { - final component = _ScaleCallbacksComponent() + final component = ScaleCallbacksComponent() ..x = 100 ..y = 100 ..width = 150 @@ -110,7 +110,7 @@ void main() { await tester.pumpWidget(GameWidget(game: game)); await tester.pump(); - await _zoomFrom( + await tester.zoomFrom( tester, startLocation1: const Offset(180, 150), offset1: const Offset(15, 2), @@ -129,14 +129,14 @@ void main() { 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( + await tester.zoomFrom( tester, startLocation1: const Offset(250, 200), offset1: const Offset(15, 2), @@ -152,7 +152,7 @@ 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)); @@ -163,14 +163,14 @@ void main() { 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( + await tester.zoomFrom( tester, startLocation1: const Offset(50, 100), offset1: const Offset(15, 2), @@ -187,7 +187,7 @@ void main() { testWidgets( 'isScaled is changed', (tester) async { - final component = _ScaleCallbacksComponent() + final component = ScaleCallbacksComponent() ..size = Vector2.all(100) ..x = 100 ..y = 100; @@ -198,7 +198,7 @@ void main() { await tester.pump(); // Inside component - await _zoomFrom( + await tester.zoomFrom( tester, startLocation1: const Offset(180, 100), offset1: const Offset(15, 2), @@ -209,7 +209,7 @@ void main() { expect(component.isScaledStateChange, equals(4)); // Outside component - await _zoomFrom( + await tester.zoomFrom( tester, startLocation1: const Offset(330, 300), offset1: const Offset(15, 2), @@ -227,20 +227,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( + await tester.zoomFrom( tester, startLocation1: const Offset(80, 50), offset1: const Offset(15, 2), @@ -256,7 +256,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 +268,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), @@ -283,7 +283,7 @@ void main() { }); testWidgets( - 'scale event scale respects camera & zoom', + 'scale event scale factor respects camera & zoom', (tester) async { final resolution = Vector2(80, 60); final game = FlameGame( @@ -297,7 +297,7 @@ void main() { game.camera.viewfinder.zoom = 3; await game.world.add( - _ScaleWithCallbacksComponent( + ScaleWithCallbacksComponent( position: Vector2.all(-5), size: Vector2.all(10), onScaleUpdate: (event) { @@ -312,7 +312,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), @@ -340,7 +340,7 @@ void main() { game.camera.viewfinder.zoom = 3; await game.world.add( - _ScaleWithCallbacksComponent( + ScaleWithCallbacksComponent( position: Vector2.all(-5), size: Vector2.all(10), onScaleUpdate: (event) { @@ -355,7 +355,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), @@ -376,201 +376,3 @@ void main() { }, ); } - -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..7275658582e --- /dev/null +++ b/packages/flame/test/events/component_mixins/scale_drag_callbacks_test.dart @@ -0,0 +1,623 @@ +import 'package:flame/components.dart'; +import 'package:flame/events.dart' hide PointerMoveEvent; +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( + '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( + tester, + 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( + tester, + 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( + tester, + 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( + tester, + 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( + tester, + 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( + tester, + 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 + 2); + expect(nDragEvents, intervals * 2 + 2); + }, + ); + + testWidgets( + '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 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.skip(1), List.generate(21, (i) => i + 1)); + }, + ); + + testWidgets( + 'drag event delta respects camera & zoom', + (tester) async { + // canvas size is 800x600 so this means a 10x logical scale across + // both dimensions + final resolution = Vector2(80, 60); + final game = FlameGame( + camera: CameraComponent.withFixedResolution( + width: resolution.x, + height: resolution.y, + ), + ); + + 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 resolution = Vector2(80, 60); + final game = FlameGame( + camera: CameraComponent.withFixedResolution( + width: resolution.x, + height: resolution.y, + ), + ); + + 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 resolution = Vector2(80, 60); + final game = FlameGame( + camera: CameraComponent.withFixedResolution( + width: resolution.x, + height: resolution.y, + ), + ); + + 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 multiDragScaleDispatcher''', + (tester) async { + final resolution = Vector2(80, 60); + final game = FlameGame( + camera: CameraComponent.withFixedResolution( + width: resolution.x, + height: resolution.y, + ), + ); + + 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 resolution = Vector2(80, 60); + final game = FlameGame( + camera: CameraComponent.withFixedResolution( + width: resolution.x, + height: resolution.y, + ), + ); + 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( + tester, + 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 resolution = Vector2(80, 60); + final game = FlameGame( + camera: CameraComponent.withFixedResolution( + width: resolution.x, + height: resolution.y, + ), + ); + 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( + tester, + 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, + ); + }, + ); + }); +} \ No newline at end of file