diff --git a/doc/flame/inputs/drag_events.md b/doc/flame/inputs/drag_events.md index e0887366ac3..cbda45dd0d4 100644 --- a/doc/flame/inputs/drag_events.md +++ b/doc/flame/inputs/drag_events.md @@ -87,8 +87,11 @@ position associated with this event. ### onDragCancel -The precise semantics when this event occurs is not clear, so we provide a default implementation -which simply converts this event into an `onDragEnd`. +This event is fired when the drag gesture is interrupted before it ends naturally, for example when +another gesture recognizer wins the gesture arena or a second pointer triggers a scale takeover. +Unlike `onDragEnd` it carries no velocity information. The default implementation simply resets the +drag state; override it and call `onDragEnd(event.toDragEnd())` yourself if you want a cancellation +handled identically to a natural drag end. ## Mixins diff --git a/doc/flame/migration.md b/doc/flame/migration.md index 63bf2be1315..d145b5fe0e2 100644 --- a/doc/flame/migration.md +++ b/doc/flame/migration.md @@ -7,6 +7,50 @@ major versions of Flame, together with the steps required to migrate your code. ## Migrating from v1.38.0 to v2.0.0 +### `onDragCancel` no longer delegates to `onDragEnd` + +`DragCallbacks.onDragCancel` used to convert the cancellation into an `onDragEnd` event by default, +which made a cancelled drag look exactly like a completed one. A cancellation means that the gesture +was interrupted (another recognizer won the gesture arena, a second pointer triggered a scale +takeover, a system event, etc.) and it carries no velocity, so components such as drag-to-dismiss +would apply their action even though the drag never finished. This is not a rare event either, since +with `MultiDragScaleDispatcher` every two finger pinch cancels the individual pointer drags. + +The default implementation now only resets `isDragged`, which means that `onDragEnd` is no longer +called when a drag is cancelled. If you were relying on the old behavior, override `onDragCancel` and +forward the event yourself with `DragCancelEvent.toDragEnd`: + +```dart +// Before +class MyComponent extends PositionComponent with DragCallbacks { + @override + void onDragEnd(DragEndEvent event) { + super.onDragEnd(event); + // This also ran when the drag was cancelled. + dismiss(); + } +} + +// After +class MyComponent extends PositionComponent with DragCallbacks { + @override + void onDragEnd(DragEndEvent event) { + super.onDragEnd(event); + dismiss(); + } + + @override + void onDragCancel(DragCancelEvent event) { + super.onDragCancel(event); + onDragEnd(event.toDragEnd()); + } +} +``` + +If a cancelled drag should instead be reverted, put that logic in `onDragCancel` without calling +`onDragEnd`. + + ### `MultiDragDispatcher` removed The deprecated `MultiDragDispatcher` and `MultiDragDispatcherKey` aliases have been removed. Use diff --git a/doc/tutorials/klondike/app/lib/step4/components/card.dart b/doc/tutorials/klondike/app/lib/step4/components/card.dart index c5141d46bc7..8f99afad943 100644 --- a/doc/tutorials/klondike/app/lib/step4/components/card.dart +++ b/doc/tutorials/klondike/app/lib/step4/components/card.dart @@ -262,5 +262,13 @@ class Card extends PositionComponent with DragCallbacks { } } + @override + void onDragCancel(DragCancelEvent event) { + super.onDragCancel(event); + // A cancelled drag is handled as if the card had been dropped where it is, + // otherwise the card would be left floating in the middle of the table. + onDragEnd(event.toDragEnd()); + } + //#endregion } diff --git a/doc/tutorials/klondike/app/lib/step5/components/card.dart b/doc/tutorials/klondike/app/lib/step5/components/card.dart index f109e0b4f85..40cf3c292e9 100644 --- a/doc/tutorials/klondike/app/lib/step5/components/card.dart +++ b/doc/tutorials/klondike/app/lib/step5/components/card.dart @@ -356,6 +356,14 @@ class Card extends PositionComponent } } + @override + void onDragCancel(DragCancelEvent event) { + super.onDragCancel(event); + // A cancelled drag is handled as if the card had been dropped where it is, + // otherwise the card would be left floating in the middle of the table. + onDragEnd(event.toDragEnd()); + } + //#endregion //#region Card-Tapping diff --git a/doc/tutorials/klondike/step4.md b/doc/tutorials/klondike/step4.md index 84676b1640c..ec523cf95c4 100644 --- a/doc/tutorials/klondike/step4.md +++ b/doc/tutorials/klondike/step4.md @@ -984,6 +984,20 @@ attached cards into the pile, and the same when it comes to returning the cards } ``` +There is one more case to take care of: a drag can be *cancelled* instead of ended, for example when +the player puts a second finger on the screen and the gesture turns into a pinch. Flame does not +turn a cancellation into an `onDragEnd` event, so unless we handle it the card would be left +floating in the middle of the table. Here we simply treat it as if the card had been dropped where +it currently is: + +```dart + @override + void onDragCancel(DragCancelEvent event) { + super.onDragCancel(event); + onDragEnd(event.toDragEnd()); + } +``` + Well, this is it! The game is now fully playable. Press the button below to see what the resulting code looks like, or to play it live. In the next section we will discuss how to make it more animated with the help of effects. diff --git a/examples/lib/stories/bridge_libraries/flame_forge2d/drag_callbacks_example.dart b/examples/lib/stories/bridge_libraries/flame_forge2d/drag_callbacks_example.dart index 090b6e8fc44..c43b44c4a17 100644 --- a/examples/lib/stories/bridge_libraries/flame_forge2d/drag_callbacks_example.dart +++ b/examples/lib/stories/bridge_libraries/flame_forge2d/drag_callbacks_example.dart @@ -44,4 +44,10 @@ class DraggableBall extends Ball with DragCallbacks { super.onDragEnd(event); paint = originalPaint; } + + @override + void onDragCancel(DragCancelEvent event) { + super.onDragCancel(event); + paint = originalPaint; + } } diff --git a/examples/lib/stories/bridge_libraries/flame_forge2d/joints/mouse_joint.dart b/examples/lib/stories/bridge_libraries/flame_forge2d/joints/mouse_joint.dart index eee8816274d..3f296674605 100644 --- a/examples/lib/stories/bridge_libraries/flame_forge2d/joints/mouse_joint.dart +++ b/examples/lib/stories/bridge_libraries/flame_forge2d/joints/mouse_joint.dart @@ -62,6 +62,19 @@ class MouseJointWorld extends Forge2DWorld @override void onDragEnd(DragEndEvent info) { super.onDragEnd(info); + _destroyMouseJoint(); + } + + @override + void onDragCancel(DragCancelEvent event) { + super.onDragCancel(event); + _destroyMouseJoint(); + } + + void _destroyMouseJoint() { + if (mouseJoint == null) { + return; + } destroyJoint(mouseJoint!); mouseJoint = null; } diff --git a/examples/lib/stories/bridge_libraries/flame_forge2d/utils/boxes.dart b/examples/lib/stories/bridge_libraries/flame_forge2d/utils/boxes.dart index ecdb7b05a4c..0b4d963a267 100644 --- a/examples/lib/stories/bridge_libraries/flame_forge2d/utils/boxes.dart +++ b/examples/lib/stories/bridge_libraries/flame_forge2d/utils/boxes.dart @@ -106,4 +106,15 @@ class DraggableBox extends Box with DragCallbacks { _destroyJoint = true; event.continuePropagation = false; } + + @override + void onDragCancel(DragCancelEvent event) { + super.onDragCancel(event); + + if (mouseJoint == null) { + return; + } + + _destroyJoint = true; + } } diff --git a/packages/flame/lib/src/events/callbacks/drag_callbacks.dart b/packages/flame/lib/src/events/callbacks/drag_callbacks.dart index bb05312471f..b02d90ce930 100644 --- a/packages/flame/lib/src/events/callbacks/drag_callbacks.dart +++ b/packages/flame/lib/src/events/callbacks/drag_callbacks.dart @@ -54,10 +54,17 @@ mixin DragCallbacks on Component { /// The drag was cancelled. /// - /// This is a very rare event, so we provide a default implementation that - /// converts it into an [onDragEnd] event. + /// Unlike [onDragEnd], a cancellation is not the natural end of a gesture: it + /// happens when the drag is interrupted (another recognizer wins the gesture + /// arena, a second pointer triggers a scale takeover, a system event, etc.), + /// so it carries no meaningful velocity. The default implementation only + /// resets the drag state. Override this and call + /// `onDragEnd(event.toDragEnd())` yourself if you want a cancellation + /// handled identically to a natural drag end. @mustCallSuper - void onDragCancel(DragCancelEvent event) => onDragEnd(event.toDragEnd()); + void onDragCancel(DragCancelEvent event) { + _isDragged = false; + } @override @mustCallSuper 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 d52b1985605..4e78c0f90df 100644 --- a/packages/flame/test/events/component_mixins/drag_callbacks_test.dart +++ b/packages/flame/test/events/component_mixins/drag_callbacks_test.dart @@ -142,12 +142,41 @@ void main() { ); expect(component.dragCancelEvent, equals(1)); - expect(component.dragEndEvent, equals(1)); + // onDragCancel no longer delegates to onDragEnd. + expect(component.dragEndEvent, equals(0)); expect(component.isDragged, isFalse); dispatcher.onDragEnd(DragEndEvent(1, DragEndDetails())); expect(component.dragCancelEvent, equals(1)); - expect(component.dragEndEvent, equals(1)); + expect(component.dragEndEvent, equals(0)); + }, + ); + + testWithFlameGame( + 'onDragCancel resets isDragged without delegating to onDragEnd', + (game) async { + final component = DragCallbacksComponent() + ..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); + + dispatcher.onDragCancel(DragCancelEvent(1)); + + expect(component.dragCancelEvent, equals(1)); + expect(component.dragEndEvent, equals(0)); + expect(component.isDragged, isFalse); }, ); @@ -271,6 +300,7 @@ void main() { var nDragStartCalled = 0; var nDragUpdateCalled = 0; var nDragEndCalled = 0; + var nDragCancelCalled = 0; final game = FlameGame( children: [ DragWithCallbacksComponent( @@ -279,6 +309,7 @@ void main() { onDragStart: (e) => nDragStartCalled++, onDragUpdate: (e) => nDragUpdateCalled++, onDragEnd: (e) => nDragEndCalled++, + onDragCancel: (e) => nDragCancelCalled++, ), ], ); @@ -306,7 +337,9 @@ void main() { await gesture.cancel(); await tester.pump(const Duration(seconds: 1)); expect(nDragStartCalled, 2); - expect(nDragEndCalled, 2); + expect(nDragCancelCalled, 1); + // The cancellation must not be reported as a drag end. + expect(nDragEndCalled, 1); }, ); diff --git a/packages/flame/test/events/component_mixins/input_test_helper.dart b/packages/flame/test/events/component_mixins/input_test_helper.dart index 1f9a42cdb6f..110782e3a7f 100644 --- a/packages/flame/test/events/component_mixins/input_test_helper.dart +++ b/packages/flame/test/events/component_mixins/input_test_helper.dart @@ -96,6 +96,7 @@ class DragWithCallbacksComponent extends PositionComponent with DragCallbacks { this._onDragStart, this._onDragUpdate, this._onDragEnd, + this._onDragCancel, super.position, super.size, }); @@ -103,6 +104,7 @@ class DragWithCallbacksComponent extends PositionComponent with DragCallbacks { final void Function(DragStartEvent)? _onDragStart; final void Function(DragUpdateEvent)? _onDragUpdate; final void Function(DragEndEvent)? _onDragEnd; + final void Function(DragCancelEvent)? _onDragCancel; @override void onDragStart(DragStartEvent event) { @@ -120,6 +122,12 @@ class DragWithCallbacksComponent extends PositionComponent with DragCallbacks { super.onDragEnd(event); return _onDragEnd?.call(event); } + + @override + void onDragCancel(DragCancelEvent event) { + super.onDragCancel(event); + return _onDragCancel?.call(event); + } } class ScaleWithCallbacksComponent extends PositionComponent 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 index 0058860593e..9399a92d8d8 100644 --- a/packages/flame/test/events/component_mixins/scale_drag_callbacks_test.dart +++ b/packages/flame/test/events/component_mixins/scale_drag_callbacks_test.dart @@ -117,12 +117,13 @@ void main() { ); expect(component.dragCancelEvent, equals(1)); - expect(component.dragEndEvent, equals(1)); + // onDragCancel no longer delegates to onDragEnd. + expect(component.dragEndEvent, equals(0)); expect(component.isDragged, isFalse); dispatcher.onDragEnd(DragEndEvent(1, DragEndDetails())); expect(component.dragCancelEvent, equals(1)); - expect(component.dragEndEvent, equals(1)); + expect(component.dragEndEvent, equals(0)); }, ); @@ -188,13 +189,13 @@ void main() { dispatcher.onDragCancel(DragCancelEvent(1)); expect(component.dragCancelEvent, equals(1)); - // onDragCancel delegates to onDragEnd internally - expect(component.dragEndEvent, equals(1)); + // onDragCancel no longer delegates to onDragEnd. + expect(component.dragEndEvent, equals(0)); 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)); + expect(component.dragEndEvent, equals(0)); }, );