Skip to content

feat: Update scale event with custom recognizer#3782

Open
stilnat wants to merge 87 commits into
flame-engine:mainfrom
stilnat:recognizer-v2
Open

feat: Update scale event with custom recognizer#3782
stilnat wants to merge 87 commits into
flame-engine:mainfrom
stilnat:recognizer-v2

Conversation

@stilnat
Copy link
Copy Markdown
Contributor

@stilnat stilnat commented Nov 24, 2025

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 :

  • Recompute already computed stuff like rotation and scale factor -> inefficient
  • Make the code difficult to understand, had to introduce hard dependencies between scale and drag dispatcher
  • Limits the allowed multi drag gestures : those looking like scale gestures simply would not register as such but only as scale

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

  • I have followed the Contributor Guide when preparing my PR.
  • I have updated/added tests for ALL new/updated/fixed functionality.
  • I have updated/added relevant documentation in docs and added dartdoc comments with ///.
  • I have updated/added relevant examples in examples or docs.

I wonder if gesture_input.md should be updated

Breaking Change?

  • Yes, this PR is a breaking change.
  • No, this PR is not a breaking change.

Related Issues

I believe it Closes #2635

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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,
          ),
        ),
      );
    }

Comment thread packages/flame/lib/src/events/multi_drag_scale_recognizer.dart Outdated
Comment thread packages/flame/lib/src/events/multi_drag_scale_recognizer.dart Outdated
Comment thread packages/flame/lib/src/events/multi_drag_scale_recognizer.dart Outdated
Comment thread examples/lib/stories/input/scale_drag_example.dart Outdated
spydon added 3 commits May 17, 2026 15:59
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.
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 17 out of 17 changed files in this pull request and generated 2 comments.

Comment thread packages/flame/lib/src/events/multi_drag_scale_recognizer.dart Outdated
Comment thread packages/flame/lib/src/events/multi_drag_scale_recognizer.dart Outdated
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.
Comment thread packages/flame/lib/src/events/flame_game_mixins/multi_drag_dispatcher.dart Outdated
Comment thread packages/flame/lib/src/events/component_mixins/scale_callbacks.dart Outdated
spydon added 4 commits May 17, 2026 17:18
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.
Comment thread packages/flame/lib/src/events/tagged_component.dart Outdated
Comment thread packages/flame/lib/src/events/flame_game_mixins/scale_drag_dispatcher.dart Outdated
Comment thread packages/flame/lib/src/events/component_mixins/drag_callbacks.dart Outdated
Comment thread packages/flame/lib/src/events/flame_game_mixins/scale_drag_dispatcher.dart Outdated
Comment thread packages/flame/lib/src/events/multi_drag_scale_recognizer.dart Outdated
…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 !
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.accepted even when hasDrag is 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 gating resolve(accepted) on recognizer.hasDrag and/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), _accepted never flushes _pendingDelta into a first Drag.update. If the arena resolves after the pointer has already moved, that initial movement will be dropped and _pendingDelta is never cleared. Consider delivering an initial DragUpdateDetails using _pendingDelta in 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}');
}
Comment thread packages/flame/lib/src/events/tagged_component.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';
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

wow, good catch

/// and a pointer id.
///
/// This class is used by [MultiTapDispatcher] and [MultiDragDispatcher]
/// This class is used by [MultiTapDispatcher] and [MultiDragScaleDispatcher]
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

you might need one of those docs only imports

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

ScaleDetector doesn't work when a Component with DragCallbacks is added

4 participants