feat: Update scale event with custom recognizer#3782
Open
stilnat wants to merge 87 commits into
Open
Conversation
… scale-gesture
Contributor
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 17 out of 17 changed files in this pull request and generated 3 comments.
Comments suppressed due to low confidence (1)
packages/flame/lib/src/events/multi_drag_scale_recognizer.dart:506
- Per-pointer Drag.update is skipped whenever 2+ pointers are down (
recognizer._pointers.length < 2). This effectively disables true multi-drag whenever scale is possible, which contradicts the intent of combining drag + scale without competition. If this is intentional (i.e. you only want focal-point dragging during scale), it should be explicitly documented and/or the per-pointer drag start/end behavior should be adjusted to avoid creating “silent” drags that never update.
if (_drag != null && recognizer._pointers.length < 2) {
_drag!.update(
DragUpdateDetails(
globalPosition: event.position,
delta: delta,
sourceTimeStamp: event.timeStamp,
localPosition: PointerEvent.transformPosition(
event.transform,
event.position,
),
),
);
}
luanpotter
reviewed
May 17, 2026
Removed the _pointers.length < 2 guard in _DragPointerState._move so that every pointer always emits its own drag update through its own FlameDragAdapter. Removed the focal-point-through-one-adapter block from _updateScale, which was suppressing per-pointer drag and causing only a single component to receive drag updates during a two-finger gesture. Each FlameDragAdapter carries its own id, so MultiDragScaleDispatcher routes each pointer's events to the correct component via _records. Two fingers on two different DragCallbacks components now each receive independent drag start/update/end events, as the class documentation describes.
_dispose() was calling resolve(rejected) unconditionally, even when the pointer had already been accepted or rejected. Added a !_resolved guard so the entry is only rejected if it was never previously resolved, avoiding potential double-resolution asserts in debug mode.
When a ScaleCallbacks component is removed while a scale gesture is active, onScaleUpdate now detects the stale record, fires onScaleEnd on the component, and clears it from the records set. Previously the dispatcher would call methods on an unmounted component. Also adds tests for the stale scale handling and onDragCancel routing in MultiDragScaleDispatcher.
When two fingers are on the same component, onDragUpdate fires once per finger. Applying localDelta each time doubles (or more) the intended movement. Guard onDragUpdate with isScaling and move the translation into onScaleUpdate using the focal point delta, so there is exactly one authoritative source of translation regardless of how many fingers are active.
luanpotter
reviewed
May 17, 2026
luanpotter
reviewed
May 17, 2026
Both DragCallbacks.onMount and ScaleCallbacks.onMount contained the dispatcher upgrade matrix, written asymmetrically: DragCallbacks had the full logic and ScaleCallbacks implicitly depended on it for combined components via an early-return guard. Extract the full matrix into setupEventDispatcher() in dispatcher_setup.dart with explicit hasDrag/hasScale parameters. Both onMount implementations now reduce to a single call, and all upgrade paths (including ScaleDispatcher+hasDrag and MultiDragDispatcher+hasScale) are visible in one place.
…nt types Previously three separate dispatcher+recognizer pairs handled drag-only, scale-only, and combined gestures, requiring a complex upgrade/markForRemoval mechanism when the set of active components changed at runtime. Now a single MultiDragScaleDispatcher backed by MultiDragScaleGestureRecognizer serves all three cases. Two boolean flags (hasDrag/hasScale) gate the corresponding logic, and enableDrag()/enableScale() are called as components mount. The static addDispatcher() factory replaces dispatcher_setup.dart. Scale state fields are grouped into _ScaleState and drag per-pointer state into _DragState + _DragPointerState, replacing the previous jumbled layout. Deleted: MultiDragDispatcher, ScaleDispatcher, dispatcher_setup.dart.
luanpotter
reviewed
May 17, 2026
luanpotter
reviewed
May 17, 2026
luanpotter
reviewed
May 17, 2026
luanpotter
reviewed
May 17, 2026
luanpotter
reviewed
May 17, 2026
…is removed Add reference counts to MultiDragScaleDispatcher so that removing the last component of a given type sets hasDrag/hasScale back to false on the underlying recognizer.
- Fix dartdoc reference for MultiDragScaleDispatcher in TaggedComponent - Remove redundant _hasDrag/_hasScale copies; use _dragCount > 0 as source of truth - Extend Dispatcher<FlameGame> instead of Component to use HasGameReference - Decouple DragCallbacks and ScaleCallbacks mixins so each independently registers only its own concern, relying on reference counting in the dispatcher - Save initialLine/currentLine to locals in _computeRotationFactor to avoid !
Contributor
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 19 out of 19 changed files in this pull request and generated 7 comments.
Comments suppressed due to low confidence (2)
packages/flame/lib/src/events/multi_drag_scale_recognizer.dart:495
- This acceptance logic can resolve
GestureDisposition.acceptedeven whenhasDragis false (e.g., scale-only mode still accepts after pan slop; disabled mode accepts on any move). That can cause the recognizer to win the arena and suppress other recognizers while not producing any drag callbacks. Consider gatingresolve(accepted)onrecognizer.hasDragand/or on actually having an active 2+ pointer scale gesture (if the goal is only to accept for scale).
currentPosition = event.position;
if (!_resolved) {
_pendingDelta += delta;
if (!recognizer.hasScale) {
// Drag-only mode: accept on any movement. If accepted synchronously,
// _accepted fires the initial update with _pendingDelta; no extra
// update fires here because we are still in the if(!_resolved) branch.
_arenaEntry?.resolve(GestureDisposition.accepted);
} else {
final distance = (currentPosition - initialPosition).distance;
if (distance > computePanSlop(kind, recognizer.gestureSettings)) {
_arenaEntry?.resolve(GestureDisposition.accepted);
} else if (recognizer._drag.count >= 2) {
packages/flame/lib/src/events/multi_drag_scale_recognizer.dart:541
- In scale-enabled mode (
recognizer.hasScale == true),_acceptednever flushes_pendingDeltainto a firstDrag.update. If the arena resolves after the pointer has already moved, that initial movement will be dropped and_pendingDeltais never cleared. Consider delivering an initialDragUpdateDetailsusing_pendingDeltain both modes (or otherwise ensuring the pre-accept movement is not lost).
}
void _accepted(Drag? Function() starter) {
if (!_resolved) {
_resolved = true;
_drag = starter();
// In drag-only mode, fire an initial update matching
// ImmediateMultiDragGestureRecognizer: delta is Offset.zero when
// accepted before any moves (via microtask), or the accumulated
// pending delta when accepted during a move.
// Scale mode skips this to avoid an extra update per pointer.
if (_drag != null && !recognizer.hasScale) {
_drag!.update(
DragUpdateDetails(
globalPosition: initialPosition + _pendingDelta,
delta: _pendingDelta,
),
);
Comment on lines
+155
to
+160
| if (_scale.active && _drag.count >= 2) { | ||
| _scale.velocityTracker?.addPosition( | ||
| event.timeStamp, | ||
| Offset(_scale.scaleFactor, 0), | ||
| ); | ||
|
|
Comment on lines
+58
to
+67
| @override | ||
| void addAllowedPointer(PointerDownEvent event) { | ||
| assert(!_drag.pointers.containsKey(event.pointer)); | ||
| final state = _DragPointerState(recognizer: this, event: event); | ||
| _drag.pointers[event.pointer] = state; | ||
| GestureBinding.instance.pointerRouter.addRoute(event.pointer, _handleEvent); | ||
| state.arenaEntry = GestureBinding.instance.gestureArena.add( | ||
| event.pointer, | ||
| this, | ||
| ); |
Comment on lines
20
to
28
| export 'src/events/flame_game_mixins/multi_tap_dispatcher.dart' | ||
| show MultiTapDispatcher, MultiTapDispatcherKey; | ||
| export 'src/events/flame_game_mixins/non_primary_tap_dispatcher.dart' | ||
| show NonPrimaryTapDispatcher, NonPrimaryTapDispatcherKey; | ||
| export 'src/events/flame_game_mixins/pointer_move_dispatcher.dart' | ||
| show PointerMoveDispatcher, MouseMoveDispatcherKey; | ||
| export 'src/events/flame_game_mixins/scale_drag_dispatcher.dart' | ||
| show MultiDragScaleDispatcher, MultiDragScaleDispatcherKey; | ||
| export 'src/events/flame_game_mixins/scroll_dispatcher.dart' |
Comment on lines
+131
to
150
| ```dart | ||
| class InteractiveRect extends RectangleComponent | ||
| with ScaleCallbacks, DragCallbacks { | ||
|
|
||
| @override | ||
| void onDragUpdate(DragUpdateEvent event) => position += event.delta; | ||
| void onDragUpdate(DragUpdateEvent event) { | ||
| position += event.localDelta; | ||
| } | ||
|
|
||
| @override | ||
| void onDragEnd(DragEndEvent event) => _isDragged = false; | ||
| void onScaleStart(ScaleStartEvent event) { | ||
| super.onScaleStart(event); | ||
| // store initial angle/scale for relative updates | ||
| } | ||
|
|
||
| @override | ||
| void render(Canvas canvas) { | ||
| _paint.color = _isDragged? Colors.red : Colors.white; | ||
| canvas.drawRect(size.toRect(), _paint); | ||
| void onScaleUpdate(ScaleUpdateEvent event) { | ||
| angle = initialAngle + event.rotation; | ||
| } | ||
| } |
Comment on lines
119
to
137
| ```dart | ||
| class ScaleOnlyRectangle extends RectangleComponent with ScaleCallbacks { | ||
| ScaleOnlyRectangle({ | ||
| required Vector2 position, | ||
| required Vector2 size, | ||
| Color color = Colors.blue, | ||
| Anchor anchor = Anchor.center, | ||
| }) : super( | ||
| position: position, | ||
| size: size, | ||
| anchor: anchor, | ||
| paint: Paint()..color = color, | ||
| ); | ||
| class InteractiveRect extends RectangleComponent | ||
| with ScaleCallbacks, DragCallbacks { | ||
|
|
||
| @override | ||
| Future<void> onLoad() async { | ||
| final text = TextComponent( | ||
| text: 'scale', | ||
| textRenderer: TextPaint( | ||
| style: const TextStyle(fontSize: 25, color: Colors.white), | ||
| ), | ||
| position: size / 2, | ||
| anchor: Anchor.center, | ||
| ); | ||
| add(text); | ||
| void onDragUpdate(DragUpdateEvent event) { | ||
| position += event.localDelta; | ||
| } | ||
|
|
||
| bool isScaling = false; | ||
| double initialAngle = 0; | ||
| Vector2 initialScale = Vector2.all(1); | ||
| double lastScale = 1.0; | ||
|
|
||
| /// ScaleCallbacks overrides | ||
| @override | ||
| void onScaleStart(ScaleStartEvent event) { | ||
| super.onScaleStart(event); | ||
| isScaling = true; | ||
| initialAngle = angle; | ||
| initialScale = scale; | ||
| lastScale = 1.0; | ||
| debugPrint('Scale started at ${event.devicePosition}'); | ||
| // store initial angle/scale for relative updates | ||
| } | ||
|
|
||
| @override | ||
| void onScaleUpdate(ScaleUpdateEvent event) { | ||
| super.onScaleUpdate(event); | ||
| // scale rectangle size by pinch | ||
| angle = initialAngle + event.rotation; | ||
| // delta scale since last frame | ||
| if (lastScale == 0) { | ||
| return; | ||
| } | ||
| final scaleDelta = event.scale / lastScale; | ||
| lastScale = event.scale; // update for next frame | ||
|
|
||
| // apply delta gently | ||
| scale *= sqrt(scaleDelta); | ||
|
|
||
| // clamp | ||
| scale.clamp(Vector2.all(0.8), Vector2.all(3)); | ||
| } | ||
|
|
||
| @override | ||
| void onScaleEnd(ScaleEndEvent event) { | ||
| super.onScaleEnd(event); | ||
| isScaling = false; | ||
| debugPrint('Scale ended with velocity ${event.velocity}'); | ||
| } |
| 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'; |
luanpotter
reviewed
May 17, 2026
| /// and a pointer id. | ||
| /// | ||
| /// This class is used by [MultiTapDispatcher] and [MultiDragDispatcher] | ||
| /// This class is used by [MultiTapDispatcher] and [MultiDragScaleDispatcher] |
Member
There was a problem hiding this comment.
you might need one of those docs only imports
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Description
Currently flame handle scale input events in a very hacky way, by getting the drag gesture recognizer data and recomputing
the data for the scale gesture. This has multiple issues :
This PR aims to fix all those issues by introducing a new gesture recognizer, which is basically just a mix of ScaleGestureRecognizer and immediateMultiDragGestureRecognizer, allowing pointers to be used for both gestures
without competing. I used the existing flutter code to write it.
I modified a bit ScaleCallbacks and DragCallbacks, so they use their original dispatcher if there is only one type
of them (it's a bit more efficient), and so they upgrade to using MultiDragScaleDispatcher if both mixins are mounted.
Transition between the two is smooth as the old dispatcher wait for all gestures it started to finish before removing itself.
Checklist
docsand added dartdoc comments with///.examplesordocs.I wonder if gesture_input.md should be updated
Breaking Change?
Related Issues
I believe it Closes #2635